feat(bridge): add wifi/serial bridge runtime and UI

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-28 00:38:21 +12:00
parent 2cf019079e
commit 78dc8ffc77
92 changed files with 5679 additions and 1790 deletions

View File

@@ -7,7 +7,7 @@ from models.device import (
validate_device_type,
)
from models.group import Group
from models.transport import get_current_sender
from models.transport import get_current_bridge
from settings import get_settings
from util.brightness_combine import effective_brightness_for_mac
from util.driver_patterns import driver_patterns_dir
@@ -141,10 +141,10 @@ def _http_post_pattern_source(ip, filename, code_text, reload_patterns=True, tim
return b" 2" in first_line
async def _identify_send_off_after_delay(sender, dev_id):
async def _identify_send_off_after_delay(bridge, dev_id):
try:
await asyncio.sleep(IDENTIFY_OFF_DELAY_S)
await sender.send(
await bridge.send(
{"v": "1", "select": ["off"]},
addr=dev_id,
)
@@ -152,13 +152,13 @@ async def _identify_send_off_after_delay(sender, dev_id):
pass
async def _identify_send_off_after_delay_broadcast(sender, group_ids=None):
async def _identify_send_off_after_delay_broadcast(bridge, group_ids=None):
try:
await asyncio.sleep(IDENTIFY_OFF_DELAY_S)
body = {"v": "1", "select": ["off"]}
if group_ids:
body["groups"] = [str(g) for g in group_ids if str(g).strip()]
await sender.send(body)
await bridge.send(body)
except Exception:
pass
@@ -173,11 +173,11 @@ async def send_identify_to_device(dev_id: str) -> tuple[int, str]:
dev = devices.read(dev_id)
if not dev:
return 404, "Device not found"
sender = get_current_sender()
if not sender:
bridge = get_current_bridge()
if not bridge:
return 503, "Transport not configured"
try:
ok = await sender.send(
ok = await bridge.send(
{
"v": "1",
"presets": {_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
@@ -189,7 +189,7 @@ async def send_identify_to_device(dev_id: str) -> tuple[int, str]:
return 503, "Send failed"
asyncio.create_task(
_identify_send_off_after_delay(sender, dev_id)
_identify_send_off_after_delay(bridge, dev_id)
)
except Exception as e:
return 503, str(e)
@@ -209,8 +209,8 @@ async def send_identify_to_group_devices(
from util.driver_delivery import deliver_json_messages
errors: list[dict] = []
sender = get_current_sender()
if not sender:
bridge = get_current_bridge()
if not bridge:
return 0, [{"mac": "*", "error": "Transport not configured"}]
body = {
@@ -224,7 +224,7 @@ async def send_identify_to_group_devices(
try:
deliveries, _chunks = await deliver_json_messages(
sender,
bridge,
[json.dumps(body, separators=(",", ":"))],
None,
devices,
@@ -236,7 +236,7 @@ async def send_identify_to_group_devices(
if deliveries < 1:
return 0, errors + [{"mac": "*", "error": "Send failed"}]
asyncio.create_task(_identify_send_off_after_delay_broadcast(sender, gids))
asyncio.create_task(_identify_send_off_after_delay_broadcast(bridge, gids))
seen: set[str] = set()
for raw in macs:
@@ -413,6 +413,46 @@ async def delete_device(request, id):
}
@controller.post("/groups")
async def update_device_groups(request):
"""Push current group membership to all ESP-NOW drivers in the registry."""
_ = request
from util.espnow_registry import push_groups_all_espnow_devices
result = await push_groups_all_espnow_devices()
status = 200 if result.get("ok") else 503
if not result.get("total"):
return (
json.dumps({"ok": False, "error": "No ESP-NOW devices in registry"}),
400,
{"Content-Type": "application/json"},
)
return json.dumps(result), status, {"Content-Type": "application/json"}
@controller.post("/ping")
async def ping_devices(request):
"""
Broadcast ESP-NOW PING_REQ; collect PING_RSP until timeout (default 3 s).
JSON body: ``{"timeout_s": 3.0}`` (optional).
"""
from util.espnow_ping import run_ping
timeout_s = 3.0
try:
body = request.json or {}
if isinstance(body, dict) and body.get("timeout_s") is not None:
timeout_s = float(body["timeout_s"])
except (TypeError, ValueError):
return json.dumps({"error": "Invalid timeout_s"}), 400, {
"Content-Type": "application/json",
}
timeout_s = max(0.5, min(30.0, timeout_s))
result = await run_ping(timeout_s=timeout_s)
status = 200 if result.get("ok") else 503
return json.dumps(result), status, {"Content-Type": "application/json"}
@controller.post("/<id>/identify")
async def identify_device(request, id):
"""
@@ -454,13 +494,13 @@ async def push_device_output_brightness(request, id):
zone_brightness=zb,
)
sender = get_current_sender()
if not sender:
bridge = get_current_bridge()
if not bridge:
return json.dumps({"error": "Transport not configured"}), 503, {
"Content-Type": "application/json",
}
try:
ok = await sender.send({"v": "1", "b": b_val, "save": True}, addr=id)
ok = await bridge.send({"v": "1", "b": b_val, "save": True}, addr=id)
if not ok:
return json.dumps({"error": "Send failed"}), 503, {
"Content-Type": "application/json",
@@ -484,8 +524,8 @@ async def push_driver_config(request, id):
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
sender = get_current_sender()
if not sender:
bridge = get_current_bridge()
if not bridge:
return json.dumps({"error": "Transport not configured"}), 503, {
"Content-Type": "application/json",
}
@@ -514,7 +554,7 @@ async def push_driver_config(request, id):
"error": "Provide at least one of name, num_leds, color_order, startup_mode"
}
), 400, {"Content-Type": "application/json"}
ok = await sender.send({"v": "1", "device_config": dc, "save": True}, addr=id)
ok = await bridge.send({"v": "1", "device_config": dc, "save": True}, addr=id)
if not ok:
return json.dumps({"error": "Send failed"}), 503, {
"Content-Type": "application/json",

View File

@@ -3,7 +3,7 @@ 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.transport import get_current_bridge
from util.espnow_registry import push_groups_for_group_devices
from settings import get_settings
from util.brightness_combine import effective_brightness_for_mac
@@ -62,6 +62,12 @@ async def get_group(request, session, id):
return json.dumps(group), 200, {"Content-Type": "application/json"}
def _sanitize_group_bridge_id_write(data):
"""Per-group bridge assignment is disabled; ignore writes."""
if isinstance(data, dict) and "bridge_id" in data:
data["bridge_id"] = None
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):
@@ -92,6 +98,7 @@ async def create_group(request, session):
name = data.get("name", "")
profile_scoped = bool(data.pop("profile_scoped", False))
_sanitize_group_profile_id_write(data, session)
_sanitize_group_bridge_id_write(data)
group_id = groups.create(name)
if data:
groups.update(group_id, data)
@@ -119,6 +126,7 @@ async def update_group(request, session, id):
return json.dumps({"error": "Invalid JSON"}), 400, {"Content-Type": "application/json"}
data = dict(data)
_sanitize_group_profile_id_write(data, session)
_sanitize_group_bridge_id_write(data)
if groups.update(id, data):
g = groups.read(id)
if g:
@@ -217,10 +225,10 @@ async def push_group_driver_config(request, session, id):
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
sent = 0
errors = []
sender = get_current_sender()
if not sender:
bridge = get_current_bridge()
if not bridge:
return json.dumps({"error": "Transport not configured"}), 503
body = {"v": "1", "device_config": dc, "save": True}
payload = {"v": "1", "device_config": dc, "save": True}
for mac in mac_list:
m = str(mac).strip().lower().replace(":", "").replace("-", "")
if len(m) != 12:
@@ -230,7 +238,7 @@ async def push_group_driver_config(request, session, id):
errors.append({"mac": m, "error": "not in registry"})
continue
try:
if await sender.send(body, addr=m):
if await bridge.send(payload, addr=m):
sent += 1
else:
errors.append({"mac": m, "error": "send failed"})
@@ -260,7 +268,7 @@ async def push_group_output_brightness(request, session, id):
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
sent = 0
errors = []
sender = get_current_sender()
bridge = get_current_bridge()
async def _push_brightness_one(m: str, dev: dict) -> tuple[str, bool, str | None]:
b_val = effective_brightness_for_mac(
@@ -270,10 +278,10 @@ async def push_group_output_brightness(request, session, id):
m,
zone_brightness=None,
)
if not sender:
if not bridge:
return m, False, "transport not configured"
try:
ok = await sender.send({"v": "1", "b": b_val, "save": True}, addr=m)
ok = await bridge.send({"v": "1", "b": b_val, "save": True}, addr=m)
return m, bool(ok), None if ok else "send failed"
except Exception as e:
return m, False, str(e)

View File

@@ -4,7 +4,7 @@ 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 models.transport import get_current_bridge
from util.driver_delivery import (
build_preset_json_chunks,
deliver_json_messages,
@@ -224,8 +224,8 @@ async def send_presets(request, session):
if default_id is not None and str(default_id) not in presets_by_name:
default_id = None
sender = get_current_sender()
if not sender:
bridge = get_current_bridge()
if not bridge:
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
send_delay_s = 0.1
@@ -264,8 +264,7 @@ async def send_presets(request, session):
deliveries = 0
for msg in chunk_messages:
d, _chunks = await deliver_json_messages(
sender,
[msg],
bridge, [msg],
target_list,
Device(),
delay_s=send_delay_s,
@@ -278,7 +277,7 @@ async def send_presets(request, session):
separators=(",", ":"),
)
d, _chunks = await deliver_json_messages(
sender,
bridge,
[def_msg],
target_list,
Device(),
@@ -294,7 +293,7 @@ async def send_presets(request, session):
body["groups"] = list(group_ids)
wire_messages.append(json.dumps(body, separators=(",", ":")))
deliveries, _chunks = await deliver_json_messages(
sender,
bridge,
wire_messages,
None,
Device(),
@@ -343,8 +342,8 @@ async def push_driver_messages(request, session):
if not target_list:
target_list = None
sender = get_current_sender()
if not sender:
bridge = get_current_bridge()
if not bridge:
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
messages = []
@@ -385,7 +384,7 @@ async def push_driver_messages(request, session):
try:
deliveries, _chunks = await deliver_json_messages(
sender,
bridge,
messages,
target_list,
Device(),

View File

@@ -2,7 +2,7 @@ from microdot import Microdot
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.transport import get_current_bridge
from models.preset import Preset
from util.profile_bundle import export_sequence_bundle, import_sequence_bundle
import json
@@ -254,7 +254,7 @@ async def stop_sequence_playback(request, session):
@with_session
async def play_sequence(request, session, id):
"""Start server-driven playback for a sequence in a zone (body: {\"zone_id\": \"...\"})."""
if not get_current_sender():
if not get_current_bridge():
return (
json.dumps({"error": "Transport not configured"}),
503,

View File

@@ -88,6 +88,13 @@ def _validate_audio_beat_phase_ms(value):
return v
def _validate_audio_input_volume(value):
v = int(value)
if v < 0 or v > 200:
raise ValueError("audio_input_volume must be between 0 and 200")
return v
@controller.put('')
async def update_settings(request):
"""Update general settings."""
@@ -104,6 +111,8 @@ async def update_settings(request):
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)
elif key == 'audio_input_volume' and value is not None:
settings[key] = _validate_audio_input_volume(value)
else:
settings[key] = value
settings.save()

View File

@@ -0,0 +1,282 @@
"""Pi WiFi and saved ESP-NOW bridge profiles."""
from __future__ import annotations
import json
import secrets
from microdot import Microdot
from settings import get_settings
from util.bridge_profiles import find_bridge_profile, normalise_bridges
from util.bridge_runtime import (
active_bridge_profile_id,
bridge_connected,
bridge_serial_connected,
bridge_ws_connected,
connect_bridge_profile,
connect_bridge_serial,
connect_bridge_wifi,
)
from util.pi_wifi import list_wifi_interfaces, nmcli_available, scan_wifi
controller = Microdot()
def _bridge_transport(settings) -> str:
mode = str(settings.get("bridge_transport") or "wifi").strip().lower()
return mode if mode in ("wifi", "serial") else "wifi"
def _bridges_payload(settings) -> dict:
return {
"ok": True,
"wifi_interface": settings.get("wifi_interface") or "",
"bridge_ws_url": settings.get("bridge_ws_url") or "",
"bridge_connected": bridge_connected(),
"bridge_wifi_connected": bridge_ws_connected(),
"bridge_serial_connected": bridge_serial_connected(),
"bridge_transport": _bridge_transport(settings),
"active_bridge_id": active_bridge_profile_id(settings) or "",
"bridge_serial_port": settings.get("bridge_serial_port") or "",
"bridge_serial_baudrate": int(settings.get("bridge_serial_baudrate") or 921600),
"bridges": normalise_bridges(settings.get("bridges")),
}
@controller.get("/interfaces")
async def wifi_interfaces(request):
_ = request
if not nmcli_available():
return (
json.dumps({"ok": False, "error": "nmcli not found (install NetworkManager)"}),
503,
{"Content-Type": "application/json"},
)
return (
json.dumps({"ok": True, "interfaces": list_wifi_interfaces()}),
200,
{"Content-Type": "application/json"},
)
@controller.get("/scan")
async def wifi_scan(request):
device = (request.args.get("device") or "").strip()
if not device:
return json.dumps({"error": "device query param required"}), 400, {
"Content-Type": "application/json",
}
if not nmcli_available():
return json.dumps({"ok": False, "error": "nmcli not found"}), 503, {
"Content-Type": "application/json",
}
try:
networks = await scan_wifi(device)
return json.dumps({"ok": True, "device": device, "networks": networks}), 200, {
"Content-Type": "application/json",
}
except Exception as e:
return json.dumps({"ok": False, "error": str(e)}), 500, {
"Content-Type": "application/json",
}
@controller.get("/bridges")
async def get_bridges(request):
_ = request
settings = get_settings()
return json.dumps(_bridges_payload(settings)), 200, {"Content-Type": "application/json"}
@controller.put("/bridges")
async def put_bridges(request):
try:
data = request.json or {}
settings = get_settings()
if "wifi_interface" in data:
settings["wifi_interface"] = str(data.get("wifi_interface") or "").strip()
if "bridge_transport" in data:
mode = str(data.get("bridge_transport") or "").strip().lower()
if mode in ("wifi", "serial"):
settings["bridge_transport"] = mode
if "bridge_ws_url" in data:
settings["bridge_ws_url"] = str(data.get("bridge_ws_url") or "").strip()
if "bridge_serial_port" in data:
settings["bridge_serial_port"] = str(data.get("bridge_serial_port") or "").strip()
if "bridge_serial_baudrate" in data:
settings["bridge_serial_baudrate"] = int(data.get("bridge_serial_baudrate") or 921600)
if "bridges" in data:
settings["bridges"] = normalise_bridges(data.get("bridges"))
settings.save()
return json.dumps({"ok": True, "message": "Bridge profiles saved"}), 200, {
"Content-Type": "application/json",
}
except Exception as e:
return json.dumps({"ok": False, "error": str(e)}), 400, {
"Content-Type": "application/json",
}
@controller.delete("/bridges/<bridge_id>")
async def delete_bridge_profile(request, bridge_id):
_ = request
settings = get_settings()
bid = str(bridge_id or "").strip()
bridges = normalise_bridges(settings.get("bridges"))
kept = [b for b in bridges if str(b.get("id") or "") != bid]
if len(kept) == len(bridges):
return json.dumps({"ok": False, "error": "Bridge profile not found"}), 404, {
"Content-Type": "application/json",
}
settings["bridges"] = kept
settings.save()
payload = _bridges_payload(settings)
payload["message"] = "Bridge profile deleted"
return json.dumps(payload), 200, {"Content-Type": "application/json"}
@controller.post("/bridges/<bridge_id>/connect")
async def connect_saved_bridge(request, bridge_id):
_ = request
settings = get_settings()
profile = find_bridge_profile(settings, bridge_id)
if not profile:
return json.dumps({"error": "Bridge profile not found"}), 404, {
"Content-Type": "application/json",
}
try:
ok, err = await connect_bridge_profile(profile, settings)
if not ok:
return json.dumps({"ok": False, "error": err or "Connect failed"}), 400, {
"Content-Type": "application/json",
}
payload = _bridges_payload(settings)
payload["message"] = f"Connected to {profile.get('label')}"
return json.dumps(payload), 200, {"Content-Type": "application/json"}
except Exception as e:
return json.dumps({"ok": False, "error": str(e)}), 500, {
"Content-Type": "application/json",
}
@controller.post("/connect")
async def wifi_connect_bridge(request):
"""Join a bridge AP and open its WebSocket."""
try:
data = request.json or {}
settings = get_settings()
device = str(data.get("device") or settings.get("wifi_interface") or "").strip()
ssid = str(data.get("ssid") or "").strip()
password = str(data.get("password") or "")
ap_ip = str(data.get("ap_ip") or "192.168.4.1").strip()
try:
ws_port = int(data.get("ws_port") or 80)
except (TypeError, ValueError):
ws_port = 80
label = str(data.get("label") or ssid).strip() or ssid
save_profile = bool(data.get("save_profile", True))
if not device:
return json.dumps({"error": "WiFi interface (device) is required"}), 400, {
"Content-Type": "application/json",
}
if not ssid:
return json.dumps({"error": "ssid is required"}), 400, {
"Content-Type": "application/json",
}
settings["wifi_interface"] = device
bridges = normalise_bridges(settings.get("bridges"))
profile_id = None
if save_profile:
profile_id = secrets.token_hex(6)
bridges = [
b
for b in bridges
if not (b.get("transport") == "wifi" and b.get("ssid") == ssid)
]
bridges.append(
{
"id": profile_id,
"label": label,
"transport": "wifi",
"ssid": ssid,
"password": password,
"ap_ip": ap_ip,
"ws_port": ws_port,
}
)
settings["bridges"] = bridges
settings.save()
profile = {
"transport": "wifi",
"ssid": ssid,
"password": password,
"ap_ip": ap_ip,
"ws_port": ws_port,
"wifi_interface": device,
}
ok, err = await connect_bridge_wifi(profile, settings)
if not ok:
return json.dumps({"ok": False, "error": err or "Connect failed"}), 400, {
"Content-Type": "application/json",
}
payload = _bridges_payload(settings)
payload["profile_id"] = profile_id
payload["message"] = f"Connected to {ssid}"
return json.dumps(payload), 200, {"Content-Type": "application/json"}
except Exception as e:
return json.dumps({"ok": False, "error": str(e)}), 500, {
"Content-Type": "application/json",
}
@controller.post("/serial/connect")
async def serial_connect_bridge(request):
try:
data = request.json or {}
port = str(data.get("port") or data.get("serial_port") or "").strip()
save_profile = bool(data.get("save_profile", True))
label = str(data.get("label") or port).strip() or port
try:
baud = int(data.get("baudrate") or data.get("serial_baudrate") or 921600)
except (TypeError, ValueError):
baud = 921600
if not port:
return json.dumps({"error": "port is required"}), 400, {
"Content-Type": "application/json",
}
settings = get_settings()
bridges = normalise_bridges(settings.get("bridges"))
profile_id = None
if save_profile:
profile_id = secrets.token_hex(6)
bridges = [
b
for b in bridges
if not (b.get("transport") == "serial" and b.get("serial_port") == port)
]
bridges.append(
{
"id": profile_id,
"label": label,
"transport": "serial",
"serial_port": port,
"serial_baudrate": baud,
}
)
settings["bridges"] = bridges
settings.save()
profile = {"transport": "serial", "serial_port": port, "serial_baudrate": baud}
ok, err = await connect_bridge_serial(profile, settings)
if not ok:
return json.dumps({"ok": False, "error": err}), 500, {
"Content-Type": "application/json",
}
payload = _bridges_payload(settings)
payload["profile_id"] = profile_id
payload["message"] = f"Connected on {port}"
return json.dumps(payload), 200, {"Content-Type": "application/json"}
except Exception as e:
return json.dumps({"ok": False, "error": str(e)}), 500, {
"Content-Type": "application/json",
}

View File

@@ -7,7 +7,7 @@ import signal
from microdot import Microdot, send_file
from microdot.websocket import with_websocket
from microdot.session import Session
from settings import get_settings
from settings import WIFI_CHANNEL_DEFAULT, get_settings
import controllers.preset as preset
import controllers.profile as profile
@@ -20,10 +20,19 @@ import controllers.pattern as pattern
import controllers.settings as settings_controller
import controllers.device as device_controller
import controllers.led_tool as led_tool_controller
from models.transport import get_sender, set_sender, get_current_sender
from models.transport import (
get_bridge,
set_bridge,
get_current_bridge,
BridgeSerialTransport,
BridgeWsTransport,
)
from models.device import Device
from models.bridge_serial_client import init_bridge_serial_client
from models.bridge_ws_client import init_bridge_client
from util.espnow_registry import handle_bridge_uplink
from util.bridge_runtime import set_bridge_uplink_handler
import controllers.wifi_bridge as wifi_bridge_controller
from util.audio_detector import AudioBeatDetector
@@ -37,18 +46,48 @@ async def main(port=80):
print(settings)
print("Starting")
sender = get_sender(settings)
set_sender(sender)
set_bridge_uplink_handler(handle_bridge_uplink)
bridge_url = str(settings.get("bridge_ws_url") or "").strip()
if bridge_url:
try:
ch = int(settings.get("wifi_channel", 1))
except (TypeError, ValueError):
ch = 1
bridge = init_bridge_client(bridge_url, wifi_channel=ch)
bridge.set_uplink_handler(handle_bridge_uplink)
bridge.start()
bridge = get_bridge(settings)
set_bridge(bridge)
bridge_mode = str(settings.get("bridge_transport") or "wifi").strip().lower()
if bridge_mode == "wifi":
ws_url = str(settings.get("bridge_ws_url") or "").strip()
if ws_url:
try:
ch = int(settings.get("wifi_channel", WIFI_CHANNEL_DEFAULT))
except (TypeError, ValueError):
ch = WIFI_CHANNEL_DEFAULT
ws_client = init_bridge_client(ws_url, wifi_channel=ch)
ws_client.set_uplink_handler(handle_bridge_uplink)
ws_client.start()
set_bridge(BridgeWsTransport())
elif bridge_mode == "serial":
serial_port = str(settings.get("bridge_serial_port") or "").strip()
if serial_port:
baud = 115200
for prof in settings.get("bridges") or []:
if not isinstance(prof, dict):
continue
if str(prof.get("transport") or "").strip().lower() != "serial":
continue
if str(prof.get("serial_port") or "").strip() != serial_port:
continue
try:
baud = int(prof.get("serial_baudrate") or baud)
except (TypeError, ValueError):
pass
break
else:
try:
baud = int(settings.get("bridge_serial_baudrate") or baud)
except (TypeError, ValueError):
pass
serial_client = init_bridge_serial_client(serial_port, baudrate=baud)
serial_client.set_uplink_handler(handle_bridge_uplink)
serial_client.start()
set_bridge(BridgeSerialTransport())
app = Microdot()
audio_detector = AudioBeatDetector()
@@ -63,7 +102,8 @@ async def main(port=80):
persisted = read_audio_run_state()
if persisted.get("enabled"):
dev = coerce_audio_device(persisted.get("device"))
sel = persisted.get("device_select") or persisted.get("device")
dev = coerce_audio_device(sel)
audio_detector.start(device=dev)
print("[startup] audio beat detector started from saved run state")
except Exception as e:
@@ -101,6 +141,7 @@ async def main(port=80):
app.mount(scene.controller, '/scenes')
app.mount(pattern.controller, '/patterns')
app.mount(settings_controller.controller, '/settings')
app.mount(wifi_bridge_controller.controller, '/settings/wifi')
app.mount(device_controller.controller, '/devices')
app.mount(led_tool_controller.controller, '/led-tool')
@@ -163,12 +204,13 @@ async def main(port=80):
device = payload.get("device", None)
if device in ("", None):
device = None
else:
try:
device = int(device)
except (TypeError, ValueError):
pass
device_select = str(payload.get("device_select") or "").strip()
if not device_select and device not in ("", None):
device_select = str(device).strip()
try:
from util.pulse_audio_devices import resolve_capture_device
device = resolve_capture_device(device)
audio_detector.start(device=device)
from util.audio_run_persist import write_audio_run_state
@@ -176,12 +218,31 @@ async def main(port=80):
enabled=True,
device=device,
device_override=str(payload.get("device_override") or ""),
device_select=str(payload.get("device_select") or ""),
device_select=device_select,
)
return {"ok": True, "status": audio_detector.status()}
except Exception as e:
return {"ok": False, "error": str(e)}, 500
@app.route('/api/audio/device', methods=['PUT'])
async def audio_set_device(request):
"""Save preferred input device without toggling run state."""
payload = request.json if isinstance(request.json, dict) else {}
device_select = str(payload.get("device_select") or "").strip()
device_override = str(payload.get("device_override") or "").strip()
raw = device_override if device_override else device_select
device = raw if raw else None
from util.audio_run_persist import read_audio_run_state, write_audio_run_state
prev = read_audio_run_state()
write_audio_run_state(
enabled=bool(prev.get("enabled")),
device=device if raw else None,
device_override=device_override,
device_select=device_select,
)
return {"ok": True, "audio_run": read_audio_run_state()}
@app.route('/api/audio/stop', methods=['POST'])
async def audio_stop(request):
_ = request
@@ -247,6 +308,11 @@ async def main(port=80):
from util.audio_run_persist import read_audio_run_state
st["beat_phase_ms"] = int(settings.get("audio_beat_phase_ms") or 0)
try:
st["input_volume"] = int(settings.get("audio_input_volume") or 100)
except (TypeError, ValueError):
st["input_volume"] = 100
st["input_volume"] = max(0, min(200, st["input_volume"]))
seq_wait = str(settings.get("sequence_switch_wait") or "beat").strip().lower()
if seq_wait not in ("beat", "downbeat"):
seq_wait = "beat"
@@ -273,11 +339,11 @@ async def main(port=80):
break
try:
if isinstance(data, (bytes, bytearray)):
await sender.send(bytes(data))
await bridge.send(bytes(data))
continue
parsed = json.loads(data)
addr = parsed.pop("to", None)
await sender.send(parsed, addr=addr)
await bridge.send(parsed, addr=addr)
except json.JSONDecodeError:
pass
except Exception:

View File

@@ -0,0 +1,199 @@
"""Persistent USB/serial client to the ESP-NOW bridge."""
from __future__ import annotations
import asyncio
import json
from typing import Awaitable, Callable, Optional, Union
import serial
import serial_asyncio
from util.bridge_serial_frame import feed_serial_buffer, pack_serial_frame
from util.espnow_wire import parse_ws_frame
UplinkHandler = Callable[[bytes, bytes], Awaitable[None]]
class BridgeSerialClient:
def __init__(
self,
port: str,
*,
baudrate: int = 921600,
reconnect_delay_s: float = 2.0,
):
self._port = str(port or "").strip()
self._baudrate = int(baudrate)
self._reconnect_delay_s = reconnect_delay_s
self._reader: Optional[asyncio.StreamReader] = None
self._writer: Optional[asyncio.StreamWriter] = None
self._send_lock = asyncio.Lock()
self._uplink_handler: Optional[UplinkHandler] = None
self._task: Optional[asyncio.Task] = None
self._read_task: Optional[asyncio.Task] = None
self._connected = asyncio.Event()
self._disconnect_event = asyncio.Event()
self._stop = False
self._read_buf = bytearray()
self._bad_frame_count = 0
def set_uplink_handler(self, handler: Optional[UplinkHandler]) -> None:
self._uplink_handler = handler
def _signal_disconnect(self) -> None:
self._connected.clear()
self._disconnect_event.set()
async def _close_serial(self) -> None:
reader = self._reader
writer = self._writer
self._reader = None
self._writer = None
if writer is not None:
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
async def _read_loop(self) -> None:
try:
while not self._disconnect_event.is_set() and not self._stop:
reader = self._reader
if reader is None:
break
try:
chunk = await reader.read(4096)
except (serial.SerialException, OSError, asyncio.IncompleteReadError) as e:
print(f"[bridge-serial] read error: {e!r}")
break
if not chunk:
await asyncio.sleep(0.01)
continue
frames = feed_serial_buffer(self._read_buf, chunk)
handler = self._uplink_handler
if handler is None:
continue
for frame in frames:
try:
peer, pkt, _bcast = parse_ws_frame(frame)
except ValueError:
self._bad_frame_count += 1
if self._bad_frame_count <= 3:
print(
f"[bridge-serial] ignored frame ({len(frame)} B), "
f"expected ws uplink header"
)
continue
self._bad_frame_count = 0
await handler(peer, pkt)
except asyncio.CancelledError:
raise
finally:
self._signal_disconnect()
async def run_forever(self) -> None:
while not self._stop:
try:
await self._connect_once()
except asyncio.CancelledError:
raise
except Exception as e:
print(f"[bridge-serial] connection error: {e!r}")
self._signal_disconnect()
self._disconnect_event.clear()
await self._close_serial()
if self._stop:
break
print("[bridge-serial] disconnected, reconnecting...")
await asyncio.sleep(self._reconnect_delay_s)
async def _connect_once(self) -> None:
if not self._port:
raise serial.SerialException("serial port not configured")
print(f"[bridge-serial] opening {self._port!r} @ {self._baudrate}")
self._read_buf.clear()
self._disconnect_event.clear()
reader, writer = await serial_asyncio.open_serial_connection(
url=self._port,
baudrate=self._baudrate,
exclusive=True,
)
self._reader = reader
self._writer = writer
self._connected.set()
self._read_task = asyncio.create_task(self._read_loop())
print("[bridge-serial] connected")
try:
await self._disconnect_event.wait()
finally:
read_task = self._read_task
self._read_task = None
if read_task is not None:
read_task.cancel()
try:
await read_task
except asyncio.CancelledError:
pass
await self._close_serial()
async def wait_connected(self, timeout: float = 30.0) -> bool:
try:
await asyncio.wait_for(self._connected.wait(), timeout=timeout)
writer = self._writer
return writer is not None and not writer.is_closing()
except asyncio.TimeoutError:
return False
async def send_packet(self, packet: Union[bytes, str, dict]) -> bool:
if isinstance(packet, dict):
packet = json.dumps(packet, separators=(",", ":"))
if isinstance(packet, str):
packet = packet.encode("utf-8")
if not await self.wait_connected(timeout=30.0):
return False
writer = self._writer
if writer is None or writer.is_closing():
return False
frame = pack_serial_frame(bytes(packet))
async with self._send_lock:
try:
writer = self._writer
if writer is None or writer.is_closing():
return False
writer.write(frame)
await writer.drain()
return True
except (serial.SerialException, OSError, ConnectionError) as e:
print(f"[bridge-serial] send failed: {e!r}")
self._signal_disconnect()
return False
def start(self) -> asyncio.Task:
self._stop = False
if self._task is None or self._task.done():
self._task = asyncio.create_task(self.run_forever())
return self._task
def stop(self) -> None:
self._stop = True
self._signal_disconnect()
task = self._task
if task is not None and not task.done():
task.cancel()
_client: Optional[BridgeSerialClient] = None
def get_bridge_serial_client() -> Optional[BridgeSerialClient]:
return _client
def init_bridge_serial_client(port: str, *, baudrate: int = 921600) -> BridgeSerialClient:
global _client
if _client is not None:
_client.stop()
_client = BridgeSerialClient(port, baudrate=baudrate)
return _client

View File

@@ -9,13 +9,16 @@ from typing import Awaitable, Callable, Optional, Union
import websockets
from websockets.exceptions import ConnectionClosed
from settings import WIFI_CHANNEL_DEFAULT
from util.espnow_wire import parse_ws_frame
UplinkHandler = Callable[[bytes, bytes], Awaitable[None]]
class BridgeWsClient:
def __init__(self, url: str, *, wifi_channel: int = 1, reconnect_delay_s: float = 2.0):
def __init__(
self, url: str, *, wifi_channel: int = WIFI_CHANNEL_DEFAULT, reconnect_delay_s: float = 2.0
):
self._url = url.strip()
self._wifi_channel = wifi_channel
self._reconnect_delay_s = reconnect_delay_s
@@ -25,6 +28,7 @@ class BridgeWsClient:
self._task: Optional[asyncio.Task] = None
self._connected = asyncio.Event()
self._disconnect_event = asyncio.Event()
self._stop = False
def set_uplink_handler(self, handler: Optional[UplinkHandler]) -> None:
self._uplink_handler = handler
@@ -43,7 +47,7 @@ class BridgeWsClient:
pass
async def run_forever(self) -> None:
while True:
while not self._stop:
try:
await self._connect_once()
except asyncio.CancelledError:
@@ -53,6 +57,8 @@ class BridgeWsClient:
self._signal_disconnect()
self._disconnect_event.clear()
await self._close_ws()
if self._stop:
break
print("[bridge] disconnected, reconnecting...")
await asyncio.sleep(self._reconnect_delay_s)
@@ -136,10 +142,18 @@ class BridgeWsClient:
return await self.send_packet(packet)
def start(self) -> asyncio.Task:
self._stop = False
if self._task is None or self._task.done():
self._task = asyncio.create_task(self.run_forever())
return self._task
def stop(self) -> None:
self._stop = True
self._signal_disconnect()
task = self._task
if task is not None and not task.done():
task.cancel()
_client: Optional[BridgeWsClient] = None
@@ -148,7 +162,9 @@ def get_bridge_client() -> Optional[BridgeWsClient]:
return _client
def init_bridge_client(url: str, *, wifi_channel: int = 1) -> BridgeWsClient:
def init_bridge_client(url: str, *, wifi_channel: int = WIFI_CHANNEL_DEFAULT) -> BridgeWsClient:
global _client
if _client is not None:
_client.stop()
_client = BridgeWsClient(url, wifi_channel=wifi_channel)
return _client

View File

@@ -38,29 +38,6 @@ def normalize_mac(mac):
return None
def resolve_device_mac_for_select_routing(devices, name_key):
"""
Map a v1 ``select`` map key to device storage id (MAC).
Matches the registry **name**, or ``led-<12hex>`` as a MAC hint (default driver
name form) so routing still works after the device is renamed in the registry.
"""
k = str(name_key or "").strip()
if not k:
return None
for did in devices.list():
doc = devices.read(did) or {}
if str(doc.get("name") or "").strip() == k:
m = normalize_mac(did)
if m:
return m
if k.startswith("led-"):
m = normalize_mac(k[4:])
if m and devices.read(m):
return m
return None
def derive_device_mac(mac=None, address=None, transport="espnow"):
"""
Resolve the device MAC used as storage id.

View File

@@ -54,6 +54,9 @@ class Group(Model):
if "output_brightness" not in doc:
doc["output_brightness"] = 255
changed = True
if "bridge_id" not in doc:
doc["bridge_id"] = None
changed = True
return changed
def create(self, name=""):
@@ -66,6 +69,7 @@ class Group(Model):
"wifi_color_order": None,
"wifi_startup_mode": None,
"output_brightness": 255,
"bridge_id": None,
"pattern": "on",
"colors": ["000000", "FF0000"],
"brightness": 100,

View File

@@ -1,8 +1,9 @@
"""Transport to LED drivers via ESP-NOW bridge WebSocket."""
"""Transport to LED drivers via ESP-NOW bridge WebSocket or USB serial."""
import json
from typing import Any, Dict, List, Optional, Union
from models.bridge_serial_client import get_bridge_serial_client
from models.bridge_ws_client import get_bridge_client
from util.bridge_envelope import (
BROADCAST_HEX,
@@ -15,14 +16,14 @@ from util.bridge_envelope import (
from util.espnow_wire import WIRE_MAGIC
class NullSender:
class NullBridge:
"""No bridge configured."""
async def send(self, data, addr=None):
return True
return False
class BridgeWsSender:
class BridgeWsTransport:
"""Send v1 JSON or devices envelope via bridge WebSocket."""
async def send(self, data: Union[bytes, str, Dict[str, Any]], addr=None) -> bool:
@@ -73,6 +74,57 @@ class BridgeWsSender:
return await client.send_packet(envelope)
class BridgeSerialTransport:
"""Send v1 JSON or devices envelope via bridge USB/serial."""
async def send(self, data: Union[bytes, str, Dict[str, Any]], addr=None) -> bool:
client = get_bridge_serial_client()
if client is None:
return False
if isinstance(data, dict):
if data.get("v") == "1" and ("devices" in data or "dv" in data):
from util.v1_wire import compact_envelope
return await client.send_packet(compact_envelope(data))
packet = json.dumps(data, separators=(",", ":")).encode("utf-8")
elif isinstance(data, str):
packet = data.encode("utf-8")
elif isinstance(data, (bytes, bytearray)):
packet = bytes(data)
else:
return False
if not packet:
return False
if packet[0] == WIRE_MAGIC:
return await client.send_packet(packet)
if packet[0:1] != b"{":
return False
mac_key = _addr_to_envelope_key(addr)
if mac_key is None:
return await client.send_packet(packet)
try:
body = json.loads(packet.decode("utf-8"))
except (UnicodeError, ValueError, TypeError):
return False
if not isinstance(body, dict) or body.get("v") != "1":
return False
envelope = build_devices_envelope({mac_key: body})
return await client.send_packet(envelope)
async def send_envelope(self, envelope: Dict[str, Any]) -> bool:
client = get_bridge_serial_client()
if client is None:
return False
return await client.send_packet(envelope)
def _addr_to_envelope_key(addr) -> Optional[str]:
if addr is None:
return BROADCAST_MAC
@@ -88,24 +140,32 @@ def _addr_to_envelope_key(addr) -> Optional[str]:
return None
_current_sender = None
_current_bridge = None
def set_sender(sender):
global _current_sender
_current_sender = sender
def set_bridge(bridge):
global _current_bridge
_current_bridge = bridge
def get_current_sender():
return _current_sender
def get_current_bridge():
return _current_bridge
def get_sender(settings):
url = str(settings.get("bridge_ws_url") or "").strip()
if not url:
def get_bridge(settings):
mode = str(settings.get("bridge_transport") or "wifi").strip().lower()
if mode == "wifi":
url = str(settings.get("bridge_ws_url") or "").strip()
if not url:
print("[startup] bridge WiFi disabled (set bridge_ws_url in settings.json)")
return NullBridge()
print(f"[startup] ESP-NOW via bridge WebSocket {url!r}")
return BridgeWsTransport()
port = str(settings.get("bridge_serial_port") or "").strip()
if not port:
print(
"[startup] bridge disabled (set bridge_ws_url in settings.json, e.g. ws://192.168.4.1/ws)"
"[startup] bridge serial disabled (set bridge_serial_port in settings.json)"
)
return NullSender()
print(f"[startup] ESP-NOW via bridge WebSocket {url!r} (devices envelope)")
return BridgeWsSender()
return NullBridge()
print(f"[startup] ESP-NOW via bridge USB serial {port!r}")
return BridgeSerialTransport()

View File

@@ -183,9 +183,9 @@ async def send_json_line_to_ip(ip: str, json_str: str) -> bool:
async def _recv_forward_loop(ip: str, ws) -> None:
from models.transport import get_current_sender
from models.transport import get_current_bridge
sender = get_current_sender()
bridge = get_current_bridge()
async for message in ws:
if isinstance(message, bytes):
try:
@@ -199,13 +199,13 @@ async def _recv_forward_loop(ip: str, ws) -> None:
if not text:
continue
print(f"[WS] recv {ip}: {text}")
if not sender:
if not bridge:
continue
try:
parsed = json.loads(text)
except json.JSONDecodeError:
try:
await sender.send(text)
await bridge.send(text)
except Exception:
pass
continue
@@ -213,12 +213,12 @@ async def _recv_forward_loop(ip: str, ws) -> None:
addr = parsed.pop("to", None)
payload = json.dumps(parsed) if parsed else "{}"
try:
await sender.send(payload, addr=addr)
await bridge.send(payload, addr=addr)
except Exception as e:
print(f"[WS] forward to bridge failed: {e}")
else:
try:
await sender.send(text)
await bridge.send(text)
except Exception:
pass

View File

@@ -2,6 +2,8 @@ import json
import os
import binascii
WIFI_CHANNEL_DEFAULT = 5
def _settings_path():
"""Path to settings.json in project root (writable without root)."""
@@ -51,10 +53,20 @@ class Settings(dict):
self.save()
# ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 111
if 'wifi_channel' not in self:
self['wifi_channel'] = 1
self['wifi_channel'] = WIFI_CHANNEL_DEFAULT
# WebSocket URL of ESP-NOW bridge (Pi is client), e.g. ws://192.168.4.1/ws
if 'bridge_ws_url' not in self:
self['bridge_ws_url'] = ''
if 'wifi_interface' not in self:
self['wifi_interface'] = ''
if 'bridges' not in self:
self['bridges'] = []
if 'bridge_transport' not in self:
self['bridge_transport'] = 'serial'
if 'bridge_serial_port' not in self:
self['bridge_serial_port'] = ''
if 'bridge_serial_baudrate' not in self:
self['bridge_serial_baudrate'] = 115200
# Zone UI global brightness (0255); shared across browsers/devices.
if 'global_brightness' not in self:
self['global_brightness'] = 255
@@ -66,6 +78,9 @@ class Settings(dict):
# 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
# Input gain for beat detection (percent, 0200).
if 'audio_input_volume' not in self:
self['audio_input_volume'] = 100
def save(self):
try:

View File

@@ -2,7 +2,6 @@
let pollTimer = null;
let audioDetectorRunning = false;
let lastBeatSeq = 0;
let lastLoggedSequenceBeatFractions = "";
/** Prior poll had server zone sequence playback active (`status.sequence.active === true`). */
let prevZoneSequencePlaybackActive = false;
/**
@@ -10,10 +9,11 @@
* next beat bumps `beat_seq` (avoids the stuck final cumulative value vs sequence readout).
*/
let headerBeatStickyIdleAfterSeq = false;
/** Suppresses duplicate `console.log` when the same `beat_seq` + server `beat_readout` repeats. */
let lastBeatConsoleKey = "";
/** @type {Set<ReturnType<typeof setTimeout>>} */
const pendingBeatPhaseTimers = new Set();
let cachedBeatPhaseMs = 0;
/** @type {{ device: string|number|null, device_override: string, device_select: string }} */
let cachedAudioRun = { device: null, device_override: "", device_select: "" };
function el(id) {
return document.getElementById(id);
@@ -28,40 +28,11 @@
}
}
/**
* On each new audio `beat_seq`, log server `beat_readout` once (deduped when poll repeats the
* same `beat_seq` + line).
* @param {Record<string, unknown>} status
*/
function logServerBeatConsoleOnPollEdge(status) {
const beatSeq = Number((status && status.beat_seq) || 0);
const line = String((status && status.beat_readout) || "").trim();
const key = `${beatSeq}\t${line}`;
if (key !== lastBeatConsoleKey) {
lastBeatConsoleKey = key;
if (!line) return;
const seq = /** @type {Record<string, unknown>|undefined} */ (status && status.sequence);
const seqBeats = !!seq && !!seq.active;
let out = line;
if (seqBeats) {
const nLanes = Number(seq && seq.num_lanes);
const lanesNote =
Number.isFinite(nLanes) && nLanes > 1
? `lane 1 of ${nLanes} (readout is for this lane only)`
: "lane 1";
out = `${line}${lanesNote}`;
}
console.log(out);
}
}
function updateBpmDisplay(bpm) {
const node = el("audio-bpm-value");
if (!node) return;
node.textContent = Number.isFinite(bpm) ? bpm.toFixed(1) : "--";
const topNode = el("audio-top-bpm-value");
if (topNode) {
topNode.textContent = Number.isFinite(bpm) ? bpm.toFixed(1) : "--";
const text = Number.isFinite(bpm) ? bpm.toFixed(1) : "--";
for (const id of ["audio-bpm-value", "audio-top-bpm-value"]) {
const node = el(id);
if (node) node.textContent = text;
}
}
@@ -73,38 +44,6 @@
return !!(seq && seq.active);
}
/** Build sequence beat fractions for debug logging (browser console only). */
function formatSequenceBeatFractionsForLog(status) {
const seq = /** @type {Record<string, unknown>|undefined} */ (status && status.sequence);
if (!seq || !seq.active) return null;
const laneBeatAt = Number(seq.lane0_beat_in_step);
const laneBeatsPerStep = Number(seq.lane0_beats_per_step);
if (
!Number.isFinite(laneBeatAt) ||
laneBeatAt <= 0 ||
!Number.isFinite(laneBeatsPerStep) ||
laneBeatsPerStep <= 0
) {
return null;
}
const presetFraction = `${Math.floor(laneBeatAt)}/${Math.floor(laneBeatsPerStep)}`;
const sequenceBeatAt = Number(seq.sequence_beat_at);
const sequenceBeatsPerPass = Number(seq.sequence_beats_per_pass);
if (
!Number.isFinite(sequenceBeatAt) ||
sequenceBeatAt <= 0 ||
!Number.isFinite(sequenceBeatsPerPass) ||
sequenceBeatsPerPass <= 0
) {
return null;
}
const sequenceFraction = `${Math.floor(sequenceBeatAt)}/${Math.floor(sequenceBeatsPerPass)}`;
return `${presetFraction} ${sequenceFraction}`;
}
function updateHitTypeDisplay(hitType, confidence) {
const node = el("audio-hit-type-value");
if (!node) return;
@@ -136,11 +75,9 @@
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;
}
function setResetDetectorEnabled(on) {
const btn = el("audio-reset-btn");
if (btn) btn.disabled = !on;
}
async function resetAudioTracking() {
@@ -160,20 +97,21 @@
}
}
function beatSyncButtonTitle(zoneSeqActive) {
if (!audioDetectorRunning) return "Start beat detection";
if (zoneSeqActive) return "Sync step to music (S)";
return "Beat detection running";
}
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 disabled = audioDetectorRunning && !zoneSeqActive;
const title = beatSyncButtonTitle(zoneSeqActive);
for (const id of ["audio-top-beat-sync", "audio-modal-beat-sync"]) {
const btn = el(id);
if (!btn) continue;
btn.disabled = disabled;
btn.title = title;
}
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() {
@@ -212,17 +150,41 @@
return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable;
}
function flashBeatSyncButton(btn) {
if (!btn) return;
btn.classList.add("flash");
setTimeout(() => btn.classList.remove("flash"), 90);
}
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 (syncBtn && top && top.classList.contains("audio-running")) {
syncBtn.classList.add("flash");
setTimeout(() => syncBtn.classList.remove("flash"), 90);
const topSync = el("audio-top-beat-sync");
if (topSync && top && top.classList.contains("audio-running")) {
flashBeatSyncButton(topSync);
}
const modalSync = el("audio-modal-beat-sync");
if (modalSync && audioDetectorRunning) {
flashBeatSyncButton(modalSync);
}
}
function gainPercentToDb(pct) {
const gain = Math.max(0.001, pct / 100);
return 20 * Math.log10(gain);
}
function formatGainReadout(pct) {
const db = gainPercentToDb(pct);
const dbText = db >= 0 ? `+${db.toFixed(2)}` : db.toFixed(2);
return `${pct}% (${dbText} dB)`;
}
function updateInputLevelDisplay(level) {
const pct = Number.isFinite(level) ? Math.round(Math.min(1, Math.max(0, level)) * 100) : 0;
const bar = el("audio-input-level-bar");
const meter = el("audio-modal")?.querySelector(".audio-input-level-meter");
if (bar) bar.style.width = `${pct}%`;
if (meter) meter.setAttribute("aria-valuenow", String(pct));
}
function clearBeatPhaseTimers() {
@@ -231,24 +193,38 @@
}
function getBeatPhaseDelayMs() {
const inp = el("audio-beat-phase-ms");
if (inp && String(inp.value).trim() !== "") {
const n = parseInt(String(inp.value).trim(), 10);
if (Number.isFinite(n)) return Math.min(500, Math.max(0, n));
}
return 0;
return Math.min(500, Math.max(0, cachedBeatPhaseMs));
}
async function persistBeatPhaseMs() {
const ms = getBeatPhaseDelayMs();
function getInputVolumePercent() {
const inp = el("audio-input-volume");
if (!inp) return 100;
const n = parseInt(String(inp.value).trim(), 10);
if (!Number.isFinite(n)) return 100;
return Math.min(200, Math.max(0, n));
}
function updateInputVolumeReadout() {
const readout = el("audio-input-volume-readout");
const slider = el("audio-input-volume");
const pct = getInputVolumePercent();
if (readout) readout.textContent = formatGainReadout(pct);
if (slider) {
slider.style.setProperty("--audio-volume-pct", `${(pct / 200) * 100}%`);
}
}
async function persistInputVolume() {
const vol = getInputVolumePercent();
updateInputVolumeReadout();
try {
await fetch("/settings", {
method: "PUT",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ audio_beat_phase_ms: ms }),
body: JSON.stringify({ audio_input_volume: vol }),
});
} catch (e) {
console.warn("beat phase ms save failed", e);
console.warn("input volume save failed", e);
}
}
@@ -277,7 +253,7 @@
async function stopAudioOnly() {
audioDetectorRunning = false;
setTopBpmVisible(false);
setNavResetVisible(false);
setResetDetectorEnabled(false);
clearBeatPhaseTimers();
if (pollTimer) {
clearInterval(pollTimer);
@@ -286,8 +262,8 @@
lastBeatSeq = 0;
prevZoneSequencePlaybackActive = false;
headerBeatStickyIdleAfterSeq = false;
lastBeatConsoleKey = "";
updateBeatReadoutDisplays({});
updateInputLevelDisplay(0);
try {
await fetch("/api/audio/stop", { method: "POST" });
} catch (e) {
@@ -313,8 +289,9 @@
updateBeatReadoutDisplays({});
audioDetectorRunning = !!status.running;
updateBpmDisplay(null);
updateInputLevelDisplay(0);
setTopBpmVisible(!!status.running);
setNavResetVisible(!!status.running);
setResetDetectorEnabled(!!status.running);
if (!status.running && pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
@@ -324,11 +301,14 @@
audioDetectorRunning = !!status.running;
const zoneSeqActive = sequencePlaybackActiveFromStatus(status);
setTopBpmVisible(!!status.running || zoneSeqActive);
setNavResetVisible(!!status.running);
setResetDetectorEnabled(!!status.running);
updateSequenceSyncControls(zoneSeqActive);
updateBpmDisplay(status.bpm);
updateHitTypeDisplay(status.beat_type, Number(status.beat_type_confidence));
updateBarPhaseDisplay(status);
updateInputLevelDisplay(
status.running ? Number(status.input_level) : 0,
);
applyServerAudioUiFields(status);
if (typeof window.applySequenceSwitchWaitFromServer === "function") {
window.applySequenceSwitchWaitFromServer(status.sequence_switch_wait);
@@ -344,7 +324,6 @@
prevZoneSequencePlaybackActive = zoneSeqActive;
if (startedSeq) {
headerBeatStickyIdleAfterSeq = false;
lastLoggedSequenceBeatFractions = "";
}
if (endedSeq) {
headerBeatStickyIdleAfterSeq = true;
@@ -354,38 +333,137 @@
if (!zoneSeqActive && headerBeatStickyIdleAfterSeq) {
if (beatSeq > lastBeatSeq) {
lastBeatSeq = beatSeq;
logServerBeatConsoleOnPollEdge(status);
scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs());
headerBeatStickyIdleAfterSeq = false;
}
} else if (beatSeq > lastBeatSeq) {
lastBeatSeq = beatSeq;
logServerBeatConsoleOnPollEdge(status);
scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs());
}
const beatFractions = formatSequenceBeatFractionsForLog(status);
if (beatFractions) {
if (beatFractions !== lastLoggedSequenceBeatFractions) {
lastLoggedSequenceBeatFractions = beatFractions;
}
} else {
lastLoggedSequenceBeatFractions = "";
}
updateBeatReadoutDisplays(status);
} catch (e) {
console.warn("audio status poll failed", e);
}
}
async function startAudio() {
/** Ignore server device sync briefly after the user picks from the dropdown. */
let deviceSelectLockUntil = 0;
/** Suppress change handler while rebuilding or programmatically setting the select. */
let suppressDeviceSelectEvents = false;
/** Last explicit UI choice (dropdown); not overwritten by server poll. */
let uiDeviceSelectId = "";
function lockDeviceSelect(ms = 10000) {
deviceSelectLockUntil = Date.now() + ms;
}
function preferredSavedDeviceId() {
return cachedAudioRun.device_select ? String(cachedAudioRun.device_select) : "";
}
function optionIdForSavedDevice(select, savedId) {
const saved = savedId == null ? "" : String(savedId);
if (!saved || !select) return "";
if (selectHasDeviceOptionId(select, saved)) return saved;
if (!/^-?\d+$/.test(saved)) return "";
for (const opt of select.options) {
if (String(opt.dataset.sdIndex ?? "") === saved) return opt.value;
}
return "";
}
function restoreDeviceSelectAfterRefresh(select, defaultId, restoreId = "") {
const picked = restoreId || getSelectedDeviceId();
if (picked && selectHasDeviceOptionId(select, picked)) {
setSelectedDeviceId(picked);
return;
}
const saved = preferredSavedDeviceId();
const savedId = optionIdForSavedDevice(select, saved) || saved;
if (savedId && selectHasDeviceOptionId(select, savedId)) {
setSelectedDeviceId(savedId);
return;
}
if (defaultId && selectHasDeviceOptionId(select, defaultId)) {
setSelectedDeviceId(defaultId);
return;
}
setSelectedDeviceId("");
}
function getSelectedDeviceId() {
return String(el("audio-device-select")?.value ?? "");
}
function selectHasDeviceOptionId(select, deviceId) {
const id = deviceId == null ? "" : String(deviceId);
return [...select.options].some((opt) => opt.value === id);
}
function audioRunPreferredDeviceId(run) {
return run.device_select ? String(run.device_select) : "";
}
function setSelectedDeviceId(deviceId, { force = false } = {}) {
const id = deviceId == null ? "" : String(deviceId);
const select = el("audio-device-select");
if (!select) return false;
if (id !== "" && !selectHasDeviceOptionId(select, id)) {
if (!force) return false;
}
suppressDeviceSelectEvents = true;
try {
select.value = id;
uiDeviceSelectId = id;
} finally {
suppressDeviceSelectEvents = false;
}
return true;
}
function readDeviceForm() {
return { override: "", selected: getSelectedDeviceId() };
}
async function persistDeviceSelection(deviceId) {
const selected = deviceId != null ? String(deviceId) : getSelectedDeviceId();
uiDeviceSelectId = selected;
cachedAudioRun.device_select = selected;
try {
const res = await fetch("/api/audio/device", {
method: "PUT",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ device_select: selected, device_override: "" }),
});
const data = await res.json().catch(() => ({}));
if (data?.audio_run && typeof data.audio_run === "object") {
const saved = data.audio_run.device_select
? String(data.audio_run.device_select)
: "";
if (saved === selected) {
cachedAudioRun.device_select = saved;
}
}
} catch (e) {
console.warn("device selection save failed", e);
}
}
async function startAudio(deviceId) {
const selected =
deviceId != null && deviceId !== undefined
? String(deviceId)
: uiDeviceSelectId || getSelectedDeviceId();
lockDeviceSelect();
uiDeviceSelectId = selected;
cachedAudioRun.device_select = selected;
await stopAudioOnly();
const override = (el("audio-device-override")?.value || "").trim();
const selected = el("audio-device-select")?.value || "";
const rawDevice = override !== "" ? override : selected;
await persistDeviceSelection(selected);
const rawDevice = selected;
const numeric = rawDevice !== "" && /^-?\d+$/.test(rawDevice) ? Number(rawDevice) : rawDevice;
const body = {
device: rawDevice === "" ? null : numeric,
device_override: override,
device_override: "",
device_select: selected,
};
const res = await fetch("/api/audio/start", {
@@ -397,6 +475,8 @@
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "Failed to start audio detector");
}
cachedAudioRun.device_select = selected;
setSelectedDeviceId(selected);
updateBpmDisplay(null);
updateHitTypeDisplay("unknown", NaN);
pollTimer = setInterval(pollStatus, 250);
@@ -405,36 +485,36 @@
async function refreshDevices() {
const select = el("audio-device-select");
const debug = el("audio-devices-debug");
if (!select) return;
const current = select.value;
const res = await fetch("/api/audio/devices");
const data = await res.json();
// Re-read after fetch so a pick during the request is not overwritten by a stale value.
const restoreId = getSelectedDeviceId();
const inputs = Array.isArray(data?.devices) ? data.devices.slice() : [];
if (debug) {
debug.value = JSON.stringify(data?.diagnostics || data, null, 2);
}
inputs.sort((a, b) => {
const am = String(a?.name || "").toLowerCase().includes("monitor");
const bm = String(b?.name || "").toLowerCase().includes("monitor");
if (am !== bm) return am ? -1 : 1;
return Number(a?.id || 0) - Number(b?.id || 0);
});
select.innerHTML = '<option value="">System default input</option>';
select.innerHTML = "";
const defaultOpt = document.createElement("option");
defaultOpt.value = "";
defaultOpt.textContent = "System default input";
select.appendChild(defaultOpt);
let defaultId = "";
inputs.forEach((d, idx) => {
const option = document.createElement("option");
option.value = String(d.id);
option.textContent = d.label || d.name || `Input ${idx + 1}`;
if (d.is_default) {
defaultId = String(d.id);
const opt = document.createElement("option");
opt.value = String(d.id);
const text = d.display_name || d.name || `Input ${idx + 1}`;
opt.textContent = text;
const title = d.label || d.name || "";
if (title && title !== text) opt.title = title;
if (d.sounddevice_index != null && d.sounddevice_index !== "") {
opt.dataset.sdIndex = String(d.sounddevice_index);
}
select.appendChild(option);
select.appendChild(opt);
if (d.is_default) defaultId = String(d.id);
});
if (current) {
select.value = current;
} else if (defaultId) {
select.value = defaultId;
suppressDeviceSelectEvents = true;
try {
restoreDeviceSelectAfterRefresh(select, defaultId, restoreId);
} finally {
suppressDeviceSelectEvents = false;
}
}
@@ -444,7 +524,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 resetBtn = el("audio-reset-btn");
const refreshBtn = el("audio-refresh-btn");
if (!modal || !openBtn) return;
@@ -455,6 +535,8 @@
} catch (e) {
console.warn("audio device refresh failed", e);
}
await loadServerAudioUiFields();
setResetDetectorEnabled(audioDetectorRunning);
});
if (closeBtn) {
closeBtn.addEventListener("click", () => {
@@ -463,9 +545,9 @@
}
if (startBtn) {
startBtn.addEventListener("click", async () => {
const picked = getSelectedDeviceId();
try {
await startAudio();
await refreshDevices();
await startAudio(picked);
} catch (e) {
console.error("audio start failed", e);
alert("Failed to start audio input. Check mic permissions.");
@@ -477,8 +559,8 @@
await stopAudio();
});
}
if (navResetBtn) {
navResetBtn.addEventListener("click", () => resetAudioTracking());
if (resetBtn) {
resetBtn.addEventListener("click", () => resetAudioTracking());
}
if (refreshBtn) {
refreshBtn.addEventListener("click", async () => {
@@ -489,35 +571,38 @@
}
});
}
const phaseInp = el("audio-beat-phase-ms");
if (phaseInp) {
phaseInp.addEventListener("change", () => {
void persistBeatPhaseMs();
});
phaseInp.addEventListener("input", () => {
void persistBeatPhaseMs();
const deviceSelect = el("audio-device-select");
if (deviceSelect) {
deviceSelect.addEventListener("change", async () => {
if (suppressDeviceSelectEvents) return;
const picked = getSelectedDeviceId();
uiDeviceSelectId = picked;
lockDeviceSelect();
cachedAudioRun.device_select = picked;
await persistDeviceSelection(picked);
});
}
const bindSync = (node, mode) => {
if (!node) return;
node.addEventListener("click", async () => {
try {
await syncSequenceBeatPhase(mode);
} catch (e) {
console.warn("sequence beat sync failed", e);
}
const volInp = el("audio-input-volume");
if (volInp) {
volInp.addEventListener("input", () => {
updateInputVolumeReadout();
void persistInputVolume();
});
};
const topBpm = el("audio-top-beat-sync");
if (topBpm) {
topBpm.addEventListener("click", () => {
void handleTopBpmButtonClick();
volInp.addEventListener("change", () => {
updateInputVolumeReadout();
void persistInputVolume();
});
updateInputVolumeReadout();
}
for (const id of ["audio-top-beat-sync", "audio-modal-beat-sync"]) {
const btn = el(id);
if (btn) {
btn.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;
@@ -548,39 +633,50 @@
}
}
/** Apply server-owned audio UI fields from status (device form, beat phase delay). */
/** Apply server-owned audio UI fields from status (volume; device dropdown is user-owned). */
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 && run.device_override != null) ov.value = String(run.device_override);
if (sel && run.device_select) sel.value = String(run.device_select);
cachedAudioRun = {
device: run.device ?? null,
device_override: run.device_override != null ? String(run.device_override) : "",
device_select: run.device_select ? String(run.device_select) : "",
};
}
const phaseInp = el("audio-beat-phase-ms");
if (
phaseInp &&
status.beat_phase_ms != null &&
document.activeElement !== phaseInp
) {
if (status.beat_phase_ms != null) {
const ms = parseInt(String(status.beat_phase_ms), 10);
if (Number.isFinite(ms)) {
phaseInp.value = String(Math.min(500, Math.max(0, ms)));
cachedBeatPhaseMs = Math.min(500, Math.max(0, ms));
}
}
const volInp = el("audio-input-volume");
if (
volInp &&
status.input_volume != null &&
document.activeElement !== volInp
) {
const vol = parseInt(String(status.input_volume), 10);
if (Number.isFinite(vol)) {
volInp.value = String(Math.min(200, Math.max(0, vol)));
updateInputVolumeReadout();
}
}
}
async function loadServerAudioUiFields() {
try {
await refreshDevices();
} catch (e) {
console.warn("audio device list refresh failed", e);
}
try {
const res = await fetch("/api/audio/status", { cache: "no-store" });
const data = await res.json();
applyServerAudioUiFields(data?.status || {});
const status = data?.status || {};
applyServerAudioUiFields(status);
const select = el("audio-device-select");
const saved = audioRunPreferredDeviceId(status.audio_run || {});
if (select && saved && selectHasDeviceOptionId(select, saved)) {
uiDeviceSelectId = saved;
setSelectedDeviceId(saved);
}
updateInputLevelDisplay(status.running ? Number(status.input_level) : 0);
} catch (e) {
console.warn("audio status load failed", e);
}

View File

@@ -18,6 +18,15 @@ const DEVICES_MODAL_POLL_MS = 1000;
let devicesModalLiveTimer = null;
/** Last ESP-NOW ping result per MAC (hex, no separators). Cleared only when a new ping marks offline. */
const espnowPingStatusByMac = new Map();
/** Aggregate ping dot state (Devices / Settings ping buttons). */
let lastEspnowPingAggregate = {
state: 'unknown',
title: 'Not pinged yet',
};
function stopDevicesModalLiveRefresh() {
if (devicesModalLiveTimer != null) {
clearInterval(devicesModalLiveTimer);
@@ -53,11 +62,189 @@ function startDevicesModalLiveRefresh() {
}, DEVICES_MODAL_POLL_MS);
}
const DEVICE_DOT_CLASSES = [
'device-status-dot--online',
'device-status-dot--offline',
'device-status-dot--unknown',
'device-status-dot--pinging',
];
function normalizeDeviceMacKey(mac) {
return String(mac || '')
.trim()
.toLowerCase()
.replace(/[:-]/g, '');
}
function findPingResponse(responses, deviceId) {
if (!responses || typeof responses !== 'object') return null;
const want = normalizeDeviceMacKey(deviceId);
for (const [mac, info] of Object.entries(responses)) {
if (normalizeDeviceMacKey(mac) === want) return info;
}
return null;
}
function setDeviceStatusDot(dot, state, title) {
if (!dot) return;
dot.classList.remove(...DEVICE_DOT_CLASSES);
if (state === 'online') dot.classList.add('device-status-dot--online');
else if (state === 'offline') dot.classList.add('device-status-dot--offline');
else if (state === 'pinging') dot.classList.add('device-status-dot--pinging');
else dot.classList.add('device-status-dot--unknown');
dot.title = title;
dot.setAttribute('aria-label', title);
}
function updatePingStatusDot(dotEl, state, title) {
if (!dotEl) return;
dotEl.classList.remove(...DEVICE_DOT_CLASSES);
if (state === 'online') dotEl.classList.add('device-status-dot--online');
else if (state === 'offline') dotEl.classList.add('device-status-dot--offline');
else if (state === 'pinging') dotEl.classList.add('device-status-dot--pinging');
else dotEl.classList.add('device-status-dot--unknown');
dotEl.title = title;
dotEl.setAttribute('aria-label', title);
}
function rememberEspnowPingAggregate(state, title) {
lastEspnowPingAggregate = { state, title };
}
function applyEspnowPingAggregateToDots() {
for (const id of ['devices-ping-dot']) {
updatePingStatusDot(document.getElementById(id), lastEspnowPingAggregate.state, lastEspnowPingAggregate.title);
}
}
async function runUpdateGroups(btn) {
const statusEl = document.getElementById('devices-groups-status');
const prevLabel = btn ? btn.textContent : '';
if (btn) {
btn.disabled = true;
btn.textContent = 'Updating…';
}
if (statusEl) statusEl.textContent = 'Sending group membership…';
try {
const res = await fetch('/devices/groups', {
method: 'POST',
headers: { Accept: 'application/json' },
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
const err = data.error || 'Update groups failed';
if (statusEl) statusEl.textContent = err;
return;
}
const sent = Number(data.sent) || 0;
const failed = Number(data.failed) || 0;
if (statusEl) {
statusEl.textContent =
failed > 0
? `Sent to ${sent} driver${sent === 1 ? '' : 's'}, ${failed} failed`
: `Sent to ${sent} driver${sent === 1 ? '' : 's'}`;
}
} catch (error) {
if (statusEl) statusEl.textContent = error.message;
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = prevLabel;
}
}
}
async function runEspnowPing({ btn, dot, statusEl } = {}) {
const prevLabel = btn ? btn.textContent : '';
if (btn) {
btn.disabled = true;
btn.textContent = 'Pinging…';
}
updatePingStatusDot(dot, 'pinging', 'Ping in progress…');
if (statusEl) statusEl.textContent = 'Waiting for replies (3 s)…';
applyEspnowPingToDeviceRows(null, 'pinging');
try {
const res = await fetch('/devices/ping', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ timeout_s: 3 }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
const err = data.error || 'Ping failed';
rememberEspnowPingAggregate('offline', err);
updatePingStatusDot(dot, 'offline', err);
applyEspnowPingAggregateToDots();
if (statusEl) statusEl.textContent = err;
return;
}
const count = Object.keys(data.responses || {}).length;
const registered = Number(data.registered) || 0;
const aggState = count > 0 ? 'online' : 'offline';
const aggTitle =
count > 0
? `${count} driver${count === 1 ? '' : 's'} replied`
: 'No drivers replied';
rememberEspnowPingAggregate(aggState, aggTitle);
updatePingStatusDot(dot, aggState, aggTitle);
applyEspnowPingAggregateToDots();
if (statusEl) {
let msg = `${count} response${count === 1 ? '' : 's'}`;
if (registered > 0) {
msg += ` · ${registered} new in list`;
}
statusEl.textContent = msg;
}
await refreshDevicesListQuiet();
applyEspnowPingToDeviceRows(data.responses, 'done');
} catch (error) {
const msg = `Error: ${error.message}`;
rememberEspnowPingAggregate('offline', msg);
updatePingStatusDot(dot, 'offline', msg);
applyEspnowPingAggregateToDots();
if (statusEl) statusEl.textContent = error.message;
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = prevLabel;
}
}
}
function applyEspnowPingToDeviceRows(responses, phase) {
const container = document.getElementById('devices-list-modal');
if (!container) return;
container.querySelectorAll('.profiles-row[data-device-transport="espnow"]').forEach((row) => {
const dot = row.querySelector('.device-status-dot');
if (!dot) return;
if (phase === 'pinging') {
setDeviceStatusDot(dot, 'pinging', 'Ping in progress…');
return;
}
const macKey = normalizeDeviceMacKey(row.dataset.deviceId);
const info = findPingResponse(responses, row.dataset.deviceId);
if (info) {
const rtt = info.rtt_ms != null ? `${info.rtt_ms} ms` : 'ok';
const title = `Ping reply (${rtt})`;
setDeviceStatusDot(dot, 'online', title);
espnowPingStatusByMac.set(macKey, { state: 'online', title });
} else {
const title = 'No ping reply';
setDeviceStatusDot(dot, 'offline', title);
espnowPingStatusByMac.set(macKey, { state: 'offline', title });
}
});
}
function espnowPingStatusForMac(devId) {
return espnowPingStatusByMac.get(normalizeDeviceMacKey(devId)) || null;
}
function updateWifiRowDot(row, connected) {
const dot = row.querySelector('.device-status-dot');
if (!dot) return;
if ((row.dataset.deviceTransport || '') !== 'wifi') return;
dot.classList.remove('device-status-dot--online', 'device-status-dot--offline', 'device-status-dot--unknown');
dot.classList.remove(...DEVICE_DOT_CLASSES);
if (connected) {
dot.classList.add('device-status-dot--online');
dot.title = 'Connected (Wi-Fi TCP session)';
@@ -277,17 +464,16 @@ function renderDevicesList(devices) {
dot.setAttribute('role', 'img');
const live = dev && Object.prototype.hasOwnProperty.call(dev, 'connected') ? dev.connected : null;
if (live === true) {
dot.classList.add('device-status-dot--online');
dot.title = 'Connected (Wi-Fi TCP session)';
dot.setAttribute('aria-label', dot.title);
setDeviceStatusDot(dot, 'online', 'Connected (Wi-Fi TCP session)');
} else if (live === false) {
dot.classList.add('device-status-dot--offline');
dot.title = 'Not connected (no Wi-Fi TCP session)';
dot.setAttribute('aria-label', dot.title);
setDeviceStatusDot(dot, 'offline', 'Not connected (no Wi-Fi TCP session)');
} else {
dot.classList.add('device-status-dot--unknown');
dot.title = 'ESP-NOW — TCP status does not apply';
dot.setAttribute('aria-label', dot.title);
const pingCached = espnowPingStatusForMac(devId);
if (pingCached) {
setDeviceStatusDot(dot, pingCached.state, pingCached.title);
} else {
setDeviceStatusDot(dot, 'unknown', 'ESP-NOW — ping or identify to test reachability');
}
}
const label = document.createElement('span');
@@ -571,6 +757,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (typeof window.getEspnowSocket === 'function') {
window.getEspnowSocket();
}
applyEspnowPingAggregateToDots();
loadDevicesModal();
startDevicesModalLiveRefresh();
});
@@ -581,6 +768,22 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
const devicesPingBtn = document.getElementById('devices-ping-btn');
if (devicesPingBtn) {
devicesPingBtn.addEventListener('click', () => {
runEspnowPing({
btn: devicesPingBtn,
dot: document.getElementById('devices-ping-dot'),
statusEl: document.getElementById('devices-ping-status'),
});
});
}
const devicesUpdateGroupsBtn = document.getElementById('devices-update-groups-btn');
if (devicesUpdateGroupsBtn) {
devicesUpdateGroupsBtn.addEventListener('click', () => runUpdateGroups(devicesUpdateGroupsBtn));
}
const devicesModalEl = document.getElementById('devices-modal');
if (devicesModalEl) {
new MutationObserver(() => {
@@ -658,3 +861,9 @@ document.addEventListener('DOMContentLoaded', () => {
editCloseBtn.addEventListener('click', () => editDeviceModal && editDeviceModal.classList.remove('active'));
}
});
if (typeof window !== 'undefined') {
window.applyEspnowPingToDeviceRows = applyEspnowPingToDeviceRows;
window.runEspnowPing = runEspnowPing;
window.applyEspnowPingAggregateToDots = applyEspnowPingAggregateToDots;
}

View File

@@ -45,7 +45,14 @@ async function fetchDevicesMapForGroups() {
function renderGroupDevicesEditor(containerEl, macRows, devicesMap) {
if (!containerEl) return;
containerEl.innerHTML = '';
const panel =
typeof window.prepareZoneDevicesPanel === 'function'
? window.prepareZoneDevicesPanel(containerEl)
: null;
const listEl = panel ? panel.listEl : containerEl;
if (!panel) {
containerEl.innerHTML = '';
}
const entries = Object.entries(devicesMap || {}).sort(([a], [b]) => a.localeCompare(b));
macRows.forEach((row, idx) => {
@@ -72,7 +79,7 @@ function renderGroupDevicesEditor(containerEl, macRows, devicesMap) {
});
div.appendChild(label);
div.appendChild(rm);
containerEl.appendChild(div);
listEl.appendChild(div);
});
const macsInRows = new Set(macRows.map((r) => r.mac).filter(Boolean));
@@ -101,7 +108,11 @@ function renderGroupDevicesEditor(containerEl, macRows, devicesMap) {
});
addWrap.appendChild(sel);
addWrap.appendChild(addBtn);
containerEl.appendChild(addWrap);
if (panel) {
panel.addSlot.appendChild(addWrap);
} else {
containerEl.appendChild(addWrap);
}
refreshEditGroupDebug();
}
@@ -320,12 +331,6 @@ function renderGroupsList(groups) {
alert(data.error || 'Apply brightness failed');
return;
}
const n = typeof data.sent === 'number' ? data.sent : 0;
alert(
n
? `Sent brightness to ${n} driver(s).`
: 'No WiFi drivers received brightness (check connections).',
);
} catch (err) {
console.error(err);
alert('Apply brightness failed');
@@ -350,12 +355,6 @@ function renderGroupsList(groups) {
alert(data.error || 'Apply failed');
return;
}
const n = typeof data.sent === 'number' ? data.sent : 0;
alert(
n
? `Sent defaults to ${n} driver(s).`
: 'No WiFi drivers received the config (check defaults and connections).',
);
} catch (err) {
console.error(err);
alert('Apply failed');
@@ -421,15 +420,10 @@ async function identifyGroupById(gid) {
alert(data.error || 'Identify failed');
return;
}
const n = typeof data.sent === 'number' ? data.sent : 0;
const errs = Array.isArray(data.errors) ? data.errors : [];
const failed = errs.filter((e) => e && e.error).length;
let msg = n ? `Identify sent to ${n} device(s).` : 'No devices received identify.';
if (failed) {
msg += ` ${failed} failed — see console for details.`;
if (errs.some((e) => e && e.error)) {
console.warn('Group identify errors', errs);
}
alert(msg);
} catch (e) {
console.error(e);
alert('Identify failed');

View File

@@ -41,157 +41,534 @@ document.addEventListener('DOMContentLoaded', () => {
const settingsButton = document.getElementById('settings-btn');
const settingsModal = document.getElementById('settings-modal');
const settingsCloseButton = document.getElementById('settings-close-btn');
const settingsTabButtons = document.querySelectorAll('[data-settings-tab]');
const settingsTabPanels = document.querySelectorAll('[data-settings-panel]');
const ledToolIframe = document.getElementById('led-tool-iframe');
let settingsActiveTab = 'bridge';
const showSettingsMessage = (text, type = 'success') => {
const messageEl = document.getElementById('settings-message');
if (!messageEl) return;
messageEl.textContent = text;
messageEl.className = `message ${type} show`;
setTimeout(() => {
messageEl.classList.remove('show');
}, 5000);
};
async function loadDeviceSettings() {
try {
const response = await fetch('/settings');
const data = await response.json();
const nameInput = document.getElementById('device-name-input');
if (nameInput && data && typeof data === 'object') {
nameInput.value = data.device_name || 'led-controller';
}
const chInput = document.getElementById('wifi-channel-input');
if (chInput && data && typeof data === 'object') {
const ch = data.wifi_channel;
chInput.value =
ch !== undefined && ch !== null && ch !== '' ? String(ch) : '6';
}
} catch (error) {
console.error('Error loading device settings:', error);
function loadLedToolIframe() {
if (!ledToolIframe) return;
const blank = !ledToolIframe.src || ledToolIframe.src === 'about:blank';
if (blank) {
ledToolIframe.src = '/led-tool/editor';
}
}
async function loadAPStatus() {
try {
const response = await fetch('/settings/wifi/ap');
const config = await response.json();
const statusEl = document.getElementById('ap-status');
if (!statusEl) return;
if (config.active) {
statusEl.innerHTML = `
<h4>AP Status: <span class="status-connected">Active</span></h4>
<p><strong>SSID:</strong> ${config.ssid || 'N/A'}</p>
<p><strong>Channel:</strong> ${config.channel || 'Auto'}</p>
<p><strong>IP Address:</strong> ${config.ip || 'N/A'}</p>
`;
} else {
statusEl.innerHTML = `
<h4>AP Status: <span class="status-disconnected">Inactive</span></h4>
<p>Access Point is not currently active</p>
`;
}
if (config.saved_ssid) document.getElementById('ap-ssid').value = config.saved_ssid;
if (config.saved_channel) document.getElementById('ap-channel').value = config.saved_channel;
} catch (error) {
console.error('Error loading AP status:', error);
function unloadLedToolIframe() {
if (ledToolIframe) {
ledToolIframe.src = 'about:blank';
}
}
function switchSettingsTab(tabId) {
if (!tabId) tabId = 'bridge';
settingsActiveTab = tabId;
for (const btn of settingsTabButtons) {
const on = btn.getAttribute('data-settings-tab') === tabId;
btn.classList.toggle('active', on);
btn.setAttribute('aria-selected', on ? 'true' : 'false');
}
for (const panel of settingsTabPanels) {
const on = panel.getAttribute('data-settings-panel') === tabId;
panel.classList.toggle('active', on);
panel.hidden = !on;
}
if (settingsModal) {
settingsModal.classList.toggle('settings-modal--led-tool', tabId === 'led-tool');
}
if (tabId === 'led-tool') {
loadLedToolIframe();
}
}
for (const btn of settingsTabButtons) {
btn.addEventListener('click', () => {
switchSettingsTab(btn.getAttribute('data-settings-tab'));
});
}
window.openSettingsModal = (tabId) => {
if (!settingsModal) return;
if (tabId) {
switchSettingsTab(tabId);
} else {
switchSettingsTab(settingsActiveTab);
}
settingsModal.classList.add('active');
if (!tabId || tabId === 'bridge') {
loadBridgeSettings();
}
};
const bridgeWsStatus = document.getElementById('bridge-ws-status');
const bridgeConnectionDetails = document.getElementById('bridge-connection-details');
const bridgeProfilesList = document.getElementById('bridge-profiles-list');
let lastBridgeSettings = null;
const bridgeSerialPortSelect = document.getElementById('bridge-serial-port');
const bridgeSerialBaudInput = document.getElementById('bridge-serial-baud');
const bridgeSerialConnectBtn = document.getElementById('bridge-serial-connect-btn');
const bridgeSerialSaveProfileBtn = document.getElementById('bridge-serial-save-profile-btn');
const bridgeSerialRefreshBtn = document.getElementById('bridge-serial-refresh-btn');
const bridgeWifiInterfaceSelect = document.getElementById('bridge-wifi-interface');
const bridgeWifiRefreshInterfacesBtn = document.getElementById('bridge-wifi-refresh-interfaces-btn');
const bridgeWifiSsidSelect = document.getElementById('bridge-wifi-ssid');
const bridgeWifiSsidManual = document.getElementById('bridge-wifi-ssid-manual');
const bridgeWifiPassword = document.getElementById('bridge-wifi-password');
const bridgeWifiConnectBtn = document.getElementById('bridge-wifi-connect-btn');
const bridgeWifiSaveProfileBtn = document.getElementById('bridge-wifi-save-profile-btn');
const bridgeWifiScanBtn = document.getElementById('bridge-wifi-scan-btn');
const bridgeWifiApIp = document.getElementById('bridge-wifi-ap-ip');
const bridgeWifiWsPort = document.getElementById('bridge-wifi-ws-port');
function setBridgeWsStatus(text, isError = false) {
if (!bridgeWsStatus) return;
bridgeWsStatus.textContent = text || '';
bridgeWsStatus.style.color = isError ? '#f44336' : '';
}
function connLabel(ok) {
return ok ? 'connected' : 'not connected';
}
function bridgeStatusLine(data) {
if (!data) return '';
const mode = data.bridge_transport === 'serial' ? 'USB serial' : 'WiFi';
const active = data.active_bridge_id
? (data.bridges || []).find((b) => b.id === data.active_bridge_id)
: null;
const activeBit = active ? ` — active profile: ${active.label}` : '';
if (data.bridge_transport === 'wifi' && data.bridge_ws_url) {
return `${mode}: ${data.bridge_ws_url} (${connLabel(data.bridge_connected)})${activeBit}`;
}
if (data.bridge_serial_port) {
return `${mode}: ${data.bridge_serial_port} (${connLabel(data.bridge_connected)})${activeBit}`;
}
return `Bridge ${mode} (${connLabel(data.bridge_connected)})${activeBit}`;
}
function renderBridgeConnectionDetails(data) {
if (!bridgeConnectionDetails) return;
bridgeConnectionDetails.innerHTML = '';
if (!data) return;
const rows = [
['Transport in use', data.bridge_transport === 'serial' ? 'USB serial' : 'WiFi'],
[
'WiFi WebSocket',
data.bridge_ws_url
? `${data.bridge_ws_url} (${connLabel(data.bridge_wifi_connected)})`
: connLabel(false),
],
[
'USB serial',
data.bridge_serial_port
? `${data.bridge_serial_port} (${connLabel(data.bridge_serial_connected)})`
: connLabel(false),
],
];
const active = (data.bridges || []).find((b) => b.id === data.active_bridge_id);
if (active) {
const detail =
active.transport === 'wifi'
? `WiFi ${active.ssid}`
: `USB ${active.serial_port}`;
rows.push(['Active saved profile', `${active.label} (${detail})`]);
} else if (data.bridge_connected) {
rows.push(['Active saved profile', '— (connected, no matching saved profile)']);
}
for (const [k, v] of rows) {
const li = document.createElement('li');
li.textContent = `${k}: ${v}`;
bridgeConnectionDetails.appendChild(li);
}
}
function resolvedBridgeSsid() {
const manual = bridgeWifiSsidManual?.value?.trim();
if (manual) return manual;
return bridgeWifiSsidSelect?.value?.trim() || '';
}
async function loadBridgeSettings() {
try {
const bridgesRes = await fetch('/settings/wifi/bridges');
const bridgesData = await bridgesRes.json().catch(() => ({}));
lastBridgeSettings = bridgesData;
if (bridgeSerialBaudInput && bridgesData.bridge_serial_baudrate) {
bridgeSerialBaudInput.value = String(bridgesData.bridge_serial_baudrate);
}
await loadSerialPorts(bridgesData.bridge_serial_port || '');
await loadWifiInterfaces(bridgesData.wifi_interface || '');
renderBridgeConnectionDetails(bridgesData);
setBridgeWsStatus(bridgeStatusLine(bridgesData));
renderBridgeProfiles(bridgesData.bridges || [], bridgesData);
} catch (err) {
setBridgeWsStatus(err.message, true);
}
}
async function loadWifiInterfaces(selectedDevice) {
if (!bridgeWifiInterfaceSelect) return;
try {
const res = await fetch('/settings/wifi/interfaces');
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
setBridgeWsStatus(data.error || 'WiFi interfaces unavailable', true);
return;
}
const current = selectedDevice || bridgeWifiInterfaceSelect.value;
bridgeWifiInterfaceSelect.innerHTML = '<option value="">— select adapter —</option>';
for (const iface of data.interfaces || []) {
const opt = document.createElement('option');
opt.value = iface.device;
const bits = [iface.device];
if (iface.label && iface.label !== iface.device) bits.push(iface.label);
if (iface.state) bits.push(`(${iface.state})`);
opt.textContent = bits.join(' — ');
bridgeWifiInterfaceSelect.appendChild(opt);
}
if (current) bridgeWifiInterfaceSelect.value = current;
} catch (err) {
setBridgeWsStatus(err.message, true);
}
}
async function scanBridgeWifi() {
const device = bridgeWifiInterfaceSelect?.value?.trim();
if (!device) {
setBridgeWsStatus('Select a WiFi adapter first', true);
return;
}
setBridgeWsStatus('Scanning…');
try {
const res = await fetch(
`/settings/wifi/scan?device=${encodeURIComponent(device)}`
);
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
setBridgeWsStatus(data.error || 'Scan failed', true);
return;
}
if (!bridgeWifiSsidSelect) return;
const prev = resolvedBridgeSsid();
bridgeWifiSsidSelect.innerHTML = '<option value="">— select network —</option>';
for (const net of data.networks || []) {
const opt = document.createElement('option');
opt.value = net.ssid;
opt.textContent = `${net.ssid} (${net.signal}%)`;
bridgeWifiSsidSelect.appendChild(opt);
}
if (prev) {
bridgeWifiSsidSelect.value = prev;
if (!bridgeWifiSsidSelect.value && bridgeWifiSsidManual) {
bridgeWifiSsidManual.value = prev;
}
}
setBridgeWsStatus(`Found ${(data.networks || []).length} network(s)`);
} catch (err) {
setBridgeWsStatus(err.message, true);
}
}
async function loadSerialPorts(selectedPort) {
if (!bridgeSerialPortSelect) return;
try {
const res = await fetch('/led-tool/ports');
const data = await res.json().catch(() => ({}));
const current = selectedPort || bridgeSerialPortSelect.value;
bridgeSerialPortSelect.innerHTML = '<option value="">— select port —</option>';
for (const p of data.ports || []) {
const opt = document.createElement('option');
opt.value = p.device;
opt.textContent = p.description ? `${p.device}${p.description}` : p.device;
bridgeSerialPortSelect.appendChild(opt);
}
if (current) bridgeSerialPortSelect.value = current;
} catch (err) {
setBridgeWsStatus(err.message, true);
}
}
function profileStatusFor(p, data) {
const activeId = data.active_bridge_id || '';
const isActive = Boolean(activeId && p.id === activeId && data.bridge_connected);
if (isActive) {
return { text: 'Connected', className: 'settings-bridge-profile-status--connected' };
}
return { text: 'Not connected', className: 'settings-bridge-profile-status--idle' };
}
async function deleteBridgeProfile(id, label) {
const name = label || id;
if (!window.confirm(`Delete saved bridge profile “${name}”?`)) return;
setBridgeWsStatus('Deleting…');
try {
const res = await fetch(`/settings/wifi/bridges/${encodeURIComponent(id)}`, {
method: 'DELETE',
headers: { Accept: 'application/json' },
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
setBridgeWsStatus(data.error || 'Delete failed', true);
return;
}
setBridgeWsStatus(data.message || 'Profile deleted');
await loadBridgeSettings();
} catch (err) {
setBridgeWsStatus(err.message, true);
}
}
function renderBridgeProfiles(profiles, bridgesData) {
if (!bridgeProfilesList) return;
bridgeProfilesList.innerHTML = '';
const data = bridgesData || lastBridgeSettings || {};
const activeId = data.active_bridge_id || '';
if (!profiles.length) {
bridgeProfilesList.innerHTML = '<li>No saved bridge profiles.</li>';
return;
}
for (const p of profiles) {
const li = document.createElement('li');
const isActive = Boolean(activeId && p.id === activeId && data.bridge_connected);
li.className =
'settings-bridge-profile-row' + (isActive ? ' settings-bridge-profile-row--active' : '');
const main = document.createElement('div');
main.className = 'settings-bridge-profile-main';
const label = document.createElement('span');
label.className = 'settings-bridge-profile-label';
if (p.transport === 'wifi') {
label.textContent = `${p.label} — WiFi ${p.ssid}`;
} else {
label.textContent = `${p.label} — USB ${p.serial_port}`;
}
const status = document.createElement('span');
const st = profileStatusFor(p, data);
status.className = 'settings-bridge-profile-status ' + st.className;
status.textContent = st.text;
main.appendChild(label);
main.appendChild(status);
const actions = document.createElement('div');
actions.className = 'settings-bridge-profile-actions';
const connectBtn = document.createElement('button');
connectBtn.type = 'button';
connectBtn.className = 'btn btn-secondary btn-small';
connectBtn.textContent = 'Connect';
connectBtn.addEventListener('click', () => connectSavedBridge(p.id));
const deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.className = 'btn btn-secondary btn-small settings-bridge-profile-delete';
deleteBtn.textContent = 'Delete';
deleteBtn.addEventListener('click', () => deleteBridgeProfile(p.id, p.label));
actions.appendChild(connectBtn);
actions.appendChild(deleteBtn);
li.appendChild(main);
li.appendChild(actions);
bridgeProfilesList.appendChild(li);
}
}
async function connectSavedBridge(id) {
setBridgeWsStatus('Connecting…');
try {
const res = await fetch(`/settings/wifi/bridges/${encodeURIComponent(id)}/connect`, {
method: 'POST',
headers: { Accept: 'application/json' },
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
setBridgeWsStatus(data.error || 'Connect failed', true);
return;
}
setBridgeWsStatus(data.message ? `${data.message}${bridgeStatusLine(data)}` : bridgeStatusLine(data));
await loadBridgeSettings();
} catch (err) {
setBridgeWsStatus(err.message, true);
}
}
async function connectBridgeWifi(saveProfile) {
const device = bridgeWifiInterfaceSelect?.value?.trim();
const ssid = resolvedBridgeSsid();
const password = bridgeWifiPassword?.value || '';
const apIp = bridgeWifiApIp?.value?.trim() || '192.168.4.1';
const wsPort = parseInt(bridgeWifiWsPort?.value, 10) || 80;
const label = document.getElementById('bridge-wifi-label')?.value?.trim() || ssid;
if (!device) {
setBridgeWsStatus('Select a WiFi adapter', true);
return;
}
if (!ssid) {
setBridgeWsStatus('Enter or select a bridge SSID', true);
return;
}
setBridgeWsStatus('Connecting…');
try {
const res = await fetch('/settings/wifi/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
device,
ssid,
password,
ap_ip: apIp,
ws_port: wsPort,
label,
save_profile: saveProfile,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
setBridgeWsStatus(data.error || 'Connect failed', true);
return;
}
setBridgeWsStatus(data.message ? `${data.message}${bridgeStatusLine(data)}` : bridgeStatusLine(data));
await loadBridgeSettings();
} catch (err) {
setBridgeWsStatus(err.message, true);
}
}
async function connectBridgeSerial(saveProfile) {
const port = bridgeSerialPortSelect ? bridgeSerialPortSelect.value : '';
const baud = parseInt(bridgeSerialBaudInput?.value, 10) || 115200;
const label = document.getElementById('bridge-serial-label')?.value?.trim() || port;
if (!port) {
setBridgeWsStatus('Select a USB serial port', true);
return;
}
setBridgeWsStatus('Connecting…');
try {
const res = await fetch('/settings/wifi/serial/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ port, baudrate: baud, label, save_profile: saveProfile }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
setBridgeWsStatus(data.error || 'Connect failed', true);
return;
}
setBridgeWsStatus(data.message ? `${data.message}${bridgeStatusLine(data)}` : bridgeStatusLine(data));
await loadBridgeSettings();
} catch (err) {
setBridgeWsStatus(err.message, true);
}
}
if (bridgeSerialRefreshBtn) {
bridgeSerialRefreshBtn.addEventListener('click', () => loadSerialPorts());
}
if (bridgeSerialConnectBtn) {
bridgeSerialConnectBtn.addEventListener('click', () => connectBridgeSerial(true));
}
if (bridgeWifiRefreshInterfacesBtn) {
bridgeWifiRefreshInterfacesBtn.addEventListener('click', () => loadWifiInterfaces());
}
if (bridgeWifiScanBtn) {
bridgeWifiScanBtn.addEventListener('click', () => scanBridgeWifi());
}
if (bridgeWifiConnectBtn) {
bridgeWifiConnectBtn.addEventListener('click', () => connectBridgeWifi(true));
}
if (bridgeWifiSaveProfileBtn) {
bridgeWifiSaveProfileBtn.addEventListener('click', async () => {
const device = bridgeWifiInterfaceSelect?.value?.trim();
const ssid = resolvedBridgeSsid();
if (!ssid) {
setBridgeWsStatus('SSID required to save profile', true);
return;
}
const password = bridgeWifiPassword?.value || '';
const apIp = bridgeWifiApIp?.value?.trim() || '192.168.4.1';
const wsPort = parseInt(bridgeWifiWsPort?.value, 10) || 80;
const label = document.getElementById('bridge-wifi-label')?.value?.trim() || ssid;
try {
const res = await fetch('/settings/wifi/bridges');
const data = await res.json().catch(() => ({}));
const bridges = Array.isArray(data.bridges) ? data.bridges : [];
bridges.push({
id: crypto.randomUUID ? crypto.randomUUID().slice(0, 12) : String(Date.now()),
label,
transport: 'wifi',
ssid,
password,
ap_ip: apIp,
ws_port: wsPort,
});
const putRes = await fetch('/settings/wifi/bridges', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ bridges, wifi_interface: device || data.wifi_interface }),
});
const putData = await putRes.json().catch(() => ({}));
if (!putRes.ok || !putData.ok) {
setBridgeWsStatus(putData.error || 'Save failed', true);
return;
}
setBridgeWsStatus('WiFi profile saved');
await loadBridgeSettings();
} catch (err) {
setBridgeWsStatus(err.message, true);
}
});
}
if (bridgeSerialSaveProfileBtn) {
bridgeSerialSaveProfileBtn.addEventListener('click', async () => {
const port = bridgeSerialPortSelect ? bridgeSerialPortSelect.value : '';
if (!port) {
setBridgeWsStatus('Port required to save profile', true);
return;
}
const baud = parseInt(bridgeSerialBaudInput?.value, 10) || 115200;
const label = document.getElementById('bridge-serial-label')?.value?.trim() || port;
try {
const res = await fetch('/settings/wifi/bridges');
const data = await res.json().catch(() => ({}));
const bridges = Array.isArray(data.bridges) ? data.bridges : [];
bridges.push({
id: crypto.randomUUID ? crypto.randomUUID().slice(0, 12) : String(Date.now()),
label,
transport: 'serial',
serial_port: port,
serial_baudrate: baud,
});
const putRes = await fetch('/settings/wifi/bridges', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ bridges }),
});
const putData = await putRes.json().catch(() => ({}));
if (!putRes.ok || !putData.ok) {
setBridgeWsStatus(putData.error || 'Save failed', true);
return;
}
setBridgeWsStatus('Serial profile saved');
await loadBridgeSettings();
} catch (err) {
setBridgeWsStatus(err.message, true);
}
});
}
if (settingsButton && settingsModal) {
settingsButton.addEventListener('click', () => {
switchSettingsTab('bridge');
settingsModal.classList.add('active');
// Load current WiFi status/config when opening
loadDeviceSettings();
loadAPStatus();
loadBridgeSettings();
});
}
if (settingsCloseButton && settingsModal) {
settingsCloseButton.addEventListener('click', () => {
settingsModal.classList.remove('active');
});
}
const deviceForm = document.getElementById('device-form');
if (deviceForm) {
deviceForm.addEventListener('submit', async (e) => {
e.preventDefault();
const nameInput = document.getElementById('device-name-input');
const deviceName = nameInput ? nameInput.value.trim() : '';
if (!deviceName) {
showSettingsMessage('Device name is required', 'error');
return;
}
const chRaw = document.getElementById('wifi-channel-input')
? document.getElementById('wifi-channel-input').value
: '6';
const wifiChannel = parseInt(chRaw, 10);
if (Number.isNaN(wifiChannel) || wifiChannel < 1 || wifiChannel > 11) {
showSettingsMessage('WiFi channel must be between 1 and 11', 'error');
return;
}
try {
const response = await fetch('/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
device_name: deviceName,
wifi_channel: wifiChannel,
}),
});
const result = await response.json();
if (response.ok) {
showSettingsMessage(
'Device settings saved. They will apply on next restart where relevant.',
'success',
);
} else {
showSettingsMessage(`Error: ${result.error || 'Failed to save device name'}`, 'error');
}
} catch (error) {
showSettingsMessage(`Error: ${error.message}`, 'error');
}
});
}
const apForm = document.getElementById('ap-form');
if (apForm) {
apForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = {
ssid: document.getElementById('ap-ssid').value,
password: document.getElementById('ap-password').value,
channel: document.getElementById('ap-channel').value || null,
};
if (formData.password && formData.password.length > 0 && formData.password.length < 8) {
showSettingsMessage('AP password must be at least 8 characters', 'error');
return;
}
if (formData.channel) {
formData.channel = parseInt(formData.channel, 10);
if (formData.channel < 1 || formData.channel > 11) {
showSettingsMessage('Channel must be between 1 and 11', 'error');
return;
}
}
try {
const response = await fetch('/settings/wifi/ap', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
const result = await response.json();
if (response.ok) {
showSettingsMessage('Access Point configured successfully!', 'success');
setTimeout(loadAPStatus, 1000);
} else {
showSettingsMessage(`Error: ${result.error || 'Failed to configure AP'}`, 'error');
}
} catch (error) {
showSettingsMessage(`Error: ${error.message}`, 'error');
}
settingsModal.classList.remove('settings-modal--led-tool');
unloadLedToolIframe();
});
}
});

View File

@@ -1,22 +0,0 @@
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 iframe = document.getElementById('led-tool-iframe');
if (!openBtn || !modal || !iframe) {
return;
}
openBtn.addEventListener('click', () => {
iframe.src = '/led-tool/editor';
modal.classList.add('active');
});
if (closeBtn) {
closeBtn.addEventListener('click', () => {
modal.classList.remove('active');
iframe.src = 'about:blank';
});
}
});

View File

@@ -98,29 +98,8 @@ document.addEventListener('DOMContentLoaded', () => {
: [];
};
const postDriverSequence = async (sequence, targetMacs, delayS = 0.05, pushOptions = {}) => {
if (typeof window.postDriverSequence === 'function') {
return window.postDriverSequence(sequence, targetMacs, delayS, pushOptions);
}
const body = { sequence, delay_s: delayS };
if (pushOptions && pushOptions.unicast === true) {
body.unicast = true;
if (Array.isArray(targetMacs) && targetMacs.length) {
body.targets = [...new Set(targetMacs)];
}
}
const res = await fetch('/presets/push', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err && err.error) || res.statusText || 'Send failed');
}
return res.json().catch(() => ({}));
};
const postDriverSequence = (sequence, targetMacs, delayS, pushOptions) =>
window.postDriverSequence(sequence, targetMacs, delayS, pushOptions);
const nReadableStringFromMeta = (meta, key) => {
if (!meta || typeof meta !== 'object') {

View File

@@ -2134,7 +2134,7 @@ const sendPresetSelectViaEspNow = async (presetId, deviceNames) => {
try {
window.sendPresetViaEspNow = sendPresetViaEspNow;
window.postDriverSequence = postDriverSequence;
// Expose a generic ESPNow sender so other scripts (zones.js) can send
// Expose a generic ESP-NOW bridge helper so other scripts (zones.js) can send
// non-preset messages such as global brightness.
window.sendEspnowRaw = sendEspnowMessage;
window.getEspnowSocket = getEspnowSocket;

View File

@@ -213,6 +213,7 @@ header h1 {
min-width: 0;
}
.audio-beat-sync-btn,
.audio-top-beat-sync {
display: inline-flex;
align-items: center;
@@ -228,11 +229,13 @@ header h1 {
text-align: left;
}
.audio-beat-sync-btn:disabled,
.audio-top-beat-sync:disabled {
cursor: default;
opacity: 0.85;
}
.audio-beat-sync-btn:not(:disabled):hover,
.audio-top-beat-sync:not(:disabled):hover {
border-color: #6a6a6a;
background-color: #2a2a2a;
@@ -313,15 +316,24 @@ header h1 {
text-align: right;
}
.audio-beat-sync-btn.flash,
.audio-top-beat-sync.flash {
background-color: #ff5252;
border-color: #ff8a80;
}
.audio-beat-sync-btn.flash .audio-top-indicator-value,
.audio-beat-sync-btn.flash .audio-top-indicator-label,
.audio-beat-sync-btn.flash .audio-top-beat-readout,
.audio-beat-sync-btn.flash .audio-top-beat-readout::before,
.audio-beat-sync-btn.flash .audio-top-bar-phase,
.audio-beat-sync-btn.flash .audio-top-bar-phase::before,
.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 {
.audio-top-beat-sync.flash .audio-top-beat-readout::before,
.audio-top-beat-sync.flash .audio-top-bar-phase,
.audio-top-beat-sync.flash .audio-top-bar-phase::before {
color: #fff;
}
@@ -854,6 +866,11 @@ body.preset-ui-run .edit-mode-only {
border: 1px solid #757575;
}
.device-status-dot--pinging {
background: #ffb300;
box-shadow: 0 0 6px rgba(255, 179, 0, 0.45);
}
.btn-group {
display: flex;
gap: 0.5rem;
@@ -915,37 +932,107 @@ body.preset-ui-run .edit-mode-only {
margin-top: 1rem;
}
#audio-modal .audio-settings-section .audio-modal-beat-readout {
display: block;
#audio-modal .audio-modal-beat-sync {
width: 100%;
max-width: none;
}
.audio-modal-beat-readout {
#audio-modal .audio-modal-content {
max-width: 640px;
}
.audio-device-block {
margin-bottom: 1rem;
}
.audio-device-select-row {
align-items: stretch;
gap: 0.5rem;
}
.audio-device-select-row select {
flex: 1;
min-width: 10rem;
min-height: 2.25rem;
font-size: 0.85rem;
line-height: 1.35;
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;
min-width: 0;
}
.audio-modal-beat-readout:disabled {
cursor: default;
opacity: 0.55;
#audio-modal .audio-modal-beat-sync {
width: 100%;
}
.audio-modal-beat-readout:not(:disabled):hover {
border-color: #6a6a6a;
background-color: #333;
.audio-volume-block {
margin-top: 0.5rem;
}
.audio-volume-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.audio-volume-header label {
margin: 0;
}
.audio-volume-readout {
font-size: 0.9rem;
color: #e0e0e0;
white-space: nowrap;
}
.audio-volume-slider-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.audio-volume-slider {
--audio-volume-pct: 50%;
--audio-volume-unity: 50%;
flex: 1;
width: 100%;
height: 0.45rem;
margin: 0.35rem 0;
accent-color: #ff9800;
background: transparent;
}
.audio-volume-scale {
position: relative;
display: flex;
justify-content: space-between;
font-size: 0.72rem;
color: #9e9e9e;
margin: 0.15rem 0 0.45rem;
min-height: 1rem;
}
.audio-volume-scale-unity {
position: absolute;
left: var(--audio-volume-unity, 50%);
transform: translateX(-50%);
white-space: nowrap;
}
#audio-modal .audio-volume-block {
--audio-volume-unity: 50%;
}
.audio-input-level-meter {
width: 100%;
height: 0.35rem;
border: none;
border-radius: 2px;
background: #2a2a2a;
overflow: hidden;
}
.audio-input-level-bar {
height: 100%;
width: 0%;
background: #ff9800;
transition: width 60ms linear;
border-radius: 2px;
}
.audio-hit-type-readout {
@@ -1335,8 +1422,7 @@ body.preset-ui-run .edit-mode-only {
/* Header / global dialogs */
#help-modal.active,
#audio-modal.active,
#settings-modal.active,
#led-tool-modal.active {
#settings-modal.active {
z-index: 1080;
}
@@ -1636,7 +1722,35 @@ body.preset-ui-run .edit-mode-only {
font-weight: 600;
}
.zone-devices-editor {
.zone-devices-editor.zone-devices-panel {
display: flex;
flex-direction: column;
gap: 0;
margin-bottom: 0.5rem;
max-height: 14rem;
overflow: hidden;
min-height: 0;
}
.zone-devices-list {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-right: 0.15rem;
}
.zone-devices-add-slot {
flex: 0 0 auto;
padding-top: 0.5rem;
margin-top: 0.35rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
/* Legacy: single container without panel split (prefer zone-devices-panel.js). */
.zone-devices-editor:not(.zone-devices-panel) {
display: flex;
flex-direction: column;
gap: 0.5rem;
@@ -1879,8 +1993,211 @@ body.preset-ui-run .edit-mode-only {
max-width: 900px;
max-height: 90vh;
overflow-y: auto;
}#settings-modal .modal-content > p.muted-text {
margin-bottom: 1rem;
}#settings-modal .settings-section.ap-settings-section {
}
#settings-modal.settings-modal--led-tool .modal-content {
max-width: 960px;
width: 95vw;
}
#settings-modal .settings-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin: 0.75rem 0 1rem;
border-bottom: 1px solid #4a4a4a;
padding-bottom: 0.5rem;
}
#settings-modal .settings-tab-btn {
background: #3a3a3a;
color: #ccc;
border: 1px solid #4a4a4a;
border-radius: 4px 4px 0 0;
padding: 0.45rem 0.85rem;
font-size: 0.95rem;
cursor: pointer;
}
#settings-modal .settings-tab-btn:hover {
color: #fff;
border-color: #6a5acd;
}
#settings-modal .settings-tab-btn.active {
background: #1a1a1a;
color: #fff;
border-color: #6a5acd;
border-bottom-color: #1a1a1a;
margin-bottom: -1px;
}
#settings-modal .settings-tab-panel:not(.active) {
display: none;
}
#settings-modal .settings-led-tool-intro {
margin: 0 0 0.75rem;
}
#settings-modal .settings-led-tool-iframe {
width: 100%;
height: min(75vh, 720px);
border: 1px solid #4a4a4a;
border-radius: 4px;
background: #0b1020;
}
#settings-modal .settings-section.ap-settings-section {
margin-top: 1.5rem;
}
.settings-ping-results {
margin-top: 0.75rem;
min-height: 1.25rem;
}
.settings-ping-list {
list-style: none;
margin: 0;
padding: 0;
font-family: monospace;
font-size: 0.9rem;
}
.settings-ping-list li {
padding: 0.35rem 0;
border-bottom: 1px solid #333;
}
.settings-ping-list li:last-child {
border-bottom: none;
}
.settings-wifi-scan-list {
list-style: none;
padding: 0;
margin: 0.5rem 0 0;
}
.settings-wifi-scan-list li {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 0.35rem;
}
.settings-subheading {
margin: 0 0 0.35rem;
font-size: 1rem;
font-weight: 600;
}
.settings-bridge-connection-details {
list-style: none;
margin: 0 0 0.75rem;
padding: 0;
}
.settings-bridge-connection-details li {
margin: 0.2rem 0;
}
.settings-bridge-profiles {
list-style: none;
padding: 0;
margin: 1rem 0 0;
}
.settings-bridge-profile-item {
margin-bottom: 0.65rem;
padding-bottom: 0.65rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.settings-bridge-profile-item:last-child {
border-bottom: none;
}
.settings-bridge-profile-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.settings-bridge-groups-panel {
margin: 0.5rem 0 0 0.25rem;
padding: 0.5rem 0.75rem;
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
.settings-bridge-groups-hint {
margin: 0 0 0.5rem;
font-size: 0.85rem;
}
.settings-bridge-groups-checkboxes {
display: flex;
flex-direction: column;
gap: 0.35rem;
max-height: 14rem;
overflow-y: auto;
}
.settings-bridge-group-check {
display: flex;
align-items: flex-start;
gap: 0.45rem;
font-size: 0.9rem;
cursor: pointer;
}
.settings-bridge-group-check input:disabled + span {
opacity: 0.55;
}
.settings-bridge-profile-main {
display: flex;
align-items: center;
gap: 0.65rem;
flex: 1;
min-width: 10rem;
flex-wrap: wrap;
}
.settings-bridge-profile-label {
flex: 1 1 auto;
min-width: 8rem;
}
.settings-bridge-profile-status {
font-size: 0.85rem;
white-space: nowrap;
}
.settings-bridge-profile-status--connected {
color: #81c784;
}
.settings-bridge-profile-status--idle {
color: rgba(255, 255, 255, 0.45);
}
.settings-bridge-profile-actions {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.settings-bridge-profile-delete {
opacity: 0.85;
}
.settings-ping-empty {
margin: 0;
font-style: italic;
}

View File

@@ -0,0 +1,27 @@
/**
* Scrollable device/group list with a fixed add row (dropdown + button) below.
* Used by group and zone edit modals.
*/
function prepareZoneDevicesPanel(containerEl) {
if (!containerEl) return null;
let listEl = containerEl.querySelector('.zone-devices-list');
let addSlot = containerEl.querySelector('.zone-devices-add-slot');
if (!listEl) {
containerEl.innerHTML = '';
containerEl.classList.add('zone-devices-panel');
listEl = document.createElement('div');
listEl.className = 'zone-devices-list';
addSlot = document.createElement('div');
addSlot.className = 'zone-devices-add-slot';
containerEl.appendChild(listEl);
containerEl.appendChild(addSlot);
} else {
listEl.innerHTML = '';
addSlot.innerHTML = '';
}
return { listEl, addSlot };
}
if (typeof window !== 'undefined') {
window.prepareZoneDevicesPanel = prepareZoneDevicesPanel;
}

View File

@@ -115,7 +115,7 @@ function sendZoneBrightness(zoneId, value) {
await window.postDriverSequence([{ v: '1', b: val, save: true }], targetMacs, 0);
return;
}
// Fallback to raw websocket sender if presets.js helper isn't available yet.
// Fallback to raw websocket bridge helper if presets.js helper isn't available yet.
if (typeof window.sendEspnowRaw === 'function') {
window.sendEspnowRaw({ v: '1', b: val, save: true });
}
@@ -414,7 +414,14 @@ function rowsToNames(rows) {
function renderZoneGroupsEditor(containerEl, rows, groupsMap) {
if (!containerEl) return;
containerEl.innerHTML = "";
const panel =
typeof window.prepareZoneDevicesPanel === "function"
? window.prepareZoneDevicesPanel(containerEl)
: null;
const listEl = panel ? panel.listEl : containerEl;
if (!panel) {
containerEl.innerHTML = "";
}
const entries = Object.entries(groupsMap || {}).sort(([a], [b]) => a.localeCompare(b));
rows.forEach((row, idx) => {
@@ -441,7 +448,7 @@ function renderZoneGroupsEditor(containerEl, rows, groupsMap) {
});
div.appendChild(label);
div.appendChild(rm);
containerEl.appendChild(div);
listEl.appendChild(div);
});
const idsInRows = new Set(rows.map((r) => String(r.id)));
@@ -470,7 +477,11 @@ function renderZoneGroupsEditor(containerEl, rows, groupsMap) {
});
addWrap.appendChild(sel);
addWrap.appendChild(addBtn);
containerEl.appendChild(addWrap);
if (panel) {
panel.addSlot.appendChild(addWrap);
} else {
containerEl.appendChild(addWrap);
}
}
/** Read zone device names from the presets section (JSON attr preferred; legacy comma list fallback). */
@@ -898,116 +909,6 @@ async function loadZoneContent(zoneId) {
}
}
// Send all presets used by all tabs in the current profile via /presets/send.
async function sendProfilePresets() {
try {
// Load current profile to get its tabs
const profileRes = await fetch('/profiles/current', {
headers: { Accept: 'application/json' },
});
if (!profileRes.ok) {
alert('Failed to load current profile.');
return;
}
const profileData = await profileRes.json();
const profile = profileData.profile || {};
let zoneList = null;
if (Array.isArray(profile.zones)) {
zoneList = profile.zones;
} else if (profile.zones) {
zoneList = [profile.zones];
}
if (!zoneList || zoneList.length === 0) {
if (Array.isArray(profile.zones)) {
zoneList = profile.zones;
} else if (profile.zones) {
zoneList = [profile.zones];
}
}
if (!zoneList || zoneList.length === 0) {
console.warn('sendProfilePresets: no zones found', {
profileData,
profile,
});
}
if (!zoneList.length) {
alert('Current profile has no zones to send presets for.');
return;
}
let totalSent = 0;
let totalMessages = 0;
let zonesWithPresets = 0;
for (const zoneId of zoneList) {
try {
const tabResp = await fetch(`/zones/${zoneId}`, {
headers: { Accept: 'application/json' },
});
if (!tabResp.ok) {
continue;
}
const tabData = await tabResp.json();
let presetIds = [];
if (Array.isArray(tabData.presets_flat)) {
presetIds = tabData.presets_flat;
} else if (Array.isArray(tabData.presets)) {
if (tabData.presets.length && typeof tabData.presets[0] === 'string') {
presetIds = tabData.presets;
} else if (tabData.presets.length && Array.isArray(tabData.presets[0])) {
presetIds = tabData.presets.flat();
}
}
presetIds = (presetIds || []).filter(Boolean);
if (!presetIds.length) {
continue;
}
zonesWithPresets += 1;
const payload = { preset_ids: presetIds };
if (tabData.default_preset) {
payload.default = tabData.default_preset;
}
const gids = Array.isArray(tabData.group_ids)
? tabData.group_ids.map((g) => String(g).trim()).filter((g) => g.length > 0)
: [];
if (gids.length > 0) {
payload.group_ids = gids;
}
const response = await fetch('/presets/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(payload),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const msg = (data && data.error) || `Failed to send presets for zone ${zoneId}.`;
console.warn(msg);
continue;
}
totalSent += typeof data.presets_sent === 'number' ? data.presets_sent : presetIds.length;
totalMessages += typeof data.messages_sent === 'number' ? data.messages_sent : 0;
} catch (e) {
console.error('Failed to send profile presets for zone:', zoneId, e);
}
}
if (!zonesWithPresets) {
alert('No presets to send for the current profile.');
return;
}
const messagesLabel = totalMessages ? totalMessages : '?';
alert(`Sent ${totalSent} preset(s) across ${zonesWithPresets} zone(s) (${messagesLabel} driver send(s)).`);
} catch (error) {
console.error('Failed to send profile presets:', error);
alert('Failed to send profile presets.');
}
}
function tabPresetIdsInOrder(tabData) {
return tabPresetIdsInZoneDoc(tabData);
}
@@ -1380,14 +1281,6 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
// Profile-wide "Send Presets" button in header
const sendProfilePresetsBtn = document.getElementById('send-profile-presets-btn');
if (sendProfilePresetsBtn) {
sendProfilePresetsBtn.addEventListener('click', async () => {
await sendProfilePresets();
});
}
const menuBrightnessSlider = document.getElementById('menu-brightness-slider');
const headerBrightnessSlider = document.getElementById('header-brightness-slider');
(async () => {

View File

@@ -18,7 +18,7 @@
<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)">
<button type="button" id="audio-top-beat-sync" class="audio-beat-sync-btn 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>
@@ -38,10 +38,8 @@
<button class="btn btn-secondary edit-mode-only" id="sequences-btn">Sequences</button>
<button class="btn btn-secondary edit-mode-only" id="patterns-btn">Patterns</button>
<button class="btn btn-secondary edit-mode-only" id="color-palette-btn">Colour Palette</button>
<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 edit-mode-only" id="settings-btn">Settings</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>
@@ -68,10 +66,8 @@
<button type="button" class="edit-mode-only" data-target="sequences-btn">Sequences</button>
<button type="button" class="edit-mode-only" data-target="patterns-btn">Patterns</button>
<button type="button" class="edit-mode-only" data-target="color-palette-btn">Colour Palette</button>
<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" class="edit-mode-only" data-target="settings-btn">Settings</button>
<button type="button" data-target="help-btn">Help</button>
</div>
</div>
@@ -171,7 +167,12 @@
<div class="modal-content">
<h2>Devices</h2>
<div id="devices-list-modal" class="profiles-list"></div>
<div class="modal-actions">
<div class="modal-actions" style="align-items:center;gap:0.75rem;flex-wrap:wrap;">
<button type="button" class="btn btn-secondary" id="devices-ping-btn" title="ESP-NOW broadcast ping (3 s)">Ping drivers</button>
<span id="devices-ping-dot" class="device-status-dot device-status-dot--unknown" role="img" title="Not pinged yet" aria-label="Not pinged yet"></span>
<span id="devices-ping-status" class="muted-text" aria-live="polite"></span>
<button type="button" class="btn btn-secondary" id="devices-update-groups-btn" title="Push group membership from Device groups to all ESP-NOW drivers">Update groups</button>
<span id="devices-groups-status" class="muted-text" aria-live="polite"></span>
<button type="button" class="btn btn-secondary" id="devices-close-btn">Close</button>
</div>
</div>
@@ -607,11 +608,11 @@
<li><strong>Colour Palette</strong>: build profile colours and use <strong>From Palette</strong> in preset editor to add linked colours (badge <strong>P</strong>) that update when palette colours change.</li>
</ul>
<h3>What led-tool does</h3>
<h3>LED Tool (Settings tab)</h3>
<ul>
<li><strong>USB device setup</strong>: updates <code>settings.json</code> on ESP32 drivers over serial (for example name, pin, LED count, Wi-Fi credentials).</li>
<li><strong>Deploy and maintenance</strong>: uploads driver files, flashes firmware, resets device, and follows serial logs.</li>
<li><strong>Scope</strong>: led-tool configures devices directly; this web UI controls profiles/zones/presets and sends runtime messages.</li>
<li><strong>Scope</strong>: led-tool configures devices directly; this web UI controls profiles/zones/presets and sends runtime messages. Open <strong>Settings → LED Tool</strong> in Edit mode.</li>
</ul>
<div class="modal-actions">
@@ -622,138 +623,146 @@
<!-- Audio Modal -->
<div id="audio-modal" class="modal">
<div class="modal-content">
<div class="modal-content audio-modal-content">
<h2>Audio Beat Detection</h2>
<p class="muted-text">Select an input device and start beat detection.</p>
<div class="form-group">
<div class="form-group audio-device-block">
<label for="audio-device-select">Input device</label>
<div class="profiles-actions">
<select id="audio-device-select" style="flex: 1;">
<option value="">Default input</option>
<div class="profiles-actions audio-device-select-row">
<select id="audio-device-select">
<option value="">System default input</option>
</select>
<button type="button" class="btn btn-secondary" id="audio-refresh-btn">Refresh</button>
<button type="button" class="btn btn-secondary btn-small" id="audio-refresh-btn" title="Refresh device list">Refresh</button>
</div>
<small>Tip: for Pulse/pipewire playback capture, use a source containing <code>monitor</code>.</small>
<small class="muted-text">Same sources as PulseAudio volume control. Pick a <strong>monitor</strong> source to follow playback.</small>
</div>
<div class="form-group">
<label for="audio-device-override">Manual device override (optional)</label>
<input type="text" id="audio-device-override" placeholder='e.g. 3 or "alsa_output....monitor"'>
</div>
<div class="form-group">
<label>Current BPM</label>
<div class="audio-bpm-row">
<div id="audio-bpm-value" class="audio-bpm-readout">--</div>
</div>
<label>Beat indicators</label>
<button type="button" id="audio-modal-beat-sync" class="audio-beat-sync-btn audio-modal-beat-sync" disabled title="Start beat detection">
<span class="audio-top-indicator-label">BPM</span>
<span id="audio-bpm-value" class="audio-top-indicator-value">--</span>
<span id="audio-modal-beat-readout" class="audio-top-beat-readout" aria-live="polite"></span>
<span id="audio-bar-phase-value" class="audio-top-bar-phase" aria-live="polite" title="Bar phase (beat in bar)"></span>
</button>
<small class="muted-text">Flashes on each beat (same as the header). Tap on a downbeat while a sequence is playing to sync (<kbd>S</kbd>).</small>
</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 class="form-group audio-volume-block">
<div class="audio-volume-header">
<label for="audio-input-volume">Volume</label>
<span id="audio-input-volume-readout" class="audio-volume-readout" aria-live="polite">100% (0.00 dB)</span>
</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 class="audio-volume-slider-row">
<input type="range" id="audio-input-volume" class="audio-volume-slider" min="0" max="200" value="100" step="1" aria-label="Input gain">
</div>
<div class="audio-volume-scale" aria-hidden="true">
<span class="audio-volume-scale-silence">Silence</span>
<span class="audio-volume-scale-unity">100% (0 dB)</span>
</div>
<div class="audio-input-level-meter" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-label="Live input level">
<div id="audio-input-level-bar" class="audio-input-level-bar"></div>
</div>
<small class="muted-text">Gain before beat detection (saved on the controller). The bar shows live input level while running.</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 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>
<button type="button" class="btn btn-secondary" id="audio-reset-btn" disabled title="Clear stuck BPM / beat tracking">Reset detector</button>
<button type="button" class="btn btn-secondary" id="audio-close-btn">Close</button>
</div>
<div class="form-group" style="margin-top: 0.75rem;">
<label for="audio-devices-debug">Detected devices (Python)</label>
<textarea id="audio-devices-debug" rows="8" readonly style="width:100%; font-family:monospace; resize:vertical;"></textarea>
</div>
</div>
</div>
<!-- Settings Modal -->
<div id="settings-modal" class="modal">
<div class="modal-content">
<h2>Device Settings</h2>
<p class="muted-text">Configure WiFi Access Point and device settings.</p>
<div id="settings-message" class="message"></div>
<!-- Device Name -->
<div class="settings-section">
<h3>Device</h3>
<form id="device-form">
<div class="form-group">
<label for="device-name-input">Device Name</label>
<input type="text" id="device-name-input" name="device_name" placeholder="e.g. led-controller" required>
<small>This name may be used for mDNS (e.g. <code>name.local</code>) and UI display.</small>
</div>
<div class="form-group">
<label for="wifi-channel-input">WiFi channel (ESP-NOW)</label>
<input type="number" id="wifi-channel-input" name="wifi_channel" min="1" max="11" required>
<small>STA channel (111) for LED drivers and the serial bridge. Use the same value everywhere.</small>
</div>
<div class="btn-group">
<button type="submit" class="btn btn-primary btn-full">Save device settings</button>
</div>
</form>
<div class="modal-content settings-modal-content">
<h2>Settings</h2>
<div class="settings-tabs" role="tablist" aria-label="Settings sections">
<button type="button" class="settings-tab-btn active" role="tab" id="settings-tab-bridge" data-settings-tab="bridge" aria-selected="true" aria-controls="settings-panel-bridge">Bridge</button>
<button type="button" class="settings-tab-btn" role="tab" id="settings-tab-led-tool" data-settings-tab="led-tool" aria-selected="false" aria-controls="settings-panel-led-tool">LED Tool</button>
</div>
<!-- WiFi Access Point Settings -->
<div class="settings-section ap-settings-section">
<h3>WiFi Access Point</h3>
<div id="settings-panel-bridge" class="settings-tab-panel active" data-settings-panel="bridge" role="tabpanel" aria-labelledby="settings-tab-bridge">
<div class="settings-section">
<div class="profiles-actions" style="align-items:center;gap:0.75rem;flex-wrap:wrap;margin-bottom:0.5rem;">
<span id="bridge-ws-status" class="muted-text" aria-live="polite"></span>
</div>
<ul id="bridge-connection-details" class="settings-bridge-connection-details muted-text" aria-live="polite"></ul>
<div id="ap-status" class="status-info">
<h4>AP Status</h4>
<p>Loading...</p>
<h3 class="settings-subheading">USB serial</h3>
<p class="muted-text" style="margin-top:0;margin-bottom:0.75rem;">Pi ↔ bridge over USB/UART. The bridge still uses WiFi radio for ESP-NOW only.</p>
<div class="form-group">
<label for="bridge-serial-label">Profile label</label>
<input type="text" id="bridge-serial-label" placeholder="e.g. Pi USB bridge" autocomplete="off">
</div>
<div class="form-group">
<label for="bridge-serial-port">USB serial port</label>
<select id="bridge-serial-port">
<option value="">— select port —</option>
</select>
<button type="button" class="btn btn-secondary btn-small" id="bridge-serial-refresh-btn" style="margin-top:0.5rem;">Refresh ports</button>
</div>
<div class="form-group">
<label for="bridge-serial-baud">Baud rate</label>
<input type="number" id="bridge-serial-baud" value="115200" min="9600" max="3000000" step="1">
</div>
<div class="profiles-actions" style="gap:0.5rem;flex-wrap:wrap;margin-bottom:1.25rem;">
<button type="button" class="btn btn-primary btn-small" id="bridge-serial-connect-btn">Connect serial</button>
<button type="button" class="btn btn-secondary btn-small" id="bridge-serial-save-profile-btn">Save serial profile</button>
</div>
<form id="ap-form">
<div class="form-group">
<label for="ap-ssid">AP SSID (Network Name)</label>
<input type="text" id="ap-ssid" name="ssid" placeholder="Enter AP name" required>
<small>The name of the WiFi access point this device creates</small>
<h3 class="settings-subheading">WiFi</h3>
<p class="muted-text" style="margin-top:0;margin-bottom:0.75rem;">Pi joins the bridge access point, then connects to <code>ws://&lt;bridge-ip&gt;/ws</code>.</p>
<div class="form-group">
<label for="bridge-wifi-interface">WiFi adapter</label>
<select id="bridge-wifi-interface">
<option value="">— select adapter —</option>
</select>
<button type="button" class="btn btn-secondary btn-small" id="bridge-wifi-refresh-interfaces-btn" style="margin-top:0.5rem;">Refresh adapters</button>
</div>
<div class="form-group">
<label for="bridge-wifi-ssid">Bridge SSID</label>
<div class="profiles-actions" style="gap:0.5rem;flex-wrap:wrap;align-items:flex-end;">
<select id="bridge-wifi-ssid" style="flex:1;min-width:12rem;">
<option value="">— scan or type below —</option>
</select>
<button type="button" class="btn btn-secondary btn-small" id="bridge-wifi-scan-btn">Scan</button>
</div>
<input type="text" id="bridge-wifi-ssid-manual" placeholder="Or type SSID" autocomplete="off" style="margin-top:0.5rem;">
</div>
<div class="form-group">
<label for="bridge-wifi-password">Password</label>
<input type="password" id="bridge-wifi-password" autocomplete="off">
</div>
<div class="form-group">
<label for="bridge-wifi-label">Profile label</label>
<input type="text" id="bridge-wifi-label" placeholder="e.g. Garden bridge" autocomplete="off">
</div>
<div class="form-group" style="display:flex;gap:1rem;flex-wrap:wrap;">
<div style="flex:1;min-width:8rem;">
<label for="bridge-wifi-ap-ip">Bridge IP</label>
<input type="text" id="bridge-wifi-ap-ip" value="192.168.4.1" autocomplete="off">
</div>
<div style="flex:0 0 6rem;">
<label for="bridge-wifi-ws-port">WS port</label>
<input type="number" id="bridge-wifi-ws-port" value="80" min="1" max="65535" step="1">
</div>
</div>
<div class="profiles-actions" style="gap:0.5rem;flex-wrap:wrap;margin-bottom:1rem;">
<button type="button" class="btn btn-primary btn-small" id="bridge-wifi-connect-btn">Connect WiFi</button>
<button type="button" class="btn btn-secondary btn-small" id="bridge-wifi-save-profile-btn">Save WiFi profile</button>
</div>
<div class="form-group">
<label for="ap-password">AP Password</label>
<input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)" data-bwignore>
<small>Leave empty for open network (min 8 characters if set)</small>
</div>
<h3 class="settings-subheading">Saved profiles</h3>
<ul id="bridge-profiles-list" class="settings-bridge-profiles muted-text"></ul>
</div>
</div>
<div class="form-group">
<label for="ap-channel">Channel (1-11)</label>
<input type="number" id="ap-channel" name="channel" min="1" max="11" placeholder="Auto">
<small>WiFi channel (1-11 for 2.4GHz). Leave empty for auto.</small>
</div>
<div class="btn-group">
<button type="submit" class="btn btn-primary btn-full">Configure AP</button>
</div>
</form>
<div id="settings-panel-led-tool" class="settings-tab-panel" data-settings-panel="led-tool" role="tabpanel" aria-labelledby="settings-tab-led-tool" hidden>
<p class="muted-text settings-led-tool-intro">USB serial setup for drivers and bridges: device settings, deploy, and firmware.</p>
<iframe id="led-tool-iframe" title="LED device settings editor" src="about:blank" allow="serial" class="settings-led-tool-iframe"></iframe>
</div>
<div class="modal-actions">
@@ -762,22 +771,11 @@
</div>
</div>
<!-- LED Tool Modal (led-tool/static settings editor) -->
<div id="led-tool-modal" class="modal">
<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>
<!-- Styles moved to /static/style.css -->
<script src="/static/zone-devices-panel.js"></script>
<script src="/static/groups.js"></script>
<script src="/static/zones.js"></script>
<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>

View File

@@ -182,7 +182,7 @@
<div class="form-group">
<label for="wifi-channel-page-input">WiFi channel (ESP-NOW)</label>
<input type="number" id="wifi-channel-page-input" name="wifi_channel" min="1" max="11" required>
<small>STA channel (111) for LED drivers and the serial bridge. Use the same value on every device.</small>
<small>2.4&nbsp;GHz channel (111) for ESP-NOW drivers and the bridge AP/STA. Set the same <code>wifi_channel</code> on the bridge and each led-driver; those devices need a reboot after a change. Saving here updates the Pi setting (restart led-controller to apply).</small>
</div>
<div class="btn-group">
<button type="submit" class="btn btn-primary btn-full">Save channel</button>
@@ -215,7 +215,7 @@
<div class="form-group">
<label for="ap-channel">Channel (1-11)</label>
<input type="number" id="ap-channel" name="channel" min="1" max="11" placeholder="Auto">
<small>WiFi channel (1-11 for 2.4GHz). Leave empty for auto.</small>
<small>Bridge AP channel (111). Stored for reference; set <code>wifi_channel</code> on the bridge itself and reboot it to apply.</small>
</div>
<div class="btn-group">

View File

@@ -1,15 +1,15 @@
# Driver message builder (`espnow_message`)
This utility builds **v1** JSON payloads for LED drivers (serial/ESP-NOW bridge and Wi-Fi TCP). See **`docs/API.md`** for the full wire format.
This utility builds **v1** JSON payloads for LED drivers (ESP-NOW bridge and Wi-Fi TCP). See **`docs/API.md`** for the full wire format.
## Usage
### Basic Message Building
```python
from util.espnow_message import build_message, build_preset_dict, build_select_dict
from util.espnow_message import build_message, build_preset_dict
# Build a message with presets and select
# Build a message with presets and select (list form; routing is by MAC envelope / groups)
presets = {
"red_blink": build_preset_dict({
"pattern": "blink",
@@ -20,27 +20,17 @@ presets = {
})
}
select = build_select_dict({
"device1": "red_blink"
})
message = build_message(presets=presets, select=select)
# Result: {"v": "1", "presets": {...}, "select": {...}}
message = build_message(presets=presets, select=["red_blink"])
# Result: {"v": "1", "presets": {...}, "select": ["red_blink"]}
```
### Building Select Messages with Step Synchronization
### Select with step
```python
from util.espnow_message import build_message, build_select_dict
from util.espnow_message import build_message
# Select with step for synchronization
select = build_select_dict(
{"device1": "rainbow_preset", "device2": "rainbow_preset"},
step_mapping={"device1": 10, "device2": 10}
)
message = build_message(select=select)
# Result: {"v": "1", "select": {"device1": ["rainbow_preset", 10], "device2": ["rainbow_preset", 10]}}
message = build_message(select=["rainbow_preset", 10])
# Result: {"v": "1", "select": ["rainbow_preset", 10]}
```
### Converting Presets

View File

@@ -10,6 +10,9 @@ from typing import Any
_HOLDOVER_BPM_MIN = 30.0
_HOLDOVER_BPM_MAX = 300.0
_HOLDOVER_MAX_S = 300.0
# After this many seconds without a detected beat, re-prime aubio and start BPM holdover
# (same window as status() uses to hide stale BPM).
_SILENCE_GAP_S = 4.0
class AudioBeatDetector:
@@ -24,6 +27,8 @@ class AudioBeatDetector:
self._holdover_thread: threading.Thread | None = None
self._holdover_stop = threading.Event()
self._holdover_active = False
self._last_real_beat_ts: float | None = None
self._last_gap_tempo_reset_ts: float = 0.0
self._status = {
"running": False,
"bpm": None,
@@ -38,9 +43,36 @@ class AudioBeatDetector:
"bar_phase_readout": "1/4",
"error": None,
"device": None,
"input_level": 0.0,
}
def list_input_devices(self):
try:
from util.pulse_audio_devices import list_pulse_matched_input_devices
pulse = list_pulse_matched_input_devices()
if pulse:
return pulse
except Exception as e:
print(f"[audio] pulse device list skipped: {e!r}")
sd_list = self._list_sounddevice_input_devices()
if sd_list:
print("[audio] device list: sounddevice fallback (install/use pactl for Pulse names)")
return sd_list
@staticmethod
def _skip_sounddevice_virtual(name: str, hostapi_name: str) -> bool:
"""Hide PortAudio/Pulse aggregate devices (pipewire, pulse, default)."""
n = name.strip().lower()
if n in ("pipewire", "pulse", "default", "sysdefault"):
return True
ha = hostapi_name.strip().lower()
if ha in ("pulse", "pipewire") and n in ("default", "pipewire", "pulse"):
return True
return False
def _list_sounddevice_input_devices(self):
import sounddevice as sd
devices = sd.query_devices()
@@ -55,15 +87,17 @@ class AudioBeatDetector:
name = str(dev.get("name", f"Input {idx}"))
chans = int(dev.get("max_input_channels", 0))
is_monitor_named = "monitor" in name.lower()
if chans <= 0 and not is_monitor_named:
continue
sr = int(dev.get("default_samplerate", 44100))
hostapi_idx = int(dev.get("hostapi", -1))
hostapi_name = (
str(hostapis[hostapi_idx].get("name", "unknown"))
if 0 <= hostapi_idx < len(hostapis)
else "unknown"
)
if self._skip_sounddevice_virtual(name, hostapi_name):
continue
if chans <= 0 and not is_monitor_named:
continue
sr = int(dev.get("default_samplerate", 44100))
is_default = default_input_idx is not None and idx == default_input_idx
ch_label = f"{chans}ch" if chans > 0 else "0ch?"
label = f"[{idx}] {name} ({ch_label} @ {sr}Hz, {hostapi_name})"
@@ -71,10 +105,14 @@ class AudioBeatDetector:
label = f"{label} [default]"
if is_monitor_named:
label = f"{label} [monitor]"
display_name = name
if is_default:
display_name = f"{display_name} (default)"
out.append(
{
"id": idx,
"name": name,
"display_name": display_name,
"label": label,
"max_input_channels": chans,
"default_samplerate": sr,
@@ -101,6 +139,13 @@ class AudioBeatDetector:
}
def start(self, device=None):
try:
from util.pulse_audio_devices import resolve_capture_device
device = resolve_capture_device(device)
except Exception as e:
self._set_error(str(e))
raise
should_restart = False
with self._lock:
should_restart = self._running
@@ -108,6 +153,8 @@ class AudioBeatDetector:
self.stop()
with self._lock:
self._stop_event.clear()
self._last_real_beat_ts = None
self._last_gap_tempo_reset_ts = 0.0
self._status.update(
{
"running": True,
@@ -162,7 +209,42 @@ class AudioBeatDetector:
self._thread = None
self._stream = None
self._pending_reset = False
self._last_real_beat_ts = None
self._last_gap_tempo_reset_ts = 0.0
self._status["running"] = False
self._status["input_level"] = 0.0
def _update_input_level(self, mono) -> None:
import numpy as np
arr = np.asarray(mono, dtype=np.float32)
if arr.size == 0:
inst = 0.0
else:
peak = float(np.max(np.abs(arr)))
rms = float(np.sqrt(np.mean(arr * arr)))
inst = min(1.0, max(peak, rms * 2.0))
with self._lock:
prev = float(self._status.get("input_level") or 0.0)
if inst >= prev:
self._status["input_level"] = inst
else:
self._status["input_level"] = max(inst, prev * 0.82)
def _decay_input_level(self) -> None:
with self._lock:
prev = float(self._status.get("input_level") or 0.0)
self._status["input_level"] = prev * 0.82
def _input_gain(self) -> float:
try:
from settings import get_settings
vol = int(get_settings().get("audio_input_volume") or 100)
except (TypeError, ValueError, ImportError):
vol = 100
vol = max(0, min(200, vol))
return vol / 100.0
def status(self):
with self._lock:
@@ -342,10 +424,47 @@ class AudioBeatDetector:
print(f"[audio] anchor_bar_phase: {e}")
return False
def _maybe_recover_after_silence_gap(self, runtime) -> None:
"""After a quiet spell, reset tempo tracking and run holdover until real beats return."""
now = time.time()
with self._lock:
if not self._running:
return
last_real = self._last_real_beat_ts
bpm = self._clamp_holdover_bpm(self._status.get("bpm"))
holdover = self._holdover_active
last_reset = self._last_gap_tempo_reset_ts
if last_real is None or bpm is None:
return
try:
gap = now - float(last_real)
except (TypeError, ValueError):
return
if gap < _SILENCE_GAP_S:
return
if not holdover:
self._start_bpm_holdover(bpm)
try:
since_reset = (
now - float(last_reset) if last_reset else _SILENCE_GAP_S
)
except (TypeError, ValueError):
since_reset = _SILENCE_GAP_S
if since_reset >= _SILENCE_GAP_S:
try:
runtime.reset_tempo_state()
except Exception as e:
print(f"[audio] silence gap tempo reset: {e}")
else:
with self._lock:
self._last_gap_tempo_reset_ts = now
def _record_beat(self, bpm, beat_type="unknown", beat_type_confidence=0.0, **phase_fields):
self._stop_bpm_holdover()
now = time.time()
self._last_real_beat_ts = now
with self._lock:
self._last_gap_tempo_reset_ts = 0.0
self._status["last_beat_ts"] = now
self._status["bpm"] = bpm
self._status["beat_type"] = beat_type
@@ -386,6 +505,9 @@ class AudioBeatDetector:
beat_mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(beat_mod)
from util.pulse_audio_devices import resolve_capture_device
device = resolve_capture_device(device)
if device is None:
try:
device = int(sd.default.device[0])
@@ -395,6 +517,10 @@ class AudioBeatDetector:
raise RuntimeError(
"no default input device; open Audio, pick an input, then Start"
)
if not isinstance(device, int):
raise RuntimeError(
f"internal error: unresolved capture device {device!r}"
)
dev_info = sd.query_devices(device, "input")
sample_rate = int(dev_info["default_samplerate"])
@@ -450,6 +576,8 @@ class AudioBeatDetector:
try:
frame = audio_q.get(timeout=0.1)
except queue.Empty:
self._decay_input_level()
self._maybe_recover_after_silence_gap(runtime)
continue
self._process_pending_reset(runtime)
if frame.shape[0] != hop_size:
@@ -457,8 +585,13 @@ class AudioBeatDetector:
frame = frame[:hop_size]
else:
frame = np.pad(frame, (0, hop_size - frame.shape[0]))
gain = self._input_gain()
if gain != 1.0:
frame = frame * gain
self._update_input_level(frame)
event = runtime.process_frame(frame, now_s=time.time())
if event is None:
self._maybe_recover_after_silence_gap(runtime)
continue
bpm = event.get("bpm")
self._record_beat(

View File

@@ -71,8 +71,6 @@ def write_audio_run_state(
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,

View File

@@ -423,6 +423,16 @@ def mark_sequence_manual_lane_select_sent(lane_index: int) -> None:
e["suppress_next_notify"] = True
def reset_manual_lane_strides() -> None:
"""Zero manual-lane beat counters after a sequence change (routes unchanged)."""
global _preset_session_beats
with _route_lock:
_preset_session_beats = 0
for e in _lane_manual.values():
if isinstance(e, dict):
e["beat_counter"] = 0
def sync_beat_route_from_push_sequence(
sequence: List[Any],
target_macs: Optional[List[str]] = None,
@@ -594,11 +604,11 @@ async def _deliver_select(
group_ids: Optional[List[str]] = None,
) -> None:
from models.device import Device
from models.transport import get_current_sender
from models.transport import get_current_bridge
from util.driver_delivery import deliver_json_messages
sender = get_current_sender()
if not sender:
bridge = get_current_bridge()
if not bridge:
return
devices = Device()
gids = [str(g).strip() for g in (group_ids or []) if str(g).strip()]
@@ -607,7 +617,7 @@ async def _deliver_select(
body["groups"] = gids
msg = json.dumps(body, separators=(",", ":"))
try:
await deliver_json_messages(sender, [msg], None, devices, delay_s=0.05)
await deliver_json_messages(bridge, [msg], None, devices, delay_s=0.05)
except Exception as e:
print(f"[beat-route] deliver failed: {e}")

View File

@@ -0,0 +1,201 @@
"""Resolve and connect the bridge assigned to device groups."""
from __future__ import annotations
from typing import Dict, List, Optional, Set, Any
from models.group import Group
from settings import get_settings
from util.bridge_profiles import find_bridge_profile
from util.bridge_runtime import connect_bridge_profile
from util.espnow_registry import push_groups_for_group_devices
def _normalize_bridge_id(raw: object) -> Optional[str]:
bid = str(raw or "").strip()
return bid if bid else None
def bridge_id_for_group_doc(gdoc: dict) -> Optional[str]:
if not isinstance(gdoc, dict):
return None
return _normalize_bridge_id(gdoc.get("bridge_id"))
def _bridge_ids_for_group_docs(docs: list) -> Set[Optional[str]]:
ids: Set[Optional[str]] = set()
for doc in docs:
if not isinstance(doc, dict):
continue
ids.add(bridge_id_for_group_doc(doc))
return ids
def bridge_id_for_group_id(group_id: str) -> Optional[str]:
gid = str(group_id or "").strip()
if not gid:
return None
gdoc = Group().read(gid)
if not gdoc:
return None
return bridge_id_for_group_doc(gdoc)
def build_group_to_bridge_map(group_ids: List[str]) -> Dict[str, Optional[str]]:
"""Map group id -> bridge profile id (``None`` = default / current connection)."""
groups = Group()
out: Dict[str, Optional[str]] = {}
for gid in group_ids:
s = str(gid).strip()
if not s or s in out:
continue
gdoc = groups.read(s)
out[s] = bridge_id_for_group_doc(gdoc) if gdoc else None
return out
def bridge_ids_for_group_ids(group_ids: List[str]) -> Set[Optional[str]]:
if not group_ids:
return set()
return set(build_group_to_bridge_map(group_ids).values())
def ordered_bridge_ids(bridge_ids: Set[Optional[str]]) -> List[Optional[str]]:
"""Stable order: default bridge first, then profile ids sorted."""
if not bridge_ids:
return []
rest = sorted(b for b in bridge_ids if b)
if None in bridge_ids:
return [None, *rest]
return rest
def bridges_needed_for_body(
body: dict, group_to_bridge: Dict[str, Optional[str]]
) -> Set[Optional[str]]:
"""Which bridge(s) must receive this v1 body (by ``groups`` / ``g``)."""
if not isinstance(body, dict):
return {None}
g = body.get("groups") or body.get("g")
if not isinstance(g, list) or not g:
return {None}
needed: Set[Optional[str]] = set()
for item in g:
gid = str(item).strip()
if gid:
needed.add(group_to_bridge.get(gid))
return needed if needed else {None}
async def ensure_bridge_for_bridge_id(bridge_id: Optional[str]) -> tuple[bool, Optional[str]]:
if not bridge_id or not str(bridge_id).strip():
return True, None
settings = get_settings()
profile = find_bridge_profile(settings, bridge_id)
if not profile:
return False, f"Unknown bridge profile {bridge_id!r}"
ok, err = await connect_bridge_profile(profile, settings)
if not ok:
return False, err or "Bridge connect failed"
return True, None
async def ensure_bridges_for_group_ids(group_ids: List[str]) -> tuple[bool, Optional[str]]:
"""Join each distinct bridge used by these groups (sequential; last stays active)."""
bridge_ids = bridge_ids_for_group_ids(group_ids)
for bid in ordered_bridge_ids(bridge_ids):
ok, err = await ensure_bridge_for_bridge_id(bid)
if not ok:
return False, err
return True, None
async def ensure_bridge_for_group_ids(group_ids: List[str]) -> tuple[bool, Optional[str]]:
"""Connect to every bridge referenced by these groups."""
if not group_ids:
return True, None
return await ensure_bridges_for_group_ids(group_ids)
async def ensure_bridge_for_group_doc(gdoc: dict) -> tuple[bool, Optional[str]]:
if not isinstance(gdoc, dict):
return True, None
bid = bridge_id_for_group_doc(gdoc)
if not bid:
return True, None
return await ensure_bridge_for_bridge_id(bid)
def count_groups_by_bridge() -> Dict[str, int]:
"""Map bridge profile id -> number of groups assigned."""
counts: Dict[str, int] = {}
groups = Group()
for _gid, doc in groups.items():
if not isinstance(doc, dict):
continue
bid = bridge_id_for_group_doc(doc)
if bid:
counts[bid] = counts.get(bid, 0) + 1
return counts
def groups_for_bridge_assignment(bridge_id: str) -> List[Dict[str, Any]]:
"""All groups with ``assigned`` flag for bridge profile ``bridge_id``."""
bid = str(bridge_id or "").strip()
if not bid:
return []
groups = Group()
out: List[Dict[str, Any]] = []
for gid, doc in groups.items():
if not isinstance(doc, dict):
continue
gbid = bridge_id_for_group_doc(doc)
devs = doc.get("devices") if isinstance(doc.get("devices"), list) else []
out.append(
{
"id": str(gid),
"name": str(doc.get("name") or gid),
"assigned": gbid == bid,
"bridge_id": gbid,
"device_count": len(devs),
}
)
out.sort(key=lambda row: str(row.get("name") or "").lower())
return out
async def assign_groups_to_bridge(
bridge_id: str, group_ids: List[str]
) -> tuple[bool, Optional[str]]:
"""Set ``bridge_id`` on listed groups; clear it on others that used this bridge."""
bid = str(bridge_id or "").strip()
if not bid:
return False, "bridge_id required"
settings = get_settings()
if not find_bridge_profile(settings, bid):
return False, f"Unknown bridge profile {bid!r}"
want = {str(g).strip() for g in group_ids if str(g).strip()}
groups = Group()
for gid in want:
if str(gid) not in groups or not isinstance(groups.read(str(gid)), dict):
return False, f"Unknown group id {gid!r}"
changed: List[dict] = []
for gid, doc in list(groups.items()):
if not isinstance(doc, dict):
continue
gsid = str(gid)
current = bridge_id_for_group_doc(doc)
if gsid in want:
if current != bid:
groups.update(gsid, {"bridge_id": bid})
g = groups.read(gsid)
if g:
changed.append(g)
elif current == bid:
groups.update(gsid, {"bridge_id": None})
g = groups.read(gsid)
if g:
changed.append(g)
for gdoc in changed:
await push_groups_for_group_devices(gdoc)
return True, None

View File

@@ -0,0 +1,67 @@
"""Saved ESP-NOW bridge profiles from settings.json."""
from __future__ import annotations
import uuid
from typing import Any, Dict, List, Optional
def normalise_bridges(raw: Any) -> List[Dict[str, Any]]:
if not isinstance(raw, list):
return []
out: List[Dict[str, Any]] = []
for item in raw:
if not isinstance(item, dict):
continue
bid = str(item.get("id") or "").strip() or uuid.uuid4().hex[:12]
label = str(item.get("label") or "").strip()
transport = str(item.get("transport") or "serial").strip().lower()
if transport == "wifi":
ssid = str(item.get("ssid") or "").strip()
if not ssid:
continue
try:
port = int(item.get("ws_port") or 80)
except (TypeError, ValueError):
port = 80
out.append(
{
"id": bid,
"label": label or ssid,
"transport": "wifi",
"ssid": ssid,
"password": str(item.get("password") or ""),
"ap_ip": str(item.get("ap_ip") or "192.168.4.1").strip(),
"ws_port": port,
}
)
continue
serial_port = str(item.get("serial_port") or "").strip()
if not serial_port:
continue
try:
baud = int(item.get("serial_baudrate") or 921600)
except (TypeError, ValueError):
baud = 921600
out.append(
{
"id": bid,
"label": label or serial_port,
"transport": "serial",
"serial_port": serial_port,
"serial_baudrate": baud,
}
)
return out
def find_bridge_profile(settings: Any, bridge_id: Optional[str]) -> Optional[Dict[str, Any]]:
if not bridge_id:
return None
bid = str(bridge_id).strip()
if not bid:
return None
for profile in normalise_bridges(settings.get("bridges")):
if profile.get("id") == bid:
return profile
return None

233
src/util/bridge_runtime.py Normal file
View File

@@ -0,0 +1,233 @@
"""Start or refresh the bridge client after WiFi or USB serial connect."""
from __future__ import annotations
from typing import Awaitable, Callable, Optional
from models.bridge_serial_client import get_bridge_serial_client, init_bridge_serial_client
from models.bridge_ws_client import get_bridge_client, init_bridge_client
from models.transport import BridgeSerialTransport, BridgeWsTransport, get_current_bridge, set_bridge
from settings import WIFI_CHANNEL_DEFAULT
from util.bridge_profiles import normalise_bridges
from util.pi_wifi import (
build_bridge_ws_url,
connect_wifi,
nmcli_available,
ssid_visible,
wait_for_device,
)
UplinkHandler = Callable[..., Awaitable[None]]
_uplink_handler: Optional[UplinkHandler] = None
def set_bridge_uplink_handler(handler: Optional[UplinkHandler]) -> None:
global _uplink_handler
_uplink_handler = handler
def _bridge_transport_mode(settings) -> str:
mode = str(settings.get("bridge_transport") or "wifi").strip().lower()
return mode if mode in ("wifi", "serial") else "wifi"
def bridge_ws_connected() -> bool:
client = get_bridge_client()
if client is None:
return False
return client._connected.is_set()
def bridge_serial_connected() -> bool:
client = get_bridge_serial_client()
if client is None:
return False
return client._connected.is_set()
def stop_bridge_ws_client() -> None:
client = get_bridge_client()
if client is not None:
client.stop()
def stop_bridge_serial_client() -> None:
client = get_bridge_serial_client()
if client is not None:
client.stop()
def bridge_connected() -> bool:
from settings import get_settings
settings = get_settings()
if _bridge_transport_mode(settings) == "serial":
return bridge_serial_connected()
return bridge_ws_connected()
def active_bridge_profile_id(settings) -> Optional[str]:
"""Saved profile id matching the current transport connection, if any."""
if not bridge_connected():
return None
mode = _bridge_transport_mode(settings)
from util.pi_wifi import build_bridge_ws_url
for profile in normalise_bridges(settings.get("bridges")):
pid = str(profile.get("id") or "").strip()
if not pid:
continue
if mode == "serial" and profile.get("transport") == "serial":
if str(profile.get("serial_port") or "") == str(
settings.get("bridge_serial_port") or ""
).strip():
return pid
if mode == "wifi" and profile.get("transport") == "wifi":
try:
url = build_bridge_ws_url(profile.get("ap_ip"), profile.get("ws_port") or 80)
except ValueError:
continue
if url == str(settings.get("bridge_ws_url") or "").strip():
return pid
return None
async def ensure_bridge_client(
url: str,
*,
wifi_channel: Optional[int] = None,
) -> bool:
"""Ensure ``BridgeWsTransport`` is active and pointed at ``url``."""
stop_bridge_serial_client()
url = str(url or "").strip()
if not url:
return False
ch = wifi_channel if wifi_channel is not None else WIFI_CHANNEL_DEFAULT
client = get_bridge_client()
if client is None:
client = init_bridge_client(url, wifi_channel=ch)
if _uplink_handler is not None:
client.set_uplink_handler(_uplink_handler)
client.start()
else:
if client._url != url:
client._url = url
client._wifi_channel = ch
if _uplink_handler is not None:
client.set_uplink_handler(_uplink_handler)
client._signal_disconnect()
current = get_current_bridge()
if current is None or not hasattr(current, "send_envelope"):
set_bridge(BridgeWsTransport())
return await client.wait_connected(timeout=30.0)
async def ensure_bridge_serial_client(
port: str,
*,
baudrate: int = 921600,
) -> bool:
"""Ensure ``BridgeSerialTransport`` is active on ``port``."""
stop_bridge_ws_client()
port = str(port or "").strip()
if not port:
return False
baud = int(baudrate)
client = get_bridge_serial_client()
if client is None:
client = init_bridge_serial_client(port, baudrate=baud)
if _uplink_handler is not None:
client.set_uplink_handler(_uplink_handler)
client.start()
set_bridge(BridgeSerialTransport())
return await client.wait_connected(timeout=20.0)
if client._port != port or client._baudrate != baud:
client.stop()
client = init_bridge_serial_client(port, baudrate=baud)
if _uplink_handler is not None:
client.set_uplink_handler(_uplink_handler)
client.start()
elif _uplink_handler is not None:
client.set_uplink_handler(_uplink_handler)
client._signal_disconnect()
set_bridge(BridgeSerialTransport())
return await client.wait_connected(timeout=20.0)
async def connect_bridge_serial(profile: dict, settings) -> tuple[bool, str]:
"""Open USB/serial to the bridge and switch transport to serial."""
if not isinstance(profile, dict):
return False, "Invalid bridge profile"
port = str(profile.get("serial_port") or settings.get("bridge_serial_port") or "").strip()
if not port:
return False, "Serial port not configured"
try:
baud = int(profile.get("serial_baudrate") or settings.get("bridge_serial_baudrate") or 921600)
except (TypeError, ValueError):
baud = 921600
settings["bridge_transport"] = "serial"
settings["bridge_serial_port"] = port
settings["bridge_serial_baudrate"] = baud
settings.save()
stop_bridge_ws_client()
if not await ensure_bridge_serial_client(port, baudrate=baud):
return False, f"Serial bridge not connected ({port})"
return True, ""
async def connect_bridge_wifi(profile: dict, settings) -> tuple[bool, str]:
"""Join bridge AP and open WebSocket to ``profile``."""
if not isinstance(profile, dict):
return False, "Invalid bridge profile"
ssid = str(profile.get("ssid") or "").strip()
if not ssid:
return False, "Bridge SSID not configured"
device = str(profile.get("wifi_interface") or settings.get("wifi_interface") or "").strip()
if not device:
return False, "WiFi interface not configured (Settings → Bridge WiFi)"
if not nmcli_available():
return False, "nmcli not found (install NetworkManager)"
try:
if not await ssid_visible(device, ssid):
return (
False,
f"SSID {ssid!r} not visible on {device} — power on the bridge and scan in Settings",
)
await connect_wifi(
device=device,
ssid=ssid,
password=str(profile.get("password") or ""),
)
await wait_for_device(device)
except Exception as e:
err = str(e).strip()
if err.startswith("Error:"):
err = err[6:].strip()
return False, err or "WiFi connect failed"
try:
url = build_bridge_ws_url(profile.get("ap_ip"), profile.get("ws_port") or 80)
except ValueError as e:
return False, str(e)
try:
ch = int(settings.get("wifi_channel", WIFI_CHANNEL_DEFAULT))
except (TypeError, ValueError):
ch = WIFI_CHANNEL_DEFAULT
settings["bridge_transport"] = "wifi"
settings["bridge_ws_url"] = url
settings["wifi_interface"] = device
settings.save()
stop_bridge_serial_client()
if not await ensure_bridge_client(url, wifi_channel=ch):
return False, f"WebSocket bridge not connected ({url})"
return True, ""
async def connect_bridge_profile(profile: dict, settings) -> tuple[bool, str]:
"""Connect using a saved bridge profile (serial or wifi)."""
if not isinstance(profile, dict):
return False, "Invalid bridge profile"
transport = str(profile.get("transport") or "serial").strip().lower()
if transport == "wifi":
return await connect_bridge_wifi(profile, settings)
return await connect_bridge_serial(profile, settings)

View File

@@ -0,0 +1,38 @@
"""Length-prefixed serial frames between Pi and ESP-NOW bridge (same payload as WebSocket)."""
from __future__ import annotations
import struct
_MAX_SERIAL_FRAME = 4096
_MAX_SERIAL_BUF = 8192
def pack_serial_frame(payload: bytes) -> bytes:
if len(payload) > _MAX_SERIAL_FRAME:
raise ValueError(f"serial frame too large ({len(payload)} B)")
return struct.pack(">H", len(payload)) + payload
def feed_serial_buffer(buf: bytearray, chunk: bytes) -> list[bytes]:
"""Append ``chunk`` to ``buf`` and return any complete frames."""
if chunk:
buf.extend(chunk)
if len(buf) > _MAX_SERIAL_BUF:
del buf[:]
frames: list[bytes] = []
while True:
if len(buf) < 2:
break
(length,) = struct.unpack(">H", buf[0:2])
if length > _MAX_SERIAL_FRAME:
del buf[:1]
continue
total = 2 + length
if len(buf) < total:
if len(buf) > _MAX_SERIAL_BUF:
del buf[:]
break
frames.append(bytes(buf[2:total]))
del buf[:total]
return frames

View File

@@ -2,7 +2,7 @@
import asyncio
import json
from typing import Any, Dict, List, Optional, Union
from typing import Any, Dict, List, Optional, Set, Union
from util.bridge_envelope import (
BROADCAST_MAC,
@@ -12,15 +12,9 @@ from util.bridge_envelope import (
split_v1_body_for_espnow,
)
from util.espnow_message import build_message
from util.espnow_wire import WIRE_MAGIC, pack_group_cmd
_MAX_JSON_ESPNOW = 240
def v1_message_bytes(body: Dict[str, Any]) -> bytes:
return json.dumps(body, separators=(",", ":")).encode("utf-8")
def _body_from_message(msg: Union[str, bytes, bytearray, Dict[str, Any]]) -> Optional[Dict[str, Any]]:
if isinstance(msg, dict):
if msg.get("v") == "1" and "devices" not in msg:
@@ -44,17 +38,7 @@ def _body_from_message(msg: Union[str, bytes, bytearray, Dict[str, Any]]) -> Opt
return None
async def deliver_envelope(sender, envelope: Dict[str, Any], delay_s: float = 0.1) -> int:
if not envelope or not isinstance(envelope.get("devices"), dict):
return 0
if await sender.send(envelope):
if delay_s > 0:
await asyncio.sleep(delay_s)
return 1
return 0
async def _deliver_v1_body(sender, mac_key: str, body: Dict[str, Any], delay_s: float) -> int:
async def _deliver_v1_body(bridge, mac_key: str, body: Dict[str, Any], delay_s: float) -> int:
deliveries = 0
try:
chunks = split_v1_body_for_espnow(body)
@@ -62,76 +46,13 @@ async def _deliver_v1_body(sender, mac_key: str, body: Dict[str, Any], delay_s:
return 0
for chunk in chunks:
env = build_devices_envelope({mac_key: chunk})
if await sender.send(env):
if await bridge.send(env):
deliveries += 1
if delay_s > 0:
await asyncio.sleep(delay_s)
return deliveries
async def deliver_packets(
sender,
packets: List[bytes],
*,
delay_s: float = 0.1,
target_macs: Optional[List[str]] = None,
unicast: bool = False,
) -> int:
if not packets:
return 0
deliveries = 0
mac_keys = _unicast_mac_keys(target_macs) if unicast and target_macs else [BROADCAST_MAC]
for mac_key in mac_keys:
for pkt in packets:
body = _body_from_message(pkt)
if body:
deliveries += await _deliver_v1_body(sender, mac_key, body, delay_s)
else:
if await sender.send(pkt):
deliveries += 1
if delay_s > 0:
await asyncio.sleep(delay_s)
return deliveries
async def deliver_binary_packets(
sender,
packets: List[bytes],
target_macs: Optional[List[str]] = None,
*,
delay_s: float = 0.1,
unicast: bool = False,
) -> int:
return await deliver_packets(
sender, packets, delay_s=delay_s, target_macs=target_macs, unicast=unicast
)
async def deliver_group_binary_packets(
sender,
group_id: str,
packets: List[bytes],
*,
delay_s: float = 0.1,
) -> int:
"""Broadcast GROUP_CMD wire packets (legacy binary passthrough on bridge)."""
from util.espnow_wire import parse_cmd
deliveries = 0
for pkt in packets:
env, save = parse_cmd(pkt)
if env is None:
continue
try:
g_pkt = pack_group_cmd(str(group_id), env, save=save)
except ValueError:
continue
if await sender.send(g_pkt):
deliveries += 1
await asyncio.sleep(delay_s)
return deliveries
def build_preset_json_chunks(
presets_by_name: Dict[str, Any],
*,
@@ -174,29 +95,6 @@ def build_preset_json_chunks(
return [c for c in chunks if c]
async def deliver_preset_broadcast_then_per_device(
sender,
chunk_messages,
target_macs,
devices_model,
default_id,
delay_s=0.1,
):
del devices_model, target_macs
deliveries = 0
for msg in chunk_messages:
body = _body_from_message(msg)
if not body:
continue
deliveries += await _deliver_v1_body(sender, BROADCAST_MAC, body, delay_s)
if default_id:
body = {"default": str(default_id), "save": True}
deliveries += await _deliver_v1_body(sender, BROADCAST_MAC, body, delay_s)
return deliveries
def _unicast_mac_keys(target_macs: Optional[List[str]]) -> List[str]:
"""One formatted MAC per target; empty list means broadcast."""
if not target_macs:
@@ -212,7 +110,7 @@ def _unicast_mac_keys(target_macs: Optional[List[str]]) -> List[str]:
async def deliver_json_messages(
sender,
bridge,
messages,
target_macs,
devices_model,
@@ -224,17 +122,27 @@ async def deliver_json_messages(
Deliver v1 JSON to drivers. Default: ESP-NOW broadcast (``ff:ff:…``); drivers
filter on ``groups`` in the body. Set ``unicast=True`` only for per-device settings
or single-device identify.
Uses the current bridge connection only (per-group bridge assignment is disabled).
"""
del devices_model
deliveries = 0
from models.transport import get_current_bridge
active = get_current_bridge() or bridge
if active is None:
raise RuntimeError("Transport not configured")
if unicast and target_macs:
mac_keys = _unicast_mac_keys(target_macs)
else:
mac_keys = [BROADCAST_MAC]
deliveries = 0
for mac_key in mac_keys:
for msg in messages:
body = _body_from_message(msg)
if not body:
continue
deliveries += await _deliver_v1_body(sender, mac_key, body, delay_s)
deliveries += await _deliver_v1_body(active, mac_key, body, delay_s)
return deliveries, len(messages)

View File

@@ -55,24 +55,6 @@ def build_message(presets=None, select=None, save=False, default=None):
return json.dumps(message)
def build_select_list(preset_name, step=None):
"""
Build a select list for one driver (unicast / per-MAC envelope).
Wire shape: ``["preset_id"]`` or ``["preset_id", step]`` — no device name.
"""
select_list = [str(preset_name)]
if step is not None:
select_list.append(step)
return select_list
def build_select_message(device_name, preset_name, step=None):
"""Legacy name-map select; prefer :func:`build_select_list` for ESP-NOW."""
del device_name
return build_select_list(preset_name, step=step)
def _hex_from_background_raw(bg_raw):
"""Coerce ``background`` / ``bg`` field to a ``#RRGGBB`` string (driver wire format)."""
if isinstance(bg_raw, str):
@@ -233,30 +215,3 @@ def build_presets_dict(presets_data, palette_colors=None):
for preset_name, preset_data in presets_data.items():
result[preset_name] = build_preset_dict(preset_data, palette_colors)
return result
def build_select_dict(device_preset_mapping, step_mapping=None):
"""
Build a select dictionary mapping device names to select lists.
Args:
device_preset_mapping: Dictionary mapping device names to preset names
step_mapping: Optional dictionary mapping device names to step values
Returns:
Dictionary with select field ready to use in build_message
Example:
select = build_select_dict(
{"device1": "rainbow_preset", "device2": "pulse_preset"},
step_mapping={"device1": 10}
)
message = build_message(select=select)
"""
select = {}
for device_name, preset_name in device_preset_mapping.items():
select_list = [preset_name]
if step_mapping and device_name in step_mapping:
select_list.append(step_mapping[device_name])
select[device_name] = select_list
return select

86
src/util/espnow_ping.py Normal file
View File

@@ -0,0 +1,86 @@
"""ESP-NOW broadcast ping: collect PING_RSP from drivers."""
from __future__ import annotations
import asyncio
import secrets
import time
from typing import Any, Dict, Optional
from models.device import Device
from models.transport import get_current_bridge
from util.espnow_wire import pack_ping_req, parse_ping_rsp
_active: Dict[int, Dict[str, Any]] = {}
def register_device_from_ping(peer_mac: bytes, name: str) -> bool:
"""Add or update registry entry from a PING_RSP (drivers may not have sent ANNOUNCE yet)."""
if not peer_mac or len(peer_mac) != 6:
return False
mac_hex = peer_mac.hex()
label = (name or "").strip() or f"led-{mac_hex}"
did, persisted = Device().upsert_espnow_announced(mac_hex, label)
if did and persisted:
print(f"[espnow] registered mac={did} name={label!r} (ping)")
return bool(persisted)
def record_ping_rsp(peer_mac: bytes, packet: bytes) -> None:
info = parse_ping_rsp(packet)
if info is None:
return
session = _active.get(info["ping_id"])
if session is None:
return
mac_hex = peer_mac.hex()
session["responses"][mac_hex] = {
"mac": mac_hex,
"name": info["name"],
"rtt_ms": int((time.monotonic() - session["sent_at"]) * 1000),
}
if register_device_from_ping(peer_mac, info["name"]):
session["registered"] = int(session.get("registered", 0)) + 1
async def run_ping(*, timeout_s: float = 3.0) -> Dict[str, Any]:
"""
Broadcast PING_REQ and collect PING_RSP until ``timeout_s``.
Returns ``{ok, ping_id, timeout_s, responses}``; ``responses`` maps MAC hex to
``{mac, name, rtt_ms}``.
"""
bridge = get_current_bridge()
if bridge is None:
return {"ok": False, "error": "Transport not configured", "responses": {}}
ping_id = secrets.randbits(32) or 1
session: Dict[str, Any] = {
"responses": {},
"sent_at": time.monotonic(),
"registered": 0,
}
_active[ping_id] = session
pkt = pack_ping_req(ping_id)
ok = await bridge.send(pkt)
if not ok:
_active.pop(ping_id, None)
return {
"ok": False,
"error": "Send failed",
"ping_id": ping_id,
"responses": {},
}
await asyncio.sleep(timeout_s)
responses = dict(session["responses"])
registered = int(session.get("registered", 0))
_active.pop(ping_id, None)
return {
"ok": True,
"ping_id": ping_id,
"timeout_s": timeout_s,
"responses": responses,
"registered": registered,
}

View File

@@ -7,10 +7,12 @@ from typing import Any, Dict, Optional
from models.device import Device, normalize_mac # noqa: F401 — re-export for callers
from models.group import Group
from models.transport import get_current_sender
from models.transport import get_current_bridge
from util.bridge_envelope import build_groups_envelope
from util.espnow_ping import record_ping_rsp
from util.espnow_wire import (
MSG_ANNOUNCE,
MSG_PING_RSP,
WIRE_MAGIC,
mac_bytes_to_hex,
parse_announce,
@@ -24,8 +26,11 @@ async def handle_bridge_uplink(peer_mac: bytes, payload: bytes) -> None:
if not payload:
return
if payload[0] == WIRE_MAGIC:
if wire_msg_type(payload) == MSG_ANNOUNCE:
mt = wire_msg_type(payload)
if mt == MSG_ANNOUNCE:
await handle_espnow_announce(peer_mac, payload)
elif mt == MSG_PING_RSP:
record_ping_rsp(peer_mac, payload)
return
if payload[:1] == b"{":
try:
@@ -128,17 +133,47 @@ async def push_groups_broadcast() -> bool:
return False
async def push_groups_all_espnow_devices() -> Dict[str, Any]:
"""Push ``set_groups`` envelopes to every ESP-NOW device in the registry."""
devices_model = Device()
macs: list[str] = []
skipped = 0
for did, doc in devices_model.items():
if str(doc.get("transport") or "espnow").strip().lower() != "espnow":
continue
mac = normalize_mac(str(did)) or normalize_mac(str(doc.get("address") or ""))
if not mac:
skipped += 1
continue
macs.append(mac)
sent = 0
failed = 0
for mac in macs:
if await push_groups_to_mac(mac):
sent += 1
else:
failed += 1
ok = bool(macs) and failed == 0
return {
"ok": ok,
"sent": sent,
"failed": failed,
"skipped": skipped,
"total": len(macs),
}
async def push_groups_to_mac(mac_hex: str) -> bool:
"""Unicast groups envelope to one driver (set_groups true)."""
mac = normalize_mac(mac_hex)
if not mac:
return False
gids = groups_for_mac(mac, Group())
sender = get_current_sender()
if sender is None:
bridge = get_current_bridge()
if bridge is None:
return False
envelope = build_groups_envelope(mac, gids)
ok = await sender.send(envelope)
ok = await bridge.send(envelope)
if ok:
print(f"[espnow] groups sent mac={mac} groups={gids!r}")
return bool(ok)

View File

@@ -22,6 +22,8 @@ MSG_ANNOUNCE = 0x01
MSG_GROUPS = 0x02
MSG_CMD = 0x03
MSG_GROUP_CMD = 0x04
MSG_PING_REQ = 0x05
MSG_PING_RSP = 0x06
MSG_BRIDGE_CH = 0x10
BROADCAST_MAC = bytes.fromhex("ffffffffffff")
@@ -238,6 +240,49 @@ def parse_group_cmd(payload: bytes) -> Optional[Tuple[str, bytes]]:
return gid, bytes(env)
def pack_ping_req(ping_id: int) -> bytes:
body = struct.pack("<I", int(ping_id) & 0xFFFFFFFF)
return _pack_header(MSG_PING_REQ, body)
def parse_ping_req(payload: bytes) -> Optional[int]:
"""Return ping_id from a PING_REQ packet or body."""
if len(payload) >= 2 and payload[0] == WIRE_MAGIC:
if payload[1] != MSG_PING_REQ:
return None
body = payload[2:]
else:
body = payload
if len(body) < 4:
return None
return int(struct.unpack_from("<I", body, 0)[0])
def pack_ping_rsp(ping_id: int, name: str) -> bytes:
name_b = name.encode("utf-8")
if len(name_b) > 250:
raise ValueError("name too long")
body = struct.pack("<I", int(ping_id) & 0xFFFFFFFF) + bytes([len(name_b)]) + name_b
return _pack_header(MSG_PING_RSP, body)
def parse_ping_rsp(payload: bytes) -> Optional[Dict[str, Any]]:
if len(payload) >= 2 and payload[0] == WIRE_MAGIC:
if payload[1] != MSG_PING_RSP:
return None
body = payload[2:]
else:
body = payload
if len(body) < 5:
return None
ping_id = int(struct.unpack_from("<I", body, 0)[0])
nl = body[4]
if len(body) < 5 + nl:
return None
name = body[5 : 5 + nl].decode("utf-8")
return {"ping_id": ping_id, "name": name}
def pack_bridge_channel(channel: int) -> bytes:
ch = max(1, min(11, int(channel)))
return _pack_header(MSG_BRIDGE_CH, bytes([ch]))

229
src/util/pi_wifi.py Normal file
View File

@@ -0,0 +1,229 @@
"""Pi WiFi helpers via NetworkManager (nmcli)."""
from __future__ import annotations
import asyncio
import re
import shutil
from typing import Any, Dict, List, Optional
def nmcli_available() -> bool:
return shutil.which("nmcli") is not None
async def _run_nmcli(*args: str, timeout_s: float = 30.0) -> tuple[int, str, str]:
proc = await asyncio.create_subprocess_exec(
"nmcli",
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=timeout_s)
except asyncio.TimeoutError:
proc.kill()
raise RuntimeError("nmcli timed out")
stdout = (stdout_b or b"").decode("utf-8", errors="replace")
stderr = (stderr_b or b"").decode("utf-8", errors="replace")
return proc.returncode or 0, stdout, stderr
def _unescape_nmcli(value: str) -> str:
return str(value or "").replace("\\:", ":").replace("\\\\", "\\")
def _interface_display_name(device: str) -> str:
"""Human-readable label for a network interface (USB model, path name, etc.)."""
dev = str(device or "").strip()
if not dev:
return ""
sysfs = f"/sys/class/net/{dev}"
try:
import subprocess
result = subprocess.run(
["udevadm", "info", "-q", "property", "-p", sysfs],
capture_output=True,
text=True,
timeout=5,
check=False,
)
except Exception:
return dev
props: Dict[str, str] = {}
for line in (result.stdout or "").splitlines():
if "=" not in line:
continue
key, value = line.split("=", 1)
props[key] = value.strip()
for key in (
"ID_MODEL_FROM_DATABASE",
"ID_MODEL_ENC",
"ID_USB_MODEL_ENC",
"ID_NET_NAME_PATH",
):
value = props.get(key, "")
if value and value.lower() not in ("n/a", "na"):
return value
vendor = props.get("ID_VENDOR_FROM_DATABASE") or props.get("ID_VENDOR_ENC") or ""
model = props.get("ID_MODEL_ENC") or props.get("ID_USB_MODEL_ENC") or ""
label = f"{vendor} {model}".strip()
return label or dev
def list_wifi_interfaces() -> List[Dict[str, str]]:
if not nmcli_available():
return []
import subprocess
result = subprocess.run(
["nmcli", "-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device", "status"],
capture_output=True,
text=True,
timeout=15,
check=False,
)
out: List[Dict[str, str]] = []
for line in (result.stdout or "").splitlines():
parts = line.split(":")
if len(parts) < 3:
continue
device, dtype = parts[0], parts[1]
if dtype != "wifi":
continue
connection = _unescape_nmcli(parts[-1]) if len(parts) >= 4 else ""
state = _unescape_nmcli(":".join(parts[2:-1] if len(parts) >= 4 else parts[2:]))
label = _interface_display_name(device)
out.append(
{
"device": device,
"type": dtype,
"state": state,
"connection": connection,
"label": label,
}
)
return out
async def scan_wifi(device: str) -> List[Dict[str, Any]]:
if not device:
raise ValueError("device is required")
code, stdout, stderr = await _run_nmcli(
"-t",
"-f",
"SSID,SIGNAL,SECURITY",
"device",
"wifi",
"list",
"ifname",
device,
timeout_s=45.0,
)
if code != 0:
raise RuntimeError(stderr.strip() or stdout.strip() or "wifi scan failed")
networks: List[Dict[str, Any]] = []
seen: set[str] = set()
for line in stdout.splitlines():
line = line.strip()
if not line:
continue
parts = line.split(":")
if len(parts) < 2:
continue
security = _unescape_nmcli(parts[-1])
try:
signal = int(parts[-2])
except (TypeError, ValueError):
continue
ssid = _unescape_nmcli(":".join(parts[:-2]))
if not ssid or ssid in seen:
continue
seen.add(ssid)
networks.append({"ssid": ssid, "signal": signal, "security": security})
networks.sort(key=lambda n: n.get("signal", 0), reverse=True)
return networks
async def rescan_wifi(device: str) -> None:
if not device:
raise ValueError("device is required")
code, stdout, stderr = await _run_nmcli(
"device",
"wifi",
"rescan",
"ifname",
device,
timeout_s=30.0,
)
if code != 0:
msg = stderr.strip() or stdout.strip() or "wifi rescan failed"
raise RuntimeError(msg)
async def ssid_visible(device: str, ssid: str) -> bool:
target = str(ssid or "").strip()
if not target:
return False
for net in await scan_wifi(device):
if str(net.get("ssid") or "") == target:
return True
return False
async def connect_wifi(
*,
device: str,
ssid: str,
password: Optional[str] = None,
rescan: bool = True,
) -> None:
ssid = str(ssid or "").strip()
if not ssid:
raise ValueError("ssid is required")
if not device:
raise ValueError("device is required")
if rescan:
await rescan_wifi(device)
args = ["device", "wifi", "connect", ssid, "ifname", device]
pw = str(password or "").strip()
if pw:
args.extend(["password", pw])
code, stdout, stderr = await _run_nmcli(*args, timeout_s=60.0)
if code != 0:
msg = stderr.strip() or stdout.strip() or "connect failed"
raise RuntimeError(msg)
async def wait_for_device(device: str, *, timeout_s: float = 25.0) -> str:
"""Return connection state string for ``device``."""
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout_s
while loop.time() < deadline:
code, stdout, _stderr = await _run_nmcli(
"-t",
"-f",
"DEVICE,STATE",
"device",
timeout_s=10.0,
)
if code == 0:
for line in stdout.splitlines():
parts = line.split(":")
if len(parts) >= 2 and parts[0] == device:
state = parts[1]
if state in ("connected", "connected (local)"):
return state
await asyncio.sleep(1.0)
raise RuntimeError(f"{device} did not connect within {int(timeout_s)}s")
def build_bridge_ws_url(ap_ip: str, ws_port: int = 80) -> str:
ip = str(ap_ip or "192.168.4.1").strip()
if not re.match(r"^\d{1,3}(\.\d{1,3}){3}$", ip):
raise ValueError("invalid ap_ip")
port = int(ws_port)
if port < 1 or port > 65535:
raise ValueError("invalid ws_port")
return f"ws://{ip}:{port}/ws"

View File

@@ -0,0 +1,331 @@
"""Enumerate capture sources the way PulseAudio / PipeWire presents them (pavucontrol)."""
from __future__ import annotations
import os
import re
import subprocess
from typing import Any, Dict, List, Optional
# Pulse virtual / null sources — not shown in pavucontrol's input list.
_SKIP_PULSE_NAMES = frozenset(
{
"auto_null",
"null",
"echo-cancel-source",
}
)
def _pactl_bin() -> str:
for path in ("/usr/bin/pactl", "/bin/pactl"):
if os.path.isfile(path) and os.access(path, os.X_OK):
return path
return "pactl"
def _pactl_ok(args: List[str]) -> bool:
try:
proc = subprocess.run(
[_pactl_bin(), *args],
capture_output=True,
text=True,
timeout=5,
check=False,
env=os.environ.copy(),
)
except (OSError, subprocess.SubprocessError):
return False
return proc.returncode == 0
def _run_pactl(args: List[str]) -> Optional[str]:
try:
proc = subprocess.run(
[_pactl_bin(), *args],
capture_output=True,
text=True,
timeout=5,
check=False,
env=os.environ.copy(),
)
except (OSError, subprocess.SubprocessError):
return None
if proc.returncode != 0:
return None
return proc.stdout
def _parse_pactl_sources_short(text: str) -> List[Dict[str, str]]:
"""``pactl list sources short`` — tab-separated name per line."""
sources: List[Dict[str, str]] = []
for line in text.splitlines():
line = line.strip()
if not line or line.lower().startswith("source"):
continue
parts = re.split(r"\s+", line, maxsplit=4)
if len(parts) < 2:
continue
name = parts[1].strip()
if not name or name in _SKIP_PULSE_NAMES:
continue
sources.append({"name": name, "description": name})
return sources
def _parse_pactl_sources(text: str) -> List[Dict[str, str]]:
sources: List[Dict[str, str]] = []
block: Dict[str, str] = {}
for raw in text.splitlines():
line = raw.strip()
if line.startswith("Source #"):
if block.get("name"):
sources.append(block)
block = {}
continue
if not line or ":" not in line:
continue
key, val = line.split(":", 1)
key = key.strip().lower()
val = val.strip()
if key == "name":
block["name"] = val
elif key == "description":
block["description"] = val
elif key == "state":
block["state"] = val
if block.get("name"):
sources.append(block)
return sources
def _default_pulse_source_name() -> Optional[str]:
out = _run_pactl(["get-default-source"])
if not out:
return None
name = out.strip()
return name or None
def _name_tokens(*parts: str) -> set:
stop = frozenset(
{
"alsa",
"input",
"output",
"monitor",
"usb",
"device",
"mono",
"stereo",
"analog",
"digital",
"audio",
"source",
"sink",
"pipewire",
"pulse",
"default",
"hw",
"facade",
"capture",
"playback",
}
)
tokens: set = set()
for part in parts:
raw = part.lower().replace(".", " ").replace("-", " ").replace("_", " ")
for tok in re.findall(r"[a-z0-9]+", raw):
if len(tok) >= 2 and tok not in stop:
tokens.add(tok)
return tokens
def _match_sounddevice_index(description: str, pulse_name: str) -> Optional[int]:
try:
import sounddevice as sd
except ImportError:
return None
devices = sd.query_devices()
desc_l = description.lower()
pulse_l = pulse_name.lower()
pulse_tokens = _name_tokens(pulse_name, description)
best: Optional[int] = None
best_score = 0
for idx, dev in enumerate(devices):
chans = int(dev.get("max_input_channels", 0))
sd_name = str(dev.get("name", ""))
sd_l = sd_name.lower()
if chans <= 0 and "monitor" not in sd_l:
continue
score = 0
if sd_name == description:
score = 100
elif desc_l == sd_l:
score = 95
elif desc_l in sd_l or sd_l in desc_l:
score = 80
elif pulse_l in sd_l or sd_l in pulse_l:
score = 60
else:
desc_tokens = _name_tokens(description, sd_name)
overlap = pulse_tokens & desc_tokens
if len(overlap) >= 1:
score = 35 + 15 * len(overlap)
if score < 50 and "monitor" in desc_l and "monitor" in sd_l:
desc_tail = desc_l.replace("monitor of", "").strip()
if desc_tail and desc_tail in sd_l:
score = max(score, 55)
if score > best_score:
best_score = score
best = idx
return best if best_score >= 35 else None
def _looks_like_pulse_source_name(text: str) -> bool:
t = text.strip().lower()
return (
t.startswith("alsa_")
or t.startswith("pulse_")
or ".monitor" in t
or "monitor_of" in t.replace("-", "_")
)
def _sounddevice_index_via_pulse_default(pulse_name: str) -> Optional[int]:
"""Set Pulse default source, then open sounddevice's default input index."""
if not pulse_name or not _pactl_ok(["set-default-source", pulse_name]):
return None
try:
import sounddevice as sd
idx = int(sd.default.device[0])
if idx >= 0:
return idx
except (TypeError, ValueError, ImportError):
pass
return None
def resolve_capture_device(device: Any) -> Any:
"""
Return a sounddevice input index (int) or None for host default.
Accepts int index, numeric string, Pulse source name, or friendly description.
"""
if device is None or device == "":
return None
if isinstance(device, int):
return device
text = str(device).strip()
if not text:
return None
try:
return int(text)
except (TypeError, ValueError):
pass
if _looks_like_pulse_source_name(text):
# Prefer pactl default — works when PortAudio names do not match Pulse ids.
idx = _sounddevice_index_via_pulse_default(text)
if idx is not None:
return idx
idx = _match_sounddevice_index(text, text)
if idx is None:
for src in list_pulse_matched_input_devices():
pn = str(src.get("pulse_name") or "")
if pn == text or pn.startswith(text) or text.startswith(pn):
pid = src.get("id")
if isinstance(pid, int):
return pid
desc = str(src.get("name") or src.get("display_name") or "")
idx = _match_sounddevice_index(desc, pn or text)
if idx is not None:
return idx
if idx is not None:
return idx
raise RuntimeError(
f"No PortAudio capture device for Pulse source {text!r}. "
"Try System default input, or set this source as default in PulseAudio first."
)
idx = _match_sounddevice_index(text, text)
if idx is not None:
return idx
raise RuntimeError(f"No input device matching {text!r}")
def _enrich_pulse_descriptions(sources: List[Dict[str, str]]) -> List[Dict[str, str]]:
"""Merge Description fields from ``pactl list sources`` when available."""
text = _run_pactl(["list", "sources"])
if not text:
return sources
by_name = {s.get("name", ""): s for s in _parse_pactl_sources(text)}
out: List[Dict[str, str]] = []
for src in sources:
name = src.get("name", "")
full = by_name.get(name) or {}
desc = full.get("description") or src.get("description") or name
merged = dict(src)
merged["description"] = desc
out.append(merged)
return out
def list_pulse_matched_input_devices() -> List[Dict[str, Any]]:
"""
Sources from ``pactl list sources``, matched to sounddevice indices when possible.
Returns [] if pactl is unavailable or yields no usable sources.
"""
short = _run_pactl(["list", "sources", "short"])
raw_sources: List[Dict[str, str]] = []
if short:
raw_sources = _parse_pactl_sources_short(short)
if not raw_sources:
text = _run_pactl(["list", "sources"])
if text:
raw_sources = _parse_pactl_sources(text)
if not raw_sources:
return []
raw_sources = _enrich_pulse_descriptions(raw_sources)
default_name = _default_pulse_source_name()
out: List[Dict[str, Any]] = []
for src in raw_sources:
pulse_name = src.get("name", "")
description = src.get("description") or pulse_name
if not pulse_name or pulse_name in _SKIP_PULSE_NAMES:
continue
if description.lower() in ("null", "auto_null"):
continue
desc_l = description.lower()
is_monitor = desc_l.startswith("monitor of") or ".monitor" in pulse_name.lower()
# Pulse source name is stable across refreshes; sounddevice index is not.
device_id: Any = pulse_name
sd_idx = _match_sounddevice_index(description, pulse_name)
is_default = default_name is not None and pulse_name == default_name
display_name = description
if is_default and "(default)" not in display_name.lower():
display_name = f"{display_name} (default)"
label = f"{description} [{pulse_name}]"
if sd_idx is not None:
label = f"[{sd_idx}] {label}"
out.append(
{
"id": device_id,
"name": description,
"display_name": display_name,
"label": label,
"pulse_name": pulse_name,
"sounddevice_index": sd_idx,
"is_monitor": is_monitor,
"is_default": is_default,
"hostapi": "PulseAudio",
}
)
if not out:
return []
out.sort(
key=lambda d: (
0 if d.get("is_monitor") else 1,
str(d.get("display_name") or "").lower(),
)
)
return out

View File

@@ -422,45 +422,58 @@ def _build_lane_wire_presets_map(lane_index: int, ctx: Dict[str, Any]) -> Dict[s
return inner_by_wire
async def _prime_lane(lane_index: int, ctx: Dict[str, Any]) -> None:
"""Upload all lane presets and select step 0 in one message (driver applies presets before select)."""
from models.transport import get_current_sender
from util.beat_driver_route import (
clear_sequence_manual_lane_route,
mark_sequence_manual_lane_select_sent,
set_sequence_manual_lane_route,
)
from util.driver_delivery import deliver_json_messages
def _build_lane_step0_wire_presets_map(lane_index: int, ctx: Dict[str, Any]) -> Dict[str, Any]:
"""Step-0 preset wire body only (one entry in ``presets``)."""
lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
presets_map: Dict[str, Any] = ctx["presets_map"]
palette_colors: List[Any] = ctx["palette_colors"]
lane = lanes[lane_index] if 0 <= lane_index < len(lanes) else []
if not lane:
return {}
step0 = lane[0]
preset_id = str(step0.get("preset_id") or "").strip()
if not preset_id:
return {}
disp = _display_preset_for_step(preset_id, presets_map, palette_colors)
if not disp:
return {}
return {preset_id: _preset_inner_from_display_preset(disp)}
def _build_lane_rest_wire_presets_map(lane_index: int, ctx: Dict[str, Any]) -> Dict[str, Any]:
"""Preset wire bodies for steps 1..n (unique ids, excluding step-0 preset)."""
lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
lane = lanes[lane_index] if 0 <= lane_index < len(lanes) else []
if not lane:
return {}
step0_pid = str(lane[0].get("preset_id") or "").strip()
full = _build_lane_wire_presets_map(lane_index, ctx)
if not step0_pid:
return full
return {k: v for k, v in full.items() if k != step0_pid}
def _prime_lane_step0_context(
lane_index: int, ctx: Dict[str, Any]
) -> Optional[Tuple[Any, List[str], List[str], str, bool]]:
"""Shared step-0 data for priming phases; None when lane has nothing to send."""
lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
presets_map: Dict[str, Any] = ctx["presets_map"]
palette_colors: List[Any] = ctx["palette_colors"]
lane_steps = lanes[lane_index] if 0 <= lane_index < len(lanes) else []
if not lane_steps:
return
inner_by_wire = _build_lane_wire_presets_map(lane_index, ctx)
if not inner_by_wire:
return
return None
step0 = lane_steps[0]
preset_id = str(step0.get("preset_id") or "").strip()
if not preset_id:
return
return None
display_preset = _display_preset_for_step(preset_id, presets_map, palette_colors)
if not display_preset:
return
return None
device_names = _resolve_lane_device_names(lane_index, ctx)
if not device_names:
return
sender = get_current_sender()
if not sender:
raise RuntimeError("Transport not configured")
return None
zone_doc = ctx.get("zone_doc") if isinstance(ctx.get("zone_doc"), dict) else {}
devices_model = ctx["devices"]
num_lanes = int(ctx["num_lanes"])
sequence_doc = ctx["sequence_doc"]
gids = _group_ids_for_lane_step(
@@ -472,15 +485,128 @@ async def _prime_lane(lane_index: int, ctx: Dict[str, Any]) -> None:
gids = [str(g).strip() for g in zg if str(g).strip()]
wire = str(preset_id)
auto = _coerce_auto(display_preset)
delay_s = 0.05
body: Dict[str, Any] = {"v": "1", "presets": dict(inner_by_wire)}
if gids:
body["groups"] = list(gids)
if auto:
body["select"] = [wire]
msg = json.dumps(body, separators=(",", ":"))
await deliver_json_messages(sender, [msg], None, devices_model, delay_s=delay_s)
return display_preset, device_names, gids, wire, auto
_SEQUENCE_PRIME_DELAY_S = 0.0
def _gids_key(gids: List[str]) -> Tuple[str, ...]:
return tuple(sorted(str(g).strip() for g in gids if str(g).strip()))
async def _deliver_presets_body(
ctx: Dict[str, Any],
inner_by_wire: Dict[str, Any],
gids: List[str],
) -> None:
"""Broadcast preset bodies (no select); drivers filter on ``groups`` when set."""
if not inner_by_wire:
return
from models.transport import get_current_bridge
from util.driver_delivery import deliver_json_messages
bridge = get_current_bridge()
if not bridge:
raise RuntimeError("Transport not configured")
body: Dict[str, Any] = {"v": "1", "presets": dict(inner_by_wire)}
gids_key = _gids_key(gids)
if gids_key:
body["groups"] = list(gids_key)
msg = json.dumps(body, separators=(",", ":"))
await deliver_json_messages(
bridge, [msg], None, ctx["devices"], delay_s=_SEQUENCE_PRIME_DELAY_S
)
def _merge_lane_wire_presets_by_gids(
ctx: Dict[str, Any],
build_map,
) -> Dict[Tuple[str, ...], Dict[str, Any]]:
"""Merge per-lane preset maps that share the same group-id set (one broadcast each)."""
merged: Dict[Tuple[str, ...], Dict[str, Any]] = {}
for i in range(int(ctx["num_lanes"])):
inner = build_map(i, ctx)
if not inner:
continue
primed = _prime_lane_step0_context(i, ctx)
if not primed:
continue
_, _, gids, _, _ = primed
key = _gids_key(gids)
merged.setdefault(key, {}).update(inner)
return merged
async def _deliver_merged_presets_by_gids(
ctx: Dict[str, Any],
merged: Dict[Tuple[str, ...], Dict[str, Any]],
) -> None:
for key, inner in merged.items():
await _deliver_presets_body(ctx, inner, list(key))
async def _deliver_lane_presets_map(
lane_index: int,
ctx: Dict[str, Any],
inner_by_wire: Dict[str, Any],
) -> None:
"""Upload a ``presets`` map for one lane (no select in the same message)."""
if not inner_by_wire:
return
primed = _prime_lane_step0_context(lane_index, ctx)
if not primed:
return
_display_preset, _device_names, gids, _wire, _auto = primed
await _deliver_presets_body(ctx, inner_by_wire, gids)
async def _prime_lane_step0_presets(lane_index: int, ctx: Dict[str, Any]) -> None:
"""Phase 1: step-0 preset body only for one lane."""
inner = _build_lane_step0_wire_presets_map(lane_index, ctx)
await _deliver_lane_presets_map(lane_index, ctx, inner)
async def _prime_lane_rest_presets(lane_index: int, ctx: Dict[str, Any]) -> None:
"""Phase 4: remaining lane preset bodies (steps 1..n, not step 0)."""
inner = _build_lane_rest_wire_presets_map(lane_index, ctx)
await _deliver_lane_presets_map(lane_index, ctx, inner)
async def _prime_lane_select(lane_index: int, ctx: Dict[str, Any]) -> None:
"""Phase 2: select step 0 for one lane (separate message from presets)."""
from models.transport import get_current_bridge
from util.driver_delivery import deliver_json_messages
primed = _prime_lane_step0_context(lane_index, ctx)
if not primed:
return
_display_preset, _device_names, gids, wire, _auto = primed
bridge = get_current_bridge()
if not bridge:
raise RuntimeError("Transport not configured")
body: Dict[str, Any] = {"v": "1", "select": [wire]}
gids_key = _gids_key(gids)
if gids_key:
body["groups"] = list(gids_key)
msg = json.dumps(body, separators=(",", ":"))
await deliver_json_messages(
bridge, [msg], None, ctx["devices"], delay_s=_SEQUENCE_PRIME_DELAY_S
)
def _prime_lane_after_select(lane_index: int, ctx: Dict[str, Any]) -> None:
"""After select: manual beat-route registration for one lane."""
from util.beat_driver_route import (
clear_sequence_manual_lane_route,
mark_sequence_manual_lane_select_sent,
set_sequence_manual_lane_route,
)
primed = _prime_lane_step0_context(lane_index, ctx)
if not primed:
return
display_preset, device_names, gids, wire, auto = primed
if auto:
clear_sequence_manual_lane_route(lane_index)
else:
@@ -491,10 +617,33 @@ async def _prime_lane(lane_index: int, ctx: Dict[str, Any]) -> None:
mark_sequence_manual_lane_select_sent(lane_index)
def _reset_after_sequence_change() -> None:
"""After sequence priming: zero beat-route strides and reset live audio tracking."""
from util.beat_driver_route import reset_manual_lane_strides
reset_manual_lane_strides()
try:
from util import audio_detector as ad_mod
det = getattr(ad_mod, "_shared_beat_detector", None)
if det is not None:
det.reset_tracking()
except Exception:
pass
async def _prime_all_lanes(ctx: Dict[str, Any]) -> None:
"""One-shot preset upload + first-step select per lane (to each lane's groups)."""
for i in range(int(ctx["num_lanes"])):
await _prime_lane(i, ctx)
"""Sequence start: step-0 presets, select, rest presets, then beat/route reset."""
num_lanes = int(ctx["num_lanes"])
step0 = _merge_lane_wire_presets_by_gids(ctx, _build_lane_step0_wire_presets_map)
await _deliver_merged_presets_by_gids(ctx, step0)
for i in range(num_lanes):
await _prime_lane_select(i, ctx)
for i in range(num_lanes):
_prime_lane_after_select(i, ctx)
rest = _merge_lane_wire_presets_by_gids(ctx, _build_lane_rest_wire_presets_map)
await _deliver_merged_presets_by_gids(ctx, rest)
_reset_after_sequence_change()
ctx["_presets_delivered"] = True
ctx["_sequence_primed"] = True
@@ -516,12 +665,12 @@ def _parse_zone_brightness_value(zone_doc: Any) -> int:
async def _deliver_zone_brightness_for_sequence(ctx: Dict[str, Any]) -> None:
"""Apply zone/global/group/device brightness like the zone slider (not inside preset ``b``)."""
from models.transport import get_current_sender
from models.transport import get_current_bridge
from util.brightness_combine import effective_brightness_for_mac
from util.driver_delivery import deliver_json_messages
sender = get_current_sender()
if not sender:
bridge = get_current_bridge()
if not bridge:
return
macs = _union_macs_for_sequence(ctx)
if not macs:
@@ -541,7 +690,7 @@ async def _deliver_zone_brightness_for_sequence(ctx: Dict[str, Any]) -> None:
)
msg = json.dumps({"v": "1", "b": eff, "save": True}, separators=(",", ":"))
await deliver_json_messages(
sender, [msg], [mac], devices_model, delay_s=0.05, unicast=True
bridge, [msg], [mac], devices_model, delay_s=0.05, unicast=True
)
@@ -696,7 +845,7 @@ async def _send_lane(
if gids and not device_names:
return
from models.transport import get_current_sender
from models.transport import get_current_bridge
from util.beat_driver_route import (
clear_sequence_manual_lane_route,
mark_sequence_manual_lane_select_sent,
@@ -704,8 +853,8 @@ async def _send_lane(
)
from util.driver_delivery import deliver_json_messages
sender = get_current_sender()
if not sender:
bridge = get_current_bridge()
if not bridge:
raise RuntimeError("Transport not configured")
if not device_names and not gids:
@@ -713,19 +862,31 @@ async def _send_lane(
wire = str(preset_id)
auto = _coerce_auto(display_preset)
# On sequence step changes, push only the preset we are switching to.
prev_wire = str(st.get("_last_wire") or "")
if wire != prev_wire:
preset_body: Dict[str, Any] = {
"v": "1",
"presets": {wire: _preset_inner_from_display_preset(display_preset)},
}
if gids:
preset_body["groups"] = [str(g) for g in gids]
preset_msg = json.dumps(preset_body, separators=(",", ":"))
await deliver_json_messages(bridge, [preset_msg], None, devices, delay_s=0.05)
st["_last_wire"] = wire
body: Dict[str, Any] = {"v": "1", "select": [wire]}
if gids:
body["groups"] = [str(g) for g in gids]
msg = json.dumps(body, separators=(",", ":"))
if auto:
clear_sequence_manual_lane_route(lane_index)
await deliver_json_messages(sender, [msg], None, devices, delay_s=0.05)
await deliver_json_messages(bridge, [msg], None, devices, delay_s=0.05)
else:
inner = _preset_inner_from_display_preset(display_preset)
set_sequence_manual_lane_route(
lane_index, device_names, wire, inner, group_ids=gids or None
)
await deliver_json_messages(sender, [msg], None, devices, delay_s=0.05)
await deliver_json_messages(bridge, [msg], None, devices, delay_s=0.05)
mark_sequence_manual_lane_select_sent(lane_index)
@@ -772,12 +933,6 @@ def _build_ctx(
}
def playback_active() -> bool:
"""True while a zone sequence run is active (step timing owned by ``process_active_beat_advance``)."""
with _beat_run_lock:
return _beat_run is not None
def playback_status() -> Dict[str, Any]:
"""Snapshot for UI (e.g. audio status poll): lane 0 step + beats within step, total steps sum."""
with _beat_run_lock:
@@ -891,6 +1046,8 @@ async def process_active_beat_advance() -> None:
if loop:
if i == 0:
lane0_looped = True
# Force step-0 preset re-upload on loop wrap, even if wire id matches.
st["_last_wire"] = ""
st["stepIdx"] = 0
await _send_lane(i, st, ctx)
else:
@@ -910,7 +1067,7 @@ async def process_active_beat_advance() -> None:
async def _clear_devices_after_sequence(ctx: Dict[str, Any]) -> None:
"""Stop beat routing and clear driver presets for devices used by this sequence run."""
from models.transport import get_current_sender
from models.transport import get_current_bridge
from util.beat_driver_route import clear_sequence_manual_lane_route, update_beat_route
from util.driver_delivery import deliver_json_messages
@@ -919,8 +1076,8 @@ async def _clear_devices_after_sequence(ctx: Dict[str, Any]) -> None:
clear_sequence_manual_lane_route(i)
update_beat_route({"enabled": False})
sender = get_current_sender()
if not sender:
bridge = get_current_bridge()
if not bridge:
return
devices = ctx.get("devices")
zone_doc = ctx.get("zone_doc") if isinstance(ctx.get("zone_doc"), dict) else {}
@@ -936,7 +1093,7 @@ async def _clear_devices_after_sequence(ctx: Dict[str, Any]) -> None:
if gids:
body["groups"] = gids
msg = json.dumps(body, separators=(",", ":"))
await deliver_json_messages(sender, [msg], None, devices, delay_s=0.05)
await deliver_json_messages(bridge, [msg], None, devices, delay_s=0.05)
def _halt_playback_state() -> Optional[Dict[str, Any]]:

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional, Union
from typing import Any, Dict, List, Optional
# Envelope: devices map
ENV_DEVICES = "dv"
@@ -33,14 +33,6 @@ _BODY_LONG_TO_SHORT = {
_BODY_SHORT_TO_LONG = {v: k for k, v in _BODY_LONG_TO_SHORT.items()}
def wire_select_list(preset_id: Union[str, int], step: Optional[Union[int, str]] = None) -> List[Any]:
"""Preset id (+ optional step) for ``select`` on unicast/broadcast to one driver."""
out: List[Any] = [str(preset_id)]
if step is not None:
out.append(step)
return out
def normalize_select_for_wire(select: Any) -> Any:
"""Long or legacy shapes → wire list ``[preset_id, step?]``."""
if isinstance(select, list):