feat(zones): profile-scoped groups, zone modes, sequence brightness

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-13 01:58:00 +12:00
parent c1c3e5d71b
commit 6c9e06f33b
21 changed files with 1034 additions and 604 deletions

View File

@@ -1,4 +1,5 @@
from microdot import Microdot from microdot import Microdot
from microdot.session import with_session
import asyncio import asyncio
from models.group import Group from models.group import Group
from models.device import Device from models.device import Device
@@ -13,46 +14,127 @@ groups = Group()
devices = Device() devices = Device()
_pi_settings = Settings() _pi_settings = Settings()
@controller.get('')
async def list_groups(request):
"""List all groups."""
return json.dumps(groups), 200, {'Content-Type': 'application/json'}
@controller.get('/<id>') def _group_doc_visible_for_profile(doc, profile_id):
async def get_group(request, id): if not isinstance(doc, dict):
"""Get a specific group by ID.""" return False
scoped = doc.get("profile_id")
if scoped is None:
scoped = doc.get("profileId")
if scoped is None or str(scoped).strip() == "":
return True
if not profile_id:
return False
return str(scoped).strip() == str(profile_id).strip()
def _filtered_groups_dict(session):
from controllers.zone import get_current_profile_id
pid = get_current_profile_id(session)
out = {}
for gid, doc in groups.items():
if not isinstance(doc, dict):
continue
if _group_doc_visible_for_profile(doc, pid):
out[str(gid)] = doc
return out
@controller.get("")
@with_session
async def list_groups(request, session):
"""List groups visible for the current profile (shared + profile-scoped)."""
return json.dumps(_filtered_groups_dict(session)), 200, {"Content-Type": "application/json"}
@controller.get("/<id>")
@with_session
async def get_group(request, session, id):
"""Get a specific group by ID (404 if scoped to another profile)."""
group = groups.read(id) group = groups.read(id)
if group: if not group or not isinstance(group, dict):
return json.dumps(group), 200, {'Content-Type': 'application/json'} return json.dumps({"error": "Group not found"}), 404
return json.dumps({"error": "Group not found"}), 404 from controllers.zone import get_current_profile_id
@controller.post('') if not _group_doc_visible_for_profile(group, get_current_profile_id(session)):
async def create_group(request): return json.dumps({"error": "Group not found"}), 404
"""Create a new group.""" return json.dumps(group), 200, {"Content-Type": "application/json"}
def _sanitize_group_profile_id_write(data, session):
"""Allow ``profile_id`` only for the active profile, or null to share across profiles."""
if not isinstance(data, dict):
return
from controllers.zone import get_current_profile_id
cur = get_current_profile_id(session)
if "profile_id" not in data and "profileId" not in data:
return
raw = data.get("profile_id")
if raw is None and "profileId" in data:
raw = data.get("profileId")
if raw is None or raw == "":
data.pop("profileId", None)
data["profile_id"] = None
return
if not cur or str(raw).strip() != str(cur).strip():
data.pop("profileId", None)
data.pop("profile_id", None)
@controller.post("")
@with_session
async def create_group(request, session):
"""Create a new group (omit ``profile_id`` for shared; or ``profile_scoped``: true for this profile only)."""
try: try:
data = request.json or {} data = dict(request.json or {})
name = data.get("name", "") name = data.get("name", "")
profile_scoped = bool(data.pop("profile_scoped", False))
_sanitize_group_profile_id_write(data, session)
group_id = groups.create(name) group_id = groups.create(name)
if data: if data:
groups.update(group_id, data) groups.update(group_id, data)
return json.dumps(groups.read(group_id)), 201, {'Content-Type': 'application/json'} if profile_scoped:
from controllers.zone import get_current_profile_id
cur = get_current_profile_id(session)
if cur:
groups.update(group_id, {"profile_id": str(cur)})
return json.dumps(groups.read(group_id)), 201, {"Content-Type": "application/json"}
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}), 400 return json.dumps({"error": str(e)}), 400
@controller.put('/<id>')
async def update_group(request, id): @controller.put("/<id>")
@with_session
async def update_group(request, session, id):
"""Update an existing group.""" """Update an existing group."""
try: try:
data = request.json data = request.json
if not isinstance(data, dict):
return json.dumps({"error": "Invalid JSON"}), 400, {"Content-Type": "application/json"}
data = dict(data)
_sanitize_group_profile_id_write(data, session)
if groups.update(id, data): if groups.update(id, data):
return json.dumps(groups.read(id)), 200, {'Content-Type': 'application/json'} g = groups.read(id)
if g:
return json.dumps(g), 200, {"Content-Type": "application/json"}
return json.dumps({"error": "Group not found"}), 404 return json.dumps({"error": "Group not found"}), 404
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}), 400 return json.dumps({"error": str(e)}), 400
@controller.delete('/<id>') @controller.delete("/<id>")
async def delete_group(request, id): @with_session
"""Delete a group.""" async def delete_group(request, session, id):
"""Delete a group (not allowed for another profile's scoped group)."""
g = groups.read(id)
if not g or not isinstance(g, dict):
return json.dumps({"error": "Group not found"}), 404
from controllers.zone import get_current_profile_id
if not _group_doc_visible_for_profile(g, get_current_profile_id(session)):
return json.dumps({"error": "Group not found"}), 404
if groups.delete(id): if groups.delete(id):
return json.dumps({"message": "Group deleted successfully"}), 200 return json.dumps({"message": "Group deleted successfully"}), 200
return json.dumps({"error": "Group not found"}), 404 return json.dumps({"error": "Group not found"}), 404
@@ -87,13 +169,25 @@ def _group_driver_config_payload(doc):
return dc return dc
@controller.post('/<id>/driver-config') def _read_group_for_session(session, id):
async def push_group_driver_config(request, id): g = groups.read(id)
if not g or not isinstance(g, dict):
return None
from controllers.zone import get_current_profile_id
if not _group_doc_visible_for_profile(g, get_current_profile_id(session)):
return None
return g
@controller.post("/<id>/driver-config")
@with_session
async def push_group_driver_config(request, session, id):
""" """
Push group WiFi defaults to every WiFi device listed in the group (TCP WebSocket). Push group WiFi defaults to every WiFi device listed in the group (TCP WebSocket).
Uses stored ``wifi_*`` fields on the group; optional JSON body may override for this send only. Uses stored ``wifi_*`` fields on the group; optional JSON body may override for this send only.
""" """
gdoc = groups.read(id) gdoc = _read_group_for_session(session, id)
if not gdoc: if not gdoc:
return json.dumps({"error": "Group not found"}), 404 return json.dumps({"error": "Group not found"}), 404
@@ -158,12 +252,13 @@ def _brightness_save_message_json(b_val: int) -> str:
return json.dumps({"v": "1", "b": b_val, "save": True}, separators=(",", ":")) return json.dumps({"v": "1", "b": b_val, "save": True}, separators=(",", ":"))
@controller.post('/<id>/brightness') @controller.post("/<id>/brightness")
async def push_group_output_brightness(request, id): @with_session
async def push_group_output_brightness(request, session, id):
""" """
Push combined brightness (global × group(s) × device) to each member — one ``b`` per device. Push combined brightness (global × group(s) × device) to each member — one ``b`` per device.
""" """
gdoc = groups.read(id) gdoc = _read_group_for_session(session, id)
if not gdoc: if not gdoc:
return json.dumps({"error": "Group not found"}), 404 return json.dumps({"error": "Group not found"}), 404
@@ -225,13 +320,14 @@ async def push_group_output_brightness(request, id):
@controller.post("/<id>/identify") @controller.post("/<id>/identify")
async def identify_group_devices(request, id): @with_session
async def identify_group_devices(request, session, id):
""" """
Run the same identify blink as ``POST /devices/<id>/identify`` for every registry member Run the same identify blink as ``POST /devices/<id>/identify`` for every registry member
in parallel so all drivers in the group blink together. in parallel so all drivers in the group blink together.
""" """
_ = request _ = request
gdoc = groups.read(id) gdoc = _read_group_for_session(session, id)
if not gdoc: if not gdoc:
return json.dumps({"error": "Group not found"}), 404, {"Content-Type": "application/json"} return json.dumps({"error": "Group not found"}), 404, {"Content-Type": "application/json"}

View File

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

View File

@@ -197,7 +197,7 @@ async def play_sequence(request, session, id):
try: try:
from util.sequence_playback import start from util.sequence_playback import start
await start(zone_id, str(id), str(current_profile_id)) await start(zone_id, str(id), str(current_profile_id), data if isinstance(data, dict) else None)
return json.dumps({"ok": True}), 200, {"Content-Type": "application/json"} return json.dumps({"ok": True}), 200, {"Content-Type": "application/json"}
except ValueError as e: except ValueError as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"} return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}

View File

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

View File

@@ -254,6 +254,12 @@ async def main(port=80):
app = Microdot() app = Microdot()
audio_detector = AudioBeatDetector() audio_detector = AudioBeatDetector()
try:
from util import audio_detector as audio_detector_module
audio_detector_module.set_shared_beat_detector(audio_detector)
except Exception as e:
print(f"[startup] audio detector shared registration skipped: {e!r}")
try: try:
from util.audio_run_persist import coerce_audio_device, read_audio_run_state from util.audio_run_persist import coerce_audio_device, read_audio_run_state

View File

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

View File

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

View File

@@ -54,9 +54,19 @@ class Sequence(Model):
if "group_ids" not in doc or not isinstance(doc.get("group_ids"), list): if "group_ids" not in doc or not isinstance(doc.get("group_ids"), list):
doc["group_ids"] = [] doc["group_ids"] = []
changed = True changed = True
if doc.get("advance_mode") not in ("time", "beats"): if doc.get("advance_mode") != "beats":
doc["advance_mode"] = "time" doc["advance_mode"] = "beats"
changed = True changed = True
if "simulated_bpm" not in doc:
doc["simulated_bpm"] = 120
changed = True
else:
try:
sb = int(float(doc["simulated_bpm"]))
doc["simulated_bpm"] = max(30, min(300, sb))
except (TypeError, ValueError):
doc["simulated_bpm"] = 120
changed = True
if "sequence_transition" not in doc: if "sequence_transition" not in doc:
doc["sequence_transition"] = 500 doc["sequence_transition"] = 500
changed = True changed = True
@@ -102,9 +112,10 @@ class Sequence(Model):
"group_ids": [], "group_ids": [],
"lanes": [[]], "lanes": [[]],
"lanes_group_ids": [[]], "lanes_group_ids": [[]],
"advance_mode": "time", "advance_mode": "beats",
"steps": [], "steps": [],
"step_duration_ms": 3000, "step_duration_ms": 3000,
"simulated_bpm": 120,
"sequence_transition": 500, "sequence_transition": 500,
"loop": True, "loop": True,
} }

View File

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

View File

@@ -83,10 +83,7 @@
lastBeatConsoleKey = key; lastBeatConsoleKey = key;
if (!line) return; if (!line) return;
const seq = /** @type {Record<string, unknown>|undefined} */ (status && status.sequence); const seq = /** @type {Record<string, unknown>|undefined} */ (status && status.sequence);
const seqBeats = const seqBeats = !!seq && !!seq.active;
!!seq &&
!!seq.active &&
String(seq.advance_mode || "").toLowerCase() === "beats";
let out = line; let out = line;
if (seqBeats) { if (seqBeats) {
const nLanes = Number(seq && seq.num_lanes); const nLanes = Number(seq && seq.num_lanes);
@@ -122,7 +119,6 @@
function formatSequenceBeatFractionsForLog(status) { function formatSequenceBeatFractionsForLog(status) {
const seq = /** @type {Record<string, unknown>|undefined} */ (status && status.sequence); const seq = /** @type {Record<string, unknown>|undefined} */ (status && status.sequence);
if (!seq || !seq.active) return null; if (!seq || !seq.active) return null;
if (seq.advance_mode !== "beats") return null;
const laneBeatAt = Number(seq.lane0_beat_in_step); const laneBeatAt = Number(seq.lane0_beat_in_step);
const laneBeatsPerStep = Number(seq.lane0_beats_per_step); const laneBeatsPerStep = Number(seq.lane0_beats_per_step);

View File

@@ -1,8 +1,27 @@
// Device groups: members (MAC ids) + WiFi driver defaults; persisted via /groups. // Device groups: members (MAC ids) + WiFi driver defaults; persisted via /groups.
// Without ``profile_id``, a group is shared across all profiles; with ``profile_id`` it is listed only for that profile.
async function getCurrentProfileIdForGroups() {
try {
const res = await fetch('/profiles/current', {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
if (!res.ok) return null;
const data = await res.json();
const id = data && (data.id || (data.profile && data.profile.id));
return id != null ? String(id) : null;
} catch {
return null;
}
}
async function fetchGroupsMap() { async function fetchGroupsMap() {
try { try {
const response = await fetch('/groups', { headers: { Accept: 'application/json' } }); const response = await fetch('/groups', {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
if (!response.ok) return {}; if (!response.ok) return {};
const data = await response.json(); const data = await response.json();
return data && typeof data === 'object' ? data : {}; return data && typeof data === 'object' ? data : {};
@@ -137,6 +156,14 @@ function refreshEditGroupDebug() {
} }
} }
function syncGroupShareCheckboxFromDoc(g) {
const cb = document.getElementById('edit-group-share-all-profiles');
if (!cb) return;
const raw = g && (g.profile_id != null ? g.profile_id : g.profileId);
const scoped = raw != null && String(raw).trim() !== '';
cb.checked = !scoped;
}
function loadWifiFieldsFromGroup(g) { function loadWifiFieldsFromGroup(g) {
const wName = document.getElementById('edit-group-wifi-driver-name'); const wName = document.getElementById('edit-group-wifi-driver-name');
const wLeds = document.getElementById('edit-group-wifi-num-leds'); const wLeds = document.getElementById('edit-group-wifi-num-leds');
@@ -189,7 +216,10 @@ async function openEditGroupModal(groupId, groupDoc) {
let g = groupDoc; let g = groupDoc;
if (!g || typeof g !== 'object') { if (!g || typeof g !== 'object') {
try { try {
const response = await fetch(`/groups/${encodeURIComponent(groupId)}`); const response = await fetch(`/groups/${encodeURIComponent(groupId)}`, {
credentials: 'same-origin',
headers: { Accept: 'application/json' },
});
if (response.ok) g = await response.json(); if (response.ok) g = await response.json();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -217,6 +247,7 @@ async function openEditGroupModal(groupId, groupDoc) {
}); });
renderGroupDevicesEditor(editor, window.__editGroupDeviceRows, dm); renderGroupDevicesEditor(editor, window.__editGroupDeviceRows, dm);
loadWifiFieldsFromGroup(g); loadWifiFieldsFromGroup(g);
syncGroupShareCheckboxFromDoc(g);
refreshEditGroupDebug(); refreshEditGroupDebug();
if (modal) modal.classList.add('active'); if (modal) modal.classList.add('active');
} }
@@ -259,8 +290,13 @@ function renderGroupsList(groups) {
const label = document.createElement('span'); const label = document.createElement('span');
const devs = Array.isArray(g.devices) ? g.devices : []; const devs = Array.isArray(g.devices) ? g.devices : [];
label.textContent = `${g.name || gid} (${devs.length} device${devs.length === 1 ? '' : 's'})`; label.textContent = `${g.name || gid} (${devs.length} device${devs.length === 1 ? '' : 's'})`;
label.style.flex = '1';
const meta = document.createElement('div');
meta.className = 'muted-text';
meta.style.fontSize = '0.8em';
const rawPid = g.profile_id != null ? g.profile_id : g.profileId;
const scoped = rawPid != null && String(rawPid).trim() !== '';
meta.textContent = scoped ? `This profile only (${rawPid})` : 'Shared across profiles';
const editBtn = document.createElement('button'); const editBtn = document.createElement('button');
editBtn.className = 'btn btn-secondary btn-small'; editBtn.className = 'btn btn-secondary btn-small';
editBtn.textContent = 'Edit'; editBtn.textContent = 'Edit';
@@ -342,7 +378,10 @@ function renderGroupsList(groups) {
delBtn.addEventListener('click', async () => { delBtn.addEventListener('click', async () => {
if (!confirm(`Delete group "${g.name || gid}"? Zones referencing it may need updating.`)) return; if (!confirm(`Delete group "${g.name || gid}"? Zones referencing it may need updating.`)) return;
try { try {
const res = await fetch(`/groups/${encodeURIComponent(gid)}`, { method: 'DELETE' }); const res = await fetch(`/groups/${encodeURIComponent(gid)}`, {
method: 'DELETE',
credentials: 'same-origin',
});
if (res.ok) await loadGroupsModal(); if (res.ok) await loadGroupsModal();
else { else {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
@@ -354,7 +393,12 @@ function renderGroupsList(groups) {
} }
}); });
row.appendChild(label); const left = document.createElement('div');
left.style.flex = '1';
left.style.minWidth = '0';
left.appendChild(label);
left.appendChild(meta);
row.appendChild(left);
row.appendChild(editBtn); row.appendChild(editBtn);
row.appendChild(brightBtn); row.appendChild(brightBtn);
row.appendChild(applyBtn); row.appendChild(applyBtn);
@@ -433,11 +477,16 @@ document.addEventListener('DOMContentLoaded', () => {
const createHandler = async () => { const createHandler = async () => {
const name = newNameInput && newNameInput.value.trim(); const name = newNameInput && newNameInput.value.trim();
if (!name) return; if (!name) return;
const profileOnly = document.getElementById('new-group-profile-only');
try { try {
const res = await fetch('/groups', { const res = await fetch('/groups', {
method: 'POST', method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ name }), body: JSON.stringify({
name,
profile_scoped: !!(profileOnly && profileOnly.checked),
}),
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) { if (!res.ok) {
@@ -445,6 +494,7 @@ document.addEventListener('DOMContentLoaded', () => {
return; return;
} }
if (newNameInput) newNameInput.value = ''; if (newNameInput) newNameInput.value = '';
if (profileOnly) profileOnly.checked = false;
await loadGroupsModal(); await loadGroupsModal();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -466,9 +516,18 @@ document.addEventListener('DOMContentLoaded', () => {
const { gid, payload } = collectGroupEditPayload(); const { gid, payload } = collectGroupEditPayload();
if (!gid) return; if (!gid) return;
const shareCb = document.getElementById('edit-group-share-all-profiles');
if (shareCb && shareCb.checked) {
payload.profile_id = null;
} else {
const pid = await getCurrentProfileIdForGroups();
payload.profile_id = pid || null;
}
try { try {
const res = await fetch(`/groups/${encodeURIComponent(gid)}`, { const res = await fetch(`/groups/${encodeURIComponent(gid)}`, {
method: 'PUT', method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });

View File

@@ -157,7 +157,7 @@ function tabDeviceNamesFromSection(section) {
: []; : [];
} }
/** Device names for ``presetId`` on the current zone tab (per-preset groups or zone default). */ /** Device names for ``presetId`` on the current zone tab (zone ``group_ids`` for presets, else tab devices). */
async function deviceNamesForPresetOnCurrentZone(presetId) { async function deviceNamesForPresetOnCurrentZone(presetId) {
const section = document.querySelector('.presets-section[data-zone-id]'); const section = document.querySelector('.presets-section[data-zone-id]');
const fallback = tabDeviceNamesFromSection(section); const fallback = tabDeviceNamesFromSection(section);
@@ -176,11 +176,11 @@ async function deviceNamesForPresetOnCurrentZone(presetId) {
} }
} }
function formatPresetTargetGroupsLine(zoneDoc, presetId, groupsMap) { function formatPresetTargetGroupsLine(zoneDoc, groupsMap) {
const zm = window.zonesManager; const zm = window.zonesManager;
const gids = const gids =
zm && typeof zm.effectiveGroupIdsForZonePreset === 'function' zm && typeof zm.effectiveGroupIdsForZonePreset === 'function'
? zm.effectiveGroupIdsForZonePreset(zoneDoc, presetId) ? zm.effectiveGroupIdsForZonePreset(zoneDoc || {})
: Array.isArray(zoneDoc && zoneDoc.group_ids) : Array.isArray(zoneDoc && zoneDoc.group_ids)
? zoneDoc.group_ids.map((x) => String(x).trim()).filter((x) => x.length > 0) ? zoneDoc.group_ids.map((x) => String(x).trim()).filter((x) => x.length > 0)
: []; : [];
@@ -242,6 +242,7 @@ document.addEventListener('DOMContentLoaded', () => {
const presetRemoveFromTabButton = document.getElementById('preset-remove-from-zone-btn'); const presetRemoveFromTabButton = document.getElementById('preset-remove-from-zone-btn');
const presetSaveButton = document.getElementById('preset-save-btn'); const presetSaveButton = document.getElementById('preset-save-btn');
const presetAddFromPaletteButton = document.getElementById('preset-add-from-palette-btn'); const presetAddFromPaletteButton = document.getElementById('preset-add-from-palette-btn');
const presetBackgroundFromPaletteButton = document.getElementById('preset-background-from-palette-btn');
if (!presetsButton || !presetsModal || !presetsList || !presetSaveButton) { if (!presetsButton || !presetsModal || !presetsList || !presetSaveButton) {
return; return;
@@ -253,6 +254,8 @@ document.addEventListener('DOMContentLoaded', () => {
let cachedPatterns = {}; let cachedPatterns = {};
let currentPresetColors = []; // Track colors for the current preset let currentPresetColors = []; // Track colors for the current preset
let currentPresetPaletteRefs = []; // Palette index refs per color (null for direct colors) let currentPresetPaletteRefs = []; // Palette index refs per color (null for direct colors)
let currentBackgroundPaletteRef = null;
let bgPaletteResolveGen = 0;
// Function to get max colors for current pattern // Function to get max colors for current pattern
const getMaxColors = () => { const getMaxColors = () => {
@@ -326,6 +329,10 @@ document.addEventListener('DOMContentLoaded', () => {
presetBackgroundButton.style.backgroundColor = color; presetBackgroundButton.style.backgroundColor = color;
presetBackgroundButton.style.color = '#fff'; presetBackgroundButton.style.color = '#fff';
presetBackgroundButton.style.borderColor = 'rgba(255, 255, 255, 0.6)'; presetBackgroundButton.style.borderColor = 'rgba(255, 255, 255, 0.6)';
presetBackgroundButton.title =
currentBackgroundPaletteRef != null
? `Background from profile palette (index ${currentBackgroundPaletteRef}); click to pick a custom colour`
: 'Choose background colour';
}; };
const updateDelayVisibilityForManualMode = () => { const updateDelayVisibilityForManualMode = () => {
@@ -640,9 +647,28 @@ document.addEventListener('DOMContentLoaded', () => {
presetBrightnessInput.value = preset.brightness || 0; presetBrightnessInput.value = preset.brightness || 0;
presetDelayInput.value = preset.delay || 0; presetDelayInput.value = preset.delay || 0;
if (presetBackgroundInput) { if (presetBackgroundInput) {
const rawBgRef = preset.background_palette_ref ?? preset.backgroundPaletteRef;
let bgRef = null;
if (rawBgRef != null && rawBgRef !== '') {
const n = typeof rawBgRef === 'number' ? rawBgRef : parseInt(String(rawBgRef), 10);
if (Number.isInteger(n) && n >= 0) {
bgRef = n;
}
}
currentBackgroundPaletteRef = bgRef;
presetBackgroundInput.value = coercePresetBackground(preset); presetBackgroundInput.value = coercePresetBackground(preset);
updatePresetBackgroundButton();
const gen = ++bgPaletteResolveGen;
void getCurrentProfilePaletteColors().then((pal) => {
if (gen !== bgPaletteResolveGen || !presetBackgroundInput) {
return;
}
presetBackgroundInput.value = resolvePresetBackgroundHex(preset, pal);
updatePresetBackgroundButton();
});
} else {
updatePresetBackgroundButton();
} }
updatePresetBackgroundButton();
if (presetManualModeInput) { if (presetManualModeInput) {
const autoVal = typeof preset.auto === 'boolean' ? preset.auto : true; const autoVal = typeof preset.auto === 'boolean' ? preset.auto : true;
presetManualModeInput.checked = !autoVal; presetManualModeInput.checked = !autoVal;
@@ -714,6 +740,7 @@ document.addEventListener('DOMContentLoaded', () => {
}; };
const clearForm = () => { const clearForm = () => {
bgPaletteResolveGen += 1;
currentEditId = null; currentEditId = null;
currentEditTabId = null; currentEditTabId = null;
currentPresetColors = []; currentPresetColors = [];
@@ -742,9 +769,6 @@ document.addEventListener('DOMContentLoaded', () => {
if (presetManualBeatNInput) { if (presetManualBeatNInput) {
presetManualBeatNInput.value = '1'; presetManualBeatNInput.value = '1';
} }
if (presetBackgroundInput) {
presetBackgroundInput.value = '#000000';
}
updatePresetBackgroundButton(); updatePresetBackgroundButton();
updateManualModeAvailability(); updateManualModeAvailability();
// Re-enable name and pattern when clearing (for new preset) // Re-enable name and pattern when clearing (for new preset)
@@ -825,6 +849,7 @@ document.addEventListener('DOMContentLoaded', () => {
brightness: presetBrightnessInput ? parseInt(presetBrightnessInput.value, 10) || 0 : 0, brightness: presetBrightnessInput ? parseInt(presetBrightnessInput.value, 10) || 0 : 0,
delay: presetDelayInput ? parseInt(presetDelayInput.value, 10) || 0 : 0, delay: presetDelayInput ? parseInt(presetDelayInput.value, 10) || 0 : 0,
background: presetBackgroundInput ? presetBackgroundInput.value : '#000000', background: presetBackgroundInput ? presetBackgroundInput.value : '#000000',
background_palette_ref: currentBackgroundPaletteRef != null ? currentBackgroundPaletteRef : null,
auto: presetManualModeInput ? !presetManualModeInput.checked : true, auto: presetManualModeInput ? !presetManualModeInput.checked : true,
manual_beat_n: (() => { manual_beat_n: (() => {
if (!presetManualBeatNInput) return 1; if (!presetManualBeatNInput) return 1;
@@ -1302,7 +1327,15 @@ document.addEventListener('DOMContentLoaded', () => {
throw new Error('Failed to load zone'); throw new Error('Failed to load zone');
} }
const tabData = await tabResponse.json(); const tabData = await tabResponse.json();
const kind =
typeof window.normalizeZoneContentKind === 'function'
? window.normalizeZoneContentKind(tabData)
: null;
if (kind === 'sequences') {
alert('This zone is for sequences only. Add a sequence from the zone Edit menu instead.');
return;
}
// Normalize to flat array to check and update usage // Normalize to flat array to check and update usage
let flat = []; let flat = [];
if (Array.isArray(tabData.presets_flat)) { if (Array.isArray(tabData.presets_flat)) {
@@ -1324,9 +1357,6 @@ document.addEventListener('DOMContentLoaded', () => {
const newGrid = arrayToGrid(flat, 3); const newGrid = arrayToGrid(flat, 3);
tabData.presets = newGrid; tabData.presets = newGrid;
tabData.presets_flat = flat; tabData.presets_flat = flat;
if (!tabData.preset_group_ids || typeof tabData.preset_group_ids !== 'object') {
tabData.preset_group_ids = {};
}
// Update zone // Update zone
const updateResponse = await fetch(`/zones/${zoneId}`, { const updateResponse = await fetch(`/zones/${zoneId}`, {
@@ -1383,6 +1413,7 @@ document.addEventListener('DOMContentLoaded', () => {
presetBackgroundInput.click(); presetBackgroundInput.click();
}); });
presetBackgroundInput.addEventListener('input', () => { presetBackgroundInput.addEventListener('input', () => {
currentBackgroundPaletteRef = null;
updatePresetBackgroundButton(); updatePresetBackgroundButton();
}); });
} }
@@ -1462,10 +1493,6 @@ document.addEventListener('DOMContentLoaded', () => {
const ref = parseInt(row.dataset.paletteIndex, 10); const ref = parseInt(row.dataset.paletteIndex, 10);
if (!color || !Number.isInteger(ref)) return; if (!color || !Number.isInteger(ref)) return;
if (currentPresetColors.includes(color) && currentPresetPaletteRefs.includes(ref)) {
alert('That palette color is already linked.');
return;
}
const maxColors = getMaxColors(); const maxColors = getMaxColors();
if (currentPresetColors.length >= maxColors) { if (currentPresetColors.length >= maxColors) {
alert(`This pattern allows a maximum of ${maxColors} color${maxColors !== 1 ? 's' : ''}.`); alert(`This pattern allows a maximum of ${maxColors} color${maxColors !== 1 ? 's' : ''}.`);
@@ -1479,7 +1506,72 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
} catch (err) { } catch (err) {
console.error('Failed to add from palette:', err); console.error('Failed to add from palette:', err);
alert('Failed to load palette colors.'); alert('Failed to load palette colours.');
}
});
}
if (presetBackgroundFromPaletteButton) {
presetBackgroundFromPaletteButton.addEventListener('click', async () => {
try {
const paletteColors = await getCurrentProfilePaletteColors();
if (!Array.isArray(paletteColors) || paletteColors.length === 0) {
alert('No profile palette colours available.');
return;
}
const modal = document.createElement('div');
modal.className = 'modal active modal-child-overlay';
modal.innerHTML = `
<div class="modal-content">
<h2>Pick background colour</h2>
<div id="pick-bg-palette-list" class="profiles-list" style="max-height: 300px; overflow-y: auto;"></div>
<div class="modal-actions">
<button class="btn btn-secondary" id="pick-bg-palette-close-btn">Close</button>
</div>
</div>
`;
document.body.appendChild(modal);
const list = modal.querySelector('#pick-bg-palette-list');
paletteColors.forEach((color, idx) => {
const row = document.createElement('div');
row.className = 'profiles-row';
row.style.display = 'flex';
row.style.alignItems = 'center';
row.style.gap = '0.75rem';
row.dataset.paletteIndex = String(idx);
row.dataset.paletteColor = color;
row.innerHTML = `
<div style="width:28px;height:28px;border-radius:4px;border:1px solid #555;background:${color};"></div>
<span style="flex:1">${color}</span>
<button class="btn btn-primary btn-small" type="button">Use</button>
`;
list.appendChild(row);
});
const close = () => modal.remove();
modal.querySelector('#pick-bg-palette-close-btn').addEventListener('click', close);
list.addEventListener('click', (e) => {
const btn = e.target.closest('button');
if (!btn) return;
const row = e.target.closest('[data-palette-index]');
if (!row) return;
const color = row.dataset.paletteColor;
const ref = parseInt(row.dataset.paletteIndex, 10);
if (!color || !Number.isInteger(ref)) return;
currentBackgroundPaletteRef = ref;
if (presetBackgroundInput) {
presetBackgroundInput.value = color;
}
updatePresetBackgroundButton();
close();
});
} catch (err) {
console.error('Failed to pick background from palette:', err);
alert('Failed to load palette colours.');
} }
}); });
} }
@@ -1663,6 +1755,26 @@ const coercePresetBackground = (preset) => {
return '#000000'; return '#000000';
}; };
/** Resolved background hex; uses ``background_palette_ref`` when set and palette is available. */
const resolvePresetBackgroundHex = (preset, paletteColors) => {
if (!preset || typeof preset !== 'object') {
return coercePresetBackground(preset);
}
const rawRef =
preset.background_palette_ref !== undefined && preset.background_palette_ref !== null
? preset.background_palette_ref
: preset.backgroundPaletteRef;
const ref = typeof rawRef === 'number' ? rawRef : parseInt(String(rawRef != null ? rawRef : ''), 10);
const pal = Array.isArray(paletteColors) ? paletteColors : [];
if (Number.isInteger(ref) && ref >= 0 && ref < pal.length && pal[ref]) {
const c = String(pal[ref]).trim();
if (/^#[0-9a-fA-F]{6}$/i.test(c)) {
return c.toUpperCase();
}
}
return coercePresetBackground(preset);
};
/** Audio beat stride for manual presets (led-controller only; firmware ignores this key). */ /** Audio beat stride for manual presets (led-controller only; firmware ignores this key). */
const coerceManualBeatN = (preset) => { const coerceManualBeatN = (preset) => {
if (!preset || typeof preset !== 'object') return 1; if (!preset || typeof preset !== 'object') return 1;
@@ -1695,7 +1807,7 @@ const sendPresetViaEspNow = async (
const wirePresetId = devicePresetId != null ? String(devicePresetId) : String(presetId); const wirePresetId = devicePresetId != null ? String(devicePresetId) : String(presetId);
const presetAuto = coercePresetAuto(preset); const presetAuto = coercePresetAuto(preset);
const presetBackground = coercePresetBackground(preset); const presetBackground = resolvePresetBackgroundHex(preset, paletteColors);
const presetMessage = { const presetMessage = {
v: '1', v: '1',
presets: { presets: {
@@ -2034,7 +2146,11 @@ const renderTabPresets = async (zoneId, options = {}) => {
} }
const tabData = await tabResponse.json(); const tabData = await tabResponse.json();
const groupsMapStrip = groupsStripRes.ok ? await groupsStripRes.json() : {}; const groupsMapStrip = groupsStripRes.ok ? await groupsStripRes.json() : {};
const ck =
typeof window.normalizeZoneContentKind === 'function'
? window.normalizeZoneContentKind(tabData)
: null;
// Get presets - support both 2D grid and flat array (for backward compatibility) // Get presets - support both 2D grid and flat array (for backward compatibility)
let presetGrid = tabData.presets; let presetGrid = tabData.presets;
if (!presetGrid || !Array.isArray(presetGrid)) { if (!presetGrid || !Array.isArray(presetGrid)) {
@@ -2045,6 +2161,9 @@ const renderTabPresets = async (zoneId, options = {}) => {
// It's a flat array, convert to grid // It's a flat array, convert to grid
presetGrid = arrayToGrid(presetGrid, 3); presetGrid = arrayToGrid(presetGrid, 3);
} }
if (ck === 'sequences') {
presetGrid = [];
}
if (!presetsResponse.ok) { if (!presetsResponse.ok) {
throw new Error('Failed to load presets'); throw new Error('Failed to load presets');
@@ -2122,13 +2241,25 @@ const renderTabPresets = async (zoneId, options = {}) => {
const validIdSet = new Set(flatPresets.map((id) => String(id))); const validIdSet = new Set(flatPresets.map((id) => String(id)));
pruneZonePresetSelection(zoneId, validIdSet); pruneZonePresetSelection(zoneId, validIdSet);
const hasSeq =
Array.isArray(tabData.sequence_ids) &&
tabData.sequence_ids.some((x) => x != null && String(x).trim());
if (flatPresets.length === 0) { if (flatPresets.length === 0) {
// Show empty message if this zone has no presets
const empty = document.createElement('p'); const empty = document.createElement('p');
empty.className = 'muted-text'; empty.className = 'muted-text';
empty.style.gridColumn = '1 / -1'; // Span all columns empty.style.gridColumn = '1 / -1'; // Span all columns
empty.textContent = 'No presets added to this zone. Open the zone\'s Edit menu and click "Add Preset" to add one.'; if (ck === 'sequences') {
presetsList.appendChild(empty); if (!hasSeq) {
empty.textContent =
"No sequences on this zone yet. Open the zone's Edit menu to add one.";
presetsList.appendChild(empty);
}
} else {
empty.textContent =
'No presets added to this zone. Open the zone\'s Edit menu and click "Add Preset" to add one.';
presetsList.appendChild(empty);
}
} else { } else {
flatPresets.forEach((presetId) => { flatPresets.forEach((presetId) => {
const preset = allPresets[presetId]; const preset = allPresets[presetId];
@@ -2138,6 +2269,7 @@ const renderTabPresets = async (zoneId, options = {}) => {
const displayPreset = { const displayPreset = {
...preset, ...preset,
colors: resolveColorsWithPaletteRefs(preset.colors, preset.palette_refs, paletteColors), colors: resolveColorsWithPaletteRefs(preset.colors, preset.palette_refs, paletteColors),
background: resolvePresetBackgroundHex(preset, paletteColors),
}; };
const wrapper = createPresetButton( const wrapper = createPresetButton(
presetId, presetId,
@@ -2153,7 +2285,7 @@ const renderTabPresets = async (zoneId, options = {}) => {
}); });
} }
if (typeof window.appendZoneSequenceTiles === 'function') { if (typeof window.appendZoneSequenceTiles === 'function' && ck !== 'presets') {
await window.appendZoneSequenceTiles(zoneId, tabData, allPresets, paletteColors, presetsList); await window.appendZoneSequenceTiles(zoneId, tabData, allPresets, paletteColors, presetsList);
} }
} catch (error) { } catch (error) {
@@ -2199,7 +2331,7 @@ const createPresetButton = (presetId, preset, zoneId, isSelected, tabData, group
presetNameLabel.className = 'pattern-button-label'; presetNameLabel.className = 'pattern-button-label';
button.appendChild(presetNameLabel); button.appendChild(presetNameLabel);
const groupsText = formatPresetTargetGroupsLine(tabData || {}, presetId, groupsMap || {}); const groupsText = formatPresetTargetGroupsLine(tabData || {}, groupsMap || {});
if (groupsText) { if (groupsText) {
const groupsSpan = document.createElement('span'); const groupsSpan = document.createElement('span');
groupsSpan.className = 'preset-tile-groups'; groupsSpan.className = 'preset-tile-groups';
@@ -2253,9 +2385,6 @@ const createPresetButton = (presetId, preset, zoneId, isSelected, tabData, group
button.addEventListener('click', () => { button.addEventListener('click', () => {
if (isDraggingPreset) return; if (isDraggingPreset) return;
console.info('Preset button pressed', { zoneId, presetId, name: (preset && preset.name) || presetId }); console.info('Preset button pressed', { zoneId, presetId, name: (preset && preset.name) || presetId });
if (typeof window.stopZoneSequencePlayback === 'function') {
window.stopZoneSequencePlayback();
}
const presetsListEl = document.getElementById('presets-list-zone'); const presetsListEl = document.getElementById('presets-list-zone');
ensureZonePresetSelection(zoneId); ensureZonePresetSelection(zoneId);
const z = String(zoneId); const z = String(zoneId);
@@ -2421,12 +2550,6 @@ const removePresetFromTab = async (zoneId, presetId) => {
tabData.presets = newGrid; tabData.presets = newGrid;
tabData.presets_flat = flat; tabData.presets_flat = flat;
if (tabData.preset_group_ids && typeof tabData.preset_group_ids === 'object') {
const pg = { ...tabData.preset_group_ids };
delete pg[String(presetId)];
tabData.preset_group_ids = pg;
}
const updateResponse = await fetch(`/zones/${zoneId}`, { const updateResponse = await fetch(`/zones/${zoneId}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },

View File

@@ -1,4 +1,4 @@
// Sequences: lanes (parallel preset chains), shared groups, time or beat advance. // Sequences: lanes (parallel preset chains); advance is always by audio beats or simulated BPM.
// Debug: in the browser console run setSequenceDebug(true) — toggling logs 1 (on) or 0 (off). // Debug: in the browser console run setSequenceDebug(true) — toggling logs 1 (on) or 0 (off).
const SEQ_DEBUG_STORAGE_KEY = 'led-controller-sequence-debug'; const SEQ_DEBUG_STORAGE_KEY = 'led-controller-sequence-debug';
@@ -24,7 +24,7 @@ function stopSequenceEditorBpmPoll() {
async function refreshSequenceEditorBpmDisplay() { async function refreshSequenceEditorBpmDisplay() {
const live = document.getElementById('sequence-editor-bpm-live'); const live = document.getElementById('sequence-editor-bpm-live');
const panel = document.getElementById('sequence-editor-beats-panel'); const panel = document.getElementById('sequence-editor-beats-panel');
if (!live || !panel || panel.style.display === 'none') return; if (!live || !panel) return;
try { try {
const res = await fetch('/api/audio/status', { headers: { Accept: 'application/json' } }); const res = await fetch('/api/audio/status', { headers: { Accept: 'application/json' } });
const j = res.ok ? await res.json() : {}; const j = res.ok ? await res.json() : {};
@@ -39,7 +39,7 @@ async function refreshSequenceEditorBpmDisplay() {
: NaN; : NaN;
if (!running) { if (!running) {
live.textContent = live.textContent =
'Audio detector is stopped — start it from the header to drive beat mode and show BPM.'; 'Audio detector is stopped — the sequence uses simulated beats at the BPM you set above.';
return; return;
} }
if (!Number.isFinite(bpm) || bpm <= 0) { if (!Number.isFinite(bpm) || bpm <= 0) {
@@ -97,15 +97,13 @@ function normalizeSequenceLanes(doc) {
} }
/** /**
* Log each preset in the sequence with its step beat count (for Audio beats mode this is how * Log each preset in the sequence with its step beat count (beats per step before advancing).
* many detector beats the step runs; in Time mode the value is still the stored step beats).
* @param {string} sequenceId * @param {string} sequenceId
* @param {Record<string, unknown>} sequenceDoc * @param {Record<string, unknown>} sequenceDoc
* @param {Record<string, unknown>} presetsMap * @param {Record<string, unknown>} presetsMap
*/ */
function logSequenceSelectionPresets(sequenceId, sequenceDoc, presetsMap) { function logSequenceSelectionPresets(sequenceId, sequenceDoc, presetsMap) {
if (!sequenceDoc || typeof sequenceDoc !== 'object') return; if (!sequenceDoc || typeof sequenceDoc !== 'object') return;
const adv = sequenceDoc.advance_mode === 'beats' ? 'beats' : 'time';
const lanes = normalizeSequenceLanes(sequenceDoc); const lanes = normalizeSequenceLanes(sequenceDoc);
const nameFor = (pid) => { const nameFor = (pid) => {
const p = presetsMap && presetsMap[pid]; const p = presetsMap && presetsMap[pid];
@@ -117,8 +115,8 @@ function logSequenceSelectionPresets(sequenceId, sequenceDoc, presetsMap) {
const nm = String(sequenceDoc.name || '').trim() || sequenceId; const nm = String(sequenceDoc.name || '').trim() || sequenceId;
const multi = const multi =
lanes.filter((lane) => lane.some((s) => s && s.preset_id)).length > 1; lanes.filter((lane) => lane.some((s) => s && s.preset_id)).length > 1;
let headerLine = `Sequence "${nm}" (${sequenceId}) — advance: ${adv}`; let headerLine = `Sequence "${nm}" (${sequenceId}) — advance: beats`;
if (adv === 'beats' && multi) { if (multi) {
headerLine += headerLine +=
' — header/audio beat readout follows lane 1 only (other lanes run in parallel)'; ' — header/audio beat readout follows lane 1 only (other lanes run in parallel)';
} }
@@ -268,11 +266,18 @@ async function resolveSequenceSendDeviceNames(zoneId, zoneDoc, groupIds) {
async function requestBackendSequencePlay(sequenceId, zoneId, sequenceDoc) { async function requestBackendSequencePlay(sequenceId, zoneId, sequenceDoc) {
// Do not call stop here: server start() already stops any prior run. A fire-and-forget // 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). // client stop can reorder after play and clear the new session (same tile re-click bug).
let bodyBpm;
if (sequenceDoc && typeof sequenceDoc === 'object' && sequenceDoc.simulated_bpm != null) {
const n = parseInt(String(sequenceDoc.simulated_bpm), 10);
if (Number.isFinite(n)) bodyBpm = Math.min(300, Math.max(30, n));
}
const body = { zone_id: String(zoneId) };
if (bodyBpm != null) body.simulated_bpm = bodyBpm;
const res = await fetch(`/sequences/${encodeURIComponent(sequenceId)}/play`, { const res = await fetch(`/sequences/${encodeURIComponent(sequenceId)}/play`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'same-origin', credentials: 'same-origin',
body: JSON.stringify({ zone_id: String(zoneId) }), body: JSON.stringify(body),
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
@@ -295,7 +300,10 @@ async function fetchSequencesMap() {
async function fetchGroupsMapSeq() { async function fetchGroupsMapSeq() {
try { try {
const res = await fetch('/groups', { headers: { Accept: 'application/json' } }); const res = await fetch('/groups', {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
if (!res.ok) return {}; if (!res.ok) return {};
const data = await res.json(); const data = await res.json();
return data && typeof data === 'object' ? data : {}; return data && typeof data === 'object' ? data : {};
@@ -335,8 +343,11 @@ function createSequenceTileRow(sequenceId, sequenceDoc, zoneId, zoneDoc, allPres
const lanes = normalizeSequenceLanes(sequenceDoc); const lanes = normalizeSequenceLanes(sequenceDoc);
const nLanes = lanes.filter((l) => l.length > 0).length || 1; const nLanes = lanes.filter((l) => l.length > 0).length || 1;
const nSteps = lanes.reduce((a, l) => a + l.length, 0); const nSteps = lanes.reduce((a, l) => a + l.length, 0);
const adv = sequenceDoc.advance_mode === 'beats' ? 'beats' : 'time'; const simRaw = sequenceDoc.simulated_bpm;
sub.textContent = `${nLanes} lane${nLanes === 1 ? '' : 's'} · ${nSteps} step${nSteps === 1 ? '' : 's'} · ${adv}`; 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.appendChild(sub);
button.addEventListener('click', () => { button.addEventListener('click', () => {
@@ -443,6 +454,14 @@ async function addSequenceToTab(sequenceId, zoneId) {
const tabResponse = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } }); const tabResponse = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
if (!tabResponse.ok) throw new Error('Failed to load zone'); if (!tabResponse.ok) throw new Error('Failed to load zone');
const tabData = await tabResponse.json(); const tabData = await tabResponse.json();
const kind =
typeof window.normalizeZoneContentKind === 'function'
? window.normalizeZoneContentKind(tabData)
: null;
if (kind === 'presets') {
alert('This zone is for presets only. Add presets from the zone Edit menu instead.');
return;
}
const list = Array.isArray(tabData.sequence_ids) ? tabData.sequence_ids.map(String) : []; const list = Array.isArray(tabData.sequence_ids) ? tabData.sequence_ids.map(String) : [];
if (list.includes(String(sequenceId))) { if (list.includes(String(sequenceId))) {
alert('Sequence is already on this zone.'); alert('Sequence is already on this zone.');
@@ -505,6 +524,16 @@ async function refreshEditTabSequencesUi(zoneId) {
const zoneRes = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } }); const zoneRes = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
if (!zoneRes.ok) throw new Error('zone'); if (!zoneRes.ok) throw new Error('zone');
const zone = await zoneRes.json(); const zone = await zoneRes.json();
const kind =
typeof window.normalizeZoneContentKind === 'function'
? window.normalizeZoneContentKind(zone)
: null;
if (kind === 'presets') {
currentEl.innerHTML =
'<span class="muted-text">This zone is for presets only. Sequences are hidden.</span>';
addEl.innerHTML = '<span class="muted-text">—</span>';
return;
}
const onZone = Array.isArray(zone.sequence_ids) ? zone.sequence_ids.map(String) : []; const onZone = Array.isArray(zone.sequence_ids) ? zone.sequence_ids.map(String) : [];
const seqMap = await fetchSequencesMap(); const seqMap = await fetchSequencesMap();
const onSet = new Set(onZone); const onSet = new Set(onZone);
@@ -586,6 +615,77 @@ async function refreshEditTabSequencesUi(zoneId) {
let sequenceEditorId = null; let sequenceEditorId = null;
/** Insert point when dragging a step row vertically within a lane. */
function getDragAfterSequenceStepRow(container, y) {
const draggableElements = [
...container.querySelectorAll(':scope > .sequence-step-row:not(.dragging)'),
];
return draggableElements.reduce(
(closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) {
return { offset, element: child };
}
return closest;
},
{ offset: Number.NEGATIVE_INFINITY, element: null },
).element;
}
/** Reorder step rows within one lane (DOM order = save order). */
function wireSequenceLaneStepsDragReorder(stepsHost) {
if (!stepsHost || stepsHost.dataset.sequenceLaneDndWired === '1') return;
stepsHost.dataset.sequenceLaneDndWired = '1';
let draggedRow = null;
stepsHost.addEventListener('dragstart', (e) => {
const handle = e.target.closest('.sequence-step-drag-handle');
if (!handle || !stepsHost.contains(handle)) return;
const row = handle.closest('.sequence-step-row');
if (!row || !stepsHost.contains(row)) return;
draggedRow = row;
row.classList.add('dragging');
try {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', 'sequence-step');
} catch (_) {
/* ignore */
}
});
stepsHost.addEventListener('dragend', () => {
if (draggedRow) draggedRow.classList.remove('dragging');
draggedRow = null;
});
stepsHost.addEventListener('dragenter', (e) => {
if (!draggedRow || !stepsHost.contains(draggedRow)) return;
e.preventDefault();
});
stepsHost.addEventListener('dragover', (e) => {
if (!draggedRow || !stepsHost.contains(draggedRow)) return;
e.preventDefault();
try {
e.dataTransfer.dropEffect = 'move';
} catch (_) {
/* ignore */
}
const afterElement = getDragAfterSequenceStepRow(stepsHost, e.clientY);
if (afterElement == null) {
stepsHost.appendChild(draggedRow);
} else if (afterElement !== draggedRow) {
stepsHost.insertBefore(draggedRow, afterElement);
}
});
stepsHost.addEventListener('drop', (e) => {
if (!draggedRow) return;
e.preventDefault();
});
}
function renderSequenceStepRow(presetsMap, step) { function renderSequenceStepRow(presetsMap, step) {
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'sequence-step-row profiles-row'; row.className = 'sequence-step-row profiles-row';
@@ -594,6 +694,15 @@ function renderSequenceStepRow(presetsMap, step) {
const top = document.createElement('div'); const top = document.createElement('div');
top.style.cssText = 'display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;'; top.style.cssText = 'display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;';
const dragHandle = document.createElement('span');
dragHandle.className = 'sequence-step-drag-handle';
dragHandle.draggable = true;
dragHandle.title = 'Drag to reorder';
dragHandle.textContent = '⠿';
dragHandle.style.cssText =
'cursor:grab;user-select:none;flex-shrink:0;line-height:1;opacity:0.75;padding:0.15rem 0.25rem;';
const presetWrap = document.createElement('div'); const presetWrap = document.createElement('div');
presetWrap.style.cssText = 'display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;'; presetWrap.style.cssText = 'display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;';
const pl = document.createElement('label'); const pl = document.createElement('label');
@@ -658,6 +767,7 @@ function renderSequenceStepRow(presetsMap, step) {
); );
}); });
top.appendChild(dragHandle);
top.appendChild(presetWrap); top.appendChild(presetWrap);
top.appendChild(beatWrap); top.appendChild(beatWrap);
top.appendChild(editPresetBtn); top.appendChild(editPresetBtn);
@@ -720,6 +830,7 @@ function renderSequenceLane(laneIndex, laneSteps, laneGroupIds, presetsMap, grou
steps.forEach((s) => { steps.forEach((s) => {
stepsHost.appendChild(renderSequenceStepRow(presetsMap, s)); stepsHost.appendChild(renderSequenceStepRow(presetsMap, s));
}); });
wireSequenceLaneStepsDragReorder(stepsHost);
wrap.appendChild(stepsHost); wrap.appendChild(stepsHost);
return wrap; return wrap;
} }
@@ -763,57 +874,22 @@ function collectLanesFromEditor() {
return { lanes, lanes_group_ids }; return { lanes, lanes_group_ids };
} }
function updateSequenceEditorTimeBpmHint() { function syncSequenceBeatsPanel() {
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 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(); stopSequenceEditorBpmPoll();
if (beatsMode && panel) { if (panel) {
panel.style.display = 'block';
void refreshSequenceEditorBpmDisplay(); void refreshSequenceEditorBpmDisplay();
sequenceBpmPollTimer = setInterval(() => void refreshSequenceEditorBpmDisplay(), 1500); sequenceBpmPollTimer = setInterval(() => void refreshSequenceEditorBpmDisplay(), 1500);
} else if (panel) {
panel.style.display = 'none';
} }
updateSequenceEditorTimeBpmHint();
} }
async function openSequenceEditor(sequenceId, existing) { async function openSequenceEditor(sequenceId, existing) {
sequenceEditorId = sequenceId != null && String(sequenceId).length ? String(sequenceId) : null; sequenceEditorId = sequenceId != null && String(sequenceId).length ? String(sequenceId) : null;
const modal = document.getElementById('sequence-editor-modal'); const modal = document.getElementById('sequence-editor-modal');
const nameInput = document.getElementById('sequence-editor-name'); const nameInput = document.getElementById('sequence-editor-name');
const durInput = document.getElementById('sequence-editor-duration'); const simBpmInput = document.getElementById('sequence-editor-simulated-bpm');
const advanceSel = document.getElementById('sequence-editor-advance-mode');
const lanesHost = document.getElementById('sequence-editor-lanes'); const lanesHost = document.getElementById('sequence-editor-lanes');
if (!modal || !nameInput || !durInput || !lanesHost) return; if (!modal || !nameInput || !lanesHost) return;
const presetsRes = await fetch('/presets', { headers: { Accept: 'application/json' } }); const presetsRes = await fetch('/presets', { headers: { Accept: 'application/json' } });
const presetsMap = presetsRes.ok ? await presetsRes.json() : {}; const presetsMap = presetsRes.ok ? await presetsRes.json() : {};
@@ -841,16 +917,12 @@ async function openSequenceEditor(sequenceId, existing) {
doc = {}; doc = {};
} }
nameInput.value = doc.name || ''; nameInput.value = doc.name || '';
durInput.value = doc.step_duration_ms != null ? String(doc.step_duration_ms) : '3000'; if (simBpmInput) {
const trInput = document.getElementById('sequence-editor-transition'); const v = parseInt(String(doc.simulated_bpm != null ? doc.simulated_bpm : 120), 10);
if (trInput) { const clamped = Number.isFinite(v) ? Math.min(300, Math.max(30, v)) : 120;
const tr = doc.sequence_transition != null ? Number(doc.sequence_transition) : 500; simBpmInput.value = String(clamped);
trInput.value = String(Number.isFinite(tr) ? Math.min(60000, Math.max(0, Math.floor(tr))) : 500);
} }
if (advanceSel) { syncSequenceBeatsPanel();
advanceSel.value = doc.advance_mode === 'beats' ? 'beats' : 'time';
}
syncSequenceAdvanceModeUi();
const lanes = normalizeSequenceLanes(doc); const lanes = normalizeSequenceLanes(doc);
lanesHost.innerHTML = ''; lanesHost.innerHTML = '';
@@ -888,9 +960,7 @@ function resolveZoneIdForPresetStripRefresh() {
async function saveSequenceEditor() { async function saveSequenceEditor() {
const nameInput = document.getElementById('sequence-editor-name'); const nameInput = document.getElementById('sequence-editor-name');
const durInput = document.getElementById('sequence-editor-duration'); const simBpmInput = document.getElementById('sequence-editor-simulated-bpm');
const trInput = document.getElementById('sequence-editor-transition');
const advanceSel = document.getElementById('sequence-editor-advance-mode');
const { lanes, lanes_group_ids } = collectLanesFromEditor(); const { lanes, lanes_group_ids } = collectLanesFromEditor();
const idxs = []; const idxs = [];
lanes.forEach((l, i) => { lanes.forEach((l, i) => {
@@ -902,17 +972,18 @@ async function saveSequenceEditor() {
} }
const nonEmpty = idxs.map((i) => lanes[i].filter((s) => s && s.preset_id)); 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 nonEmptyLg = idxs.map((i) => (lanes_group_ids[i] ? [...lanes_group_ids[i]] : []));
const advance_mode = advanceSel && advanceSel.value === 'beats' ? 'beats' : 'time'; let simulated_bpm = 120;
const trRaw = trInput && trInput.value ? parseInt(trInput.value, 10) : 500; if (simBpmInput && simBpmInput.value) {
const sequence_transition = Math.min(60000, Math.max(0, Number.isFinite(trRaw) ? trRaw : 500)); const n = parseInt(String(simBpmInput.value).trim(), 10);
if (Number.isFinite(n)) simulated_bpm = Math.min(300, Math.max(30, n));
}
const payload = { const payload = {
name: nameInput ? nameInput.value.trim() : '', name: nameInput ? nameInput.value.trim() : '',
lanes: nonEmpty, lanes: nonEmpty,
lanes_group_ids: nonEmptyLg, lanes_group_ids: nonEmptyLg,
group_ids: nonEmptyLg[0] ? [...nonEmptyLg[0]] : [], group_ids: nonEmptyLg[0] ? [...nonEmptyLg[0]] : [],
advance_mode, advance_mode: 'beats',
step_duration_ms: Math.max(200, parseInt(durInput && durInput.value ? durInput.value : '3000', 10) || 3000), simulated_bpm,
sequence_transition,
loop: true, loop: true,
steps: nonEmpty.length === 1 ? nonEmpty[0] : [], steps: nonEmpty.length === 1 ? nonEmpty[0] : [],
}; };
@@ -1089,16 +1160,6 @@ document.addEventListener('DOMContentLoaded', () => {
if (edSave) edSave.addEventListener('click', () => saveSequenceEditor()); if (edSave) edSave.addEventListener('click', () => saveSequenceEditor());
if (edDel) edDel.addEventListener('click', () => deleteCurrentSequence()); 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'); const edAddLane = document.getElementById('sequence-editor-add-lane-btn');
if (edAddLane) { if (edAddLane) {
edAddLane.addEventListener('click', async () => { edAddLane.addEventListener('click', async () => {

View File

@@ -1620,6 +1620,14 @@ body.preset-ui-run .edit-mode-only {
} }
} }
.sequence-step-drag-handle:active {
cursor: grabbing;
}
.sequence-step-row.dragging {
opacity: 0.65;
}
/* Settings modal */ /* Settings modal */
#settings-modal .modal-content { #settings-modal .modal-content {
max-width: 900px; max-width: 900px;

View File

@@ -156,7 +156,10 @@ async function fetchDevicesMap() {
async function fetchGroupsMap() { async function fetchGroupsMap() {
try { try {
const response = await fetch("/groups", { headers: { Accept: "application/json" } }); const response = await fetch("/groups", {
headers: { Accept: "application/json" },
credentials: "same-origin",
});
if (!response.ok) return {}; if (!response.ok) return {};
const data = await response.json(); const data = await response.json();
return data && typeof data === "object" ? data : {}; return data && typeof data === "object" ? data : {};
@@ -168,7 +171,7 @@ async function fetchGroupsMap() {
/** /**
* Resolve registry names + MACs for a zone document (``group_ids`` expands groups; * Resolve registry names + MACs for a zone document (``group_ids`` expands groups;
* otherwise legacy ``names``). * otherwise ``names`` only).
*/ */
async function computeZoneTargets(zone) { async function computeZoneTargets(zone) {
const dm = await fetchDevicesMap(); const dm = await fetchDevicesMap();
@@ -208,6 +211,27 @@ async function computeZoneTargets(zone) {
}; };
} }
/** Tab device list for sequences: zone ``group_ids`` first, else legacy ``names`` only. */
async function computeZoneNamesTargets(zone) {
const gids = Array.isArray(zone && zone.group_ids)
? zone.group_ids.map((x) => String(x).trim()).filter((x) => x.length > 0)
: [];
if (gids.length > 0) {
const t = await resolveTargetsFromGroupIds(gids);
return {
names: Array.isArray(t.names) ? t.names : [],
macs: Array.isArray(t.macs) ? [...new Set(t.macs.filter(Boolean))] : [],
};
}
const dm = await fetchDevicesMap();
const zoneNames = Array.isArray(zone && zone.names) ? zone.names : [];
const rows = namesToRows(zoneNames, dm);
return {
names: rowsToNames(rows),
macs: [...new Set(rows.map((r) => r.mac).filter(Boolean))],
};
}
function normalizeDeviceMac(raw) { function normalizeDeviceMac(raw) {
return String(raw || "") return String(raw || "")
.trim() .trim()
@@ -231,13 +255,8 @@ function tabPresetIdsInZoneDoc(zoneDoc) {
return (ids || []).filter(Boolean); return (ids || []).filter(Boolean);
} }
/** Group ids for a preset: explicit ``preset_group_ids[presetId]`` when non-empty, else zone ``group_ids``. */ /** Group ids used for standalone presets on this zone: zone ``group_ids`` only. */
function effectiveGroupIdsForZonePreset(zoneDoc, presetId) { function effectiveGroupIdsForZonePreset(zoneDoc) {
const pid = String(presetId);
const raw = zoneDoc && zoneDoc.preset_group_ids && zoneDoc.preset_group_ids[pid];
if (Array.isArray(raw) && raw.length > 0) {
return raw.map((x) => String(x).trim()).filter((x) => x.length > 0);
}
return Array.isArray(zoneDoc && zoneDoc.group_ids) return Array.isArray(zoneDoc && zoneDoc.group_ids)
? zoneDoc.group_ids.map((x) => String(x).trim()).filter((x) => x.length > 0) ? zoneDoc.group_ids.map((x) => String(x).trim()).filter((x) => x.length > 0)
: []; : [];
@@ -273,9 +292,10 @@ async function resolveTargetsFromGroupIds(groupIds) {
return { names, macs }; return { names, macs };
} }
/** Device names for one zone preset slot (effective groups, or whole zone by name when no groups). */ /** Device names for standalone presets: zone ``group_ids``, or all devices on the tab (``names``). */
async function resolveDeviceNamesForZonePreset(zoneDoc, presetId) { async function resolveDeviceNamesForZonePreset(zoneDoc, presetId) {
const gids = effectiveGroupIdsForZonePreset(zoneDoc, presetId); void presetId;
const gids = effectiveGroupIdsForZonePreset(zoneDoc);
if (gids.length) { if (gids.length) {
const t = await resolveTargetsFromGroupIds(gids); const t = await resolveTargetsFromGroupIds(gids);
if (t.names.length) return t.names; if (t.names.length) return t.names;
@@ -284,45 +304,17 @@ async function resolveDeviceNamesForZonePreset(zoneDoc, presetId) {
return Array.isArray(zt.names) ? zt.names.slice() : []; return Array.isArray(zt.names) ? zt.names.slice() : [];
} }
/** Union of all devices targeted by any preset on the zone (for tab strip + sequence scope). */ /** Union of devices targeted by standalone presets on the zone (same as zone preset targeting). */
async function computeZonePresetUnionTargets(zoneDoc) { async function computeZonePresetUnionTargets(zoneDoc) {
const ids = tabPresetIdsInZoneDoc(zoneDoc); return await computeZoneTargets(zoneDoc);
if (!ids.length) {
return await computeZoneTargets(zoneDoc);
}
const seen = new Set();
const names = [];
const macs = [];
for (const pid of ids) {
const gids = effectiveGroupIdsForZonePreset(zoneDoc, pid);
let t;
if (gids.length) {
t = await resolveTargetsFromGroupIds(gids);
} else {
t = await computeZoneTargets(zoneDoc);
}
const tn = Array.isArray(t.names) ? t.names : [];
const tm = Array.isArray(t.macs) ? t.macs : [];
for (let i = 0; i < tm.length; i++) {
const m = normalizeDeviceMac(tm[i]);
if (m.length !== 12 || seen.has(m)) continue;
seen.add(m);
macs.push(tm[i]);
names.push(tn[i] || m);
}
}
if (!names.length) {
return await computeZoneTargets(zoneDoc);
}
return { names, macs };
} }
/** /**
* Device names for one sequence step. Empty stepGroupIds => all zone names. * Device names for one sequence step. Empty stepGroupIds => all zone tab devices (``names`` only).
* Otherwise: devices in those groups intersected with the zone's target MACs. * 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 computeZonePresetUnionTargets(zone); const zoneT = await computeZoneNamesTargets(zone);
const names = Array.isArray(zoneT.names) ? zoneT.names : []; const names = Array.isArray(zoneT.names) ? zoneT.names : [];
const macs = Array.isArray(zoneT.macs) ? zoneT.macs : []; const macs = Array.isArray(zoneT.macs) ? zoneT.macs : [];
const gids = Array.isArray(stepGroupIds) const gids = Array.isArray(stepGroupIds)
@@ -361,7 +353,7 @@ async function resolveSequenceStepDeviceNames(zone, stepGroupIds) {
} }
async function resolveZoneDeviceMacsFromZoneData(zone) { async function resolveZoneDeviceMacsFromZoneData(zone) {
const t = await computeZonePresetUnionTargets(zone); const t = await computeZoneTargets(zone);
return t.macs; return t.macs;
} }
@@ -408,67 +400,6 @@ function rowsToNames(rows) {
return (rows || []).map((r) => String(r.name || "").trim()).filter((n) => n.length > 0); return (rows || []).map((r) => String(r.name || "").trim()).filter((n) => n.length > 0);
} }
function renderZoneDevicesEditor(containerEl, rows, devicesMap) {
if (!containerEl) return;
containerEl.innerHTML = "";
const entries = Object.entries(devicesMap || {}).sort(([a], [b]) => a.localeCompare(b));
rows.forEach((row, idx) => {
const div = document.createElement("div");
div.className = "zone-device-row profiles-row";
const label = document.createElement("span");
label.className = "zone-device-row-label";
const strong = document.createElement("strong");
strong.textContent = row.name || "—";
label.appendChild(strong);
label.appendChild(document.createTextNode(" "));
const sub = document.createElement("span");
sub.className = "muted-text";
sub.textContent = row.mac ? row.mac : "(not in registry)";
label.appendChild(sub);
const rm = document.createElement("button");
rm.type = "button";
rm.className = "btn btn-danger btn-small";
rm.textContent = "Remove";
rm.addEventListener("click", () => {
rows.splice(idx, 1);
renderZoneDevicesEditor(containerEl, rows, devicesMap);
});
div.appendChild(label);
div.appendChild(rm);
containerEl.appendChild(div);
});
const macsInRows = new Set(rows.map((r) => r.mac).filter(Boolean));
const addWrap = document.createElement("div");
addWrap.className = "zone-devices-add profiles-actions";
const sel = document.createElement("select");
sel.className = "zone-device-add-select";
sel.appendChild(new Option("Add device…", ""));
entries.forEach(([mac, d]) => {
if (macsInRows.has(mac)) return;
const labelName = d && d.name ? String(d.name).trim() : "";
const optLabel = labelName ? `${labelName}${mac}` : mac;
sel.appendChild(new Option(optLabel, mac));
});
const addBtn = document.createElement("button");
addBtn.type = "button";
addBtn.className = "btn btn-primary btn-small";
addBtn.textContent = "Add";
addBtn.addEventListener("click", () => {
const mac = sel.value;
if (!mac || !devicesMap[mac]) return;
const n = String((devicesMap[mac].name || "").trim() || mac);
rows.push({ mac, name: n });
sel.value = "";
renderZoneDevicesEditor(containerEl, rows, devicesMap);
});
addWrap.appendChild(sel);
addWrap.appendChild(addBtn);
containerEl.appendChild(addWrap);
}
function renderZoneGroupsEditor(containerEl, rows, groupsMap) { function renderZoneGroupsEditor(containerEl, rows, groupsMap) {
if (!containerEl) return; if (!containerEl) return;
containerEl.innerHTML = ""; containerEl.innerHTML = "";
@@ -530,13 +461,6 @@ function renderZoneGroupsEditor(containerEl, rows, groupsMap) {
containerEl.appendChild(addWrap); containerEl.appendChild(addWrap);
} }
/** Default group for a new zone (empty if no groups exist yet). */
async function defaultGroupIdsForNewTab() {
const gm = await fetchGroupsMap();
const ids = Object.keys(gm || {}).sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
return ids.length ? [ids[0]] : [];
}
/** Read zone device names from the presets section (JSON attr preferred; legacy comma list fallback). */ /** Read zone device names from the presets section (JSON attr preferred; legacy comma list fallback). */
function parseTabDeviceNames(section) { function parseTabDeviceNames(section) {
if (!section) return []; if (!section) return [];
@@ -566,6 +490,32 @@ function escapeHtmlAttr(s) {
.replace(/</g, "&lt;"); .replace(/</g, "&lt;");
} }
/** @returns {null | 'presets' | 'sequences'} */
function normalizeZoneContentKind(zoneDoc) {
const k = zoneDoc && zoneDoc.content_kind;
if (k === 'presets' || k === 'sequences') return k;
return null;
}
function applyZoneContentKindEditModal(kind) {
const presetsBlock = document.getElementById('edit-zone-block-presets');
const groupsBlock = document.getElementById('edit-zone-block-groups');
const seqBlock = document.getElementById('edit-zone-block-sequences');
const vis = (el, show) => {
if (el) el.style.display = show ? '' : 'none';
};
vis(groupsBlock, true);
if (!kind) {
vis(presetsBlock, true);
vis(seqBlock, true);
return;
}
vis(presetsBlock, kind === 'presets');
vis(seqBlock, kind === 'sequences');
}
window.normalizeZoneContentKind = normalizeZoneContentKind;
// Load tabs list // Load tabs list
async function loadZones() { async function loadZones() {
try { try {
@@ -623,13 +573,16 @@ function renderZonesList(tabs, tabOrder, currentZoneId) {
const zone = tabs[zoneId]; const zone = tabs[zoneId];
if (zone) { if (zone) {
const activeClass = zoneId === currentZoneId ? 'active' : ''; const activeClass = zoneId === currentZoneId ? 'active' : '';
const tabName = zone.name || `Zone ${zoneId}`; let disp = zone.name || `Zone ${zoneId}`;
const kind = normalizeZoneContentKind(zone);
if (kind === 'presets') disp += ' · presets';
else if (kind === 'sequences') disp += ' · sequences';
html += ` html += `
<button class="zone-button ${activeClass}" <button class="zone-button ${activeClass}"
data-zone-id="${zoneId}" data-zone-id="${zoneId}"
title="${editMode ? 'Click to select, right-click to edit' : 'Click to select'}" title="${editMode ? 'Click to select, right-click to edit' : 'Click to select'}"
onclick="selectZone('${zoneId}')"> onclick="selectZone('${zoneId}')">
${tabName} ${escapeHtmlAttr(disp)}
</button> </button>
`; `;
} }
@@ -669,9 +622,13 @@ function renderZonesListModal(tabs, tabOrder, currentZoneId) {
row.dataset.zoneId = String(zoneId); row.dataset.zoneId = String(zoneId);
const label = document.createElement("span"); const label = document.createElement("span");
label.textContent = (zone && zone.name) || zoneId; let disp = (zone && zone.name) || zoneId;
const kind = normalizeZoneContentKind(zone);
if (kind === 'presets') disp += ' · presets';
else if (kind === 'sequences') disp += ' · sequences';
label.textContent = disp;
if (String(zoneId) === String(currentZoneId)) { if (String(zoneId) === String(currentZoneId)) {
label.textContent = `${label.textContent}`; label.textContent = `${disp}`;
label.style.fontWeight = "bold"; label.style.fontWeight = "bold";
label.style.color = "#FFD700"; label.style.color = "#FFD700";
} }
@@ -868,7 +825,7 @@ async function loadZoneContent(zoneId) {
// Render zone content (presets section) // Render zone content (presets section)
const tabName = zone.name || `Zone ${zoneId}`; const tabName = zone.name || `Zone ${zoneId}`;
const targets = await computeZonePresetUnionTargets(zone); const targets = await computeZoneTargets(zone);
const namesJsonAttr = encodeURIComponent(JSON.stringify(targets.names)); const namesJsonAttr = encodeURIComponent(JSON.stringify(targets.names));
const macsJsonAttr = encodeURIComponent(JSON.stringify(targets.macs)); const macsJsonAttr = encodeURIComponent(JSON.stringify(targets.macs));
const legacyOk = const legacyOk =
@@ -1024,45 +981,6 @@ function tabPresetIdsInOrder(tabData) {
return tabPresetIdsInZoneDoc(tabData); return tabPresetIdsInZoneDoc(tabData);
} }
async function saveZonePresetGroupOverride(zoneId, presetId, useDefault, selectedGids) {
const tabRes = await fetch(`/zones/${zoneId}`, { headers: { Accept: "application/json" } });
if (!tabRes.ok) {
alert("Failed to load zone.");
return false;
}
const tabData = await tabRes.json();
const pg =
tabData.preset_group_ids && typeof tabData.preset_group_ids === "object"
? { ...tabData.preset_group_ids }
: {};
if (useDefault) {
delete pg[String(presetId)];
} else {
const gids = Array.isArray(selectedGids)
? selectedGids.map((x) => String(x).trim()).filter((x) => x.length > 0)
: [];
if (!gids.length) {
alert("Select at least one group, or use zone default.");
return false;
}
pg[String(presetId)] = gids;
}
tabData.preset_group_ids = pg;
const up = await fetch(`/zones/${zoneId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(tabData),
});
if (!up.ok) {
alert("Failed to save preset groups.");
return false;
}
if (typeof window.renderTabPresets === "function") {
await window.renderTabPresets(zoneId);
}
return true;
}
// Presets already on the zone (remove) and presets available to add (select). // Presets already on the zone (remove) and presets available to add (select).
async function refreshEditTabPresetsUi(zoneId) { async function refreshEditTabPresetsUi(zoneId) {
const currentEl = document.getElementById("edit-zone-presets-current"); const currentEl = document.getElementById("edit-zone-presets-current");
@@ -1081,13 +999,17 @@ async function refreshEditTabPresetsUi(zoneId) {
return; return;
} }
const tabData = await tabRes.json(); const tabData = await tabRes.json();
const kind = normalizeZoneContentKind(tabData);
if (kind === 'sequences') {
currentEl.innerHTML =
'<span class="muted-text">This zone is for sequences only. Presets are hidden.</span>';
addEl.innerHTML = '<span class="muted-text">—</span>';
return;
}
const inTabIds = tabPresetIdsInOrder(tabData); const inTabIds = tabPresetIdsInOrder(tabData);
const inTabSet = new Set(inTabIds.map((id) => String(id))); const inTabSet = new Set(inTabIds.map((id) => String(id)));
const [presetsRes, groupsMapEdit] = await Promise.all([ const presetsRes = await fetch("/presets", { headers: { Accept: "application/json" } });
fetch("/presets", { headers: { Accept: "application/json" } }),
fetchGroupsMap(),
]);
const allPresets = presetsRes.ok ? await presetsRes.json() : {}; const allPresets = presetsRes.ok ? await presetsRes.json() : {};
const makeRow = () => { const makeRow = () => {
@@ -1128,85 +1050,6 @@ async function refreshEditTabPresetsUi(zoneId) {
top.appendChild(removeBtn); top.appendChild(removeBtn);
block.appendChild(top); block.appendChild(top);
const hasExplicit =
tabData.preset_group_ids &&
typeof tabData.preset_group_ids === "object" &&
Array.isArray(tabData.preset_group_ids[presetId]) &&
tabData.preset_group_ids[presetId].length > 0;
const zoneG = Array.isArray(tabData.group_ids)
? tabData.group_ids.map((x) => String(x).trim()).filter((x) => x.length > 0)
: [];
const initialChecked = new Set(
hasExplicit
? tabData.preset_group_ids[presetId].map((x) => String(x).trim())
: zoneG,
);
const useRow = document.createElement("div");
useRow.className = "profiles-row";
useRow.style.marginTop = "0.35rem";
const useDefCb = document.createElement("input");
useDefCb.type = "checkbox";
useDefCb.id = `edit-zone-preset-use-def-${presetId}`;
useDefCb.checked = !hasExplicit;
const useDefLbl = document.createElement("label");
useDefLbl.htmlFor = useDefCb.id;
useDefLbl.style.marginLeft = "0.25rem";
useDefLbl.style.fontSize = "0.9em";
useDefLbl.textContent = "Use zone default groups";
useRow.appendChild(useDefCb);
useRow.appendChild(useDefLbl);
block.appendChild(useRow);
const boxHost = document.createElement("div");
boxHost.style.cssText = `display:${hasExplicit ? "flex" : "none"};flex-wrap:wrap;gap:0.4rem;margin-top:0.35rem;align-items:center;`;
const entries = Object.keys(groupsMapEdit || {})
.sort((a, b) => a.localeCompare(b))
.map((gid) => {
const g = groupsMapEdit[gid];
const gn = g && g.name ? String(g.name).trim() : "";
return { gid, label: gn ? `${gn} (${gid})` : `Group ${gid}` };
});
entries.forEach(({ gid, label: glabel }) => {
const id = `zpg-${zoneId}-${presetId}-${gid}`;
const lbl = document.createElement("label");
lbl.style.cssText = "display:inline-flex;align-items:center;gap:0.2rem;font-size:0.85em;";
const cb = document.createElement("input");
cb.type = "checkbox";
cb.className = "edit-zone-preset-group-cb";
cb.value = gid;
cb.id = id;
cb.checked = initialChecked.has(String(gid));
const sp = document.createElement("span");
sp.textContent = glabel;
lbl.appendChild(cb);
lbl.appendChild(sp);
boxHost.appendChild(lbl);
});
block.appendChild(boxHost);
useDefCb.addEventListener("change", () => {
boxHost.style.display = useDefCb.checked ? "none" : "flex";
});
const applyBtn = document.createElement("button");
applyBtn.type = "button";
applyBtn.className = "btn btn-primary btn-small";
applyBtn.style.marginTop = "0.4rem";
applyBtn.textContent = "Apply preset groups";
applyBtn.addEventListener("click", async () => {
const useD = !!useDefCb.checked;
const sel = [];
if (!useD) {
boxHost.querySelectorAll(".edit-zone-preset-group-cb:checked").forEach((c) => {
if (c.value) sel.push(String(c.value));
});
}
const ok = await saveZonePresetGroupOverride(zoneId, presetId, useD, sel);
if (ok) await refreshEditTabPresetsUi(zoneId);
});
block.appendChild(applyBtn);
currentEl.appendChild(block); currentEl.appendChild(block);
} }
} }
@@ -1268,7 +1111,6 @@ async function openEditZoneModal(zoneId, zone) {
const modal = document.getElementById("edit-zone-modal"); const modal = document.getElementById("edit-zone-modal");
const idInput = document.getElementById("edit-zone-id"); const idInput = document.getElementById("edit-zone-id");
const nameInput = document.getElementById("edit-zone-name"); const nameInput = document.getElementById("edit-zone-name");
const editor = document.getElementById("edit-zone-devices-editor");
let tabData = zone; let tabData = zone;
if (!tabData || typeof tabData !== "object" || tabData.error) { if (!tabData || typeof tabData !== "object" || tabData.error) {
@@ -1286,6 +1128,7 @@ async function openEditZoneModal(zoneId, zone) {
if (idInput) idInput.value = zoneId; if (idInput) idInput.value = zoneId;
if (nameInput) nameInput.value = tabData.name || ""; if (nameInput) nameInput.value = tabData.name || "";
const groupsEditor = document.getElementById("edit-zone-groups-editor");
const groupsMap = await fetchGroupsMap(); const groupsMap = await fetchGroupsMap();
const rawGids = Array.isArray(tabData.group_ids) ? tabData.group_ids : []; const rawGids = Array.isArray(tabData.group_ids) ? tabData.group_ids : [];
window.__editTabGroupRows = rawGids.map((gid) => { window.__editTabGroupRows = rawGids.map((gid) => {
@@ -1293,20 +1136,21 @@ async function openEditZoneModal(zoneId, zone) {
const g = groupsMap[id]; const g = groupsMap[id];
return { id, name: g && g.name ? String(g.name).trim() : id }; return { id, name: g && g.name ? String(g.name).trim() : id };
}); });
renderZoneGroupsEditor(editor, window.__editTabGroupRows, groupsMap); renderZoneGroupsEditor(groupsEditor, window.__editTabGroupRows, groupsMap);
if (modal) modal.classList.add("active"); if (modal) modal.classList.add("active");
applyZoneContentKindEditModal(normalizeZoneContentKind(tabData));
await refreshEditTabPresetsUi(zoneId); await refreshEditTabPresetsUi(zoneId);
if (typeof window.refreshEditTabSequencesUi === "function") { if (typeof window.refreshEditTabSequencesUi === "function") {
await window.refreshEditTabSequencesUi(zoneId); await window.refreshEditTabSequencesUi(zoneId);
} }
} }
// Update an existing zone // Update an existing zone (name, group list; devices come from groups only).
async function updateZone(zoneId, name, groupIds) { async function updateZone(zoneId, name, groupRows) {
try { try {
const gids = Array.isArray(groupIds) const gids = Array.isArray(groupRows)
? groupIds.map((x) => String(x).trim()).filter((x) => x.length > 0) ? groupRows.map((r) => String(r.id || "").trim()).filter((x) => x.length > 0)
: []; : [];
const response = await fetch(`/zones/${zoneId}`, { const response = await fetch(`/zones/${zoneId}`, {
method: 'PUT', method: 'PUT',
@@ -1315,8 +1159,9 @@ async function updateZone(zoneId, name, groupIds) {
}, },
body: JSON.stringify({ body: JSON.stringify({
name: name, name: name,
group_ids: gids,
names: [], names: [],
group_ids: gids,
preset_group_ids: {},
}) })
}); });
@@ -1339,12 +1184,11 @@ async function updateZone(zoneId, name, groupIds) {
} }
} }
// Create a new zone // Create a new zone (add devices in Edit zone). ``contentKind`` is ``'presets'`` | ``'sequences'``.
async function createZone(name, groupIds) { async function createZone(name, contentKind) {
try { try {
const gids = Array.isArray(groupIds) const ck =
? groupIds.map((x) => String(x).trim()).filter((x) => x.length > 0) contentKind === 'sequences' || contentKind === 'presets' ? contentKind : 'presets';
: [];
const response = await fetch('/zones', { const response = await fetch('/zones', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -1352,8 +1196,9 @@ async function createZone(name, groupIds) {
}, },
body: JSON.stringify({ body: JSON.stringify({
name: name, name: name,
group_ids: gids,
names: [], names: [],
group_ids: [],
content_kind: ck,
}) })
}); });
@@ -1434,8 +1279,12 @@ document.addEventListener('DOMContentLoaded', () => {
const name = newTabNameInput.value.trim(); const name = newTabNameInput.value.trim();
if (name) { if (name) {
const groupIds = await defaultGroupIdsForNewTab(); const kindRadio = document.querySelector(
await createZone(name, groupIds); 'input[name="new-zone-content-kind"]:checked',
);
const contentKind =
kindRadio && kindRadio.value === 'sequences' ? 'sequences' : 'presets';
await createZone(name, contentKind);
if (newTabNameInput) newTabNameInput.value = ""; if (newTabNameInput) newTabNameInput.value = "";
} }
}; };
@@ -1462,15 +1311,10 @@ document.addEventListener('DOMContentLoaded', () => {
const zoneId = idInput ? idInput.value : null; const zoneId = idInput ? idInput.value : null;
const name = nameInput ? nameInput.value.trim() : ""; const name = nameInput ? nameInput.value.trim() : "";
const rows = window.__editTabGroupRows || []; const groupRows = window.__editTabGroupRows || [];
const groupIds = rows.map((r) => r.id).filter(Boolean);
if (zoneId && name) { if (zoneId && name) {
if (groupIds.length === 0) { await updateZone(zoneId, name, groupRows);
alert("Add at least one device group.");
return;
}
await updateZone(zoneId, name, groupIds);
editZoneForm.reset(); editZoneForm.reset();
} }
}); });
@@ -1530,10 +1374,13 @@ window.zonesManager = {
resolveTabDeviceMacs: resolveZoneDeviceMacs, resolveTabDeviceMacs: resolveZoneDeviceMacs,
getCurrentZoneId: () => currentZoneId, getCurrentZoneId: () => currentZoneId,
computeZoneTargets, computeZoneTargets,
computeZoneNamesTargets,
computeZonePresetUnionTargets, computeZonePresetUnionTargets,
effectiveGroupIdsForZonePreset, effectiveGroupIdsForZonePreset,
resolveDeviceNamesForZonePreset, resolveDeviceNamesForZonePreset,
resolveSequenceStepDeviceNames, resolveSequenceStepDeviceNames,
fetchGroupsMap,
renderZoneGroupsEditor,
}; };
window.tabsManager = window.zonesManager; window.tabsManager = window.zonesManager;
window.tabsManager.getCurrentTabId = () => currentZoneId; window.tabsManager.getCurrentTabId = () => currentZoneId;

View File

@@ -83,6 +83,11 @@
<input type="text" id="new-zone-name" placeholder="Zone name"> <input type="text" id="new-zone-name" placeholder="Zone name">
<button class="btn btn-primary" id="create-zone-btn">Create</button> <button class="btn btn-primary" id="create-zone-btn">Create</button>
</div> </div>
<fieldset class="muted-text" style="margin:0.35rem 0 0.75rem;border:none;padding:0;">
<legend style="font-size:0.85em;margin-bottom:0.35rem;">This zone is for</legend>
<label style="margin-right:1rem;"><input type="radio" name="new-zone-content-kind" value="presets" checked> Presets</label>
<label><input type="radio" name="new-zone-content-kind" value="sequences"> Sequences</label>
</fieldset>
<div id="zones-list-modal" class="profiles-list"></div> <div id="zones-list-modal" class="profiles-list"></div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-secondary" id="zones-close-btn">Close</button> <button class="btn btn-secondary" id="zones-close-btn">Close</button>
@@ -102,16 +107,22 @@
</div> </div>
<label>Zone Name:</label> <label>Zone Name:</label>
<input type="text" id="edit-zone-name" placeholder="Enter zone name" required> <input type="text" id="edit-zone-name" placeholder="Enter zone name" required>
<label class="zone-devices-label">Device groups in this zone</label> <div id="edit-zone-block-groups">
<div id="edit-zone-devices-editor" class="zone-devices-editor"></div> <label class="zone-devices-label">Device groups on this zone</label>
<div id="edit-zone-groups-editor" class="zone-devices-editor"></div>
</div>
<div id="edit-zone-block-presets">
<label class="zone-presets-section-label">Presets on this zone</label> <label class="zone-presets-section-label">Presets on this zone</label>
<div id="edit-zone-presets-current" class="profiles-list edit-zone-presets-scroll"></div> <div id="edit-zone-presets-current" class="profiles-list edit-zone-presets-scroll"></div>
<label class="zone-presets-section-label">Add presets to this zone</label> <label class="zone-presets-section-label">Add presets to this zone</label>
<div id="edit-zone-presets-list" class="profiles-list edit-zone-presets-scroll"></div> <div id="edit-zone-presets-list" class="profiles-list edit-zone-presets-scroll"></div>
</div>
<div id="edit-zone-block-sequences">
<label class="zone-presets-section-label">Sequences on this zone</label> <label class="zone-presets-section-label">Sequences on this zone</label>
<div id="edit-zone-sequences-current" class="profiles-list edit-zone-presets-scroll"></div> <div id="edit-zone-sequences-current" class="profiles-list edit-zone-presets-scroll"></div>
<label class="zone-presets-section-label">Add a sequence to this zone</label> <label class="zone-presets-section-label">Add a sequence to this zone</label>
<div id="edit-zone-sequences-list" class="profiles-list edit-zone-presets-scroll"></div> <div id="edit-zone-sequences-list" class="profiles-list edit-zone-presets-scroll"></div>
</div>
</form> </form>
</div> </div>
</div> </div>
@@ -148,13 +159,16 @@
</div> </div>
</div> </div>
<!-- Device groups: members + WiFi driver defaults (zones reference groups) --> <!-- Device groups: members + WiFi driver defaults (zones reference groups for presets) -->
<div id="groups-modal" class="modal"> <div id="groups-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<h2>Device groups</h2> <h2>Device groups</h2>
<p class="muted-text" style="margin-top:0;">Assign drivers to a group, set WiFi defaults once per group, then attach groups to zones.</p> <p class="muted-text" style="margin-top:0;">Assign drivers to a group, set WiFi defaults once per group, then attach groups to a zone for standalone presets (sequences use each lanes groups only). By default new groups are <strong>shared</strong> across all profiles; tick “this profile only” to hide a group from other profiles.</p>
<div class="profiles-actions zone-modal-create-row"> <div class="profiles-actions zone-modal-create-row">
<input type="text" id="new-group-name" placeholder="Group name"> <input type="text" id="new-group-name" placeholder="Group name">
<label class="muted-text" style="display:inline-flex;align-items:center;gap:0.35rem;white-space:nowrap;">
<input type="checkbox" id="new-group-profile-only"> This profile only
</label>
<button class="btn btn-primary" id="create-group-btn">Create</button> <button class="btn btn-primary" id="create-group-btn">Create</button>
</div> </div>
<div id="groups-list-modal" class="profiles-list"></div> <div id="groups-list-modal" class="profiles-list"></div>
@@ -175,6 +189,10 @@
</div> </div>
<label for="edit-group-name">Group name</label> <label for="edit-group-name">Group name</label>
<input type="text" id="edit-group-name" required autocomplete="off"> <input type="text" id="edit-group-name" required autocomplete="off">
<label class="muted-text" style="display:flex;align-items:flex-start;gap:0.5rem;margin-top:0.5rem;">
<input type="checkbox" id="edit-group-share-all-profiles" style="margin-top:0.2rem;">
<span>Share with all profiles (untick to keep this group on the <strong>current profile only</strong>)</span>
</label>
<label class="zone-devices-label">Devices in this group</label> <label class="zone-devices-label">Devices in this group</label>
<div id="edit-group-devices-editor" class="zone-devices-editor"></div> <div id="edit-group-devices-editor" class="zone-devices-editor"></div>
<div class="profiles-actions" style="margin-top: 0.5rem;"> <div class="profiles-actions" style="margin-top: 0.5rem;">
@@ -315,26 +333,15 @@
<label for="sequence-editor-name">Name</label> <label for="sequence-editor-name">Name</label>
<input type="text" id="sequence-editor-name" placeholder="Sequence name" style="width:100%;max-width:24rem;"> <input type="text" id="sequence-editor-name" placeholder="Sequence name" style="width:100%;max-width:24rem;">
</div> </div>
<div class="preset-editor-field"> <div id="sequence-editor-beats-panel" style="margin:0 0 0.75rem 0;">
<label for="sequence-editor-advance-mode">Advance</label> <p class="muted-text" style="font-size:0.85em;margin:0 0 0.5rem 0;">
<select id="sequence-editor-advance-mode" style="max-width:16rem;"> Each step runs for the number of <strong>beats</strong> you set on that step.
<option value="time">Time (ms between steps)</option> When the header <strong>Audio</strong> detector is running, real beats advance the sequence.
<option value="beats">Audio beats (requires Audio detector)</option> When it is stopped, the server uses <strong>simulated</strong> beats at the BPM below.
</select> </p>
</div> <label for="sequence-editor-simulated-bpm" style="display:block;margin-bottom:0.25rem;">Simulated BPM (when audio is off)</label>
<div class="preset-editor-field" id="sequence-editor-duration-wrap"> <input type="number" id="sequence-editor-simulated-bpm" min="30" max="300" value="120" style="width:6rem;" title="Used only while the audio detector is stopped">
<label for="sequence-editor-duration">Step duration (ms), all lanes together</label> <p id="sequence-editor-bpm-live" class="muted-text" style="font-size:0.85em;margin:0.5rem 0 0 0;"></p>
<div style="display:flex;align-items:center;gap:0.6rem;flex-wrap:wrap;">
<input type="number" id="sequence-editor-duration" min="200" max="600000" value="3000" style="width:8rem;">
<span id="sequence-editor-time-bpm-hint" class="muted-text" style="font-size:0.9em;"></span>
</div>
</div>
<div class="preset-editor-field" id="sequence-editor-transition-wrap">
<label for="sequence-editor-transition">Pause before next step (ms)</label>
<input type="number" id="sequence-editor-transition" min="0" max="60000" value="500" style="width:8rem;">
</div>
<div id="sequence-editor-beats-panel" style="display:none;margin:0 0 0.75rem 0;">
<p id="sequence-editor-bpm-live" class="muted-text" style="font-size:0.85em;margin:0;"></p>
</div> </div>
<div id="sequence-editor-lanes"></div> <div id="sequence-editor-lanes"></div>
<div class="modal-actions" style="margin-top:0.75rem;"> <div class="modal-actions" style="margin-top:0.75rem;">
@@ -377,6 +384,7 @@
<label for="preset-background-input">Background</label> <label for="preset-background-input">Background</label>
<div class="profiles-actions" style="gap: 0.4rem;"> <div class="profiles-actions" style="gap: 0.4rem;">
<button type="button" class="btn btn-secondary btn-small" id="preset-background-btn" title="Choose background colour">#000000</button> <button type="button" class="btn btn-secondary btn-small" id="preset-background-btn" title="Choose background colour">#000000</button>
<button type="button" class="btn btn-secondary btn-small" id="preset-background-from-palette-btn">From Palette</button>
<input type="color" id="preset-background-input" value="#000000" title="Background colour used in patterns with background support" style="position:absolute;opacity:0;pointer-events:none;width:1px;height:1px;"> <input type="color" id="preset-background-input" value="#000000" title="Background colour used in patterns with background support" style="position:absolute;opacity:0;pointer-events:none;width:1px;height:1px;">
</div> </div>
</div> </div>

View File

@@ -280,3 +280,22 @@ class AudioBeatDetector:
with self._lock: with self._lock:
self._running = False self._running = False
self._status["running"] = False self._status["running"] = False
# Set from ``main`` so sequence playback can tell real audio from simulated beats.
_shared_beat_detector = None
def set_shared_beat_detector(det):
global _shared_beat_detector
_shared_beat_detector = det
def shared_beat_detector_running():
d = _shared_beat_detector
if d is None:
return False
try:
return bool(d.status().get("running"))
except Exception:
return False

View File

@@ -233,7 +233,7 @@ def _apply_manual_beat_route(
wire_preset_id: str, wire_preset_id: str,
preset_body: Any, preset_body: Any,
) -> None: ) -> None:
"""Enable audio→driver routing for one manual preset, or disable if invalid.""" """Enable audio→driver routing for one manual preset (clears all lanes, including sequence)."""
global _lane_manual global _lane_manual
if not device_names: if not device_names:
with _route_lock: with _route_lock:
@@ -269,6 +269,46 @@ def _apply_manual_beat_route(
_sync_public_beat_route_from_lane_table() _sync_public_beat_route_from_lane_table()
def _apply_manual_beat_route_standalone_overlay(
device_names: List[str],
wire_preset_id: str,
preset_body: Any,
) -> None:
"""Register manual beat routing on lane ``-1`` only, keeping sequence lanes ``0..n`` intact."""
global _lane_manual
if not device_names:
with _route_lock:
_lane_manual.pop(-1, None)
_sync_public_beat_route_from_lane_table()
return
if not isinstance(preset_body, dict):
with _route_lock:
_lane_manual.pop(-1, None)
_sync_public_beat_route_from_lane_table()
return
if _coerce_auto_from_body(preset_body):
with _route_lock:
_lane_manual.pop(-1, None)
_sync_public_beat_route_from_lane_table()
return
pattern = str(preset_body.get("pattern") or preset_body.get("p") or "").strip()
if pattern and not _pattern_supports_manual(pattern):
with _route_lock:
_lane_manual.pop(-1, None)
_sync_public_beat_route_from_lane_table()
return
names = [str(n).strip() for n in device_names if str(n).strip()]
with _route_lock:
_lane_manual[-1] = {
"device_names": names,
"wire_preset_id": str(wire_preset_id).strip(),
"pattern": pattern,
"manual_beat_n": _coerce_manual_beat_n(preset_body),
"beat_counter": 0,
}
_sync_public_beat_route_from_lane_table()
def set_sequence_manual_lane_route( def set_sequence_manual_lane_route(
lane_index: int, lane_index: int,
device_names: List[str], device_names: List[str],
@@ -326,7 +366,7 @@ def sync_beat_route_from_push_sequence(
sequence: List[Any], sequence: List[Any],
target_macs: Optional[List[str]] = None, target_macs: Optional[List[str]] = None,
*, *,
preserve_manual_beat_route_on_auto_select: bool = False, preserve_parallel_lane_routes: bool = False,
) -> None: ) -> None:
""" """
Update beat routing from a ``/presets/push`` body ``sequence`` (list of v1 dicts). Update beat routing from a ``/presets/push`` body ``sequence`` (list of v1 dicts).
@@ -337,9 +377,10 @@ def sync_beat_route_from_push_sequence(
is set and the merged ``presets`` contain exactly one manual preset, enable routing using is set and the merged ``presets`` contain exactly one manual preset, enable routing using
registry names for those MACs so the first advance is on the next audio beat. registry names for those MACs so the first advance is on the next audio beat.
When ``preserve_manual_beat_route_on_auto_select`` is true (zone sequence playback), an When ``preserve_parallel_lane_routes`` is true (e.g. zone sequence playback is active), an
auto preset in ``select`` does not clear manual routing — other lanes may still need auto preset in ``select`` does not clear manual routing — other lanes still receive
``notify_beat_detected`` for manual patterns in parallel. ``notify_beat_detected``. A manual preset in ``select`` is applied on lane ``-1`` only so
sequence lanes ``0..n`` keep their stride counters and wire ids.
""" """
merged_presets: Dict[str, Any] = {} merged_presets: Dict[str, Any] = {}
last_select: Optional[Dict[str, Any]] = None last_select: Optional[Dict[str, Any]] = None
@@ -361,7 +402,8 @@ def sync_beat_route_from_push_sequence(
if last_select: if last_select:
device_names = [str(k).strip() for k in last_select.keys() if str(k).strip()] device_names = [str(k).strip() for k in last_select.keys() if str(k).strip()]
if not device_names: if not device_names:
update_beat_route({"enabled": False}) if not preserve_parallel_lane_routes:
update_beat_route({"enabled": False})
return return
wire_ids: Set[str] = set() wire_ids: Set[str] = set()
@@ -372,7 +414,8 @@ def sync_beat_route_from_push_sequence(
elif val is not None: elif val is not None:
wire_ids.add(str(val).strip()) wire_ids.add(str(val).strip())
if len(wire_ids) != 1: if len(wire_ids) != 1:
update_beat_route({"enabled": False}) if not preserve_parallel_lane_routes:
update_beat_route({"enabled": False})
return return
wire_preset_id = wire_ids.pop() wire_preset_id = wire_ids.pop()
preset_body = merged_presets.get(wire_preset_id) preset_body = merged_presets.get(wire_preset_id)
@@ -382,22 +425,32 @@ def sync_beat_route_from_push_sequence(
preset_body = v preset_body = v
break break
if preset_body is None: if preset_body is None:
update_beat_route({"enabled": False}) if not preserve_parallel_lane_routes:
return
if _coerce_auto_from_body(preset_body):
if not preserve_manual_beat_route_on_auto_select:
update_beat_route({"enabled": False}) update_beat_route({"enabled": False})
return return
_apply_manual_beat_route(device_names, wire_preset_id, preset_body) if _coerce_auto_from_body(preset_body):
if not preserve_parallel_lane_routes:
update_beat_route({"enabled": False})
return
if preserve_parallel_lane_routes:
_apply_manual_beat_route_standalone_overlay(
device_names, wire_preset_id, preset_body
)
else:
_apply_manual_beat_route(device_names, wire_preset_id, preset_body)
return return
wire_id, body = _single_manual_wire_preset(merged_presets) wire_id, body = _single_manual_wire_preset(merged_presets)
if wire_id and body is not None: if wire_id and body is not None:
names = _registry_names_for_macs(target_macs) names = _registry_names_for_macs(target_macs)
_apply_manual_beat_route(names, wire_id, body) if preserve_parallel_lane_routes:
_apply_manual_beat_route_standalone_overlay(names, wire_id, body)
else:
_apply_manual_beat_route(names, wire_id, body)
return return
update_beat_route({"enabled": False}) if not preserve_parallel_lane_routes:
update_beat_route({"enabled": False})
def _pattern_supports_manual(pattern_key: str) -> bool: def _pattern_supports_manual(pattern_key: str) -> bool:

View File

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

View File

@@ -1,8 +1,7 @@
"""Server-side zone sequence playback (time or audio-beat advance). """Server-side zone sequence playback (audio beats or simulated BPM).
The browser selects a sequence and zone; this module delivers preset pushes to drivers. Steps advance on each beat from the audio detector when it is running; otherwise the server
Sequence start sends one v1 message with every preset body used in the sequence; auto steps emits beats at the sequence ``simulated_bpm`` rate until playback stops or live audio starts.
then send select-only updates. Manual steps rely on the bulk load and only update beat routing.
""" """
from __future__ import annotations from __future__ import annotations
@@ -13,12 +12,14 @@ import queue
import threading import threading
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from util.espnow_message import resolve_preset_background_hex
_thread_beat_queue: "queue.Queue[int]" = queue.Queue(maxsize=256) _thread_beat_queue: "queue.Queue[int]" = queue.Queue(maxsize=256)
_beat_consumer_started = False _beat_consumer_started = False
_beat_consumer_lock = threading.Lock() _beat_consumer_lock = threading.Lock()
_time_task: Optional[asyncio.Task] = None _sim_beat_task: Optional[asyncio.Task] = None
_time_lock = asyncio.Lock() _sim_beat_token = 0
_beat_run: Optional[Dict[str, Any]] = None _beat_run: Optional[Dict[str, Any]] = None
_beat_run_lock = threading.Lock() _beat_run_lock = threading.Lock()
@@ -91,27 +92,28 @@ def _group_ids_for_lane_step(
def _compute_zone_targets( def _compute_zone_targets(
zone_doc: Dict[str, Any], devices: Any, groups: Any zone_doc: Dict[str, Any], devices: Any, groups: Any
) -> Tuple[List[str], List[str]]: ) -> 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] = [] names: List[str] = []
macs: List[str] = [] macs: List[str] = []
if gids: gids = zone_doc.get("group_ids")
if isinstance(gids, list) and gids:
seen: set = set() seen: set = set()
for gid in gids: for gid in gids:
g = groups.read(gid) if hasattr(groups, "read") else None s = str(gid).strip()
if not s:
continue
g = groups.read(s) if hasattr(groups, "read") else None
if not isinstance(g, dict): if not isinstance(g, dict):
continue continue
devs = g.get("devices") for raw in g.get("devices") or []:
if not isinstance(devs, list):
continue
for raw in devs:
m = _norm_mac(raw) m = _norm_mac(raw)
if not m or m in seen: if not m or m in seen:
continue continue
seen.add(m) seen.add(m)
doc = devices.read(m) or {} doc = devices.read(m) if hasattr(devices, "read") else None
nm = str(doc.get("name") or "").strip() or m nm = ""
names.append(nm) if isinstance(doc, dict):
nm = str(doc.get("name") or "").strip()
names.append(nm or m)
macs.append(m) macs.append(m)
return names, macs return names, macs
zone_names = zone_doc.get("names") zone_names = zone_doc.get("names")
@@ -326,7 +328,8 @@ def _display_preset_for_step(
preset.get("palette_refs"), preset.get("palette_refs"),
palette_colors, palette_colors,
) )
return {**preset, "colors": colors} resolved_bg = resolve_preset_background_hex(preset, palette_colors)
return {**preset, "colors": colors, "background": resolved_bg}
def _preset_inner_from_display_preset(display_preset: Dict[str, Any]) -> Dict[str, Any]: def _preset_inner_from_display_preset(display_preset: Dict[str, Any]) -> Dict[str, Any]:
@@ -345,6 +348,54 @@ def _preset_inner_from_display_preset(display_preset: Dict[str, Any]) -> Dict[st
return inner return inner
def _parse_zone_brightness_value(zone_doc: Any) -> int:
"""Zone slider value stored on the zone row (0255); default 255 if unset."""
from util.brightness_combine import clamp255
if not isinstance(zone_doc, dict):
return 255
raw = zone_doc.get("brightness")
if raw is None or raw == "":
return 255
try:
return clamp255(int(raw))
except (TypeError, ValueError):
return 255
def _inner_wire_b_with_sequence_zone_brightness(
inner: Dict[str, Any],
zone_doc: Dict[str, Any],
*,
target_mac: Optional[str],
settings_obj: Any,
groups_model: Any,
devices_model: Any,
) -> Dict[str, Any]:
"""Combine preset wire ``b`` with zone brightness (and global/group/device when ``target_mac`` is set)."""
from util.brightness_combine import (
clamp255,
multiply_brightness_factors,
effective_brightness_for_mac,
)
out = dict(inner)
base = clamp255(out.get("b", 127))
zb = _parse_zone_brightness_value(zone_doc)
if target_mac and settings_obj is not None and groups_model is not None and devices_model is not None:
eff = effective_brightness_for_mac(
settings_obj,
groups_model,
devices_model,
target_mac,
zone_brightness=zb,
)
out["b"] = multiply_brightness_factors([base, eff])
else:
out["b"] = multiply_brightness_factors([base, zb])
return out
def _device_names_to_macs(device_names: List[str], devices: Any) -> List[str]: def _device_names_to_macs(device_names: List[str], devices: Any) -> List[str]:
macs: List[str] = [] macs: List[str] = []
seen: set = set() seen: set = set()
@@ -432,8 +483,24 @@ async def _deliver_sequence_presets_bulk(ctx: Dict[str, Any]) -> None:
macs = _union_macs_for_sequence(ctx) macs = _union_macs_for_sequence(ctx)
if not macs: if not macs:
return return
msg = json.dumps({"v": "1", "presets": inner_by_wire}, separators=(",", ":")) zone_doc = ctx.get("zone_doc") if isinstance(ctx.get("zone_doc"), dict) else {}
await deliver_json_messages(sender, [msg], macs, ctx["devices"], delay_s=0.05) settings_obj = ctx.get("settings")
groups_model = ctx.get("groups")
devices_model = ctx.get("devices")
delay_s = 0.05
for mac in macs:
adjusted: Dict[str, Any] = {}
for wire_pid, inner in inner_by_wire.items():
adjusted[wire_pid] = _inner_wire_b_with_sequence_zone_brightness(
inner,
zone_doc,
target_mac=mac,
settings_obj=settings_obj,
groups_model=groups_model,
devices_model=devices_model,
)
msg = json.dumps({"v": "1", "presets": adjusted}, separators=(",", ":"))
await deliver_json_messages(sender, [msg], [mac], devices_model, delay_s=delay_s)
def _coerce_auto(preset: Dict[str, Any]) -> bool: def _coerce_auto(preset: Dict[str, Any]) -> bool:
@@ -473,6 +540,9 @@ async def _deliver_preset_for_devices(
devices: Any, devices: Any,
*, *,
lane_index: Optional[int] = None, lane_index: Optional[int] = None,
zone_doc: Optional[Dict[str, Any]] = None,
settings_obj: Any = None,
groups_model: Any = None,
) -> None: ) -> None:
from models.transport import get_current_sender from models.transport import get_current_sender
from util.driver_delivery import deliver_json_messages from util.driver_delivery import deliver_json_messages
@@ -505,34 +575,61 @@ async def _deliver_preset_for_devices(
body = dict(preset_doc) body = dict(preset_doc)
auto = _coerce_auto(body) auto = _coerce_auto(body)
inner = build_preset_dict(body) inner_base = build_preset_dict(body)
mb = body.get("manual_beat_n", body.get("manualBeatN")) mb = body.get("manual_beat_n", body.get("manualBeatN"))
if mb is not None: if mb is not None:
try: try:
n = int(mb) n = int(mb)
if 1 <= n <= 64: if 1 <= n <= 64:
inner["manual_beat_n"] = n inner_base["manual_beat_n"] = n
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
wire = str(preset_id) wire = str(preset_id)
seq_list: List[Dict[str, Any]] = [{"v": "1", "presets": {wire: inner}}] zone_use = zone_doc if isinstance(zone_doc, dict) else {}
sel_append: Optional[Dict[str, Any]] = None
if auto and device_names: if auto and device_names:
sel: Dict[str, Any] = {} sel: Dict[str, Any] = {}
for n in device_names: for n in device_names:
if n: if n:
sel[str(n)] = [wire] sel[str(n)] = [wire]
if sel: if sel:
seq_list.append({"v": "1", "select": sel}) sel_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) for mac in macs:
inner = _inner_wire_b_with_sequence_zone_brightness(
inner_base,
zone_use,
target_mac=mac,
settings_obj=settings_obj,
groups_model=groups_model,
devices_model=devices,
)
seq_list: List[Dict[str, Any]] = [{"v": "1", "presets": {wire: inner}}]
if sel_append:
seq_list.append(dict(sel_append))
messages = [json.dumps(x, separators=(",", ":")) for x in seq_list]
await deliver_json_messages(sender, messages, [mac], devices, delay_s=0.05)
if not auto: if not auto:
manual_inner = _inner_wire_b_with_sequence_zone_brightness(
inner_base,
zone_use,
target_mac=macs[0] if len(macs) == 1 else None,
settings_obj=settings_obj,
groups_model=groups_model,
devices_model=devices,
)
if lane_index is not None: if lane_index is not None:
from util.beat_driver_route import set_sequence_manual_lane_route from util.beat_driver_route import set_sequence_manual_lane_route
set_sequence_manual_lane_route(lane_index, device_names, wire, inner) set_sequence_manual_lane_route(lane_index, device_names, wire, manual_inner)
else: else:
seq_one = [{"v": "1", "presets": {wire: manual_inner}}]
if sel_append:
seq_one.append(dict(sel_append))
sync_beat_route_from_push_sequence( sync_beat_route_from_push_sequence(
seq_list, target_macs=macs, preserve_manual_beat_route_on_auto_select=True seq_one, target_macs=macs, preserve_parallel_lane_routes=True
) )
@@ -595,6 +692,15 @@ async def _send_lane(
if isinstance(bulk, dict) and bulk: if isinstance(bulk, dict) and bulk:
auto = _coerce_auto(display_preset) auto = _coerce_auto(display_preset)
inner = _preset_inner_from_display_preset(display_preset) inner = _preset_inner_from_display_preset(display_preset)
zone_use = ctx.get("zone_doc") if isinstance(ctx.get("zone_doc"), dict) else {}
inner = _inner_wire_b_with_sequence_zone_brightness(
inner,
zone_use,
target_mac=macs[0] if len(macs) == 1 else None,
settings_obj=ctx.get("settings"),
groups_model=ctx.get("groups"),
devices_model=devices,
)
wire = str(preset_id) wire = str(preset_id)
if auto: if auto:
clear_sequence_manual_lane_route(lane_index) clear_sequence_manual_lane_route(lane_index)
@@ -611,7 +717,14 @@ async def _send_lane(
return return
await _deliver_preset_for_devices( await _deliver_preset_for_devices(
preset_id, display_preset, device_names, devices, lane_index=lane_index preset_id,
display_preset,
device_names,
devices,
lane_index=lane_index,
zone_doc=zone_doc,
settings_obj=ctx.get("settings"),
groups_model=groups,
) )
@@ -624,11 +737,6 @@ async def _send_all_lanes(ctx: Dict[str, Any]) -> None:
await _send_lane(i, lane_states[i], ctx) 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( def _build_ctx(
sequence_doc: Dict[str, Any], sequence_doc: Dict[str, Any],
zone_doc: Dict[str, Any], zone_doc: Dict[str, Any],
@@ -637,6 +745,7 @@ def _build_ctx(
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
from models.device import Device from models.device import Device
from models.group import Group from models.group import Group
from settings import Settings
lanes = [x for x in _normalize_sequence_lanes(sequence_doc) if len(x) > 0] lanes = [x for x in _normalize_sequence_lanes(sequence_doc) if len(x) > 0]
if not lanes: if not lanes:
@@ -655,9 +764,10 @@ def _build_ctx(
"presets_map": presets_map, "presets_map": presets_map,
"devices": devices, "devices": devices,
"groups": groups, "groups": groups,
"settings": Settings(),
"palette_colors": palette_colors, "palette_colors": palette_colors,
"loop": True, "loop": True,
"advance_mode": "beats" if _sequence_advance_beats(sequence_doc) else "time", "advance_mode": "beats",
} }
@@ -683,7 +793,6 @@ def playback_status() -> Dict[str, Any]:
if lane_states and lane0_steps > 0: if lane_states and lane0_steps > 0:
st0 = lane_states[0] st0 = lane_states[0]
idx = int(st0.get("stepIdx", 0)) idx = int(st0.get("stepIdx", 0))
advance_mode = str(ctx.get("advance_mode") or "").strip().lower()
if st0.get("done"): if st0.get("done"):
step_1based = lane0_steps step_1based = lane0_steps
sequence_beat_at = sequence_beats_per_pass sequence_beat_at = sequence_beats_per_pass
@@ -693,12 +802,8 @@ def playback_status() -> Dict[str, Any]:
step = lanes[0][idx] step = lanes[0][idx]
beats_per_step = max(1, int(step.get("beats") or 1)) beats_per_step = max(1, int(step.get("beats") or 1))
beat_count_raw = int(st0.get("beatCount", 0)) beat_count_raw = int(st0.get("beatCount", 0))
# Internal beatCount resets to 0 on step rollover; expose 1..beats_per_step in beats mode. bt = max(1, int(beats_per_step))
if advance_mode == "beats": beat_count = min(bt, max(1, beat_count_raw if beat_count_raw > 0 else 1))
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))): for j in range(min(idx, len(lane0))):
sequence_beat_at += max(1, int((lane0[j] or {}).get("beats") or 1)) sequence_beat_at += max(1, int((lane0[j] or {}).get("beats") or 1))
sequence_beat_at += beat_count sequence_beat_at += beat_count
@@ -722,10 +827,8 @@ def playback_status() -> Dict[str, Any]:
else: else:
lane0_preset_name = pid lane0_preset_name = pid
beat_readout = "" beat_readout = ""
adv_m = str(ctx.get("advance_mode") or "").strip().lower()
if ( if (
adv_m == "beats" sequence_beats_per_pass > 0
and sequence_beats_per_pass > 0
and lane_states and lane_states
and lane0_steps > 0 and lane0_steps > 0
and lane_states[0] and lane_states[0]
@@ -759,7 +862,7 @@ def playback_status() -> Dict[str, Any]:
async def process_active_beat_advance() -> None: async def process_active_beat_advance() -> None:
with _beat_run_lock: with _beat_run_lock:
ctx = _beat_run ctx = _beat_run
if not ctx or ctx.get("advance_mode") != "beats": if not ctx:
return return
lane_states: List[Dict[str, Any]] = ctx["lane_states"] lane_states: List[Dict[str, Any]] = ctx["lane_states"]
lanes: List[List[Dict[str, Any]]] = ctx["lanes"] lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
@@ -842,77 +945,54 @@ def ensure_beat_consumer_started() -> None:
loop.create_task(beat_consumer_loop()) loop.create_task(beat_consumer_loop())
_time_token = 0 def _coerce_simulated_bpm(sequence_doc: Dict[str, Any], play_options: Optional[Dict[str, Any]]) -> float:
raw = None
if isinstance(play_options, dict):
async def _time_loop(ctx: Dict[str, Any], token: int) -> None: o = play_options.get("simulated_bpm")
sequence_doc = ctx["sequence_doc"] if o is not None:
raw_dur = sequence_doc.get("step_duration_ms", 3000) raw = o
if raw is None and isinstance(sequence_doc, dict):
raw = sequence_doc.get("simulated_bpm")
try: try:
duration = max(200, int(raw_dur)) v = float(raw) if raw is not None else 120.0
except (TypeError, ValueError): except (TypeError, ValueError):
duration = 3000 v = 120.0
raw_tr = sequence_doc.get("sequence_transition") return max(30.0, min(300.0, v))
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 async def _simulated_beat_loop(ctx: Dict[str, Any], my_token: int, bpm: float) -> None:
from util import audio_detector as ad_mod
interval = 60.0 / max(30.0, min(300.0, float(bpm)))
while True: while True:
await asyncio.sleep(time_tick_lead / 1000.0)
with _beat_run_lock: with _beat_run_lock:
cur = _time_token cur_tok = _sim_beat_token
if cur != my: active = _beat_run
if cur_tok != my_token or active is None or active is not ctx:
return return
if time_sleep_tr > 0: if ad_mod.shared_beat_detector_running():
await asyncio.sleep(time_sleep_tr / 1000.0) await asyncio.sleep(0.12)
continue
await asyncio.sleep(interval)
with _beat_run_lock: with _beat_run_lock:
cur = _time_token cur_tok = _sim_beat_token
if cur != my: active = _beat_run
if cur_tok != my_token or active is None or active is not ctx:
return return
lane_states = ctx["lane_states"] if ad_mod.shared_beat_detector_running():
lanes = ctx["lanes"] continue
loop = bool(ctx.get("loop")) push_thread_beat()
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: def stop() -> None:
global _beat_run, _time_task, _time_token global _beat_run, _sim_beat_task, _sim_beat_token
with _beat_run_lock: with _beat_run_lock:
_beat_run = None _beat_run = None
_time_token += 1 _sim_beat_token += 1
t = _time_task st = _sim_beat_task
_time_task = None _sim_beat_task = None
if t and not t.done(): if st and not st.done():
t.cancel() st.cancel()
def stop_if_playing_sequence(sequence_id: str) -> bool: def stop_if_playing_sequence(sequence_id: str) -> bool:
@@ -931,8 +1011,13 @@ def stop_if_playing_sequence(sequence_id: str) -> bool:
return True return True
async def start(zone_id: str, sequence_id: str, profile_id: str) -> None: async def start(
global _beat_run, _time_task, _time_token zone_id: str,
sequence_id: str,
profile_id: str,
play_options: Optional[Dict[str, Any]] = None,
) -> None:
global _beat_run, _sim_beat_task, _sim_beat_token
from models.preset import Preset from models.preset import Preset
from models.profile import Profile from models.profile import Profile
from models.sequence import Sequence from models.sequence import Sequence
@@ -969,28 +1054,16 @@ async def start(zone_id: str, sequence_id: str, profile_id: str) -> None:
await _deliver_sequence_presets_bulk(ctx) await _deliver_sequence_presets_bulk(ctx)
advance = ctx["advance_mode"] from util.beat_driver_route import update_beat_route
if advance == "beats":
from util.beat_driver_route import update_beat_route
update_beat_route({"enabled": False}) update_beat_route({"enabled": False})
with _beat_run_lock: with _beat_run_lock:
_beat_run = ctx _beat_run = ctx
await _send_all_lanes(ctx) await _send_all_lanes(ctx)
else:
with _beat_run_lock:
_beat_run = ctx
_time_token += 1
my = _time_token
async def _run() -> None: bpm = _coerce_simulated_bpm(sequence_doc, play_options)
try: loop = asyncio.get_running_loop()
await _time_loop(ctx, my) _sim_beat_token += 1
except asyncio.CancelledError: my_tok = _sim_beat_token
pass _sim_beat_task = loop.create_task(_simulated_beat_loop(ctx, my_tok, bpm))
except Exception as e:
print(f"[sequence-playback] time loop: {e}")
loop = asyncio.get_running_loop()
_time_task = loop.create_task(_run())

View File

@@ -26,7 +26,8 @@ def test_sequence():
assert sequence["steps"] == [] assert sequence["steps"] == []
assert sequence["lanes"] == [[]] assert sequence["lanes"] == [[]]
assert sequence.get("lanes_group_ids") == [[]] assert sequence.get("lanes_group_ids") == [[]]
assert sequence.get("advance_mode") == "time" assert sequence.get("advance_mode") == "beats"
assert sequence.get("simulated_bpm") == 120
assert sequence["step_duration_ms"] == 3000 assert sequence["step_duration_ms"] == 3000
assert sequence["loop"] is True assert sequence["loop"] is True
assert sequence.get("sequence_transition") == 500 assert sequence.get("sequence_transition") == 500
@@ -42,6 +43,7 @@ def test_sequence():
"step_duration_ms": 5000, "step_duration_ms": 5000,
"loop": True, "loop": True,
"advance_mode": "beats", "advance_mode": "beats",
"simulated_bpm": 128,
} }
result = sequences.update(sequence_id, update_data) result = sequences.update(sequence_id, update_data)
assert result is True assert result is True
@@ -56,6 +58,7 @@ def test_sequence():
assert len(updated["lanes"][0]) == 2 assert len(updated["lanes"][0]) == 2
assert updated["lanes"][0][0]["beats"] == 2 assert updated["lanes"][0][0]["beats"] == 2
assert updated.get("advance_mode") == "beats" assert updated.get("advance_mode") == "beats"
assert updated.get("simulated_bpm") == 128
assert updated["step_duration_ms"] == 5000 assert updated["step_duration_ms"] == 5000
assert updated["loop"] is True assert updated["loop"] is True