18 Commits

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 00:23:15 +12:00
b140aedf00 chore(submodules): bump led-tool for settings editor
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 14:54:24 +12:00
15f8c8a039 fix(wifi): limit outbound driver WS to hello-triggered attempts
Remove periodic UDP hello loop; dial each driver at most
wifi_driver_initial_connect_attempts times per discovery hello.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 14:54:22 +12:00
70641c63af feat(led-tool): embed settings editor in main UI
Serve led-tool static editor at /led-tool/editor, filter host serial
ports, and load the modal via iframe instead of the legacy form.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 14:54:18 +12:00
ef15c54593 chore(submodules): bump led-driver and led-tool for file_hashes deploy
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 19:14:54 +12:00
301e1c64bf test: cover audio, sequences, pattern direction, and settings
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 18:32:12 +12:00
c286e504eb feat(ui): numpad, audio readout, and sequence beat controls
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 18:32:12 +12:00
964cfc6d91 feat(audio-sequences): beat phase sync and aligned playback
Add bar-phase tracking, audio reset/anchor APIs, BPM holdover, beat-phase
sequence switching, sync-phase endpoint, and sample sequence data.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 18:32:10 +12:00
7ecb5c3b3e chore(submodules): bump led-driver for pattern reverse
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 18:32:07 +12:00
879db2a7df chore(submodules): bump led-driver and led-tool
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 21:14:57 +12:00
96d1e1b5fd feat(ui): pattern modes, bundles, and zone content kind
Add profile/preset/sequence JSON import and export; map preset mode to
wire n6 with a mode dropdown for multi-mode patterns; zone edit shows
presets or sequences only with content_kind on save; update catalogue
and tests for merged pattern names.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 21:12:42 +12:00
6286297646 feat(patterns): register northern wave, candle glow, starfall, ice sparkle
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 15:11:33 +12:00
ca3fef3f8a feat(patterns): winter icicles blizzard rime in controller catalogue
Register pattern metadata and test presets for new led-driver effects; bump led-driver submodule.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 15:10:02 +12:00
6c9e06f33b feat(zones): profile-scoped groups, zone modes, sequence brightness
- Optional profile_id on groups; UI and API for shared vs profile-only groups\n- Zone content_kind (presets vs sequences); edit modal shows matching sections; devices via groups only\n- Server sequence playback folds zone brightness into preset wire b (per MAC where needed)\n- Related preset/sequence/audio/beat-route and client updates

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 01:58:00 +12:00
64 changed files with 5818 additions and 1784 deletions

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -11,15 +11,13 @@
"max_colors": 0,
"supports_manual": true
},
"rainbow": {
"n1": "Step Rate",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 0,
"supports_manual": true
},
"colour_cycle": {
"n1": "Step Rate",
"supports_reverse": true,
"n1": "Step rate",
"mode": {
"0": "Scroll palette gradient",
"1": "Rainbow wheel (preset colours ignored)"
},
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
@@ -32,6 +30,7 @@
"supports_manual": false
},
"chase": {
"supports_reverse": true,
"n1": "Colour 1 Length",
"n2": "Colour 2 Length",
"n3": "Step 1",
@@ -40,7 +39,11 @@
"max_delay": 10000,
"max_colors": 2,
"has_background": true,
"supports_manual": true
"supports_manual": true,
"mode": {
"0": "Two-colour chase",
"1": "Marquee dashes (n1 on length, n2 off, n3 step)"
}
},
"pulse": {
"n1": "Attack",
@@ -80,7 +83,7 @@
"flame": {
"n1": "Min brightness",
"n2": "Breath period (ms)",
"n3": "Spark gap min (ms, 0=default 1030 s, -1=off)",
"n3": "Spark gap min (ms, 0=default 10\u201330 s, -1=off)",
"n4": "Spark gap max (ms)",
"min_delay": 10,
"max_delay": 10000,
@@ -88,8 +91,8 @@
"supports_manual": false
},
"twinkle": {
"n1": "Twinkle activity (1255, higher = more changes)",
"n2": "Density (0255, higher = more of the strip lit)",
"n1": "Twinkle activity (1\u2013255, higher = more changes)",
"n2": "Density (0\u2013255, higher = more of the strip lit)",
"n3": "Min adjacent LEDs per twinkle (same as max for fixed length)",
"n4": "Max adjacent LEDs per twinkle (same as min for fixed length)",
"min_delay": 10,
@@ -108,58 +111,6 @@
"has_background": true,
"supports_manual": true
},
"meteor_rain": {
"n1": "Tail length",
"n2": "Speed (LEDs per frame)",
"n3": "Fade amount (1-255)",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"supports_manual": true
},
"scanner": {
"n1": "Eye width",
"n2": "End pause (frames)",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": true
},
"gradient_scroll": {
"n1": "Scroll step rate",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"supports_manual": true
},
"comet_dual": {
"n1": "Tail length",
"n2": "Speed",
"n3": "Gap",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"sparkle_trail": {
"n1": "Spark density",
"n2": "Decay",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"supports_manual": true
},
"wave": {
"n1": "Wavelength",
"n2": "Amplitude",
"n3": "Drift speed",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"supports_manual": false
},
"plasma": {
"n1": "Scale",
"n2": "Speed",
@@ -169,17 +120,6 @@
"max_delay": 10000,
"supports_manual": false
},
"segment_chase": {
"n1": "Segment size",
"n2": "Phase step",
"n3": "Segment phase offset",
"n4": "Gap per segment",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"bar_graph": {
"n1": "Level percent",
"max_colors": 10,
@@ -188,14 +128,6 @@
"has_background": true,
"supports_manual": false
},
"breathing_dual": {
"n1": "Phase offset",
"n2": "Ease",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"supports_manual": false
},
"strobe_burst": {
"n1": "Burst count",
"n2": "Burst gap",
@@ -215,15 +147,6 @@
"has_background": true,
"supports_manual": true
},
"fireflies": {
"n1": "Count",
"n2": "Twinkle speed",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"clock_sweep": {
"n1": "Hand width",
"n2": "Marker interval",
@@ -233,37 +156,57 @@
"has_background": true,
"supports_manual": true
},
"marquee": {
"n1": "On length",
"n2": "Off length",
"n3": "Step",
"aurora": {
"supports_reverse": true,
"n1": "Band count (0) or spatial period LEDs (1)",
"n2": "Shimmer (0) or blend strength (1)",
"n3": "Unused (0) or drift speed (1)",
"mode": {
"0": "Colour bands + shimmer",
"1": "Sine northern wave"
},
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": true
},
"icicles": {
"supports_reverse": true,
"n1": "Anchor spacing (LEDs)",
"n2": "Max icicle length (LEDs)",
"n3": "Phase step per refresh",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"aurora": {
"n1": "Band count",
"n2": "Shimmer",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"supports_manual": false
},
"snowfall": {
"blizzard": {
"supports_reverse": true,
"n1": "Flake density",
"n2": "Fall speed",
"n3": "Wind (128 = centred; lower/raise for drift bias)",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"heartbeat": {
"n1": "Pulse 1 ms",
"n2": "Pulse 2 ms",
"n3": "Pause ms",
"rime": {
"n1": "Crystallisation rate",
"n2": "Melt (decay) per refresh",
"n3": "Spark cap (LEDs refreshed per cycle)",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"candle_glow": {
"n1": "Candle count",
"n2": "Glow width (LEDs)",
"n3": "Flicker strength",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
@@ -287,5 +230,51 @@
"min_delay": 10,
"max_delay": 10000,
"supports_manual": false
},
"meteor": {
"supports_reverse": true,
"n1": "Tail length (01) or eye width (2)",
"n2": "Speed (LEDs per frame)",
"n3": "Fade amount (0), comet gap (1), or end pause frames (2)",
"mode": {
"0": "Fading meteor",
"1": "Dual comets",
"2": "Bouncing scanner"
},
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": true
},
"particles": {
"supports_reverse": true,
"n1": "Flake density (0) or spawn rate (1)",
"n2": "Fall speed (LEDs per frame)",
"n3": "Unused (0) or streak length (1)",
"mode": {
"0": "Snowfall flakes",
"1": "Starfall streaks"
},
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": true
},
"sparkle": {
"n1": "Spark density (01) or firefly count (2)",
"n2": "Trail decay (0) or twinkle speed (2)",
"n3": "Ice halo width LEDs (1); unused in 0 and 2",
"mode": {
"0": "Sparkle trail",
"1": "Ice burst + halo",
"2": "Fireflies"
},
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": true
}
}

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +1,5 @@
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_endpoints_pytest.py"]
python_files = ["test_*.py"]
# ``tests/models/`` is a package name clash with ``src/models``; run via tests/models/run_all.py
norecursedirs = ["models"]

View File

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

View File

@@ -8,7 +8,7 @@ from models.device import (
)
from models.group import Group
from models.transport import get_current_sender
from settings import Settings
from settings import get_settings
from util.brightness_combine import effective_brightness_for_mac
from models.wifi_ws_clients import (
normalize_tcp_peer_ip,
@@ -77,7 +77,7 @@ def _brightness_save_message_json(b_val: int) -> str:
controller = Microdot()
devices = Device()
_group_registry = Group()
_pi_settings = Settings()
_pi_settings = get_settings()
def _device_live_connected(dev_dict):

View File

@@ -1,58 +1,140 @@
from microdot import Microdot
from microdot.session import with_session
import asyncio
from models.group import Group
from models.device import Device
from models.transport import get_current_sender
from models.wifi_ws_clients import normalize_tcp_peer_ip, send_json_line_to_ip
from settings import Settings
from settings import get_settings
from util.brightness_combine import effective_brightness_for_mac
import json
controller = Microdot()
groups = Group()
devices = Device()
_pi_settings = Settings()
_pi_settings = get_settings()
@controller.get('')
async def list_groups(request):
"""List all groups."""
return json.dumps(groups), 200, {'Content-Type': 'application/json'}
@controller.get('/<id>')
async def get_group(request, id):
"""Get a specific group by ID."""
def _group_doc_visible_for_profile(doc, profile_id):
if not isinstance(doc, dict):
return False
scoped = doc.get("profile_id")
if scoped is None:
scoped = doc.get("profileId")
if scoped is None or str(scoped).strip() == "":
return True
if not profile_id:
return False
return str(scoped).strip() == str(profile_id).strip()
def _filtered_groups_dict(session):
from controllers.zone import get_current_profile_id
pid = get_current_profile_id(session)
out = {}
for gid, doc in groups.items():
if not isinstance(doc, dict):
continue
if _group_doc_visible_for_profile(doc, pid):
out[str(gid)] = doc
return out
@controller.get("")
@with_session
async def list_groups(request, session):
"""List groups visible for the current profile (shared + profile-scoped)."""
return json.dumps(_filtered_groups_dict(session)), 200, {"Content-Type": "application/json"}
@controller.get("/<id>")
@with_session
async def get_group(request, session, id):
"""Get a specific group by ID (404 if scoped to another profile)."""
group = groups.read(id)
if group:
return json.dumps(group), 200, {'Content-Type': 'application/json'}
if not group or not isinstance(group, dict):
return json.dumps({"error": "Group not found"}), 404
from controllers.zone import get_current_profile_id
@controller.post('')
async def create_group(request):
"""Create a new group."""
if not _group_doc_visible_for_profile(group, get_current_profile_id(session)):
return json.dumps({"error": "Group not found"}), 404
return json.dumps(group), 200, {"Content-Type": "application/json"}
def _sanitize_group_profile_id_write(data, session):
"""Allow ``profile_id`` only for the active profile, or null to share across profiles."""
if not isinstance(data, dict):
return
from controllers.zone import get_current_profile_id
cur = get_current_profile_id(session)
if "profile_id" not in data and "profileId" not in data:
return
raw = data.get("profile_id")
if raw is None and "profileId" in data:
raw = data.get("profileId")
if raw is None or raw == "":
data.pop("profileId", None)
data["profile_id"] = None
return
if not cur or str(raw).strip() != str(cur).strip():
data.pop("profileId", None)
data.pop("profile_id", None)
@controller.post("")
@with_session
async def create_group(request, session):
"""Create a new group (omit ``profile_id`` for shared; or ``profile_scoped``: true for this profile only)."""
try:
data = request.json or {}
data = dict(request.json or {})
name = data.get("name", "")
profile_scoped = bool(data.pop("profile_scoped", False))
_sanitize_group_profile_id_write(data, session)
group_id = groups.create(name)
if data:
groups.update(group_id, data)
return json.dumps(groups.read(group_id)), 201, {'Content-Type': 'application/json'}
if profile_scoped:
from controllers.zone import get_current_profile_id
cur = get_current_profile_id(session)
if cur:
groups.update(group_id, {"profile_id": str(cur)})
return json.dumps(groups.read(group_id)), 201, {"Content-Type": "application/json"}
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.put('/<id>')
async def update_group(request, id):
@controller.put("/<id>")
@with_session
async def update_group(request, session, id):
"""Update an existing group."""
try:
data = request.json
if not isinstance(data, dict):
return json.dumps({"error": "Invalid JSON"}), 400, {"Content-Type": "application/json"}
data = dict(data)
_sanitize_group_profile_id_write(data, session)
if groups.update(id, data):
return json.dumps(groups.read(id)), 200, {'Content-Type': 'application/json'}
g = groups.read(id)
if g:
return json.dumps(g), 200, {"Content-Type": "application/json"}
return json.dumps({"error": "Group not found"}), 404
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.delete('/<id>')
async def delete_group(request, id):
"""Delete a group."""
@controller.delete("/<id>")
@with_session
async def delete_group(request, session, id):
"""Delete a group (not allowed for another profile's scoped group)."""
g = groups.read(id)
if not g or not isinstance(g, dict):
return json.dumps({"error": "Group not found"}), 404
from controllers.zone import get_current_profile_id
if not _group_doc_visible_for_profile(g, get_current_profile_id(session)):
return json.dumps({"error": "Group not found"}), 404
if groups.delete(id):
return json.dumps({"message": "Group deleted successfully"}), 200
return json.dumps({"error": "Group not found"}), 404
@@ -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 WiFi defaults to every WiFi device listed in the group (TCP WebSocket).
Uses stored ``wifi_*`` fields on the group; optional JSON body may override for this send only.
"""
gdoc = groups.read(id)
gdoc = _read_group_for_session(session, id)
if not gdoc:
return json.dumps({"error": "Group not found"}), 404
@@ -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"}

View File

@@ -3,20 +3,40 @@ import os
import subprocess
import sys
from microdot import Microdot
from microdot import Microdot, send_file
from serial.tools import list_ports
controller = Microdot()
_STATIC_ALLOWED = frozenset(
{"settings_editor.html", "settings_editor.js", "web_serial.js"}
)
def _repo_root() -> str:
return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
def _led_tool_static_dir() -> str:
return os.path.join(_repo_root(), "led-tool", "static")
def _led_cli_path() -> str:
return os.path.join(_repo_root(), "led-tool", "cli.py")
def _filter_host_serial_ports(ports: list) -> list:
mod_path = os.path.join(_repo_root(), "led-tool", "host_ports.py")
if not os.path.isfile(mod_path):
return ports
import importlib.util
spec = importlib.util.spec_from_file_location("led_tool_host_ports", mod_path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod.filter_port_dicts(ports)
def _build_led_cli_command(port: str, payload: dict):
cmd = [sys.executable, _led_cli_path(), "--port", port]
@@ -92,16 +112,40 @@ def _extract_settings_from_stdout(stdout: str):
return None
@controller.get("/editor")
async def settings_editor_page(request):
"""led-tool settings UI (Web Serial + host serial via led-cli)."""
path = os.path.join(_led_tool_static_dir(), "settings_editor.html")
if not os.path.isfile(path):
return (
json.dumps({"error": "led-tool/static/settings_editor.html not found"}),
404,
{"Content-Type": "application/json"},
)
return send_file(path)
@controller.get("/static/<path:filename>")
async def led_tool_static(request, filename):
if filename not in _STATIC_ALLOWED:
return "Not found", 404
path = os.path.join(_led_tool_static_dir(), filename)
if not os.path.isfile(path):
return "Not found", 404
return send_file(path)
@controller.get("/ports")
async def list_serial_ports(request):
ports = []
for info in list_ports.comports():
ports.append(
ports = _filter_host_serial_ports(
[
{
"device": info.device,
"description": info.description,
"hwid": info.hwid,
}
for info in list_ports.comports()
]
)
return (
json.dumps(

View File

@@ -2,16 +2,30 @@ 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
from util.espnow_message import build_message, build_preset_dict
from util.profile_bundle import export_preset_bundle, import_preset_bundle
import json
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()
@@ -37,6 +51,41 @@ async def list_presets(request, session):
}
return json.dumps(scoped), 200, {'Content-Type': 'application/json'}
@controller.get('/<preset_id>/export')
@with_session
async def export_preset(request, session, preset_id):
"""Export one preset as a JSON bundle."""
current_profile_id = get_current_profile_id(session)
preset = presets.read(preset_id)
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
return json.dumps({"error": "Preset not found"}), 404, {'Content-Type': 'application/json'}
try:
bundle = export_preset_bundle(preset_id, presets)
return json.dumps(bundle), 200, {'Content-Type': 'application/json'}
except ValueError as e:
return json.dumps({"error": str(e)}), 404, {'Content-Type': 'application/json'}
@controller.post('/import')
@with_session
async def import_preset(request, session):
"""Import a preset bundle into the current profile."""
try:
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return json.dumps({"error": "No profile available"}), 404, {'Content-Type': 'application/json'}
body = request.json or {}
bundle = body.get("bundle") if isinstance(body, dict) else body
if not isinstance(bundle, dict):
return json.dumps({"error": "Expected JSON bundle"}), 400, {'Content-Type': 'application/json'}
new_id, preset_data = import_preset_bundle(bundle, presets, current_profile_id)
return json.dumps({new_id: preset_data}), 201, {'Content-Type': 'application/json'}
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
except Exception as e:
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
@controller.get('/<preset_id>')
@with_session
async def get_preset(request, session, preset_id):
@@ -153,6 +202,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 +211,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 +366,13 @@ async def push_driver_messages(request, session):
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
try:
from util import sequence_playback as seq_pb
from util.beat_driver_route import sync_beat_route_from_push_sequence
sync_beat_route_from_push_sequence(seq, target_macs=target_list)
preserve = bool(seq_pb.playback_status().get("active"))
sync_beat_route_from_push_sequence(
seq, target_macs=target_list, preserve_parallel_lane_routes=preserve
)
except Exception:
pass

View File

@@ -3,12 +3,15 @@ from microdot.session import with_session
from models.profile import Profile
from models.zone import Zone
from models.preset import Preset
from models.sequence import Sequence
from util.profile_bundle import export_profile_bundle, import_profile_bundle
import json
controller = Microdot()
profiles = Profile()
zones = Zone()
presets = Preset()
sequences = Sequence()
@controller.get('')
@with_session
@@ -54,18 +57,64 @@ async def get_current_profile(request, session):
return json.dumps({"id": current_id, "profile": profile}), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "No profile available"}), 404
@controller.get('/<id>')
@with_session
async def get_profile(request, id, session):
"""Get a specific profile by ID."""
# Handle 'current' as a special case
if id == 'current':
return await get_current_profile(request, session)
profile = profiles.read(id)
if profile:
return json.dumps(profile), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Profile not found"}), 404
@controller.post('/import')
@with_session
async def import_profile(request, session):
"""Import a profile bundle (optionally apply as current profile)."""
try:
body = request.json or {}
bundle = body.get("bundle") if isinstance(body, dict) else body
if not isinstance(bundle, dict):
return json.dumps({"error": "Expected JSON bundle"}), 400, {'Content-Type': 'application/json'}
name = body.get("name") if isinstance(body, dict) else None
apply_raw = body.get("apply", True) if isinstance(body, dict) else True
if isinstance(apply_raw, str):
apply = apply_raw.strip().lower() in ("1", "true", "yes", "on")
else:
apply = bool(apply_raw)
new_profile_id, profile_data = import_profile_bundle(
bundle,
profiles,
zones,
presets,
sequences,
profiles._palette_model,
name=str(name).strip() if name else None,
)
if apply:
session['current_profile'] = str(new_profile_id)
session.save()
return (
json.dumps({new_profile_id: profile_data, "id": new_profile_id}),
201,
{'Content-Type': 'application/json'},
)
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
except Exception as e:
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
@controller.get('/<id>/export')
async def export_profile(request, id):
"""Export profile, zones, presets, sequences, and palette as a JSON bundle."""
try:
bundle = export_profile_bundle(
str(id),
profiles,
zones,
presets,
sequences,
profiles._palette_model,
)
return json.dumps(bundle), 200, {'Content-Type': 'application/json'}
except ValueError as e:
return json.dumps({"error": str(e)}), 404, {'Content-Type': 'application/json'}
except Exception as e:
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
@controller.post('/<id>/apply')
@with_session
@@ -77,167 +126,6 @@ async def apply_profile(request, session, id):
session.save()
return json.dumps({"message": "Profile applied", "id": str(id)}), 200, {'Content-Type': 'application/json'}
@controller.post('')
async def create_profile(request):
"""Create a new profile."""
try:
data = dict(request.json or {})
name = data.get("name", "")
seed_raw = data.get("seed_dj_zone", False)
if isinstance(seed_raw, str):
seed_dj_zone = seed_raw.strip().lower() in ("1", "true", "yes", "on")
else:
seed_dj_zone = bool(seed_raw)
# Request-only flag: do not persist on profile records.
data.pop("seed_dj_zone", None)
profile_id = profiles.create(name)
# Avoid persisting request-only fields.
data.pop("name", None)
if data:
profiles.update(profile_id, data)
# New profiles always start with a default zone pre-populated with starter presets.
default_preset_ids = []
default_preset_defs = [
{
"name": "on",
"pattern": "on",
"colors": ["#FFFFFF"],
"brightness": 255,
"delay": 100,
"auto": True,
},
{
"name": "off",
"pattern": "off",
"colors": [],
"brightness": 0,
"delay": 100,
"auto": True,
},
{
"name": "rainbow",
"pattern": "rainbow",
"colors": [],
"brightness": 255,
"delay": 100,
"auto": True,
"n1": 2,
},
{
"name": "Colour Cycle",
"pattern": "colour_cycle",
"colors": ["#FF0000", "#00FF00", "#0000FF"],
"brightness": 255,
"delay": 100,
"auto": True,
"n1": 1,
},
{
"name": "transition",
"pattern": "transition",
"colors": ["#FF0000", "#00FF00", "#0000FF"],
"brightness": 255,
"delay": 500,
"auto": True,
},
{
"name": "flicker",
"pattern": "flicker",
"colors": ["#FFB84D"],
"brightness": 255,
"delay": 80,
"auto": True,
"n1": 30,
},
{
"name": "flame",
"pattern": "flame",
"colors": [],
"brightness": 255,
"delay": 50,
"auto": True,
"n1": 35,
"n2": 2600,
"n3": 0,
"n4": 0,
},
{
"name": "twinkle",
"pattern": "twinkle",
"colors": ["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"],
"brightness": 255,
"delay": 55,
"auto": True,
"n1": 72,
"n2": 140,
"n3": 2,
"n4": 6,
},
]
for preset_data in default_preset_defs:
pid = presets.create(profile_id)
presets.update(pid, preset_data)
default_preset_ids.append(str(pid))
default_tab_id = zones.create(name="default", names=["1"], presets=[default_preset_ids])
zones.update(default_tab_id, {
"presets_flat": default_preset_ids,
"default_preset": default_preset_ids[0] if default_preset_ids else None,
})
profile = profiles.read(profile_id) or {}
profile_tabs = profile.get("zones", []) if isinstance(profile.get("zones", []), list) else []
profile_tabs.append(str(default_tab_id))
if seed_dj_zone:
# Seed a DJ-focused zone with three starter presets.
seeded_preset_ids = []
preset_defs = [
{
"name": "DJ Rainbow",
"pattern": "rainbow",
"colors": [],
"brightness": 220,
"delay": 60,
"n1": 12,
},
{
"name": "DJ Single Color",
"pattern": "on",
"colors": ["#ff00ff"],
"brightness": 220,
"delay": 100,
},
{
"name": "DJ Transition",
"pattern": "transition",
"colors": ["#ff0000", "#00ff00", "#0000ff"],
"brightness": 220,
"delay": 250,
},
]
for preset_data in preset_defs:
pid = presets.create(profile_id)
presets.update(pid, preset_data)
seeded_preset_ids.append(str(pid))
dj_tab_id = zones.create(name="dj", names=["dj"], presets=[seeded_preset_ids])
zones.update(dj_tab_id, {
"presets_flat": seeded_preset_ids,
"default_preset": seeded_preset_ids[0] if seeded_preset_ids else None,
})
profile_tabs.append(str(dj_tab_id))
profiles.update(profile_id, {"zones": profile_tabs})
profile_data = profiles.read(profile_id)
return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'}
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.post('/<id>/clone')
async def clone_profile(request, id):
@@ -351,6 +239,184 @@ async def clone_profile(request, id):
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.get('/<id>')
@with_session
async def get_profile(request, id, session):
"""Get a specific profile by ID."""
# Handle 'current' as a special case
if id == 'current':
return await get_current_profile(request, session)
profile = profiles.read(id)
if profile:
return json.dumps(profile), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Profile not found"}), 404
@controller.post('')
async def create_profile(request):
"""Create a new profile."""
try:
data = dict(request.json or {})
name = data.get("name", "")
seed_raw = data.get("seed_dj_zone", False)
if isinstance(seed_raw, str):
seed_dj_zone = seed_raw.strip().lower() in ("1", "true", "yes", "on")
else:
seed_dj_zone = bool(seed_raw)
# Request-only flag: do not persist on profile records.
data.pop("seed_dj_zone", None)
profile_id = profiles.create(name)
# Avoid persisting request-only fields.
data.pop("name", None)
if data:
profiles.update(profile_id, data)
# New profiles always start with a default zone pre-populated with starter presets.
default_preset_ids = []
default_preset_defs = [
{
"name": "on",
"pattern": "on",
"colors": ["#FFFFFF"],
"brightness": 255,
"delay": 100,
"auto": True,
},
{
"name": "off",
"pattern": "off",
"colors": [],
"brightness": 0,
"delay": 100,
"auto": True,
},
{
"name": "rainbow",
"pattern": "colour_cycle",
"colors": [],
"brightness": 255,
"delay": 100,
"auto": True,
"n1": 2,
"mode": 1,
},
{
"name": "Colour Cycle",
"pattern": "colour_cycle",
"colors": ["#FF0000", "#00FF00", "#0000FF"],
"brightness": 255,
"delay": 100,
"auto": True,
"n1": 1,
},
{
"name": "transition",
"pattern": "transition",
"colors": ["#FF0000", "#00FF00", "#0000FF"],
"brightness": 255,
"delay": 500,
"auto": True,
},
{
"name": "flicker",
"pattern": "flicker",
"colors": ["#FFB84D"],
"brightness": 255,
"delay": 80,
"auto": True,
"n1": 30,
},
{
"name": "flame",
"pattern": "flame",
"colors": [],
"brightness": 255,
"delay": 50,
"auto": True,
"n1": 35,
"n2": 2600,
"n3": 0,
"n4": 0,
},
{
"name": "twinkle",
"pattern": "twinkle",
"colors": ["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"],
"brightness": 255,
"delay": 55,
"auto": True,
"n1": 72,
"n2": 140,
"n3": 2,
"n4": 6,
},
]
for preset_data in default_preset_defs:
pid = presets.create(profile_id)
presets.update(pid, preset_data)
default_preset_ids.append(str(pid))
default_tab_id = zones.create(name="default", names=["1"], presets=[default_preset_ids])
zones.update(default_tab_id, {
"presets_flat": default_preset_ids,
"default_preset": default_preset_ids[0] if default_preset_ids else None,
})
profile = profiles.read(profile_id) or {}
profile_tabs = profile.get("zones", []) if isinstance(profile.get("zones", []), list) else []
profile_tabs.append(str(default_tab_id))
if seed_dj_zone:
# Seed a DJ-focused zone with three starter presets.
seeded_preset_ids = []
preset_defs = [
{
"name": "DJ Rainbow",
"pattern": "colour_cycle",
"colors": [],
"brightness": 220,
"delay": 60,
"n1": 12,
"mode": 1,
},
{
"name": "DJ Single Color",
"pattern": "on",
"colors": ["#ff00ff"],
"brightness": 220,
"delay": 100,
},
{
"name": "DJ Transition",
"pattern": "transition",
"colors": ["#ff0000", "#00ff00", "#0000ff"],
"brightness": 220,
"delay": 250,
},
]
for preset_data in preset_defs:
pid = presets.create(profile_id)
presets.update(pid, preset_data)
seeded_preset_ids.append(str(pid))
dj_tab_id = zones.create(name="dj", names=["dj"], presets=[seeded_preset_ids])
zones.update(dj_tab_id, {
"presets_flat": seeded_preset_ids,
"default_preset": seeded_preset_ids[0] if seeded_preset_ids else None,
})
profile_tabs.append(str(dj_tab_id))
profiles.update(profile_id, {"zones": profile_tabs})
profile_data = profiles.read(profile_id)
return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'}
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.put('/current')
@with_session
async def update_current_profile(request, session):

View File

@@ -3,11 +3,14 @@ from microdot.session import with_session
from models.sequence import Sequence
from models.profile import Profile
from models.transport import get_current_sender
from models.preset import Preset
from util.profile_bundle import export_sequence_bundle, import_sequence_bundle
import json
controller = Microdot()
sequences = Sequence()
profiles = Profile()
presets = Preset()
def get_current_profile_id(session=None):
@@ -27,6 +30,7 @@ def get_current_profile_id(session=None):
@with_session
async def list_sequences(request, session):
"""List sequences for the current profile."""
sequences.load()
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return json.dumps({}), 200, {"Content-Type": "application/json"}
@@ -39,10 +43,62 @@ async def list_sequences(request, session):
return json.dumps(scoped), 200, {"Content-Type": "application/json"}
@controller.get("/<id>/export")
@with_session
async def export_sequence(request, session, id):
"""Export a sequence and referenced presets as a JSON bundle."""
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return json.dumps({"error": "No profile available"}), 404, {"Content-Type": "application/json"}
try:
bundle = export_sequence_bundle(
id,
sequences,
presets,
profile_id=current_profile_id,
)
return json.dumps(bundle), 200, {"Content-Type": "application/json"}
except ValueError as e:
return json.dumps({"error": str(e)}), 404, {"Content-Type": "application/json"}
@controller.post("/import")
@with_session
async def import_sequence(request, session):
"""Import a sequence bundle into the current profile."""
try:
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return (
json.dumps({"error": "No profile available"}),
404,
{"Content-Type": "application/json"},
)
body = request.json or {}
bundle = body.get("bundle") if isinstance(body, dict) else body
if not isinstance(bundle, dict):
return (
json.dumps({"error": "Expected JSON bundle"}),
400,
{"Content-Type": "application/json"},
)
new_id, seq_data = import_sequence_bundle(bundle, sequences, presets, current_profile_id)
return (
json.dumps({new_id: seq_data}),
201,
{"Content-Type": "application/json"},
)
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
except Exception as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
@controller.get("/<id>")
@with_session
async def get_sequence(request, session, id):
"""Get a specific sequence by ID (current profile only)."""
sequences.load()
current_profile_id = get_current_profile_id(session)
seq = sequences.read(id)
if (
@@ -149,15 +205,46 @@ async def delete_sequence(request, session, id):
return json.dumps({"error": "Sequence not found"}), 404
@controller.post("/sync-phase")
@with_session
async def sync_sequence_beat_phase(request, session):
"""Align beat counters while a sequence is playing (body: {\"mode\": \"step\"|\"pass\"})."""
_ = session
try:
data = request.json or {}
except Exception:
data = {}
if not isinstance(data, dict):
data = {}
mode = data.get("mode") or data.get("align") or "step"
try:
from util.sequence_playback import sync_beat_phase
if not await sync_beat_phase(str(mode)):
return (
json.dumps({"error": "No sequence is playing"}),
409,
{"Content-Type": "application/json"},
)
from util.audio_detector import anchor_shared_bar_phase
anchor_shared_bar_phase()
return json.dumps({"ok": True, "mode": str(mode).strip().lower()}), 200, {
"Content-Type": "application/json"
}
except Exception as e:
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
@controller.post("/stop")
@with_session
async def stop_sequence_playback(request, session):
"""Stop server-driven zone sequence playback."""
_ = request
try:
from util.sequence_playback import stop
from util.sequence_playback import stop_playback
stop()
await stop_playback(clear_devices=True)
return json.dumps({"ok": True}), 200, {"Content-Type": "application/json"}
except Exception as e:
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
@@ -197,8 +284,12 @@ async def play_sequence(request, session, id):
try:
from util.sequence_playback import start
await start(zone_id, str(id), str(current_profile_id))
return json.dumps({"ok": True}), 200, {"Content-Type": "application/json"}
play_opts = data if isinstance(data, dict) else None
await start(zone_id, str(id), str(current_profile_id), play_opts)
from util.sequence_playback import pending_play_status
body = {"ok": True, **pending_play_status()}
return json.dumps(body), 200, {"Content-Type": "application/json"}
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
except RuntimeError as e:

View File

@@ -4,10 +4,10 @@ import json
from microdot import Microdot, send_file
from models import wifi_ws_clients
from settings import Settings
from settings import get_settings
controller = Microdot()
settings = Settings()
settings = get_settings()
@controller.get('')
async def get_settings(request):
@@ -75,7 +75,21 @@ def _validate_global_brightness(value):
return v
@controller.put('/settings')
def _validate_sequence_switch_wait(value):
s = str(value).strip().lower()
if s not in ("beat", "downbeat"):
raise ValueError("sequence_switch_wait must be beat or downbeat")
return s
def _validate_audio_beat_phase_ms(value):
v = int(value)
if v < 0 or v > 500:
raise ValueError("audio_beat_phase_ms must be between 0 and 500")
return v
@controller.put('')
async def update_settings(request):
"""Update general settings."""
try:
@@ -87,6 +101,10 @@ async def update_settings(request):
elif key == 'global_brightness' and value is not None:
settings[key] = _validate_global_brightness(value)
global_brightness_changed = True
elif key == 'sequence_switch_wait' and value is not None:
settings[key] = _validate_sequence_switch_wait(value)
elif key == 'audio_beat_phase_ms' and value is not None:
settings[key] = _validate_audio_beat_phase_ms(value)
else:
settings[key] = value
settings.save()

View File

@@ -145,6 +145,7 @@ async def zone_content_fragment(request, session, id):
@controller.get("")
@with_session
async def list_zones(request, session):
zones.load()
profile_id = get_current_profile_id(session)
current_zone_id = get_current_zone_id(request, session)
zone_order = get_profile_zone_order(profile_id) if profile_id else []
@@ -213,6 +214,7 @@ async def set_current_zone(request, id):
@controller.get("/<id>")
async def get_zone(request, id):
zones.load()
z = zones.read(id)
if z:
return json.dumps(z), 200, {"Content-Type": "application/json"}
@@ -291,6 +293,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 +308,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:
@@ -346,6 +351,7 @@ async def clone_zone(request, session, id):
source.get("names"),
source.get("presets"),
source.get("group_ids"),
source.get("content_kind"),
)
extra = {k: v for k, v in source.items() if k not in ("name", "names", "presets")}
if extra:

View File

@@ -10,7 +10,7 @@ import traceback
from microdot import Microdot, send_file
from microdot.websocket import with_websocket
from microdot.session import Session
from settings import Settings
from settings import get_settings
import controllers.preset as preset
import controllers.profile as profile
@@ -100,11 +100,7 @@ async def _handle_udp_discovery(sock, udp_holder=None) -> None:
def _prime_wifi_outbound_driver_connections() -> None:
"""
For each WiFi device in the registry with a usable IPv4, start (or keep) the
outbound WebSocket task. The client loop reconnects automatically if the link
drops. Presets are not pushed automatically; use Send Presets / profile apply.
"""
"""On boot, dial each registered Wi-Fi driver (same 4-attempt limit as UDP hello)."""
n = 0
try:
dev = Device()
@@ -143,65 +139,6 @@ def _ipv4_address(addr: str) -> str | None:
return s
async def _periodic_wifi_driver_hello_loop(settings, udp_holder) -> None:
"""
While a registered Wi-Fi driver has no outbound WebSocket, send a short JSON hello on
UDP discovery port so the device can announce itself and we can reconnect.
"""
try:
interval = float(settings.get("wifi_driver_hello_interval_s", 10.0))
except (TypeError, ValueError):
interval = 10.0
if interval <= 0:
return
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setblocking(False)
loop = asyncio.get_running_loop()
try:
while True:
await asyncio.sleep(interval)
if udp_holder.get("closing"):
break
try:
dev = Device()
except Exception as e:
print(f"[hello] device list failed: {e!r}")
continue
for _mac_key, doc in list(dev.items()):
if not isinstance(doc, dict):
continue
if doc.get("transport") != "wifi":
continue
ip = _ipv4_address(str(doc.get("address") or ""))
if not ip:
continue
if tcp_client_registry.tcp_client_connected(ip):
continue
name = (doc.get("name") or "").strip()
mac = normalize_mac(doc.get("id") or _mac_key)
if not name or not mac:
continue
line = (
json.dumps(
{"m": "hello", "device_name": name, "mac": mac},
separators=(",", ":"),
)
+ "\n"
)
try:
await loop.sock_sendto(
sock, line.encode("utf-8"), (ip, DISCOVERY_UDP_PORT)
)
except OSError as e:
print(f"[hello] UDP to {ip!r} failed: {e!r}")
finally:
try:
sock.close()
except OSError:
pass
async def _run_udp_discovery_server(udp_holder=None) -> None:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setblocking(False)
@@ -244,7 +181,7 @@ async def _send_bridge_wifi_channel(settings, sender):
async def main(port=80):
settings = Settings()
settings = get_settings()
print(settings)
print("Starting")
@@ -254,6 +191,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
@@ -371,7 +314,12 @@ async def main(port=80):
audio_detector.start(device=device)
from util.audio_run_persist import write_audio_run_state
write_audio_run_state(enabled=True, device=device)
write_audio_run_state(
enabled=True,
device=device,
device_override=str(payload.get("device_override") or ""),
device_select=str(payload.get("device_select") or ""),
)
return {"ok": True, "status": audio_detector.status()}
except Exception as e:
return {"ok": False, "error": str(e)}, 500
@@ -385,6 +333,24 @@ async def main(port=80):
write_audio_run_state(enabled=False)
return {"ok": True, "status": audio_detector.status()}
@app.route('/api/audio/reset', methods=['POST'])
async def audio_reset(request):
"""Clear beat/BPM tracking state without stopping the detector."""
_ = request
ok = audio_detector.reset_tracking()
if not ok:
return {"ok": False, "error": "Audio detector is not running"}, 409
return {"ok": True, "status": audio_detector.status()}
@app.route('/api/audio/anchor-bar', methods=['POST'])
async def audio_anchor_bar(request):
"""Mark the current moment as bar beat 1 (downbeat)."""
_ = request
ok = audio_detector.anchor_bar_phase()
if not ok:
return {"ok": False, "error": "Audio detector is not running"}, 409
return {"ok": True, "status": audio_detector.status()}
@app.route('/api/audio/status')
async def audio_status(request):
_ = request
@@ -420,6 +386,14 @@ async def main(port=80):
if bs > 0:
beat_readout = str(bs)
st["beat_readout"] = beat_readout
from util.audio_run_persist import read_audio_run_state
st["beat_phase_ms"] = int(settings.get("audio_beat_phase_ms") or 0)
seq_wait = str(settings.get("sequence_switch_wait") or "beat").strip().lower()
if seq_wait not in ("beat", "downbeat"):
seq_wait = "beat"
st["sequence_switch_wait"] = seq_wait
st["audio_run"] = read_audio_run_state()
return {"status": st}
# Static file route
@@ -474,16 +448,30 @@ async def main(port=80):
await _send_bridge_wifi_channel(settings, sender)
_prime_wifi_outbound_driver_connections()
udp_holder = {"closing": False}
udp_holder = {"closing": False, "shutting_down": False}
loop = asyncio.get_running_loop()
server_tasks: list[asyncio.Task] = []
def _graceful_shutdown(*_args):
if udp_holder.get("shutting_down"):
raise SystemExit(0)
udp_holder["shutting_down"] = True
print("[server] shutting down...")
udp_holder["closing"] = True
try:
audio_detector.stop()
except Exception:
pass
try:
from util import sequence_playback as seq_pb
seq_pb.stop()
for attr in ("_pending_beat_task", "_sim_beat_task"):
t = getattr(seq_pb, attr, None)
if t is not None and not t.done():
t.cancel()
except Exception:
pass
u = udp_holder.get("sock")
if u is not None:
try:
@@ -492,7 +480,13 @@ async def main(port=80):
pass
tcp_client_registry.cancel_all_driver_tasks()
if getattr(app, "server", None) is not None:
try:
app.shutdown()
except Exception:
pass
for t in server_tasks:
if not t.done():
t.cancel()
shutdown_handlers_registered = False
try:
@@ -505,11 +499,17 @@ async def main(port=80):
# Await HTTP + UDP discovery; bind failures (e.g. port 80 in use) surface here.
try:
await asyncio.gather(
app.start_server(host="0.0.0.0", port=port),
_run_udp_discovery_server(udp_holder),
_periodic_wifi_driver_hello_loop(settings, udp_holder),
)
server_tasks[:] = [
asyncio.create_task(
app.start_server(host="0.0.0.0", port=port), name="http"
),
asyncio.create_task(
_run_udp_discovery_server(udp_holder), name="udp"
),
]
await asyncio.gather(*server_tasks)
except asyncio.CancelledError:
pass
except OSError as e:
if e.errno == errno.EADDRINUSE:
print(
@@ -534,6 +534,21 @@ async def main(port=80):
app.server = None
except Exception:
pass
udp_holder["closing"] = True
for t in list(server_tasks):
if not t.done():
t.cancel()
if server_tasks:
await asyncio.gather(*server_tasks, return_exceptions=True)
pending = [
t
for t in asyncio.all_tasks(loop)
if t is not asyncio.current_task() and not t.done()
]
for t in pending:
t.cancel()
if pending:
await asyncio.gather(*pending, return_exceptions=True)
if shutdown_handlers_registered:
for sig in (signal.SIGINT, signal.SIGTERM):
try:
@@ -543,5 +558,9 @@ async def main(port=80):
if __name__ == "__main__":
import os
port = int(os.environ.get("PORT", 80))
try:
asyncio.run(main(port=port))
except KeyboardInterrupt:
print("[server] interrupted")

View File

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

View File

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

View File

@@ -54,8 +54,18 @@ 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
@@ -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,
}

View File

@@ -13,7 +13,6 @@ from websockets.exceptions import ConnectionClosed
_connections: dict[str, object] = {}
_send_locks: dict[str, asyncio.Lock] = {}
_tasks: dict[str, asyncio.Task] = {}
_unreachable_counts: dict[str, int] = {}
_settings = None
_tcp_status_broadcast = None
@@ -119,7 +118,6 @@ def _register_ws(ip: str, ws) -> None:
if not key:
return
_connections[key] = ws
_unreachable_counts.pop(key, None)
if key not in _send_locks:
_send_locks[key] = asyncio.Lock()
_schedule_status_broadcast(key, True)
@@ -275,52 +273,43 @@ async def _driver_connection_loop(ip: str) -> None:
if stagger > 0:
await asyncio.sleep(stagger)
# Only bound boot-time: after we have connected once, keep retrying (Wi-Fi drops, reboots).
connected_once = False
boot_attempts = 0
try:
while True:
if not connected_once:
if boot_attempts >= max_boot_attempts:
print(
f"[WS] driver {ip} still unreachable after {max_boot_attempts} "
f"initial dial attempt(s); stopping until next UDP hello / registry prime"
)
break
boot_attempts += 1
for attempt in range(1, max_boot_attempts + 1):
try:
print(f"[WS] connecting to {uri!r}")
print(f"[WS] connecting to {uri!r} (attempt {attempt}/{max_boot_attempts})")
async with websockets.connect(
uri,
ping_interval=20,
ping_timeout=15,
open_timeout=open_timeout,
) as ws:
connected_once = True
_register_ws(ip, ws)
try:
await _recv_forward_loop(ip, ws)
finally:
unregister_tcp_writer(ip, ws)
return
except asyncio.CancelledError:
raise
except ConnectionClosed as e:
print(f"[WS] driver {ip} closed: {e}")
unregister_tcp_writer(ip, None)
return
except Exception as e:
if _benign_ws_connect_failure(e):
n = _unreachable_counts.get(ip, 0) + 1
_unreachable_counts[ip] = n
if n == 1 or (n % 30) == 0:
print(
f"[WS] driver {ip} unreachable, retry in {retry_interval_s}s: {e} (x{n})"
f"[WS] driver {ip} unreachable (attempt {attempt}/{max_boot_attempts}): {e}"
)
else:
print(f"[WS] driver {ip} session error: {e!r}")
traceback.print_exception(type(e), e, e.__traceback__)
_unreachable_counts.pop(ip, None)
unregister_tcp_writer(ip, None)
if attempt < max_boot_attempts:
await asyncio.sleep(retry_interval_s)
print(
f"[WS] driver {ip} still unreachable after {max_boot_attempts} attempt(s); "
"waiting for next UDP hello"
)
except asyncio.CancelledError:
unregister_tcp_writer(ip, None)
raise
@@ -329,10 +318,12 @@ async def _driver_connection_loop(ip: str) -> None:
def ensure_driver_connection(peer_ip: str) -> None:
"""Start (or keep) a background task that maintains ``ws://<ip>:port/ws``."""
"""Dial ``ws://<ip>:port/ws`` up to wifi_driver_initial_connect_attempts times (UDP hello only)."""
key = normalize_tcp_peer_ip(peer_ip)
if not key:
return
if tcp_client_connected(key):
return
t = _tasks.get(key)
if t is not None and not t.done():
return
@@ -353,4 +344,3 @@ def cancel_all_driver_tasks() -> None:
_schedule_status_broadcast(ip, False)
_connections.clear()
_send_locks.clear()
_unreachable_counts.clear()

View File

@@ -19,7 +19,11 @@ def _maybe_migrate_tab_json_to_zone():
class Zone(Model):
"""Preset layout row (stored in ``db/zone.json``); legacy storage was ``tab.json`` / Tab."""
"""Preset layout row (stored in ``db/zone.json``); legacy storage was ``tab.json`` / Tab.
Optional ``content_kind`` on a row: ``\"presets\"`` (preset tiles only) or ``\"sequences\"``
(sequence tiles only). Legacy rows without ``content_kind`` are inferred on load.
"""
def __init__(self):
if not getattr(Zone, "_migration_checked", False):
@@ -39,15 +43,72 @@ class Zone(Model):
if "preset_group_ids" not in doc or not isinstance(doc.get("preset_group_ids"), dict):
doc["preset_group_ids"] = {}
changed = True
if "sequence_ids" not in doc or not isinstance(doc.get("sequence_ids"), list):
doc["sequence_ids"] = []
changed = True
if not self._normalized_content_kind(doc):
doc["content_kind"] = self._infer_content_kind(doc)
changed = True
if changed:
self.save()
def create(self, name="", names=None, presets=None, group_ids=None):
@staticmethod
def _normalized_content_kind(doc):
if not isinstance(doc, dict):
return None
kind = doc.get("content_kind")
return kind if kind in ("presets", "sequences") else None
@staticmethod
def _preset_ids_in_doc(doc):
if not isinstance(doc, dict):
return []
flat = doc.get("presets_flat")
if isinstance(flat, list):
return [str(x) for x in flat if x is not None and str(x).strip()]
presets = doc.get("presets")
if not isinstance(presets, list) or not presets:
return []
if isinstance(presets[0], str):
return [str(x) for x in presets if x is not None and str(x).strip()]
if isinstance(presets[0], list):
out = []
for row in presets:
if isinstance(row, list):
out.extend(str(x) for x in row if x is not None and str(x).strip())
return out
return []
@classmethod
def _infer_content_kind(cls, doc):
kind = cls._normalized_content_kind(doc)
if kind:
return kind
seq_ids = [
str(x).strip()
for x in (doc.get("sequence_ids") or [])
if x is not None and str(x).strip()
]
preset_ids = cls._preset_ids_in_doc(doc)
if seq_ids and not preset_ids:
return "sequences"
return "presets"
def _enforce_content_kind_invariants(self, doc):
"""Presets-only zones hold no sequences; sequences-only hold no preset tiles."""
kind = self._normalized_content_kind(doc)
if kind == "presets":
doc["sequence_ids"] = []
elif kind == "sequences":
doc["presets"] = []
doc["presets_flat"] = []
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 +117,12 @@ class Zone(Model):
"default_preset": None,
"brightness": 255,
}
if content_kind in ("presets", "sequences"):
doc["content_kind"] = content_kind
if "sequence_ids" not in doc:
doc["sequence_ids"] = []
self._enforce_content_kind_invariants(doc)
self[next_id] = doc
self.save()
return next_id
@@ -67,7 +134,14 @@ class Zone(Model):
id_str = str(id)
if id_str not in self:
return False
self[id_str].update(data)
patch = dict(data) if isinstance(data, dict) else {}
doc = self[id_str]
locked_kind = self._normalized_content_kind(doc) or self._infer_content_kind(doc)
if "content_kind" in patch:
patch["content_kind"] = locked_kind
self[id_str].update(patch)
if "content_kind" in patch:
self._enforce_content_kind_invariants(self[id_str])
self.save()
return True

View File

@@ -12,11 +12,15 @@ def _settings_path():
return "settings.json"
_settings_singleton: "Settings | None" = None
class Settings(dict):
SETTINGS_FILE = None # Set in __init__ from _settings_path()
def __init__(self):
def __init__(self, *, quiet: bool = False):
super().__init__()
self._quiet = quiet
if Settings.SETTINGS_FILE is None:
Settings.SETTINGS_FILE = _settings_path()
self.load() # Load settings from file during initialization
@@ -53,12 +57,9 @@ class Settings(dict):
self['wifi_driver_ws_port'] = 80
if 'wifi_driver_ws_path' not in self:
self['wifi_driver_ws_path'] = '/ws'
# Seconds between UDP discovery nudges when a Wi-Fi driver WebSocket is
# down (0 disables). Helps drivers that reconnect after seeing traffic on 8766.
# Legacy (unused): periodic UDP nudges removed; connect only on driver hello.
if 'wifi_driver_hello_interval_s' not in self:
self['wifi_driver_hello_interval_s'] = 10.0
# Legacy key (no longer read): initial outbound dial limit uses
# wifi_driver_initial_connect_attempts instead.
self['wifi_driver_hello_interval_s'] = 0
if 'wifi_driver_connect_retry_window_s' not in self:
self['wifi_driver_connect_retry_window_s'] = 120.0
# Spread outbound dials 0..N s by device IP so six+ drivers do not all hit the AP at once.
@@ -70,7 +71,7 @@ class Settings(dict):
# Pause between outbound WebSocket dial attempts (seconds).
if 'wifi_driver_connect_retry_interval_s' not in self:
self['wifi_driver_connect_retry_interval_s'] = 2.0
# Outbound dial attempts to the saved driver IP before first success; then wait for UDP discovery.
# Outbound WebSocket dial attempts per driver UDP hello (then wait for next hello).
if 'wifi_driver_initial_connect_attempts' not in self:
self['wifi_driver_initial_connect_attempts'] = 4
# UART to ESP32 ESP-NOW bridge; default off (Wi-Fi drivers need no serial).
@@ -79,12 +80,21 @@ class Settings(dict):
# Zone UI global brightness (0255); shared across browsers/devices.
if 'global_brightness' not in self:
self['global_brightness'] = 255
# Sequence tile start: wait for beat or downbeat (server-owned).
if 'sequence_switch_wait' not in self:
self['sequence_switch_wait'] = 'beat'
elif str(self.get('sequence_switch_wait', '')).strip().lower() == 'phrase':
self['sequence_switch_wait'] = 'beat'
# Beat flash alignment delay (ms); applied by all UI clients polling audio status.
if 'audio_beat_phase_ms' not in self:
self['audio_beat_phase_ms'] = 0
def save(self):
try:
j = json.dumps(self)
with open(self.SETTINGS_FILE, 'w') as file:
file.write(j)
if not getattr(self, "_quiet", False):
print("Settings saved successfully.")
except Exception as e:
print(f"Error saving settings: {e}")
@@ -96,9 +106,11 @@ class Settings(dict):
loaded_settings = json.load(file)
self.update(loaded_settings)
loaded_from_file = True
if not getattr(self, "_quiet", False):
print("Settings loaded successfully.")
except Exception as e:
print(f"Error loading settings")
if not getattr(self, "_quiet", False):
print(f"Error loading settings: {e}")
self.clear()
finally:
# Ensure defaults are set even if file exists but is missing keys
@@ -106,3 +118,18 @@ class Settings(dict):
# Only save if file didn't exist or was invalid
if not loaded_from_file:
self.save()
def get_settings() -> Settings:
"""Process-wide settings instance (avoid re-reading settings.json on every request)."""
global _settings_singleton
if _settings_singleton is None:
_settings_singleton = Settings()
return _settings_singleton
def reload_settings() -> Settings:
"""Re-read settings.json (e.g. after external file edit)."""
global _settings_singleton
_settings_singleton = Settings(quiet=True)
return _settings_singleton

View File

@@ -1,5 +1,6 @@
(() => {
let pollTimer = null;
let audioDetectorRunning = false;
let lastBeatSeq = 0;
let lastLoggedSequenceBeatFractions = "";
/** Prior poll had server zone sequence playback active (`status.sequence.active === true`). */
@@ -14,49 +15,6 @@
/** @type {Set<ReturnType<typeof setTimeout>>} */
const pendingBeatPhaseTimers = new Set();
const STORAGE_KEY = "led-controller-audio-restore";
const PHASE_MS_KEY = "led-controller-audio-beat-phase-ms";
const STORAGE_VERSION = 1;
function readRestorePrefs() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const o = JSON.parse(raw);
if (!o || o.v !== STORAGE_VERSION || !o.restore) return null;
return {
override: typeof o.override === "string" ? o.override : "",
select: typeof o.select === "string" ? o.select : "",
};
} catch {
return null;
}
}
function writeRestorePrefs(override, select) {
try {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
v: STORAGE_VERSION,
restore: true,
override: override || "",
select: select || "",
}),
);
} catch (e) {
console.warn("audio restore prefs save failed", e);
}
}
function clearRestorePrefs() {
try {
localStorage.removeItem(STORAGE_KEY);
} catch (e) {
console.warn("audio restore prefs clear failed", e);
}
}
function el(id) {
return document.getElementById(id);
}
@@ -83,10 +41,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 +77,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);
@@ -159,21 +113,115 @@
node.textContent = `${label}${conf}`;
}
/** @param {Record<string, unknown>} status */
function updateBarPhaseDisplay(status) {
const readout = String((status && status.bar_phase_readout) || "").trim();
const phaseConf = Number((status && status.phase_confidence) || 0);
const downbeat = !!(status && status.is_downbeat);
let text = readout || "--";
if (readout && Number.isFinite(phaseConf) && phaseConf > 0) {
text = `${text} (${Math.round(phaseConf * 100)}%)`;
}
for (const id of ["audio-bar-phase-value", "audio-top-bar-phase"]) {
const node = el(id);
if (!node) continue;
node.textContent = status && status.running ? text : "";
node.classList.toggle("is-downbeat", downbeat && !!readout);
}
}
function setTopBpmVisible(on) {
const top = el("audio-top-indicator");
if (!top) return;
top.classList.toggle("audio-running", !!on);
}
function setNavResetVisible(on) {
for (const id of ["audio-nav-reset-btn", "audio-nav-reset-mobile"]) {
const node = el(id);
if (node) node.hidden = !on;
}
}
async function resetAudioTracking() {
try {
const res = await fetch("/api/audio/reset", {
method: "POST",
headers: { Accept: "application/json" },
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
console.warn("audio reset failed", data.error || res.status);
return;
}
await pollStatus();
} catch (e) {
console.warn("audio reset failed", e);
}
}
function updateSequenceSyncControls(zoneSeqActive) {
const topSync = el("audio-top-beat-sync");
if (topSync) {
topSync.disabled = audioDetectorRunning && !zoneSeqActive;
topSync.title = !audioDetectorRunning
? "Start beat detection"
: zoneSeqActive
? "Sync step to music (S)"
: "Beat detection running";
}
const modalBeat = el("audio-modal-beat-readout");
if (modalBeat) modalBeat.disabled = !zoneSeqActive;
const passBtn = el("audio-sync-pass-btn");
if (passBtn) passBtn.disabled = !zoneSeqActive;
}
async function handleTopBpmButtonClick() {
if (!audioDetectorRunning) {
try {
await startAudio();
} catch (e) {
console.error("audio start failed", e);
alert("Failed to start audio input. Check mic permissions.");
}
return;
}
try {
await syncSequenceBeatPhase("step");
} catch (e) {
console.warn("sequence beat sync failed", e);
}
}
async function syncSequenceBeatPhase(mode) {
const res = await fetch("/sequences/sync-phase", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ mode: mode || "step" }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `Sync failed (${res.status})`);
}
await pollStatus();
}
function isTypingTarget(target) {
if (!target || typeof target !== "object") return false;
const tag = String(target.tagName || "").toLowerCase();
return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable;
}
function flashBeat() {
const node = el("audio-beat-flash");
if (!node) return;
node.classList.add("active");
setTimeout(() => node.classList.remove("active"), 80);
const syncBtn = el("audio-top-beat-sync");
const top = el("audio-top-indicator");
if (top && top.classList.contains("audio-running")) {
top.classList.add("flash");
setTimeout(() => top.classList.remove("flash"), 90);
if (syncBtn && top && top.classList.contains("audio-running")) {
syncBtn.classList.add("flash");
setTimeout(() => syncBtn.classList.remove("flash"), 90);
}
}
@@ -188,17 +236,17 @@
const n = parseInt(String(inp.value).trim(), 10);
if (Number.isFinite(n)) return Math.min(500, Math.max(0, n));
}
try {
const v = parseInt(localStorage.getItem(PHASE_MS_KEY) || "0", 10);
return Number.isFinite(v) ? Math.min(500, Math.max(0, v)) : 0;
} catch {
return 0;
}
}
function persistBeatPhaseMs() {
async function persistBeatPhaseMs() {
const ms = getBeatPhaseDelayMs();
try {
localStorage.setItem(PHASE_MS_KEY, String(getBeatPhaseDelayMs()));
await fetch("/settings", {
method: "PUT",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ audio_beat_phase_ms: ms }),
});
} catch (e) {
console.warn("beat phase ms save failed", e);
}
@@ -227,7 +275,9 @@
/** Stop detector and polling; does not clear “resume on load” prefs (used before restart). */
async function stopAudioOnly() {
audioDetectorRunning = false;
setTopBpmVisible(false);
setNavResetVisible(false);
clearBeatPhaseTimers();
if (pollTimer) {
clearInterval(pollTimer);
@@ -245,10 +295,9 @@
}
}
/** User-initiated stop: also forget auto-restart on next page load. */
/** User-initiated stop (run intent cleared on server). */
async function stopAudio() {
await stopAudioOnly();
clearRestorePrefs();
}
async function pollStatus() {
@@ -262,24 +311,34 @@
node.textContent = String(status.error).trim().slice(0, 120);
}
updateBeatReadoutDisplays({});
audioDetectorRunning = !!status.running;
updateBpmDisplay(null);
setTopBpmVisible(!!status.running);
setNavResetVisible(!!status.running);
if (!status.running && pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
return;
}
setTopBpmVisible(!!status.running);
audioDetectorRunning = !!status.running;
const zoneSeqActive = sequencePlaybackActiveFromStatus(status);
setTopBpmVisible(!!status.running || zoneSeqActive);
setNavResetVisible(!!status.running);
updateSequenceSyncControls(zoneSeqActive);
updateBpmDisplay(status.bpm);
updateHitTypeDisplay(status.beat_type, Number(status.beat_type_confidence));
updateBarPhaseDisplay(status);
applyServerAudioUiFields(status);
if (typeof window.applySequenceSwitchWaitFromServer === "function") {
window.applySequenceSwitchWaitFromServer(status.sequence_switch_wait);
}
/*
* `status.beat_seq` is cumulative since Audio Start — used only for flash / sticky idle
* after sequence ends. Preset and sequence loop counts come from `manual_beat_stride` /
* `sequence` on each poll.
*/
const beatSeq = Number(status.beat_seq || 0);
const zoneSeqActive = sequencePlaybackActiveFromStatus(status);
const endedSeq = prevZoneSequencePlaybackActive && !zoneSeqActive;
const startedSeq = !prevZoneSequencePlaybackActive && zoneSeqActive;
prevZoneSequencePlaybackActive = zoneSeqActive;
@@ -324,7 +383,11 @@
const selected = el("audio-device-select")?.value || "";
const rawDevice = override !== "" ? override : selected;
const numeric = rawDevice !== "" && /^-?\d+$/.test(rawDevice) ? Number(rawDevice) : rawDevice;
const body = { device: rawDevice === "" ? null : numeric };
const body = {
device: rawDevice === "" ? null : numeric,
device_override: override,
device_select: selected,
};
const res = await fetch("/api/audio/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -334,7 +397,6 @@
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "Failed to start audio detector");
}
writeRestorePrefs(override, selected);
updateBpmDisplay(null);
updateHitTypeDisplay("unknown", NaN);
pollTimer = setInterval(pollStatus, 250);
@@ -382,6 +444,7 @@
const closeBtn = el("audio-close-btn");
const startBtn = el("audio-start-btn");
const stopBtn = el("audio-stop-btn");
const navResetBtn = el("audio-nav-reset-btn");
const refreshBtn = el("audio-refresh-btn");
if (!modal || !openBtn) return;
@@ -414,6 +477,9 @@
await stopAudio();
});
}
if (navResetBtn) {
navResetBtn.addEventListener("click", () => resetAudioTracking());
}
if (refreshBtn) {
refreshBtn.addEventListener("click", async () => {
try {
@@ -426,17 +492,41 @@
const phaseInp = el("audio-beat-phase-ms");
if (phaseInp) {
phaseInp.addEventListener("change", () => {
void persistBeatPhaseMs();
});
phaseInp.addEventListener("input", () => {
void persistBeatPhaseMs();
});
}
const bindSync = (node, mode) => {
if (!node) return;
node.addEventListener("click", async () => {
try {
const stored = parseInt(localStorage.getItem(PHASE_MS_KEY) || "0", 10);
if (Number.isFinite(stored)) {
phaseInp.value = String(Math.min(500, Math.max(0, stored)));
await syncSequenceBeatPhase(mode);
} catch (e) {
console.warn("sequence beat sync failed", e);
}
} catch {
/* ignore */
}
phaseInp.addEventListener("change", () => persistBeatPhaseMs());
phaseInp.addEventListener("input", () => persistBeatPhaseMs());
});
};
const topBpm = el("audio-top-beat-sync");
if (topBpm) {
topBpm.addEventListener("click", () => {
void handleTopBpmButtonClick();
});
}
bindSync(el("audio-modal-beat-readout"), "step");
bindSync(el("audio-sync-pass-btn"), "pass");
document.addEventListener("keydown", (ev) => {
if (ev.defaultPrevented || ev.repeat || isTypingTarget(ev.target)) return;
const k = String(ev.key || "").toLowerCase();
if (k !== "s") return;
ev.preventDefault();
const mode = ev.shiftKey ? "pass" : "step";
void syncSequenceBeatPhase(mode).catch((e) => console.warn("sequence beat sync failed", e));
});
}
async function resumePollingIfDetectorRunning() {
@@ -444,38 +534,74 @@
const res = await fetch("/api/audio/status", { cache: "no-store" });
const data = await res.json();
const status = data?.status || {};
audioDetectorRunning = !!status.running;
if (status.running && !pollTimer) {
pollTimer = setInterval(pollStatus, 250);
lastBeatSeq = Number(status.beat_seq || 0);
prevZoneSequencePlaybackActive = sequencePlaybackActiveFromStatus(status);
await pollStatus();
} else {
updateSequenceSyncControls(sequencePlaybackActiveFromStatus(status));
}
} catch (e) {
console.warn("audio resume poll check failed", e);
}
}
/**
* Apply browser-stored device fields only (GET /devices list); does not start detection.
* Beat detector run/stop is server-owned (`db/audio_run.json` + explicit Start/Stop in UI).
*/
async function applySavedAudioDeviceFormOnly() {
const prefs = readRestorePrefs();
if (!prefs) return;
/** Apply server-owned audio UI fields from status (device form, beat phase delay). */
function applyServerAudioUiFields(status) {
if (!status || typeof status !== "object") return;
const run = status.audio_run;
if (run && typeof run === "object") {
const ov = el("audio-device-override");
const sel = el("audio-device-select");
if (ov) ov.value = prefs.override || "";
if (ov && run.device_override != null) ov.value = String(run.device_override);
if (sel && run.device_select) sel.value = String(run.device_select);
}
const phaseInp = el("audio-beat-phase-ms");
if (
phaseInp &&
status.beat_phase_ms != null &&
document.activeElement !== phaseInp
) {
const ms = parseInt(String(status.beat_phase_ms), 10);
if (Number.isFinite(ms)) {
phaseInp.value = String(Math.min(500, Math.max(0, ms)));
}
}
}
async function loadServerAudioUiFields() {
try {
await refreshDevices();
} catch (e) {
console.warn("audio device list refresh failed", e);
}
if (sel && prefs.select) sel.value = prefs.select;
try {
const res = await fetch("/api/audio/status", { cache: "no-store" });
const data = await res.json();
applyServerAudioUiFields(data?.status || {});
} catch (e) {
console.warn("audio status load failed", e);
}
}
/** Called from sequences.js when server playback starts/stops without audio polling. */
window.ledControllerSequencePlaybackChanged = (active) => {
updateSequenceSyncControls(!!active);
if (active) {
setTopBpmVisible(true);
return;
}
if (!pollTimer) {
setTopBpmVisible(false);
updateSequenceSyncControls(false);
}
};
document.addEventListener("DOMContentLoaded", async () => {
bind();
await loadServerAudioUiFields();
await resumePollingIfDetectorRunning();
await applySavedAudioDeviceFormOnly();
});
})();

48
src/static/bundle_io.js Normal file
View File

@@ -0,0 +1,48 @@
/** Download/upload JSON bundles for profile, preset, and sequence import/export. */
window.downloadJsonFile = function downloadJsonFile(filename, data) {
const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
const blob = new Blob([text], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || 'bundle.json';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
};
window.pickJsonFile = function pickJsonFile() {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json,.json';
input.style.display = 'none';
document.body.appendChild(input);
input.addEventListener('change', () => {
const file = input.files && input.files[0];
input.remove();
if (!file) {
resolve(null);
return;
}
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => resolve(null);
reader.readAsText(file);
});
input.click();
});
};
window.parseJsonFileText = function parseJsonFileText(text) {
if (text == null || text === '') {
return null;
}
try {
return JSON.parse(text);
} catch (e) {
return null;
}
};

View File

@@ -1,8 +1,27 @@
// Device groups: members (MAC ids) + WiFi driver defaults; persisted via /groups.
// Without ``profile_id``, a group is shared across all profiles; with ``profile_id`` it is listed only for that profile.
async function getCurrentProfileIdForGroups() {
try {
const res = await fetch('/profiles/current', {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
if (!res.ok) return null;
const data = await res.json();
const id = data && (data.id || (data.profile && data.profile.id));
return id != null ? String(id) : null;
} catch {
return null;
}
}
async function fetchGroupsMap() {
try {
const response = await fetch('/groups', { headers: { Accept: 'application/json' } });
const response = await fetch('/groups', {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
if (!response.ok) return {};
const data = await response.json();
return data && typeof data === 'object' ? data : {};
@@ -137,6 +156,14 @@ function refreshEditGroupDebug() {
}
}
function syncGroupShareCheckboxFromDoc(g) {
const cb = document.getElementById('edit-group-share-all-profiles');
if (!cb) return;
const raw = g && (g.profile_id != null ? g.profile_id : g.profileId);
const scoped = raw != null && String(raw).trim() !== '';
cb.checked = !scoped;
}
function loadWifiFieldsFromGroup(g) {
const wName = document.getElementById('edit-group-wifi-driver-name');
const wLeds = document.getElementById('edit-group-wifi-num-leds');
@@ -189,7 +216,10 @@ async function openEditGroupModal(groupId, groupDoc) {
let g = groupDoc;
if (!g || typeof g !== 'object') {
try {
const response = await fetch(`/groups/${encodeURIComponent(groupId)}`);
const response = await fetch(`/groups/${encodeURIComponent(groupId)}`, {
credentials: 'same-origin',
headers: { Accept: 'application/json' },
});
if (response.ok) g = await response.json();
} catch (e) {
console.error(e);
@@ -217,6 +247,7 @@ async function openEditGroupModal(groupId, groupDoc) {
});
renderGroupDevicesEditor(editor, window.__editGroupDeviceRows, dm);
loadWifiFieldsFromGroup(g);
syncGroupShareCheckboxFromDoc(g);
refreshEditGroupDebug();
if (modal) modal.classList.add('active');
}
@@ -259,8 +290,13 @@ function renderGroupsList(groups) {
const label = document.createElement('span');
const devs = Array.isArray(g.devices) ? g.devices : [];
label.textContent = `${g.name || gid} (${devs.length} device${devs.length === 1 ? '' : 's'})`;
label.style.flex = '1';
const meta = document.createElement('div');
meta.className = 'muted-text';
meta.style.fontSize = '0.8em';
const rawPid = g.profile_id != null ? g.profile_id : g.profileId;
const scoped = rawPid != null && String(rawPid).trim() !== '';
meta.textContent = scoped ? `This profile only (${rawPid})` : 'Shared across profiles';
const editBtn = document.createElement('button');
editBtn.className = 'btn btn-secondary btn-small';
editBtn.textContent = 'Edit';
@@ -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),
});

View File

@@ -131,7 +131,7 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
try {
const response = await fetch('/settings/settings', {
const response = await fetch('/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({

View File

@@ -2,254 +2,21 @@ document.addEventListener('DOMContentLoaded', () => {
const openBtn = document.getElementById('led-tool-btn');
const modal = document.getElementById('led-tool-modal');
const closeBtn = document.getElementById('led-tool-close-btn');
const refreshPortsBtn = document.getElementById('led-tool-refresh-ports-btn');
const form = document.getElementById('led-tool-form');
const readBtn = document.getElementById('led-tool-read-btn');
const resetBtn = document.getElementById('led-tool-reset-btn');
const portSelect = document.getElementById('led-tool-port');
const outputEl = document.getElementById('led-tool-output');
const messageEl = document.getElementById('led-tool-message');
const iframe = document.getElementById('led-tool-iframe');
if (!openBtn || !modal || !form || !portSelect || !outputEl || !messageEl) {
if (!openBtn || !modal || !iframe) {
return;
}
const showMessage = (text, type = 'success') => {
messageEl.textContent = text;
messageEl.className = `message ${type} show`;
};
const setOutput = (text) => {
outputEl.value = text || '';
};
const parseApiResponse = async (response) => {
const bodyText = await response.text();
let data = null;
try {
data = bodyText ? JSON.parse(bodyText) : {};
} catch (error) {
data = { error: bodyText || `HTTP ${response.status}` };
}
return data;
};
const setFieldValue = (id, value) => {
const el = document.getElementById(id);
if (!el) return;
if (value === undefined || value === null) return;
el.value = String(value);
};
const populateFormFromSettings = (settings) => {
if (!settings || typeof settings !== 'object') return false;
setFieldValue('led-tool-name', settings.name);
setFieldValue('led-tool-num-leds', settings.num_leds);
setFieldValue('led-tool-led-pin', settings.led_pin);
setFieldValue('led-tool-brightness', settings.brightness);
setFieldValue('led-tool-transport', settings.transport_type);
setFieldValue('led-tool-ssid', settings.ssid);
setFieldValue('led-tool-password', settings.password);
setFieldValue('led-tool-wifi-channel', settings.wifi_channel);
setFieldValue('led-tool-default', settings.default);
return true;
};
const loadPorts = async () => {
const defaultPort = '/dev/ttyACM0';
try {
const response = await fetch('/led-tool/ports');
const data = await response.json();
const previous = portSelect.value;
portSelect.innerHTML = '<option value="">Select a serial port</option>';
for (const port of data.ports || []) {
const option = document.createElement('option');
option.value = port.device;
option.textContent = `${port.device} - ${port.description || 'Unknown'}`;
portSelect.appendChild(option);
}
if (previous) {
portSelect.value = previous;
} else if ((data.ports || []).some((p) => p.device === defaultPort)) {
portSelect.value = defaultPort;
} else {
const fallback = document.createElement('option');
fallback.value = defaultPort;
fallback.textContent = `${defaultPort} - default`;
portSelect.appendChild(fallback);
portSelect.value = defaultPort;
}
if (!data.led_cli_exists) {
showMessage('led-tool/cli.py was not found on the host.', 'error');
} else if ((data.ports || []).length === 0) {
showMessage('No serial ports found.', 'error');
} else {
showMessage(`Found ${(data.ports || []).length} serial port(s).`, 'success');
}
} catch (error) {
showMessage(`Failed to read serial ports: ${error.message}`, 'error');
}
};
openBtn.addEventListener('click', () => {
iframe.src = '/led-tool/editor';
modal.classList.add('active');
loadPorts();
});
if (closeBtn) {
closeBtn.addEventListener('click', () => {
modal.classList.remove('active');
iframe.src = 'about:blank';
});
}
if (refreshPortsBtn) {
refreshPortsBtn.addEventListener('click', () => {
loadPorts();
});
}
if (readBtn) {
readBtn.addEventListener('click', async () => {
const port = portSelect.value.trim();
if (!port) {
showMessage('Select a serial port first.', 'error');
return;
}
setOutput('Reading settings from device...');
showMessage('Reading settings over USB...', 'success');
try {
const response = await fetch(`/led-tool/settings?port=${encodeURIComponent(port)}`);
const data = await parseApiResponse(response);
if (!response.ok) {
showMessage(data.error || 'Read failed.', 'error');
setOutput(data.error || 'Request failed.');
return;
}
const output = [
`exit code: ${data.returncode}`,
'',
'stdout:',
data.stdout || '(none)',
'',
'stderr:',
data.stderr || '(none)',
].join('\n');
setOutput(output);
if (data.ok) {
const populated = populateFormFromSettings(data.settings);
if (populated) {
showMessage('Settings read and fields populated.', 'success');
} else {
showMessage('Settings read successfully.', 'success');
}
} else {
showMessage('Read completed with errors. Check output.', 'error');
}
} catch (error) {
showMessage(`Request failed: ${error.message}`, 'error');
setOutput(error.message);
}
});
}
if (resetBtn) {
resetBtn.addEventListener('click', async () => {
const port = portSelect.value.trim();
if (!port) {
showMessage('Select a serial port first.', 'error');
return;
}
setOutput('Resetting device and following output...');
showMessage('Resetting device over USB...', 'success');
try {
const response = await fetch('/led-tool/reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ port }),
});
const data = await parseApiResponse(response);
if (!response.ok) {
showMessage(data.error || 'Reset failed.', 'error');
setOutput(data.error || 'Request failed.');
return;
}
const output = [
`exit code: ${data.returncode}`,
'',
'stdout:',
data.stdout || '(none)',
'',
'stderr:',
data.stderr || '(none)',
].join('\n');
setOutput(output);
if (data.ok) {
showMessage('Device reset complete.', 'success');
} else {
showMessage('Reset completed with errors. Check output.', 'error');
}
} catch (error) {
showMessage(`Request failed: ${error.message}`, 'error');
setOutput(error.message);
}
});
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
const port = portSelect.value.trim();
if (!port) {
showMessage('Select a serial port first.', 'error');
return;
}
const payload = {
port,
name: document.getElementById('led-tool-name')?.value?.trim() || '',
num_leds: document.getElementById('led-tool-num-leds')?.value?.trim() || '',
led_pin: document.getElementById('led-tool-led-pin')?.value?.trim() || '',
brightness: document.getElementById('led-tool-brightness')?.value?.trim() || '',
transport: document.getElementById('led-tool-transport')?.value?.trim() || '',
ssid: document.getElementById('led-tool-ssid')?.value?.trim() || '',
password: document.getElementById('led-tool-password')?.value?.trim() || '',
wifi_channel: document.getElementById('led-tool-wifi-channel')?.value?.trim() || '',
default: document.getElementById('led-tool-default')?.value?.trim() || '',
};
setOutput('Running led-tool command...');
showMessage('Running command over USB...', 'success');
try {
const response = await fetch('/led-tool/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await parseApiResponse(response);
if (!response.ok) {
showMessage(data.error || 'Command failed.', 'error');
setOutput(data.error || 'Request failed.');
return;
}
const output = [
`exit code: ${data.returncode}`,
'',
'stdout:',
data.stdout || '(none)',
'',
'stderr:',
data.stderr || '(none)',
].join('\n');
setOutput(output);
if (data.ok) {
showMessage('Settings applied via USB.', 'success');
} else {
showMessage('Command completed with errors. Check output.', 'error');
}
} catch (error) {
showMessage(`Request failed: ${error.message}`, 'error');
setOutput(error.message);
}
});
});

117
src/static/numpad.js Normal file
View File

@@ -0,0 +1,117 @@
/**
* Bluetooth / USB HID numpad shortcuts (browser focus required).
*
* Numpad19,0 → zone 110 (visible zone list order)
* NumpadEnter → sequence beat sync (step), same as S
* NumpadDecimal → sequence beat sync (pass), same as Shift+S
* NumpadMultiply → reset audio detector
* NumpadAdd → brightness +16
* NumpadSubtract → brightness 16
* NumpadDivide → stop zone sequence playback
*/
(() => {
const BRIGHTNESS_STEP = 16;
function isTypingTarget(target) {
if (!target || typeof target !== "object") return false;
const tag = String(target.tagName || "").toLowerCase();
return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable;
}
function zoneIdsInListOrder() {
return [...document.querySelectorAll("#zones-list .zone-button[data-zone-id]")]
.map((el) => el.getAttribute("data-zone-id"))
.filter((id) => id != null && id !== "");
}
async function selectZoneByListIndex(oneBased) {
const order = zoneIdsInListOrder();
if (oneBased < 1 || oneBased > order.length) return;
const zoneId = order[oneBased - 1];
if (window.tabsManager && typeof window.tabsManager.selectZone === "function") {
await window.tabsManager.selectZone(zoneId);
} else if (typeof selectZone === "function") {
await selectZone(zoneId);
}
}
async function syncSequenceBeatPhase(mode) {
const res = await fetch("/sequences/sync-phase", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ mode: mode || "step" }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `Sync failed (${res.status})`);
}
}
async function resetAudioTracking() {
const res = await fetch("/api/audio/reset", {
method: "POST",
headers: { Accept: "application/json" },
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `Reset failed (${res.status})`);
}
}
function adjustZoneBrightness(delta) {
const zoneId =
(window.tabsManager && typeof window.tabsManager.getCurrentTabId === "function"
? window.tabsManager.getCurrentTabId()
: null) ||
(window.tabsManager && typeof window.tabsManager.getCurrentZoneId === "function"
? window.tabsManager.getCurrentZoneId()
: null);
if (!zoneId) return;
const slider =
document.getElementById("header-brightness-slider") ||
document.getElementById("menu-brightness-slider");
if (!slider) return;
const cur = parseInt(slider.value, 10);
const base = Number.isFinite(cur) ? cur : 127;
const next = Math.max(0, Math.min(255, base + delta));
if (String(slider.value) === String(next)) return;
slider.value = String(next);
slider.dispatchEvent(new Event("input", { bubbles: true }));
}
async function stopSequencePlayback() {
if (typeof window.stopZoneSequencePlayback === "function") {
await window.stopZoneSequencePlayback(true);
}
}
/** @type {Record<string, () => void | Promise<void>>} */
const actions = {
NumpadEnter: () => syncSequenceBeatPhase("step"),
NumpadDecimal: () => syncSequenceBeatPhase("pass"),
NumpadMultiply: () => resetAudioTracking(),
NumpadAdd: () => adjustZoneBrightness(BRIGHTNESS_STEP),
NumpadSubtract: () => adjustZoneBrightness(-BRIGHTNESS_STEP),
NumpadDivide: () => stopSequencePlayback(),
Numpad1: () => selectZoneByListIndex(1),
Numpad2: () => selectZoneByListIndex(2),
Numpad3: () => selectZoneByListIndex(3),
Numpad4: () => selectZoneByListIndex(4),
Numpad5: () => selectZoneByListIndex(5),
Numpad6: () => selectZoneByListIndex(6),
Numpad7: () => selectZoneByListIndex(7),
Numpad8: () => selectZoneByListIndex(8),
Numpad9: () => selectZoneByListIndex(9),
Numpad0: () => selectZoneByListIndex(10),
};
document.addEventListener("keydown", (ev) => {
if (ev.defaultPrevented || ev.repeat || isTypingTarget(ev.target)) return;
const code = ev.code;
if (!code || !code.startsWith("Numpad")) return;
const action = actions[code];
if (!action) return;
ev.preventDefault();
Promise.resolve(action()).catch((e) => console.warn("numpad shortcut failed:", e));
});
})();

View File

@@ -573,7 +573,12 @@ document.addEventListener('DOMContentLoaded', () => {
n3: coercePresetInt(preset.n3),
n4: coercePresetInt(preset.n4),
n5: coercePresetInt(preset.n5),
n6: coercePresetInt(preset.n6),
n6: (() => {
if (preset.mode !== undefined && preset.mode !== null && preset.mode !== '') {
return coercePresetInt(preset.mode);
}
return coercePresetInt(preset.n6);
})(),
};
});
if (!Object.keys(wirePresets).length) {

View File

@@ -4,6 +4,25 @@ let espnowSocketReady = false;
let espnowPendingMessages = [];
let currentProfileIdCache = null;
function coercePresetInt(v, def = 0) {
if (typeof v === 'number' && Number.isFinite(v)) {
return v;
}
const t = parseInt(String(v), 10);
return Number.isFinite(t) ? t : def;
}
/** Style variant for wire ``n6``; presets may store ``mode`` or legacy ``n6``. */
function presetWireN6(preset, def = 0) {
if (!preset || typeof preset !== 'object') {
return def;
}
if (preset.mode !== undefined && preset.mode !== null && preset.mode !== '') {
return coercePresetInt(preset.mode, def);
}
return coercePresetInt(preset.n6, def);
}
const getCurrentProfileId = async () => {
try {
const res = await fetch('/profiles/current', { headers: { Accept: 'application/json' } });
@@ -157,7 +176,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 +195,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 +261,11 @@ 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');
const presetModeInput = document.getElementById('preset-mode-input');
const presetModeGroup = document.getElementById('preset-mode-group');
const presetReverseInput = document.getElementById('preset-reverse-input');
const presetReverseGroup = document.getElementById('preset-reverse-group');
if (!presetsButton || !presetsModal || !presetsList || !presetSaveButton) {
return;
@@ -253,6 +277,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 = () => {
@@ -294,7 +320,8 @@ document.addEventListener('DOMContentLoaded', () => {
patternConfig.parameter_mappings &&
typeof patternConfig.parameter_mappings === 'object'
) {
patternConfig = patternConfig.parameter_mappings;
const { parameter_mappings: pm, data: _data, ...rest } = patternConfig;
patternConfig = { ...rest, ...pm };
}
return patternConfig && typeof patternConfig === 'object' ? patternConfig : null;
};
@@ -308,6 +335,62 @@ document.addEventListener('DOMContentLoaded', () => {
return cfg.supports_manual !== false;
};
const getPatternModeOptions = (patternName) => {
const cfg = resolvePatternConfig(patternName);
if (!cfg || typeof cfg.mode !== 'object' || cfg.mode === null || Array.isArray(cfg.mode)) {
return null;
}
const entries = Object.entries(cfg.mode).filter(
([, label]) => typeof label === 'string' && label.trim(),
);
if (entries.length < 2) {
return null;
}
entries.sort((a, b) => parseInt(a[0], 10) - parseInt(b[0], 10));
return entries;
};
const patternSupportsModes = (patternName) => getPatternModeOptions(patternName) !== null;
const patternSupportsReverse = (patternName) => {
const cfg = resolvePatternConfig(patternName);
return !!(cfg && cfg.supports_reverse);
};
const setPresetReverseFieldVisible = (show) => {
if (!presetReverseGroup) {
return;
}
presetReverseGroup.hidden = !show;
presetReverseGroup.style.display = show ? '' : 'none';
if (!show && presetReverseInput) {
presetReverseInput.checked = false;
}
};
const setPresetModeFieldVisible = (show) => {
if (!presetModeGroup) {
return;
}
presetModeGroup.hidden = !show;
presetModeGroup.style.display = show ? '' : 'none';
if (!show && presetModeInput) {
presetModeInput.innerHTML = '';
}
};
const presetStoredMode = (preset) => {
if (!preset || typeof preset !== 'object') {
return 0;
}
if (preset.mode !== undefined && preset.mode !== null && preset.mode !== '') {
const m = parseInt(String(preset.mode), 10);
return Number.isFinite(m) ? m : 0;
}
const n6 = parseInt(String(preset.n6), 10);
return Number.isFinite(n6) ? n6 : 0;
};
const updateManualBeatNVisibility = () => {
if (!presetManualBeatNWrap) {
return;
@@ -326,6 +409,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 +727,28 @@ document.addEventListener('DOMContentLoaded', () => {
presetBrightnessInput.value = preset.brightness || 0;
presetDelayInput.value = preset.delay || 0;
if (presetBackgroundInput) {
presetBackgroundInput.value = coercePresetBackground(preset);
const rawBgRef = preset.background_palette_ref ?? preset.backgroundPaletteRef;
let bgRef = null;
if (rawBgRef != null && rawBgRef !== '') {
const n = typeof rawBgRef === 'number' ? rawBgRef : parseInt(String(rawBgRef), 10);
if (Number.isInteger(n) && n >= 0) {
bgRef = n;
}
}
currentBackgroundPaletteRef = bgRef;
presetBackgroundInput.value = coercePresetBackground(preset);
updatePresetBackgroundButton();
const gen = ++bgPaletteResolveGen;
void getCurrentProfilePaletteColors().then((pal) => {
if (gen !== bgPaletteResolveGen || !presetBackgroundInput) {
return;
}
presetBackgroundInput.value = resolvePresetBackgroundHex(preset, pal);
updatePresetBackgroundButton();
});
} else {
updatePresetBackgroundButton();
}
if (presetManualModeInput) {
const autoVal = typeof preset.auto === 'boolean' ? preset.auto : true;
presetManualModeInput.checked = !autoVal;
@@ -685,6 +791,12 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
if (presetReverseInput) {
const n5raw = preset.n5;
const n5 = typeof n5raw === 'number' ? n5raw : parseInt(String(n5raw != null ? n5raw : '0'), 10);
presetReverseInput.checked = Number.isFinite(n5) && n5 > 0;
}
// Set n values, checking both n keys and descriptive names
for (let i = 1; i <= 8; i++) {
const nKey = `n${i}`;
@@ -708,12 +820,13 @@ document.addEventListener('DOMContentLoaded', () => {
}
// After values: show only mapped n params with labels from pattern.json; clear hidden inputs
updatePresetNLabels(patternName);
updatePresetNLabels(patternName, preset);
updateManualModeAvailability();
updatePresetEditorTabActionsVisibility();
};
const clearForm = () => {
bgPaletteResolveGen += 1;
currentEditId = null;
currentEditTabId = null;
currentPresetColors = [];
@@ -739,12 +852,13 @@ document.addEventListener('DOMContentLoaded', () => {
if (presetManualModeInput) {
presetManualModeInput.checked = false;
}
if (presetReverseInput) {
presetReverseInput.checked = false;
}
setPresetReverseFieldVisible(false);
if (presetManualBeatNInput) {
presetManualBeatNInput.value = '1';
}
if (presetBackgroundInput) {
presetBackgroundInput.value = '#000000';
}
updatePresetBackgroundButton();
updateManualModeAvailability();
// Re-enable name and pattern when clearing (for new preset)
@@ -769,10 +883,29 @@ document.addEventListener('DOMContentLoaded', () => {
return section ? section.dataset.zoneId : null;
};
const updatePresetEditorTabActionsVisibility = () => {
const updatePresetEditorTabActionsVisibility = async () => {
if (!presetRemoveFromTabButton) return;
const show = Boolean(currentEditTabId && currentEditId);
presetRemoveFromTabButton.hidden = !show;
if (!currentEditTabId || !currentEditId) {
presetRemoveFromTabButton.hidden = true;
return;
}
try {
const tabRes = await fetch(`/zones/${currentEditTabId}`, {
headers: { Accept: 'application/json' },
});
if (!tabRes.ok) {
presetRemoveFromTabButton.hidden = false;
return;
}
const tabData = await tabRes.json();
const allowed =
typeof window.zoneAllowsPresets === 'function'
? window.zoneAllowsPresets(tabData, currentEditTabId)
: true;
presetRemoveFromTabButton.hidden = !allowed;
} catch (e) {
presetRemoveFromTabButton.hidden = false;
}
};
const updateTabDefaultPreset = async (presetId) => {
@@ -803,8 +936,15 @@ document.addEventListener('DOMContentLoaded', () => {
if (presetEditorModal) {
presetEditorModal.classList.add('active');
}
const patternName = presetPatternInput ? presetPatternInput.value : '';
const modeBefore = patternSupportsModes(patternName)
? presetStoredMode({
mode: presetModeInput ? presetModeInput.value : undefined,
n6: getNumberInput('preset-n6-input'),
})
: 0;
loadPatterns().then(() => {
updatePresetNLabels(presetPatternInput ? presetPatternInput.value : '');
updatePresetNLabels(patternName, { mode: modeBefore, n6: modeBefore });
updateColorSectionVisibility();
});
};
@@ -825,6 +965,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;
@@ -834,11 +975,27 @@ document.addEventListener('DOMContentLoaded', () => {
})(),
};
// Always store numeric parameters as n1..n8.
// Always store numeric parameters as n1..n8 (except n6 when pattern uses mode).
const modeEntries = patternSupportsModes(payload.pattern)
? getPatternModeOptions(payload.pattern)
: null;
const reverseField = patternSupportsReverse(payload.pattern);
for (let i = 1; i <= 8; i++) {
const nKey = `n${i}`;
if (modeEntries && nKey === 'n6') {
continue;
}
if (reverseField && nKey === 'n5') {
continue;
}
payload[nKey] = getNumberInput(`preset-${nKey}-input`);
}
if (reverseField) {
payload.n5 = presetReverseInput && presetReverseInput.checked ? 1 : 0;
}
if (modeEntries && presetModeInput) {
payload.mode = parseInt(presetModeInput.value, 10) || 0;
}
return payload;
};
@@ -925,30 +1082,8 @@ document.addEventListener('DOMContentLoaded', () => {
}
};
const updatePresetNLabels = (patternName) => {
const rawPatternName = String(patternName || '').trim();
const normalizedPatternName = rawPatternName.endsWith('.py')
? rawPatternName.slice(0, -3)
: rawPatternName;
let patternConfig =
(cachedPatterns && cachedPatterns[rawPatternName]) ||
(cachedPatterns && cachedPatterns[normalizedPatternName]) ||
null;
if (!patternConfig && cachedPatterns && typeof cachedPatterns === 'object') {
const lower = normalizedPatternName.toLowerCase();
const matchedKey = Object.keys(cachedPatterns).find(
(k) => String(k).toLowerCase() === lower,
);
if (matchedKey) {
patternConfig = cachedPatterns[matchedKey];
}
}
if (patternConfig && typeof patternConfig === 'object' && patternConfig.data && typeof patternConfig.data === 'object') {
patternConfig = patternConfig.data;
}
if (patternConfig && typeof patternConfig === 'object' && patternConfig.parameter_mappings && typeof patternConfig.parameter_mappings === 'object') {
patternConfig = patternConfig.parameter_mappings;
}
const updatePresetNLabels = (patternName, presetForMode = null) => {
const patternConfig = resolvePatternConfig(patternName);
const labels = {};
const visibleNKeys = new Set();
@@ -964,9 +1099,45 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
const modeEntries = patternSupportsModes(patternName) ? getPatternModeOptions(patternName) : null;
const reverseField = patternSupportsReverse(patternName);
if (modeEntries) {
visibleNKeys.delete('n6');
}
if (reverseField) {
visibleNKeys.delete('n5');
}
setPresetReverseFieldVisible(reverseField);
if (reverseField && presetReverseInput) {
const n5raw = presetForMode && presetForMode.n5 !== undefined ? presetForMode.n5 : 0;
const n5 = typeof n5raw === 'number' ? n5raw : parseInt(String(n5raw), 10);
presetReverseInput.checked = Number.isFinite(n5) && n5 > 0;
}
if (presetModeInput) {
if (modeEntries) {
setPresetModeFieldVisible(true);
presetModeInput.innerHTML = '';
modeEntries.forEach(([val, label]) => {
const opt = document.createElement('option');
opt.value = val;
opt.textContent = label.trim();
presetModeInput.appendChild(opt);
});
const modeVal = presetForMode ? presetStoredMode(presetForMode) : 0;
const modeStr = String(modeVal);
if ([...presetModeInput.options].some((o) => o.value === modeStr)) {
presetModeInput.value = modeStr;
} else if (presetModeInput.options.length) {
presetModeInput.selectedIndex = 0;
}
} else {
setPresetModeFieldVisible(false);
}
}
const hasPatternMeta =
patternConfig && typeof patternConfig === 'object' && Object.keys(patternConfig).length > 0;
const hasAnyNLabel = visibleNKeys.size > 0;
const hasAnyNLabel = visibleNKeys.size > 0 || Boolean(modeEntries);
for (let i = 1; i <= 8; i++) {
const nKey = `n${i}`;
@@ -1048,6 +1219,26 @@ document.addEventListener('DOMContentLoaded', () => {
void sendPresetViaEspNow(presetId, preset || {}, []);
});
const exportButton = document.createElement('button');
exportButton.className = 'btn btn-secondary btn-small';
exportButton.textContent = 'Export';
exportButton.addEventListener('click', async () => {
try {
const response = await fetch(`/presets/${presetId}/export`, {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error('Export failed');
}
const bundle = await response.json();
const safeName = ((preset && preset.name) || presetId).replace(/[^\w.-]+/g, '_');
window.downloadJsonFile(`preset-${safeName}.json`, bundle);
} catch (error) {
console.error('Export preset failed:', error);
alert('Failed to export preset.');
}
});
const deleteButton = document.createElement('button');
deleteButton.className = 'btn btn-danger btn-small';
deleteButton.textContent = 'Delete';
@@ -1077,6 +1268,7 @@ document.addEventListener('DOMContentLoaded', () => {
row.appendChild(label);
row.appendChild(details);
row.appendChild(editButton);
row.appendChild(exportButton);
row.appendChild(sendButton);
row.appendChild(deleteButton);
presetsList.appendChild(row);
@@ -1123,6 +1315,34 @@ document.addEventListener('DOMContentLoaded', () => {
if (presetsCloseButton) {
presetsCloseButton.addEventListener('click', closeModal);
}
const importPresetBtn = document.getElementById('import-preset-btn');
if (importPresetBtn) {
importPresetBtn.addEventListener('click', async () => {
const text = await window.pickJsonFile();
if (!text) return;
const bundle = window.parseJsonFileText(text);
if (!bundle || bundle.kind !== 'preset') {
alert('Invalid preset bundle file.');
return;
}
try {
const response = await fetch('/presets/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ bundle }),
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || 'Import failed');
}
await loadPresets();
} catch (error) {
console.error('Import preset failed:', error);
alert(error.message || 'Failed to import preset.');
}
});
}
if (presetsAddButton) {
presetsAddButton.addEventListener('click', () => {
clearForm();
@@ -1174,6 +1394,22 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
try {
const zoneCheck = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
if (zoneCheck.ok) {
const zoneDoc = await zoneCheck.json();
if (
typeof window.zoneAllowsPresets === 'function' &&
!window.zoneAllowsPresets(zoneDoc, zoneId)
) {
alert('This zone is for sequences only. Add a sequence from the zone Edit menu instead.');
return;
}
}
} catch (e) {
console.warn('Could not verify zone content kind:', e);
}
// Load all presets
try {
const response = await fetch('/presets', {
@@ -1302,6 +1538,13 @@ document.addEventListener('DOMContentLoaded', () => {
throw new Error('Failed to load zone');
}
const tabData = await tabResponse.json();
if (
typeof window.zoneAllowsPresets === 'function' &&
!window.zoneAllowsPresets(tabData, zoneId)
) {
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 +1567,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 +1623,7 @@ document.addEventListener('DOMContentLoaded', () => {
presetBackgroundInput.click();
});
presetBackgroundInput.addEventListener('input', () => {
currentBackgroundPaletteRef = null;
updatePresetBackgroundButton();
});
}
@@ -1462,10 +1703,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 +1716,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.');
}
});
}
@@ -1605,14 +1907,6 @@ document.addEventListener('DOMContentLoaded', () => {
clearForm();
});
const coercePresetInt = (v, def = 0) => {
if (typeof v === 'number' && Number.isFinite(v)) {
return v;
}
const t = parseInt(String(v), 10);
return Number.isFinite(t) ? t : def;
};
/** Device field ``a`` / API ``auto``; missing → auto-run (matches server build_preset_dict). */
const coercePresetAuto = (preset) => {
if (!preset || typeof preset !== 'object') {
@@ -1663,6 +1957,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 +2009,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: {
@@ -1714,7 +2028,7 @@ const sendPresetViaEspNow = async (
n3: coercePresetInt(preset.n3),
n4: coercePresetInt(preset.n4),
n5: coercePresetInt(preset.n5),
n6: coercePresetInt(preset.n6),
n6: presetWireN6(preset),
manual_beat_n: coerceManualBeatN(preset),
},
},
@@ -1817,7 +2131,7 @@ try {
// window may not exist in some environments; ignore.
}
// Store selected preset(s) per zone (multi-select; merge send order = click order, last wins on device).
// Store selected preset per zone (single-select; one tile active, one driver push per click).
const zoneSelectedPresetIds = {};
const zonePresetSelectionOrder = {};
@@ -1844,19 +2158,21 @@ function getOrderedZonePresetSelection(zoneId) {
return (zonePresetSelectionOrder[z] || []).filter((id) => set.has(String(id)));
}
async function sendMergedZonePresetSelection(zoneId, tabData, allPresets) {
const ids = getOrderedZonePresetSelection(zoneId);
if (!ids.length) return;
for (let i = 0; i < ids.length; i += 1) {
const pid = ids[i];
const preset = allPresets[pid];
if (!preset) continue;
/** Preset id that should show the tile outline (last click in selection order). */
function getLastZonePresetSelectionId(zoneId) {
const order = getOrderedZonePresetSelection(zoneId);
return order.length ? String(order[order.length - 1]) : null;
}
async function sendZonePresetSelection(zoneId, tabData, presetId, preset, allPresets) {
const pid = String(presetId);
const body = (allPresets && allPresets[pid]) || preset;
if (!body) return;
const names =
window.zonesManager && typeof window.zonesManager.resolveDeviceNamesForZonePreset === 'function'
? await window.zonesManager.resolveDeviceNamesForZonePreset(tabData, pid)
: [];
await sendPresetViaEspNow(pid, preset, names, false, false, '2');
}
await sendPresetViaEspNow(pid, body, names, false, false, '2');
}
// Store selected preset per zone
@@ -1941,6 +2257,12 @@ const savePresetGrid = async (zoneId, presetGrid) => {
throw new Error('Failed to load zone');
}
const tabData = await tabResponse.json();
if (
typeof window.zoneAllowsPresets === 'function' &&
!window.zoneAllowsPresets(tabData, zoneId)
) {
throw new Error('This zone is for sequences only.');
}
// Store as 2D grid
tabData.presets = presetGrid;
@@ -2034,6 +2356,12 @@ const renderTabPresets = async (zoneId, options = {}) => {
}
const tabData = await tabResponse.json();
const groupsMapStrip = groupsStripRes.ok ? await groupsStripRes.json() : {};
const ck =
typeof window.effectiveZoneContentKind === 'function'
? window.effectiveZoneContentKind(tabData)
: typeof window.normalizeZoneContentKind === 'function'
? window.normalizeZoneContentKind(tabData)
: 'presets';
// Get presets - support both 2D grid and flat array (for backward compatibility)
let presetGrid = tabData.presets;
@@ -2045,6 +2373,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,22 +2453,37 @@ 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.';
if (ck === 'sequences') {
if (!hasSeq) {
empty.textContent =
"No sequences on this zone yet. Open the zone's Edit menu to add one.";
presetsList.appendChild(empty);
}
} else {
empty.textContent =
'No presets added to this zone. Open the zone\'s Edit menu and click "Add Preset" to add one.';
presetsList.appendChild(empty);
}
} else {
flatPresets.forEach((presetId) => {
const preset = allPresets[presetId];
if (preset) {
ensureZonePresetSelection(zoneId);
const isSelected = zoneSelectedPresetIds[String(zoneId)].has(String(presetId));
const lastSelectedId = getLastZonePresetSelectionId(zoneId);
const isSelected =
lastSelectedId !== null && lastSelectedId === String(presetId);
const displayPreset = {
...preset,
colors: resolveColorsWithPaletteRefs(preset.colors, preset.palette_refs, paletteColors),
background: resolvePresetBackgroundHex(preset, paletteColors),
};
const wrapper = createPresetButton(
presetId,
@@ -2153,7 +2499,11 @@ const renderTabPresets = async (zoneId, options = {}) => {
});
}
if (typeof window.appendZoneSequenceTiles === 'function') {
if (
typeof window.appendZoneSequenceTiles === 'function' &&
(typeof window.zoneAllowsSequences !== 'function' ||
window.zoneAllowsSequences(tabData, zoneId))
) {
await window.appendZoneSequenceTiles(zoneId, tabData, allPresets, paletteColors, presetsList);
}
} catch (error) {
@@ -2179,7 +2529,9 @@ const createPresetButton = (presetId, preset, zoneId, isSelected, tabData, group
}
const colors = Array.isArray(preset.colors) ? preset.colors.filter((c) => c) : [];
const isRainbow = (preset.pattern || '').toLowerCase() === 'rainbow';
const pat = (preset.pattern || '').toLowerCase();
const mode = presetWireN6(preset);
const isRainbow = pat === 'rainbow' || (pat === 'colour_cycle' && mode === 1);
const barColors = isRainbow
? ['#FF0000', '#FF7F00', '#FFFF00', '#00FF00', '#0000FF', '#4B0082', '#8F00FF']
: colors;
@@ -2199,7 +2551,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,41 +2605,36 @@ 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);
const set = zoneSelectedPresetIds[z];
const order = zonePresetSelectionOrder[z];
const idStr = String(presetId);
if (set.has(idStr)) {
set.delete(idStr);
zonePresetSelectionOrder[z] = order.filter((x) => String(x) !== idStr);
} else {
const wasSelected = set.has(idStr);
set.clear();
zonePresetSelectionOrder[z] = [];
if (!wasSelected) {
set.add(idStr);
order.push(idStr);
zonePresetSelectionOrder[z] = [idStr];
}
const outlinePresetId = getLastZonePresetSelectionId(zoneId);
if (presetsListEl) {
presetsListEl.querySelectorAll('.preset-tile-row:not(.sequence-tile-row)').forEach((rw) => {
const pid = rw.dataset.presetId;
const btnEl = rw.querySelector('.preset-tile-main');
if (!btnEl || !pid) return;
if (set.has(String(pid))) btnEl.classList.add('active');
if (outlinePresetId && String(pid) === outlinePresetId) btnEl.classList.add('active');
else btnEl.classList.remove('active');
});
}
const orderList = getOrderedZonePresetSelection(zoneId);
if (orderList.length) {
const lastPid = orderList[orderList.length - 1];
selectedPresets[zoneId] = lastPid;
selectedPresetPayloads[zoneId] = (allPresets && allPresets[lastPid]) || preset;
if (!wasSelected) {
selectedPresets[zoneId] = idStr;
selectedPresetPayloads[zoneId] = (allPresets && allPresets[idStr]) || preset;
void sendZonePresetSelection(zoneId, tabData, idStr, preset, allPresets);
} else {
delete selectedPresets[zoneId];
delete selectedPresetPayloads[zoneId];
}
void sendMergedZonePresetSelection(zoneId, tabData, allPresets);
});
if (canDrag) {
@@ -2397,6 +2744,13 @@ const removePresetFromTab = async (zoneId, presetId) => {
throw new Error('Failed to load zone');
}
const tabData = await tabResponse.json();
if (
typeof window.zoneAllowsPresets === 'function' &&
!window.zoneAllowsPresets(tabData, zoneId)
) {
alert('This zone is for sequences only.');
return;
}
// Normalize to flat array
let flat = [];
@@ -2421,12 +2775,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' },

View File

@@ -6,6 +6,7 @@ document.addEventListener("DOMContentLoaded", () => {
const newProfileInput = document.getElementById("new-profile-name");
const newProfileSeedDjInput = document.getElementById("new-profile-seed-dj");
const createProfileButton = document.getElementById("create-profile-btn");
const importProfileButton = document.getElementById("import-profile-btn");
if (!profilesButton || !profilesModal || !profilesList) {
return;
@@ -101,6 +102,26 @@ document.addEventListener("DOMContentLoaded", () => {
}
});
const exportButton = document.createElement("button");
exportButton.className = "btn btn-secondary btn-small";
exportButton.textContent = "Export";
exportButton.addEventListener("click", async () => {
try {
const response = await fetch(`/profiles/${profileId}/export`, {
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error("Export failed");
}
const bundle = await response.json();
const safeName = ((profile && profile.name) || profileId).replace(/[^\w.-]+/g, "_");
window.downloadJsonFile(`profile-${safeName}.json`, bundle);
} catch (error) {
console.error("Export profile failed:", error);
alert("Failed to export profile.");
}
});
const cloneButton = document.createElement("button");
cloneButton.className = "btn btn-secondary btn-small";
cloneButton.textContent = "Clone";
@@ -177,6 +198,7 @@ document.addEventListener("DOMContentLoaded", () => {
row.appendChild(label);
row.appendChild(applyButton);
if (editMode) {
row.appendChild(exportButton);
row.appendChild(cloneButton);
row.appendChild(deleteButton);
}
@@ -276,6 +298,60 @@ document.addEventListener("DOMContentLoaded", () => {
if (createProfileButton) {
createProfileButton.addEventListener("click", createProfile);
}
const importProfile = async () => {
if (!isEditModeActive()) {
return;
}
const text = await window.pickJsonFile();
if (!text) {
return;
}
const bundle = window.parseJsonFileText(text);
if (!bundle || typeof bundle !== "object") {
alert("Invalid JSON file.");
return;
}
const defaultName =
(bundle.profile && bundle.profile.name) || "Imported profile";
const name = prompt("Profile name for import:", defaultName);
if (name === null) {
return;
}
const trimmed = String(name).trim();
if (!trimmed) {
alert("Profile name cannot be empty.");
return;
}
try {
const response = await fetch("/profiles/import", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ bundle, name: trimmed, apply: true }),
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || "Import failed");
}
const data = await response.json();
const newProfileId = data.id || Object.keys(data).find((k) => k !== "id");
if (newProfileId) {
await fetch(`/profiles/${newProfileId}/apply`, {
method: "POST",
headers: { Accept: "application/json" },
});
}
await loadProfiles();
await refreshTabsForActiveProfile();
} catch (error) {
console.error("Import profile failed:", error);
alert(error.message || "Failed to import profile.");
}
};
if (importProfileButton) {
importProfileButton.addEventListener("click", importProfile);
}
if (newProfileInput) {
newProfileInput.addEventListener("keypress", (event) => {
if (event.key === "Enter") {

View File

@@ -1,14 +1,98 @@
// Sequences: lanes (parallel preset chains), shared groups, time or beat advance.
// Debug: in the browser console run setSequenceDebug(true) — toggling logs 1 (on) or 0 (off).
// Sequences: lanes (parallel preset chains); advance is always by audio beats or simulated BPM.
// Debug: in the browser console run setSequenceDebug(true) — session only, not persisted.
const SEQ_DEBUG_STORAGE_KEY = 'led-controller-sequence-debug';
/** @type {'beat'|'downbeat'} */
let sequenceSwitchWaitFor = 'beat';
let sequenceDebugEnabled = false;
let sequenceSwitchSaveInFlight = false;
async function loadSequenceSwitchWaitForFromServer() {
try {
const res = await fetch('/settings', {
cache: 'no-store',
headers: { Accept: 'application/json' },
});
if (!res.ok) return;
const data = await res.json();
const raw = data && data.sequence_switch_wait;
if (raw === 'downbeat' || raw === 'beat') {
sequenceSwitchWaitFor = raw;
} else if (raw === 'phrase') {
sequenceSwitchWaitFor = 'beat';
}
} catch {
/* keep default */
}
}
async function persistSequenceSwitchWaitFor() {
sequenceSwitchSaveInFlight = true;
try {
const res = await fetch('/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ sequence_switch_wait: sequenceSwitchWaitFor }),
});
if (!res.ok) {
console.warn('[sequence] could not save switch wait to server', res.status);
}
} catch (e) {
console.warn('[sequence] could not save switch wait to server', e);
} finally {
sequenceSwitchSaveInFlight = false;
}
}
function getSequenceSwitchWaitFor() {
return sequenceSwitchWaitFor === 'downbeat' ? 'downbeat' : 'beat';
}
async function setSequenceSwitchWaitFor(waitFor) {
sequenceSwitchWaitFor = waitFor === 'downbeat' ? 'downbeat' : 'beat';
updateSequenceSwitchToggleUI();
await persistSequenceSwitchWaitFor();
}
function updateSequenceSwitchToggleUI() {
const mode = getSequenceSwitchWaitFor();
const ariaLabels = {
beat: 'Switch sequence on beat',
downbeat: 'Switch sequence on downbeat',
};
document.querySelectorAll('.seq-switch-toggle').forEach((btn) => {
btn.setAttribute('aria-pressed', mode === 'beat' ? 'false' : 'true');
btn.setAttribute('aria-label', ariaLabels[mode] || ariaLabels.beat);
btn.classList.toggle('seq-switch-toggle--downbeat', mode === 'downbeat');
});
document.querySelectorAll('.seq-switch-toggle-wrap').forEach((wrap) => {
wrap.classList.toggle('nav-slide-toggle-wrap--downbeat', mode === 'downbeat');
});
}
async function initSequenceSwitchToggle() {
await loadSequenceSwitchWaitForFromServer();
updateSequenceSwitchToggleUI();
document.querySelectorAll('.seq-switch-toggle').forEach((btn) => {
btn.addEventListener('click', () => {
void setSequenceSwitchWaitFor(getSequenceSwitchWaitFor() === 'beat' ? 'downbeat' : 'beat');
});
});
}
/** Sync toggle when settings changed elsewhere (e.g. another tab via audio status poll). */
function applySequenceSwitchWaitFromServer(raw) {
if (sequenceSwitchSaveInFlight) return;
let mode = 'beat';
if (raw === 'downbeat') mode = 'downbeat';
else if (raw !== 'beat' && raw !== 'phrase') return;
if (mode === getSequenceSwitchWaitFor()) return;
sequenceSwitchWaitFor = mode;
updateSequenceSwitchToggleUI();
}
function seqDebugEnabled() {
try {
return localStorage.getItem(SEQ_DEBUG_STORAGE_KEY) === '1';
} catch {
return false;
}
return sequenceDebugEnabled;
}
/** @type {ReturnType<typeof setInterval> | null} */
@@ -24,7 +108,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 +123,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 +181,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 +199,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)';
}
@@ -134,6 +216,12 @@ function logSequenceSelectionPresets(sequenceId, sequenceDoc, presetsMap) {
});
}
function zoneGroupIdsFromDoc(zoneDoc) {
return Array.isArray(zoneDoc && zoneDoc.group_ids)
? zoneDoc.group_ids.map((x) => String(x).trim()).filter(Boolean)
: [];
}
function groupIdsForLaneStep(sequenceDoc, step, laneIndex, numLanes) {
const lgs = Array.isArray(sequenceDoc.lanes_group_ids) ? sequenceDoc.lanes_group_ids : [];
if (laneIndex < lgs.length) {
@@ -157,7 +245,6 @@ function groupIdsForLaneStep(sequenceDoc, step, laneIndex, numLanes) {
function buildLaneGroupIdsForEditor(doc, laneIndex, numLanes) {
const raw = Array.isArray(doc && doc.lanes_group_ids) ? doc.lanes_group_ids : [];
const shared = Array.isArray(doc && doc.group_ids) ? doc.group_ids.map(String) : [];
if (laneIndex < raw.length) {
const row = raw[laneIndex];
if (Array.isArray(row)) {
@@ -167,17 +254,10 @@ function buildLaneGroupIdsForEditor(doc, laneIndex, numLanes) {
if (numLanes > 1 && laneIndex >= raw.length) {
return [];
}
if (numLanes === 1) {
const lanes = normalizeSequenceLanes(doc);
const first = lanes[0] && lanes[0][0];
const sg =
first && Array.isArray(first.group_ids) ? first.group_ids.map(String).filter(Boolean) : [];
return sg.length ? sg : shared.slice();
}
return shared.slice();
return [];
}
function renderLaneGroupCheckboxes(groupsMap, selectedIds) {
function renderLaneGroupCheckboxes(groupsMap, selectedIds, zoneGroupIds) {
const wrap = document.createElement('div');
wrap.className = 'sequence-lane-groups-wrap';
wrap.style.cssText = 'margin-bottom:0.6rem;';
@@ -185,15 +265,17 @@ function renderLaneGroupCheckboxes(groupsMap, selectedIds) {
hint.className = 'muted-text';
hint.style.fontSize = '0.85em';
hint.style.marginBottom = '0.35rem';
hint.textContent = 'Groups for this lane (none = whole zone)';
hint.textContent = 'Only checked groups are used on this lane';
wrap.appendChild(hint);
const row = document.createElement('div');
row.className = 'sequence-lane-groups';
row.style.cssText = 'display:flex;flex-wrap:wrap;gap:0.5rem;align-items:center;';
const sel = new Set((selectedIds || []).map((x) => String(x)));
Object.keys(groupsMap)
.sort((a, b) => a.localeCompare(b))
.forEach((gid) => {
const zg = Array.isArray(zoneGroupIds) ? zoneGroupIds.map(String).filter(Boolean) : [];
const gidsToShow = zg.length
? zg
: Object.keys(groupsMap).sort((a, b) => a.localeCompare(b));
gidsToShow.forEach((gid) => {
const g = groupsMap[gid];
const gn = g && g.name ? String(g.name) : gid;
const id = `seq-lg-${gid}-${Math.random().toString(36).slice(2)}`;
@@ -251,13 +333,6 @@ function presetsSectionElForZone(zoneId) {
/** Match preset tiles: prefer DOM device list, then zone JSON (same as parseTabDeviceNames + computeZoneTargets). */
async function resolveSequenceSendDeviceNames(zoneId, zoneDoc, groupIds) {
const gids = Array.isArray(groupIds) ? groupIds.map((x) => String(x).trim()).filter((x) => x.length > 0) : [];
if (!gids.length) {
const section = presetsSectionElForZone(zoneId);
if (typeof window.parseTabDeviceNames === 'function' && section) {
const fromDom = window.parseTabDeviceNames(section);
if (Array.isArray(fromDom) && fromDom.length) return fromDom;
}
}
if (window.zonesManager && typeof window.zonesManager.resolveSequenceStepDeviceNames === 'function' && zoneDoc) {
return await window.zonesManager.resolveSequenceStepDeviceNames(zoneDoc, gids);
}
@@ -268,11 +343,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 +377,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 : {};
@@ -315,30 +400,12 @@ function createSequenceTileRow(sequenceId, sequenceDoc, zoneId, zoneDoc, allPres
button.className = 'pattern-button preset-tile-main sequence-tile-main';
button.title = sequenceDoc.name || `Sequence ${sequenceId}`;
const badge = document.createElement('span');
badge.textContent = 'SEQ';
badge.className = 'sequence-tile-badge';
badge.style.cssText =
'position:absolute;left:4px;top:4px;font-size:10px;font-weight:700;color:#fff;background:rgba(0,100,180,0.9);padding:2px 5px;border-radius:3px;pointer-events:none;z-index:2;';
button.style.position = 'relative';
button.appendChild(badge);
const label = document.createElement('span');
label.textContent = sequenceDoc.name || sequenceId;
label.style.fontWeight = 'bold';
label.className = 'pattern-button-label';
button.appendChild(label);
const sub = document.createElement('span');
sub.className = 'muted-text';
sub.style.cssText = 'display:block;font-size:0.8em;margin-top:0.2rem;';
const lanes = normalizeSequenceLanes(sequenceDoc);
const nLanes = lanes.filter((l) => l.length > 0).length || 1;
const nSteps = lanes.reduce((a, l) => a + l.length, 0);
const adv = sequenceDoc.advance_mode === 'beats' ? 'beats' : 'time';
sub.textContent = `${nLanes} lane${nLanes === 1 ? '' : 's'} · ${nSteps} step${nSteps === 1 ? '' : 's'} · ${adv}`;
button.appendChild(sub);
button.addEventListener('click', () => {
const strip = document.getElementById('presets-list-zone');
const clearActiveStrip = () => {
@@ -443,6 +510,13 @@ 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();
if (
typeof window.zoneAllowsSequences === 'function' &&
!window.zoneAllowsSequences(tabData, zoneId)
) {
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 +579,15 @@ 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();
if (
typeof window.zoneAllowsSequences === 'function' &&
!window.zoneAllowsSequences(zone, zoneId)
) {
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 +669,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 +748,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 +821,7 @@ function renderSequenceStepRow(presetsMap, step) {
);
});
top.appendChild(dragHandle);
top.appendChild(presetWrap);
top.appendChild(beatWrap);
top.appendChild(editPresetBtn);
@@ -673,7 +837,7 @@ function renderSequenceStepRow(presetsMap, step) {
return row;
}
function renderSequenceLane(laneIndex, laneSteps, laneGroupIds, presetsMap, groupsMap) {
function renderSequenceLane(laneIndex, laneSteps, laneGroupIds, presetsMap, groupsMap, zoneGroupIds) {
const wrap = document.createElement('div');
wrap.className = 'sequence-lane';
wrap.dataset.laneIndex = String(laneIndex);
@@ -712,7 +876,7 @@ function renderSequenceLane(laneIndex, laneSteps, laneGroupIds, presetsMap, grou
head.appendChild(headBtns);
wrap.appendChild(head);
wrap.appendChild(renderLaneGroupCheckboxes(groupsMap, laneGroupIds));
wrap.appendChild(renderLaneGroupCheckboxes(groupsMap, laneGroupIds, zoneGroupIds));
const stepsHost = document.createElement('div');
stepsHost.className = 'sequence-lane-steps';
@@ -720,6 +884,7 @@ function renderSequenceLane(laneIndex, laneSteps, laneGroupIds, presetsMap, grou
steps.forEach((s) => {
stepsHost.appendChild(renderSequenceStepRow(presetsMap, s));
});
wireSequenceLaneStepsDragReorder(stepsHost);
wrap.appendChild(stepsHost);
return wrap;
}
@@ -763,62 +928,45 @@ 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() : {};
const groupsMap = await fetchGroupsMapSeq();
let zoneDoc = {};
const zoneIdForEditor = resolveZoneIdForPresetStripRefresh();
if (zoneIdForEditor) {
try {
const zr = await fetch(`/zones/${encodeURIComponent(zoneIdForEditor)}`, {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
if (zr.ok) {
const zj = await zr.json();
if (zj && typeof zj === 'object' && !zj.error) zoneDoc = zj;
}
} catch (_) {
/* no zone context */
}
}
const zoneGroupIds = zoneGroupIdsFromDoc(zoneDoc);
let doc = existing;
if (sequenceEditorId) {
try {
@@ -841,26 +989,22 @@ 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 = '';
if (!lanes.some((l) => l.length > 0)) {
const lg0 = buildLaneGroupIdsForEditor(doc, 0, 1);
lanesHost.appendChild(renderSequenceLane(0, [], lg0, presetsMap, groupsMap));
lanesHost.appendChild(renderSequenceLane(0, [], lg0, presetsMap, groupsMap, zoneGroupIds));
} else {
lanes.forEach((laneSteps, i) => {
const lg = buildLaneGroupIdsForEditor(doc, i, lanes.length);
lanesHost.appendChild(renderSequenceLane(i, laneSteps, lg, presetsMap, groupsMap));
lanesHost.appendChild(renderSequenceLane(i, laneSteps, lg, presetsMap, groupsMap, zoneGroupIds));
});
}
refreshSequenceEditorLaneTitles();
@@ -888,9 +1032,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 +1044,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] : [],
};
@@ -1021,26 +1164,41 @@ async function loadSequencesModalList() {
const nSteps = ln.reduce((a, l) => a + l.length, 0);
const nLanes = ln.filter((l) => l.length > 0).length || 1;
title.textContent = `${doc.name || id}${nLanes} lane(s), ${nSteps} step(s)`;
const exportBtn = document.createElement('button');
exportBtn.type = 'button';
exportBtn.className = 'btn btn-secondary btn-small';
exportBtn.textContent = 'Export';
exportBtn.addEventListener('click', async () => {
try {
const response = await fetch(`/sequences/${id}/export`, {
headers: { Accept: 'application/json' },
});
if (!response.ok) throw new Error('Export failed');
const bundle = await response.json();
const safeName = String(doc.name || id).replace(/[^\w.-]+/g, '_');
window.downloadJsonFile(`sequence-${safeName}.json`, bundle);
} catch (e) {
console.error(e);
alert('Failed to export sequence.');
}
});
const edit = document.createElement('button');
edit.type = 'button';
edit.className = 'btn btn-secondary btn-small';
edit.textContent = 'Edit';
edit.addEventListener('click', () => openSequenceEditor(id, doc));
row.appendChild(title);
row.appendChild(exportBtn);
row.appendChild(edit);
listEl.appendChild(row);
});
}
window.applySequenceSwitchWaitFromServer = applySequenceSwitchWaitFromServer;
window.stopZoneSequencePlayback = stopZoneSequencePlayback;
/** @param {boolean} on */
window.setSequenceDebug = function setSequenceDebug(on) {
try {
if (on) localStorage.setItem(SEQ_DEBUG_STORAGE_KEY, '1');
else localStorage.removeItem(SEQ_DEBUG_STORAGE_KEY);
} catch (e) {
console.warn('[sequence] could not persist debug flag', e);
}
sequenceDebugEnabled = !!on;
console.log(seqDebugEnabled() ? 1 : 0);
};
window.appendZoneSequenceTiles = appendZoneSequenceTiles;
@@ -1049,6 +1207,7 @@ window.addSequenceToTab = addSequenceToTab;
window.removeSequenceFromTab = removeSequenceFromTab;
document.addEventListener('DOMContentLoaded', () => {
void initSequenceSwitchToggle();
const btn = document.getElementById('sequences-btn');
const modal = document.getElementById('sequences-modal');
const closeBtn = document.getElementById('sequences-close-btn');
@@ -1068,6 +1227,33 @@ document.addEventListener('DOMContentLoaded', () => {
openSequenceEditor(null, null);
});
}
const importSeqBtn = document.getElementById('import-sequence-btn');
if (importSeqBtn) {
importSeqBtn.addEventListener('click', async () => {
const text = await window.pickJsonFile();
if (!text) return;
const bundle = window.parseJsonFileText(text);
if (!bundle || bundle.kind !== 'sequence') {
alert('Invalid sequence bundle file.');
return;
}
try {
const response = await fetch('/sequences/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ bundle }),
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || 'Import failed');
}
await loadSequencesModalList();
} catch (e) {
console.error(e);
alert(e.message || 'Failed to import sequence.');
}
});
}
const openPresetsFromSeq = document.getElementById('sequences-open-presets-btn');
if (openPresetsFromSeq) {
openPresetsFromSeq.addEventListener('click', () => {
@@ -1089,16 +1275,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 () => {

View File

@@ -106,7 +106,7 @@ header h1 {
font-weight: 600;
}
/* Second header row: BPM, brightness, desktop buttons / mobile menu */
/* Top header row: BPM, brightness, desktop buttons, mobile menu (above zone tabs) */
.header-end {
display: flex;
align-items: center;
@@ -199,20 +199,43 @@ header h1 {
.audio-top-indicator {
display: none;
flex-direction: column;
align-items: stretch;
gap: 0.15rem;
padding: 0.25rem 0.55rem;
border: 1px solid #4a4a4a;
border-radius: 6px;
background-color: #1a1a1a;
align-items: center;
gap: 0.35rem;
min-width: 9rem;
}
.audio-top-indicator-main {
.audio-top-indicator.audio-running {
display: inline-flex;
}
.audio-top-indicator .audio-top-beat-sync {
flex: 1;
min-width: 0;
}
.audio-top-beat-sync {
display: inline-flex;
align-items: center;
gap: 0.4rem;
width: 100%;
min-height: 2.25rem;
padding: 0.3rem 0.55rem;
border: 1px solid #4a4a4a;
border-radius: 6px;
background-color: #1a1a1a;
cursor: pointer;
font-family: inherit;
text-align: left;
}
.audio-top-beat-sync:disabled {
cursor: default;
opacity: 0.85;
}
.audio-top-beat-sync:not(:disabled):hover {
border-color: #6a6a6a;
background-color: #2a2a2a;
}
.audio-top-indicator-extra {
@@ -226,10 +249,6 @@ header h1 {
text-overflow: ellipsis;
}
.audio-top-indicator.audio-running {
display: inline-flex;
}
.audio-top-indicator-label {
font-size: 0.72rem;
color: #bdbdbd;
@@ -245,16 +264,46 @@ header h1 {
}
.audio-top-beat-readout {
font-size: 0.62rem;
font-size: 0.75rem;
color: #b0bec5;
line-height: 1.25;
max-width: 12rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
text-align: left;
min-width: 2rem;
text-align: right;
}
.audio-top-beat-readout:empty {
display: none;
}
.audio-top-beat-readout:not(:empty)::before {
content: "·";
margin-right: 0.35rem;
color: #757575;
}
.audio-top-bar-phase {
font-size: 0.7rem;
color: #90a4ae;
line-height: 1.25;
white-space: nowrap;
}
.audio-top-bar-phase:empty {
display: none;
}
.audio-top-bar-phase:not(:empty)::before {
content: "·";
margin-right: 0.35rem;
color: #757575;
}
.audio-top-bar-phase.is-downbeat {
color: #ffab91;
}
.audio-top-indicator-subvalue {
@@ -264,16 +313,15 @@ header h1 {
text-align: right;
}
.audio-top-indicator.flash {
.audio-top-beat-sync.flash {
background-color: #ff5252;
border-color: #ff8a80;
}
.audio-top-indicator.flash .audio-top-indicator-value,
.audio-top-indicator.flash .audio-top-indicator-label,
.audio-top-indicator.flash .audio-top-indicator-subvalue,
.audio-top-indicator.flash .audio-top-indicator-extra,
.audio-top-indicator.flash .audio-top-beat-readout {
.audio-top-beat-sync.flash .audio-top-indicator-value,
.audio-top-beat-sync.flash .audio-top-indicator-label,
.audio-top-beat-sync.flash .audio-top-beat-readout,
.audio-top-beat-sync.flash .audio-top-beat-readout::before {
color: #fff;
}
@@ -333,7 +381,7 @@ body.preset-ui-run .edit-mode-only {
.zones-container {
background-color: transparent;
padding: 0.35rem 0 0;
padding: 0;
flex: 0 0 auto;
width: 100%;
min-width: 0;
@@ -598,6 +646,39 @@ body.preset-ui-run .edit-mode-only {
font-weight: 500;
}
.preset-mode-field {
margin-top: 0.75rem;
margin-bottom: 0.25rem;
}
.preset-mode-field label {
display: block;
font-weight: 500;
margin-bottom: 0.35rem;
}
.preset-mode-input {
display: block;
width: 100%;
max-width: 100%;
box-sizing: border-box;
padding: 0.5rem 0.6rem;
background-color: #3a3a3a;
color: #fff;
border: 1px solid #4a4a4a;
border-radius: 4px;
font-size: 1rem;
}
.preset-mode-input:focus {
outline: none;
border-color: #6a9fff;
}
#preset-editor-modal .preset-mode-field {
grid-column: 1 / -1;
}
.n-input {
flex: 0 0 var(--n-input-width, 5ch);
width: var(--n-input-width, 5ch);
@@ -830,12 +911,41 @@ body.preset-ui-run .edit-mode-only {
min-width: 5rem;
}
#audio-modal .audio-settings-section {
margin-top: 1rem;
}
#audio-modal .audio-settings-section .audio-modal-beat-readout {
display: block;
width: 100%;
max-width: none;
}
.audio-modal-beat-readout {
flex: 1;
min-width: 10rem;
min-height: 2.25rem;
font-size: 0.85rem;
line-height: 1.35;
text-align: left;
text-align: center;
border: 1px solid #4a4a4a;
border-radius: 6px;
background-color: #252525;
padding: 0.35rem 0.65rem;
cursor: pointer;
font-family: inherit;
color: #b0bec5;
}
.audio-modal-beat-readout:disabled {
cursor: default;
opacity: 0.55;
}
.audio-modal-beat-readout:not(:disabled):hover {
border-color: #6a6a6a;
background-color: #333;
color: #e0e0e0;
}
.audio-hit-type-readout {
@@ -970,13 +1080,98 @@ body.preset-ui-run .edit-mode-only {
white-space: normal;
}
.ui-mode-toggle--edit {
background-color: #4a3f8f;
border: 1px solid #7b6fd6;
.nav-slide-toggle-wrap {
display: inline-flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
}
.ui-mode-toggle--edit:hover {
.nav-slide-toggle-side-label {
font-size: 0.82rem;
color: #888;
user-select: none;
line-height: 1;
}
.nav-slide-toggle-wrap:not(.nav-slide-toggle-wrap--downbeat) .nav-slide-toggle-side-label--beat,
.nav-slide-toggle-wrap--downbeat .nav-slide-toggle-side-label--downbeat {
color: #e8e8e8;
font-weight: 500;
}
.nav-slide-toggle-switch {
position: relative;
width: 2.75rem;
height: 1.4rem;
padding: 0;
margin: 0;
color: inherit;
font: inherit;
appearance: none;
border: 1px solid #4a4a4a;
border-radius: 999px;
background-color: #2a2a2a;
cursor: pointer;
flex-shrink: 0;
}
.nav-slide-toggle-switch:hover {
border-color: #666;
}
.nav-slide-toggle-switch:focus-visible {
outline: 2px solid #7b6fd6;
outline-offset: 2px;
}
.nav-slide-toggle-track {
display: block;
width: 100%;
height: 100%;
border-radius: inherit;
}
.nav-slide-toggle-thumb {
position: absolute;
top: 50%;
left: 2px;
width: 1rem;
height: 1rem;
border-radius: 50%;
background-color: #bdbdbd;
transform: translateY(-50%);
transition: left 0.2s ease, background-color 0.2s ease;
}
.nav-slide-toggle-switch.seq-switch-toggle--downbeat {
background-color: #4a3f8f;
border-color: #7b6fd6;
}
.nav-slide-toggle-switch.seq-switch-toggle--downbeat:hover {
background-color: #5a4f9f;
border-color: #8b7fe6;
}
.nav-slide-toggle-switch.seq-switch-toggle--downbeat .nav-slide-toggle-thumb {
left: calc(100% - 1rem - 2px);
transform: translateY(-50%);
background-color: #e8e4ff;
}
.main-menu-dropdown .nav-slide-toggle-wrap--mobile {
display: flex;
justify-content: center;
align-items: center;
gap: 0.35rem;
width: 100%;
max-width: 100%;
box-sizing: border-box;
padding: 0.45rem 0.5rem;
border-bottom: 1px solid #333;
flex-shrink: 1;
min-width: 0;
}
/* Preset select buttons inside the zone grid */
@@ -1228,13 +1423,43 @@ body.preset-ui-run .edit-mode-only {
display: none;
}
/* Beat/downbeat toggle lives in the mobile menu only */
#seq-switch-toggle-wrap {
display: none !important;
}
.main-menu-dropdown {
max-width: min(16rem, calc(100vw - 1rem));
}
#seq-switch-toggle-wrap-mobile .nav-slide-toggle-side-label {
font-size: 0.7rem;
flex-shrink: 1;
min-width: 0;
}
#seq-switch-toggle-wrap-mobile .nav-slide-toggle-switch.seq-switch-toggle {
width: 3.6rem;
height: 1.25rem;
flex-shrink: 0;
}
#seq-switch-toggle-wrap-mobile .nav-slide-toggle-switch .nav-slide-toggle-thumb {
width: 0.9rem;
height: 0.9rem;
}
#seq-switch-toggle-wrap-mobile .nav-slide-toggle-switch.seq-switch-toggle--downbeat .nav-slide-toggle-thumb {
left: calc(100% - 0.9rem - 2px);
transform: translateY(-50%);
}
.header-menu-mobile {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.35rem;
margin-top: 0;
margin-left: 0;
}
.header-end {
@@ -1244,10 +1469,15 @@ body.preset-ui-run .edit-mode-only {
.header-end .audio-top-indicator {
min-width: 5rem;
padding: 0.2rem 0.45rem;
flex-shrink: 0;
}
.header-end .audio-top-beat-sync {
padding: 0.2rem 0.4rem;
min-height: 2rem;
gap: 0.3rem;
}
.btn {
font-size: 0.8rem;
padding: 0.4rem 0.7rem;
@@ -1383,6 +1613,22 @@ body.preset-ui-run .edit-mode-only {
min-width: 8rem;
}
.zone-content-kind-row {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 1rem;
margin: 0.35rem 0 0.75rem;
}
.zone-content-kind-row label {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin: 0;
white-space: nowrap;
}
.zone-devices-label {
display: block;
margin-top: 0.75rem;
@@ -1620,6 +1866,14 @@ body.preset-ui-run .edit-mode-only {
}
}
.sequence-step-drag-handle:active {
cursor: grabbing;
}
.sequence-step-row.dragging {
opacity: 0.65;
}
/* Settings modal */
#settings-modal .modal-content {
max-width: 900px;

View File

@@ -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,52 +304,23 @@ 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 };
}
/**
* 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. Only devices in checked lane groups (within the zone tab).
*/
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)
? stepGroupIds.map((x) => String(x).trim()).filter((x) => x.length > 0)
: [];
if (!gids.length) {
return names.slice();
return [];
}
const zoneMacSet = new Set(
macs.map((m) => normalizeDeviceMac(m)).filter((m) => m.length === 12),
@@ -361,7 +352,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 +399,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 +460,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 +489,55 @@ function escapeHtmlAttr(s) {
.replace(/</g, "&lt;");
}
/** @returns {null | 'presets' | 'sequences'} */
function normalizeZoneContentKind(zoneDoc) {
const k = zoneDoc && zoneDoc.content_kind;
if (k === 'presets' || k === 'sequences') return k;
return null;
}
/** Display/save kind when ``content_kind`` is missing (legacy rows). */
function effectiveZoneContentKind(zoneDoc) {
const explicit = normalizeZoneContentKind(zoneDoc);
if (explicit) return explicit;
const seqIds = Array.isArray(zoneDoc && zoneDoc.sequence_ids)
? zoneDoc.sequence_ids.filter(Boolean)
: [];
const presetIds = tabPresetIdsInZoneDoc(zoneDoc || {});
if (seqIds.length > 0 && presetIds.length === 0) return 'sequences';
return 'presets';
}
/** @returns {boolean} */
function zoneAllowsPresets(zoneDoc, zoneId) {
void zoneId;
return effectiveZoneContentKind(zoneDoc) === 'presets';
}
/** @returns {boolean} */
function zoneAllowsSequences(zoneDoc, zoneId) {
void zoneId;
return effectiveZoneContentKind(zoneDoc) === 'sequences';
}
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';
};
const k = kind === 'sequences' ? 'sequences' : 'presets';
vis(groupsBlock, true);
vis(presetsBlock, k === 'presets');
vis(seqBlock, k === 'sequences');
}
window.normalizeZoneContentKind = normalizeZoneContentKind;
window.effectiveZoneContentKind = effectiveZoneContentKind;
window.zoneAllowsPresets = zoneAllowsPresets;
window.zoneAllowsSequences = zoneAllowsSequences;
// Load tabs list
async function loadZones() {
try {
@@ -622,14 +594,14 @@ function renderZonesList(tabs, tabOrder, currentZoneId) {
for (const zoneId of tabOrder) {
const zone = tabs[zoneId];
if (zone) {
const activeClass = zoneId === currentZoneId ? 'active' : '';
const tabName = zone.name || `Zone ${zoneId}`;
const activeClass = String(zoneId) === String(currentZoneId) ? 'active' : '';
const disp = zone.name || `Zone ${zoneId}`;
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 +641,10 @@ function renderZonesListModal(tabs, tabOrder, currentZoneId) {
row.dataset.zoneId = String(zoneId);
const label = document.createElement("span");
label.textContent = (zone && zone.name) || zoneId;
const disp = zone.name || `Zone ${zoneId}`;
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 +841,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 +997,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 +1015,16 @@ async function refreshEditTabPresetsUi(zoneId) {
return;
}
const tabData = await tabRes.json();
if (!zoneAllowsPresets(tabData, zoneId)) {
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 +1065,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 +1126,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 +1143,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,30 +1151,59 @@ 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);
const kind = effectiveZoneContentKind(tabData);
const typeLabel = document.getElementById('edit-zone-type-label');
if (typeLabel) {
typeLabel.textContent =
kind === 'sequences'
? 'Zone type: Sequences (set when the zone was created)'
: 'Zone type: Presets (set when the zone was created)';
}
if (modal) modal.classList.add("active");
applyZoneContentKindEditModal(kind);
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)
: [];
let existing = {};
try {
const cur = await fetch(`/zones/${encodeURIComponent(zoneId)}`, {
headers: { Accept: 'application/json' },
});
if (cur.ok) {
const j = await cur.json();
if (j && typeof j === 'object') existing = j;
}
} catch (_) {
/* use empty existing */
}
const lockedKind = effectiveZoneContentKind(existing);
const response = await fetch(`/zones/${zoneId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
...existing,
name: name,
group_ids: gids,
names: [],
group_ids: gids,
preset_group_ids:
existing.preset_group_ids && typeof existing.preset_group_ids === 'object'
? existing.preset_group_ids
: {},
content_kind: lockedKind,
})
});
@@ -1325,6 +1212,9 @@ async function updateZone(zoneId, name, groupIds) {
// Reload tabs list
await loadZonesModal();
await loadZones();
if (String(currentZoneId) === String(zoneId)) {
await loadZoneContent(zoneId);
}
// Close modal
document.getElementById('edit-zone-modal').classList.remove('active');
return true;
@@ -1339,12 +1229,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 +1241,9 @@ async function createZone(name, groupIds) {
},
body: JSON.stringify({
name: name,
group_ids: gids,
names: [],
group_ids: [],
content_kind: ck,
})
});
@@ -1434,8 +1324,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 +1356,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();
}
});
@@ -1517,6 +1406,8 @@ document.addEventListener('DOMContentLoaded', () => {
});
});
window.selectZone = selectZone;
// Export for use in other scripts
window.zonesManager = {
loadZones,
@@ -1530,10 +1421,13 @@ window.zonesManager = {
resolveTabDeviceMacs: resolveZoneDeviceMacs,
getCurrentZoneId: () => currentZoneId,
computeZoneTargets,
computeZoneNamesTargets,
computeZonePresetUnionTargets,
effectiveGroupIdsForZonePreset,
resolveDeviceNamesForZonePreset,
resolveSequenceStepDeviceNames,
fetchGroupsMap,
renderZoneGroupsEditor,
};
window.tabsManager = window.zonesManager;
window.tabsManager.getCurrentTabId = () => currentZoneId;

View File

@@ -9,18 +9,21 @@
<body>
<div class="app-container">
<header>
<div class="zones-container">
<div id="zones-list">
Loading zones...
</div>
</div>
<div class="header-end">
<div id="audio-top-indicator" class="audio-top-indicator" title="Live audio BPM">
<div class="audio-top-indicator-main">
<div class="nav-slide-toggle-wrap seq-switch-toggle-wrap edit-mode-only" id="seq-switch-toggle-wrap">
<span class="nav-slide-toggle-side-label nav-slide-toggle-side-label--beat">Beat</span>
<button type="button" role="switch" class="nav-slide-toggle-switch seq-switch-toggle" id="seq-switch-toggle" aria-pressed="false" aria-label="Switch sequence on beat" title="When starting a sequence: wait for beat or downbeat">
<span class="nav-slide-toggle-track" aria-hidden="true"><span class="nav-slide-toggle-thumb"></span></span>
</button>
<span class="nav-slide-toggle-side-label nav-slide-toggle-side-label--downbeat">Downbeat</span>
</div>
<div id="audio-top-indicator" class="audio-top-indicator">
<button type="button" id="audio-top-beat-sync" class="audio-top-beat-sync" disabled title="Sync step to music (S)">
<span class="audio-top-indicator-label">BPM</span>
<span id="audio-top-bpm-value" class="audio-top-indicator-value">--</span>
<span id="audio-top-beat-readout" class="audio-top-beat-readout" aria-live="polite"></span>
</div>
<span id="audio-top-bar-phase" class="audio-top-bar-phase" aria-live="polite" title="Bar phase (beat in bar)"></span>
</button>
</div>
<div class="header-actions">
<div class="header-brightness-control">
@@ -38,12 +41,20 @@
<button class="btn btn-secondary edit-mode-only" id="send-profile-presets-btn">Send Presets</button>
<button class="btn btn-secondary edit-mode-only" id="led-tool-btn">LED Tool</button>
<button class="btn btn-secondary" id="audio-btn">Audio</button>
<button type="button" class="btn btn-secondary" id="audio-nav-reset-btn" hidden title="Clear stuck BPM / beat tracking">Reset detector</button>
<button class="btn btn-secondary" id="help-btn">Help</button>
<button type="button" class="btn btn-secondary ui-mode-toggle" id="ui-mode-toggle" aria-pressed="false" title="Switch preset strip mode — label is the mode you will switch to">Edit mode</button>
</div>
<div class="header-menu-mobile">
<button class="btn btn-secondary" id="main-menu-btn">Menu</button>
<div id="main-menu-dropdown" class="main-menu-dropdown">
<div class="nav-slide-toggle-wrap nav-slide-toggle-wrap--mobile seq-switch-toggle-wrap" id="seq-switch-toggle-wrap-mobile">
<span class="nav-slide-toggle-side-label nav-slide-toggle-side-label--beat">Beat</span>
<button type="button" role="switch" class="nav-slide-toggle-switch seq-switch-toggle" id="seq-switch-toggle-mobile" aria-pressed="false" aria-label="Switch sequence on beat" title="Beat or downbeat">
<span class="nav-slide-toggle-track" aria-hidden="true"><span class="nav-slide-toggle-thumb"></span></span>
</button>
<span class="nav-slide-toggle-side-label nav-slide-toggle-side-label--downbeat">Downbeat</span>
</div>
<button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button>
<div class="menu-brightness-control">
<label for="menu-brightness-slider">Brightness</label>
@@ -60,10 +71,16 @@
<button type="button" class="edit-mode-only" data-target="send-profile-presets-btn">Send Presets</button>
<button type="button" class="edit-mode-only" data-target="led-tool-btn">LED Tool</button>
<button type="button" data-target="audio-btn">Audio</button>
<button type="button" id="audio-nav-reset-mobile" data-target="audio-nav-reset-btn" hidden>Reset detector</button>
<button type="button" data-target="help-btn">Help</button>
</div>
</div>
</div>
<div class="zones-container">
<div id="zones-list">
Loading zones...
</div>
</div>
</header>
<div class="main-content">
@@ -83,6 +100,10 @@
<input type="text" id="new-zone-name" placeholder="Zone name">
<button class="btn btn-primary" id="create-zone-btn">Create</button>
</div>
<div class="zone-content-kind-row muted-text">
<label><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>
</div>
<div id="zones-list-modal" class="profiles-list"></div>
<div class="modal-actions">
<button class="btn btn-secondary" id="zones-close-btn">Close</button>
@@ -96,22 +117,29 @@
<h2>Edit Zone</h2>
<form id="edit-zone-form">
<input type="hidden" id="edit-zone-id">
<div class="modal-actions" style="margin-bottom: 1rem;">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-zone-modal').classList.remove('active')">Close</button>
</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>
<p id="edit-zone-type-label" class="zone-content-kind-row muted-text" aria-live="polite"></p>
<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>
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-zone-modal').classList.remove('active')">Close</button>
</div>
</form>
</div>
</div>
@@ -123,6 +151,7 @@
<div class="profiles-actions">
<input type="text" id="new-profile-name" placeholder="Profile name">
<button class="btn btn-primary" id="create-profile-btn">Create</button>
<button type="button" class="btn btn-secondary" id="import-profile-btn">Import</button>
</div>
<div class="form-group" style="margin-top: 0.5rem; margin-bottom: 0;">
<label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0;">
@@ -148,13 +177,16 @@
</div>
</div>
<!-- Device groups: members + WiFi driver defaults (zones reference groups) -->
<!-- Device groups: members + WiFi driver defaults (zones reference groups for presets) -->
<div id="groups-modal" class="modal">
<div class="modal-content">
<h2>Device groups</h2>
<p class="muted-text" style="margin-top:0;">Assign drivers to a group, set WiFi defaults once per group, then attach groups to zones.</p>
<p class="muted-text" style="margin-top:0;">Assign drivers to a group, set WiFi defaults once per group, then attach groups to a zone for standalone presets (sequences use each lanes groups only). By default new groups are <strong>shared</strong> across all profiles; tick “this profile only” to hide a group from other profiles.</p>
<div class="profiles-actions zone-modal-create-row">
<input type="text" id="new-group-name" placeholder="Group name">
<label class="muted-text" style="display:inline-flex;align-items:center;gap:0.35rem;white-space:nowrap;">
<input type="checkbox" id="new-group-profile-only"> This profile only
</label>
<button class="btn btn-primary" id="create-group-btn">Create</button>
</div>
<div id="groups-list-modal" class="profiles-list"></div>
@@ -169,12 +201,12 @@
<h2>Edit device group</h2>
<form id="edit-group-form">
<input type="hidden" id="edit-group-id">
<div class="modal-actions" style="margin-bottom: 1rem;">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" id="edit-group-close-btn">Cancel</button>
</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;">
@@ -209,6 +241,10 @@
<label for="edit-group-debug" style="margin-top:1rem;display:block;">Debug</label>
<small class="muted-text" style="display:block;margin-bottom:0.35rem;">Stored row and the JSON preview for <strong>Save</strong> (updates as you edit).</small>
<textarea id="edit-group-debug" rows="8" readonly spellcheck="false" style="width:100%;font-family:monospace;resize:vertical;"></textarea>
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" id="edit-group-close-btn">Cancel</button>
</div>
</form>
</div>
</div>
@@ -283,6 +319,7 @@
<h2>Presets</h2>
<div class="modal-actions">
<button class="btn btn-primary" id="preset-add-btn">Add</button>
<button type="button" class="btn btn-secondary" id="import-preset-btn">Import</button>
<button class="btn btn-danger" id="preset-clear-device-btn">Clear Device Presets</button>
</div>
<div id="presets-list" class="profiles-list"></div>
@@ -298,6 +335,7 @@
<h2>Sequences</h2>
<div class="modal-actions">
<button type="button" class="btn btn-primary" id="sequence-add-btn">Add</button>
<button type="button" class="btn btn-secondary" id="import-sequence-btn">Import</button>
<button type="button" class="btn btn-secondary" id="sequences-open-presets-btn">Presets</button>
</div>
<div id="sequences-list" class="profiles-list"></div>
@@ -315,32 +353,23 @@
<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>
<label style="display:block;margin-top:0.65rem;">
<input type="checkbox" id="sequence-editor-loop" checked>
Loop sequence (restart from the first step after the last)
</label>
</div>
<div id="sequence-editor-lanes"></div>
<div class="modal-actions" style="margin-top:0.75rem;">
<button type="button" class="btn btn-secondary btn-small" id="sequence-editor-add-lane-btn">Add lane</button>
</div>
<div class="modal-actions preset-editor-modal-actions">
<button type="button" class="btn btn-secondary btn-small" id="sequence-editor-add-lane-btn">Add lane</button>
<button type="button" class="btn btn-danger" id="sequence-editor-delete-btn">Delete</button>
<button type="button" class="btn btn-primary" id="sequence-editor-save-btn">Save</button>
<button type="button" class="btn btn-secondary" id="sequence-editor-close-btn">Close</button>
@@ -377,6 +406,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>
@@ -393,6 +423,16 @@
<span class="muted-text" style="font-size: 0.85em;">beats (this app only)</span>
</div>
</div>
<div class="preset-editor-field" id="preset-reverse-group" hidden>
<label for="preset-reverse-input" style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0;">
<input type="checkbox" id="preset-reverse-input">
Reverse direction (strip installed upside down)
</label>
</div>
<div class="preset-editor-field preset-mode-field" id="preset-mode-group" hidden>
<label for="preset-mode-input" id="preset-mode-label">Mode</label>
<select id="preset-mode-input" class="preset-mode-input"></select>
</div>
<div class="n-params-grid">
<div class="n-param-group">
<label for="preset-n1-input" id="preset-n1-label">n1:</label>
@@ -603,22 +643,45 @@
<label>Current BPM</label>
<div class="audio-bpm-row">
<div id="audio-bpm-value" class="audio-bpm-readout">--</div>
<div id="audio-modal-beat-readout" class="audio-modal-beat-readout muted-text" aria-live="polite"></div>
</div>
</div>
<div class="form-group">
<label>Detected hit type</label>
<div id="audio-hit-type-value" class="audio-hit-type-readout">unknown</div>
</div>
<div class="form-group">
<label>Bar phase</label>
<div class="audio-bpm-row">
<div id="audio-bar-phase-value" class="audio-bpm-readout" title="Beat in bar (kick hints downbeat)">--</div>
</div>
<small class="muted-text">Bar uses kick-heavy hits (default 4/4). Tap <strong>Sync</strong> on a downbeat to lock bar phase.</small>
</div>
<div class="form-group">
<label>Flash on beat</label>
<div id="audio-beat-flash" class="audio-beat-flash" aria-hidden="true"></div>
</div>
<div class="settings-section audio-settings-section">
<h3>Audio settings</h3>
<div class="form-group">
<label for="audio-beat-phase-ms">Beat phase shift (ms)</label>
<input type="number" id="audio-beat-phase-ms" min="0" max="500" step="5" value="0" style="width:6rem;">
<small class="muted-text">Delays beat flashes and sequenced beats so they line up with what you hear (saved in this browser).</small>
<small class="muted-text">Delays beat flashes so they line up with what you hear (saved on the controller).</small>
</div>
<div class="form-group">
<label>Beat sync</label>
<button type="button" id="audio-modal-beat-readout" class="audio-modal-beat-readout muted-text" disabled title="Sync step to music (S)" aria-live="polite"></button>
<small class="muted-text">While a sequence is playing, tap the BPM/beat button in the header on a downbeat to align the step counter. Shortcut: <kbd>S</kbd>.</small>
</div>
<div class="form-group">
<label>Sequence alignment</label>
<div class="profiles-actions" style="flex-wrap: wrap;">
<button type="button" class="btn btn-secondary" id="audio-sync-pass-btn">Restart pass</button>
</div>
<small class="muted-text"><strong>Restart pass</strong> jumps to step 1 of the sequence (<kbd>Shift+S</kbd>). Use <strong>Reset detector</strong> in the header (while audio is running) to clear stuck BPM/beat tracking without stopping audio.</small>
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-primary" id="audio-start-btn">Start</button>
<button type="button" class="btn btn-secondary" id="audio-stop-btn">Stop</button>
@@ -699,79 +762,14 @@
</div>
</div>
<!-- LED Tool Modal -->
<!-- LED Tool Modal (led-tool/static settings editor) -->
<div id="led-tool-modal" class="modal">
<div class="modal-content">
<h2>LED Tool (USB)</h2>
<p class="muted-text" style="margin-top: 0;">Configure a driver connected over USB using <code>led-tool</code>.</p>
<div id="led-tool-message" class="message" style="margin-bottom: 0.75rem;"></div>
<form id="led-tool-form">
<div class="form-group">
<label for="led-tool-port">Serial port</label>
<div class="profiles-actions" style="gap: 0.5rem;">
<select id="led-tool-port" required style="flex:1;">
<option value="">Select a serial port</option>
</select>
<button type="button" class="btn btn-secondary" id="led-tool-refresh-ports-btn">Refresh</button>
</div>
</div>
<div class="form-group">
<label for="led-tool-name">Name</label>
<input type="text" id="led-tool-name" placeholder="led-abcdef123456">
</div>
<div class="profiles-actions">
<div class="preset-editor-field">
<label for="led-tool-num-leds">Num LEDs</label>
<input type="number" id="led-tool-num-leds" min="1" max="5000" placeholder="60">
</div>
<div class="preset-editor-field">
<label for="led-tool-led-pin">LED pin</label>
<input type="number" id="led-tool-led-pin" min="0" max="48" placeholder="4">
</div>
</div>
<div class="profiles-actions">
<div class="preset-editor-field">
<label for="led-tool-brightness">Brightness</label>
<input type="number" id="led-tool-brightness" min="0" max="255" placeholder="255">
</div>
<div class="preset-editor-field">
<label for="led-tool-wifi-channel">WiFi channel</label>
<input type="number" id="led-tool-wifi-channel" min="1" max="11" placeholder="6">
</div>
</div>
<div class="profiles-actions">
<div class="preset-editor-field">
<label for="led-tool-transport">Transport</label>
<select id="led-tool-transport">
<option value="">(no change)</option>
<option value="espnow">espnow</option>
<option value="wifi">wifi</option>
</select>
</div>
<div class="preset-editor-field">
<label for="led-tool-default">Default preset</label>
<input type="text" id="led-tool-default" placeholder="on">
</div>
</div>
<div class="form-group">
<label for="led-tool-ssid">SSID</label>
<input type="text" id="led-tool-ssid" placeholder="Your WiFi SSID">
</div>
<div class="form-group">
<label for="led-tool-password">WiFi password</label>
<input type="password" id="led-tool-password" placeholder="WiFi password">
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" id="led-tool-read-btn">Read</button>
<button type="button" class="btn btn-secondary" id="led-tool-reset-btn">Reset</button>
<button type="submit" class="btn btn-primary">Apply via USB</button>
<div class="modal-content" style="max-width: 960px; width: 95vw;">
<div class="modal-actions" style="margin-bottom: 0.5rem;">
<h2 style="margin: 0; flex: 1;">LED Tool — device settings</h2>
<button type="button" class="btn btn-secondary" id="led-tool-close-btn">Close</button>
</div>
</form>
<label for="led-tool-output" style="margin-top:0.5rem; display:block;">Command output</label>
<textarea id="led-tool-output" rows="12" readonly style="width:100%; font-family:monospace; resize:vertical;"></textarea>
<iframe id="led-tool-iframe" title="LED device settings editor" src="about:blank" allow="serial" style="width:100%;height:min(75vh,720px);border:1px solid #4a4a4a;border-radius:4px;background:#0b1020;"></iframe>
</div>
</div>
@@ -781,6 +779,7 @@
<script src="/static/help.js"></script>
<script src="/static/led_tool.js"></script>
<script src="/static/color_palette.js"></script>
<script src="/static/bundle_io.js"></script>
<script src="/static/profiles.js"></script>
<script src="/static/zone_palette.js"></script>
<script src="/static/patterns.js"></script>
@@ -788,5 +787,6 @@
<script src="/static/sequences.js"></script>
<script src="/static/devices.js"></script>
<script src="/static/audio.js"></script>
<script src="/static/numpad.js"></script>
</body>
</html>

View File

@@ -261,7 +261,7 @@
return;
}
try {
const response = await fetch('/settings/settings', {
const response = await fetch('/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ wifi_channel: wifiChannel }),

View File

@@ -4,6 +4,12 @@ import os
import queue
import threading
import time
from typing import Any
_HOLDOVER_BPM_MIN = 30.0
_HOLDOVER_BPM_MAX = 300.0
_HOLDOVER_MAX_S = 300.0
class AudioBeatDetector:
@@ -13,6 +19,11 @@ class AudioBeatDetector:
self._stream = None
self._running = False
self._stop_event = threading.Event()
self._runtime = None
self._pending_reset = False
self._holdover_thread: threading.Thread | None = None
self._holdover_stop = threading.Event()
self._holdover_active = False
self._status = {
"running": False,
"bpm": None,
@@ -20,6 +31,11 @@ class AudioBeatDetector:
"beat_seq": 0,
"beat_type": "unknown",
"beat_type_confidence": 0.0,
"bar_beat": 1,
"beats_per_bar": 4,
"is_downbeat": False,
"phase_confidence": 0.0,
"bar_phase_readout": "1/4",
"error": None,
"device": None,
}
@@ -100,6 +116,11 @@ class AudioBeatDetector:
"beat_seq": 0,
"beat_type": "unknown",
"beat_type_confidence": 0.0,
"bar_beat": 1,
"beats_per_bar": 4,
"is_downbeat": False,
"phase_confidence": 0.0,
"bar_phase_readout": "1/4",
"error": None,
"device": device,
}
@@ -111,6 +132,7 @@ class AudioBeatDetector:
self._thread.start()
def stop(self):
self._stop_bpm_holdover()
with self._lock:
self._stop_event.set()
t = self._thread
@@ -139,11 +161,159 @@ class AudioBeatDetector:
self._running = False
self._thread = None
self._stream = None
self._pending_reset = False
self._status["running"] = False
def status(self):
with self._lock:
return dict(self._status)
st = dict(self._status)
holdover = self._holdover_active
last = st.get("last_beat_ts")
if st.get("running") and last is not None and not holdover:
try:
if (time.time() - float(last)) > 4.0:
st["bpm"] = None
except (TypeError, ValueError):
pass
return st
def _apply_tracking_reset_status(self) -> None:
"""Refresh published status after a tracking reset (lock must be held)."""
bpb = max(1, int(self._status.get("beats_per_bar") or 4))
self._status.update(
{
"running": True,
"beat_type": "unknown",
"beat_type_confidence": 0.0,
"bar_beat": 1,
"is_downbeat": True,
"phase_confidence": 0.0,
"bar_phase_readout": f"1/{bpb}",
}
)
def _clamp_holdover_bpm(self, bpm: Any) -> float | None:
try:
v = float(bpm)
except (TypeError, ValueError):
return None
if not (_HOLDOVER_BPM_MIN <= v <= _HOLDOVER_BPM_MAX):
return None
return v
def _holdover_interval_s(self, bpm: float) -> float:
return 60.0 / max(_HOLDOVER_BPM_MIN, min(_HOLDOVER_BPM_MAX, float(bpm)))
def _stop_bpm_holdover(self) -> None:
with self._lock:
self._holdover_active = False
self._holdover_stop.set()
t = self._holdover_thread
if t and t.is_alive() and t is not threading.current_thread():
t.join(timeout=2.0)
with self._lock:
if self._holdover_thread is t:
self._holdover_thread = None
def _advance_holdover_bar_phase_locked(self) -> dict:
"""Advance bar phase for one synthetic beat (lock must be held)."""
bpb = max(1, int(self._status.get("beats_per_bar") or 4))
prev = int(self._status.get("bar_beat") or 1)
bar_beat = (prev % bpb) + 1
is_downbeat = bar_beat == 1
bar_readout = f"{bar_beat}/{bpb}"
self._status["bar_beat"] = bar_beat
self._status["is_downbeat"] = is_downbeat
self._status["bar_phase_readout"] = bar_readout
return {
"bar_beat": bar_beat,
"beats_per_bar": bpb,
"is_downbeat": is_downbeat,
"bar_phase_readout": bar_readout,
}
def _emit_holdover_beat(self, bpm: float) -> None:
now = time.time()
with self._lock:
if not self._running or not self._holdover_active:
return
self._advance_holdover_bar_phase_locked()
self._status["last_beat_ts"] = now
self._status["bpm"] = float(bpm)
self._status["beat_type"] = "holdover"
self._status["beat_type_confidence"] = 0.0
self._status["beat_seq"] = int(self._status.get("beat_seq", 0)) + 1
try:
from util import sequence_playback as seq_pb
seq_pb.push_thread_beat()
except Exception as e:
print(f"[audio] holdover beat queue: {e}")
def _holdover_loop(self, bpm: float, started_at: float) -> None:
interval = self._holdover_interval_s(bpm)
while not self._holdover_stop.is_set():
with self._lock:
if not self._running or not self._holdover_active:
return
if (time.time() - started_at) > _HOLDOVER_MAX_S:
self._holdover_active = False
return
last = self._status.get("last_beat_ts")
if last is not None:
try:
delay = max(0.02, float(last) + interval - time.time())
except (TypeError, ValueError):
delay = interval
else:
delay = interval
if self._holdover_stop.wait(delay):
return
self._emit_holdover_beat(bpm)
def _start_bpm_holdover(self, bpm: float) -> None:
bpm_v = self._clamp_holdover_bpm(bpm)
if bpm_v is None:
return
self._stop_bpm_holdover()
self._holdover_stop.clear()
started_at = time.time()
with self._lock:
self._holdover_active = True
self._holdover_thread = threading.Thread(
target=self._holdover_loop,
args=(bpm_v, started_at),
name="audio-bpm-holdover",
daemon=True,
)
t = self._holdover_thread
t.start()
def _process_pending_reset(self, runtime) -> None:
"""Run ``reset_state`` on the audio thread (safe for aubio tempo)."""
with self._lock:
if not self._pending_reset:
return
self._pending_reset = False
try:
runtime.reset_state()
with self._lock:
self._apply_tracking_reset_status()
except Exception as e:
print(f"[audio] pending reset: {e}")
def reset_tracking(self) -> bool:
"""Clear detector tempo history without stopping the input stream."""
holdover_bpm = None
with self._lock:
if not self._running or self._runtime is None:
return False
holdover_bpm = self._clamp_holdover_bpm(self._status.get("bpm"))
self._pending_reset = True
self._apply_tracking_reset_status()
if holdover_bpm is not None:
self._start_bpm_holdover(holdover_bpm)
return True
def _set_error(self, msg):
print(f"[audio] {msg}")
@@ -152,7 +322,28 @@ class AudioBeatDetector:
self._status["running"] = False
self._running = False
def _record_beat(self, bpm, beat_type="unknown", beat_type_confidence=0.0):
def anchor_bar_phase(self) -> bool:
"""Mark the current moment as bar beat 1 (downbeat), e.g. after manual sync."""
with self._lock:
rt = self._runtime
if rt is None:
return False
try:
rt.anchor_bar_phase(time.time())
with self._lock:
self._status["bar_beat"] = 1
self._status["is_downbeat"] = True
self._status["bar_phase_readout"] = f"1/{int(self._status.get('beats_per_bar') or 4)}"
self._status["phase_confidence"] = max(
float(self._status.get("phase_confidence") or 0.0), 0.85
)
return True
except Exception as e:
print(f"[audio] anchor_bar_phase: {e}")
return False
def _record_beat(self, bpm, beat_type="unknown", beat_type_confidence=0.0, **phase_fields):
self._stop_bpm_holdover()
now = time.time()
with self._lock:
self._status["last_beat_ts"] = now
@@ -160,6 +351,16 @@ class AudioBeatDetector:
self._status["beat_type"] = beat_type
self._status["beat_type_confidence"] = float(beat_type_confidence or 0.0)
self._status["beat_seq"] = int(self._status.get("beat_seq", 0)) + 1
if phase_fields.get("bar_beat") is not None:
self._status["bar_beat"] = int(phase_fields["bar_beat"])
if phase_fields.get("beats_per_bar") is not None:
self._status["beats_per_bar"] = int(phase_fields["beats_per_bar"])
if phase_fields.get("is_downbeat") is not None:
self._status["is_downbeat"] = bool(phase_fields["is_downbeat"])
if phase_fields.get("phase_confidence") is not None:
self._status["phase_confidence"] = float(phase_fields["phase_confidence"])
if phase_fields.get("bar_phase_readout"):
self._status["bar_phase_readout"] = str(phase_fields["bar_phase_readout"])
try:
from util import sequence_playback as seq_pb
@@ -210,15 +411,17 @@ class AudioBeatDetector:
flux_weight=0.3,
threshold_multiplier=1.35,
ema_alpha=0.08,
min_ioi_ms=85.0,
min_ioi_ms=100.0,
bpm_window=8,
post_url="",
aubio_method="default",
aubio_threshold=0.12,
silence_gate_db=-58.0,
aubio_threshold=0.14,
beats_per_bar=4,
)
runtime = beat_mod.BeatDetectRuntime(args)
runtime.setup(sample_rate=sample_rate)
with self._lock:
self._runtime = runtime
hop_size = runtime.frame_size
audio_q = queue.Queue(maxsize=64)
@@ -243,10 +446,12 @@ class AudioBeatDetector:
stream.start()
try:
while not self._stop_event.is_set():
self._process_pending_reset(runtime)
try:
frame = audio_q.get(timeout=0.1)
except queue.Empty:
continue
self._process_pending_reset(runtime)
if frame.shape[0] != hop_size:
if frame.shape[0] > hop_size:
frame = frame[:hop_size]
@@ -260,6 +465,11 @@ class AudioBeatDetector:
bpm,
beat_type=event.get("beat_type", "unknown"),
beat_type_confidence=event.get("beat_type_confidence", 0.0),
bar_beat=event.get("bar_beat"),
beats_per_bar=event.get("beats_per_bar"),
is_downbeat=event.get("is_downbeat"),
phase_confidence=event.get("phase_confidence"),
bar_phase_readout=event.get("bar_phase_readout"),
)
finally:
try:
@@ -280,3 +490,45 @@ class AudioBeatDetector:
with self._lock:
self._running = False
self._status["running"] = False
self._runtime = None
# 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
def shared_beat_status_snapshot() -> dict:
"""Thread-safe copy of live detector status, or {} if audio is off."""
d = _shared_beat_detector
if d is None:
return {}
try:
return dict(d.status())
except Exception:
return {}
def anchor_shared_bar_phase() -> bool:
"""Anchor bar phase on the shared detector (no-op if audio is off)."""
d = _shared_beat_detector
if d is None:
return False
try:
return bool(d.anchor_bar_phase())
except Exception:
return False

View File

@@ -30,20 +30,64 @@ def read_audio_run_state() -> Dict[str, Any]:
except (OSError, json.JSONDecodeError, TypeError):
return {"enabled": False, "device": None}
if not isinstance(raw, dict):
return {"enabled": False, "device": None}
return {
"enabled": False,
"device": None,
"device_override": "",
"device_select": "",
}
enabled = bool(raw.get("enabled"))
dev = raw.get("device", None)
return {"enabled": enabled, "device": dev}
return {
"enabled": enabled,
"device": dev,
"device_override": str(raw.get("device_override") or ""),
"device_select": str(raw.get("device_select") or ""),
}
def write_audio_run_state(*, enabled: bool, device: Any = None) -> None:
"""Write run intent. When ``enabled`` is false, keep ``device`` from the previous file for next start."""
def write_audio_run_state(
*,
enabled: bool,
device: Any = None,
device_override: str | None = None,
device_select: str | None = None,
) -> None:
"""Write run intent. When ``enabled`` is false, keep device fields from the previous file."""
path = _db_path()
prev = read_audio_run_state()
if enabled:
data = {"enabled": True, "device": device}
data = {
"enabled": True,
"device": device,
"device_override": (
str(device_override)
if device_override is not None
else str(prev.get("device_override") or "")
),
"device_select": (
str(device_select)
if device_select is not None
else str(prev.get("device_select") or "")
),
}
if device_select is None and device is not None:
data["device_select"] = str(device)
else:
data = {"enabled": False, "device": prev.get("device")}
data = {
"enabled": False,
"device": prev.get("device"),
"device_override": (
str(device_override)
if device_override is not None
else str(prev.get("device_override") or "")
),
"device_select": (
str(device_select)
if device_select is not None
else str(prev.get("device_select") or "")
),
}
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:

View File

@@ -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,50 @@ 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:
if _sequence_lane_covers_standalone_overlay(names, str(wire_preset_id).strip()):
_lane_manual.pop(-1, None)
_sync_public_beat_route_from_lane_table()
return
_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],
@@ -310,6 +354,11 @@ def set_sequence_manual_lane_route(
"manual_beat_n": mn,
"beat_counter": bc,
}
overlay = _lane_manual.get(-1)
if overlay and _lane_route_targets_key(names, wid) == _lane_route_targets_key(
overlay.get("device_names") or [], str(overlay.get("wire_preset_id") or "")
):
_lane_manual.pop(-1, None)
_sync_public_beat_route_from_lane_table()
@@ -322,11 +371,54 @@ def clear_sequence_manual_lane_route(lane_index: int) -> None:
_sync_public_beat_route_from_lane_table()
def _lane_route_targets_key(device_names: List[str], wire_preset_id: str) -> Tuple[Tuple[str, ...], str]:
names = tuple(sorted({str(n).strip() for n in (device_names or []) if str(n).strip()}))
return names, str(wire_preset_id or "").strip()
def _sequence_lane_covers_standalone_overlay(device_names: List[str], wire_preset_id: str) -> bool:
"""True when a sequence lane (0..n) already routes the same device(s) and wire preset."""
key = _lane_route_targets_key(device_names, wire_preset_id)
for lane_key, entry in _lane_manual.items():
if not isinstance(lane_key, int) or lane_key < 0:
continue
other = _lane_route_targets_key(
entry.get("device_names") or [], str(entry.get("wire_preset_id") or "")
)
if other == key:
return True
return False
def mark_manual_select_sent_for_targets(
device_names: List[str], wire_preset_id: str
) -> None:
"""A ``select`` was just sent for these targets; skip one duplicate on the next beat."""
key = _lane_route_targets_key(device_names, wire_preset_id)
with _route_lock:
for entry in _lane_manual.values():
if not isinstance(entry, dict):
continue
other = _lane_route_targets_key(
entry.get("device_names") or [], str(entry.get("wire_preset_id") or "")
)
if other == key:
entry["suppress_next_notify"] = True
def mark_sequence_manual_lane_select_sent(lane_index: int) -> None:
"""A ``select`` was just sent for this lane; skip one duplicate on the next beat."""
with _route_lock:
e = _lane_manual.get(lane_index)
if e is not None:
e["suppress_next_notify"] = True
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 +429,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,6 +454,7 @@ def sync_beat_route_from_push_sequence(
if last_select:
device_names = [str(k).strip() for k in last_select.keys() if str(k).strip()]
if not device_names:
if not preserve_parallel_lane_routes:
update_beat_route({"enabled": False})
return
@@ -372,6 +466,7 @@ def sync_beat_route_from_push_sequence(
elif val is not None:
wire_ids.add(str(val).strip())
if len(wire_ids) != 1:
if not preserve_parallel_lane_routes:
update_beat_route({"enabled": False})
return
wire_preset_id = wire_ids.pop()
@@ -382,21 +477,32 @@ def sync_beat_route_from_push_sequence(
preset_body = v
break
if preset_body is None:
if not preserve_parallel_lane_routes:
update_beat_route({"enabled": False})
return
if _coerce_auto_from_body(preset_body):
if not preserve_manual_beat_route_on_auto_select:
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)
mark_manual_select_sent_for_targets(device_names, wire_preset_id)
return
wire_id, body = _single_manual_wire_preset(merged_presets)
if wire_id and body is not None:
names = _registry_names_for_macs(target_macs)
if preserve_parallel_lane_routes:
_apply_manual_beat_route_standalone_overlay(names, wire_id, body)
else:
_apply_manual_beat_route(names, wire_id, body)
return
if not preserve_parallel_lane_routes:
update_beat_route({"enabled": False})
@@ -494,6 +600,7 @@ def notify_beat_detected() -> None:
if not _lane_manual:
return
work = []
seen_targets: Set[Tuple[Tuple[str, ...], str]] = set()
for key in sorted(_lane_manual.keys()):
e = _lane_manual[key]
names = e.get("device_names") or []
@@ -502,6 +609,8 @@ def notify_beat_detected() -> None:
pattern = str(e.get("pattern") or "")
if pattern and not _pattern_supports_manual(pattern):
continue
if e.pop("suppress_next_notify", False):
continue
try:
n = int(e.get("manual_beat_n") or 1)
except (TypeError, ValueError):
@@ -511,7 +620,12 @@ def notify_beat_detected() -> None:
c = int(e["beat_counter"])
if (c - 1) % n != 0:
continue
work.append((list(names), str(e.get("wire_preset_id") or "2")))
wire = str(e.get("wire_preset_id") or "2")
target_key = _lane_route_targets_key(names, wire)
if target_key in seen_targets:
continue
seen_targets.add(target_key)
work.append((list(names), wire))
if work:
_preset_session_beats += 1
if not work:

View File

@@ -43,6 +43,8 @@ import json
import struct
from typing import Any, Dict, List, Optional, Tuple
from util.espnow_message import wire_n6
BINARY_ENVELOPE_VERSION_1 = 1
BINARY_ENVELOPE_VERSION_2 = 2
HEADER_LEN = 5
@@ -108,7 +110,7 @@ def _pack_preset_dict(name: str, preset: Dict[str, Any]) -> bytes:
n3 = _clamp_i16(preset.get("n3", 0))
n4 = _clamp_i16(preset.get("n4", 0))
n5 = _clamp_i16(preset.get("n5", 0))
n6 = _clamp_i16(preset.get("n6", 0))
n6 = _clamp_i16(wire_n6(preset))
parts.append(
struct.pack(
"<HBBhhhhhh",

View File

@@ -78,12 +78,63 @@ 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 wire_n6(preset_data, default=0):
"""Resolve style mode for the wire (``n6``); preset may store ``mode`` or ``n6``."""
if not isinstance(preset_data, dict):
return default
if preset_data.get("mode") is not None:
try:
return max(0, int(preset_data["mode"]))
except (TypeError, ValueError):
pass
try:
return max(0, int(preset_data.get("n6", default) or 0))
except (TypeError, ValueError):
return default
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 +188,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 = {
@@ -158,18 +203,19 @@ def build_preset_dict(preset_data):
"n3": preset_data.get("n3", 0),
"n4": preset_data.get("n4", 0),
"n5": preset_data.get("n5", 0),
"n6": preset_data.get("n6", 0)
"n6": wire_n6(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 +236,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

441
src/util/profile_bundle.py Normal file
View File

@@ -0,0 +1,441 @@
"""Export/import profile bundles (profile, zones, presets, sequences, palette)."""
from __future__ import annotations
import copy
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
BUNDLE_VERSION = 1
KIND_PROFILE = "profile"
KIND_PRESET = "preset"
KIND_SEQUENCE = "sequence"
def _allocate_id(model, cache: Dict[str, int]) -> str:
if "next" not in cache:
max_id = max((int(k) for k in model.keys() if str(k).isdigit()), default=0)
cache["next"] = max_id + 1
next_id = str(cache["next"])
cache["next"] += 1
return next_id
def _palette_colors(palette_model, palette_id) -> List:
if not palette_id:
return []
try:
colors = palette_model.read(str(palette_id))
except Exception:
colors = None
if isinstance(colors, list):
return list(colors)
if isinstance(colors, dict) and isinstance(colors.get("colors"), list):
return list(colors["colors"])
return []
def _walk_preset_refs(value, out: Set[str]) -> None:
if isinstance(value, list):
for item in value:
_walk_preset_refs(item, out)
elif value is not None and value != "":
out.add(str(value))
def _preset_ids_in_zone(zone: Dict[str, Any]) -> Set[str]:
ids: Set[str] = set()
if not isinstance(zone, dict):
return ids
_walk_preset_refs(zone.get("presets"), ids)
_walk_preset_refs(zone.get("presets_flat"), ids)
if zone.get("default_preset") not in (None, ""):
ids.add(str(zone["default_preset"]))
return ids
def _preset_ids_in_sequence(seq: Dict[str, Any]) -> Set[str]:
ids: Set[str] = set()
if not isinstance(seq, dict):
return ids
for lane in seq.get("lanes") or []:
if not isinstance(lane, list):
continue
for step in lane:
if isinstance(step, dict) and step.get("preset_id") not in (None, ""):
ids.add(str(step["preset_id"]))
for step in seq.get("steps") or []:
if isinstance(step, dict) and step.get("preset_id") not in (None, ""):
ids.add(str(step["preset_id"]))
return ids
def _map_preset_container(
value,
id_map: Dict[str, str],
preset_cache: Dict[str, int],
new_profile_id: str,
new_presets: Dict[str, Dict[str, Any]],
presets_model,
) -> Any:
if isinstance(value, list):
return [
_map_preset_container(v, id_map, preset_cache, new_profile_id, new_presets, presets_model)
for v in value
]
if value is None:
return None
preset_id = str(value)
if preset_id in id_map:
return id_map[preset_id]
preset_data = presets_model.read(preset_id)
if not preset_data:
return None
new_preset_id = _allocate_id(presets_model, preset_cache)
clone_data = dict(preset_data)
clone_data["profile_id"] = str(new_profile_id)
new_presets[new_preset_id] = clone_data
id_map[preset_id] = new_preset_id
return new_preset_id
def _map_sequence_lanes(
seq: Dict[str, Any],
preset_id_map: Dict[str, str],
) -> Dict[str, Any]:
out = copy.deepcopy(seq)
lanes = out.get("lanes")
if isinstance(lanes, list):
for lane in lanes:
if not isinstance(lane, list):
continue
for step in lane:
if not isinstance(step, dict):
continue
pid = step.get("preset_id")
if pid is not None and str(pid) in preset_id_map:
step["preset_id"] = preset_id_map[str(pid)]
steps = out.get("steps")
if isinstance(steps, list):
for step in steps:
if not isinstance(step, dict):
continue
pid = step.get("preset_id")
if pid is not None and str(pid) in preset_id_map:
step["preset_id"] = preset_id_map[str(pid)]
return out
def export_profile_bundle(
profile_id: str,
profiles_model,
zones_model,
presets_model,
sequences_model,
palette_model,
) -> Dict[str, Any]:
source = profiles_model.read(profile_id)
if not source:
raise ValueError("Profile not found")
zone_ids = source.get("zones")
if not isinstance(zone_ids, list) or not zone_ids:
zone_ids = source.get("zone_order") or []
zone_ids = [str(z) for z in zone_ids if z is not None]
zones_out: Dict[str, Any] = {}
preset_ids: Set[str] = set()
sequence_ids: Set[str] = set()
for zid in zone_ids:
zone = zones_model.read(zid)
if not zone:
continue
zones_out[zid] = copy.deepcopy(zone)
preset_ids |= _preset_ids_in_zone(zone)
for sid in zone.get("sequence_ids") or []:
if sid is not None and str(sid).strip():
sequence_ids.add(str(sid))
sequences_out: Dict[str, Any] = {}
for sid in sequence_ids:
seq = sequences_model.read(sid)
if not seq or str(seq.get("profile_id")) != str(profile_id):
continue
sequences_out[sid] = copy.deepcopy(seq)
preset_ids |= _preset_ids_in_sequence(seq)
presets_out: Dict[str, Any] = {}
for pid in preset_ids:
pdata = presets_model.read(pid)
if pdata and str(pdata.get("profile_id")) == str(profile_id):
presets_out[pid] = copy.deepcopy(pdata)
profile_doc = copy.deepcopy(source)
profile_doc.pop("palette", None)
return {
"version": BUNDLE_VERSION,
"kind": KIND_PROFILE,
"profile": profile_doc,
"palette": {"colors": _palette_colors(palette_model, source.get("palette_id"))},
"zones": zones_out,
"presets": presets_out,
"sequences": sequences_out,
}
def import_profile_bundle(
bundle: Dict[str, Any],
profiles_model,
zones_model,
presets_model,
sequences_model,
palette_model,
*,
name: Optional[str] = None,
) -> Tuple[str, Dict[str, Any]]:
if not isinstance(bundle, dict):
raise ValueError("Invalid bundle")
if bundle.get("version") not in (BUNDLE_VERSION, str(BUNDLE_VERSION)):
raise ValueError("Unsupported bundle version")
if bundle.get("kind") not in (KIND_PROFILE, None):
raise ValueError("Not a profile bundle")
source_profile = bundle.get("profile")
if not isinstance(source_profile, dict):
raise ValueError("Bundle missing profile")
source_name = source_profile.get("name") or "Imported profile"
new_name = (name or source_name).strip() or source_name
profile_type = source_profile.get("type", "zones")
profile_cache: Dict[str, int] = {}
palette_cache: Dict[str, int] = {}
zone_cache: Dict[str, int] = {}
preset_cache: Dict[str, int] = {}
sequence_cache: Dict[str, int] = {}
new_profile_id = _allocate_id(profiles_model, profile_cache)
new_palette_id = _allocate_id(palette_model, palette_cache)
palette_in = bundle.get("palette") or {}
palette_colors = palette_in.get("colors") if isinstance(palette_in, dict) else []
if not isinstance(palette_colors, list):
palette_colors = []
preset_id_map: Dict[str, str] = {}
new_presets: Dict[str, Dict[str, Any]] = {}
new_zones: Dict[str, Dict[str, Any]] = {}
new_sequences: Dict[str, Dict[str, Any]] = {}
sequence_id_map: Dict[str, str] = {}
zones_in = bundle.get("zones") if isinstance(bundle.get("zones"), dict) else {}
presets_in = bundle.get("presets") if isinstance(bundle.get("presets"), dict) else {}
sequences_in = bundle.get("sequences") if isinstance(bundle.get("sequences"), dict) else {}
for old_pid, pdata in presets_in.items():
if not isinstance(pdata, dict):
continue
new_pid = _allocate_id(presets_model, preset_cache)
clone = copy.deepcopy(pdata)
clone["profile_id"] = str(new_profile_id)
new_presets[new_pid] = clone
preset_id_map[str(old_pid)] = new_pid
for old_sid, sdata in sequences_in.items():
if not isinstance(sdata, dict):
continue
new_sid = _allocate_id(sequences_model, sequence_cache)
clone = _map_sequence_lanes(sdata, preset_id_map)
clone["profile_id"] = str(new_profile_id)
new_sequences[new_sid] = clone
sequence_id_map[str(old_sid)] = new_sid
source_zone_order = source_profile.get("zones")
if not isinstance(source_zone_order, list):
source_zone_order = list(zones_in.keys())
cloned_zone_ids: List[str] = []
for old_zid in source_zone_order:
zone = zones_in.get(str(old_zid))
if not isinstance(zone, dict):
continue
new_zid = _allocate_id(zones_model, zone_cache)
clone_data: Dict[str, Any] = {
"name": zone.get("name") or f"Zone {old_zid}",
"names": list(zone.get("names") or []),
}
mapped_presets = _map_preset_container(
zone.get("presets"),
preset_id_map,
preset_cache,
new_profile_id,
new_presets,
presets_model,
)
if mapped_presets is not None:
clone_data["presets"] = mapped_presets
extra = {
k: v
for k, v in zone.items()
if k not in ("name", "names", "presets")
}
if "presets_flat" in extra:
extra["presets_flat"] = _map_preset_container(
extra.get("presets_flat"),
preset_id_map,
preset_cache,
new_profile_id,
new_presets,
presets_model,
)
if "default_preset" in extra and extra["default_preset"] is not None:
old_dp = str(extra["default_preset"])
if old_dp in preset_id_map:
extra["default_preset"] = preset_id_map[old_dp]
if "sequence_ids" in extra and isinstance(extra.get("sequence_ids"), list):
extra["sequence_ids"] = [
sequence_id_map.get(str(s), str(s))
for s in extra["sequence_ids"]
if s is not None
]
clone_data.update(extra)
new_zones[new_zid] = clone_data
cloned_zone_ids.append(new_zid)
new_profile_data = {
"name": new_name,
"type": profile_type,
"zones": cloned_zone_ids,
"scenes": list(source_profile.get("scenes", []))
if isinstance(source_profile.get("scenes"), list)
else [],
"palette_id": str(new_palette_id),
}
palette_model[str(new_palette_id)] = list(palette_colors)
for pid, pdata in new_presets.items():
presets_model[pid] = pdata
for zid, zdata in new_zones.items():
zones_model[zid] = zdata
for sid, sdata in new_sequences.items():
sequences_model[sid] = sdata
profiles_model[str(new_profile_id)] = new_profile_data
palette_model.save()
presets_model.save()
zones_model.save()
sequences_model.save()
profiles_model.save()
return str(new_profile_id), new_profile_data
def export_preset_bundle(preset_id: str, presets_model) -> Dict[str, Any]:
preset = presets_model.read(preset_id)
if not preset:
raise ValueError("Preset not found")
return {
"version": BUNDLE_VERSION,
"kind": KIND_PRESET,
"preset": copy.deepcopy(preset),
}
def import_preset_bundle(
bundle: Dict[str, Any],
presets_model,
profile_id: str,
) -> Tuple[str, Dict[str, Any]]:
if not isinstance(bundle, dict):
raise ValueError("Invalid bundle")
if bundle.get("kind") != KIND_PRESET:
raise ValueError("Not a preset bundle")
preset = bundle.get("preset")
if not isinstance(preset, dict):
raise ValueError("Bundle missing preset")
new_id = presets_model.create(profile_id)
data = copy.deepcopy(preset)
data["profile_id"] = str(profile_id)
presets_model.update(new_id, data)
return str(new_id), presets_model.read(new_id) or data
def export_sequence_bundle(
sequence_id: str,
sequences_model,
presets_model,
*,
profile_id: Optional[str] = None,
) -> Dict[str, Any]:
seq = sequences_model.read(sequence_id)
if not seq:
raise ValueError("Sequence not found")
if profile_id is not None and str(seq.get("profile_id")) != str(profile_id):
raise ValueError("Sequence not found")
pid = str(profile_id or seq.get("profile_id") or "")
preset_ids = _preset_ids_in_sequence(seq)
presets_out: Dict[str, Any] = {}
for old_pid in preset_ids:
pdata = presets_model.read(old_pid)
if pdata and (not pid or str(pdata.get("profile_id")) == pid):
presets_out[old_pid] = copy.deepcopy(pdata)
return {
"version": BUNDLE_VERSION,
"kind": KIND_SEQUENCE,
"sequence": copy.deepcopy(seq),
"presets": presets_out,
}
def import_sequence_bundle(
bundle: Dict[str, Any],
sequences_model,
presets_model,
profile_id: str,
) -> Tuple[str, Dict[str, Any]]:
if not isinstance(bundle, dict):
raise ValueError("Invalid bundle")
if bundle.get("kind") != KIND_SEQUENCE:
raise ValueError("Not a sequence bundle")
seq = bundle.get("sequence")
if not isinstance(seq, dict):
raise ValueError("Bundle missing sequence")
preset_cache: Dict[str, int] = {}
preset_id_map: Dict[str, str] = {}
new_presets: Dict[str, Dict[str, Any]] = {}
presets_in = bundle.get("presets") if isinstance(bundle.get("presets"), dict) else {}
for old_pid, pdata in presets_in.items():
if not isinstance(pdata, dict):
continue
new_pid = _allocate_id(presets_model, preset_cache)
clone = copy.deepcopy(pdata)
clone["profile_id"] = str(profile_id)
new_presets[new_pid] = clone
preset_id_map[str(old_pid)] = new_pid
for old_pid in _preset_ids_in_sequence(seq):
op = str(old_pid)
if op not in preset_id_map:
pdata = presets_model.read(op)
if pdata:
new_pid = _allocate_id(presets_model, preset_cache)
clone = copy.deepcopy(pdata)
clone["profile_id"] = str(profile_id)
new_presets[new_pid] = clone
preset_id_map[op] = new_pid
for pid, pdata in new_presets.items():
presets_model[pid] = pdata
if new_presets:
presets_model.save()
new_seq_id = sequences_model.create(profile_id)
mapped = _map_sequence_lanes(seq, preset_id_map)
mapped["profile_id"] = str(profile_id)
sequences_model.update(new_seq_id, mapped)
return str(new_seq_id), sequences_model.read(new_seq_id) or mapped

File diff suppressed because it is too large Load Diff

View File

@@ -112,12 +112,6 @@ def parse_args() -> argparse.Namespace:
default=0.12,
help="Aubio detection threshold",
)
parser.add_argument(
"--silence-gate-db",
type=float,
default=-58.0,
help="Ignore beat triggers when frame RMS is below this dB level",
)
return parser.parse_args()
@@ -131,6 +125,141 @@ def _estimate_bpm(beat_times: Deque[float]) -> float | None:
return 60.0 / float(np.median(valid))
def _is_plausible_ioi(
last_trigger_s: float,
beat_times: Deque[float],
now_s: float,
*,
min_ratio: float = 0.42,
max_ratio: float = 2.5,
) -> bool:
"""Reject double-time / half-time false triggers vs recent median interval."""
if last_trigger_s <= 0 or len(beat_times) < 2:
return True
ioi = now_s - last_trigger_s
if ioi <= 0:
return False
intervals = np.diff(np.array(list(beat_times)[-8:], dtype=np.float64))
if intervals.size == 0:
return True
med = float(np.median(intervals))
if med < 0.05:
return True
return (ioi >= med * min_ratio) and (ioi <= med * max_ratio)
class BarPhaseTracker:
"""Track beat-in-bar from downbeat counting (kick hints)."""
def __init__(self, beats_per_bar: int = 4, kick_conf_min: float = 1.15):
self.beats_per_bar = max(1, int(beats_per_bar))
self.kick_conf_min = float(kick_conf_min)
self.bar_beat = 1
self.is_downbeat = True
self.confidence = 0.0
self._last_downbeat_s = 0.0
self._aligned_kicks = 0
self._total_beats = 0
def reset(self) -> None:
self.bar_beat = 1
self.is_downbeat = True
self.confidence = 0.0
self._last_downbeat_s = 0.0
self._aligned_kicks = 0
self._total_beats = 0
def anchor_downbeat(self, now_s: float) -> None:
self.bar_beat = 1
self.is_downbeat = True
self._last_downbeat_s = float(now_s)
self.confidence = max(self.confidence, 0.85)
def _bar_duration_s(
self, bpm: float | None, median_ioi: float | None
) -> float | None:
if bpm is not None and bpm > 0:
return (60.0 / float(bpm)) * self.beats_per_bar
if median_ioi is not None and median_ioi > 0:
return float(median_ioi) * self.beats_per_bar
return None
@staticmethod
def _near_whole_bars(elapsed: float, bar_dur: float, tol: float = 0.14) -> bool:
if bar_dur <= 0 or elapsed <= 0:
return False
n = elapsed / bar_dur
nearest = max(1, round(n))
return abs(n - nearest) <= tol
def on_beat(
self,
now_s: float,
beat_type: str,
beat_type_conf: float,
*,
bpm: float | None = None,
median_ioi: float | None = None,
) -> dict[str, int | float | bool | str]:
self._total_beats += 1
bar_dur = self._bar_duration_s(bpm, median_ioi)
is_kick = (
str(beat_type or "").lower() == "kick"
and float(beat_type_conf or 0.0) >= self.kick_conf_min
)
downbeat_locked = False
if is_kick:
if self._last_downbeat_s <= 0 or self._total_beats <= 2:
downbeat_locked = True
elif bar_dur and self._near_whole_bars(
now_s - self._last_downbeat_s, bar_dur
):
downbeat_locked = True
elif is_kick and self.bar_beat >= max(2, self.beats_per_bar - 1):
downbeat_locked = True
prev_bar_beat = int(self.bar_beat)
if downbeat_locked:
self.bar_beat = 1
self.is_downbeat = True
self._last_downbeat_s = float(now_s)
self._aligned_kicks += 1
elif self._total_beats <= 1:
self.bar_beat = 1
self.is_downbeat = True
else:
self.bar_beat = (prev_bar_beat % self.beats_per_bar) + 1
self.is_downbeat = self.bar_beat == 1
if self._total_beats >= self.beats_per_bar:
bars_seen = max(1, self._total_beats // self.beats_per_bar)
self.confidence = min(1.0, self._aligned_kicks / bars_seen)
return {
"bar_beat": int(self.bar_beat),
"beats_per_bar": int(self.beats_per_bar),
"is_downbeat": bool(self.is_downbeat),
"phase_confidence": round(float(self.confidence), 3),
"bar_phase_readout": f"{int(self.bar_beat)}/{int(self.beats_per_bar)}",
}
def _resolve_bpm(
beat_times: Deque[float],
aubio_bpm: float | None,
) -> float | None:
estimated = _estimate_bpm(beat_times)
if estimated is None:
return aubio_bpm
if aubio_bpm is None or aubio_bpm <= 0:
return estimated
ratio = float(aubio_bpm) / estimated
if ratio > 1.75 or ratio < 0.57:
return estimated
return estimated
def _load_aubio_if_needed(mode: str):
if mode == "custom":
return None
@@ -170,6 +299,8 @@ class BeatDetectRuntime:
)
self.last_trigger_s = 0.0
self.debounce_s = float(args.min_ioi_ms) / 1000.0
bpb = int(getattr(args, "beats_per_bar", 4) or 4)
self.bar_phase = BarPhaseTracker(beats_per_bar=bpb)
def setup(self, sample_rate: int):
self.sample_rate = int(sample_rate)
@@ -192,6 +323,9 @@ class BeatDetectRuntime:
self.beat_times.clear()
self.tempo = None
if self.aubio is not None:
self._init_aubio_tempo(win_size)
def _init_aubio_tempo(self, win_size: int):
self.tempo = self.aubio.tempo(
self.args.aubio_method, win_size, self.frame_size, self.sample_rate
)
@@ -200,6 +334,27 @@ class BeatDetectRuntime:
if hasattr(self.tempo, "set_minioi_ms"):
self.tempo.set_minioi_ms(float(self.args.min_ioi_ms))
def reset_tempo_state(self) -> None:
"""Clear tempo/aubio history without losing bar phase."""
self.baseline = 1e-6
if self.prev_mag is not None:
self.prev_mag[:] = 0.0
self.beat_times.clear()
self.last_trigger_s = 0.0
if self.aubio is not None and self.sample_rate > 0:
win_size = max(1024, self.frame_size * max(2, self.args.win_mult))
self._init_aubio_tempo(win_size)
def reset_state(self):
"""Full reset (manual): tempo history and bar phase."""
self.reset_tempo_state()
self.bar_phase.reset()
def anchor_bar_phase(self, now_s: float | None = None) -> None:
if now_s is None:
now_s = time.time()
self.bar_phase.anchor_downbeat(now_s)
def _classify_hit(self, mag: np.ndarray):
total = float(np.mean(mag) + 1e-9)
kick = float(np.mean(mag[self.kick_mask])) / total if np.any(self.kick_mask) else 0.0
@@ -227,8 +382,6 @@ class BeatDetectRuntime:
f32 = frame.astype(np.float32)
rms = float(np.sqrt(np.mean(f32 * f32) + 1e-12))
db = 20.0 * np.log10(max(rms, 1e-12))
if db < float(self.args.silence_gate_db):
return None
mag = np.abs(np.fft.rfft(f32 * self.window)).astype(np.float32)
band_energy = float(np.mean(mag[self.band_mask]))
flux = float(np.mean(np.maximum(0.0, mag - self.prev_mag)))
@@ -260,14 +413,30 @@ class BeatDetectRuntime:
should_trigger = aubio_hit
else:
should_trigger = custom_hit or aubio_hit
if should_trigger and not _is_plausible_ioi(
self.last_trigger_s, self.beat_times, now_s
):
should_trigger = False
if not should_trigger:
return None
self.last_trigger_s = now_s
self.beat_times.append(now_s)
bpm = aubio_bpm if aubio_bpm is not None else _estimate_bpm(self.beat_times)
bpm = _resolve_bpm(self.beat_times, aubio_bpm)
strength = score / max(1e-9, self.baseline)
beat_type, beat_type_conf = self._classify_hit(mag)
median_ioi = None
if len(self.beat_times) >= 2:
intervals = np.diff(np.array(self.beat_times, dtype=np.float64))
if intervals.size > 0:
median_ioi = float(np.median(intervals))
phase = self.bar_phase.on_beat(
now_s,
beat_type,
beat_type_conf,
bpm=bpm,
median_ioi=median_ioi,
)
if self.args.mode == "custom":
src = "custom"
elif self.args.mode == "aubio":
@@ -288,6 +457,7 @@ class BeatDetectRuntime:
"beat_type": beat_type,
"beat_type_confidence": beat_type_conf,
"db": db,
**phase,
}

View File

@@ -25,18 +25,20 @@ def test_preset():
print("\nTesting update preset")
update_data = {
"name": "test_preset",
"pattern": "on",
"pattern": "colour_cycle",
"colors": ["#FF0000", "#00FF00"],
"delay": 100,
"brightness": 127,
"n1": 10,
"n2": 20
"n2": 20,
"mode": 1,
}
result = presets.update(preset_id, update_data)
assert result is True
updated = presets.read(preset_id)
assert updated["name"] == "test_preset"
assert updated["pattern"] == "on"
assert updated["pattern"] == "colour_cycle"
assert updated["mode"] == 1
assert updated["delay"] == 100
print("\nTesting list presets")

View File

@@ -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

View File

@@ -0,0 +1,61 @@
"""Reset detector must not stop the stream or clear ``running``."""
import os
import sys
import time
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SRC_PATH = os.path.join(PROJECT_ROOT, "src")
if SRC_PATH not in sys.path:
sys.path.insert(0, SRC_PATH)
from util.audio_detector import AudioBeatDetector # noqa: E402
class _FakeRuntime:
def __init__(self):
self.reset_calls = 0
def reset_state(self):
self.reset_calls += 1
def test_reset_tracking_false_when_not_running():
det = AudioBeatDetector()
assert det.reset_tracking() is False
def test_reset_tracking_queues_on_audio_thread():
det = AudioBeatDetector()
rt = _FakeRuntime()
with det._lock:
det._running = True
det._runtime = rt
det._status["running"] = True
det._status["bpm"] = 128.0
det._status["beat_seq"] = 7
assert det.reset_tracking() is True
assert rt.reset_calls == 0
assert det._pending_reset is True
st = det.status()
assert st["running"] is True
assert st["bpm"] == 128.0
assert st["beat_seq"] == 7
det._process_pending_reset(rt)
assert rt.reset_calls == 1
assert det._pending_reset is False
assert det.status()["running"] is True
def test_status_keeps_bpm_during_holdover():
det = AudioBeatDetector()
with det._lock:
det._running = True
det._holdover_active = True
det._status["running"] = True
det._status["bpm"] = 128.0
det._status["last_beat_ts"] = time.time() - 10.0
assert det.status()["bpm"] == 128.0

70
tests/test_bar_phase.py Normal file
View File

@@ -0,0 +1,70 @@
"""Bar phase (beat-in-bar) tracking for audio beat detection."""
import os
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from tests.beat_detect import BarPhaseTracker # noqa: E402
def test_bar_phase_increments_on_non_kick_beats():
tr = BarPhaseTracker(beats_per_bar=4)
r1 = tr.on_beat(1.0, "snare", 1.3, bpm=120.0)
assert r1["bar_beat"] == 1
r2 = tr.on_beat(1.5, "snare", 1.2, bpm=120.0)
assert r2["bar_beat"] == 2
r3 = tr.on_beat(2.0, "hat", 1.1, bpm=120.0)
assert r3["bar_beat"] == 3
def test_kick_near_bar_boundary_resets_to_downbeat():
tr = BarPhaseTracker(beats_per_bar=4)
tr.on_beat(0.0, "kick", 1.4, bpm=120.0)
tr.on_beat(0.5, "snare", 1.2, bpm=120.0)
tr.on_beat(1.0, "snare", 1.2, bpm=120.0)
tr.on_beat(1.5, "snare", 1.2, bpm=120.0)
r = tr.on_beat(2.0, "kick", 1.5, bpm=120.0)
assert r["bar_beat"] == 1
assert r["is_downbeat"] is True
def test_anchor_downbeat_sets_confidence():
tr = BarPhaseTracker(beats_per_bar=4)
tr.anchor_downbeat(10.0)
assert tr.bar_beat == 1
assert tr.confidence >= 0.85
def test_reset_tempo_preserves_bar_phase():
from argparse import Namespace
from tests.beat_detect import BeatDetectRuntime # noqa: E402
args = Namespace(
mode="custom",
hop_size=256,
win_mult=2,
min_band_hz=45.0,
max_band_hz=180.0,
energy_weight=0.7,
flux_weight=0.3,
threshold_multiplier=1.35,
ema_alpha=0.08,
min_ioi_ms=100.0,
bpm_window=8,
aubio_method="default",
aubio_threshold=0.12,
beats_per_bar=4,
)
rt = BeatDetectRuntime(args)
rt.setup(44100)
rt.bar_phase.on_beat(0.0, "kick", 1.5, bpm=120.0)
rt.bar_phase.on_beat(0.5, "snare", 1.2, bpm=120.0)
assert rt.bar_phase.bar_beat == 2
rt.reset_tempo_state()
assert rt.bar_phase.bar_beat == 2
rt.reset_state()
assert rt.bar_phase.bar_beat == 1

View File

@@ -0,0 +1,28 @@
"""Beat interval plausibility helpers (audio detector)."""
import os
import sys
from collections import deque
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from tests.beat_detect import _is_plausible_ioi, _resolve_bpm # noqa: E402
def test_is_plausible_ioi_rejects_double_time():
times = deque([0.0, 0.5, 1.0])
assert _is_plausible_ioi(1.0, times, 1.15) is False
def test_is_plausible_ioi_accepts_steady_grid():
times = deque([0.0, 0.5, 1.0])
assert _is_plausible_ioi(1.0, times, 1.5) is True
def test_resolve_bpm_prefers_intervals_over_wrong_aubio():
times = deque([0.0, 0.5, 1.0, 1.5, 2.0])
bpm = _resolve_bpm(times, 70.0)
assert bpm is not None
assert abs(bpm - 120.0) < 5.0

View File

@@ -0,0 +1,105 @@
"""Manual beat route: suppress duplicate select after sequence step change."""
import os
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SRC_PATH = os.path.join(PROJECT_ROOT, "src")
if SRC_PATH not in sys.path:
sys.path.insert(0, SRC_PATH)
from util import beat_driver_route as bdr # noqa: E402
def _patch_delivery(monkeypatch):
delivered = []
async def fake_batch(pairs):
delivered.extend(pairs)
def fake_schedule(coro, _loop):
import asyncio
asyncio.run(coro)
monkeypatch.setattr(bdr, "_deliver_select_batch", fake_batch)
monkeypatch.setattr(bdr, "_main_loop", object())
monkeypatch.setattr("asyncio.run_coroutine_threadsafe", fake_schedule)
return delivered
def test_suppress_next_notify_skips_one_select(monkeypatch):
delivered = _patch_delivery(monkeypatch)
bdr.set_sequence_manual_lane_route(
0,
["desk"],
"5",
{"p": "chase", "a": False, "manual_beat_n": 1},
)
bdr.mark_sequence_manual_lane_select_sent(0)
bdr.notify_beat_detected()
assert delivered == []
bdr.notify_beat_detected()
assert delivered == [(["desk"], "5")]
def test_suppress_does_not_advance_beat_counter(monkeypatch):
delivered = _patch_delivery(monkeypatch)
bdr.set_sequence_manual_lane_route(
0,
["desk"],
"42",
{"p": "radiate", "a": False, "manual_beat_n": 2},
)
bdr.mark_sequence_manual_lane_select_sent(0)
bdr.notify_beat_detected()
assert delivered == []
bdr.notify_beat_detected()
assert delivered == [(["desk"], "42")]
delivered.clear()
bdr.notify_beat_detected()
assert delivered == []
bdr.notify_beat_detected()
assert delivered == [(["desk"], "42")]
def test_duplicate_lanes_dedupe_to_one_select_per_beat(monkeypatch):
delivered = _patch_delivery(monkeypatch)
body = {"p": "radiate", "a": False, "manual_beat_n": 1}
entry = {
"device_names": ["desk"],
"wire_preset_id": "42",
"pattern": "radiate",
"manual_beat_n": 1,
"beat_counter": 0,
}
with bdr._route_lock:
bdr._lane_manual.clear()
bdr._lane_manual[-1] = dict(entry)
bdr._lane_manual[0] = dict(entry)
bdr.notify_beat_detected()
assert delivered == [(["desk"], "42")]
def test_standalone_overlay_skipped_when_sequence_lane_covers(monkeypatch):
delivered = _patch_delivery(monkeypatch)
body = {"p": "radiate", "a": False, "manual_beat_n": 1}
bdr.set_sequence_manual_lane_route(1, ["desk"], "42", body)
bdr._apply_manual_beat_route_standalone_overlay(["desk"], "42", body)
with bdr._route_lock:
assert -1 not in bdr._lane_manual
assert 1 in bdr._lane_manual
bdr.notify_beat_detected()
assert delivered == [(["desk"], "42")]

View File

@@ -29,6 +29,21 @@ def test_pack_parse_v2_brightness_only():
assert data == {"v": "1", "b": 128}
def test_pack_parse_v2_mode_maps_to_n6():
raw = pack_binary_envelope_v2(
presets={
"m": {
"p": "meteor",
"c": ["#aabbcc"],
"mode": 2,
"n6": 0,
}
},
)
data = parse_binary_envelope_v2(raw)
assert data["presets"]["m"]["n6"] == 2
def test_pack_parse_v2_full():
raw = pack_binary_envelope_v2(
presets={

View File

@@ -50,6 +50,17 @@ def _find_id_by_field(list_resp_json: Dict[str, Any], field: str, value: str) ->
raise AssertionError(f"Could not find id for {field}={value!r}")
def _create_and_apply_profile(c: requests.Session, base_url: str) -> str:
"""Sequences/scenes/presets need an active profile in session."""
unique_profile_name = f"pytest-profile-{uuid.uuid4().hex[:8]}"
resp = c.post(f"{base_url}/profiles", json={"name": unique_profile_name})
assert resp.status_code == 201
profile_id = next(iter(resp.json().keys()))
resp = c.post(f"{base_url}/profiles/{profile_id}/apply")
assert resp.status_code == 200
return str(profile_id)
def _start_microdot_server(app: Microdot, host: str, port: int):
"""
Start Microdot server on a background thread.
@@ -341,19 +352,27 @@ def test_settings_controller(server):
)
assert resp.status_code == 400
resp = c.put(f"{base_url}/settings/settings", json={"wifi_channel": 11})
resp = c.put(f"{base_url}/settings", json={"wifi_channel": 11})
assert resp.status_code == 200
resp = c.put(f"{base_url}/settings/settings", json={"wifi_channel": 12})
resp = c.put(f"{base_url}/settings", json={"wifi_channel": 12})
assert resp.status_code == 400
resp = c.put(f"{base_url}/settings/settings", json={"global_brightness": 42})
resp = c.put(f"{base_url}/settings", json={"global_brightness": 42})
assert resp.status_code == 200
resp = c.get(f"{base_url}/settings")
assert resp.status_code == 200
assert resp.json().get("global_brightness") == 42
resp = c.put(f"{base_url}/settings/settings", json={"global_brightness": 300})
resp = c.put(
f"{base_url}/settings",
json={"sequence_switch_wait": "downbeat"},
)
assert resp.status_code == 200
resp = c.get(f"{base_url}/settings")
assert resp.json().get("sequence_switch_wait") == "downbeat"
resp = c.put(f"{base_url}/settings", json={"global_brightness": 300})
assert resp.status_code == 400
@@ -474,6 +493,36 @@ def test_profiles_presets_zones_endpoints(server, monkeypatch):
resp = c.delete(f"{base_url}/zones/{zone_id}")
assert resp.status_code == 200
resp = c.get(f"{base_url}/profiles/{profile_id}/export")
assert resp.status_code == 200
bundle = resp.json()
assert bundle.get("kind") == "profile"
assert isinstance(bundle.get("presets"), dict)
import_name = f"pytest-imported-{uuid.uuid4().hex[:8]}"
resp = c.post(
f"{base_url}/profiles/import",
json={"bundle": bundle, "name": import_name, "apply": False},
)
assert resp.status_code == 201
imported_profile_id = resp.json().get("id") or next(
k for k in resp.json().keys() if k != "id"
)
resp = c.delete(f"{base_url}/profiles/{imported_profile_id}")
assert resp.status_code == 200
resp = c.get(f"{base_url}/presets/{first_preset_id}/export")
assert resp.status_code == 200
assert resp.json().get("kind") == "preset"
resp = c.post(
f"{base_url}/presets/import",
json={"bundle": resp.json()},
)
assert resp.status_code == 201
imported_preset_id = next(iter(resp.json().keys()))
resp = c.delete(f"{base_url}/presets/{imported_preset_id}")
assert resp.status_code == 200
# Profile clone + update endpoints.
clone_name = f"pytest-profile-clone-{uuid.uuid4().hex[:8]}"
resp = c.post(f"{base_url}/profiles/{profile_id}/clone", json={"name": clone_name})
@@ -508,6 +557,8 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
base_url: str = server["base_url"]
sender: DummySender = server["sender"]
_create_and_apply_profile(c, base_url)
# Groups.
unique_group_name = f"pytest-group-{uuid.uuid4().hex[:8]}"
resp = c.post(f"{base_url}/groups", json={"name": unique_group_name})
@@ -715,6 +766,13 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
assert resp.status_code == 200
definitions = resp.json()
assert isinstance(definitions, dict)
assert "colour_cycle" in definitions
cc_mode = definitions["colour_cycle"].get("mode")
assert isinstance(cc_mode, dict)
assert "0" in cc_mode and "1" in cc_mode
assert "blink" in definitions
blink_mode = definitions["blink"].get("mode")
assert not isinstance(blink_mode, dict) or len(blink_mode) < 2
pattern_id = f"pytest_pattern_{uuid.uuid4().hex[:8]}"
resp = c.post(

View File

@@ -0,0 +1,36 @@
"""LED strip reverse (n5) mapping for upside-down installs."""
import os
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DRIVER_SRC = os.path.join(PROJECT_ROOT, "led-driver", "src")
if DRIVER_SRC not in sys.path:
sys.path.insert(0, DRIVER_SRC)
from patterns.pattern_direction import is_reversed, led_i, signed # noqa: E402
from preset import Preset # noqa: E402
class _FakeDriver:
num_leds = 10
def test_preset_reverse_sets_n5():
p = Preset({"p": "chase", "reverse": True})
assert p.n5 == 1
assert is_reversed(p) is True
def test_led_i_mirrors_index():
drv = _FakeDriver()
p = Preset({"p": "chase", "n5": 1})
assert led_i(drv, p, 0) == 9
assert led_i(drv, p, 9) == 0
assert led_i(drv, p, 3) == 6
def test_signed_negates_when_reversed():
p = Preset({"p": "chase", "n5": 1})
assert signed(p, 4) == -4
assert signed(Preset({"p": "chase", "n5": 0}), 4) == 4

View File

@@ -0,0 +1,51 @@
"""Preset style mode: ``mode`` field, wire ``n6``, and pattern.json metadata."""
import json
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
sys.path.insert(0, str(PROJECT_ROOT / "led-driver" / "src"))
from patterns.pattern_modes import style_mode # noqa: E402
from preset import Preset # noqa: E402
from util.espnow_message import build_preset_dict, wire_n6 # noqa: E402
def test_wire_n6_prefers_mode_over_n6():
assert wire_n6({"mode": 2, "n6": 0}) == 2
assert wire_n6({"n6": 1}) == 1
assert wire_n6({}) == 0
def test_build_preset_dict_maps_mode_to_n6():
wire = build_preset_dict({"pattern": "meteor", "mode": 2, "colors": ["#ffffff"]})
assert wire["n6"] == 2
assert wire["p"] == "meteor"
def test_preset_edit_accepts_mode_alias():
p = Preset({"p": "colour_cycle", "mode": 1, "d": 100, "c": [(255, 255, 255)]})
assert p.n6 == 1
def test_style_mode_reads_mode_and_legacy_pattern_id():
p = Preset({"p": "colour_cycle", "mode": 0, "d": 100, "c": [(255, 0, 0)]})
assert style_mode(p, 0, {"rainbow": 1}) == 0
legacy = Preset({"p": "rainbow", "d": 100, "c": [(255, 0, 0)]})
assert style_mode(legacy, 0, {"rainbow": 1}) == 1
def test_pattern_json_defines_mode_for_merged_patterns():
path = PROJECT_ROOT / "db" / "pattern.json"
definitions = json.loads(path.read_text(encoding="utf-8"))
for name in ("colour_cycle", "chase", "aurora", "meteor", "particles", "sparkle"):
assert name in definitions, name
mode = definitions[name].get("mode")
assert isinstance(mode, dict), name
assert len(mode) >= 2, name
blink = definitions.get("blink", {})
assert "mode" not in blink or not isinstance(blink.get("mode"), dict) or len(blink.get("mode", {})) < 2

View File

@@ -0,0 +1,133 @@
"""Unit tests for profile/preset/sequence bundle import/export."""
import json
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from models.pallet import Palette # noqa: E402
from models.preset import Preset # noqa: E402
from models.profile import Profile # noqa: E402
from models.sequence import Sequence # noqa: E402
from models.zone import Zone # noqa: E402
from util.profile_bundle import ( # noqa: E402
export_preset_bundle,
export_profile_bundle,
export_sequence_bundle,
import_preset_bundle,
import_profile_bundle,
import_sequence_bundle,
)
def _fresh_models(tmp_path, monkeypatch):
import models.model as model_mod
db = tmp_path / "db"
db.mkdir()
monkeypatch.setattr(model_mod, "_db_dir", lambda: str(db))
for cls in (Profile, Zone, Preset, Sequence, Palette):
if hasattr(cls, "_instance"):
delattr(cls, "_instance")
profiles = Profile()
zones = Zone()
presets = Preset()
sequences = Sequence()
palette = Palette()
return profiles, zones, presets, sequences, palette
def test_profile_export_import_round_trip(tmp_path, monkeypatch):
profiles, zones, presets, sequences, palette = _fresh_models(tmp_path, monkeypatch)
pid = profiles.create("Source")
zid = zones.create(name="main")
preset_id = presets.create(pid)
presets.update(
preset_id,
{
"name": "Test preset",
"pattern": "blink",
"colors": ["#ff0000"],
"brightness": 200,
"delay": 50,
},
)
zones.update(
zid,
{
"presets_flat": [str(preset_id)],
"default_preset": str(preset_id),
},
)
seq_id = sequences.create(pid)
sequences.update(
seq_id,
{
"name": "Beat seq",
"lanes": [[{"preset_id": str(preset_id), "group_ids": [], "beats": 2}]],
"lanes_group_ids": [[]],
},
)
zones.update(zid, {"sequence_ids": [str(seq_id)]})
profiles.update(pid, {"zones": [str(zid)]})
palette_id = profiles.read(pid)["palette_id"]
palette.update(palette_id, {"colors": ["#112233", "#445566"]})
bundle = export_profile_bundle(
str(pid), profiles, zones, presets, sequences, palette
)
assert bundle["kind"] == "profile"
assert str(preset_id) in bundle["presets"]
assert str(seq_id) in bundle["sequences"]
assert bundle["palette"]["colors"] == ["#112233", "#445566"]
new_pid, _ = import_profile_bundle(
bundle, profiles, zones, presets, sequences, palette, name="Imported"
)
assert new_pid != str(pid)
found = [
presets.read(k)
for k in presets.list()
if isinstance(presets.read(k), dict)
and str(presets.read(k).get("profile_id")) == str(new_pid)
and presets.read(k).get("name") == "Test preset"
]
assert found
def test_preset_export_import(tmp_path, monkeypatch):
profiles, zones, presets, sequences, palette = _fresh_models(tmp_path, monkeypatch)
pid = profiles.create("P")
preset_id = presets.create(pid)
presets.update(preset_id, {"name": "Solo", "pattern": "on", "colors": ["#00ff00"]})
bundle = export_preset_bundle(str(preset_id), presets)
assert bundle["kind"] == "preset"
new_id, data = import_preset_bundle(bundle, presets, str(pid))
assert new_id != str(preset_id)
assert data["name"] == "Solo"
def test_sequence_export_import_with_presets(tmp_path, monkeypatch):
profiles, zones, presets, sequences, palette = _fresh_models(tmp_path, monkeypatch)
pid = profiles.create("P")
preset_id = presets.create(pid)
presets.update(preset_id, {"name": "Step", "pattern": "off"})
seq_id = sequences.create(pid)
sequences.update(
seq_id,
{"name": "S", "lanes": [[{"preset_id": str(preset_id), "beats": 1}]]},
)
bundle = export_sequence_bundle(str(seq_id), sequences, presets, profile_id=str(pid))
assert str(preset_id) in bundle["presets"]
new_seq_id, doc = import_sequence_bundle(bundle, sequences, presets, str(pid))
assert new_seq_id != str(seq_id)
assert doc["lanes"][0][0]["preset_id"] != str(preset_id)

View File

@@ -0,0 +1,43 @@
"""Sequence beat phase alignment (sync to musical downbeat)."""
import os
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SRC_PATH = os.path.join(PROJECT_ROOT, "src")
if SRC_PATH not in sys.path:
sys.path.insert(0, SRC_PATH)
from util.sequence_playback import apply_beat_phase_sync # noqa: E402
def _ctx(lane_states):
return {"lane_states": lane_states, "sequence_loop_beat": 5}
def test_apply_beat_phase_sync_step_resets_beat_count_only():
ctx = _ctx(
[
{"stepIdx": 2, "beatCount": 3, "done": False},
{"stepIdx": 1, "beatCount": 1, "done": True},
]
)
ok, resend = apply_beat_phase_sync(ctx, "step")
assert ok is True
assert resend is False
assert ctx["lane_states"][0]["stepIdx"] == 2
assert ctx["lane_states"][0]["beatCount"] == 0
assert ctx["lane_states"][1]["beatCount"] == 1
assert ctx["sequence_loop_beat"] == 5
def test_apply_beat_phase_sync_pass_restarts_pass():
ctx = _ctx([{"stepIdx": 2, "beatCount": 3, "done": False}])
ok, resend = apply_beat_phase_sync(ctx, "pass")
assert ok is True
assert resend is True
st = ctx["lane_states"][0]
assert st["stepIdx"] == 0
assert st["beatCount"] == 0
assert st["done"] is False
assert ctx["sequence_loop_beat"] == 0

View File

@@ -0,0 +1,88 @@
"""Deferred sequence start on beat / downbeat."""
import asyncio
import os
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SRC_PATH = os.path.join(PROJECT_ROOT, "src")
if SRC_PATH not in sys.path:
sys.path.insert(0, SRC_PATH)
from util import sequence_playback as sp # noqa: E402
def test_normalize_wait_for():
assert sp._normalize_wait_for({"wait_for": "beat"}) == "beat"
assert sp._normalize_wait_for({"start_on": "downbeat"}) == "downbeat"
assert sp._normalize_wait_for({"wait_for": "next_beat"}) == "beat"
assert sp._normalize_wait_for({}) is None
assert sp._play_options_without_wait({"wait_for": "beat", "zone_id": "1"}) == {"zone_id": "1"}
def test_pending_play_status_empty():
sp.clear_pending_play()
assert sp.pending_play_status() == {"pending": False}
def test_queue_and_clear_pending():
sp.clear_pending_play()
sp._queue_pending_start("z1", "s1", "p1", {"simulated_bpm": 120}, "beat", bpm=120.0)
st = sp.pending_play_status()
assert st["pending"] is True
assert st["wait_for"] == "beat"
assert st["sequence_id"] == "s1"
sp.clear_pending_play()
assert sp.pending_play_status()["pending"] is False
def test_try_consume_pending_beat():
sp.clear_pending_play()
sp._queue_pending_start("z1", "s1", "p1", None, "beat", bpm=120.0)
async def fake_start(*_a, **_k):
return None
sp._start_immediate = fake_start # type: ignore[method-assign]
assert asyncio.run(sp._try_consume_pending_play(is_downbeat=False)) is True
assert sp.pending_play_status()["pending"] is False
def test_try_consume_pending_downbeat_skips_upbeat():
sp.clear_pending_play()
sp._queue_pending_start("z1", "s1", "p1", None, "downbeat", bpm=120.0)
assert asyncio.run(sp._try_consume_pending_play(is_downbeat=False)) is False
assert sp.pending_play_status()["pending"] is True
async def fake_start(*_a, **_k):
return None
sp._start_immediate = fake_start # type: ignore[method-assign]
assert asyncio.run(sp._try_consume_pending_play(is_downbeat=True)) is True
sp.clear_pending_play()
def test_downbeat_start_counts_trigger_beat(monkeypatch):
"""The downbeat that starts playback is beat 1 of the step, not beat 0."""
sp.clear_pending_play()
sp.stop()
async def fake_start(_z, _s, _p, _opts):
sp._beat_run = {
"lanes": [[{"preset_id": "1", "beats": 4}]],
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
"num_lanes": 1,
"sequence_loop_beat": 0,
}
monkeypatch.setattr(sp, "_start_immediate", fake_start)
sp._queue_pending_start("z1", "s1", "p1", None, "downbeat", bpm=120.0)
async def run():
assert await sp._try_consume_pending_play(is_downbeat=True) is True
await sp.process_active_beat_advance()
asyncio.run(run())
assert sp._beat_run["lane_states"][0]["beatCount"] == 1
sp.stop()

View File

@@ -0,0 +1,30 @@
"""Sequence playback loop flag coercion."""
import os
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SRC_PATH = os.path.join(PROJECT_ROOT, "src")
if SRC_PATH not in sys.path:
sys.path.insert(0, SRC_PATH)
from util.sequence_playback import ( # noqa: E402
_coerce_loop,
_ordered_unique_preset_ids_in_lane,
)
def test_coerce_loop():
assert _coerce_loop({"loop": True}) is True
assert _coerce_loop({"loop": False}) is False
assert _coerce_loop({"sequence_loop": 0}) is False
assert _coerce_loop({}) is True
def test_ordered_unique_preset_ids_in_lane():
lane = [
{"preset_id": "6", "beats": 1},
{"preset_id": "4", "beats": 2},
{"preset_id": "6", "beats": 1},
]
assert _ordered_unique_preset_ids_in_lane(lane) == ["6", "4"]

View File

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

22
tests/test_ui_settings.py Normal file
View File

@@ -0,0 +1,22 @@
"""Server-owned UI settings (no browser localStorage)."""
import os
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SRC_PATH = os.path.join(PROJECT_ROOT, "src")
if SRC_PATH not in sys.path:
sys.path.insert(0, SRC_PATH)
from util.audio_run_persist import read_audio_run_state # noqa: E402
from util.sequence_playback import _sequence_switch_wait_from_settings # noqa: E402
def test_audio_run_state_includes_device_form_fields():
st = read_audio_run_state()
assert "device_override" in st
assert "device_select" in st
def test_sequence_switch_wait_from_settings():
assert _sequence_switch_wait_from_settings() in ("beat", "downbeat")

View File

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