chore(release): beta-1.03

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-10 16:55:07 +12:00
parent 822d9d8e01
commit c29325b393
22 changed files with 1816 additions and 184 deletions

View File

@@ -1 +1 @@
{"1": {"name": "Main Group", "devices": ["1", "2", "3"]}, "2": {"name": "Accent Group", "devices": ["4", "5"]}}
{"1": {"name": "Main Group", "devices": ["188b0e1560a8"], "wifi_driver_display_name": "desk", "wifi_driver_num_leds": 59, "wifi_color_order": "rgb", "wifi_startup_mode": "default", "pattern": "on", "colors": ["000000", "FF0000"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "output_brightness": 255}}

View File

@@ -96,7 +96,7 @@
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": false
"supports_manual": true
},
"radiate": {
"n1": "Node spacing (LEDs)",

File diff suppressed because one or more lines are too long

View File

@@ -2,10 +2,14 @@ from microdot import Microdot
from models.device import (
Device,
derive_device_mac,
normalize_mac,
validate_device_transport,
validate_device_type,
)
from models.group import Group
from models.transport import get_current_sender
from settings import Settings
from util.brightness_combine import effective_brightness_for_mac
from models.wifi_ws_clients import (
normalize_tcp_peer_ip,
send_json_line_to_ip,
@@ -52,8 +56,28 @@ def _compact_v1_json(*, presets=None, select=None, save=False):
# Seconds after identify blink before selecting built-in ``off`` (tests may monkeypatch).
IDENTIFY_OFF_DELAY_S = 2.0
def _validate_output_brightness(value):
if value is None:
return None
try:
b = int(value)
except (TypeError, ValueError):
raise ValueError("output_brightness must be an integer 0255")
if b < 0 or b > 255:
raise ValueError("output_brightness must be between 0 and 255")
return b
def _brightness_save_message_json(b_val: int) -> str:
b_val = max(0, min(255, int(b_val)))
return json.dumps({"v": "1", "b": b_val, "save": True}, separators=(",", ":"))
controller = Microdot()
devices = Device()
_group_registry = Group()
_pi_settings = Settings()
def _device_live_connected(dev_dict):
@@ -154,6 +178,42 @@ async def list_devices(request):
return json.dumps(devices_data), 200, {"Content-Type": "application/json"}
@controller.post("/resolve-brightness")
async def resolve_brightness_batch(request):
"""
POST JSON ``{ \"macs\": [\"..\"], \"zone_brightness\": optional 0255 }``.
Returns ``{ \"values\": { mac: combined_int } }`` — global × group(s) × device × zone (optional).
"""
try:
data = request.json or {}
except Exception:
data = {}
macs = data.get("macs")
if not isinstance(macs, list):
return json.dumps({"error": "macs must be an array"}), 400, {
"Content-Type": "application/json",
}
zb = None
if isinstance(data, dict) and data.get("zone_brightness") is not None:
try:
zb = _validate_output_brightness(data.get("zone_brightness"))
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
values = {}
for raw in macs:
m = normalize_mac(str(raw))
if not m:
continue
values[m] = effective_brightness_for_mac(
_pi_settings,
_group_registry,
devices,
m,
zone_brightness=zb,
)
return json.dumps({"values": values}), 200, {"Content-Type": "application/json"}
@controller.get("/<id>")
async def get_device(request, id):
"""Get a device by ID (includes ``connected`` for live Wi-Fi WebSocket presence)."""
@@ -239,7 +299,17 @@ async def update_device(request, id):
data["transport"] = validate_device_transport(data.get("transport"))
if "zones" in data and isinstance(data["zones"], list):
data["zones"] = [str(t) for t in data["zones"]]
if "output_brightness" in data:
data["output_brightness"] = _validate_output_brightness(data.get("output_brightness"))
prev_doc = devices.read(id)
if devices.update(id, data):
if prev_doc and "name" in data:
on = str(prev_doc.get("name") or "").strip()
nn = str(data.get("name") or "").strip()
if on and nn and on != nn:
from util.beat_driver_route import remap_beat_route_device_name
remap_beat_route_device_name(on, nn)
return json.dumps(devices.read(id)), 200, {"Content-Type": "application/json"}
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
@@ -320,6 +390,120 @@ async def identify_device(request, id):
}
@controller.post("/<id>/brightness")
async def push_device_output_brightness(request, id):
"""
Push combined brightness to the driver: global × group(s) × device × optional ``zone_brightness``
in JSON body — single ``b`` (``v``/``b``/``save``). WiFi or ESPNOW.
"""
dev = devices.read(id)
if not dev:
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
body = request.json or {}
zb = None
if isinstance(body, dict) and body.get("zone_brightness") is not None:
try:
zb = _validate_output_brightness(body.get("zone_brightness"))
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
b_val = effective_brightness_for_mac(
_pi_settings,
_group_registry,
devices,
id,
zone_brightness=zb,
)
msg = _brightness_save_message_json(b_val)
transport = (dev.get("transport") or "espnow").strip().lower()
if transport == "wifi":
ip = normalize_tcp_peer_ip(str(dev.get("address") or ""))
if not ip:
return json.dumps({"error": "Device has no IP address"}), 400, {
"Content-Type": "application/json",
}
ok = await send_json_line_to_ip(ip, msg)
if not ok:
return json.dumps({"error": "Wi-Fi driver not connected"}), 503, {
"Content-Type": "application/json",
}
else:
sender = get_current_sender()
if not sender:
return json.dumps({"error": "Transport not configured"}), 503, {
"Content-Type": "application/json",
}
try:
await sender.send(msg, addr=id)
except Exception as e:
return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}
return json.dumps({"message": "brightness sent", "brightness": b_val}), 200, {
"Content-Type": "application/json",
}
@controller.post("/<id>/driver-config")
async def push_driver_config(request, id):
"""
Push ``device_config`` to a WiFi LED driver over WebSocket.
Body JSON: optional ``name``, ``num_leds``, ``color_order``, ``startup_mode`` (default|last|off).
"""
dev = devices.read(id)
if not dev:
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
if (dev.get("transport") or "").lower() != "wifi":
return json.dumps({"error": "driver-config is only for Wi-Fi devices"}), 400, {
"Content-Type": "application/json",
}
wifi_ip = str(dev.get("address") or "").strip()
if not wifi_ip:
return json.dumps({"error": "Device has no IP address"}), 400, {
"Content-Type": "application/json",
}
body = request.json or {}
dc = {}
if isinstance(body.get("name"), str) and body["name"].strip():
dc["name"] = body["name"].strip()
if "num_leds" in body:
try:
n = int(body["num_leds"])
if 1 <= n <= 2048:
dc["num_leds"] = n
except (TypeError, ValueError):
pass
if isinstance(body.get("color_order"), str):
co = body["color_order"].strip().lower()
if co in ("rgb", "rbg", "grb", "gbr", "brg", "bgr"):
dc["color_order"] = co
if isinstance(body.get("startup_mode"), str):
sm = body["startup_mode"].strip().lower()
if sm in ("default", "last", "off"):
dc["startup_mode"] = sm
if not dc:
return json.dumps(
{
"error": "Provide at least one of name, num_leds, color_order, startup_mode"
}
), 400, {"Content-Type": "application/json"}
msg = json.dumps(
{"v": "1", "device_config": dc, "save": True}, separators=(",", ":")
)
ok = await send_json_line_to_ip(wifi_ip, msg)
if not ok:
return json.dumps({"error": "Wi-Fi driver not connected"}), 503, {
"Content-Type": "application/json",
}
return json.dumps({"message": "driver-config sent"}), 200, {
"Content-Type": "application/json",
}
@controller.post("/<id>/patterns/push")
async def push_patterns_ota(request, id):
"""

View File

@@ -1,9 +1,16 @@
from microdot import Microdot
from models.group import Group
from models.device import Device
from models.transport import get_current_sender
from models.wifi_ws_clients import normalize_tcp_peer_ip, send_json_line_to_ip
from settings import Settings
from util.brightness_combine import effective_brightness_for_mac
import json
controller = Microdot()
groups = Group()
devices = Device()
_pi_settings = Settings()
@controller.get('')
async def list_groups(request):
@@ -48,3 +55,150 @@ async def delete_group(request, id):
if groups.delete(id):
return json.dumps({"message": "Group deleted successfully"}), 200
return json.dumps({"error": "Group not found"}), 404
def _group_driver_config_payload(doc):
"""Build ``device_config`` dict from stored group WiFi defaults (non-empty only)."""
dc = {}
if not isinstance(doc, dict):
return dc
nm = doc.get("wifi_driver_display_name")
if isinstance(nm, str) and nm.strip():
dc["name"] = nm.strip()
nled = doc.get("wifi_driver_num_leds")
if nled is not None:
try:
n = int(nled)
if 1 <= n <= 2048:
dc["num_leds"] = n
except (TypeError, ValueError):
pass
co = doc.get("wifi_color_order")
if isinstance(co, str):
c = co.strip().lower()
if c in ("rgb", "rbg", "grb", "gbr", "brg", "bgr"):
dc["color_order"] = c
sm = doc.get("wifi_startup_mode")
if isinstance(sm, str):
s = sm.strip().lower()
if s in ("default", "last", "off"):
dc["startup_mode"] = s
return dc
@controller.post('/<id>/driver-config')
async def push_group_driver_config(request, id):
"""
Push group WiFi defaults to every WiFi device listed in the group (TCP WebSocket).
Uses stored ``wifi_*`` fields on the group; optional JSON body may override for this send only.
"""
gdoc = groups.read(id)
if not gdoc:
return json.dumps({"error": "Group not found"}), 404
body = request.json or {}
merged = dict(gdoc)
if isinstance(body, dict):
for k in (
"wifi_driver_display_name",
"wifi_driver_num_leds",
"wifi_color_order",
"wifi_startup_mode",
):
if k in body:
merged[k] = body[k]
dc = _group_driver_config_payload(merged)
if not dc:
return json.dumps(
{"error": "No driver defaults on this group (set display name, LEDs, colour order, or power-on pattern)"}
), 400, {"Content-Type": "application/json"}
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
sent = 0
errors = []
for mac in mac_list:
m = str(mac).strip().lower().replace(":", "").replace("-", "")
if len(m) != 12:
continue
dev = devices.read(m)
if not dev:
errors.append({"mac": m, "error": "not in registry"})
continue
if (dev.get("transport") or "").lower() != "wifi":
continue
ip = normalize_tcp_peer_ip(str(dev.get("address") or ""))
if not ip:
errors.append({"mac": m, "error": "no IP"})
continue
msg = json.dumps(
{"v": "1", "device_config": dc, "save": True}, separators=(",", ":")
)
ok = await send_json_line_to_ip(ip, msg)
if ok:
sent += 1
else:
errors.append({"mac": m, "error": "driver not connected"})
return json.dumps(
{"message": "driver-config sent", "sent": sent, "errors": errors}
), 200, {"Content-Type": "application/json"}
def _brightness_save_message_json(b_val: int) -> str:
b_val = max(0, min(255, int(b_val)))
return json.dumps({"v": "1", "b": b_val, "save": True}, separators=(",", ":"))
@controller.post('/<id>/brightness')
async def push_group_output_brightness(request, id):
"""
Push combined brightness (global × group(s) × device) to each member — one ``b`` per device.
"""
gdoc = groups.read(id)
if not gdoc:
return json.dumps({"error": "Group not found"}), 404
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
sent = 0
errors = []
for mac in mac_list:
m = str(mac).strip().lower().replace(":", "").replace("-", "")
if len(m) != 12:
continue
dev = devices.read(m)
if not dev:
errors.append({"mac": m, "error": "not in registry"})
continue
b_val = effective_brightness_for_mac(
_pi_settings,
groups,
devices,
m,
zone_brightness=None,
)
msg = _brightness_save_message_json(b_val)
transport = (dev.get("transport") or "espnow").strip().lower()
if transport == "wifi":
ip = normalize_tcp_peer_ip(str(dev.get("address") or ""))
if not ip:
errors.append({"mac": m, "error": "no IP"})
continue
ok = await send_json_line_to_ip(ip, msg)
if ok:
sent += 1
else:
errors.append({"mac": m, "error": "driver not connected"})
else:
sender = get_current_sender()
if not sender:
errors.append({"mac": m, "error": "transport not configured"})
continue
try:
await sender.send(msg, addr=m)
sent += 1
except Exception as e:
errors.append({"mac": m, "error": str(e)})
return json.dumps(
{"message": "brightness sent", "sent": sent, "errors": errors}
), 200, {"Content-Type": "application/json"}

View File

@@ -318,7 +318,7 @@ async def push_driver_messages(request, session):
try:
from util.beat_driver_route import sync_beat_route_from_push_sequence
sync_beat_route_from_push_sequence(seq)
sync_beat_route_from_push_sequence(seq, target_macs=target_list)
except Exception:
pass

View File

@@ -290,6 +290,7 @@ async def create_zone(request, session):
ids_str = request.form.get("ids", "1").strip()
names = [i.strip() for i in ids_str.split(",") if i.strip()]
preset_ids = None
group_ids = []
else:
data = request.json or {}
name = data.get("name", "")
@@ -297,11 +298,18 @@ async def create_zone(request, session):
if names is None:
names = data.get("ids")
preset_ids = data.get("presets", None)
group_ids = data.get("group_ids")
if group_ids is None:
group_ids = []
if isinstance(group_ids, list):
group_ids = [str(x) for x in group_ids if x is not None]
else:
group_ids = []
if not name:
return json.dumps({"error": "Zone name cannot be empty"}), 400
zid = zones.create(name, names, preset_ids)
zid = zones.create(name, names, preset_ids, group_ids)
profile_id = get_current_profile_id(session)
if profile_id:
@@ -333,7 +341,12 @@ async def clone_zone(request, session, id):
data = request.json or {}
source_name = source.get("name") or f"Zone {id}"
new_name = data.get("name") or f"{source_name} Copy"
clone_id = zones.create(new_name, source.get("names"), source.get("presets"))
clone_id = zones.create(
new_name,
source.get("names"),
source.get("presets"),
source.get("group_ids"),
)
extra = {k: v for k, v in source.items() if k not in ("name", "names", "presets")}
if extra:
zones.update(clone_id, extra)

View File

@@ -38,6 +38,29 @@ def normalize_mac(mac):
return None
def resolve_device_mac_for_select_routing(devices, name_key):
"""
Map a v1 ``select`` map key to device storage id (MAC).
Matches the registry **name**, or ``led-<12hex>`` as a MAC hint (default driver
name form) so routing still works after the device is renamed in the registry.
"""
k = str(name_key or "").strip()
if not k:
return None
for did in devices.list():
doc = devices.read(did) or {}
if str(doc.get("name") or "").strip() == k:
m = normalize_mac(did)
if m:
return m
if k.startswith("led-"):
m = normalize_mac(k[4:])
if m and devices.read(m):
return m
return None
def derive_device_mac(mac=None, address=None, transport="espnow"):
"""
Resolve the device MAC used as storage id.

View File

@@ -1,14 +1,66 @@
from models.model import Model
class Group(Model):
"""Device groups (members + optional WiFi driver defaults); also pattern fields for sequences."""
def __init__(self):
super().__init__()
def load(self):
super().load()
changed = False
for gid, doc in list(self.items()):
if not isinstance(doc, dict):
continue
if self._migrate_record(doc):
changed = True
if changed:
self.save()
def _migrate_record(self, doc):
changed = False
raw_dev = doc.get("devices")
if raw_dev is None:
doc["devices"] = []
changed = True
elif isinstance(raw_dev, list):
norm = []
for x in raw_dev:
if x is None:
continue
s = str(x).strip().lower().replace(":", "").replace("-", "")
if len(s) == 12 and all(c in "0123456789abcdef" for c in s):
norm.append(s)
else:
norm.append(str(x).strip())
if norm != raw_dev:
doc["devices"] = norm
changed = True
for key in (
"wifi_driver_display_name",
"wifi_driver_num_leds",
"wifi_color_order",
"wifi_startup_mode",
):
if key not in doc:
doc[key] = None
changed = True
if "output_brightness" not in doc:
doc["output_brightness"] = 255
changed = True
return changed
def create(self, name=""):
next_id = self.get_next_id()
self[next_id] = {
"name": name,
"devices": [],
"wifi_driver_display_name": None,
"wifi_driver_num_leds": None,
"wifi_color_order": None,
"wifi_startup_mode": None,
"output_brightness": 255,
"pattern": "on",
"colors": ["000000", "FF0000"],
"brightness": 100,
@@ -22,7 +74,7 @@ class Group(Model):
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0
"n8": 0,
}
self.save()
return next_id

View File

@@ -261,33 +261,33 @@ async def _driver_connection_loop(ip: str) -> None:
retry_interval_s = 2.0
retry_interval_s = max(0.2, retry_interval_s)
try:
retry_window_s = float(_settings.get("wifi_driver_connect_retry_window_s", 120.0))
max_boot_attempts = int(_settings.get("wifi_driver_initial_connect_attempts", 4))
except (TypeError, ValueError):
retry_window_s = 120.0
retry_window_s = max(5.0, retry_window_s)
max_boot_attempts = 4
max_boot_attempts = max(1, max_boot_attempts)
try:
open_timeout = float(_settings.get("wifi_driver_ws_open_timeout", 45.0))
except (TypeError, ValueError):
open_timeout = 45.0
open_timeout = max(5.0, open_timeout)
loop = asyncio.get_running_loop()
stagger = _stagger_delay_s_for_ip(ip)
if stagger > 0:
await asyncio.sleep(stagger)
# Only bound boot-time: after we have connected once, keep retrying (Wi-Fi drops, reboots).
connected_once = False
deadline = loop.time() + retry_window_s
boot_attempts = 0
try:
while True:
now = loop.time()
if not connected_once and now >= deadline:
print(
f"[WS] driver {ip} still unreachable after {int(retry_window_s)}s "
f"(initial window); stopping until next UDP hello / registry prime"
)
break
if not connected_once:
if boot_attempts >= max_boot_attempts:
print(
f"[WS] driver {ip} still unreachable after {max_boot_attempts} "
f"initial dial attempt(s); stopping until next UDP hello / registry prime"
)
break
boot_attempts += 1
try:
print(f"[WS] connecting to {uri!r}")
async with websockets.connect(

View File

@@ -27,11 +27,27 @@ class Zone(Model):
Zone._migration_checked = True
super().__init__()
def create(self, name="", names=None, presets=None):
def load(self):
super().load()
changed = False
for zid, doc in list(self.items()):
if not isinstance(doc, dict):
continue
if "group_ids" not in doc:
doc["group_ids"] = []
changed = True
if changed:
self.save()
def create(self, name="", names=None, presets=None, group_ids=None):
next_id = self.get_next_id()
gid_list = []
if isinstance(group_ids, list):
gid_list = [str(x) for x in group_ids if x is not None]
self[next_id] = {
"name": name,
"names": names if names else [],
"group_ids": gid_list,
"presets": presets if presets else [],
"default_preset": None,
"brightness": 255,

View File

@@ -57,8 +57,8 @@ class Settings(dict):
# down (0 disables). Helps drivers that reconnect after seeing traffic on 8766.
if 'wifi_driver_hello_interval_s' not in self:
self['wifi_driver_hello_interval_s'] = 10.0
# Outbound WebSocket dial: total seconds to keep trying before first success
# (many devices booting at once need more than a short window).
# Legacy key (no longer read): initial outbound dial limit uses
# wifi_driver_initial_connect_attempts instead.
if 'wifi_driver_connect_retry_window_s' not in self:
self['wifi_driver_connect_retry_window_s'] = 120.0
# Spread outbound dials 0..N s by device IP so six+ drivers do not all hit the AP at once.
@@ -70,6 +70,9 @@ class Settings(dict):
# Pause between outbound WebSocket dial attempts (seconds).
if 'wifi_driver_connect_retry_interval_s' not in self:
self['wifi_driver_connect_retry_interval_s'] = 2.0
# Outbound dial attempts to the saved driver IP before first success; then wait for UDP discovery.
if 'wifi_driver_initial_connect_attempts' not in self:
self['wifi_driver_initial_connect_attempts'] = 4
# UART to ESP32 ESP-NOW bridge; default off (Wi-Fi drivers need no serial).
if 'serial_enabled' not in self:
self['serial_enabled'] = False

View File

@@ -2,6 +2,48 @@
let pollTimer = null;
let lastBeatSeq = 0;
const STORAGE_KEY = "led-controller-audio-restore";
const STORAGE_VERSION = 1;
function readRestorePrefs() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const o = JSON.parse(raw);
if (!o || o.v !== STORAGE_VERSION || !o.restore) return null;
return {
override: typeof o.override === "string" ? o.override : "",
select: typeof o.select === "string" ? o.select : "",
};
} catch {
return null;
}
}
function writeRestorePrefs(override, select) {
try {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
v: STORAGE_VERSION,
restore: true,
override: override || "",
select: select || "",
}),
);
} catch (e) {
console.warn("audio restore prefs save failed", e);
}
}
function clearRestorePrefs() {
try {
localStorage.removeItem(STORAGE_KEY);
} catch (e) {
console.warn("audio restore prefs clear failed", e);
}
}
function el(id) {
return document.getElementById(id);
}
@@ -31,19 +73,27 @@
node.textContent = `${label}${conf}`;
}
function setTopBpmVisible(on) {
const top = el("audio-top-indicator");
if (!top) return;
top.classList.toggle("audio-running", !!on);
}
function flashBeat() {
const node = el("audio-beat-flash");
if (!node) return;
node.classList.add("active");
setTimeout(() => node.classList.remove("active"), 80);
const top = el("audio-top-indicator");
if (top) {
if (top && top.classList.contains("audio-running")) {
top.classList.add("flash");
setTimeout(() => top.classList.remove("flash"), 90);
}
}
async function stopAudio() {
/** Stop detector and polling; does not clear “resume on load” prefs (used before restart). */
async function stopAudioOnly() {
setTopBpmVisible(false);
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
@@ -57,6 +107,12 @@
}
}
/** User-initiated stop: also forget auto-restart on next page load. */
async function stopAudio() {
await stopAudioOnly();
clearRestorePrefs();
}
async function pollStatus() {
try {
const res = await fetch("/api/audio/status");
@@ -68,12 +124,14 @@
node.textContent = String(status.error).trim().slice(0, 120);
}
updateBpmDisplay(null);
setTopBpmVisible(!!status.running);
if (!status.running && pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
return;
}
setTopBpmVisible(!!status.running);
updateBpmDisplay(status.bpm);
updateHitTypeDisplay(status.beat_type, Number(status.beat_type_confidence));
const seq = Number(status.beat_seq || 0);
@@ -88,7 +146,7 @@
}
async function startAudio() {
await stopAudio();
await stopAudioOnly();
const override = (el("audio-device-override")?.value || "").trim();
const selected = el("audio-device-select")?.value || "";
const rawDevice = override !== "" ? override : selected;
@@ -103,6 +161,7 @@
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "Failed to start audio detector");
}
writeRestorePrefs(override, selected);
updateBpmDisplay(null);
updateHitTypeDisplay("unknown", NaN);
updateBeatCounter(0);
@@ -211,8 +270,30 @@
}
}
document.addEventListener("DOMContentLoaded", () => {
async function restoreAudioIfNeeded() {
if (pollTimer) return;
const prefs = readRestorePrefs();
if (!prefs) return;
const ov = el("audio-device-override");
const sel = el("audio-device-select");
if (ov) ov.value = prefs.override || "";
try {
await refreshDevices();
} catch (e) {
console.warn("audio restore refresh devices failed", e);
}
if (sel && prefs.select) sel.value = prefs.select;
try {
await startAudio();
} catch (e) {
console.warn("audio auto-restart failed", e);
clearRestorePrefs();
}
}
document.addEventListener("DOMContentLoaded", async () => {
bind();
resumePollingIfDetectorRunning();
await resumePollingIfDetectorRunning();
await restoreAudioIfNeeded();
});
})();

View File

@@ -149,8 +149,10 @@ function applyTransportVisibility(transport) {
const isWifi = transport === 'wifi';
const esp = document.getElementById('edit-device-address-espnow');
const wifiWrap = document.getElementById('edit-device-address-wifi-wrap');
const drvWrap = document.getElementById('edit-device-wifi-driver-wrap');
if (esp) esp.hidden = isWifi;
if (wifiWrap) wifiWrap.hidden = !isWifi;
if (drvWrap) drvWrap.hidden = !isWifi;
}
function getAddressForPayload(transport) {
@@ -166,6 +168,63 @@ function getAddressForPayload(transport) {
return hex || null;
}
function collectDeviceEditPayload() {
const idInput = document.getElementById('edit-device-id');
const nameInput = document.getElementById('edit-device-name');
const typeSel = document.getElementById('edit-device-type');
const transportSel = document.getElementById('edit-device-transport');
const devId = idInput && idInput.value;
const transport = (transportSel && transportSel.value) || 'espnow';
const address = getAddressForPayload(transport);
const obr = document.getElementById('edit-device-output-brightness');
let output_brightness = 255;
if (obr && obr.value !== '') {
const n = parseInt(obr.value, 10);
output_brightness = !Number.isNaN(n) ? Math.max(0, Math.min(255, n)) : 255;
}
const payload = {
name: nameInput ? nameInput.value.trim() : '',
type: (typeSel && typeSel.value) || 'led',
transport,
address,
output_brightness,
};
if (transport === 'wifi') {
const dn = document.getElementById('edit-device-wifi-driver-name');
const nl = document.getElementById('edit-device-wifi-num-leds');
const co = document.getElementById('edit-device-wifi-color-order');
const ws = document.getElementById('edit-device-wifi-startup-mode');
if (dn && dn.value.trim()) payload.wifi_driver_display_name = dn.value.trim();
if (nl && nl.value !== '') {
const n = parseInt(nl.value, 10);
if (!Number.isNaN(n) && n >= 1) payload.wifi_driver_num_leds = n;
}
if (co && co.value) payload.wifi_color_order = co.value;
if (ws && ws.value) payload.wifi_startup_mode = ws.value;
}
return { devId, payload };
}
function refreshEditDeviceDebug() {
const ta = document.getElementById('edit-device-debug');
if (!ta) return;
try {
const { devId, payload } = collectDeviceEditPayload();
const loaded = window.__editDeviceLoadedSnapshot;
ta.value = JSON.stringify(
{
device_id: devId || null,
loaded_from_server: loaded != null ? loaded : null,
save_payload_preview: payload,
},
null,
2,
);
} catch (e) {
ta.value = String(e);
}
}
async function loadDevicesModal() {
const container = document.getElementById('devices-list-modal');
if (!container) return;
@@ -307,6 +366,11 @@ function renderDevicesList(devices) {
}
function openEditDeviceModal(devId, dev) {
try {
window.__editDeviceLoadedSnapshot = dev ? JSON.parse(JSON.stringify(dev)) : null;
} catch (e) {
window.__editDeviceLoadedSnapshot = dev || null;
}
const modal = document.getElementById('edit-device-modal');
const idInput = document.getElementById('edit-device-id');
const storageLabel = document.getElementById('edit-device-storage-id');
@@ -325,20 +389,83 @@ function openEditDeviceModal(devId, dev) {
applyTransportVisibility(tr);
setAddressToBoxes(addressBoxes, tr === 'espnow' ? ((dev && dev.address) || '') : '');
if (wifiInput) wifiInput.value = tr === 'wifi' ? ((dev && dev.address) || '') : '';
const wName = document.getElementById('edit-device-wifi-driver-name');
const wLeds = document.getElementById('edit-device-wifi-num-leds');
const wCo = document.getElementById('edit-device-wifi-color-order');
const wStart = document.getElementById('edit-device-wifi-startup-mode');
if (wName) {
const savedDisp =
dev && Object.prototype.hasOwnProperty.call(dev, 'wifi_driver_display_name')
? dev.wifi_driver_display_name
: undefined;
if (savedDisp != null && String(savedDisp).trim() !== '') {
wName.value = String(savedDisp).trim();
} else {
wName.value = dev && dev.name ? String(dev.name) : '';
}
}
if (wLeds) {
wLeds.value =
dev && dev.wifi_driver_num_leds != null && dev.wifi_driver_num_leds !== ''
? String(dev.wifi_driver_num_leds)
: '';
}
if (wCo) {
const co = (dev && dev.wifi_color_order) || 'rgb';
wCo.value = ['rgb', 'rbg', 'grb', 'gbr', 'brg', 'bgr'].includes(String(co).toLowerCase())
? String(co).toLowerCase()
: 'rgb';
}
if (wStart) {
const sm = (dev && dev.wifi_startup_mode) || 'default';
wStart.value = ['default', 'last', 'off'].includes(String(sm).toLowerCase())
? String(sm).toLowerCase()
: 'default';
}
const obr = document.getElementById('edit-device-output-brightness');
const obv = document.getElementById('edit-device-output-brightness-value');
if (obr) {
let bv = 255;
if (dev && dev.output_brightness != null && dev.output_brightness !== '') {
const n = parseInt(String(dev.output_brightness), 10);
if (!Number.isNaN(n)) bv = Math.max(0, Math.min(255, n));
}
obr.value = String(bv);
if (obv) obv.textContent = String(bv);
}
refreshEditDeviceDebug();
modal.classList.add('active');
}
async function updateDevice(devId, name, type, transport, address) {
async function updateDevice(devId, name, type, transport, address, wifiDriverFields, outputBrightness) {
try {
const payload = {
name,
type: type || 'led',
transport: transport || 'espnow',
address,
};
if (typeof outputBrightness === 'number') {
payload.output_brightness = Math.max(0, Math.min(255, Math.round(outputBrightness)));
}
if (transport === 'wifi' && wifiDriverFields && typeof wifiDriverFields === 'object') {
if (wifiDriverFields.wifi_driver_display_name != null) {
payload.wifi_driver_display_name = wifiDriverFields.wifi_driver_display_name;
}
if (wifiDriverFields.wifi_driver_num_leds != null) {
payload.wifi_driver_num_leds = wifiDriverFields.wifi_driver_num_leds;
}
if (wifiDriverFields.wifi_color_order != null) {
payload.wifi_color_order = wifiDriverFields.wifi_color_order;
}
if (wifiDriverFields.wifi_startup_mode != null) {
payload.wifi_startup_mode = wifiDriverFields.wifi_startup_mode;
}
}
const res = await fetch(`/devices/${encodeURIComponent(devId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
type: type || 'led',
transport: transport || 'espnow',
address,
}),
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (res.ok) {
@@ -354,6 +481,41 @@ async function updateDevice(devId, name, type, transport, address) {
}
}
async function pushWifiDriverConfig(devId, fields) {
const push = {};
if (fields.name != null && String(fields.name).trim()) push.name = String(fields.name).trim();
if (fields.num_leds != null && fields.num_leds !== '') {
const n = parseInt(String(fields.num_leds), 10);
if (!Number.isNaN(n) && n >= 1) push.num_leds = n;
}
if (fields.color_order != null && String(fields.color_order).trim()) {
push.color_order = String(fields.color_order).trim().toLowerCase();
}
if (fields.startup_mode != null && String(fields.startup_mode).trim()) {
const sm = String(fields.startup_mode).trim().toLowerCase();
if (sm === 'default' || sm === 'last' || sm === 'off') push.startup_mode = sm;
}
if (Object.keys(push).length === 0) return { ok: true, skipped: true };
try {
const res = await fetch(`/devices/${encodeURIComponent(devId)}/driver-config`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(push),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.error || 'Could not send settings to the driver (is it connected?)');
return { ok: false };
}
return { ok: true };
} catch (e) {
console.error('pushWifiDriverConfig:', e);
alert('Could not send settings to the driver');
return { ok: false };
}
}
document.addEventListener('DOMContentLoaded', () => {
window.addEventListener('deviceTcpStatus', (ev) => {
const { ip, connected } = ev.detail || {};
@@ -380,10 +542,19 @@ document.addEventListener('DOMContentLoaded', () => {
makeHexAddressBoxes(document.getElementById('edit-device-address-boxes'));
const devOutBr = document.getElementById('edit-device-output-brightness');
const devOutBrVal = document.getElementById('edit-device-output-brightness-value');
if (devOutBr && devOutBrVal) {
devOutBr.addEventListener('input', () => {
devOutBrVal.textContent = devOutBr.value;
});
}
const transportEdit = document.getElementById('edit-device-transport');
if (transportEdit) {
transportEdit.addEventListener('change', () => {
applyTransportVisibility(transportEdit.value);
refreshEditDeviceDebug();
});
}
@@ -420,24 +591,67 @@ document.addEventListener('DOMContentLoaded', () => {
}
if (editForm) {
editForm.addEventListener('input', () => refreshEditDeviceDebug());
editForm.addEventListener('change', () => refreshEditDeviceDebug());
editForm.addEventListener('submit', async (e) => {
e.preventDefault();
const idInput = document.getElementById('edit-device-id');
const nameInput = document.getElementById('edit-device-name');
const typeSel = document.getElementById('edit-device-type');
const transportSel = document.getElementById('edit-device-transport');
const devId = idInput && idInput.value;
const { devId, payload } = collectDeviceEditPayload();
if (!devId) return;
const transport = (transportSel && transportSel.value) || 'espnow';
const address = getAddressForPayload(transport);
const transport = payload.transport || 'espnow';
let wifiDriverFields = null;
if (transport === 'wifi') {
wifiDriverFields = {};
if (payload.wifi_driver_display_name != null) {
wifiDriverFields.wifi_driver_display_name = payload.wifi_driver_display_name;
}
if (payload.wifi_driver_num_leds != null) {
wifiDriverFields.wifi_driver_num_leds = payload.wifi_driver_num_leds;
}
if (payload.wifi_color_order != null) {
wifiDriverFields.wifi_color_order = payload.wifi_color_order;
}
if (payload.wifi_startup_mode != null) {
wifiDriverFields.wifi_startup_mode = payload.wifi_startup_mode;
}
}
const ok = await updateDevice(
devId,
nameInput ? nameInput.value.trim() : '',
(typeSel && typeSel.value) || 'led',
payload.name,
payload.type,
transport,
address
payload.address,
wifiDriverFields,
payload.output_brightness,
);
if (ok) editDeviceModal.classList.remove('active');
if (!ok) return;
try {
const brRes = await fetch(`/devices/${encodeURIComponent(devId)}/brightness`, {
method: 'POST',
credentials: 'same-origin',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
if (!brRes.ok && brRes.status !== 503) {
const brData = await brRes.json().catch(() => ({}));
console.warn('brightness push:', brData.error || brRes.status);
}
} catch (e) {
console.warn('brightness push failed', e);
}
if (transport === 'wifi' && wifiDriverFields) {
const dn = document.getElementById('edit-device-wifi-driver-name');
const nl = document.getElementById('edit-device-wifi-num-leds');
const co = document.getElementById('edit-device-wifi-color-order');
const ws = document.getElementById('edit-device-wifi-startup-mode');
const pushRes = await pushWifiDriverConfig(devId, {
name: dn ? dn.value : '',
num_leds: nl ? nl.value : '',
color_order: co ? co.value : '',
startup_mode: ws ? ws.value : '',
});
if (!pushRes.ok) return;
}
editDeviceModal.classList.remove('active');
});
}
if (editCloseBtn) {

452
src/static/groups.js Normal file
View File

@@ -0,0 +1,452 @@
// Device groups: members (MAC ids) + WiFi driver defaults; persisted via /groups.
async function fetchGroupsMap() {
try {
const response = await fetch('/groups', { headers: { Accept: 'application/json' } });
if (!response.ok) return {};
const data = await response.json();
return data && typeof data === 'object' ? data : {};
} catch (e) {
console.error('fetchGroupsMap:', e);
return {};
}
}
async function fetchDevicesMapForGroups() {
try {
const response = await fetch('/devices', { headers: { Accept: 'application/json' } });
if (!response.ok) return {};
const data = await response.json();
return data && typeof data === 'object' ? data : {};
} catch (e) {
console.error('fetchDevicesMapForGroups:', e);
return {};
}
}
function renderGroupDevicesEditor(containerEl, macRows, devicesMap) {
if (!containerEl) return;
containerEl.innerHTML = '';
const entries = Object.entries(devicesMap || {}).sort(([a], [b]) => a.localeCompare(b));
macRows.forEach((row, idx) => {
const div = document.createElement('div');
div.className = 'zone-device-row profiles-row';
const label = document.createElement('span');
label.className = 'zone-device-row-label';
const strong = document.createElement('strong');
strong.textContent = row.label || row.mac || '—';
label.appendChild(strong);
label.appendChild(document.createTextNode(' '));
const sub = document.createElement('span');
sub.className = 'muted-text';
sub.textContent = row.mac || '';
label.appendChild(sub);
const rm = document.createElement('button');
rm.type = 'button';
rm.className = 'btn btn-danger btn-small';
rm.textContent = 'Remove';
rm.addEventListener('click', () => {
macRows.splice(idx, 1);
renderGroupDevicesEditor(containerEl, macRows, devicesMap);
});
div.appendChild(label);
div.appendChild(rm);
containerEl.appendChild(div);
});
const macsInRows = new Set(macRows.map((r) => r.mac).filter(Boolean));
const addWrap = document.createElement('div');
addWrap.className = 'zone-devices-add profiles-actions';
const sel = document.createElement('select');
sel.className = 'zone-device-add-select';
sel.appendChild(new Option('Add device…', ''));
entries.forEach(([mac, d]) => {
if (macsInRows.has(mac)) return;
const labelName = d && d.name ? String(d.name).trim() : '';
const optLabel = labelName ? `${labelName}${mac}` : mac;
sel.appendChild(new Option(optLabel, mac));
});
const addBtn = document.createElement('button');
addBtn.type = 'button';
addBtn.className = 'btn btn-primary btn-small';
addBtn.textContent = 'Add';
addBtn.addEventListener('click', () => {
const mac = sel.value;
if (!mac || !devicesMap[mac]) return;
const n = String((devicesMap[mac].name || '').trim() || mac);
macRows.push({ mac, label: n });
sel.value = '';
renderGroupDevicesEditor(containerEl, macRows, devicesMap);
});
addWrap.appendChild(sel);
addWrap.appendChild(addBtn);
containerEl.appendChild(addWrap);
refreshEditGroupDebug();
}
function collectGroupEditPayload() {
const idInput = document.getElementById('edit-group-id');
const nameInput = document.getElementById('edit-group-name');
const gid = idInput && idInput.value;
const rows = window.__editGroupDeviceRows || [];
const devices = rows.map((r) => r.mac).filter(Boolean);
const payload = {
name: nameInput ? nameInput.value.trim() : '',
devices,
};
const dn = document.getElementById('edit-group-wifi-driver-name');
const nl = document.getElementById('edit-group-wifi-num-leds');
const co = document.getElementById('edit-group-wifi-color-order');
const ws = document.getElementById('edit-group-wifi-startup-mode');
if (dn && dn.value.trim()) payload.wifi_driver_display_name = dn.value.trim();
else payload.wifi_driver_display_name = null;
if (nl && nl.value !== '') {
const n = parseInt(nl.value, 10);
if (!Number.isNaN(n) && n >= 1) payload.wifi_driver_num_leds = n;
else payload.wifi_driver_num_leds = null;
} else payload.wifi_driver_num_leds = null;
if (co && co.value) payload.wifi_color_order = co.value;
if (ws && ws.value) payload.wifi_startup_mode = ws.value;
const gob = document.getElementById('edit-group-output-brightness');
if (gob && gob.value !== '') {
const nb = parseInt(gob.value, 10);
if (!Number.isNaN(nb)) payload.output_brightness = Math.max(0, Math.min(255, nb));
}
return { gid, payload };
}
function refreshEditGroupDebug() {
const ta = document.getElementById('edit-group-debug');
if (!ta) return;
try {
const { gid, payload } = collectGroupEditPayload();
const loaded = window.__editGroupLoadedSnapshot;
ta.value = JSON.stringify(
{
group_id: gid || null,
loaded_from_server: loaded != null ? loaded : null,
save_payload_preview: payload,
},
null,
2,
);
} catch (e) {
ta.value = String(e);
}
}
function loadWifiFieldsFromGroup(g) {
const wName = document.getElementById('edit-group-wifi-driver-name');
const wLeds = document.getElementById('edit-group-wifi-num-leds');
const wCo = document.getElementById('edit-group-wifi-color-order');
const wStart = document.getElementById('edit-group-wifi-startup-mode');
if (wName) {
const v = g && Object.prototype.hasOwnProperty.call(g, 'wifi_driver_display_name')
? g.wifi_driver_display_name
: null;
wName.value = v != null && String(v).trim() !== '' ? String(v).trim() : '';
}
if (wLeds) {
const v = g && g.wifi_driver_num_leds;
wLeds.value =
v != null && v !== '' && String(v).trim() !== ''
? String(v)
: '';
}
if (wCo) {
const co = (g && g.wifi_color_order) || 'rgb';
wCo.value = ['rgb', 'rbg', 'grb', 'gbr', 'brg', 'bgr'].includes(String(co).toLowerCase())
? String(co).toLowerCase()
: 'rgb';
}
if (wStart) {
const sm = (g && g.wifi_startup_mode) || 'default';
wStart.value = ['default', 'last', 'off'].includes(String(sm).toLowerCase())
? String(sm).toLowerCase()
: 'default';
}
const gob = document.getElementById('edit-group-output-brightness');
const gobv = document.getElementById('edit-group-output-brightness-value');
if (gob) {
let bv = 255;
if (g && g.output_brightness != null && g.output_brightness !== '') {
const n = parseInt(String(g.output_brightness), 10);
if (!Number.isNaN(n)) bv = Math.max(0, Math.min(255, n));
}
gob.value = String(bv);
if (gobv) gobv.textContent = String(bv);
}
}
async function openEditGroupModal(groupId, groupDoc) {
const modal = document.getElementById('edit-group-modal');
const idInput = document.getElementById('edit-group-id');
const nameInput = document.getElementById('edit-group-name');
const editor = document.getElementById('edit-group-devices-editor');
let g = groupDoc;
if (!g || typeof g !== 'object') {
try {
const response = await fetch(`/groups/${encodeURIComponent(groupId)}`);
if (response.ok) g = await response.json();
} catch (e) {
console.error(e);
}
}
g = g || {};
try {
window.__editGroupLoadedSnapshot = JSON.parse(JSON.stringify(g));
} catch (e) {
window.__editGroupLoadedSnapshot = g;
}
if (idInput) idInput.value = groupId;
if (nameInput) nameInput.value = g.name || '';
const dm = await fetchDevicesMapForGroups();
const macs = Array.isArray(g.devices) ? g.devices : [];
window.__editGroupDeviceRows = macs.map((m) => {
const mac = String(m).trim().toLowerCase().replace(/:/g, '').replace(/-/g, '');
const d = dm[mac];
return {
mac,
label: d && d.name ? String(d.name).trim() : mac,
};
});
renderGroupDevicesEditor(editor, window.__editGroupDeviceRows, dm);
loadWifiFieldsFromGroup(g);
refreshEditGroupDebug();
if (modal) modal.classList.add('active');
}
async function loadGroupsModal() {
const container = document.getElementById('groups-list-modal');
if (!container) return;
container.innerHTML = '<span class="muted-text">Loading...</span>';
try {
const data = await fetchGroupsMap();
renderGroupsList(data || {});
} catch (e) {
console.error('loadGroupsModal:', e);
container.innerHTML = '<span class="muted-text">Failed to load groups.</span>';
}
}
function renderGroupsList(groups) {
const container = document.getElementById('groups-list-modal');
if (!container) return;
container.innerHTML = '';
const ids = Object.keys(groups).filter((k) => groups[k] && typeof groups[k] === 'object');
if (ids.length === 0) {
const p = document.createElement('p');
p.className = 'muted-text';
p.textContent = 'No groups yet. Create one to assign devices and WiFi defaults.';
container.appendChild(p);
return;
}
ids.sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
ids.forEach((gid) => {
const g = groups[gid];
const row = document.createElement('div');
row.className = 'profiles-row';
row.style.display = 'flex';
row.style.alignItems = 'center';
row.style.gap = '0.5rem';
row.style.flexWrap = 'wrap';
const label = document.createElement('span');
const devs = Array.isArray(g.devices) ? g.devices : [];
label.textContent = `${g.name || gid} (${devs.length} device${devs.length === 1 ? '' : 's'})`;
label.style.flex = '1';
const editBtn = document.createElement('button');
editBtn.className = 'btn btn-secondary btn-small';
editBtn.textContent = 'Edit';
editBtn.addEventListener('click', () => openEditGroupModal(gid, g));
const brightBtn = document.createElement('button');
brightBtn.className = 'btn btn-secondary btn-small';
brightBtn.type = 'button';
brightBtn.textContent = 'Apply brightness';
brightBtn.title = 'Push group output brightness to WiFi drivers in this group';
brightBtn.addEventListener('click', async () => {
try {
const res = await fetch(`/groups/${encodeURIComponent(gid)}/brightness`, {
method: 'POST',
credentials: 'same-origin',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.error || 'Apply brightness failed');
return;
}
const n = typeof data.sent === 'number' ? data.sent : 0;
alert(
n
? `Sent brightness to ${n} driver(s).`
: 'No WiFi drivers received brightness (check connections).',
);
} catch (err) {
console.error(err);
alert('Apply brightness failed');
}
});
const applyBtn = document.createElement('button');
applyBtn.className = 'btn btn-primary btn-small';
applyBtn.type = 'button';
applyBtn.textContent = 'Apply defaults to drivers';
applyBtn.title = 'Push WiFi defaults to each connected driver in this group';
applyBtn.addEventListener('click', async () => {
try {
const res = await fetch(`/groups/${encodeURIComponent(gid)}/driver-config`, {
method: 'POST',
credentials: 'same-origin',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.error || 'Apply failed');
return;
}
const n = typeof data.sent === 'number' ? data.sent : 0;
alert(
n
? `Sent defaults to ${n} driver(s).`
: 'No WiFi drivers received the config (check defaults and connections).',
);
} catch (err) {
console.error(err);
alert('Apply failed');
}
});
const delBtn = document.createElement('button');
delBtn.className = 'btn btn-danger btn-small';
delBtn.textContent = 'Delete';
delBtn.addEventListener('click', async () => {
if (!confirm(`Delete group "${g.name || gid}"? Zones referencing it may need updating.`)) return;
try {
const res = await fetch(`/groups/${encodeURIComponent(gid)}`, { method: 'DELETE' });
if (res.ok) await loadGroupsModal();
else {
const data = await res.json().catch(() => ({}));
alert(data.error || 'Delete failed');
}
} catch (err) {
console.error(err);
alert('Delete failed');
}
});
row.appendChild(label);
row.appendChild(editBtn);
row.appendChild(brightBtn);
row.appendChild(applyBtn);
row.appendChild(delBtn);
container.appendChild(row);
});
}
document.addEventListener('DOMContentLoaded', () => {
const groupsBtn = document.getElementById('groups-btn');
const groupsModal = document.getElementById('groups-modal');
const groupsCloseBtn = document.getElementById('groups-close-btn');
const newNameInput = document.getElementById('new-group-name');
const createBtn = document.getElementById('create-group-btn');
const editForm = document.getElementById('edit-group-form');
const editCloseBtn = document.getElementById('edit-group-close-btn');
const editModal = document.getElementById('edit-group-modal');
if (groupsBtn && groupsModal) {
groupsBtn.addEventListener('click', () => {
groupsModal.classList.add('active');
loadGroupsModal();
});
}
if (groupsCloseBtn && groupsModal) {
groupsCloseBtn.addEventListener('click', () => groupsModal.classList.remove('active'));
}
const grpOutBr = document.getElementById('edit-group-output-brightness');
const grpOutBrVal = document.getElementById('edit-group-output-brightness-value');
if (grpOutBr && grpOutBrVal) {
grpOutBr.addEventListener('input', () => {
grpOutBrVal.textContent = grpOutBr.value;
});
}
const createHandler = async () => {
const name = newNameInput && newNameInput.value.trim();
if (!name) return;
try {
const res = await fetch('/groups', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ name }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.error || 'Create failed');
return;
}
if (newNameInput) newNameInput.value = '';
await loadGroupsModal();
} catch (e) {
console.error(e);
alert('Create failed');
}
};
if (createBtn) createBtn.addEventListener('click', createHandler);
if (newNameInput) {
newNameInput.addEventListener('keypress', (ev) => {
if (ev.key === 'Enter') createHandler();
});
}
if (editForm) {
editForm.addEventListener('input', () => refreshEditGroupDebug());
editForm.addEventListener('change', () => refreshEditGroupDebug());
editForm.addEventListener('submit', async (e) => {
e.preventDefault();
const { gid, payload } = collectGroupEditPayload();
if (!gid) return;
try {
const res = await fetch(`/groups/${encodeURIComponent(gid)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.error || 'Save failed');
return;
}
try {
await fetch(`/groups/${encodeURIComponent(gid)}/brightness`, {
method: 'POST',
credentials: 'same-origin',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
} catch (_) {
/* ignore push errors after save */
}
if (editModal) editModal.classList.remove('active');
await loadGroupsModal();
} catch (err) {
console.error(err);
alert('Save failed');
}
});
}
if (editCloseBtn && editModal) {
editCloseBtn.addEventListener('click', () => editModal.classList.remove('active'));
}
});

View File

@@ -1694,7 +1694,8 @@ const sendPresetViaEspNow = async (
: [];
const sequence = [presetMessage];
if (names.length > 0) {
// Auto: apply preset immediately via select. Manual: load definition only — first step is on the next audio beat.
if (names.length > 0 && presetAuto) {
const select = {};
names.forEach((name) => {
if (name) {

View File

@@ -94,10 +94,11 @@ header {
background-color: #1a1a1a;
padding: 0.75rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
border-bottom: 2px solid #4a4a4a;
gap: 0.75rem;
gap: 0.65rem;
}
header h1 {
@@ -105,14 +106,15 @@ header h1 {
font-weight: 600;
}
/* BPM + desktop actions + mobile menu share one row; BPM stays visible on mobile. */
/* Second header row: BPM, brightness, desktop buttons / mobile menu */
.header-end {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: nowrap;
flex-wrap: wrap;
justify-content: flex-end;
margin-left: auto;
margin-left: 0;
width: 100%;
min-width: 0;
}
@@ -196,7 +198,7 @@ header h1 {
}
.audio-top-indicator {
display: inline-flex;
display: none;
align-items: center;
gap: 0.4rem;
padding: 0.25rem 0.55rem;
@@ -206,6 +208,10 @@ header h1 {
min-width: 6.5rem;
}
.audio-top-indicator.audio-running {
display: inline-flex;
}
.audio-top-indicator-label {
font-size: 0.72rem;
color: #bdbdbd;
@@ -294,8 +300,9 @@ body.preset-ui-run .edit-mode-only {
.zones-container {
background-color: transparent;
padding: 0.5rem 0;
flex: 1;
padding: 0.35rem 0 0;
flex: 0 0 auto;
width: 100%;
min-width: 0;
align-self: stretch;
display: flex;
@@ -1087,12 +1094,16 @@ body.preset-ui-run .edit-mode-only {
/* Mobile-friendly layout */
@media (max-width: 1000px) {
header {
flex-direction: row;
align-items: center;
gap: 0.25rem;
} header h1 {
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
}
header h1 {
font-size: 1.1rem;
} /* On mobile, hide header buttons; all actions (including Tabs) are in the Menu dropdown */
}
/* On mobile, hide header buttons; all actions (including Tabs) are in the Menu dropdown */
.header-actions {
display: none;
}
@@ -1123,8 +1134,9 @@ body.preset-ui-run .edit-mode-only {
}
.zones-container {
padding: 0.5rem 0;
padding: 0.35rem 0 0;
border-bottom: none;
width: 100%;
}
.zone-content {

View File

@@ -64,6 +64,47 @@ function sendZoneBrightness(zoneId, value) {
? await window.tabsManager.resolveTabDeviceMacs(names)
: [];
if (typeof window.postDriverSequence === 'function') {
if (targetMacs.length > 0) {
let resolved = {};
try {
const rr = await fetch('/devices/resolve-brightness', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
macs: targetMacs,
zone_brightness: val,
}),
});
if (rr.ok) {
const pack = await rr.json().catch(() => ({}));
if (pack && pack.values && typeof pack.values === 'object') {
resolved = pack.values;
}
}
} catch (re) {
console.warn('resolve-brightness failed:', re);
}
for (const mac of targetMacs) {
const k = String(mac).toLowerCase();
const b =
resolved[k] != null && resolved[k] !== ''
? parseInt(resolved[k], 10)
: val;
const bv = Number.isNaN(b)
? val
: Math.max(0, Math.min(255, b));
await window.postDriverSequence(
[{ v: '1', b: bv, save: true }],
[mac],
0,
);
}
return;
}
await window.postDriverSequence([{ v: '1', b: val, save: true }], targetMacs, 0);
return;
}
@@ -107,8 +148,81 @@ async function fetchDevicesMap() {
}
}
async function fetchGroupsMap() {
try {
const response = await fetch("/groups", { headers: { Accept: "application/json" } });
if (!response.ok) return {};
const data = await response.json();
return data && typeof data === "object" ? data : {};
} catch (e) {
console.error("fetchGroupsMap:", e);
return {};
}
}
/**
* Resolve registry names + MACs for a zone document (``group_ids`` expands groups;
* otherwise legacy ``names``).
*/
async function computeZoneTargets(zone) {
const dm = await fetchDevicesMap();
const gids = Array.isArray(zone && zone.group_ids)
? zone.group_ids.map((x) => String(x).trim()).filter((x) => x.length > 0)
: [];
if (gids.length > 0) {
const gm = await fetchGroupsMap();
const seen = new Set();
const names = [];
const macs = [];
for (const gid of gids) {
const g = gm[gid];
if (!g || !Array.isArray(g.devices)) continue;
for (const raw of g.devices) {
const m = String(raw || "")
.trim()
.toLowerCase()
.replace(/:/g, "")
.replace(/-/g, "");
if (m.length !== 12) continue;
if (seen.has(m)) continue;
seen.add(m);
const d = dm[m];
const n = d && String((d.name || "").trim()) ? String(d.name).trim() : m;
names.push(n);
macs.push(m);
}
}
return { names, macs };
}
const zoneNames = Array.isArray(zone && zone.names) ? zone.names : [];
const rows = namesToRows(zoneNames, dm);
return {
names: rowsToNames(rows),
macs: [...new Set(rows.map((r) => r.mac).filter(Boolean))],
};
}
async function resolveZoneDeviceMacsFromZoneData(zone) {
const t = await computeZoneTargets(zone);
return t.macs;
}
/** Registry MACs for zone device names (order matches zone names; skips unknown names). */
async function resolveZoneDeviceMacs(zoneNames) {
const section = document.querySelector(".presets-section[data-zone-id]");
if (section) {
const enc = section.getAttribute("data-zone-target-macs-json");
if (enc) {
try {
const macs = JSON.parse(decodeURIComponent(enc));
if (Array.isArray(macs) && macs.length) {
return [...new Set(macs.map((m) => String(m).toLowerCase()))];
}
} catch (e) {
/* fall through */
}
}
}
const dm = await fetchDevicesMap();
const rows = namesToRows(Array.isArray(zoneNames) ? zoneNames : [], dm);
const macs = rows.map((r) => r.mac).filter(Boolean);
@@ -197,15 +311,72 @@ function renderZoneDevicesEditor(containerEl, rows, devicesMap) {
containerEl.appendChild(addWrap);
}
/** Default device name list when creating a zone (refined in Edit zone). */
async function defaultDeviceNamesForNewTab() {
const dm = await fetchDevicesMap();
const macs = Object.keys(dm);
if (macs.length > 0) {
const m0 = macs[0];
return [String((dm[m0].name || "").trim() || m0)];
}
return ["1"];
function renderZoneGroupsEditor(containerEl, rows, groupsMap) {
if (!containerEl) return;
containerEl.innerHTML = "";
const entries = Object.entries(groupsMap || {}).sort(([a], [b]) => a.localeCompare(b));
rows.forEach((row, idx) => {
const div = document.createElement("div");
div.className = "zone-device-row profiles-row";
const label = document.createElement("span");
label.className = "zone-device-row-label";
const strong = document.createElement("strong");
strong.textContent = row.name || row.id || "—";
label.appendChild(strong);
label.appendChild(document.createTextNode(" "));
const sub = document.createElement("span");
sub.className = "muted-text";
sub.textContent = `group ${row.id}`;
label.appendChild(sub);
const rm = document.createElement("button");
rm.type = "button";
rm.className = "btn btn-danger btn-small";
rm.textContent = "Remove";
rm.addEventListener("click", () => {
rows.splice(idx, 1);
renderZoneGroupsEditor(containerEl, rows, groupsMap);
});
div.appendChild(label);
div.appendChild(rm);
containerEl.appendChild(div);
});
const idsInRows = new Set(rows.map((r) => String(r.id)));
const addWrap = document.createElement("div");
addWrap.className = "zone-devices-add profiles-actions";
const sel = document.createElement("select");
sel.className = "zone-device-add-select";
sel.appendChild(new Option("Add group…", ""));
entries.forEach(([gid, g]) => {
if (idsInRows.has(gid)) return;
const gn = g && g.name ? String(g.name).trim() : "";
const optLabel = gn ? `${gn} (${gid})` : `Group ${gid}`;
sel.appendChild(new Option(optLabel, gid));
});
const addBtn = document.createElement("button");
addBtn.type = "button";
addBtn.className = "btn btn-primary btn-small";
addBtn.textContent = "Add";
addBtn.addEventListener("click", () => {
const gid = sel.value;
if (!gid || !groupsMap[gid]) return;
const gn = groupsMap[gid].name ? String(groupsMap[gid].name).trim() : gid;
rows.push({ id: gid, name: gn });
sel.value = "";
renderZoneGroupsEditor(containerEl, rows, groupsMap);
});
addWrap.appendChild(sel);
addWrap.appendChild(addBtn);
containerEl.appendChild(addWrap);
}
/** Default group for a new zone (empty if no groups exist yet). */
async function defaultGroupIdsForNewTab() {
const gm = await fetchGroupsMap();
const ids = Object.keys(gm || {}).sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
return ids.length ? [ids[0]] : [];
}
/** Read zone device names from the presets section (JSON attr preferred; legacy comma list fallback). */
@@ -539,12 +710,16 @@ async function loadZoneContent(zoneId) {
// Render zone content (presets section)
const tabName = zone.name || `Zone ${zoneId}`;
const names = Array.isArray(zone.names) ? zone.names : [];
const namesJsonAttr = encodeURIComponent(JSON.stringify(names));
const legacyOk = names.length > 0 && !names.some((n) => /[",]/.test(String(n)));
const legacyAttr = legacyOk ? ` data-device-names="${escapeHtmlAttr(names.join(","))}"` : "";
const targets = await computeZoneTargets(zone);
const namesJsonAttr = encodeURIComponent(JSON.stringify(targets.names));
const macsJsonAttr = encodeURIComponent(JSON.stringify(targets.macs));
const legacyOk =
targets.names.length > 0 && !targets.names.some((n) => /[",]/.test(String(n)));
const legacyAttr = legacyOk
? ` data-device-names="${escapeHtmlAttr(targets.names.join(","))}"`
: "";
container.innerHTML = `
<div class="presets-section" data-zone-id="${zoneId}" data-device-names-json="${namesJsonAttr}"${legacyAttr}>
<div class="presets-section" data-zone-id="${zoneId}" data-device-names-json="${namesJsonAttr}" data-zone-target-macs-json="${macsJsonAttr}"${legacyAttr}>
<div id="presets-list-zone" class="presets-list">
<!-- Presets will be loaded here by presets.js -->
</div>
@@ -639,8 +814,7 @@ async function sendProfilePresets() {
continue;
}
zonesWithPresets += 1;
const zoneNames = Array.isArray(tabData.names) ? tabData.names : [];
const targets = await resolveZoneDeviceMacs(zoneNames);
const targets = await resolveZoneDeviceMacsFromZoneData(tabData);
const payload = { preset_ids: presetIds };
if (tabData.default_preset) {
payload.default = tabData.default_preset;
@@ -831,31 +1005,25 @@ async function openEditZoneModal(zoneId, zone) {
if (idInput) idInput.value = zoneId;
if (nameInput) nameInput.value = tabData.name || "";
const devicesMap = await fetchDevicesMap();
const zoneNames =
Array.isArray(tabData.names) && tabData.names.length > 0 ? tabData.names : ["1"];
window.__editTabDeviceRows = namesToRows(zoneNames, devicesMap);
renderZoneDevicesEditor(editor, window.__editTabDeviceRows, devicesMap);
const groupsMap = await fetchGroupsMap();
const rawGids = Array.isArray(tabData.group_ids) ? tabData.group_ids : [];
window.__editTabGroupRows = rawGids.map((gid) => {
const id = String(gid);
const g = groupsMap[id];
return { id, name: g && g.name ? String(g.name).trim() : id };
});
renderZoneGroupsEditor(editor, window.__editTabGroupRows, groupsMap);
if (modal) modal.classList.add("active");
await refreshEditTabPresetsUi(zoneId);
}
function normalizeTabNamesArg(namesOrString) {
if (Array.isArray(namesOrString)) {
return namesOrString.map((n) => String(n).trim()).filter((n) => n.length > 0);
}
if (typeof namesOrString === "string" && namesOrString.trim()) {
return namesOrString.split(",").map((id) => id.trim()).filter((id) => id.length > 0);
}
return ["1"];
}
// Update an existing zone
async function updateZone(zoneId, name, namesOrString) {
async function updateZone(zoneId, name, groupIds) {
try {
let names = normalizeTabNamesArg(namesOrString);
if (!names.length) names = ["1"];
const gids = Array.isArray(groupIds)
? groupIds.map((x) => String(x).trim()).filter((x) => x.length > 0)
: [];
const response = await fetch(`/zones/${zoneId}`, {
method: 'PUT',
headers: {
@@ -863,7 +1031,8 @@ async function updateZone(zoneId, name, namesOrString) {
},
body: JSON.stringify({
name: name,
names: names
group_ids: gids,
names: [],
})
});
@@ -887,10 +1056,11 @@ async function updateZone(zoneId, name, namesOrString) {
}
// Create a new zone
async function createZone(name, namesOrString) {
async function createZone(name, groupIds) {
try {
let names = normalizeTabNamesArg(namesOrString);
if (!names.length) names = ["1"];
const gids = Array.isArray(groupIds)
? groupIds.map((x) => String(x).trim()).filter((x) => x.length > 0)
: [];
const response = await fetch('/zones', {
method: 'POST',
headers: {
@@ -898,7 +1068,8 @@ async function createZone(name, namesOrString) {
},
body: JSON.stringify({
name: name,
names: names
group_ids: gids,
names: [],
})
});
@@ -979,8 +1150,8 @@ document.addEventListener('DOMContentLoaded', () => {
const name = newTabNameInput.value.trim();
if (name) {
const deviceNames = await defaultDeviceNamesForNewTab();
await createZone(name, deviceNames);
const groupIds = await defaultGroupIdsForNewTab();
await createZone(name, groupIds);
if (newTabNameInput) newTabNameInput.value = "";
}
};
@@ -1007,15 +1178,15 @@ document.addEventListener('DOMContentLoaded', () => {
const zoneId = idInput ? idInput.value : null;
const name = nameInput ? nameInput.value.trim() : "";
const rows = window.__editTabDeviceRows || [];
const deviceNames = rowsToNames(rows);
const rows = window.__editTabGroupRows || [];
const groupIds = rows.map((r) => r.id).filter(Boolean);
if (zoneId && name) {
if (deviceNames.length === 0) {
alert("Add at least one device.");
if (groupIds.length === 0) {
alert("Add at least one device group.");
return;
}
await updateZone(zoneId, name, deviceNames);
await updateZone(zoneId, name, groupIds);
editZoneForm.reset();
}
});
@@ -1066,6 +1237,7 @@ window.zonesManager = {
updateZone,
openEditZoneModal,
resolveZoneDeviceMacs,
resolveZoneDeviceMacsFromZoneData,
resolveTabDeviceMacs: resolveZoneDeviceMacs,
getCurrentZoneId: () => currentZoneId,
};

View File

@@ -27,6 +27,7 @@
</div>
<button class="btn btn-secondary" id="profiles-btn">Profiles</button>
<button class="btn btn-secondary edit-mode-only" id="devices-btn">Devices</button>
<button class="btn btn-secondary edit-mode-only" id="groups-btn">Groups</button>
<button class="btn btn-secondary edit-mode-only" id="zones-btn">Zones</button>
<button class="btn btn-secondary edit-mode-only" id="presets-btn">Presets</button>
<button class="btn btn-secondary edit-mode-only" id="patterns-btn">Patterns</button>
@@ -47,6 +48,7 @@
</div>
<button type="button" data-target="profiles-btn">Profiles</button>
<button type="button" class="edit-mode-only" data-target="devices-btn">Devices</button>
<button type="button" class="edit-mode-only" data-target="groups-btn">Groups</button>
<button type="button" class="edit-mode-only" data-target="zones-btn">Tabs</button>
<button type="button" class="edit-mode-only" data-target="presets-btn">Presets</button>
<button type="button" class="edit-mode-only" data-target="patterns-btn">Patterns</button>
@@ -96,7 +98,7 @@
</div>
<label>Zone Name:</label>
<input type="text" id="edit-zone-name" placeholder="Enter zone name" required>
<label class="zone-devices-label">Devices in this zone</label>
<label class="zone-devices-label">Device groups in this zone</label>
<div id="edit-zone-devices-editor" class="zone-devices-editor"></div>
<label class="zone-presets-section-label">Presets on this zone</label>
<div id="edit-zone-presets-current" class="profiles-list edit-zone-presets-scroll"></div>
@@ -138,6 +140,67 @@
</div>
</div>
<!-- Device groups: members + WiFi driver defaults (zones reference groups) -->
<div id="groups-modal" class="modal">
<div class="modal-content">
<h2>Device groups</h2>
<p class="muted-text" style="margin-top:0;">Assign drivers to a group, set WiFi defaults once per group, then attach groups to zones.</p>
<div class="profiles-actions zone-modal-create-row">
<input type="text" id="new-group-name" placeholder="Group name">
<button class="btn btn-primary" id="create-group-btn">Create</button>
</div>
<div id="groups-list-modal" class="profiles-list"></div>
<div class="modal-actions">
<button class="btn btn-secondary" id="groups-close-btn">Close</button>
</div>
</div>
</div>
<div id="edit-group-modal" class="modal">
<div class="modal-content">
<h2>Edit device group</h2>
<form id="edit-group-form">
<input type="hidden" id="edit-group-id">
<div class="modal-actions" style="margin-bottom: 1rem;">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" id="edit-group-close-btn">Cancel</button>
</div>
<label for="edit-group-name">Group name</label>
<input type="text" id="edit-group-name" required autocomplete="off">
<label class="zone-devices-label">Devices in this group</label>
<div id="edit-group-devices-editor" class="zone-devices-editor"></div>
<label for="edit-group-output-brightness" style="margin-top:0.75rem;display:block;">Group output brightness (0255)</label>
<div class="profiles-actions" style="align-items: center; gap: 0.75rem;">
<input type="range" id="edit-group-output-brightness" min="0" max="255" value="255" style="flex:1;">
<span id="edit-group-output-brightness-value" class="muted-text" style="min-width:2.5rem;">255</span>
</div>
<p class="muted-text" style="margin-top:0.75rem;margin-bottom:0.35rem;">WiFi driver defaults (apply to all members via <strong>Apply defaults to drivers</strong> on the list)</p>
<label for="edit-group-wifi-driver-name">Display name</label>
<input type="text" id="edit-group-wifi-driver-name" placeholder="hello / discovery name" autocomplete="off">
<label for="edit-group-wifi-num-leds" style="margin-top:0.5rem;display:block;">Number of LEDs</label>
<input type="number" id="edit-group-wifi-num-leds" min="1" max="2048" step="1" placeholder="119">
<label for="edit-group-wifi-color-order" style="margin-top:0.5rem;display:block;">Colour order</label>
<select id="edit-group-wifi-color-order">
<option value="rgb">RGB</option>
<option value="rbg">RBG</option>
<option value="grb">GRB</option>
<option value="gbr">GBR</option>
<option value="brg">BRG</option>
<option value="bgr">BGR</option>
</select>
<label for="edit-group-wifi-startup-mode" style="margin-top:0.5rem;display:block;">Power-on pattern</label>
<select id="edit-group-wifi-startup-mode">
<option value="default">Default preset</option>
<option value="last">Last preset</option>
<option value="off">Off</option>
</select>
<label for="edit-group-debug" style="margin-top:1rem;display:block;">Debug</label>
<small class="muted-text" style="display:block;margin-bottom:0.35rem;">Stored row and the JSON preview for <strong>Save</strong> (updates as you edit).</small>
<textarea id="edit-group-debug" rows="8" readonly spellcheck="false" style="width:100%;font-family:monospace;resize:vertical;"></textarea>
</form>
</div>
</div>
<div id="edit-device-modal" class="modal">
<div class="modal-content">
<h2>Edit device</h2>
@@ -163,6 +226,37 @@
<label for="edit-device-address-wifi">Address (IP or hostname)</label>
<input type="text" id="edit-device-address-wifi" placeholder="192.168.1.50" autocomplete="off">
</div>
<div id="edit-device-wifi-driver-wrap" hidden>
<p class="muted-text" style="margin-top:0.75rem;margin-bottom:0.35rem;">On-device settings (sent over WiFi when connected). For shared defaults across several drivers, use <strong>Groups</strong>.</p>
<label for="edit-device-wifi-driver-name">Display name</label>
<input type="text" id="edit-device-wifi-driver-name" placeholder="hello / discovery name" autocomplete="off">
<label for="edit-device-wifi-num-leds" style="margin-top:0.5rem;display:block;">Number of LEDs</label>
<input type="number" id="edit-device-wifi-num-leds" min="1" max="2048" step="1" placeholder="119">
<label for="edit-device-wifi-color-order" style="margin-top:0.5rem;display:block;">Colour order</label>
<select id="edit-device-wifi-color-order">
<option value="rgb">RGB</option>
<option value="rbg">RBG</option>
<option value="grb">GRB</option>
<option value="gbr">GBR</option>
<option value="brg">BRG</option>
<option value="bgr">BGR</option>
</select>
<label for="edit-device-wifi-startup-mode" style="margin-top:0.5rem;display:block;">Power-on pattern</label>
<select id="edit-device-wifi-startup-mode">
<option value="default">Default preset</option>
<option value="last">Last preset</option>
<option value="off">Off</option>
</select>
</div>
<label for="edit-device-output-brightness" style="margin-top:0.75rem;display:block;">Output brightness (0255)</label>
<div class="profiles-actions" style="align-items: center; gap: 0.75rem;">
<input type="range" id="edit-device-output-brightness" min="0" max="255" value="255" style="flex:1;">
<span id="edit-device-output-brightness-value" class="muted-text" style="min-width:2.5rem;">255</span>
</div>
<small class="muted-text" style="display:block;margin-top:0.25rem;">Saved on the device; use <strong>Save</strong> to push to the driver (when connected).</small>
<label for="edit-device-debug" style="margin-top:1rem;display:block;">Debug</label>
<small class="muted-text" style="display:block;margin-bottom:0.35rem;">Stored registry row and the JSON preview for <strong>Save</strong> (updates as you edit).</small>
<textarea id="edit-device-debug" rows="8" readonly spellcheck="false" style="width:100%;font-family:monospace;resize:vertical;"></textarea>
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" id="edit-device-close-btn">Cancel</button>
@@ -388,19 +482,20 @@
<li><strong>Select zone</strong>: left-click a zone button in the top bar.</li>
<li><strong>Select preset</strong>: left-click a preset tile to send a <code>select</code> message to all devices in the zone.</li>
<li><strong>Profiles</strong>: open <strong>Profiles</strong> to apply a profile. Profile editing actions are hidden in Run mode.</li>
<li><strong>Devices</strong>: open <strong>Devices</strong> to see drivers (Wi-Fi clients appear when they connect); edit or remove rows as needed.</li>
<li><strong>Devices</strong>: open <strong>Devices</strong> to see drivers (Wi-Fi clients appear when they connect); edit addresses or remove rows.</li>
<li><strong>Groups</strong>: define device groups, WiFi driver defaults, then assign groups to zones.</li>
<li><strong>Send all presets</strong>: this action is available in <strong>Edit mode</strong> and pushes every preset used in the current zone to all zone devices.</li>
<li><strong>Switch modes</strong>: use the mode button in the menu. The button label shows the mode you will switch to.</li>
</ul>
<h3>Edit mode</h3>
<ul>
<li><strong>Tabs</strong>: create, edit, and manage tabs and device assignments.</li>
<li><strong>Tabs</strong>: create, edit, and manage zones and which <strong>device groups</strong> each zone drives.</li>
<li><strong>Presets</strong>: create/manage reusable presets and edit preset details.</li>
<li><strong>Preset tiles</strong>: each tile shows <strong>Edit</strong> and <strong>Remove</strong> controls in Edit mode.</li>
<li><strong>Reorder presets</strong>: drag and drop preset tiles to save zone order.</li>
<li><strong>Profiles</strong>: create/clone/delete profiles. New profiles get a populated <strong>default</strong> zone and can optionally seed a <strong>DJ zone</strong>.</li>
<li><strong>Devices</strong>: view, edit, or remove registry entries (tabs use <strong>names</strong>; each row is keyed by <strong>MAC</strong>).</li>
<li><strong>Devices</strong>: registry rows are keyed by <strong>MAC</strong>; edit a device for transport/IP and per-driver WiFi settings, or use <strong>Groups</strong> for shared defaults.</li>
<li><strong>Colour Palette</strong>: build profile colours and use <strong>From Palette</strong> in preset editor to add linked colours (badge <strong>P</strong>) that update when palette colours change.</li>
</ul>
@@ -605,6 +700,7 @@
</div>
<!-- Styles moved to /static/style.css -->
<script src="/static/groups.js"></script>
<script src="/static/zones.js"></script>
<script src="/static/help.js"></script>
<script src="/static/led_tool.js"></script>

View File

@@ -94,12 +94,85 @@ def _coerce_auto_from_body(body: Any) -> bool:
return True
def sync_beat_route_from_push_sequence(sequence: List[Any]) -> None:
def _registry_names_for_macs(macs: Optional[List[str]]) -> List[str]:
"""Resolve push ``targets`` MAC list to registry device names (order preserved, de-duplicated)."""
if not macs:
return []
from models.device import Device, normalize_mac
devices = Device()
out: List[str] = []
seen: Set[str] = set()
for raw in macs:
m = normalize_mac(str(raw))
if not m:
continue
doc = devices.read(m) or {}
nm = str(doc.get("name") or "").strip()
if nm and nm not in seen:
seen.add(nm)
out.append(nm)
return out
def _single_manual_wire_preset(
merged_presets: Dict[str, Any],
) -> tuple[Optional[str], Optional[Dict[str, Any]]]:
"""If exactly one manual (non-auto) preset is present, return its wire id and body."""
manual: List[tuple[str, Dict[str, Any]]] = []
for wid, body in merged_presets.items():
if not isinstance(body, dict):
continue
if _coerce_auto_from_body(body):
continue
manual.append((str(wid).strip(), body))
if len(manual) != 1:
return None, None
return manual[0][0], manual[0][1]
def _apply_manual_beat_route(
device_names: List[str],
wire_preset_id: str,
preset_body: Any,
) -> None:
"""Enable audio→driver routing for one manual preset, or disable if invalid."""
if not device_names:
update_beat_route({"enabled": False})
return
if not isinstance(preset_body, dict):
update_beat_route({"enabled": False})
return
if _coerce_auto_from_body(preset_body):
update_beat_route({"enabled": False})
return
pattern = str(preset_body.get("pattern") or preset_body.get("p") or "").strip()
if pattern and not _pattern_supports_manual(pattern):
update_beat_route({"enabled": False})
return
update_beat_route(
{
"enabled": True,
"device_names": device_names,
"wire_preset_id": wire_preset_id,
"is_manual": True,
"pattern": pattern,
"manual_beat_n": _coerce_manual_beat_n(preset_body),
}
)
def sync_beat_route_from_push_sequence(
sequence: List[Any], target_macs: Optional[List[str]] = None
) -> None:
"""
Update beat routing from a ``/presets/push`` body ``sequence`` (list of v1 dicts).
When the batch includes a ``select`` and preset bodies, and the selected preset is
manual (auto off), enables the route; otherwise disables it.
With a ``select`` map: use its keys as device names (existing behaviour).
Without ``select`` (e.g. manual preset loaded without immediate select): if ``target_macs``
is set and the merged ``presets`` contain exactly one manual preset, enable routing using
registry names for those MACs so the first advance is on the next audio beat.
"""
merged_presets: Dict[str, Any] = {}
last_select: Optional[Dict[str, Any]] = None
@@ -117,54 +190,40 @@ def sync_beat_route_from_push_sequence(sequence: List[Any]) -> None:
sel = item.get("select")
if isinstance(sel, dict) and sel:
last_select = sel
if not last_select:
if last_select:
device_names = [str(k).strip() for k in last_select.keys() if str(k).strip()]
if not device_names:
update_beat_route({"enabled": False})
return
wire_ids: Set[str] = set()
for name in device_names:
val = last_select.get(name)
if isinstance(val, list) and val:
wire_ids.add(str(val[0]).strip())
elif val is not None:
wire_ids.add(str(val).strip())
if len(wire_ids) != 1:
update_beat_route({"enabled": False})
return
wire_preset_id = wire_ids.pop()
preset_body = merged_presets.get(wire_preset_id)
if preset_body is None:
for k, v in merged_presets.items():
if str(k).strip() == wire_preset_id:
preset_body = v
break
_apply_manual_beat_route(device_names, wire_preset_id, preset_body)
return
device_names = [str(k).strip() for k in last_select.keys() if str(k).strip()]
if not device_names:
update_beat_route({"enabled": False})
wire_id, body = _single_manual_wire_preset(merged_presets)
if wire_id and body is not None:
names = _registry_names_for_macs(target_macs)
_apply_manual_beat_route(names, wire_id, body)
return
wire_ids: Set[str] = set()
for name in device_names:
val = last_select.get(name)
if isinstance(val, list) and val:
wire_ids.add(str(val[0]).strip())
elif val is not None:
wire_ids.add(str(val).strip())
if len(wire_ids) != 1:
update_beat_route({"enabled": False})
return
wire_preset_id = wire_ids.pop()
preset_body = merged_presets.get(wire_preset_id)
if preset_body is None:
for k, v in merged_presets.items():
if str(k).strip() == wire_preset_id:
preset_body = v
break
if not isinstance(preset_body, dict):
update_beat_route({"enabled": False})
return
if _coerce_auto_from_body(preset_body):
update_beat_route({"enabled": False})
return
pattern = str(preset_body.get("pattern") or preset_body.get("p") or "").strip()
if pattern and not _pattern_supports_manual(pattern):
update_beat_route({"enabled": False})
return
update_beat_route(
{
"enabled": True,
"device_names": device_names,
"wire_preset_id": wire_preset_id,
"is_manual": True,
"pattern": pattern,
"manual_beat_n": _coerce_manual_beat_n(preset_body),
}
)
update_beat_route({"enabled": False})
def _pattern_supports_manual(pattern_key: str) -> bool:
@@ -186,45 +245,59 @@ def _pattern_supports_manual(pattern_key: str) -> bool:
return True
def _macs_for_registry_names(device_names: List[str]) -> List[str]:
from models.device import Device
want = {str(n).strip() for n in device_names if str(n).strip()}
if not want:
return []
devices = Device()
macs: List[str] = []
seen = set()
for did in devices.list():
doc = devices.read(did) or {}
nm = str(doc.get("name") or "").strip()
if nm not in want:
continue
key = str(did).strip().lower().replace(":", "").replace("-", "")
if len(key) == 12 and key not in seen:
seen.add(key)
macs.append(key)
return macs
def remap_beat_route_device_name(old_name: str, new_name: str) -> None:
"""Update cached audio-beat target names after a device registry rename."""
global _beat_route
o = str(old_name or "").strip()
n = str(new_name or "").strip()
if not o or not n or o == n:
return
with _route_lock:
if not _beat_route.get("enabled"):
return
names = _beat_route.get("device_names") or []
new_list: List[str] = []
changed = False
for item in names:
if str(item).strip() == o:
new_list.append(n)
changed = True
else:
new_list.append(str(item))
if changed:
_beat_route = {**_beat_route, "device_names": new_list}
async def _deliver_select(device_names: List[str], wire_preset_id: str) -> None:
from models.device import Device
from models.device import resolve_device_mac_for_select_routing
from models.transport import get_current_sender
from util.driver_delivery import deliver_json_messages
sender = get_current_sender()
if not sender:
return
select = {str(n).strip(): [wire_preset_id] for n in device_names if str(n).strip()}
devices = Device()
seen_macs: List[str] = []
seen_set: Set[str] = set()
for n in device_names:
mac = resolve_device_mac_for_select_routing(devices, n)
if mac and mac not in seen_set:
seen_set.add(mac)
seen_macs.append(mac)
if not seen_macs:
return
select: Dict[str, Any] = {}
for mac in seen_macs:
doc = devices.read(mac) or {}
nm = str(doc.get("name") or "").strip()
if nm:
select[nm] = [wire_preset_id]
if not select:
return
msg = json.dumps({"v": "1", "select": select}, separators=(",", ":"))
macs = _macs_for_registry_names(list(select.keys()))
if not macs:
return
devices = Device()
try:
await deliver_json_messages(sender, [msg], macs, devices, delay_s=0.05)
await deliver_json_messages(sender, [msg], seen_macs, devices, delay_s=0.05)
except Exception as e:
print(f"[beat-route] deliver failed: {e}")

View File

@@ -0,0 +1,86 @@
"""
Combine global, group, device (and optional zone) brightness into one 0255 wire value.
Formula: ``(f1/255) * (f2/255) * ... * (fn/255) * 255`` with integer rounding — one ``b`` sent to the driver.
"""
from __future__ import annotations
from models.device import normalize_mac
def clamp255(value) -> int:
try:
v = int(value)
except (TypeError, ValueError):
return 255
return max(0, min(255, v))
def multiply_brightness_factors(factors: list) -> int:
"""Product ``(f1/255)*(f2/255)*...*255``; each factor clamped to 0..255."""
if not factors:
return 255
fs = [clamp255(f) for f in factors]
if len(fs) == 1:
return fs[0]
num = 1
for f in fs:
num *= f
den = 255 ** (len(fs) - 1)
return max(0, min(255, (num + den // 2) // den))
def _mac_in_device_list(raw_list, target_mac: str) -> bool:
tm = normalize_mac(target_mac)
if not tm:
return False
if not isinstance(raw_list, list):
return False
for raw in raw_list:
if normalize_mac(str(raw)) == tm:
return True
return False
def effective_brightness_for_mac(
settings_obj,
groups_model,
devices_model,
mac: str,
*,
zone_brightness=None,
) -> int:
"""
Factors (each 0..255): Pi **global_brightness**, each group's **output_brightness**
(neutral 255 if the device is in no group), device **output_brightness** (default 255),
optional **zone_brightness** from the zone slider when applying live.
"""
m = normalize_mac(mac)
if not m:
return 255
g_global = clamp255(settings_obj.get("global_brightness", 255))
dev_doc = devices_model.read(m)
if dev_doc is not None and dev_doc.get("output_brightness") is not None:
d_b = clamp255(dev_doc.get("output_brightness"))
else:
d_b = 255
group_factors = []
for _gid, gdoc in groups_model.items():
if not isinstance(gdoc, dict):
continue
if not _mac_in_device_list(gdoc.get("devices"), m):
continue
group_factors.append(clamp255(gdoc.get("output_brightness", 255)))
if not group_factors:
group_factors = [255]
factors = [g_global, *group_factors, d_b]
if zone_brightness is not None:
factors.append(clamp255(zone_brightness))
return multiply_brightness_factors(factors)