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:
@@ -1,4 +1,5 @@
|
||||
from microdot import Microdot
|
||||
from microdot.session import with_session
|
||||
import asyncio
|
||||
from models.group import Group
|
||||
from models.device import Device
|
||||
@@ -13,46 +14,127 @@ groups = Group()
|
||||
devices = Device()
|
||||
_pi_settings = Settings()
|
||||
|
||||
@controller.get('')
|
||||
async def list_groups(request):
|
||||
"""List all groups."""
|
||||
return json.dumps(groups), 200, {'Content-Type': 'application/json'}
|
||||
|
||||
@controller.get('/<id>')
|
||||
async def get_group(request, id):
|
||||
"""Get a specific group by ID."""
|
||||
def _group_doc_visible_for_profile(doc, profile_id):
|
||||
if not isinstance(doc, dict):
|
||||
return False
|
||||
scoped = doc.get("profile_id")
|
||||
if scoped is None:
|
||||
scoped = doc.get("profileId")
|
||||
if scoped is None or str(scoped).strip() == "":
|
||||
return True
|
||||
if not profile_id:
|
||||
return False
|
||||
return str(scoped).strip() == str(profile_id).strip()
|
||||
|
||||
|
||||
def _filtered_groups_dict(session):
|
||||
from controllers.zone import get_current_profile_id
|
||||
|
||||
pid = get_current_profile_id(session)
|
||||
out = {}
|
||||
for gid, doc in groups.items():
|
||||
if not isinstance(doc, dict):
|
||||
continue
|
||||
if _group_doc_visible_for_profile(doc, pid):
|
||||
out[str(gid)] = doc
|
||||
return out
|
||||
|
||||
|
||||
@controller.get("")
|
||||
@with_session
|
||||
async def list_groups(request, session):
|
||||
"""List groups visible for the current profile (shared + profile-scoped)."""
|
||||
return json.dumps(_filtered_groups_dict(session)), 200, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
@controller.get("/<id>")
|
||||
@with_session
|
||||
async def get_group(request, session, id):
|
||||
"""Get a specific group by ID (404 if scoped to another profile)."""
|
||||
group = groups.read(id)
|
||||
if group:
|
||||
return json.dumps(group), 200, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"error": "Group not found"}), 404
|
||||
if not group or not isinstance(group, dict):
|
||||
return json.dumps({"error": "Group not found"}), 404
|
||||
from controllers.zone import get_current_profile_id
|
||||
|
||||
@controller.post('')
|
||||
async def create_group(request):
|
||||
"""Create a new group."""
|
||||
if not _group_doc_visible_for_profile(group, get_current_profile_id(session)):
|
||||
return json.dumps({"error": "Group not found"}), 404
|
||||
return json.dumps(group), 200, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
def _sanitize_group_profile_id_write(data, session):
|
||||
"""Allow ``profile_id`` only for the active profile, or null to share across profiles."""
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
from controllers.zone import get_current_profile_id
|
||||
|
||||
cur = get_current_profile_id(session)
|
||||
if "profile_id" not in data and "profileId" not in data:
|
||||
return
|
||||
raw = data.get("profile_id")
|
||||
if raw is None and "profileId" in data:
|
||||
raw = data.get("profileId")
|
||||
if raw is None or raw == "":
|
||||
data.pop("profileId", None)
|
||||
data["profile_id"] = None
|
||||
return
|
||||
if not cur or str(raw).strip() != str(cur).strip():
|
||||
data.pop("profileId", None)
|
||||
data.pop("profile_id", None)
|
||||
|
||||
|
||||
@controller.post("")
|
||||
@with_session
|
||||
async def create_group(request, session):
|
||||
"""Create a new group (omit ``profile_id`` for shared; or ``profile_scoped``: true for this profile only)."""
|
||||
try:
|
||||
data = request.json or {}
|
||||
data = dict(request.json or {})
|
||||
name = data.get("name", "")
|
||||
profile_scoped = bool(data.pop("profile_scoped", False))
|
||||
_sanitize_group_profile_id_write(data, session)
|
||||
group_id = groups.create(name)
|
||||
if data:
|
||||
groups.update(group_id, data)
|
||||
return json.dumps(groups.read(group_id)), 201, {'Content-Type': 'application/json'}
|
||||
if profile_scoped:
|
||||
from controllers.zone import get_current_profile_id
|
||||
|
||||
cur = get_current_profile_id(session)
|
||||
if cur:
|
||||
groups.update(group_id, {"profile_id": str(cur)})
|
||||
return json.dumps(groups.read(group_id)), 201, {"Content-Type": "application/json"}
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
@controller.put('/<id>')
|
||||
async def update_group(request, id):
|
||||
|
||||
@controller.put("/<id>")
|
||||
@with_session
|
||||
async def update_group(request, session, id):
|
||||
"""Update an existing group."""
|
||||
try:
|
||||
data = request.json
|
||||
if not isinstance(data, dict):
|
||||
return json.dumps({"error": "Invalid JSON"}), 400, {"Content-Type": "application/json"}
|
||||
data = dict(data)
|
||||
_sanitize_group_profile_id_write(data, session)
|
||||
if groups.update(id, data):
|
||||
return json.dumps(groups.read(id)), 200, {'Content-Type': 'application/json'}
|
||||
g = groups.read(id)
|
||||
if g:
|
||||
return json.dumps(g), 200, {"Content-Type": "application/json"}
|
||||
return json.dumps({"error": "Group not found"}), 404
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
@controller.delete('/<id>')
|
||||
async def delete_group(request, id):
|
||||
"""Delete a group."""
|
||||
@controller.delete("/<id>")
|
||||
@with_session
|
||||
async def delete_group(request, session, id):
|
||||
"""Delete a group (not allowed for another profile's scoped group)."""
|
||||
g = groups.read(id)
|
||||
if not g or not isinstance(g, dict):
|
||||
return json.dumps({"error": "Group not found"}), 404
|
||||
from controllers.zone import get_current_profile_id
|
||||
|
||||
if not _group_doc_visible_for_profile(g, get_current_profile_id(session)):
|
||||
return json.dumps({"error": "Group not found"}), 404
|
||||
if groups.delete(id):
|
||||
return json.dumps({"message": "Group deleted successfully"}), 200
|
||||
return json.dumps({"error": "Group not found"}), 404
|
||||
@@ -87,13 +169,25 @@ def _group_driver_config_payload(doc):
|
||||
return dc
|
||||
|
||||
|
||||
@controller.post('/<id>/driver-config')
|
||||
async def push_group_driver_config(request, id):
|
||||
def _read_group_for_session(session, id):
|
||||
g = groups.read(id)
|
||||
if not g or not isinstance(g, dict):
|
||||
return None
|
||||
from controllers.zone import get_current_profile_id
|
||||
|
||||
if not _group_doc_visible_for_profile(g, get_current_profile_id(session)):
|
||||
return None
|
||||
return g
|
||||
|
||||
|
||||
@controller.post("/<id>/driver-config")
|
||||
@with_session
|
||||
async def push_group_driver_config(request, session, id):
|
||||
"""
|
||||
Push group Wi‑Fi defaults to every Wi‑Fi device listed in the group (TCP WebSocket).
|
||||
Uses stored ``wifi_*`` fields on the group; optional JSON body may override for this send only.
|
||||
"""
|
||||
gdoc = groups.read(id)
|
||||
gdoc = _read_group_for_session(session, id)
|
||||
if not gdoc:
|
||||
return json.dumps({"error": "Group not found"}), 404
|
||||
|
||||
@@ -158,12 +252,13 @@ def _brightness_save_message_json(b_val: int) -> str:
|
||||
return json.dumps({"v": "1", "b": b_val, "save": True}, separators=(",", ":"))
|
||||
|
||||
|
||||
@controller.post('/<id>/brightness')
|
||||
async def push_group_output_brightness(request, id):
|
||||
@controller.post("/<id>/brightness")
|
||||
@with_session
|
||||
async def push_group_output_brightness(request, session, id):
|
||||
"""
|
||||
Push combined brightness (global × group(s) × device) to each member — one ``b`` per device.
|
||||
"""
|
||||
gdoc = groups.read(id)
|
||||
gdoc = _read_group_for_session(session, id)
|
||||
if not gdoc:
|
||||
return json.dumps({"error": "Group not found"}), 404
|
||||
|
||||
@@ -225,13 +320,14 @@ async def push_group_output_brightness(request, id):
|
||||
|
||||
|
||||
@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
|
||||
in parallel so all drivers in the group blink together.
|
||||
"""
|
||||
_ = request
|
||||
gdoc = groups.read(id)
|
||||
gdoc = _read_group_for_session(session, id)
|
||||
if not gdoc:
|
||||
return json.dumps({"error": "Group not found"}), 404, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from microdot import Microdot
|
||||
from microdot.session import with_session
|
||||
from models.preset import Preset
|
||||
from models.profile import Profile
|
||||
from models.pallet import Palette
|
||||
from models.device import Device, normalize_mac
|
||||
from models.transport import get_current_sender
|
||||
from util.driver_delivery import deliver_json_messages, deliver_preset_broadcast_then_per_device
|
||||
@@ -12,6 +13,18 @@ controller = Microdot()
|
||||
presets = Preset()
|
||||
profiles = Profile()
|
||||
|
||||
|
||||
def _palette_colors_for_profile(profile_id):
|
||||
prof = profiles.read(str(profile_id))
|
||||
if not isinstance(prof, dict):
|
||||
return None
|
||||
pid = prof.get("palette_id") or prof.get("paletteId")
|
||||
if not pid:
|
||||
return None
|
||||
cols = Palette().read(str(pid))
|
||||
return cols if isinstance(cols, list) else None
|
||||
|
||||
|
||||
def get_current_profile_id(session=None):
|
||||
"""Get the current active profile ID from session or fallback to first."""
|
||||
profile_list = profiles.list()
|
||||
@@ -153,6 +166,7 @@ async def send_presets(request, session):
|
||||
|
||||
# Build API-compliant preset map keyed by preset ID, include name
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
palette_colors = _palette_colors_for_profile(current_profile_id)
|
||||
presets_by_name = {}
|
||||
for pid in preset_ids:
|
||||
preset_data = presets.read(str(pid))
|
||||
@@ -161,7 +175,7 @@ async def send_presets(request, session):
|
||||
if str(preset_data.get("profile_id")) != str(current_profile_id):
|
||||
continue
|
||||
preset_key = str(pid)
|
||||
preset_payload = build_preset_dict(preset_data)
|
||||
preset_payload = build_preset_dict(preset_data, palette_colors)
|
||||
preset_payload["name"] = preset_data.get("name", "")
|
||||
presets_by_name[preset_key] = preset_payload
|
||||
|
||||
@@ -316,9 +330,13 @@ async def push_driver_messages(request, session):
|
||||
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
|
||||
|
||||
try:
|
||||
from util import sequence_playback as seq_pb
|
||||
from util.beat_driver_route import sync_beat_route_from_push_sequence
|
||||
|
||||
sync_beat_route_from_push_sequence(seq, target_macs=target_list)
|
||||
preserve = bool(seq_pb.playback_status().get("active"))
|
||||
sync_beat_route_from_push_sequence(
|
||||
seq, target_macs=target_list, preserve_parallel_lane_routes=preserve
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -197,7 +197,7 @@ async def play_sequence(request, session, id):
|
||||
try:
|
||||
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"}
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||
|
||||
@@ -291,6 +291,7 @@ async def create_zone(request, session):
|
||||
names = [i.strip() for i in ids_str.split(",") if i.strip()]
|
||||
preset_ids = None
|
||||
group_ids = []
|
||||
content_kind = None
|
||||
else:
|
||||
data = request.json or {}
|
||||
name = data.get("name", "")
|
||||
@@ -305,11 +306,13 @@ async def create_zone(request, session):
|
||||
group_ids = [str(x) for x in group_ids if x is not None]
|
||||
else:
|
||||
group_ids = []
|
||||
raw_kind = data.get("content_kind")
|
||||
content_kind = raw_kind if raw_kind in ("presets", "sequences") else None
|
||||
|
||||
if not name:
|
||||
return json.dumps({"error": "Zone name cannot be empty"}), 400
|
||||
|
||||
zid = zones.create(name, names, preset_ids, group_ids)
|
||||
zid = zones.create(name, names, preset_ids, group_ids, content_kind)
|
||||
|
||||
profile_id = get_current_profile_id(session)
|
||||
if profile_id:
|
||||
|
||||
@@ -254,6 +254,12 @@ async def main(port=80):
|
||||
|
||||
app = Microdot()
|
||||
audio_detector = AudioBeatDetector()
|
||||
try:
|
||||
from util import audio_detector as audio_detector_module
|
||||
|
||||
audio_detector_module.set_shared_beat_detector(audio_detector)
|
||||
except Exception as e:
|
||||
print(f"[startup] audio detector shared registration skipped: {e!r}")
|
||||
try:
|
||||
from util.audio_run_persist import coerce_audio_device, read_audio_run_state
|
||||
|
||||
|
||||
@@ -2,7 +2,12 @@ from models.model import Model
|
||||
|
||||
|
||||
class Group(Model):
|
||||
"""Device groups (members + optional Wi‑Fi driver defaults); also pattern fields for sequences."""
|
||||
"""Device groups (members + optional Wi‑Fi driver defaults); also pattern fields for sequences.
|
||||
|
||||
Omit ``profile_id`` (or set it null) for a **shared** group: every profile can attach it to
|
||||
zones and sequences. Set ``profile_id`` to a profile id to show the group only when that
|
||||
profile is active (still one global record in ``group.json``).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
@@ -15,6 +15,9 @@ class Preset(Model):
|
||||
if default_profile_id is not None:
|
||||
preset_data["profile_id"] = str(default_profile_id)
|
||||
changed = True
|
||||
if isinstance(preset_data, dict) and "group_ids" in preset_data:
|
||||
preset_data.pop("group_ids", None)
|
||||
changed = True
|
||||
if changed:
|
||||
self.save()
|
||||
except Exception:
|
||||
|
||||
@@ -54,9 +54,19 @@ class Sequence(Model):
|
||||
if "group_ids" not in doc or not isinstance(doc.get("group_ids"), list):
|
||||
doc["group_ids"] = []
|
||||
changed = True
|
||||
if doc.get("advance_mode") not in ("time", "beats"):
|
||||
doc["advance_mode"] = "time"
|
||||
if doc.get("advance_mode") != "beats":
|
||||
doc["advance_mode"] = "beats"
|
||||
changed = True
|
||||
if "simulated_bpm" not in doc:
|
||||
doc["simulated_bpm"] = 120
|
||||
changed = True
|
||||
else:
|
||||
try:
|
||||
sb = int(float(doc["simulated_bpm"]))
|
||||
doc["simulated_bpm"] = max(30, min(300, sb))
|
||||
except (TypeError, ValueError):
|
||||
doc["simulated_bpm"] = 120
|
||||
changed = True
|
||||
if "sequence_transition" not in doc:
|
||||
doc["sequence_transition"] = 500
|
||||
changed = True
|
||||
@@ -102,9 +112,10 @@ class Sequence(Model):
|
||||
"group_ids": [],
|
||||
"lanes": [[]],
|
||||
"lanes_group_ids": [[]],
|
||||
"advance_mode": "time",
|
||||
"advance_mode": "beats",
|
||||
"steps": [],
|
||||
"step_duration_ms": 3000,
|
||||
"simulated_bpm": 120,
|
||||
"sequence_transition": 500,
|
||||
"loop": True,
|
||||
}
|
||||
|
||||
@@ -19,7 +19,11 @@ def _maybe_migrate_tab_json_to_zone():
|
||||
|
||||
|
||||
class Zone(Model):
|
||||
"""Preset layout row (stored in ``db/zone.json``); legacy storage was ``tab.json`` / Tab."""
|
||||
"""Preset layout row (stored in ``db/zone.json``); legacy storage was ``tab.json`` / Tab.
|
||||
|
||||
Optional ``content_kind`` on a row: ``\"presets\"`` (preset tiles only) or ``\"sequences\"``
|
||||
(sequence tiles only). Omitted or unknown => both (legacy behaviour).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
if not getattr(Zone, "_migration_checked", False):
|
||||
@@ -42,12 +46,12 @@ class Zone(Model):
|
||||
if changed:
|
||||
self.save()
|
||||
|
||||
def create(self, name="", names=None, presets=None, group_ids=None):
|
||||
def create(self, name="", names=None, presets=None, group_ids=None, content_kind=None):
|
||||
next_id = self.get_next_id()
|
||||
gid_list = []
|
||||
if isinstance(group_ids, list):
|
||||
gid_list = [str(x) for x in group_ids if x is not None]
|
||||
self[next_id] = {
|
||||
gid_list = [str(x).strip() for x in group_ids if x is not None and str(x).strip()]
|
||||
doc = {
|
||||
"name": name,
|
||||
"names": names if names else [],
|
||||
"group_ids": gid_list,
|
||||
@@ -56,6 +60,9 @@ class Zone(Model):
|
||||
"default_preset": None,
|
||||
"brightness": 255,
|
||||
}
|
||||
if content_kind in ("presets", "sequences"):
|
||||
doc["content_kind"] = content_kind
|
||||
self[next_id] = doc
|
||||
self.save()
|
||||
return next_id
|
||||
|
||||
|
||||
@@ -83,10 +83,7 @@
|
||||
lastBeatConsoleKey = key;
|
||||
if (!line) return;
|
||||
const seq = /** @type {Record<string, unknown>|undefined} */ (status && status.sequence);
|
||||
const seqBeats =
|
||||
!!seq &&
|
||||
!!seq.active &&
|
||||
String(seq.advance_mode || "").toLowerCase() === "beats";
|
||||
const seqBeats = !!seq && !!seq.active;
|
||||
let out = line;
|
||||
if (seqBeats) {
|
||||
const nLanes = Number(seq && seq.num_lanes);
|
||||
@@ -122,7 +119,6 @@
|
||||
function formatSequenceBeatFractionsForLog(status) {
|
||||
const seq = /** @type {Record<string, unknown>|undefined} */ (status && status.sequence);
|
||||
if (!seq || !seq.active) return null;
|
||||
if (seq.advance_mode !== "beats") return null;
|
||||
|
||||
const laneBeatAt = Number(seq.lane0_beat_in_step);
|
||||
const laneBeatsPerStep = Number(seq.lane0_beats_per_step);
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
// Device groups: members (MAC ids) + Wi‑Fi driver defaults; persisted via /groups.
|
||||
// Without ``profile_id``, a group is shared across all profiles; with ``profile_id`` it is listed only for that profile.
|
||||
|
||||
async function getCurrentProfileIdForGroups() {
|
||||
try {
|
||||
const res = await fetch('/profiles/current', {
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
const id = data && (data.id || (data.profile && data.profile.id));
|
||||
return id != null ? String(id) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchGroupsMap() {
|
||||
try {
|
||||
const response = await fetch('/groups', { headers: { Accept: 'application/json' } });
|
||||
const response = await fetch('/groups', {
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!response.ok) return {};
|
||||
const data = await response.json();
|
||||
return data && typeof data === 'object' ? data : {};
|
||||
@@ -137,6 +156,14 @@ function refreshEditGroupDebug() {
|
||||
}
|
||||
}
|
||||
|
||||
function syncGroupShareCheckboxFromDoc(g) {
|
||||
const cb = document.getElementById('edit-group-share-all-profiles');
|
||||
if (!cb) return;
|
||||
const raw = g && (g.profile_id != null ? g.profile_id : g.profileId);
|
||||
const scoped = raw != null && String(raw).trim() !== '';
|
||||
cb.checked = !scoped;
|
||||
}
|
||||
|
||||
function loadWifiFieldsFromGroup(g) {
|
||||
const wName = document.getElementById('edit-group-wifi-driver-name');
|
||||
const wLeds = document.getElementById('edit-group-wifi-num-leds');
|
||||
@@ -189,7 +216,10 @@ async function openEditGroupModal(groupId, groupDoc) {
|
||||
let g = groupDoc;
|
||||
if (!g || typeof g !== 'object') {
|
||||
try {
|
||||
const response = await fetch(`/groups/${encodeURIComponent(groupId)}`);
|
||||
const response = await fetch(`/groups/${encodeURIComponent(groupId)}`, {
|
||||
credentials: 'same-origin',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (response.ok) g = await response.json();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -217,6 +247,7 @@ async function openEditGroupModal(groupId, groupDoc) {
|
||||
});
|
||||
renderGroupDevicesEditor(editor, window.__editGroupDeviceRows, dm);
|
||||
loadWifiFieldsFromGroup(g);
|
||||
syncGroupShareCheckboxFromDoc(g);
|
||||
refreshEditGroupDebug();
|
||||
if (modal) modal.classList.add('active');
|
||||
}
|
||||
@@ -259,8 +290,13 @@ function renderGroupsList(groups) {
|
||||
const label = document.createElement('span');
|
||||
const devs = Array.isArray(g.devices) ? g.devices : [];
|
||||
label.textContent = `${g.name || gid} (${devs.length} device${devs.length === 1 ? '' : 's'})`;
|
||||
label.style.flex = '1';
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'muted-text';
|
||||
meta.style.fontSize = '0.8em';
|
||||
const rawPid = g.profile_id != null ? g.profile_id : g.profileId;
|
||||
const scoped = rawPid != null && String(rawPid).trim() !== '';
|
||||
meta.textContent = scoped ? `This profile only (${rawPid})` : 'Shared across profiles';
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.className = 'btn btn-secondary btn-small';
|
||||
editBtn.textContent = 'Edit';
|
||||
@@ -342,7 +378,10 @@ function renderGroupsList(groups) {
|
||||
delBtn.addEventListener('click', async () => {
|
||||
if (!confirm(`Delete group "${g.name || gid}"? Zones referencing it may need updating.`)) return;
|
||||
try {
|
||||
const res = await fetch(`/groups/${encodeURIComponent(gid)}`, { method: 'DELETE' });
|
||||
const res = await fetch(`/groups/${encodeURIComponent(gid)}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (res.ok) await loadGroupsModal();
|
||||
else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
@@ -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(brightBtn);
|
||||
row.appendChild(applyBtn);
|
||||
@@ -433,11 +477,16 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const createHandler = async () => {
|
||||
const name = newNameInput && newNameInput.value.trim();
|
||||
if (!name) return;
|
||||
const profileOnly = document.getElementById('new-group-profile-only');
|
||||
try {
|
||||
const res = await fetch('/groups', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
profile_scoped: !!(profileOnly && profileOnly.checked),
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
@@ -445,6 +494,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
return;
|
||||
}
|
||||
if (newNameInput) newNameInput.value = '';
|
||||
if (profileOnly) profileOnly.checked = false;
|
||||
await loadGroupsModal();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -466,9 +516,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const { gid, payload } = collectGroupEditPayload();
|
||||
if (!gid) return;
|
||||
|
||||
const shareCb = document.getElementById('edit-group-share-all-profiles');
|
||||
if (shareCb && shareCb.checked) {
|
||||
payload.profile_id = null;
|
||||
} else {
|
||||
const pid = await getCurrentProfileIdForGroups();
|
||||
payload.profile_id = pid || null;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/groups/${encodeURIComponent(gid)}`, {
|
||||
method: 'PUT',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
const section = document.querySelector('.presets-section[data-zone-id]');
|
||||
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 gids =
|
||||
zm && typeof zm.effectiveGroupIdsForZonePreset === 'function'
|
||||
? zm.effectiveGroupIdsForZonePreset(zoneDoc, presetId)
|
||||
? zm.effectiveGroupIdsForZonePreset(zoneDoc || {})
|
||||
: Array.isArray(zoneDoc && zoneDoc.group_ids)
|
||||
? 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 presetSaveButton = document.getElementById('preset-save-btn');
|
||||
const presetAddFromPaletteButton = document.getElementById('preset-add-from-palette-btn');
|
||||
const presetBackgroundFromPaletteButton = document.getElementById('preset-background-from-palette-btn');
|
||||
|
||||
if (!presetsButton || !presetsModal || !presetsList || !presetSaveButton) {
|
||||
return;
|
||||
@@ -253,6 +254,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
let cachedPatterns = {};
|
||||
let currentPresetColors = []; // Track colors for the current preset
|
||||
let currentPresetPaletteRefs = []; // Palette index refs per color (null for direct colors)
|
||||
let currentBackgroundPaletteRef = null;
|
||||
let bgPaletteResolveGen = 0;
|
||||
|
||||
// Function to get max colors for current pattern
|
||||
const getMaxColors = () => {
|
||||
@@ -326,6 +329,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
presetBackgroundButton.style.backgroundColor = color;
|
||||
presetBackgroundButton.style.color = '#fff';
|
||||
presetBackgroundButton.style.borderColor = 'rgba(255, 255, 255, 0.6)';
|
||||
presetBackgroundButton.title =
|
||||
currentBackgroundPaletteRef != null
|
||||
? `Background from profile palette (index ${currentBackgroundPaletteRef}); click to pick a custom colour`
|
||||
: 'Choose background colour';
|
||||
};
|
||||
|
||||
const updateDelayVisibilityForManualMode = () => {
|
||||
@@ -640,9 +647,28 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
presetBrightnessInput.value = preset.brightness || 0;
|
||||
presetDelayInput.value = preset.delay || 0;
|
||||
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);
|
||||
updatePresetBackgroundButton();
|
||||
const gen = ++bgPaletteResolveGen;
|
||||
void getCurrentProfilePaletteColors().then((pal) => {
|
||||
if (gen !== bgPaletteResolveGen || !presetBackgroundInput) {
|
||||
return;
|
||||
}
|
||||
presetBackgroundInput.value = resolvePresetBackgroundHex(preset, pal);
|
||||
updatePresetBackgroundButton();
|
||||
});
|
||||
} else {
|
||||
updatePresetBackgroundButton();
|
||||
}
|
||||
updatePresetBackgroundButton();
|
||||
if (presetManualModeInput) {
|
||||
const autoVal = typeof preset.auto === 'boolean' ? preset.auto : true;
|
||||
presetManualModeInput.checked = !autoVal;
|
||||
@@ -714,6 +740,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
};
|
||||
|
||||
const clearForm = () => {
|
||||
bgPaletteResolveGen += 1;
|
||||
currentEditId = null;
|
||||
currentEditTabId = null;
|
||||
currentPresetColors = [];
|
||||
@@ -742,9 +769,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (presetManualBeatNInput) {
|
||||
presetManualBeatNInput.value = '1';
|
||||
}
|
||||
if (presetBackgroundInput) {
|
||||
presetBackgroundInput.value = '#000000';
|
||||
}
|
||||
updatePresetBackgroundButton();
|
||||
updateManualModeAvailability();
|
||||
// Re-enable name and pattern when clearing (for new preset)
|
||||
@@ -825,6 +849,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
brightness: presetBrightnessInput ? parseInt(presetBrightnessInput.value, 10) || 0 : 0,
|
||||
delay: presetDelayInput ? parseInt(presetDelayInput.value, 10) || 0 : 0,
|
||||
background: presetBackgroundInput ? presetBackgroundInput.value : '#000000',
|
||||
background_palette_ref: currentBackgroundPaletteRef != null ? currentBackgroundPaletteRef : null,
|
||||
auto: presetManualModeInput ? !presetManualModeInput.checked : true,
|
||||
manual_beat_n: (() => {
|
||||
if (!presetManualBeatNInput) return 1;
|
||||
@@ -1302,6 +1327,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
throw new Error('Failed to load zone');
|
||||
}
|
||||
const tabData = await tabResponse.json();
|
||||
const kind =
|
||||
typeof window.normalizeZoneContentKind === 'function'
|
||||
? window.normalizeZoneContentKind(tabData)
|
||||
: null;
|
||||
if (kind === 'sequences') {
|
||||
alert('This zone is for sequences only. Add a sequence from the zone Edit menu instead.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize to flat array to check and update usage
|
||||
let flat = [];
|
||||
@@ -1324,9 +1357,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const newGrid = arrayToGrid(flat, 3);
|
||||
tabData.presets = newGrid;
|
||||
tabData.presets_flat = flat;
|
||||
if (!tabData.preset_group_ids || typeof tabData.preset_group_ids !== 'object') {
|
||||
tabData.preset_group_ids = {};
|
||||
}
|
||||
|
||||
// Update zone
|
||||
const updateResponse = await fetch(`/zones/${zoneId}`, {
|
||||
@@ -1383,6 +1413,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
presetBackgroundInput.click();
|
||||
});
|
||||
presetBackgroundInput.addEventListener('input', () => {
|
||||
currentBackgroundPaletteRef = null;
|
||||
updatePresetBackgroundButton();
|
||||
});
|
||||
}
|
||||
@@ -1462,10 +1493,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const ref = parseInt(row.dataset.paletteIndex, 10);
|
||||
if (!color || !Number.isInteger(ref)) return;
|
||||
|
||||
if (currentPresetColors.includes(color) && currentPresetPaletteRefs.includes(ref)) {
|
||||
alert('That palette color is already linked.');
|
||||
return;
|
||||
}
|
||||
const maxColors = getMaxColors();
|
||||
if (currentPresetColors.length >= maxColors) {
|
||||
alert(`This pattern allows a maximum of ${maxColors} color${maxColors !== 1 ? 's' : ''}.`);
|
||||
@@ -1479,7 +1506,72 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to add from palette:', err);
|
||||
alert('Failed to load palette colors.');
|
||||
alert('Failed to load palette colours.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (presetBackgroundFromPaletteButton) {
|
||||
presetBackgroundFromPaletteButton.addEventListener('click', async () => {
|
||||
try {
|
||||
const paletteColors = await getCurrentProfilePaletteColors();
|
||||
if (!Array.isArray(paletteColors) || paletteColors.length === 0) {
|
||||
alert('No profile palette colours available.');
|
||||
return;
|
||||
}
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal active modal-child-overlay';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content">
|
||||
<h2>Pick background colour</h2>
|
||||
<div id="pick-bg-palette-list" class="profiles-list" style="max-height: 300px; overflow-y: auto;"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" id="pick-bg-palette-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const list = modal.querySelector('#pick-bg-palette-list');
|
||||
paletteColors.forEach((color, idx) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'profiles-row';
|
||||
row.style.display = 'flex';
|
||||
row.style.alignItems = 'center';
|
||||
row.style.gap = '0.75rem';
|
||||
row.dataset.paletteIndex = String(idx);
|
||||
row.dataset.paletteColor = color;
|
||||
row.innerHTML = `
|
||||
<div style="width:28px;height:28px;border-radius:4px;border:1px solid #555;background:${color};"></div>
|
||||
<span style="flex:1">${color}</span>
|
||||
<button class="btn btn-primary btn-small" type="button">Use</button>
|
||||
`;
|
||||
list.appendChild(row);
|
||||
});
|
||||
|
||||
const close = () => modal.remove();
|
||||
modal.querySelector('#pick-bg-palette-close-btn').addEventListener('click', close);
|
||||
|
||||
list.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('button');
|
||||
if (!btn) return;
|
||||
const row = e.target.closest('[data-palette-index]');
|
||||
if (!row) return;
|
||||
const color = row.dataset.paletteColor;
|
||||
const ref = parseInt(row.dataset.paletteIndex, 10);
|
||||
if (!color || !Number.isInteger(ref)) return;
|
||||
|
||||
currentBackgroundPaletteRef = ref;
|
||||
if (presetBackgroundInput) {
|
||||
presetBackgroundInput.value = color;
|
||||
}
|
||||
updatePresetBackgroundButton();
|
||||
close();
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to pick background from palette:', err);
|
||||
alert('Failed to load palette colours.');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1663,6 +1755,26 @@ const coercePresetBackground = (preset) => {
|
||||
return '#000000';
|
||||
};
|
||||
|
||||
/** Resolved background hex; uses ``background_palette_ref`` when set and palette is available. */
|
||||
const resolvePresetBackgroundHex = (preset, paletteColors) => {
|
||||
if (!preset || typeof preset !== 'object') {
|
||||
return coercePresetBackground(preset);
|
||||
}
|
||||
const rawRef =
|
||||
preset.background_palette_ref !== undefined && preset.background_palette_ref !== null
|
||||
? preset.background_palette_ref
|
||||
: preset.backgroundPaletteRef;
|
||||
const ref = typeof rawRef === 'number' ? rawRef : parseInt(String(rawRef != null ? rawRef : ''), 10);
|
||||
const pal = Array.isArray(paletteColors) ? paletteColors : [];
|
||||
if (Number.isInteger(ref) && ref >= 0 && ref < pal.length && pal[ref]) {
|
||||
const c = String(pal[ref]).trim();
|
||||
if (/^#[0-9a-fA-F]{6}$/i.test(c)) {
|
||||
return c.toUpperCase();
|
||||
}
|
||||
}
|
||||
return coercePresetBackground(preset);
|
||||
};
|
||||
|
||||
/** Audio beat stride for manual presets (led-controller only; firmware ignores this key). */
|
||||
const coerceManualBeatN = (preset) => {
|
||||
if (!preset || typeof preset !== 'object') return 1;
|
||||
@@ -1695,7 +1807,7 @@ const sendPresetViaEspNow = async (
|
||||
|
||||
const wirePresetId = devicePresetId != null ? String(devicePresetId) : String(presetId);
|
||||
const presetAuto = coercePresetAuto(preset);
|
||||
const presetBackground = coercePresetBackground(preset);
|
||||
const presetBackground = resolvePresetBackgroundHex(preset, paletteColors);
|
||||
const presetMessage = {
|
||||
v: '1',
|
||||
presets: {
|
||||
@@ -2034,6 +2146,10 @@ const renderTabPresets = async (zoneId, options = {}) => {
|
||||
}
|
||||
const tabData = await tabResponse.json();
|
||||
const groupsMapStrip = groupsStripRes.ok ? await groupsStripRes.json() : {};
|
||||
const ck =
|
||||
typeof window.normalizeZoneContentKind === 'function'
|
||||
? window.normalizeZoneContentKind(tabData)
|
||||
: null;
|
||||
|
||||
// Get presets - support both 2D grid and flat array (for backward compatibility)
|
||||
let presetGrid = tabData.presets;
|
||||
@@ -2045,6 +2161,9 @@ const renderTabPresets = async (zoneId, options = {}) => {
|
||||
// It's a flat array, convert to grid
|
||||
presetGrid = arrayToGrid(presetGrid, 3);
|
||||
}
|
||||
if (ck === 'sequences') {
|
||||
presetGrid = [];
|
||||
}
|
||||
|
||||
if (!presetsResponse.ok) {
|
||||
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)));
|
||||
pruneZonePresetSelection(zoneId, validIdSet);
|
||||
|
||||
const hasSeq =
|
||||
Array.isArray(tabData.sequence_ids) &&
|
||||
tabData.sequence_ids.some((x) => x != null && String(x).trim());
|
||||
|
||||
if (flatPresets.length === 0) {
|
||||
// Show empty message if this zone has no presets
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'muted-text';
|
||||
empty.style.gridColumn = '1 / -1'; // Span all columns
|
||||
empty.textContent = 'No presets added to this zone. Open the zone\'s Edit menu and click "Add Preset" to add one.';
|
||||
presetsList.appendChild(empty);
|
||||
if (ck === 'sequences') {
|
||||
if (!hasSeq) {
|
||||
empty.textContent =
|
||||
"No sequences on this zone yet. Open the zone's Edit menu to add one.";
|
||||
presetsList.appendChild(empty);
|
||||
}
|
||||
} else {
|
||||
empty.textContent =
|
||||
'No presets added to this zone. Open the zone\'s Edit menu and click "Add Preset" to add one.';
|
||||
presetsList.appendChild(empty);
|
||||
}
|
||||
} else {
|
||||
flatPresets.forEach((presetId) => {
|
||||
const preset = allPresets[presetId];
|
||||
@@ -2138,6 +2269,7 @@ const renderTabPresets = async (zoneId, options = {}) => {
|
||||
const displayPreset = {
|
||||
...preset,
|
||||
colors: resolveColorsWithPaletteRefs(preset.colors, preset.palette_refs, paletteColors),
|
||||
background: resolvePresetBackgroundHex(preset, paletteColors),
|
||||
};
|
||||
const wrapper = createPresetButton(
|
||||
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);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -2199,7 +2331,7 @@ const createPresetButton = (presetId, preset, zoneId, isSelected, tabData, group
|
||||
presetNameLabel.className = 'pattern-button-label';
|
||||
button.appendChild(presetNameLabel);
|
||||
|
||||
const groupsText = formatPresetTargetGroupsLine(tabData || {}, presetId, groupsMap || {});
|
||||
const groupsText = formatPresetTargetGroupsLine(tabData || {}, groupsMap || {});
|
||||
if (groupsText) {
|
||||
const groupsSpan = document.createElement('span');
|
||||
groupsSpan.className = 'preset-tile-groups';
|
||||
@@ -2253,9 +2385,6 @@ const createPresetButton = (presetId, preset, zoneId, isSelected, tabData, group
|
||||
button.addEventListener('click', () => {
|
||||
if (isDraggingPreset) return;
|
||||
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');
|
||||
ensureZonePresetSelection(zoneId);
|
||||
const z = String(zoneId);
|
||||
@@ -2421,12 +2550,6 @@ const removePresetFromTab = async (zoneId, presetId) => {
|
||||
tabData.presets = newGrid;
|
||||
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}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -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).
|
||||
|
||||
const SEQ_DEBUG_STORAGE_KEY = 'led-controller-sequence-debug';
|
||||
@@ -24,7 +24,7 @@ function stopSequenceEditorBpmPoll() {
|
||||
async function refreshSequenceEditorBpmDisplay() {
|
||||
const live = document.getElementById('sequence-editor-bpm-live');
|
||||
const panel = document.getElementById('sequence-editor-beats-panel');
|
||||
if (!live || !panel || panel.style.display === 'none') return;
|
||||
if (!live || !panel) return;
|
||||
try {
|
||||
const res = await fetch('/api/audio/status', { headers: { Accept: 'application/json' } });
|
||||
const j = res.ok ? await res.json() : {};
|
||||
@@ -39,7 +39,7 @@ async function refreshSequenceEditorBpmDisplay() {
|
||||
: NaN;
|
||||
if (!running) {
|
||||
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;
|
||||
}
|
||||
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
|
||||
* many detector beats the step runs; in Time mode the value is still the stored step beats).
|
||||
* Log each preset in the sequence with its step beat count (beats per step before advancing).
|
||||
* @param {string} sequenceId
|
||||
* @param {Record<string, unknown>} sequenceDoc
|
||||
* @param {Record<string, unknown>} presetsMap
|
||||
*/
|
||||
function logSequenceSelectionPresets(sequenceId, sequenceDoc, presetsMap) {
|
||||
if (!sequenceDoc || typeof sequenceDoc !== 'object') return;
|
||||
const adv = sequenceDoc.advance_mode === 'beats' ? 'beats' : 'time';
|
||||
const lanes = normalizeSequenceLanes(sequenceDoc);
|
||||
const nameFor = (pid) => {
|
||||
const p = presetsMap && presetsMap[pid];
|
||||
@@ -117,8 +115,8 @@ function logSequenceSelectionPresets(sequenceId, sequenceDoc, presetsMap) {
|
||||
const nm = String(sequenceDoc.name || '').trim() || sequenceId;
|
||||
const multi =
|
||||
lanes.filter((lane) => lane.some((s) => s && s.preset_id)).length > 1;
|
||||
let headerLine = `Sequence "${nm}" (${sequenceId}) — advance: ${adv}`;
|
||||
if (adv === 'beats' && multi) {
|
||||
let headerLine = `Sequence "${nm}" (${sequenceId}) — advance: beats`;
|
||||
if (multi) {
|
||||
headerLine +=
|
||||
' — 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) {
|
||||
// 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).
|
||||
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`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ zone_id: String(zoneId) }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
@@ -295,7 +300,10 @@ async function fetchSequencesMap() {
|
||||
|
||||
async function fetchGroupsMapSeq() {
|
||||
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 {};
|
||||
const data = await res.json();
|
||||
return data && typeof data === 'object' ? data : {};
|
||||
@@ -335,8 +343,11 @@ function createSequenceTileRow(sequenceId, sequenceDoc, zoneId, zoneDoc, allPres
|
||||
const lanes = normalizeSequenceLanes(sequenceDoc);
|
||||
const nLanes = lanes.filter((l) => l.length > 0).length || 1;
|
||||
const nSteps = lanes.reduce((a, l) => a + l.length, 0);
|
||||
const adv = sequenceDoc.advance_mode === 'beats' ? 'beats' : 'time';
|
||||
sub.textContent = `${nLanes} lane${nLanes === 1 ? '' : 's'} · ${nSteps} step${nSteps === 1 ? '' : 's'} · ${adv}`;
|
||||
const simRaw = sequenceDoc.simulated_bpm;
|
||||
let sim = parseInt(String(simRaw != null ? simRaw : 120), 10);
|
||||
if (!Number.isFinite(sim)) sim = 120;
|
||||
sim = Math.min(300, Math.max(30, sim));
|
||||
sub.textContent = `${nLanes} lane${nLanes === 1 ? '' : 's'} · ${nSteps} step${nSteps === 1 ? '' : 's'} · beats · ${sim} BPM sim`;
|
||||
button.appendChild(sub);
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
@@ -443,6 +454,14 @@ async function addSequenceToTab(sequenceId, zoneId) {
|
||||
const tabResponse = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
|
||||
if (!tabResponse.ok) throw new Error('Failed to load zone');
|
||||
const tabData = await tabResponse.json();
|
||||
const 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) : [];
|
||||
if (list.includes(String(sequenceId))) {
|
||||
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' } });
|
||||
if (!zoneRes.ok) throw new Error('zone');
|
||||
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 seqMap = await fetchSequencesMap();
|
||||
const onSet = new Set(onZone);
|
||||
@@ -586,6 +615,77 @@ async function refreshEditTabSequencesUi(zoneId) {
|
||||
|
||||
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) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'sequence-step-row profiles-row';
|
||||
@@ -594,6 +694,15 @@ function renderSequenceStepRow(presetsMap, step) {
|
||||
|
||||
const top = document.createElement('div');
|
||||
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');
|
||||
presetWrap.style.cssText = 'display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;';
|
||||
const pl = document.createElement('label');
|
||||
@@ -658,6 +767,7 @@ function renderSequenceStepRow(presetsMap, step) {
|
||||
);
|
||||
});
|
||||
|
||||
top.appendChild(dragHandle);
|
||||
top.appendChild(presetWrap);
|
||||
top.appendChild(beatWrap);
|
||||
top.appendChild(editPresetBtn);
|
||||
@@ -720,6 +830,7 @@ function renderSequenceLane(laneIndex, laneSteps, laneGroupIds, presetsMap, grou
|
||||
steps.forEach((s) => {
|
||||
stepsHost.appendChild(renderSequenceStepRow(presetsMap, s));
|
||||
});
|
||||
wireSequenceLaneStepsDragReorder(stepsHost);
|
||||
wrap.appendChild(stepsHost);
|
||||
return wrap;
|
||||
}
|
||||
@@ -763,57 +874,22 @@ function collectLanesFromEditor() {
|
||||
return { lanes, lanes_group_ids };
|
||||
}
|
||||
|
||||
function updateSequenceEditorTimeBpmHint() {
|
||||
const hint = document.getElementById('sequence-editor-time-bpm-hint');
|
||||
const durInput = document.getElementById('sequence-editor-duration');
|
||||
const sel = document.getElementById('sequence-editor-advance-mode');
|
||||
if (!hint) return;
|
||||
if (sel && sel.value === 'beats') {
|
||||
hint.textContent = '';
|
||||
return;
|
||||
}
|
||||
const raw = durInput && durInput.value;
|
||||
const parsed = parseInt(String(raw != null ? raw : '').trim(), 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
hint.textContent = '';
|
||||
return;
|
||||
}
|
||||
const ms = Math.max(200, parsed);
|
||||
const bpm = 60000 / ms;
|
||||
let rounded;
|
||||
if (bpm >= 100) rounded = Math.round(bpm * 10) / 10;
|
||||
else if (bpm >= 10) rounded = Math.round(bpm * 100) / 100;
|
||||
else rounded = Math.round(bpm * 1000) / 1000;
|
||||
hint.textContent = `${rounded} BPM`;
|
||||
}
|
||||
|
||||
function syncSequenceAdvanceModeUi() {
|
||||
const sel = document.getElementById('sequence-editor-advance-mode');
|
||||
const dw = document.getElementById('sequence-editor-duration-wrap');
|
||||
const tw = document.getElementById('sequence-editor-transition-wrap');
|
||||
function syncSequenceBeatsPanel() {
|
||||
const panel = document.getElementById('sequence-editor-beats-panel');
|
||||
const beatsMode = sel && sel.value === 'beats';
|
||||
if (dw) dw.style.display = beatsMode ? 'none' : 'block';
|
||||
if (tw) tw.style.display = beatsMode ? 'none' : 'block';
|
||||
stopSequenceEditorBpmPoll();
|
||||
if (beatsMode && panel) {
|
||||
panel.style.display = 'block';
|
||||
if (panel) {
|
||||
void refreshSequenceEditorBpmDisplay();
|
||||
sequenceBpmPollTimer = setInterval(() => void refreshSequenceEditorBpmDisplay(), 1500);
|
||||
} else if (panel) {
|
||||
panel.style.display = 'none';
|
||||
}
|
||||
updateSequenceEditorTimeBpmHint();
|
||||
}
|
||||
|
||||
async function openSequenceEditor(sequenceId, existing) {
|
||||
sequenceEditorId = sequenceId != null && String(sequenceId).length ? String(sequenceId) : null;
|
||||
const modal = document.getElementById('sequence-editor-modal');
|
||||
const nameInput = document.getElementById('sequence-editor-name');
|
||||
const durInput = document.getElementById('sequence-editor-duration');
|
||||
const advanceSel = document.getElementById('sequence-editor-advance-mode');
|
||||
const simBpmInput = document.getElementById('sequence-editor-simulated-bpm');
|
||||
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 presetsMap = presetsRes.ok ? await presetsRes.json() : {};
|
||||
@@ -841,16 +917,12 @@ async function openSequenceEditor(sequenceId, existing) {
|
||||
doc = {};
|
||||
}
|
||||
nameInput.value = doc.name || '';
|
||||
durInput.value = doc.step_duration_ms != null ? String(doc.step_duration_ms) : '3000';
|
||||
const trInput = document.getElementById('sequence-editor-transition');
|
||||
if (trInput) {
|
||||
const tr = doc.sequence_transition != null ? Number(doc.sequence_transition) : 500;
|
||||
trInput.value = String(Number.isFinite(tr) ? Math.min(60000, Math.max(0, Math.floor(tr))) : 500);
|
||||
if (simBpmInput) {
|
||||
const v = parseInt(String(doc.simulated_bpm != null ? doc.simulated_bpm : 120), 10);
|
||||
const clamped = Number.isFinite(v) ? Math.min(300, Math.max(30, v)) : 120;
|
||||
simBpmInput.value = String(clamped);
|
||||
}
|
||||
if (advanceSel) {
|
||||
advanceSel.value = doc.advance_mode === 'beats' ? 'beats' : 'time';
|
||||
}
|
||||
syncSequenceAdvanceModeUi();
|
||||
syncSequenceBeatsPanel();
|
||||
|
||||
const lanes = normalizeSequenceLanes(doc);
|
||||
lanesHost.innerHTML = '';
|
||||
@@ -888,9 +960,7 @@ function resolveZoneIdForPresetStripRefresh() {
|
||||
|
||||
async function saveSequenceEditor() {
|
||||
const nameInput = document.getElementById('sequence-editor-name');
|
||||
const durInput = document.getElementById('sequence-editor-duration');
|
||||
const trInput = document.getElementById('sequence-editor-transition');
|
||||
const advanceSel = document.getElementById('sequence-editor-advance-mode');
|
||||
const simBpmInput = document.getElementById('sequence-editor-simulated-bpm');
|
||||
const { lanes, lanes_group_ids } = collectLanesFromEditor();
|
||||
const idxs = [];
|
||||
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 nonEmptyLg = idxs.map((i) => (lanes_group_ids[i] ? [...lanes_group_ids[i]] : []));
|
||||
const advance_mode = advanceSel && advanceSel.value === 'beats' ? 'beats' : 'time';
|
||||
const trRaw = trInput && trInput.value ? parseInt(trInput.value, 10) : 500;
|
||||
const sequence_transition = Math.min(60000, Math.max(0, Number.isFinite(trRaw) ? trRaw : 500));
|
||||
let simulated_bpm = 120;
|
||||
if (simBpmInput && simBpmInput.value) {
|
||||
const n = parseInt(String(simBpmInput.value).trim(), 10);
|
||||
if (Number.isFinite(n)) simulated_bpm = Math.min(300, Math.max(30, n));
|
||||
}
|
||||
const payload = {
|
||||
name: nameInput ? nameInput.value.trim() : '',
|
||||
lanes: nonEmpty,
|
||||
lanes_group_ids: nonEmptyLg,
|
||||
group_ids: nonEmptyLg[0] ? [...nonEmptyLg[0]] : [],
|
||||
advance_mode,
|
||||
step_duration_ms: Math.max(200, parseInt(durInput && durInput.value ? durInput.value : '3000', 10) || 3000),
|
||||
sequence_transition,
|
||||
advance_mode: 'beats',
|
||||
simulated_bpm,
|
||||
loop: true,
|
||||
steps: nonEmpty.length === 1 ? nonEmpty[0] : [],
|
||||
};
|
||||
@@ -1089,16 +1160,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (edSave) edSave.addEventListener('click', () => saveSequenceEditor());
|
||||
if (edDel) edDel.addEventListener('click', () => deleteCurrentSequence());
|
||||
|
||||
const advanceSel = document.getElementById('sequence-editor-advance-mode');
|
||||
if (advanceSel) {
|
||||
advanceSel.addEventListener('change', () => syncSequenceAdvanceModeUi());
|
||||
}
|
||||
const durForBpmHint = document.getElementById('sequence-editor-duration');
|
||||
if (durForBpmHint) {
|
||||
durForBpmHint.addEventListener('input', () => updateSequenceEditorTimeBpmHint());
|
||||
durForBpmHint.addEventListener('change', () => updateSequenceEditorTimeBpmHint());
|
||||
}
|
||||
|
||||
const edAddLane = document.getElementById('sequence-editor-add-lane-btn');
|
||||
if (edAddLane) {
|
||||
edAddLane.addEventListener('click', async () => {
|
||||
|
||||
@@ -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 .modal-content {
|
||||
max-width: 900px;
|
||||
|
||||
@@ -156,7 +156,10 @@ async function fetchDevicesMap() {
|
||||
|
||||
async function fetchGroupsMap() {
|
||||
try {
|
||||
const response = await fetch("/groups", { headers: { Accept: "application/json" } });
|
||||
const response = await fetch("/groups", {
|
||||
headers: { Accept: "application/json" },
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!response.ok) return {};
|
||||
const data = await response.json();
|
||||
return data && typeof data === "object" ? data : {};
|
||||
@@ -168,7 +171,7 @@ async function fetchGroupsMap() {
|
||||
|
||||
/**
|
||||
* Resolve registry names + MACs for a zone document (``group_ids`` expands groups;
|
||||
* otherwise legacy ``names``).
|
||||
* otherwise ``names`` only).
|
||||
*/
|
||||
async function computeZoneTargets(zone) {
|
||||
const dm = await fetchDevicesMap();
|
||||
@@ -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) {
|
||||
return String(raw || "")
|
||||
.trim()
|
||||
@@ -231,13 +255,8 @@ function tabPresetIdsInZoneDoc(zoneDoc) {
|
||||
return (ids || []).filter(Boolean);
|
||||
}
|
||||
|
||||
/** Group ids for a preset: explicit ``preset_group_ids[presetId]`` when non-empty, else zone ``group_ids``. */
|
||||
function effectiveGroupIdsForZonePreset(zoneDoc, presetId) {
|
||||
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);
|
||||
}
|
||||
/** Group ids used for standalone presets on this zone: zone ``group_ids`` only. */
|
||||
function effectiveGroupIdsForZonePreset(zoneDoc) {
|
||||
return Array.isArray(zoneDoc && zoneDoc.group_ids)
|
||||
? zoneDoc.group_ids.map((x) => String(x).trim()).filter((x) => x.length > 0)
|
||||
: [];
|
||||
@@ -273,9 +292,10 @@ async function resolveTargetsFromGroupIds(groupIds) {
|
||||
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) {
|
||||
const gids = effectiveGroupIdsForZonePreset(zoneDoc, presetId);
|
||||
void presetId;
|
||||
const gids = effectiveGroupIdsForZonePreset(zoneDoc);
|
||||
if (gids.length) {
|
||||
const t = await resolveTargetsFromGroupIds(gids);
|
||||
if (t.names.length) return t.names;
|
||||
@@ -284,45 +304,17 @@ async function resolveDeviceNamesForZonePreset(zoneDoc, presetId) {
|
||||
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) {
|
||||
const ids = tabPresetIdsInZoneDoc(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 };
|
||||
return await computeZoneTargets(zoneDoc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Device names for one sequence step. Empty stepGroupIds => all zone names.
|
||||
* Otherwise: devices in those groups intersected with the zone's target MACs.
|
||||
* Device names for one sequence step. Empty stepGroupIds => all zone tab devices (``names`` only).
|
||||
* Otherwise: lane groups intersected with that tab device list (not zone ``group_ids``).
|
||||
*/
|
||||
async function resolveSequenceStepDeviceNames(zone, stepGroupIds) {
|
||||
const zoneT = await computeZonePresetUnionTargets(zone);
|
||||
const zoneT = await computeZoneNamesTargets(zone);
|
||||
const names = Array.isArray(zoneT.names) ? zoneT.names : [];
|
||||
const macs = Array.isArray(zoneT.macs) ? zoneT.macs : [];
|
||||
const gids = Array.isArray(stepGroupIds)
|
||||
@@ -361,7 +353,7 @@ async function resolveSequenceStepDeviceNames(zone, stepGroupIds) {
|
||||
}
|
||||
|
||||
async function resolveZoneDeviceMacsFromZoneData(zone) {
|
||||
const t = await computeZonePresetUnionTargets(zone);
|
||||
const t = await computeZoneTargets(zone);
|
||||
return t.macs;
|
||||
}
|
||||
|
||||
@@ -408,67 +400,6 @@ function rowsToNames(rows) {
|
||||
return (rows || []).map((r) => String(r.name || "").trim()).filter((n) => n.length > 0);
|
||||
}
|
||||
|
||||
function renderZoneDevicesEditor(containerEl, rows, devicesMap) {
|
||||
if (!containerEl) return;
|
||||
containerEl.innerHTML = "";
|
||||
const entries = Object.entries(devicesMap || {}).sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
rows.forEach((row, idx) => {
|
||||
const div = document.createElement("div");
|
||||
div.className = "zone-device-row profiles-row";
|
||||
const label = document.createElement("span");
|
||||
label.className = "zone-device-row-label";
|
||||
const strong = document.createElement("strong");
|
||||
strong.textContent = row.name || "—";
|
||||
label.appendChild(strong);
|
||||
label.appendChild(document.createTextNode(" "));
|
||||
const sub = document.createElement("span");
|
||||
sub.className = "muted-text";
|
||||
sub.textContent = row.mac ? row.mac : "(not in registry)";
|
||||
label.appendChild(sub);
|
||||
|
||||
const rm = document.createElement("button");
|
||||
rm.type = "button";
|
||||
rm.className = "btn btn-danger btn-small";
|
||||
rm.textContent = "Remove";
|
||||
rm.addEventListener("click", () => {
|
||||
rows.splice(idx, 1);
|
||||
renderZoneDevicesEditor(containerEl, rows, devicesMap);
|
||||
});
|
||||
div.appendChild(label);
|
||||
div.appendChild(rm);
|
||||
containerEl.appendChild(div);
|
||||
});
|
||||
|
||||
const macsInRows = new Set(rows.map((r) => r.mac).filter(Boolean));
|
||||
const addWrap = document.createElement("div");
|
||||
addWrap.className = "zone-devices-add profiles-actions";
|
||||
const sel = document.createElement("select");
|
||||
sel.className = "zone-device-add-select";
|
||||
sel.appendChild(new Option("Add device…", ""));
|
||||
entries.forEach(([mac, d]) => {
|
||||
if (macsInRows.has(mac)) return;
|
||||
const labelName = d && d.name ? String(d.name).trim() : "";
|
||||
const optLabel = labelName ? `${labelName} — ${mac}` : mac;
|
||||
sel.appendChild(new Option(optLabel, mac));
|
||||
});
|
||||
const addBtn = document.createElement("button");
|
||||
addBtn.type = "button";
|
||||
addBtn.className = "btn btn-primary btn-small";
|
||||
addBtn.textContent = "Add";
|
||||
addBtn.addEventListener("click", () => {
|
||||
const mac = sel.value;
|
||||
if (!mac || !devicesMap[mac]) return;
|
||||
const n = String((devicesMap[mac].name || "").trim() || mac);
|
||||
rows.push({ mac, name: n });
|
||||
sel.value = "";
|
||||
renderZoneDevicesEditor(containerEl, rows, devicesMap);
|
||||
});
|
||||
addWrap.appendChild(sel);
|
||||
addWrap.appendChild(addBtn);
|
||||
containerEl.appendChild(addWrap);
|
||||
}
|
||||
|
||||
function renderZoneGroupsEditor(containerEl, rows, groupsMap) {
|
||||
if (!containerEl) return;
|
||||
containerEl.innerHTML = "";
|
||||
@@ -530,13 +461,6 @@ function renderZoneGroupsEditor(containerEl, rows, groupsMap) {
|
||||
containerEl.appendChild(addWrap);
|
||||
}
|
||||
|
||||
/** Default group for a new zone (empty if no groups exist yet). */
|
||||
async function defaultGroupIdsForNewTab() {
|
||||
const gm = await fetchGroupsMap();
|
||||
const ids = Object.keys(gm || {}).sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
|
||||
return ids.length ? [ids[0]] : [];
|
||||
}
|
||||
|
||||
/** Read zone device names from the presets section (JSON attr preferred; legacy comma list fallback). */
|
||||
function parseTabDeviceNames(section) {
|
||||
if (!section) return [];
|
||||
@@ -566,6 +490,32 @@ function escapeHtmlAttr(s) {
|
||||
.replace(/</g, "<");
|
||||
}
|
||||
|
||||
/** @returns {null | 'presets' | 'sequences'} */
|
||||
function normalizeZoneContentKind(zoneDoc) {
|
||||
const k = zoneDoc && zoneDoc.content_kind;
|
||||
if (k === 'presets' || k === 'sequences') return k;
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyZoneContentKindEditModal(kind) {
|
||||
const presetsBlock = document.getElementById('edit-zone-block-presets');
|
||||
const groupsBlock = document.getElementById('edit-zone-block-groups');
|
||||
const seqBlock = document.getElementById('edit-zone-block-sequences');
|
||||
const vis = (el, show) => {
|
||||
if (el) el.style.display = show ? '' : 'none';
|
||||
};
|
||||
vis(groupsBlock, true);
|
||||
if (!kind) {
|
||||
vis(presetsBlock, true);
|
||||
vis(seqBlock, true);
|
||||
return;
|
||||
}
|
||||
vis(presetsBlock, kind === 'presets');
|
||||
vis(seqBlock, kind === 'sequences');
|
||||
}
|
||||
|
||||
window.normalizeZoneContentKind = normalizeZoneContentKind;
|
||||
|
||||
// Load tabs list
|
||||
async function loadZones() {
|
||||
try {
|
||||
@@ -623,13 +573,16 @@ function renderZonesList(tabs, tabOrder, currentZoneId) {
|
||||
const zone = tabs[zoneId];
|
||||
if (zone) {
|
||||
const activeClass = zoneId === currentZoneId ? 'active' : '';
|
||||
const tabName = zone.name || `Zone ${zoneId}`;
|
||||
let disp = zone.name || `Zone ${zoneId}`;
|
||||
const kind = normalizeZoneContentKind(zone);
|
||||
if (kind === 'presets') disp += ' · presets';
|
||||
else if (kind === 'sequences') disp += ' · sequences';
|
||||
html += `
|
||||
<button class="zone-button ${activeClass}"
|
||||
data-zone-id="${zoneId}"
|
||||
title="${editMode ? 'Click to select, right-click to edit' : 'Click to select'}"
|
||||
onclick="selectZone('${zoneId}')">
|
||||
${tabName}
|
||||
${escapeHtmlAttr(disp)}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
@@ -669,9 +622,13 @@ function renderZonesListModal(tabs, tabOrder, currentZoneId) {
|
||||
row.dataset.zoneId = String(zoneId);
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.textContent = (zone && zone.name) || zoneId;
|
||||
let disp = (zone && zone.name) || zoneId;
|
||||
const kind = normalizeZoneContentKind(zone);
|
||||
if (kind === 'presets') disp += ' · presets';
|
||||
else if (kind === 'sequences') disp += ' · sequences';
|
||||
label.textContent = disp;
|
||||
if (String(zoneId) === String(currentZoneId)) {
|
||||
label.textContent = `✓ ${label.textContent}`;
|
||||
label.textContent = `✓ ${disp}`;
|
||||
label.style.fontWeight = "bold";
|
||||
label.style.color = "#FFD700";
|
||||
}
|
||||
@@ -868,7 +825,7 @@ async function loadZoneContent(zoneId) {
|
||||
|
||||
// Render zone content (presets section)
|
||||
const tabName = zone.name || `Zone ${zoneId}`;
|
||||
const targets = await computeZonePresetUnionTargets(zone);
|
||||
const targets = await computeZoneTargets(zone);
|
||||
const namesJsonAttr = encodeURIComponent(JSON.stringify(targets.names));
|
||||
const macsJsonAttr = encodeURIComponent(JSON.stringify(targets.macs));
|
||||
const legacyOk =
|
||||
@@ -1024,45 +981,6 @@ function tabPresetIdsInOrder(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).
|
||||
async function refreshEditTabPresetsUi(zoneId) {
|
||||
const currentEl = document.getElementById("edit-zone-presets-current");
|
||||
@@ -1081,13 +999,17 @@ async function refreshEditTabPresetsUi(zoneId) {
|
||||
return;
|
||||
}
|
||||
const tabData = await tabRes.json();
|
||||
const kind = normalizeZoneContentKind(tabData);
|
||||
if (kind === 'sequences') {
|
||||
currentEl.innerHTML =
|
||||
'<span class="muted-text">This zone is for sequences only. Presets are hidden.</span>';
|
||||
addEl.innerHTML = '<span class="muted-text">—</span>';
|
||||
return;
|
||||
}
|
||||
const inTabIds = tabPresetIdsInOrder(tabData);
|
||||
const inTabSet = new Set(inTabIds.map((id) => String(id)));
|
||||
|
||||
const [presetsRes, groupsMapEdit] = await Promise.all([
|
||||
fetch("/presets", { headers: { Accept: "application/json" } }),
|
||||
fetchGroupsMap(),
|
||||
]);
|
||||
const presetsRes = await fetch("/presets", { headers: { Accept: "application/json" } });
|
||||
const allPresets = presetsRes.ok ? await presetsRes.json() : {};
|
||||
|
||||
const makeRow = () => {
|
||||
@@ -1128,85 +1050,6 @@ async function refreshEditTabPresetsUi(zoneId) {
|
||||
top.appendChild(removeBtn);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1268,7 +1111,6 @@ async function openEditZoneModal(zoneId, zone) {
|
||||
const modal = document.getElementById("edit-zone-modal");
|
||||
const idInput = document.getElementById("edit-zone-id");
|
||||
const nameInput = document.getElementById("edit-zone-name");
|
||||
const editor = document.getElementById("edit-zone-devices-editor");
|
||||
|
||||
let tabData = zone;
|
||||
if (!tabData || typeof tabData !== "object" || tabData.error) {
|
||||
@@ -1286,6 +1128,7 @@ async function openEditZoneModal(zoneId, zone) {
|
||||
if (idInput) idInput.value = zoneId;
|
||||
if (nameInput) nameInput.value = tabData.name || "";
|
||||
|
||||
const groupsEditor = document.getElementById("edit-zone-groups-editor");
|
||||
const groupsMap = await fetchGroupsMap();
|
||||
const rawGids = Array.isArray(tabData.group_ids) ? tabData.group_ids : [];
|
||||
window.__editTabGroupRows = rawGids.map((gid) => {
|
||||
@@ -1293,20 +1136,21 @@ async function openEditZoneModal(zoneId, zone) {
|
||||
const g = groupsMap[id];
|
||||
return { id, name: g && g.name ? String(g.name).trim() : id };
|
||||
});
|
||||
renderZoneGroupsEditor(editor, window.__editTabGroupRows, groupsMap);
|
||||
renderZoneGroupsEditor(groupsEditor, window.__editTabGroupRows, groupsMap);
|
||||
|
||||
if (modal) modal.classList.add("active");
|
||||
applyZoneContentKindEditModal(normalizeZoneContentKind(tabData));
|
||||
await refreshEditTabPresetsUi(zoneId);
|
||||
if (typeof window.refreshEditTabSequencesUi === "function") {
|
||||
await window.refreshEditTabSequencesUi(zoneId);
|
||||
}
|
||||
}
|
||||
|
||||
// Update an existing zone
|
||||
async function updateZone(zoneId, name, groupIds) {
|
||||
// Update an existing zone (name, group list; devices come from groups only).
|
||||
async function updateZone(zoneId, name, groupRows) {
|
||||
try {
|
||||
const gids = Array.isArray(groupIds)
|
||||
? groupIds.map((x) => String(x).trim()).filter((x) => x.length > 0)
|
||||
const gids = Array.isArray(groupRows)
|
||||
? groupRows.map((r) => String(r.id || "").trim()).filter((x) => x.length > 0)
|
||||
: [];
|
||||
const response = await fetch(`/zones/${zoneId}`, {
|
||||
method: 'PUT',
|
||||
@@ -1315,8 +1159,9 @@ async function updateZone(zoneId, name, groupIds) {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
group_ids: gids,
|
||||
names: [],
|
||||
group_ids: gids,
|
||||
preset_group_ids: {},
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1339,12 +1184,11 @@ async function updateZone(zoneId, name, groupIds) {
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new zone
|
||||
async function createZone(name, groupIds) {
|
||||
// Create a new zone (add devices in Edit zone). ``contentKind`` is ``'presets'`` | ``'sequences'``.
|
||||
async function createZone(name, contentKind) {
|
||||
try {
|
||||
const gids = Array.isArray(groupIds)
|
||||
? groupIds.map((x) => String(x).trim()).filter((x) => x.length > 0)
|
||||
: [];
|
||||
const ck =
|
||||
contentKind === 'sequences' || contentKind === 'presets' ? contentKind : 'presets';
|
||||
const response = await fetch('/zones', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -1352,8 +1196,9 @@ async function createZone(name, groupIds) {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
group_ids: gids,
|
||||
names: [],
|
||||
group_ids: [],
|
||||
content_kind: ck,
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1434,8 +1279,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const name = newTabNameInput.value.trim();
|
||||
|
||||
if (name) {
|
||||
const groupIds = await defaultGroupIdsForNewTab();
|
||||
await createZone(name, groupIds);
|
||||
const kindRadio = document.querySelector(
|
||||
'input[name="new-zone-content-kind"]:checked',
|
||||
);
|
||||
const contentKind =
|
||||
kindRadio && kindRadio.value === 'sequences' ? 'sequences' : 'presets';
|
||||
await createZone(name, contentKind);
|
||||
if (newTabNameInput) newTabNameInput.value = "";
|
||||
}
|
||||
};
|
||||
@@ -1462,15 +1311,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
const zoneId = idInput ? idInput.value : null;
|
||||
const name = nameInput ? nameInput.value.trim() : "";
|
||||
const rows = window.__editTabGroupRows || [];
|
||||
const groupIds = rows.map((r) => r.id).filter(Boolean);
|
||||
const groupRows = window.__editTabGroupRows || [];
|
||||
|
||||
if (zoneId && name) {
|
||||
if (groupIds.length === 0) {
|
||||
alert("Add at least one device group.");
|
||||
return;
|
||||
}
|
||||
await updateZone(zoneId, name, groupIds);
|
||||
await updateZone(zoneId, name, groupRows);
|
||||
editZoneForm.reset();
|
||||
}
|
||||
});
|
||||
@@ -1530,10 +1374,13 @@ window.zonesManager = {
|
||||
resolveTabDeviceMacs: resolveZoneDeviceMacs,
|
||||
getCurrentZoneId: () => currentZoneId,
|
||||
computeZoneTargets,
|
||||
computeZoneNamesTargets,
|
||||
computeZonePresetUnionTargets,
|
||||
effectiveGroupIdsForZonePreset,
|
||||
resolveDeviceNamesForZonePreset,
|
||||
resolveSequenceStepDeviceNames,
|
||||
fetchGroupsMap,
|
||||
renderZoneGroupsEditor,
|
||||
};
|
||||
window.tabsManager = window.zonesManager;
|
||||
window.tabsManager.getCurrentTabId = () => currentZoneId;
|
||||
|
||||
@@ -83,6 +83,11 @@
|
||||
<input type="text" id="new-zone-name" placeholder="Zone name">
|
||||
<button class="btn btn-primary" id="create-zone-btn">Create</button>
|
||||
</div>
|
||||
<fieldset class="muted-text" style="margin:0.35rem 0 0.75rem;border:none;padding:0;">
|
||||
<legend style="font-size:0.85em;margin-bottom:0.35rem;">This zone is for</legend>
|
||||
<label style="margin-right:1rem;"><input type="radio" name="new-zone-content-kind" value="presets" checked> Presets</label>
|
||||
<label><input type="radio" name="new-zone-content-kind" value="sequences"> Sequences</label>
|
||||
</fieldset>
|
||||
<div id="zones-list-modal" class="profiles-list"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" id="zones-close-btn">Close</button>
|
||||
@@ -102,16 +107,22 @@
|
||||
</div>
|
||||
<label>Zone Name:</label>
|
||||
<input type="text" id="edit-zone-name" placeholder="Enter zone name" required>
|
||||
<label class="zone-devices-label">Device groups in this zone</label>
|
||||
<div id="edit-zone-devices-editor" class="zone-devices-editor"></div>
|
||||
<div id="edit-zone-block-groups">
|
||||
<label class="zone-devices-label">Device groups on this zone</label>
|
||||
<div id="edit-zone-groups-editor" class="zone-devices-editor"></div>
|
||||
</div>
|
||||
<div id="edit-zone-block-presets">
|
||||
<label class="zone-presets-section-label">Presets on this zone</label>
|
||||
<div id="edit-zone-presets-current" class="profiles-list edit-zone-presets-scroll"></div>
|
||||
<label class="zone-presets-section-label">Add presets to this zone</label>
|
||||
<div id="edit-zone-presets-list" class="profiles-list edit-zone-presets-scroll"></div>
|
||||
</div>
|
||||
<div id="edit-zone-block-sequences">
|
||||
<label class="zone-presets-section-label">Sequences on this zone</label>
|
||||
<div id="edit-zone-sequences-current" class="profiles-list edit-zone-presets-scroll"></div>
|
||||
<label class="zone-presets-section-label">Add a sequence to this zone</label>
|
||||
<div id="edit-zone-sequences-list" class="profiles-list edit-zone-presets-scroll"></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -148,13 +159,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device groups: members + Wi‑Fi driver defaults (zones reference groups) -->
|
||||
<!-- Device groups: members + Wi‑Fi driver defaults (zones reference groups for presets) -->
|
||||
<div id="groups-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Device groups</h2>
|
||||
<p class="muted-text" style="margin-top:0;">Assign drivers to a group, set Wi‑Fi defaults once per group, then attach groups to zones.</p>
|
||||
<p class="muted-text" style="margin-top:0;">Assign drivers to a group, set Wi‑Fi defaults once per group, then attach groups to a zone for standalone presets (sequences use each lane’s groups only). By default new groups are <strong>shared</strong> across all profiles; tick “this profile only” to hide a group from other profiles.</p>
|
||||
<div class="profiles-actions zone-modal-create-row">
|
||||
<input type="text" id="new-group-name" placeholder="Group name">
|
||||
<label class="muted-text" style="display:inline-flex;align-items:center;gap:0.35rem;white-space:nowrap;">
|
||||
<input type="checkbox" id="new-group-profile-only"> This profile only
|
||||
</label>
|
||||
<button class="btn btn-primary" id="create-group-btn">Create</button>
|
||||
</div>
|
||||
<div id="groups-list-modal" class="profiles-list"></div>
|
||||
@@ -175,6 +189,10 @@
|
||||
</div>
|
||||
<label for="edit-group-name">Group name</label>
|
||||
<input type="text" id="edit-group-name" required autocomplete="off">
|
||||
<label class="muted-text" style="display:flex;align-items:flex-start;gap:0.5rem;margin-top:0.5rem;">
|
||||
<input type="checkbox" id="edit-group-share-all-profiles" style="margin-top:0.2rem;">
|
||||
<span>Share with all profiles (untick to keep this group on the <strong>current profile only</strong>)</span>
|
||||
</label>
|
||||
<label class="zone-devices-label">Devices in this group</label>
|
||||
<div id="edit-group-devices-editor" class="zone-devices-editor"></div>
|
||||
<div class="profiles-actions" style="margin-top: 0.5rem;">
|
||||
@@ -315,26 +333,15 @@
|
||||
<label for="sequence-editor-name">Name</label>
|
||||
<input type="text" id="sequence-editor-name" placeholder="Sequence name" style="width:100%;max-width:24rem;">
|
||||
</div>
|
||||
<div class="preset-editor-field">
|
||||
<label for="sequence-editor-advance-mode">Advance</label>
|
||||
<select id="sequence-editor-advance-mode" style="max-width:16rem;">
|
||||
<option value="time">Time (ms between steps)</option>
|
||||
<option value="beats">Audio beats (requires Audio detector)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="preset-editor-field" id="sequence-editor-duration-wrap">
|
||||
<label for="sequence-editor-duration">Step duration (ms), all lanes together</label>
|
||||
<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 id="sequence-editor-beats-panel" style="margin:0 0 0.75rem 0;">
|
||||
<p class="muted-text" style="font-size:0.85em;margin:0 0 0.5rem 0;">
|
||||
Each step runs for the number of <strong>beats</strong> you set on that step.
|
||||
When the header <strong>Audio</strong> detector is running, real beats advance the sequence.
|
||||
When it is stopped, the server uses <strong>simulated</strong> beats at the BPM below.
|
||||
</p>
|
||||
<label for="sequence-editor-simulated-bpm" style="display:block;margin-bottom:0.25rem;">Simulated BPM (when audio is off)</label>
|
||||
<input type="number" id="sequence-editor-simulated-bpm" min="30" max="300" value="120" style="width:6rem;" title="Used only while the audio detector is stopped">
|
||||
<p id="sequence-editor-bpm-live" class="muted-text" style="font-size:0.85em;margin:0.5rem 0 0 0;">—</p>
|
||||
</div>
|
||||
<div id="sequence-editor-lanes"></div>
|
||||
<div class="modal-actions" style="margin-top:0.75rem;">
|
||||
@@ -377,6 +384,7 @@
|
||||
<label for="preset-background-input">Background</label>
|
||||
<div class="profiles-actions" style="gap: 0.4rem;">
|
||||
<button type="button" class="btn btn-secondary btn-small" id="preset-background-btn" title="Choose background colour">#000000</button>
|
||||
<button type="button" class="btn btn-secondary btn-small" id="preset-background-from-palette-btn">From Palette</button>
|
||||
<input type="color" id="preset-background-input" value="#000000" title="Background colour used in patterns with background support" style="position:absolute;opacity:0;pointer-events:none;width:1px;height:1px;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -280,3 +280,22 @@ class AudioBeatDetector:
|
||||
with self._lock:
|
||||
self._running = False
|
||||
self._status["running"] = False
|
||||
|
||||
|
||||
# Set from ``main`` so sequence playback can tell real audio from simulated beats.
|
||||
_shared_beat_detector = None
|
||||
|
||||
|
||||
def set_shared_beat_detector(det):
|
||||
global _shared_beat_detector
|
||||
_shared_beat_detector = det
|
||||
|
||||
|
||||
def shared_beat_detector_running():
|
||||
d = _shared_beat_detector
|
||||
if d is None:
|
||||
return False
|
||||
try:
|
||||
return bool(d.status().get("running"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -233,7 +233,7 @@ def _apply_manual_beat_route(
|
||||
wire_preset_id: str,
|
||||
preset_body: Any,
|
||||
) -> None:
|
||||
"""Enable audio→driver routing for one manual preset, or disable if invalid."""
|
||||
"""Enable audio→driver routing for one manual preset (clears all lanes, including sequence)."""
|
||||
global _lane_manual
|
||||
if not device_names:
|
||||
with _route_lock:
|
||||
@@ -269,6 +269,46 @@ def _apply_manual_beat_route(
|
||||
_sync_public_beat_route_from_lane_table()
|
||||
|
||||
|
||||
def _apply_manual_beat_route_standalone_overlay(
|
||||
device_names: List[str],
|
||||
wire_preset_id: str,
|
||||
preset_body: Any,
|
||||
) -> None:
|
||||
"""Register manual beat routing on lane ``-1`` only, keeping sequence lanes ``0..n`` intact."""
|
||||
global _lane_manual
|
||||
if not device_names:
|
||||
with _route_lock:
|
||||
_lane_manual.pop(-1, None)
|
||||
_sync_public_beat_route_from_lane_table()
|
||||
return
|
||||
if not isinstance(preset_body, dict):
|
||||
with _route_lock:
|
||||
_lane_manual.pop(-1, None)
|
||||
_sync_public_beat_route_from_lane_table()
|
||||
return
|
||||
if _coerce_auto_from_body(preset_body):
|
||||
with _route_lock:
|
||||
_lane_manual.pop(-1, None)
|
||||
_sync_public_beat_route_from_lane_table()
|
||||
return
|
||||
pattern = str(preset_body.get("pattern") or preset_body.get("p") or "").strip()
|
||||
if pattern and not _pattern_supports_manual(pattern):
|
||||
with _route_lock:
|
||||
_lane_manual.pop(-1, None)
|
||||
_sync_public_beat_route_from_lane_table()
|
||||
return
|
||||
names = [str(n).strip() for n in device_names if str(n).strip()]
|
||||
with _route_lock:
|
||||
_lane_manual[-1] = {
|
||||
"device_names": names,
|
||||
"wire_preset_id": str(wire_preset_id).strip(),
|
||||
"pattern": pattern,
|
||||
"manual_beat_n": _coerce_manual_beat_n(preset_body),
|
||||
"beat_counter": 0,
|
||||
}
|
||||
_sync_public_beat_route_from_lane_table()
|
||||
|
||||
|
||||
def set_sequence_manual_lane_route(
|
||||
lane_index: int,
|
||||
device_names: List[str],
|
||||
@@ -326,7 +366,7 @@ def sync_beat_route_from_push_sequence(
|
||||
sequence: List[Any],
|
||||
target_macs: Optional[List[str]] = None,
|
||||
*,
|
||||
preserve_manual_beat_route_on_auto_select: bool = False,
|
||||
preserve_parallel_lane_routes: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
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
|
||||
registry names for those MACs so the first advance is on the next audio beat.
|
||||
|
||||
When ``preserve_manual_beat_route_on_auto_select`` is true (zone sequence playback), an
|
||||
auto preset in ``select`` does not clear manual routing — other lanes may still need
|
||||
``notify_beat_detected`` for manual patterns in parallel.
|
||||
When ``preserve_parallel_lane_routes`` is true (e.g. zone sequence playback is active), an
|
||||
auto preset in ``select`` does not clear manual routing — other lanes still receive
|
||||
``notify_beat_detected``. A manual preset in ``select`` is applied on lane ``-1`` only so
|
||||
sequence lanes ``0..n`` keep their stride counters and wire ids.
|
||||
"""
|
||||
merged_presets: Dict[str, Any] = {}
|
||||
last_select: Optional[Dict[str, Any]] = None
|
||||
@@ -361,7 +402,8 @@ def sync_beat_route_from_push_sequence(
|
||||
if last_select:
|
||||
device_names = [str(k).strip() for k in last_select.keys() if str(k).strip()]
|
||||
if not device_names:
|
||||
update_beat_route({"enabled": False})
|
||||
if not preserve_parallel_lane_routes:
|
||||
update_beat_route({"enabled": False})
|
||||
return
|
||||
|
||||
wire_ids: Set[str] = set()
|
||||
@@ -372,7 +414,8 @@ def sync_beat_route_from_push_sequence(
|
||||
elif val is not None:
|
||||
wire_ids.add(str(val).strip())
|
||||
if len(wire_ids) != 1:
|
||||
update_beat_route({"enabled": False})
|
||||
if not preserve_parallel_lane_routes:
|
||||
update_beat_route({"enabled": False})
|
||||
return
|
||||
wire_preset_id = wire_ids.pop()
|
||||
preset_body = merged_presets.get(wire_preset_id)
|
||||
@@ -382,22 +425,32 @@ def sync_beat_route_from_push_sequence(
|
||||
preset_body = v
|
||||
break
|
||||
if preset_body is None:
|
||||
update_beat_route({"enabled": False})
|
||||
return
|
||||
if _coerce_auto_from_body(preset_body):
|
||||
if not preserve_manual_beat_route_on_auto_select:
|
||||
if not preserve_parallel_lane_routes:
|
||||
update_beat_route({"enabled": False})
|
||||
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
|
||||
|
||||
wire_id, body = _single_manual_wire_preset(merged_presets)
|
||||
if wire_id and body is not None:
|
||||
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
|
||||
|
||||
update_beat_route({"enabled": False})
|
||||
if not preserve_parallel_lane_routes:
|
||||
update_beat_route({"enabled": False})
|
||||
|
||||
|
||||
def _pattern_supports_manual(pattern_key: str) -> bool:
|
||||
|
||||
@@ -78,12 +78,48 @@ def build_select_message(device_name, preset_name, step=None):
|
||||
return {device_name: select_list}
|
||||
|
||||
|
||||
def build_preset_dict(preset_data):
|
||||
def _hex_from_background_raw(bg_raw):
|
||||
"""Coerce ``background`` / ``bg`` field to a ``#RRGGBB`` string (driver wire format)."""
|
||||
if isinstance(bg_raw, str):
|
||||
bg = bg_raw if bg_raw.startswith("#") else f"#{bg_raw}"
|
||||
return bg
|
||||
if isinstance(bg_raw, (list, tuple)) and len(bg_raw) == 3:
|
||||
return f"#{int(bg_raw[0]):02x}{int(bg_raw[1]):02x}{int(bg_raw[2]):02x}"
|
||||
return "#000000"
|
||||
|
||||
|
||||
def resolve_preset_background_hex(preset_data, palette_colors=None):
|
||||
"""
|
||||
Resolved background as ``#RRGGBB``. When ``palette_colors`` is a non-empty list and
|
||||
``background_palette_ref`` is set, uses that palette index; otherwise stored ``background`` / ``bg``.
|
||||
"""
|
||||
if not isinstance(preset_data, dict):
|
||||
return "#000000"
|
||||
pal = list(palette_colors) if isinstance(palette_colors, list) else []
|
||||
ref = preset_data.get("background_palette_ref", preset_data.get("backgroundPaletteRef"))
|
||||
if pal and ref is not None:
|
||||
try:
|
||||
idx = int(ref)
|
||||
except (TypeError, ValueError):
|
||||
idx = None
|
||||
else:
|
||||
if isinstance(idx, int) and 0 <= idx < len(pal):
|
||||
c = pal[idx]
|
||||
if isinstance(c, str) and c.strip().startswith("#"):
|
||||
s = c.strip()
|
||||
if len(s) == 7 and all(ch in "0123456789abcdefABCDEF" for ch in s[1:]):
|
||||
return s.upper()
|
||||
bg_raw = preset_data.get("background", preset_data.get("bg", "#000000"))
|
||||
return _hex_from_background_raw(bg_raw)
|
||||
|
||||
|
||||
def build_preset_dict(preset_data, palette_colors=None):
|
||||
"""
|
||||
Convert preset data to API-compliant format.
|
||||
|
||||
Args:
|
||||
preset_data: Dictionary with preset fields (may include name, pattern, colors, etc.)
|
||||
palette_colors: Optional list of ``#RRGGBB`` strings for ``background_palette_ref`` resolution.
|
||||
|
||||
Returns:
|
||||
Dictionary with preset in API-compliant format (without name field)
|
||||
@@ -137,13 +173,7 @@ def build_preset_dict(preset_data):
|
||||
auto_raw = preset_data.get("auto", preset_data.get("a", True))
|
||||
auto_bool = _coerce_auto(auto_raw)
|
||||
|
||||
bg_raw = preset_data.get("background", preset_data.get("bg", "#000000"))
|
||||
if isinstance(bg_raw, str):
|
||||
bg = bg_raw if bg_raw.startswith("#") else f"#{bg_raw}"
|
||||
elif isinstance(bg_raw, (list, tuple)) and len(bg_raw) == 3:
|
||||
bg = f"#{int(bg_raw[0]):02x}{int(bg_raw[1]):02x}{int(bg_raw[2]):02x}"
|
||||
else:
|
||||
bg = "#000000"
|
||||
bg = resolve_preset_background_hex(preset_data, palette_colors)
|
||||
|
||||
# Build payload using the short keys expected by led-driver
|
||||
preset = {
|
||||
@@ -164,12 +194,13 @@ def build_preset_dict(preset_data):
|
||||
return preset
|
||||
|
||||
|
||||
def build_presets_dict(presets_data):
|
||||
def build_presets_dict(presets_data, palette_colors=None):
|
||||
"""
|
||||
Convert multiple presets to API-compliant format.
|
||||
|
||||
Args:
|
||||
presets_data: Dictionary mapping preset names to preset data
|
||||
palette_colors: Optional list of ``#RRGGBB`` strings for background palette ref resolution.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping preset names to API-compliant preset objects
|
||||
@@ -190,7 +221,7 @@ def build_presets_dict(presets_data):
|
||||
"""
|
||||
result = {}
|
||||
for preset_name, preset_data in presets_data.items():
|
||||
result[preset_name] = build_preset_dict(preset_data)
|
||||
result[preset_name] = build_preset_dict(preset_data, palette_colors)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
Sequence start sends one v1 message with every preset body used in the sequence; auto steps
|
||||
then send select-only updates. Manual steps rely on the bulk load and only update beat routing.
|
||||
Steps advance on each beat from the audio detector when it is running; otherwise the server
|
||||
emits beats at the sequence ``simulated_bpm`` rate until playback stops or live audio starts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -13,12 +12,14 @@ import queue
|
||||
import threading
|
||||
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)
|
||||
_beat_consumer_started = False
|
||||
_beat_consumer_lock = threading.Lock()
|
||||
|
||||
_time_task: Optional[asyncio.Task] = None
|
||||
_time_lock = asyncio.Lock()
|
||||
_sim_beat_task: Optional[asyncio.Task] = None
|
||||
_sim_beat_token = 0
|
||||
|
||||
_beat_run: Optional[Dict[str, Any]] = None
|
||||
_beat_run_lock = threading.Lock()
|
||||
@@ -91,27 +92,28 @@ def _group_ids_for_lane_step(
|
||||
def _compute_zone_targets(
|
||||
zone_doc: Dict[str, Any], devices: Any, groups: Any
|
||||
) -> Tuple[List[str], List[str]]:
|
||||
gids = zone_doc.get("group_ids")
|
||||
gids = [str(x).strip() for x in gids if isinstance(gids, list) and x is not None and str(x).strip()]
|
||||
names: List[str] = []
|
||||
macs: List[str] = []
|
||||
if gids:
|
||||
gids = zone_doc.get("group_ids")
|
||||
if isinstance(gids, list) and gids:
|
||||
seen: set = set()
|
||||
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):
|
||||
continue
|
||||
devs = g.get("devices")
|
||||
if not isinstance(devs, list):
|
||||
continue
|
||||
for raw in devs:
|
||||
for raw in g.get("devices") or []:
|
||||
m = _norm_mac(raw)
|
||||
if not m or m in seen:
|
||||
continue
|
||||
seen.add(m)
|
||||
doc = devices.read(m) or {}
|
||||
nm = str(doc.get("name") or "").strip() or m
|
||||
names.append(nm)
|
||||
doc = devices.read(m) if hasattr(devices, "read") else None
|
||||
nm = ""
|
||||
if isinstance(doc, dict):
|
||||
nm = str(doc.get("name") or "").strip()
|
||||
names.append(nm or m)
|
||||
macs.append(m)
|
||||
return names, macs
|
||||
zone_names = zone_doc.get("names")
|
||||
@@ -326,7 +328,8 @@ def _display_preset_for_step(
|
||||
preset.get("palette_refs"),
|
||||
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]:
|
||||
@@ -345,6 +348,54 @@ def _preset_inner_from_display_preset(display_preset: Dict[str, Any]) -> Dict[st
|
||||
return inner
|
||||
|
||||
|
||||
def _parse_zone_brightness_value(zone_doc: Any) -> int:
|
||||
"""Zone slider value stored on the zone row (0–255); 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]:
|
||||
macs: List[str] = []
|
||||
seen: set = set()
|
||||
@@ -432,8 +483,24 @@ async def _deliver_sequence_presets_bulk(ctx: Dict[str, Any]) -> None:
|
||||
macs = _union_macs_for_sequence(ctx)
|
||||
if not macs:
|
||||
return
|
||||
msg = json.dumps({"v": "1", "presets": inner_by_wire}, separators=(",", ":"))
|
||||
await deliver_json_messages(sender, [msg], macs, ctx["devices"], delay_s=0.05)
|
||||
zone_doc = ctx.get("zone_doc") if isinstance(ctx.get("zone_doc"), dict) else {}
|
||||
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:
|
||||
@@ -473,6 +540,9 @@ async def _deliver_preset_for_devices(
|
||||
devices: Any,
|
||||
*,
|
||||
lane_index: Optional[int] = None,
|
||||
zone_doc: Optional[Dict[str, Any]] = None,
|
||||
settings_obj: Any = None,
|
||||
groups_model: Any = None,
|
||||
) -> None:
|
||||
from models.transport import get_current_sender
|
||||
from util.driver_delivery import deliver_json_messages
|
||||
@@ -505,34 +575,61 @@ async def _deliver_preset_for_devices(
|
||||
|
||||
body = dict(preset_doc)
|
||||
auto = _coerce_auto(body)
|
||||
inner = build_preset_dict(body)
|
||||
inner_base = build_preset_dict(body)
|
||||
mb = body.get("manual_beat_n", body.get("manualBeatN"))
|
||||
if mb is not None:
|
||||
try:
|
||||
n = int(mb)
|
||||
if 1 <= n <= 64:
|
||||
inner["manual_beat_n"] = n
|
||||
inner_base["manual_beat_n"] = n
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
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:
|
||||
sel: Dict[str, Any] = {}
|
||||
for n in device_names:
|
||||
if n:
|
||||
sel[str(n)] = [wire]
|
||||
if sel:
|
||||
seq_list.append({"v": "1", "select": sel})
|
||||
messages = [json.dumps(x, separators=(",", ":")) for x in seq_list]
|
||||
await deliver_json_messages(sender, messages, macs, devices, delay_s=0.05)
|
||||
sel_append = {"v": "1", "select": sel}
|
||||
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
seq_one = [{"v": "1", "presets": {wire: manual_inner}}]
|
||||
if sel_append:
|
||||
seq_one.append(dict(sel_append))
|
||||
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:
|
||||
auto = _coerce_auto(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)
|
||||
if auto:
|
||||
clear_sequence_manual_lane_route(lane_index)
|
||||
@@ -611,7 +717,14 @@ async def _send_lane(
|
||||
return
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def _sequence_advance_beats(sequence_doc: Dict[str, Any]) -> bool:
|
||||
raw = sequence_doc.get("advance_mode")
|
||||
return isinstance(raw, str) and raw.strip().lower() == "beats"
|
||||
|
||||
|
||||
def _build_ctx(
|
||||
sequence_doc: Dict[str, Any],
|
||||
zone_doc: Dict[str, Any],
|
||||
@@ -637,6 +745,7 @@ def _build_ctx(
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
from models.device import Device
|
||||
from models.group import Group
|
||||
from settings import Settings
|
||||
|
||||
lanes = [x for x in _normalize_sequence_lanes(sequence_doc) if len(x) > 0]
|
||||
if not lanes:
|
||||
@@ -655,9 +764,10 @@ def _build_ctx(
|
||||
"presets_map": presets_map,
|
||||
"devices": devices,
|
||||
"groups": groups,
|
||||
"settings": Settings(),
|
||||
"palette_colors": palette_colors,
|
||||
"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:
|
||||
st0 = lane_states[0]
|
||||
idx = int(st0.get("stepIdx", 0))
|
||||
advance_mode = str(ctx.get("advance_mode") or "").strip().lower()
|
||||
if st0.get("done"):
|
||||
step_1based = lane0_steps
|
||||
sequence_beat_at = sequence_beats_per_pass
|
||||
@@ -693,12 +802,8 @@ def playback_status() -> Dict[str, Any]:
|
||||
step = lanes[0][idx]
|
||||
beats_per_step = max(1, int(step.get("beats") or 1))
|
||||
beat_count_raw = int(st0.get("beatCount", 0))
|
||||
# Internal beatCount resets to 0 on step rollover; expose 1..beats_per_step in beats mode.
|
||||
if advance_mode == "beats":
|
||||
bt = max(1, int(beats_per_step))
|
||||
beat_count = min(bt, max(1, beat_count_raw if beat_count_raw > 0 else 1))
|
||||
else:
|
||||
beat_count = beat_count_raw
|
||||
bt = max(1, int(beats_per_step))
|
||||
beat_count = min(bt, max(1, beat_count_raw if beat_count_raw > 0 else 1))
|
||||
for j in range(min(idx, len(lane0))):
|
||||
sequence_beat_at += max(1, int((lane0[j] or {}).get("beats") or 1))
|
||||
sequence_beat_at += beat_count
|
||||
@@ -722,10 +827,8 @@ def playback_status() -> Dict[str, Any]:
|
||||
else:
|
||||
lane0_preset_name = pid
|
||||
beat_readout = ""
|
||||
adv_m = str(ctx.get("advance_mode") or "").strip().lower()
|
||||
if (
|
||||
adv_m == "beats"
|
||||
and sequence_beats_per_pass > 0
|
||||
sequence_beats_per_pass > 0
|
||||
and lane_states
|
||||
and lane0_steps > 0
|
||||
and lane_states[0]
|
||||
@@ -759,7 +862,7 @@ def playback_status() -> Dict[str, Any]:
|
||||
async def process_active_beat_advance() -> None:
|
||||
with _beat_run_lock:
|
||||
ctx = _beat_run
|
||||
if not ctx or ctx.get("advance_mode") != "beats":
|
||||
if not ctx:
|
||||
return
|
||||
lane_states: List[Dict[str, Any]] = ctx["lane_states"]
|
||||
lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
|
||||
@@ -842,77 +945,54 @@ def ensure_beat_consumer_started() -> None:
|
||||
loop.create_task(beat_consumer_loop())
|
||||
|
||||
|
||||
_time_token = 0
|
||||
|
||||
|
||||
async def _time_loop(ctx: Dict[str, Any], token: int) -> None:
|
||||
sequence_doc = ctx["sequence_doc"]
|
||||
raw_dur = sequence_doc.get("step_duration_ms", 3000)
|
||||
def _coerce_simulated_bpm(sequence_doc: Dict[str, Any], play_options: Optional[Dict[str, Any]]) -> float:
|
||||
raw = None
|
||||
if isinstance(play_options, dict):
|
||||
o = play_options.get("simulated_bpm")
|
||||
if o is not None:
|
||||
raw = o
|
||||
if raw is None and isinstance(sequence_doc, dict):
|
||||
raw = sequence_doc.get("simulated_bpm")
|
||||
try:
|
||||
duration = max(200, int(raw_dur))
|
||||
v = float(raw) if raw is not None else 120.0
|
||||
except (TypeError, ValueError):
|
||||
duration = 3000
|
||||
raw_tr = sequence_doc.get("sequence_transition")
|
||||
try:
|
||||
tr_in = int(raw_tr) if raw_tr is not None else 0
|
||||
except (TypeError, ValueError):
|
||||
tr_in = 0
|
||||
transition_ms = min(60000, max(0, tr_in))
|
||||
min_step = 200
|
||||
time_sleep_tr = min(transition_ms, max(0, duration - min_step))
|
||||
time_tick_lead = max(min_step, duration - time_sleep_tr)
|
||||
v = 120.0
|
||||
return max(30.0, min(300.0, v))
|
||||
|
||||
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:
|
||||
await asyncio.sleep(time_tick_lead / 1000.0)
|
||||
with _beat_run_lock:
|
||||
cur = _time_token
|
||||
if cur != my:
|
||||
cur_tok = _sim_beat_token
|
||||
active = _beat_run
|
||||
if cur_tok != my_token or active is None or active is not ctx:
|
||||
return
|
||||
if time_sleep_tr > 0:
|
||||
await asyncio.sleep(time_sleep_tr / 1000.0)
|
||||
if ad_mod.shared_beat_detector_running():
|
||||
await asyncio.sleep(0.12)
|
||||
continue
|
||||
await asyncio.sleep(interval)
|
||||
with _beat_run_lock:
|
||||
cur = _time_token
|
||||
if cur != my:
|
||||
cur_tok = _sim_beat_token
|
||||
active = _beat_run
|
||||
if cur_tok != my_token or active is None or active is not ctx:
|
||||
return
|
||||
lane_states = ctx["lane_states"]
|
||||
lanes = ctx["lanes"]
|
||||
loop = bool(ctx.get("loop"))
|
||||
lane0_looped = False
|
||||
for i in range(ctx["num_lanes"]):
|
||||
st = lane_states[i]
|
||||
if st.get("done"):
|
||||
continue
|
||||
ln = len(lanes[i])
|
||||
if int(st.get("stepIdx", 0)) + 1 >= ln:
|
||||
if loop:
|
||||
if i == 0:
|
||||
lane0_looped = True
|
||||
st["stepIdx"] = 0
|
||||
else:
|
||||
st["done"] = True
|
||||
else:
|
||||
st["stepIdx"] = int(st.get("stepIdx", 0)) + 1
|
||||
if lane0_looped:
|
||||
ctx["sequence_loop_beat"] = 1
|
||||
else:
|
||||
ctx["sequence_loop_beat"] = int(ctx.get("sequence_loop_beat", 0)) + 1
|
||||
if all(s.get("done") for s in lane_states):
|
||||
stop()
|
||||
return
|
||||
await _send_all_lanes(ctx)
|
||||
if ad_mod.shared_beat_detector_running():
|
||||
continue
|
||||
push_thread_beat()
|
||||
|
||||
|
||||
def stop() -> None:
|
||||
global _beat_run, _time_task, _time_token
|
||||
global _beat_run, _sim_beat_task, _sim_beat_token
|
||||
with _beat_run_lock:
|
||||
_beat_run = None
|
||||
_time_token += 1
|
||||
t = _time_task
|
||||
_time_task = None
|
||||
if t and not t.done():
|
||||
t.cancel()
|
||||
_sim_beat_token += 1
|
||||
st = _sim_beat_task
|
||||
_sim_beat_task = None
|
||||
if st and not st.done():
|
||||
st.cancel()
|
||||
|
||||
|
||||
def stop_if_playing_sequence(sequence_id: str) -> bool:
|
||||
@@ -931,8 +1011,13 @@ def stop_if_playing_sequence(sequence_id: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def start(zone_id: str, sequence_id: str, profile_id: str) -> None:
|
||||
global _beat_run, _time_task, _time_token
|
||||
async def start(
|
||||
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.profile import Profile
|
||||
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)
|
||||
|
||||
advance = ctx["advance_mode"]
|
||||
if advance == "beats":
|
||||
from util.beat_driver_route import update_beat_route
|
||||
from util.beat_driver_route import update_beat_route
|
||||
|
||||
update_beat_route({"enabled": False})
|
||||
with _beat_run_lock:
|
||||
_beat_run = ctx
|
||||
await _send_all_lanes(ctx)
|
||||
else:
|
||||
with _beat_run_lock:
|
||||
_beat_run = ctx
|
||||
_time_token += 1
|
||||
my = _time_token
|
||||
update_beat_route({"enabled": False})
|
||||
with _beat_run_lock:
|
||||
_beat_run = ctx
|
||||
await _send_all_lanes(ctx)
|
||||
|
||||
async def _run() -> None:
|
||||
try:
|
||||
await _time_loop(ctx, my)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"[sequence-playback] time loop: {e}")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
_time_task = loop.create_task(_run())
|
||||
bpm = _coerce_simulated_bpm(sequence_doc, play_options)
|
||||
loop = asyncio.get_running_loop()
|
||||
_sim_beat_token += 1
|
||||
my_tok = _sim_beat_token
|
||||
_sim_beat_task = loop.create_task(_simulated_beat_loop(ctx, my_tok, bpm))
|
||||
|
||||
|
||||
@@ -26,7 +26,8 @@ def test_sequence():
|
||||
assert sequence["steps"] == []
|
||||
assert sequence["lanes"] == [[]]
|
||||
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["loop"] is True
|
||||
assert sequence.get("sequence_transition") == 500
|
||||
@@ -42,6 +43,7 @@ def test_sequence():
|
||||
"step_duration_ms": 5000,
|
||||
"loop": True,
|
||||
"advance_mode": "beats",
|
||||
"simulated_bpm": 128,
|
||||
}
|
||||
result = sequences.update(sequence_id, update_data)
|
||||
assert result is True
|
||||
@@ -56,6 +58,7 @@ def test_sequence():
|
||||
assert len(updated["lanes"][0]) == 2
|
||||
assert updated["lanes"][0][0]["beats"] == 2
|
||||
assert updated.get("advance_mode") == "beats"
|
||||
assert updated.get("simulated_bpm") == 128
|
||||
assert updated["step_duration_ms"] == 5000
|
||||
assert updated["loop"] is True
|
||||
|
||||
|
||||
Reference in New Issue
Block a user