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"
watch = "python -m watchfiles \"python tests/web.py\" src tests"
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-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'"

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)",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 2,
"max_colors": 10,
"has_background": 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
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("")
async def list_devices(request):
"""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
/ ESP-NOW. No ``save``. After ``IDENTIFY_OFF_DELAY_S``, a background task selects ``off``.
"""
dev = devices.read(id)
if not dev:
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
sender = get_current_sender()
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"}
status, err = await send_identify_to_device(id)
if status == 200:
return json.dumps({"message": "Identify sent"}), 200, {
"Content-Type": "application/json",
}
return json.dumps({"error": err}), status, {"Content-Type": "application/json"}
@controller.post("/<id>/brightness")

View File

@@ -1,4 +1,6 @@
from microdot import Microdot
from microdot.session import with_session
import asyncio
from models.group import Group
from models.device import Device
from models.transport import get_current_sender
@@ -12,46 +14,127 @@ groups = Group()
devices = Device()
_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>')
async def get_group(request, id):
"""Get a specific group by ID."""
def _group_doc_visible_for_profile(doc, profile_id):
if not isinstance(doc, dict):
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)
if group:
return json.dumps(group), 200, {'Content-Type': 'application/json'}
if not group or not isinstance(group, dict):
return json.dumps({"error": "Group not found"}), 404
from controllers.zone import get_current_profile_id
@controller.post('')
async def create_group(request):
"""Create a new group."""
if not _group_doc_visible_for_profile(group, get_current_profile_id(session)):
return json.dumps({"error": "Group not found"}), 404
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:
data = request.json or {}
data = dict(request.json or {})
name = data.get("name", "")
profile_scoped = bool(data.pop("profile_scoped", False))
_sanitize_group_profile_id_write(data, session)
group_id = groups.create(name)
if 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:
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."""
try:
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):
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
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.delete('/<id>')
async def delete_group(request, id):
"""Delete a group."""
@controller.delete("/<id>")
@with_session
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):
return json.dumps({"message": "Group deleted successfully"}), 200
return json.dumps({"error": "Group not found"}), 404
@@ -86,13 +169,25 @@ def _group_driver_config_payload(doc):
return dc
@controller.post('/<id>/driver-config')
async def push_group_driver_config(request, id):
def _read_group_for_session(session, 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).
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:
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 []
sent = 0
errors = []
msg = json.dumps(
{"v": "1", "device_config": dc, "save": True}, separators=(",", ":")
)
tasks = []
meta_macs = []
for mac in mac_list:
m = str(mac).strip().lower().replace(":", "").replace("-", "")
if len(m) != 12:
@@ -130,12 +230,15 @@ async def push_group_driver_config(request, id):
if not ip:
errors.append({"mac": m, "error": "no IP"})
continue
msg = json.dumps(
{"v": "1", "device_config": dc, "save": True}, separators=(",", ":")
)
ok = await send_json_line_to_ip(ip, msg)
if ok:
tasks.append(send_json_line_to_ip(ip, msg))
meta_macs.append(m)
if tasks:
results = await asyncio.gather(*tasks, return_exceptions=True)
for m, r in zip(meta_macs, results):
if r is True:
sent += 1
elif isinstance(r, Exception):
errors.append({"mac": m, "error": str(r)})
else:
errors.append({"mac": m, "error": "driver not connected"})
@@ -149,26 +252,22 @@ def _brightness_save_message_json(b_val: int) -> str:
return json.dumps({"v": "1", "b": b_val, "save": True}, separators=(",", ":"))
@controller.post('/<id>/brightness')
async def push_group_output_brightness(request, id):
@controller.post("/<id>/brightness")
@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.
"""
gdoc = groups.read(id)
gdoc = _read_group_for_session(session, id)
if not gdoc:
return json.dumps({"error": "Group not found"}), 404
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
sent = 0
errors = []
for mac in mac_list:
m = str(mac).strip().lower().replace(":", "").replace("-", "")
if len(m) != 12:
continue
dev = devices.read(m)
if not dev:
errors.append({"mac": m, "error": "not in registry"})
continue
sender = get_current_sender()
async def _push_brightness_one(m: str, dev: dict) -> tuple[str, bool, str | None]:
b_val = effective_brightness_for_mac(
_pi_settings,
groups,
@@ -181,24 +280,80 @@ async def push_group_output_brightness(request, id):
if transport == "wifi":
ip = normalize_tcp_peer_ip(str(dev.get("address") or ""))
if not ip:
errors.append({"mac": m, "error": "no IP"})
continue
return m, False, "no IP"
ok = await send_json_line_to_ip(ip, msg)
if ok:
sent += 1
else:
errors.append({"mac": m, "error": "driver not connected"})
else:
sender = get_current_sender()
return m, bool(ok), None if ok else "driver not connected"
if not sender:
errors.append({"mac": m, "error": "transport not configured"})
continue
return m, False, "transport not configured"
try:
await sender.send(msg, addr=m)
sent += 1
return m, True, None
except Exception as e:
errors.append({"mac": m, "error": str(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:
sent += 1
elif err:
errors.append({"mac": m, "error": err})
return json.dumps(
{"message": "brightness sent", "sent": sent, "errors": errors}
), 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 models.preset import Preset
from models.profile import Profile
from models.pallet import Palette
from models.device import Device, normalize_mac
from models.transport import get_current_sender
from util.driver_delivery import deliver_json_messages, deliver_preset_broadcast_then_per_device
@@ -12,6 +13,18 @@ controller = Microdot()
presets = Preset()
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):
"""Get the current active profile ID from session or fallback to first."""
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
current_profile_id = get_current_profile_id(session)
palette_colors = _palette_colors_for_profile(current_profile_id)
presets_by_name = {}
for pid in preset_ids:
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):
continue
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", "")
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'}
try:
from util import sequence_playback as seq_pb
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:
pass

View File

@@ -1,51 +1,207 @@
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
controller = Microdot()
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>')
async def get_sequence(request, id):
"""Get a specific sequence by ID."""
sequence = sequences.read(id)
if sequence:
return json.dumps(sequence), 200, {'Content-Type': 'application/json'}
def get_current_profile_id(session=None):
"""Get the current active profile ID from session or fallback to first."""
profile_list = profiles.list()
session_profile = None
if session is not None:
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
@controller.post('')
async def create_sequence(request):
"""Create a new sequence."""
@controller.post("")
@with_session
async def create_sequence(request, session):
"""Create a new sequence for the current profile."""
try:
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:
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
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
@controller.put('/<id>')
async def update_sequence(request, id):
"""Update an existing sequence."""
@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
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):
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
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):
"""Delete a sequence."""
if sequences.delete(id):
return json.dumps({"message": "Sequence deleted successfully"}), 200
@controller.delete("/<id>")
@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):
return (
json.dumps({"message": "Sequence deleted successfully"}),
200,
{"Content-Type": "application/json"},
)
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()]
preset_ids = None
group_ids = []
content_kind = None
else:
data = request.json or {}
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]
else:
group_ids = []
raw_kind = data.get("content_kind")
content_kind = raw_kind if raw_kind in ("presets", "sequences") else None
if not name:
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)
if profile_id:

View File

@@ -2,6 +2,7 @@ import asyncio
import errno
import json
import os
import secrets
import signal
import socket
import threading
@@ -38,6 +39,11 @@ _tcp_device_lock = threading.Lock()
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(
device_name: str, peer_ip: str, mac, device_type=None
) -> None:
@@ -248,9 +254,28 @@ async def main(port=80):
app = Microdot()
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
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
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_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)
@app.route('/')
@app.route("/")
def index(request):
"""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)
@app.route('/favicon.ico')
@@ -319,6 +375,9 @@ async def main(port=80):
pass
try:
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()}
except Exception as e:
return {"ok": False, "error": str(e)}, 500
@@ -327,12 +386,47 @@ async def main(port=80):
async def audio_stop(request):
_ = request
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()}
@app.route('/api/audio/status')
async def audio_status(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
@app.route("/static/<path:path>")

View File

@@ -2,7 +2,12 @@ from models.model import 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):
super().__init__()

View File

@@ -15,6 +15,9 @@ class Preset(Model):
if default_profile_id is not None:
preset_data["profile_id"] = str(default_profile_id)
changed = True
if isinstance(preset_data, dict) and "group_ids" in preset_data:
preset_data.pop("group_ids", None)
changed = True
if changed:
self.save()
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):
"""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):
if not getattr(Zone, "_migration_checked", False):
@@ -36,22 +40,29 @@ class Zone(Model):
if "group_ids" not in doc:
doc["group_ids"] = []
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:
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()
gid_list = []
if isinstance(group_ids, list):
gid_list = [str(x) for x in group_ids if x is not None]
self[next_id] = {
gid_list = [str(x).strip() for x in group_ids if x is not None and str(x).strip()]
doc = {
"name": name,
"names": names if names else [],
"group_ids": gid_list,
"preset_group_ids": {},
"presets": presets if presets else [],
"default_preset": None,
"brightness": 255,
}
if content_kind in ("presets", "sequences"):
doc["content_kind"] = content_kind
self[next_id] = doc
self.save()
return next_id

View File

@@ -1,8 +1,21 @@
(() => {
let pollTimer = null;
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 PHASE_MS_KEY = "led-controller-audio-beat-phase-ms";
const STORAGE_VERSION = 1;
function readRestorePrefs() {
@@ -48,6 +61,42 @@
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) {
const node = el("audio-bpm-value");
if (!node) return;
@@ -58,11 +107,44 @@
}
}
function updateBeatCounter(seq) {
const topNode = el("audio-top-beat-count");
if (!topNode) return;
const n = Number(seq);
topNode.textContent = Number.isFinite(n) && n >= 0 ? `#${Math.floor(n)}` : "#0";
/** Zone sequence playback (server); only when `active === true` is beat X/Y meaningful. */
function sequencePlaybackActiveFromStatus(status) {
const seq = /** @type {Record<string, unknown>|undefined} */ (
status && status.sequence
);
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) {
@@ -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). */
async function stopAudioOnly() {
setTopBpmVisible(false);
clearBeatPhaseTimers();
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
lastBeatSeq = 0;
updateBeatCounter(0);
prevZoneSequencePlaybackActive = false;
headerBeatStickyIdleAfterSeq = false;
lastBeatConsoleKey = "";
updateBeatReadoutDisplays({});
try {
await fetch("/api/audio/stop", { method: "POST" });
} catch (e) {
@@ -115,7 +249,7 @@
async function pollStatus() {
try {
const res = await fetch("/api/audio/status");
const res = await fetch("/api/audio/status", { cache: "no-store" });
const data = await res.json();
const status = data?.status || {};
if (status.error && String(status.error).trim()) {
@@ -123,6 +257,7 @@
if (node) {
node.textContent = String(status.error).trim().slice(0, 120);
}
updateBeatReadoutDisplays({});
updateBpmDisplay(null);
setTopBpmVisible(!!status.running);
if (!status.running && pollTimer) {
@@ -134,12 +269,46 @@
setTopBpmVisible(!!status.running);
updateBpmDisplay(status.bpm);
updateHitTypeDisplay(status.beat_type, Number(status.beat_type_confidence));
const seq = Number(status.beat_seq || 0);
updateBeatCounter(seq);
if (seq > lastBeatSeq) {
lastBeatSeq = seq;
flashBeat();
/*
* `status.beat_seq` is cumulative since Audio Start — used only for flash / sticky idle
* after sequence ends. Preset and sequence loop counts come from `manual_beat_stride` /
* `sequence` on each poll.
*/
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) {
console.warn("audio status poll failed", e);
}
@@ -164,7 +333,6 @@
writeRestorePrefs(override, selected);
updateBpmDisplay(null);
updateHitTypeDisplay("unknown", NaN);
updateBeatCounter(0);
pollTimer = setInterval(pollStatus, 250);
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() {
try {
const res = await fetch("/api/audio/status");
const res = await fetch("/api/audio/status", { cache: "no-store" });
const data = await res.json();
const status = data?.status || {};
if (status.running && !pollTimer) {
pollTimer = setInterval(pollStatus, 250);
lastBeatSeq = Number(status.beat_seq || 0);
updateBeatCounter(lastBeatSeq);
prevZoneSequencePlaybackActive = sequencePlaybackActiveFromStatus(status);
await pollStatus();
}
} 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();
if (!prefs) return;
const ov = el("audio-device-override");
@@ -280,20 +464,14 @@
try {
await refreshDevices();
} 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;
try {
await startAudio();
} catch (e) {
console.warn("audio auto-restart failed", e);
clearRestorePrefs();
}
}
document.addEventListener("DOMContentLoaded", async () => {
bind();
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.
// 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() {
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 {};
const data = await response.json();
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) {
const wName = document.getElementById('edit-group-wifi-driver-name');
const wLeds = document.getElementById('edit-group-wifi-num-leds');
@@ -189,7 +216,10 @@ async function openEditGroupModal(groupId, groupDoc) {
let g = groupDoc;
if (!g || typeof g !== 'object') {
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();
} catch (e) {
console.error(e);
@@ -217,6 +247,7 @@ async function openEditGroupModal(groupId, groupDoc) {
});
renderGroupDevicesEditor(editor, window.__editGroupDeviceRows, dm);
loadWifiFieldsFromGroup(g);
syncGroupShareCheckboxFromDoc(g);
refreshEditGroupDebug();
if (modal) modal.classList.add('active');
}
@@ -259,8 +290,13 @@ function renderGroupsList(groups) {
const label = document.createElement('span');
const devs = Array.isArray(g.devices) ? g.devices : [];
label.textContent = `${g.name || gid} (${devs.length} device${devs.length === 1 ? '' : 's'})`;
label.style.flex = '1';
const 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');
editBtn.className = 'btn btn-secondary btn-small';
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');
delBtn.className = 'btn btn-danger btn-small';
delBtn.textContent = 'Delete';
delBtn.addEventListener('click', async () => {
if (!confirm(`Delete group "${g.name || gid}"? Zones referencing it may need updating.`)) return;
try {
const res = await fetch(`/groups/${encodeURIComponent(gid)}`, { method: 'DELETE' });
const res = await fetch(`/groups/${encodeURIComponent(gid)}`, {
method: 'DELETE',
credentials: 'same-origin',
});
if (res.ok) await loadGroupsModal();
else {
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(brightBtn);
row.appendChild(applyBtn);
row.appendChild(identifyBtn);
row.appendChild(delBtn);
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', () => {
const groupsBtn = document.getElementById('groups-btn');
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 name = newNameInput && newNameInput.value.trim();
if (!name) return;
const profileOnly = document.getElementById('new-group-profile-only');
try {
const res = await fetch('/groups', {
method: 'POST',
credentials: 'same-origin',
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(() => ({}));
if (!res.ok) {
@@ -396,6 +494,7 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
if (newNameInput) newNameInput.value = '';
if (profileOnly) profileOnly.checked = false;
await loadGroupsModal();
} catch (e) {
console.error(e);
@@ -417,9 +516,18 @@ document.addEventListener('DOMContentLoaded', () => {
const { gid, payload } = collectGroupEditPayload();
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 {
const res = await fetch(`/groups/${encodeURIComponent(gid)}`, {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload),
});
@@ -449,4 +557,15 @@ document.addEventListener('DOMContentLoaded', () => {
if (editCloseBtn && editModal) {
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 () => {
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 = {
sequence,
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 presetSaveButton = document.getElementById('preset-save-btn');
const presetAddFromPaletteButton = document.getElementById('preset-add-from-palette-btn');
const presetBackgroundFromPaletteButton = document.getElementById('preset-background-from-palette-btn');
if (!presetsButton || !presetsModal || !presetsList || !presetSaveButton) {
return;
@@ -213,6 +254,8 @@ document.addEventListener('DOMContentLoaded', () => {
let cachedPatterns = {};
let currentPresetColors = []; // Track colors for the current preset
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
const getMaxColors = () => {
@@ -286,6 +329,10 @@ document.addEventListener('DOMContentLoaded', () => {
presetBackgroundButton.style.backgroundColor = color;
presetBackgroundButton.style.color = '#fff';
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 = () => {
@@ -600,9 +647,28 @@ document.addEventListener('DOMContentLoaded', () => {
presetBrightnessInput.value = preset.brightness || 0;
presetDelayInput.value = preset.delay || 0;
if (presetBackgroundInput) {
presetBackgroundInput.value = coercePresetBackground(preset);
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);
updatePresetBackgroundButton();
const gen = ++bgPaletteResolveGen;
void getCurrentProfilePaletteColors().then((pal) => {
if (gen !== bgPaletteResolveGen || !presetBackgroundInput) {
return;
}
presetBackgroundInput.value = resolvePresetBackgroundHex(preset, pal);
updatePresetBackgroundButton();
});
} else {
updatePresetBackgroundButton();
}
if (presetManualModeInput) {
const autoVal = typeof preset.auto === 'boolean' ? preset.auto : true;
presetManualModeInput.checked = !autoVal;
@@ -674,6 +740,7 @@ document.addEventListener('DOMContentLoaded', () => {
};
const clearForm = () => {
bgPaletteResolveGen += 1;
currentEditId = null;
currentEditTabId = null;
currentPresetColors = [];
@@ -702,9 +769,6 @@ document.addEventListener('DOMContentLoaded', () => {
if (presetManualBeatNInput) {
presetManualBeatNInput.value = '1';
}
if (presetBackgroundInput) {
presetBackgroundInput.value = '#000000';
}
updatePresetBackgroundButton();
updateManualModeAvailability();
// 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,
delay: presetDelayInput ? parseInt(presetDelayInput.value, 10) || 0 : 0,
background: presetBackgroundInput ? presetBackgroundInput.value : '#000000',
background_palette_ref: currentBackgroundPaletteRef != null ? currentBackgroundPaletteRef : null,
auto: presetManualModeInput ? !presetManualModeInput.checked : true,
manual_beat_n: (() => {
if (!presetManualBeatNInput) return 1;
@@ -1169,7 +1234,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Create modal
const modal = document.createElement('div');
modal.className = 'modal active';
modal.className = 'modal active modal-child-overlay';
modal.id = 'add-preset-to-zone-modal';
modal.innerHTML = `
<div class="modal-content">
@@ -1262,6 +1327,14 @@ document.addEventListener('DOMContentLoaded', () => {
throw new Error('Failed to load zone');
}
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
let flat = [];
@@ -1340,6 +1413,7 @@ document.addEventListener('DOMContentLoaded', () => {
presetBackgroundInput.click();
});
presetBackgroundInput.addEventListener('input', () => {
currentBackgroundPaletteRef = null;
updatePresetBackgroundButton();
});
}
@@ -1378,7 +1452,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
const modal = document.createElement('div');
modal.className = 'modal active';
modal.className = 'modal active modal-child-overlay';
modal.innerHTML = `
<div class="modal-content">
<h2>Pick Palette Color</h2>
@@ -1419,10 +1493,6 @@ document.addEventListener('DOMContentLoaded', () => {
const ref = parseInt(row.dataset.paletteIndex, 10);
if (!color || !Number.isInteger(ref)) return;
if (currentPresetColors.includes(color) && currentPresetPaletteRefs.includes(ref)) {
alert('That palette color is already linked.');
return;
}
const maxColors = getMaxColors();
if (currentPresetColors.length >= maxColors) {
alert(`This pattern allows a maximum of ${maxColors} color${maxColors !== 1 ? 's' : ''}.`);
@@ -1436,7 +1506,72 @@ document.addEventListener('DOMContentLoaded', () => {
});
} catch (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.');
return;
}
// Send current editor values and then select on all devices in the current zone (if any)
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
// Send current editor values to zone devices (if any); never persist on device.
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');
});
}
@@ -1466,9 +1599,8 @@ document.addEventListener('DOMContentLoaded', () => {
alert('Preset name is required.');
return;
}
const section = document.querySelector('.presets-section[data-zone-id]');
const deviceNames = tabDeviceNamesFromSection(section);
const presetId = currentEditId || payload.name;
const deviceNames = await deviceNamesForPresetOnCurrentZone(presetId);
await sendPresetViaEspNow(presetId, payload, deviceNames, true, true, '1');
await updateTabDefaultPreset(presetId);
await sendDefaultPreset('1', deviceNames);
@@ -1503,9 +1635,9 @@ document.addEventListener('DOMContentLoaded', () => {
throw new Error('Failed to save preset');
}
// Same device targeting as Try: zone tab supplies names and selection without persistence.
const section = document.querySelector('.presets-section[data-zone-id]');
const deviceNames = tabDeviceNamesFromSection(section);
// Same device targeting as Try: per-preset zone groups when in a zone tab.
const presetIdForSend = currentEditId || payload.name;
const deviceNames = await deviceNamesForPresetOnCurrentZone(presetIdForSend);
// Use saved preset from server response for sending
const saved = await response.json().catch(() => null);
@@ -1623,6 +1755,26 @@ const coercePresetBackground = (preset) => {
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). */
const coerceManualBeatN = (preset) => {
if (!preset || typeof preset !== 'object') return 1;
@@ -1644,6 +1796,7 @@ const sendPresetViaEspNow = async (
saveToDevice = true,
setDefault = false,
devicePresetId = null,
pushOptions = null,
) => {
try {
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 presetAuto = coercePresetAuto(preset);
const presetBackground = coercePresetBackground(preset);
const presetBackground = resolvePresetBackgroundHex(preset, paletteColors);
const presetMessage = {
v: '1',
presets: {
@@ -1707,7 +1860,7 @@ const sendPresetViaEspNow = async (
}
}
await postDriverSequence(sequence, targetMacs, 0.05);
await postDriverSequence(sequence, targetMacs, 0.05, pushOptions);
} catch (error) {
console.error('Failed to send preset to devices:', error);
alert('Failed to send preset to devices.');
@@ -1776,6 +1929,48 @@ try {
// 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
const selectedPresets = {};
// Store selected preset payload per zone for beat-trigger reliability.
@@ -1920,19 +2115,41 @@ const insertDraggingOntoTarget = (presetsList, dragging, dropTarget) => {
};
// 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');
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 {
// Get zone data to see which presets are associated
const tabResponse = await fetch(`/zones/${zoneId}`, {
const [tabResponse, groupsStripRes, presetsResponse] = await Promise.all([
fetch(`/zones/${zoneId}`, {
headers: { Accept: 'application/json' },
});
}),
fetch('/groups', { headers: { Accept: 'application/json' } }),
fetch('/presets', {
headers: { Accept: 'application/json' },
}),
]);
if (!tabResponse.ok) {
throw new Error('Failed to load zone');
}
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)
let presetGrid = tabData.presets;
@@ -1944,11 +2161,10 @@ const renderTabPresets = async (zoneId) => {
// It's a flat array, convert to grid
presetGrid = arrayToGrid(presetGrid, 3);
}
if (ck === 'sequences') {
presetGrid = [];
}
// Get all presets
const presetsResponse = await fetch('/presets', {
headers: { Accept: 'application/json' },
});
if (!presetsResponse.ok) {
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 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) {
// Show empty message if this zone has no presets
const empty = document.createElement('p');
empty.className = 'muted-text';
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') {
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 {
flatPresets.forEach((presetId) => {
const preset = allPresets[presetId];
if (preset) {
const isSelected = presetId === selectedPresetId;
ensureZonePresetSelection(zoneId);
const isSelected = zoneSelectedPresetIds[String(zoneId)].has(String(presetId));
const displayPreset = {
...preset,
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);
}
});
}
if (typeof window.appendZoneSequenceTiles === 'function' && ck !== 'presets') {
await window.appendZoneSequenceTiles(zoneId, tabData, allPresets, paletteColors, presetsList);
}
} catch (error) {
console.error('Failed to render zone presets:', error);
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 row = document.createElement('div');
@@ -2069,7 +2308,6 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
button.className = 'pattern-button preset-tile-main';
if (isSelected) {
button.classList.add('active');
selectedPresetPayloads[zoneId] = preset;
}
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';
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 bgColor = coercePresetBackground(preset);
bgSwatch.title = `Background: ${bgColor}`;
@@ -2111,7 +2357,7 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
`;
button.appendChild(bgSwatch);
const isManualPreset = preset && typeof preset.auto === 'boolean' ? !preset.auto : false;
const isManualPreset = preset && !coercePresetAuto(preset);
if (isManualPreset) {
const manualBadge = document.createElement('span');
manualBadge.textContent = '1';
@@ -2138,18 +2384,39 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
button.addEventListener('click', () => {
if (isDraggingPreset) return;
console.info('Preset button pressed', { zoneId, presetId, name: (preset && preset.name) || presetId });
const presetsListEl = document.getElementById('presets-list-zone');
if (presetsListEl) {
presetsListEl.querySelectorAll('.pattern-button').forEach((btn) => btn.classList.remove('active'));
ensureZonePresetSelection(zoneId);
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');
selectedPresets[zoneId] = presetId;
selectedPresetPayloads[zoneId] = preset;
const section = row.closest('.presets-section');
const deviceNames = tabDeviceNamesFromSection(section);
sendPresetViaEspNow(presetId, preset, deviceNames, false, false, '2').catch((err) => {
console.error(err);
if (presetsListEl) {
presetsListEl.querySelectorAll('.preset-tile-row:not(.sequence-tile-row)').forEach((rw) => {
const pid = rw.dataset.presetId;
const btnEl = rw.querySelector('.preset-tile-main');
if (!btnEl || !pid) return;
if (set.has(String(pid))) btnEl.classList.add('active');
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) {
@@ -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') {
const actions = document.createElement('div');
@@ -2192,9 +2461,11 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
});
actions.appendChild(editBtn);
row.appendChild(actions);
top.appendChild(actions);
}
row.appendChild(top);
return row;
};
@@ -2297,6 +2568,10 @@ const removePresetFromTab = async (zoneId, presetId) => {
try {
window.removePresetFromTab = removePresetFromTab;
} catch (e) {}
try {
window.renderTabPresets = renderTabPresets;
window.getPresetUiMode = getPresetUiMode;
} catch (e) {}
// Listen for HTMX swaps to render presets
document.body.addEventListener('htmx:afterSwap', (event) => {
@@ -2327,10 +2602,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
const mainMenu = document.getElementById('main-menu-dropdown');
if (mainMenu) mainMenu.classList.remove('open');
const leftPanel = document.querySelector('.presets-section[data-zone-id]');
if (leftPanel) {
renderTabPresets(leftPanel.dataset.zoneId);
}
// Preset strip re-renders from `zones.js` after `loadZones()` (no driver/playback side effects).
});
});
});

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 {
display: none;
align-items: center;
gap: 0.4rem;
flex-direction: column;
align-items: stretch;
gap: 0.15rem;
padding: 0.25rem 0.55rem;
border: 1px solid #4a4a4a;
border-radius: 6px;
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 {
@@ -226,6 +244,19 @@ header h1 {
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 {
font-size: 0.75rem;
color: #9e9e9e;
@@ -240,7 +271,9 @@ header h1 {
.audio-top-indicator.flash .audio-top-indicator-value,
.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;
}
@@ -620,7 +653,8 @@ body.preset-ui-run .edit-mode-only {
overflow-x: hidden;
display: grid;
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;
row-gap: 0.3rem;
align-content: start;
@@ -784,6 +818,26 @@ body.preset-ui-run .edit-mode-only {
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 {
font-size: 1.1rem;
font-weight: 600;
@@ -851,17 +905,43 @@ body.preset-ui-run .edit-mode-only {
flex-direction: row;
align-items: stretch;
min-width: 0;
min-height: 0;
min-height: 5rem;
}
.preset-tile-row--run .preset-tile-actions {
display: none;
.preset-tile-row-top {
display: flex;
flex-direction: row;
align-items: stretch;
flex: 1;
min-width: 0;
min-height: 5rem;
}
.preset-tile-main {
flex: 1;
min-width: 0;
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. */
@@ -1030,6 +1110,46 @@ body.preset-ui-run .edit-mode-only {
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 {
background-color: #2e2e2e;
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 .modal-content {
max-width: 900px;

View File

@@ -1,6 +1,12 @@
// Zone management JavaScript
let currentZoneId = 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) {
const v = parseInt(n, 10);
@@ -150,7 +156,10 @@ async function fetchDevicesMap() {
async function fetchGroupsMap() {
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 {};
const data = await response.json();
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;
* otherwise legacy ``names``).
* otherwise ``names`` only).
*/
async function computeZoneTargets(zone) {
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) {
const t = await computeZoneTargets(zone);
return t.macs;
@@ -250,67 +400,6 @@ function rowsToNames(rows) {
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) {
if (!containerEl) return;
containerEl.innerHTML = "";
@@ -372,13 +461,6 @@ function renderZoneGroupsEditor(containerEl, rows, groupsMap) {
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). */
function parseTabDeviceNames(section) {
if (!section) return [];
@@ -408,6 +490,32 @@ function escapeHtmlAttr(s) {
.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
async function loadZones() {
try {
@@ -465,13 +573,16 @@ function renderZonesList(tabs, tabOrder, currentZoneId) {
const zone = tabs[zoneId];
if (zone) {
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 += `
<button class="zone-button ${activeClass}"
data-zone-id="${zoneId}"
title="${editMode ? 'Click to select, right-click to edit' : 'Click to select'}"
onclick="selectZone('${zoneId}')">
${tabName}
${escapeHtmlAttr(disp)}
</button>
`;
}
@@ -511,9 +622,13 @@ function renderZonesListModal(tabs, tabOrder, currentZoneId) {
row.dataset.zoneId = String(zoneId);
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)) {
label.textContent = `${label.textContent}`;
label.textContent = `${disp}`;
label.style.fontWeight = "bold";
label.style.color = "#FFD700";
}
@@ -735,8 +850,14 @@ async function loadZoneContent(zoneId) {
? Math.max(0, Math.min(255, Math.round(zoneBrightness)))
: 255;
applyBrightnessSliders(normalizedBrightness);
// Apply this zone's saved brightness when switching zones.
const initialHydration = isFirstZoneContentHydration;
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
if (typeof renderTabPresets === 'function') {
@@ -857,17 +978,7 @@ async function sendProfilePresets() {
}
function tabPresetIdsInOrder(tabData) {
let ids = [];
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);
return tabPresetIdsInZoneDoc(tabData);
}
// Presets already on the zone (remove) and presets available to add (select).
@@ -888,6 +999,13 @@ async function refreshEditTabPresetsUi(zoneId) {
return;
}
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 inTabSet = new Set(inTabIds.map((id) => String(id)));
@@ -911,8 +1029,12 @@ async function refreshEditTabPresetsUi(zoneId) {
for (const presetId of inTabIds) {
const preset = allPresets[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");
label.style.fontWeight = "600";
label.textContent = name;
const removeBtn = document.createElement("button");
removeBtn.type = "button";
@@ -924,9 +1046,11 @@ async function refreshEditTabPresetsUi(zoneId) {
await window.removePresetFromTab(zoneId, presetId);
await refreshEditTabPresetsUi(zoneId);
});
row.appendChild(label);
row.appendChild(removeBtn);
currentEl.appendChild(row);
top.appendChild(label);
top.appendChild(removeBtn);
block.appendChild(top);
currentEl.appendChild(block);
}
}
@@ -987,7 +1111,6 @@ async function openEditZoneModal(zoneId, zone) {
const modal = document.getElementById("edit-zone-modal");
const idInput = document.getElementById("edit-zone-id");
const nameInput = document.getElementById("edit-zone-name");
const editor = document.getElementById("edit-zone-devices-editor");
let tabData = zone;
if (!tabData || typeof tabData !== "object" || tabData.error) {
@@ -1005,6 +1128,7 @@ async function openEditZoneModal(zoneId, zone) {
if (idInput) idInput.value = zoneId;
if (nameInput) nameInput.value = tabData.name || "";
const groupsEditor = document.getElementById("edit-zone-groups-editor");
const groupsMap = await fetchGroupsMap();
const rawGids = Array.isArray(tabData.group_ids) ? tabData.group_ids : [];
window.__editTabGroupRows = rawGids.map((gid) => {
@@ -1012,17 +1136,21 @@ async function openEditZoneModal(zoneId, zone) {
const g = groupsMap[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");
applyZoneContentKindEditModal(normalizeZoneContentKind(tabData));
await refreshEditTabPresetsUi(zoneId);
if (typeof window.refreshEditTabSequencesUi === "function") {
await window.refreshEditTabSequencesUi(zoneId);
}
}
// Update an existing zone
async function updateZone(zoneId, name, groupIds) {
// Update an existing zone (name, group list; devices come from groups only).
async function updateZone(zoneId, name, groupRows) {
try {
const gids = Array.isArray(groupIds)
? groupIds.map((x) => String(x).trim()).filter((x) => x.length > 0)
const gids = Array.isArray(groupRows)
? groupRows.map((r) => String(r.id || "").trim()).filter((x) => x.length > 0)
: [];
const response = await fetch(`/zones/${zoneId}`, {
method: 'PUT',
@@ -1031,8 +1159,9 @@ async function updateZone(zoneId, name, groupIds) {
},
body: JSON.stringify({
name: name,
group_ids: gids,
names: [],
group_ids: gids,
preset_group_ids: {},
})
});
@@ -1055,12 +1184,11 @@ async function updateZone(zoneId, name, groupIds) {
}
}
// Create a new zone
async function createZone(name, groupIds) {
// Create a new zone (add devices in Edit zone). ``contentKind`` is ``'presets'`` | ``'sequences'``.
async function createZone(name, contentKind) {
try {
const gids = Array.isArray(groupIds)
? groupIds.map((x) => String(x).trim()).filter((x) => x.length > 0)
: [];
const ck =
contentKind === 'sequences' || contentKind === 'presets' ? contentKind : 'presets';
const response = await fetch('/zones', {
method: 'POST',
headers: {
@@ -1068,8 +1196,9 @@ async function createZone(name, groupIds) {
},
body: JSON.stringify({
name: name,
group_ids: gids,
names: [],
group_ids: [],
content_kind: ck,
})
});
@@ -1150,8 +1279,12 @@ document.addEventListener('DOMContentLoaded', () => {
const name = newTabNameInput.value.trim();
if (name) {
const groupIds = await defaultGroupIdsForNewTab();
await createZone(name, groupIds);
const kindRadio = document.querySelector(
'input[name="new-zone-content-kind"]:checked',
);
const contentKind =
kindRadio && kindRadio.value === 'sequences' ? 'sequences' : 'presets';
await createZone(name, contentKind);
if (newTabNameInput) newTabNameInput.value = "";
}
};
@@ -1178,15 +1311,10 @@ document.addEventListener('DOMContentLoaded', () => {
const zoneId = idInput ? idInput.value : null;
const name = nameInput ? nameInput.value.trim() : "";
const rows = window.__editTabGroupRows || [];
const groupIds = rows.map((r) => r.id).filter(Boolean);
const groupRows = window.__editTabGroupRows || [];
if (zoneId && name) {
if (groupIds.length === 0) {
alert("Add at least one device group.");
return;
}
await updateZone(zoneId, name, groupIds);
await updateZone(zoneId, name, groupRows);
editZoneForm.reset();
}
});
@@ -1220,10 +1348,15 @@ document.addEventListener('DOMContentLoaded', () => {
// When run/edit mode toggles, refresh tabs UI so edit actions show/hide immediately.
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
btn.addEventListener('click', async () => {
suppressZoneContentDriverSideEffects = true;
try {
await loadZones();
if (zonesModal && zonesModal.classList.contains("active")) {
await loadZonesModal();
}
} finally {
suppressZoneContentDriverSideEffects = false;
}
});
});
});
@@ -1240,6 +1373,14 @@ window.zonesManager = {
resolveZoneDeviceMacsFromZoneData,
resolveTabDeviceMacs: resolveZoneDeviceMacs,
getCurrentZoneId: () => currentZoneId,
computeZoneTargets,
computeZoneNamesTargets,
computeZonePresetUnionTargets,
effectiveGroupIdsForZonePreset,
resolveDeviceNamesForZonePreset,
resolveSequenceStepDeviceNames,
fetchGroupsMap,
renderZoneGroupsEditor,
};
window.tabsManager = window.zonesManager;
window.tabsManager.getCurrentTabId = () => currentZoneId;

View File

@@ -16,9 +16,11 @@
</div>
<div class="header-end">
<div id="audio-top-indicator" class="audio-top-indicator" title="Live audio BPM">
<div class="audio-top-indicator-main">
<span class="audio-top-indicator-label">BPM</span>
<span id="audio-top-bpm-value" class="audio-top-indicator-value">--</span>
<span id="audio-top-beat-count" class="audio-top-indicator-subvalue">#0</span>
<span id="audio-top-beat-readout" class="audio-top-beat-readout" aria-live="polite"></span>
</div>
</div>
<div class="header-actions">
<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="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="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="color-palette-btn">Colour Palette</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="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="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="color-palette-btn">Colour Palette</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">
<button class="btn btn-primary" id="create-zone-btn">Create</button>
</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 class="modal-actions">
<button class="btn btn-secondary" id="zones-close-btn">Close</button>
@@ -98,12 +107,22 @@
</div>
<label>Zone Name:</label>
<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-devices-editor" class="zone-devices-editor"></div>
<div id="edit-zone-block-groups">
<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>
<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>
<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>
</div>
</div>
@@ -140,13 +159,16 @@
</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 class="modal-content">
<h2>Device groups</h2>
<p class="muted-text" style="margin-top:0;">Assign drivers to a group, set WiFi defaults once per group, then attach groups to zones.</p>
<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">
<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>
</div>
<div id="groups-list-modal" class="profiles-list"></div>
@@ -167,8 +189,16 @@
</div>
<label for="edit-group-name">Group name</label>
<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>
<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>
<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;">
@@ -280,6 +310,51 @@
</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 -->
<div id="preset-editor-modal" class="modal">
<div class="modal-content">
@@ -309,6 +384,7 @@
<label for="preset-background-input">Background</label>
<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-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;">
</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>
<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>
<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>
</div>
</div>
@@ -533,7 +609,10 @@
</div>
<div class="form-group">
<label>Current BPM</label>
<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 class="form-group">
<label>Detected hit type</label>
@@ -543,6 +622,11 @@
<label>Flash on beat</label>
<div id="audio-beat-flash" class="audio-beat-flash" aria-hidden="true"></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">
<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>
@@ -709,6 +793,7 @@
<script src="/static/zone_palette.js"></script>
<script src="/static/patterns.js"></script>
<script src="/static/presets.js"></script>
<script src="/static/sequences.js"></script>
<script src="/static/devices.js"></script>
<script src="/static/audio.js"></script>
</body>

View File

@@ -161,11 +161,11 @@ class AudioBeatDetector:
self._status["beat_type_confidence"] = float(beat_type_confidence or 0.0)
self._status["beat_seq"] = int(self._status.get("beat_seq", 0)) + 1
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:
print(f"[audio] beat driver route: {e}")
print(f"[audio] sequence beat queue: {e}")
def _run_loop(self, device):
try:
@@ -280,3 +280,22 @@ class AudioBeatDetector:
with self._lock:
self._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 os
import threading
from typing import Any, Dict, List, Optional, Set
from typing import Any, Dict, List, Optional, Set, Tuple
_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] = {
"enabled": False,
"device_names": [],
@@ -18,6 +22,7 @@ _beat_route: Dict[str, Any] = {
"manual_beat_n": 1,
}
_beat_counter: int = 0
_preset_session_beats: int = 0
_main_loop: Optional[asyncio.AbstractEventLoop] = None
@@ -26,16 +31,65 @@ def set_beat_route_main_loop(loop: asyncio.AbstractEventLoop) -> None:
_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:
"""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):
return
with _route_lock:
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
_preset_session_beats = 0
return
old = dict(_beat_route)
names = payload.get("device_names")
if not isinstance(names, list):
names = []
@@ -44,15 +98,20 @@ def update_beat_route(payload: Dict[str, Any]) -> None:
except (TypeError, ValueError):
n_raw = 1
manual_n = max(1, min(64, n_raw))
_beat_route = {
"enabled": bool(payload.get("enabled", False)),
"device_names": [str(n).strip() for n in names if str(n).strip()],
"wire_preset_id": str(payload.get("wire_preset_id") or "2"),
"is_manual": bool(payload.get("is_manual", False)),
new_wire = str(payload.get("wire_preset_id") or "2")
old_wire = str(old.get("wire_preset_id") or "2")
if not old.get("enabled") or old_wire != new_wire:
_preset_session_beats = 0
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(),
"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]:
@@ -60,6 +119,44 @@ def get_beat_route() -> Dict[str, Any]:
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:
"""Beats between audio-triggered selects (led-controller only); default 1 = every beat."""
if not isinstance(body, dict):
@@ -136,34 +233,140 @@ def _apply_manual_beat_route(
wire_preset_id: str,
preset_body: Any,
) -> 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:
update_beat_route({"enabled": False})
with _route_lock:
_lane_manual.clear()
_sync_public_beat_route_from_lane_table()
return
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
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
pattern = str(preset_body.get("pattern") or preset_body.get("p") or "").strip()
if pattern and not _pattern_supports_manual(pattern):
update_beat_route({"enabled": False})
with _route_lock:
_lane_manual.clear()
_sync_public_beat_route_from_lane_table()
return
update_beat_route(
{
"enabled": True,
"device_names": device_names,
"wire_preset_id": wire_preset_id,
"is_manual": True,
names = [str(n).strip() for n in device_names if str(n).strip()]
with _route_lock:
_lane_manual.clear()
_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 _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(
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:
"""
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``
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.
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] = {}
last_select: Optional[Dict[str, Any]] = None
@@ -194,6 +402,7 @@ def sync_beat_route_from_push_sequence(
if last_select:
device_names = [str(k).strip() for k in last_select.keys() if str(k).strip()]
if not device_names:
if not preserve_parallel_lane_routes:
update_beat_route({"enabled": False})
return
@@ -205,6 +414,7 @@ def sync_beat_route_from_push_sequence(
elif val is not None:
wire_ids.add(str(val).strip())
if len(wire_ids) != 1:
if not preserve_parallel_lane_routes:
update_beat_route({"enabled": False})
return
wire_preset_id = wire_ids.pop()
@@ -214,15 +424,32 @@ def sync_beat_route_from_push_sequence(
if str(k).strip() == wire_preset_id:
preset_body = v
break
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
wire_id, body = _single_manual_wire_preset(merged_presets)
if wire_id and body is not None:
names = _registry_names_for_macs(target_macs)
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
if not preserve_parallel_lane_routes:
update_beat_route({"enabled": False})
@@ -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:
"""Update cached audio-beat target names after a device registry rename."""
global _beat_route
global _lane_manual
o = str(old_name or "").strip()
n = str(new_name or "").strip()
if not o or not n or o == n:
return
with _route_lock:
if not _beat_route.get("enabled"):
return
names = _beat_route.get("device_names") or []
any_changed = False
for e in _lane_manual.values():
names = e.get("device_names") or []
if not isinstance(names, list):
continue
new_list: List[str] = []
changed = False
row_changed = False
for item in names:
if str(item).strip() == o:
new_list.append(n)
changed = True
row_changed = True
else:
new_list.append(str(item))
if changed:
_beat_route = {**_beat_route, "device_names": new_list}
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:
@@ -302,35 +534,45 @@ async def _deliver_select(device_names: List[str], wire_preset_id: str) -> None:
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:
"""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:
r = dict(_beat_route)
if not r.get("enabled"):
if not _lane_manual:
return
if not r.get("is_manual"):
return
pattern = r.get("pattern") or ""
work = []
for key in sorted(_lane_manual.keys()):
e = _lane_manual[key]
names = e.get("device_names") or []
if not isinstance(names, list) or not names:
continue
pattern = str(e.get("pattern") or "")
if pattern and not _pattern_supports_manual(pattern):
return
names = r.get("device_names") or []
if not names:
return
continue
try:
n = int(r.get("manual_beat_n") or 1)
n = int(e.get("manual_beat_n") or 1)
except (TypeError, ValueError):
n = 1
n = max(1, min(64, n))
_beat_counter += 1
if ((_beat_counter - 1) % n) != 0:
e["beat_counter"] = int(e.get("beat_counter", 0)) + 1
c = int(e["beat_counter"])
if (c - 1) % n != 0:
continue
work.append((list(names), str(e.get("wire_preset_id") or "2")))
if work:
_preset_session_beats += 1
if not work:
return
preset_id = str(r.get("wire_preset_id") or "2")
names_copy = list(names)
loop = _main_loop
if loop is None:
return
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:
print(f"[beat-route] schedule failed: {e}")

View File

@@ -78,12 +78,48 @@ def build_select_message(device_name, preset_name, step=None):
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.
Args:
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:
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_bool = _coerce_auto(auto_raw)
bg_raw = preset_data.get("background", preset_data.get("bg", "#000000"))
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"
bg = resolve_preset_background_hex(preset_data, palette_colors)
# Build payload using the short keys expected by led-driver
preset = {
@@ -164,12 +194,13 @@ def build_preset_dict(preset_data):
return preset
def build_presets_dict(presets_data):
def build_presets_dict(presets_data, palette_colors=None):
"""
Convert multiple presets to API-compliant format.
Args:
presets_data: Dictionary mapping preset names to preset data
palette_colors: Optional list of ``#RRGGBB`` strings for background palette ref resolution.
Returns:
Dictionary mapping preset names to API-compliant preset objects
@@ -190,7 +221,7 @@ def build_presets_dict(presets_data):
"""
result = {}
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

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -1,16 +1,19 @@
from models.squence import Sequence
from models.sequence import Sequence
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():
"""Test Sequence model CRUD operations."""
# Clean up any existing test file
if os.path.exists("Sequence.json"):
os.remove("Sequence.json")
if os.path.exists(_PROJECT_DB):
os.remove(_PROJECT_DB)
sequences = 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}")
assert sequence_id is not None
assert sequence_id in sequences
@@ -19,27 +22,45 @@ def test_sequence():
sequence = sequences.read(sequence_id)
print(f"Read: {sequence}")
assert sequence is not None
assert sequence["group_name"] == "test_group"
assert len(sequence["presets"]) == 2
assert "sequence_duration" in sequence
assert "sequence_loop" in sequence
assert sequence["profile_id"] == "1"
assert sequence["steps"] == []
assert sequence["lanes"] == [[]]
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")
update_data = {
"group_name": "updated_group",
"presets": ["preset3", "preset4", "preset5"],
"sequence_duration": 5000,
"sequence_transition": 1000,
"sequence_loop": True,
"sequence_repeat_count": 3
"name": "updated_seq",
"steps": [
{"preset_id": "5", "group_ids": ["1"], "beats": 2},
{"preset_id": "6", "group_ids": [], "beats": 4},
],
"lanes_group_ids": [["1"]],
"step_duration_ms": 5000,
"loop": True,
"advance_mode": "beats",
"simulated_bpm": 128,
}
result = sequences.update(sequence_id, update_data)
assert result is True
updated = sequences.read(sequence_id)
assert updated["group_name"] == "updated_group"
assert len(updated["presets"]) == 3
assert updated["sequence_duration"] == 5000
assert updated["sequence_loop"] is True
assert updated["name"] == "updated_seq"
assert len(updated["steps"]) == 2
assert updated["steps"][0]["preset_id"] == "5"
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")
sequence_list = sequences.list()
@@ -58,5 +79,5 @@ def test_sequence():
print("\nAll sequence tests passed!")
if __name__ == '__main__':
if __name__ == "__main__":
test_sequence()

View File

@@ -750,6 +750,46 @@ def test_presets_ui(browser: BrowserTest) -> bool:
print(f"\nBrowser presets UI tests: {passed}/{total} passed")
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:
"""Test 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(("Profiles UI", test_profiles_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(("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.scene as models_scene # 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
for cls in (
@@ -527,21 +527,24 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
assert resp.status_code == 200
# 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(
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
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}")
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.json()["sequence_duration"] == 1234
assert resp.json()["step_duration_ms"] == 1234
resp = c.delete(f"{base_url}/sequences/{seq_id}")
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()