chore(release): beta-1.03
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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}}
|
||||
@@ -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
Submodule led-driver updated: 170a0e05ab...2a768376d0
@@ -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 0–255")
|
||||
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 0–255 }``.
|
||||
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``). Wi‑Fi or ESP‑NOW.
|
||||
"""
|
||||
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 Wi‑Fi 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):
|
||||
"""
|
||||
|
||||
@@ -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 Wi‑Fi 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 Wi‑Fi defaults to every Wi‑Fi 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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,14 +1,66 @@
|
||||
from models.model import Model
|
||||
|
||||
|
||||
class Group(Model):
|
||||
"""Device groups (members + optional Wi‑Fi 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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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
452
src/static/groups.js
Normal file
@@ -0,0 +1,452 @@
|
||||
// Device groups: members (MAC ids) + Wi‑Fi 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 Wi‑Fi 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 Wi‑Fi 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 Wi‑Fi 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 Wi‑Fi 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 Wi‑Fi 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'));
|
||||
}
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 + Wi‑Fi 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 Wi‑Fi 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 (0–255)</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;">Wi‑Fi 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 Wi‑Fi 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 (0–255)</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, Wi‑Fi 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 Wi‑Fi 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>
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
86
src/util/brightness_combine.py
Normal file
86
src/util/brightness_combine.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
Combine global, group, device (and optional zone) brightness into one 0–255 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)
|
||||
Reference in New Issue
Block a user