Compare commits
8 Commits
ef15c54593
...
beta-1.06
| Author | SHA1 | Date | |
|---|---|---|---|
| f02eaa6bad | |||
| 7015032f5c | |||
| d7a3fa96c5 | |||
| 7a7bedc07c | |||
| baec87068a | |||
| b140aedf00 | |||
| 15f8c8a039 | |||
| 70641c63af |
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"1": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000", "#050500"], "2": [], "3": [], "4": [], "5": [], "6": [], "7": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"], "8": [], "9": [], "10": [], "11": [], "12": ["#890b0b", "#0b8935"], "13": []}
|
||||
{"1":["#FF0000","#00FF00","#0000FF","#FFFF00","#FF00FF","#00FFFF","#FFFFFF","#000000","#050500"],"2":[],"3":[],"4":[],"5":[],"6":[],"7":["#FF0000","#00FF00","#0000FF","#FFFF00","#FF00FF","#00FFFF","#FFFFFF","#000000"],"8":[],"9":[],"10":[],"11":[],"12":["#890b0b","#0b8935"],"13":[],"14":["#E8F4FF","#9ECFFF","#5080C8","#FFFFFF","#B0DCFF","#0A1520","#FF8020","#071018"]}
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"1": {"name": "default", "type": "zones", "zones": ["1", "9", "8", "10"], "scenes": [], "palette_id": "1"}, "2": {"name": "test", "type": "zones", "zones": ["6", "7"], "scenes": [], "palette_id": "12"}}
|
||||
{"1":{"name":"default","type":"zones","zones":["1","9","8","10"],"scenes":[],"palette_id":"1"},"2":{"name":"test","type":"zones","zones":["6","7"],"scenes":[],"palette_id":"12"},"3":{"name":"Winter","type":"zones","zones":["11","12"],"scenes":[],"palette_id":"14"}}
|
||||
File diff suppressed because one or more lines are too long
2
led-tool
2
led-tool
Submodule led-tool updated: 1edcb8b1f7...bd4d2060ae
419
scripts/create_winter_profile.py
Normal file
419
scripts/create_winter_profile.py
Normal 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()
|
||||
@@ -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,17 +112,41 @@ 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(
|
||||
{
|
||||
|
||||
73
src/main.py
73
src/main.py
@@ -100,11 +100,7 @@ async def _handle_udp_discovery(sock, udp_holder=None) -> None:
|
||||
|
||||
|
||||
def _prime_wifi_outbound_driver_connections() -> None:
|
||||
"""
|
||||
For each Wi‑Fi 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,69 +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 not udp_holder.get("closing"):
|
||||
slept = 0.0
|
||||
while slept < interval and not udp_holder.get("closing"):
|
||||
chunk = min(1.0, interval - slept)
|
||||
await asyncio.sleep(chunk)
|
||||
slept += chunk
|
||||
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)
|
||||
@@ -573,10 +506,6 @@ async def main(port=80):
|
||||
asyncio.create_task(
|
||||
_run_udp_discovery_server(udp_holder), name="udp"
|
||||
),
|
||||
asyncio.create_task(
|
||||
_periodic_wifi_driver_hello_loop(settings, udp_holder),
|
||||
name="hello",
|
||||
),
|
||||
]
|
||||
await asyncio.gather(*server_tasks)
|
||||
except asyncio.CancelledError:
|
||||
|
||||
@@ -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})"
|
||||
)
|
||||
print(
|
||||
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)
|
||||
await asyncio.sleep(retry_interval_s)
|
||||
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()
|
||||
|
||||
@@ -134,7 +134,11 @@ class Zone(Model):
|
||||
id_str = str(id)
|
||||
if id_str not in self:
|
||||
return False
|
||||
patch = data if isinstance(data, dict) else {}
|
||||
patch = dict(data) if isinstance(data, dict) else {}
|
||||
doc = self[id_str]
|
||||
locked_kind = self._normalized_content_kind(doc) or self._infer_content_kind(doc)
|
||||
if "content_kind" in patch:
|
||||
patch["content_kind"] = locked_kind
|
||||
self[id_str].update(patch)
|
||||
if "content_kind" in patch:
|
||||
self._enforce_content_kind_invariants(self[id_str])
|
||||
|
||||
@@ -57,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.
|
||||
@@ -74,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).
|
||||
|
||||
@@ -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`). */
|
||||
@@ -161,13 +162,37 @@
|
||||
|
||||
function updateSequenceSyncControls(zoneSeqActive) {
|
||||
const topSync = el("audio-top-beat-sync");
|
||||
if (topSync) topSync.disabled = !zoneSeqActive;
|
||||
if (topSync) {
|
||||
topSync.disabled = audioDetectorRunning && !zoneSeqActive;
|
||||
topSync.title = !audioDetectorRunning
|
||||
? "Start beat detection"
|
||||
: zoneSeqActive
|
||||
? "Sync step to music (S)"
|
||||
: "Beat detection running";
|
||||
}
|
||||
const modalBeat = el("audio-modal-beat-readout");
|
||||
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",
|
||||
@@ -250,6 +275,7 @@
|
||||
|
||||
/** Stop detector and polling; does not clear “resume on load” prefs (used before restart). */
|
||||
async function stopAudioOnly() {
|
||||
audioDetectorRunning = false;
|
||||
setTopBpmVisible(false);
|
||||
setNavResetVisible(false);
|
||||
clearBeatPhaseTimers();
|
||||
@@ -285,6 +311,7 @@
|
||||
node.textContent = String(status.error).trim().slice(0, 120);
|
||||
}
|
||||
updateBeatReadoutDisplays({});
|
||||
audioDetectorRunning = !!status.running;
|
||||
updateBpmDisplay(null);
|
||||
setTopBpmVisible(!!status.running);
|
||||
setNavResetVisible(!!status.running);
|
||||
@@ -294,6 +321,7 @@
|
||||
}
|
||||
return;
|
||||
}
|
||||
audioDetectorRunning = !!status.running;
|
||||
const zoneSeqActive = sequencePlaybackActiveFromStatus(status);
|
||||
setTopBpmVisible(!!status.running || zoneSeqActive);
|
||||
setNavResetVisible(!!status.running);
|
||||
@@ -482,7 +510,12 @@
|
||||
}
|
||||
});
|
||||
};
|
||||
bindSync(el("audio-top-beat-sync"), "step");
|
||||
const topBpm = el("audio-top-beat-sync");
|
||||
if (topBpm) {
|
||||
topBpm.addEventListener("click", () => {
|
||||
void handleTopBpmButtonClick();
|
||||
});
|
||||
}
|
||||
bindSync(el("audio-modal-beat-readout"), "step");
|
||||
bindSync(el("audio-sync-pass-btn"), "pass");
|
||||
|
||||
@@ -501,11 +534,14 @@
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -216,6 +216,12 @@ function logSequenceSelectionPresets(sequenceId, sequenceDoc, presetsMap) {
|
||||
});
|
||||
}
|
||||
|
||||
function zoneGroupIdsFromDoc(zoneDoc) {
|
||||
return Array.isArray(zoneDoc && zoneDoc.group_ids)
|
||||
? zoneDoc.group_ids.map((x) => String(x).trim()).filter(Boolean)
|
||||
: [];
|
||||
}
|
||||
|
||||
function groupIdsForLaneStep(sequenceDoc, step, laneIndex, numLanes) {
|
||||
const lgs = Array.isArray(sequenceDoc.lanes_group_ids) ? sequenceDoc.lanes_group_ids : [];
|
||||
if (laneIndex < lgs.length) {
|
||||
@@ -239,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)) {
|
||||
@@ -249,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;';
|
||||
@@ -267,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)}`;
|
||||
@@ -333,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);
|
||||
}
|
||||
@@ -407,33 +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 simRaw = sequenceDoc.simulated_bpm;
|
||||
let sim = parseInt(String(simRaw != null ? simRaw : 120), 10);
|
||||
if (!Number.isFinite(sim)) sim = 120;
|
||||
sim = Math.min(300, Math.max(30, sim));
|
||||
sub.textContent = `${nLanes} lane${nLanes === 1 ? '' : 's'} · ${nSteps} step${nSteps === 1 ? '' : 's'} · beats · ${sim} BPM sim`;
|
||||
button.appendChild(sub);
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
const strip = document.getElementById('presets-list-zone');
|
||||
const clearActiveStrip = () => {
|
||||
@@ -540,7 +512,7 @@ async function addSequenceToTab(sequenceId, zoneId) {
|
||||
const tabData = await tabResponse.json();
|
||||
if (
|
||||
typeof window.zoneAllowsSequences === 'function' &&
|
||||
!window.zoneAllowsSequences(tabData)
|
||||
!window.zoneAllowsSequences(tabData, zoneId)
|
||||
) {
|
||||
alert('This zone is for presets only. Add presets from the zone Edit menu instead.');
|
||||
return;
|
||||
@@ -609,7 +581,7 @@ async function refreshEditTabSequencesUi(zoneId) {
|
||||
const zone = await zoneRes.json();
|
||||
if (
|
||||
typeof window.zoneAllowsSequences === 'function' &&
|
||||
!window.zoneAllowsSequences(zone)
|
||||
!window.zoneAllowsSequences(zone, zoneId)
|
||||
) {
|
||||
currentEl.innerHTML =
|
||||
'<span class="muted-text">This zone is for presets only. Sequences are hidden.</span>';
|
||||
@@ -865,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);
|
||||
@@ -904,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';
|
||||
@@ -977,6 +949,24 @@ async function openSequenceEditor(sequenceId, existing) {
|
||||
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 {
|
||||
@@ -1010,11 +1000,11 @@ async function openSequenceEditor(sequenceId, existing) {
|
||||
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();
|
||||
|
||||
@@ -310,8 +310,7 @@ async function computeZonePresetUnionTargets(zoneDoc) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Device names for one sequence step. Empty stepGroupIds => all zone tab devices (``names`` only).
|
||||
* Otherwise: lane groups intersected with that tab device list (not zone ``group_ids``).
|
||||
* Device names for one sequence step. Only devices in checked lane groups (within the zone tab).
|
||||
*/
|
||||
async function resolveSequenceStepDeviceNames(zone, stepGroupIds) {
|
||||
const zoneT = await computeZoneNamesTargets(zone);
|
||||
@@ -321,7 +320,7 @@ async function resolveSequenceStepDeviceNames(zone, stepGroupIds) {
|
||||
? stepGroupIds.map((x) => String(x).trim()).filter((x) => x.length > 0)
|
||||
: [];
|
||||
if (!gids.length) {
|
||||
return names.slice();
|
||||
return [];
|
||||
}
|
||||
const zoneMacSet = new Set(
|
||||
macs.map((m) => normalizeDeviceMac(m)).filter((m) => m.length === 12),
|
||||
@@ -509,43 +508,15 @@ function effectiveZoneContentKind(zoneDoc) {
|
||||
return 'presets';
|
||||
}
|
||||
|
||||
/** @returns {'presets' | 'sequences'} */
|
||||
function editModalContentKindSelected() {
|
||||
const radio = document.querySelector('input[name="edit-zone-content-kind"]:checked');
|
||||
return radio && radio.value === 'sequences' ? 'sequences' : 'presets';
|
||||
}
|
||||
|
||||
function activeZoneContentKind(zoneDoc, zoneId) {
|
||||
const modal = document.getElementById('edit-zone-modal');
|
||||
const editingId = document.getElementById('edit-zone-id')?.value;
|
||||
if (
|
||||
modal &&
|
||||
modal.classList.contains('active') &&
|
||||
zoneId != null &&
|
||||
zoneId !== '' &&
|
||||
String(editingId) === String(zoneId)
|
||||
) {
|
||||
return editModalContentKindSelected();
|
||||
}
|
||||
return effectiveZoneContentKind(zoneDoc);
|
||||
}
|
||||
|
||||
/** True when the zone row has an explicit presets vs sequences type (not legacy inferred). */
|
||||
function zoneHasExplicitContentKind(zoneDoc) {
|
||||
return normalizeZoneContentKind(zoneDoc) !== null;
|
||||
}
|
||||
|
||||
/** @returns {boolean} */
|
||||
function zoneAllowsPresets(zoneDoc, zoneId) {
|
||||
void zoneId;
|
||||
if (!zoneHasExplicitContentKind(zoneDoc)) return true;
|
||||
return effectiveZoneContentKind(zoneDoc) === 'presets';
|
||||
}
|
||||
|
||||
/** @returns {boolean} */
|
||||
function zoneAllowsSequences(zoneDoc, zoneId) {
|
||||
void zoneId;
|
||||
if (!zoneHasExplicitContentKind(zoneDoc)) return true;
|
||||
return effectiveZoneContentKind(zoneDoc) === 'sequences';
|
||||
}
|
||||
|
||||
@@ -623,7 +594,7 @@ function renderZonesList(tabs, tabOrder, currentZoneId) {
|
||||
for (const zoneId of tabOrder) {
|
||||
const zone = tabs[zoneId];
|
||||
if (zone) {
|
||||
const activeClass = zoneId === currentZoneId ? 'active' : '';
|
||||
const activeClass = String(zoneId) === String(currentZoneId) ? 'active' : '';
|
||||
const disp = zone.name || `Zone ${zoneId}`;
|
||||
html += `
|
||||
<button class="zone-button ${activeClass}"
|
||||
@@ -1183,9 +1154,13 @@ async function openEditZoneModal(zoneId, zone) {
|
||||
renderZoneGroupsEditor(groupsEditor, window.__editTabGroupRows, groupsMap);
|
||||
|
||||
const kind = effectiveZoneContentKind(tabData);
|
||||
document.querySelectorAll('input[name="edit-zone-content-kind"]').forEach((radio) => {
|
||||
radio.checked = radio.value === kind;
|
||||
});
|
||||
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);
|
||||
@@ -1196,13 +1171,11 @@ async function openEditZoneModal(zoneId, zone) {
|
||||
}
|
||||
|
||||
// Update an existing zone (name, group list; devices come from groups only).
|
||||
async function updateZone(zoneId, name, groupRows, contentKind) {
|
||||
async function updateZone(zoneId, name, groupRows) {
|
||||
try {
|
||||
const gids = Array.isArray(groupRows)
|
||||
? groupRows.map((r) => String(r.id || "").trim()).filter((x) => x.length > 0)
|
||||
: [];
|
||||
const ck =
|
||||
contentKind === 'sequences' || contentKind === 'presets' ? contentKind : 'presets';
|
||||
let existing = {};
|
||||
try {
|
||||
const cur = await fetch(`/zones/${encodeURIComponent(zoneId)}`, {
|
||||
@@ -1215,6 +1188,7 @@ async function updateZone(zoneId, name, groupRows, contentKind) {
|
||||
} catch (_) {
|
||||
/* use empty existing */
|
||||
}
|
||||
const lockedKind = effectiveZoneContentKind(existing);
|
||||
const response = await fetch(`/zones/${zoneId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
@@ -1229,7 +1203,7 @@ async function updateZone(zoneId, name, groupRows, contentKind) {
|
||||
existing.preset_group_ids && typeof existing.preset_group_ids === 'object'
|
||||
? existing.preset_group_ids
|
||||
: {},
|
||||
content_kind: ck,
|
||||
content_kind: lockedKind,
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1238,6 +1212,9 @@ async function updateZone(zoneId, name, groupRows, contentKind) {
|
||||
// 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;
|
||||
@@ -1369,18 +1346,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('input[name="edit-zone-content-kind"]').forEach((radio) => {
|
||||
radio.addEventListener('change', async () => {
|
||||
applyZoneContentKindEditModal(editModalContentKindSelected());
|
||||
const zoneId = document.getElementById('edit-zone-id')?.value;
|
||||
if (!zoneId) return;
|
||||
await refreshEditTabPresetsUi(zoneId);
|
||||
if (typeof window.refreshEditTabSequencesUi === 'function') {
|
||||
await window.refreshEditTabSequencesUi(zoneId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Set up edit zone form
|
||||
const editZoneForm = document.getElementById('edit-zone-form');
|
||||
if (editZoneForm) {
|
||||
@@ -1394,7 +1359,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const groupRows = window.__editTabGroupRows || [];
|
||||
|
||||
if (zoneId && name) {
|
||||
await updateZone(zoneId, name, groupRows, editModalContentKindSelected());
|
||||
await updateZone(zoneId, name, groupRows);
|
||||
editZoneForm.reset();
|
||||
}
|
||||
});
|
||||
@@ -1441,6 +1406,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
});
|
||||
|
||||
window.selectZone = selectZone;
|
||||
|
||||
// Export for use in other scripts
|
||||
window.zonesManager = {
|
||||
loadZones,
|
||||
|
||||
@@ -119,10 +119,7 @@
|
||||
<input type="hidden" id="edit-zone-id">
|
||||
<label>Zone Name:</label>
|
||||
<input type="text" id="edit-zone-name" placeholder="Enter zone name" required>
|
||||
<div class="zone-content-kind-row muted-text">
|
||||
<label><input type="radio" name="edit-zone-content-kind" value="presets" checked> Presets</label>
|
||||
<label><input type="radio" name="edit-zone-content-kind" value="sequences"> Sequences</label>
|
||||
</div>
|
||||
<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>
|
||||
@@ -765,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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -78,8 +78,13 @@ def _normalize_sequence_lanes(doc: Dict[str, Any]) -> List[List[Dict[str, Any]]]
|
||||
|
||||
|
||||
def _group_ids_for_lane_step(
|
||||
sequence_doc: Dict[str, Any], step: Dict[str, Any], lane_index: int, num_lanes: int
|
||||
sequence_doc: Dict[str, Any],
|
||||
step: Dict[str, Any],
|
||||
lane_index: int,
|
||||
num_lanes: int,
|
||||
zone_doc: Optional[Dict[str, Any]] = None,
|
||||
) -> List[str]:
|
||||
_ = zone_doc
|
||||
lgs = sequence_doc.get("lanes_group_ids")
|
||||
if isinstance(lgs, list) and lane_index < len(lgs):
|
||||
for_lane = lgs[lane_index]
|
||||
@@ -234,7 +239,7 @@ def _resolve_step_device_names(
|
||||
) -> List[str]:
|
||||
z_names, z_macs = _compute_zone_targets(zone_doc, devices, groups)
|
||||
if not step_group_ids:
|
||||
return list(z_names)
|
||||
return []
|
||||
zone_mac_set = {m for m in (_norm_mac(x) for x in z_macs) if m}
|
||||
zone_name_by_mac: Dict[str, str] = {}
|
||||
for i, m in enumerate(z_macs):
|
||||
@@ -273,12 +278,29 @@ def _lane_has_non_empty_lanes_group_ids(sequence_doc: Dict[str, Any], lane_index
|
||||
return any(x is not None and str(x).strip() for x in for_lane)
|
||||
|
||||
|
||||
def _partition_devices_for_lane(
|
||||
num_lanes: int,
|
||||
*,
|
||||
lane_has_own_groups: bool,
|
||||
step_group_ids: List[str],
|
||||
) -> bool:
|
||||
"""Split zone devices across lanes only when lanes lack explicit group targeting."""
|
||||
if num_lanes <= 1:
|
||||
return False
|
||||
if lane_has_own_groups:
|
||||
return False
|
||||
# No lane groups (whole zone): every lane uses all zone / zone-group devices.
|
||||
if not step_group_ids:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _split_device_names_for_lane(
|
||||
all_names: List[str],
|
||||
lane_index: int,
|
||||
num_lanes: int,
|
||||
*,
|
||||
partition_shared_zone: bool = True,
|
||||
partition_shared_zone: bool = False,
|
||||
) -> List[str]:
|
||||
names = [n for n in all_names if n and str(n).strip()]
|
||||
if num_lanes <= 1 or not partition_shared_zone:
|
||||
@@ -368,15 +390,20 @@ def _resolve_lane_device_names(lane_index: int, ctx: Dict[str, Any]) -> List[str
|
||||
lane = lanes[lane_index] if 0 <= lane_index < len(lanes) else []
|
||||
if not lane:
|
||||
return []
|
||||
gids = _group_ids_for_lane_step(sequence_doc, lane[0], lane_index, num_lanes)
|
||||
gids = _group_ids_for_lane_step(
|
||||
sequence_doc, lane[0], lane_index, num_lanes, zone_doc=zone_doc
|
||||
)
|
||||
device_names = _resolve_step_device_names(
|
||||
zone_doc, gids, devices, groups, sequence_doc=sequence_doc
|
||||
)
|
||||
lane_own = _lane_has_non_empty_lanes_group_ids(sequence_doc, lane_index)
|
||||
return _split_device_names_for_lane(
|
||||
device_names,
|
||||
lane_index,
|
||||
num_lanes,
|
||||
partition_shared_zone=not _lane_has_non_empty_lanes_group_ids(sequence_doc, lane_index),
|
||||
partition_shared_zone=_partition_devices_for_lane(
|
||||
num_lanes, lane_has_own_groups=lane_own, step_group_ids=gids
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -545,16 +572,19 @@ def _union_macs_for_sequence(ctx: Dict[str, Any]) -> List[str]:
|
||||
for step in lane:
|
||||
if not isinstance(step, dict):
|
||||
continue
|
||||
gids = _group_ids_for_lane_step(sequence_doc, step, lane_index, num_lanes)
|
||||
gids = _group_ids_for_lane_step(
|
||||
sequence_doc, step, lane_index, num_lanes, zone_doc=zone_doc
|
||||
)
|
||||
device_names = _resolve_step_device_names(
|
||||
zone_doc, gids, devices, groups, sequence_doc=sequence_doc
|
||||
)
|
||||
lane_own = _lane_has_non_empty_lanes_group_ids(sequence_doc, lane_index)
|
||||
device_names = _split_device_names_for_lane(
|
||||
device_names,
|
||||
lane_index,
|
||||
num_lanes,
|
||||
partition_shared_zone=not _lane_has_non_empty_lanes_group_ids(
|
||||
sequence_doc, lane_index
|
||||
partition_shared_zone=_partition_devices_for_lane(
|
||||
num_lanes, lane_has_own_groups=lane_own, step_group_ids=gids
|
||||
),
|
||||
)
|
||||
if gids and not device_names:
|
||||
@@ -563,10 +593,7 @@ def _union_macs_for_sequence(ctx: Dict[str, Any]) -> List[str]:
|
||||
if m and m not in seen:
|
||||
seen.add(m)
|
||||
out.append(m)
|
||||
if out:
|
||||
return out
|
||||
_, z_macs = _compute_zone_targets(zone_doc, devices, groups)
|
||||
return list(z_macs)
|
||||
return out
|
||||
|
||||
|
||||
def _coerce_loop(sequence_doc: Dict[str, Any]) -> bool:
|
||||
@@ -641,15 +668,22 @@ async def _send_lane(
|
||||
display_preset = _display_preset_for_step(preset_id, presets_map, palette_colors)
|
||||
if not display_preset:
|
||||
return
|
||||
gids = _group_ids_for_lane_step(sequence_doc, step, lane_index, int(ctx["num_lanes"]))
|
||||
device_names = _resolve_step_device_names(
|
||||
ctx["zone_doc"], gids, devices, ctx["groups"], sequence_doc=sequence_doc
|
||||
num_lanes = int(ctx["num_lanes"])
|
||||
zone_doc = ctx["zone_doc"]
|
||||
gids = _group_ids_for_lane_step(
|
||||
sequence_doc, step, lane_index, num_lanes, zone_doc=zone_doc
|
||||
)
|
||||
device_names = _resolve_step_device_names(
|
||||
zone_doc, gids, devices, ctx["groups"], sequence_doc=sequence_doc
|
||||
)
|
||||
lane_own = _lane_has_non_empty_lanes_group_ids(sequence_doc, lane_index)
|
||||
device_names = _split_device_names_for_lane(
|
||||
device_names,
|
||||
lane_index,
|
||||
int(ctx["num_lanes"]),
|
||||
partition_shared_zone=not _lane_has_non_empty_lanes_group_ids(sequence_doc, lane_index),
|
||||
num_lanes,
|
||||
partition_shared_zone=_partition_devices_for_lane(
|
||||
num_lanes, lane_has_own_groups=lane_own, step_group_ids=gids
|
||||
),
|
||||
)
|
||||
if gids and not device_names:
|
||||
return
|
||||
|
||||
28
tests/test_sequence_zone_groups.py
Normal file
28
tests/test_sequence_zone_groups.py
Normal 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=[])
|
||||
27
tests/test_zone_content_kind.py
Normal file
27
tests/test_zone_content_kind.py
Normal 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") == []
|
||||
Reference in New Issue
Block a user