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

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

View File

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

View File

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

View File

@@ -71,8 +71,6 @@ def write_audio_run_state(
else str(prev.get("device_select") or "")
),
}
if device_select is None and device is not None:
data["device_select"] = str(device)
else:
data = {
"enabled": False,

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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