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