feat(settings): server global brightness and Wi-Fi driver resync
- Serve GET /settings as JSON by removing duplicate HTML route (use /settings/page for the standalone UI). - Save global_brightness via PUT; broadcast to connected drivers; push saved level when outbound WS connects. - Zones UI loads brightness from GET /settings only (no localStorage). - Bump led-driver submodule for settings.save on brightness with save flag. - Extend API doc and endpoint tests for global_brightness. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -42,7 +42,7 @@ Profiles are selected with **`POST /profiles/<id>/apply`**, which sets `current_
|
|||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET | `/` | Main UI (`templates/index.html`) |
|
| 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 | `/favicon.ico` | Empty response (204) |
|
||||||
| GET | `/static/<path>` | Static files under `src/static/` |
|
| GET | `/static/<path>` | Static files under `src/static/` |
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ Below, `<id>` 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"}`. |
|
| 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). |
|
| 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. |
|
| 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`
|
### Devices — `/devices`
|
||||||
|
|
||||||
|
|||||||
Submodule led-driver updated: 3b38264b70...2fcaf2f064
@@ -1,7 +1,11 @@
|
|||||||
from microdot import Microdot, send_file
|
import asyncio
|
||||||
from settings import Settings
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
from microdot import Microdot, send_file
|
||||||
|
|
||||||
|
from models import wifi_ws_clients
|
||||||
|
from settings import Settings
|
||||||
|
|
||||||
controller = Microdot()
|
controller = Microdot()
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
||||||
@@ -63,17 +67,36 @@ def _validate_wifi_channel(value):
|
|||||||
return ch
|
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')
|
@controller.put('/settings')
|
||||||
async def update_settings(request):
|
async def update_settings(request):
|
||||||
"""Update general settings."""
|
"""Update general settings."""
|
||||||
try:
|
try:
|
||||||
data = request.json
|
data = request.json
|
||||||
|
global_brightness_changed = False
|
||||||
for key, value in data.items():
|
for key, value in data.items():
|
||||||
if key == 'wifi_channel' and value is not None:
|
if key == 'wifi_channel' and value is not None:
|
||||||
settings[key] = _validate_wifi_channel(value)
|
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:
|
else:
|
||||||
settings[key] = value
|
settings[key] = value
|
||||||
settings.save()
|
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'}
|
return json.dumps({"message": "Settings updated successfully"}), 200, {'Content-Type': 'application/json'}
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return json.dumps({"error": str(e)}), 400
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|||||||
@@ -285,12 +285,6 @@ async def main(port=80):
|
|||||||
"""Serve the main web UI."""
|
"""Serve the main web UI."""
|
||||||
return send_file('templates/index.html')
|
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)
|
# Favicon: avoid 404 in browser console (no file needed)
|
||||||
@app.route('/favicon.ico')
|
@app.route('/favicon.ico')
|
||||||
def favicon(request):
|
def favicon(request):
|
||||||
|
|||||||
@@ -84,6 +84,36 @@ def prune_stale_tcp_writers() -> None:
|
|||||||
_schedule_status_broadcast(ip, False)
|
_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:
|
def _register_ws(ip: str, ws) -> None:
|
||||||
key = normalize_tcp_peer_ip(ip)
|
key = normalize_tcp_peer_ip(ip)
|
||||||
if not key:
|
if not key:
|
||||||
@@ -94,6 +124,15 @@ def _register_ws(ip: str, ws) -> None:
|
|||||||
_send_locks[key] = asyncio.Lock()
|
_send_locks[key] = asyncio.Lock()
|
||||||
_schedule_status_broadcast(key, True)
|
_schedule_status_broadcast(key, True)
|
||||||
print(f"[WS] driver connected {key!r}")
|
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:
|
def unregister_tcp_writer(peer_ip: str, ws=None) -> str:
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ class Settings(dict):
|
|||||||
# UART to ESP32 ESP-NOW bridge; default off (Wi-Fi drivers need no serial).
|
# UART to ESP32 ESP-NOW bridge; default off (Wi-Fi drivers need no serial).
|
||||||
if 'serial_enabled' not in self:
|
if 'serial_enabled' not in self:
|
||||||
self['serial_enabled'] = False
|
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):
|
def save(self):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -2,32 +2,12 @@
|
|||||||
let currentZoneId = null;
|
let currentZoneId = null;
|
||||||
let brightnessSendTimeout = null;
|
let brightnessSendTimeout = null;
|
||||||
|
|
||||||
const UI_BRIGHTNESS_STORAGE_KEY = "led_controller_ui_brightness";
|
|
||||||
|
|
||||||
function clamp255(n) {
|
function clamp255(n) {
|
||||||
const v = parseInt(n, 10);
|
const v = parseInt(n, 10);
|
||||||
if (Number.isNaN(v)) return null;
|
if (Number.isNaN(v)) return null;
|
||||||
return Math.max(0, Math.min(255, v));
|
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) {
|
function applyBrightnessSliders(val) {
|
||||||
const v = clamp255(val);
|
const v = clamp255(val);
|
||||||
if (v === null) return;
|
if (v === null) return;
|
||||||
@@ -37,9 +17,25 @@ function applyBrightnessSliders(val) {
|
|||||||
if (menuSlider) menuSlider.value = String(v);
|
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) {
|
function sendZoneBrightness(value) {
|
||||||
const val = Math.max(0, Math.min(255, parseInt(value, 10) || 0));
|
const val = Math.max(0, Math.min(255, parseInt(value, 10) || 0));
|
||||||
persistUiBrightness(val);
|
|
||||||
const headerSlider = document.getElementById('header-brightness-slider');
|
const headerSlider = document.getElementById('header-brightness-slider');
|
||||||
const menuSlider = document.getElementById('menu-brightness-slider');
|
const menuSlider = document.getElementById('menu-brightness-slider');
|
||||||
if (headerSlider && String(headerSlider.value) !== String(val)) {
|
if (headerSlider && String(headerSlider.value) !== String(val)) {
|
||||||
@@ -54,6 +50,7 @@ function sendZoneBrightness(value) {
|
|||||||
brightnessSendTimeout = setTimeout(() => {
|
brightnessSendTimeout = setTimeout(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
await saveGlobalBrightnessToServer(val);
|
||||||
const section = document.querySelector('.presets-section[data-zone-id]');
|
const section = document.querySelector('.presets-section[data-zone-id]');
|
||||||
const names = typeof window.parseTabDeviceNames === 'function'
|
const names = typeof window.parseTabDeviceNames === 'function'
|
||||||
? window.parseTabDeviceNames(section)
|
? window.parseTabDeviceNames(section)
|
||||||
@@ -1027,22 +1024,41 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
const menuBrightnessSlider = document.getElementById('menu-brightness-slider');
|
const menuBrightnessSlider = document.getElementById('menu-brightness-slider');
|
||||||
const headerBrightnessSlider = document.getElementById('header-brightness-slider');
|
const headerBrightnessSlider = document.getElementById('header-brightness-slider');
|
||||||
const savedBr = loadSavedUiBrightness();
|
(async () => {
|
||||||
if (savedBr !== null) {
|
let fromServer = null;
|
||||||
applyBrightnessSliders(savedBr);
|
try {
|
||||||
}
|
const res = await fetch('/settings', {
|
||||||
if (menuBrightnessSlider) {
|
headers: { Accept: 'application/json' },
|
||||||
menuBrightnessSlider.addEventListener('input', (e) => {
|
credentials: 'same-origin',
|
||||||
sendZoneBrightness(e.target.value);
|
});
|
||||||
});
|
if (res.ok) {
|
||||||
}
|
const data = await res.json();
|
||||||
if (headerBrightnessSlider) {
|
const g = data.global_brightness;
|
||||||
headerBrightnessSlider.addEventListener('input', (e) => {
|
if (typeof g === 'number' && g >= 0 && g <= 255) {
|
||||||
sendZoneBrightness(e.target.value);
|
fromServer = Math.round(g);
|
||||||
});
|
} else if (g != null && g !== '') {
|
||||||
// Apply saved (or default) level to devices once the page is ready.
|
const n = parseInt(String(g), 10);
|
||||||
sendZoneBrightness(headerBrightnessSlider.value);
|
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.
|
// When run/edit mode toggles, refresh tabs UI so edit actions show/hide immediately.
|
||||||
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
|
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
|
||||||
|
|||||||
@@ -347,6 +347,15 @@ def test_settings_controller(server):
|
|||||||
resp = c.put(f"{base_url}/settings/settings", json={"wifi_channel": 12})
|
resp = c.put(f"{base_url}/settings/settings", json={"wifi_channel": 12})
|
||||||
assert resp.status_code == 400
|
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):
|
def test_profiles_presets_zones_endpoints(server, monkeypatch):
|
||||||
c: requests.Session = server["client"]
|
c: requests.Session = server["client"]
|
||||||
|
|||||||
Reference in New Issue
Block a user