diff --git a/docs/API.md b/docs/API.md index fcb64cd..c4a2442 100644 --- a/docs/API.md +++ b/docs/API.md @@ -42,7 +42,7 @@ Profiles are selected with **`POST /profiles//apply`**, which sets `current_ | Method | Path | Description | |--------|------|-------------| | GET | `/` | Main UI (`templates/index.html`) | -| GET | `/settings` | Settings page (`templates/settings.html`) | +| GET | `/settings/page` | Standalone settings page (`templates/settings.html`) | | GET | `/favicon.ico` | Empty response (204) | | GET | `/static/` | Static files under `src/static/` | @@ -72,7 +72,7 @@ Below, `` values are string identifiers used by the JSON stores (numeric str | PUT | `/settings/settings` | Merge keys into settings and save. Returns `{"message": "Settings updated successfully"}`. | | GET | `/settings/wifi/ap` | Saved Wi‑Fi AP fields: `saved_ssid`, `saved_password`, `saved_channel`, `active` (Pi: `active` is always false). | | POST | `/settings/wifi/ap` | Body: `ssid` (required), `password`, `channel` (1–11). Persists AP-related settings. | -| GET | `/settings/page` | Serves `templates/settings.html` (same page as `GET /settings` from the root app, for convenience). | +| GET | `/settings/page` | Serves `templates/settings.html`. | ### Devices — `/devices` diff --git a/led-driver b/led-driver index 3b38264..2fcaf2f 160000 --- a/led-driver +++ b/led-driver @@ -1 +1 @@ -Subproject commit 3b38264b7032ce9927d108e1a91adcb2e9827ee6 +Subproject commit 2fcaf2f06425cb668d527c525d6d406a372ce4b3 diff --git a/src/controllers/settings.py b/src/controllers/settings.py index 02795eb..9bfdb79 100644 --- a/src/controllers/settings.py +++ b/src/controllers/settings.py @@ -1,7 +1,11 @@ -from microdot import Microdot, send_file -from settings import Settings +import asyncio import json +from microdot import Microdot, send_file + +from models import wifi_ws_clients +from settings import Settings + controller = Microdot() settings = Settings() @@ -63,17 +67,36 @@ def _validate_wifi_channel(value): return ch +def _validate_global_brightness(value): + """Return int 0–255 or raise ValueError.""" + v = int(value) + if v < 0 or v > 255: + raise ValueError("global_brightness must be between 0 and 255") + return v + + @controller.put('/settings') async def update_settings(request): """Update general settings.""" try: data = request.json + global_brightness_changed = False for key, value in data.items(): if key == 'wifi_channel' and value is not None: settings[key] = _validate_wifi_channel(value) + elif key == 'global_brightness' and value is not None: + settings[key] = _validate_global_brightness(value) + global_brightness_changed = True else: settings[key] = value settings.save() + if global_brightness_changed: + try: + asyncio.get_running_loop().create_task( + wifi_ws_clients.broadcast_global_brightness_to_tcp_drivers() + ) + except RuntimeError: + pass return json.dumps({"message": "Settings updated successfully"}), 200, {'Content-Type': 'application/json'} except ValueError as e: return json.dumps({"error": str(e)}), 400 diff --git a/src/main.py b/src/main.py index e8fcef4..7817179 100644 --- a/src/main.py +++ b/src/main.py @@ -284,12 +284,6 @@ async def main(port=80): def index(request): """Serve the main web UI.""" return send_file('templates/index.html') - - # Serve settings page - @app.route('/settings') - def settings_page(request): - """Serve the settings page.""" - return send_file('templates/settings.html') # Favicon: avoid 404 in browser console (no file needed) @app.route('/favicon.ico') diff --git a/src/models/wifi_ws_clients.py b/src/models/wifi_ws_clients.py index fdd6a2c..8ecd7d2 100644 --- a/src/models/wifi_ws_clients.py +++ b/src/models/wifi_ws_clients.py @@ -84,6 +84,36 @@ def prune_stale_tcp_writers() -> None: _schedule_status_broadcast(ip, False) +def _global_brightness_message_text() -> str | None: + """v1 JSON line for saved zone UI brightness; works with shipping driver firmware (applies ``b`` in RAM).""" + global _settings + if _settings is None: + return None + try: + b = int(_settings.get("global_brightness", 255)) + except (TypeError, ValueError): + b = 255 + b = max(0, min(255, b)) + return json.dumps({"v": "1", "b": b}) + + +async def sync_global_brightness_to_driver(ip: str) -> bool: + """Push Pi-stored global brightness to one Wi-Fi driver over the outbound WebSocket.""" + text = _global_brightness_message_text() + if not text: + return False + return await send_json_line_to_ip(ip, text) + + +async def broadcast_global_brightness_to_tcp_drivers() -> None: + """Push saved global brightness to every connected Wi-Fi driver.""" + text = _global_brightness_message_text() + if not text: + return + for ip in list_connected_ips(): + await send_json_line_to_ip(ip, text) + + def _register_ws(ip: str, ws) -> None: key = normalize_tcp_peer_ip(ip) if not key: @@ -94,6 +124,15 @@ def _register_ws(ip: str, ws) -> None: _send_locks[key] = asyncio.Lock() _schedule_status_broadcast(key, True) print(f"[WS] driver connected {key!r}") + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + + async def _apply_saved_brightness(): + await sync_global_brightness_to_driver(key) + + loop.create_task(_apply_saved_brightness()) def unregister_tcp_writer(peer_ip: str, ws=None) -> str: diff --git a/src/settings.py b/src/settings.py index 43ab9c9..c28a894 100644 --- a/src/settings.py +++ b/src/settings.py @@ -73,6 +73,9 @@ class Settings(dict): # UART to ESP32 ESP-NOW bridge; default off (Wi-Fi drivers need no serial). if 'serial_enabled' not in self: self['serial_enabled'] = False + # Zone UI global brightness (0–255); shared across browsers/devices. + if 'global_brightness' not in self: + self['global_brightness'] = 255 def save(self): try: diff --git a/src/static/zones.js b/src/static/zones.js index 7baf1b7..216a3fe 100644 --- a/src/static/zones.js +++ b/src/static/zones.js @@ -2,32 +2,12 @@ let currentZoneId = null; let brightnessSendTimeout = null; -const UI_BRIGHTNESS_STORAGE_KEY = "led_controller_ui_brightness"; - function clamp255(n) { const v = parseInt(n, 10); if (Number.isNaN(v)) return null; return Math.max(0, Math.min(255, v)); } -function loadSavedUiBrightness() { - try { - const raw = localStorage.getItem(UI_BRIGHTNESS_STORAGE_KEY); - if (raw == null) return null; - return clamp255(raw); - } catch (_) { - return null; - } -} - -function persistUiBrightness(value) { - const v = clamp255(value); - if (v === null) return; - try { - localStorage.setItem(UI_BRIGHTNESS_STORAGE_KEY, String(v)); - } catch (_) {} -} - function applyBrightnessSliders(val) { const v = clamp255(val); if (v === null) return; @@ -37,9 +17,25 @@ function applyBrightnessSliders(val) { if (menuSlider) menuSlider.value = String(v); } +async function saveGlobalBrightnessToServer(val) { + try { + const res = await fetch("/settings/settings", { + method: "PUT", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ global_brightness: val }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + console.warn("global_brightness save failed:", err.error || res.status); + } + } catch (e) { + console.warn("global_brightness save failed:", e); + } +} + function sendZoneBrightness(value) { const val = Math.max(0, Math.min(255, parseInt(value, 10) || 0)); - persistUiBrightness(val); const headerSlider = document.getElementById('header-brightness-slider'); const menuSlider = document.getElementById('menu-brightness-slider'); if (headerSlider && String(headerSlider.value) !== String(val)) { @@ -54,6 +50,7 @@ function sendZoneBrightness(value) { brightnessSendTimeout = setTimeout(() => { (async () => { try { + await saveGlobalBrightnessToServer(val); const section = document.querySelector('.presets-section[data-zone-id]'); const names = typeof window.parseTabDeviceNames === 'function' ? window.parseTabDeviceNames(section) @@ -1027,22 +1024,41 @@ document.addEventListener('DOMContentLoaded', () => { const menuBrightnessSlider = document.getElementById('menu-brightness-slider'); const headerBrightnessSlider = document.getElementById('header-brightness-slider'); - const savedBr = loadSavedUiBrightness(); - if (savedBr !== null) { - applyBrightnessSliders(savedBr); - } - if (menuBrightnessSlider) { - menuBrightnessSlider.addEventListener('input', (e) => { - sendZoneBrightness(e.target.value); - }); - } - if (headerBrightnessSlider) { - headerBrightnessSlider.addEventListener('input', (e) => { - sendZoneBrightness(e.target.value); - }); - // Apply saved (or default) level to devices once the page is ready. - sendZoneBrightness(headerBrightnessSlider.value); - } + (async () => { + let fromServer = null; + try { + const res = await fetch('/settings', { + headers: { Accept: 'application/json' }, + credentials: 'same-origin', + }); + if (res.ok) { + const data = await res.json(); + const g = data.global_brightness; + if (typeof g === 'number' && g >= 0 && g <= 255) { + fromServer = Math.round(g); + } else if (g != null && g !== '') { + const n = parseInt(String(g), 10); + if (!Number.isNaN(n) && n >= 0 && n <= 255) { + fromServer = n; + } + } + } + } catch (_) {} + if (fromServer !== null) { + applyBrightnessSliders(fromServer); + } + if (menuBrightnessSlider) { + menuBrightnessSlider.addEventListener('input', (e) => { + sendZoneBrightness(e.target.value); + }); + } + if (headerBrightnessSlider) { + headerBrightnessSlider.addEventListener('input', (e) => { + sendZoneBrightness(e.target.value); + }); + sendZoneBrightness(headerBrightnessSlider.value); + } + })(); // When run/edit mode toggles, refresh tabs UI so edit actions show/hide immediately. document.querySelectorAll('.ui-mode-toggle').forEach((btn) => { diff --git a/tests/test_endpoints_pytest.py b/tests/test_endpoints_pytest.py index 2fcc7a8..4b5be47 100644 --- a/tests/test_endpoints_pytest.py +++ b/tests/test_endpoints_pytest.py @@ -347,6 +347,15 @@ def test_settings_controller(server): resp = c.put(f"{base_url}/settings/settings", json={"wifi_channel": 12}) assert resp.status_code == 400 + resp = c.put(f"{base_url}/settings/settings", json={"global_brightness": 42}) + assert resp.status_code == 200 + resp = c.get(f"{base_url}/settings") + assert resp.status_code == 200 + assert resp.json().get("global_brightness") == 42 + + resp = c.put(f"{base_url}/settings/settings", json={"global_brightness": 300}) + assert resp.status_code == 400 + def test_profiles_presets_zones_endpoints(server, monkeypatch): c: requests.Session = server["client"]