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 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 22:21:19 +02:00
co-authored by Claude Opus 5
parent fed0d35e03
commit d5d2d8f336
8 changed files with 1201 additions and 1016 deletions
+17 -10
View File
@@ -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 | | 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/#` | | `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 | | `heat` | `heat.php` | `heatMQTT.js` | `getHeaterData`, `getWaterData`, `getSunrise` | `solarManager/#`, `weatherStation/#`, Wallbox-Topics |
| `history` | `history.php` | `jahresstatistik.js`, `historyMQTT.js` | `energyHistory` (6 ×), `getStats` | — | | `history` | `history.php` | `jahresstatistik.js`, `historyMQTT.js` | `energyHistory` (6 ×), `getStats` | — |
| `skoda` | `skoda.php` | `skodaMQTT.js` | `skoda.php?was=…`, `skodaCmd.php`, `tile.php` | `solarManager/#` | | `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 | | `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 | | `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 | | `mountMeteogram()`, `updateWeatherCards()` | Wetterteile der Solar- und Wetterseite |
| `reiterMerken()` | offener Reiter steht in der Adresse und übersteht ein Neuladen | | `reiterMerken()` | offener Reiter steht in der Adresse und übersteht ein Neuladen |
| `loadingHTML(text)` | der eine Ladezustand für alle Modals | | `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: herausgreifen, Vorzeichen drehen (`negativ`), skalieren (`format:
"leistung"` für Watt-Quellen, `"leistung_kw"` für Quellen, die schon "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 Kilowatt melden — die Wallboxen), runden (`stellen`), Einheit anhängen. Klick auf eine Kachel
öffnet das Raum-Modal über `ajax/room.php?room=<Raumnummer>`; Heizsymbol, öffnet das Raum-Modal über `ajax/room.php?room=<Raumnummer>`; bedient wird es
Regler und Ist-Werte lesen aus dem Thermostat-Zweig des Raums (`mqttZweig()`). 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 **`solarMQTT.js`** — die Solar-Seite. Startet die Energiefluss-Übersicht
(`energieflussStarten()`) samt Anlage-Ansicht, füllt die Bewässerungszonen, (`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 | | Endpunkt | Aufruf | liefert / tut |
|---|---|---| |---|---|---|
| `room.php` | `?room=<rooms.id>`, `POST ?action=command\|temp\|jalousie` | Raum-Modal bauen; ein Kommando oder eine Solltemperatur senden | | `room.php` | `?room=<rooms.id>`, `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`) | | `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 | | `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 | | `getStats.php` | `GET` | Jahresstatistik, alle Jahre, mit Metadaten je Kennzahl |
@@ -480,7 +488,6 @@ flowchart LR
CMD["commands.php"] --> MYSQL CMD["commands.php"] --> MYSQL
CMD --> TAH["tahoma_EG.php"] CMD --> TAH["tahoma_EG.php"]
CMD --> PHPMQTT["ajax/phpMQTT.php"] CMD --> PHPMQTT["ajax/phpMQTT.php"]
RCTRL["roomControls.php"] --> REITER["reiter.php"]
HOME["home.php"] --> ROOMS HOME["home.php"] --> ROOMS
HOME --> REITER HOME --> REITER
SET["settings.php"] --> COSTS & KACH & REITER SET["settings.php"] --> COSTS & KACH & REITER
@@ -641,9 +648,9 @@ sequenceDiagram
``` ```
`roomControls.php` entscheidet **an den Fähigkeiten**, nicht am Typnamen: Hat `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 das Gerät ein `setClosure`, wird es ein Rollladen mit Auf/Stop/Zu und
Reglern; hat es drei Parameter rot/grün/blau, wird es eine Lampe mit Höhenstufen, mit `my` kommt der my-Knopf dazu, mit Neigung die Lamellenstufen;
Farbwähler. Ein neu gefundenes Gerät bekommt so ohne Codeänderung ein 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. passendes Bedienelement.
`commands.php` ist das Gegenstück zu `transports.py` im SolarManager-Repo — `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` | | 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) | | 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 | | 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 | | Neue Einstellung | Reiter in `$reiter` (`restricted/settings.php`), Abschnitt in `settings.js`, Aktion in `ajax/settings.php`, Modellfunktion daneben |
--- ---
+79 -18
View File
@@ -12,6 +12,9 @@
* GET ?room=12 Inhalt des Modals, 12 = rooms.id * GET ?room=12 Inhalt des Modals, 12 = rooms.id
* POST ?action=command {"command_id": N, "params": {...}} - ein Kommando * POST ?action=command {"command_id": N, "params": {...}} - ein Kommando
* POST ?action=temp {"room": 12, "temp": 21} - Solltemperatur * 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 * Frueher hiess der Raum "EG_Bad" und wurde am Unterstrich zerlegt. Das hielt
* weder ein Leerzeichen im Namen aus (erst nachgeruestet) noch eine * weder ein Leerzeichen im Namen aus (erst nachgeruestet) noch eine
@@ -161,6 +164,50 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
"faehrt" => ($zustaende["core:MovingState"] ?? null) === true, "faehrt" => ($zustaende["core:MovingState"] ?? null) === true,
"position" => $zahl("core:ClosureState"), "position" => $zahl("core:ClosureState"),
"neigung" => $zahl("core:SlateOrientationState"), "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); 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. // einer Geraeteliste: ein Regler fuer den Raum statt einer Karte je Geraet.
$fertig = []; $fertig = [];
if ($hatThermostat) { if ($hatThermostat) {
$karte = "<div class='card mb-2 raum-geraet'><div class='card-body py-2'>" // Ring mit Ist-Temperatur und ziehbarem Soll-Punkt, daneben - und + und
. "<div class='d-flex justify-content-between align-items-baseline'>" // die Schnellwahl. Alle Zahlen kommen live ueber MQTT (der Browser hoert
. "<label class='form-label mb-0 small' for='modal-slider'>Solltemperatur</label>" // ohnehin auf den Thermostat-Zweig) und werden von js/solar/raumfenster.js
. "<span id='modal-slider-label' class='fw-semibold text-nowrap ms-2' style='color:#297195'></span>" // eingetragen. Das Thermostat nimmt ganze Grad von 10 bis 30 °C, siehe
. "</div>" // action=temp.
. "<input type='range' id='modal-slider' class='form-range range-color-track' min='10' max='30'>" $fertig["heizung"] = "<div class='rf-heiz' data-rf-heiz-teil data-min='10' data-max='30'>"
// Der Ist-Zustand kommt live ueber MQTT, nicht aus der Datenbank - . "<div class='rf-ring'><svg viewBox='0 0 170 170'>"
// der Browser hoert ohnehin schon auf Raumtemp/#, und ein Wert von . "<defs><linearGradient id='rfHeizVerlauf' gradientUnits='userSpaceOnUse' gradientTransform='rotate(-135 85 85)' x1='15' y1='0' x2='155' y2='0'>"
// vorhin waere neben dem Regler nur irrefuehrend. . "<stop offset='0' stop-color='#4b9fd6'/><stop offset='.55' stop-color='#e8a33a'/><stop offset='1' stop-color='#e5624f'/>"
. "<div class='d-flex flex-wrap gap-3 small text-body-secondary'>" . "</linearGradient></defs>"
. "<span><i class='bi bi-thermometer-half me-1'></i><span data-mqtt='Temp[degC]'></span> °C</span>" . "<circle class='rf-ring-spur' cx='85' cy='85' r='70' transform='rotate(135 85 85)'/>"
. "<span><i class='bi bi-droplet me-1'></i><span data-mqtt='rHum[%]'></span> %rF</span>" . "<circle class='rf-ring-ist' cx='85' cy='85' r='70' transform='rotate(135 85 85)'/>"
. "<span data-mqtt-heizung class='d-none'><i class='bi bi-fire text-danger me-1'></i>heizt</span>" . "<circle class='rf-ring-griff' cx='85' cy='85' r='70' transform='rotate(135 85 85)' data-rf-ringzug/>"
. "</div></div></div>"; . "<circle class='rf-ring-soll' cx='155' cy='85' r='10' data-rf-ringzug tabindex='0' role='slider'"
$fertig["heizung"] = $karte; . " aria-label='Solltemperatur' aria-valuemin='10' aria-valuemax='30'/></svg>"
. "<div class='rf-ring-mitte'><small>im Raum</small><b data-rf-heiz='ist'></b><span data-rf-heiz='soll'>Soll </span></div></div>"
. "<div class='rf-heiz-rechts'><h3>Solltemperatur</h3>"
. "<div class='rf-stepper'><button type='button' data-rf-schritt='-1' aria-label='1 Grad kälter'></button>"
. "<output data-rf-heiz='wert'></output>"
. "<button type='button' data-rf-schritt='1' aria-label='1 Grad wärmer'>+</button></div>"
. "<div class='rf-chips'>"
. "<button type='button' class='rf-chip' data-rf-soll='10' aria-pressed='false'>Minimum <span>10°</span></button>"
. "<button type='button' class='rf-chip' data-rf-soll='17' aria-pressed='false'>Eco <span>17°</span></button>"
. "<button type='button' class='rf-chip' data-rf-soll='21' aria-pressed='false'>Komfort <span>21°</span></button>"
. "<button type='button' class='rf-chip' data-rf-soll='23' aria-pressed='false'>Warm <span>23°</span></button></div>"
. "<div class='rf-hinweis' data-rf-heiz='hinweis' hidden><i class='bi bi-info-circle'></i><span></span></div>"
. "<div class='rf-pillen'><span class='rf-pille'><i class='bi bi-droplet'></i><span data-rf-heiz='feuchte'></span></span>"
. "<span class='rf-pille' data-rf-heiz='puffer' hidden><i class='bi bi-fire'></i>Pufferbetrieb</span></div>"
. "</div></div>";
} }
// Der Thermostat-Zweig reist mit: das Modal fuellt Ist-Werte und Heizsymbol // Der Thermostat-Zweig reist mit: das Modal fuellt Ist-Werte und Heizsymbol
// aus dem laufenden MQTT-Strom und muss wissen, wo es nachsehen soll. // aus dem laufenden MQTT-Strom und muss wissen, wo es nachsehen soll.
echo "<div class='p-1' id='roomView' data-room='" . $raum["nr"] . "'" echo "<div class='rf' id='roomView' data-room='" . $raum["nr"] . "'"
. " data-thermostat='" . htmlspecialchars((string)$raum["thermostat"], ENT_QUOTES) . "'>"; . " data-thermostat='" . htmlspecialchars((string)$raum["thermostat"], ENT_QUOTES) . "'>";
if (!$gruppen && !$fertig) { if (!$gruppen && !$fertig) {
echo "<p class='text-body-secondary mb-0'>Diesem Raum ist noch nichts zugeordnet. " echo "<p class='rf-leer'>Diesem Raum ist noch nichts zugeordnet. "
. "Das geht über das Haus-Symbol in der Karte „Automatismen“.</p>"; . "Das geht über das Haus-Symbol in der Karte „Automatismen“.</p>";
} }
echo zeichneGruppen($gruppen, $fertig); echo zeichneGruppen($gruppen, $fertig);
echo "<div id='roomFeedback' class='small mt-2'></div>"; echo "<div id='roomFeedback' class='rf-meldung' aria-live='polite'>Änderungen gelten sofort</div>";
echo "</div>"; echo "</div>";
+213 -238
View File
@@ -922,262 +922,237 @@ input[type='range']::-webkit-slider-runnable-track {
.auto-zeile.tat.leer { grid-template-areas: "sym was weg"; } .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 Raum-Modal (Klick auf einen Raum im Grundriss)
Erzeugt von restricted/roomControls.php, verdrahtet in js/solar/homeMQTT.js. 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 .rf { display: grid; gap: .75rem; padding: .9rem 1rem .8rem; --a: #60afbf; }
Beschriftungen nicht nebeneinander - dann scrollt die Leiste seitwaerts, .rf h3 { margin: 0; font-size: .68rem; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--ton-schrift-2, #9aa6ad); }
statt die Namen umzubrechen. Die durchgehende Linie unter den Reitern .rf .rf-leer { margin: 0; color: var(--ton-schrift-2, #9aa6ad); }
darf dabei nicht mitscrollen, sie gehoert an den unteren Rand. */ .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 /* Waehrend ein Kommando unterwegs ist - Lampen, Schalter, Heizung. Jalousien
ueber eine Minute; ohne sichtbare Sperre tippt man in der Zeit nach. */ zeigen stattdessen "faehrt…" (.rf-bewegt). */
.raum-geraet.raum-warte { .rf .raum-geraet, .rf .rf-heiz { position: relative; }
opacity: 0.55; .rf .raum-warte { opacity: .6; pointer-events: none; }
pointer-events: none; .rf .raum-warte::after {
} content: ""; position: absolute; top: .55rem; right: .55rem; width: .85rem; height: .85rem;
.raum-geraet.raum-warte::after { border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%;
content: ""; animation: raum-dreht .8s linear infinite;
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); }
} }
@keyframes raum-dreht { to { transform: rotate(360deg); } }
/* -------------------------------------------------------------------------- /* Reiter als Kacheln mit Zustand */
Regler im Raum-Modal .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 /* Bausteine */
blauen Griff sticht auf den dunklen Karten heraus, und in einem Raum mit .rf .rf-chips { display: flex; flex-wrap: wrap; gap: .35rem; }
drei Jalousien sind das sechs helle Balken. Bahn und Griff tragen deshalb .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; }
dasselbe Blaugruen wie der Temperaturregler, und der ungefuellte Teil ist .rf .rf-chip:hover { background: #3f474e; }
ein Grau, das in beiden Themes traegt. .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 /* Regler wie im Ladefenster */
Autoladung und Heizstab bleiben, wie sie sind. .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%)); }
#roomView input[type='range'] { .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%)); }
-webkit-appearance: none; .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); }
appearance: none; .rf input[type="range"]::-moz-range-thumb { width: 1rem; height: 1rem; border-radius: 50%; background: #fff; border: 4px solid var(--a); }
width: 100%; .rf input[type="range"]:focus-visible { outline: 2px solid var(--a); outline-offset: 4px; }
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);
}
/* Schieberegler stehen dicht untereinander (Helligkeit, Farbe). Ohne den /* --- Beschattung --- */
Abstand kleben Beschriftung und Bahn aneinander. .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); }
Die senkrechten Spalten stehen dagegen nebeneinander - dort wuerde der .rf .rf-alle h3 { margin-right: auto; }
Abstand die zweite Spalte um genau diese 0,35 rem tiefer setzen. */ .rf .rf-alle .bi { font-size: .8rem; }
.raum-regler + .raum-regler:not(.raum-spalte) {
margin-top: 0.35rem;
}
/* Messwerte als Gitter statt als Textwurst: ein Stromzaehler hat sechzehn .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; }
Werte, die in einer Zeile niemand findet. auto-fill statt fester Spalten, .rf .rf-behang.rf-bewegt { border-color: rgba(96, 175, 191, .6); }
damit auf dem Handy zwei und auf dem Schirm vier nebeneinander passen. */ .rf .rf-behang-mitte { display: grid; gap: .5rem; min-width: 0; align-content: start; }
.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;
}
/* 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); }
/* -------------------------------------------------------------------------- .rf .rf-fahrt { display: grid; grid-template-columns: 1fr auto auto 1fr; gap: .35rem; }
Raum-Modal: Jalousien .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 - .rf .rf-reihe { display: grid; grid-template-columns: 4.3rem minmax(0, 1fr); align-items: center; gap: .5rem; }
sie ist die einzige der drei Spalten, die von Breite profitiert. */ .rf .rf-reihe-titel { font-size: .76rem; color: var(--ton-schrift-2, #9aa6ad); }
.raum-jalousie { .rf .rf-segment { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 3px; padding: 3px; border-radius: .6rem; background: #22292d; }
display: flex; .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; }
align-items: stretch; .rf .rf-segment button:hover { color: var(--ton-schrift, #e8ede9); }
justify-content: space-between; .rf .rf-segment button[aria-checked="true"] { background: #363d43; color: var(--ton-schrift, #e8ede9); box-shadow: inset 0 0 0 1.5px var(--a); }
gap: 0.4rem; .rf .rf-segment button:focus-visible { outline: 2px solid var(--a); }
}
/* 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;
}
/* Auf, Stop, Zu untereinander - die Knoepfe zeigen in die Richtung, in die .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; }
sie fahren. Breit genug fuer einen Daumen: sie sind das, was man im .rf .rf-my-zeile > .bi { color: #f7c000; font-size: .72rem; }
Vorbeigehen antippt, die Regler daneben braucht man seltener. */ .rf .rf-my-zeile.frage { padding: .35rem .5rem; border-radius: .55rem; background: rgba(247, 192, 0, .08); color: var(--ton-schrift, #e8ede9); }
.raum-fahrt { .rf .rf-my-knoepfe { display: inline-flex; gap: .3rem; margin-left: auto; }
flex: 0 0 3.1rem; .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; }
.raum-fahrt .btn {
padding: 0.3rem 0;
line-height: 1.5;
}
/* Eine Spalte mit senkrechtem Regler: Name, oberes Wort, Bahn, unteres /* --- Heizung --- */
Wort, Zahl. Breit genug fuer "Position" in der kleinen Schrift. */ .rf .rf-heiz { display: grid; grid-template-columns: 10.5rem minmax(0, 1fr); gap: 1rem; align-items: center; --a: #e5624f; }
.raum-spalte { .rf .rf-ring { position: relative; width: 10.5rem; aspect-ratio: 1; max-width: 100%; }
flex: 0 0 3.4rem; .rf .rf-ring svg { width: 100%; height: 100%; display: block; touch-action: none; }
display: flex; .rf .rf-ring-spur { fill: none; stroke: #2b3237; stroke-width: 12; stroke-linecap: round; }
flex-direction: column; .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); }
align-items: center; .rf .rf-ring-griff { fill: none; stroke: transparent; stroke-width: 36; cursor: pointer; }
text-align: center; .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; }
.raum-name { .rf .rf-ring-soll:focus-visible { outline: none; stroke: #fff; fill: #e5624f; }
font-size: 0.62rem; .rf .rf-ring-mitte { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; pointer-events: none; }
opacity: 0.8; .rf .rf-ring-mitte small { font-size: .7rem; color: var(--ton-schrift-2, #9aa6ad); }
white-space: nowrap; .rf .rf-ring-mitte b { font-size: 1.9rem; font-weight: 600; line-height: 1.05; font-variant-numeric: tabular-nums; }
line-height: 1.2; .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. /* --- Licht --- */
Gedreht statt writing-mode: so gebaute Regler lassen sich am Handy kaum .rf .rf-lampe { display: grid; gap: .55rem; padding: .7rem; border-radius: .75rem; background: var(--ton-flaeche-2, #2b3237); --a: #f7c000; }
anfassen - das Ziehen wird als Wischen gelesen und blaettert die Seite. .rf .rf-lampe-kopf { display: flex; align-items: center; gap: .7rem; }
Gedreht bewegt sich der Regler in seinen eigenen Koordinaten waagerecht, .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; }
und touch-action haelt die Seite still, solange der Finger auf ihm liegt. .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); }
rotate(90deg) legt den kleinen Wert nach oben: "oben" ist 0 %. */ .rf .rf-birne:focus-visible { outline: 2px solid #f7c000; outline-offset: 3px; }
.raum-senkrecht { .rf .rf-lampe-name { display: grid; min-width: 0; }
position: relative; .rf .rf-lampe-name b { font-weight: 500; font-size: .92rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
width: 1.6rem; .rf .rf-lampe-name span { font-size: .76rem; color: var(--ton-schrift-2, #9aa6ad); font-variant-numeric: tabular-nums; }
height: 6.5rem; .rf .rf-lampe-regler { display: grid; gap: .5rem; transition: opacity .3s; }
margin: 0.1rem 0; .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; }
#roomView .raum-senkrecht input[type='range'] { .rf .rf-hell::-moz-range-track { background: linear-gradient(90deg, #4a3f22 0, var(--farbe, #ffd27a) var(--wert, 0%), #394046 var(--wert, 0%)) !important; }
position: absolute; .rf .rf-farben { display: flex; flex-wrap: wrap; gap: .45rem; align-items: center; }
top: 50%; .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; }
left: 50%; .rf .rf-farbe:hover { transform: scale(1.1); }
width: 6.5rem; /* = Hoehe des Kastens, die Laenge der Bahn */ .rf .rf-farbe[aria-pressed="true"] { box-shadow: 0 0 0 2px var(--ton-flaeche-2, #2b3237), 0 0 0 4px var(--f); }
height: 1.6rem; /* = Breite des Kastens, die Flaeche zum Treffen */ .rf .rf-farbe:focus-visible, .rf .rf-farbe-eigen:focus-within { outline: 2px solid #f7c000; outline-offset: 3px; }
transform: translate(-50%, -50%) rotate(90deg); .rf .rf-farbe-eigen { background: conic-gradient(#f55, #fd5, #5f7, #5df, #75f, #f5d, #f55); }
touch-action: none; .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. */ /* --- Schalter --- */
.raum-ende { .rf .rf-schalter-liste { display: grid; grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); gap: .5rem; }
font-size: 0.68rem; .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; }
opacity: 0.65; .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); }
line-height: 1.1; .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 /* --- Messwerte und Sonstiges --- */
js/solar/homeMQTT.js - schmal mit Luft dazwischen heisst offen, .rf .raum-werte { display: grid; grid-template-columns: repeat(auto-fill, minmax(6.5rem, 1fr)); gap: .4rem; }
aneinanderliegend heisst geschlossen. */ .rf .raum-wert { background: rgba(255, 255, 255, .04); border-radius: .5rem; padding: .3rem .5rem; overflow: hidden; }
.raum-lamellen { .rf .raum-wert-zahl { font-size: .95rem; font-weight: 600; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
display: block; .rf .raum-wert-einheit { font-size: .7rem; font-weight: 400; opacity: .6; }
align-self: center; .rf .raum-wert-name { font-size: .68rem; opacity: .6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
height: 6.9rem; /* Ein Kommando mit mehreren Parametern traegt Beschriftung, Felder und Knopf
/* Der Rahmen macht aus dem Streifen ein Fenster - ohne ihn liest sich das in einer input-group - ohne Mindestbreite schrumpfen die Felder am Handy
geschlossene Bild wie eine zweite Reglerbahn. */ auf nichts. */
background: rgba(0, 120, 143, 0.14); .rf .input-group.raum-cmd > .form-control,
border: 1px solid rgba(128, 128, 128, 0.35); .rf .input-group.raum-cmd > .form-select { min-width: 5rem; }
border-radius: 0.2rem;
} /* --- Handy --- */
.raum-lamelle { @media (max-width: 575.98px) {
fill: currentColor; .rf { padding: .75rem .75rem .7rem; }
opacity: 0.6; .rf .rf-reiter button { grid-template-columns: 1fr; justify-items: center; text-align: center; padding: .4rem .25rem; row-gap: .1rem; min-width: 5.5rem; }
stroke: rgba(0, 0, 0, 0.35); .rf .rf-reiter .rf-sym { grid-row: auto; width: 1.6rem; height: 1.6rem; }
stroke-width: 0.6; .rf .rf-reiter b { font-size: .76rem; }
vector-effect: non-scaling-stroke; .rf .rf-reiter .rf-st { font-size: .68rem; }
} .rf .rf-behang { grid-template-columns: 3.6rem minmax(0, 1fr); gap: .6rem; padding: .6rem; }
.raum-endschiene { /* Ueber die ganze Kartenhoehe waere das Fenster hier ein Strich - also ein
fill: currentColor; festes Seitenverhaeltnis, oben ausgerichtet. */
opacity: 0.85; .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 ------------------------------------ */ /* --- Einstellungen: Preistabellen ------------------------------------ */
+1 -1
View File
@@ -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", "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"]], "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, "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, "heat" => ["template" => "heat.php", "mqtt" => true, "charts" => true, "meteogram" => false, "leaflet" => false,
"scripts" => ["js/solar/heatMQTT.js"]], "scripts" => ["js/solar/heatMQTT.js"]],
"history" => ["template" => "history.php", "mqtt" => false, "charts" => true, "meteogram" => false, "leaflet" => false, "history" => ["template" => "history.php", "mqtt" => false, "charts" => true, "meteogram" => false, "leaflet" => false,
-25
View File
@@ -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. */ /** Meteoblue-Meteogramm einhängen, sofern die Seite einen Platz dafür hat. */
// Mindestbreite, in der das Meteogramm von meteoblue geladen wird. Der // Mindestbreite, in der das Meteogramm von meteoblue geladen wird. Der
// Anbieter legt sein Bild einmal beim Laden fest und zeichnet es bei einer // Anbieter legt sein Bild einmal beim Laden fest und zeichnet es bei einer
+10 -409
View File
@@ -180,6 +180,7 @@ const homeSVG = {
// mitsamt den Werten, die auf der Kachel stehen sollen. Jeder Raum // mitsamt den Werten, die auf der Kachel stehen sollen. Jeder Raum
// bekommt die gleiche Behandlung - Raeume und ihre Werte kommen dort // bekommt die gleiche Behandlung - Raeume und ihre Werte kommen dort
// dazu, nicht hier. // dazu, nicht hier.
if (typeof raumfensterMqtt === "function") raumfensterMqtt();
homeRooms.forEach(function (room) { homeRooms.forEach(function (room) {
const el = (part) => document.getElementById(room.floor + "_" + part); 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 // Ersetzung bleibt fuer die alten Namen ("Kueche"), bis sie jemand
// unter Einstellungen -> Grundriss umbenennt. // unter Einstellungen -> Grundriss umbenennt.
const titel = eintrag const titel = eintrag
? eintrag.floor + " · " + eintrag.mqtt.replace("ue", "ü").replace("ae", "ä").replace("oe", "ö") ? eintrag.mqtt.replace("ue", "ü").replace("ae", "ä").replace("oe", "ö")
: "Raum"; : "Raum";
const modalTitel = document.getElementById("modal-title"); const modalTitel = document.getElementById("modal-title");
modalTitel.textContent = titel; 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 // Der Speichern-Knopf gehoert allen Modals gemeinsam. Ein Klon ohne
// Zuhoerer stellt sicher, dass nicht der Handler eines vorher // Zuhoerer stellt sicher, dass nicht der Handler eines vorher
@@ -346,414 +353,8 @@ function switchFloor(floor){
.then(a => a.text()) .then(a => a.text())
.then(html => { .then(html => {
koerper.innerHTML = html; koerper.innerHTML = html;
// Verdrahten, zeichnen, Jalousien nachfragen: js/solar/raumfenster.js.
const slider = document.getElementById("modal-slider"); raumfensterStarten(koerper.querySelector("#roomView") || koerper, raum, eintrag?.thermostat);
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);
}) })
.catch(fehler => { koerper.innerHTML = "Konnte den Raum nicht laden: " + fehler.message; }); .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;
}
+637
View File
@@ -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 = "<span>my von <b>" + myText(d.myPos, d.myNeig) + "</b> auf <b>" + myText(pos, neig) + "</b> ändern?</span>"
+ "<span class='rf-my-knoepfe'><button type='button' class='rf-chip ja' data-rf-my-tun='ja'>Speichern</button>"
+ "<button type='button' class='rf-chip' data-rf-my-tun='nein'>Abbrechen</button></span>";
} else {
my.className = "rf-my-zeile";
my.innerHTML = "<i class='bi bi-star-fill'></i><span>my-Position " + myText(d.myPos, d.myNeig) + "</span>"
+ (aufMy || faehrt || pos === "" || d.mySpeichert === "1" ? ""
: "<button type='button' class='rf-link' data-rf-my-tun='frage'>aktuellen Stand als my speichern</button>")
+ (d.mySpeichert === "1" ? "<span class='rf-link'>wird gespeichert…</span>" : "");
}
}
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) + "<small>" + RF_S + "°C</small>";
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" + "<small>" + (chip ? chip.firstChild.textContent.trim() : "&nbsp;") + "</small>";
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);
}));
}
+238 -309
View File
@@ -14,15 +14,10 @@
* auch ein neu gefundenes Geraet sein passendes Bedienelement, ohne dass hier * auch ein neu gefundenes Geraet sein passendes Bedienelement, ohne dass hier
* jemand einen Typnamen nachtraegt. * jemand einen Typnamen nachtraegt.
* *
* Das Gegenstueck im Browser ist raumBedienungAktivieren() in * Das Gegenstueck im Browser ist js/solar/raumfenster.js - dort haengen die
* js/solar/homeMQTT.js - dort haengen die Zuhoerer an den data-Attributen, * Zuhoerer an den data-Attributen, die hier gesetzt werden.
* 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 * Was das Kommando schaltet: "ein", "aus", "um" - oder null, wenn es kein
* Schaltkommando ist. * 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 * Beschattung zuerst: das ist es, was man in einem Raum am haeufigsten
* anfasst. Messwerte zuletzt - die schaut man an, man bedient sie nicht. * anfasst. Messwerte zuletzt - die schaut man an, man bedient sie nicht.
@@ -181,21 +178,24 @@ function bedienform($d)
function raumGruppenTitel() function raumGruppenTitel()
{ {
return [ return [
"beschattung" => ["Beschattung", "bi-blinds"], // "bi-blinds" gibt es in der eingebundenen Icon-Version nicht - die
"heizung" => ["Heizung", "bi-thermometer-half"], // Kachel blieb leer. Ein eigenes Zeichen: Fensterrahmen mit Lamellen.
"licht" => ["Licht", "bi-lightbulb"], "beschattung" => ["Beschattung", "<svg viewBox='24 24' width='16' height='16' fill='none' stroke='currentColor' stroke-width='1.8' stroke-linecap='round'><rect x='4' y='3' width='16' height='18' rx='1.5'/><path d='M4 7.5h16M4 12h16M4 16.5h16'/></svg>", "#60afbf"],
"schalter" => ["Schalten", "bi-toggle-on"], "heizung" => ["Heizung", "bi-thermometer-half", "#e5624f"],
"sonstiges" => ["Geräte", "bi-sliders"], "licht" => ["Licht", "bi-lightbulb", "#f7c000"],
"messwerte" => ["Messwerte", "bi-speedometer2"], "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 * Jede Kachel traegt unter ihrem Namen den Zustand der Gruppe ("2 von 3 zu",
* vorbei, um zum Licht zu kommen. Bei nur einer Gruppe bleibt die Leiste * "25,7 °C", "1 an") - den setzt js/solar/raumfenster.js, weil er sich
* weg - ein einzelner Tab beschriftet nur sich selbst. * 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 * $fertig sind Gruppen, deren Inhalt der Aufrufer schon gebaut hat und die
* nicht aus einer Geraeteliste entstehen - die Heizung ist so eine: ein * nicht aus einer Geraeteliste entstehen - die Heizung ist so eine: ein
@@ -213,180 +213,58 @@ function zeichneGruppen($gruppen, $fertig = [])
return ""; return "";
} }
// Aus den Gruppen werden Reiter. Wie sie aussehen und wie umgeschaltet $leiste = "<div class='rf-reiter' role='tablist'>";
// wird, steht in restricted/reiter.php - hier nur, was drinsteht. $flaechen = "";
$reiter = []; $erste = true;
foreach ($vorhanden as $form => $titel) { foreach ($vorhanden as $form => $titel) {
$leiste .= "<button type='button' role='tab' data-rf-reiter='" . $form . "'"
. " aria-selected='" . ($erste ? "true" : "false") . "' style='--c:" . $titel[2] . "'>"
. "<span class='rf-sym'>" . (strpos($titel[1], "<svg") === 0 ? $titel[1] : "<i class='bi " . $titel[1] . "'></i>") . "</span>"
. "<b>" . htmlspecialchars($titel[0]) . "</b>"
. "<span class='rf-st' data-rf-status='" . $form . "'></span></button>";
$inhalt = ""; $inhalt = "";
if (isset($fertig[$form])) { if (isset($fertig[$form])) {
$inhalt = $fertig[$form]; $inhalt = $fertig[$form];
} else { } else {
if ($form === "beschattung" && count($gruppen[$form]) > 1) {
$inhalt .= alleImRaum($gruppen[$form]);
}
if ($form === "schalter") {
$inhalt .= "<div class='rf-schalter-liste'>";
}
foreach ($gruppen[$form] as $d) { foreach ($gruppen[$form] as $d) {
$inhalt .= zeichneGeraet($d, $form); $inhalt .= zeichneGeraet($d, $form);
} }
if ($form === "schalter") {
$inhalt .= "</div>";
} }
$reiter[] = [
"id" => $form,
"titel" => $titel[0],
"symbol" => $titel[1],
"inhalt" => $inhalt,
];
} }
// "schmal", weil das Modal eng ist: kleinere Reiter, die waagerecht $flaechen .= "<div class='rf-flaeche' role='tabpanel' data-rf-flaeche='" . $form . "'"
// scrollen statt umzubrechen. . " style='--a:" . $titel[2] . "'" . ($erste ? "" : " hidden") . ">" . $inhalt . "</div>";
return reiterBlock($reiter, ["name" => "raumtab", "schmal" => true, $erste = false;
"klasse" => "mb-2"]); }
return $leiste . "</div>" . $flaechen;
} }
// =========================================================================== // ===========================================================================
// Bausteine // Bausteine
// =========================================================================== // ===========================================================================
/** Rahmen fuer Geraete ohne eigene Bedienform (Messwerte, Sonstiges). */
function karteAuf($d, $status = "") function karteAuf($d, $status = "")
{ {
$html = "<div class='card mb-2 raum-geraet' data-actor='" . intval($d["id"]) . "'>" $html = "<div class='rf-karte raum-geraet' data-actor='" . intval($d["id"]) . "'>"
. "<div class='card-body py-2'>" . "<div class='rf-kopf-zeile'><b>" . htmlspecialchars($d["label"]) . "</b>";
. "<div class='d-flex justify-content-between align-items-center gap-2 mb-2'>"
. "<span class='fw-semibold small text-truncate'>" . htmlspecialchars($d["label"]) . "</span>";
if ($status !== "") { if ($status !== "") {
$html .= "<span class='small text-body-secondary text-nowrap raum-status'>" . $status . "</span>"; $html .= "<span class='rf-pille'>" . $status . "</span>";
} }
return $html . "</div>"; return $html . "</div>";
} }
function karteZu() function karteZu()
{ {
return "</div></div>"; return "</div>";
}
/**
* 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 "<div class='raum-regler' data-cmd='" . intval($cmd["id"]) . "'"
. " data-param='" . intval($param["id"]) . "'>"
. "<div class='d-flex justify-content-between small'>"
. "<span>" . htmlspecialchars($titel) . "</span>"
. "<span class='raum-regler-wert fw-semibold'>" . $wert . $einheit . "</span></div>"
. reglerFeld($min, $max, $wert, $einheit)
. "</div>";
}
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 "<input type='range' class='form-range range-color-track' min='" . $min . "' max='" . $max . "'"
. " step='1' value='" . $wert . "' data-einheit='" . htmlspecialchars($einheit, ENT_QUOTES) . "'"
. ($beschriftung === "" ? ""
: " aria-label='" . htmlspecialchars($beschriftung, ENT_QUOTES) . "'")
. ">";
}
/**
* 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 "<div class='raum-regler raum-spalte' data-cmd='" . intval($cmd["id"]) . "'"
. " data-param='" . intval($param["id"]) . "' data-rolle='" . $rolle . "'" . $mit . ">"
. "<span class='raum-name'>" . htmlspecialchars($titel) . "</span>"
. "<span class='raum-ende'>" . htmlspecialchars($oben) . "</span>"
. "<div class='raum-senkrecht'>" . reglerFeld($min, $max, $wert, "%", $titel) . "</div>"
. "<span class='raum-ende'>" . htmlspecialchars($unten) . "</span>"
. "</div>";
}
/**
* 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 = "<svg class='raum-lamellen' viewBox='0 0 100 60' preserveAspectRatio='none' aria-hidden='true'>"
. "<defs><clipPath id='" . $schnitt . "'>"
. "<rect class='raum-abdeckung' x='0' y='0' width='100' height='60'></rect>"
. "</clipPath></defs>"
. "<g clip-path='url(#" . $schnitt . ")'>";
foreach ([5, 15, 25, 35, 45, 55] as $mitte) {
$html .= "<rect class='raum-lamelle' x='0' width='100' data-mitte='" . $mitte . "'"
. " y='" . ($mitte - 1) . "' height='2'></rect>";
}
return $html . "</g>"
. "<rect class='raum-endschiene' x='0' y='57' width='100' height='3'></rect>"
. "</svg>";
}
/** Kippschalter mit dem Stand, den das Geraet zuletzt gemeldet hat. */
function kippSchalter($d, $schalter, $an)
{
$id = "sw" . intval($d["id"]);
$html = "<div class='form-check form-switch raum-schalter'"
. " data-ein='" . (isset($schalter["ein"]) ? intval($schalter["ein"]["id"]) : "") . "'"
. " data-aus='" . (isset($schalter["aus"]) ? intval($schalter["aus"]["id"]) : "") . "'"
. " data-um='" . (isset($schalter["um"]) ? intval($schalter["um"]["id"]) : "") . "'>"
. "<input class='form-check-input' type='checkbox' role='switch' id='" . $id . "'"
. ($an ? " checked" : "") . ">"
. "<label class='form-check-label small' for='" . $id . "'>"
. ($an ? "An" : "Aus") . "</label></div>";
return $html;
} }
/** Ein "wahr"-artiger Wert - die Geraete melden mal True, mal 1, mal "on". */ /** 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, * Nur Kommandos ohne Parameter: ein Menuepunkt bietet nichts zum Einstellen,
* ein "Position+Neigung" von hier aus wuerde ohne Werte losgeschickt. Was * 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) function mehrMenue($cmds)
{ {
@@ -411,9 +289,8 @@ function mehrMenue($cmds)
if (!$cmds) { if (!$cmds) {
return ""; return "";
} }
$html = "<div class='dropdown'>" $html = "<div class='dropdown rf-mehr'>"
. "<button class='btn btn-sm btn-link text-body-secondary p-0' type='button'" . "<button class='rf-mehr-knopf' type='button' data-bs-toggle='dropdown' aria-label='Weitere Befehle'>"
. " data-bs-toggle='dropdown' aria-label='Weitere Befehle'>"
. "<i class='bi bi-three-dots'></i></button><ul class='dropdown-menu dropdown-menu-end'>"; . "<i class='bi bi-three-dots'></i></button><ul class='dropdown-menu dropdown-menu-end'>";
foreach ($cmds as $c) { foreach ($cmds as $c) {
$html .= "<li><button class='dropdown-item small' type='button' data-cmd='" . intval($c["id"]) . "'>" $html .= "<li><button class='dropdown-item small' type='button' data-cmd='" . intval($c["id"]) . "'>"
@@ -422,109 +299,145 @@ function mehrMenue($cmds)
return $html . "</ul></div>"; return $html . "</ul></div>";
} }
/** 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 = "<div class='rf-reihe'><span class='rf-reihe-titel'>" . htmlspecialchars($titel) . "</span>"
. "<div class='rf-segment' role='radiogroup' aria-label='" . htmlspecialchars($titel, ENT_QUOTES) . "'>";
foreach ($stufen as $s) {
$html .= "<button type='button' role='radio' aria-checked='false' data-" . $rolle . "='" . $s[0] . "'>"
. $s[1] . "</button>";
}
return $html . "</div></div>";
}
// =========================================================================== // ===========================================================================
// Die Bedienformen // Die Bedienformen
// =========================================================================== // ===========================================================================
/** /** Zahl aus einem Zustand, oder "" wenn es keinen gibt. */
* Der zuletzt gemeldete Stand in Worten. function zustandsZahl($d, $namen)
*
* "0 % zu" muss man erst uebersetzen, "offen" nicht. Die Prozentzahl steht
* ohnehin am Schieberegler darunter.
*/
function beschattungsStand($position, $neigung)
{ {
if ($position === null || $position === "") { $wert = zustandsWert($d, $namen);
return ""; return is_numeric($wert) ? strval(intval($wert)) : "";
} }
$p = intval($position);
$text = $p === 0 ? "offen" : ($p === 100 ? "geschlossen" : $p . " % zu"); /**
if ($neigung !== null && $neigung !== "" && $p > 0) { * "Alle im Raum": ein Knopf, viele Geraete. Das JavaScript geht die Karten
$text .= ", Neigung " . intval($neigung) . " %"; * 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);
} }
return htmlspecialchars($text); return "<div class='rf-alle'><h3>Alle im Raum</h3><div class='rf-chips'>"
. "<button type='button' class='rf-chip' data-alle='auf'><i class='bi bi-chevron-up'></i>Alle auf</button>"
. "<button type='button' class='rf-chip' data-alle='zu'><i class='bi bi-chevron-down'></i>Alle zu</button>"
. ($mitMy ? "<button type='button' class='rf-chip' data-alle='my'><i class='bi bi-star'></i>Alle auf my</button>" : "")
. ($mitNeigung ? "<button type='button' class='rf-chip' data-alle='sicht'><i class='bi bi-distribute-vertical'></i>Sichtschutz</button>" : "")
. "</div></div>";
} }
/** /**
* Rollladen, Jalousie, Markise, Garagentor. * Rollladen, Jalousie, Markise, Garagentor.
* *
* Auf/Stop/Zu gross zum Antippen, darunter die Feinheiten. Die Schieber * Links das Fenster, wie der Behang davor gerade steht - ziehen stellt die
* starten auf dem zuletzt gemeldeten Stand - so sieht man, wo das Geraet * Hoehe fein ein. Rechts gross Auf / Stop / my / Zu, darunter Hoehe und
* gerade steht, bevor man es bewegt. * 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) function zeichneBeschattung($d)
{ {
$auf = kommandoMitUrl($d, ["up"]); $auf = kommandoMitUrl($d, ["up"]);
$stop = kommandoMitUrl($d, ["stop"]); $stop = kommandoMitUrl($d, ["stop"]);
$zu = kommandoMitUrl($d, ["down"]); $zu = kommandoMitUrl($d, ["down"]);
$my = kommandoMitUrl($d, ["my"]);
$position = kommandoMitParameter($d, POSITION_PARAMETER); $position = kommandoMitParameter($d, POSITION_PARAMETER);
$neigung = kommandoMitParameter($d, NEIGUNG_PARAMETER); $neigung = kommandoMitParameter($d, NEIGUNG_PARAMETER);
$kombi = kombiKommando($d); $kombi = kombiKommando($d);
// 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 // Was das Geraet zuletzt gemeldet hat. Bei Somfy heisst die Lamelle
// "Lamellenausrichtung", die Hoehe schlicht "Position". // "Lamellenausrichtung", die Hoehe schlicht "Position".
$standPos = zustandsWert($d, ["Position"]); "pos" => zustandsZahl($d, ["Position"]),
$standNeigung = zustandsWert($d, ["Lamellenausrichtung"]); "neig" => $neigung ? zustandsZahl($d, ["Lamellenausrichtung"]) : "",
$status = beschattungsStand($standPos, $standNeigung); "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 $html = "<div class='rf-behang raum-geraet' data-actor='" . intval($d["id"]) . "'";
// Mehr-Menue nichts mehr verloren. foreach ($attr as $name => $wert) {
$benutzt = array_filter([$auf, $stop, $zu, $position, $neigung, $kombi]); $html .= " data-" . $name . "='" . htmlspecialchars(strval($wert), ENT_QUOTES) . "'";
$benutzteIds = array_map(function ($c) { return $c["id"]; }, $benutzt); }
$rest = array_values(array_filter($d["commands"], function ($c) use ($benutzteIds) { $html .= ">";
return !in_array($c["id"], $benutzteIds, true);
}));
$html = karteAuf($d, $status); // Das Bild: dunkles Glas, davor Lamellen (mit Neigung) oder ein Panzer.
$html .= "<div class='raum-jalousie'>"; // Mehr Lamellen als je sichtbar - die Hoehe schneidet ab.
$html .= "<div class='rf-fenster'" . ($hoehe ? " data-rf-zieh title='Ziehen zum Einstellen'" : "") . " aria-hidden='true'>"
. "<div class='rf-glas'><div class='rf-behang-teil'>"
. ($neigung ? "<div class='rf-lamellen'>" . str_repeat("<i></i>", 40) . "</div>" : "<div class='rf-panzer'></div>")
. "<div class='rf-schiene'></div></div></div></div>";
// Auf, Stop, Zu untereinander: so zeigen die Knoepfe in die Richtung, in $html .= "<div class='rf-behang-mitte'>"
// die sie fahren. . "<div class='rf-kopf-zeile'><b>" . htmlspecialchars($d["label"]) . "</b>"
if ($auf || $stop || $zu) { . "<span class='rf-pille' data-rf-stand></span>"
$html .= "<div class='btn-group-vertical btn-group-sm raum-fahrt' role='group'>"; . mehrMenue(restKommandos($d, [$auf, $stop, $zu, $my, $position, $neigung, $kombi])) . "</div>";
foreach ([[$auf, "bi-chevron-up", "Auf"], [$stop, "bi-stop-fill", "Stop"],
[$zu, "bi-chevron-down", "Zu"]] as $k) { if ($auf || $stop || $zu || $my) {
if (!$k[0]) { $html .= "<div class='rf-fahrt'>"
continue; . ($auf ? "<button type='button' data-rf-fahrt='auf'><i class='bi bi-chevron-up'></i>Auf</button>" : "<span></span>")
. ($stop ? "<button type='button' class='rf-stop' data-rf-fahrt='stop' aria-label='Stop'><i class='bi bi-stop-fill'></i></button>" : "")
. ($my ? "<button type='button' class='rf-my' data-rf-fahrt='my'><i class='bi bi-star-fill'></i>my</button>" : "")
. ($zu ? "<button type='button' data-rf-fahrt='zu'><i class='bi bi-chevron-down'></i>Zu</button>" : "<span></span>")
. "</div>";
} }
$html .= "<button type='button' class='btn btn-outline-primary' data-cmd='" . intval($k[0]["id"]) . "'" $prozent = function ($v) { return $v . "&#8201;%"; };
. " title='" . htmlspecialchars($k[2], ENT_QUOTES) . "'" if ($hoehe) {
. " aria-label='" . htmlspecialchars($k[2], ENT_QUOTES) . "'>" $html .= stufenReihe("Höhe", "rf-pos", [[0, "offen"], [25, $prozent(25)], [50, "halb"], [75, $prozent(75)], [100, "zu"]]);
. "<i class='bi " . $k[1] . "'></i></button>";
}
$html .= "</div>";
}
// 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"]);
}
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");
} }
if ($neigung) { if ($neigung) {
$html .= senkrechterRegler($neigung, $neigung["params"][0], $standNeigung, $html .= stufenReihe("Lamellen", "rf-neig", [[0, "offen"], [25, $prozent(25)], [50, "halb"], [75, $prozent(75)], [100, "zu"]]);
"Neigung", "auf", "zu", "neigung");
} }
$html .= mehrMenue($rest); // Die my-Position ist im Motor gespeichert und nur bei IO-Geraeten lesbar.
return $html . "</div>" . karteZu(); // Speichern geht ueber ajax/room.php?action=my, nicht ueber ein Kommando
// aus der Geraetedatenbank.
if ($attr["my-pos"] !== "" && $my) {
$html .= "<div class='rf-my-zeile' data-rf-my></div>";
}
return $html . "</div></div>";
} }
/** /**
* Lampe: Kippschalter, Helligkeit, Farbe, Effekt. * Lampe: die Birne ist der Schalter und leuchtet in ihrer Farbe. Darunter
* * Helligkeit, Farbfelder und - eingeklappt - Effekt und Preset.
* 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.
*/ */
function zeichneLicht($d) function zeichneLicht($d)
{ {
@@ -533,88 +446,105 @@ function zeichneLicht($d)
$helligkeit = kommandoMitParameter($d, "brightness"); $helligkeit = kommandoMitParameter($d, "brightness");
$farbe = farbKommando($d); $farbe = farbKommando($d);
$benutzteIds = []; // Der zuletzt gemeldete Farbwert steht als [r, g, b, w] in der Datenbank.
foreach ($schalter as $c) {
$benutzteIds[] = $c["id"];
}
foreach ([$helligkeit, $farbe] as $c) {
if ($c) {
$benutzteIds[] = $c["id"];
}
}
$html = karteAuf($d, "");
$html .= "<div class='d-flex justify-content-between align-items-center mb-2'>";
$html .= $schalter ? kippSchalter($d, $schalter, $an) : "<span></span>";
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 // Steht dort schwarz, leuchtet die Lampe ueber ihren Weisskanal - ein
// schwarzes Feld waere dann eine falsche Auskunft. // schwarzes Feld waere dann eine falsche Auskunft.
$hex = is_array($roh) && count($roh) >= 3 $roh = json_decode(strval(zustandsWert($d, ["Farbe", "color"])), true);
&& (intval($roh[0]) || intval($roh[1]) || intval($roh[2])) $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])) ? sprintf("#%02x%02x%02x", intval($roh[0]), intval($roh[1]), intval($roh[2]))
: "#ffffff"; : "#fff1d6";
$html .= "<span class='raum-farbe d-inline-flex align-items-center gap-1'"
. " data-cmd='" . intval($farbe["id"]) . "'"; $benutzt = array_values($schalter);
$benutzt[] = $helligkeit;
$benutzt[] = $farbe;
$html = "<div class='rf-lampe raum-geraet' data-actor='" . intval($d["id"]) . "'"
. " data-ein='" . (isset($schalter["ein"]) ? intval($schalter["ein"]["id"]) : "") . "'"
. " data-aus='" . (isset($schalter["aus"]) ? intval($schalter["aus"]["id"]) : "") . "'"
. " data-um='" . (isset($schalter["um"]) ? intval($schalter["um"]["id"]) : "") . "'"
. " data-an='" . ($an ? "1" : "") . "' style='--farbe:" . $hex . "'>";
$html .= "<div class='rf-lampe-kopf'>"
. "<button type='button' class='rf-birne' data-rf-birne aria-pressed='" . ($an ? "true" : "false") . "'"
. " aria-label='" . htmlspecialchars($d["label"], ENT_QUOTES) . " schalten'><i class='bi bi-lightbulb'></i></button>"
. "<div class='rf-lampe-name'><b>" . htmlspecialchars($d["label"]) . "</b><span data-rf-lampe-stand></span></div>";
// 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)) . "</div><div class='rf-lampe-regler'>";
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 .= "<input type='range' class='rf-hell' min='" . $min . "' max='" . $max . "' value='" . $wert . "'"
. " data-cmd='" . intval($helligkeit["id"]) . "' data-param='" . intval($p["id"]) . "'"
. " aria-label='Helligkeit'>";
}
if ($farbe) {
$html .= "<div class='rf-farben' data-cmd='" . intval($farbe["id"]) . "'";
foreach ($farbe["params"] as $p) { foreach ($farbe["params"] as $p) {
$html .= " data-" . strtolower($p["name"]) . "='" . intval($p["id"]) . "'"; $html .= " data-" . strtolower($p["name"]) . "='" . intval($p["id"]) . "'";
} }
$html .= "><label class='small text-body-secondary' for='col" . intval($d["id"]) . "'>Farbe</label>" $html .= ">";
. "<input type='color' class='form-control form-control-color form-control-sm'" foreach (["#ffd9a0" => "Warmweiß", "#fff1d6" => "Weiß", "#dfeeff" => "Kaltweiß", "#ff6b5b" => "Rot",
. " id='col" . intval($d["id"]) . "' value='" . $hex . "'></span>"; "#ffb347" => "Orange", "#7ddc6f" => "Grün", "#5bb8ff" => "Blau", "#b48cff" => "Lila"] as $f => $name) {
$html .= "<button type='button' class='rf-farbe' style='--f:" . $f . "' data-farbe='" . $f . "'"
. " aria-pressed='" . ($f === $hex ? "true" : "false") . "' aria-label='" . $name . "' title='" . $name . "'></button>";
}
$html .= "<label class='rf-farbe rf-farbe-eigen' title='Eigene Farbe'>"
. "<input type='color' value='" . $hex . "' aria-label='Eigene Farbe'></label></div>";
}
if ($listen) {
$html .= "<details class='rf-effekte'><summary>" . htmlspecialchars(implode(" & ", array_map(function ($c) { return $c["name"]; }, $listen))) . "</summary>";
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 .= "<div class='rf-chips' data-cmd='" . intval($c["id"]) . "' data-param='" . intval($p["id"]) . "'>";
foreach ($p["options"] as $o) {
$html .= "<button type='button' class='rf-chip' data-wert='" . htmlspecialchars($o["value"], ENT_QUOTES) . "'"
. " aria-pressed='false'>" . htmlspecialchars($o["label"]) . "</button>";
} }
$html .= "</div>"; $html .= "</div>";
} else {
if ($helligkeit) { $html .= "<label class='rf-auswahl'><span>" . htmlspecialchars($c["name"]) . "</span>"
$html .= reglerZeile("Helligkeit", $helligkeit, $helligkeit["params"][0], . "<select class='form-select form-select-sm' data-cmd='" . intval($c["id"]) . "' data-param='" . intval($p["id"]) . "'>";
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;
}
$benutzteIds[] = $c["id"];
$p = $c["params"][0];
$html .= "<div class='input-group input-group-sm mb-1 raum-cmd' data-cmdrow='" . intval($c["id"]) . "'>"
. "<span class='input-group-text'>" . htmlspecialchars($c["name"]) . "</span>"
. "<select class='form-select raum-sofort' data-param='" . intval($p["id"]) . "'"
. " data-cmd='" . intval($c["id"]) . "'>";
foreach ($p["options"] as $o) { foreach ($p["options"] as $o) {
$html .= "<option value='" . htmlspecialchars($o["value"], ENT_QUOTES) . "'>" $html .= "<option value='" . htmlspecialchars($o["value"], ENT_QUOTES) . "'>" . htmlspecialchars($o["label"]) . "</option>";
. htmlspecialchars($o["label"]) . "</option>";
} }
$html .= "</select></div>"; $html .= "</select></label>";
}
}
$html .= "</details>";
}
return $html . "</div></div>";
} }
$rest = array_values(array_filter($d["commands"], function ($c) use ($benutzteIds) { /** Alles, was nur an und aus kennt: eine Kachel zum Antippen. */
return !in_array($c["id"], $benutzteIds, true);
}));
if ($rest) {
$html .= "<div class='d-flex justify-content-end'>" . mehrMenue($rest) . "</div>";
}
return $html . karteZu();
}
/** Alles, was nur an und aus kennt. */
function zeichneSchalter($d) function zeichneSchalter($d)
{ {
$schalter = schaltKommandos($d); $schalter = schaltKommandos($d);
$an = istAn(zustandsWert($d, ["An", "ison", "on", "state", "Status"])); $an = istAn(zustandsWert($d, ["An", "ison", "on", "state", "Status"]));
$benutzteIds = array_map(function ($c) { return $c["id"]; }, array_values($schalter)); return "<button type='button' class='rf-schalter raum-geraet' data-actor='" . intval($d["id"]) . "'"
$rest = array_values(array_filter($d["commands"], function ($c) use ($benutzteIds) { . " data-ein='" . (isset($schalter["ein"]) ? intval($schalter["ein"]["id"]) : "") . "'"
return !in_array($c["id"], $benutzteIds, true); . " data-aus='" . (isset($schalter["aus"]) ? intval($schalter["aus"]["id"]) : "") . "'"
})); . " data-um='" . (isset($schalter["um"]) ? intval($schalter["um"]["id"]) : "") . "'"
. " aria-pressed='" . ($an ? "true" : "false") . "'>"
$html = karteAuf($d, ""); . "<span class='rf-sym'><i class='bi bi-power'></i></span>"
$html .= "<div class='d-flex justify-content-between align-items-center'>" . "<b>" . htmlspecialchars($d["label"]) . "</b><span class='rf-st'>" . ($an ? "an" : "aus") . "</span></button>";
. kippSchalter($d, $schalter, $an) . mehrMenue($rest) . "</div>";
return $html . karteZu();
} }
/** /**
@@ -636,9 +566,8 @@ function zeichneMesswerte($d)
} }
$html .= "</div>"; $html .= "</div>";
if ($weitere) { if ($weitere) {
$html .= "<details class='mt-2'><summary class='small text-body-secondary'>" $html .= "<details class='rf-effekte'><summary>" . count($weitere) . " weitere Werte</summary>"
. count($weitere) . " weitere Werte</summary>" . "<div class='raum-werte'>";
. "<div class='raum-werte mt-2'>";
foreach ($weitere as $s) { foreach ($weitere as $s) {
$html .= wertKachel($s); $html .= wertKachel($s);
} }