Raum-Modal Licht: Preset als Chips, Effekt als durchsuchbarer Picker, WLED-Zustand live

Die native Auswahlliste zeichnete Chrome hell auf hell. Welcher Effekt und
welches Preset gerade laufen, liest room.php?action=wled direkt von der Lampe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 22:27:40 +02:00
co-authored by Claude Opus 5
parent d5d2d8f336
commit 0eb01403c5
5 changed files with 173 additions and 42 deletions
+7 -7
View File
@@ -115,7 +115,7 @@ einen Einmal-Link (`addUser.php`, Tabelle `addUser`, eine Minute gültig).
welche Seite es gibt und was sie braucht:
```php
"home" => ["template" => "home.php", "mqtt" => true, "charts" => false,
"home" => ["`POST ?action=command|temp|jalousie|my|wled` | Raum-Modal bauen; ein Kommando oder eine Solltemperatur senden; Jalousie-Stand und WLED-Zustand (Effekt, Preset) live lesen;late" => "home.php", "mqtt" => true, "charts" => false,
"meteogram" => false, "leaflet" => false,
"scripts" => ["js/solar/autoActionFuncs.js", "js/solar/homeMQTT.js"]],
```
@@ -184,7 +184,7 @@ genau die Topics, die auf den Kacheln stehen (`tileTopics()` in `rooms.php`).
| Seite (`?action=`) | Vorlage | Seitenskripte | holt per fetch | hört auf MQTT |
|---|---|---|---|---|
| `solar` (Vorgabe) | `solar.php` | `jahresstatistik.js`, `anlageKatalog.js`, `anlage.js`, `speicher.js`, `energieflussKatalog.js`, `energiefluss.js`, `bewaesserungAnzeige.js`, `ladefenster.js`, `bedienfenster.js`, `solarMQTT.js` | `speicher`, `getProdData`, `getConsData`, `getForecastData`, `getSunrise`, `getStats`, `carEG`, `carOG`, `heater`, `watering` | `solarManager/#`, `weatherStation/#`, `wattpilot/#`, `go-eCharger/#`, `Gartenwasser/#` |
| `home` | `home.php` | `autoActionFuncs.js`, `zeitleiste.js`, `raumfenster.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, `Raum`POST ?action=command|temp|jalousie|my|wled` | Raum-Modal bauen; ein Kommando oder eine Solltemperatur senden; Jalousie-Stand und WLED-Zustand (Effekt, Preset) live lesen;/#` |
| `heat` | `heat.php` | `heatMQTT.js` | `getHeaterData`, `getWaterData`, `getSunrise` | `solarManager/#`, `weatherStation/#`, Wallbox-Topics |
| `history` | `history.php` | `jahresstatistik.js`, `historyMQTT.js` | `energyHistory` (6 ×), `getStats` | — |
| `skoda` | `skoda.php` | `skodaMQTT.js` | `skoda.php?was=…`, `skodaCmd.php`, `tile.php` | `solarManager/#` |
@@ -429,7 +429,7 @@ klein: Diagramme füllen bzw. Logzeilen nachladen und einfärben.
| Endpunkt | Aufruf | liefert / tut |
|---|---|---|
| `room.php` | `?room=<rooms.id>`, `POST ?action=command\|temp\|jalousie\|my` | Raum-Modal bauen; ein Kommando oder eine Solltemperatur senden; Jalousie-Stand lesen; aktuellen Stand als my-Position in den Motor schreiben (`setMemorized1Position`/`-Orientation`) |
| `room.php` | `?room=<rooms.id>`, `POST ?action=command|temp|jalousie|my|wled` | Raum-Modal bauen; ein Kommando oder eine Solltemperatur senden; Jalousie-Stand und WLED-Zustand (Effekt, Preset) live lesen;\|temp\|jalousie\|my` | Raum-Modal bauen; ein Kommando oder eine Solltemperatur senden; Jalousie-Stand lesen; aktuellen Stand als my-Position in den Motor schreiben (`setMemorized1Position`/`-Orientation`) |
| `AutoAction.php` | `?action=editor\|list\|zeitleiste\|followers\|werte`, `POST save\|delete\|toggle` | Automatik-Editor, Übersicht als Liste und als Zeitleiste (`&datum=YYYY-MM-DD`) |
| `settings.php` | `?action=list\|kacheln\|geraete\|grundriss\|skoda-keys`, `POST save\|kacheln-save\|geraete-raeume\|geraet-loeschen\|etage-speichern\|etagen-reihenfolge\|etage-loeschen\|etage-bild\|raum-speichern\|raum-position\|raum-loeschen\|skoda-keys-save` | Einstellungsseite; die Grundriss-Aktionen antworten mit dem ganzen neuen Stand, `skoda-keys` meldet nur, ob ein Schlüssel hinterlegt ist und worauf er endet |
| `getStats.php` | `GET` | Jahresstatistik, alle Jahre, mit Metadaten je Kennzahl |
@@ -532,7 +532,7 @@ erDiagram
|---|---|---|
| `EnergyFlow`, `EnergyFlow_hourly` | Leistungen im Rohtakt und als Stundenarchiv | `solarManager.py` |
| `stats_daily` | Tageswerte für die Jahresstatistik | `solarManager.py` |
| `Heater`, `puffertemp`, `wasser`, `zisterne`, `windrad`, `kiga_windrad` | Heizung, Speicher, Wasser, Windrad | `solarManager.py` |
| `Heater`, `puffer`POST ?action=command|temp|jalousie|my|wled` | Raum-Modal bauen; ein Kommando oder eine Solltemperatur senden; Jalousie-Stand und WLED-Zustand (Effekt, Preset) live lesen;`, `wasser`, `zisterne`, `windrad`, `kiga_windrad` | Heizung, Speicher, Wasser, Windrad | `solarManager.py` |
| `skoda`, `skoda_raw` | Fahrzeugzustand im Verlauf | `gatherSkodaData.py` |
| `skoda_ladepunkte` | Wallbox-Verlauf während einer Ladung, im Minutentakt, wird nicht ausgedünnt (`solarLog_skoda_ladepunkte.sql`) | `gatherSkodaData.py`; ältere Ladungen `skoda_ladepunkte_nachtragen.py` |
| `byd`, `byd_zellen` | BYD-Speicher direkt aus der BMU: Ladestand, SOH, Temperaturen, Spreizung, Zähler alle 5 Minuten; alle 128 Zellspannungen und 64 Temperaturen alle 15 Minuten (`solarLog_byd.sql`) | `gatherBYDData.py` |
@@ -568,7 +568,7 @@ Automatiken lässt sich nicht löschen.
Ist und Feuchte aus diesem Zweig, sonst nur der Name. Derselbe Zweig trägt
Heizsymbol und Temperaturregler (`mqttZweig()` in `homeMQTT.js`,
`<zweig>/changeSetTemp` in `room.php`) — nichts setzt mehr voraus, dass ein
Thermostat unter `Raumtemp/<Etage>/<Raum>` meldet.
Thermostat unter `Raum`POST ?action=command|temp|jalousie|my|wled` | Raum-Modal bauen; ein Kommando oder eine Solltemperatur senden; Jalousie-Stand und WLED-Zustand (Effekt, Preset) live lesen;/<Etage>/<Raum>` meldet.
**Grundrissbilder** kommen per Upload nach `tiles/grundriss/` — das einzige
Verzeichnis, in das der Webserver schreiben darf, und nicht im Git. PNG, JPEG
@@ -814,8 +814,8 @@ Drei Eigenschaften, die man kennen muss:
Die **Wetterstation** hängt am Websocket von `192.168.179.42`;
`wsMQTTbridge.py` im SolarManager legt ihre Felder unter `weatherStation/#`
ab. Von dort kommen Außentemperatur, Wind und Böe — die Außentemperatur ist
für den Hitzeschutz die bessere Bedingung als die Raumtemperatur (ist es
ab. Von dort kommen Außen`POST ?action=command|temp|jalousie|my|wled` | Raum-Modal bauen; ein Kommando oder eine Solltemperatur senden; Jalousie-Stand und WLED-Zustand (Effekt, Preset) live lesen;eratur, Wind und Böe — die Außentemperatur ist
für den Hitzeschutz die bessere Bedingung als die Raum`POST ?action=command|temp|jalousie|my|wled` | Raum-Modal bauen; ein Kommando oder eine Solltemperatur senden; Jalousie-Stand und WLED-Zustand (Effekt, Preset) live lesen;eratur (ist es
drinnen schon warm, ist es zum Verschatten zu spät), und ohne den Wind gibt
es für Markise und Sonnensegel keinen Sturmschutz. Die trägen Werte
(Temperatur, Feuchte, Druck, Taupunkt) gehen nur bei jeder einundzwanzigsten
+33
View File
@@ -168,6 +168,39 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
"myNeigung" => $zahl("core:Memorized1OrientationState"),
]);
}
// Den Zustand einer WLED-Lampe live lesen: an/aus, Helligkeit, Farbe,
// Effekt und Preset. In actor_states stehen nur die ersten drei, und
// ohne Effekt und Preset koennte das Raum-Modal nicht zeigen, was
// gerade laeuft.
if (($_GET["action"] ?? "") === "wled") {
$db = commandDb();
$stmt = $db->prepare("SELECT url FROM actors WHERE id = ?");
$id = intval($body["actor"] ?? 0);
$stmt->bind_param("i", $id);
$stmt->execute();
$zeile = $stmt->get_result()->fetch_assoc();
$stmt->close();
if (!$zeile || strpos(strval($zeile["url"]), "wled://") !== 0) {
raumAntwort(["error" => "Keine WLED-Lampe mit dieser Nummer."], 404);
}
$ch = curl_init("http://" . rtrim(substr($zeile["url"], strlen("wled://")), "/") . "/json/state");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 3);
$stand = json_decode(strval(curl_exec($ch)), true);
curl_close($ch);
if (!is_array($stand)) {
raumAntwort(["error" => "Die Lampe antwortet nicht."], 503);
}
$segment = $stand["seg"][0] ?? [];
$farbe = $segment["col"][0] ?? null;
raumAntwort([
"an" => !empty($stand["on"]),
"bri" => isset($stand["bri"]) ? intval($stand["bri"]) : null,
"farbe" => is_array($farbe) && count($farbe) >= 3 ? array_map("intval", array_slice($farbe, 0, 3)) : null,
"effect" => isset($segment["fx"]) ? intval($segment["fx"]) : null,
"preset" => isset($stand["ps"]) ? intval($stand["ps"]) : null,
]);
}
// 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
+25 -1
View File
@@ -1111,7 +1111,31 @@ input[type='range']::-webkit-slider-runnable-track {
.rf .rf-farbe:focus-visible, .rf .rf-farbe-eigen:focus-within { outline: 2px solid #f7c000; outline-offset: 3px; }
.rf .rf-farbe-eigen { background: conic-gradient(#f55, #fd5, #5f7, #5df, #75f, #f5d, #f55); }
.rf .rf-farbe-eigen input { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%; }
.rf .rf-auswahl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: .5rem; align-items: center; font-size: .78rem; color: var(--ton-schrift-2, #9aa6ad); margin: 0; }
/* Preset, Effekt: Titel links, Chips oder Picker rechts. Lange Listen
klappen als durchsuchbarer Kasten unter der Zeile auf. */
.rf .rf-liste { display: grid; grid-template-columns: 4.3rem minmax(0, 1fr); align-items: start; gap: .4rem .5rem; padding-top: .55rem; border-top: 1px solid rgba(255, 255, 255, .06); }
.rf .rf-liste + .rf-liste { border-top: 0; padding-top: 0; }
.rf .rf-liste > .rf-reihe-titel { padding-top: .35rem; }
.rf .rf-liste .rf-chip { padding: .28rem .65rem; font-size: .78rem; }
.rf .rf-liste .rf-chip[aria-pressed="true"] { color: #1a1406; }
.rf .rf-picker-knopf { all: unset; box-sizing: border-box; cursor: pointer; display: flex; align-items: center; justify-content: space-between; gap: .5rem; min-width: 0; padding: .38rem .7rem; border-radius: .6rem; background: #22292d; font-size: .82rem; font-weight: 500; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .06); }
.rf .rf-picker-knopf span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rf .rf-picker-knopf .bi { font-size: .75rem; color: var(--ton-schrift-2, #9aa6ad); transition: transform .2s; }
.rf .rf-picker-knopf:hover { background: #283035; }
.rf .rf-picker-knopf[aria-expanded="true"] { box-shadow: inset 0 0 0 1.5px var(--a); }
.rf .rf-picker-knopf[aria-expanded="true"] .bi { transform: rotate(180deg); }
.rf .rf-picker-knopf:focus-visible { outline: 2px solid var(--a); outline-offset: 1px; }
.rf .rf-picker { grid-column: 1 / -1; display: grid; gap: .45rem; padding: .5rem; border-radius: .65rem; background: #22292d; animation: rf-auf .2s cubic-bezier(.2, .7, .2, 1); }
.rf .rf-picker[hidden] { display: none; }
.rf .rf-suche { all: unset; box-sizing: border-box; width: 100%; padding: .4rem .65rem; border-radius: .5rem; background: var(--ton-flaeche-2, #2b3237); color: var(--ton-schrift, #e8ede9); font-size: .82rem; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .08); }
.rf .rf-suche::placeholder { color: var(--ton-schrift-3, #6f7a81); }
.rf .rf-suche:focus { box-shadow: inset 0 0 0 1.5px var(--a); }
.rf .rf-picker-liste { display: flex; flex-wrap: wrap; align-content: flex-start; gap: .3rem; max-height: 11rem; overflow-y: auto; padding-right: .25rem; scrollbar-width: thin; scrollbar-color: #3f474e transparent; }
.rf .rf-picker-liste .rf-chip[hidden] { display: none; }
@media (max-width: 575.98px) {
.rf .rf-liste { grid-template-columns: 1fr; gap: .3rem; }
.rf .rf-liste > .rf-reihe-titel { padding-top: 0; }
}
/* --- Schalter --- */
.rf .rf-schalter-liste { display: grid; grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); gap: .5rem; }
+79 -11
View File
@@ -620,18 +620,86 @@ function rfLampeStarten(karte) {
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)));
// Preset, Effekt und andere Listen: Chips, lange Listen im aufklappbaren
// Picker mit Suche.
karte.querySelectorAll(".rf-liste").forEach(liste => {
const knopf = liste.querySelector(".rf-picker-knopf");
const picker = liste.querySelector(".rf-picker");
const suche = liste.querySelector(".rf-suche");
const auf = offen => {
if (!picker) return;
picker.hidden = !offen;
knopf.setAttribute("aria-expanded", String(offen));
if (offen) {
suche.value = "";
liste.querySelectorAll("[data-wert]").forEach(k => { k.hidden = false; });
const gewaehlt = liste.querySelector("[data-wert][aria-pressed='true']");
if (gewaehlt) gewaehlt.scrollIntoView({ block: "nearest" });
suche.focus({ preventScroll: true });
}
};
if (knopf) knopf.addEventListener("click", () => auf(picker.hidden));
if (suche) {
suche.addEventListener("input", () => {
const text = suche.value.trim().toLowerCase();
liste.querySelectorAll("[data-wert]").forEach(k => { k.hidden = text !== "" && !k.textContent.toLowerCase().includes(text); });
});
suche.addEventListener("keydown", e => {
if (e.key === "Escape") { e.stopPropagation(); auf(false); knopf.focus(); }
if (e.key === "Enter") {
const erster = [...liste.querySelectorAll("[data-wert]")].find(k => !k.hidden);
if (erster) erster.click();
}
});
}
liste.querySelectorAll("[data-wert]").forEach(k => k.addEventListener("click", () => {
rfListeSetzen(liste, k.dataset.wert);
auf(false);
const params = {};
params[gruppe.dataset.param] = k.dataset.wert;
rfKommando(Number(gruppe.dataset.cmd), params, gruppe);
params[liste.dataset.param] = k.dataset.wert;
rfKommando(Number(liste.dataset.cmd), params, liste);
}));
});
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);
}));
if (karte.dataset.wled) rfWledLesen(karte);
}
/** Einen Eintrag einer Liste als gewaehlt zeigen; null = keiner. */
function rfListeSetzen(liste, wert) {
let name = null;
liste.querySelectorAll("[data-wert]").forEach(k => {
const ja = wert !== null && k.dataset.wert === String(wert);
k.setAttribute("aria-pressed", String(ja));
if (ja) name = k.textContent;
});
const anzeige = liste.querySelector("[data-rf-picker-wert]");
if (anzeige) anzeige.textContent = name === null ? "" : name;
}
/**
* Den wirklichen Zustand einer WLED-Lampe holen. In der Datenbank stehen nur
* an/aus, Helligkeit und Farbe vom letzten Poll; welcher Effekt und welches
* Preset laufen, weiss nur die Lampe selbst.
*/
function rfWledLesen(karte) {
rfRequest("wled", { actor: Number(karte.dataset.actor) }).then(stand => {
if (!stand || stand.error || !document.body.contains(karte)) return;
karte.dataset.an = stand.an ? "1" : "";
const hell = karte.querySelector(".rf-hell");
if (hell && stand.bri !== null && document.activeElement !== hell) hell.value = stand.bri;
if (stand.farbe && (stand.farbe[0] || stand.farbe[1] || stand.farbe[2])) {
const hex = "#" + stand.farbe.map(v => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("");
karte.style.setProperty("--farbe", hex);
const eigen = karte.querySelector(".rf-farbe-eigen input");
if (eigen) eigen.value = hex;
karte.querySelectorAll("[data-farbe]").forEach(k => k.setAttribute("aria-pressed", String(k.dataset.farbe === hex)));
}
karte.querySelectorAll(".rf-liste").forEach(liste => {
const wert = stand[liste.dataset.feld];
// WLED meldet ps = -1, wenn kein Preset aktiv ist.
if (typeof wert !== "undefined") rfListeSetzen(liste, wert === null || wert < 0 ? null : wert);
});
rfLampeZeichnen(karte);
rfStatus();
});
}
+29 -23
View File
@@ -462,7 +462,8 @@ function zeichneLicht($d)
. " 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 . "'>";
. " data-an='" . ($an ? "1" : "") . "'" . (strpos(strval($d["url"]), "wled://") === 0 ? " data-wled='1'" : "")
. " style='--farbe:" . $hex . "'>";
$html .= "<div class='rf-lampe-kopf'>"
. "<button type='button' class='rf-birne' data-rf-birne aria-pressed='" . ($an ? "true" : "false") . "'"
@@ -506,29 +507,34 @@ function zeichneLicht($d)
. "<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>";
} else {
$html .= "<label class='rf-auswahl'><span>" . htmlspecialchars($c["name"]) . "</span>"
. "<select class='form-select form-select-sm' data-cmd='" . intval($c["id"]) . "' data-param='" . intval($p["id"]) . "'>";
foreach ($p["options"] as $o) {
$html .= "<option value='" . htmlspecialchars($o["value"], ENT_QUOTES) . "'>" . htmlspecialchars($o["label"]) . "</option>";
}
$html .= "</select></label>";
}
// Presets, Effekte und andere Listen. Wenige Eintraege stehen als Chips
// da; lange Listen (WLED hat ueber 180 Effekte) bekommen einen Knopf mit
// dem aktuellen Eintrag, der eine durchsuchbare Liste aufklappt. Die
// native Auswahlliste zeichnet der Browser hell auf hell und passt nicht
// in das dunkle Fenster. Welcher Eintrag gerade gilt, liest
// js/solar/raumfenster.js bei WLED live aus (room.php?action=wled);
// data-feld ist der Parametername, "effect" oder "preset".
foreach ($listen as $c) {
$p = $c["params"][0];
$kopf = "<div class='rf-liste' data-cmd='" . intval($c["id"]) . "' data-param='" . intval($p["id"]) . "'"
. " data-feld='" . htmlspecialchars(strtolower($p["name"]), ENT_QUOTES) . "'>"
. "<span class='rf-reihe-titel'>" . htmlspecialchars($c["name"]) . "</span>";
$knoepfe = "";
foreach ($p["options"] as $o) {
$knoepfe .= "<button type='button' class='rf-chip' data-wert='" . htmlspecialchars($o["value"], ENT_QUOTES) . "'"
. " aria-pressed='false'>" . htmlspecialchars($o["label"]) . "</button>";
}
if (count($p["options"]) <= 16) {
$html .= $kopf . "<div class='rf-chips'>" . $knoepfe . "</div></div>";
} else {
$html .= $kopf
. "<button type='button' class='rf-picker-knopf' aria-expanded='false'>"
. "<span data-rf-picker-wert></span><i class='bi bi-chevron-down'></i></button>"
. "<div class='rf-picker' hidden>"
. "<input type='search' class='rf-suche' placeholder='" . htmlspecialchars($c["name"], ENT_QUOTES) . " suchen …'"
. " aria-label='" . htmlspecialchars($c["name"], ENT_QUOTES) . " suchen'>"
. "<div class='rf-picker-liste'>" . $knoepfe . "</div></div></div>";
}
$html .= "</details>";
}
return $html . "</div></div>";
}