5 Commits

Author SHA1 Message Date
f02eaa6bad chore(submodules): bump led-tool for Web Serial fixes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 00:23:21 +12:00
7015032f5c test: cover zone content kind lock and sequence groups
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 00:23:21 +12:00
d7a3fa96c5 feat(db): add Winter profile with 2x3 grid sequences
Winter profile, scoped groups, presets, and five multi-lane sequences;
include setup script for regeneration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 00:23:21 +12:00
7a7bedc07c fix(sequences): target only checked lane groups
Use zone group checkboxes in the editor; empty lane groups no longer
fall back to the whole zone. Remove cross-lane device splitting.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 00:23:21 +12:00
baec87068a feat(ui): lock zone type and start audio from BPM
Zone preset vs sequence is fixed at create; edit shows read-only type.
Header BPM button starts beat detection when the detector is stopped.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 00:23:15 +12:00
15 changed files with 632 additions and 130 deletions

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"1": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000", "#050500"], "2": [], "3": [], "4": [], "5": [], "6": [], "7": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"], "8": [], "9": [], "10": [], "11": [], "12": ["#890b0b", "#0b8935"], "13": []} {"1":["#FF0000","#00FF00","#0000FF","#FFFF00","#FF00FF","#00FFFF","#FFFFFF","#000000","#050500"],"2":[],"3":[],"4":[],"5":[],"6":[],"7":["#FF0000","#00FF00","#0000FF","#FFFF00","#FF00FF","#00FFFF","#FFFFFF","#000000"],"8":[],"9":[],"10":[],"11":[],"12":["#890b0b","#0b8935"],"13":[],"14":["#E8F4FF","#9ECFFF","#5080C8","#FFFFFF","#B0DCFF","#0A1520","#FF8020","#071018"]}

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"1": {"name": "default", "type": "zones", "zones": ["1", "9", "8", "10"], "scenes": [], "palette_id": "1"}, "2": {"name": "test", "type": "zones", "zones": ["6", "7"], "scenes": [], "palette_id": "12"}} {"1":{"name":"default","type":"zones","zones":["1","9","8","10"],"scenes":[],"palette_id":"1"},"2":{"name":"test","type":"zones","zones":["6","7"],"scenes":[],"palette_id":"12"},"3":{"name":"Winter","type":"zones","zones":["11","12"],"scenes":[],"palette_id":"14"}}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,419 @@
#!/usr/bin/env python3
"""Add Winter profile: 6-light 2x3 grid, presets, and sequences."""
from __future__ import annotations
import json
from copy import deepcopy
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DB = ROOT / "db"
PROFILE_ID = "3"
PALETTE_ID = "14"
ZONE_PRESETS_ID = "11"
ZONE_SEQUENCES_ID = "12"
# 2x3 grid device MACs (placeholders — assign real devices in the UI)
DEVICE_MACS = [
"a0b100000001", # r0c0 top-left
"a0b100000002", # r0c1
"a0b100000003", # r0c2
"a0b100000004", # r1c0 bottom-left
"a0b100000005", # r1c1
"a0b100000006", # r1c2
]
GROUP_CELL = {
"a0b100000001": "6",
"a0b100000002": "7",
"a0b100000003": "8",
"a0b100000004": "9",
"a0b100000005": "10",
"a0b100000006": "11",
}
GROUP_TOP_ROW = "12"
GROUP_BOTTOM_ROW = "13"
GROUP_COL_LEFT = "14"
GROUP_COL_MID = "15"
GROUP_COL_RIGHT = "16"
GROUP_ALL = "17"
PRESET_OFF = "78"
PRESET_TWINKLE = "79"
PRESET_ICICLES = "80"
PRESET_BLIZZARD = "81"
PRESET_RIME = "82"
PRESET_AURORA = "83"
PRESET_STARFALL = "84"
PRESET_SPARKLE = "85"
PRESET_COOL_WHITE = "86"
PRESET_CHASE_ICE = "87"
SEQ_CASCADE = "12"
SEQ_ROWS = "13"
SEQ_COLUMNS = "14"
SEQ_BLIZZARD_ALL = "15"
SEQ_ROTATION = "16"
def load_json(name: str) -> dict:
path = DB / f"{name}.json"
return json.loads(path.read_text(encoding="utf-8"))
def save_json(name: str, data: dict) -> None:
path = DB / f"{name}.json"
path.write_text(json.dumps(data, separators=(",", ":")), encoding="utf-8")
def preset_skeleton(name: str, pattern: str, colors: list, **extra) -> dict:
doc = {
"name": name,
"pattern": pattern,
"colors": colors,
"brightness": 220,
"delay": 80,
"auto": True,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": PROFILE_ID,
"background": "#0A1520",
"manual_beat_n": 1,
}
doc.update(extra)
if "palette_refs" not in doc and pattern not in ("on", "off"):
doc["palette_refs"] = [None] * len(colors)
return doc
def seq_doc(
name: str,
lanes: list,
lanes_group_ids: list,
*,
loop: bool = True,
simulated_bpm: int = 90,
) -> dict:
steps = [step for lane in lanes for step in lane]
return {
"name": name,
"profile_id": PROFILE_ID,
"group_ids": [GROUP_ALL],
"lanes": lanes,
"lanes_group_ids": lanes_group_ids,
"advance_mode": "beats",
"steps": steps,
"step_duration_ms": 3000,
"simulated_bpm": simulated_bpm,
"sequence_transition": 500,
"loop": loop,
}
def main() -> None:
profiles = load_json("profile")
palettes = load_json("palette")
groups = load_json("group")
devices = load_json("device")
zones = load_json("zone")
sequences = load_json("sequence")
presets = load_json("preset")
labels = [
("winter top-left", 0),
("winter top-centre", 1),
("winter top-right", 2),
("winter bottom-left", 3),
("winter bottom-centre", 4),
("winter bottom-right", 5),
]
profiles[PROFILE_ID] = {
"name": "Winter",
"type": "zones",
"zones": [ZONE_PRESETS_ID, ZONE_SEQUENCES_ID],
"scenes": [],
"palette_id": PALETTE_ID,
}
palettes[PALETTE_ID] = [
"#E8F4FF",
"#9ECFFF",
"#5080C8",
"#FFFFFF",
"#B0DCFF",
"#0A1520",
"#FF8020",
"#071018",
]
for mac, (label, _idx) in zip(DEVICE_MACS, labels):
devices[mac] = {
"id": mac,
"name": label,
"type": "led",
"transport": "wifi",
"address": "",
"default_pattern": None,
"zones": [],
"output_brightness": 255,
"wifi_color_order": "rgb",
"wifi_startup_mode": "default",
}
def group_row(gid: str, name: str, macs: list) -> None:
groups[gid] = {
"name": name,
"devices": macs,
"profile_id": PROFILE_ID,
"wifi_color_order": "rgb",
"wifi_startup_mode": "default",
"output_brightness": 255,
"pattern": "on",
"colors": ["000000", "E8F4FF"],
"brightness": 100,
"delay": 100,
"step_offset": 0,
"step_increment": 1,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
}
for mac, gid in zip(DEVICE_MACS, GROUP_CELL.values()):
group_row(gid, labels[DEVICE_MACS.index(mac)][0], [mac])
group_row(GROUP_TOP_ROW, "winter top row", DEVICE_MACS[:3])
group_row(GROUP_BOTTOM_ROW, "winter bottom row", DEVICE_MACS[3:])
group_row(GROUP_COL_LEFT, "winter left column", [DEVICE_MACS[0], DEVICE_MACS[3]])
group_row(GROUP_COL_MID, "winter centre column", [DEVICE_MACS[1], DEVICE_MACS[4]])
group_row(GROUP_COL_RIGHT, "winter right column", [DEVICE_MACS[2], DEVICE_MACS[5]])
group_row(GROUP_ALL, "winter grid (all)", list(DEVICE_MACS))
presets[PRESET_OFF] = preset_skeleton("winter off", "off", [], brightness=0, delay=100)
presets[PRESET_TWINKLE] = preset_skeleton(
"winter twinkle",
"twinkle",
["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"],
n1=150,
n2=20,
n4=10,
delay=100,
)
presets[PRESET_ICICLES] = preset_skeleton(
"winter icicles",
"icicles",
["#F0F8FF", "#9ECFFF", "#FFFFFF"],
n1=14,
n2=11,
n3=1,
delay=80,
)
presets[PRESET_BLIZZARD] = preset_skeleton(
"winter blizzard",
"blizzard",
["#FFFFFF", "#CDE8FF", "#AACCF5"],
n1=110,
n2=2,
n3=140,
delay=45,
)
presets[PRESET_RIME] = preset_skeleton(
"winter rime",
"rime",
["#E8F4FF", "#FFFFFF", "#B8DCF8"],
n1=40,
n2=18,
n3=4,
delay=120,
)
presets[PRESET_AURORA] = preset_skeleton(
"winter aurora",
"aurora",
["#183050", "#5090C8", "#C8E8FF"],
n1=22,
n2=210,
n6=1,
delay=90,
)
presets[PRESET_STARFALL] = preset_skeleton(
"winter starfall",
"particles",
["#FFFFFF", "#C8E8FF", "#FFF8E0"],
n1=16,
n2=2,
n3=12,
n6=1,
delay=55,
)
presets[PRESET_SPARKLE] = preset_skeleton(
"winter ice sparkle",
"sparkle",
["#E8F4FF", "#B0DCFF", "#FFFFFF"],
n1=70,
n2=165,
n3=1,
n6=1,
delay=50,
)
presets[PRESET_COOL_WHITE] = preset_skeleton(
"winter cool white",
"on",
["#E6F2FF"],
brightness=200,
delay=100,
)
presets[PRESET_CHASE_ICE] = preset_skeleton(
"winter ice chase",
"chase",
["#E8F4FF", "#5080C8"],
auto=False,
n1=20,
n2=20,
n3=15,
n4=15,
delay=120,
background="#071018",
)
grid_presets = [
[PRESET_ICICLES, PRESET_TWINKLE, PRESET_BLIZZARD],
[PRESET_RIME, PRESET_AURORA, PRESET_STARFALL],
]
flat = [p for row in grid_presets for p in row]
zones[ZONE_PRESETS_ID] = {
"name": "Winter grid",
"names": [],
"group_ids": [GROUP_ALL],
"preset_group_ids": {},
"presets": grid_presets,
"presets_flat": flat,
"default_preset": PRESET_TWINKLE,
"brightness": 200,
"sequence_ids": [],
"content_kind": "presets",
}
sequences[SEQ_CASCADE] = seq_doc(
"Winter cell cascade",
[
[{"preset_id": PRESET_ICICLES, "beats": 6}],
[{"preset_id": PRESET_SPARKLE, "beats": 6}],
[{"preset_id": PRESET_BLIZZARD, "beats": 6}],
[{"preset_id": PRESET_RIME, "beats": 6}],
[{"preset_id": PRESET_AURORA, "beats": 6}],
[{"preset_id": PRESET_STARFALL, "beats": 6}],
],
[
[GROUP_CELL[DEVICE_MACS[0]]],
[GROUP_CELL[DEVICE_MACS[1]]],
[GROUP_CELL[DEVICE_MACS[2]]],
[GROUP_CELL[DEVICE_MACS[3]]],
[GROUP_CELL[DEVICE_MACS[4]]],
[GROUP_CELL[DEVICE_MACS[5]]],
],
simulated_bpm=85,
)
sequences[SEQ_ROWS] = seq_doc(
"Winter row waves",
[
[
{"preset_id": PRESET_BLIZZARD, "beats": 8},
{"preset_id": PRESET_ICICLES, "beats": 8},
],
[
{"preset_id": PRESET_AURORA, "beats": 8},
{"preset_id": PRESET_RIME, "beats": 8},
],
],
[[GROUP_TOP_ROW], [GROUP_BOTTOM_ROW]],
simulated_bpm=80,
)
sequences[SEQ_COLUMNS] = seq_doc(
"Winter column chase",
[
[{"preset_id": PRESET_CHASE_ICE, "beats": 12}],
[{"preset_id": PRESET_TWINKLE, "beats": 12}],
[{"preset_id": PRESET_STARFALL, "beats": 12}],
],
[[GROUP_COL_LEFT], [GROUP_COL_MID], [GROUP_COL_RIGHT]],
simulated_bpm=95,
)
sequences[SEQ_BLIZZARD_ALL] = seq_doc(
"Winter full blizzard",
[[{"preset_id": PRESET_BLIZZARD, "beats": 16}]],
[[GROUP_ALL]],
simulated_bpm=75,
)
sequences[SEQ_ROTATION] = seq_doc(
"Winter showcase",
[
[
{"preset_id": PRESET_ICICLES, "beats": 8},
{"preset_id": PRESET_BLIZZARD, "beats": 8},
{"preset_id": PRESET_RIME, "beats": 8},
{"preset_id": PRESET_AURORA, "beats": 8},
{"preset_id": PRESET_STARFALL, "beats": 8},
{"preset_id": PRESET_TWINKLE, "beats": 8},
]
],
[[GROUP_ALL]],
simulated_bpm=72,
)
zones[ZONE_SEQUENCES_ID] = {
"name": "Winter sequences",
"names": [],
"group_ids": [GROUP_ALL],
"preset_group_ids": {},
"presets": [],
"presets_flat": [],
"default_preset": None,
"brightness": 200,
"sequence_ids": [
SEQ_CASCADE,
SEQ_ROWS,
SEQ_COLUMNS,
SEQ_BLIZZARD_ALL,
SEQ_ROTATION,
],
"content_kind": "sequences",
}
save_json("profile", profiles)
save_json("palette", palettes)
save_json("group", groups)
save_json("device", devices)
save_json("zone", zones)
save_json("sequence", sequences)
save_json("preset", presets)
print("Winter profile created:")
print(f" profile {PROFILE_ID}, palette {PALETTE_ID}")
print(f" zones {ZONE_PRESETS_ID} (presets 2x3), {ZONE_SEQUENCES_ID} (sequences)")
print(f" devices {', '.join(DEVICE_MACS)}")
print(f" groups {GROUP_CELL} + rows/cols/all")
print(f" presets {PRESET_OFF}-{PRESET_CHASE_ICE}")
print(f" sequences {SEQ_CASCADE}-{SEQ_ROTATION}")
if __name__ == "__main__":
main()

View File

@@ -134,7 +134,11 @@ class Zone(Model):
id_str = str(id) id_str = str(id)
if id_str not in self: if id_str not in self:
return False return False
patch = data if isinstance(data, dict) else {} patch = dict(data) if isinstance(data, dict) else {}
doc = self[id_str]
locked_kind = self._normalized_content_kind(doc) or self._infer_content_kind(doc)
if "content_kind" in patch:
patch["content_kind"] = locked_kind
self[id_str].update(patch) self[id_str].update(patch)
if "content_kind" in patch: if "content_kind" in patch:
self._enforce_content_kind_invariants(self[id_str]) self._enforce_content_kind_invariants(self[id_str])

View File

@@ -1,5 +1,6 @@
(() => { (() => {
let pollTimer = null; let pollTimer = null;
let audioDetectorRunning = false;
let lastBeatSeq = 0; let lastBeatSeq = 0;
let lastLoggedSequenceBeatFractions = ""; let lastLoggedSequenceBeatFractions = "";
/** Prior poll had server zone sequence playback active (`status.sequence.active === true`). */ /** Prior poll had server zone sequence playback active (`status.sequence.active === true`). */
@@ -161,13 +162,37 @@
function updateSequenceSyncControls(zoneSeqActive) { function updateSequenceSyncControls(zoneSeqActive) {
const topSync = el("audio-top-beat-sync"); const topSync = el("audio-top-beat-sync");
if (topSync) topSync.disabled = !zoneSeqActive; if (topSync) {
topSync.disabled = audioDetectorRunning && !zoneSeqActive;
topSync.title = !audioDetectorRunning
? "Start beat detection"
: zoneSeqActive
? "Sync step to music (S)"
: "Beat detection running";
}
const modalBeat = el("audio-modal-beat-readout"); const modalBeat = el("audio-modal-beat-readout");
if (modalBeat) modalBeat.disabled = !zoneSeqActive; if (modalBeat) modalBeat.disabled = !zoneSeqActive;
const passBtn = el("audio-sync-pass-btn"); const passBtn = el("audio-sync-pass-btn");
if (passBtn) passBtn.disabled = !zoneSeqActive; if (passBtn) passBtn.disabled = !zoneSeqActive;
} }
async function handleTopBpmButtonClick() {
if (!audioDetectorRunning) {
try {
await startAudio();
} catch (e) {
console.error("audio start failed", e);
alert("Failed to start audio input. Check mic permissions.");
}
return;
}
try {
await syncSequenceBeatPhase("step");
} catch (e) {
console.warn("sequence beat sync failed", e);
}
}
async function syncSequenceBeatPhase(mode) { async function syncSequenceBeatPhase(mode) {
const res = await fetch("/sequences/sync-phase", { const res = await fetch("/sequences/sync-phase", {
method: "POST", method: "POST",
@@ -250,6 +275,7 @@
/** Stop detector and polling; does not clear “resume on load” prefs (used before restart). */ /** Stop detector and polling; does not clear “resume on load” prefs (used before restart). */
async function stopAudioOnly() { async function stopAudioOnly() {
audioDetectorRunning = false;
setTopBpmVisible(false); setTopBpmVisible(false);
setNavResetVisible(false); setNavResetVisible(false);
clearBeatPhaseTimers(); clearBeatPhaseTimers();
@@ -285,6 +311,7 @@
node.textContent = String(status.error).trim().slice(0, 120); node.textContent = String(status.error).trim().slice(0, 120);
} }
updateBeatReadoutDisplays({}); updateBeatReadoutDisplays({});
audioDetectorRunning = !!status.running;
updateBpmDisplay(null); updateBpmDisplay(null);
setTopBpmVisible(!!status.running); setTopBpmVisible(!!status.running);
setNavResetVisible(!!status.running); setNavResetVisible(!!status.running);
@@ -294,6 +321,7 @@
} }
return; return;
} }
audioDetectorRunning = !!status.running;
const zoneSeqActive = sequencePlaybackActiveFromStatus(status); const zoneSeqActive = sequencePlaybackActiveFromStatus(status);
setTopBpmVisible(!!status.running || zoneSeqActive); setTopBpmVisible(!!status.running || zoneSeqActive);
setNavResetVisible(!!status.running); setNavResetVisible(!!status.running);
@@ -482,7 +510,12 @@
} }
}); });
}; };
bindSync(el("audio-top-beat-sync"), "step"); const topBpm = el("audio-top-beat-sync");
if (topBpm) {
topBpm.addEventListener("click", () => {
void handleTopBpmButtonClick();
});
}
bindSync(el("audio-modal-beat-readout"), "step"); bindSync(el("audio-modal-beat-readout"), "step");
bindSync(el("audio-sync-pass-btn"), "pass"); bindSync(el("audio-sync-pass-btn"), "pass");
@@ -501,11 +534,14 @@
const res = await fetch("/api/audio/status", { cache: "no-store" }); const res = await fetch("/api/audio/status", { cache: "no-store" });
const data = await res.json(); const data = await res.json();
const status = data?.status || {}; const status = data?.status || {};
audioDetectorRunning = !!status.running;
if (status.running && !pollTimer) { if (status.running && !pollTimer) {
pollTimer = setInterval(pollStatus, 250); pollTimer = setInterval(pollStatus, 250);
lastBeatSeq = Number(status.beat_seq || 0); lastBeatSeq = Number(status.beat_seq || 0);
prevZoneSequencePlaybackActive = sequencePlaybackActiveFromStatus(status); prevZoneSequencePlaybackActive = sequencePlaybackActiveFromStatus(status);
await pollStatus(); await pollStatus();
} else {
updateSequenceSyncControls(sequencePlaybackActiveFromStatus(status));
} }
} catch (e) { } catch (e) {
console.warn("audio resume poll check failed", e); console.warn("audio resume poll check failed", e);

View File

@@ -216,6 +216,12 @@ function logSequenceSelectionPresets(sequenceId, sequenceDoc, presetsMap) {
}); });
} }
function zoneGroupIdsFromDoc(zoneDoc) {
return Array.isArray(zoneDoc && zoneDoc.group_ids)
? zoneDoc.group_ids.map((x) => String(x).trim()).filter(Boolean)
: [];
}
function groupIdsForLaneStep(sequenceDoc, step, laneIndex, numLanes) { function groupIdsForLaneStep(sequenceDoc, step, laneIndex, numLanes) {
const lgs = Array.isArray(sequenceDoc.lanes_group_ids) ? sequenceDoc.lanes_group_ids : []; const lgs = Array.isArray(sequenceDoc.lanes_group_ids) ? sequenceDoc.lanes_group_ids : [];
if (laneIndex < lgs.length) { if (laneIndex < lgs.length) {
@@ -239,7 +245,6 @@ function groupIdsForLaneStep(sequenceDoc, step, laneIndex, numLanes) {
function buildLaneGroupIdsForEditor(doc, laneIndex, numLanes) { function buildLaneGroupIdsForEditor(doc, laneIndex, numLanes) {
const raw = Array.isArray(doc && doc.lanes_group_ids) ? doc.lanes_group_ids : []; 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) { if (laneIndex < raw.length) {
const row = raw[laneIndex]; const row = raw[laneIndex];
if (Array.isArray(row)) { if (Array.isArray(row)) {
@@ -249,17 +254,10 @@ function buildLaneGroupIdsForEditor(doc, laneIndex, numLanes) {
if (numLanes > 1 && laneIndex >= raw.length) { if (numLanes > 1 && laneIndex >= raw.length) {
return []; return [];
} }
if (numLanes === 1) { return [];
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) { function renderLaneGroupCheckboxes(groupsMap, selectedIds, zoneGroupIds) {
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.className = 'sequence-lane-groups-wrap'; wrap.className = 'sequence-lane-groups-wrap';
wrap.style.cssText = 'margin-bottom:0.6rem;'; wrap.style.cssText = 'margin-bottom:0.6rem;';
@@ -267,15 +265,17 @@ function renderLaneGroupCheckboxes(groupsMap, selectedIds) {
hint.className = 'muted-text'; hint.className = 'muted-text';
hint.style.fontSize = '0.85em'; hint.style.fontSize = '0.85em';
hint.style.marginBottom = '0.35rem'; hint.style.marginBottom = '0.35rem';
hint.textContent = 'Groups for this lane (none = whole zone)'; hint.textContent = 'Only checked groups are used on this lane';
wrap.appendChild(hint); wrap.appendChild(hint);
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'sequence-lane-groups'; row.className = 'sequence-lane-groups';
row.style.cssText = 'display:flex;flex-wrap:wrap;gap:0.5rem;align-items:center;'; row.style.cssText = 'display:flex;flex-wrap:wrap;gap:0.5rem;align-items:center;';
const sel = new Set((selectedIds || []).map((x) => String(x))); const sel = new Set((selectedIds || []).map((x) => String(x)));
Object.keys(groupsMap) const zg = Array.isArray(zoneGroupIds) ? zoneGroupIds.map(String).filter(Boolean) : [];
.sort((a, b) => a.localeCompare(b)) const gidsToShow = zg.length
.forEach((gid) => { ? zg
: Object.keys(groupsMap).sort((a, b) => a.localeCompare(b));
gidsToShow.forEach((gid) => {
const g = groupsMap[gid]; const g = groupsMap[gid];
const gn = g && g.name ? String(g.name) : gid; const gn = g && g.name ? String(g.name) : gid;
const id = `seq-lg-${gid}-${Math.random().toString(36).slice(2)}`; const id = `seq-lg-${gid}-${Math.random().toString(36).slice(2)}`;
@@ -333,13 +333,6 @@ function presetsSectionElForZone(zoneId) {
/** Match preset tiles: prefer DOM device list, then zone JSON (same as parseTabDeviceNames + computeZoneTargets). */ /** Match preset tiles: prefer DOM device list, then zone JSON (same as parseTabDeviceNames + computeZoneTargets). */
async function resolveSequenceSendDeviceNames(zoneId, zoneDoc, groupIds) { async function resolveSequenceSendDeviceNames(zoneId, zoneDoc, groupIds) {
const gids = Array.isArray(groupIds) ? groupIds.map((x) => String(x).trim()).filter((x) => x.length > 0) : []; 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) { if (window.zonesManager && typeof window.zonesManager.resolveSequenceStepDeviceNames === 'function' && zoneDoc) {
return await window.zonesManager.resolveSequenceStepDeviceNames(zoneDoc, gids); return await window.zonesManager.resolveSequenceStepDeviceNames(zoneDoc, gids);
} }
@@ -407,33 +400,12 @@ function createSequenceTileRow(sequenceId, sequenceDoc, zoneId, zoneDoc, allPres
button.className = 'pattern-button preset-tile-main sequence-tile-main'; button.className = 'pattern-button preset-tile-main sequence-tile-main';
button.title = sequenceDoc.name || `Sequence ${sequenceId}`; 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'); const label = document.createElement('span');
label.textContent = sequenceDoc.name || sequenceId; label.textContent = sequenceDoc.name || sequenceId;
label.style.fontWeight = 'bold'; label.style.fontWeight = 'bold';
label.className = 'pattern-button-label'; label.className = 'pattern-button-label';
button.appendChild(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 simRaw = sequenceDoc.simulated_bpm;
let sim = parseInt(String(simRaw != null ? simRaw : 120), 10);
if (!Number.isFinite(sim)) sim = 120;
sim = Math.min(300, Math.max(30, sim));
sub.textContent = `${nLanes} lane${nLanes === 1 ? '' : 's'} · ${nSteps} step${nSteps === 1 ? '' : 's'} · beats · ${sim} BPM sim`;
button.appendChild(sub);
button.addEventListener('click', () => { button.addEventListener('click', () => {
const strip = document.getElementById('presets-list-zone'); const strip = document.getElementById('presets-list-zone');
const clearActiveStrip = () => { const clearActiveStrip = () => {
@@ -540,7 +512,7 @@ async function addSequenceToTab(sequenceId, zoneId) {
const tabData = await tabResponse.json(); const tabData = await tabResponse.json();
if ( if (
typeof window.zoneAllowsSequences === 'function' && typeof window.zoneAllowsSequences === 'function' &&
!window.zoneAllowsSequences(tabData) !window.zoneAllowsSequences(tabData, zoneId)
) { ) {
alert('This zone is for presets only. Add presets from the zone Edit menu instead.'); alert('This zone is for presets only. Add presets from the zone Edit menu instead.');
return; return;
@@ -609,7 +581,7 @@ async function refreshEditTabSequencesUi(zoneId) {
const zone = await zoneRes.json(); const zone = await zoneRes.json();
if ( if (
typeof window.zoneAllowsSequences === 'function' && typeof window.zoneAllowsSequences === 'function' &&
!window.zoneAllowsSequences(zone) !window.zoneAllowsSequences(zone, zoneId)
) { ) {
currentEl.innerHTML = currentEl.innerHTML =
'<span class="muted-text">This zone is for presets only. Sequences are hidden.</span>'; '<span class="muted-text">This zone is for presets only. Sequences are hidden.</span>';
@@ -865,7 +837,7 @@ function renderSequenceStepRow(presetsMap, step) {
return row; return row;
} }
function renderSequenceLane(laneIndex, laneSteps, laneGroupIds, presetsMap, groupsMap) { function renderSequenceLane(laneIndex, laneSteps, laneGroupIds, presetsMap, groupsMap, zoneGroupIds) {
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.className = 'sequence-lane'; wrap.className = 'sequence-lane';
wrap.dataset.laneIndex = String(laneIndex); wrap.dataset.laneIndex = String(laneIndex);
@@ -904,7 +876,7 @@ function renderSequenceLane(laneIndex, laneSteps, laneGroupIds, presetsMap, grou
head.appendChild(headBtns); head.appendChild(headBtns);
wrap.appendChild(head); wrap.appendChild(head);
wrap.appendChild(renderLaneGroupCheckboxes(groupsMap, laneGroupIds)); wrap.appendChild(renderLaneGroupCheckboxes(groupsMap, laneGroupIds, zoneGroupIds));
const stepsHost = document.createElement('div'); const stepsHost = document.createElement('div');
stepsHost.className = 'sequence-lane-steps'; stepsHost.className = 'sequence-lane-steps';
@@ -977,6 +949,24 @@ async function openSequenceEditor(sequenceId, existing) {
const presetsMap = presetsRes.ok ? await presetsRes.json() : {}; const presetsMap = presetsRes.ok ? await presetsRes.json() : {};
const groupsMap = await fetchGroupsMapSeq(); const groupsMap = await fetchGroupsMapSeq();
let zoneDoc = {};
const zoneIdForEditor = resolveZoneIdForPresetStripRefresh();
if (zoneIdForEditor) {
try {
const zr = await fetch(`/zones/${encodeURIComponent(zoneIdForEditor)}`, {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
if (zr.ok) {
const zj = await zr.json();
if (zj && typeof zj === 'object' && !zj.error) zoneDoc = zj;
}
} catch (_) {
/* no zone context */
}
}
const zoneGroupIds = zoneGroupIdsFromDoc(zoneDoc);
let doc = existing; let doc = existing;
if (sequenceEditorId) { if (sequenceEditorId) {
try { try {
@@ -1010,11 +1000,11 @@ async function openSequenceEditor(sequenceId, existing) {
lanesHost.innerHTML = ''; lanesHost.innerHTML = '';
if (!lanes.some((l) => l.length > 0)) { if (!lanes.some((l) => l.length > 0)) {
const lg0 = buildLaneGroupIdsForEditor(doc, 0, 1); const lg0 = buildLaneGroupIdsForEditor(doc, 0, 1);
lanesHost.appendChild(renderSequenceLane(0, [], lg0, presetsMap, groupsMap)); lanesHost.appendChild(renderSequenceLane(0, [], lg0, presetsMap, groupsMap, zoneGroupIds));
} else { } else {
lanes.forEach((laneSteps, i) => { lanes.forEach((laneSteps, i) => {
const lg = buildLaneGroupIdsForEditor(doc, i, lanes.length); const lg = buildLaneGroupIdsForEditor(doc, i, lanes.length);
lanesHost.appendChild(renderSequenceLane(i, laneSteps, lg, presetsMap, groupsMap)); lanesHost.appendChild(renderSequenceLane(i, laneSteps, lg, presetsMap, groupsMap, zoneGroupIds));
}); });
} }
refreshSequenceEditorLaneTitles(); refreshSequenceEditorLaneTitles();

View File

@@ -310,8 +310,7 @@ async function computeZonePresetUnionTargets(zoneDoc) {
} }
/** /**
* Device names for one sequence step. Empty stepGroupIds => all zone tab devices (``names`` only). * Device names for one sequence step. Only devices in checked lane groups (within the zone tab).
* Otherwise: lane groups intersected with that tab device list (not zone ``group_ids``).
*/ */
async function resolveSequenceStepDeviceNames(zone, stepGroupIds) { async function resolveSequenceStepDeviceNames(zone, stepGroupIds) {
const zoneT = await computeZoneNamesTargets(zone); const zoneT = await computeZoneNamesTargets(zone);
@@ -321,7 +320,7 @@ async function resolveSequenceStepDeviceNames(zone, stepGroupIds) {
? stepGroupIds.map((x) => String(x).trim()).filter((x) => x.length > 0) ? stepGroupIds.map((x) => String(x).trim()).filter((x) => x.length > 0)
: []; : [];
if (!gids.length) { if (!gids.length) {
return names.slice(); return [];
} }
const zoneMacSet = new Set( const zoneMacSet = new Set(
macs.map((m) => normalizeDeviceMac(m)).filter((m) => m.length === 12), macs.map((m) => normalizeDeviceMac(m)).filter((m) => m.length === 12),
@@ -509,43 +508,15 @@ function effectiveZoneContentKind(zoneDoc) {
return 'presets'; return 'presets';
} }
/** @returns {'presets' | 'sequences'} */
function editModalContentKindSelected() {
const radio = document.querySelector('input[name="edit-zone-content-kind"]:checked');
return radio && radio.value === 'sequences' ? 'sequences' : 'presets';
}
function activeZoneContentKind(zoneDoc, zoneId) {
const modal = document.getElementById('edit-zone-modal');
const editingId = document.getElementById('edit-zone-id')?.value;
if (
modal &&
modal.classList.contains('active') &&
zoneId != null &&
zoneId !== '' &&
String(editingId) === String(zoneId)
) {
return editModalContentKindSelected();
}
return effectiveZoneContentKind(zoneDoc);
}
/** True when the zone row has an explicit presets vs sequences type (not legacy inferred). */
function zoneHasExplicitContentKind(zoneDoc) {
return normalizeZoneContentKind(zoneDoc) !== null;
}
/** @returns {boolean} */ /** @returns {boolean} */
function zoneAllowsPresets(zoneDoc, zoneId) { function zoneAllowsPresets(zoneDoc, zoneId) {
void zoneId; void zoneId;
if (!zoneHasExplicitContentKind(zoneDoc)) return true;
return effectiveZoneContentKind(zoneDoc) === 'presets'; return effectiveZoneContentKind(zoneDoc) === 'presets';
} }
/** @returns {boolean} */ /** @returns {boolean} */
function zoneAllowsSequences(zoneDoc, zoneId) { function zoneAllowsSequences(zoneDoc, zoneId) {
void zoneId; void zoneId;
if (!zoneHasExplicitContentKind(zoneDoc)) return true;
return effectiveZoneContentKind(zoneDoc) === 'sequences'; return effectiveZoneContentKind(zoneDoc) === 'sequences';
} }
@@ -623,7 +594,7 @@ function renderZonesList(tabs, tabOrder, currentZoneId) {
for (const zoneId of tabOrder) { for (const zoneId of tabOrder) {
const zone = tabs[zoneId]; const zone = tabs[zoneId];
if (zone) { if (zone) {
const activeClass = zoneId === currentZoneId ? 'active' : ''; const activeClass = String(zoneId) === String(currentZoneId) ? 'active' : '';
const disp = zone.name || `Zone ${zoneId}`; const disp = zone.name || `Zone ${zoneId}`;
html += ` html += `
<button class="zone-button ${activeClass}" <button class="zone-button ${activeClass}"
@@ -1183,9 +1154,13 @@ async function openEditZoneModal(zoneId, zone) {
renderZoneGroupsEditor(groupsEditor, window.__editTabGroupRows, groupsMap); renderZoneGroupsEditor(groupsEditor, window.__editTabGroupRows, groupsMap);
const kind = effectiveZoneContentKind(tabData); const kind = effectiveZoneContentKind(tabData);
document.querySelectorAll('input[name="edit-zone-content-kind"]').forEach((radio) => { const typeLabel = document.getElementById('edit-zone-type-label');
radio.checked = radio.value === kind; if (typeLabel) {
}); typeLabel.textContent =
kind === 'sequences'
? 'Zone type: Sequences (set when the zone was created)'
: 'Zone type: Presets (set when the zone was created)';
}
if (modal) modal.classList.add("active"); if (modal) modal.classList.add("active");
applyZoneContentKindEditModal(kind); applyZoneContentKindEditModal(kind);
@@ -1196,13 +1171,11 @@ async function openEditZoneModal(zoneId, zone) {
} }
// Update an existing zone (name, group list; devices come from groups only). // Update an existing zone (name, group list; devices come from groups only).
async function updateZone(zoneId, name, groupRows, contentKind) { async function updateZone(zoneId, name, groupRows) {
try { try {
const gids = Array.isArray(groupRows) const gids = Array.isArray(groupRows)
? groupRows.map((r) => String(r.id || "").trim()).filter((x) => x.length > 0) ? groupRows.map((r) => String(r.id || "").trim()).filter((x) => x.length > 0)
: []; : [];
const ck =
contentKind === 'sequences' || contentKind === 'presets' ? contentKind : 'presets';
let existing = {}; let existing = {};
try { try {
const cur = await fetch(`/zones/${encodeURIComponent(zoneId)}`, { const cur = await fetch(`/zones/${encodeURIComponent(zoneId)}`, {
@@ -1215,6 +1188,7 @@ async function updateZone(zoneId, name, groupRows, contentKind) {
} catch (_) { } catch (_) {
/* use empty existing */ /* use empty existing */
} }
const lockedKind = effectiveZoneContentKind(existing);
const response = await fetch(`/zones/${zoneId}`, { const response = await fetch(`/zones/${zoneId}`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
@@ -1229,7 +1203,7 @@ async function updateZone(zoneId, name, groupRows, contentKind) {
existing.preset_group_ids && typeof existing.preset_group_ids === 'object' existing.preset_group_ids && typeof existing.preset_group_ids === 'object'
? existing.preset_group_ids ? existing.preset_group_ids
: {}, : {},
content_kind: ck, content_kind: lockedKind,
}) })
}); });
@@ -1238,6 +1212,9 @@ async function updateZone(zoneId, name, groupRows, contentKind) {
// Reload tabs list // Reload tabs list
await loadZonesModal(); await loadZonesModal();
await loadZones(); await loadZones();
if (String(currentZoneId) === String(zoneId)) {
await loadZoneContent(zoneId);
}
// Close modal // Close modal
document.getElementById('edit-zone-modal').classList.remove('active'); document.getElementById('edit-zone-modal').classList.remove('active');
return true; return true;
@@ -1369,18 +1346,6 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
} }
document.querySelectorAll('input[name="edit-zone-content-kind"]').forEach((radio) => {
radio.addEventListener('change', async () => {
applyZoneContentKindEditModal(editModalContentKindSelected());
const zoneId = document.getElementById('edit-zone-id')?.value;
if (!zoneId) return;
await refreshEditTabPresetsUi(zoneId);
if (typeof window.refreshEditTabSequencesUi === 'function') {
await window.refreshEditTabSequencesUi(zoneId);
}
});
});
// Set up edit zone form // Set up edit zone form
const editZoneForm = document.getElementById('edit-zone-form'); const editZoneForm = document.getElementById('edit-zone-form');
if (editZoneForm) { if (editZoneForm) {
@@ -1394,7 +1359,7 @@ document.addEventListener('DOMContentLoaded', () => {
const groupRows = window.__editTabGroupRows || []; const groupRows = window.__editTabGroupRows || [];
if (zoneId && name) { if (zoneId && name) {
await updateZone(zoneId, name, groupRows, editModalContentKindSelected()); await updateZone(zoneId, name, groupRows);
editZoneForm.reset(); editZoneForm.reset();
} }
}); });
@@ -1441,6 +1406,8 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
}); });
window.selectZone = selectZone;
// Export for use in other scripts // Export for use in other scripts
window.zonesManager = { window.zonesManager = {
loadZones, loadZones,

View File

@@ -119,10 +119,7 @@
<input type="hidden" id="edit-zone-id"> <input type="hidden" id="edit-zone-id">
<label>Zone Name:</label> <label>Zone Name:</label>
<input type="text" id="edit-zone-name" placeholder="Enter zone name" required> <input type="text" id="edit-zone-name" placeholder="Enter zone name" required>
<div class="zone-content-kind-row muted-text"> <p id="edit-zone-type-label" class="zone-content-kind-row muted-text" aria-live="polite"></p>
<label><input type="radio" name="edit-zone-content-kind" value="presets" checked> Presets</label>
<label><input type="radio" name="edit-zone-content-kind" value="sequences"> Sequences</label>
</div>
<div id="edit-zone-block-groups"> <div id="edit-zone-block-groups">
<label class="zone-devices-label">Device groups on this zone</label> <label class="zone-devices-label">Device groups on this zone</label>
<div id="edit-zone-groups-editor" class="zone-devices-editor"></div> <div id="edit-zone-groups-editor" class="zone-devices-editor"></div>

View File

@@ -78,8 +78,13 @@ def _normalize_sequence_lanes(doc: Dict[str, Any]) -> List[List[Dict[str, Any]]]
def _group_ids_for_lane_step( def _group_ids_for_lane_step(
sequence_doc: Dict[str, Any], step: Dict[str, Any], lane_index: int, num_lanes: int sequence_doc: Dict[str, Any],
step: Dict[str, Any],
lane_index: int,
num_lanes: int,
zone_doc: Optional[Dict[str, Any]] = None,
) -> List[str]: ) -> List[str]:
_ = zone_doc
lgs = sequence_doc.get("lanes_group_ids") lgs = sequence_doc.get("lanes_group_ids")
if isinstance(lgs, list) and lane_index < len(lgs): if isinstance(lgs, list) and lane_index < len(lgs):
for_lane = lgs[lane_index] for_lane = lgs[lane_index]
@@ -234,7 +239,7 @@ def _resolve_step_device_names(
) -> List[str]: ) -> List[str]:
z_names, z_macs = _compute_zone_targets(zone_doc, devices, groups) z_names, z_macs = _compute_zone_targets(zone_doc, devices, groups)
if not step_group_ids: if not step_group_ids:
return list(z_names) return []
zone_mac_set = {m for m in (_norm_mac(x) for x in z_macs) if m} zone_mac_set = {m for m in (_norm_mac(x) for x in z_macs) if m}
zone_name_by_mac: Dict[str, str] = {} zone_name_by_mac: Dict[str, str] = {}
for i, m in enumerate(z_macs): for i, m in enumerate(z_macs):
@@ -273,12 +278,29 @@ def _lane_has_non_empty_lanes_group_ids(sequence_doc: Dict[str, Any], lane_index
return any(x is not None and str(x).strip() for x in for_lane) return any(x is not None and str(x).strip() for x in for_lane)
def _partition_devices_for_lane(
num_lanes: int,
*,
lane_has_own_groups: bool,
step_group_ids: List[str],
) -> bool:
"""Split zone devices across lanes only when lanes lack explicit group targeting."""
if num_lanes <= 1:
return False
if lane_has_own_groups:
return False
# No lane groups (whole zone): every lane uses all zone / zone-group devices.
if not step_group_ids:
return False
return False
def _split_device_names_for_lane( def _split_device_names_for_lane(
all_names: List[str], all_names: List[str],
lane_index: int, lane_index: int,
num_lanes: int, num_lanes: int,
*, *,
partition_shared_zone: bool = True, partition_shared_zone: bool = False,
) -> List[str]: ) -> List[str]:
names = [n for n in all_names if n and str(n).strip()] names = [n for n in all_names if n and str(n).strip()]
if num_lanes <= 1 or not partition_shared_zone: if num_lanes <= 1 or not partition_shared_zone:
@@ -368,15 +390,20 @@ def _resolve_lane_device_names(lane_index: int, ctx: Dict[str, Any]) -> List[str
lane = lanes[lane_index] if 0 <= lane_index < len(lanes) else [] lane = lanes[lane_index] if 0 <= lane_index < len(lanes) else []
if not lane: if not lane:
return [] return []
gids = _group_ids_for_lane_step(sequence_doc, lane[0], lane_index, num_lanes) gids = _group_ids_for_lane_step(
sequence_doc, lane[0], lane_index, num_lanes, zone_doc=zone_doc
)
device_names = _resolve_step_device_names( device_names = _resolve_step_device_names(
zone_doc, gids, devices, groups, sequence_doc=sequence_doc zone_doc, gids, devices, groups, sequence_doc=sequence_doc
) )
lane_own = _lane_has_non_empty_lanes_group_ids(sequence_doc, lane_index)
return _split_device_names_for_lane( return _split_device_names_for_lane(
device_names, device_names,
lane_index, lane_index,
num_lanes, num_lanes,
partition_shared_zone=not _lane_has_non_empty_lanes_group_ids(sequence_doc, lane_index), partition_shared_zone=_partition_devices_for_lane(
num_lanes, lane_has_own_groups=lane_own, step_group_ids=gids
),
) )
@@ -545,16 +572,19 @@ def _union_macs_for_sequence(ctx: Dict[str, Any]) -> List[str]:
for step in lane: for step in lane:
if not isinstance(step, dict): if not isinstance(step, dict):
continue continue
gids = _group_ids_for_lane_step(sequence_doc, step, lane_index, num_lanes) gids = _group_ids_for_lane_step(
sequence_doc, step, lane_index, num_lanes, zone_doc=zone_doc
)
device_names = _resolve_step_device_names( device_names = _resolve_step_device_names(
zone_doc, gids, devices, groups, sequence_doc=sequence_doc zone_doc, gids, devices, groups, sequence_doc=sequence_doc
) )
lane_own = _lane_has_non_empty_lanes_group_ids(sequence_doc, lane_index)
device_names = _split_device_names_for_lane( device_names = _split_device_names_for_lane(
device_names, device_names,
lane_index, lane_index,
num_lanes, num_lanes,
partition_shared_zone=not _lane_has_non_empty_lanes_group_ids( partition_shared_zone=_partition_devices_for_lane(
sequence_doc, lane_index num_lanes, lane_has_own_groups=lane_own, step_group_ids=gids
), ),
) )
if gids and not device_names: if gids and not device_names:
@@ -563,10 +593,7 @@ def _union_macs_for_sequence(ctx: Dict[str, Any]) -> List[str]:
if m and m not in seen: if m and m not in seen:
seen.add(m) seen.add(m)
out.append(m) out.append(m)
if out:
return out return out
_, z_macs = _compute_zone_targets(zone_doc, devices, groups)
return list(z_macs)
def _coerce_loop(sequence_doc: Dict[str, Any]) -> bool: def _coerce_loop(sequence_doc: Dict[str, Any]) -> bool:
@@ -641,15 +668,22 @@ async def _send_lane(
display_preset = _display_preset_for_step(preset_id, presets_map, palette_colors) display_preset = _display_preset_for_step(preset_id, presets_map, palette_colors)
if not display_preset: if not display_preset:
return return
gids = _group_ids_for_lane_step(sequence_doc, step, lane_index, int(ctx["num_lanes"])) num_lanes = int(ctx["num_lanes"])
device_names = _resolve_step_device_names( zone_doc = ctx["zone_doc"]
ctx["zone_doc"], gids, devices, ctx["groups"], sequence_doc=sequence_doc gids = _group_ids_for_lane_step(
sequence_doc, step, lane_index, num_lanes, zone_doc=zone_doc
) )
device_names = _resolve_step_device_names(
zone_doc, gids, devices, ctx["groups"], sequence_doc=sequence_doc
)
lane_own = _lane_has_non_empty_lanes_group_ids(sequence_doc, lane_index)
device_names = _split_device_names_for_lane( device_names = _split_device_names_for_lane(
device_names, device_names,
lane_index, lane_index,
int(ctx["num_lanes"]), num_lanes,
partition_shared_zone=not _lane_has_non_empty_lanes_group_ids(sequence_doc, lane_index), partition_shared_zone=_partition_devices_for_lane(
num_lanes, lane_has_own_groups=lane_own, step_group_ids=gids
),
) )
if gids and not device_names: if gids and not device_names:
return return

View File

@@ -0,0 +1,28 @@
"""Sequence playback targets only explicitly checked lane groups."""
from util.sequence_playback import (
_group_ids_for_lane_step,
_partition_devices_for_lane,
_resolve_step_device_names,
_split_device_names_for_lane,
)
def test_empty_lane_groups_do_not_default_to_zone():
zone = {"group_ids": ["g1", "g2"]}
seq = {"lanes": [[{"preset_id": "1", "beats": 1}]], "lanes_group_ids": [[]]}
gids = _group_ids_for_lane_step(seq, seq["lanes"][0][0], 0, 1, zone_doc=zone)
assert gids == []
def test_resolve_step_with_no_groups_returns_empty():
zone = {"group_ids": ["g1"], "names": ["dev-a"]}
names = _resolve_step_device_names(zone, [], None, None)
assert names == []
def test_whole_zone_not_partitioned_across_lanes():
names = ["dev-a", "dev-b", "dev-c"]
assert _split_device_names_for_lane(names, 0, 2, partition_shared_zone=False) == names
assert _split_device_names_for_lane(names, 1, 2, partition_shared_zone=False) == names
assert not _partition_devices_for_lane(2, lane_has_own_groups=False, step_group_ids=[])

View File

@@ -0,0 +1,27 @@
"""Zone content_kind is fixed after create."""
import json
import os
import sys
import tempfile
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from models.zone import Zone # noqa: E402
def test_update_cannot_change_content_kind():
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "zone.json")
with open(path, "w", encoding="utf-8") as f:
json.dump({}, f)
z = Zone()
z.file = path
z.clear()
zid = z.create("preset zone", group_ids=[], content_kind="presets")
z.update(zid, {"content_kind": "sequences", "name": "preset zone"})
doc = z.read(zid)
assert doc["content_kind"] == "presets"
assert doc.get("sequence_ids") == []