From d5d2d8f3368a3063cc56549971d2298d0d2bab31 Mon Sep 17 00:00:00 2001 From: "m0@nas" Date: Tue, 15 Sep 2026 22:21:19 +0200 Subject: [PATCH] Raum-Modal im neuen Stil: Reiter-Kacheln, Beschattung mit my, Heizring, Licht - Beschattung: Auf/Stop/my/Zu, Hoehe und Lamellen in 25-%-Stufen, Ziehen im Fensterbild, neue Behang-Grafik ueber die ganze Kartenhoehe, Alle im Raum - my-Position: aktuellen Stand per setMemorized1Position/-Orientation in den Motor schreiben (room.php?action=my), gespeicherte Werte werden angezeigt - Heizung: Ring mit Ist-Temperatur und ziehbarem Soll-Punkt, -/+ und Schnellwahl - Licht: Birne als Schalter, Helligkeit, Farbfelder, Effekt/Preset eingeklappt - Logik aus homeMQTT.js nach js/solar/raumfenster.js; bindModalSlider entfaellt Co-Authored-By: Claude Opus 5 --- README.md | 27 +- ajax/room.php | 103 ++++-- css/solar.css | 451 ++++++++++++------------- index.php | 2 +- js/solar/common.js | 25 -- js/solar/homeMQTT.js | 419 +----------------------- js/solar/raumfenster.js | 637 ++++++++++++++++++++++++++++++++++++ restricted/roomControls.php | 553 ++++++++++++++----------------- 8 files changed, 1201 insertions(+), 1016 deletions(-) create mode 100644 js/solar/raumfenster.js diff --git a/README.md b/README.md index e622118..db9e4dc 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ genau die Topics, die auf den Kacheln stehen (`tileTopics()` in `rooms.php`). | Seite (`?action=`) | Vorlage | Seitenskripte | holt per fetch | hört auf MQTT | |---|---|---|---|---| | `solar` (Vorgabe) | `solar.php` | `jahresstatistik.js`, `anlageKatalog.js`, `anlage.js`, `speicher.js`, `energieflussKatalog.js`, `energiefluss.js`, `bewaesserungAnzeige.js`, `ladefenster.js`, `bedienfenster.js`, `solarMQTT.js` | `speicher`, `getProdData`, `getConsData`, `getForecastData`, `getSunrise`, `getStats`, `carEG`, `carOG`, `heater`, `watering` | `solarManager/#`, `weatherStation/#`, `wattpilot/#`, `go-eCharger/#`, `Gartenwasser/#` | -| `home` | `home.php` | `autoActionFuncs.js`, `zeitleiste.js`, `homeMQTT.js` | `room.php`, `AutoAction.php` | die Topics der Kacheln, `Raumtemp/#` | +| `home` | `home.php` | `autoActionFuncs.js`, `zeitleiste.js`, `raumfenster.js`, `homeMQTT.js` | `room.php`, `AutoAction.php` | die Topics der Kacheln, `Raumtemp/#` | | `heat` | `heat.php` | `heatMQTT.js` | `getHeaterData`, `getWaterData`, `getSunrise` | `solarManager/#`, `weatherStation/#`, Wallbox-Topics | | `history` | `history.php` | `jahresstatistik.js`, `historyMQTT.js` | `energyHistory` (6 ×), `getStats` | — | | `skoda` | `skoda.php` | `skodaMQTT.js` | `skoda.php?was=…`, `skodaCmd.php`, `tile.php` | `solarManager/#` | @@ -296,7 +296,6 @@ eine Bibliothek es vorgibt. |---|---| | `powerToString(w)` | Leistung als `W` oder `kW`, dieselbe Rundung überall | | `getData(chart, url, opt)` | Diagrammdaten holen, eintragen, alle 5 min auffrischen; optional Sonnenauf-/-untergang aus `getSunrise.php` dazu | -| `bindModalSlider()` | Schieberegler im Modal an ihre Anzeige koppeln | | `mountMeteogram()`, `updateWeatherCards()` | Wetterteile der Solar- und Wetterseite | | `reiterMerken()` | offener Reiter steht in der Adresse und übersteht ein Neuladen | | `loadingHTML(text)` | der eine Ladezustand für alle Modals | @@ -312,8 +311,17 @@ Text wird: Topic oder Wechselrichtersumme lesen, ggf. JSON-Pfad herausgreifen, Vorzeichen drehen (`negativ`), skalieren (`format: "leistung"` für Watt-Quellen, `"leistung_kw"` für Quellen, die schon Kilowatt melden — die Wallboxen), runden (`stellen`), Einheit anhängen. Klick auf eine Kachel -öffnet das Raum-Modal über `ajax/room.php?room=`; Heizsymbol, -Regler und Ist-Werte lesen aus dem Thermostat-Zweig des Raums (`mqttZweig()`). +öffnet das Raum-Modal über `ajax/room.php?room=`; bedient wird es +von `raumfenster.js`. Heizsymbol und Ist-Werte lesen aus dem Thermostat-Zweig +des Raums (`mqttZweig()`). + +**`raumfenster.js`** — das Raum-Modal im Stil von Lade-, Heiz- und +Bewässerungsfenster: Reiter-Kacheln mit Zustand, Beschattung mit +Auf/Stop/my/Zu, Höhe und Lamellen in 25-%-Stufen, Ziehen im Fensterbild und +„aktuellen Stand als my speichern“; Heizung als Ring mit ziehbarem Soll-Punkt; +Lampen mit Birne als Schalter, Farbfeldern und Effekten. Jede Wahl gilt +sofort. Jalousien werden nach einem Befehl bis zum Stillstand verfolgt +(`rfJalousieVerfolgen()`), die Heizwerte kommen über `raumfensterMqtt()` live. **`solarMQTT.js`** — die Solar-Seite. Startet die Energiefluss-Übersicht (`energieflussStarten()`) samt Anlage-Ansicht, füllt die Bewässerungszonen, @@ -421,7 +429,7 @@ klein: Diagramme füllen bzw. Logzeilen nachladen und einfärben. | Endpunkt | Aufruf | liefert / tut | |---|---|---| -| `room.php` | `?room=`, `POST ?action=command\|temp\|jalousie` | Raum-Modal bauen; ein Kommando oder eine Solltemperatur senden | +| `room.php` | `?room=`, `POST ?action=command\|temp\|jalousie\|my` | Raum-Modal bauen; ein Kommando oder eine Solltemperatur senden; Jalousie-Stand lesen; aktuellen Stand als my-Position in den Motor schreiben (`setMemorized1Position`/`-Orientation`) | | `AutoAction.php` | `?action=editor\|list\|zeitleiste\|followers\|werte`, `POST save\|delete\|toggle` | Automatik-Editor, Übersicht als Liste und als Zeitleiste (`&datum=YYYY-MM-DD`) | | `settings.php` | `?action=list\|kacheln\|geraete\|grundriss\|skoda-keys`, `POST save\|kacheln-save\|geraete-raeume\|geraet-loeschen\|etage-speichern\|etagen-reihenfolge\|etage-loeschen\|etage-bild\|raum-speichern\|raum-position\|raum-loeschen\|skoda-keys-save` | Einstellungsseite; die Grundriss-Aktionen antworten mit dem ganzen neuen Stand, `skoda-keys` meldet nur, ob ein Schlüssel hinterlegt ist und worauf er endet | | `getStats.php` | `GET` | Jahresstatistik, alle Jahre, mit Metadaten je Kennzahl | @@ -480,7 +488,6 @@ flowchart LR CMD["commands.php"] --> MYSQL CMD --> TAH["tahoma_EG.php"] CMD --> PHPMQTT["ajax/phpMQTT.php"] - RCTRL["roomControls.php"] --> REITER["reiter.php"] HOME["home.php"] --> ROOMS HOME --> REITER SET["settings.php"] --> COSTS & KACH & REITER @@ -641,9 +648,9 @@ sequenceDiagram ``` `roomControls.php` entscheidet **an den Fähigkeiten**, nicht am Typnamen: Hat -das Gerät ein `setClosure`, wird es ein Rollladen mit Auf/Stop/Zu und zwei -Reglern; hat es drei Parameter rot/grün/blau, wird es eine Lampe mit -Farbwähler. Ein neu gefundenes Gerät bekommt so ohne Codeänderung ein +das Gerät ein `setClosure`, wird es ein Rollladen mit Auf/Stop/Zu und +Höhenstufen, mit `my` kommt der my-Knopf dazu, mit Neigung die Lamellenstufen; +hat es drei Parameter rot/grün/blau, wird es eine Lampe mit Farbfeldern. Ein neu gefundenes Gerät bekommt so ohne Codeänderung ein passendes Bedienelement. `commands.php` ist das Gegenstück zu `transports.py` im SolarManager-Repo — @@ -888,7 +895,7 @@ ausgeliefert. | Neue Geräteart erkennen | neues Modul unter `restricted/deviceDiscovery/modules/` + Eintrag in der Liste in `device_discovery.py` | | Neuer Geräteweg zum Schalten | `commands.php` (Browser) **und** `transports.py` im SolarManager-Repo (Runner) | | Aussehen der ganzen Seite | `css/solar.css`, oberster Abschnitt: die `--ton-*`-Tokens und die Bootstrap-Zuordnung | -| Reiter irgendwo | `reiterLeiste()`/`reiterBlock()` aus `restricted/reiter.php` benutzen, keine eigene Variante bauen | +| Reiter irgendwo | `reiterLeiste()`/`reiterBlock()` aus `restricted/reiter.php` benutzen, keine eigene Variante bauen. Ausnahme: das Raum-Modal hat Reiter-Kacheln mit Zustand (`zeichneGruppen()`), passend zu den anderen Bedienfenstern | | Neue Einstellung | Reiter in `$reiter` (`restricted/settings.php`), Abschnitt in `settings.js`, Aktion in `ajax/settings.php`, Modellfunktion daneben | --- diff --git a/ajax/room.php b/ajax/room.php index a860184..8e8a86f 100644 --- a/ajax/room.php +++ b/ajax/room.php @@ -12,6 +12,9 @@ * GET ?room=12 Inhalt des Modals, 12 = rooms.id * POST ?action=command {"command_id": N, "params": {...}} - ein Kommando * POST ?action=temp {"room": 12, "temp": 21} - Solltemperatur + * POST ?action=jalousie {"actor": 5} - Stand einer Jalousie + * POST ?action=my {"actor": 5} - aktuellen Stand als + * my-Position speichern * * Frueher hiess der Raum "EG_Bad" und wurde am Unterstrich zerlegt. Das hielt * weder ein Leerzeichen im Namen aus (erst nachgeruestet) noch eine @@ -158,9 +161,53 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") { ? intval($zustaende[$feld]) : null; }; raumAntwort([ - "faehrt" => ($zustaende["core:MovingState"] ?? null) === true, - "position" => $zahl("core:ClosureState"), - "neigung" => $zahl("core:SlateOrientationState"), + "faehrt" => ($zustaende["core:MovingState"] ?? null) === true, + "position" => $zahl("core:ClosureState"), + "neigung" => $zahl("core:SlateOrientationState"), + "myPosition" => $zahl("core:Memorized1PositionState"), + "myNeigung" => $zahl("core:Memorized1OrientationState"), + ]); + } + // Den Stand, auf dem die Jalousie gerade steht, als my-Position in den + // Motor schreiben. Die Box kennt dafuer setMemorized1Position und bei + // Raffstores zusaetzlich setMemorized1Orientation; die Geraetesuche + // uebernimmt beide nicht als Kommando, deshalb geht es hier direkt. + // Gespeichert wird, was die Box meldet - nicht, was im Browser steht: + // der koennte einer Fahrt hinterherhinken. + if (($_GET["action"] ?? "") === "my") { + $url = tahomaAktorUrl(intval($body["actor"] ?? 0)); + if ($url === null) { + raumAntwort(["error" => "Kein Tahoma-Gerät mit dieser Nummer."], 404); + } + $vorher = tahomaZustaende($url); + if ($vorher === null) { + raumAntwort(["error" => "Tahoma-Box antwortet nicht."], 503); + } + if (($vorher["core:MovingState"] ?? null) === true) { + raumAntwort(["error" => "Die Jalousie fährt noch – bitte warten, bis sie steht."], 409); + } + if (!isset($vorher["core:Memorized1PositionState"]) || !is_numeric($vorher["core:ClosureState"] ?? null)) { + raumAntwort(["error" => "Dieses Gerät kann keine my-Position speichern."], 400); + } + $position = intval($vorher["core:ClosureState"]); + $neigung = null; + tahomaKommando($url, "setMemorized1Position", [$position]); + $text = "my-Position " . $position . " %"; + if (isset($vorher["core:Memorized1OrientationState"]) && is_numeric($vorher["core:SlateOrientationState"] ?? null)) { + $neigung = intval($vorher["core:SlateOrientationState"]); + tahomaKommando($url, "setMemorized1Orientation", [$neigung]); + $text .= ", Lamellen " . $neigung . " %"; + } + // Die Box braucht einen Moment, bis sie den neuen Wert meldet. + sleep(2); + $nachher = tahomaZustaende($url); + if ($nachher !== null) { + tahomaZustaendeSchreiben($url, $nachher); + } + raumAntwort([ + "ok" => $text . " gespeichert", + "myPosition" => $position, + "myNeigung" => $neigung, ]); } raumAntwort(["error" => "Unbekannte Aktion."], 400); @@ -210,34 +257,48 @@ foreach ($katalog as $d) { // einer Geraeteliste: ein Regler fuer den Raum statt einer Karte je Geraet. $fertig = []; if ($hatThermostat) { - $karte = "
" - . "
" - . "" - . "" - . "
" - . "" - // Der Ist-Zustand kommt live ueber MQTT, nicht aus der Datenbank - - // der Browser hoert ohnehin schon auf Raumtemp/#, und ein Wert von - // vorhin waere neben dem Regler nur irrefuehrend. - . "
" - . " °C" - . " %rF" - . "heizt" - . "
"; - $fertig["heizung"] = $karte; + // Ring mit Ist-Temperatur und ziehbarem Soll-Punkt, daneben - und + und + // die Schnellwahl. Alle Zahlen kommen live ueber MQTT (der Browser hoert + // ohnehin auf den Thermostat-Zweig) und werden von js/solar/raumfenster.js + // eingetragen. Das Thermostat nimmt ganze Grad von 10 bis 30 °C, siehe + // action=temp. + $fertig["heizung"] = "
" + . "
" + . "" + . "" + . "" + . "" + . "" + . "" + . "" + . "
im RaumSoll –
" + . "

Solltemperatur

" + . "
" + . "" + . "
" + . "
" + . "" + . "" + . "" + . "
" + . "" + . "
" + . "
" + . "
"; } // Der Thermostat-Zweig reist mit: das Modal fuellt Ist-Werte und Heizsymbol // aus dem laufenden MQTT-Strom und muss wissen, wo es nachsehen soll. -echo "
"; if (!$gruppen && !$fertig) { - echo "

Diesem Raum ist noch nichts zugeordnet. " + echo "

Diesem Raum ist noch nichts zugeordnet. " . "Das geht über das Haus-Symbol in der Karte „Automatismen“.

"; } echo zeichneGruppen($gruppen, $fertig); -echo "
"; +echo "
Änderungen gelten sofort
"; echo "
"; diff --git a/css/solar.css b/css/solar.css index bac42ec..3073461 100644 --- a/css/solar.css +++ b/css/solar.css @@ -922,262 +922,237 @@ input[type='range']::-webkit-slider-runnable-track { .auto-zeile.tat.leer { grid-template-areas: "sym was weg"; } } -/* Raum-Modal. Ein Kommando mit mehreren Parametern ("Position+Neigung") - traegt Beschriftung, zwei Zahlenfelder und den Ausloeseknopf in einer - input-group - auf einem Handy schrumpfen die Felder dabei auf Nullbreite. - Eine Mindestbreite laesst die Zeile stattdessen umbrechen. */ -.input-group.raum-cmd > .form-control, -.input-group.raum-cmd > .form-select { - min-width: 5rem; -} - - /* -------------------------------------------------------------------------- - Raum-Modal: Bedienelemente - Erzeugt von restricted/roomControls.php, verdrahtet in js/solar/homeMQTT.js. + Raum-Modal (Klick auf einen Raum im Grundriss) + Markup: ajax/room.php + restricted/roomControls.php, + Verhalten: js/solar/raumfenster.js. Gleiche Bauart wie Lade-, Heiz- und + Bewaesserungsfenster (.lade): Akzentfarbe je Bereich, Stufen statt + Schieber, jede Wahl gilt sofort. + + Alle Regeln tragen .rf davor: solar.css laedt vor AdminLTE, und die + Knoepfe hier setzen die Bootstrap-Gestaltung mit all: unset zurueck. -------------------------------------------------------------------------- */ +#modalEV .modal-dialog:has(.rf) { max-width: 42rem; } +#modalEV .modal-content:has(.rf) .modal-footer { display: none; } +#modalEV .modal-title .rf-etage { font-size: .7rem; font-weight: 600; padding: .05rem .5rem; border-radius: 99px; background: rgba(255, 255, 255, .07); color: var(--ton-schrift-2, #9aa6ad); vertical-align: middle; font-family: var(--bs-body-font-family); } -/* Die Geraetegruppen als Reiter. Auf einem Handy passen fuenf - Beschriftungen nicht nebeneinander - dann scrollt die Leiste seitwaerts, - statt die Namen umzubrechen. Die durchgehende Linie unter den Reitern - darf dabei nicht mitscrollen, sie gehoert an den unteren Rand. */ +.rf { display: grid; gap: .75rem; padding: .9rem 1rem .8rem; --a: #60afbf; } +.rf h3 { margin: 0; font-size: .68rem; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--ton-schrift-2, #9aa6ad); } +.rf .rf-leer { margin: 0; color: var(--ton-schrift-2, #9aa6ad); } +.rf .rf-meldung { font-size: .76rem; color: var(--ton-schrift-3, #6f7a81); } +.rf .rf-meldung.gut { color: #6cbe58; } +.rf .rf-meldung.fehler { color: #e5624f; } +.rf .bi { line-height: 1; } -/* Waehrend ein Kommando unterwegs ist. Ein "Zu" an einer Jalousie dauert - ueber eine Minute; ohne sichtbare Sperre tippt man in der Zeit nach. */ -.raum-geraet.raum-warte { - opacity: 0.55; - pointer-events: none; -} -.raum-geraet.raum-warte::after { - content: ""; - position: absolute; - top: 0.5rem; - right: 0.5rem; - width: 0.9rem; - height: 0.9rem; - border: 2px solid currentColor; - border-right-color: transparent; - border-radius: 50%; - animation: raum-dreht 0.8s linear infinite; -} -.raum-geraet { - position: relative; -} -@keyframes raum-dreht { - to { transform: rotate(360deg); } +/* Waehrend ein Kommando unterwegs ist - Lampen, Schalter, Heizung. Jalousien + zeigen stattdessen "faehrt…" (.rf-bewegt). */ +.rf .raum-geraet, .rf .rf-heiz { position: relative; } +.rf .raum-warte { opacity: .6; pointer-events: none; } +.rf .raum-warte::after { + content: ""; position: absolute; top: .55rem; right: .55rem; width: .85rem; height: .85rem; + border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; + animation: raum-dreht .8s linear infinite; } +@keyframes raum-dreht { to { transform: rotate(360deg); } } -/* -------------------------------------------------------------------------- - Regler im Raum-Modal +/* Reiter als Kacheln mit Zustand */ +.rf .rf-reiter { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: .4rem; overflow-x: auto; scrollbar-width: none; } +.rf .rf-reiter::-webkit-scrollbar { display: none; } +.rf .rf-reiter button { + all: unset; box-sizing: border-box; cursor: pointer; min-width: 6.5rem; + display: grid; grid-template-columns: auto minmax(0, 1fr); column-gap: .5rem; align-items: center; + padding: .45rem .6rem; border-radius: .75rem; background: var(--ton-flaeche-2, #2b3237); border: 1.5px solid transparent; + transition: background .2s, border-color .2s; +} +.rf .rf-reiter button:hover { background: #313940; } +.rf .rf-reiter button:focus-visible { outline: 2px solid var(--c); outline-offset: 1px; } +.rf .rf-reiter .rf-sym { grid-row: span 2; width: 1.9rem; height: 1.9rem; border-radius: .55rem; display: grid; place-items: center; background: rgba(255, 255, 255, .06); color: var(--c); font-size: 1rem; } +.rf .rf-reiter b { font-weight: 500; font-size: .84rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.rf .rf-reiter .rf-st { font-size: .72rem; color: var(--ton-schrift-2, #9aa6ad); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-variant-numeric: tabular-nums; } +.rf .rf-reiter .rf-st:empty::before { content: "\00a0"; } +.rf .rf-reiter button[aria-selected="true"] { border-color: var(--c); background: color-mix(in srgb, var(--c) 11%, var(--ton-flaeche-2, #2b3237)); } +.rf .rf-reiter button[aria-selected="true"] .rf-sym { background: color-mix(in srgb, var(--c) 24%, transparent); } +.rf .rf-flaeche { display: grid; gap: .6rem; animation: rf-auf .25s cubic-bezier(.2, .7, .2, 1); } +.rf .rf-flaeche[hidden] { display: none; } +@keyframes rf-auf { from { opacity: 0; transform: translateY(4px); } } - Eigenes Aussehen statt des Bootstrap-Standards: dessen weisse Bahn mit dem - blauen Griff sticht auf den dunklen Karten heraus, und in einem Raum mit - drei Jalousien sind das sechs helle Balken. Bahn und Griff tragen deshalb - dasselbe Blaugruen wie der Temperaturregler, und der ungefuellte Teil ist - ein Grau, das in beiden Themes traegt. +/* Bausteine */ +.rf .rf-chips { display: flex; flex-wrap: wrap; gap: .35rem; } +.rf .rf-chip { all: unset; box-sizing: border-box; cursor: pointer; padding: .32rem .7rem; border-radius: 99px; background: #363d43; font-size: .8rem; font-weight: 500; display: inline-flex; align-items: center; gap: .35rem; font-variant-numeric: tabular-nums; } +.rf .rf-chip:hover { background: #3f474e; } +.rf .rf-chip span { opacity: .7; } +.rf .rf-chip[aria-pressed="true"] { background: var(--a); color: #0c1a1d; font-weight: 600; } +.rf .rf-chip:focus-visible { outline: 2px solid var(--a); outline-offset: 1px; } +.rf .rf-pille { font-size: .7rem; font-weight: 600; border-radius: 99px; padding: .05rem .5rem; background: rgba(255, 255, 255, .06); color: var(--ton-schrift-2, #9aa6ad); white-space: nowrap; display: inline-flex; align-items: center; gap: .3rem; } +.rf .rf-pille:empty { display: none; } +.rf .rf-pille.faehrt { color: #60afbf; background: rgba(96, 175, 191, .15); } +.rf .rf-pille.faehrt::before { content: ""; width: .4rem; height: .4rem; border-radius: 50%; background: currentColor; animation: rf-puls 1.2s infinite; } +@keyframes rf-puls { 50% { opacity: .3; } } +.rf .rf-link { all: unset; cursor: pointer; color: #60afbf; font-weight: 500; margin-left: auto; } +.rf .rf-link:hover { text-decoration: underline; } +.rf .rf-link:focus-visible { outline: 2px solid #60afbf; outline-offset: 2px; border-radius: 3px; } +.rf .rf-kopf-zeile { display: flex; align-items: center; gap: .5rem; min-width: 0; } +.rf .rf-kopf-zeile > b { font-weight: 500; font-size: .92rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.rf .rf-kopf-zeile .rf-pille { margin-left: auto; } +.rf .rf-mehr { flex: none; } +.rf .rf-kopf-zeile b + .rf-mehr, .rf .rf-kopf-zeile .rf-pille:empty + .rf-mehr, .rf .rf-lampe-name + .rf-mehr { margin-left: auto; } +.rf .rf-mehr-knopf { all: unset; cursor: pointer; width: 1.75rem; height: 1.75rem; display: grid; place-items: center; border-radius: .5rem; color: var(--ton-schrift-2, #9aa6ad); } +.rf .rf-mehr-knopf:hover { background: #363d43; } +.rf .rf-karte { display: grid; gap: .5rem; padding: .7rem .75rem; border-radius: .75rem; background: var(--ton-flaeche-2, #2b3237); } +.rf .rf-karte .btn { --bs-btn-padding-y: .2rem; } +.rf details.rf-effekte summary { cursor: pointer; font-size: .8rem; color: var(--ton-schrift-2, #9aa6ad); list-style: none; display: flex; align-items: center; gap: .35rem; } +.rf details.rf-effekte summary::-webkit-details-marker { display: none; } +.rf details.rf-effekte summary::after { content: "›"; font-size: 1rem; transition: transform .2s; } +.rf details.rf-effekte[open] summary::after { transform: rotate(90deg); } +.rf details.rf-effekte[open] { display: grid; gap: .5rem; } - Bewusst nur innerhalb von #roomView: die Regler in den Modals fuer - Autoladung und Heizstab bleiben, wie sie sind. - -------------------------------------------------------------------------- */ -#roomView input[type='range'] { - -webkit-appearance: none; - appearance: none; - width: 100%; - height: 1.5rem; - padding: 0; - margin: 0; - background: transparent; -} -#roomView input[type='range']:focus { - outline: 0; -} -/* Getrennt notiert, nicht als Liste: einen Selektor, den er nicht kennt, - wirft der Browser samt der ganzen Regel weg - und dann bliebe die Bahn - ungestaltet. */ -#roomView input[type='range']::-webkit-slider-runnable-track { - height: 0.375rem; - border: 0; - border-radius: 0.375rem; - background: linear-gradient(to right, #00788F, #00788F), rgba(128, 128, 128, 0.32); - background-size: var(--background-size, 0%) 100%; - background-repeat: no-repeat; -} -#roomView input[type='range']::-moz-range-track { - height: 0.375rem; - border: 0; - border-radius: 0.375rem; - background: linear-gradient(to right, #00788F, #00788F), rgba(128, 128, 128, 0.32); - background-size: var(--background-size, 0%) 100%; - background-repeat: no-repeat; -} -#roomView input[type='range']::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 1.05rem; - height: 1.05rem; - /* (Bahnhoehe - Griffhoehe) / 2, damit der Griff auf der Bahn sitzt. */ - margin-top: -0.34rem; - border: 2px solid #fff; - border-radius: 50%; - background: #00788F; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.45); -} -#roomView input[type='range']::-moz-range-thumb { - width: 1.05rem; - height: 1.05rem; - border: 2px solid #fff; - border-radius: 50%; - background: #00788F; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.45); -} +/* Regler wie im Ladefenster */ +.rf input[type="range"] { -webkit-appearance: none; appearance: none; width: 100%; height: 1.9rem; background: transparent; margin: 0; } +.rf input[type="range"]::-webkit-slider-runnable-track { height: .5rem; border-radius: .25rem; background: linear-gradient(90deg, var(--a) 0 var(--wert, 0%), #394046 var(--wert, 0%)); } +.rf input[type="range"]::-moz-range-track { height: .5rem; border-radius: .25rem; background: linear-gradient(90deg, var(--a) 0 var(--wert, 0%), #394046 var(--wert, 0%)); } +.rf input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 1.5rem; height: 1.5rem; margin-top: -.5rem; border-radius: 50%; background: #fff; border: 4px solid var(--a); box-shadow: 0 .15rem .5rem rgba(0, 0, 0, .4); } +.rf input[type="range"]::-moz-range-thumb { width: 1rem; height: 1rem; border-radius: 50%; background: #fff; border: 4px solid var(--a); } +.rf input[type="range"]:focus-visible { outline: 2px solid var(--a); outline-offset: 4px; } -/* Schieberegler stehen dicht untereinander (Helligkeit, Farbe). Ohne den - Abstand kleben Beschriftung und Bahn aneinander. - Die senkrechten Spalten stehen dagegen nebeneinander - dort wuerde der - Abstand die zweite Spalte um genau diese 0,35 rem tiefer setzen. */ -.raum-regler + .raum-regler:not(.raum-spalte) { - margin-top: 0.35rem; -} +/* --- Beschattung --- */ +.rf .rf-alle { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem .75rem; padding: .6rem .7rem; border-radius: .75rem; background: var(--ton-flaeche-2, #2b3237); } +.rf .rf-alle h3 { margin-right: auto; } +.rf .rf-alle .bi { font-size: .8rem; } -/* Messwerte als Gitter statt als Textwurst: ein Stromzaehler hat sechzehn - Werte, die in einer Zeile niemand findet. auto-fill statt fester Spalten, - damit auf dem Handy zwei und auf dem Schirm vier nebeneinander passen. */ -.raum-werte { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(6.5rem, 1fr)); - gap: 0.4rem; -} -.raum-wert { - background: rgba(128, 128, 128, 0.08); - border-radius: 0.35rem; - padding: 0.3rem 0.45rem; - overflow: hidden; -} -.raum-wert-zahl { - font-size: 0.95rem; - font-weight: 600; - line-height: 1.2; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.raum-wert-einheit { - font-size: 0.7rem; - font-weight: 400; - opacity: 0.6; -} -.raum-wert-name { - font-size: 0.68rem; - opacity: 0.6; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} +.rf .rf-behang { display: grid; grid-template-columns: 4.75rem minmax(0, 1fr); gap: .85rem; padding: .7rem; border-radius: .75rem; background: var(--ton-flaeche-2, #2b3237); border: 1.5px solid transparent; transition: border-color .3s; } +.rf .rf-behang.rf-bewegt { border-color: rgba(96, 175, 191, .6); } +.rf .rf-behang-mitte { display: grid; gap: .5rem; min-width: 0; align-content: start; } +/* Das Fenster: dunkles Glas mit Tageslicht-Schimmer, helle Lamellen, die + Unterleiste im Akzent. Ueber die ganze Hoehe der Karte. */ +.rf .rf-fenster { position: relative; min-height: 6rem; padding: .25rem; border-radius: .75rem; background: #1c2226; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .07); touch-action: none; } +.rf .rf-fenster[data-rf-zieh] { cursor: ns-resize; } +.rf .rf-glas { position: relative; height: 100%; border-radius: .5rem; overflow: hidden; + background: radial-gradient(120% 55% at 80% 0%, rgba(126, 214, 230, .5), transparent 60%), linear-gradient(170deg, #1f4c5a 0%, #173640 55%, #122a31 100%); } +.rf .rf-glas::after { content: ""; position: absolute; inset: 0; background: linear-gradient(115deg, transparent 38%, rgba(255, 255, 255, .09) 44%, transparent 52%); pointer-events: none; } +.rf .rf-behang-teil { position: absolute; left: 0; right: 0; top: 0; height: 0; overflow: hidden; transition: height .35s cubic-bezier(.2, .7, .2, 1); } +.rf .rf-lamellen { position: absolute; left: 0; right: 0; bottom: 3px; display: grid; gap: 1.5px; } +.rf .rf-lamellen i { display: block; height: 5px; border-radius: 3px; background: linear-gradient(180deg, #e9f1f3, #b9c9cf); transform: scaleY(var(--kipp, 1)); box-shadow: 0 1px 0 rgba(0, 0, 0, .25); transition: transform .35s; } +.rf .rf-panzer { position: absolute; inset: 0 0 3px; background: repeating-linear-gradient(0deg, #d5e0e4 0 5px, #a9bac0 5px 6.5px); } +.rf .rf-schiene { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; border-radius: 2px; background: #60afbf; box-shadow: 0 0 8px rgba(96, 175, 191, .7); } -/* -------------------------------------------------------------------------- - Raum-Modal: Jalousien - -------------------------------------------------------------------------- */ +.rf .rf-fahrt { display: grid; grid-template-columns: 1fr auto auto 1fr; gap: .35rem; } +.rf .rf-fahrt button { all: unset; box-sizing: border-box; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: .35rem; height: 2.4rem; padding: 0 .6rem; border-radius: .6rem; background: #363d43; font-weight: 600; font-size: .86rem; } +.rf .rf-fahrt button:hover { background: #3f474e; } +.rf .rf-fahrt button:active { transform: scale(.97); } +.rf .rf-fahrt button:focus-visible { outline: 2px solid #60afbf; outline-offset: 1px; } +.rf .rf-fahrt .rf-stop { width: 2.75rem; } +.rf .rf-fahrt .rf-my .bi { color: #f7c000; font-size: .8rem; } +.rf .rf-fahrt .rf-my.aktiv { background: color-mix(in srgb, #f7c000 16%, #363d43); box-shadow: inset 0 0 0 1.5px #f7c000; } -/* Fahrknoepfe, Hoehe und Neigung nebeneinander. Die Neigung nimmt den Rest - - sie ist die einzige der drei Spalten, die von Breite profitiert. */ -.raum-jalousie { - display: flex; - align-items: stretch; - justify-content: space-between; - gap: 0.4rem; -} -/* Das Fensterbild steht zwischen Knoepfen und Reglern - schmal, mit Luft - zu beiden Seiten. Der uebrige Platz verteilt sich auf die Zwischenraeume - (space-between an .raum-jalousie), statt das Bild breitzuziehen. */ -.raum-jalousie .raum-lamellen { - flex: 0 1 5rem; - min-width: 2.2rem; - margin: 0 0.4rem; -} -.raum-jalousie .dropdown { - align-self: flex-start; -} +.rf .rf-reihe { display: grid; grid-template-columns: 4.3rem minmax(0, 1fr); align-items: center; gap: .5rem; } +.rf .rf-reihe-titel { font-size: .76rem; color: var(--ton-schrift-2, #9aa6ad); } +.rf .rf-segment { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 3px; padding: 3px; border-radius: .6rem; background: #22292d; } +.rf .rf-segment button { all: unset; box-sizing: border-box; cursor: pointer; text-align: center; padding: .3rem .1rem; border-radius: .45rem; font-size: .76rem; font-weight: 500; color: var(--ton-schrift-2, #9aa6ad); font-variant-numeric: tabular-nums; white-space: nowrap; } +.rf .rf-segment button:hover { color: var(--ton-schrift, #e8ede9); } +.rf .rf-segment button[aria-checked="true"] { background: #363d43; color: var(--ton-schrift, #e8ede9); box-shadow: inset 0 0 0 1.5px var(--a); } +.rf .rf-segment button:focus-visible { outline: 2px solid var(--a); } -/* Auf, Stop, Zu untereinander - die Knoepfe zeigen in die Richtung, in die - sie fahren. Breit genug fuer einen Daumen: sie sind das, was man im - Vorbeigehen antippt, die Regler daneben braucht man seltener. */ -.raum-fahrt { - flex: 0 0 3.1rem; -} -.raum-fahrt .btn { - padding: 0.3rem 0; - line-height: 1.5; -} +.rf .rf-my-zeile { display: flex; flex-wrap: wrap; align-items: center; gap: .25rem .5rem; font-size: .76rem; color: var(--ton-schrift-3, #6f7a81); min-height: 1.5rem; } +.rf .rf-my-zeile > .bi { color: #f7c000; font-size: .72rem; } +.rf .rf-my-zeile.frage { padding: .35rem .5rem; border-radius: .55rem; background: rgba(247, 192, 0, .08); color: var(--ton-schrift, #e8ede9); } +.rf .rf-my-knoepfe { display: inline-flex; gap: .3rem; margin-left: auto; } +.rf .rf-my-zeile .rf-chip { padding: .2rem .6rem; font-size: .76rem; } +.rf .rf-my-zeile .rf-chip.ja { background: #f7c000; color: #1a1406; font-weight: 600; } -/* Eine Spalte mit senkrechtem Regler: Name, oberes Wort, Bahn, unteres - Wort, Zahl. Breit genug fuer "Position" in der kleinen Schrift. */ -.raum-spalte { - flex: 0 0 3.4rem; - display: flex; - flex-direction: column; - align-items: center; - text-align: center; -} -.raum-name { - font-size: 0.62rem; - opacity: 0.8; - white-space: nowrap; - line-height: 1.2; -} +/* --- Heizung --- */ +.rf .rf-heiz { display: grid; grid-template-columns: 10.5rem minmax(0, 1fr); gap: 1rem; align-items: center; --a: #e5624f; } +.rf .rf-ring { position: relative; width: 10.5rem; aspect-ratio: 1; max-width: 100%; } +.rf .rf-ring svg { width: 100%; height: 100%; display: block; touch-action: none; } +.rf .rf-ring-spur { fill: none; stroke: #2b3237; stroke-width: 12; stroke-linecap: round; } +.rf .rf-ring-ist { fill: none; stroke: url(#rfHeizVerlauf); stroke-width: 12; stroke-linecap: round; transition: stroke-dasharray .6s cubic-bezier(.2, .7, .2, 1); } +.rf .rf-ring-griff { fill: none; stroke: transparent; stroke-width: 36; cursor: pointer; } +.rf .rf-ring-soll { fill: #fff; stroke: #e5624f; stroke-width: 4; cursor: grab; transform-box: view-box; transform-origin: 85px 85px; transition: transform .35s cubic-bezier(.2, .7, .2, 1); } +.rf .rf-heiz.zieht .rf-ring-soll { cursor: grabbing; transition: none; } +.rf .rf-ring-soll:focus-visible { outline: none; stroke: #fff; fill: #e5624f; } +.rf .rf-ring-mitte { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; pointer-events: none; } +.rf .rf-ring-mitte small { font-size: .7rem; color: var(--ton-schrift-2, #9aa6ad); } +.rf .rf-ring-mitte b { font-size: 1.9rem; font-weight: 600; line-height: 1.05; font-variant-numeric: tabular-nums; } +.rf .rf-ring-mitte b small { font-size: .95rem; font-weight: 500; color: var(--ton-schrift, #e8ede9); } +.rf .rf-ring-mitte span { font-size: .74rem; color: var(--ton-schrift-2, #9aa6ad); } +.rf .rf-heiz-rechts { display: grid; gap: .6rem; min-width: 0; } +.rf .rf-stepper { display: grid; grid-template-columns: 3rem minmax(0, 1fr) 3rem; align-items: center; gap: .5rem; background: var(--ton-flaeche-2, #2b3237); border-radius: .85rem; padding: .35rem; } +.rf .rf-stepper button { all: unset; cursor: pointer; height: 3rem; border-radius: .6rem; display: grid; place-items: center; background: #363d43; font-size: 1.4rem; font-weight: 500; } +.rf .rf-stepper button:hover { background: #3f474e; } +.rf .rf-stepper button:active { transform: scale(.95); } +.rf .rf-stepper button:focus-visible { outline: 2px solid #e5624f; } +.rf .rf-stepper output { text-align: center; font-size: 1.55rem; font-weight: 600; font-variant-numeric: tabular-nums; line-height: 1.15; } +.rf .rf-stepper output small { display: block; font-size: .7rem; font-weight: 400; color: var(--ton-schrift-2, #9aa6ad); } +.rf .rf-hinweis { padding: .45rem .65rem; border-radius: .6rem; background: rgba(255, 255, 255, .05); color: var(--ton-schrift-2, #9aa6ad); font-size: .8rem; display: flex; gap: .45rem; align-items: flex-start; } +.rf .rf-hinweis[hidden] { display: none; } +.rf .rf-hinweis.an { background: rgba(229, 98, 79, .12); color: #f3b3a9; } +.rf .rf-hinweis .bi { margin-top: .15rem; } +.rf .rf-pillen { display: flex; flex-wrap: wrap; gap: .35rem; } +.rf .rf-pillen .rf-pille { font-size: .74rem; padding: .15rem .6rem; } +.rf .rf-pillen .rf-pille[hidden] { display: none; } -/* Der Kasten, in dem ein gewoehnlicher Regler auf der Seite liegt. - Gedreht statt writing-mode: so gebaute Regler lassen sich am Handy kaum - anfassen - das Ziehen wird als Wischen gelesen und blaettert die Seite. - Gedreht bewegt sich der Regler in seinen eigenen Koordinaten waagerecht, - und touch-action haelt die Seite still, solange der Finger auf ihm liegt. - rotate(90deg) legt den kleinen Wert nach oben: "oben" ist 0 %. */ -.raum-senkrecht { - position: relative; - width: 1.6rem; - height: 6.5rem; - margin: 0.1rem 0; -} -#roomView .raum-senkrecht input[type='range'] { - position: absolute; - top: 50%; - left: 50%; - width: 6.5rem; /* = Hoehe des Kastens, die Laenge der Bahn */ - height: 1.6rem; /* = Breite des Kastens, die Flaeche zum Treffen */ - transform: translate(-50%, -50%) rotate(90deg); - touch-action: none; -} +/* --- Licht --- */ +.rf .rf-lampe { display: grid; gap: .55rem; padding: .7rem; border-radius: .75rem; background: var(--ton-flaeche-2, #2b3237); --a: #f7c000; } +.rf .rf-lampe-kopf { display: flex; align-items: center; gap: .7rem; } +.rf .rf-birne { all: unset; cursor: pointer; flex: none; width: 3rem; height: 3rem; border-radius: .9rem; display: grid; place-items: center; background: #22292d; color: var(--ton-schrift-3, #6f7a81); font-size: 1.35rem; transition: background .3s, box-shadow .3s, color .3s; } +.rf .rf-birne[aria-pressed="true"] { color: #1a1406; background: var(--farbe, #ffd27a); box-shadow: 0 0 0 4px color-mix(in srgb, var(--farbe, #ffd27a) 22%, transparent), 0 .35rem 1.3rem color-mix(in srgb, var(--farbe, #ffd27a) 45%, transparent); } +.rf .rf-birne:focus-visible { outline: 2px solid #f7c000; outline-offset: 3px; } +.rf .rf-lampe-name { display: grid; min-width: 0; } +.rf .rf-lampe-name b { font-weight: 500; font-size: .92rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.rf .rf-lampe-name span { font-size: .76rem; color: var(--ton-schrift-2, #9aa6ad); font-variant-numeric: tabular-nums; } +.rf .rf-lampe-regler { display: grid; gap: .5rem; transition: opacity .3s; } +.rf .rf-lampe.aus .rf-lampe-regler { opacity: .5; } +.rf .rf-hell::-webkit-slider-runnable-track { background: linear-gradient(90deg, #4a3f22 0, var(--farbe, #ffd27a) var(--wert, 0%), #394046 var(--wert, 0%)) !important; } +.rf .rf-hell::-moz-range-track { background: linear-gradient(90deg, #4a3f22 0, var(--farbe, #ffd27a) var(--wert, 0%), #394046 var(--wert, 0%)) !important; } +.rf .rf-farben { display: flex; flex-wrap: wrap; gap: .45rem; align-items: center; } +.rf .rf-farbe { all: unset; box-sizing: border-box; cursor: pointer; width: 1.65rem; height: 1.65rem; border-radius: 50%; background: var(--f); box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .25); transition: transform .15s; position: relative; } +.rf .rf-farbe:hover { transform: scale(1.1); } +.rf .rf-farbe[aria-pressed="true"] { box-shadow: 0 0 0 2px var(--ton-flaeche-2, #2b3237), 0 0 0 4px var(--f); } +.rf .rf-farbe:focus-visible, .rf .rf-farbe-eigen:focus-within { outline: 2px solid #f7c000; outline-offset: 3px; } +.rf .rf-farbe-eigen { background: conic-gradient(#f55, #fd5, #5f7, #5df, #75f, #f5d, #f55); } +.rf .rf-farbe-eigen input { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%; } +.rf .rf-auswahl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: .5rem; align-items: center; font-size: .78rem; color: var(--ton-schrift-2, #9aa6ad); margin: 0; } -/* Die Endstellungen in Worten statt in Prozent. */ -.raum-ende { - font-size: 0.68rem; - opacity: 0.65; - line-height: 1.1; -} +/* --- Schalter --- */ +.rf .rf-schalter-liste { display: grid; grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); gap: .5rem; } +.rf .rf-schalter { all: unset; box-sizing: border-box; cursor: pointer; display: grid; grid-template-columns: auto minmax(0, 1fr); column-gap: .6rem; align-items: center; padding: .6rem; border-radius: .75rem; background: var(--ton-flaeche-2, #2b3237); border: 1.5px solid transparent; position: relative; } +.rf .rf-schalter .rf-sym { grid-row: span 2; width: 2.1rem; height: 2.1rem; border-radius: .6rem; display: grid; place-items: center; background: #22292d; color: var(--ton-schrift-3, #6f7a81); } +.rf .rf-schalter b { font-weight: 500; font-size: .84rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.rf .rf-schalter .rf-st { font-size: .72rem; color: var(--ton-schrift-2, #9aa6ad); } +.rf .rf-schalter[aria-pressed="true"] { border-color: #6cbe58; } +.rf .rf-schalter[aria-pressed="true"] .rf-sym { background: rgba(108, 190, 88, .18); color: #6cbe58; } +.rf .rf-schalter:focus-visible { outline: 2px solid #6cbe58; } -/* Die Lamellen zur Neigung. Ihre Hoehe setzt lamellenZeichnen() in - js/solar/homeMQTT.js - schmal mit Luft dazwischen heisst offen, - aneinanderliegend heisst geschlossen. */ -.raum-lamellen { - display: block; - align-self: center; - height: 6.9rem; - /* Der Rahmen macht aus dem Streifen ein Fenster - ohne ihn liest sich das - geschlossene Bild wie eine zweite Reglerbahn. */ - background: rgba(0, 120, 143, 0.14); - border: 1px solid rgba(128, 128, 128, 0.35); - border-radius: 0.2rem; -} -.raum-lamelle { - fill: currentColor; - opacity: 0.6; - stroke: rgba(0, 0, 0, 0.35); - stroke-width: 0.6; - vector-effect: non-scaling-stroke; -} -.raum-endschiene { - fill: currentColor; - opacity: 0.85; +/* --- Messwerte und Sonstiges --- */ +.rf .raum-werte { display: grid; grid-template-columns: repeat(auto-fill, minmax(6.5rem, 1fr)); gap: .4rem; } +.rf .raum-wert { background: rgba(255, 255, 255, .04); border-radius: .5rem; padding: .3rem .5rem; overflow: hidden; } +.rf .raum-wert-zahl { font-size: .95rem; font-weight: 600; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.rf .raum-wert-einheit { font-size: .7rem; font-weight: 400; opacity: .6; } +.rf .raum-wert-name { font-size: .68rem; opacity: .6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +/* Ein Kommando mit mehreren Parametern traegt Beschriftung, Felder und Knopf + in einer input-group - ohne Mindestbreite schrumpfen die Felder am Handy + auf nichts. */ +.rf .input-group.raum-cmd > .form-control, +.rf .input-group.raum-cmd > .form-select { min-width: 5rem; } + +/* --- Handy --- */ +@media (max-width: 575.98px) { + .rf { padding: .75rem .75rem .7rem; } + .rf .rf-reiter button { grid-template-columns: 1fr; justify-items: center; text-align: center; padding: .4rem .25rem; row-gap: .1rem; min-width: 5.5rem; } + .rf .rf-reiter .rf-sym { grid-row: auto; width: 1.6rem; height: 1.6rem; } + .rf .rf-reiter b { font-size: .76rem; } + .rf .rf-reiter .rf-st { font-size: .68rem; } + .rf .rf-behang { grid-template-columns: 3.6rem minmax(0, 1fr); gap: .6rem; padding: .6rem; } + /* Ueber die ganze Kartenhoehe waere das Fenster hier ein Strich - also ein + festes Seitenverhaeltnis, oben ausgerichtet. */ + .rf .rf-fenster { align-self: start; height: auto; aspect-ratio: 9 / 16; min-height: 0; } + .rf .rf-behang .rf-kopf-zeile { flex-wrap: wrap; row-gap: .15rem; } + .rf .rf-behang .rf-kopf-zeile > b { flex: 1 1 calc(100% - 2.5rem); } + .rf .rf-behang .rf-kopf-zeile .rf-pille { order: 3; margin-left: 0; } + .rf .rf-fahrt button { padding: 0 .4rem; } + .rf .rf-reihe { grid-template-columns: 1fr; gap: .2rem; } + .rf .rf-heiz { grid-template-columns: 1fr; justify-items: center; } + .rf .rf-heiz-rechts { width: 100%; } } /* --- Einstellungen: Preistabellen ------------------------------------ */ diff --git a/index.php b/index.php index 65fcac0..40bbe30 100644 --- a/index.php +++ b/index.php @@ -103,7 +103,7 @@ if (isset($_GET["addUser"])) { "scripts" => ["js/solar/jahresstatistik.js", "js/solar/anlageKatalog.js", "js/solar/anlage.js", "js/solar/speicher.js", "js/solar/energieflussKatalog.js", "js/solar/energiefluss.js", "js/solar/bewaesserungAnzeige.js", "js/solar/ladefenster.js", "js/solar/bedienfenster.js", "js/solar/solarMQTT.js"]], "home" => ["template" => "home.php", "mqtt" => true, "charts" => false, "meteogram" => false, "leaflet" => false, - "scripts" => ["js/solar/autoActionFuncs.js", "js/solar/zeitleiste.js", "js/solar/homeMQTT.js"]], + "scripts" => ["js/solar/autoActionFuncs.js", "js/solar/zeitleiste.js", "js/solar/raumfenster.js", "js/solar/homeMQTT.js"]], "heat" => ["template" => "heat.php", "mqtt" => true, "charts" => true, "meteogram" => false, "leaflet" => false, "scripts" => ["js/solar/heatMQTT.js"]], "history" => ["template" => "history.php", "mqtt" => false, "charts" => true, "meteogram" => false, "leaflet" => false, diff --git a/js/solar/common.js b/js/solar/common.js index 82baf5c..da180b5 100644 --- a/js/solar/common.js +++ b/js/solar/common.js @@ -73,31 +73,6 @@ async function getData(chart, url, options = {}) { } } -/** - * Schieberegler im Modal mit seiner Beschriftung verbinden. - * label(wert) liefert den anzuzeigenden Text. - */ -function bindModalSlider(slider, span, label) { - slider.oninput = function () { - const min = Number(slider.min); - const max = Number(slider.max); - const percent = (Number(slider.value) - min) * 100 / (max - min); - slider.style.setProperty("--background-size", `${percent}%`); - - // Beschriftung mitlaufen lassen, an den Raendern etwas gebremst - let margin = percent; - if (margin < 10) { - margin = margin - 0.4 * margin; - } else if (margin < 85) { - margin = margin - 4; - } else { - margin = margin - 4 - 0.5 * (margin - 85); - } - span.setAttribute('style', 'margin-left:' + margin + '%;'); - span.innerHTML = label(slider.value); - }; -} - /** Meteoblue-Meteogramm einhängen, sofern die Seite einen Platz dafür hat. */ // Mindestbreite, in der das Meteogramm von meteoblue geladen wird. Der // Anbieter legt sein Bild einmal beim Laden fest und zeichnet es bei einer diff --git a/js/solar/homeMQTT.js b/js/solar/homeMQTT.js index ca1ea28..97028e3 100644 --- a/js/solar/homeMQTT.js +++ b/js/solar/homeMQTT.js @@ -180,6 +180,7 @@ const homeSVG = { // mitsamt den Werten, die auf der Kachel stehen sollen. Jeder Raum // bekommt die gleiche Behandlung - Raeume und ihre Werte kommen dort // dazu, nicht hier. + if (typeof raumfensterMqtt === "function") raumfensterMqtt(); homeRooms.forEach(function (room) { const el = (part) => document.getElementById(room.floor + "_" + part); @@ -318,10 +319,16 @@ function switchFloor(floor){ // Ersetzung bleibt fuer die alten Namen ("Kueche"), bis sie jemand // unter Einstellungen -> Grundriss umbenennt. const titel = eintrag - ? eintrag.floor + " · " + eintrag.mqtt.replace("ue", "ü").replace("ae", "ä").replace("oe", "ö") + ? eintrag.mqtt.replace("ue", "ü").replace("ae", "ä").replace("oe", "ö") : "Raum"; const modalTitel = document.getElementById("modal-title"); modalTitel.textContent = titel; + if (eintrag) { + const etage = document.createElement("span"); + etage.className = "rf-etage"; + etage.textContent = eintrag.floor; + modalTitel.append(" ", etage); + } // Der Speichern-Knopf gehoert allen Modals gemeinsam. Ein Klon ohne // Zuhoerer stellt sicher, dass nicht der Handler eines vorher @@ -346,414 +353,8 @@ function switchFloor(floor){ .then(a => a.text()) .then(html => { koerper.innerHTML = html; - - const slider = document.getElementById("modal-slider"); - if (slider) { - bindModalSlider(slider, document.getElementById("modal-slider-label"), - wert => wert + " °C"); - const stand = mqttZweig(eintrag?.thermostat)?.["Set Temp[degC]"]; - slider.value = typeof stand !== "undefined" ? stand : 10; - // Nur "input": das zeichnet die Anzeige, schickt aber nichts. - slider.dispatchEvent(new Event("input")); - // "change" kommt beim Loslassen - wie bei den Rollladenreglern. - slider.addEventListener("change", () => sendeSolltemperatur(raum)); - } - - raumBedienungAktivieren(koerper); - // Jede Jalousie im Raum einmal an der Box nachfragen. Der Wert im - // Modal kommt aus actor_states und ist damit hoechstens so frisch - // wie der letzte Poll des Runners - und wer das Modal waehrend einer - // Fahrt aufmacht, saehe sonst den Stand von davor und nie wieder - // etwas anderes: gezeichnet wird nur beim Oeffnen. - koerper.querySelectorAll(".raum-geraet").forEach(k => jalousieVerfolgen(k, null)); - raumMesswerteAktualisieren(eintrag?.thermostat); + // Verdrahten, zeichnen, Jalousien nachfragen: js/solar/raumfenster.js. + raumfensterStarten(koerper.querySelector("#roomView") || koerper, raum, eintrag?.thermostat); }) .catch(fehler => { koerper.innerHTML = "Konnte den Raum nicht laden: " + fehler.message; }); } - - /** - * Die Bedienelemente des Raum-Modals mit ihren Kommandos verbinden. - * - * Welches Geraet welches Element bekommt, entscheidet der Server - * (restricted/roomControls.php); hier haengen nur die Zuhoerer an den - * data-Attributen, die er dabei setzt. - */ - function raumBedienungAktivieren(koerper) { - koerper.querySelectorAll("button[data-cmd]").forEach(k => { - k.addEventListener("click", () => schalte(k)); - }); - - // Schieberegler: waehrend des Ziehens nur die Anzeige, geschickt wird - // erst beim Loslassen. Sonst bekaeme eine Jalousie bei jedem Pixel ein - // neues Ziel und kaeme aus dem Anfahren nicht mehr heraus. - koerper.querySelectorAll(".raum-jalousie").forEach(lamellenZeichnen); - koerper.querySelectorAll(".raum-regler").forEach(regler => { - const schieber = regler.querySelector("input[type=range]"); - reglerAnzeigen(regler); - schieber.addEventListener("input", () => reglerAnzeigen(regler)); - schieber.addEventListener("change", () => { - const params = {}; - params[regler.dataset.param] = schieber.value; - // Der Hoehenregler nimmt die Neigung mit: eine Hoehenfahrt rastet die - // Lamellen um, und der Server faehrt sie nur dann wieder an, wenn der - // Befehl ein Neigungsziel dabei hat (Kugelschreiber-Mechanik, siehe - // sendeTahoma() in restricted/commands.php). Welcher Parameter das - // ist, sagt der Server ueber data-mit-param. - if (regler.dataset.mitParam) { - const daneben = regler.closest(".raum-jalousie") - .querySelector("[data-rolle='" + regler.dataset.mitRolle + "'] input[type=range]"); - if (daneben) params[regler.dataset.mitParam] = daneben.value; - } - raumKommando(Number(regler.dataset.cmd), params, regler); - }); - }); - - // Kippschalter: An und Aus sind zwei getrennte Kommandos. Kennt das - // Geraet nur "umschalten", tut es auch das - dann stimmt die Stellung - // hinterher, weil wir sie selbst gesetzt haben. - koerper.querySelectorAll(".raum-schalter").forEach(schalter => { - const kasten = schalter.querySelector("input[type=checkbox]"); - const beschriftung = schalter.querySelector("label"); - kasten.addEventListener("change", () => { - const ziel = kasten.checked ? schalter.dataset.ein : schalter.dataset.aus; - const cmd = ziel || schalter.dataset.um; - beschriftung.textContent = kasten.checked ? "An" : "Aus"; - if (!cmd) return; - raumKommando(Number(cmd), {}, schalter); - }); - }); - - // Farbe: der Browser bringt seinen Farbwaehler mit, das Geraet will drei - // Zahlen 0-255. Zerlegt wird deshalb erst hier. - koerper.querySelectorAll(".raum-farbe").forEach(feld => { - const eingabe = feld.querySelector("input[type=color]"); - eingabe.addEventListener("change", () => { - const hex = eingabe.value; - const params = {}; - params[feld.dataset.red] = parseInt(hex.substr(1, 2), 16); - params[feld.dataset.green] = parseInt(hex.substr(3, 2), 16); - params[feld.dataset.blue] = parseInt(hex.substr(5, 2), 16); - raumKommando(Number(feld.dataset.cmd), params, feld); - }); - }); - - // Auswahllisten, die ohne zusaetzlichen Knopf schalten - Effekt, Preset. - koerper.querySelectorAll("select.raum-sofort").forEach(auswahl => { - auswahl.addEventListener("change", () => { - const params = {}; - params[auswahl.dataset.param] = auswahl.value; - raumKommando(Number(auswahl.dataset.cmd), params, auswahl); - }); - }); - } - - /** - * Anzeige, gefuellte Bahn und Lamellenbild eines Reglers nachziehen. - * - * Was 0 % und 100 % bedeuten, steht bei den senkrechten Reglern als Wort - * an ihren Enden ("oben"/"unten", "auf"/"zu"); eine Zahl steht dort nicht - * mehr - das Bild daneben zeigt beim Ziehen mehr als sie. - */ - function reglerAnzeigen(regler) { - const schieber = regler.querySelector("input[type=range]"); - const anzeige = regler.querySelector(".raum-regler-wert"); - const wert = Number(schieber.value); - - if (anzeige) { - anzeige.textContent = wert + (schieber.dataset.einheit || ""); - } - - // Der gefuellte Anteil der Bahn - dieselbe Eigenschaft, aus der auch - // bindModalSlider() in common.js den Temperaturregler faerbt. - const min = Number(schieber.min); - const max = Number(schieber.max); - schieber.style.setProperty("--background-size", - ((wert - min) * 100 / (max - min)) + "%"); - - lamellenZeichnen(regler.closest(".raum-jalousie")); - } - - /** Der Stand eines der beiden Regler einer Jalousie, als Anteil 0..1. */ - function reglerAnteil(jalousie, rolle, ersatz) { - const schieber = jalousie.querySelector("[data-rolle=" + rolle + "] input[type=range]"); - if (!schieber) return ersatz; - const min = Number(schieber.min); - return (Number(schieber.value) - min) / (Number(schieber.max) - min); - } - - /** - * Das Fenster zeichnen, wie die Jalousie davor gerade steht. - * - * Zwei Zahlen in einem Bild. Die Hoehe schneidet ab - gezeichnet wird nur - * der Teil des Fensters, den die Jalousie bedeckt, mit der Endschiene an - * seiner Unterkante. Die Neigung bestimmt, wie dick eine Lamelle von vorn - * erscheint: w * sin(Winkel). Bei "auf" sieht man nur ihre Kante und viel - * Luft dazwischen, bei "zu" deckt sie den Abstand zur naechsten - * vollstaendig ab - genau das, was das Licht abhaelt. Von Mitte zu Mitte - * sind es 10 Einheiten, die Lamelle ist 11 tief; ab etwa 65 % schliesst - * das Bild, so wie es die Jalousie auch tut. - * - * Ein Rollladen ohne Neigung bekommt volle Lamellenhoehe - er ist ja ein - * geschlossener Panzer. - */ - function lamellenZeichnen(jalousie) { - if (!jalousie) return; - const bild = jalousie.querySelector(".raum-lamellen"); - if (!bild) return; - - const hoch = bild.viewBox.baseVal.height; - const abdeckung = hoch * reglerAnteil(jalousie, "position", 1); - const kippung = reglerAnteil(jalousie, "neigung", 1); - const dicke = Math.max(1.6, 11 * Math.sin(kippung * Math.PI / 2)); - - bild.querySelector(".raum-abdeckung").setAttribute("height", abdeckung); - bild.querySelectorAll(".raum-lamelle").forEach(lamelle => { - const mitte = Number(lamelle.dataset.mitte); - lamelle.setAttribute("height", dicke); - lamelle.setAttribute("y", mitte - dicke / 2); - }); - // Die Endschiene sitzt auf der Unterkante und bleibt im Bild, auch wenn - // die Jalousie ganz oben steht. - const schiene = bild.querySelector(".raum-endschiene"); - const dick = schiene.height.baseVal.value; - schiene.setAttribute("y", Math.max(0, Math.min(hoch - dick, abdeckung - dick))); - } - - /** - * Die Ist-Werte im Heizungsteil aus dem laufenden MQTT-Strom fuellen. - * - * Der Browser hoert ohnehin schon auf Raumtemp/#, und ein Wert aus der - * Datenbank waere neben dem Regler nur ein Stand von vorhin. - */ - function raumMesswerteAktualisieren(thermostat) { - const werte = mqttZweig(thermostat); - if (!werte) return; - document.querySelectorAll("#roomView [data-mqtt]").forEach(feld => { - const wert = werte[feld.dataset.mqtt]; - if (typeof wert !== "undefined") feld.textContent = wert; - }); - const heizt = document.querySelector("#roomView [data-mqtt-heizung]"); - if (heizt) heizt.classList.toggle("d-none", String(werte["Heating"]) !== "true"); - } - - /** Ein Kommando des angeklickten Knopfes ausfuehren. */ - function schalte(knopf) { - const zeile = knopf.closest("[data-cmdrow]"); - const params = {}; - if (zeile) { - zeile.querySelectorAll("[data-param]").forEach(f => { params[f.dataset.param] = f.value; }); - } - raumKommando(Number(knopf.dataset.cmd), params, knopf); - } - - /** - * Ein Kommando schicken und die Karte so lange sperren. - * - * Ein "Zu" an einer Jalousie dauert ueber eine Minute (die Mechanik will - * zweimal geschwenkt werden, siehe restricted/commands.php). Ohne sichtbare - * Sperre tippt man in der Zeit dreimal nach. - */ - function raumKommando(cmdId, params, element) { - const karte = element.closest(".raum-geraet") || element; - karte.classList.add("raum-warte"); - // Die Verfolgung laeuft neben dem Auftrag her, nicht nach ihm: ein "Zu" - // faehrt herunter, wartet und neigt danach - die Antwort kaeme also erst, - // wenn die Jalousie schon steht. Beides nebeneinander geht, seit - // ajax/room.php die Sitzung freigibt. Der Auftrag geht mit, weil nur er - // weiss, wann das Manoever zu Ende ist. - const auftrag = raumRequest("command", { command_id: cmdId, params: params }); - jalousieVerfolgen(karte, auftrag); - auftrag.then(antwort => { - karte.classList.remove("raum-warte"); - raumMeldung(antwort); - }); - } - - // Takt und Grenzen der Verfolgung. Dieselben Zahlen wie die Konstanten - // JALOUSIE_* in restricted/commands.php: die Box meldet core:MovingState - // traege, kurz nach dem Kommando steht dort noch "faehrt nicht" - deshalb - // der Vorlauf, in dem ein Stillstand nichts bedeutet. - const JALOUSIE_TAKT_MS = 2000; - const JALOUSIE_VORLAUF_MS = 8000; - const JALOUSIE_MAX_MS = 120000; - - // Wie lange nach dem Ende des Auftrags noch nachgesehen wird. Eine - // Neigungsfahrt setzt core:MovingState nie (siehe warteAufJalousie() in - // restricted/commands.php), dauert aber gemessen bis zu fuenfzehn Sekunden - // von Anschlag zu Anschlag - ohne diesen Nachlauf stuende der Wert von - // vor dem letzten Schwenk in der Anzeige. - const JALOUSIE_NACHLAUF_MS = 14000; - - /** - * Nach einem Fahrbefehl abwarten, bis die Jalousie steht, und dann ihren - * wirklichen Stand zeigen. - * - * actor_states schreibt sonst nur der Runner fort, und der fragt die - * Tahoma-Box alle fuenf Minuten ab - so lange zeigte das Modal nach einem - * Fahrbefehl den Stand von davor. Hier wird genau dieses eine Geraet - * gefragt; ajax/room.php haelt den frischen Wert gleich in der Tabelle - * fest, damit auch das naechste Oeffnen und der Automatik-Editor ihn haben. - * - * Waehrend der Fahrt wird bewusst nichts angezeigt. An der Anlage - * gemessen: die Hoehe (core:ClosureState) steht die ganze Fahrt ueber auf - * dem Wert von vor dem Kommando und springt erst am Ende um - wer sie - * anzeigte, holte den Regler vom eingestellten Ziel auf den alten Stand - * zurueck, nur um ihn zwanzig Sekunden spaeter wieder vorzuschieben. Die - * Neigung meldet zwar Zwischenwerte, aber ausgerechnet die der - * Kugelschreiber-Mechanik: bei "Neigung 80" liefe der Regler 62 -> 28 -> - * 38 -> 79, ueber einen Umweg, den niemand angefordert hat. - */ - function jalousieVerfolgen(karte, auftrag) { - if (!karte || !karte.querySelector(".raum-jalousie")) return; - const actor = Number(karte.dataset.actor); - if (!actor) return; - // Ohne Auftrag - beim Oeffnen des Modals - gibt es keine Traegheit - // abzuwarten und nichts nachlaufen zu lassen: was die Box beim ersten - // Blick meldet, gilt. Faehrt sie gerade, wird bis zum Stillstand - // weitergesehen. - const vorlaufNoetig = !!auftrag; - - // Ein zweiter Fahrbefehl schiebt nur das Ende hinaus. Zwei Schleifen - // wuerden dieselbe Box doppelt so oft fragen, ohne mehr zu zeigen. - karte.dataset.jalousieBis = String(Date.now() + JALOUSIE_MAX_MS); - if (karte.dataset.jalousieLaeuft) return; - karte.dataset.jalousieLaeuft = "1"; - - const beginn = Date.now(); - let gefahren = false; - // Der zuletzt im Stillstand gelesene Stand. "core:MovingState ist false" - // heisst noch nicht "steht": die Box meldet das Ende der Fahrt, bevor die - // Werte darauf nachgezogen haben. Fertig ist es erst, wenn zwei Abfragen - // hintereinander dasselbe sagen. - let letzterStand = null; - // Solange der Auftrag laeuft, ist nichts entschieden: die - // Kugelschreiber-Mechanik schickt zwei Befehle mit einer Wartezeit - // dazwischen, und zwischen ihnen steht die Jalousie still. Wer da - // aufhoert, schreibt die Zwischenstellung fest - gemessen "Neigung 0", - // wo 62 gemeint war. - let ruheAb = 0; - if (auftrag) { - ruheAb = Infinity; - Promise.resolve(auftrag).then( - function () { ruheAb = Date.now() + JALOUSIE_NACHLAUF_MS; }, - function () { ruheAb = Date.now(); } - ); - } - - function aufhoeren() { - delete karte.dataset.jalousieLaeuft; - delete karte.dataset.jalousieBis; - } - - function runde() { - raumRequest("jalousie", { actor: actor }).then(stand => { - // Ein Fehler soll auffallen. Ohne die Meldung endet die Schleife - // still, und die Anzeige bleibt einfach stehen - nicht zu - // unterscheiden von "die Verfolgung gibt es gar nicht". - if (stand && stand.error) raumMeldung(stand); - // Modal zu, Box stumm, Geraet unbekannt - in jedem Fall aufhoeren - // statt zwei Minuten lang ins Leere zu fragen. - if (!stand || stand.error || !document.body.contains(karte)) { - aufhoeren(); - return; - } - const abgelesen = stand.faehrt - ? null - : String(stand.position) + "/" + String(stand.neigung); - const steht = abgelesen !== null && abgelesen === letzterStand; - letzterStand = abgelesen; - - if (stand.faehrt) { - gefahren = true; - } else if (Date.now() >= ruheAb) { - // Erst wenn der Auftrag durch ist. Zwischen den beiden Befehlen der - // Kugelschreiber-Mechanik steht die Jalousie still und meldet - // "Neigung 0" - anzeigen hiesse, den Umweg vorzufuehren. - jalousieAnzeigen(karte, stand); - } - if (Date.now() > Number(karte.dataset.jalousieBis)) { - aufhoeren(); - return; - } - // Steht sie still, ist die Fahrt vorbei - aber erst, wenn wir sie - // haben fahren sehen oder der Vorlauf um ist. Eine letzte Runde - // danach holt den Wert, den die Box beim Anhalten setzt. - // Beim blossen Aufmachen eines Raums, in dem sich nichts geruehrt - // hat, gibt es nichts abzuwarten - dann bleibt es bei einer Abfrage - // je Jalousie. - if (!vorlaufNoetig && !gefahren && !stand.faehrt) { - aufhoeren(); - return; - } - const fertig = steht - && Date.now() >= ruheAb - && (gefahren || !vorlaufNoetig - || Date.now() - beginn >= JALOUSIE_VORLAUF_MS); - if (fertig) { - aufhoeren(); - return; - } - setTimeout(() => runde(false), JALOUSIE_TAKT_MS); - }); - } - // Beim Oeffnen sofort nachsehen, nach einem Kommando erst im Takt: da - // stuende die Box ohnehin noch auf dem alten Wert. - setTimeout(runde, auftrag ? JALOUSIE_TAKT_MS : 0); - } - - /** - * Die gemeldete Stellung in die beiden Regler und das Bild uebernehmen. - * Aufgerufen nur im Stillstand, siehe jalousieVerfolgen(). - */ - function jalousieAnzeigen(karte, stand) { - [["position", stand.position], ["neigung", stand.neigung]].forEach(paar => { - const wert = paar[1]; - if (wert === null || typeof wert === "undefined") return; - const regler = karte.querySelector(".raum-regler[data-rolle='" + paar[0] + "']"); - if (!regler) return; - const schieber = regler.querySelector("input[type=range]"); - // Wer gerade zieht, bestimmt. Sonst spraenge ihm der Schieber unter - // dem Daumen weg, waehrend die Jalousie noch das alte Ziel anfaehrt. - if (document.activeElement === schieber) return; - schieber.value = wert; - reglerAnzeigen(regler); - }); - } - - /** - * Die neue Solltemperatur schicken. - * - * Das Modal bleibt danach offen: frueher schloss es sich, weil der - * Speichern-Knopf das Ende der Bedienung war. Jetzt ist das Verschieben - * des Reglers die Bedienung, und wer dreimal nachjustiert, will nicht - * dreimal neu aufmachen. - */ - function sendeSolltemperatur(raum) { - const slider = document.getElementById("modal-slider"); - if (!slider) return; - const karte = slider.closest(".raum-geraet"); - if (karte) karte.classList.add("raum-warte"); - raumRequest("temp", { room: raum, temp: Number(slider.value) }) - .then(antwort => { - if (karte) karte.classList.remove("raum-warte"); - raumMeldung(antwort); - }); - } - - function raumRequest(action, body) { - return fetch("./ajax/room.php?action=" + action, { - method: "POST", - headers: { "Content-Type": "application/json", "Requested-With-Ajax": "ajax" }, - body: JSON.stringify(body) - }) - .then(a => a.json()) - .catch(fehler => ({ error: fehler.message })); - } - - /** Rueckmeldung im Modal statt als alert - man schaltet oft mehrmals. */ - function raumMeldung(antwort) { - const ziel = document.getElementById("roomFeedback"); - if (!ziel) return; - ziel.className = "small mt-2 " + (antwort.error ? "text-danger" : "text-body-secondary"); - ziel.textContent = antwort.error ? antwort.error : "✓ " + antwort.ok; - } diff --git a/js/solar/raumfenster.js b/js/solar/raumfenster.js new file mode 100644 index 0000000..a9711bb --- /dev/null +++ b/js/solar/raumfenster.js @@ -0,0 +1,637 @@ +/* + * Raum-Modal: Beschattung, Heizung, Licht und Schalter eines Raums. + * + * Das Markup kommt fertig aus ajax/room.php (Bedienformen in + * restricted/roomControls.php); hier haengen die Zuhoerer an den + * data-Attributen, und hier wird gezeichnet, was sich beim Bedienen aendert: + * Zustand der Reiter, Behang-Bild, Heizring, Lampen. + * + * Wie im Lade-, Heiz- und Bewaesserungsfenster gilt jede Wahl sofort - es + * gibt keinen Speichern-Knopf. + * + * Aufgerufen von openRoomView() in js/solar/homeMQTT.js; die Ist-Werte der + * Heizung kommen ueber raumfensterMqtt() aus dem laufenden MQTT-Strom. + */ + +const RF_S = " "; // schmales Leerzeichen vor Einheiten +let raumfenster = null; // {wurzel, raum, thermostat} + +function raumfensterStarten(wurzel, raum, thermostat) { + raumfenster = { wurzel: wurzel, raum: raum, thermostat: thermostat }; + + wurzel.querySelectorAll("[data-rf-reiter]").forEach(k => k.addEventListener("click", () => rfReiter(wurzel, k))); + wurzel.querySelectorAll(".rf-behang").forEach(rfBehangStarten); + wurzel.querySelectorAll(".rf-alle [data-alle]").forEach(k => k.addEventListener("click", () => rfAlle(k))); + wurzel.querySelectorAll(".rf-lampe").forEach(rfLampeStarten); + wurzel.querySelectorAll(".rf-schalter").forEach(k => k.addEventListener("click", () => rfSchalten(k, k))); + // Mehr-Menue und "Sonstiges": Kommandos so, wie sie in der Datenbank stehen. + wurzel.querySelectorAll("button[data-cmd]").forEach(k => k.addEventListener("click", () => rfSchalte(k))); + const heiz = wurzel.querySelector("[data-rf-heiz-teil]"); + if (heiz) rfHeizungStarten(heiz); + + rfStatus(); + raumfensterMqtt(); + // Jede Jalousie im Raum einmal an der Box nachfragen. Der Wert im Modal + // kommt aus actor_states und ist damit hoechstens so frisch wie der letzte + // Poll des Runners - und wer das Modal waehrend einer Fahrt aufmacht, saehe + // sonst den Stand von davor. + wurzel.querySelectorAll(".rf-behang").forEach(k => rfJalousieVerfolgen(k, null)); +} + +/* --- Reiter ---------------------------------------------------------------- */ + +function rfReiter(wurzel, knopf) { + wurzel.querySelectorAll("[data-rf-reiter]").forEach(k => k.setAttribute("aria-selected", String(k === knopf))); + wurzel.querySelectorAll("[data-rf-flaeche]").forEach(f => { f.hidden = f.dataset.rfFlaeche !== knopf.dataset.rfReiter; }); +} + +/** Die Zeile unter jedem Reiter: was in dieser Gruppe gerade los ist. */ +function rfStatus() { + if (!raumfenster) return; + const w = raumfenster.wurzel; + const setze = (form, text) => { const el = w.querySelector("[data-rf-status='" + form + "']"); if (el) el.textContent = text; }; + + const behaenge = [...w.querySelectorAll(".rf-behang")]; + const bekannt = behaenge.filter(k => k.dataset.pos !== ""); + const zu = bekannt.filter(k => Number(k.dataset.pos) >= 95).length; + setze("beschattung", !bekannt.length ? behaenge.length + " Geräte" + : zu === 0 ? "alle offen" : zu === bekannt.length ? "alle zu" : zu + " von " + bekannt.length + " zu"); + + const an = w.querySelectorAll(".rf-lampe[data-an='1']").length; + setze("licht", an ? an + " an" : "alles aus"); + const schalter = w.querySelectorAll(".rf-schalter[aria-pressed='true']").length; + setze("schalter", schalter ? schalter + " an" : "alles aus"); + const zahl = (sel, wort) => { const n = w.querySelectorAll(sel).length; return n + " " + wort; }; + setze("sonstiges", zahl("[data-rf-flaeche='sonstiges'] .rf-karte", "Geräte")); + setze("messwerte", zahl("[data-rf-flaeche='messwerte'] .rf-karte", "Geräte")); + // "heizung" setzt rfHeizungZeichnen() mit der Ist-Temperatur. +} + +/* --- Kommandos --------------------------------------------------------------- */ + +function rfRequest(action, body) { + return fetch("./ajax/room.php?action=" + action, { + method: "POST", + headers: { "Content-Type": "application/json", "Requested-With-Ajax": "ajax" }, + body: JSON.stringify(body) + }) + .then(a => a.json()) + .catch(fehler => ({ error: fehler.message })); +} + +/** Rueckmeldung unten im Fenster; nach ein paar Sekunden wieder der Hinweis. */ +function rfMeldung(antwort) { + const ziel = document.getElementById("roomFeedback"); + if (!ziel) return; + clearTimeout(rfMeldung.takt); + ziel.classList.toggle("fehler", !!antwort.error); + ziel.classList.toggle("gut", !antwort.error); + ziel.textContent = antwort.error ? antwort.error : "✓ " + antwort.ok; + rfMeldung.takt = setTimeout(() => { + ziel.classList.remove("fehler", "gut"); + ziel.textContent = "Änderungen gelten sofort"; + }, antwort.error ? 8000 : 4000); +} + +/** + * Ein Kommando schicken. Eine Jalousie zeigt dabei "fährt…" und wird bis zum + * Stillstand verfolgt; alles andere ist kurz gesperrt, damit man nicht + * nachtippt. + */ +function rfKommando(cmdId, params, element) { + const karte = element.closest(".raum-geraet") || element; + const behang = karte.classList.contains("rf-behang"); + if (behang) { + karte.dataset.faehrt = "1"; + rfBehangZeichnen(karte); + } else { + karte.classList.add("raum-warte"); + } + const auftrag = rfRequest("command", { command_id: cmdId, params: params }); + if (behang) rfJalousieVerfolgen(karte, auftrag); + return auftrag.then(antwort => { + karte.classList.remove("raum-warte"); + if (antwort.error && behang) { + delete karte.dataset.faehrt; + rfBehangZeichnen(karte); + } + rfMeldung(antwort); + return antwort; + }); +} + +/** Ein Knopf mit data-cmd; Parameter stehen, wenn noetig, in seiner Zeile. */ +function rfSchalte(knopf) { + const zeile = knopf.closest("[data-cmdrow]"); + const params = {}; + if (zeile) zeile.querySelectorAll("[data-param]").forEach(f => { params[f.dataset.param] = f.value; }); + rfKommando(Number(knopf.dataset.cmd), params, knopf); +} + +/* --- Beschattung -------------------------------------------------------------- */ + +function rfLamellenWort(n) { + n = Number(n); + return n <= 12 ? "offen" : n >= 88 ? "zu" : Math.abs(n - 50) <= 12 ? "halb" : n + RF_S + "%"; +} + +function rfStandText(pos, neig) { + if (pos === "") return ""; + const p = Number(pos); + let text = p <= 2 ? "offen" : p >= 98 ? "zu" : p + RF_S + "% zu"; + if (neig !== "" && p > 2) text += " · Lamellen " + rfLamellenWort(neig); + return text; +} + +function rfBehangZeichnen(karte) { + const d = karte.dataset; + const pos = d.pos, neig = d.neig; + const teil = karte.querySelector(".rf-behang-teil"); + if (teil) teil.style.height = (pos === "" ? 0 : Number(pos)) + "%"; + const lamellen = karte.querySelector(".rf-lamellen"); + if (lamellen) lamellen.style.setProperty("--kipp", 0.2 + 0.8 * (neig === "" ? 0 : Number(neig)) / 100); + + const faehrt = d.faehrt === "1"; + karte.classList.toggle("rf-bewegt", faehrt); + const pille = karte.querySelector("[data-rf-stand]"); + if (pille) { + pille.textContent = faehrt ? "fährt…" : rfStandText(pos, neig); + pille.classList.toggle("faehrt", faehrt); + } + + const aufMy = d.myPos !== "" && pos !== "" && Math.abs(Number(pos) - Number(d.myPos)) <= 2 + && (d.myNeig === "" || neig === "" || Math.abs(Number(neig) - Number(d.myNeig)) <= 2); + const myKnopf = karte.querySelector("[data-rf-fahrt='my']"); + if (myKnopf) myKnopf.classList.toggle("aktiv", aufMy && !faehrt); + + karte.querySelectorAll("[data-rf-pos]").forEach(k => + k.setAttribute("aria-checked", String(pos !== "" && !aufMy && Math.abs(Number(k.dataset.rfPos) - Number(pos)) <= 2))); + karte.querySelectorAll("[data-rf-neig]").forEach(k => + k.setAttribute("aria-checked", String(neig !== "" && Math.abs(Number(k.dataset.rfNeig) - Number(neig)) <= 2))); + + const my = karte.querySelector("[data-rf-my]"); + if (my) { + const myText = (p, n) => p + RF_S + "%" + (n !== "" && n !== null && typeof n !== "undefined" ? " · Lamellen " + rfLamellenWort(n) : ""); + if (d.myFrage === "1") { + my.className = "rf-my-zeile frage"; + my.innerHTML = "my von " + myText(d.myPos, d.myNeig) + " auf " + myText(pos, neig) + " ändern?" + + "" + + ""; + } else { + my.className = "rf-my-zeile"; + my.innerHTML = "my-Position " + myText(d.myPos, d.myNeig) + "" + + (aufMy || faehrt || pos === "" || d.mySpeichert === "1" ? "" + : "") + + (d.mySpeichert === "1" ? "wird gespeichert…" : ""); + } + } + rfStatus(); +} + +function rfBehangStarten(karte) { + const d = karte.dataset; + rfBehangZeichnen(karte); + + karte.querySelectorAll("[data-rf-fahrt]").forEach(k => k.addEventListener("click", () => { + const cmd = d[k.dataset.rfFahrt]; + if (!cmd) return; + rfKommando(Number(cmd), {}, karte); + })); + + const hoehe = function (wert) { + const params = {}; + params[d.hoeheParam] = wert; + // Die Hoehe nimmt die Neigung mit: eine Hoehenfahrt rastet die Lamellen + // um, und der Server faehrt sie nur dann wieder an, wenn der Befehl ein + // Neigungsziel dabei hat (siehe sendeTahoma() in restricted/commands.php). + if (d.hoeheMit) params[d.hoeheMit] = d.neig === "" ? 0 : Number(d.neig); + d.pos = String(wert); + rfKommando(Number(d.hoeheCmd), params, karte); + }; + karte.querySelectorAll("[data-rf-pos]").forEach(k => k.addEventListener("click", () => hoehe(Number(k.dataset.rfPos)))); + karte.querySelectorAll("[data-rf-neig]").forEach(k => k.addEventListener("click", () => { + const params = {}; + params[d.neigParam] = Number(k.dataset.rfNeig); + d.neig = k.dataset.rfNeig; + rfKommando(Number(d.neigCmd), params, karte); + })); + + // Im Fensterbild ziehen: die Hoehe fein einstellen. Waehrend des Ziehens + // nur das Bild, geschickt wird beim Loslassen - sonst bekaeme die Jalousie + // bei jedem Pixel ein neues Ziel. + const fenster = karte.querySelector("[data-rf-zieh]"); + if (fenster && d.hoeheCmd) { + let zug = null; + const wert = e => { + const r = fenster.querySelector(".rf-glas").getBoundingClientRect(); + return Math.max(0, Math.min(100, (e.clientY - r.top) / r.height * 100)); + }; + fenster.addEventListener("pointerdown", e => { + zug = e.pointerId; + d.zieht = "1"; + fenster.setPointerCapture(e.pointerId); + karte.querySelector(".rf-behang-teil").style.height = wert(e) + "%"; + e.preventDefault(); + }); + fenster.addEventListener("pointermove", e => { + if (zug !== e.pointerId) return; + const w = wert(e); + karte.querySelector(".rf-behang-teil").style.height = w + "%"; + const pille = karte.querySelector("[data-rf-stand]"); + if (pille) pille.textContent = rfStandText(String(Math.round(w)), d.neig); + }); + const ende = e => { + if (zug !== e.pointerId) return; + zug = null; + delete d.zieht; + hoehe(Math.round(wert(e) / 5) * 5); + }; + fenster.addEventListener("pointerup", ende); + fenster.addEventListener("pointercancel", () => { zug = null; delete d.zieht; rfBehangZeichnen(karte); }); + } + + // my-Position speichern: erst nachfragen, dann schreibt der Server den + // Stand, den die Box meldet, in den Motor. + karte.addEventListener("click", e => { + const k = e.target.closest("[data-rf-my-tun]"); + if (!k) return; + const tun = k.dataset.rfMyTun; + if (tun === "frage") d.myFrage = "1"; + if (tun === "nein") delete d.myFrage; + if (tun === "ja") { + delete d.myFrage; + d.mySpeichert = "1"; + rfRequest("my", { actor: Number(d.actor) }).then(antwort => { + delete d.mySpeichert; + if (!antwort.error) { + d.myPos = String(antwort.myPosition); + if (antwort.myNeigung !== null && typeof antwort.myNeigung !== "undefined") d.myNeig = String(antwort.myNeigung); + } + rfMeldung(antwort); + rfBehangZeichnen(karte); + }); + } + rfBehangZeichnen(karte); + }); +} + +/** "Alle im Raum": jedem Behang sein eigenes Kommando. */ +function rfAlle(knopf) { + const was = knopf.dataset.alle; + knopf.closest("[data-rf-flaeche]").querySelectorAll(".rf-behang").forEach(karte => { + const d = karte.dataset; + if (was === "sicht") { + if (!d.hoeheMit) return; + const params = {}; + params[d.hoeheParam] = 100; + params[d.hoeheMit] = 50; + d.pos = "100"; + d.neig = "50"; + rfKommando(Number(d.hoeheCmd), params, karte); + return; + } + if (d[was]) rfKommando(Number(d[was]), {}, karte); + }); +} + +// Takt und Grenzen der Verfolgung. Dieselben Zahlen wie die Konstanten +// JALOUSIE_* in restricted/commands.php: die Box meldet core:MovingState +// traege, kurz nach dem Kommando steht dort noch "faehrt nicht" - deshalb +// der Vorlauf, in dem ein Stillstand nichts bedeutet. +const RF_JALOUSIE_TAKT_MS = 2000; +const RF_JALOUSIE_VORLAUF_MS = 8000; +const RF_JALOUSIE_MAX_MS = 120000; + +// Wie lange nach dem Ende des Auftrags noch nachgesehen wird. Eine +// Neigungsfahrt setzt core:MovingState nie (siehe warteAufJalousie() in +// restricted/commands.php), dauert aber gemessen bis zu fuenfzehn Sekunden +// von Anschlag zu Anschlag - ohne diesen Nachlauf stuende der Wert von vor +// dem letzten Schwenk in der Anzeige. +const RF_JALOUSIE_NACHLAUF_MS = 14000; + +/** + * Nach einem Fahrbefehl abwarten, bis die Jalousie steht, und dann ihren + * wirklichen Stand zeigen. + * + * actor_states schreibt sonst nur der Runner fort, und der fragt die + * Tahoma-Box alle fuenf Minuten ab. Hier wird genau dieses eine Geraet + * gefragt; ajax/room.php haelt den frischen Wert gleich in der Tabelle fest. + * + * Waehrend der Fahrt wird kein Zwischenstand angezeigt. An der Anlage + * gemessen: die Hoehe (core:ClosureState) steht die ganze Fahrt ueber auf dem + * Wert von vor dem Kommando und springt erst am Ende um, und die Neigung + * meldet ausgerechnet die Umwege der Kugelschreiber-Mechanik. + */ +function rfJalousieVerfolgen(karte, auftrag) { + const actor = Number(karte.dataset.actor); + if (!actor) return; + // Ohne Auftrag - beim Oeffnen des Modals - gibt es keine Traegheit + // abzuwarten und nichts nachlaufen zu lassen. + const vorlaufNoetig = !!auftrag; + + // Ein zweiter Fahrbefehl schiebt nur das Ende hinaus. Zwei Schleifen + // wuerden dieselbe Box doppelt so oft fragen, ohne mehr zu zeigen. + karte.dataset.jalousieBis = String(Date.now() + RF_JALOUSIE_MAX_MS); + if (karte.dataset.jalousieLaeuft) return; + karte.dataset.jalousieLaeuft = "1"; + + const beginn = Date.now(); + let gefahren = false; + // Fertig ist es erst, wenn zwei Abfragen im Stillstand dasselbe sagen: die + // Box meldet das Ende der Fahrt, bevor die Werte nachgezogen haben. + let letzterStand = null; + // Solange der Auftrag laeuft, ist nichts entschieden: die + // Kugelschreiber-Mechanik schickt zwei Befehle mit einer Wartezeit + // dazwischen, und zwischen ihnen steht die Jalousie still. + let ruheAb = 0; + if (auftrag) { + ruheAb = Infinity; + Promise.resolve(auftrag).then( + function () { ruheAb = Date.now() + RF_JALOUSIE_NACHLAUF_MS; }, + function () { ruheAb = Date.now(); } + ); + } + + function aufhoeren() { + delete karte.dataset.jalousieLaeuft; + delete karte.dataset.jalousieBis; + delete karte.dataset.faehrt; + rfBehangZeichnen(karte); + } + + function runde() { + rfRequest("jalousie", { actor: actor }).then(stand => { + // Ein Fehler soll auffallen, sonst bliebe die Anzeige still stehen. + if (stand && stand.error) rfMeldung(stand); + if (!stand || stand.error || !document.body.contains(karte)) { + aufhoeren(); + return; + } + const abgelesen = stand.faehrt ? null : String(stand.position) + "/" + String(stand.neigung); + const steht = abgelesen !== null && abgelesen === letzterStand; + letzterStand = abgelesen; + + if (stand.faehrt) { + gefahren = true; + } else if (Date.now() >= ruheAb) { + rfJalousieAnzeigen(karte, stand); + } + if (Date.now() > Number(karte.dataset.jalousieBis)) { + aufhoeren(); + return; + } + // Beim blossen Aufmachen eines Raums, in dem sich nichts geruehrt hat, + // bleibt es bei einer Abfrage je Jalousie. + if (!vorlaufNoetig && !gefahren && !stand.faehrt) { + aufhoeren(); + return; + } + if (!vorlaufNoetig && stand.faehrt) { + karte.dataset.faehrt = "1"; + rfBehangZeichnen(karte); + } + const fertig = steht && Date.now() >= ruheAb + && (gefahren || !vorlaufNoetig || Date.now() - beginn >= RF_JALOUSIE_VORLAUF_MS); + if (fertig) { + aufhoeren(); + return; + } + setTimeout(runde, RF_JALOUSIE_TAKT_MS); + }); + } + // Beim Oeffnen sofort nachsehen, nach einem Kommando erst im Takt: da + // stuende die Box ohnehin noch auf dem alten Wert. + setTimeout(runde, auftrag ? RF_JALOUSIE_TAKT_MS : 0); +} + +/** Die gemeldete Stellung uebernehmen - nur im Stillstand, siehe oben. */ +function rfJalousieAnzeigen(karte, stand) { + const d = karte.dataset; + // Wer gerade im Bild zieht, bestimmt. + if (d.zieht) return; + const setze = (feld, wert) => { if (wert !== null && typeof wert !== "undefined") d[feld] = String(wert); }; + setze("pos", stand.position); + if (d.neigCmd) setze("neig", stand.neigung); + if (d.myPos !== undefined && karte.querySelector("[data-rf-my]")) { + setze("myPos", stand.myPosition); + if (d.neigCmd) setze("myNeig", stand.myNeigung); + } + rfBehangZeichnen(karte); +} + +/* --- Heizung ------------------------------------------------------------------ */ + +const RF_RING_R = 70; +const RF_RING_UMFANG = 2 * Math.PI * RF_RING_R; +const RF_RING_BOGEN = RF_RING_UMFANG * 270 / 360; +const RF_HEIZ_HALTEN_MS = 8000; // so lange gilt die eigene Wahl vor dem MQTT-Stand + +function rfHeizungStarten(teil) { + const min = Number(teil.dataset.min), max = Number(teil.dataset.max); + const zustand = { soll: null, eigen: 0, senden: null }; + teil._rf = zustand; + teil.querySelector(".rf-ring-spur").setAttribute("stroke-dasharray", RF_RING_BOGEN + " " + RF_RING_UMFANG); + teil.querySelector(".rf-ring-griff").setAttribute("stroke-dasharray", RF_RING_BOGEN + " " + RF_RING_UMFANG); + + const waehlen = function (wert, sofort) { + wert = Math.max(min, Math.min(max, Math.round(wert))); + zustand.soll = wert; + zustand.eigen = Date.now(); + rfHeizungZeichnen(teil); + clearTimeout(zustand.senden); + // Mehrmals - oder + tippen ergibt einen Befehl, nicht fuenf. + zustand.senden = setTimeout(() => { + teil.classList.add("raum-warte"); + rfRequest("temp", { room: raumfenster.raum, temp: wert }).then(antwort => { + teil.classList.remove("raum-warte"); + rfMeldung(antwort); + }); + }, sofort ? 0 : 700); + }; + + teil.querySelectorAll("[data-rf-schritt]").forEach(k => k.addEventListener("click", () => { + if (zustand.soll === null) return; + waehlen(zustand.soll + Number(k.dataset.rfSchritt), false); + })); + teil.querySelectorAll("[data-rf-soll]").forEach(k => k.addEventListener("click", () => waehlen(Number(k.dataset.rfSoll), true))); + + // Den Soll-Punkt auf dem Ring ziehen oder auf den Ring tippen. Waehrend des + // Ziehens wird nur gezeichnet, geschickt wird beim Loslassen. + const svg = teil.querySelector(".rf-ring svg"); + const ausZeiger = e => { + const r = svg.getBoundingClientRect(); + const grad = Math.atan2(e.clientY - (r.top + r.height / 2), e.clientX - (r.left + r.width / 2)) * 180 / Math.PI; + let a = (grad - 135 + 720) % 360; + if (a > 270) a = a > 315 ? 0 : 270; + return min + a / 270 * (max - min); + }; + let zug = null; + svg.addEventListener("pointerdown", e => { + if (!e.target.closest("[data-rf-ringzug]")) return; + zug = e.pointerId; + svg.setPointerCapture(e.pointerId); + teil.classList.add("zieht"); + zustand.soll = Math.round(ausZeiger(e)); + zustand.eigen = Date.now(); + rfHeizungZeichnen(teil); + e.preventDefault(); + }); + svg.addEventListener("pointermove", e => { + if (zug !== e.pointerId) return; + zustand.soll = Math.round(ausZeiger(e)); + zustand.eigen = Date.now(); + rfHeizungZeichnen(teil); + }); + svg.addEventListener("pointerup", e => { + if (zug !== e.pointerId) return; + zug = null; + teil.classList.remove("zieht"); + waehlen(zustand.soll, true); + }); + svg.addEventListener("pointercancel", () => { zug = null; teil.classList.remove("zieht"); }); + const punkt = teil.querySelector(".rf-ring-soll"); + punkt.addEventListener("keydown", e => { + const schritt = { ArrowUp: 1, ArrowRight: 1, ArrowDown: -1, ArrowLeft: -1 }[e.key]; + if (!schritt || zustand.soll === null) return; + e.preventDefault(); + waehlen(zustand.soll + schritt, false); + }); +} + +/** Ist, Soll und Hinweis in den Heizungsteil schreiben. */ +function rfHeizungZeichnen(teil) { + const z = teil._rf; + const werte = (raumfenster && typeof mqttZweig === "function") ? mqttZweig(raumfenster.thermostat) : null; + const zahl = v => (v === null || v === "" || typeof v === "undefined" || isNaN(Number(v))) ? null : Number(v); + const ist = werte ? zahl(werte["Temp[degC]"]) : null; + const gemeldet = werte ? zahl(werte["Set Temp[degC]"]) : null; + // Die eigene Wahl gilt, bis das Thermostat sie bestaetigt hat. + if (gemeldet !== null && (z.soll === null || Date.now() - z.eigen > RF_HEIZ_HALTEN_MS)) z.soll = gemeldet; + const soll = z.soll; + + const min = Number(teil.dataset.min), max = Number(teil.dataset.max); + const anteil = v => Math.max(0, Math.min(1, (v - min) / (max - min))); + const komma = (v, stellen) => v.toFixed(stellen).replace(".", ","); + const feld = name => teil.querySelector("[data-rf-heiz='" + name + "']"); + + teil.querySelector(".rf-ring-ist").setAttribute("stroke-dasharray", + (ist === null ? 0 : RF_RING_BOGEN * anteil(ist)) + " " + RF_RING_UMFANG); + const punkt = teil.querySelector(".rf-ring-soll"); + punkt.style.transform = "rotate(" + (135 + 270 * anteil(soll === null ? min : soll)) + "deg)"; + punkt.style.visibility = soll === null ? "hidden" : ""; + if (soll !== null) punkt.setAttribute("aria-valuenow", soll); + + feld("ist").innerHTML = ist === null ? "–" : komma(ist, 1) + "" + RF_S + "°C"; + feld("soll").textContent = "Soll " + (soll === null ? "–" : komma(soll, 0) + RF_S + "°C"); + const chip = soll === null ? null : teil.querySelector("[data-rf-soll='" + soll + "']"); + feld("wert").innerHTML = soll === null ? "–" + : soll + RF_S + "°C" + "" + (chip ? chip.firstChild.textContent.trim() : " ") + ""; + teil.querySelectorAll("[data-rf-soll]").forEach(k => k.setAttribute("aria-pressed", String(k === chip))); + + const heizt = werte && String(werte["Heating"]) === "true"; + const hinweis = feld("hinweis"); + let text = ""; + if (ist !== null && soll !== null) { + if (heizt) text = ist < soll ? "Heizt – noch " + komma(soll - ist, 1) + RF_S + "°C bis zum Soll." : "Heizt gerade."; + else if (ist > soll + 0.2) text = "Heizt nicht – der Raum ist " + komma(ist - soll, 1) + RF_S + "°C wärmer als das Soll."; + else text = "Heizt nicht – das Soll ist erreicht."; + } + hinweis.hidden = !text; + hinweis.classList.toggle("an", !!heizt); + hinweis.querySelector("span").textContent = text; + + const feuchte = werte ? zahl(werte["rHum[%]"]) : null; + feld("feuchte").textContent = feuchte === null ? "–" : Math.round(feuchte) + RF_S + "% Luftfeuchte"; + feld("puffer").hidden = !(werte && werte["mode"] == "Overheating"); + + const status = raumfenster && raumfenster.wurzel.querySelector("[data-rf-status='heizung']"); + if (status) status.textContent = ist === null ? "–" : komma(ist, 1) + RF_S + "°C" + (heizt ? " · heizt" : ""); +} + +/** Aufgerufen bei jeder MQTT-Nachricht (homeSVG.updateValuesMQTT). */ +function raumfensterMqtt() { + if (!raumfenster || !document.body.contains(raumfenster.wurzel)) return; + const teil = raumfenster.wurzel.querySelector("[data-rf-heiz-teil]"); + if (teil && !teil.classList.contains("zieht")) rfHeizungZeichnen(teil); +} + +/* --- Licht und Schalter --------------------------------------------------------- */ + +/** An/Aus schicken - kennt das Geraet nur "umschalten", tut es auch das. */ +function rfSchalten(element, karte) { + const an = karte.classList.contains("rf-schalter") ? karte.getAttribute("aria-pressed") !== "true" : karte.dataset.an !== "1"; + const cmd = (an ? karte.dataset.ein : karte.dataset.aus) || karte.dataset.um; + if (!cmd) return; + if (karte.classList.contains("rf-schalter")) { + karte.setAttribute("aria-pressed", String(an)); + karte.querySelector(".rf-st").textContent = an ? "an" : "aus"; + } else { + karte.dataset.an = an ? "1" : ""; + rfLampeZeichnen(karte); + } + rfStatus(); + rfKommando(Number(cmd), {}, element); +} + +function rfLampeZeichnen(karte) { + const an = karte.dataset.an === "1"; + karte.classList.toggle("aus", !an); + karte.querySelector("[data-rf-birne]").setAttribute("aria-pressed", String(an)); + const hell = karte.querySelector(".rf-hell"); + let text = an ? "an" : "aus"; + if (hell) { + const min = Number(hell.min), max = Number(hell.max); + const prozent = Math.round((Number(hell.value) - min) / (max - min) * 100); + hell.style.setProperty("--wert", prozent + "%"); + if (an) text += " · " + prozent + RF_S + "%"; + } + karte.querySelector("[data-rf-lampe-stand]").textContent = text; +} + +function rfLampeStarten(karte) { + rfLampeZeichnen(karte); + karte.querySelector("[data-rf-birne]").addEventListener("click", e => rfSchalten(e.currentTarget, karte)); + + const hell = karte.querySelector(".rf-hell"); + if (hell) { + hell.addEventListener("input", () => rfLampeZeichnen(karte)); + hell.addEventListener("change", () => { + const params = {}; + params[hell.dataset.param] = hell.value; + rfKommando(Number(hell.dataset.cmd), params, hell); + }); + } + + // Farbe: das Geraet will drei Zahlen 0-255, zerlegt wird deshalb hier. + const farben = karte.querySelector(".rf-farben"); + if (farben) { + const setzen = hex => { + karte.style.setProperty("--farbe", hex); + farben.querySelectorAll("[data-farbe]").forEach(k => k.setAttribute("aria-pressed", String(k.dataset.farbe === hex))); + const params = {}; + params[farben.dataset.red] = parseInt(hex.substr(1, 2), 16); + params[farben.dataset.green] = parseInt(hex.substr(3, 2), 16); + params[farben.dataset.blue] = parseInt(hex.substr(5, 2), 16); + rfKommando(Number(farben.dataset.cmd), params, farben); + }; + farben.querySelectorAll("[data-farbe]").forEach(k => k.addEventListener("click", () => setzen(k.dataset.farbe))); + const eigen = farben.querySelector("input[type=color]"); + eigen.addEventListener("input", () => karte.style.setProperty("--farbe", eigen.value)); + eigen.addEventListener("change", () => setzen(eigen.value)); + } + + // Effekt und Preset: Chips oder - bei langen Listen - eine Auswahl. + karte.querySelectorAll(".rf-effekte .rf-chips[data-cmd]").forEach(gruppe => { + gruppe.querySelectorAll("[data-wert]").forEach(k => k.addEventListener("click", () => { + gruppe.querySelectorAll("[data-wert]").forEach(x => x.setAttribute("aria-pressed", String(x === k))); + const params = {}; + params[gruppe.dataset.param] = k.dataset.wert; + rfKommando(Number(gruppe.dataset.cmd), params, gruppe); + })); + }); + karte.querySelectorAll(".rf-effekte select[data-cmd]").forEach(auswahl => auswahl.addEventListener("change", () => { + const params = {}; + params[auswahl.dataset.param] = auswahl.value; + rfKommando(Number(auswahl.dataset.cmd), params, auswahl); + })); +} diff --git a/restricted/roomControls.php b/restricted/roomControls.php index d69ab22..0e3c04b 100644 --- a/restricted/roomControls.php +++ b/restricted/roomControls.php @@ -14,15 +14,10 @@ * auch ein neu gefundenes Geraet sein passendes Bedienelement, ohne dass hier * jemand einen Typnamen nachtraegt. * - * Das Gegenstueck im Browser ist raumBedienungAktivieren() in - * js/solar/homeMQTT.js - dort haengen die Zuhoerer an den data-Attributen, - * die hier gesetzt werden. + * Das Gegenstueck im Browser ist js/solar/raumfenster.js - dort haengen die + * Zuhoerer an den data-Attributen, die hier gesetzt werden. */ -// Die Geraetegruppen werden als Reiter gezeichnet - eine Bauart fuer die -// ganze Seite, siehe restricted/reiter.php. -require_once(__DIR__ . "/reiter.php"); - /** * Was das Kommando schaltet: "ein", "aus", "um" - oder null, wenn es kein * Schaltkommando ist. @@ -173,7 +168,9 @@ function bedienform($d) } /** - * Die Gruppen in der Reihenfolge, in der ihre Tabs erscheinen. + * Die Gruppen in der Reihenfolge, in der ihre Reiter erscheinen, mit Symbol + * und Akzentfarbe - dieselben Farben wie in Lade-, Heiz- und + * Bewaesserungsfenster. * * Beschattung zuerst: das ist es, was man in einem Raum am haeufigsten * anfasst. Messwerte zuletzt - die schaut man an, man bedient sie nicht. @@ -181,21 +178,24 @@ function bedienform($d) function raumGruppenTitel() { return [ - "beschattung" => ["Beschattung", "bi-blinds"], - "heizung" => ["Heizung", "bi-thermometer-half"], - "licht" => ["Licht", "bi-lightbulb"], - "schalter" => ["Schalten", "bi-toggle-on"], - "sonstiges" => ["Geräte", "bi-sliders"], - "messwerte" => ["Messwerte", "bi-speedometer2"], + // "bi-blinds" gibt es in der eingebundenen Icon-Version nicht - die + // Kachel blieb leer. Ein eigenes Zeichen: Fensterrahmen mit Lamellen. + "beschattung" => ["Beschattung", "", "#60afbf"], + "heizung" => ["Heizung", "bi-thermometer-half", "#e5624f"], + "licht" => ["Licht", "bi-lightbulb", "#f7c000"], + "schalter" => ["Schalten", "bi-toggle-on", "#6cbe58"], + "sonstiges" => ["Geräte", "bi-sliders", "#9aa6ad"], + "messwerte" => ["Messwerte", "bi-speedometer2", "#4b9fd6"], ]; } /** - * Die Geraetegruppen als Tabs. + * Die Geraetegruppen als Reiter-Kacheln. * - * Vorher standen sie untereinander, und man scrollte an drei Rollladen - * vorbei, um zum Licht zu kommen. Bei nur einer Gruppe bleibt die Leiste - * weg - ein einzelner Tab beschriftet nur sich selbst. + * Jede Kachel traegt unter ihrem Namen den Zustand der Gruppe ("2 von 3 zu", + * "25,7 °C", "1 an") - den setzt js/solar/raumfenster.js, weil er sich + * waehrend des Bedienens aendert. Bei nur einer Gruppe bleibt die Leiste + * trotzdem stehen: sie ist hier auch die Zusammenfassung. * * $fertig sind Gruppen, deren Inhalt der Aufrufer schon gebaut hat und die * nicht aus einer Geraeteliste entstehen - die Heizung ist so eine: ein @@ -213,180 +213,58 @@ function zeichneGruppen($gruppen, $fertig = []) return ""; } - // Aus den Gruppen werden Reiter. Wie sie aussehen und wie umgeschaltet - // wird, steht in restricted/reiter.php - hier nur, was drinsteht. - $reiter = []; + $leiste = "
"; + $flaechen = ""; + $erste = true; foreach ($vorhanden as $form => $titel) { + $leiste .= ""; + $inhalt = ""; if (isset($fertig[$form])) { $inhalt = $fertig[$form]; } else { + if ($form === "beschattung" && count($gruppen[$form]) > 1) { + $inhalt .= alleImRaum($gruppen[$form]); + } + if ($form === "schalter") { + $inhalt .= "
"; + } foreach ($gruppen[$form] as $d) { $inhalt .= zeichneGeraet($d, $form); } + if ($form === "schalter") { + $inhalt .= "
"; + } } - $reiter[] = [ - "id" => $form, - "titel" => $titel[0], - "symbol" => $titel[1], - "inhalt" => $inhalt, - ]; + $flaechen .= "
" . $inhalt . "
"; + $erste = false; } - // "schmal", weil das Modal eng ist: kleinere Reiter, die waagerecht - // scrollen statt umzubrechen. - return reiterBlock($reiter, ["name" => "raumtab", "schmal" => true, - "klasse" => "mb-2"]); + return $leiste . "
" . $flaechen; } // =========================================================================== // Bausteine // =========================================================================== +/** Rahmen fuer Geraete ohne eigene Bedienform (Messwerte, Sonstiges). */ function karteAuf($d, $status = "") { - $html = "
" - . "
" - . "
" - . "" . htmlspecialchars($d["label"]) . ""; + $html = "
" + . "
" . htmlspecialchars($d["label"]) . ""; if ($status !== "") { - $html .= "" . $status . ""; + $html .= "" . $status . ""; } return $html . "
"; } function karteZu() { - return "
"; -} - -/** - * Ein Schieberegler, der beim Loslassen schaltet. - * - * Waehrend des Ziehens zu schicken hiesse, bei jedem Pixel ein Kommando an - * die Box zu geben - eine Jalousie kaeme aus dem Anfahren nicht mehr heraus. - */ -function reglerZeile($titel, $cmd, $param, $wert, $einheit = "%") -{ - $min = reglerMin($param); - $max = reglerMax($param); - $wert = reglerWert($wert, $min, $max); - return "
" - . "
" - . "" . htmlspecialchars($titel) . "" - . "" . $wert . $einheit . "
" - . reglerFeld($min, $max, $wert, $einheit) - . "
"; -} - -function reglerMin($param) -{ - return $param["min"] === null ? 0 : intval($param["min"]); -} - -function reglerMax($param) -{ - return $param["max"] === null ? 100 : intval($param["max"]); -} - -function reglerWert($wert, $min, $max) -{ - $wert = $wert === null || $wert === "" ? $min : intval($wert); - return max($min, min($max, $wert)); -} - -function reglerFeld($min, $max, $wert, $einheit, $beschriftung = "") -{ - return ""; -} - -/** - * Ein senkrechter Regler mit Wort an jedem Ende. - * - * Beide Werte einer Jalousie bewegen etwas, das oben und unten hat; ein - * waagerechter Schieber dafuer muss erst uebersetzt werden. Senkrecht steht - * der Griff dort, wo der Rollladen steht. - * - * Gedreht wird mit transform, nicht mit writing-mode. writing-mode ist zwar - * der neuere Weg, aber am Handy laesst sich ein so gebauter Regler nur - * schlecht anfassen - das Ziehen wird als Wischen gelesen und blaettert die - * Seite, statt den Griff mitzunehmen. Ein gedrehter gewoehnlicher Regler - * bewegt sich in seinen eigenen Koordinaten waagerecht, wird von jedem - * Browser unterstuetzt und traegt dieselbe Gestaltung wie die liegenden. - */ -function senkrechterRegler($cmd, $param, $wert, $titel, $oben, $unten, $rolle, - $mitParam = null, $mitRolle = "") -{ - $min = reglerMin($param); - $max = reglerMax($param); - $wert = reglerWert($wert, $min, $max); - // $mitParam: ein zweiter Parameter desselben Kommandos, dessen Wert der - // Regler mit der Rolle $mitRolle beisteuert. Damit schickt der - // Hoehenregler die Neigung mit, ohne dass das JavaScript wissen muss, - // welche Nummern das sind. - $mit = $mitParam === null ? "" - : " data-mit-param='" . intval($mitParam["id"]) . "' data-mit-rolle='" . $mitRolle . "'"; - // Keine Prozentzahl darunter: was der Regler bewirkt, zeigt das - // Lamellenbild daneben, und die gesparte Zeile macht die Bahn laenger. - return "
" - . "" . htmlspecialchars($titel) . "" - . "" . htmlspecialchars($oben) . "" - . "
" . reglerFeld($min, $max, $wert, "%", $titel) . "
" - . "" . htmlspecialchars($unten) . "" - . "
"; -} - -/** - * Das Fenster, wie die Jalousie davor gerade steht. - * - * Zwei Zahlen in einem Bild: wie weit sie heruntergefahren ist und wie die - * Lamellen stehen. Weder "60 %" noch "30 %" sagt, ob man noch hinaussieht - - * das Bild sagt es. - * - * Die Hoehe schneidet ab: gezeichnet wird nur der Teil des Fensters, den die - * Jalousie bedeckt, mit der Endschiene an seiner Unterkante. Die Neigung - * bestimmt, wie dick eine Lamelle von vorn erscheint - w * sin(Winkel), - * genau das, was das Licht abhaelt. Beides setzt lamellenZeichnen() in - * js/solar/homeMQTT.js. - * - * Die Kennung des Beschnitts traegt die Geraetenummer: in einem Raum haengen - * mehrere dieser Bilder, und url(#...) findet sonst immer dasselbe. - */ -function lamellenBild($actorId) -{ - $schnitt = "lamellen" . intval($actorId); - $html = ""; -} - -/** Kippschalter mit dem Stand, den das Geraet zuletzt gemeldet hat. */ -function kippSchalter($d, $schalter, $an) -{ - $id = "sw" . intval($d["id"]); - $html = "
" - . "" - . "
"; - return $html; + return "
"; } /** Ein "wahr"-artiger Wert - die Geraete melden mal True, mal 1, mal "on". */ @@ -403,7 +281,7 @@ function istAn($wert) * * Nur Kommandos ohne Parameter: ein Menuepunkt bietet nichts zum Einstellen, * ein "Position+Neigung" von hier aus wuerde ohne Werte losgeschickt. Was - * Parameter braucht, hat oben seinen Schieberegler. + * Parameter braucht, hat oben seine Bedienung. */ function mehrMenue($cmds) { @@ -411,9 +289,8 @@ function mehrMenue($cmds) if (!$cmds) { return ""; } - $html = ""; } +/** Kommandos, die nicht unter $benutzt stehen. */ +function restKommandos($d, $benutzt) +{ + $ids = array_map(function ($c) { return $c["id"]; }, array_filter($benutzt)); + return array_values(array_filter($d["commands"], function ($c) use ($ids) { + return !in_array($c["id"], $ids, true); + })); +} + +/** Stufenwahl als Segment: $stufen = [[wert, beschriftung], ...]. */ +function stufenReihe($titel, $rolle, $stufen) +{ + $html = "
" . htmlspecialchars($titel) . "" + . "
"; + foreach ($stufen as $s) { + $html .= ""; + } + return $html . "
"; +} + // =========================================================================== // Die Bedienformen // =========================================================================== -/** - * Der zuletzt gemeldete Stand in Worten. - * - * "0 % zu" muss man erst uebersetzen, "offen" nicht. Die Prozentzahl steht - * ohnehin am Schieberegler darunter. - */ -function beschattungsStand($position, $neigung) +/** Zahl aus einem Zustand, oder "" wenn es keinen gibt. */ +function zustandsZahl($d, $namen) { - if ($position === null || $position === "") { - return ""; + $wert = zustandsWert($d, $namen); + return is_numeric($wert) ? strval(intval($wert)) : ""; +} + +/** + * "Alle im Raum": ein Knopf, viele Geraete. Das JavaScript geht die Karten + * durch und schickt jedem das passende eigene Kommando - "Sichtschutz" nur + * denen, die Hoehe und Neigung zusammen setzen koennen. + */ +function alleImRaum($geraete) +{ + $mitMy = false; + $mitNeigung = false; + foreach ($geraete as $d) { + $mitMy = $mitMy || kommandoMitUrl($d, ["my"]); + $mitNeigung = $mitNeigung || kombiKommando($d); } - $p = intval($position); - $text = $p === 0 ? "offen" : ($p === 100 ? "geschlossen" : $p . " % zu"); - if ($neigung !== null && $neigung !== "" && $p > 0) { - $text .= ", Neigung " . intval($neigung) . " %"; - } - return htmlspecialchars($text); + return "

Alle im Raum

" + . "" + . "" + . ($mitMy ? "" : "") + . ($mitNeigung ? "" : "") + . "
"; } /** * Rollladen, Jalousie, Markise, Garagentor. * - * Auf/Stop/Zu gross zum Antippen, darunter die Feinheiten. Die Schieber - * starten auf dem zuletzt gemeldeten Stand - so sieht man, wo das Geraet - * gerade steht, bevor man es bewegt. + * Links das Fenster, wie der Behang davor gerade steht - ziehen stellt die + * Hoehe fein ein. Rechts gross Auf / Stop / my / Zu, darunter Hoehe und + * Lamellen als Stufen und die my-Position, die sich dort auch speichern + * laesst. Alles wird von js/solar/raumfenster.js ueber die data-Attribute + * verdrahtet; welche es gibt, haengt an den Faehigkeiten des Geraets. */ function zeichneBeschattung($d) { $auf = kommandoMitUrl($d, ["up"]); $stop = kommandoMitUrl($d, ["stop"]); $zu = kommandoMitUrl($d, ["down"]); + $my = kommandoMitUrl($d, ["my"]); $position = kommandoMitParameter($d, POSITION_PARAMETER); $neigung = kommandoMitParameter($d, NEIGUNG_PARAMETER); $kombi = kombiKommando($d); - // Was das Geraet zuletzt gemeldet hat. Bei Somfy heisst die Lamelle - // "Lamellenausrichtung", die Hoehe schlicht "Position". - $standPos = zustandsWert($d, ["Position"]); - $standNeigung = zustandsWert($d, ["Lamellenausrichtung"]); - $status = beschattungsStand($standPos, $standNeigung); + // Die Hoehe geht, wenn es das gibt, ueber das Kombi-Kommando und nimmt die + // Neigung mit - sonst raste die Mechanik bei der Hoehenfahrt um und + // niemand faehrt sie wieder an (siehe sendeTahoma() in commands.php). + $hoehe = $kombi ?: $position; + $attr = [ + "auf" => $auf ? $auf["id"] : "", + "stop" => $stop ? $stop["id"] : "", + "zu" => $zu ? $zu["id"] : "", + "my" => $my ? $my["id"] : "", + "hoehe-cmd" => $hoehe ? $hoehe["id"] : "", + "hoehe-param" => $hoehe ? ($kombi ? parameterNamens($kombi, POSITION_PARAMETER)["id"] : $position["params"][0]["id"]) : "", + "hoehe-mit" => $kombi && $neigung ? parameterNamens($kombi, NEIGUNG_PARAMETER)["id"] : "", + "neig-cmd" => $neigung ? $neigung["id"] : "", + "neig-param" => $neigung ? $neigung["params"][0]["id"] : "", + // Was das Geraet zuletzt gemeldet hat. Bei Somfy heisst die Lamelle + // "Lamellenausrichtung", die Hoehe schlicht "Position". + "pos" => zustandsZahl($d, ["Position"]), + "neig" => $neigung ? zustandsZahl($d, ["Lamellenausrichtung"]) : "", + "my-pos" => zustandsZahl($d, ["Gespeicherte Position"]), + "my-neig" => $neigung ? zustandsZahl($d, ["Gespeicherte Neigung"]) : "", + ]; - // $kombi gehoert dazu: es steckt jetzt hinter dem Hoehenregler und hat im - // Mehr-Menue nichts mehr verloren. - $benutzt = array_filter([$auf, $stop, $zu, $position, $neigung, $kombi]); - $benutzteIds = array_map(function ($c) { return $c["id"]; }, $benutzt); - $rest = array_values(array_filter($d["commands"], function ($c) use ($benutzteIds) { - return !in_array($c["id"], $benutzteIds, true); - })); - - $html = karteAuf($d, $status); - $html .= "
"; - - // Auf, Stop, Zu untereinander: so zeigen die Knoepfe in die Richtung, in - // die sie fahren. - if ($auf || $stop || $zu) { - $html .= "
"; - foreach ([[$auf, "bi-chevron-up", "Auf"], [$stop, "bi-stop-fill", "Stop"], - [$zu, "bi-chevron-down", "Zu"]] as $k) { - if (!$k[0]) { - continue; - } - $html .= ""; - } - $html .= "
"; + $html = "
$wert) { + $html .= " data-" . $name . "='" . htmlspecialchars(strval($wert), ENT_QUOTES) . "'"; } - // Erst das Bild, dann die Regler: neben den Fahrknoepfen faende der - // Daumen sonst beim Antippen gleich einen Schieber. - if ($position || $neigung) { - $html .= lamellenBild($d["id"]); + $html .= ">"; + + // Das Bild: dunkles Glas, davor Lamellen (mit Neigung) oder ein Panzer. + // Mehr Lamellen als je sichtbar - die Hoehe schneidet ab. + $html .= ""; + + $html .= "
" + . "
" . htmlspecialchars($d["label"]) . "" + . "" + . mehrMenue(restKommandos($d, [$auf, $stop, $zu, $my, $position, $neigung, $kombi])) . "
"; + + if ($auf || $stop || $zu || $my) { + $html .= "
" + . ($auf ? "" : "") + . ($stop ? "" : "") + . ($my ? "" : "") + . ($zu ? "" : "") + . "
"; } - if ($position) { - // Kann das Geraet beides zusammen, geht die Hoehe ueber dieses - // Kommando und nimmt die Neigung mit - sonst raste die Mechanik bei - // der Hoehenfahrt um und niemand faehrt sie wieder an. - $hoehenCmd = $kombi ?: $position; - $hoehenParam = $kombi ? parameterNamens($kombi, POSITION_PARAMETER) - : $position["params"][0]; - $mitParam = $kombi ? parameterNamens($kombi, NEIGUNG_PARAMETER) : null; - $html .= senkrechterRegler($hoehenCmd, $hoehenParam, $standPos, - "Position", "oben", "unten", "position", - $neigung ? $mitParam : null, "neigung"); + $prozent = function ($v) { return $v . " %"; }; + if ($hoehe) { + $html .= stufenReihe("Höhe", "rf-pos", [[0, "offen"], [25, $prozent(25)], [50, "halb"], [75, $prozent(75)], [100, "zu"]]); } if ($neigung) { - $html .= senkrechterRegler($neigung, $neigung["params"][0], $standNeigung, - "Neigung", "auf", "zu", "neigung"); + $html .= stufenReihe("Lamellen", "rf-neig", [[0, "offen"], [25, $prozent(25)], [50, "halb"], [75, $prozent(75)], [100, "zu"]]); } - $html .= mehrMenue($rest); - return $html . "
" . karteZu(); + // Die my-Position ist im Motor gespeichert und nur bei IO-Geraeten lesbar. + // Speichern geht ueber ajax/room.php?action=my, nicht ueber ein Kommando + // aus der Geraetedatenbank. + if ($attr["my-pos"] !== "" && $my) { + $html .= "
"; + } + return $html . "
"; } /** - * Lampe: Kippschalter, Helligkeit, Farbe, Effekt. - * - * Die Farbe kommt in der Datenbank als drei Parameter 0-255 an. Drei - * Zahlenfelder sind keine Farbwahl - deshalb ein Farbfeld, das der Browser - * aufmacht, und die Zerlegung passiert erst beim Absenden. + * Lampe: die Birne ist der Schalter und leuchtet in ihrer Farbe. Darunter + * Helligkeit, Farbfelder und - eingeklappt - Effekt und Preset. */ function zeichneLicht($d) { @@ -533,88 +446,105 @@ function zeichneLicht($d) $helligkeit = kommandoMitParameter($d, "brightness"); $farbe = farbKommando($d); - $benutzteIds = []; - foreach ($schalter as $c) { - $benutzteIds[] = $c["id"]; - } - foreach ([$helligkeit, $farbe] as $c) { - if ($c) { - $benutzteIds[] = $c["id"]; + // Der zuletzt gemeldete Farbwert steht als [r, g, b, w] in der Datenbank. + // Steht dort schwarz, leuchtet die Lampe ueber ihren Weisskanal - ein + // schwarzes Feld waere dann eine falsche Auskunft. + $roh = json_decode(strval(zustandsWert($d, ["Farbe", "color"])), true); + $hex = is_array($roh) && count($roh) >= 3 && (intval($roh[0]) || intval($roh[1]) || intval($roh[2])) + ? sprintf("#%02x%02x%02x", intval($roh[0]), intval($roh[1]), intval($roh[2])) + : "#fff1d6"; + + $benutzt = array_values($schalter); + $benutzt[] = $helligkeit; + $benutzt[] = $farbe; + + $html = "
"; + + $html .= "
" + . "" + . "
" . htmlspecialchars($d["label"]) . "
"; + + // Effekt und Preset sammeln, bevor das Mehr-Menue die Reste bekommt. + $listen = []; + foreach ($d["commands"] as $c) { + if (in_array($c, $benutzt, true) || count($c["params"]) !== 1 || !count($c["params"][0]["options"])) { + continue; } + $listen[] = $c; + $benutzt[] = $c; + } + $html .= mehrMenue(restKommandos($d, $benutzt)) . "
"; + + if ($helligkeit) { + $p = $helligkeit["params"][0]; + $min = $p["min"] === null ? 0 : intval($p["min"]); + $max = $p["max"] === null ? 100 : intval($p["max"]); + $wert = zustandsWert($d, ["Helligkeit", "brightness", "bri"]); + $wert = max($min, min($max, $wert === null || $wert === "" ? $max : intval($wert))); + $html .= ""; } - $html = karteAuf($d, ""); - $html .= "
"; - $html .= $schalter ? kippSchalter($d, $schalter, $an) : ""; if ($farbe) { - // Der zuletzt gemeldete Farbwert steht als [r, g, b, w] in der - // Datenbank; alles andere faellt auf Weiss zurueck. - $roh = json_decode(strval(zustandsWert($d, ["Farbe", "color"])), true); - // Steht dort schwarz, leuchtet die Lampe ueber ihren Weisskanal - ein - // schwarzes Feld waere dann eine falsche Auskunft. - $hex = is_array($roh) && count($roh) >= 3 - && (intval($roh[0]) || intval($roh[1]) || intval($roh[2])) - ? sprintf("#%02x%02x%02x", intval($roh[0]), intval($roh[1]), intval($roh[2])) - : "#ffffff"; - $html .= "Farbe" - . ""; - } - $html .= "
"; - - if ($helligkeit) { - $html .= reglerZeile("Helligkeit", $helligkeit, $helligkeit["params"][0], - zustandsWert($d, ["Helligkeit", "brightness", "bri"]), ""); - } - - // Effekt und Preset sind Listen - die gehoeren in eine Auswahl, nicht in - // ein Zahlenfeld. Sie schalten sofort, ohne zusaetzlichen Knopf. - foreach ($d["commands"] as $c) { - if (in_array($c["id"], $benutzteIds, true) || count($c["params"]) !== 1 - || !count($c["params"][0]["options"])) { - continue; + $html .= ">"; + foreach (["#ffd9a0" => "Warmweiß", "#fff1d6" => "Weiß", "#dfeeff" => "Kaltweiß", "#ff6b5b" => "Rot", + "#ffb347" => "Orange", "#7ddc6f" => "Grün", "#5bb8ff" => "Blau", "#b48cff" => "Lila"] as $f => $name) { + $html .= ""; } - $benutzteIds[] = $c["id"]; - $p = $c["params"][0]; - $html .= "
" - . "" . htmlspecialchars($c["name"]) . "" - . "
"; + $html .= "
"; } - $rest = array_values(array_filter($d["commands"], function ($c) use ($benutzteIds) { - return !in_array($c["id"], $benutzteIds, true); - })); - if ($rest) { - $html .= "
" . mehrMenue($rest) . "
"; + if ($listen) { + $html .= "
" . htmlspecialchars(implode(" & ", array_map(function ($c) { return $c["name"]; }, $listen))) . ""; + foreach ($listen as $c) { + $p = $c["params"][0]; + // Wenige Eintraege als Chips, lange Listen (WLED hat ueber 180 + // Effekte) als Auswahl - Chips waeren dort eine Wand. + if (count($p["options"]) <= 16) { + $html .= "
"; + foreach ($p["options"] as $o) { + $html .= ""; + } + $html .= "
"; + } else { + $html .= ""; + } + } + $html .= "
"; } - return $html . karteZu(); + return $html . "
"; } -/** Alles, was nur an und aus kennt. */ +/** Alles, was nur an und aus kennt: eine Kachel zum Antippen. */ function zeichneSchalter($d) { $schalter = schaltKommandos($d); $an = istAn(zustandsWert($d, ["An", "ison", "on", "state", "Status"])); - $benutzteIds = array_map(function ($c) { return $c["id"]; }, array_values($schalter)); - $rest = array_values(array_filter($d["commands"], function ($c) use ($benutzteIds) { - return !in_array($c["id"], $benutzteIds, true); - })); - - $html = karteAuf($d, ""); - $html .= "
" - . kippSchalter($d, $schalter, $an) . mehrMenue($rest) . "
"; - return $html . karteZu(); + return ""; } /** @@ -636,9 +566,8 @@ function zeichneMesswerte($d) } $html .= ""; if ($weitere) { - $html .= "
" - . count($weitere) . " weitere Werte" - . "
"; + $html .= "
" . count($weitere) . " weitere Werte" + . "
"; foreach ($weitere as $s) { $html .= wertKachel($s); }