diff --git a/db/sequence.json b/db/sequence.json index fee2521..1c14e09 100644 --- a/db/sequence.json +++ b/db/sequence.json @@ -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}} \ No newline at end of file +{"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": [[]]}} \ No newline at end of file diff --git a/src/controllers/sequence.py b/src/controllers/sequence.py index 43d509a..d15162d 100644 --- a/src/controllers/sequence.py +++ b/src/controllers/sequence.py @@ -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('/') -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("/") +@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.""" - try: - data = request.json or {} - group_name = data.get("group_name", "") - preset_names = data.get("presets", None) - sequence_id = sequences.create(group_name, preset_names) - if data: - sequences.update(sequence_id, data) - return json.dumps(sequences.read(sequence_id)), 201, {'Content-Type': 'application/json'} - except Exception as e: - return json.dumps({"error": str(e)}), 400 -@controller.put('/') -async def update_sequence(request, id): - """Update an existing 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 {} + except Exception: + return ( + json.dumps({"error": "Invalid JSON"}), + 400, + {"Content-Type": "application/json"}, + ) + current_profile_id = get_current_profile_id(session) + if not current_profile_id: + return ( + json.dumps({"error": "No profile available"}), + 404, + {"Content-Type": "application/json"}, + ) + sequence_id = sequences.create(current_profile_id) + if not isinstance(data, dict): + data = {} + data = dict(data) + data["profile_id"] = str(current_profile_id) + if sequences.update(sequence_id, data): + seq_data = sequences.read(sequence_id) + return ( + json.dumps({sequence_id: seq_data}), + 201, + {"Content-Type": "application/json"}, + ) + return ( + json.dumps({"error": "Failed to create sequence"}), + 400, + {"Content-Type": "application/json"}, + ) + except Exception as e: + return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"} + + +@controller.put("/") +@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('/') -async def delete_sequence(request, id): - """Delete a sequence.""" + +@controller.delete("/") +@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 + 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("//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)) + 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"} diff --git a/src/models/sequence.py b/src/models/sequence.py new file mode 100644 index 0000000..726c158 --- /dev/null +++ b/src/models/sequence.py @@ -0,0 +1,148 @@ +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") not in ("time", "beats"): + doc["advance_mode"] = "time" + 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": "time", + "steps": [], + "step_duration_ms": 3000, + "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()) diff --git a/src/models/squence.py b/src/models/squence.py deleted file mode 100644 index 6d39ffb..0000000 --- a/src/models/squence.py +++ /dev/null @@ -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()) diff --git a/src/static/sequences.js b/src/static/sequences.js new file mode 100644 index 0000000..72a8c66 --- /dev/null +++ b/src/static/sequences.js @@ -0,0 +1,1115 @@ +// Sequences: lanes (parallel preset chains), shared groups, time or beat advance. +// Debug: in the browser console run setSequenceDebug(true) — toggling logs 1 (on) or 0 (off). + +const SEQ_DEBUG_STORAGE_KEY = 'led-controller-sequence-debug'; + +function seqDebugEnabled() { + try { + return localStorage.getItem(SEQ_DEBUG_STORAGE_KEY) === '1'; + } catch { + return false; + } +} + +/** @type {ReturnType | null} */ +let sequenceBpmPollTimer = null; + +function stopSequenceEditorBpmPoll() { + if (sequenceBpmPollTimer) { + clearInterval(sequenceBpmPollTimer); + sequenceBpmPollTimer = null; + } +} + +async function refreshSequenceEditorBpmDisplay() { + const live = document.getElementById('sequence-editor-bpm-live'); + const panel = document.getElementById('sequence-editor-beats-panel'); + if (!live || !panel || panel.style.display === 'none') return; + try { + const res = await fetch('/api/audio/status', { headers: { Accept: 'application/json' } }); + const j = res.ok ? await res.json() : {}; + const st = j && j.status ? j.status : {}; + const running = !!st.running; + const bpmRaw = st.bpm; + const bpm = + typeof bpmRaw === 'number' && Number.isFinite(bpmRaw) + ? bpmRaw + : typeof bpmRaw === 'string' && bpmRaw.trim() + ? parseFloat(bpmRaw) + : NaN; + if (!running) { + live.textContent = + 'Audio detector is stopped — start it from the header to drive beat mode and show BPM.'; + return; + } + if (!Number.isFinite(bpm) || bpm <= 0) { + live.textContent = 'Audio detector running; BPM will appear after a few beats.'; + return; + } + const msPer = Math.round(60000 / bpm); + const rounded = Math.round(bpm * 10) / 10; + live.textContent = `Current estimate: ${rounded} BPM (~${msPer} ms per beat).`; + } catch (_) { + live.textContent = 'Could not read audio status.'; + } +} + +/** @param {boolean} [clearSequenceTileSelection] When false, leaves the active highlight on sequence tiles (used when restarting playback so the click handler’s selection is not cleared). */ +async function stopZoneSequencePlayback(clearSequenceTileSelection = true) { + // Clear selection **before** awaiting fetch so overlapping stop() calls cannot finish out of + // order and strip .active from tiles after a later render or click (intermittent dead UI). + if (clearSequenceTileSelection) { + document.querySelectorAll('.sequence-tile-main.active').forEach((btn) => btn.classList.remove('active')); + } + try { + const res = await fetch('/sequences/stop', { + method: 'POST', + headers: { Accept: 'application/json' }, + credentials: 'same-origin', + }); + if (!res.ok) { + console.warn('Sequence stop failed:', res.status); + } + } catch (e) { + console.warn('Sequence stop:', e); + } +} + +function normalizeSequenceLanes(doc) { + let lanesRaw = Array.isArray(doc && doc.lanes) ? doc.lanes : []; + let lanes = lanesRaw.filter((l) => Array.isArray(l)); + const hasAnyLaneSteps = lanes.some((l) => l.length > 0); + if ((!lanes.length || !hasAnyLaneSteps) && Array.isArray(doc && doc.steps) && doc.steps.length) { + lanes = [doc.steps.slice()]; + } + if (!lanes.length) lanes = [[]]; + return lanes.map((lane) => + lane + .filter((s) => s && typeof s === 'object') + .map((s) => ({ + preset_id: s.preset_id != null ? String(s.preset_id) : String(s.presetId || ''), + beats: Math.max(1, parseInt(String(s.beats != null ? s.beats : 1), 10) || 1), + group_ids: Array.isArray(s.group_ids) + ? s.group_ids.map((x) => String(x).trim()).filter(Boolean) + : [], + })), + ); +} + +/** + * Log each preset in the sequence with its step beat count (for Audio beats mode this is how + * many detector beats the step runs; in Time mode the value is still the stored step beats). + * @param {string} sequenceId + * @param {Record} sequenceDoc + * @param {Record} presetsMap + */ +function logSequenceSelectionPresets(sequenceId, sequenceDoc, presetsMap) { + if (!sequenceDoc || typeof sequenceDoc !== 'object') return; + const adv = sequenceDoc.advance_mode === 'beats' ? 'beats' : 'time'; + const lanes = normalizeSequenceLanes(sequenceDoc); + const nameFor = (pid) => { + const p = presetsMap && presetsMap[pid]; + if (p && typeof p === 'object' && p.name != null && String(p.name).trim()) { + return String(p.name).trim(); + } + return pid || '(unknown preset)'; + }; + const nm = String(sequenceDoc.name || '').trim() || sequenceId; + const multi = + lanes.filter((lane) => lane.some((s) => s && s.preset_id)).length > 1; + let headerLine = `Sequence "${nm}" (${sequenceId}) — advance: ${adv}`; + if (adv === 'beats' && multi) { + headerLine += + ' — header/audio beat readout follows lane 1 only (other lanes run in parallel)'; + } + console.log(headerLine); + lanes.forEach((lane, li) => { + const steps = lane.filter((s) => s && s.preset_id); + if (!steps.length) return; + if (multi) console.log(`Lane ${li + 1}`); + steps.forEach((step) => { + const b = step.beats; + console.log(` ${nameFor(step.preset_id)}: ${b} beat${b === 1 ? '' : 's'}`); + }); + }); +} + +function groupIdsForLaneStep(sequenceDoc, step, laneIndex, numLanes) { + const lgs = Array.isArray(sequenceDoc.lanes_group_ids) ? sequenceDoc.lanes_group_ids : []; + if (laneIndex < lgs.length) { + const forLane = lgs[laneIndex]; + if (Array.isArray(forLane)) { + return forLane.map((x) => String(x).trim()).filter(Boolean); + } + } + if (numLanes > 1 && laneIndex >= lgs.length) { + return []; + } + const shared = Array.isArray(sequenceDoc.group_ids) + ? sequenceDoc.group_ids.map((x) => String(x).trim()).filter((x) => x.length > 0) + : []; + if (shared.length) return shared; + if (numLanes === 1 && step && Array.isArray(step.group_ids) && step.group_ids.length) { + return step.group_ids.map((x) => String(x).trim()).filter(Boolean); + } + return []; +} + +function buildLaneGroupIdsForEditor(doc, laneIndex, numLanes) { + const raw = Array.isArray(doc && doc.lanes_group_ids) ? doc.lanes_group_ids : []; + const shared = Array.isArray(doc && doc.group_ids) ? doc.group_ids.map(String) : []; + if (laneIndex < raw.length) { + const row = raw[laneIndex]; + if (Array.isArray(row)) { + return row.map(String).filter(Boolean); + } + } + if (numLanes > 1 && laneIndex >= raw.length) { + return []; + } + if (numLanes === 1) { + const lanes = normalizeSequenceLanes(doc); + const first = lanes[0] && lanes[0][0]; + const sg = + first && Array.isArray(first.group_ids) ? first.group_ids.map(String).filter(Boolean) : []; + return sg.length ? sg : shared.slice(); + } + return shared.slice(); +} + +function renderLaneGroupCheckboxes(groupsMap, selectedIds) { + const wrap = document.createElement('div'); + wrap.className = 'sequence-lane-groups-wrap'; + wrap.style.cssText = 'margin-bottom:0.6rem;'; + const hint = document.createElement('div'); + hint.className = 'muted-text'; + hint.style.fontSize = '0.85em'; + hint.style.marginBottom = '0.35rem'; + hint.textContent = 'Groups for this lane (none = whole zone)'; + wrap.appendChild(hint); + const row = document.createElement('div'); + row.className = 'sequence-lane-groups'; + row.style.cssText = 'display:flex;flex-wrap:wrap;gap:0.5rem;align-items:center;'; + const sel = new Set((selectedIds || []).map((x) => String(x))); + Object.keys(groupsMap) + .sort((a, b) => a.localeCompare(b)) + .forEach((gid) => { + const g = groupsMap[gid]; + const gn = g && g.name ? String(g.name) : gid; + const id = `seq-lg-${gid}-${Math.random().toString(36).slice(2)}`; + const lbl = document.createElement('label'); + lbl.style.cssText = 'display:inline-flex;align-items:center;gap:0.25rem;font-size:0.9em;'; + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.className = 'sequence-lane-group'; + cb.value = gid; + cb.id = id; + if (sel.has(String(gid))) cb.checked = true; + const sp = document.createElement('span'); + sp.textContent = `${gn} (${gid})`; + lbl.appendChild(cb); + lbl.appendChild(sp); + row.appendChild(lbl); + }); + const editG = document.createElement('button'); + editG.type = 'button'; + editG.className = 'btn btn-secondary btn-small'; + editG.textContent = 'Edit groups'; + editG.title = 'Open Device groups'; + editG.addEventListener('click', async () => { + if (typeof window.openDeviceGroupsModal === 'function') { + await window.openDeviceGroupsModal(); + return; + } + const b = document.getElementById('groups-btn'); + if (b) b.click(); + else alert('Groups could not be opened.'); + }); + row.appendChild(editG); + wrap.appendChild(row); + return wrap; +} + +function splitDeviceNamesForLane(allNames, laneIndex, numLanes) { + const names = Array.isArray(allNames) ? allNames.filter((n) => n && String(n).trim()) : []; + if (numLanes <= 1) return names; + if (names.length >= numLanes) { + const n = names[laneIndex]; + return n ? [n] : []; + } + return names; +} + +function presetsSectionElForZone(zoneId) { + if (zoneId != null && String(zoneId).length) { + const el = document.querySelector(`.presets-section[data-zone-id="${String(zoneId)}"]`); + if (el) return el; + } + return document.querySelector('.presets-section[data-zone-id]'); +} + +/** Match preset tiles: prefer DOM device list, then zone JSON (same as parseTabDeviceNames + computeZoneTargets). */ +async function resolveSequenceSendDeviceNames(zoneId, zoneDoc, groupIds) { + const gids = Array.isArray(groupIds) ? groupIds.map((x) => String(x).trim()).filter((x) => x.length > 0) : []; + if (!gids.length) { + const section = presetsSectionElForZone(zoneId); + if (typeof window.parseTabDeviceNames === 'function' && section) { + const fromDom = window.parseTabDeviceNames(section); + if (Array.isArray(fromDom) && fromDom.length) return fromDom; + } + } + if (window.zonesManager && typeof window.zonesManager.resolveSequenceStepDeviceNames === 'function' && zoneDoc) { + return await window.zonesManager.resolveSequenceStepDeviceNames(zoneDoc, gids); + } + return []; +} + +/** Start sequence playback on the server (ESP-NOW / TCP delivery from backend). */ +async function requestBackendSequencePlay(sequenceId, zoneId, sequenceDoc) { + // Do not call stop here: server start() already stops any prior run. A fire-and-forget + // client stop can reorder after play and clear the new session (same tile re-click bug). + const res = await fetch(`/sequences/${encodeURIComponent(sequenceId)}/play`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ zone_id: String(zoneId) }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error((err && err.error) || res.statusText); + } + console.log(Number(sequenceId)); +} + +async function fetchSequencesMap() { + try { + const res = await fetch('/sequences', { cache: 'no-store', headers: { Accept: 'application/json' } }); + if (!res.ok) return {}; + const data = await res.json(); + return data && typeof data === 'object' ? data : {}; + } catch (e) { + console.error('fetchSequencesMap:', e); + return {}; + } +} + +async function fetchGroupsMapSeq() { + try { + const res = await fetch('/groups', { headers: { Accept: 'application/json' } }); + if (!res.ok) return {}; + const data = await res.json(); + return data && typeof data === 'object' ? data : {}; + } catch (e) { + return {}; + } +} + +function createSequenceTileRow(sequenceId, sequenceDoc, zoneId, zoneDoc, allPresets, uiMode) { + const row = document.createElement('div'); + const canDrag = false; + row.className = `preset-tile-row preset-tile-row--${uiMode} sequence-tile-row${canDrag ? ' draggable-preset' : ''}`; + row.dataset.sequenceId = sequenceId; + + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'pattern-button preset-tile-main sequence-tile-main'; + button.title = sequenceDoc.name || `Sequence ${sequenceId}`; + + const badge = document.createElement('span'); + badge.textContent = 'SEQ'; + badge.className = 'sequence-tile-badge'; + badge.style.cssText = + 'position:absolute;left:4px;top:4px;font-size:10px;font-weight:700;color:#fff;background:rgba(0,100,180,0.9);padding:2px 5px;border-radius:3px;pointer-events:none;z-index:2;'; + button.style.position = 'relative'; + button.appendChild(badge); + + const label = document.createElement('span'); + label.textContent = sequenceDoc.name || sequenceId; + label.style.fontWeight = 'bold'; + label.className = 'pattern-button-label'; + button.appendChild(label); + + const sub = document.createElement('span'); + sub.className = 'muted-text'; + sub.style.cssText = 'display:block;font-size:0.8em;margin-top:0.2rem;'; + const lanes = normalizeSequenceLanes(sequenceDoc); + const nLanes = lanes.filter((l) => l.length > 0).length || 1; + const nSteps = lanes.reduce((a, l) => a + l.length, 0); + const adv = sequenceDoc.advance_mode === 'beats' ? 'beats' : 'time'; + sub.textContent = `${nLanes} lane${nLanes === 1 ? '' : 's'} · ${nSteps} step${nSteps === 1 ? '' : 's'} · ${adv}`; + button.appendChild(sub); + + button.addEventListener('click', () => { + const strip = document.getElementById('presets-list-zone'); + const clearActiveStrip = () => { + if (strip) strip.querySelectorAll('.pattern-button').forEach((btn) => btn.classList.remove('active')); + }; + clearActiveStrip(); + button.classList.add('active'); + void (async () => { + let seq = sequenceDoc; + let zone = zoneDoc; + let presets = allPresets; + try { + const [sr, zr, pr] = await Promise.all([ + fetch(`/sequences/${encodeURIComponent(sequenceId)}`, { + headers: { Accept: 'application/json' }, + credentials: 'same-origin', + }), + fetch(`/zones/${encodeURIComponent(zoneId)}`, { + headers: { Accept: 'application/json' }, + credentials: 'same-origin', + }), + fetch('/presets', { headers: { Accept: 'application/json' }, credentials: 'same-origin' }), + ]); + if (sr.ok) { + const j = await sr.json(); + if (j && typeof j === 'object' && !j.error) seq = j; + } + if (zr.ok) { + const j = await zr.json(); + if (j && typeof j === 'object' && !j.error) zone = j; + } + if (pr.ok) { + const raw = await pr.json(); + if (raw && typeof raw === 'object') { + if (typeof window.filterPresetsForCurrentProfile === 'function') { + presets = await window.filterPresetsForCurrentProfile(raw); + } else { + presets = raw; + } + } + } + } catch (e) { + console.warn('Sequence play: refresh fetch failed, using cached data', e); + } + logSequenceSelectionPresets(sequenceId, seq, presets); + try { + await requestBackendSequencePlay(sequenceId, zoneId, seq); + } catch (e) { + console.error(e); + alert(e.message || 'Sequence playback failed.'); + } + })(); + }); + + row.appendChild(button); + + if (uiMode === 'edit') { + const actions = document.createElement('div'); + actions.className = 'preset-tile-actions'; + const editBtn = document.createElement('button'); + editBtn.type = 'button'; + editBtn.className = 'btn btn-secondary btn-small'; + editBtn.textContent = 'Edit'; + editBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + openSequenceEditor(sequenceId, sequenceDoc); + }); + actions.appendChild(editBtn); + row.appendChild(actions); + } + + return row; +} + +async function appendZoneSequenceTiles(zoneId, zoneDoc, allPresets, paletteColors, presetsListEl) { + if (!presetsListEl || !zoneId) return; + const ids = Array.isArray(zoneDoc.sequence_ids) ? zoneDoc.sequence_ids.map((x) => String(x)) : []; + if (!ids.length) return; + + const sequences = await fetchSequencesMap(); + const uiMode = typeof window.getPresetUiMode === 'function' ? window.getPresetUiMode() : 'run'; + + for (const sid of ids) { + const seq = sequences[sid]; + if (!seq) continue; + const row = createSequenceTileRow(sid, seq, zoneId, zoneDoc, allPresets, uiMode); + presetsListEl.appendChild(row); + } +} + +async function addSequenceToTab(sequenceId, zoneId) { + if (!zoneId) { + const leftPanel = document.querySelector('.presets-section[data-zone-id]'); + zoneId = leftPanel ? leftPanel.dataset.zoneId : null; + } + if (!zoneId || !sequenceId) { + alert('Could not determine zone or sequence.'); + return; + } + try { + const tabResponse = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } }); + if (!tabResponse.ok) throw new Error('Failed to load zone'); + const tabData = await tabResponse.json(); + const list = Array.isArray(tabData.sequence_ids) ? tabData.sequence_ids.map(String) : []; + if (list.includes(String(sequenceId))) { + alert('Sequence is already on this zone.'); + return; + } + list.push(String(sequenceId)); + tabData.sequence_ids = list; + const updateResponse = await fetch(`/zones/${zoneId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(tabData), + }); + if (!updateResponse.ok) throw new Error('Failed to update zone'); + if (typeof window.renderTabPresets === 'function') { + await window.renderTabPresets(zoneId); + } + } catch (e) { + console.error('addSequenceToTab:', e); + alert('Failed to add sequence to zone.'); + } +} + +async function removeSequenceFromTab(zoneId, sequenceId) { + if (!zoneId || !sequenceId) return; + try { + const tabResponse = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } }); + if (!tabResponse.ok) throw new Error('Failed to load zone'); + const tabData = await tabResponse.json(); + const list = Array.isArray(tabData.sequence_ids) ? tabData.sequence_ids.map(String) : []; + const next = list.filter((x) => String(x) !== String(sequenceId)); + if (next.length === list.length) { + alert('Sequence is not on this zone.'); + return; + } + tabData.sequence_ids = next; + const updateResponse = await fetch(`/zones/${zoneId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(tabData), + }); + if (!updateResponse.ok) throw new Error('Failed to update zone'); + if (typeof window.refreshEditTabSequencesUi === 'function') { + await window.refreshEditTabSequencesUi(zoneId); + } + if (typeof window.renderTabPresets === 'function') { + await window.renderTabPresets(zoneId); + } + } catch (e) { + console.error('removeSequenceFromTab:', e); + alert('Failed to remove sequence from zone.'); + } +} + +async function refreshEditTabSequencesUi(zoneId) { + const currentEl = document.getElementById('edit-zone-sequences-current'); + const addEl = document.getElementById('edit-zone-sequences-list'); + if (!currentEl || !addEl || !zoneId) return; + + try { + const zoneRes = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } }); + if (!zoneRes.ok) throw new Error('zone'); + const zone = await zoneRes.json(); + const onZone = Array.isArray(zone.sequence_ids) ? zone.sequence_ids.map(String) : []; + const seqMap = await fetchSequencesMap(); + const onSet = new Set(onZone); + + currentEl.innerHTML = ''; + if (!onZone.length) { + currentEl.innerHTML = 'No sequences on this zone yet.'; + } else { + for (const sid of onZone) { + const sdoc = seqMap[sid] || {}; + const name = sdoc.name || sid; + const row = document.createElement('div'); + row.className = 'profiles-row'; + row.style.display = 'flex'; + row.style.justifyContent = 'space-between'; + row.style.alignItems = 'center'; + row.style.gap = '0.5rem'; + const span = document.createElement('span'); + span.textContent = `${name} — ${sid}`; + const rm = document.createElement('button'); + rm.type = 'button'; + rm.className = 'btn btn-danger btn-small'; + rm.textContent = 'Remove'; + rm.addEventListener('click', async () => { + if (!window.confirm(`Remove this sequence from the zone?\n\n${name}`)) return; + await removeSequenceFromTab(zoneId, sid); + }); + row.appendChild(span); + row.appendChild(rm); + currentEl.appendChild(row); + } + } + + addEl.innerHTML = ''; + const allIds = Object.keys(seqMap); + const available = allIds.filter((id) => !onSet.has(String(id))); + if (!available.length) { + addEl.innerHTML = + 'No sequences to add. Create one in Sequences or all are already on this zone.'; + } else { + const wrap = document.createElement('div'); + wrap.className = 'zone-devices-add profiles-actions'; + const sel = document.createElement('select'); + sel.className = 'zone-device-add-select'; + sel.setAttribute('aria-label', 'Sequence to add to this zone'); + sel.appendChild(new Option('Add sequence…', '')); + available + .slice() + .sort((a, b) => { + const na = (seqMap[a] && seqMap[a].name) || a; + const nb = (seqMap[b] && seqMap[b].name) || b; + return String(na).localeCompare(String(nb), undefined, { sensitivity: 'base' }); + }) + .forEach((id) => { + const n = (seqMap[id] && seqMap[id].name) || id; + sel.appendChild(new Option(`${n} — ${id}`, id)); + }); + const addBtn = document.createElement('button'); + addBtn.type = 'button'; + addBtn.className = 'btn btn-primary btn-small'; + addBtn.textContent = 'Add'; + addBtn.addEventListener('click', async () => { + const id = sel.value; + if (!id) return; + await addSequenceToTab(id, zoneId); + sel.value = ''; + await refreshEditTabSequencesUi(zoneId); + }); + wrap.appendChild(sel); + wrap.appendChild(addBtn); + addEl.appendChild(wrap); + } + } catch (e) { + console.error('refreshEditTabSequencesUi:', e); + currentEl.innerHTML = 'Failed to load sequences.'; + addEl.innerHTML = ''; + } +} + +let sequenceEditorId = null; + +function renderSequenceStepRow(presetsMap, step) { + const row = document.createElement('div'); + row.className = 'sequence-step-row profiles-row'; + row.style.cssText = + 'display:flex;flex-direction:column;gap:0.35rem;margin-bottom:0.75rem;padding:0.5rem;border:1px solid rgba(255,255,255,0.12);border-radius:6px;'; + + const top = document.createElement('div'); + top.style.cssText = 'display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;'; + const presetWrap = document.createElement('div'); + presetWrap.style.cssText = 'display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;'; + const pl = document.createElement('label'); + pl.textContent = 'Preset'; + const psel = document.createElement('select'); + psel.className = 'sequence-step-preset zone-device-add-select'; + psel.appendChild(new Option('Select…', '')); + const pids = Object.keys(presetsMap).sort((a, b) => { + const na = (presetsMap[a] && presetsMap[a].name) || a; + const nb = (presetsMap[b] && presetsMap[b].name) || b; + return String(na).localeCompare(String(nb), undefined, { sensitivity: 'base' }); + }); + const curPreset = step && step.preset_id != null ? String(step.preset_id) : ''; + pids.forEach((pid) => { + const n = (presetsMap[pid] && presetsMap[pid].name) || pid; + psel.appendChild(new Option(`${n} — ${pid}`, pid)); + }); + if (curPreset) psel.value = curPreset; + presetWrap.appendChild(pl); + presetWrap.appendChild(psel); + + const beatWrap = document.createElement('div'); + beatWrap.style.cssText = 'display:flex;align-items:center;gap:0.35rem;'; + const bl = document.createElement('label'); + bl.textContent = 'Beats'; + const beatsInp = document.createElement('input'); + beatsInp.type = 'number'; + beatsInp.className = 'sequence-step-beats'; + beatsInp.autocomplete = 'off'; + beatsInp.min = '1'; + beatsInp.max = '256'; + beatsInp.value = String( + step && step.beats != null ? Math.max(1, parseInt(String(step.beats), 10) || 1) : 1, + ); + beatsInp.style.width = '4rem'; + beatWrap.appendChild(bl); + beatWrap.appendChild(beatsInp); + + const editPresetBtn = document.createElement('button'); + editPresetBtn.type = 'button'; + editPresetBtn.className = 'btn btn-secondary btn-small'; + editPresetBtn.textContent = 'Edit preset'; + editPresetBtn.addEventListener('click', async (e) => { + e.preventDefault(); + const presetId = psel.value ? String(psel.value) : ''; + if (!presetId) { + alert('Select a preset first.'); + return; + } + let preset = presetsMap[presetId]; + try { + const r = await fetch(`/presets/${presetId}`, { headers: { Accept: 'application/json' } }); + if (r.ok) preset = await r.json(); + } catch (_) { + /* keep cached */ + } + preset = preset && typeof preset === 'object' ? preset : {}; + document.dispatchEvent( + new CustomEvent('editPreset', { + detail: { presetId, preset, zoneId: null }, + }), + ); + }); + + top.appendChild(presetWrap); + top.appendChild(beatWrap); + top.appendChild(editPresetBtn); + row.appendChild(top); + + const rm = document.createElement('button'); + rm.type = 'button'; + rm.className = 'btn btn-danger btn-small'; + rm.textContent = 'Remove step'; + rm.addEventListener('click', () => row.remove()); + row.appendChild(rm); + + return row; +} + +function renderSequenceLane(laneIndex, laneSteps, laneGroupIds, presetsMap, groupsMap) { + const wrap = document.createElement('div'); + wrap.className = 'sequence-lane'; + wrap.dataset.laneIndex = String(laneIndex); + + const head = document.createElement('div'); + head.style.cssText = + 'display:flex;align-items:center;justify-content:space-between;gap:0.5rem;margin-bottom:0.5rem;flex-wrap:wrap;'; + const title = document.createElement('strong'); + title.textContent = `Lane ${laneIndex + 1}`; + head.appendChild(title); + const headBtns = document.createElement('div'); + headBtns.style.cssText = 'display:flex;gap:0.35rem;flex-wrap:wrap;'; + const addStep = document.createElement('button'); + addStep.type = 'button'; + addStep.className = 'btn btn-secondary btn-small'; + addStep.textContent = 'Add step'; + addStep.addEventListener('click', () => { + const stepsHost = wrap.querySelector('.sequence-lane-steps'); + if (stepsHost) { + stepsHost.appendChild(renderSequenceStepRow(presetsMap, { preset_id: '', beats: 1 })); + } + }); + const rmLane = document.createElement('button'); + rmLane.type = 'button'; + rmLane.className = 'btn btn-danger btn-small sequence-lane-remove-lane'; + rmLane.textContent = 'Remove lane'; + rmLane.addEventListener('click', () => { + const host = document.getElementById('sequence-editor-lanes'); + const n = host ? host.querySelectorAll('.sequence-lane').length : 1; + if (n <= 1) return; + wrap.remove(); + refreshSequenceEditorLaneTitles(); + }); + headBtns.appendChild(addStep); + headBtns.appendChild(rmLane); + head.appendChild(headBtns); + wrap.appendChild(head); + + wrap.appendChild(renderLaneGroupCheckboxes(groupsMap, laneGroupIds)); + + const stepsHost = document.createElement('div'); + stepsHost.className = 'sequence-lane-steps'; + const steps = Array.isArray(laneSteps) && laneSteps.length ? laneSteps : [{ preset_id: '', beats: 1 }]; + steps.forEach((s) => { + stepsHost.appendChild(renderSequenceStepRow(presetsMap, s)); + }); + wrap.appendChild(stepsHost); + return wrap; +} + +function refreshSequenceEditorLaneTitles() { + const host = document.getElementById('sequence-editor-lanes'); + if (!host) return; + const lanes = [...host.querySelectorAll('.sequence-lane')]; + lanes.forEach((el, i) => { + const t = el.querySelector(':scope > div strong'); + if (t) t.textContent = `Lane ${i + 1}`; + const rm = el.querySelector('.sequence-lane-remove-lane'); + if (rm) rm.disabled = lanes.length <= 1; + }); +} + +function collectLanesFromEditor() { + const host = document.getElementById('sequence-editor-lanes'); + const laneEls = host ? [...host.querySelectorAll('.sequence-lane')] : []; + const lanes = []; + const lanes_group_ids = []; + laneEls.forEach((laneEl) => { + const g = []; + laneEl.querySelectorAll('.sequence-lane-group:checked').forEach((c) => { + if (c.value) g.push(String(c.value)); + }); + lanes_group_ids.push(g); + const steps = []; + const stepsHost = laneEl.querySelector('.sequence-lane-steps'); + const rows = stepsHost ? stepsHost.querySelectorAll(':scope > .sequence-step-row') : []; + rows.forEach((row) => { + const presetSel = row.querySelector('.sequence-step-preset'); + const beatsInp = row.querySelector('.sequence-step-beats'); + const presetId = presetSel && presetSel.value ? String(presetSel.value) : ''; + if (!presetId) return; + const beats = Math.max(1, parseInt(beatsInp && beatsInp.value ? beatsInp.value : '1', 10) || 1); + steps.push({ preset_id: presetId, beats }); + }); + lanes.push(steps); + }); + return { lanes, lanes_group_ids }; +} + +function updateSequenceEditorTimeBpmHint() { + const hint = document.getElementById('sequence-editor-time-bpm-hint'); + const durInput = document.getElementById('sequence-editor-duration'); + const sel = document.getElementById('sequence-editor-advance-mode'); + if (!hint) return; + if (sel && sel.value === 'beats') { + hint.textContent = ''; + return; + } + const raw = durInput && durInput.value; + const parsed = parseInt(String(raw != null ? raw : '').trim(), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + hint.textContent = ''; + return; + } + const ms = Math.max(200, parsed); + const bpm = 60000 / ms; + let rounded; + if (bpm >= 100) rounded = Math.round(bpm * 10) / 10; + else if (bpm >= 10) rounded = Math.round(bpm * 100) / 100; + else rounded = Math.round(bpm * 1000) / 1000; + hint.textContent = `${rounded} BPM`; +} + +function syncSequenceAdvanceModeUi() { + const sel = document.getElementById('sequence-editor-advance-mode'); + const dw = document.getElementById('sequence-editor-duration-wrap'); + const tw = document.getElementById('sequence-editor-transition-wrap'); + const panel = document.getElementById('sequence-editor-beats-panel'); + const beatsMode = sel && sel.value === 'beats'; + if (dw) dw.style.display = beatsMode ? 'none' : 'block'; + if (tw) tw.style.display = beatsMode ? 'none' : 'block'; + stopSequenceEditorBpmPoll(); + if (beatsMode && panel) { + panel.style.display = 'block'; + void refreshSequenceEditorBpmDisplay(); + sequenceBpmPollTimer = setInterval(() => void refreshSequenceEditorBpmDisplay(), 1500); + } else if (panel) { + panel.style.display = 'none'; + } + updateSequenceEditorTimeBpmHint(); +} + +async function openSequenceEditor(sequenceId, existing) { + sequenceEditorId = sequenceId != null && String(sequenceId).length ? String(sequenceId) : null; + const modal = document.getElementById('sequence-editor-modal'); + const nameInput = document.getElementById('sequence-editor-name'); + const durInput = document.getElementById('sequence-editor-duration'); + const advanceSel = document.getElementById('sequence-editor-advance-mode'); + const lanesHost = document.getElementById('sequence-editor-lanes'); + if (!modal || !nameInput || !durInput || !lanesHost) return; + + const presetsRes = await fetch('/presets', { headers: { Accept: 'application/json' } }); + const presetsMap = presetsRes.ok ? await presetsRes.json() : {}; + const groupsMap = await fetchGroupsMapSeq(); + + let doc = existing; + if (sequenceEditorId) { + try { + const r = await fetch(`/sequences/${encodeURIComponent(sequenceEditorId)}`, { + cache: 'no-store', + headers: { Accept: 'application/json' }, + credentials: 'same-origin', + }); + if (r.ok) { + const j = await r.json(); + if (j && typeof j === 'object' && !j.error) { + doc = j; + } + } + } catch (_) { + /* keep existing */ + } + } + if (!doc || typeof doc !== 'object') { + doc = {}; + } + nameInput.value = doc.name || ''; + durInput.value = doc.step_duration_ms != null ? String(doc.step_duration_ms) : '3000'; + const trInput = document.getElementById('sequence-editor-transition'); + if (trInput) { + const tr = doc.sequence_transition != null ? Number(doc.sequence_transition) : 500; + trInput.value = String(Number.isFinite(tr) ? Math.min(60000, Math.max(0, Math.floor(tr))) : 500); + } + if (advanceSel) { + advanceSel.value = doc.advance_mode === 'beats' ? 'beats' : 'time'; + } + syncSequenceAdvanceModeUi(); + + const lanes = normalizeSequenceLanes(doc); + lanesHost.innerHTML = ''; + if (!lanes.some((l) => l.length > 0)) { + const lg0 = buildLaneGroupIdsForEditor(doc, 0, 1); + lanesHost.appendChild(renderSequenceLane(0, [], lg0, presetsMap, groupsMap)); + } else { + lanes.forEach((laneSteps, i) => { + const lg = buildLaneGroupIdsForEditor(doc, i, lanes.length); + lanesHost.appendChild(renderSequenceLane(i, laneSteps, lg, presetsMap, groupsMap)); + }); + } + refreshSequenceEditorLaneTitles(); + + const edDel = document.getElementById('sequence-editor-delete-btn'); + if (edDel) edDel.style.display = sequenceEditorId ? 'inline-block' : 'none'; + + modal.classList.add('active'); +} + +/** Zone id for refreshing the visible preset/sequence strip if `getCurrentZoneId` is not set yet. */ +function resolveZoneIdForPresetStripRefresh() { + if (window.zonesManager && typeof window.zonesManager.getCurrentZoneId === 'function') { + const z = window.zonesManager.getCurrentZoneId(); + if (z != null && String(z).trim() !== '') { + return String(z).trim(); + } + } + const sec = document.querySelector('.presets-section[data-zone-id]'); + if (sec && sec.dataset.zoneId != null && String(sec.dataset.zoneId).trim() !== '') { + return String(sec.dataset.zoneId).trim(); + } + return null; +} + +async function saveSequenceEditor() { + const nameInput = document.getElementById('sequence-editor-name'); + const durInput = document.getElementById('sequence-editor-duration'); + const trInput = document.getElementById('sequence-editor-transition'); + const advanceSel = document.getElementById('sequence-editor-advance-mode'); + const { lanes, lanes_group_ids } = collectLanesFromEditor(); + const idxs = []; + lanes.forEach((l, i) => { + if (l.some((s) => s && s.preset_id)) idxs.push(i); + }); + if (!idxs.length) { + alert('Add at least one step with a preset selected.'); + return; + } + const nonEmpty = idxs.map((i) => lanes[i].filter((s) => s && s.preset_id)); + const nonEmptyLg = idxs.map((i) => (lanes_group_ids[i] ? [...lanes_group_ids[i]] : [])); + const advance_mode = advanceSel && advanceSel.value === 'beats' ? 'beats' : 'time'; + const trRaw = trInput && trInput.value ? parseInt(trInput.value, 10) : 500; + const sequence_transition = Math.min(60000, Math.max(0, Number.isFinite(trRaw) ? trRaw : 500)); + const payload = { + name: nameInput ? nameInput.value.trim() : '', + lanes: nonEmpty, + lanes_group_ids: nonEmptyLg, + group_ids: nonEmptyLg[0] ? [...nonEmptyLg[0]] : [], + advance_mode, + step_duration_ms: Math.max(200, parseInt(durInput && durInput.value ? durInput.value : '3000', 10) || 3000), + sequence_transition, + loop: true, + steps: nonEmpty.length === 1 ? nonEmpty[0] : [], + }; + + try { + if (sequenceEditorId) { + const res = await fetch(`/sequences/${sequenceEditorId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error((err && err.error) || res.statusText); + } + } else { + const res = await fetch('/sequences', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error((err && err.error) || res.statusText); + } + } + document.getElementById('sequence-editor-modal') && document.getElementById('sequence-editor-modal').classList.remove('active'); + stopSequenceEditorBpmPoll(); + await loadSequencesModalList(); + const zid = resolveZoneIdForPresetStripRefresh(); + if (zid && typeof window.refreshEditTabSequencesUi === 'function') { + await window.refreshEditTabSequencesUi(zid); + } + if (zid && typeof window.renderTabPresets === 'function') { + await window.renderTabPresets(zid); + } + } catch (e) { + console.error(e); + alert(e.message || 'Failed to save sequence.'); + } +} + +async function deleteCurrentSequence() { + const idToDelete = sequenceEditorId; + if (!idToDelete) return; + if (!window.confirm('Delete this sequence? It will be removed from the server.')) return; + try { + const res = await fetch(`/sequences/${idToDelete}`, { method: 'DELETE' }); + if (!res.ok) throw new Error('Delete failed'); + const edModal = document.getElementById('sequence-editor-modal'); + if (edModal) edModal.classList.remove('active'); + stopSequenceEditorBpmPoll(); + sequenceEditorId = null; + await loadSequencesModalList(); + const zid = resolveZoneIdForPresetStripRefresh(); + if (zid) { + const tabResponse = await fetch(`/zones/${zid}`, { headers: { Accept: 'application/json' } }); + if (tabResponse.ok) { + const tabData = await tabResponse.json(); + const list = Array.isArray(tabData.sequence_ids) ? tabData.sequence_ids.map(String) : []; + const sid = String(idToDelete); + if (list.includes(sid)) { + tabData.sequence_ids = list.filter((x) => x !== sid); + await fetch(`/zones/${zid}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(tabData), + }); + } + } + if (typeof window.refreshEditTabSequencesUi === 'function') { + await window.refreshEditTabSequencesUi(zid); + } + if (typeof window.renderTabPresets === 'function') { + await window.renderTabPresets(zid); + } + } + } catch (e) { + console.error(e); + alert('Failed to delete sequence.'); + } +} + +async function loadSequencesModalList() { + const listEl = document.getElementById('sequences-list'); + if (!listEl) return; + listEl.innerHTML = '

Loading…

'; + const map = await fetchSequencesMap(); + const ids = Object.keys(map).sort((a, b) => { + const na = (map[a] && map[a].name) || a; + const nb = (map[b] && map[b].name) || b; + return String(na).localeCompare(String(nb), undefined, { sensitivity: 'base' }); + }); + listEl.innerHTML = ''; + if (!ids.length) { + listEl.innerHTML = '

No sequences yet. Click Add.

'; + return; + } + ids.forEach((id) => { + const doc = map[id] || {}; + const row = document.createElement('div'); + row.className = 'profiles-row'; + row.style.cssText = 'display:flex;justify-content:space-between;align-items:center;gap:0.5rem;'; + const title = document.createElement('span'); + const ln = normalizeSequenceLanes(doc); + const nSteps = ln.reduce((a, l) => a + l.length, 0); + const nLanes = ln.filter((l) => l.length > 0).length || 1; + title.textContent = `${doc.name || id} — ${nLanes} lane(s), ${nSteps} step(s)`; + const edit = document.createElement('button'); + edit.type = 'button'; + edit.className = 'btn btn-secondary btn-small'; + edit.textContent = 'Edit'; + edit.addEventListener('click', () => openSequenceEditor(id, doc)); + row.appendChild(title); + row.appendChild(edit); + listEl.appendChild(row); + }); +} + +window.stopZoneSequencePlayback = stopZoneSequencePlayback; +/** @param {boolean} on */ +window.setSequenceDebug = function setSequenceDebug(on) { + try { + if (on) localStorage.setItem(SEQ_DEBUG_STORAGE_KEY, '1'); + else localStorage.removeItem(SEQ_DEBUG_STORAGE_KEY); + } catch (e) { + console.warn('[sequence] could not persist debug flag', e); + } + console.log(seqDebugEnabled() ? 1 : 0); +}; +window.appendZoneSequenceTiles = appendZoneSequenceTiles; +window.refreshEditTabSequencesUi = refreshEditTabSequencesUi; +window.addSequenceToTab = addSequenceToTab; +window.removeSequenceFromTab = removeSequenceFromTab; + +document.addEventListener('DOMContentLoaded', () => { + const btn = document.getElementById('sequences-btn'); + const modal = document.getElementById('sequences-modal'); + const closeBtn = document.getElementById('sequences-close-btn'); + const addBtn = document.getElementById('sequence-add-btn'); + if (btn && modal) { + btn.addEventListener('click', () => { + modal.classList.add('active'); + loadSequencesModalList(); + }); + } + if (closeBtn && modal) { + closeBtn.addEventListener('click', () => modal.classList.remove('active')); + } + if (addBtn) { + addBtn.addEventListener('click', () => { + sequenceEditorId = null; + openSequenceEditor(null, null); + }); + } + const openPresetsFromSeq = document.getElementById('sequences-open-presets-btn'); + if (openPresetsFromSeq) { + openPresetsFromSeq.addEventListener('click', () => { + const b = document.getElementById('presets-btn'); + if (b) b.click(); + else alert('Presets is not available.'); + }); + } + + const edClose = document.getElementById('sequence-editor-close-btn'); + const edSave = document.getElementById('sequence-editor-save-btn'); + const edDel = document.getElementById('sequence-editor-delete-btn'); + if (edClose) { + edClose.addEventListener('click', () => { + stopSequenceEditorBpmPoll(); + document.getElementById('sequence-editor-modal') && document.getElementById('sequence-editor-modal').classList.remove('active'); + }); + } + if (edSave) edSave.addEventListener('click', () => saveSequenceEditor()); + if (edDel) edDel.addEventListener('click', () => deleteCurrentSequence()); + + const advanceSel = document.getElementById('sequence-editor-advance-mode'); + if (advanceSel) { + advanceSel.addEventListener('change', () => syncSequenceAdvanceModeUi()); + } + const durForBpmHint = document.getElementById('sequence-editor-duration'); + if (durForBpmHint) { + durForBpmHint.addEventListener('input', () => updateSequenceEditorTimeBpmHint()); + durForBpmHint.addEventListener('change', () => updateSequenceEditorTimeBpmHint()); + } + + const edAddLane = document.getElementById('sequence-editor-add-lane-btn'); + if (edAddLane) { + edAddLane.addEventListener('click', async () => { + const host = document.getElementById('sequence-editor-lanes'); + if (!host) return; + const presetsRes = await fetch('/presets', { headers: { Accept: 'application/json' } }); + const presetsMap = presetsRes.ok ? await presetsRes.json() : {}; + const groupsMap = await fetchGroupsMapSeq(); + const idx = host.querySelectorAll('.sequence-lane').length; + host.appendChild(renderSequenceLane(idx, [], [], presetsMap, groupsMap)); + refreshSequenceEditorLaneTitles(); + }); + } +}); diff --git a/src/util/beat_driver_route.py b/src/util/beat_driver_route.py index 5ff46b1..ccf5645 100644 --- a/src/util/beat_driver_route.py +++ b/src/util/beat_driver_route.py @@ -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): @@ -137,33 +234,99 @@ def _apply_manual_beat_route( preset_body: Any, ) -> None: """Enable audio→driver routing for one manual preset, or disable if invalid.""" + 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 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_manual_beat_route_on_auto_select: bool = False, ) -> None: """ Update beat routing from a ``/presets/push`` body ``sequence`` (list of v1 dicts). @@ -173,6 +336,10 @@ 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_manual_beat_route_on_auto_select`` is true (zone sequence playback), an + auto preset in ``select`` does not clear manual routing — other lanes may still need + ``notify_beat_detected`` for manual patterns in parallel. """ merged_presets: Dict[str, Any] = {} last_select: Optional[Dict[str, Any]] = None @@ -214,6 +381,13 @@ def sync_beat_route_from_push_sequence( if str(k).strip() == wire_preset_id: preset_body = v break + if preset_body is None: + update_beat_route({"enabled": False}) + return + if _coerce_auto_from_body(preset_body): + if not preserve_manual_beat_route_on_auto_select: + update_beat_route({"enabled": False}) + return _apply_manual_beat_route(device_names, wire_preset_id, preset_body) return @@ -247,25 +421,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 [] - new_list: List[str] = [] - changed = False - for item in names: - if str(item).strip() == o: - new_list.append(n) - changed = True - else: - new_list.append(str(item)) - if changed: - _beat_route = {**_beat_route, "device_names": new_list} + 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] = [] + row_changed = False + for item in names: + if str(item).strip() == o: + new_list.append(n) + row_changed = True + else: + new_list.append(str(item)) + if row_changed: + e["device_names"] = new_list + any_changed = True + if any_changed: + _sync_public_beat_route_from_lane_table() async def _deliver_select(device_names: List[str], wire_preset_id: str) -> None: @@ -302,35 +481,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 "" - if pattern and not _pattern_supports_manual(pattern): - return - names = r.get("device_names") or [] - if not names: - return - try: - n = int(r.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: - return - preset_id = str(r.get("wire_preset_id") or "2") - names_copy = list(names) + 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): + continue + try: + n = int(e.get("manual_beat_n") or 1) + except (TypeError, ValueError): + n = 1 + n = max(1, min(64, n)) + 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 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}") diff --git a/src/util/sequence_playback.py b/src/util/sequence_playback.py new file mode 100644 index 0000000..6f0931e --- /dev/null +++ b/src/util/sequence_playback.py @@ -0,0 +1,996 @@ +"""Server-side zone sequence playback (time or audio-beat advance). + +The browser selects a sequence and zone; this module delivers preset pushes to drivers. +Sequence start sends one v1 message with every preset body used in the sequence; auto steps +then send select-only updates. Manual steps rely on the bulk load and only update beat routing. +""" + +from __future__ import annotations + +import asyncio +import json +import queue +import threading +from typing import Any, Dict, List, Optional, Tuple + +_thread_beat_queue: "queue.Queue[int]" = queue.Queue(maxsize=256) +_beat_consumer_started = False +_beat_consumer_lock = threading.Lock() + +_time_task: Optional[asyncio.Task] = None +_time_lock = asyncio.Lock() + +_beat_run: Optional[Dict[str, Any]] = None +_beat_run_lock = threading.Lock() + + +def _norm_mac(raw: Any) -> Optional[str]: + from models.device import normalize_mac + + return normalize_mac(raw) + + +def _normalize_sequence_lanes(doc: Dict[str, Any]) -> List[List[Dict[str, Any]]]: + lanes_raw = doc.get("lanes") if isinstance(doc.get("lanes"), list) else [] + lanes = [x for x in lanes_raw if isinstance(x, list)] + has_any = any(len(x) > 0 for x in lanes) + steps = doc.get("steps") + if (not lanes or not has_any) and isinstance(steps, list) and steps: + lanes = [list(steps)] + if not lanes: + lanes = [[]] + out: List[List[Dict[str, Any]]] = [] + for lane in lanes: + row: List[Dict[str, Any]] = [] + for s in lane: + if not isinstance(s, dict): + continue + pid = s.get("preset_id", s.get("presetId")) + try: + b_raw = s.get("beats") + b_n = int(b_raw) if b_raw is not None else 1 + except (TypeError, ValueError): + b_n = 1 + row.append( + { + "preset_id": str(pid).strip() if pid is not None else "", + "beats": max(1, b_n), + "group_ids": [ + str(x).strip() + for x in (s.get("group_ids") or []) + if x is not None and str(x).strip() + ], + } + ) + out.append(row) + return out + + +def _group_ids_for_lane_step( + sequence_doc: Dict[str, Any], step: Dict[str, Any], lane_index: int, num_lanes: int +) -> List[str]: + lgs = sequence_doc.get("lanes_group_ids") + if isinstance(lgs, list) and lane_index < len(lgs): + for_lane = lgs[lane_index] + if isinstance(for_lane, list): + return [str(x).strip() for x in for_lane if x is not None and str(x).strip()] + # Multi-lane doc with a shorter ``lanes_group_ids``: do not fall back to ``group_ids`` + # (editor stores lane 0's groups there; applying it to other lanes targets the wrong groups). + if num_lanes > 1 and isinstance(lgs, list) and lane_index >= len(lgs): + return [] + shared = sequence_doc.get("group_ids") + if isinstance(shared, list) and shared: + return [str(x).strip() for x in shared if x is not None and str(x).strip()] + if num_lanes == 1: + sg = step.get("group_ids") + if isinstance(sg, list) and sg: + return [str(x).strip() for x in sg if x is not None and str(x).strip()] + return [] + + +def _compute_zone_targets( + zone_doc: Dict[str, Any], devices: Any, groups: Any +) -> Tuple[List[str], List[str]]: + gids = zone_doc.get("group_ids") + gids = [str(x).strip() for x in gids if isinstance(gids, list) and x is not None and str(x).strip()] + names: List[str] = [] + macs: List[str] = [] + if gids: + seen: set = set() + for gid in gids: + g = groups.read(gid) if hasattr(groups, "read") else None + if not isinstance(g, dict): + continue + devs = g.get("devices") + if not isinstance(devs, list): + continue + for raw in devs: + m = _norm_mac(raw) + if not m or m in seen: + continue + seen.add(m) + doc = devices.read(m) or {} + nm = str(doc.get("name") or "").strip() or m + names.append(nm) + macs.append(m) + return names, macs + zone_names = zone_doc.get("names") + if not isinstance(zone_names, list): + zone_names = [] + name_to_mac: Dict[str, str] = {} + for did in devices.list(): + m = _norm_mac(did) + if not m: + continue + doc = devices.read(did) or {} + nm = str(doc.get("name") or "").strip() + if nm: + name_to_mac[nm] = m + for zn in zone_names: + z = str(zn).strip() + if not z: + continue + m = name_to_mac.get(z) + if m and m not in macs: + names.append(z) + macs.append(m) + return names, macs + + +def _sequence_referenced_group_ids(sequence_doc: Dict[str, Any]) -> List[str]: + """Group ids mentioned on the sequence (shared, per-lane, per-step, legacy steps).""" + seen: set = set() + out: List[str] = [] + + def add(raw: Any) -> None: + if raw is None: + return + s = str(raw).strip() + if not s or s in seen: + return + seen.add(s) + out.append(s) + + g0 = sequence_doc.get("group_ids") + if isinstance(g0, list): + for x in g0: + add(x) + lgs = sequence_doc.get("lanes_group_ids") + if isinstance(lgs, list): + for row in lgs: + if isinstance(row, list): + for x in row: + add(x) + for lane_key in ("lanes", "steps"): + lanes_raw = sequence_doc.get(lane_key) + if not isinstance(lanes_raw, list): + continue + for lane in lanes_raw: + if lane_key == "steps": + step = lane if isinstance(lane, dict) else None + if step: + sg = step.get("group_ids") + if isinstance(sg, list): + for x in sg: + add(x) + continue + if not isinstance(lane, list): + continue + for step in lane: + if not isinstance(step, dict): + continue + sg = step.get("group_ids") + if isinstance(sg, list): + for x in sg: + add(x) + return out + + +def _extend_mac_scope_for_sequence_groups( + zone_mac_set: set, + zone_name_by_mac: Dict[str, str], + sequence_doc: Dict[str, Any], + devices: Any, + groups: Any, +) -> None: + """Include MACs from any group the sequence references so per-lane groups can differ from the zone tab.""" + for gid in _sequence_referenced_group_ids(sequence_doc): + g = groups.read(gid) if hasattr(groups, "read") else None + if not isinstance(g, dict): + continue + for raw in g.get("devices") or []: + m = _norm_mac(raw) + if not m: + continue + zone_mac_set.add(m) + if m not in zone_name_by_mac: + doc = devices.read(m) if hasattr(devices, "read") else None + if isinstance(doc, dict): + nm = str(doc.get("name") or "").strip() or m + else: + nm = m + zone_name_by_mac[m] = nm + + +def _resolve_step_device_names( + zone_doc: Dict[str, Any], + step_group_ids: List[str], + devices: Any, + groups: Any, + *, + sequence_doc: Optional[Dict[str, Any]] = None, +) -> List[str]: + z_names, z_macs = _compute_zone_targets(zone_doc, devices, groups) + if not step_group_ids: + return list(z_names) + zone_mac_set = {m for m in (_norm_mac(x) for x in z_macs) if m} + zone_name_by_mac: Dict[str, str] = {} + for i, m in enumerate(z_macs): + mn = _norm_mac(m) + if mn and mn not in zone_name_by_mac: + zone_name_by_mac[mn] = z_names[i] if i < len(z_names) else mn + if sequence_doc is not None: + _extend_mac_scope_for_sequence_groups( + zone_mac_set, zone_name_by_mac, sequence_doc, devices, groups + ) + step_macs: set = set() + for gid in step_group_ids: + g = groups.read(gid) if hasattr(groups, "read") else None + if not isinstance(g, dict): + continue + for raw in g.get("devices") or []: + m = _norm_mac(raw) + if m and m in zone_mac_set: + step_macs.add(m) + out: List[str] = [] + for m in step_macs: + n = zone_name_by_mac.get(m) + if n: + out.append(n) + return out + + +def _lane_has_non_empty_lanes_group_ids(sequence_doc: Dict[str, Any], lane_index: int) -> bool: + """True when this lane's targets come from ``lanes_group_ids[lane]`` (already lane-scoped).""" + lgs = sequence_doc.get("lanes_group_ids") + if not isinstance(lgs, list) or lane_index < 0 or lane_index >= len(lgs): + return False + for_lane = lgs[lane_index] + if not isinstance(for_lane, list) or not for_lane: + return False + return any(x is not None and str(x).strip() for x in for_lane) + + +def _split_device_names_for_lane( + all_names: List[str], + lane_index: int, + num_lanes: int, + *, + partition_shared_zone: bool = True, +) -> List[str]: + names = [n for n in all_names if n and str(n).strip()] + if num_lanes <= 1 or not partition_shared_zone: + return names + if len(names) >= num_lanes: + n = names[lane_index] + return [n] if n else [] + return names + + +def _resolve_colors_with_palette_refs( + colors: Any, palette_refs: Any, palette_colors: List[Any] +) -> List[Any]: + base = list(colors) if isinstance(colors, list) else [] + refs = list(palette_refs) if isinstance(palette_refs, list) else [] + pal = list(palette_colors) if isinstance(palette_colors, list) else [] + out: List[Any] = [] + for idx, color in enumerate(base): + ref_raw = refs[idx] if idx < len(refs) else None + try: + ref = int(ref_raw) if ref_raw is not None else None + except (TypeError, ValueError): + ref = None + if isinstance(ref, int) and 0 <= ref < len(pal) and pal[ref]: + out.append(pal[ref]) + else: + out.append(color) + return out + + +def _ordered_unique_preset_ids_from_lanes(lanes: List[List[Dict[str, Any]]]) -> List[str]: + seen: set = set() + out: List[str] = [] + for lane in lanes: + for step in lane: + if not isinstance(step, dict): + continue + pid = str(step.get("preset_id") or "").strip() + if not pid or pid in seen: + continue + seen.add(pid) + out.append(pid) + return out + + +def _display_preset_for_step( + preset_id: str, + presets_map: Dict[str, Any], + palette_colors: List[Any], +) -> Optional[Dict[str, Any]]: + preset = presets_map.get(preset_id) + if not isinstance(preset, dict): + return None + base_colors = preset.get("colors") or preset.get("c") or ["#FFFFFF"] + colors = _resolve_colors_with_palette_refs( + base_colors if isinstance(base_colors, list) else [base_colors], + preset.get("palette_refs"), + palette_colors, + ) + return {**preset, "colors": colors} + + +def _preset_inner_from_display_preset(display_preset: Dict[str, Any]) -> Dict[str, Any]: + from util.espnow_message import build_preset_dict + + body = dict(display_preset) + inner = build_preset_dict(body) + mb = body.get("manual_beat_n", body.get("manualBeatN")) + if mb is not None: + try: + n = int(mb) + if 1 <= n <= 64: + inner["manual_beat_n"] = n + except (TypeError, ValueError): + pass + return inner + + +def _device_names_to_macs(device_names: List[str], devices: Any) -> List[str]: + macs: List[str] = [] + seen: set = set() + for nm in device_names: + key = str(nm).strip() + if not key: + continue + m = None + for did in devices.list(): + doc = devices.read(did) or {} + if str(doc.get("name") or "").strip() == key: + m = _norm_mac(did) + break + if not m and key.startswith("led-"): + m = _norm_mac(key[4:]) + if m and m not in seen: + seen.add(m) + macs.append(m) + return macs + + +def _union_macs_for_sequence(ctx: Dict[str, Any]) -> List[str]: + """MACs that appear on any lane/step (union); falls back to full zone targets.""" + lanes: List[List[Dict[str, Any]]] = ctx["lanes"] + sequence_doc: Dict[str, Any] = ctx["sequence_doc"] + zone_doc: Dict[str, Any] = ctx["zone_doc"] + devices = ctx["devices"] + groups = ctx["groups"] + num_lanes = int(ctx["num_lanes"]) + seen: set = set() + out: List[str] = [] + for lane_index, lane in enumerate(lanes): + for step in lane: + if not isinstance(step, dict): + continue + gids = _group_ids_for_lane_step(sequence_doc, step, lane_index, num_lanes) + device_names = _resolve_step_device_names( + zone_doc, gids, devices, groups, sequence_doc=sequence_doc + ) + device_names = _split_device_names_for_lane( + device_names, + lane_index, + num_lanes, + partition_shared_zone=not _lane_has_non_empty_lanes_group_ids( + sequence_doc, lane_index + ), + ) + if gids and not device_names: + continue + for m in _device_names_to_macs(device_names, devices): + if m and m not in seen: + seen.add(m) + out.append(m) + if out: + return out + _, z_macs = _compute_zone_targets(zone_doc, devices, groups) + return list(z_macs) + + +def _build_sequence_wire_presets_map(ctx: Dict[str, Any]) -> Dict[str, Any]: + lanes: List[List[Dict[str, Any]]] = ctx["lanes"] + presets_map: Dict[str, Any] = ctx["presets_map"] + palette_colors: List[Any] = ctx["palette_colors"] + inner_by_wire: Dict[str, Any] = {} + for pid in _ordered_unique_preset_ids_from_lanes(lanes): + disp = _display_preset_for_step(pid, presets_map, palette_colors) + if not disp: + continue + inner_by_wire[str(pid)] = _preset_inner_from_display_preset(disp) + return inner_by_wire + + +async def _deliver_sequence_presets_bulk(ctx: Dict[str, Any]) -> None: + """Push all preset definitions used in the sequence once; step advances use select (auto) only.""" + from models.transport import get_current_sender + from util.driver_delivery import deliver_json_messages + + inner_by_wire = _build_sequence_wire_presets_map(ctx) + ctx["_sequence_wire_presets"] = inner_by_wire + if not inner_by_wire: + return + sender = get_current_sender() + if not sender: + raise RuntimeError("Transport not configured") + macs = _union_macs_for_sequence(ctx) + if not macs: + return + msg = json.dumps({"v": "1", "presets": inner_by_wire}, separators=(",", ":")) + await deliver_json_messages(sender, [msg], macs, ctx["devices"], delay_s=0.05) + + +def _coerce_auto(preset: Dict[str, Any]) -> bool: + raw = preset.get("auto", preset.get("a", True)) + if isinstance(raw, bool): + return raw + if raw is None: + return True + if isinstance(raw, int): + return raw != 0 + if isinstance(raw, str): + lo = raw.strip().lower() + if lo in ("false", "0", "no", "off"): + return False + if lo in ("true", "1", "yes", "on"): + return True + return True + + +def _load_palette_colors(profile_id: str) -> List[Any]: + from models.profile import Profile + from models.pallet import Palette + + prof = Profile().read(profile_id) + if not isinstance(prof, dict): + return [] + pid = prof.get("palette_id") or prof.get("paletteId") + if not pid: + return [] + return Palette().read(str(pid)) or [] + + +async def _deliver_preset_for_devices( + preset_id: str, + preset_doc: Dict[str, Any], + device_names: List[str], + devices: Any, + *, + lane_index: Optional[int] = None, +) -> None: + from models.transport import get_current_sender + from util.driver_delivery import deliver_json_messages + from util.beat_driver_route import sync_beat_route_from_push_sequence + from util.espnow_message import build_preset_dict + + sender = get_current_sender() + if not sender: + raise RuntimeError("Transport not configured") + + macs: List[str] = [] + seen: set = set() + for nm in device_names: + key = str(nm).strip() + if not key: + continue + m = None + for did in devices.list(): + doc = devices.read(did) or {} + if str(doc.get("name") or "").strip() == key: + m = _norm_mac(did) + break + if not m and key.startswith("led-"): + m = _norm_mac(key[4:]) + if m and m not in seen: + seen.add(m) + macs.append(m) + if not macs: + return + + body = dict(preset_doc) + auto = _coerce_auto(body) + inner = build_preset_dict(body) + mb = body.get("manual_beat_n", body.get("manualBeatN")) + if mb is not None: + try: + n = int(mb) + if 1 <= n <= 64: + inner["manual_beat_n"] = n + except (TypeError, ValueError): + pass + wire = str(preset_id) + seq_list: List[Dict[str, Any]] = [{"v": "1", "presets": {wire: inner}}] + if auto and device_names: + sel: Dict[str, Any] = {} + for n in device_names: + if n: + sel[str(n)] = [wire] + if sel: + seq_list.append({"v": "1", "select": sel}) + messages = [json.dumps(x, separators=(",", ":")) for x in seq_list] + await deliver_json_messages(sender, messages, macs, devices, delay_s=0.05) + if not auto: + if lane_index is not None: + from util.beat_driver_route import set_sequence_manual_lane_route + + set_sequence_manual_lane_route(lane_index, device_names, wire, inner) + else: + sync_beat_route_from_push_sequence( + seq_list, target_macs=macs, preserve_manual_beat_route_on_auto_select=True + ) + + +async def _send_lane( + lane_index: int, + st: Dict[str, Any], + ctx: Dict[str, Any], +) -> None: + lanes: List[List[Dict[str, Any]]] = ctx["lanes"] + sequence_doc: Dict[str, Any] = ctx["sequence_doc"] + presets_map: Dict[str, Any] = ctx["presets_map"] + zone_doc: Dict[str, Any] = ctx["zone_doc"] + devices = ctx["devices"] + groups = ctx["groups"] + palette_colors: List[Any] = ctx["palette_colors"] + num_lanes = ctx["num_lanes"] + + if st.get("done"): + return + lane_steps = lanes[lane_index] + idx = int(st.get("stepIdx", 0)) + if idx < 0 or idx >= len(lane_steps): + return + step = lane_steps[idx] + preset_id = str(step.get("preset_id") or "").strip() + if not preset_id: + return + display_preset = _display_preset_for_step(preset_id, presets_map, palette_colors) + if not display_preset: + return + gids = _group_ids_for_lane_step(sequence_doc, step, lane_index, num_lanes) + device_names = _resolve_step_device_names( + zone_doc, gids, devices, groups, sequence_doc=sequence_doc + ) + device_names = _split_device_names_for_lane( + device_names, + lane_index, + num_lanes, + partition_shared_zone=not _lane_has_non_empty_lanes_group_ids(sequence_doc, lane_index), + ) + if gids and not device_names: + return + + from models.transport import get_current_sender + from util.beat_driver_route import ( + clear_sequence_manual_lane_route, + set_sequence_manual_lane_route, + ) + from util.driver_delivery import deliver_json_messages + + sender = get_current_sender() + if not sender: + raise RuntimeError("Transport not configured") + + macs = _device_names_to_macs(device_names, devices) + if not macs: + return + + bulk = ctx.get("_sequence_wire_presets") + if isinstance(bulk, dict) and bulk: + auto = _coerce_auto(display_preset) + inner = _preset_inner_from_display_preset(display_preset) + wire = str(preset_id) + if auto: + clear_sequence_manual_lane_route(lane_index) + sel: Dict[str, Any] = {} + for n in device_names: + if n: + sel[str(n)] = [wire] + if not sel: + return + msg = json.dumps({"v": "1", "select": sel}, separators=(",", ":")) + await deliver_json_messages(sender, [msg], macs, devices, delay_s=0.05) + else: + set_sequence_manual_lane_route(lane_index, device_names, wire, inner) + return + + await _deliver_preset_for_devices( + preset_id, display_preset, device_names, devices, lane_index=lane_index + ) + + +async def _send_all_lanes(ctx: Dict[str, Any]) -> None: + lane_states: List[Dict[str, Any]] = ctx["lane_states"] + num_lanes = ctx["num_lanes"] + for i in range(num_lanes): + if lane_states[i].get("done"): + continue + await _send_lane(i, lane_states[i], ctx) + + +def _sequence_advance_beats(sequence_doc: Dict[str, Any]) -> bool: + raw = sequence_doc.get("advance_mode") + return isinstance(raw, str) and raw.strip().lower() == "beats" + + +def _build_ctx( + sequence_doc: Dict[str, Any], + zone_doc: Dict[str, Any], + presets_map: Dict[str, Any], + profile_id: str, +) -> Optional[Dict[str, Any]]: + from models.device import Device + from models.group import Group + + lanes = [x for x in _normalize_sequence_lanes(sequence_doc) if len(x) > 0] + if not lanes: + return None + devices = Device() + groups = Group() + palette_colors = _load_palette_colors(profile_id) + num_lanes = len(lanes) + lane_states = [{"stepIdx": 0, "beatCount": 0, "done": False} for _ in range(num_lanes)] + return { + "lanes": lanes, + "lane_states": lane_states, + "num_lanes": num_lanes, + "sequence_doc": sequence_doc, + "zone_doc": zone_doc, + "presets_map": presets_map, + "devices": devices, + "groups": groups, + "palette_colors": palette_colors, + "loop": True, + "advance_mode": "beats" if _sequence_advance_beats(sequence_doc) else "time", + } + + +def playback_status() -> Dict[str, Any]: + """Snapshot for UI (e.g. audio status poll): lane 0 step + beats within step, total steps sum.""" + with _beat_run_lock: + ctx = _beat_run + if not ctx: + return {"active": False, "beat_readout": ""} + lanes: List[List[Dict[str, Any]]] = ctx.get("lanes") or [] + lane_states: List[Dict[str, Any]] = ctx.get("lane_states") or [] + num_lanes = int(ctx.get("num_lanes") or 0) + total_steps = sum(len(l) for l in lanes) + lane0_steps = len(lanes[0]) if lanes else 0 + beat_count = 0 + beats_per_step = 1 + step_1based = 0 + lane0 = lanes[0] if lanes else [] + sequence_beats_per_pass = 0 + for step in lane0: + sequence_beats_per_pass += max(1, int((step or {}).get("beats") or 1)) + sequence_beat_at = 0 + if lane_states and lane0_steps > 0: + st0 = lane_states[0] + idx = int(st0.get("stepIdx", 0)) + advance_mode = str(ctx.get("advance_mode") or "").strip().lower() + if st0.get("done"): + step_1based = lane0_steps + sequence_beat_at = sequence_beats_per_pass + else: + step_1based = idx + 1 + if 0 <= idx < len(lanes[0]): + step = lanes[0][idx] + beats_per_step = max(1, int(step.get("beats") or 1)) + beat_count_raw = int(st0.get("beatCount", 0)) + # Internal beatCount resets to 0 on step rollover; expose 1..beats_per_step in beats mode. + if advance_mode == "beats": + bt = max(1, int(beats_per_step)) + beat_count = min(bt, max(1, beat_count_raw if beat_count_raw > 0 else 1)) + else: + beat_count = beat_count_raw + for j in range(min(idx, len(lane0))): + sequence_beat_at += max(1, int((lane0[j] or {}).get("beats") or 1)) + sequence_beat_at += beat_count + lane0_preset_id = "" + lane0_preset_name = "" + pm_raw = ctx.get("presets_map") + presets_map_status: Dict[str, Any] = pm_raw if isinstance(pm_raw, dict) else {} + if lane_states and lane0_steps > 0 and lane0: + st_preset = lane_states[0] + if not st_preset.get("done"): + ix = int(st_preset.get("stepIdx", 0)) + if 0 <= ix < len(lane0): + stp = lane0[ix] or {} + pid = str(stp.get("preset_id") or "").strip() + lane0_preset_id = pid + if pid: + pdoc = presets_map_status.get(pid) + if isinstance(pdoc, dict): + nm = str(pdoc.get("name") or "").strip() + lane0_preset_name = nm or pid + else: + lane0_preset_name = pid + beat_readout = "" + adv_m = str(ctx.get("advance_mode") or "").strip().lower() + if ( + adv_m == "beats" + and sequence_beats_per_pass > 0 + and lane_states + and lane0_steps > 0 + and lane_states[0] + and not lane_states[0].get("done") + ): + tot = max(1, int(sequence_beats_per_pass)) + at = int(sequence_beat_at) + # Pass position within this run: inclusive 1..tot + sp = min(tot, max(1, at if at > 0 else 1)) + beat_readout = f"{sp}/{tot}" + return { + "active": True, + "advance_mode": ctx.get("advance_mode"), + "sequence_id": ctx.get("sequence_id"), + "zone_id": ctx.get("zone_id"), + "num_lanes": num_lanes, + "total_sequence_steps": total_steps, + "lane0_current_step": step_1based, + "lane0_lane_length": lane0_steps, + "lane0_beat_in_step": beat_count, + "lane0_beats_per_step": beats_per_step, + "lane0_preset_id": lane0_preset_id, + "lane0_preset_name": lane0_preset_name, + "sequence_beat_at": sequence_beat_at, + "sequence_beats_per_pass": sequence_beats_per_pass, + "sequence_loop_beat": int(ctx.get("sequence_loop_beat", 0)), + "beat_readout": beat_readout, + } + + +async def process_active_beat_advance() -> None: + with _beat_run_lock: + ctx = _beat_run + if not ctx or ctx.get("advance_mode") != "beats": + return + lane_states: List[Dict[str, Any]] = ctx["lane_states"] + lanes: List[List[Dict[str, Any]]] = ctx["lanes"] + loop = bool(ctx.get("loop")) + lane0_looped = False + for i in range(ctx["num_lanes"]): + st = lane_states[i] + if st.get("done"): + continue + lane_steps = lanes[i] + if not lane_steps: + continue + st["beatCount"] = int(st.get("beatCount", 0)) + 1 + step = lane_steps[int(st.get("stepIdx", 0))] + need = max(1, int(step.get("beats") or 1)) + if int(st["beatCount"]) >= need: + st["beatCount"] = 0 + if int(st.get("stepIdx", 0)) + 1 >= len(lane_steps): + if loop: + if i == 0: + lane0_looped = True + st["stepIdx"] = 0 + await _send_lane(i, st, ctx) + else: + st["done"] = True + else: + st["stepIdx"] = int(st.get("stepIdx", 0)) + 1 + await _send_lane(i, st, ctx) + if lane0_looped: + # First beat of the next loop (was 0 here so single-step / first wrap never left 0). + ctx["sequence_loop_beat"] = 1 + else: + ctx["sequence_loop_beat"] = int(ctx.get("sequence_loop_beat", 0)) + 1 + if all(s.get("done") for s in lane_states): + stop() + + +def push_thread_beat() -> None: + try: + _thread_beat_queue.put_nowait(1) + except queue.Full: + pass + + +async def beat_consumer_loop() -> None: + while True: + n = 0 + try: + while True: + _thread_beat_queue.get_nowait() + n += 1 + except queue.Empty: + pass + if n: + from util.beat_driver_route import notify_beat_detected + + for _ in range(n): + try: + await process_active_beat_advance() + except Exception as e: + print(f"[sequence-playback] beat advance: {e}") + try: + notify_beat_detected() + except Exception as e: + print(f"[sequence-playback] notify_beat_detected: {e}") + else: + await asyncio.sleep(0.012) + + +def ensure_beat_consumer_started() -> None: + global _beat_consumer_started + with _beat_consumer_lock: + if _beat_consumer_started: + return + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + _beat_consumer_started = True + loop.create_task(beat_consumer_loop()) + + +_time_token = 0 + + +async def _time_loop(ctx: Dict[str, Any], token: int) -> None: + sequence_doc = ctx["sequence_doc"] + raw_dur = sequence_doc.get("step_duration_ms", 3000) + try: + duration = max(200, int(raw_dur)) + except (TypeError, ValueError): + duration = 3000 + raw_tr = sequence_doc.get("sequence_transition") + try: + tr_in = int(raw_tr) if raw_tr is not None else 0 + except (TypeError, ValueError): + tr_in = 0 + transition_ms = min(60000, max(0, tr_in)) + min_step = 200 + time_sleep_tr = min(transition_ms, max(0, duration - min_step)) + time_tick_lead = max(min_step, duration - time_sleep_tr) + + await _send_all_lanes(ctx) + my = token + while True: + await asyncio.sleep(time_tick_lead / 1000.0) + with _beat_run_lock: + cur = _time_token + if cur != my: + return + if time_sleep_tr > 0: + await asyncio.sleep(time_sleep_tr / 1000.0) + with _beat_run_lock: + cur = _time_token + if cur != my: + return + lane_states = ctx["lane_states"] + lanes = ctx["lanes"] + loop = bool(ctx.get("loop")) + lane0_looped = False + for i in range(ctx["num_lanes"]): + st = lane_states[i] + if st.get("done"): + continue + ln = len(lanes[i]) + if int(st.get("stepIdx", 0)) + 1 >= ln: + if loop: + if i == 0: + lane0_looped = True + st["stepIdx"] = 0 + else: + st["done"] = True + else: + st["stepIdx"] = int(st.get("stepIdx", 0)) + 1 + if lane0_looped: + ctx["sequence_loop_beat"] = 1 + else: + ctx["sequence_loop_beat"] = int(ctx.get("sequence_loop_beat", 0)) + 1 + if all(s.get("done") for s in lane_states): + stop() + return + await _send_all_lanes(ctx) + + +def stop() -> None: + global _beat_run, _time_task, _time_token + with _beat_run_lock: + _beat_run = None + _time_token += 1 + t = _time_task + _time_task = None + if t and not t.done(): + t.cancel() + + +def stop_if_playing_sequence(sequence_id: str) -> bool: + """If zone sequence playback is running this sequence id, stop it (e.g. after save/delete).""" + sid = str(sequence_id).strip() + if not sid: + return False + with _beat_run_lock: + ctx = _beat_run + if not ctx: + return False + cur = ctx.get("sequence_id") + if cur is None or str(cur).strip() != sid: + return False + stop() + return True + + +async def start(zone_id: str, sequence_id: str, profile_id: str) -> None: + global _beat_run, _time_task, _time_token + from models.preset import Preset + from models.profile import Profile + from models.sequence import Sequence + from models.zone import Zone + + stop() + seq_m = Sequence() + zone_m = Zone() + prof_m = Profile() + sequence_doc = seq_m.read(sequence_id) + zone_doc = zone_m.read(zone_id) + if not sequence_doc or str(sequence_doc.get("profile_id")) != str(profile_id): + raise ValueError("sequence not found") + if not zone_doc: + raise ValueError("zone not found") + prof = prof_m.read(profile_id) + if not prof: + raise ValueError("profile not found") + + presets_map: Dict[str, Any] = {} + pr = Preset() + for pid in pr.list(): + doc = pr.read(pid) + if isinstance(doc, dict) and str(doc.get("profile_id")) == str(profile_id): + presets_map[str(pid)] = doc + + ctx = _build_ctx(sequence_doc, zone_doc, presets_map, profile_id) + if not ctx: + raise ValueError("sequence has no steps") + + ctx["sequence_id"] = str(sequence_id) + ctx["zone_id"] = str(zone_id) + ctx["sequence_loop_beat"] = 0 + + await _deliver_sequence_presets_bulk(ctx) + + advance = ctx["advance_mode"] + if advance == "beats": + from util.beat_driver_route import update_beat_route + + update_beat_route({"enabled": False}) + with _beat_run_lock: + _beat_run = ctx + await _send_all_lanes(ctx) + else: + with _beat_run_lock: + _beat_run = ctx + _time_token += 1 + my = _time_token + + async def _run() -> None: + try: + await _time_loop(ctx, my) + except asyncio.CancelledError: + pass + except Exception as e: + print(f"[sequence-playback] time loop: {e}") + + loop = asyncio.get_running_loop() + _time_task = loop.create_task(_run()) + diff --git a/tests/models/test_sequence.py b/tests/models/test_sequence.py index 7bd9935..3ad9699 100644 --- a/tests/models/test_sequence.py +++ b/tests/models/test_sequence.py @@ -1,62 +1,80 @@ -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 - + print("\nTesting read 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") == "time" + 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", } 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["step_duration_ms"] == 5000 + assert updated["loop"] is True + print("\nTesting list sequences") sequence_list = sequences.list() print(f"Sequence list: {sequence_list}") assert sequence_id in sequence_list - + print("\nTesting delete sequence") deleted = sequences.delete(sequence_id) assert deleted is True assert sequence_id not in sequences - + print("\nTesting read after delete") sequence = sequences.read(sequence_id) assert sequence is None - + print("\nAll sequence tests passed!") -if __name__ == '__main__': +if __name__ == "__main__": test_sequence() diff --git a/tests/test_endpoints_pytest.py b/tests/test_endpoints_pytest.py index 4b5be47..442c724 100644 --- a/tests/test_endpoints_pytest.py +++ b/tests/test_endpoints_pytest.py @@ -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