4 Commits

Author SHA1 Message Date
6c9e06f33b feat(zones): profile-scoped groups, zone modes, sequence brightness
- Optional profile_id on groups; UI and API for shared vs profile-only groups\n- Zone content_kind (presets vs sequences); edit modal shows matching sections; devices via groups only\n- Server sequence playback folds zone brightness into preset wire b (per MAC where needed)\n- Related preset/sequence/audio/beat-route and client updates

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 01:58:00 +12:00
c1c3e5d71b feat(ui): edit tab zones, audio readout, live reload
- Zones/presets/sequence strip and Pipfile dev command fix
- Optional live reload and beat test audio asset + generator

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 00:44:20 +12:00
c64dd736f2 feat(api): parallel group sends and batch identify
- asyncio.gather for group brightness and driver-config Wi-Fi pushes
- Batch identify envelope for group members

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 00:44:13 +12:00
cad0aa7e59 feat(sequences): multi-lane playback and per-lane manual beats
- Add sequence_playback with beat and time advance, zone targeting fixes
- Per-lane manual beat routing in beat_driver_route (parallel lanes)
- Sequence API, editor JS, fix sequence model filename, tests

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 00:44:08 +12:00
34 changed files with 4983 additions and 538 deletions

View File

@@ -27,7 +27,7 @@ python_version = "3.11"
web = "python tests/web.py" web = "python tests/web.py"
watch = "python -m watchfiles \"python tests/web.py\" src tests" watch = "python -m watchfiles \"python tests/web.py\" src tests"
run = "sh -c 'cd src && python main.py'" run = "sh -c 'cd src && python main.py'"
dev = "python -m watchfiles \"sh -c 'cd src && python main.py'\" src" dev = "python -m watchfiles \"sh -c 'cd src && LED_CONTROLLER_LIVE_RELOAD=1 python main.py'\" src"
test = "python -m pytest" test = "python -m pytest"
test-browser = "sh -c 'python tests/web.py > /tmp/led-controller-web.log 2>&1 & pid=$!; trap \"kill $pid\" EXIT; sleep 2; LED_CONTROLLER_RUN_BROWSER_TESTS=1 LED_CONTROLLER_DEVICE_IP=http://127.0.0.1:5000 python -m pytest tests/test_browser.py'" test-browser = "sh -c 'python tests/web.py > /tmp/led-controller-web.log 2>&1 & pid=$!; trap \"kill $pid\" EXIT; sleep 2; LED_CONTROLLER_RUN_BROWSER_TESTS=1 LED_CONTROLLER_DEVICE_IP=http://127.0.0.1:5000 python -m pytest tests/test_browser.py'"
test-browser-device = "sh -c 'LED_CONTROLLER_RUN_BROWSER_TESTS=1 python -m pytest tests/test_browser.py'" test-browser-device = "sh -c 'LED_CONTROLLER_RUN_BROWSER_TESTS=1 python -m pytest tests/test_browser.py'"

View File

@@ -1 +1 @@
{"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}} {"1": {"name": "group1", "devices": ["e8f60a16fb00", "e8f60a170794"], "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}, "2": {"name": "group2", "devices": ["188b0e1560a8"], "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "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}}

View File

@@ -104,7 +104,7 @@
"n3": "In time (ms)", "n3": "In time (ms)",
"min_delay": 10, "min_delay": 10,
"max_delay": 10000, "max_delay": 10000,
"max_colors": 2, "max_colors": 10,
"has_background": true, "has_background": true,
"supports_manual": true "supports_manual": true
}, },

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"1": {"group_name": "Main Group", "presets": ["1", "2"], "sequence_duration": 3000, "sequence_transition": 500, "sequence_loop": true, "sequence_repeat_count": 0, "sequence_active": false, "sequence_index": 0, "sequence_start_time": 0}, "2": {"group_name": "Accent Group", "presets": ["2", "3"], "sequence_duration": 2000, "sequence_transition": 300, "sequence_loop": true, "sequence_repeat_count": 0, "sequence_active": false, "sequence_index": 0, "sequence_start_time": 0}} {"1": {"group_name": "Main Group", "presets": ["1", "2"], "sequence_duration": 3000, "sequence_transition": 500, "sequence_loop": true, "sequence_repeat_count": 0, "sequence_active": false, "sequence_index": 0, "sequence_start_time": 0, "steps": [], "step_duration_ms": 3000, "loop": true, "name": "Main Group", "profile_id": "1", "lanes": [[{"preset_id": "42", "beats": 6}, {"preset_id": "5", "beats": 2}], [{"preset_id": "6", "beats": 1}]], "group_ids": ["1"], "advance_mode": "beats", "lanes_group_ids": [["1"], ["2"]]}, "2": {"group_name": "Accent Group", "presets": ["2", "3"], "sequence_duration": 2000, "sequence_transition": 300, "sequence_loop": true, "sequence_repeat_count": 0, "sequence_active": false, "sequence_index": 0, "sequence_start_time": 0, "steps": [{"preset_id": "2", "group_ids": [], "beats": 1}, {"preset_id": "3", "group_ids": [], "beats": 1}], "step_duration_ms": 2000, "loop": true, "name": "Accent Group", "profile_id": "1", "lanes": [[{"preset_id": "2", "group_ids": [], "beats": 1}, {"preset_id": "3", "group_ids": [], "beats": 1}]], "group_ids": [], "advance_mode": "time", "lanes_group_ids": [[]]}}

View File

@@ -167,6 +167,107 @@ async def _identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, nam
pass pass
async def send_identify_to_device(dev_id: str) -> tuple[int, str]:
"""
Send the same identify blink as ``POST /devices/<id>/identify``.
Returns ``(http_status, "")`` on success, or ``(status, error_message)`` on failure
(status matches the single-device route).
"""
dev = devices.read(dev_id)
if not dev:
return 404, "Device not found"
sender = get_current_sender()
if not sender:
return 503, "Transport not configured"
name = str(dev.get("name") or "").strip()
if not name:
return 400, "Device must have a name to identify"
transport = dev.get("transport") or "espnow"
wifi_ip = None
if transport == "wifi":
wifi_ip = dev.get("address")
if not wifi_ip:
return 400, "Device has no IP address"
try:
msg = _compact_v1_json(
presets={_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
select={name: [_IDENTIFY_PRESET_KEY]},
)
if transport == "wifi":
ok = await send_json_line_to_ip(wifi_ip, msg)
if not ok:
return 503, "Wi-Fi driver not connected"
else:
await sender.send(msg, addr=dev_id)
asyncio.create_task(
_identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, name)
)
except Exception as e:
return 503, str(e)
return 200, ""
async def send_identify_to_group_devices(macs: list[str]) -> tuple[int, list[dict]]:
"""
Identify every listed registry MAC in one delivery round: merged ``select`` and a single
ESP-NOW split envelope when multiple peers share the serial bridge (avoids per-device
``SerialSender`` lock serialisation). Wi-Fi peers are sent in parallel as in
``deliver_json_messages``.
"""
from util.driver_delivery import deliver_json_messages
errors: list[dict] = []
sender = get_current_sender()
if not sender:
return 0, [{"mac": "*", "error": "Transport not configured"}]
merged_select: dict[str, list[str]] = {}
valid_macs: list[str] = []
for dev_id in macs:
dev = devices.read(dev_id)
if not dev:
errors.append({"mac": dev_id, "error": "Device not found"})
continue
name = str(dev.get("name") or "").strip()
if not name:
errors.append({"mac": dev_id, "error": "Device must have a name to identify"})
continue
transport = (dev.get("transport") or "espnow").strip().lower()
if transport == "wifi":
if not dev.get("address"):
errors.append({"mac": dev_id, "error": "Device has no IP address"})
continue
merged_select[name] = [_IDENTIFY_PRESET_KEY]
valid_macs.append(dev_id)
if not merged_select:
return 0, errors
try:
msg = _compact_v1_json(
presets={_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
select=merged_select,
)
await deliver_json_messages(sender, [msg], valid_macs, devices, delay_s=0)
except Exception as e:
return 0, errors + [{"mac": "*", "error": str(e)}]
for dev_id in valid_macs:
dev = devices.read(dev_id) or {}
name = str(dev.get("name") or "").strip()
transport = (dev.get("transport") or "espnow").strip().lower()
wifi_ip = dev.get("address") if transport == "wifi" else None
asyncio.create_task(
_identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, name)
)
return len(valid_macs), errors
@controller.get("") @controller.get("")
async def list_devices(request): async def list_devices(request):
"""List all devices (includes ``connected`` for live Wi-Fi WebSocket presence).""" """List all devices (includes ``connected`` for live Wi-Fi WebSocket presence)."""
@@ -341,53 +442,12 @@ async def identify_device(request, id):
this device name — same combined shape as profile sends the driver already accepts over TCP this device name — same combined shape as profile sends the driver already accepts over TCP
/ ESP-NOW. No ``save``. After ``IDENTIFY_OFF_DELAY_S``, a background task selects ``off``. / ESP-NOW. No ``save``. After ``IDENTIFY_OFF_DELAY_S``, a background task selects ``off``.
""" """
dev = devices.read(id) status, err = await send_identify_to_device(id)
if not dev: if status == 200:
return json.dumps({"error": "Device not found"}), 404, { return json.dumps({"message": "Identify sent"}), 200, {
"Content-Type": "application/json", "Content-Type": "application/json",
} }
sender = get_current_sender() return json.dumps({"error": err}), status, {"Content-Type": "application/json"}
if not sender:
return json.dumps({"error": "Transport not configured"}), 503, {
"Content-Type": "application/json",
}
name = str(dev.get("name") or "").strip()
if not name:
return json.dumps({"error": "Device must have a name to identify"}), 400, {
"Content-Type": "application/json",
}
transport = dev.get("transport") or "espnow"
wifi_ip = None
if transport == "wifi":
wifi_ip = dev.get("address")
if not wifi_ip:
return json.dumps({"error": "Device has no IP address"}), 400, {
"Content-Type": "application/json",
}
try:
msg = _compact_v1_json(
presets={_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
select={name: [_IDENTIFY_PRESET_KEY]},
)
if transport == "wifi":
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",
}
else:
await sender.send(msg, addr=id)
asyncio.create_task(
_identify_send_off_after_delay(sender, transport, wifi_ip, id, name)
)
except Exception as e:
return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}
return json.dumps({"message": "Identify sent"}), 200, {
"Content-Type": "application/json",
}
@controller.post("/<id>/brightness") @controller.post("/<id>/brightness")

View File

@@ -1,4 +1,6 @@
from microdot import Microdot from microdot import Microdot
from microdot.session import with_session
import asyncio
from models.group import Group from models.group import Group
from models.device import Device from models.device import Device
from models.transport import get_current_sender from models.transport import get_current_sender
@@ -12,46 +14,127 @@ groups = Group()
devices = Device() devices = Device()
_pi_settings = Settings() _pi_settings = Settings()
@controller.get('')
async def list_groups(request):
"""List all groups."""
return json.dumps(groups), 200, {'Content-Type': 'application/json'}
@controller.get('/<id>') def _group_doc_visible_for_profile(doc, profile_id):
async def get_group(request, id): if not isinstance(doc, dict):
"""Get a specific group by ID.""" return False
scoped = doc.get("profile_id")
if scoped is None:
scoped = doc.get("profileId")
if scoped is None or str(scoped).strip() == "":
return True
if not profile_id:
return False
return str(scoped).strip() == str(profile_id).strip()
def _filtered_groups_dict(session):
from controllers.zone import get_current_profile_id
pid = get_current_profile_id(session)
out = {}
for gid, doc in groups.items():
if not isinstance(doc, dict):
continue
if _group_doc_visible_for_profile(doc, pid):
out[str(gid)] = doc
return out
@controller.get("")
@with_session
async def list_groups(request, session):
"""List groups visible for the current profile (shared + profile-scoped)."""
return json.dumps(_filtered_groups_dict(session)), 200, {"Content-Type": "application/json"}
@controller.get("/<id>")
@with_session
async def get_group(request, session, id):
"""Get a specific group by ID (404 if scoped to another profile)."""
group = groups.read(id) group = groups.read(id)
if group: if not group or not isinstance(group, dict):
return json.dumps(group), 200, {'Content-Type': 'application/json'} return json.dumps({"error": "Group not found"}), 404
return json.dumps({"error": "Group not found"}), 404 from controllers.zone import get_current_profile_id
@controller.post('') if not _group_doc_visible_for_profile(group, get_current_profile_id(session)):
async def create_group(request): return json.dumps({"error": "Group not found"}), 404
"""Create a new group.""" return json.dumps(group), 200, {"Content-Type": "application/json"}
def _sanitize_group_profile_id_write(data, session):
"""Allow ``profile_id`` only for the active profile, or null to share across profiles."""
if not isinstance(data, dict):
return
from controllers.zone import get_current_profile_id
cur = get_current_profile_id(session)
if "profile_id" not in data and "profileId" not in data:
return
raw = data.get("profile_id")
if raw is None and "profileId" in data:
raw = data.get("profileId")
if raw is None or raw == "":
data.pop("profileId", None)
data["profile_id"] = None
return
if not cur or str(raw).strip() != str(cur).strip():
data.pop("profileId", None)
data.pop("profile_id", None)
@controller.post("")
@with_session
async def create_group(request, session):
"""Create a new group (omit ``profile_id`` for shared; or ``profile_scoped``: true for this profile only)."""
try: try:
data = request.json or {} data = dict(request.json or {})
name = data.get("name", "") name = data.get("name", "")
profile_scoped = bool(data.pop("profile_scoped", False))
_sanitize_group_profile_id_write(data, session)
group_id = groups.create(name) group_id = groups.create(name)
if data: if data:
groups.update(group_id, data) groups.update(group_id, data)
return json.dumps(groups.read(group_id)), 201, {'Content-Type': 'application/json'} if profile_scoped:
from controllers.zone import get_current_profile_id
cur = get_current_profile_id(session)
if cur:
groups.update(group_id, {"profile_id": str(cur)})
return json.dumps(groups.read(group_id)), 201, {"Content-Type": "application/json"}
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}), 400 return json.dumps({"error": str(e)}), 400
@controller.put('/<id>')
async def update_group(request, id): @controller.put("/<id>")
@with_session
async def update_group(request, session, id):
"""Update an existing group.""" """Update an existing group."""
try: try:
data = request.json data = request.json
if not isinstance(data, dict):
return json.dumps({"error": "Invalid JSON"}), 400, {"Content-Type": "application/json"}
data = dict(data)
_sanitize_group_profile_id_write(data, session)
if groups.update(id, data): if groups.update(id, data):
return json.dumps(groups.read(id)), 200, {'Content-Type': 'application/json'} g = groups.read(id)
if g:
return json.dumps(g), 200, {"Content-Type": "application/json"}
return json.dumps({"error": "Group not found"}), 404 return json.dumps({"error": "Group not found"}), 404
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}), 400 return json.dumps({"error": str(e)}), 400
@controller.delete('/<id>') @controller.delete("/<id>")
async def delete_group(request, id): @with_session
"""Delete a group.""" async def delete_group(request, session, id):
"""Delete a group (not allowed for another profile's scoped group)."""
g = groups.read(id)
if not g or not isinstance(g, dict):
return json.dumps({"error": "Group not found"}), 404
from controllers.zone import get_current_profile_id
if not _group_doc_visible_for_profile(g, get_current_profile_id(session)):
return json.dumps({"error": "Group not found"}), 404
if groups.delete(id): if groups.delete(id):
return json.dumps({"message": "Group deleted successfully"}), 200 return json.dumps({"message": "Group deleted successfully"}), 200
return json.dumps({"error": "Group not found"}), 404 return json.dumps({"error": "Group not found"}), 404
@@ -86,13 +169,25 @@ def _group_driver_config_payload(doc):
return dc return dc
@controller.post('/<id>/driver-config') def _read_group_for_session(session, id):
async def push_group_driver_config(request, id): g = groups.read(id)
if not g or not isinstance(g, dict):
return None
from controllers.zone import get_current_profile_id
if not _group_doc_visible_for_profile(g, get_current_profile_id(session)):
return None
return g
@controller.post("/<id>/driver-config")
@with_session
async def push_group_driver_config(request, session, id):
""" """
Push group WiFi defaults to every WiFi device listed in the group (TCP WebSocket). Push group WiFi defaults to every WiFi device listed in the group (TCP WebSocket).
Uses stored ``wifi_*`` fields on the group; optional JSON body may override for this send only. Uses stored ``wifi_*`` fields on the group; optional JSON body may override for this send only.
""" """
gdoc = groups.read(id) gdoc = _read_group_for_session(session, id)
if not gdoc: if not gdoc:
return json.dumps({"error": "Group not found"}), 404 return json.dumps({"error": "Group not found"}), 404
@@ -116,6 +211,11 @@ async def push_group_driver_config(request, id):
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else [] mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
sent = 0 sent = 0
errors = [] errors = []
msg = json.dumps(
{"v": "1", "device_config": dc, "save": True}, separators=(",", ":")
)
tasks = []
meta_macs = []
for mac in mac_list: for mac in mac_list:
m = str(mac).strip().lower().replace(":", "").replace("-", "") m = str(mac).strip().lower().replace(":", "").replace("-", "")
if len(m) != 12: if len(m) != 12:
@@ -130,14 +230,17 @@ async def push_group_driver_config(request, id):
if not ip: if not ip:
errors.append({"mac": m, "error": "no IP"}) errors.append({"mac": m, "error": "no IP"})
continue continue
msg = json.dumps( tasks.append(send_json_line_to_ip(ip, msg))
{"v": "1", "device_config": dc, "save": True}, separators=(",", ":") meta_macs.append(m)
) if tasks:
ok = await send_json_line_to_ip(ip, msg) results = await asyncio.gather(*tasks, return_exceptions=True)
if ok: for m, r in zip(meta_macs, results):
sent += 1 if r is True:
else: sent += 1
errors.append({"mac": m, "error": "driver not connected"}) elif isinstance(r, Exception):
errors.append({"mac": m, "error": str(r)})
else:
errors.append({"mac": m, "error": "driver not connected"})
return json.dumps( return json.dumps(
{"message": "driver-config sent", "sent": sent, "errors": errors} {"message": "driver-config sent", "sent": sent, "errors": errors}
@@ -149,26 +252,22 @@ def _brightness_save_message_json(b_val: int) -> str:
return json.dumps({"v": "1", "b": b_val, "save": True}, separators=(",", ":")) return json.dumps({"v": "1", "b": b_val, "save": True}, separators=(",", ":"))
@controller.post('/<id>/brightness') @controller.post("/<id>/brightness")
async def push_group_output_brightness(request, id): @with_session
async def push_group_output_brightness(request, session, id):
""" """
Push combined brightness (global × group(s) × device) to each member — one ``b`` per device. Push combined brightness (global × group(s) × device) to each member — one ``b`` per device.
""" """
gdoc = groups.read(id) gdoc = _read_group_for_session(session, id)
if not gdoc: if not gdoc:
return json.dumps({"error": "Group not found"}), 404 return json.dumps({"error": "Group not found"}), 404
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else [] mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
sent = 0 sent = 0
errors = [] errors = []
for mac in mac_list: sender = get_current_sender()
m = str(mac).strip().lower().replace(":", "").replace("-", "")
if len(m) != 12: async def _push_brightness_one(m: str, dev: dict) -> tuple[str, bool, str | None]:
continue
dev = devices.read(m)
if not dev:
errors.append({"mac": m, "error": "not in registry"})
continue
b_val = effective_brightness_for_mac( b_val = effective_brightness_for_mac(
_pi_settings, _pi_settings,
groups, groups,
@@ -181,24 +280,80 @@ async def push_group_output_brightness(request, id):
if transport == "wifi": if transport == "wifi":
ip = normalize_tcp_peer_ip(str(dev.get("address") or "")) ip = normalize_tcp_peer_ip(str(dev.get("address") or ""))
if not ip: if not ip:
errors.append({"mac": m, "error": "no IP"}) return m, False, "no IP"
continue
ok = await send_json_line_to_ip(ip, msg) ok = await send_json_line_to_ip(ip, msg)
return m, bool(ok), None if ok else "driver not connected"
if not sender:
return m, False, "transport not configured"
try:
await sender.send(msg, addr=m)
return m, True, None
except Exception as e:
return m, False, str(e)
tasks: list = []
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
tasks.append(_push_brightness_one(m, dev))
if tasks:
results = await asyncio.gather(*tasks, return_exceptions=True)
for r in results:
if isinstance(r, Exception):
errors.append({"mac": "*", "error": str(r)})
continue
m, ok, err = r
if ok: if ok:
sent += 1 sent += 1
else: elif err:
errors.append({"mac": m, "error": "driver not connected"}) errors.append({"mac": m, "error": err})
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( return json.dumps(
{"message": "brightness sent", "sent": sent, "errors": errors} {"message": "brightness sent", "sent": sent, "errors": errors}
), 200, {"Content-Type": "application/json"} ), 200, {"Content-Type": "application/json"}
@controller.post("/<id>/identify")
@with_session
async def identify_group_devices(request, session, id):
"""
Run the same identify blink as ``POST /devices/<id>/identify`` for every registry member
in parallel so all drivers in the group blink together.
"""
_ = request
gdoc = _read_group_for_session(session, id)
if not gdoc:
return json.dumps({"error": "Group not found"}), 404, {"Content-Type": "application/json"}
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
if not mac_list:
return json.dumps({"error": "Group has no devices"}), 400, {"Content-Type": "application/json"}
from controllers.device import send_identify_to_group_devices
normalized: list[str] = []
errors: list[dict] = []
for mac in mac_list:
m = str(mac).strip().lower().replace(":", "").replace("-", "")
if len(m) != 12:
errors.append({"mac": str(mac), "error": "invalid MAC"})
continue
normalized.append(m)
if not normalized:
return json.dumps(
{"message": "identify group done", "sent": 0, "errors": errors}
), 200, {"Content-Type": "application/json"}
sent, batch_errors = await send_identify_to_group_devices(normalized)
errors.extend(batch_errors)
return json.dumps(
{"message": "identify group done", "sent": sent, "errors": errors}
), 200, {"Content-Type": "application/json"}

View File

@@ -2,6 +2,7 @@ from microdot import Microdot
from microdot.session import with_session from microdot.session import with_session
from models.preset import Preset from models.preset import Preset
from models.profile import Profile from models.profile import Profile
from models.pallet import Palette
from models.device import Device, normalize_mac from models.device import Device, normalize_mac
from models.transport import get_current_sender from models.transport import get_current_sender
from util.driver_delivery import deliver_json_messages, deliver_preset_broadcast_then_per_device from util.driver_delivery import deliver_json_messages, deliver_preset_broadcast_then_per_device
@@ -12,6 +13,18 @@ controller = Microdot()
presets = Preset() presets = Preset()
profiles = Profile() profiles = Profile()
def _palette_colors_for_profile(profile_id):
prof = profiles.read(str(profile_id))
if not isinstance(prof, dict):
return None
pid = prof.get("palette_id") or prof.get("paletteId")
if not pid:
return None
cols = Palette().read(str(pid))
return cols if isinstance(cols, list) else None
def get_current_profile_id(session=None): def get_current_profile_id(session=None):
"""Get the current active profile ID from session or fallback to first.""" """Get the current active profile ID from session or fallback to first."""
profile_list = profiles.list() profile_list = profiles.list()
@@ -153,6 +166,7 @@ async def send_presets(request, session):
# Build API-compliant preset map keyed by preset ID, include name # Build API-compliant preset map keyed by preset ID, include name
current_profile_id = get_current_profile_id(session) current_profile_id = get_current_profile_id(session)
palette_colors = _palette_colors_for_profile(current_profile_id)
presets_by_name = {} presets_by_name = {}
for pid in preset_ids: for pid in preset_ids:
preset_data = presets.read(str(pid)) preset_data = presets.read(str(pid))
@@ -161,7 +175,7 @@ async def send_presets(request, session):
if str(preset_data.get("profile_id")) != str(current_profile_id): if str(preset_data.get("profile_id")) != str(current_profile_id):
continue continue
preset_key = str(pid) preset_key = str(pid)
preset_payload = build_preset_dict(preset_data) preset_payload = build_preset_dict(preset_data, palette_colors)
preset_payload["name"] = preset_data.get("name", "") preset_payload["name"] = preset_data.get("name", "")
presets_by_name[preset_key] = preset_payload presets_by_name[preset_key] = preset_payload
@@ -316,9 +330,13 @@ async def push_driver_messages(request, session):
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'} return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
try: try:
from util import sequence_playback as seq_pb
from util.beat_driver_route import sync_beat_route_from_push_sequence from util.beat_driver_route import sync_beat_route_from_push_sequence
sync_beat_route_from_push_sequence(seq, target_macs=target_list) preserve = bool(seq_pb.playback_status().get("active"))
sync_beat_route_from_push_sequence(
seq, target_macs=target_list, preserve_parallel_lane_routes=preserve
)
except Exception: except Exception:
pass pass

View File

@@ -1,51 +1,207 @@
from microdot import Microdot from microdot import Microdot
from models.squence import Sequence from microdot.session import with_session
from models.sequence import Sequence
from models.profile import Profile
from models.transport import get_current_sender
import json import json
controller = Microdot() controller = Microdot()
sequences = Sequence() sequences = Sequence()
profiles = Profile()
@controller.get('')
async def list_sequences(request):
"""List all sequences."""
return json.dumps(sequences), 200, {'Content-Type': 'application/json'}
@controller.get('/<id>') def get_current_profile_id(session=None):
async def get_sequence(request, id): """Get the current active profile ID from session or fallback to first."""
"""Get a specific sequence by ID.""" profile_list = profiles.list()
sequence = sequences.read(id) session_profile = None
if sequence: if session is not None:
return json.dumps(sequence), 200, {'Content-Type': 'application/json'} session_profile = session.get("current_profile")
if session_profile and session_profile in profile_list:
return session_profile
if profile_list:
return profile_list[0]
return None
@controller.get("")
@with_session
async def list_sequences(request, session):
"""List sequences for the current profile."""
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return json.dumps({}), 200, {"Content-Type": "application/json"}
scoped = {
sid: sdata
for sid, sdata in sequences.items()
if isinstance(sdata, dict)
and str(sdata.get("profile_id")) == str(current_profile_id)
}
return json.dumps(scoped), 200, {"Content-Type": "application/json"}
@controller.get("/<id>")
@with_session
async def get_sequence(request, session, id):
"""Get a specific sequence by ID (current profile only)."""
current_profile_id = get_current_profile_id(session)
seq = sequences.read(id)
if (
seq
and current_profile_id
and str(seq.get("profile_id")) == str(current_profile_id)
):
return json.dumps(seq), 200, {"Content-Type": "application/json"}
return json.dumps({"error": "Sequence not found"}), 404 return json.dumps({"error": "Sequence not found"}), 404
@controller.post('')
async def create_sequence(request):
"""Create a new sequence."""
try:
data = request.json or {}
group_name = data.get("group_name", "")
preset_names = data.get("presets", None)
sequence_id = sequences.create(group_name, preset_names)
if data:
sequences.update(sequence_id, data)
return json.dumps(sequences.read(sequence_id)), 201, {'Content-Type': 'application/json'}
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.put('/<id>') @controller.post("")
async def update_sequence(request, id): @with_session
"""Update an existing sequence.""" async def create_sequence(request, session):
"""Create a new sequence for the current profile."""
try: try:
try:
data = request.json or {}
except Exception:
return (
json.dumps({"error": "Invalid JSON"}),
400,
{"Content-Type": "application/json"},
)
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return (
json.dumps({"error": "No profile available"}),
404,
{"Content-Type": "application/json"},
)
sequence_id = sequences.create(current_profile_id)
if not isinstance(data, dict):
data = {}
data = dict(data)
data["profile_id"] = str(current_profile_id)
if sequences.update(sequence_id, data):
seq_data = sequences.read(sequence_id)
return (
json.dumps({sequence_id: seq_data}),
201,
{"Content-Type": "application/json"},
)
return (
json.dumps({"error": "Failed to create sequence"}),
400,
{"Content-Type": "application/json"},
)
except Exception as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
@controller.put("/<id>")
@with_session
async def update_sequence(request, session, id):
"""Update an existing sequence (current profile only)."""
try:
current_profile_id = get_current_profile_id(session)
seq = sequences.read(id)
if not seq or str(seq.get("profile_id")) != str(current_profile_id):
return json.dumps({"error": "Sequence not found"}), 404
data = request.json data = request.json
if not isinstance(data, dict):
return (
json.dumps({"error": "Invalid JSON"}),
400,
{"Content-Type": "application/json"},
)
data = dict(data)
data["profile_id"] = str(current_profile_id)
if sequences.update(id, data): if sequences.update(id, data):
return json.dumps(sequences.read(id)), 200, {'Content-Type': 'application/json'} try:
from util.sequence_playback import stop_if_playing_sequence
stop_if_playing_sequence(str(id))
except Exception:
pass
return json.dumps(sequences.read(id)), 200, {"Content-Type": "application/json"}
return json.dumps({"error": "Sequence not found"}), 404 return json.dumps({"error": "Sequence not found"}), 404
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}), 400 return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
@controller.delete('/<id>')
async def delete_sequence(request, id): @controller.delete("/<id>")
"""Delete a sequence.""" @with_session
async def delete_sequence(request, session, id):
"""Delete a sequence (current profile only)."""
current_profile_id = get_current_profile_id(session)
seq = sequences.read(id)
if not seq or str(seq.get("profile_id")) != str(current_profile_id):
return json.dumps({"error": "Sequence not found"}), 404
try:
from util.sequence_playback import stop_if_playing_sequence
stop_if_playing_sequence(str(id))
except Exception:
pass
if sequences.delete(id): if sequences.delete(id):
return json.dumps({"message": "Sequence deleted successfully"}), 200 return (
json.dumps({"message": "Sequence deleted successfully"}),
200,
{"Content-Type": "application/json"},
)
return json.dumps({"error": "Sequence not found"}), 404 return json.dumps({"error": "Sequence not found"}), 404
@controller.post("/stop")
@with_session
async def stop_sequence_playback(request, session):
"""Stop server-driven zone sequence playback."""
_ = request
try:
from util.sequence_playback import stop
stop()
return json.dumps({"ok": True}), 200, {"Content-Type": "application/json"}
except Exception as e:
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
@controller.post("/<id>/play")
@with_session
async def play_sequence(request, session, id):
"""Start server-driven playback for a sequence in a zone (body: {\"zone_id\": \"...\"})."""
if not get_current_sender():
return (
json.dumps({"error": "Transport not configured"}),
503,
{"Content-Type": "application/json"},
)
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return (
json.dumps({"error": "No profile available"}),
404,
{"Content-Type": "application/json"},
)
try:
data = request.json or {}
except Exception:
data = {}
if not isinstance(data, dict):
data = {}
zone_id = data.get("zone_id") or data.get("zoneId")
if zone_id is None or str(zone_id).strip() == "":
return (
json.dumps({"error": "zone_id required"}),
400,
{"Content-Type": "application/json"},
)
zone_id = str(zone_id).strip()
try:
from util.sequence_playback import start
await start(zone_id, str(id), str(current_profile_id), data if isinstance(data, dict) else None)
return json.dumps({"ok": True}), 200, {"Content-Type": "application/json"}
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
except RuntimeError as e:
return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}
except Exception as e:
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}

View File

@@ -291,6 +291,7 @@ async def create_zone(request, session):
names = [i.strip() for i in ids_str.split(",") if i.strip()] names = [i.strip() for i in ids_str.split(",") if i.strip()]
preset_ids = None preset_ids = None
group_ids = [] group_ids = []
content_kind = None
else: else:
data = request.json or {} data = request.json or {}
name = data.get("name", "") name = data.get("name", "")
@@ -305,11 +306,13 @@ async def create_zone(request, session):
group_ids = [str(x) for x in group_ids if x is not None] group_ids = [str(x) for x in group_ids if x is not None]
else: else:
group_ids = [] group_ids = []
raw_kind = data.get("content_kind")
content_kind = raw_kind if raw_kind in ("presets", "sequences") else None
if not name: if not name:
return json.dumps({"error": "Zone name cannot be empty"}), 400 return json.dumps({"error": "Zone name cannot be empty"}), 400
zid = zones.create(name, names, preset_ids, group_ids) zid = zones.create(name, names, preset_ids, group_ids, content_kind)
profile_id = get_current_profile_id(session) profile_id = get_current_profile_id(session)
if profile_id: if profile_id:

View File

@@ -2,6 +2,7 @@ import asyncio
import errno import errno
import json import json
import os import os
import secrets
import signal import signal
import socket import socket
import threading import threading
@@ -38,6 +39,11 @@ _tcp_device_lock = threading.Lock()
DISCOVERY_UDP_PORT = 8766 DISCOVERY_UDP_PORT = 8766
def _live_reload_enabled() -> bool:
v = os.environ.get("LED_CONTROLLER_LIVE_RELOAD", "").strip().lower()
return v not in ("", "0", "false", "no")
def _register_udp_device_sync( def _register_udp_device_sync(
device_name: str, peer_ip: str, mac, device_type=None device_name: str, peer_ip: str, mac, device_type=None
) -> None: ) -> None:
@@ -248,9 +254,28 @@ async def main(port=80):
app = Microdot() app = Microdot()
audio_detector = AudioBeatDetector() audio_detector = AudioBeatDetector()
try:
from util import audio_detector as audio_detector_module
audio_detector_module.set_shared_beat_detector(audio_detector)
except Exception as e:
print(f"[startup] audio detector shared registration skipped: {e!r}")
try:
from util.audio_run_persist import coerce_audio_device, read_audio_run_state
persisted = read_audio_run_state()
if persisted.get("enabled"):
dev = coerce_audio_device(persisted.get("device"))
audio_detector.start(device=dev)
print("[startup] audio beat detector started from saved run state")
except Exception as e:
print(f"[startup] audio auto-start skipped: {e!r}")
from util import beat_driver_route from util import beat_driver_route
beat_driver_route.set_beat_route_main_loop(asyncio.get_running_loop()) beat_driver_route.set_beat_route_main_loop(asyncio.get_running_loop())
from util import sequence_playback as seq_pb
seq_pb.ensure_beat_consumer_started()
# Initialize sessions with a secret key from settings # Initialize sessions with a secret key from settings
secret_key = settings.get('session_secret_key', 'led-controller-secret-key-change-in-production') secret_key = settings.get('session_secret_key', 'led-controller-secret-key-change-in-production')
@@ -284,11 +309,42 @@ async def main(port=80):
tcp_client_registry.set_settings(settings) tcp_client_registry.set_settings(settings)
tcp_client_registry.set_tcp_status_broadcaster(broadcast_device_tcp_status) tcp_client_registry.set_tcp_status_broadcaster(broadcast_device_tcp_status)
live_reload = _live_reload_enabled()
dev_build_id = secrets.token_hex(12) if live_reload else None
if live_reload:
print(
"[dev] LED_CONTROLLER_LIVE_RELOAD: browser refreshes when the server process restarts"
)
if dev_build_id:
@app.route("/__dev/build-id")
def dev_build_id_route(request):
_ = request
return (
dev_build_id,
200,
{
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "no-store",
},
)
# Serve index.html at root (cwd is src/ when run via pipenv run run) # Serve index.html at root (cwd is src/ when run via pipenv run run)
@app.route('/') @app.route("/")
def index(request): def index(request):
"""Serve the main web UI.""" """Serve the main web UI."""
return send_file('templates/index.html') if dev_build_id:
try:
with open("templates/index.html", encoding="utf-8") as f:
html = f.read()
tag = '<script src="/static/dev-live-reload.js" defer></script>'
if "</body>" in html:
html = html.replace("</body>", tag + "\n</body>", 1)
return html, 200, {"Content-Type": "text/html; charset=utf-8"}
except OSError:
pass
return send_file("templates/index.html")
# Favicon: avoid 404 in browser console (no file needed) # Favicon: avoid 404 in browser console (no file needed)
@app.route('/favicon.ico') @app.route('/favicon.ico')
@@ -319,6 +375,9 @@ async def main(port=80):
pass pass
try: try:
audio_detector.start(device=device) audio_detector.start(device=device)
from util.audio_run_persist import write_audio_run_state
write_audio_run_state(enabled=True, device=device)
return {"ok": True, "status": audio_detector.status()} return {"ok": True, "status": audio_detector.status()}
except Exception as e: except Exception as e:
return {"ok": False, "error": str(e)}, 500 return {"ok": False, "error": str(e)}, 500
@@ -327,12 +386,47 @@ async def main(port=80):
async def audio_stop(request): async def audio_stop(request):
_ = request _ = request
audio_detector.stop() audio_detector.stop()
from util.audio_run_persist import write_audio_run_state
write_audio_run_state(enabled=False)
return {"ok": True, "status": audio_detector.status()} return {"ok": True, "status": audio_detector.status()}
@app.route('/api/audio/status') @app.route('/api/audio/status')
async def audio_status(request): async def audio_status(request):
_ = request _ = request
return {"status": audio_detector.status()} from util import beat_driver_route
from util import sequence_playback
st = audio_detector.status()
st["sequence"] = sequence_playback.playback_status()
st["manual_beat_stride"] = beat_driver_route.manual_beat_stride_status()
seq = st.get("sequence")
beat_readout = ""
if isinstance(seq, dict) and str(seq.get("beat_readout") or "").strip():
beat_readout = str(seq.get("beat_readout") or "").strip()
elif st.get("running"):
mb = st.get("manual_beat_stride")
if isinstance(mb, dict) and mb.get("active"):
try:
n = int(mb.get("stride_n") or 1)
except (TypeError, ValueError):
n = 1
n = max(1, min(64, n))
try:
bi = int(mb.get("beat_in_stride") or 1)
except (TypeError, ValueError):
bi = 1
pos = min(n, max(1, bi))
beat_readout = f"{pos}/{n}"
else:
try:
bs = int(st.get("beat_seq") or 0)
except (TypeError, ValueError):
bs = 0
if bs > 0:
beat_readout = str(bs)
st["beat_readout"] = beat_readout
return {"status": st}
# Static file route # Static file route
@app.route("/static/<path:path>") @app.route("/static/<path:path>")

View File

@@ -2,7 +2,12 @@ from models.model import Model
class Group(Model): class Group(Model):
"""Device groups (members + optional WiFi driver defaults); also pattern fields for sequences.""" """Device groups (members + optional WiFi driver defaults); also pattern fields for sequences.
Omit ``profile_id`` (or set it null) for a **shared** group: every profile can attach it to
zones and sequences. Set ``profile_id`` to a profile id to show the group only when that
profile is active (still one global record in ``group.json``).
"""
def __init__(self): def __init__(self):
super().__init__() super().__init__()

View File

@@ -15,6 +15,9 @@ class Preset(Model):
if default_profile_id is not None: if default_profile_id is not None:
preset_data["profile_id"] = str(default_profile_id) preset_data["profile_id"] = str(default_profile_id)
changed = True changed = True
if isinstance(preset_data, dict) and "group_ids" in preset_data:
preset_data.pop("group_ids", None)
changed = True
if changed: if changed:
self.save() self.save()
except Exception: except Exception:

159
src/models/sequence.py Normal file
View File

@@ -0,0 +1,159 @@
from models.model import Model
class Sequence(Model):
def load(self):
super().load()
self._migrate_after_load()
def _migrate_after_load(self):
try:
from models.profile import Profile
profiles = Profile()
profile_list = profiles.list()
default_profile_id = profile_list[0] if profile_list else None
except Exception:
default_profile_id = None
changed = False
for _sid, doc in list(self.items()):
if not isinstance(doc, dict):
continue
if not isinstance(doc.get("steps"), list):
presets = doc.get("presets")
if isinstance(presets, list) and presets:
doc["steps"] = [
{"preset_id": str(p), "group_ids": []} for p in presets
]
else:
doc["steps"] = []
changed = True
if "step_duration_ms" not in doc:
dur = doc.get("sequence_duration")
doc["step_duration_ms"] = (
int(dur) if isinstance(dur, (int, float)) else 3000
)
changed = True
if "loop" not in doc:
doc["loop"] = bool(doc.get("sequence_loop", False))
changed = True
if "name" not in doc:
doc["name"] = str(doc.get("group_name") or "")
changed = True
if "profile_id" not in doc and default_profile_id is not None:
doc["profile_id"] = str(default_profile_id)
changed = True
if not isinstance(doc.get("lanes"), list):
steps = doc.get("steps")
if isinstance(steps, list) and steps:
doc["lanes"] = [list(steps)]
else:
doc["lanes"] = [[]]
changed = True
if "group_ids" not in doc or not isinstance(doc.get("group_ids"), list):
doc["group_ids"] = []
changed = True
if doc.get("advance_mode") != "beats":
doc["advance_mode"] = "beats"
changed = True
if "simulated_bpm" not in doc:
doc["simulated_bpm"] = 120
changed = True
else:
try:
sb = int(float(doc["simulated_bpm"]))
doc["simulated_bpm"] = max(30, min(300, sb))
except (TypeError, ValueError):
doc["simulated_bpm"] = 120
changed = True
if "sequence_transition" not in doc:
doc["sequence_transition"] = 500
changed = True
# Ensure each step has beats (beat-based advance); default 1
for lane in doc.get("lanes") or []:
if not isinstance(lane, list):
continue
for step in lane:
if not isinstance(step, dict):
continue
if "beats" not in step:
step["beats"] = 1
changed = True
# Per-lane group ids (parallel to ``lanes``)
lanes_list = [x for x in (doc.get("lanes") or []) if isinstance(x, list)]
n_lanes = len(lanes_list)
lg = doc.get("lanes_group_ids")
if n_lanes and (not isinstance(lg, list) or len(lg) != n_lanes):
shared = doc.get("group_ids") if isinstance(doc.get("group_ids"), list) else []
shared_s = [str(x).strip() for x in shared if x is not None and str(x).strip()]
if n_lanes == 1 and lanes_list[0]:
first = lanes_list[0][0] if isinstance(lanes_list[0][0], dict) else {}
step_g = (
first.get("group_ids")
if isinstance(first.get("group_ids"), list)
else []
)
step_s = [
str(x).strip() for x in step_g if x is not None and str(x).strip()
]
doc["lanes_group_ids"] = [step_s if step_s else list(shared_s)]
else:
doc["lanes_group_ids"] = [list(shared_s) for _ in range(n_lanes)]
changed = True
if changed:
self.save()
def create(self, profile_id=None):
next_id = self.get_next_id()
self[next_id] = {
"name": "",
"profile_id": str(profile_id) if profile_id is not None else None,
"group_ids": [],
"lanes": [[]],
"lanes_group_ids": [[]],
"advance_mode": "beats",
"steps": [],
"step_duration_ms": 3000,
"simulated_bpm": 120,
"sequence_transition": 500,
"loop": True,
}
self.save()
return next_id
def read(self, id):
id_str = str(id)
return self.get(id_str, None)
def update(self, id, data):
id_str = str(id)
if id_str not in self:
return False
if not isinstance(data, dict):
return False
data = dict(data)
steps = data.get("steps")
lanes = data.get("lanes")
if isinstance(steps, list) and steps:
lanes_ok = (
isinstance(lanes, list)
and lanes
and any(isinstance(x, list) and len(x) > 0 for x in lanes)
)
if not lanes_ok:
data["lanes"] = [list(steps)]
self[id_str].update(data)
self.save()
return True
def delete(self, id):
id_str = str(id)
if id_str not in self:
return False
self.pop(id_str)
self.save()
return True
def list(self):
return list(self.keys())

View File

@@ -1,44 +0,0 @@
from models.model import Model
class Sequence(Model):
def __init__(self):
super().__init__()
def create(self, group_name="", preset_names=None):
next_id = self.get_next_id()
self[next_id] = {
"group_name": group_name,
"presets": preset_names if preset_names else [],
"sequence_duration": 3000, # Duration per preset in ms
"sequence_transition": 500, # Transition time in ms
"sequence_loop": False,
"sequence_repeat_count": 0, # 0 = infinite
"sequence_active": False,
"sequence_index": 0,
"sequence_start_time": 0
}
self.save()
return next_id
def read(self, id):
id_str = str(id)
return self.get(id_str, None)
def update(self, id, data):
id_str = str(id)
if id_str not in self:
return False
self[id_str].update(data)
self.save()
return True
def delete(self, id):
id_str = str(id)
if id_str not in self:
return False
self.pop(id_str)
self.save()
return True
def list(self):
return list(self.keys())

View File

@@ -19,7 +19,11 @@ def _maybe_migrate_tab_json_to_zone():
class Zone(Model): class Zone(Model):
"""Preset layout row (stored in ``db/zone.json``); legacy storage was ``tab.json`` / Tab.""" """Preset layout row (stored in ``db/zone.json``); legacy storage was ``tab.json`` / Tab.
Optional ``content_kind`` on a row: ``\"presets\"`` (preset tiles only) or ``\"sequences\"``
(sequence tiles only). Omitted or unknown => both (legacy behaviour).
"""
def __init__(self): def __init__(self):
if not getattr(Zone, "_migration_checked", False): if not getattr(Zone, "_migration_checked", False):
@@ -36,22 +40,29 @@ class Zone(Model):
if "group_ids" not in doc: if "group_ids" not in doc:
doc["group_ids"] = [] doc["group_ids"] = []
changed = True changed = True
if "preset_group_ids" not in doc or not isinstance(doc.get("preset_group_ids"), dict):
doc["preset_group_ids"] = {}
changed = True
if changed: if changed:
self.save() self.save()
def create(self, name="", names=None, presets=None, group_ids=None): def create(self, name="", names=None, presets=None, group_ids=None, content_kind=None):
next_id = self.get_next_id() next_id = self.get_next_id()
gid_list = [] gid_list = []
if isinstance(group_ids, list): if isinstance(group_ids, list):
gid_list = [str(x) for x in group_ids if x is not None] gid_list = [str(x).strip() for x in group_ids if x is not None and str(x).strip()]
self[next_id] = { doc = {
"name": name, "name": name,
"names": names if names else [], "names": names if names else [],
"group_ids": gid_list, "group_ids": gid_list,
"preset_group_ids": {},
"presets": presets if presets else [], "presets": presets if presets else [],
"default_preset": None, "default_preset": None,
"brightness": 255, "brightness": 255,
} }
if content_kind in ("presets", "sequences"):
doc["content_kind"] = content_kind
self[next_id] = doc
self.save() self.save()
return next_id return next_id

View File

@@ -1,8 +1,21 @@
(() => { (() => {
let pollTimer = null; let pollTimer = null;
let lastBeatSeq = 0; let lastBeatSeq = 0;
let lastLoggedSequenceBeatFractions = "";
/** Prior poll had server zone sequence playback active (`status.sequence.active === true`). */
let prevZoneSequencePlaybackActive = false;
/**
* After sequence playback ends/stops while audio keeps running, keep header # idle until the
* next beat bumps `beat_seq` (avoids the stuck final cumulative value vs sequence readout).
*/
let headerBeatStickyIdleAfterSeq = false;
/** Suppresses duplicate `console.log` when the same `beat_seq` + server `beat_readout` repeats. */
let lastBeatConsoleKey = "";
/** @type {Set<ReturnType<typeof setTimeout>>} */
const pendingBeatPhaseTimers = new Set();
const STORAGE_KEY = "led-controller-audio-restore"; const STORAGE_KEY = "led-controller-audio-restore";
const PHASE_MS_KEY = "led-controller-audio-beat-phase-ms";
const STORAGE_VERSION = 1; const STORAGE_VERSION = 1;
function readRestorePrefs() { function readRestorePrefs() {
@@ -48,6 +61,42 @@
return document.getElementById(id); return document.getElementById(id);
} }
/** @param {Record<string, unknown>} status */
function updateBeatReadoutDisplays(status) {
const text = String((status && status.beat_readout) || "").trim();
for (const id of ["audio-top-beat-readout", "audio-modal-beat-readout"]) {
const n = el(id);
if (n) n.textContent = text;
}
}
/**
* On each new audio `beat_seq`, log server `beat_readout` once (deduped when poll repeats the
* same `beat_seq` + line).
* @param {Record<string, unknown>} status
*/
function logServerBeatConsoleOnPollEdge(status) {
const beatSeq = Number((status && status.beat_seq) || 0);
const line = String((status && status.beat_readout) || "").trim();
const key = `${beatSeq}\t${line}`;
if (key !== lastBeatConsoleKey) {
lastBeatConsoleKey = key;
if (!line) return;
const seq = /** @type {Record<string, unknown>|undefined} */ (status && status.sequence);
const seqBeats = !!seq && !!seq.active;
let out = line;
if (seqBeats) {
const nLanes = Number(seq && seq.num_lanes);
const lanesNote =
Number.isFinite(nLanes) && nLanes > 1
? `lane 1 of ${nLanes} (readout is for this lane only)`
: "lane 1";
out = `${line}${lanesNote}`;
}
console.log(out);
}
}
function updateBpmDisplay(bpm) { function updateBpmDisplay(bpm) {
const node = el("audio-bpm-value"); const node = el("audio-bpm-value");
if (!node) return; if (!node) return;
@@ -58,11 +107,44 @@
} }
} }
function updateBeatCounter(seq) { /** Zone sequence playback (server); only when `active === true` is beat X/Y meaningful. */
const topNode = el("audio-top-beat-count"); function sequencePlaybackActiveFromStatus(status) {
if (!topNode) return; const seq = /** @type {Record<string, unknown>|undefined} */ (
const n = Number(seq); status && status.sequence
topNode.textContent = Number.isFinite(n) && n >= 0 ? `#${Math.floor(n)}` : "#0"; );
return !!(seq && seq.active);
}
/** Build sequence beat fractions for debug logging (browser console only). */
function formatSequenceBeatFractionsForLog(status) {
const seq = /** @type {Record<string, unknown>|undefined} */ (status && status.sequence);
if (!seq || !seq.active) return null;
const laneBeatAt = Number(seq.lane0_beat_in_step);
const laneBeatsPerStep = Number(seq.lane0_beats_per_step);
if (
!Number.isFinite(laneBeatAt) ||
laneBeatAt <= 0 ||
!Number.isFinite(laneBeatsPerStep) ||
laneBeatsPerStep <= 0
) {
return null;
}
const presetFraction = `${Math.floor(laneBeatAt)}/${Math.floor(laneBeatsPerStep)}`;
const sequenceBeatAt = Number(seq.sequence_beat_at);
const sequenceBeatsPerPass = Number(seq.sequence_beats_per_pass);
if (
!Number.isFinite(sequenceBeatAt) ||
sequenceBeatAt <= 0 ||
!Number.isFinite(sequenceBeatsPerPass) ||
sequenceBeatsPerPass <= 0
) {
return null;
}
const sequenceFraction = `${Math.floor(sequenceBeatAt)}/${Math.floor(sequenceBeatsPerPass)}`;
return `${presetFraction} ${sequenceFraction}`;
} }
function updateHitTypeDisplay(hitType, confidence) { function updateHitTypeDisplay(hitType, confidence) {
@@ -91,15 +173,67 @@
} }
} }
function clearBeatPhaseTimers() {
pendingBeatPhaseTimers.forEach((t) => clearTimeout(t));
pendingBeatPhaseTimers.clear();
}
function getBeatPhaseDelayMs() {
const inp = el("audio-beat-phase-ms");
if (inp && String(inp.value).trim() !== "") {
const n = parseInt(String(inp.value).trim(), 10);
if (Number.isFinite(n)) return Math.min(500, Math.max(0, n));
}
try {
const v = parseInt(localStorage.getItem(PHASE_MS_KEY) || "0", 10);
return Number.isFinite(v) ? Math.min(500, Math.max(0, v)) : 0;
} catch {
return 0;
}
}
function persistBeatPhaseMs() {
try {
localStorage.setItem(PHASE_MS_KEY, String(getBeatPhaseDelayMs()));
} catch (e) {
console.warn("beat phase ms save failed", e);
}
}
function scheduleBeatPhaseFire(seq, delayMs) {
let tid = null;
const run = () => {
if (tid != null) pendingBeatPhaseTimers.delete(tid);
flashBeat();
try {
window.dispatchEvent(
new CustomEvent("ledControllerAudioBeat", { detail: { beatSeq: seq } }),
);
} catch (e) {
/* ignore */
}
};
if (delayMs <= 0) {
run();
return;
}
tid = setTimeout(run, delayMs);
pendingBeatPhaseTimers.add(tid);
}
/** Stop detector and polling; does not clear “resume on load” prefs (used before restart). */ /** Stop detector and polling; does not clear “resume on load” prefs (used before restart). */
async function stopAudioOnly() { async function stopAudioOnly() {
setTopBpmVisible(false); setTopBpmVisible(false);
clearBeatPhaseTimers();
if (pollTimer) { if (pollTimer) {
clearInterval(pollTimer); clearInterval(pollTimer);
pollTimer = null; pollTimer = null;
} }
lastBeatSeq = 0; lastBeatSeq = 0;
updateBeatCounter(0); prevZoneSequencePlaybackActive = false;
headerBeatStickyIdleAfterSeq = false;
lastBeatConsoleKey = "";
updateBeatReadoutDisplays({});
try { try {
await fetch("/api/audio/stop", { method: "POST" }); await fetch("/api/audio/stop", { method: "POST" });
} catch (e) { } catch (e) {
@@ -115,7 +249,7 @@
async function pollStatus() { async function pollStatus() {
try { try {
const res = await fetch("/api/audio/status"); const res = await fetch("/api/audio/status", { cache: "no-store" });
const data = await res.json(); const data = await res.json();
const status = data?.status || {}; const status = data?.status || {};
if (status.error && String(status.error).trim()) { if (status.error && String(status.error).trim()) {
@@ -123,6 +257,7 @@
if (node) { if (node) {
node.textContent = String(status.error).trim().slice(0, 120); node.textContent = String(status.error).trim().slice(0, 120);
} }
updateBeatReadoutDisplays({});
updateBpmDisplay(null); updateBpmDisplay(null);
setTopBpmVisible(!!status.running); setTopBpmVisible(!!status.running);
if (!status.running && pollTimer) { if (!status.running && pollTimer) {
@@ -134,12 +269,46 @@
setTopBpmVisible(!!status.running); setTopBpmVisible(!!status.running);
updateBpmDisplay(status.bpm); updateBpmDisplay(status.bpm);
updateHitTypeDisplay(status.beat_type, Number(status.beat_type_confidence)); updateHitTypeDisplay(status.beat_type, Number(status.beat_type_confidence));
const seq = Number(status.beat_seq || 0); /*
updateBeatCounter(seq); * `status.beat_seq` is cumulative since Audio Start — used only for flash / sticky idle
if (seq > lastBeatSeq) { * after sequence ends. Preset and sequence loop counts come from `manual_beat_stride` /
lastBeatSeq = seq; * `sequence` on each poll.
flashBeat(); */
const beatSeq = Number(status.beat_seq || 0);
const zoneSeqActive = sequencePlaybackActiveFromStatus(status);
const endedSeq = prevZoneSequencePlaybackActive && !zoneSeqActive;
const startedSeq = !prevZoneSequencePlaybackActive && zoneSeqActive;
prevZoneSequencePlaybackActive = zoneSeqActive;
if (startedSeq) {
headerBeatStickyIdleAfterSeq = false;
lastLoggedSequenceBeatFractions = "";
} }
if (endedSeq) {
headerBeatStickyIdleAfterSeq = true;
clearBeatPhaseTimers();
lastBeatSeq = beatSeq;
}
if (!zoneSeqActive && headerBeatStickyIdleAfterSeq) {
if (beatSeq > lastBeatSeq) {
lastBeatSeq = beatSeq;
logServerBeatConsoleOnPollEdge(status);
scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs());
headerBeatStickyIdleAfterSeq = false;
}
} else if (beatSeq > lastBeatSeq) {
lastBeatSeq = beatSeq;
logServerBeatConsoleOnPollEdge(status);
scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs());
}
const beatFractions = formatSequenceBeatFractionsForLog(status);
if (beatFractions) {
if (beatFractions !== lastLoggedSequenceBeatFractions) {
lastLoggedSequenceBeatFractions = beatFractions;
}
} else {
lastLoggedSequenceBeatFractions = "";
}
updateBeatReadoutDisplays(status);
} catch (e) { } catch (e) {
console.warn("audio status poll failed", e); console.warn("audio status poll failed", e);
} }
@@ -164,7 +333,6 @@
writeRestorePrefs(override, selected); writeRestorePrefs(override, selected);
updateBpmDisplay(null); updateBpmDisplay(null);
updateHitTypeDisplay("unknown", NaN); updateHitTypeDisplay("unknown", NaN);
updateBeatCounter(0);
pollTimer = setInterval(pollStatus, 250); pollTimer = setInterval(pollStatus, 250);
await pollStatus(); await pollStatus();
} }
@@ -252,17 +420,30 @@
}); });
} }
const phaseInp = el("audio-beat-phase-ms");
if (phaseInp) {
try {
const stored = parseInt(localStorage.getItem(PHASE_MS_KEY) || "0", 10);
if (Number.isFinite(stored)) {
phaseInp.value = String(Math.min(500, Math.max(0, stored)));
}
} catch {
/* ignore */
}
phaseInp.addEventListener("change", () => persistBeatPhaseMs());
phaseInp.addEventListener("input", () => persistBeatPhaseMs());
}
} }
async function resumePollingIfDetectorRunning() { async function resumePollingIfDetectorRunning() {
try { try {
const res = await fetch("/api/audio/status"); const res = await fetch("/api/audio/status", { cache: "no-store" });
const data = await res.json(); const data = await res.json();
const status = data?.status || {}; const status = data?.status || {};
if (status.running && !pollTimer) { if (status.running && !pollTimer) {
pollTimer = setInterval(pollStatus, 250); pollTimer = setInterval(pollStatus, 250);
lastBeatSeq = Number(status.beat_seq || 0); lastBeatSeq = Number(status.beat_seq || 0);
updateBeatCounter(lastBeatSeq); prevZoneSequencePlaybackActive = sequencePlaybackActiveFromStatus(status);
await pollStatus(); await pollStatus();
} }
} catch (e) { } catch (e) {
@@ -270,8 +451,11 @@
} }
} }
async function restoreAudioIfNeeded() { /**
if (pollTimer) return; * Apply browser-stored device fields only (GET /devices list); does not start detection.
* Beat detector run/stop is server-owned (`db/audio_run.json` + explicit Start/Stop in UI).
*/
async function applySavedAudioDeviceFormOnly() {
const prefs = readRestorePrefs(); const prefs = readRestorePrefs();
if (!prefs) return; if (!prefs) return;
const ov = el("audio-device-override"); const ov = el("audio-device-override");
@@ -280,20 +464,14 @@
try { try {
await refreshDevices(); await refreshDevices();
} catch (e) { } catch (e) {
console.warn("audio restore refresh devices failed", e); console.warn("audio device list refresh failed", e);
} }
if (sel && prefs.select) sel.value = prefs.select; 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 () => { document.addEventListener("DOMContentLoaded", async () => {
bind(); bind();
await resumePollingIfDetectorRunning(); await resumePollingIfDetectorRunning();
await restoreAudioIfNeeded(); await applySavedAudioDeviceFormOnly();
}); });
})(); })();

View File

@@ -0,0 +1,25 @@
/* Polls server build id; full reload when watchfiles restarts Python (new process = new id). */
(function () {
var prev = null;
function tick() {
fetch('/__dev/build-id', { cache: 'no-store', credentials: 'same-origin' })
.then(function (r) {
return r.ok ? r.text() : '';
})
.then(function (id) {
id = (id || '').trim();
if (!id) return;
if (prev === null) {
prev = id;
return;
}
if (id !== prev) {
prev = id;
window.location.reload();
}
})
.catch(function () {});
}
setInterval(tick, 750);
tick();
})();

View File

@@ -1,8 +1,27 @@
// Device groups: members (MAC ids) + WiFi driver defaults; persisted via /groups. // Device groups: members (MAC ids) + WiFi driver defaults; persisted via /groups.
// Without ``profile_id``, a group is shared across all profiles; with ``profile_id`` it is listed only for that profile.
async function getCurrentProfileIdForGroups() {
try {
const res = await fetch('/profiles/current', {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
if (!res.ok) return null;
const data = await res.json();
const id = data && (data.id || (data.profile && data.profile.id));
return id != null ? String(id) : null;
} catch {
return null;
}
}
async function fetchGroupsMap() { async function fetchGroupsMap() {
try { try {
const response = await fetch('/groups', { headers: { Accept: 'application/json' } }); const response = await fetch('/groups', {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
if (!response.ok) return {}; if (!response.ok) return {};
const data = await response.json(); const data = await response.json();
return data && typeof data === 'object' ? data : {}; return data && typeof data === 'object' ? data : {};
@@ -137,6 +156,14 @@ function refreshEditGroupDebug() {
} }
} }
function syncGroupShareCheckboxFromDoc(g) {
const cb = document.getElementById('edit-group-share-all-profiles');
if (!cb) return;
const raw = g && (g.profile_id != null ? g.profile_id : g.profileId);
const scoped = raw != null && String(raw).trim() !== '';
cb.checked = !scoped;
}
function loadWifiFieldsFromGroup(g) { function loadWifiFieldsFromGroup(g) {
const wName = document.getElementById('edit-group-wifi-driver-name'); const wName = document.getElementById('edit-group-wifi-driver-name');
const wLeds = document.getElementById('edit-group-wifi-num-leds'); const wLeds = document.getElementById('edit-group-wifi-num-leds');
@@ -189,7 +216,10 @@ async function openEditGroupModal(groupId, groupDoc) {
let g = groupDoc; let g = groupDoc;
if (!g || typeof g !== 'object') { if (!g || typeof g !== 'object') {
try { try {
const response = await fetch(`/groups/${encodeURIComponent(groupId)}`); const response = await fetch(`/groups/${encodeURIComponent(groupId)}`, {
credentials: 'same-origin',
headers: { Accept: 'application/json' },
});
if (response.ok) g = await response.json(); if (response.ok) g = await response.json();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -217,6 +247,7 @@ async function openEditGroupModal(groupId, groupDoc) {
}); });
renderGroupDevicesEditor(editor, window.__editGroupDeviceRows, dm); renderGroupDevicesEditor(editor, window.__editGroupDeviceRows, dm);
loadWifiFieldsFromGroup(g); loadWifiFieldsFromGroup(g);
syncGroupShareCheckboxFromDoc(g);
refreshEditGroupDebug(); refreshEditGroupDebug();
if (modal) modal.classList.add('active'); if (modal) modal.classList.add('active');
} }
@@ -259,8 +290,13 @@ function renderGroupsList(groups) {
const label = document.createElement('span'); const label = document.createElement('span');
const devs = Array.isArray(g.devices) ? g.devices : []; const devs = Array.isArray(g.devices) ? g.devices : [];
label.textContent = `${g.name || gid} (${devs.length} device${devs.length === 1 ? '' : 's'})`; label.textContent = `${g.name || gid} (${devs.length} device${devs.length === 1 ? '' : 's'})`;
label.style.flex = '1';
const meta = document.createElement('div');
meta.className = 'muted-text';
meta.style.fontSize = '0.8em';
const rawPid = g.profile_id != null ? g.profile_id : g.profileId;
const scoped = rawPid != null && String(rawPid).trim() !== '';
meta.textContent = scoped ? `This profile only (${rawPid})` : 'Shared across profiles';
const editBtn = document.createElement('button'); const editBtn = document.createElement('button');
editBtn.className = 'btn btn-secondary btn-small'; editBtn.className = 'btn btn-secondary btn-small';
editBtn.textContent = 'Edit'; editBtn.textContent = 'Edit';
@@ -326,13 +362,26 @@ function renderGroupsList(groups) {
} }
}); });
const identifyBtn = document.createElement('button');
identifyBtn.className = 'btn btn-secondary btn-small';
identifyBtn.type = 'button';
identifyBtn.textContent = 'Identify';
identifyBtn.title =
'Identify all devices in this group at once (red blink at 10 Hz)';
identifyBtn.addEventListener('click', async () => {
await identifyGroupById(gid);
});
const delBtn = document.createElement('button'); const delBtn = document.createElement('button');
delBtn.className = 'btn btn-danger btn-small'; delBtn.className = 'btn btn-danger btn-small';
delBtn.textContent = 'Delete'; delBtn.textContent = 'Delete';
delBtn.addEventListener('click', async () => { delBtn.addEventListener('click', async () => {
if (!confirm(`Delete group "${g.name || gid}"? Zones referencing it may need updating.`)) return; if (!confirm(`Delete group "${g.name || gid}"? Zones referencing it may need updating.`)) return;
try { try {
const res = await fetch(`/groups/${encodeURIComponent(gid)}`, { method: 'DELETE' }); const res = await fetch(`/groups/${encodeURIComponent(gid)}`, {
method: 'DELETE',
credentials: 'same-origin',
});
if (res.ok) await loadGroupsModal(); if (res.ok) await loadGroupsModal();
else { else {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
@@ -344,15 +393,49 @@ function renderGroupsList(groups) {
} }
}); });
row.appendChild(label); const left = document.createElement('div');
left.style.flex = '1';
left.style.minWidth = '0';
left.appendChild(label);
left.appendChild(meta);
row.appendChild(left);
row.appendChild(editBtn); row.appendChild(editBtn);
row.appendChild(brightBtn); row.appendChild(brightBtn);
row.appendChild(applyBtn); row.appendChild(applyBtn);
row.appendChild(identifyBtn);
row.appendChild(delBtn); row.appendChild(delBtn);
container.appendChild(row); container.appendChild(row);
}); });
} }
async function identifyGroupById(gid) {
if (!gid) return;
try {
const res = await fetch(`/groups/${encodeURIComponent(gid)}/identify`, {
method: 'POST',
credentials: 'same-origin',
headers: { Accept: 'application/json' },
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.error || 'Identify failed');
return;
}
const n = typeof data.sent === 'number' ? data.sent : 0;
const errs = Array.isArray(data.errors) ? data.errors : [];
const failed = errs.filter((e) => e && e.error).length;
let msg = n ? `Identify sent to ${n} device(s).` : 'No devices received identify.';
if (failed) {
msg += ` ${failed} failed — see console for details.`;
console.warn('Group identify errors', errs);
}
alert(msg);
} catch (e) {
console.error(e);
alert('Identify failed');
}
}
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const groupsBtn = document.getElementById('groups-btn'); const groupsBtn = document.getElementById('groups-btn');
const groupsModal = document.getElementById('groups-modal'); const groupsModal = document.getElementById('groups-modal');
@@ -381,14 +464,29 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
} }
const editIdentifyBtn = document.getElementById('edit-group-identify-btn');
if (editIdentifyBtn) {
editIdentifyBtn.addEventListener('click', async () => {
const idInput = document.getElementById('edit-group-id');
const gid = idInput && idInput.value;
if (!gid) return;
await identifyGroupById(gid);
});
}
const createHandler = async () => { const createHandler = async () => {
const name = newNameInput && newNameInput.value.trim(); const name = newNameInput && newNameInput.value.trim();
if (!name) return; if (!name) return;
const profileOnly = document.getElementById('new-group-profile-only');
try { try {
const res = await fetch('/groups', { const res = await fetch('/groups', {
method: 'POST', method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ name }), body: JSON.stringify({
name,
profile_scoped: !!(profileOnly && profileOnly.checked),
}),
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) { if (!res.ok) {
@@ -396,6 +494,7 @@ document.addEventListener('DOMContentLoaded', () => {
return; return;
} }
if (newNameInput) newNameInput.value = ''; if (newNameInput) newNameInput.value = '';
if (profileOnly) profileOnly.checked = false;
await loadGroupsModal(); await loadGroupsModal();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -417,9 +516,18 @@ document.addEventListener('DOMContentLoaded', () => {
const { gid, payload } = collectGroupEditPayload(); const { gid, payload } = collectGroupEditPayload();
if (!gid) return; if (!gid) return;
const shareCb = document.getElementById('edit-group-share-all-profiles');
if (shareCb && shareCb.checked) {
payload.profile_id = null;
} else {
const pid = await getCurrentProfileIdForGroups();
payload.profile_id = pid || null;
}
try { try {
const res = await fetch(`/groups/${encodeURIComponent(gid)}`, { const res = await fetch(`/groups/${encodeURIComponent(gid)}`, {
method: 'PUT', method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
@@ -449,4 +557,15 @@ document.addEventListener('DOMContentLoaded', () => {
if (editCloseBtn && editModal) { if (editCloseBtn && editModal) {
editCloseBtn.addEventListener('click', () => editModal.classList.remove('active')); editCloseBtn.addEventListener('click', () => editModal.classList.remove('active'));
} }
window.openDeviceGroupsModal = async () => {
const gm = document.getElementById('groups-modal');
if (!gm) return;
gm.classList.add('active');
try {
await loadGroupsModal();
} catch (e) {
console.error('openDeviceGroupsModal', e);
}
};
}); });

View File

@@ -29,6 +29,9 @@ const filterPresetsForCurrentProfile = async (presetsObj) => {
}), }),
); );
}; };
try {
window.filterPresetsForCurrentProfile = filterPresetsForCurrentProfile;
} catch (e) {}
const getCurrentProfileData = async () => { const getCurrentProfileData = async () => {
try { try {
@@ -154,7 +157,44 @@ function tabDeviceNamesFromSection(section) {
: []; : [];
} }
async function postDriverSequence(sequence, targetMacs, delayS) { /** Device names for ``presetId`` on the current zone tab (zone ``group_ids`` for presets, else tab devices). */
async function deviceNamesForPresetOnCurrentZone(presetId) {
const section = document.querySelector('.presets-section[data-zone-id]');
const fallback = tabDeviceNamesFromSection(section);
if (!section || !presetId) return fallback;
const zm = window.zonesManager;
if (!zm || typeof zm.resolveDeviceNamesForZonePreset !== 'function') return fallback;
const zoneId = section.dataset.zoneId;
try {
const res = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
if (!res.ok) return fallback;
const zd = await res.json();
const names = await zm.resolveDeviceNamesForZonePreset(zd, String(presetId));
return names.length ? names : fallback;
} catch (_) {
return fallback;
}
}
function formatPresetTargetGroupsLine(zoneDoc, groupsMap) {
const zm = window.zonesManager;
const gids =
zm && typeof zm.effectiveGroupIdsForZonePreset === 'function'
? zm.effectiveGroupIdsForZonePreset(zoneDoc || {})
: Array.isArray(zoneDoc && zoneDoc.group_ids)
? zoneDoc.group_ids.map((x) => String(x).trim()).filter((x) => x.length > 0)
: [];
const parts = (gids || [])
.map((id) => {
const g = groupsMap && groupsMap[id];
const gn = g && g.name ? String(g.name).trim() : '';
return gn;
})
.filter(Boolean);
return parts.length ? parts.join(', ') : '';
}
async function postDriverSequence(sequence, targetMacs, delayS, pushOptions) {
const body = { const body = {
sequence, sequence,
targets: Array.isArray(targetMacs) && targetMacs.length ? [...new Set(targetMacs)] : undefined, targets: Array.isArray(targetMacs) && targetMacs.length ? [...new Set(targetMacs)] : undefined,
@@ -202,6 +242,7 @@ document.addEventListener('DOMContentLoaded', () => {
const presetRemoveFromTabButton = document.getElementById('preset-remove-from-zone-btn'); const presetRemoveFromTabButton = document.getElementById('preset-remove-from-zone-btn');
const presetSaveButton = document.getElementById('preset-save-btn'); const presetSaveButton = document.getElementById('preset-save-btn');
const presetAddFromPaletteButton = document.getElementById('preset-add-from-palette-btn'); const presetAddFromPaletteButton = document.getElementById('preset-add-from-palette-btn');
const presetBackgroundFromPaletteButton = document.getElementById('preset-background-from-palette-btn');
if (!presetsButton || !presetsModal || !presetsList || !presetSaveButton) { if (!presetsButton || !presetsModal || !presetsList || !presetSaveButton) {
return; return;
@@ -213,6 +254,8 @@ document.addEventListener('DOMContentLoaded', () => {
let cachedPatterns = {}; let cachedPatterns = {};
let currentPresetColors = []; // Track colors for the current preset let currentPresetColors = []; // Track colors for the current preset
let currentPresetPaletteRefs = []; // Palette index refs per color (null for direct colors) let currentPresetPaletteRefs = []; // Palette index refs per color (null for direct colors)
let currentBackgroundPaletteRef = null;
let bgPaletteResolveGen = 0;
// Function to get max colors for current pattern // Function to get max colors for current pattern
const getMaxColors = () => { const getMaxColors = () => {
@@ -286,6 +329,10 @@ document.addEventListener('DOMContentLoaded', () => {
presetBackgroundButton.style.backgroundColor = color; presetBackgroundButton.style.backgroundColor = color;
presetBackgroundButton.style.color = '#fff'; presetBackgroundButton.style.color = '#fff';
presetBackgroundButton.style.borderColor = 'rgba(255, 255, 255, 0.6)'; presetBackgroundButton.style.borderColor = 'rgba(255, 255, 255, 0.6)';
presetBackgroundButton.title =
currentBackgroundPaletteRef != null
? `Background from profile palette (index ${currentBackgroundPaletteRef}); click to pick a custom colour`
: 'Choose background colour';
}; };
const updateDelayVisibilityForManualMode = () => { const updateDelayVisibilityForManualMode = () => {
@@ -600,9 +647,28 @@ document.addEventListener('DOMContentLoaded', () => {
presetBrightnessInput.value = preset.brightness || 0; presetBrightnessInput.value = preset.brightness || 0;
presetDelayInput.value = preset.delay || 0; presetDelayInput.value = preset.delay || 0;
if (presetBackgroundInput) { if (presetBackgroundInput) {
const rawBgRef = preset.background_palette_ref ?? preset.backgroundPaletteRef;
let bgRef = null;
if (rawBgRef != null && rawBgRef !== '') {
const n = typeof rawBgRef === 'number' ? rawBgRef : parseInt(String(rawBgRef), 10);
if (Number.isInteger(n) && n >= 0) {
bgRef = n;
}
}
currentBackgroundPaletteRef = bgRef;
presetBackgroundInput.value = coercePresetBackground(preset); presetBackgroundInput.value = coercePresetBackground(preset);
updatePresetBackgroundButton();
const gen = ++bgPaletteResolveGen;
void getCurrentProfilePaletteColors().then((pal) => {
if (gen !== bgPaletteResolveGen || !presetBackgroundInput) {
return;
}
presetBackgroundInput.value = resolvePresetBackgroundHex(preset, pal);
updatePresetBackgroundButton();
});
} else {
updatePresetBackgroundButton();
} }
updatePresetBackgroundButton();
if (presetManualModeInput) { if (presetManualModeInput) {
const autoVal = typeof preset.auto === 'boolean' ? preset.auto : true; const autoVal = typeof preset.auto === 'boolean' ? preset.auto : true;
presetManualModeInput.checked = !autoVal; presetManualModeInput.checked = !autoVal;
@@ -674,6 +740,7 @@ document.addEventListener('DOMContentLoaded', () => {
}; };
const clearForm = () => { const clearForm = () => {
bgPaletteResolveGen += 1;
currentEditId = null; currentEditId = null;
currentEditTabId = null; currentEditTabId = null;
currentPresetColors = []; currentPresetColors = [];
@@ -702,9 +769,6 @@ document.addEventListener('DOMContentLoaded', () => {
if (presetManualBeatNInput) { if (presetManualBeatNInput) {
presetManualBeatNInput.value = '1'; presetManualBeatNInput.value = '1';
} }
if (presetBackgroundInput) {
presetBackgroundInput.value = '#000000';
}
updatePresetBackgroundButton(); updatePresetBackgroundButton();
updateManualModeAvailability(); updateManualModeAvailability();
// Re-enable name and pattern when clearing (for new preset) // Re-enable name and pattern when clearing (for new preset)
@@ -785,6 +849,7 @@ document.addEventListener('DOMContentLoaded', () => {
brightness: presetBrightnessInput ? parseInt(presetBrightnessInput.value, 10) || 0 : 0, brightness: presetBrightnessInput ? parseInt(presetBrightnessInput.value, 10) || 0 : 0,
delay: presetDelayInput ? parseInt(presetDelayInput.value, 10) || 0 : 0, delay: presetDelayInput ? parseInt(presetDelayInput.value, 10) || 0 : 0,
background: presetBackgroundInput ? presetBackgroundInput.value : '#000000', background: presetBackgroundInput ? presetBackgroundInput.value : '#000000',
background_palette_ref: currentBackgroundPaletteRef != null ? currentBackgroundPaletteRef : null,
auto: presetManualModeInput ? !presetManualModeInput.checked : true, auto: presetManualModeInput ? !presetManualModeInput.checked : true,
manual_beat_n: (() => { manual_beat_n: (() => {
if (!presetManualBeatNInput) return 1; if (!presetManualBeatNInput) return 1;
@@ -1169,7 +1234,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Create modal // Create modal
const modal = document.createElement('div'); const modal = document.createElement('div');
modal.className = 'modal active'; modal.className = 'modal active modal-child-overlay';
modal.id = 'add-preset-to-zone-modal'; modal.id = 'add-preset-to-zone-modal';
modal.innerHTML = ` modal.innerHTML = `
<div class="modal-content"> <div class="modal-content">
@@ -1262,7 +1327,15 @@ document.addEventListener('DOMContentLoaded', () => {
throw new Error('Failed to load zone'); throw new Error('Failed to load zone');
} }
const tabData = await tabResponse.json(); const tabData = await tabResponse.json();
const kind =
typeof window.normalizeZoneContentKind === 'function'
? window.normalizeZoneContentKind(tabData)
: null;
if (kind === 'sequences') {
alert('This zone is for sequences only. Add a sequence from the zone Edit menu instead.');
return;
}
// Normalize to flat array to check and update usage // Normalize to flat array to check and update usage
let flat = []; let flat = [];
if (Array.isArray(tabData.presets_flat)) { if (Array.isArray(tabData.presets_flat)) {
@@ -1284,7 +1357,7 @@ document.addEventListener('DOMContentLoaded', () => {
const newGrid = arrayToGrid(flat, 3); const newGrid = arrayToGrid(flat, 3);
tabData.presets = newGrid; tabData.presets = newGrid;
tabData.presets_flat = flat; tabData.presets_flat = flat;
// Update zone // Update zone
const updateResponse = await fetch(`/zones/${zoneId}`, { const updateResponse = await fetch(`/zones/${zoneId}`, {
method: 'PUT', method: 'PUT',
@@ -1340,6 +1413,7 @@ document.addEventListener('DOMContentLoaded', () => {
presetBackgroundInput.click(); presetBackgroundInput.click();
}); });
presetBackgroundInput.addEventListener('input', () => { presetBackgroundInput.addEventListener('input', () => {
currentBackgroundPaletteRef = null;
updatePresetBackgroundButton(); updatePresetBackgroundButton();
}); });
} }
@@ -1378,7 +1452,7 @@ document.addEventListener('DOMContentLoaded', () => {
} }
const modal = document.createElement('div'); const modal = document.createElement('div');
modal.className = 'modal active'; modal.className = 'modal active modal-child-overlay';
modal.innerHTML = ` modal.innerHTML = `
<div class="modal-content"> <div class="modal-content">
<h2>Pick Palette Color</h2> <h2>Pick Palette Color</h2>
@@ -1419,10 +1493,6 @@ document.addEventListener('DOMContentLoaded', () => {
const ref = parseInt(row.dataset.paletteIndex, 10); const ref = parseInt(row.dataset.paletteIndex, 10);
if (!color || !Number.isInteger(ref)) return; if (!color || !Number.isInteger(ref)) return;
if (currentPresetColors.includes(color) && currentPresetPaletteRefs.includes(ref)) {
alert('That palette color is already linked.');
return;
}
const maxColors = getMaxColors(); const maxColors = getMaxColors();
if (currentPresetColors.length >= maxColors) { if (currentPresetColors.length >= maxColors) {
alert(`This pattern allows a maximum of ${maxColors} color${maxColors !== 1 ? 's' : ''}.`); alert(`This pattern allows a maximum of ${maxColors} color${maxColors !== 1 ? 's' : ''}.`);
@@ -1436,7 +1506,72 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
} catch (err) { } catch (err) {
console.error('Failed to add from palette:', err); console.error('Failed to add from palette:', err);
alert('Failed to load palette colors.'); alert('Failed to load palette colours.');
}
});
}
if (presetBackgroundFromPaletteButton) {
presetBackgroundFromPaletteButton.addEventListener('click', async () => {
try {
const paletteColors = await getCurrentProfilePaletteColors();
if (!Array.isArray(paletteColors) || paletteColors.length === 0) {
alert('No profile palette colours available.');
return;
}
const modal = document.createElement('div');
modal.className = 'modal active modal-child-overlay';
modal.innerHTML = `
<div class="modal-content">
<h2>Pick background colour</h2>
<div id="pick-bg-palette-list" class="profiles-list" style="max-height: 300px; overflow-y: auto;"></div>
<div class="modal-actions">
<button class="btn btn-secondary" id="pick-bg-palette-close-btn">Close</button>
</div>
</div>
`;
document.body.appendChild(modal);
const list = modal.querySelector('#pick-bg-palette-list');
paletteColors.forEach((color, idx) => {
const row = document.createElement('div');
row.className = 'profiles-row';
row.style.display = 'flex';
row.style.alignItems = 'center';
row.style.gap = '0.75rem';
row.dataset.paletteIndex = String(idx);
row.dataset.paletteColor = color;
row.innerHTML = `
<div style="width:28px;height:28px;border-radius:4px;border:1px solid #555;background:${color};"></div>
<span style="flex:1">${color}</span>
<button class="btn btn-primary btn-small" type="button">Use</button>
`;
list.appendChild(row);
});
const close = () => modal.remove();
modal.querySelector('#pick-bg-palette-close-btn').addEventListener('click', close);
list.addEventListener('click', (e) => {
const btn = e.target.closest('button');
if (!btn) return;
const row = e.target.closest('[data-palette-index]');
if (!row) return;
const color = row.dataset.paletteColor;
const ref = parseInt(row.dataset.paletteIndex, 10);
if (!color || !Number.isInteger(ref)) return;
currentBackgroundPaletteRef = ref;
if (presetBackgroundInput) {
presetBackgroundInput.value = color;
}
updatePresetBackgroundButton();
close();
});
} catch (err) {
console.error('Failed to pick background from palette:', err);
alert('Failed to load palette colours.');
} }
}); });
} }
@@ -1449,12 +1584,10 @@ document.addEventListener('DOMContentLoaded', () => {
alert('Preset name is required to send.'); alert('Preset name is required to send.');
return; return;
} }
// Send current editor values and then select on all devices in the current zone (if any) // Send current editor values to zone devices (if any); never persist on device.
const section = document.querySelector('.presets-section[data-zone-id]');
const deviceNames = tabDeviceNamesFromSection(section);
// Work out the preset ID: for existing presets use currentEditId, otherwise fall back to name
const presetId = currentEditId || payload.name; const presetId = currentEditId || payload.name;
// Try sends preset first, then select; never persist on device. const deviceNames = await deviceNamesForPresetOnCurrentZone(presetId);
// Auto: load + immediate select. Manual: load only; first advance on the next audio beat.
await sendPresetViaEspNow(presetId, payload, deviceNames, false, false, '2'); await sendPresetViaEspNow(presetId, payload, deviceNames, false, false, '2');
}); });
} }
@@ -1466,9 +1599,8 @@ document.addEventListener('DOMContentLoaded', () => {
alert('Preset name is required.'); alert('Preset name is required.');
return; return;
} }
const section = document.querySelector('.presets-section[data-zone-id]');
const deviceNames = tabDeviceNamesFromSection(section);
const presetId = currentEditId || payload.name; const presetId = currentEditId || payload.name;
const deviceNames = await deviceNamesForPresetOnCurrentZone(presetId);
await sendPresetViaEspNow(presetId, payload, deviceNames, true, true, '1'); await sendPresetViaEspNow(presetId, payload, deviceNames, true, true, '1');
await updateTabDefaultPreset(presetId); await updateTabDefaultPreset(presetId);
await sendDefaultPreset('1', deviceNames); await sendDefaultPreset('1', deviceNames);
@@ -1503,9 +1635,9 @@ document.addEventListener('DOMContentLoaded', () => {
throw new Error('Failed to save preset'); throw new Error('Failed to save preset');
} }
// Same device targeting as Try: zone tab supplies names and selection without persistence. // Same device targeting as Try: per-preset zone groups when in a zone tab.
const section = document.querySelector('.presets-section[data-zone-id]'); const presetIdForSend = currentEditId || payload.name;
const deviceNames = tabDeviceNamesFromSection(section); const deviceNames = await deviceNamesForPresetOnCurrentZone(presetIdForSend);
// Use saved preset from server response for sending // Use saved preset from server response for sending
const saved = await response.json().catch(() => null); const saved = await response.json().catch(() => null);
@@ -1623,6 +1755,26 @@ const coercePresetBackground = (preset) => {
return '#000000'; return '#000000';
}; };
/** Resolved background hex; uses ``background_palette_ref`` when set and palette is available. */
const resolvePresetBackgroundHex = (preset, paletteColors) => {
if (!preset || typeof preset !== 'object') {
return coercePresetBackground(preset);
}
const rawRef =
preset.background_palette_ref !== undefined && preset.background_palette_ref !== null
? preset.background_palette_ref
: preset.backgroundPaletteRef;
const ref = typeof rawRef === 'number' ? rawRef : parseInt(String(rawRef != null ? rawRef : ''), 10);
const pal = Array.isArray(paletteColors) ? paletteColors : [];
if (Number.isInteger(ref) && ref >= 0 && ref < pal.length && pal[ref]) {
const c = String(pal[ref]).trim();
if (/^#[0-9a-fA-F]{6}$/i.test(c)) {
return c.toUpperCase();
}
}
return coercePresetBackground(preset);
};
/** Audio beat stride for manual presets (led-controller only; firmware ignores this key). */ /** Audio beat stride for manual presets (led-controller only; firmware ignores this key). */
const coerceManualBeatN = (preset) => { const coerceManualBeatN = (preset) => {
if (!preset || typeof preset !== 'object') return 1; if (!preset || typeof preset !== 'object') return 1;
@@ -1644,6 +1796,7 @@ const sendPresetViaEspNow = async (
saveToDevice = true, saveToDevice = true,
setDefault = false, setDefault = false,
devicePresetId = null, devicePresetId = null,
pushOptions = null,
) => { ) => {
try { try {
const baseColors = Array.isArray(preset.colors) && preset.colors.length const baseColors = Array.isArray(preset.colors) && preset.colors.length
@@ -1654,7 +1807,7 @@ const sendPresetViaEspNow = async (
const wirePresetId = devicePresetId != null ? String(devicePresetId) : String(presetId); const wirePresetId = devicePresetId != null ? String(devicePresetId) : String(presetId);
const presetAuto = coercePresetAuto(preset); const presetAuto = coercePresetAuto(preset);
const presetBackground = coercePresetBackground(preset); const presetBackground = resolvePresetBackgroundHex(preset, paletteColors);
const presetMessage = { const presetMessage = {
v: '1', v: '1',
presets: { presets: {
@@ -1707,7 +1860,7 @@ const sendPresetViaEspNow = async (
} }
} }
await postDriverSequence(sequence, targetMacs, 0.05); await postDriverSequence(sequence, targetMacs, 0.05, pushOptions);
} catch (error) { } catch (error) {
console.error('Failed to send preset to devices:', error); console.error('Failed to send preset to devices:', error);
alert('Failed to send preset to devices.'); alert('Failed to send preset to devices.');
@@ -1776,6 +1929,48 @@ try {
// window may not exist in some environments; ignore. // window may not exist in some environments; ignore.
} }
// Store selected preset(s) per zone (multi-select; merge send order = click order, last wins on device).
const zoneSelectedPresetIds = {};
const zonePresetSelectionOrder = {};
function ensureZonePresetSelection(zoneId) {
const z = String(zoneId);
if (!zoneSelectedPresetIds[z]) zoneSelectedPresetIds[z] = new Set();
if (!zonePresetSelectionOrder[z]) zonePresetSelectionOrder[z] = [];
}
function pruneZonePresetSelection(zoneId, validIdSet) {
const z = String(zoneId);
ensureZonePresetSelection(z);
const set = zoneSelectedPresetIds[z];
for (const id of [...set]) {
if (!validIdSet.has(String(id))) set.delete(id);
}
zonePresetSelectionOrder[z] = (zonePresetSelectionOrder[z] || []).filter((id) => set.has(String(id)));
}
function getOrderedZonePresetSelection(zoneId) {
const z = String(zoneId);
ensureZonePresetSelection(z);
const set = zoneSelectedPresetIds[z];
return (zonePresetSelectionOrder[z] || []).filter((id) => set.has(String(id)));
}
async function sendMergedZonePresetSelection(zoneId, tabData, allPresets) {
const ids = getOrderedZonePresetSelection(zoneId);
if (!ids.length) return;
for (let i = 0; i < ids.length; i += 1) {
const pid = ids[i];
const preset = allPresets[pid];
if (!preset) continue;
const names =
window.zonesManager && typeof window.zonesManager.resolveDeviceNamesForZonePreset === 'function'
? await window.zonesManager.resolveDeviceNamesForZonePreset(tabData, pid)
: [];
await sendPresetViaEspNow(pid, preset, names, false, false, '2');
}
}
// Store selected preset per zone // Store selected preset per zone
const selectedPresets = {}; const selectedPresets = {};
// Store selected preset payload per zone for beat-trigger reliability. // Store selected preset payload per zone for beat-trigger reliability.
@@ -1920,20 +2115,42 @@ const insertDraggingOntoTarget = (presetsList, dragging, dropTarget) => {
}; };
// Function to render presets for a specific zone in 2D grid // Function to render presets for a specific zone in 2D grid
const renderTabPresets = async (zoneId) => { /**
* @param {string} zoneId
* @param {{ stopSequencePlayback?: boolean }} [options] - pass `{ stopSequencePlayback: true }` only when
* the UI action should stop server zone sequence playback (default: do not POST /sequences/stop).
*/
const renderTabPresets = async (zoneId, options = {}) => {
const presetsList = document.getElementById('presets-list-zone'); const presetsList = document.getElementById('presets-list-zone');
if (!presetsList) return; if (!presetsList) return;
const stopSeq = options.stopSequencePlayback === true;
if (stopSeq && typeof window.stopZoneSequencePlayback === 'function') {
// Pass false: an earlier render's stop() can finish after this pass rebuilds the DOM and
// would otherwise clear .active from new sequence tiles (breaks edit/run selection).
await window.stopZoneSequencePlayback(false);
}
try { try {
// Get zone data to see which presets are associated const [tabResponse, groupsStripRes, presetsResponse] = await Promise.all([
const tabResponse = await fetch(`/zones/${zoneId}`, { fetch(`/zones/${zoneId}`, {
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
}); }),
fetch('/groups', { headers: { Accept: 'application/json' } }),
fetch('/presets', {
headers: { Accept: 'application/json' },
}),
]);
if (!tabResponse.ok) { if (!tabResponse.ok) {
throw new Error('Failed to load zone'); throw new Error('Failed to load zone');
} }
const tabData = await tabResponse.json(); const tabData = await tabResponse.json();
const groupsMapStrip = groupsStripRes.ok ? await groupsStripRes.json() : {};
const ck =
typeof window.normalizeZoneContentKind === 'function'
? window.normalizeZoneContentKind(tabData)
: null;
// Get presets - support both 2D grid and flat array (for backward compatibility) // Get presets - support both 2D grid and flat array (for backward compatibility)
let presetGrid = tabData.presets; let presetGrid = tabData.presets;
if (!presetGrid || !Array.isArray(presetGrid)) { if (!presetGrid || !Array.isArray(presetGrid)) {
@@ -1944,11 +2161,10 @@ const renderTabPresets = async (zoneId) => {
// It's a flat array, convert to grid // It's a flat array, convert to grid
presetGrid = arrayToGrid(presetGrid, 3); presetGrid = arrayToGrid(presetGrid, 3);
} }
if (ck === 'sequences') {
presetGrid = [];
}
// Get all presets
const presetsResponse = await fetch('/presets', {
headers: { Accept: 'application/json' },
});
if (!presetsResponse.ok) { if (!presetsResponse.ok) {
throw new Error('Failed to load presets'); throw new Error('Failed to load presets');
} }
@@ -2021,41 +2237,64 @@ const renderTabPresets = async (zoneId) => {
}); });
} }
// Get the currently selected preset for this zone
const selectedPresetId = selectedPresets[zoneId];
// Render presets in grid layout
// Flatten the grid and render all presets (grid CSS will handle layout)
const flatPresets = presetGrid.flat().filter(id => id); const flatPresets = presetGrid.flat().filter(id => id);
const validIdSet = new Set(flatPresets.map((id) => String(id)));
pruneZonePresetSelection(zoneId, validIdSet);
const hasSeq =
Array.isArray(tabData.sequence_ids) &&
tabData.sequence_ids.some((x) => x != null && String(x).trim());
if (flatPresets.length === 0) { if (flatPresets.length === 0) {
// Show empty message if this zone has no presets
const empty = document.createElement('p'); const empty = document.createElement('p');
empty.className = 'muted-text'; empty.className = 'muted-text';
empty.style.gridColumn = '1 / -1'; // Span all columns empty.style.gridColumn = '1 / -1'; // Span all columns
empty.textContent = 'No presets added to this zone. Open the zone\'s Edit menu and click "Add Preset" to add one.'; if (ck === 'sequences') {
presetsList.appendChild(empty); if (!hasSeq) {
empty.textContent =
"No sequences on this zone yet. Open the zone's Edit menu to add one.";
presetsList.appendChild(empty);
}
} else {
empty.textContent =
'No presets added to this zone. Open the zone\'s Edit menu and click "Add Preset" to add one.';
presetsList.appendChild(empty);
}
} else { } else {
flatPresets.forEach((presetId) => { flatPresets.forEach((presetId) => {
const preset = allPresets[presetId]; const preset = allPresets[presetId];
if (preset) { if (preset) {
const isSelected = presetId === selectedPresetId; ensureZonePresetSelection(zoneId);
const isSelected = zoneSelectedPresetIds[String(zoneId)].has(String(presetId));
const displayPreset = { const displayPreset = {
...preset, ...preset,
colors: resolveColorsWithPaletteRefs(preset.colors, preset.palette_refs, paletteColors), colors: resolveColorsWithPaletteRefs(preset.colors, preset.palette_refs, paletteColors),
background: resolvePresetBackgroundHex(preset, paletteColors),
}; };
const wrapper = createPresetButton(presetId, displayPreset, zoneId, isSelected); const wrapper = createPresetButton(
presetId,
displayPreset,
zoneId,
isSelected,
tabData,
groupsMapStrip,
allPresets,
);
presetsList.appendChild(wrapper); presetsList.appendChild(wrapper);
} }
}); });
} }
if (typeof window.appendZoneSequenceTiles === 'function' && ck !== 'presets') {
await window.appendZoneSequenceTiles(zoneId, tabData, allPresets, paletteColors, presetsList);
}
} catch (error) { } catch (error) {
console.error('Failed to render zone presets:', error); console.error('Failed to render zone presets:', error);
presetsList.innerHTML = '<p class="muted-text">Failed to load presets.</p>'; presetsList.innerHTML = '<p class="muted-text">Failed to load presets.</p>';
} }
}; };
const createPresetButton = (presetId, preset, zoneId, isSelected = false) => { const createPresetButton = (presetId, preset, zoneId, isSelected, tabData, groupsMap, allPresets) => {
const uiMode = getPresetUiMode(); const uiMode = getPresetUiMode();
const row = document.createElement('div'); const row = document.createElement('div');
@@ -2069,7 +2308,6 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
button.className = 'pattern-button preset-tile-main'; button.className = 'pattern-button preset-tile-main';
if (isSelected) { if (isSelected) {
button.classList.add('active'); button.classList.add('active');
selectedPresetPayloads[zoneId] = preset;
} }
const colors = Array.isArray(preset.colors) ? preset.colors.filter((c) => c) : []; const colors = Array.isArray(preset.colors) ? preset.colors.filter((c) => c) : [];
@@ -2093,6 +2331,14 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
presetNameLabel.className = 'pattern-button-label'; presetNameLabel.className = 'pattern-button-label';
button.appendChild(presetNameLabel); button.appendChild(presetNameLabel);
const groupsText = formatPresetTargetGroupsLine(tabData || {}, groupsMap || {});
if (groupsText) {
const groupsSpan = document.createElement('span');
groupsSpan.className = 'preset-tile-groups';
groupsSpan.textContent = groupsText;
button.appendChild(groupsSpan);
}
const bgSwatch = document.createElement('span'); const bgSwatch = document.createElement('span');
const bgColor = coercePresetBackground(preset); const bgColor = coercePresetBackground(preset);
bgSwatch.title = `Background: ${bgColor}`; bgSwatch.title = `Background: ${bgColor}`;
@@ -2111,7 +2357,7 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
`; `;
button.appendChild(bgSwatch); button.appendChild(bgSwatch);
const isManualPreset = preset && typeof preset.auto === 'boolean' ? !preset.auto : false; const isManualPreset = preset && !coercePresetAuto(preset);
if (isManualPreset) { if (isManualPreset) {
const manualBadge = document.createElement('span'); const manualBadge = document.createElement('span');
manualBadge.textContent = '1'; manualBadge.textContent = '1';
@@ -2138,18 +2384,39 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
button.addEventListener('click', () => { button.addEventListener('click', () => {
if (isDraggingPreset) return; if (isDraggingPreset) return;
console.info('Preset button pressed', { zoneId, presetId, name: (preset && preset.name) || presetId });
const presetsListEl = document.getElementById('presets-list-zone'); const presetsListEl = document.getElementById('presets-list-zone');
if (presetsListEl) { ensureZonePresetSelection(zoneId);
presetsListEl.querySelectorAll('.pattern-button').forEach((btn) => btn.classList.remove('active')); const z = String(zoneId);
const set = zoneSelectedPresetIds[z];
const order = zonePresetSelectionOrder[z];
const idStr = String(presetId);
if (set.has(idStr)) {
set.delete(idStr);
zonePresetSelectionOrder[z] = order.filter((x) => String(x) !== idStr);
} else {
set.add(idStr);
order.push(idStr);
} }
button.classList.add('active'); if (presetsListEl) {
selectedPresets[zoneId] = presetId; presetsListEl.querySelectorAll('.preset-tile-row:not(.sequence-tile-row)').forEach((rw) => {
selectedPresetPayloads[zoneId] = preset; const pid = rw.dataset.presetId;
const section = row.closest('.presets-section'); const btnEl = rw.querySelector('.preset-tile-main');
const deviceNames = tabDeviceNamesFromSection(section); if (!btnEl || !pid) return;
sendPresetViaEspNow(presetId, preset, deviceNames, false, false, '2').catch((err) => { if (set.has(String(pid))) btnEl.classList.add('active');
console.error(err); else btnEl.classList.remove('active');
}); });
}
const orderList = getOrderedZonePresetSelection(zoneId);
if (orderList.length) {
const lastPid = orderList[orderList.length - 1];
selectedPresets[zoneId] = lastPid;
selectedPresetPayloads[zoneId] = (allPresets && allPresets[lastPid]) || preset;
} else {
delete selectedPresets[zoneId];
delete selectedPresetPayloads[zoneId];
}
void sendMergedZonePresetSelection(zoneId, tabData, allPresets);
}); });
if (canDrag) { if (canDrag) {
@@ -2173,7 +2440,9 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
}); });
} }
row.appendChild(button); const top = document.createElement('div');
top.className = 'preset-tile-row-top';
top.appendChild(button);
if (uiMode === 'edit') { if (uiMode === 'edit') {
const actions = document.createElement('div'); const actions = document.createElement('div');
@@ -2192,9 +2461,11 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
}); });
actions.appendChild(editBtn); actions.appendChild(editBtn);
row.appendChild(actions); top.appendChild(actions);
} }
row.appendChild(top);
return row; return row;
}; };
@@ -2297,6 +2568,10 @@ const removePresetFromTab = async (zoneId, presetId) => {
try { try {
window.removePresetFromTab = removePresetFromTab; window.removePresetFromTab = removePresetFromTab;
} catch (e) {} } catch (e) {}
try {
window.renderTabPresets = renderTabPresets;
window.getPresetUiMode = getPresetUiMode;
} catch (e) {}
// Listen for HTMX swaps to render presets // Listen for HTMX swaps to render presets
document.body.addEventListener('htmx:afterSwap', (event) => { document.body.addEventListener('htmx:afterSwap', (event) => {
@@ -2327,10 +2602,7 @@ document.addEventListener('DOMContentLoaded', () => {
} }
const mainMenu = document.getElementById('main-menu-dropdown'); const mainMenu = document.getElementById('main-menu-dropdown');
if (mainMenu) mainMenu.classList.remove('open'); if (mainMenu) mainMenu.classList.remove('open');
const leftPanel = document.querySelector('.presets-section[data-zone-id]'); // Preset strip re-renders from `zones.js` after `loadZones()` (no driver/playback side effects).
if (leftPanel) {
renderTabPresets(leftPanel.dataset.zoneId);
}
}); });
}); });
}); });

1176
src/static/sequences.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -199,13 +199,31 @@ header h1 {
.audio-top-indicator { .audio-top-indicator {
display: none; display: none;
align-items: center; flex-direction: column;
gap: 0.4rem; align-items: stretch;
gap: 0.15rem;
padding: 0.25rem 0.55rem; padding: 0.25rem 0.55rem;
border: 1px solid #4a4a4a; border: 1px solid #4a4a4a;
border-radius: 6px; border-radius: 6px;
background-color: #1a1a1a; background-color: #1a1a1a;
min-width: 6.5rem; min-width: 9rem;
}
.audio-top-indicator-main {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.audio-top-indicator-extra {
font-size: 0.62rem;
color: #9e9e9e;
line-height: 1.25;
text-align: right;
max-width: 16rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.audio-top-indicator.audio-running { .audio-top-indicator.audio-running {
@@ -226,6 +244,19 @@ header h1 {
text-align: right; text-align: right;
} }
.audio-top-beat-readout {
font-size: 0.62rem;
color: #b0bec5;
line-height: 1.25;
max-width: 12rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
text-align: left;
}
.audio-top-indicator-subvalue { .audio-top-indicator-subvalue {
font-size: 0.75rem; font-size: 0.75rem;
color: #9e9e9e; color: #9e9e9e;
@@ -240,7 +271,9 @@ header h1 {
.audio-top-indicator.flash .audio-top-indicator-value, .audio-top-indicator.flash .audio-top-indicator-value,
.audio-top-indicator.flash .audio-top-indicator-label, .audio-top-indicator.flash .audio-top-indicator-label,
.audio-top-indicator.flash .audio-top-indicator-subvalue { .audio-top-indicator.flash .audio-top-indicator-subvalue,
.audio-top-indicator.flash .audio-top-indicator-extra,
.audio-top-indicator.flash .audio-top-beat-readout {
color: #fff; color: #fff;
} }
@@ -620,7 +653,8 @@ body.preset-ui-run .edit-mode-only {
overflow-x: hidden; overflow-x: hidden;
display: grid; display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr)); grid-template-columns: repeat(8, minmax(0, 1fr));
grid-auto-rows: 5rem; /* min-content height prevents taller tiles (edit actions, wrapping) from overlapping the next row and stealing clicks */
grid-auto-rows: minmax(5rem, auto);
column-gap: 0.3rem; column-gap: 0.3rem;
row-gap: 0.3rem; row-gap: 0.3rem;
align-content: start; align-content: start;
@@ -784,6 +818,26 @@ body.preset-ui-run .edit-mode-only {
border-radius: 6px; border-radius: 6px;
} }
.audio-bpm-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.audio-bpm-row .audio-bpm-readout {
flex: 0 0 auto;
min-width: 5rem;
}
.audio-modal-beat-readout {
flex: 1;
min-width: 10rem;
font-size: 0.85rem;
line-height: 1.35;
text-align: left;
}
.audio-hit-type-readout { .audio-hit-type-readout {
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 600;
@@ -851,17 +905,43 @@ body.preset-ui-run .edit-mode-only {
flex-direction: row; flex-direction: row;
align-items: stretch; align-items: stretch;
min-width: 0; min-width: 0;
min-height: 0; min-height: 5rem;
} }
.preset-tile-row--run .preset-tile-actions { .preset-tile-row-top {
display: none; display: flex;
flex-direction: row;
align-items: stretch;
flex: 1;
min-width: 0;
min-height: 5rem;
} }
.preset-tile-main { .preset-tile-main {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
height: 5rem; height: 5rem;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 0.12rem;
}
.preset-tile-main .preset-tile-groups {
font-size: 0.68rem;
font-weight: 500;
line-height: 1.15;
opacity: 0.88;
text-align: center;
max-width: 100%;
padding: 0 0.35rem;
box-sizing: border-box;
word-break: break-word;
}
.preset-tile-row--run .preset-tile-actions {
display: none;
} }
/* Edit only beside the preset tile in edit mode. */ /* Edit only beside the preset tile in edit mode. */
@@ -1030,6 +1110,46 @@ body.preset-ui-run .edit-mode-only {
display: flex; display: flex;
} }
/* Stack sequence modals below groups / preset editor so in-modal actions stay visible */
#sequence-editor-modal.active,
#sequences-modal.active {
z-index: 1040;
}
#groups-modal.active,
#edit-group-modal.active,
#presets-modal.active {
z-index: 1050;
}
#preset-editor-modal.active {
z-index: 1060;
}
/* Child / overlay modals: must paint above preset editor (1060) and list modals (1050). */
#color-palette-modal.active,
#pattern-editor-modal.active,
#edit-device-modal.active,
#edit-zone-modal.active {
z-index: 1070;
}
/* Patterns library (often used next to presets); below preset editor, above sequences. */
#patterns-modal.active {
z-index: 1055;
}
/* Header / global dialogs */
#help-modal.active,
#audio-modal.active,
#settings-modal.active,
#led-tool-modal.active {
z-index: 1080;
}
/* JS-appended overlays (e.g. preset “From Palette”, add-preset-to-zone) — must sit above #preset-editor-modal */
.modal.modal-child-overlay.active {
z-index: 1080;
}
.modal-content { .modal-content {
background-color: #2e2e2e; background-color: #2e2e2e;
padding: 2rem; padding: 2rem;
@@ -1500,6 +1620,14 @@ body.preset-ui-run .edit-mode-only {
} }
} }
.sequence-step-drag-handle:active {
cursor: grabbing;
}
.sequence-step-row.dragging {
opacity: 0.65;
}
/* Settings modal */ /* Settings modal */
#settings-modal .modal-content { #settings-modal .modal-content {
max-width: 900px; max-width: 900px;

View File

@@ -1,6 +1,12 @@
// Zone management JavaScript // Zone management JavaScript
let currentZoneId = null; let currentZoneId = null;
let brightnessSendTimeout = null; let brightnessSendTimeout = null;
/**
* When true, the next `loadZoneContent` skips `sendZoneBrightness` (run/edit toggle: same zone, UI only).
*/
let suppressZoneContentDriverSideEffects = false;
/** First successful `loadZoneContent` after open: skip hardware brightness push (read-only hydration). */
let isFirstZoneContentHydration = true;
function clamp255(n) { function clamp255(n) {
const v = parseInt(n, 10); const v = parseInt(n, 10);
@@ -150,7 +156,10 @@ async function fetchDevicesMap() {
async function fetchGroupsMap() { async function fetchGroupsMap() {
try { try {
const response = await fetch("/groups", { headers: { Accept: "application/json" } }); const response = await fetch("/groups", {
headers: { Accept: "application/json" },
credentials: "same-origin",
});
if (!response.ok) return {}; if (!response.ok) return {};
const data = await response.json(); const data = await response.json();
return data && typeof data === "object" ? data : {}; return data && typeof data === "object" ? data : {};
@@ -162,7 +171,7 @@ async function fetchGroupsMap() {
/** /**
* Resolve registry names + MACs for a zone document (``group_ids`` expands groups; * Resolve registry names + MACs for a zone document (``group_ids`` expands groups;
* otherwise legacy ``names``). * otherwise ``names`` only).
*/ */
async function computeZoneTargets(zone) { async function computeZoneTargets(zone) {
const dm = await fetchDevicesMap(); const dm = await fetchDevicesMap();
@@ -202,6 +211,147 @@ async function computeZoneTargets(zone) {
}; };
} }
/** Tab device list for sequences: zone ``group_ids`` first, else legacy ``names`` only. */
async function computeZoneNamesTargets(zone) {
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 t = await resolveTargetsFromGroupIds(gids);
return {
names: Array.isArray(t.names) ? t.names : [],
macs: Array.isArray(t.macs) ? [...new Set(t.macs.filter(Boolean))] : [],
};
}
const dm = await fetchDevicesMap();
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))],
};
}
function normalizeDeviceMac(raw) {
return String(raw || "")
.trim()
.toLowerCase()
.replace(/:/g, "")
.replace(/-/g, "");
}
/** Flat preset ids on a zone document (grid or flat). */
function tabPresetIdsInZoneDoc(zoneDoc) {
let ids = [];
if (Array.isArray(zoneDoc && zoneDoc.presets_flat)) {
ids = zoneDoc.presets_flat.slice();
} else if (Array.isArray(zoneDoc && zoneDoc.presets)) {
if (zoneDoc.presets.length && typeof zoneDoc.presets[0] === "string") {
ids = zoneDoc.presets.slice();
} else if (zoneDoc.presets.length && Array.isArray(zoneDoc.presets[0])) {
ids = zoneDoc.presets.flat();
}
}
return (ids || []).filter(Boolean);
}
/** Group ids used for standalone presets on this zone: zone ``group_ids`` only. */
function effectiveGroupIdsForZonePreset(zoneDoc) {
return Array.isArray(zoneDoc && zoneDoc.group_ids)
? zoneDoc.group_ids.map((x) => String(x).trim()).filter((x) => x.length > 0)
: [];
}
/** Resolve device names + MACs from a list of group ids (same rules as zone group expansion). */
async function resolveTargetsFromGroupIds(groupIds) {
const dm = await fetchDevicesMap();
const gids = Array.isArray(groupIds)
? groupIds.map((x) => String(x).trim()).filter((x) => x.length > 0)
: [];
if (!gids.length) {
return { names: [], macs: [] };
}
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 = normalizeDeviceMac(raw);
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 };
}
/** Device names for standalone presets: zone ``group_ids``, or all devices on the tab (``names``). */
async function resolveDeviceNamesForZonePreset(zoneDoc, presetId) {
void presetId;
const gids = effectiveGroupIdsForZonePreset(zoneDoc);
if (gids.length) {
const t = await resolveTargetsFromGroupIds(gids);
if (t.names.length) return t.names;
}
const zt = await computeZoneTargets(zoneDoc);
return Array.isArray(zt.names) ? zt.names.slice() : [];
}
/** Union of devices targeted by standalone presets on the zone (same as zone preset targeting). */
async function computeZonePresetUnionTargets(zoneDoc) {
return await computeZoneTargets(zoneDoc);
}
/**
* Device names for one sequence step. Empty stepGroupIds => all zone tab devices (``names`` only).
* Otherwise: lane groups intersected with that tab device list (not zone ``group_ids``).
*/
async function resolveSequenceStepDeviceNames(zone, stepGroupIds) {
const zoneT = await computeZoneNamesTargets(zone);
const names = Array.isArray(zoneT.names) ? zoneT.names : [];
const macs = Array.isArray(zoneT.macs) ? zoneT.macs : [];
const gids = Array.isArray(stepGroupIds)
? stepGroupIds.map((x) => String(x).trim()).filter((x) => x.length > 0)
: [];
if (!gids.length) {
return names.slice();
}
const zoneMacSet = new Set(
macs.map((m) => normalizeDeviceMac(m)).filter((m) => m.length === 12),
);
const zoneNameByMac = new Map();
for (let i = 0; i < macs.length; i++) {
const m = normalizeDeviceMac(macs[i]);
if (m.length === 12 && !zoneNameByMac.has(m)) {
zoneNameByMac.set(m, names[i] || m);
}
}
const gm = await fetchGroupsMap();
const stepMacs = new Set();
for (const gid of gids) {
const g = gm[gid];
if (!g || !Array.isArray(g.devices)) continue;
for (const raw of g.devices) {
const m = normalizeDeviceMac(raw);
if (m.length !== 12 || !zoneMacSet.has(m)) continue;
stepMacs.add(m);
}
}
const out = [];
for (const m of stepMacs) {
const n = zoneNameByMac.get(m);
if (n) out.push(n);
}
return out;
}
async function resolveZoneDeviceMacsFromZoneData(zone) { async function resolveZoneDeviceMacsFromZoneData(zone) {
const t = await computeZoneTargets(zone); const t = await computeZoneTargets(zone);
return t.macs; return t.macs;
@@ -250,67 +400,6 @@ function rowsToNames(rows) {
return (rows || []).map((r) => String(r.name || "").trim()).filter((n) => n.length > 0); return (rows || []).map((r) => String(r.name || "").trim()).filter((n) => n.length > 0);
} }
function renderZoneDevicesEditor(containerEl, rows, devicesMap) {
if (!containerEl) return;
containerEl.innerHTML = "";
const entries = Object.entries(devicesMap || {}).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 || "—";
label.appendChild(strong);
label.appendChild(document.createTextNode(" "));
const sub = document.createElement("span");
sub.className = "muted-text";
sub.textContent = row.mac ? row.mac : "(not in registry)";
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);
renderZoneDevicesEditor(containerEl, rows, devicesMap);
});
div.appendChild(label);
div.appendChild(rm);
containerEl.appendChild(div);
});
const macsInRows = new Set(rows.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);
rows.push({ mac, name: n });
sel.value = "";
renderZoneDevicesEditor(containerEl, rows, devicesMap);
});
addWrap.appendChild(sel);
addWrap.appendChild(addBtn);
containerEl.appendChild(addWrap);
}
function renderZoneGroupsEditor(containerEl, rows, groupsMap) { function renderZoneGroupsEditor(containerEl, rows, groupsMap) {
if (!containerEl) return; if (!containerEl) return;
containerEl.innerHTML = ""; containerEl.innerHTML = "";
@@ -372,13 +461,6 @@ function renderZoneGroupsEditor(containerEl, rows, groupsMap) {
containerEl.appendChild(addWrap); 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). */ /** Read zone device names from the presets section (JSON attr preferred; legacy comma list fallback). */
function parseTabDeviceNames(section) { function parseTabDeviceNames(section) {
if (!section) return []; if (!section) return [];
@@ -408,6 +490,32 @@ function escapeHtmlAttr(s) {
.replace(/</g, "&lt;"); .replace(/</g, "&lt;");
} }
/** @returns {null | 'presets' | 'sequences'} */
function normalizeZoneContentKind(zoneDoc) {
const k = zoneDoc && zoneDoc.content_kind;
if (k === 'presets' || k === 'sequences') return k;
return null;
}
function applyZoneContentKindEditModal(kind) {
const presetsBlock = document.getElementById('edit-zone-block-presets');
const groupsBlock = document.getElementById('edit-zone-block-groups');
const seqBlock = document.getElementById('edit-zone-block-sequences');
const vis = (el, show) => {
if (el) el.style.display = show ? '' : 'none';
};
vis(groupsBlock, true);
if (!kind) {
vis(presetsBlock, true);
vis(seqBlock, true);
return;
}
vis(presetsBlock, kind === 'presets');
vis(seqBlock, kind === 'sequences');
}
window.normalizeZoneContentKind = normalizeZoneContentKind;
// Load tabs list // Load tabs list
async function loadZones() { async function loadZones() {
try { try {
@@ -465,13 +573,16 @@ function renderZonesList(tabs, tabOrder, currentZoneId) {
const zone = tabs[zoneId]; const zone = tabs[zoneId];
if (zone) { if (zone) {
const activeClass = zoneId === currentZoneId ? 'active' : ''; const activeClass = zoneId === currentZoneId ? 'active' : '';
const tabName = zone.name || `Zone ${zoneId}`; let disp = zone.name || `Zone ${zoneId}`;
const kind = normalizeZoneContentKind(zone);
if (kind === 'presets') disp += ' · presets';
else if (kind === 'sequences') disp += ' · sequences';
html += ` html += `
<button class="zone-button ${activeClass}" <button class="zone-button ${activeClass}"
data-zone-id="${zoneId}" data-zone-id="${zoneId}"
title="${editMode ? 'Click to select, right-click to edit' : 'Click to select'}" title="${editMode ? 'Click to select, right-click to edit' : 'Click to select'}"
onclick="selectZone('${zoneId}')"> onclick="selectZone('${zoneId}')">
${tabName} ${escapeHtmlAttr(disp)}
</button> </button>
`; `;
} }
@@ -511,9 +622,13 @@ function renderZonesListModal(tabs, tabOrder, currentZoneId) {
row.dataset.zoneId = String(zoneId); row.dataset.zoneId = String(zoneId);
const label = document.createElement("span"); const label = document.createElement("span");
label.textContent = (zone && zone.name) || zoneId; let disp = (zone && zone.name) || zoneId;
const kind = normalizeZoneContentKind(zone);
if (kind === 'presets') disp += ' · presets';
else if (kind === 'sequences') disp += ' · sequences';
label.textContent = disp;
if (String(zoneId) === String(currentZoneId)) { if (String(zoneId) === String(currentZoneId)) {
label.textContent = `${label.textContent}`; label.textContent = `${disp}`;
label.style.fontWeight = "bold"; label.style.fontWeight = "bold";
label.style.color = "#FFD700"; label.style.color = "#FFD700";
} }
@@ -735,8 +850,14 @@ async function loadZoneContent(zoneId) {
? Math.max(0, Math.min(255, Math.round(zoneBrightness))) ? Math.max(0, Math.min(255, Math.round(zoneBrightness)))
: 255; : 255;
applyBrightnessSliders(normalizedBrightness); applyBrightnessSliders(normalizedBrightness);
// Apply this zone's saved brightness when switching zones. const initialHydration = isFirstZoneContentHydration;
sendZoneBrightness(zoneId, normalizedBrightness); if (isFirstZoneContentHydration) {
isFirstZoneContentHydration = false;
}
if (!suppressZoneContentDriverSideEffects && !initialHydration) {
// Apply this zone's saved brightness when switching zones (not initial page load or UI-only strip refresh).
sendZoneBrightness(zoneId, normalizedBrightness);
}
// Trigger presets loading if the function exists // Trigger presets loading if the function exists
if (typeof renderTabPresets === 'function') { if (typeof renderTabPresets === 'function') {
@@ -857,17 +978,7 @@ async function sendProfilePresets() {
} }
function tabPresetIdsInOrder(tabData) { function tabPresetIdsInOrder(tabData) {
let ids = []; return tabPresetIdsInZoneDoc(tabData);
if (Array.isArray(tabData.presets_flat)) {
ids = tabData.presets_flat.slice();
} else if (Array.isArray(tabData.presets)) {
if (tabData.presets.length && typeof tabData.presets[0] === "string") {
ids = tabData.presets.slice();
} else if (tabData.presets.length && Array.isArray(tabData.presets[0])) {
ids = tabData.presets.flat();
}
}
return (ids || []).filter(Boolean);
} }
// Presets already on the zone (remove) and presets available to add (select). // Presets already on the zone (remove) and presets available to add (select).
@@ -888,6 +999,13 @@ async function refreshEditTabPresetsUi(zoneId) {
return; return;
} }
const tabData = await tabRes.json(); const tabData = await tabRes.json();
const kind = normalizeZoneContentKind(tabData);
if (kind === 'sequences') {
currentEl.innerHTML =
'<span class="muted-text">This zone is for sequences only. Presets are hidden.</span>';
addEl.innerHTML = '<span class="muted-text">—</span>';
return;
}
const inTabIds = tabPresetIdsInOrder(tabData); const inTabIds = tabPresetIdsInOrder(tabData);
const inTabSet = new Set(inTabIds.map((id) => String(id))); const inTabSet = new Set(inTabIds.map((id) => String(id)));
@@ -911,8 +1029,12 @@ async function refreshEditTabPresetsUi(zoneId) {
for (const presetId of inTabIds) { for (const presetId of inTabIds) {
const preset = allPresets[presetId] || {}; const preset = allPresets[presetId] || {};
const name = preset.name || presetId; const name = preset.name || presetId;
const row = makeRow(); const block = document.createElement("div");
block.style.cssText =
"border:1px solid rgba(255,255,255,0.12);border-radius:6px;padding:0.5rem 0.65rem;margin-bottom:0.65rem;";
const top = makeRow();
const label = document.createElement("span"); const label = document.createElement("span");
label.style.fontWeight = "600";
label.textContent = name; label.textContent = name;
const removeBtn = document.createElement("button"); const removeBtn = document.createElement("button");
removeBtn.type = "button"; removeBtn.type = "button";
@@ -924,9 +1046,11 @@ async function refreshEditTabPresetsUi(zoneId) {
await window.removePresetFromTab(zoneId, presetId); await window.removePresetFromTab(zoneId, presetId);
await refreshEditTabPresetsUi(zoneId); await refreshEditTabPresetsUi(zoneId);
}); });
row.appendChild(label); top.appendChild(label);
row.appendChild(removeBtn); top.appendChild(removeBtn);
currentEl.appendChild(row); block.appendChild(top);
currentEl.appendChild(block);
} }
} }
@@ -987,7 +1111,6 @@ async function openEditZoneModal(zoneId, zone) {
const modal = document.getElementById("edit-zone-modal"); const modal = document.getElementById("edit-zone-modal");
const idInput = document.getElementById("edit-zone-id"); const idInput = document.getElementById("edit-zone-id");
const nameInput = document.getElementById("edit-zone-name"); const nameInput = document.getElementById("edit-zone-name");
const editor = document.getElementById("edit-zone-devices-editor");
let tabData = zone; let tabData = zone;
if (!tabData || typeof tabData !== "object" || tabData.error) { if (!tabData || typeof tabData !== "object" || tabData.error) {
@@ -1005,6 +1128,7 @@ async function openEditZoneModal(zoneId, zone) {
if (idInput) idInput.value = zoneId; if (idInput) idInput.value = zoneId;
if (nameInput) nameInput.value = tabData.name || ""; if (nameInput) nameInput.value = tabData.name || "";
const groupsEditor = document.getElementById("edit-zone-groups-editor");
const groupsMap = await fetchGroupsMap(); const groupsMap = await fetchGroupsMap();
const rawGids = Array.isArray(tabData.group_ids) ? tabData.group_ids : []; const rawGids = Array.isArray(tabData.group_ids) ? tabData.group_ids : [];
window.__editTabGroupRows = rawGids.map((gid) => { window.__editTabGroupRows = rawGids.map((gid) => {
@@ -1012,17 +1136,21 @@ async function openEditZoneModal(zoneId, zone) {
const g = groupsMap[id]; const g = groupsMap[id];
return { id, name: g && g.name ? String(g.name).trim() : id }; return { id, name: g && g.name ? String(g.name).trim() : id };
}); });
renderZoneGroupsEditor(editor, window.__editTabGroupRows, groupsMap); renderZoneGroupsEditor(groupsEditor, window.__editTabGroupRows, groupsMap);
if (modal) modal.classList.add("active"); if (modal) modal.classList.add("active");
applyZoneContentKindEditModal(normalizeZoneContentKind(tabData));
await refreshEditTabPresetsUi(zoneId); await refreshEditTabPresetsUi(zoneId);
if (typeof window.refreshEditTabSequencesUi === "function") {
await window.refreshEditTabSequencesUi(zoneId);
}
} }
// Update an existing zone // Update an existing zone (name, group list; devices come from groups only).
async function updateZone(zoneId, name, groupIds) { async function updateZone(zoneId, name, groupRows) {
try { try {
const gids = Array.isArray(groupIds) const gids = Array.isArray(groupRows)
? groupIds.map((x) => String(x).trim()).filter((x) => x.length > 0) ? groupRows.map((r) => String(r.id || "").trim()).filter((x) => x.length > 0)
: []; : [];
const response = await fetch(`/zones/${zoneId}`, { const response = await fetch(`/zones/${zoneId}`, {
method: 'PUT', method: 'PUT',
@@ -1031,8 +1159,9 @@ async function updateZone(zoneId, name, groupIds) {
}, },
body: JSON.stringify({ body: JSON.stringify({
name: name, name: name,
group_ids: gids,
names: [], names: [],
group_ids: gids,
preset_group_ids: {},
}) })
}); });
@@ -1055,12 +1184,11 @@ async function updateZone(zoneId, name, groupIds) {
} }
} }
// Create a new zone // Create a new zone (add devices in Edit zone). ``contentKind`` is ``'presets'`` | ``'sequences'``.
async function createZone(name, groupIds) { async function createZone(name, contentKind) {
try { try {
const gids = Array.isArray(groupIds) const ck =
? groupIds.map((x) => String(x).trim()).filter((x) => x.length > 0) contentKind === 'sequences' || contentKind === 'presets' ? contentKind : 'presets';
: [];
const response = await fetch('/zones', { const response = await fetch('/zones', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -1068,8 +1196,9 @@ async function createZone(name, groupIds) {
}, },
body: JSON.stringify({ body: JSON.stringify({
name: name, name: name,
group_ids: gids,
names: [], names: [],
group_ids: [],
content_kind: ck,
}) })
}); });
@@ -1150,8 +1279,12 @@ document.addEventListener('DOMContentLoaded', () => {
const name = newTabNameInput.value.trim(); const name = newTabNameInput.value.trim();
if (name) { if (name) {
const groupIds = await defaultGroupIdsForNewTab(); const kindRadio = document.querySelector(
await createZone(name, groupIds); 'input[name="new-zone-content-kind"]:checked',
);
const contentKind =
kindRadio && kindRadio.value === 'sequences' ? 'sequences' : 'presets';
await createZone(name, contentKind);
if (newTabNameInput) newTabNameInput.value = ""; if (newTabNameInput) newTabNameInput.value = "";
} }
}; };
@@ -1178,15 +1311,10 @@ document.addEventListener('DOMContentLoaded', () => {
const zoneId = idInput ? idInput.value : null; const zoneId = idInput ? idInput.value : null;
const name = nameInput ? nameInput.value.trim() : ""; const name = nameInput ? nameInput.value.trim() : "";
const rows = window.__editTabGroupRows || []; const groupRows = window.__editTabGroupRows || [];
const groupIds = rows.map((r) => r.id).filter(Boolean);
if (zoneId && name) { if (zoneId && name) {
if (groupIds.length === 0) { await updateZone(zoneId, name, groupRows);
alert("Add at least one device group.");
return;
}
await updateZone(zoneId, name, groupIds);
editZoneForm.reset(); editZoneForm.reset();
} }
}); });
@@ -1220,9 +1348,14 @@ document.addEventListener('DOMContentLoaded', () => {
// When run/edit mode toggles, refresh tabs UI so edit actions show/hide immediately. // When run/edit mode toggles, refresh tabs UI so edit actions show/hide immediately.
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => { document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
await loadZones(); suppressZoneContentDriverSideEffects = true;
if (zonesModal && zonesModal.classList.contains("active")) { try {
await loadZonesModal(); await loadZones();
if (zonesModal && zonesModal.classList.contains("active")) {
await loadZonesModal();
}
} finally {
suppressZoneContentDriverSideEffects = false;
} }
}); });
}); });
@@ -1240,6 +1373,14 @@ window.zonesManager = {
resolveZoneDeviceMacsFromZoneData, resolveZoneDeviceMacsFromZoneData,
resolveTabDeviceMacs: resolveZoneDeviceMacs, resolveTabDeviceMacs: resolveZoneDeviceMacs,
getCurrentZoneId: () => currentZoneId, getCurrentZoneId: () => currentZoneId,
computeZoneTargets,
computeZoneNamesTargets,
computeZonePresetUnionTargets,
effectiveGroupIdsForZonePreset,
resolveDeviceNamesForZonePreset,
resolveSequenceStepDeviceNames,
fetchGroupsMap,
renderZoneGroupsEditor,
}; };
window.tabsManager = window.zonesManager; window.tabsManager = window.zonesManager;
window.tabsManager.getCurrentTabId = () => currentZoneId; window.tabsManager.getCurrentTabId = () => currentZoneId;

View File

@@ -16,9 +16,11 @@
</div> </div>
<div class="header-end"> <div class="header-end">
<div id="audio-top-indicator" class="audio-top-indicator" title="Live audio BPM"> <div id="audio-top-indicator" class="audio-top-indicator" title="Live audio BPM">
<span class="audio-top-indicator-label">BPM</span> <div class="audio-top-indicator-main">
<span id="audio-top-bpm-value" class="audio-top-indicator-value">--</span> <span class="audio-top-indicator-label">BPM</span>
<span id="audio-top-beat-count" class="audio-top-indicator-subvalue">#0</span> <span id="audio-top-bpm-value" class="audio-top-indicator-value">--</span>
<span id="audio-top-beat-readout" class="audio-top-beat-readout" aria-live="polite"></span>
</div>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<div class="header-brightness-control"> <div class="header-brightness-control">
@@ -30,6 +32,7 @@
<button class="btn btn-secondary edit-mode-only" id="groups-btn">Groups</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="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="presets-btn">Presets</button>
<button class="btn btn-secondary edit-mode-only" id="sequences-btn">Sequences</button>
<button class="btn btn-secondary edit-mode-only" id="patterns-btn">Patterns</button> <button class="btn btn-secondary edit-mode-only" id="patterns-btn">Patterns</button>
<button class="btn btn-secondary edit-mode-only" id="color-palette-btn">Colour Palette</button> <button class="btn btn-secondary edit-mode-only" id="color-palette-btn">Colour Palette</button>
<button class="btn btn-secondary edit-mode-only" id="send-profile-presets-btn">Send Presets</button> <button class="btn btn-secondary edit-mode-only" id="send-profile-presets-btn">Send Presets</button>
@@ -51,6 +54,7 @@
<button type="button" class="edit-mode-only" data-target="groups-btn">Groups</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="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="presets-btn">Presets</button>
<button type="button" class="edit-mode-only" data-target="sequences-btn">Sequences</button>
<button type="button" class="edit-mode-only" data-target="patterns-btn">Patterns</button> <button type="button" class="edit-mode-only" data-target="patterns-btn">Patterns</button>
<button type="button" class="edit-mode-only" data-target="color-palette-btn">Colour Palette</button> <button type="button" class="edit-mode-only" data-target="color-palette-btn">Colour Palette</button>
<button type="button" class="edit-mode-only" data-target="send-profile-presets-btn">Send Presets</button> <button type="button" class="edit-mode-only" data-target="send-profile-presets-btn">Send Presets</button>
@@ -79,6 +83,11 @@
<input type="text" id="new-zone-name" placeholder="Zone name"> <input type="text" id="new-zone-name" placeholder="Zone name">
<button class="btn btn-primary" id="create-zone-btn">Create</button> <button class="btn btn-primary" id="create-zone-btn">Create</button>
</div> </div>
<fieldset class="muted-text" style="margin:0.35rem 0 0.75rem;border:none;padding:0;">
<legend style="font-size:0.85em;margin-bottom:0.35rem;">This zone is for</legend>
<label style="margin-right:1rem;"><input type="radio" name="new-zone-content-kind" value="presets" checked> Presets</label>
<label><input type="radio" name="new-zone-content-kind" value="sequences"> Sequences</label>
</fieldset>
<div id="zones-list-modal" class="profiles-list"></div> <div id="zones-list-modal" class="profiles-list"></div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-secondary" id="zones-close-btn">Close</button> <button class="btn btn-secondary" id="zones-close-btn">Close</button>
@@ -98,12 +107,22 @@
</div> </div>
<label>Zone Name:</label> <label>Zone Name:</label>
<input type="text" id="edit-zone-name" placeholder="Enter zone name" required> <input type="text" id="edit-zone-name" placeholder="Enter zone name" required>
<label class="zone-devices-label">Device groups in this zone</label> <div id="edit-zone-block-groups">
<div id="edit-zone-devices-editor" class="zone-devices-editor"></div> <label class="zone-devices-label">Device groups on this zone</label>
<div id="edit-zone-groups-editor" class="zone-devices-editor"></div>
</div>
<div id="edit-zone-block-presets">
<label class="zone-presets-section-label">Presets on this zone</label> <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> <div id="edit-zone-presets-current" class="profiles-list edit-zone-presets-scroll"></div>
<label class="zone-presets-section-label">Add presets to this zone</label> <label class="zone-presets-section-label">Add presets to this zone</label>
<div id="edit-zone-presets-list" class="profiles-list edit-zone-presets-scroll"></div> <div id="edit-zone-presets-list" class="profiles-list edit-zone-presets-scroll"></div>
</div>
<div id="edit-zone-block-sequences">
<label class="zone-presets-section-label">Sequences on this zone</label>
<div id="edit-zone-sequences-current" class="profiles-list edit-zone-presets-scroll"></div>
<label class="zone-presets-section-label">Add a sequence to this zone</label>
<div id="edit-zone-sequences-list" class="profiles-list edit-zone-presets-scroll"></div>
</div>
</form> </form>
</div> </div>
</div> </div>
@@ -140,13 +159,16 @@
</div> </div>
</div> </div>
<!-- Device groups: members + WiFi driver defaults (zones reference groups) --> <!-- Device groups: members + WiFi driver defaults (zones reference groups for presets) -->
<div id="groups-modal" class="modal"> <div id="groups-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<h2>Device groups</h2> <h2>Device groups</h2>
<p class="muted-text" style="margin-top:0;">Assign drivers to a group, set WiFi defaults once per group, then attach groups to zones.</p> <p class="muted-text" style="margin-top:0;">Assign drivers to a group, set WiFi defaults once per group, then attach groups to a zone for standalone presets (sequences use each lanes groups only). By default new groups are <strong>shared</strong> across all profiles; tick “this profile only” to hide a group from other profiles.</p>
<div class="profiles-actions zone-modal-create-row"> <div class="profiles-actions zone-modal-create-row">
<input type="text" id="new-group-name" placeholder="Group name"> <input type="text" id="new-group-name" placeholder="Group name">
<label class="muted-text" style="display:inline-flex;align-items:center;gap:0.35rem;white-space:nowrap;">
<input type="checkbox" id="new-group-profile-only"> This profile only
</label>
<button class="btn btn-primary" id="create-group-btn">Create</button> <button class="btn btn-primary" id="create-group-btn">Create</button>
</div> </div>
<div id="groups-list-modal" class="profiles-list"></div> <div id="groups-list-modal" class="profiles-list"></div>
@@ -167,8 +189,16 @@
</div> </div>
<label for="edit-group-name">Group name</label> <label for="edit-group-name">Group name</label>
<input type="text" id="edit-group-name" required autocomplete="off"> <input type="text" id="edit-group-name" required autocomplete="off">
<label class="muted-text" style="display:flex;align-items:flex-start;gap:0.5rem;margin-top:0.5rem;">
<input type="checkbox" id="edit-group-share-all-profiles" style="margin-top:0.2rem;">
<span>Share with all profiles (untick to keep this group on the <strong>current profile only</strong>)</span>
</label>
<label class="zone-devices-label">Devices in this group</label> <label class="zone-devices-label">Devices in this group</label>
<div id="edit-group-devices-editor" class="zone-devices-editor"></div> <div id="edit-group-devices-editor" class="zone-devices-editor"></div>
<div class="profiles-actions" style="margin-top: 0.5rem;">
<button type="button" class="btn btn-secondary btn-small" id="edit-group-identify-btn">Identify devices in group</button>
</div>
<p class="muted-text" style="margin-top:0.25rem;">Runs identify on every driver in the group at the same time so they blink together.</p>
<label for="edit-group-output-brightness" style="margin-top:0.75rem;display:block;">Group output brightness (0255)</label> <label for="edit-group-output-brightness" style="margin-top:0.75rem;display:block;">Group output brightness (0255)</label>
<div class="profiles-actions" style="align-items: center; gap: 0.75rem;"> <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;"> <input type="range" id="edit-group-output-brightness" min="0" max="255" value="255" style="flex:1;">
@@ -280,6 +310,51 @@
</div> </div>
</div> </div>
<!-- Sequences Modal -->
<div id="sequences-modal" class="modal">
<div class="modal-content">
<h2>Sequences</h2>
<div class="modal-actions">
<button type="button" class="btn btn-primary" id="sequence-add-btn">Add</button>
<button type="button" class="btn btn-secondary" id="sequences-open-presets-btn">Presets</button>
</div>
<div id="sequences-list" class="profiles-list"></div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" id="sequences-close-btn">Close</button>
</div>
</div>
</div>
<!-- Sequence Editor Modal -->
<div id="sequence-editor-modal" class="modal">
<div class="modal-content">
<h2>Sequence</h2>
<div class="preset-editor-field">
<label for="sequence-editor-name">Name</label>
<input type="text" id="sequence-editor-name" placeholder="Sequence name" style="width:100%;max-width:24rem;">
</div>
<div id="sequence-editor-beats-panel" style="margin:0 0 0.75rem 0;">
<p class="muted-text" style="font-size:0.85em;margin:0 0 0.5rem 0;">
Each step runs for the number of <strong>beats</strong> you set on that step.
When the header <strong>Audio</strong> detector is running, real beats advance the sequence.
When it is stopped, the server uses <strong>simulated</strong> beats at the BPM below.
</p>
<label for="sequence-editor-simulated-bpm" style="display:block;margin-bottom:0.25rem;">Simulated BPM (when audio is off)</label>
<input type="number" id="sequence-editor-simulated-bpm" min="30" max="300" value="120" style="width:6rem;" title="Used only while the audio detector is stopped">
<p id="sequence-editor-bpm-live" class="muted-text" style="font-size:0.85em;margin:0.5rem 0 0 0;"></p>
</div>
<div id="sequence-editor-lanes"></div>
<div class="modal-actions" style="margin-top:0.75rem;">
<button type="button" class="btn btn-secondary btn-small" id="sequence-editor-add-lane-btn">Add lane</button>
</div>
<div class="modal-actions preset-editor-modal-actions">
<button type="button" class="btn btn-danger" id="sequence-editor-delete-btn">Delete</button>
<button type="button" class="btn btn-primary" id="sequence-editor-save-btn">Save</button>
<button type="button" class="btn btn-secondary" id="sequence-editor-close-btn">Close</button>
</div>
</div>
</div>
<!-- Preset Editor Modal --> <!-- Preset Editor Modal -->
<div id="preset-editor-modal" class="modal"> <div id="preset-editor-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
@@ -309,6 +384,7 @@
<label for="preset-background-input">Background</label> <label for="preset-background-input">Background</label>
<div class="profiles-actions" style="gap: 0.4rem;"> <div class="profiles-actions" style="gap: 0.4rem;">
<button type="button" class="btn btn-secondary btn-small" id="preset-background-btn" title="Choose background colour">#000000</button> <button type="button" class="btn btn-secondary btn-small" id="preset-background-btn" title="Choose background colour">#000000</button>
<button type="button" class="btn btn-secondary btn-small" id="preset-background-from-palette-btn">From Palette</button>
<input type="color" id="preset-background-input" value="#000000" title="Background colour used in patterns with background support" style="position:absolute;opacity:0;pointer-events:none;width:1px;height:1px;"> <input type="color" id="preset-background-input" value="#000000" title="Background colour used in patterns with background support" style="position:absolute;opacity:0;pointer-events:none;width:1px;height:1px;">
</div> </div>
</div> </div>
@@ -321,7 +397,7 @@
<p id="preset-manual-mode-hint" class="muted-text" style="display: none; margin-top: 0.35rem; font-size: 0.85em;"></p> <p id="preset-manual-mode-hint" class="muted-text" style="display: none; margin-top: 0.35rem; font-size: 0.85em;"></p>
<div id="preset-manual-beat-n-wrap" class="preset-editor-field" style="display: none; margin-top: 0.5rem;"> <div id="preset-manual-beat-n-wrap" class="preset-editor-field" style="display: none; margin-top: 0.5rem;">
<label for="preset-manual-beat-n-input">Audio beat: every</label> <label for="preset-manual-beat-n-input">Audio beat: every</label>
<input type="number" id="preset-manual-beat-n-input" min="1" max="64" value="1" style="width: 4rem;" title="Controller only; not sent to pattern logic"> <input type="number" id="preset-manual-beat-n-input" min="1" max="64" value="1" style="width: 4rem;" title="Controller only; not sent to pattern logic" autocomplete="off">
<span class="muted-text" style="font-size: 0.85em;">beats (this app only)</span> <span class="muted-text" style="font-size: 0.85em;">beats (this app only)</span>
</div> </div>
</div> </div>
@@ -533,7 +609,10 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Current BPM</label> <label>Current BPM</label>
<div id="audio-bpm-value" class="audio-bpm-readout">--</div> <div class="audio-bpm-row">
<div id="audio-bpm-value" class="audio-bpm-readout">--</div>
<div id="audio-modal-beat-readout" class="audio-modal-beat-readout muted-text" aria-live="polite"></div>
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Detected hit type</label> <label>Detected hit type</label>
@@ -543,6 +622,11 @@
<label>Flash on beat</label> <label>Flash on beat</label>
<div id="audio-beat-flash" class="audio-beat-flash" aria-hidden="true"></div> <div id="audio-beat-flash" class="audio-beat-flash" aria-hidden="true"></div>
</div> </div>
<div class="form-group">
<label for="audio-beat-phase-ms">Beat phase shift (ms)</label>
<input type="number" id="audio-beat-phase-ms" min="0" max="500" step="5" value="0" style="width:6rem;">
<small class="muted-text">Delays beat flashes and sequenced beats so they line up with what you hear (saved in this browser).</small>
</div>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="btn btn-primary" id="audio-start-btn">Start</button> <button type="button" class="btn btn-primary" id="audio-start-btn">Start</button>
<button type="button" class="btn btn-secondary" id="audio-stop-btn">Stop</button> <button type="button" class="btn btn-secondary" id="audio-stop-btn">Stop</button>
@@ -709,6 +793,7 @@
<script src="/static/zone_palette.js"></script> <script src="/static/zone_palette.js"></script>
<script src="/static/patterns.js"></script> <script src="/static/patterns.js"></script>
<script src="/static/presets.js"></script> <script src="/static/presets.js"></script>
<script src="/static/sequences.js"></script>
<script src="/static/devices.js"></script> <script src="/static/devices.js"></script>
<script src="/static/audio.js"></script> <script src="/static/audio.js"></script>
</body> </body>

View File

@@ -161,11 +161,11 @@ class AudioBeatDetector:
self._status["beat_type_confidence"] = float(beat_type_confidence or 0.0) self._status["beat_type_confidence"] = float(beat_type_confidence or 0.0)
self._status["beat_seq"] = int(self._status.get("beat_seq", 0)) + 1 self._status["beat_seq"] = int(self._status.get("beat_seq", 0)) + 1
try: try:
from util.beat_driver_route import notify_beat_detected from util import sequence_playback as seq_pb
notify_beat_detected() seq_pb.push_thread_beat()
except Exception as e: except Exception as e:
print(f"[audio] beat driver route: {e}") print(f"[audio] sequence beat queue: {e}")
def _run_loop(self, device): def _run_loop(self, device):
try: try:
@@ -280,3 +280,22 @@ class AudioBeatDetector:
with self._lock: with self._lock:
self._running = False self._running = False
self._status["running"] = False self._status["running"] = False
# Set from ``main`` so sequence playback can tell real audio from simulated beats.
_shared_beat_detector = None
def set_shared_beat_detector(det):
global _shared_beat_detector
_shared_beat_detector = det
def shared_beat_detector_running():
d = _shared_beat_detector
if d is None:
return False
try:
return bool(d.status().get("running"))
except Exception:
return False

View File

@@ -0,0 +1,52 @@
"""Persist whether the audio beat detector should be running (survives process restarts)."""
from __future__ import annotations
import json
import os
from typing import Any, Dict, Optional
def _db_path() -> str:
base = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
return os.path.join(base, "db", "audio_run.json")
def coerce_audio_device(device: Any) -> Optional[Any]:
"""Match ``/api/audio/start`` body coercion (None = host default input)."""
if device in ("", None):
return None
try:
return int(device)
except (TypeError, ValueError):
return device
def read_audio_run_state() -> Dict[str, Any]:
path = _db_path()
try:
with open(path, "r", encoding="utf-8") as f:
raw = json.load(f)
except (OSError, json.JSONDecodeError, TypeError):
return {"enabled": False, "device": None}
if not isinstance(raw, dict):
return {"enabled": False, "device": None}
enabled = bool(raw.get("enabled"))
dev = raw.get("device", None)
return {"enabled": enabled, "device": dev}
def write_audio_run_state(*, enabled: bool, device: Any = None) -> None:
"""Write run intent. When ``enabled`` is false, keep ``device`` from the previous file for next start."""
path = _db_path()
prev = read_audio_run_state()
if enabled:
data = {"enabled": True, "device": device}
else:
data = {"enabled": False, "device": prev.get("device")}
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
except OSError as e:
print(f"[audio_run_persist] save failed: {e!r}")

View File

@@ -6,9 +6,13 @@ import asyncio
import json import json
import os import os
import threading import threading
from typing import Any, Dict, List, Optional, Set from typing import Any, Dict, List, Optional, Set, Tuple
_route_lock = threading.Lock() _route_lock = threading.Lock()
# Per-lane manual routes: key ``-1`` = legacy single-route (preset push / UI); keys ``0..n`` =
# zone sequence lanes so every manual lane gets its own stride counter and wire.
_lane_manual: Dict[int, Dict[str, Any]] = {}
# Public mirror for ``get_beat_route`` / header UI (derived from lane table).
_beat_route: Dict[str, Any] = { _beat_route: Dict[str, Any] = {
"enabled": False, "enabled": False,
"device_names": [], "device_names": [],
@@ -18,6 +22,7 @@ _beat_route: Dict[str, Any] = {
"manual_beat_n": 1, "manual_beat_n": 1,
} }
_beat_counter: int = 0 _beat_counter: int = 0
_preset_session_beats: int = 0
_main_loop: Optional[asyncio.AbstractEventLoop] = None _main_loop: Optional[asyncio.AbstractEventLoop] = None
@@ -26,16 +31,65 @@ def set_beat_route_main_loop(loop: asyncio.AbstractEventLoop) -> None:
_main_loop = loop _main_loop = loop
def _pick_display_lane_key() -> Optional[int]:
"""Lane key used for header stride readout (prefer sequence lane 0)."""
if not _lane_manual:
return None
if 0 in _lane_manual:
return 0
seq_keys = [k for k in _lane_manual if isinstance(k, int) and k >= 0]
if seq_keys:
return min(seq_keys)
if -1 in _lane_manual:
return -1
return min(_lane_manual.keys())
def _sync_public_beat_route_from_lane_table() -> None:
"""Mirror ``_lane_manual`` into legacy ``_beat_route`` shape for API consumers."""
global _beat_route, _beat_counter
pick = _pick_display_lane_key()
if pick is None:
_beat_route = {
"enabled": False,
"device_names": [],
"wire_preset_id": "2",
"is_manual": False,
"pattern": "",
"manual_beat_n": 1,
}
_beat_counter = 0
return
e = _lane_manual[pick]
_beat_route = {
"enabled": True,
"device_names": list(e.get("device_names") or []),
"wire_preset_id": str(e.get("wire_preset_id") or "2"),
"is_manual": True,
"pattern": str(e.get("pattern") or ""),
"manual_beat_n": int(e.get("manual_beat_n") or 1),
}
_beat_counter = int(e.get("beat_counter", 0))
def update_beat_route(payload: Dict[str, Any]) -> None: def update_beat_route(payload: Dict[str, Any]) -> None:
"""Internal: set or clear routing from explicit fields (tests / future APIs).""" """Internal: set or clear routing from explicit fields (tests / future APIs)."""
global _beat_route, _beat_counter global _lane_manual, _beat_route, _beat_counter, _preset_session_beats
if not isinstance(payload, dict): if not isinstance(payload, dict):
return return
with _route_lock: with _route_lock:
if payload.get("enabled") is False: if payload.get("enabled") is False:
_beat_route = {**_beat_route, "enabled": False} _lane_manual.clear()
_beat_route = {
**_beat_route,
"enabled": False,
"is_manual": False,
"device_names": [],
}
_beat_counter = 0 _beat_counter = 0
_preset_session_beats = 0
return return
old = dict(_beat_route)
names = payload.get("device_names") names = payload.get("device_names")
if not isinstance(names, list): if not isinstance(names, list):
names = [] names = []
@@ -44,15 +98,20 @@ def update_beat_route(payload: Dict[str, Any]) -> None:
except (TypeError, ValueError): except (TypeError, ValueError):
n_raw = 1 n_raw = 1
manual_n = max(1, min(64, n_raw)) manual_n = max(1, min(64, n_raw))
_beat_route = { new_wire = str(payload.get("wire_preset_id") or "2")
"enabled": bool(payload.get("enabled", False)), old_wire = str(old.get("wire_preset_id") or "2")
"device_names": [str(n).strip() for n in names if str(n).strip()], if not old.get("enabled") or old_wire != new_wire:
"wire_preset_id": str(payload.get("wire_preset_id") or "2"), _preset_session_beats = 0
"is_manual": bool(payload.get("is_manual", False)), clean_names = [str(n).strip() for n in names if str(n).strip()]
_lane_manual.clear()
_lane_manual[-1] = {
"device_names": clean_names,
"wire_preset_id": new_wire,
"pattern": str(payload.get("pattern") or "").strip(), "pattern": str(payload.get("pattern") or "").strip(),
"manual_beat_n": manual_n, "manual_beat_n": manual_n,
"beat_counter": 0,
} }
_beat_counter = 0 _sync_public_beat_route_from_lane_table()
def get_beat_route() -> Dict[str, Any]: def get_beat_route() -> Dict[str, Any]:
@@ -60,6 +119,44 @@ def get_beat_route() -> Dict[str, Any]:
return dict(_beat_route) return dict(_beat_route)
def manual_beat_stride_status() -> Dict[str, Any]:
"""Audio-beat stride for a live manual preset (not sequence). For UI readout with BPM.
``beat_in_stride`` is always in ``1..stride_n`` when ``active`` (1-based within the stride).
With multiple sequence manual lanes, reflects lane 0 (or the smallest lane index).
"""
with _route_lock:
pick = _pick_display_lane_key()
if pick is None or pick not in _lane_manual:
wid = str(_beat_route.get("wire_preset_id") or "").strip()
return {"active": False, "preset_session_beats": 0, "wire_preset_id": wid}
e = _lane_manual[pick]
c = int(e.get("beat_counter", 0))
psb = int(_preset_session_beats)
wid = str(e.get("wire_preset_id") or "").strip()
try:
n = int(e.get("manual_beat_n") or 1)
except (TypeError, ValueError):
n = 1
n = max(1, min(64, n))
if c <= 0:
return {
"active": True,
"beat_in_stride": 1,
"stride_n": n,
"preset_session_beats": psb,
"wire_preset_id": wid,
}
beat_in_stride = ((c - 1) % n) + 1
return {
"active": True,
"beat_in_stride": beat_in_stride,
"stride_n": n,
"preset_session_beats": psb,
"wire_preset_id": wid,
}
def _coerce_manual_beat_n(body: Any) -> int: def _coerce_manual_beat_n(body: Any) -> int:
"""Beats between audio-triggered selects (led-controller only); default 1 = every beat.""" """Beats between audio-triggered selects (led-controller only); default 1 = every beat."""
if not isinstance(body, dict): if not isinstance(body, dict):
@@ -136,34 +233,140 @@ def _apply_manual_beat_route(
wire_preset_id: str, wire_preset_id: str,
preset_body: Any, preset_body: Any,
) -> None: ) -> None:
"""Enable audio→driver routing for one manual preset, or disable if invalid.""" """Enable audio→driver routing for one manual preset (clears all lanes, including sequence)."""
global _lane_manual
if not device_names: if not device_names:
update_beat_route({"enabled": False}) with _route_lock:
_lane_manual.clear()
_sync_public_beat_route_from_lane_table()
return return
if not isinstance(preset_body, dict): if not isinstance(preset_body, dict):
update_beat_route({"enabled": False}) with _route_lock:
_lane_manual.clear()
_sync_public_beat_route_from_lane_table()
return return
if _coerce_auto_from_body(preset_body): if _coerce_auto_from_body(preset_body):
update_beat_route({"enabled": False}) with _route_lock:
_lane_manual.clear()
_sync_public_beat_route_from_lane_table()
return return
pattern = str(preset_body.get("pattern") or preset_body.get("p") or "").strip() pattern = str(preset_body.get("pattern") or preset_body.get("p") or "").strip()
if pattern and not _pattern_supports_manual(pattern): if pattern and not _pattern_supports_manual(pattern):
update_beat_route({"enabled": False}) with _route_lock:
_lane_manual.clear()
_sync_public_beat_route_from_lane_table()
return return
update_beat_route( names = [str(n).strip() for n in device_names if str(n).strip()]
{ with _route_lock:
"enabled": True, _lane_manual.clear()
"device_names": device_names, _lane_manual[-1] = {
"wire_preset_id": wire_preset_id, "device_names": names,
"is_manual": True, "wire_preset_id": str(wire_preset_id).strip(),
"pattern": pattern, "pattern": pattern,
"manual_beat_n": _coerce_manual_beat_n(preset_body), "manual_beat_n": _coerce_manual_beat_n(preset_body),
"beat_counter": 0,
} }
) _sync_public_beat_route_from_lane_table()
def _apply_manual_beat_route_standalone_overlay(
device_names: List[str],
wire_preset_id: str,
preset_body: Any,
) -> None:
"""Register manual beat routing on lane ``-1`` only, keeping sequence lanes ``0..n`` intact."""
global _lane_manual
if not device_names:
with _route_lock:
_lane_manual.pop(-1, None)
_sync_public_beat_route_from_lane_table()
return
if not isinstance(preset_body, dict):
with _route_lock:
_lane_manual.pop(-1, None)
_sync_public_beat_route_from_lane_table()
return
if _coerce_auto_from_body(preset_body):
with _route_lock:
_lane_manual.pop(-1, None)
_sync_public_beat_route_from_lane_table()
return
pattern = str(preset_body.get("pattern") or preset_body.get("p") or "").strip()
if pattern and not _pattern_supports_manual(pattern):
with _route_lock:
_lane_manual.pop(-1, None)
_sync_public_beat_route_from_lane_table()
return
names = [str(n).strip() for n in device_names if str(n).strip()]
with _route_lock:
_lane_manual[-1] = {
"device_names": names,
"wire_preset_id": str(wire_preset_id).strip(),
"pattern": pattern,
"manual_beat_n": _coerce_manual_beat_n(preset_body),
"beat_counter": 0,
}
_sync_public_beat_route_from_lane_table()
def set_sequence_manual_lane_route(
lane_index: int,
device_names: List[str],
wire_preset_id: str,
preset_body: Any,
) -> None:
"""Register or update one sequence lane's manual beat route (parallel lanes, independent strides)."""
global _lane_manual
names = [str(n).strip() for n in (device_names or []) if str(n).strip()]
if not names or not isinstance(preset_body, dict) or _coerce_auto_from_body(preset_body):
with _route_lock:
if lane_index in _lane_manual:
del _lane_manual[lane_index]
_sync_public_beat_route_from_lane_table()
return
pattern = str(preset_body.get("pattern") or preset_body.get("p") or "").strip()
if pattern and not _pattern_supports_manual(pattern):
with _route_lock:
if lane_index in _lane_manual:
del _lane_manual[lane_index]
_sync_public_beat_route_from_lane_table()
return
mn = _coerce_manual_beat_n(preset_body)
wid = str(wire_preset_id).strip()
with _route_lock:
old = _lane_manual.get(lane_index)
bc = 0
if (
old
and str(old.get("wire_preset_id") or "") == wid
and int(old.get("manual_beat_n") or 1) == mn
and set(old.get("device_names") or []) == set(names)
):
bc = int(old.get("beat_counter", 0))
_lane_manual[lane_index] = {
"device_names": names,
"wire_preset_id": wid,
"pattern": pattern,
"manual_beat_n": mn,
"beat_counter": bc,
}
_sync_public_beat_route_from_lane_table()
def clear_sequence_manual_lane_route(lane_index: int) -> None:
"""Remove beat routing for one sequence lane (e.g. step switched to auto)."""
global _lane_manual
with _route_lock:
if lane_index in _lane_manual:
del _lane_manual[lane_index]
_sync_public_beat_route_from_lane_table()
def sync_beat_route_from_push_sequence( def sync_beat_route_from_push_sequence(
sequence: List[Any], target_macs: Optional[List[str]] = None sequence: List[Any],
target_macs: Optional[List[str]] = None,
*,
preserve_parallel_lane_routes: bool = False,
) -> None: ) -> None:
""" """
Update beat routing from a ``/presets/push`` body ``sequence`` (list of v1 dicts). Update beat routing from a ``/presets/push`` body ``sequence`` (list of v1 dicts).
@@ -173,6 +376,11 @@ def sync_beat_route_from_push_sequence(
Without ``select`` (e.g. manual preset loaded without immediate select): if ``target_macs`` 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 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. registry names for those MACs so the first advance is on the next audio beat.
When ``preserve_parallel_lane_routes`` is true (e.g. zone sequence playback is active), an
auto preset in ``select`` does not clear manual routing — other lanes still receive
``notify_beat_detected``. A manual preset in ``select`` is applied on lane ``-1`` only so
sequence lanes ``0..n`` keep their stride counters and wire ids.
""" """
merged_presets: Dict[str, Any] = {} merged_presets: Dict[str, Any] = {}
last_select: Optional[Dict[str, Any]] = None last_select: Optional[Dict[str, Any]] = None
@@ -194,7 +402,8 @@ def sync_beat_route_from_push_sequence(
if last_select: if last_select:
device_names = [str(k).strip() for k in last_select.keys() if str(k).strip()] device_names = [str(k).strip() for k in last_select.keys() if str(k).strip()]
if not device_names: if not device_names:
update_beat_route({"enabled": False}) if not preserve_parallel_lane_routes:
update_beat_route({"enabled": False})
return return
wire_ids: Set[str] = set() wire_ids: Set[str] = set()
@@ -205,7 +414,8 @@ def sync_beat_route_from_push_sequence(
elif val is not None: elif val is not None:
wire_ids.add(str(val).strip()) wire_ids.add(str(val).strip())
if len(wire_ids) != 1: if len(wire_ids) != 1:
update_beat_route({"enabled": False}) if not preserve_parallel_lane_routes:
update_beat_route({"enabled": False})
return return
wire_preset_id = wire_ids.pop() wire_preset_id = wire_ids.pop()
preset_body = merged_presets.get(wire_preset_id) preset_body = merged_presets.get(wire_preset_id)
@@ -214,16 +424,33 @@ def sync_beat_route_from_push_sequence(
if str(k).strip() == wire_preset_id: if str(k).strip() == wire_preset_id:
preset_body = v preset_body = v
break break
_apply_manual_beat_route(device_names, wire_preset_id, preset_body) if preset_body is None:
if not preserve_parallel_lane_routes:
update_beat_route({"enabled": False})
return
if _coerce_auto_from_body(preset_body):
if not preserve_parallel_lane_routes:
update_beat_route({"enabled": False})
return
if preserve_parallel_lane_routes:
_apply_manual_beat_route_standalone_overlay(
device_names, wire_preset_id, preset_body
)
else:
_apply_manual_beat_route(device_names, wire_preset_id, preset_body)
return return
wire_id, body = _single_manual_wire_preset(merged_presets) wire_id, body = _single_manual_wire_preset(merged_presets)
if wire_id and body is not None: if wire_id and body is not None:
names = _registry_names_for_macs(target_macs) names = _registry_names_for_macs(target_macs)
_apply_manual_beat_route(names, wire_id, body) if preserve_parallel_lane_routes:
_apply_manual_beat_route_standalone_overlay(names, wire_id, body)
else:
_apply_manual_beat_route(names, wire_id, body)
return return
update_beat_route({"enabled": False}) if not preserve_parallel_lane_routes:
update_beat_route({"enabled": False})
def _pattern_supports_manual(pattern_key: str) -> bool: def _pattern_supports_manual(pattern_key: str) -> bool:
@@ -247,25 +474,30 @@ def _pattern_supports_manual(pattern_key: str) -> bool:
def remap_beat_route_device_name(old_name: str, new_name: str) -> None: def remap_beat_route_device_name(old_name: str, new_name: str) -> None:
"""Update cached audio-beat target names after a device registry rename.""" """Update cached audio-beat target names after a device registry rename."""
global _beat_route global _lane_manual
o = str(old_name or "").strip() o = str(old_name or "").strip()
n = str(new_name or "").strip() n = str(new_name or "").strip()
if not o or not n or o == n: if not o or not n or o == n:
return return
with _route_lock: with _route_lock:
if not _beat_route.get("enabled"): any_changed = False
return for e in _lane_manual.values():
names = _beat_route.get("device_names") or [] names = e.get("device_names") or []
new_list: List[str] = [] if not isinstance(names, list):
changed = False continue
for item in names: new_list: List[str] = []
if str(item).strip() == o: row_changed = False
new_list.append(n) for item in names:
changed = True if str(item).strip() == o:
else: new_list.append(n)
new_list.append(str(item)) row_changed = True
if changed: else:
_beat_route = {**_beat_route, "device_names": new_list} new_list.append(str(item))
if row_changed:
e["device_names"] = new_list
any_changed = True
if any_changed:
_sync_public_beat_route_from_lane_table()
async def _deliver_select(device_names: List[str], wire_preset_id: str) -> None: async def _deliver_select(device_names: List[str], wire_preset_id: str) -> None:
@@ -302,35 +534,45 @@ async def _deliver_select(device_names: List[str], wire_preset_id: str) -> None:
print(f"[beat-route] deliver failed: {e}") print(f"[beat-route] deliver failed: {e}")
async def _deliver_select_batch(pairs: List[Tuple[List[str], str]]) -> None:
for names, pid in pairs:
await _deliver_select(names, pid)
def notify_beat_detected() -> None: def notify_beat_detected() -> None:
"""Invoked from the audio thread when a beat is detected.""" """Invoked from the audio thread when a beat is detected."""
global _beat_counter global _preset_session_beats
work: List[Tuple[List[str], str]] = []
with _route_lock: with _route_lock:
r = dict(_beat_route) if not _lane_manual:
if not r.get("enabled"):
return return
if not r.get("is_manual"): work = []
return for key in sorted(_lane_manual.keys()):
pattern = r.get("pattern") or "" e = _lane_manual[key]
if pattern and not _pattern_supports_manual(pattern): names = e.get("device_names") or []
return if not isinstance(names, list) or not names:
names = r.get("device_names") or [] continue
if not names: pattern = str(e.get("pattern") or "")
return if pattern and not _pattern_supports_manual(pattern):
try: continue
n = int(r.get("manual_beat_n") or 1) try:
except (TypeError, ValueError): n = int(e.get("manual_beat_n") or 1)
n = 1 except (TypeError, ValueError):
n = max(1, min(64, n)) n = 1
_beat_counter += 1 n = max(1, min(64, n))
if ((_beat_counter - 1) % n) != 0: e["beat_counter"] = int(e.get("beat_counter", 0)) + 1
return c = int(e["beat_counter"])
preset_id = str(r.get("wire_preset_id") or "2") if (c - 1) % n != 0:
names_copy = list(names) continue
work.append((list(names), str(e.get("wire_preset_id") or "2")))
if work:
_preset_session_beats += 1
if not work:
return
loop = _main_loop loop = _main_loop
if loop is None: if loop is None:
return return
try: try:
asyncio.run_coroutine_threadsafe(_deliver_select(names_copy, preset_id), loop) asyncio.run_coroutine_threadsafe(_deliver_select_batch(work), loop)
except Exception as e: except Exception as e:
print(f"[beat-route] schedule failed: {e}") print(f"[beat-route] schedule failed: {e}")

View File

@@ -78,13 +78,49 @@ def build_select_message(device_name, preset_name, step=None):
return {device_name: select_list} return {device_name: select_list}
def build_preset_dict(preset_data): def _hex_from_background_raw(bg_raw):
"""Coerce ``background`` / ``bg`` field to a ``#RRGGBB`` string (driver wire format)."""
if isinstance(bg_raw, str):
bg = bg_raw if bg_raw.startswith("#") else f"#{bg_raw}"
return bg
if isinstance(bg_raw, (list, tuple)) and len(bg_raw) == 3:
return f"#{int(bg_raw[0]):02x}{int(bg_raw[1]):02x}{int(bg_raw[2]):02x}"
return "#000000"
def resolve_preset_background_hex(preset_data, palette_colors=None):
"""
Resolved background as ``#RRGGBB``. When ``palette_colors`` is a non-empty list and
``background_palette_ref`` is set, uses that palette index; otherwise stored ``background`` / ``bg``.
"""
if not isinstance(preset_data, dict):
return "#000000"
pal = list(palette_colors) if isinstance(palette_colors, list) else []
ref = preset_data.get("background_palette_ref", preset_data.get("backgroundPaletteRef"))
if pal and ref is not None:
try:
idx = int(ref)
except (TypeError, ValueError):
idx = None
else:
if isinstance(idx, int) and 0 <= idx < len(pal):
c = pal[idx]
if isinstance(c, str) and c.strip().startswith("#"):
s = c.strip()
if len(s) == 7 and all(ch in "0123456789abcdefABCDEF" for ch in s[1:]):
return s.upper()
bg_raw = preset_data.get("background", preset_data.get("bg", "#000000"))
return _hex_from_background_raw(bg_raw)
def build_preset_dict(preset_data, palette_colors=None):
""" """
Convert preset data to API-compliant format. Convert preset data to API-compliant format.
Args: Args:
preset_data: Dictionary with preset fields (may include name, pattern, colors, etc.) preset_data: Dictionary with preset fields (may include name, pattern, colors, etc.)
palette_colors: Optional list of ``#RRGGBB`` strings for ``background_palette_ref`` resolution.
Returns: Returns:
Dictionary with preset in API-compliant format (without name field) Dictionary with preset in API-compliant format (without name field)
@@ -137,13 +173,7 @@ def build_preset_dict(preset_data):
auto_raw = preset_data.get("auto", preset_data.get("a", True)) auto_raw = preset_data.get("auto", preset_data.get("a", True))
auto_bool = _coerce_auto(auto_raw) auto_bool = _coerce_auto(auto_raw)
bg_raw = preset_data.get("background", preset_data.get("bg", "#000000")) bg = resolve_preset_background_hex(preset_data, palette_colors)
if isinstance(bg_raw, str):
bg = bg_raw if bg_raw.startswith("#") else f"#{bg_raw}"
elif isinstance(bg_raw, (list, tuple)) and len(bg_raw) == 3:
bg = f"#{int(bg_raw[0]):02x}{int(bg_raw[1]):02x}{int(bg_raw[2]):02x}"
else:
bg = "#000000"
# Build payload using the short keys expected by led-driver # Build payload using the short keys expected by led-driver
preset = { preset = {
@@ -164,13 +194,14 @@ def build_preset_dict(preset_data):
return preset return preset
def build_presets_dict(presets_data): def build_presets_dict(presets_data, palette_colors=None):
""" """
Convert multiple presets to API-compliant format. Convert multiple presets to API-compliant format.
Args: Args:
presets_data: Dictionary mapping preset names to preset data presets_data: Dictionary mapping preset names to preset data
palette_colors: Optional list of ``#RRGGBB`` strings for background palette ref resolution.
Returns: Returns:
Dictionary mapping preset names to API-compliant preset objects Dictionary mapping preset names to API-compliant preset objects
@@ -190,7 +221,7 @@ def build_presets_dict(presets_data):
""" """
result = {} result = {}
for preset_name, preset_data in presets_data.items(): for preset_name, preset_data in presets_data.items():
result[preset_name] = build_preset_dict(preset_data) result[preset_name] = build_preset_dict(preset_data, palette_colors)
return result return result

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -1,62 +1,83 @@
from models.squence import Sequence from models.sequence import Sequence
import os import os
_HERE = os.path.dirname(os.path.abspath(__file__))
_PROJECT_DB = os.path.normpath(os.path.join(_HERE, "..", "..", "db", "sequence.json"))
def test_sequence(): def test_sequence():
"""Test Sequence model CRUD operations.""" """Test Sequence model CRUD operations."""
# Clean up any existing test file if os.path.exists(_PROJECT_DB):
if os.path.exists("Sequence.json"): os.remove(_PROJECT_DB)
os.remove("Sequence.json")
sequences = Sequence() sequences = Sequence()
print("Testing create sequence") print("Testing create sequence")
sequence_id = sequences.create("test_group", ["preset1", "preset2"]) sequence_id = sequences.create("1")
print(f"Created sequence with ID: {sequence_id}") print(f"Created sequence with ID: {sequence_id}")
assert sequence_id is not None assert sequence_id is not None
assert sequence_id in sequences assert sequence_id in sequences
print("\nTesting read sequence") print("\nTesting read sequence")
sequence = sequences.read(sequence_id) sequence = sequences.read(sequence_id)
print(f"Read: {sequence}") print(f"Read: {sequence}")
assert sequence is not None assert sequence is not None
assert sequence["group_name"] == "test_group" assert sequence["profile_id"] == "1"
assert len(sequence["presets"]) == 2 assert sequence["steps"] == []
assert "sequence_duration" in sequence assert sequence["lanes"] == [[]]
assert "sequence_loop" in sequence assert sequence.get("lanes_group_ids") == [[]]
assert sequence.get("advance_mode") == "beats"
assert sequence.get("simulated_bpm") == 120
assert sequence["step_duration_ms"] == 3000
assert sequence["loop"] is True
assert sequence.get("sequence_transition") == 500
print("\nTesting update sequence") print("\nTesting update sequence")
update_data = { update_data = {
"group_name": "updated_group", "name": "updated_seq",
"presets": ["preset3", "preset4", "preset5"], "steps": [
"sequence_duration": 5000, {"preset_id": "5", "group_ids": ["1"], "beats": 2},
"sequence_transition": 1000, {"preset_id": "6", "group_ids": [], "beats": 4},
"sequence_loop": True, ],
"sequence_repeat_count": 3 "lanes_group_ids": [["1"]],
"step_duration_ms": 5000,
"loop": True,
"advance_mode": "beats",
"simulated_bpm": 128,
} }
result = sequences.update(sequence_id, update_data) result = sequences.update(sequence_id, update_data)
assert result is True assert result is True
updated = sequences.read(sequence_id) updated = sequences.read(sequence_id)
assert updated["group_name"] == "updated_group" assert updated["name"] == "updated_seq"
assert len(updated["presets"]) == 3 assert len(updated["steps"]) == 2
assert updated["sequence_duration"] == 5000 assert updated["steps"][0]["preset_id"] == "5"
assert updated["sequence_loop"] is True assert updated["steps"][0]["group_ids"] == ["1"]
assert updated["steps"][0].get("beats") == 2
assert isinstance(updated.get("lanes"), list)
assert len(updated["lanes"]) == 1
assert len(updated["lanes"][0]) == 2
assert updated["lanes"][0][0]["beats"] == 2
assert updated.get("advance_mode") == "beats"
assert updated.get("simulated_bpm") == 128
assert updated["step_duration_ms"] == 5000
assert updated["loop"] is True
print("\nTesting list sequences") print("\nTesting list sequences")
sequence_list = sequences.list() sequence_list = sequences.list()
print(f"Sequence list: {sequence_list}") print(f"Sequence list: {sequence_list}")
assert sequence_id in sequence_list assert sequence_id in sequence_list
print("\nTesting delete sequence") print("\nTesting delete sequence")
deleted = sequences.delete(sequence_id) deleted = sequences.delete(sequence_id)
assert deleted is True assert deleted is True
assert sequence_id not in sequences assert sequence_id not in sequences
print("\nTesting read after delete") print("\nTesting read after delete")
sequence = sequences.read(sequence_id) sequence = sequences.read(sequence_id)
assert sequence is None assert sequence is None
print("\nAll sequence tests passed!") print("\nAll sequence tests passed!")
if __name__ == '__main__': if __name__ == "__main__":
test_sequence() test_sequence()

View File

@@ -750,6 +750,46 @@ def test_presets_ui(browser: BrowserTest) -> bool:
print(f"\nBrowser presets UI tests: {passed}/{total} passed") print(f"\nBrowser presets UI tests: {passed}/{total} passed")
return passed == total return passed == total
def test_preset_editor_palette_child_modal_zindex(browser: BrowserTest) -> bool:
"""
Regression: preset editor 'From Palette' builds a body-appended .modal without an id.
It must use .modal-child-overlay so z-index clears #preset-editor-modal (1060).
"""
print("\n=== Testing preset child overlay z-index ===")
if not browser.setup():
return False
try:
if not browser.navigate("/"):
return False
z_editor, z_child = browser.driver.execute_script(
"""
const ed = document.getElementById('preset-editor-modal');
if (!ed) { return [0, 0]; }
ed.classList.add('active');
const m = document.createElement('div');
m.className = 'modal active modal-child-overlay';
m.innerHTML = '<div class="modal-content"><p>stack probe</p></div>';
document.body.appendChild(m);
const ze = parseInt(getComputedStyle(ed).zIndex, 10) || 0;
const zc = parseInt(getComputedStyle(m).zIndex, 10) || 0;
m.remove();
ed.classList.remove('active');
return [ze, zc];
"""
)
print(f" preset-editor z-index={z_editor} modal-child-overlay z-index={z_child}")
if z_child > z_editor >= 1000:
print("✓ Child overlay stacks above preset editor")
return True
print("✗ Child overlay did not stack above preset editor")
return False
except Exception as e:
print(f"✗ z-index probe failed: {e}")
return False
finally:
browser.teardown()
def test_color_palette_ui(browser: BrowserTest) -> bool: def test_color_palette_ui(browser: BrowserTest) -> bool:
"""Test color palette UI in browser.""" """Test color palette UI in browser."""
print("\n=== Testing Color Palette UI in Browser ===") print("\n=== Testing Color Palette UI in Browser ===")
@@ -1105,6 +1145,9 @@ def main():
results.append(("Zones UI", test_zones_ui(browser))) results.append(("Zones UI", test_zones_ui(browser)))
results.append(("Profiles UI", test_profiles_ui(browser))) results.append(("Profiles UI", test_profiles_ui(browser)))
results.append(("Presets UI", test_presets_ui(browser))) results.append(("Presets UI", test_presets_ui(browser)))
results.append(
("Preset palette child z-index", test_preset_editor_palette_child_modal_zindex(browser))
)
results.append(("Color Palette UI", test_color_palette_ui(browser))) results.append(("Color Palette UI", test_color_palette_ui(browser)))
results.append(("Preset Drag and Drop", test_preset_drag_and_drop(browser))) results.append(("Preset Drag and Drop", test_preset_drag_and_drop(browser)))

View File

@@ -123,7 +123,7 @@ def server(monkeypatch, tmp_path_factory):
import models.pallet as models_pallet # noqa: E402 import models.pallet as models_pallet # noqa: E402
import models.scene as models_scene # noqa: E402 import models.scene as models_scene # noqa: E402
import models.pattern as models_pattern # noqa: E402 import models.pattern as models_pattern # noqa: E402
import models.squence as models_sequence # noqa: E402 import models.sequence as models_sequence # noqa: E402
import models.device as models_device # noqa: E402 import models.device as models_device # noqa: E402
for cls in ( for cls in (
@@ -527,21 +527,24 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
assert resp.status_code == 200 assert resp.status_code == 200
# Sequences. # Sequences.
unique_seq_group_name = f"pytest-seq-group-{uuid.uuid4().hex[:8]}" unique_seq_name = f"pytest-seq-{uuid.uuid4().hex[:8]}"
resp = c.post( resp = c.post(
f"{base_url}/sequences", f"{base_url}/sequences",
json={"group_name": unique_seq_group_name, "presets": []}, json={
"name": unique_seq_name,
"steps": [{"preset_id": "1", "group_ids": []}],
},
) )
assert resp.status_code == 201 assert resp.status_code == 201
sequences_list = c.get(f"{base_url}/sequences").json() sequences_list = c.get(f"{base_url}/sequences").json()
seq_id = _find_id_by_field(sequences_list, "group_name", unique_seq_group_name) seq_id = _find_id_by_field(sequences_list, "name", unique_seq_name)
resp = c.get(f"{base_url}/sequences/{seq_id}") resp = c.get(f"{base_url}/sequences/{seq_id}")
assert resp.status_code == 200 assert resp.status_code == 200
resp = c.put(f"{base_url}/sequences/{seq_id}", json={"sequence_duration": 1234}) resp = c.put(f"{base_url}/sequences/{seq_id}", json={"step_duration_ms": 1234})
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["sequence_duration"] == 1234 assert resp.json()["step_duration_ms"] == 1234
resp = c.delete(f"{base_url}/sequences/{seq_id}") resp = c.delete(f"{base_url}/sequences/{seq_id}")
assert resp.status_code == 200 assert resp.status_code == 200

View File

@@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""
Metronome-style mono click track for testing the audio beat detector.
Without ``-o``: streams S16LE PCM to ``aplay`` (stdin) until you press Ctrl+C.
With ``-o``: writes a WAV file of fixed length and exits.
Examples:
python3 tools/generate_beat_test_track.py
python3 tools/generate_beat_test_track.py --bpm 90
python3 tools/generate_beat_test_track.py -o tests/audio/beat_test_120bpm.wav --duration 30
"""
from __future__ import annotations
import argparse
import math
import shutil
import struct
import subprocess
import wave
from pathlib import Path
def _parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument(
"-o",
"--output",
type=Path,
default=None,
help="If set, write this WAV file and exit (no live playback)",
)
p.add_argument("--bpm", type=float, default=120.0, help="Beats per minute (default: 120)")
p.add_argument(
"--duration",
type=float,
default=30.0,
help="With -o only: click section length in seconds after intro (default: 30)",
)
p.add_argument(
"--intro-silence",
type=float,
default=0.5,
help="Leading silence in seconds (default: 0.5)",
)
p.add_argument("--sample-rate", type=int, default=44100, help="Sample rate Hz (default: 44100)")
p.add_argument(
"--click-ms",
type=float,
default=18.0,
help="Approximate click length in ms (default: 18)",
)
p.add_argument(
"--freq",
type=float,
default=1000.0,
help="Click sine frequency Hz (default: 1000)",
)
return p.parse_args()
def _click_int16_samples(sr: int, click_ms: float, freq: float) -> tuple[list[int], int]:
"""One click; returns samples and click_len (same as len(samples))."""
click_len = max(1, int(sr * max(4.0, click_ms) / 1000.0))
freq_clamped = max(200.0, min(4000.0, float(freq)))
floats: list[float] = []
for i in range(click_len):
t = i / sr
env = math.sin(0.5 * math.pi * (i + 1) / click_len) ** 2
floats.append(env * math.sin(2.0 * math.pi * freq_clamped * t))
peak = max(abs(x) for x in floats) or 1.0
scale = 0.92 / peak
out: list[int] = []
for x in floats:
v = int(round(max(-1.0, min(1.0, x * scale)) * 32767.0))
out.append(max(-32767, min(32767, v)))
return out, click_len
def _render_scaled_samples(
sr: int,
bpm: float,
intro: float,
dur: float,
click_ms: float,
freq: float,
) -> tuple[list[float], int, float, int]:
beat_sec = 60.0 / bpm
click_len = max(1, int(sr * max(4.0, click_ms) / 1000.0))
freq_clamped = max(200.0, min(4000.0, float(freq)))
total_sec = intro + dur
n_samples = int(sr * total_sec)
intro_samples = int(sr * intro)
samples = [0.0] * n_samples
beat_samples = int(round(sr * beat_sec))
if beat_samples < click_len + 1:
raise SystemExit("BPM too high for this sample rate / click length")
beat_idx = 0
while True:
start = intro_samples + beat_idx * beat_samples
if start >= n_samples:
break
for i in range(click_len):
pos = start + i
if pos >= n_samples:
break
t = i / sr
env = math.sin(0.5 * math.pi * (i + 1) / click_len) ** 2
s = env * math.sin(2.0 * math.pi * freq_clamped * t)
samples[pos] += s
beat_idx += 1
peak = max(abs(x) for x in samples) or 1.0
scale = 0.92 / peak
for i in range(n_samples):
samples[i] = max(-1.0, min(1.0, samples[i] * scale))
return samples, sr, total_sec, beat_idx
def write_wav_mono16(path: Path, samples: list[float], sr: int) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with wave.open(str(path), "w") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(sr)
for x in samples:
v = int(round(x * 32767.0))
w.writeframes(struct.pack("<h", v))
def _stream_aplay_until_interrupt(
sr: int,
bpm: float,
intro_silence: float,
click_ms: float,
freq: float,
) -> None:
aplay = shutil.which("aplay")
if not aplay:
raise SystemExit("aplay not found; install alsa-utils, or use -o to write a WAV file.")
click_samps, click_len = _click_int16_samples(sr, click_ms, freq)
beat_samples = int(round(sr * 60.0 / bpm))
if beat_samples < click_len + 1:
raise SystemExit("BPM too high for this sample rate / click length")
silence_samples = beat_samples - click_len
beat_chunk = struct.pack("<" + "h" * len(click_samps), *click_samps) + (
b"\x00\x00" * silence_samples
)
intro_samples = int(sr * max(0.0, float(intro_silence)))
intro_chunk = b"\x00\x00" * intro_samples
argv = [aplay, "-q", "-t", "raw", "-f", "S16_LE", "-c", "1", "-r", str(sr)]
proc = subprocess.Popen(argv, stdin=subprocess.PIPE)
if proc.stdin is None:
raise SystemExit("aplay did not open stdin")
print(
f"Streaming {bpm} BPM, {sr} Hz mono -> aplay (raw). Ctrl+C to stop.",
flush=True,
)
try:
if intro_chunk:
proc.stdin.write(intro_chunk)
beats_written = 0
# Write several beats per syscall to reduce overhead
batch = 8
multi = beat_chunk * batch
while True:
proc.stdin.write(multi)
beats_written += batch
if beats_written % 256 == 0:
proc.stdin.flush()
except BrokenPipeError:
print("aplay exited.", flush=True)
except KeyboardInterrupt:
print("\nStopped.", flush=True)
finally:
try:
proc.stdin.close()
except BrokenPipeError:
pass
proc.wait(timeout=3)
def main() -> None:
args = _parse_args()
sr = max(8000, min(96000, int(args.sample_rate)))
bpm = max(40.0, min(240.0, float(args.bpm)))
if args.output is not None:
intro = max(0.0, float(args.intro_silence))
dur = max(1.0, float(args.duration))
samples, sr_u, total_sec, beats = _render_scaled_samples(
sr, bpm, intro, dur, float(args.click_ms), float(args.freq)
)
write_wav_mono16(args.output, samples, sr_u)
print(
f"Wrote {args.output} ({len(samples)} samples, {total_sec:.1f}s, {sr_u} Hz mono): "
f"{bpm} BPM, ~{beats} beats"
)
return
_stream_aplay_until_interrupt(
sr,
bpm,
float(args.intro_silence),
float(args.click_ms),
float(args.freq),
)
if __name__ == "__main__":
main()