feat(bridge): add wifi/serial bridge runtime and UI
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
282
src/controllers/wifi_bridge.py
Normal file
282
src/controllers/wifi_bridge.py
Normal file
@@ -0,0 +1,282 @@
|
||||
"""Pi Wi‑Fi 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": "Wi‑Fi 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",
|
||||
}
|
||||
110
src/main.py
110
src/main.py
@@ -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:
|
||||
|
||||
199
src/models/bridge_serial_client.py
Normal file
199
src/models/bridge_serial_client.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 Wi‑Fi 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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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; 1–11
|
||||
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 (0–255); 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, 0–200).
|
||||
if 'audio_input_volume' not in self:
|
||||
self['audio_input_volume'] = 100
|
||||
|
||||
def save(self):
|
||||
try:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 Wi‑Fi 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 Wi‑Fi 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');
|
||||
|
||||
@@ -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' : 'Wi‑Fi';
|
||||
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' : 'Wi‑Fi'],
|
||||
[
|
||||
'Wi‑Fi 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'
|
||||
? `Wi‑Fi ${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 || 'Wi‑Fi 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 Wi‑Fi 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} — Wi‑Fi ${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 Wi‑Fi 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('Wi‑Fi 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();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
27
src/static/zone-devices-panel.js
Normal file
27
src/static/zone-devices-panel.js
Normal 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;
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 (1–11) 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 Wi‑Fi 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">Wi‑Fi</h3>
|
||||
<p class="muted-text" style="margin-top:0;margin-bottom:0.75rem;">Pi joins the bridge access point, then connects to <code>ws://<bridge-ip>/ws</code>.</p>
|
||||
<div class="form-group">
|
||||
<label for="bridge-wifi-interface">Wi‑Fi 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 Wi‑Fi</button>
|
||||
<button type="button" class="btn btn-secondary btn-small" id="bridge-wifi-save-profile-btn">Save Wi‑Fi 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>
|
||||
|
||||
@@ -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 (1–11) for LED drivers and the serial bridge. Use the same value on every device.</small>
|
||||
<small>2.4 GHz channel (1–11) 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 (1–11). Stored for reference; set <code>wifi_channel</code> on the bridge itself and reboot it to apply.</small>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
201
src/util/bridge_for_group.py
Normal file
201
src/util/bridge_for_group.py
Normal 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
|
||||
67
src/util/bridge_profiles.py
Normal file
67
src/util/bridge_profiles.py
Normal 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
233
src/util/bridge_runtime.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""Start or refresh the bridge client after Wi‑Fi 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, "Wi‑Fi interface not configured (Settings → Bridge Wi‑Fi)"
|
||||
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 "Wi‑Fi 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)
|
||||
38
src/util/bridge_serial_frame.py
Normal file
38
src/util/bridge_serial_frame.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
86
src/util/espnow_ping.py
Normal 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,
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
229
src/util/pi_wifi.py
Normal file
@@ -0,0 +1,229 @@
|
||||
"""Pi Wi‑Fi 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"
|
||||
331
src/util/pulse_audio_devices.py
Normal file
331
src/util/pulse_audio_devices.py
Normal 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
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user