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 |
|---|---|---|---|---|
| `solar` (Vorgabe) | `solar.php` | `jahresstatistik.js`, `anlageKatalog.js`, `anlage.js`, `speicher.js`, `energieflussKatalog.js`, `energiefluss.js`, `bewaesserungAnzeige.js`, `ladefenster.js`, `bedienfenster.js`, `solarMQTT.js` | `speicher`, `getProdData`, `getConsData`, `getForecastData`, `getSunrise`, `getStats`, `carEG`, `carOG`, `heater`, `watering` | `solarManager/#`, `weatherStation/#`, `wattpilot/#`, `go-eCharger/#`, `Gartenwasser/#` |
| `home` | `home.php` | `autoActionFuncs.js`, `zeitleiste.js`, `homeMQTT.js` | `room.php`, `AutoAction.php` | die Topics der Kacheln, `Raumtemp/#` |
| `home` | `home.php` | `autoActionFuncs.js`, `zeitleiste.js`, `raumfenster.js`, `homeMQTT.js` | `room.php`, `AutoAction.php` | die Topics der Kacheln, `Raumtemp/#` |
| `heat` | `heat.php` | `heatMQTT.js` | `getHeaterData`, `getWaterData`, `getSunrise` | `solarManager/#`, `weatherStation/#`, Wallbox-Topics |
| `history` | `history.php` | `jahresstatistik.js`, `historyMQTT.js` | `energyHistory` (6 ×), `getStats` | — |
| `skoda` | `skoda.php` | `skodaMQTT.js` | `skoda.php?was=…`, `skodaCmd.php`, `tile.php` | `solarManager/#` |
@@ -296,7 +296,6 @@ eine Bibliothek es vorgibt.
|---|---|
| `powerToString(w)` | Leistung als `W` oder `kW`, dieselbe Rundung überall |
| `getData(chart, url, opt)` | Diagrammdaten holen, eintragen, alle 5 min auffrischen; optional Sonnenauf-/-untergang aus `getSunrise.php` dazu |
| `bindModalSlider()` | Schieberegler im Modal an ihre Anzeige koppeln |
| `mountMeteogram()`, `updateWeatherCards()` | Wetterteile der Solar- und Wetterseite |
| `reiterMerken()` | offener Reiter steht in der Adresse und übersteht ein Neuladen |
| `loadingHTML(text)` | der eine Ladezustand für alle Modals |
@@ -312,8 +311,17 @@ Text wird: Topic oder Wechselrichtersumme lesen, ggf. JSON-Pfad
herausgreifen, Vorzeichen drehen (`negativ`), skalieren (`format:
"leistung"` für Watt-Quellen, `"leistung_kw"` für Quellen, die schon
Kilowatt melden — die Wallboxen), runden (`stellen`), Einheit anhängen. Klick auf eine Kachel
öffnet das Raum-Modal über `ajax/room.php?room=<Raumnummer>`; Heizsymbol,
Regler und Ist-Werte lesen aus dem Thermostat-Zweig des Raums (`mqttZweig()`).
öffnet das Raum-Modal über `ajax/room.php?room=<Raumnummer>`; bedient wird es
von `raumfenster.js`. Heizsymbol und Ist-Werte lesen aus dem Thermostat-Zweig
des Raums (`mqttZweig()`).
**`raumfenster.js`** — das Raum-Modal im Stil von Lade-, Heiz- und
Bewässerungsfenster: Reiter-Kacheln mit Zustand, Beschattung mit
Auf/Stop/my/Zu, Höhe und Lamellen in 25-%-Stufen, Ziehen im Fensterbild und
„aktuellen Stand als my speichern“; Heizung als Ring mit ziehbarem Soll-Punkt;
Lampen mit Birne als Schalter, Farbfeldern und Effekten. Jede Wahl gilt
sofort. Jalousien werden nach einem Befehl bis zum Stillstand verfolgt
(`rfJalousieVerfolgen()`), die Heizwerte kommen über `raumfensterMqtt()` live.
**`solarMQTT.js`** — die Solar-Seite. Startet die Energiefluss-Übersicht
(`energieflussStarten()`) samt Anlage-Ansicht, füllt die Bewässerungszonen,
@@ -421,7 +429,7 @@ klein: Diagramme füllen bzw. Logzeilen nachladen und einfärben.
| Endpunkt | Aufruf | liefert / tut |
|---|---|---|
| `room.php` | `?room=<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`) |
| `settings.php` | `?action=list\|kacheln\|geraete\|grundriss\|skoda-keys`, `POST save\|kacheln-save\|geraete-raeume\|geraet-loeschen\|etage-speichern\|etagen-reihenfolge\|etage-loeschen\|etage-bild\|raum-speichern\|raum-position\|raum-loeschen\|skoda-keys-save` | Einstellungsseite; die Grundriss-Aktionen antworten mit dem ganzen neuen Stand, `skoda-keys` meldet nur, ob ein Schlüssel hinterlegt ist und worauf er endet |
| `getStats.php` | `GET` | Jahresstatistik, alle Jahre, mit Metadaten je Kennzahl |
@@ -480,7 +488,6 @@ flowchart LR
CMD["commands.php"] --> MYSQL
CMD --> TAH["tahoma_EG.php"]
CMD --> PHPMQTT["ajax/phpMQTT.php"]
RCTRL["roomControls.php"] --> REITER["reiter.php"]
HOME["home.php"] --> ROOMS
HOME --> REITER
SET["settings.php"] --> COSTS & KACH & REITER
@@ -641,9 +648,9 @@ sequenceDiagram
```
`roomControls.php` entscheidet **an den Fähigkeiten**, nicht am Typnamen: Hat
das Gerät ein `setClosure`, wird es ein Rollladen mit Auf/Stop/Zu und zwei
Reglern; hat es drei Parameter rot/grün/blau, wird es eine Lampe mit
Farbwähler. Ein neu gefundenes Gerät bekommt so ohne Codeänderung ein
das Gerät ein `setClosure`, wird es ein Rollladen mit Auf/Stop/Zu und
Höhenstufen, mit `my` kommt der my-Knopf dazu, mit Neigung die Lamellenstufen;
hat es drei Parameter rot/grün/blau, wird es eine Lampe mit Farbfeldern. Ein neu gefundenes Gerät bekommt so ohne Codeänderung ein
passendes Bedienelement.
`commands.php` ist das Gegenstück zu `transports.py` im SolarManager-Repo —
@@ -888,7 +895,7 @@ ausgeliefert.
| Neue Geräteart erkennen | neues Modul unter `restricted/deviceDiscovery/modules/` + Eintrag in der Liste in `device_discovery.py` |
| Neuer Geräteweg zum Schalten | `commands.php` (Browser) **und** `transports.py` im SolarManager-Repo (Runner) |
| Aussehen der ganzen Seite | `css/solar.css`, oberster Abschnitt: die `--ton-*`-Tokens und die Bootstrap-Zuordnung |
| Reiter irgendwo | `reiterLeiste()`/`reiterBlock()` aus `restricted/reiter.php` benutzen, keine eigene Variante bauen |
| Reiter irgendwo | `reiterLeiste()`/`reiterBlock()` aus `restricted/reiter.php` benutzen, keine eigene Variante bauen. Ausnahme: das Raum-Modal hat Reiter-Kacheln mit Zustand (`zeichneGruppen()`), passend zu den anderen Bedienfenstern |
| Neue Einstellung | Reiter in `$reiter` (`restricted/settings.php`), Abschnitt in `settings.js`, Aktion in `ajax/settings.php`, Modellfunktion daneben |
---
+79 -18
View File
@@ -12,6 +12,9 @@
* GET ?room=12 Inhalt des Modals, 12 = rooms.id
* POST ?action=command {"command_id": N, "params": {...}} - ein Kommando
* POST ?action=temp {"room": 12, "temp": 21} - Solltemperatur
* POST ?action=jalousie {"actor": 5} - Stand einer Jalousie
* POST ?action=my {"actor": 5} - aktuellen Stand als
* my-Position speichern
*
* Frueher hiess der Raum "EG_Bad" und wurde am Unterstrich zerlegt. Das hielt
* weder ein Leerzeichen im Namen aus (erst nachgeruestet) noch eine
@@ -161,6 +164,50 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
"faehrt" => ($zustaende["core:MovingState"] ?? null) === true,
"position" => $zahl("core:ClosureState"),
"neigung" => $zahl("core:SlateOrientationState"),
"myPosition" => $zahl("core:Memorized1PositionState"),
"myNeigung" => $zahl("core:Memorized1OrientationState"),
]);
}
// Den Stand, auf dem die Jalousie gerade steht, als my-Position in den
// Motor schreiben. Die Box kennt dafuer setMemorized1Position und bei
// Raffstores zusaetzlich setMemorized1Orientation; die Geraetesuche
// uebernimmt beide nicht als Kommando, deshalb geht es hier direkt.
// Gespeichert wird, was die Box meldet - nicht, was im Browser steht:
// der koennte einer Fahrt hinterherhinken.
if (($_GET["action"] ?? "") === "my") {
$url = tahomaAktorUrl(intval($body["actor"] ?? 0));
if ($url === null) {
raumAntwort(["error" => "Kein Tahoma-Gerät mit dieser Nummer."], 404);
}
$vorher = tahomaZustaende($url);
if ($vorher === null) {
raumAntwort(["error" => "Tahoma-Box antwortet nicht."], 503);
}
if (($vorher["core:MovingState"] ?? null) === true) {
raumAntwort(["error" => "Die Jalousie fährt noch bitte warten, bis sie steht."], 409);
}
if (!isset($vorher["core:Memorized1PositionState"]) || !is_numeric($vorher["core:ClosureState"] ?? null)) {
raumAntwort(["error" => "Dieses Gerät kann keine my-Position speichern."], 400);
}
$position = intval($vorher["core:ClosureState"]);
$neigung = null;
tahomaKommando($url, "setMemorized1Position", [$position]);
$text = "my-Position " . $position . "%";
if (isset($vorher["core:Memorized1OrientationState"]) && is_numeric($vorher["core:SlateOrientationState"] ?? null)) {
$neigung = intval($vorher["core:SlateOrientationState"]);
tahomaKommando($url, "setMemorized1Orientation", [$neigung]);
$text .= ", Lamellen " . $neigung . "%";
}
// Die Box braucht einen Moment, bis sie den neuen Wert meldet.
sleep(2);
$nachher = tahomaZustaende($url);
if ($nachher !== null) {
tahomaZustaendeSchreiben($url, $nachher);
}
raumAntwort([
"ok" => $text . " gespeichert",
"myPosition" => $position,
"myNeigung" => $neigung,
]);
}
raumAntwort(["error" => "Unbekannte Aktion."], 400);
@@ -210,34 +257,48 @@ foreach ($katalog as $d) {
// einer Geraeteliste: ein Regler fuer den Raum statt einer Karte je Geraet.
$fertig = [];
if ($hatThermostat) {
$karte = "<div class='card mb-2 raum-geraet'><div class='card-body py-2'>"
. "<div class='d-flex justify-content-between align-items-baseline'>"
. "<label class='form-label mb-0 small' for='modal-slider'>Solltemperatur</label>"
. "<span id='modal-slider-label' class='fw-semibold text-nowrap ms-2' style='color:#297195'></span>"
. "</div>"
. "<input type='range' id='modal-slider' class='form-range range-color-track' min='10' max='30'>"
// Der Ist-Zustand kommt live ueber MQTT, nicht aus der Datenbank -
// der Browser hoert ohnehin schon auf Raumtemp/#, und ein Wert von
// vorhin waere neben dem Regler nur irrefuehrend.
. "<div class='d-flex flex-wrap gap-3 small text-body-secondary'>"
. "<span><i class='bi bi-thermometer-half me-1'></i><span data-mqtt='Temp[degC]'></span> °C</span>"
. "<span><i class='bi bi-droplet me-1'></i><span data-mqtt='rHum[%]'></span> %rF</span>"
. "<span data-mqtt-heizung class='d-none'><i class='bi bi-fire text-danger me-1'></i>heizt</span>"
. "</div></div></div>";
$fertig["heizung"] = $karte;
// Ring mit Ist-Temperatur und ziehbarem Soll-Punkt, daneben - und + und
// die Schnellwahl. Alle Zahlen kommen live ueber MQTT (der Browser hoert
// ohnehin auf den Thermostat-Zweig) und werden von js/solar/raumfenster.js
// eingetragen. Das Thermostat nimmt ganze Grad von 10 bis 30 °C, siehe
// action=temp.
$fertig["heizung"] = "<div class='rf-heiz' data-rf-heiz-teil data-min='10' data-max='30'>"
. "<div class='rf-ring'><svg viewBox='0 0 170 170'>"
. "<defs><linearGradient id='rfHeizVerlauf' gradientUnits='userSpaceOnUse' gradientTransform='rotate(-135 85 85)' x1='15' y1='0' x2='155' y2='0'>"
. "<stop offset='0' stop-color='#4b9fd6'/><stop offset='.55' stop-color='#e8a33a'/><stop offset='1' stop-color='#e5624f'/>"
. "</linearGradient></defs>"
. "<circle class='rf-ring-spur' cx='85' cy='85' r='70' transform='rotate(135 85 85)'/>"
. "<circle class='rf-ring-ist' cx='85' cy='85' r='70' transform='rotate(135 85 85)'/>"
. "<circle class='rf-ring-griff' cx='85' cy='85' r='70' transform='rotate(135 85 85)' data-rf-ringzug/>"
. "<circle class='rf-ring-soll' cx='155' cy='85' r='10' data-rf-ringzug tabindex='0' role='slider'"
. " 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
// 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) . "'>";
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>";
}
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>";
+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"; }
}
/* Raum-Modal. Ein Kommando mit mehreren Parametern ("Position+Neigung")
traegt Beschriftung, zwei Zahlenfelder und den Ausloeseknopf in einer
input-group - auf einem Handy schrumpfen die Felder dabei auf Nullbreite.
Eine Mindestbreite laesst die Zeile stattdessen umbrechen. */
.input-group.raum-cmd > .form-control,
.input-group.raum-cmd > .form-select {
min-width: 5rem;
}
/* --------------------------------------------------------------------------
Raum-Modal: Bedienelemente
Erzeugt von restricted/roomControls.php, verdrahtet in js/solar/homeMQTT.js.
Raum-Modal (Klick auf einen Raum im Grundriss)
Markup: ajax/room.php + restricted/roomControls.php,
Verhalten: js/solar/raumfenster.js. Gleiche Bauart wie Lade-, Heiz- und
Bewaesserungsfenster (.lade): Akzentfarbe je Bereich, Stufen statt
Schieber, jede Wahl gilt sofort.
Alle Regeln tragen .rf davor: solar.css laedt vor AdminLTE, und die
Knoepfe hier setzen die Bootstrap-Gestaltung mit all: unset zurueck.
-------------------------------------------------------------------------- */
#modalEV .modal-dialog:has(.rf) { max-width: 42rem; }
#modalEV .modal-content:has(.rf) .modal-footer { display: none; }
#modalEV .modal-title .rf-etage { font-size: .7rem; font-weight: 600; padding: .05rem .5rem; border-radius: 99px; background: rgba(255, 255, 255, .07); color: var(--ton-schrift-2, #9aa6ad); vertical-align: middle; font-family: var(--bs-body-font-family); }
/* Die Geraetegruppen als Reiter. Auf einem Handy passen fuenf
Beschriftungen nicht nebeneinander - dann scrollt die Leiste seitwaerts,
statt die Namen umzubrechen. Die durchgehende Linie unter den Reitern
darf dabei nicht mitscrollen, sie gehoert an den unteren Rand. */
.rf { display: grid; gap: .75rem; padding: .9rem 1rem .8rem; --a: #60afbf; }
.rf h3 { margin: 0; font-size: .68rem; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--ton-schrift-2, #9aa6ad); }
.rf .rf-leer { margin: 0; color: var(--ton-schrift-2, #9aa6ad); }
.rf .rf-meldung { font-size: .76rem; color: var(--ton-schrift-3, #6f7a81); }
.rf .rf-meldung.gut { color: #6cbe58; }
.rf .rf-meldung.fehler { color: #e5624f; }
.rf .bi { line-height: 1; }
/* Waehrend ein Kommando unterwegs ist. Ein "Zu" an einer Jalousie dauert
ueber eine Minute; ohne sichtbare Sperre tippt man in der Zeit nach. */
.raum-geraet.raum-warte {
opacity: 0.55;
pointer-events: none;
}
.raum-geraet.raum-warte::after {
content: "";
position: absolute;
top: 0.5rem;
right: 0.5rem;
width: 0.9rem;
height: 0.9rem;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: raum-dreht 0.8s linear infinite;
}
.raum-geraet {
position: relative;
}
@keyframes raum-dreht {
to { transform: rotate(360deg); }
/* Waehrend ein Kommando unterwegs ist - Lampen, Schalter, Heizung. Jalousien
zeigen stattdessen "faehrt…" (.rf-bewegt). */
.rf .raum-geraet, .rf .rf-heiz { position: relative; }
.rf .raum-warte { opacity: .6; pointer-events: none; }
.rf .raum-warte::after {
content: ""; position: absolute; top: .55rem; right: .55rem; width: .85rem; height: .85rem;
border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%;
animation: raum-dreht .8s linear infinite;
}
@keyframes raum-dreht { to { transform: rotate(360deg); } }
/* --------------------------------------------------------------------------
Regler im Raum-Modal
/* Reiter als Kacheln mit Zustand */
.rf .rf-reiter { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: .4rem; overflow-x: auto; scrollbar-width: none; }
.rf .rf-reiter::-webkit-scrollbar { display: none; }
.rf .rf-reiter button {
all: unset; box-sizing: border-box; cursor: pointer; min-width: 6.5rem;
display: grid; grid-template-columns: auto minmax(0, 1fr); column-gap: .5rem; align-items: center;
padding: .45rem .6rem; border-radius: .75rem; background: var(--ton-flaeche-2, #2b3237); border: 1.5px solid transparent;
transition: background .2s, border-color .2s;
}
.rf .rf-reiter button:hover { background: #313940; }
.rf .rf-reiter button:focus-visible { outline: 2px solid var(--c); outline-offset: 1px; }
.rf .rf-reiter .rf-sym { grid-row: span 2; width: 1.9rem; height: 1.9rem; border-radius: .55rem; display: grid; place-items: center; background: rgba(255, 255, 255, .06); color: var(--c); font-size: 1rem; }
.rf .rf-reiter b { font-weight: 500; font-size: .84rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rf .rf-reiter .rf-st { font-size: .72rem; color: var(--ton-schrift-2, #9aa6ad); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-variant-numeric: tabular-nums; }
.rf .rf-reiter .rf-st:empty::before { content: "\00a0"; }
.rf .rf-reiter button[aria-selected="true"] { border-color: var(--c); background: color-mix(in srgb, var(--c) 11%, var(--ton-flaeche-2, #2b3237)); }
.rf .rf-reiter button[aria-selected="true"] .rf-sym { background: color-mix(in srgb, var(--c) 24%, transparent); }
.rf .rf-flaeche { display: grid; gap: .6rem; animation: rf-auf .25s cubic-bezier(.2, .7, .2, 1); }
.rf .rf-flaeche[hidden] { display: none; }
@keyframes rf-auf { from { opacity: 0; transform: translateY(4px); } }
Eigenes Aussehen statt des Bootstrap-Standards: dessen weisse Bahn mit dem
blauen Griff sticht auf den dunklen Karten heraus, und in einem Raum mit
drei Jalousien sind das sechs helle Balken. Bahn und Griff tragen deshalb
dasselbe Blaugruen wie der Temperaturregler, und der ungefuellte Teil ist
ein Grau, das in beiden Themes traegt.
/* Bausteine */
.rf .rf-chips { display: flex; flex-wrap: wrap; gap: .35rem; }
.rf .rf-chip { all: unset; box-sizing: border-box; cursor: pointer; padding: .32rem .7rem; border-radius: 99px; background: #363d43; font-size: .8rem; font-weight: 500; display: inline-flex; align-items: center; gap: .35rem; font-variant-numeric: tabular-nums; }
.rf .rf-chip:hover { background: #3f474e; }
.rf .rf-chip span { opacity: .7; }
.rf .rf-chip[aria-pressed="true"] { background: var(--a); color: #0c1a1d; font-weight: 600; }
.rf .rf-chip:focus-visible { outline: 2px solid var(--a); outline-offset: 1px; }
.rf .rf-pille { font-size: .7rem; font-weight: 600; border-radius: 99px; padding: .05rem .5rem; background: rgba(255, 255, 255, .06); color: var(--ton-schrift-2, #9aa6ad); white-space: nowrap; display: inline-flex; align-items: center; gap: .3rem; }
.rf .rf-pille:empty { display: none; }
.rf .rf-pille.faehrt { color: #60afbf; background: rgba(96, 175, 191, .15); }
.rf .rf-pille.faehrt::before { content: ""; width: .4rem; height: .4rem; border-radius: 50%; background: currentColor; animation: rf-puls 1.2s infinite; }
@keyframes rf-puls { 50% { opacity: .3; } }
.rf .rf-link { all: unset; cursor: pointer; color: #60afbf; font-weight: 500; margin-left: auto; }
.rf .rf-link:hover { text-decoration: underline; }
.rf .rf-link:focus-visible { outline: 2px solid #60afbf; outline-offset: 2px; border-radius: 3px; }
.rf .rf-kopf-zeile { display: flex; align-items: center; gap: .5rem; min-width: 0; }
.rf .rf-kopf-zeile > b { font-weight: 500; font-size: .92rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rf .rf-kopf-zeile .rf-pille { margin-left: auto; }
.rf .rf-mehr { flex: none; }
.rf .rf-kopf-zeile b + .rf-mehr, .rf .rf-kopf-zeile .rf-pille:empty + .rf-mehr, .rf .rf-lampe-name + .rf-mehr { margin-left: auto; }
.rf .rf-mehr-knopf { all: unset; cursor: pointer; width: 1.75rem; height: 1.75rem; display: grid; place-items: center; border-radius: .5rem; color: var(--ton-schrift-2, #9aa6ad); }
.rf .rf-mehr-knopf:hover { background: #363d43; }
.rf .rf-karte { display: grid; gap: .5rem; padding: .7rem .75rem; border-radius: .75rem; background: var(--ton-flaeche-2, #2b3237); }
.rf .rf-karte .btn { --bs-btn-padding-y: .2rem; }
.rf details.rf-effekte summary { cursor: pointer; font-size: .8rem; color: var(--ton-schrift-2, #9aa6ad); list-style: none; display: flex; align-items: center; gap: .35rem; }
.rf details.rf-effekte summary::-webkit-details-marker { display: none; }
.rf details.rf-effekte summary::after { content: ""; font-size: 1rem; transition: transform .2s; }
.rf details.rf-effekte[open] summary::after { transform: rotate(90deg); }
.rf details.rf-effekte[open] { display: grid; gap: .5rem; }
Bewusst nur innerhalb von #roomView: die Regler in den Modals fuer
Autoladung und Heizstab bleiben, wie sie sind.
-------------------------------------------------------------------------- */
#roomView input[type='range'] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 1.5rem;
padding: 0;
margin: 0;
background: transparent;
}
#roomView input[type='range']:focus {
outline: 0;
}
/* Getrennt notiert, nicht als Liste: einen Selektor, den er nicht kennt,
wirft der Browser samt der ganzen Regel weg - und dann bliebe die Bahn
ungestaltet. */
#roomView input[type='range']::-webkit-slider-runnable-track {
height: 0.375rem;
border: 0;
border-radius: 0.375rem;
background: linear-gradient(to right, #00788F, #00788F), rgba(128, 128, 128, 0.32);
background-size: var(--background-size, 0%) 100%;
background-repeat: no-repeat;
}
#roomView input[type='range']::-moz-range-track {
height: 0.375rem;
border: 0;
border-radius: 0.375rem;
background: linear-gradient(to right, #00788F, #00788F), rgba(128, 128, 128, 0.32);
background-size: var(--background-size, 0%) 100%;
background-repeat: no-repeat;
}
#roomView input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 1.05rem;
height: 1.05rem;
/* (Bahnhoehe - Griffhoehe) / 2, damit der Griff auf der Bahn sitzt. */
margin-top: -0.34rem;
border: 2px solid #fff;
border-radius: 50%;
background: #00788F;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.45);
}
#roomView input[type='range']::-moz-range-thumb {
width: 1.05rem;
height: 1.05rem;
border: 2px solid #fff;
border-radius: 50%;
background: #00788F;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.45);
}
/* Regler wie im Ladefenster */
.rf input[type="range"] { -webkit-appearance: none; appearance: none; width: 100%; height: 1.9rem; background: transparent; margin: 0; }
.rf input[type="range"]::-webkit-slider-runnable-track { height: .5rem; border-radius: .25rem; background: linear-gradient(90deg, var(--a) 0 var(--wert, 0%), #394046 var(--wert, 0%)); }
.rf input[type="range"]::-moz-range-track { height: .5rem; border-radius: .25rem; background: linear-gradient(90deg, var(--a) 0 var(--wert, 0%), #394046 var(--wert, 0%)); }
.rf input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 1.5rem; height: 1.5rem; margin-top: -.5rem; border-radius: 50%; background: #fff; border: 4px solid var(--a); box-shadow: 0 .15rem .5rem rgba(0, 0, 0, .4); }
.rf input[type="range"]::-moz-range-thumb { width: 1rem; height: 1rem; border-radius: 50%; background: #fff; border: 4px solid var(--a); }
.rf input[type="range"]:focus-visible { outline: 2px solid var(--a); outline-offset: 4px; }
/* Schieberegler stehen dicht untereinander (Helligkeit, Farbe). Ohne den
Abstand kleben Beschriftung und Bahn aneinander.
Die senkrechten Spalten stehen dagegen nebeneinander - dort wuerde der
Abstand die zweite Spalte um genau diese 0,35 rem tiefer setzen. */
.raum-regler + .raum-regler:not(.raum-spalte) {
margin-top: 0.35rem;
}
/* --- Beschattung --- */
.rf .rf-alle { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem .75rem; padding: .6rem .7rem; border-radius: .75rem; background: var(--ton-flaeche-2, #2b3237); }
.rf .rf-alle h3 { margin-right: auto; }
.rf .rf-alle .bi { font-size: .8rem; }
/* Messwerte als Gitter statt als Textwurst: ein Stromzaehler hat sechzehn
Werte, die in einer Zeile niemand findet. auto-fill statt fester Spalten,
damit auf dem Handy zwei und auf dem Schirm vier nebeneinander passen. */
.raum-werte {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(6.5rem, 1fr));
gap: 0.4rem;
}
.raum-wert {
background: rgba(128, 128, 128, 0.08);
border-radius: 0.35rem;
padding: 0.3rem 0.45rem;
overflow: hidden;
}
.raum-wert-zahl {
font-size: 0.95rem;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.raum-wert-einheit {
font-size: 0.7rem;
font-weight: 400;
opacity: 0.6;
}
.raum-wert-name {
font-size: 0.68rem;
opacity: 0.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rf .rf-behang { display: grid; grid-template-columns: 4.75rem minmax(0, 1fr); gap: .85rem; padding: .7rem; border-radius: .75rem; background: var(--ton-flaeche-2, #2b3237); border: 1.5px solid transparent; transition: border-color .3s; }
.rf .rf-behang.rf-bewegt { border-color: rgba(96, 175, 191, .6); }
.rf .rf-behang-mitte { display: grid; gap: .5rem; min-width: 0; align-content: start; }
/* Das Fenster: dunkles Glas mit Tageslicht-Schimmer, helle Lamellen, die
Unterleiste im Akzent. Ueber die ganze Hoehe der Karte. */
.rf .rf-fenster { position: relative; min-height: 6rem; padding: .25rem; border-radius: .75rem; background: #1c2226; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .07); touch-action: none; }
.rf .rf-fenster[data-rf-zieh] { cursor: ns-resize; }
.rf .rf-glas { position: relative; height: 100%; border-radius: .5rem; overflow: hidden;
background: radial-gradient(120% 55% at 80% 0%, rgba(126, 214, 230, .5), transparent 60%), linear-gradient(170deg, #1f4c5a 0%, #173640 55%, #122a31 100%); }
.rf .rf-glas::after { content: ""; position: absolute; inset: 0; background: linear-gradient(115deg, transparent 38%, rgba(255, 255, 255, .09) 44%, transparent 52%); pointer-events: none; }
.rf .rf-behang-teil { position: absolute; left: 0; right: 0; top: 0; height: 0; overflow: hidden; transition: height .35s cubic-bezier(.2, .7, .2, 1); }
.rf .rf-lamellen { position: absolute; left: 0; right: 0; bottom: 3px; display: grid; gap: 1.5px; }
.rf .rf-lamellen i { display: block; height: 5px; border-radius: 3px; background: linear-gradient(180deg, #e9f1f3, #b9c9cf); transform: scaleY(var(--kipp, 1)); box-shadow: 0 1px 0 rgba(0, 0, 0, .25); transition: transform .35s; }
.rf .rf-panzer { position: absolute; inset: 0 0 3px; background: repeating-linear-gradient(0deg, #d5e0e4 0 5px, #a9bac0 5px 6.5px); }
.rf .rf-schiene { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; border-radius: 2px; background: #60afbf; box-shadow: 0 0 8px rgba(96, 175, 191, .7); }
/* --------------------------------------------------------------------------
Raum-Modal: Jalousien
-------------------------------------------------------------------------- */
.rf .rf-fahrt { display: grid; grid-template-columns: 1fr auto auto 1fr; gap: .35rem; }
.rf .rf-fahrt button { all: unset; box-sizing: border-box; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: .35rem; height: 2.4rem; padding: 0 .6rem; border-radius: .6rem; background: #363d43; font-weight: 600; font-size: .86rem; }
.rf .rf-fahrt button:hover { background: #3f474e; }
.rf .rf-fahrt button:active { transform: scale(.97); }
.rf .rf-fahrt button:focus-visible { outline: 2px solid #60afbf; outline-offset: 1px; }
.rf .rf-fahrt .rf-stop { width: 2.75rem; }
.rf .rf-fahrt .rf-my .bi { color: #f7c000; font-size: .8rem; }
.rf .rf-fahrt .rf-my.aktiv { background: color-mix(in srgb, #f7c000 16%, #363d43); box-shadow: inset 0 0 0 1.5px #f7c000; }
/* Fahrknoepfe, Hoehe und Neigung nebeneinander. Die Neigung nimmt den Rest -
sie ist die einzige der drei Spalten, die von Breite profitiert. */
.raum-jalousie {
display: flex;
align-items: stretch;
justify-content: space-between;
gap: 0.4rem;
}
/* Das Fensterbild steht zwischen Knoepfen und Reglern - schmal, mit Luft
zu beiden Seiten. Der uebrige Platz verteilt sich auf die Zwischenraeume
(space-between an .raum-jalousie), statt das Bild breitzuziehen. */
.raum-jalousie .raum-lamellen {
flex: 0 1 5rem;
min-width: 2.2rem;
margin: 0 0.4rem;
}
.raum-jalousie .dropdown {
align-self: flex-start;
}
.rf .rf-reihe { display: grid; grid-template-columns: 4.3rem minmax(0, 1fr); align-items: center; gap: .5rem; }
.rf .rf-reihe-titel { font-size: .76rem; color: var(--ton-schrift-2, #9aa6ad); }
.rf .rf-segment { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 3px; padding: 3px; border-radius: .6rem; background: #22292d; }
.rf .rf-segment button { all: unset; box-sizing: border-box; cursor: pointer; text-align: center; padding: .3rem .1rem; border-radius: .45rem; font-size: .76rem; font-weight: 500; color: var(--ton-schrift-2, #9aa6ad); font-variant-numeric: tabular-nums; white-space: nowrap; }
.rf .rf-segment button:hover { color: var(--ton-schrift, #e8ede9); }
.rf .rf-segment button[aria-checked="true"] { background: #363d43; color: var(--ton-schrift, #e8ede9); box-shadow: inset 0 0 0 1.5px var(--a); }
.rf .rf-segment button:focus-visible { outline: 2px solid var(--a); }
/* Auf, Stop, Zu untereinander - die Knoepfe zeigen in die Richtung, in die
sie fahren. Breit genug fuer einen Daumen: sie sind das, was man im
Vorbeigehen antippt, die Regler daneben braucht man seltener. */
.raum-fahrt {
flex: 0 0 3.1rem;
}
.raum-fahrt .btn {
padding: 0.3rem 0;
line-height: 1.5;
}
.rf .rf-my-zeile { display: flex; flex-wrap: wrap; align-items: center; gap: .25rem .5rem; font-size: .76rem; color: var(--ton-schrift-3, #6f7a81); min-height: 1.5rem; }
.rf .rf-my-zeile > .bi { color: #f7c000; font-size: .72rem; }
.rf .rf-my-zeile.frage { padding: .35rem .5rem; border-radius: .55rem; background: rgba(247, 192, 0, .08); color: var(--ton-schrift, #e8ede9); }
.rf .rf-my-knoepfe { display: inline-flex; gap: .3rem; margin-left: auto; }
.rf .rf-my-zeile .rf-chip { padding: .2rem .6rem; font-size: .76rem; }
.rf .rf-my-zeile .rf-chip.ja { background: #f7c000; color: #1a1406; font-weight: 600; }
/* Eine Spalte mit senkrechtem Regler: Name, oberes Wort, Bahn, unteres
Wort, Zahl. Breit genug fuer "Position" in der kleinen Schrift. */
.raum-spalte {
flex: 0 0 3.4rem;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.raum-name {
font-size: 0.62rem;
opacity: 0.8;
white-space: nowrap;
line-height: 1.2;
}
/* --- Heizung --- */
.rf .rf-heiz { display: grid; grid-template-columns: 10.5rem minmax(0, 1fr); gap: 1rem; align-items: center; --a: #e5624f; }
.rf .rf-ring { position: relative; width: 10.5rem; aspect-ratio: 1; max-width: 100%; }
.rf .rf-ring svg { width: 100%; height: 100%; display: block; touch-action: none; }
.rf .rf-ring-spur { fill: none; stroke: #2b3237; stroke-width: 12; stroke-linecap: round; }
.rf .rf-ring-ist { fill: none; stroke: url(#rfHeizVerlauf); stroke-width: 12; stroke-linecap: round; transition: stroke-dasharray .6s cubic-bezier(.2, .7, .2, 1); }
.rf .rf-ring-griff { fill: none; stroke: transparent; stroke-width: 36; cursor: pointer; }
.rf .rf-ring-soll { fill: #fff; stroke: #e5624f; stroke-width: 4; cursor: grab; transform-box: view-box; transform-origin: 85px 85px; transition: transform .35s cubic-bezier(.2, .7, .2, 1); }
.rf .rf-heiz.zieht .rf-ring-soll { cursor: grabbing; transition: none; }
.rf .rf-ring-soll:focus-visible { outline: none; stroke: #fff; fill: #e5624f; }
.rf .rf-ring-mitte { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; pointer-events: none; }
.rf .rf-ring-mitte small { font-size: .7rem; color: var(--ton-schrift-2, #9aa6ad); }
.rf .rf-ring-mitte b { font-size: 1.9rem; font-weight: 600; line-height: 1.05; font-variant-numeric: tabular-nums; }
.rf .rf-ring-mitte b small { font-size: .95rem; font-weight: 500; color: var(--ton-schrift, #e8ede9); }
.rf .rf-ring-mitte span { font-size: .74rem; color: var(--ton-schrift-2, #9aa6ad); }
.rf .rf-heiz-rechts { display: grid; gap: .6rem; min-width: 0; }
.rf .rf-stepper { display: grid; grid-template-columns: 3rem minmax(0, 1fr) 3rem; align-items: center; gap: .5rem; background: var(--ton-flaeche-2, #2b3237); border-radius: .85rem; padding: .35rem; }
.rf .rf-stepper button { all: unset; cursor: pointer; height: 3rem; border-radius: .6rem; display: grid; place-items: center; background: #363d43; font-size: 1.4rem; font-weight: 500; }
.rf .rf-stepper button:hover { background: #3f474e; }
.rf .rf-stepper button:active { transform: scale(.95); }
.rf .rf-stepper button:focus-visible { outline: 2px solid #e5624f; }
.rf .rf-stepper output { text-align: center; font-size: 1.55rem; font-weight: 600; font-variant-numeric: tabular-nums; line-height: 1.15; }
.rf .rf-stepper output small { display: block; font-size: .7rem; font-weight: 400; color: var(--ton-schrift-2, #9aa6ad); }
.rf .rf-hinweis { padding: .45rem .65rem; border-radius: .6rem; background: rgba(255, 255, 255, .05); color: var(--ton-schrift-2, #9aa6ad); font-size: .8rem; display: flex; gap: .45rem; align-items: flex-start; }
.rf .rf-hinweis[hidden] { display: none; }
.rf .rf-hinweis.an { background: rgba(229, 98, 79, .12); color: #f3b3a9; }
.rf .rf-hinweis .bi { margin-top: .15rem; }
.rf .rf-pillen { display: flex; flex-wrap: wrap; gap: .35rem; }
.rf .rf-pillen .rf-pille { font-size: .74rem; padding: .15rem .6rem; }
.rf .rf-pillen .rf-pille[hidden] { display: none; }
/* Der Kasten, in dem ein gewoehnlicher Regler auf der Seite liegt.
Gedreht statt writing-mode: so gebaute Regler lassen sich am Handy kaum
anfassen - das Ziehen wird als Wischen gelesen und blaettert die Seite.
Gedreht bewegt sich der Regler in seinen eigenen Koordinaten waagerecht,
und touch-action haelt die Seite still, solange der Finger auf ihm liegt.
rotate(90deg) legt den kleinen Wert nach oben: "oben" ist 0 %. */
.raum-senkrecht {
position: relative;
width: 1.6rem;
height: 6.5rem;
margin: 0.1rem 0;
}
#roomView .raum-senkrecht input[type='range'] {
position: absolute;
top: 50%;
left: 50%;
width: 6.5rem; /* = Hoehe des Kastens, die Laenge der Bahn */
height: 1.6rem; /* = Breite des Kastens, die Flaeche zum Treffen */
transform: translate(-50%, -50%) rotate(90deg);
touch-action: none;
}
/* --- Licht --- */
.rf .rf-lampe { display: grid; gap: .55rem; padding: .7rem; border-radius: .75rem; background: var(--ton-flaeche-2, #2b3237); --a: #f7c000; }
.rf .rf-lampe-kopf { display: flex; align-items: center; gap: .7rem; }
.rf .rf-birne { all: unset; cursor: pointer; flex: none; width: 3rem; height: 3rem; border-radius: .9rem; display: grid; place-items: center; background: #22292d; color: var(--ton-schrift-3, #6f7a81); font-size: 1.35rem; transition: background .3s, box-shadow .3s, color .3s; }
.rf .rf-birne[aria-pressed="true"] { color: #1a1406; background: var(--farbe, #ffd27a); box-shadow: 0 0 0 4px color-mix(in srgb, var(--farbe, #ffd27a) 22%, transparent), 0 .35rem 1.3rem color-mix(in srgb, var(--farbe, #ffd27a) 45%, transparent); }
.rf .rf-birne:focus-visible { outline: 2px solid #f7c000; outline-offset: 3px; }
.rf .rf-lampe-name { display: grid; min-width: 0; }
.rf .rf-lampe-name b { font-weight: 500; font-size: .92rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rf .rf-lampe-name span { font-size: .76rem; color: var(--ton-schrift-2, #9aa6ad); font-variant-numeric: tabular-nums; }
.rf .rf-lampe-regler { display: grid; gap: .5rem; transition: opacity .3s; }
.rf .rf-lampe.aus .rf-lampe-regler { opacity: .5; }
.rf .rf-hell::-webkit-slider-runnable-track { background: linear-gradient(90deg, #4a3f22 0, var(--farbe, #ffd27a) var(--wert, 0%), #394046 var(--wert, 0%)) !important; }
.rf .rf-hell::-moz-range-track { background: linear-gradient(90deg, #4a3f22 0, var(--farbe, #ffd27a) var(--wert, 0%), #394046 var(--wert, 0%)) !important; }
.rf .rf-farben { display: flex; flex-wrap: wrap; gap: .45rem; align-items: center; }
.rf .rf-farbe { all: unset; box-sizing: border-box; cursor: pointer; width: 1.65rem; height: 1.65rem; border-radius: 50%; background: var(--f); box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .25); transition: transform .15s; position: relative; }
.rf .rf-farbe:hover { transform: scale(1.1); }
.rf .rf-farbe[aria-pressed="true"] { box-shadow: 0 0 0 2px var(--ton-flaeche-2, #2b3237), 0 0 0 4px var(--f); }
.rf .rf-farbe:focus-visible, .rf .rf-farbe-eigen:focus-within { outline: 2px solid #f7c000; outline-offset: 3px; }
.rf .rf-farbe-eigen { background: conic-gradient(#f55, #fd5, #5f7, #5df, #75f, #f5d, #f55); }
.rf .rf-farbe-eigen input { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%; }
.rf .rf-auswahl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: .5rem; align-items: center; font-size: .78rem; color: var(--ton-schrift-2, #9aa6ad); margin: 0; }
/* Die Endstellungen in Worten statt in Prozent. */
.raum-ende {
font-size: 0.68rem;
opacity: 0.65;
line-height: 1.1;
}
/* --- Schalter --- */
.rf .rf-schalter-liste { display: grid; grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); gap: .5rem; }
.rf .rf-schalter { all: unset; box-sizing: border-box; cursor: pointer; display: grid; grid-template-columns: auto minmax(0, 1fr); column-gap: .6rem; align-items: center; padding: .6rem; border-radius: .75rem; background: var(--ton-flaeche-2, #2b3237); border: 1.5px solid transparent; position: relative; }
.rf .rf-schalter .rf-sym { grid-row: span 2; width: 2.1rem; height: 2.1rem; border-radius: .6rem; display: grid; place-items: center; background: #22292d; color: var(--ton-schrift-3, #6f7a81); }
.rf .rf-schalter b { font-weight: 500; font-size: .84rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rf .rf-schalter .rf-st { font-size: .72rem; color: var(--ton-schrift-2, #9aa6ad); }
.rf .rf-schalter[aria-pressed="true"] { border-color: #6cbe58; }
.rf .rf-schalter[aria-pressed="true"] .rf-sym { background: rgba(108, 190, 88, .18); color: #6cbe58; }
.rf .rf-schalter:focus-visible { outline: 2px solid #6cbe58; }
/* Die Lamellen zur Neigung. Ihre Hoehe setzt lamellenZeichnen() in
js/solar/homeMQTT.js - schmal mit Luft dazwischen heisst offen,
aneinanderliegend heisst geschlossen. */
.raum-lamellen {
display: block;
align-self: center;
height: 6.9rem;
/* Der Rahmen macht aus dem Streifen ein Fenster - ohne ihn liest sich das
geschlossene Bild wie eine zweite Reglerbahn. */
background: rgba(0, 120, 143, 0.14);
border: 1px solid rgba(128, 128, 128, 0.35);
border-radius: 0.2rem;
}
.raum-lamelle {
fill: currentColor;
opacity: 0.6;
stroke: rgba(0, 0, 0, 0.35);
stroke-width: 0.6;
vector-effect: non-scaling-stroke;
}
.raum-endschiene {
fill: currentColor;
opacity: 0.85;
/* --- Messwerte und Sonstiges --- */
.rf .raum-werte { display: grid; grid-template-columns: repeat(auto-fill, minmax(6.5rem, 1fr)); gap: .4rem; }
.rf .raum-wert { background: rgba(255, 255, 255, .04); border-radius: .5rem; padding: .3rem .5rem; overflow: hidden; }
.rf .raum-wert-zahl { font-size: .95rem; font-weight: 600; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rf .raum-wert-einheit { font-size: .7rem; font-weight: 400; opacity: .6; }
.rf .raum-wert-name { font-size: .68rem; opacity: .6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Ein Kommando mit mehreren Parametern traegt Beschriftung, Felder und Knopf
in einer input-group - ohne Mindestbreite schrumpfen die Felder am Handy
auf nichts. */
.rf .input-group.raum-cmd > .form-control,
.rf .input-group.raum-cmd > .form-select { min-width: 5rem; }
/* --- Handy --- */
@media (max-width: 575.98px) {
.rf { padding: .75rem .75rem .7rem; }
.rf .rf-reiter button { grid-template-columns: 1fr; justify-items: center; text-align: center; padding: .4rem .25rem; row-gap: .1rem; min-width: 5.5rem; }
.rf .rf-reiter .rf-sym { grid-row: auto; width: 1.6rem; height: 1.6rem; }
.rf .rf-reiter b { font-size: .76rem; }
.rf .rf-reiter .rf-st { font-size: .68rem; }
.rf .rf-behang { grid-template-columns: 3.6rem minmax(0, 1fr); gap: .6rem; padding: .6rem; }
/* Ueber die ganze Kartenhoehe waere das Fenster hier ein Strich - also ein
festes Seitenverhaeltnis, oben ausgerichtet. */
.rf .rf-fenster { align-self: start; height: auto; aspect-ratio: 9 / 16; min-height: 0; }
.rf .rf-behang .rf-kopf-zeile { flex-wrap: wrap; row-gap: .15rem; }
.rf .rf-behang .rf-kopf-zeile > b { flex: 1 1 calc(100% - 2.5rem); }
.rf .rf-behang .rf-kopf-zeile .rf-pille { order: 3; margin-left: 0; }
.rf .rf-fahrt button { padding: 0 .4rem; }
.rf .rf-reihe { grid-template-columns: 1fr; gap: .2rem; }
.rf .rf-heiz { grid-template-columns: 1fr; justify-items: center; }
.rf .rf-heiz-rechts { width: 100%; }
}
/* --- Einstellungen: Preistabellen ------------------------------------ */
+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",
"js/solar/energieflussKatalog.js", "js/solar/energiefluss.js", "js/solar/bewaesserungAnzeige.js", "js/solar/ladefenster.js", "js/solar/bedienfenster.js", "js/solar/solarMQTT.js"]],
"home" => ["template" => "home.php", "mqtt" => true, "charts" => false, "meteogram" => false, "leaflet" => false,
"scripts" => ["js/solar/autoActionFuncs.js", "js/solar/zeitleiste.js", "js/solar/homeMQTT.js"]],
"scripts" => ["js/solar/autoActionFuncs.js", "js/solar/zeitleiste.js", "js/solar/raumfenster.js", "js/solar/homeMQTT.js"]],
"heat" => ["template" => "heat.php", "mqtt" => true, "charts" => true, "meteogram" => false, "leaflet" => false,
"scripts" => ["js/solar/heatMQTT.js"]],
"history" => ["template" => "history.php", "mqtt" => false, "charts" => true, "meteogram" => false, "leaflet" => false,
-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. */
// Mindestbreite, in der das Meteogramm von meteoblue geladen wird. Der
// 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
// bekommt die gleiche Behandlung - Raeume und ihre Werte kommen dort
// dazu, nicht hier.
if (typeof raumfensterMqtt === "function") raumfensterMqtt();
homeRooms.forEach(function (room) {
const el = (part) => document.getElementById(room.floor + "_" + part);
@@ -318,10 +319,16 @@ function switchFloor(floor){
// Ersetzung bleibt fuer die alten Namen ("Kueche"), bis sie jemand
// unter Einstellungen -> Grundriss umbenennt.
const titel = eintrag
? eintrag.floor + " · " + eintrag.mqtt.replace("ue", "ü").replace("ae", "ä").replace("oe", "ö")
? eintrag.mqtt.replace("ue", "ü").replace("ae", "ä").replace("oe", "ö")
: "Raum";
const modalTitel = document.getElementById("modal-title");
modalTitel.textContent = titel;
if (eintrag) {
const etage = document.createElement("span");
etage.className = "rf-etage";
etage.textContent = eintrag.floor;
modalTitel.append(" ", etage);
}
// Der Speichern-Knopf gehoert allen Modals gemeinsam. Ein Klon ohne
// Zuhoerer stellt sicher, dass nicht der Handler eines vorher
@@ -346,414 +353,8 @@ function switchFloor(floor){
.then(a => a.text())
.then(html => {
koerper.innerHTML = html;
const slider = document.getElementById("modal-slider");
if (slider) {
bindModalSlider(slider, document.getElementById("modal-slider-label"),
wert => wert + " °C");
const stand = mqttZweig(eintrag?.thermostat)?.["Set Temp[degC]"];
slider.value = typeof stand !== "undefined" ? stand : 10;
// Nur "input": das zeichnet die Anzeige, schickt aber nichts.
slider.dispatchEvent(new Event("input"));
// "change" kommt beim Loslassen - wie bei den Rollladenreglern.
slider.addEventListener("change", () => sendeSolltemperatur(raum));
}
raumBedienungAktivieren(koerper);
// Jede Jalousie im Raum einmal an der Box nachfragen. Der Wert im
// Modal kommt aus actor_states und ist damit hoechstens so frisch
// wie der letzte Poll des Runners - und wer das Modal waehrend einer
// Fahrt aufmacht, saehe sonst den Stand von davor und nie wieder
// etwas anderes: gezeichnet wird nur beim Oeffnen.
koerper.querySelectorAll(".raum-geraet").forEach(k => jalousieVerfolgen(k, null));
raumMesswerteAktualisieren(eintrag?.thermostat);
// Verdrahten, zeichnen, Jalousien nachfragen: js/solar/raumfenster.js.
raumfensterStarten(koerper.querySelector("#roomView") || koerper, raum, eintrag?.thermostat);
})
.catch(fehler => { koerper.innerHTML = "Konnte den Raum nicht laden: " + fehler.message; });
}
/**
* Die Bedienelemente des Raum-Modals mit ihren Kommandos verbinden.
*
* Welches Geraet welches Element bekommt, entscheidet der Server
* (restricted/roomControls.php); hier haengen nur die Zuhoerer an den
* data-Attributen, die er dabei setzt.
*/
function raumBedienungAktivieren(koerper) {
koerper.querySelectorAll("button[data-cmd]").forEach(k => {
k.addEventListener("click", () => schalte(k));
});
// Schieberegler: waehrend des Ziehens nur die Anzeige, geschickt wird
// erst beim Loslassen. Sonst bekaeme eine Jalousie bei jedem Pixel ein
// neues Ziel und kaeme aus dem Anfahren nicht mehr heraus.
koerper.querySelectorAll(".raum-jalousie").forEach(lamellenZeichnen);
koerper.querySelectorAll(".raum-regler").forEach(regler => {
const schieber = regler.querySelector("input[type=range]");
reglerAnzeigen(regler);
schieber.addEventListener("input", () => reglerAnzeigen(regler));
schieber.addEventListener("change", () => {
const params = {};
params[regler.dataset.param] = schieber.value;
// Der Hoehenregler nimmt die Neigung mit: eine Hoehenfahrt rastet die
// Lamellen um, und der Server faehrt sie nur dann wieder an, wenn der
// Befehl ein Neigungsziel dabei hat (Kugelschreiber-Mechanik, siehe
// sendeTahoma() in restricted/commands.php). Welcher Parameter das
// ist, sagt der Server ueber data-mit-param.
if (regler.dataset.mitParam) {
const daneben = regler.closest(".raum-jalousie")
.querySelector("[data-rolle='" + regler.dataset.mitRolle + "'] input[type=range]");
if (daneben) params[regler.dataset.mitParam] = daneben.value;
}
raumKommando(Number(regler.dataset.cmd), params, regler);
});
});
// Kippschalter: An und Aus sind zwei getrennte Kommandos. Kennt das
// Geraet nur "umschalten", tut es auch das - dann stimmt die Stellung
// hinterher, weil wir sie selbst gesetzt haben.
koerper.querySelectorAll(".raum-schalter").forEach(schalter => {
const kasten = schalter.querySelector("input[type=checkbox]");
const beschriftung = schalter.querySelector("label");
kasten.addEventListener("change", () => {
const ziel = kasten.checked ? schalter.dataset.ein : schalter.dataset.aus;
const cmd = ziel || schalter.dataset.um;
beschriftung.textContent = kasten.checked ? "An" : "Aus";
if (!cmd) return;
raumKommando(Number(cmd), {}, schalter);
});
});
// Farbe: der Browser bringt seinen Farbwaehler mit, das Geraet will drei
// Zahlen 0-255. Zerlegt wird deshalb erst hier.
koerper.querySelectorAll(".raum-farbe").forEach(feld => {
const eingabe = feld.querySelector("input[type=color]");
eingabe.addEventListener("change", () => {
const hex = eingabe.value;
const params = {};
params[feld.dataset.red] = parseInt(hex.substr(1, 2), 16);
params[feld.dataset.green] = parseInt(hex.substr(3, 2), 16);
params[feld.dataset.blue] = parseInt(hex.substr(5, 2), 16);
raumKommando(Number(feld.dataset.cmd), params, feld);
});
});
// Auswahllisten, die ohne zusaetzlichen Knopf schalten - Effekt, Preset.
koerper.querySelectorAll("select.raum-sofort").forEach(auswahl => {
auswahl.addEventListener("change", () => {
const params = {};
params[auswahl.dataset.param] = auswahl.value;
raumKommando(Number(auswahl.dataset.cmd), params, auswahl);
});
});
}
/**
* Anzeige, gefuellte Bahn und Lamellenbild eines Reglers nachziehen.
*
* Was 0 % und 100 % bedeuten, steht bei den senkrechten Reglern als Wort
* an ihren Enden ("oben"/"unten", "auf"/"zu"); eine Zahl steht dort nicht
* mehr - das Bild daneben zeigt beim Ziehen mehr als sie.
*/
function reglerAnzeigen(regler) {
const schieber = regler.querySelector("input[type=range]");
const anzeige = regler.querySelector(".raum-regler-wert");
const wert = Number(schieber.value);
if (anzeige) {
anzeige.textContent = wert + (schieber.dataset.einheit || "");
}
// Der gefuellte Anteil der Bahn - dieselbe Eigenschaft, aus der auch
// bindModalSlider() in common.js den Temperaturregler faerbt.
const min = Number(schieber.min);
const max = Number(schieber.max);
schieber.style.setProperty("--background-size",
((wert - min) * 100 / (max - min)) + "%");
lamellenZeichnen(regler.closest(".raum-jalousie"));
}
/** Der Stand eines der beiden Regler einer Jalousie, als Anteil 0..1. */
function reglerAnteil(jalousie, rolle, ersatz) {
const schieber = jalousie.querySelector("[data-rolle=" + rolle + "] input[type=range]");
if (!schieber) return ersatz;
const min = Number(schieber.min);
return (Number(schieber.value) - min) / (Number(schieber.max) - min);
}
/**
* Das Fenster zeichnen, wie die Jalousie davor gerade steht.
*
* Zwei Zahlen in einem Bild. Die Hoehe schneidet ab - gezeichnet wird nur
* der Teil des Fensters, den die Jalousie bedeckt, mit der Endschiene an
* seiner Unterkante. Die Neigung bestimmt, wie dick eine Lamelle von vorn
* erscheint: w * sin(Winkel). Bei "auf" sieht man nur ihre Kante und viel
* Luft dazwischen, bei "zu" deckt sie den Abstand zur naechsten
* vollstaendig ab - genau das, was das Licht abhaelt. Von Mitte zu Mitte
* sind es 10 Einheiten, die Lamelle ist 11 tief; ab etwa 65 % schliesst
* das Bild, so wie es die Jalousie auch tut.
*
* Ein Rollladen ohne Neigung bekommt volle Lamellenhoehe - er ist ja ein
* geschlossener Panzer.
*/
function lamellenZeichnen(jalousie) {
if (!jalousie) return;
const bild = jalousie.querySelector(".raum-lamellen");
if (!bild) return;
const hoch = bild.viewBox.baseVal.height;
const abdeckung = hoch * reglerAnteil(jalousie, "position", 1);
const kippung = reglerAnteil(jalousie, "neigung", 1);
const dicke = Math.max(1.6, 11 * Math.sin(kippung * Math.PI / 2));
bild.querySelector(".raum-abdeckung").setAttribute("height", abdeckung);
bild.querySelectorAll(".raum-lamelle").forEach(lamelle => {
const mitte = Number(lamelle.dataset.mitte);
lamelle.setAttribute("height", dicke);
lamelle.setAttribute("y", mitte - dicke / 2);
});
// Die Endschiene sitzt auf der Unterkante und bleibt im Bild, auch wenn
// die Jalousie ganz oben steht.
const schiene = bild.querySelector(".raum-endschiene");
const dick = schiene.height.baseVal.value;
schiene.setAttribute("y", Math.max(0, Math.min(hoch - dick, abdeckung - dick)));
}
/**
* Die Ist-Werte im Heizungsteil aus dem laufenden MQTT-Strom fuellen.
*
* Der Browser hoert ohnehin schon auf Raumtemp/#, und ein Wert aus der
* Datenbank waere neben dem Regler nur ein Stand von vorhin.
*/
function raumMesswerteAktualisieren(thermostat) {
const werte = mqttZweig(thermostat);
if (!werte) return;
document.querySelectorAll("#roomView [data-mqtt]").forEach(feld => {
const wert = werte[feld.dataset.mqtt];
if (typeof wert !== "undefined") feld.textContent = wert;
});
const heizt = document.querySelector("#roomView [data-mqtt-heizung]");
if (heizt) heizt.classList.toggle("d-none", String(werte["Heating"]) !== "true");
}
/** Ein Kommando des angeklickten Knopfes ausfuehren. */
function schalte(knopf) {
const zeile = knopf.closest("[data-cmdrow]");
const params = {};
if (zeile) {
zeile.querySelectorAll("[data-param]").forEach(f => { params[f.dataset.param] = f.value; });
}
raumKommando(Number(knopf.dataset.cmd), params, knopf);
}
/**
* Ein Kommando schicken und die Karte so lange sperren.
*
* Ein "Zu" an einer Jalousie dauert ueber eine Minute (die Mechanik will
* zweimal geschwenkt werden, siehe restricted/commands.php). Ohne sichtbare
* Sperre tippt man in der Zeit dreimal nach.
*/
function raumKommando(cmdId, params, element) {
const karte = element.closest(".raum-geraet") || element;
karte.classList.add("raum-warte");
// Die Verfolgung laeuft neben dem Auftrag her, nicht nach ihm: ein "Zu"
// faehrt herunter, wartet und neigt danach - die Antwort kaeme also erst,
// wenn die Jalousie schon steht. Beides nebeneinander geht, seit
// ajax/room.php die Sitzung freigibt. Der Auftrag geht mit, weil nur er
// weiss, wann das Manoever zu Ende ist.
const auftrag = raumRequest("command", { command_id: cmdId, params: params });
jalousieVerfolgen(karte, auftrag);
auftrag.then(antwort => {
karte.classList.remove("raum-warte");
raumMeldung(antwort);
});
}
// Takt und Grenzen der Verfolgung. Dieselben Zahlen wie die Konstanten
// JALOUSIE_* in restricted/commands.php: die Box meldet core:MovingState
// traege, kurz nach dem Kommando steht dort noch "faehrt nicht" - deshalb
// der Vorlauf, in dem ein Stillstand nichts bedeutet.
const JALOUSIE_TAKT_MS = 2000;
const JALOUSIE_VORLAUF_MS = 8000;
const JALOUSIE_MAX_MS = 120000;
// Wie lange nach dem Ende des Auftrags noch nachgesehen wird. Eine
// Neigungsfahrt setzt core:MovingState nie (siehe warteAufJalousie() in
// restricted/commands.php), dauert aber gemessen bis zu fuenfzehn Sekunden
// von Anschlag zu Anschlag - ohne diesen Nachlauf stuende der Wert von
// vor dem letzten Schwenk in der Anzeige.
const JALOUSIE_NACHLAUF_MS = 14000;
/**
* Nach einem Fahrbefehl abwarten, bis die Jalousie steht, und dann ihren
* wirklichen Stand zeigen.
*
* actor_states schreibt sonst nur der Runner fort, und der fragt die
* Tahoma-Box alle fuenf Minuten ab - so lange zeigte das Modal nach einem
* Fahrbefehl den Stand von davor. Hier wird genau dieses eine Geraet
* gefragt; ajax/room.php haelt den frischen Wert gleich in der Tabelle
* fest, damit auch das naechste Oeffnen und der Automatik-Editor ihn haben.
*
* Waehrend der Fahrt wird bewusst nichts angezeigt. An der Anlage
* gemessen: die Hoehe (core:ClosureState) steht die ganze Fahrt ueber auf
* dem Wert von vor dem Kommando und springt erst am Ende um - wer sie
* anzeigte, holte den Regler vom eingestellten Ziel auf den alten Stand
* zurueck, nur um ihn zwanzig Sekunden spaeter wieder vorzuschieben. Die
* Neigung meldet zwar Zwischenwerte, aber ausgerechnet die der
* Kugelschreiber-Mechanik: bei "Neigung 80" liefe der Regler 62 -> 28 ->
* 38 -> 79, ueber einen Umweg, den niemand angefordert hat.
*/
function jalousieVerfolgen(karte, auftrag) {
if (!karte || !karte.querySelector(".raum-jalousie")) return;
const actor = Number(karte.dataset.actor);
if (!actor) return;
// Ohne Auftrag - beim Oeffnen des Modals - gibt es keine Traegheit
// abzuwarten und nichts nachlaufen zu lassen: was die Box beim ersten
// Blick meldet, gilt. Faehrt sie gerade, wird bis zum Stillstand
// weitergesehen.
const vorlaufNoetig = !!auftrag;
// Ein zweiter Fahrbefehl schiebt nur das Ende hinaus. Zwei Schleifen
// wuerden dieselbe Box doppelt so oft fragen, ohne mehr zu zeigen.
karte.dataset.jalousieBis = String(Date.now() + JALOUSIE_MAX_MS);
if (karte.dataset.jalousieLaeuft) return;
karte.dataset.jalousieLaeuft = "1";
const beginn = Date.now();
let gefahren = false;
// Der zuletzt im Stillstand gelesene Stand. "core:MovingState ist false"
// heisst noch nicht "steht": die Box meldet das Ende der Fahrt, bevor die
// Werte darauf nachgezogen haben. Fertig ist es erst, wenn zwei Abfragen
// hintereinander dasselbe sagen.
let letzterStand = null;
// Solange der Auftrag laeuft, ist nichts entschieden: die
// Kugelschreiber-Mechanik schickt zwei Befehle mit einer Wartezeit
// dazwischen, und zwischen ihnen steht die Jalousie still. Wer da
// aufhoert, schreibt die Zwischenstellung fest - gemessen "Neigung 0",
// wo 62 gemeint war.
let ruheAb = 0;
if (auftrag) {
ruheAb = Infinity;
Promise.resolve(auftrag).then(
function () { ruheAb = Date.now() + JALOUSIE_NACHLAUF_MS; },
function () { ruheAb = Date.now(); }
);
}
function aufhoeren() {
delete karte.dataset.jalousieLaeuft;
delete karte.dataset.jalousieBis;
}
function runde() {
raumRequest("jalousie", { actor: actor }).then(stand => {
// Ein Fehler soll auffallen. Ohne die Meldung endet die Schleife
// still, und die Anzeige bleibt einfach stehen - nicht zu
// unterscheiden von "die Verfolgung gibt es gar nicht".
if (stand && stand.error) raumMeldung(stand);
// Modal zu, Box stumm, Geraet unbekannt - in jedem Fall aufhoeren
// statt zwei Minuten lang ins Leere zu fragen.
if (!stand || stand.error || !document.body.contains(karte)) {
aufhoeren();
return;
}
const abgelesen = stand.faehrt
? null
: String(stand.position) + "/" + String(stand.neigung);
const steht = abgelesen !== null && abgelesen === letzterStand;
letzterStand = abgelesen;
if (stand.faehrt) {
gefahren = true;
} else if (Date.now() >= ruheAb) {
// Erst wenn der Auftrag durch ist. Zwischen den beiden Befehlen der
// Kugelschreiber-Mechanik steht die Jalousie still und meldet
// "Neigung 0" - anzeigen hiesse, den Umweg vorzufuehren.
jalousieAnzeigen(karte, stand);
}
if (Date.now() > Number(karte.dataset.jalousieBis)) {
aufhoeren();
return;
}
// Steht sie still, ist die Fahrt vorbei - aber erst, wenn wir sie
// haben fahren sehen oder der Vorlauf um ist. Eine letzte Runde
// danach holt den Wert, den die Box beim Anhalten setzt.
// Beim blossen Aufmachen eines Raums, in dem sich nichts geruehrt
// hat, gibt es nichts abzuwarten - dann bleibt es bei einer Abfrage
// je Jalousie.
if (!vorlaufNoetig && !gefahren && !stand.faehrt) {
aufhoeren();
return;
}
const fertig = steht
&& Date.now() >= ruheAb
&& (gefahren || !vorlaufNoetig
|| Date.now() - beginn >= JALOUSIE_VORLAUF_MS);
if (fertig) {
aufhoeren();
return;
}
setTimeout(() => runde(false), JALOUSIE_TAKT_MS);
});
}
// Beim Oeffnen sofort nachsehen, nach einem Kommando erst im Takt: da
// stuende die Box ohnehin noch auf dem alten Wert.
setTimeout(runde, auftrag ? JALOUSIE_TAKT_MS : 0);
}
/**
* Die gemeldete Stellung in die beiden Regler und das Bild uebernehmen.
* Aufgerufen nur im Stillstand, siehe jalousieVerfolgen().
*/
function jalousieAnzeigen(karte, stand) {
[["position", stand.position], ["neigung", stand.neigung]].forEach(paar => {
const wert = paar[1];
if (wert === null || typeof wert === "undefined") return;
const regler = karte.querySelector(".raum-regler[data-rolle='" + paar[0] + "']");
if (!regler) return;
const schieber = regler.querySelector("input[type=range]");
// Wer gerade zieht, bestimmt. Sonst spraenge ihm der Schieber unter
// dem Daumen weg, waehrend die Jalousie noch das alte Ziel anfaehrt.
if (document.activeElement === schieber) return;
schieber.value = wert;
reglerAnzeigen(regler);
});
}
/**
* Die neue Solltemperatur schicken.
*
* Das Modal bleibt danach offen: frueher schloss es sich, weil der
* Speichern-Knopf das Ende der Bedienung war. Jetzt ist das Verschieben
* des Reglers die Bedienung, und wer dreimal nachjustiert, will nicht
* dreimal neu aufmachen.
*/
function sendeSolltemperatur(raum) {
const slider = document.getElementById("modal-slider");
if (!slider) return;
const karte = slider.closest(".raum-geraet");
if (karte) karte.classList.add("raum-warte");
raumRequest("temp", { room: raum, temp: Number(slider.value) })
.then(antwort => {
if (karte) karte.classList.remove("raum-warte");
raumMeldung(antwort);
});
}
function raumRequest(action, body) {
return fetch("./ajax/room.php?action=" + action, {
method: "POST",
headers: { "Content-Type": "application/json", "Requested-With-Ajax": "ajax" },
body: JSON.stringify(body)
})
.then(a => a.json())
.catch(fehler => ({ error: fehler.message }));
}
/** Rueckmeldung im Modal statt als alert - man schaltet oft mehrmals. */
function raumMeldung(antwort) {
const ziel = document.getElementById("roomFeedback");
if (!ziel) return;
ziel.className = "small mt-2 " + (antwort.error ? "text-danger" : "text-body-secondary");
ziel.textContent = antwort.error ? antwort.error : "✓ " + antwort.ok;
}
+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);
}));
}
+237 -308
View File
@@ -14,15 +14,10 @@
* auch ein neu gefundenes Geraet sein passendes Bedienelement, ohne dass hier
* jemand einen Typnamen nachtraegt.
*
* Das Gegenstueck im Browser ist raumBedienungAktivieren() in
* js/solar/homeMQTT.js - dort haengen die Zuhoerer an den data-Attributen,
* die hier gesetzt werden.
* Das Gegenstueck im Browser ist js/solar/raumfenster.js - dort haengen die
* Zuhoerer an den data-Attributen, die hier gesetzt werden.
*/
// Die Geraetegruppen werden als Reiter gezeichnet - eine Bauart fuer die
// ganze Seite, siehe restricted/reiter.php.
require_once(__DIR__ . "/reiter.php");
/**
* Was das Kommando schaltet: "ein", "aus", "um" - oder null, wenn es kein
* Schaltkommando ist.
@@ -173,7 +168,9 @@ function bedienform($d)
}
/**
* Die Gruppen in der Reihenfolge, in der ihre Tabs erscheinen.
* Die Gruppen in der Reihenfolge, in der ihre Reiter erscheinen, mit Symbol
* und Akzentfarbe - dieselben Farben wie in Lade-, Heiz- und
* Bewaesserungsfenster.
*
* Beschattung zuerst: das ist es, was man in einem Raum am haeufigsten
* anfasst. Messwerte zuletzt - die schaut man an, man bedient sie nicht.
@@ -181,21 +178,24 @@ function bedienform($d)
function raumGruppenTitel()
{
return [
"beschattung" => ["Beschattung", "bi-blinds"],
"heizung" => ["Heizung", "bi-thermometer-half"],
"licht" => ["Licht", "bi-lightbulb"],
"schalter" => ["Schalten", "bi-toggle-on"],
"sonstiges" => ["Geräte", "bi-sliders"],
"messwerte" => ["Messwerte", "bi-speedometer2"],
// "bi-blinds" gibt es in der eingebundenen Icon-Version nicht - die
// Kachel blieb leer. Ein eigenes Zeichen: Fensterrahmen mit Lamellen.
"beschattung" => ["Beschattung", "<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"],
"heizung" => ["Heizung", "bi-thermometer-half", "#e5624f"],
"licht" => ["Licht", "bi-lightbulb", "#f7c000"],
"schalter" => ["Schalten", "bi-toggle-on", "#6cbe58"],
"sonstiges" => ["Geräte", "bi-sliders", "#9aa6ad"],
"messwerte" => ["Messwerte", "bi-speedometer2", "#4b9fd6"],
];
}
/**
* Die Geraetegruppen als Tabs.
* Die Geraetegruppen als Reiter-Kacheln.
*
* Vorher standen sie untereinander, und man scrollte an drei Rollladen
* vorbei, um zum Licht zu kommen. Bei nur einer Gruppe bleibt die Leiste
* weg - ein einzelner Tab beschriftet nur sich selbst.
* Jede Kachel traegt unter ihrem Namen den Zustand der Gruppe ("2 von 3 zu",
* "25,7 °C", "1 an") - den setzt js/solar/raumfenster.js, weil er sich
* waehrend des Bedienens aendert. Bei nur einer Gruppe bleibt die Leiste
* trotzdem stehen: sie ist hier auch die Zusammenfassung.
*
* $fertig sind Gruppen, deren Inhalt der Aufrufer schon gebaut hat und die
* nicht aus einer Geraeteliste entstehen - die Heizung ist so eine: ein
@@ -213,180 +213,58 @@ function zeichneGruppen($gruppen, $fertig = [])
return "";
}
// Aus den Gruppen werden Reiter. Wie sie aussehen und wie umgeschaltet
// wird, steht in restricted/reiter.php - hier nur, was drinsteht.
$reiter = [];
$leiste = "<div class='rf-reiter' role='tablist'>";
$flaechen = "";
$erste = true;
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 = "";
if (isset($fertig[$form])) {
$inhalt = $fertig[$form];
} 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) {
$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
// scrollen statt umzubrechen.
return reiterBlock($reiter, ["name" => "raumtab", "schmal" => true,
"klasse" => "mb-2"]);
$flaechen .= "<div class='rf-flaeche' role='tabpanel' data-rf-flaeche='" . $form . "'"
. " style='--a:" . $titel[2] . "'" . ($erste ? "" : " hidden") . ">" . $inhalt . "</div>";
$erste = false;
}
return $leiste . "</div>" . $flaechen;
}
// ===========================================================================
// Bausteine
// ===========================================================================
/** Rahmen fuer Geraete ohne eigene Bedienform (Messwerte, Sonstiges). */
function karteAuf($d, $status = "")
{
$html = "<div class='card mb-2 raum-geraet' data-actor='" . intval($d["id"]) . "'>"
. "<div class='card-body py-2'>"
. "<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>";
$html = "<div class='rf-karte raum-geraet' data-actor='" . intval($d["id"]) . "'>"
. "<div class='rf-kopf-zeile'><b>" . htmlspecialchars($d["label"]) . "</b>";
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>";
}
function karteZu()
{
return "</div></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;
return "</div>";
}
/** Ein "wahr"-artiger Wert - die Geraete melden mal True, mal 1, mal "on". */
@@ -403,7 +281,7 @@ function istAn($wert)
*
* Nur Kommandos ohne Parameter: ein Menuepunkt bietet nichts zum Einstellen,
* ein "Position+Neigung" von hier aus wuerde ohne Werte losgeschickt. Was
* Parameter braucht, hat oben seinen Schieberegler.
* Parameter braucht, hat oben seine Bedienung.
*/
function mehrMenue($cmds)
{
@@ -411,9 +289,8 @@ function mehrMenue($cmds)
if (!$cmds) {
return "";
}
$html = "<div class='dropdown'>"
. "<button class='btn btn-sm btn-link text-body-secondary p-0' type='button'"
. " data-bs-toggle='dropdown' aria-label='Weitere Befehle'>"
$html = "<div class='dropdown rf-mehr'>"
. "<button class='rf-mehr-knopf' type='button' data-bs-toggle='dropdown' aria-label='Weitere Befehle'>"
. "<i class='bi bi-three-dots'></i></button><ul class='dropdown-menu dropdown-menu-end'>";
foreach ($cmds as $c) {
$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>";
}
/** 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
// ===========================================================================
/**
* Der zuletzt gemeldete Stand in Worten.
*
* "0 % zu" muss man erst uebersetzen, "offen" nicht. Die Prozentzahl steht
* ohnehin am Schieberegler darunter.
*/
function beschattungsStand($position, $neigung)
/** Zahl aus einem Zustand, oder "" wenn es keinen gibt. */
function zustandsZahl($d, $namen)
{
if ($position === null || $position === "") {
return "";
$wert = zustandsWert($d, $namen);
return is_numeric($wert) ? strval(intval($wert)) : "";
}
/**
* "Alle im Raum": ein Knopf, viele Geraete. Das JavaScript geht die Karten
* durch und schickt jedem das passende eigene Kommando - "Sichtschutz" nur
* denen, die Hoehe und Neigung zusammen setzen koennen.
*/
function alleImRaum($geraete)
{
$mitMy = false;
$mitNeigung = false;
foreach ($geraete as $d) {
$mitMy = $mitMy || kommandoMitUrl($d, ["my"]);
$mitNeigung = $mitNeigung || kombiKommando($d);
}
$p = intval($position);
$text = $p === 0 ? "offen" : ($p === 100 ? "geschlossen" : $p . " % zu");
if ($neigung !== null && $neigung !== "" && $p > 0) {
$text .= ", Neigung " . intval($neigung) . " %";
}
return htmlspecialchars($text);
return "<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.
*
* Auf/Stop/Zu gross zum Antippen, darunter die Feinheiten. Die Schieber
* starten auf dem zuletzt gemeldeten Stand - so sieht man, wo das Geraet
* gerade steht, bevor man es bewegt.
* Links das Fenster, wie der Behang davor gerade steht - ziehen stellt die
* Hoehe fein ein. Rechts gross Auf / Stop / my / Zu, darunter Hoehe und
* Lamellen als Stufen und die my-Position, die sich dort auch speichern
* laesst. Alles wird von js/solar/raumfenster.js ueber die data-Attribute
* verdrahtet; welche es gibt, haengt an den Faehigkeiten des Geraets.
*/
function zeichneBeschattung($d)
{
$auf = kommandoMitUrl($d, ["up"]);
$stop = kommandoMitUrl($d, ["stop"]);
$zu = kommandoMitUrl($d, ["down"]);
$my = kommandoMitUrl($d, ["my"]);
$position = kommandoMitParameter($d, POSITION_PARAMETER);
$neigung = kommandoMitParameter($d, NEIGUNG_PARAMETER);
$kombi = kombiKommando($d);
// Die Hoehe geht, wenn es das gibt, ueber das Kombi-Kommando und nimmt die
// Neigung mit - sonst raste die Mechanik bei der Hoehenfahrt um und
// niemand faehrt sie wieder an (siehe sendeTahoma() in commands.php).
$hoehe = $kombi ?: $position;
$attr = [
"auf" => $auf ? $auf["id"] : "",
"stop" => $stop ? $stop["id"] : "",
"zu" => $zu ? $zu["id"] : "",
"my" => $my ? $my["id"] : "",
"hoehe-cmd" => $hoehe ? $hoehe["id"] : "",
"hoehe-param" => $hoehe ? ($kombi ? parameterNamens($kombi, POSITION_PARAMETER)["id"] : $position["params"][0]["id"]) : "",
"hoehe-mit" => $kombi && $neigung ? parameterNamens($kombi, NEIGUNG_PARAMETER)["id"] : "",
"neig-cmd" => $neigung ? $neigung["id"] : "",
"neig-param" => $neigung ? $neigung["params"][0]["id"] : "",
// Was das Geraet zuletzt gemeldet hat. Bei Somfy heisst die Lamelle
// "Lamellenausrichtung", die Hoehe schlicht "Position".
$standPos = zustandsWert($d, ["Position"]);
$standNeigung = zustandsWert($d, ["Lamellenausrichtung"]);
$status = beschattungsStand($standPos, $standNeigung);
"pos" => zustandsZahl($d, ["Position"]),
"neig" => $neigung ? zustandsZahl($d, ["Lamellenausrichtung"]) : "",
"my-pos" => zustandsZahl($d, ["Gespeicherte Position"]),
"my-neig" => $neigung ? zustandsZahl($d, ["Gespeicherte Neigung"]) : "",
];
// $kombi gehoert dazu: es steckt jetzt hinter dem Hoehenregler und hat im
// Mehr-Menue nichts mehr verloren.
$benutzt = array_filter([$auf, $stop, $zu, $position, $neigung, $kombi]);
$benutzteIds = array_map(function ($c) { return $c["id"]; }, $benutzt);
$rest = array_values(array_filter($d["commands"], function ($c) use ($benutzteIds) {
return !in_array($c["id"], $benutzteIds, true);
}));
$html = "<div class='rf-behang raum-geraet' data-actor='" . intval($d["id"]) . "'";
foreach ($attr as $name => $wert) {
$html .= " data-" . $name . "='" . htmlspecialchars(strval($wert), ENT_QUOTES) . "'";
}
$html .= ">";
$html = karteAuf($d, $status);
$html .= "<div class='raum-jalousie'>";
// Das Bild: dunkles Glas, davor Lamellen (mit Neigung) oder ein Panzer.
// 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
// die sie fahren.
if ($auf || $stop || $zu) {
$html .= "<div class='btn-group-vertical btn-group-sm raum-fahrt' role='group'>";
foreach ([[$auf, "bi-chevron-up", "Auf"], [$stop, "bi-stop-fill", "Stop"],
[$zu, "bi-chevron-down", "Zu"]] as $k) {
if (!$k[0]) {
continue;
$html .= "<div class='rf-behang-mitte'>"
. "<div class='rf-kopf-zeile'><b>" . htmlspecialchars($d["label"]) . "</b>"
. "<span class='rf-pille' data-rf-stand></span>"
. mehrMenue(restKommandos($d, [$auf, $stop, $zu, $my, $position, $neigung, $kombi])) . "</div>";
if ($auf || $stop || $zu || $my) {
$html .= "<div class='rf-fahrt'>"
. ($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"]) . "'"
. " title='" . htmlspecialchars($k[2], ENT_QUOTES) . "'"
. " aria-label='" . htmlspecialchars($k[2], ENT_QUOTES) . "'>"
. "<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");
$prozent = function ($v) { return $v . "&#8201;%"; };
if ($hoehe) {
$html .= stufenReihe("Höhe", "rf-pos", [[0, "offen"], [25, $prozent(25)], [50, "halb"], [75, $prozent(75)], [100, "zu"]]);
}
if ($neigung) {
$html .= senkrechterRegler($neigung, $neigung["params"][0], $standNeigung,
"Neigung", "auf", "zu", "neigung");
$html .= stufenReihe("Lamellen", "rf-neig", [[0, "offen"], [25, $prozent(25)], [50, "halb"], [75, $prozent(75)], [100, "zu"]]);
}
$html .= mehrMenue($rest);
return $html . "</div>" . karteZu();
// Die my-Position ist im Motor gespeichert und nur bei IO-Geraeten lesbar.
// Speichern geht ueber ajax/room.php?action=my, nicht ueber ein Kommando
// aus der Geraetedatenbank.
if ($attr["my-pos"] !== "" && $my) {
$html .= "<div class='rf-my-zeile' data-rf-my></div>";
}
return $html . "</div></div>";
}
/**
* Lampe: Kippschalter, Helligkeit, Farbe, Effekt.
*
* Die Farbe kommt in der Datenbank als drei Parameter 0-255 an. Drei
* Zahlenfelder sind keine Farbwahl - deshalb ein Farbfeld, das der Browser
* aufmacht, und die Zerlegung passiert erst beim Absenden.
* Lampe: die Birne ist der Schalter und leuchtet in ihrer Farbe. Darunter
* Helligkeit, Farbfelder und - eingeklappt - Effekt und Preset.
*/
function zeichneLicht($d)
{
@@ -533,88 +446,105 @@ function zeichneLicht($d)
$helligkeit = kommandoMitParameter($d, "brightness");
$farbe = farbKommando($d);
$benutzteIds = [];
foreach ($schalter as $c) {
$benutzteIds[] = $c["id"];
}
foreach ([$helligkeit, $farbe] as $c) {
if ($c) {
$benutzteIds[] = $c["id"];
}
}
$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);
// Der zuletzt gemeldete Farbwert steht als [r, g, b, w] in der Datenbank.
// Steht dort schwarz, leuchtet die Lampe ueber ihren Weisskanal - ein
// schwarzes Feld waere dann eine falsche Auskunft.
$hex = is_array($roh) && count($roh) >= 3
&& (intval($roh[0]) || intval($roh[1]) || intval($roh[2]))
$roh = json_decode(strval(zustandsWert($d, ["Farbe", "color"])), true);
$hex = is_array($roh) && count($roh) >= 3 && (intval($roh[0]) || intval($roh[1]) || intval($roh[2]))
? sprintf("#%02x%02x%02x", intval($roh[0]), intval($roh[1]), intval($roh[2]))
: "#ffffff";
$html .= "<span class='raum-farbe d-inline-flex align-items-center gap-1'"
. " data-cmd='" . intval($farbe["id"]) . "'";
: "#fff1d6";
$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) {
$html .= " data-" . strtolower($p["name"]) . "='" . intval($p["id"]) . "'";
}
$html .= "><label class='small text-body-secondary' for='col" . intval($d["id"]) . "'>Farbe</label>"
. "<input type='color' class='form-control form-control-color form-control-sm'"
. " id='col" . intval($d["id"]) . "' value='" . $hex . "'></span>";
$html .= ">";
foreach (["#ffd9a0" => "Warmweiß", "#fff1d6" => "Weiß", "#dfeeff" => "Kaltweiß", "#ff6b5b" => "Rot",
"#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>";
if ($helligkeit) {
$html .= reglerZeile("Helligkeit", $helligkeit, $helligkeit["params"][0],
zustandsWert($d, ["Helligkeit", "brightness", "bri"]), "");
}
// Effekt und Preset sind Listen - die gehoeren in eine Auswahl, nicht in
// ein Zahlenfeld. Sie schalten sofort, ohne zusaetzlichen Knopf.
foreach ($d["commands"] as $c) {
if (in_array($c["id"], $benutzteIds, true) || count($c["params"]) !== 1
|| !count($c["params"][0]["options"])) {
continue;
}
$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"]) . "'>";
} else {
$html .= "<label class='rf-auswahl'><span>" . htmlspecialchars($c["name"]) . "</span>"
. "<select class='form-select form-select-sm' data-cmd='" . intval($c["id"]) . "' data-param='" . intval($p["id"]) . "'>";
foreach ($p["options"] as $o) {
$html .= "<option value='" . htmlspecialchars($o["value"], ENT_QUOTES) . "'>"
. htmlspecialchars($o["label"]) . "</option>";
$html .= "<option value='" . htmlspecialchars($o["value"], ENT_QUOTES) . "'>" . htmlspecialchars($o["label"]) . "</option>";
}
$html .= "</select></div>";
$html .= "</select></label>";
}
$rest = array_values(array_filter($d["commands"], function ($c) use ($benutzteIds) {
return !in_array($c["id"], $benutzteIds, true);
}));
if ($rest) {
$html .= "<div class='d-flex justify-content-end'>" . mehrMenue($rest) . "</div>";
}
return $html . karteZu();
$html .= "</details>";
}
return $html . "</div></div>";
}
/** Alles, was nur an und aus kennt. */
/** Alles, was nur an und aus kennt: eine Kachel zum Antippen. */
function zeichneSchalter($d)
{
$schalter = schaltKommandos($d);
$an = istAn(zustandsWert($d, ["An", "ison", "on", "state", "Status"]));
$benutzteIds = array_map(function ($c) { return $c["id"]; }, array_values($schalter));
$rest = array_values(array_filter($d["commands"], function ($c) use ($benutzteIds) {
return !in_array($c["id"], $benutzteIds, true);
}));
$html = karteAuf($d, "");
$html .= "<div class='d-flex justify-content-between align-items-center'>"
. kippSchalter($d, $schalter, $an) . mehrMenue($rest) . "</div>";
return $html . karteZu();
return "<button type='button' class='rf-schalter 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"]) : "") . "'"
. " aria-pressed='" . ($an ? "true" : "false") . "'>"
. "<span class='rf-sym'><i class='bi bi-power'></i></span>"
. "<b>" . htmlspecialchars($d["label"]) . "</b><span class='rf-st'>" . ($an ? "an" : "aus") . "</span></button>";
}
/**
@@ -636,9 +566,8 @@ function zeichneMesswerte($d)
}
$html .= "</div>";
if ($weitere) {
$html .= "<details class='mt-2'><summary class='small text-body-secondary'>"
. count($weitere) . " weitere Werte</summary>"
. "<div class='raum-werte mt-2'>";
$html .= "<details class='rf-effekte'><summary>" . count($weitere) . " weitere Werte</summary>"
. "<div class='raum-werte'>";
foreach ($weitere as $s) {
$html .= wertKachel($s);
}