diff --git a/README.md b/README.md index db9e4dc..e0e6bdd 100644 --- a/README.md +++ b/README.md @@ -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=`, `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=`, `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`, `/changeSetTemp` in `room.php`) — nichts setzt mehr voraus, dass ein -Thermostat unter `Raumtemp//` 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;//` 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 diff --git a/ajax/room.php b/ajax/room.php index 8e8a86f..0dc0b06 100644 --- a/ajax/room.php +++ b/ajax/room.php @@ -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 diff --git a/css/solar.css b/css/solar.css index 3073461..18aba3a 100644 --- a/css/solar.css +++ b/css/solar.css @@ -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; } diff --git a/js/solar/raumfenster.js b/js/solar/raumfenster.js index a9711bb..6370ea8 100644 --- a/js/solar/raumfenster.js +++ b/js/solar/raumfenster.js @@ -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(); + }); } diff --git a/restricted/roomControls.php b/restricted/roomControls.php index 0e3c04b..dac0b8e 100644 --- a/restricted/roomControls.php +++ b/restricted/roomControls.php @@ -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 .= "
" . "
"; } - if ($listen) { - $html .= "
" . htmlspecialchars(implode(" & ", array_map(function ($c) { return $c["name"]; }, $listen))) . ""; - foreach ($listen as $c) { - $p = $c["params"][0]; - // Wenige Eintraege als Chips, lange Listen (WLED hat ueber 180 - // Effekte) als Auswahl - Chips waeren dort eine Wand. - if (count($p["options"]) <= 16) { - $html .= "
"; - foreach ($p["options"] as $o) { - $html .= ""; - } - $html .= "
"; - } else { - $html .= ""; - } + // 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 = "
" + . "" . htmlspecialchars($c["name"]) . ""; + $knoepfe = ""; + foreach ($p["options"] as $o) { + $knoepfe .= ""; + } + if (count($p["options"]) <= 16) { + $html .= $kopf . "
" . $knoepfe . "
"; + } else { + $html .= $kopf + . "" + . ""; } - $html .= "
"; } return $html . ""; }