"""Audio control endpoints — mic mute, speaker mute, device profile selection.""" from __future__ import annotations import asyncio import os import subprocess import threading from fastapi import APIRouter, HTTPException from pydantic import BaseModel from Project.Sanad.core.logger import get_logger from Project.Sanad.voice import audio_devices as ad log = get_logger("audio_route") router = APIRouter() # ─────────────────────── G1 built-in speaker (DDS) ─────────────────────── # # pactl set-sink-mute has NO effect on the G1 built-in speaker because # sanad_voice.py streams PCM to it via the Unitree DDS AudioClient API, # bypassing PulseAudio entirely. To actually silence the built-in speaker # mid-playback we must call AudioClient.SetVolume(0) over DDS. # # This module keeps a lazily-initialized AudioClient + a cached volume so # the dashboard can mute/unmute without waiting on DDS init for every click. _g1_audio_client = None _g1_audio_lock = threading.Lock() _g1_current_volume: int = 100 # what's actually on the hardware right now _g1_user_volume: int = 100 # the user's preferred "unmuted" level _g1_init_error: str = "" def _load_persisted_g1_volume() -> int: """Read the saved G1 volume from data/motions/config.json. Keys are `audio.g1_volume` (persistent target level 0-100). Returns 100 if no value is stored — matches the default the Unitree SDK sets on the voice service. """ try: from Project.Sanad.config import load_config cfg = load_config() or {} audio = cfg.get("audio") or {} vol = int(audio.get("g1_volume", 100)) return max(0, min(100, vol)) except Exception: return 100 def _save_persisted_g1_volume(level: int) -> None: """Persist the user's volume choice to config.json so it survives restart.""" try: from Project.Sanad.config import load_config, save_config cfg = load_config() or {} audio = cfg.get("audio") if isinstance(cfg.get("audio"), dict) else {} audio["g1_volume"] = max(0, min(100, int(level))) cfg["audio"] = audio save_config(cfg) except Exception as exc: log.warning("could not persist g1_volume: %s", exc) # Initialize user volume from the persisted value so the dashboard shows # the correct level on first load even if no one has touched it yet. _g1_user_volume = _load_persisted_g1_volume() _g1_current_volume = _g1_user_volume def _get_g1_audio_client(): """Lazy-init AudioClient. Safe to call from multiple routes.""" global _g1_audio_client, _g1_init_error if _g1_audio_client is not None: return _g1_audio_client try: from unitree_sdk2py.core.channel import ChannelFactoryInitialize from unitree_sdk2py.g1.audio.g1_audio_client import AudioClient except ImportError as exc: _g1_init_error = f"unitree_sdk2py not installed: {exc}" return None iface = os.environ.get("SANAD_DDS_INTERFACE", "eth0") # ChannelFactoryInitialize can only be called once per process. The # arm controller normally calls it first at startup — the second call # either no-ops or raises, so wrap it defensively. try: ChannelFactoryInitialize(0, iface) except Exception as exc: log.debug("ChannelFactoryInitialize already called or failed: %s", exc) try: client = AudioClient() client.SetTimeout(5.0) client.Init() _g1_audio_client = client log.info("G1 AudioClient initialized for dashboard mute control (iface=%s)", iface) return client except Exception as exc: _g1_init_error = f"AudioClient init failed: {exc}" log.warning("G1 AudioClient init failed: %s", exc) return None def _pactl(args: list[str]) -> subprocess.CompletedProcess[str]: return subprocess.run(["pactl", *args], check=True, text=True, capture_output=True) def _get_muted(kind: str, name: str) -> bool: if not name: return False try: cmd = "get-source-mute" if kind == "source" else "get-sink-mute" r = _pactl([cmd, name]) return (r.stdout or "").strip().lower().endswith("yes") except (FileNotFoundError, subprocess.CalledProcessError): return False def _set_muted(kind: str, name: str, muted: bool) -> bool: if not name: return False cmd = "set-source-mute" if kind == "source" else "set-sink-mute" _pactl([cmd, name, "1" if muted else "0"]) return _get_muted(kind, name) def _current_sink_source() -> tuple[str, str]: cur = ad.current_selection() return cur.get("sink", ""), cur.get("source", "") # ─────────────────────── status / mute ─────────────────────── @router.get("/status") async def audio_status(): """Return current device + mute state + G1 speaker volume. `speaker_muted` is the EFFECTIVE mute state — True if either the PulseAudio sink is muted OR the G1 built-in speaker volume is 0. `pulse_sink_muted` and `g1_speaker_muted` are the per-path states. `g1_current_volume` = what's live on the hardware. `g1_user_volume` = the user's preferred unmuted level (what we restore to when they un-mute). """ def _do(): sink, source = _current_sink_source() cur = ad.current_selection() pulse_muted = _get_muted("sink", sink) # Read cached state — avoid DDS GetVolume round-trips on every poll global _g1_current_volume, _g1_user_volume g1_muted = _g1_current_volume == 0 return { "mic_muted": _get_muted("source", source), # Effective (OR of both paths) — the badge the user sees "speaker_muted": pulse_muted or g1_muted, # Per-path breakdown so the UI can distinguish "pulse_sink_muted": pulse_muted, "g1_speaker_muted": g1_muted, "g1_current_volume": _g1_current_volume, "g1_user_volume": _g1_user_volume, # Only report available once an AudioClient has actually been # built — reporting True before any init attempt made the UI # advertise G1 speaker controls that then 503 on first use. # `g1_init_error` surfaces *why* it's unavailable (or "" if # init was never attempted yet). "g1_available": _g1_audio_client is not None, "g1_init_error": _g1_init_error, "sink": sink, "source": source, "current": cur, "pactl_available": ad.pactl_available(), } return await asyncio.to_thread(_do) @router.post("/mic/mute") async def toggle_mic(muted: bool | None = None): def _do(): _, source = _current_sink_source() if not source: raise HTTPException(503, "No source device selected") target = muted if muted is not None else not _get_muted("source", source) try: actual = _set_muted("source", source, target) except (FileNotFoundError, subprocess.CalledProcessError) as exc: raise HTTPException(500, f"pactl failed: {exc}") return {"mic_muted": actual, "source": source} return await asyncio.to_thread(_do) @router.post("/speaker/mute") async def toggle_speaker(muted: bool | None = None): """Mute/unmute the SPEAKER — both the PulseAudio sink AND the G1 built-in speaker, so the effect is audible regardless of which playback path is currently active (Anker PowerConf via PyAudio vs G1 built-in via Unitree DDS AudioClient). Each of the two paths is attempted independently; the response reports which one(s) succeeded. If either path is muted, the button shows as "Muted". """ def _do(): global _g1_current_volume, _g1_user_volume sink, _ = _current_sink_source() # Decide target state — if muted is None, toggle based on # whichever path is currently "not muted" if muted is None: pulse_cur = _get_muted("sink", sink) if sink else False g1_cur = _g1_current_volume == 0 # Toggle: if anything is live, mute everything; else unmute all target = not (pulse_cur or g1_cur) else: target = bool(muted) result = {"speaker_muted": target, "pulse": None, "g1": None} # ── Path 1: PulseAudio sink (Anker PowerConf, USB, etc.) ── if sink: try: actual_pulse = _set_muted("sink", sink, target) result["pulse"] = {"ok": True, "muted": actual_pulse, "sink": sink} except (FileNotFoundError, subprocess.CalledProcessError) as exc: result["pulse"] = {"ok": False, "error": f"pactl failed: {exc}"} else: result["pulse"] = {"ok": False, "error": "no sink selected"} # ── Path 2: G1 built-in speaker via DDS AudioClient ── # Mute = SetVolume(0). Unmute = SetVolume(_g1_user_volume) so the # user's chosen level is restored (instead of always jumping back # to 100). client = _get_g1_audio_client() if client is None: result["g1"] = {"ok": False, "error": _g1_init_error or "AudioClient unavailable"} else: volume = 0 if target else _g1_user_volume try: with _g1_audio_lock: code = client.SetVolume(volume) _g1_current_volume = volume result["g1"] = { "ok": True, "muted": volume == 0, "volume": volume, "code": code, } log.info("G1 speaker volume set to %d (rc=%s)", volume, code) except Exception as exc: result["g1"] = {"ok": False, "error": f"SetVolume failed: {exc}"} # Final effective state — either path counts as muted pulse_muted = result["pulse"].get("muted", False) if result["pulse"] else False g1_muted = result["g1"].get("muted", False) if result["g1"] else False result["speaker_muted"] = bool(pulse_muted or g1_muted) if target else False result["sink"] = sink result["g1_current_volume"] = _g1_current_volume result["g1_user_volume"] = _g1_user_volume return result return await asyncio.to_thread(_do) @router.post("/g1-speaker/mute") async def toggle_g1_speaker_only(muted: bool | None = None): """Mute/unmute ONLY the G1 built-in speaker via DDS AudioClient. Useful for testing the DDS path in isolation — the normal /speaker/mute endpoint hits both PulseAudio and G1 at once. On unmute, restores the user's last chosen volume (not always 100). """ def _do(): global _g1_current_volume client = _get_g1_audio_client() if client is None: raise HTTPException( 503, f"G1 AudioClient unavailable: {_g1_init_error or 'unknown'}", ) if muted is None: target = _g1_current_volume > 0 # toggle else: target = bool(muted) volume = 0 if target else _g1_user_volume try: with _g1_audio_lock: code = client.SetVolume(volume) _g1_current_volume = volume except Exception as exc: raise HTTPException(500, f"SetVolume failed: {exc}") log.info("G1 speaker volume set to %d (rc=%s)", volume, code) return { "g1_muted": volume == 0, "volume": volume, "user_volume": _g1_user_volume, "return_code": code, } return await asyncio.to_thread(_do) # ─────────────────────── G1 speaker volume (0-100) ─────────────────────── class G1VolumePayload(BaseModel): level: int # 0..100 @router.get("/g1-speaker/volume") async def get_g1_volume(): """Return the current G1 speaker volume state. Response: { "available": true, # AudioClient available? "current_volume": 75, # what's on hardware right now "user_volume": 75, # user's preferred unmuted level "muted": false, # current_volume == 0 "persisted": 75, # value from config.json } """ def _do(): return { # True only after an AudioClient was actually constructed — # `init_error` (below) explains an unavailable/never-tried state. "available": _g1_audio_client is not None, "current_volume": _g1_current_volume, "user_volume": _g1_user_volume, "muted": _g1_current_volume == 0, "persisted": _load_persisted_g1_volume(), "init_error": _g1_init_error, } return await asyncio.to_thread(_do) @router.post("/g1-speaker/volume") async def set_g1_volume(payload: G1VolumePayload): """Set the G1 built-in speaker volume via DDS AudioClient. Body: `{"level": 0..100}` Effects: - Immediately applies to hardware via AudioClient.SetVolume(level). - Persists to data/motions/config.json under `audio.g1_volume` so it survives restarts. - If level > 0, updates _g1_user_volume (the "unmuted" restore target). level == 0 is a soft mute that preserves user_volume. - Takes effect on the live playback immediately — you can slide the volume down mid-speech and hear it get quieter. """ def _do(): global _g1_current_volume, _g1_user_volume level = int(payload.level) if not 0 <= level <= 100: raise HTTPException(400, "level must be 0..100") # 1) G1 chest speaker (DDS) — best-effort so it works even when an # external sink (JBL) is the active output. code = None client = _get_g1_audio_client() if client is not None: try: with _g1_audio_lock: code = client.SetVolume(level) _g1_current_volume = level except Exception as exc: log.warning("G1 SetVolume failed: %s", exc) if level > 0: _g1_user_volume = level # 2) The ACTIVE profile's PulseAudio sink (JBL / Anker / …). Target the # RESOLVED sink from the saved selection, NOT @DEFAULT_SINK@ — the PA # default can be a different sink (e.g. the chest platform-sound) even # when the JBL is the selected output, so @DEFAULT_SINK@ would move the # wrong sink and the slider would appear to do nothing on the JBL. pa_applied = False try: sink = (ad.load_state() or {}).get("sink") or "@DEFAULT_SINK@" _pactl(["set-sink-volume", sink, "%d%%" % level]) if level > 0: _pactl(["set-sink-mute", sink, "0"]) pa_applied = True except Exception as exc: log.warning("PA set-sink-volume failed: %s", exc) if client is None and not pa_applied: raise HTTPException(503, "No speaker available (G1 + PulseAudio both failed)") _save_persisted_g1_volume(_g1_user_volume) log.info("volume → %d (g1_rc=%s, pa=%s, user_pref=%d)", level, code, pa_applied, _g1_user_volume) return { "ok": True, "current_volume": level, "user_volume": _g1_user_volume, "muted": level == 0, "return_code": code, "pa_applied": pa_applied, "persisted": True, } return await asyncio.to_thread(_do) # ─────────────────────── device profiles ─────────────────────── @router.get("/devices") async def list_devices(): """Full device + profile listing for the dashboard picker.""" return await asyncio.to_thread(ad.status) @router.get("/profiles") async def list_profiles(): """Just the named profiles + which are currently plugged in.""" def _do(): from dataclasses import asdict detected = ad.detect_plugged_profiles() if ad.pactl_available() else [] detected_ids = {d["profile"]["id"] for d in detected} return { "profiles": [ { **asdict(p), "available": p.id in detected_ids, } for p in ad.PROFILES ], "detected_ids": list(detected_ids), } return await asyncio.to_thread(_do) class ProfileSelect(BaseModel): profile_id: str @router.post("/select-profile") async def select_profile(payload: ProfileSelect): def _do(): result = ad.select_profile(payload.profile_id) if not result.get("ok"): raise HTTPException(409, result.get("error") or "Could not select profile") # Best-effort: tell the audio_manager to refresh its cached state try: from Project.Sanad.main import audio_mgr if audio_mgr is not None and hasattr(audio_mgr, "refresh_devices"): audio_mgr.refresh_devices() except Exception: pass return result return await asyncio.to_thread(_do) class ManualSelect(BaseModel): sink: str source: str @router.post("/select-manual") async def select_manual(payload: ManualSelect): def _do(): if not payload.sink and not payload.source: raise HTTPException(400, "At least one of sink/source required") result = ad.select_manual(payload.sink, payload.source) if not result.get("ok"): raise HTTPException(500, str(result.get("errors") or "Selection failed")) try: from Project.Sanad.main import audio_mgr if audio_mgr is not None and hasattr(audio_mgr, "refresh_devices"): audio_mgr.refresh_devices() except Exception: pass return result return await asyncio.to_thread(_do) @router.post("/refresh") async def refresh_devices(): """Re-scan plugged devices and re-resolve current selection.""" return await asyncio.to_thread(ad.status) @router.post("/apply") async def apply_audio(): """Re-scan all USB ports, resolve the best profile, and set pactl defaults. Use this after plugging/unplugging devices or switching USB ports. """ def _do(): result = ad.apply_current_selection() # Also refresh AudioManager so it picks up the new sink/source try: from Project.Sanad.main import audio_mgr if audio_mgr is not None: audio_mgr.refresh_devices() except Exception: pass # Hot-swap the live Gemini voice to the selected profile too, so picking # a device (e.g. the JBL) moves BOTH record playback AND the live voice # to it — without dropping the session. Best-effort; no-op if not running. try: from Project.Sanad.main import live_sub pid = (ad.load_state() or {}).get("profile_id") if (pid and live_sub is not None and hasattr(live_sub, "send_profile") and hasattr(live_sub, "is_running") and live_sub.is_running()): live_sub.send_profile(pid, reason="dashboard audio Apply") except Exception: pass # Restore the user's SAVED volume to the selected sink (USB/BT speakers # like the JBL otherwise come back at a low PulseAudio default). Use the # saved level, NOT a forced 100%, so the slider/sink keep the user's # choice across selects + restarts. Target the resolved sink. try: sink = (ad.load_state() or {}).get("sink") or "@DEFAULT_SINK@" _pactl(["set-sink-volume", sink, "%d%%" % _g1_user_volume]) if _g1_user_volume > 0: _pactl(["set-sink-mute", sink, "0"]) except Exception: pass return result return await asyncio.to_thread(_do) # ─────────────────────── Reset endpoints (Pulse + USB) ─────────────────────── # # Two distinct recovery paths for the dashboard's audio panel: # # POST /api/audio/reset — SOFT: restart pulseaudio / pipewire-pulse. # Fixes Pulse-side state (stuck profile, lost default sink, crashed # module). Cannot recover a kernel-side missing USB capture descriptor # — snd-usb-audio parses those at probe time and Pulse can't influence # that. Use for "devices look weird" failures. # # POST /api/audio/usb-reset — HARD: unbind+rebind snd-usb-audio scoped # to the Anker VID:PID. Forces snd-usb-audio to re-parse UAC1 # descriptors → input profile reappears even after the firmware/USB # handshake dropped it. Use for "Anker mic missing from pactl" — the # symptom soft-reset cannot fix. # # Both gate with module-level locks (no concurrent reset), refuse while Live # Gemini is running or a record is mid-playback, and return structured # before/after diagnostics so the dashboard can show meaningful toasts. _RESET_LOCK = threading.Lock() _USB_RESET_LOCK = threading.Lock() # Anker PowerConf A3321 — used both for VID:PID matching in sysfs and for # logging. Change here if you add support for a different USB conference # device (Hollyland etc). _USB_RESET_TARGETS = ( {"vid": "291a", "pid": "3301", "label": "Anker PowerConf"}, ) def _refuse_if_busy() -> None: """Raise HTTPException(409) if Live Gemini is active or a record is playing. Used by both reset endpoints — a userspace audio restart mid-stream leaves the active session in a broken state (PortAudio handle pointing at a dead Pulse, in-flight write() raises, etc.). Cheaper to refuse than to recover. """ try: from Project.Sanad.main import live_sub except Exception: live_sub = None if live_sub is not None: try: st = live_sub.status() or {} except Exception: st = {} state = (st.get("state") or "").lower() if st.get("running") or state not in ("", "stopped", "error"): raise HTTPException( 409, f"Stop Live Gemini before resetting audio (state={state or '?'}).", ) try: from Project.Sanad.main import audio_mgr except Exception: audio_mgr = None if audio_mgr is not None and hasattr(audio_mgr, "playback_status"): try: ps = audio_mgr.playback_status() or {} if ps.get("playing"): raise HTTPException( 409, "Stop the active playback before resetting audio.", ) except HTTPException: raise except Exception: pass def _detect_pa_flavour() -> str: """Return 'pipewire' if pipewire-pulse is the active daemon, else 'pulse'.""" try: r = subprocess.run( ["pgrep", "-x", "pipewire-pulse"], check=False, capture_output=True, text=True, timeout=1.0, ) if r.returncode == 0 and (r.stdout or "").strip(): return "pipewire" except (FileNotFoundError, subprocess.SubprocessError): pass return "pulse" def _kill_audio_daemon(flavour: str) -> dict: """Issue the restart command for the detected daemon. Non-zero exit is a soft warning (some installs return 1 when there's no daemon to kill).""" if flavour == "pipewire": cmd = ["systemctl", "--user", "restart", "pipewire-pulse.service"] else: cmd = ["pulseaudio", "-k"] try: r = subprocess.run(cmd, check=False, capture_output=True, text=True, timeout=5.0) info = {"cmd": " ".join(cmd), "returncode": r.returncode, "stderr": (r.stderr or "").strip()[:300]} if r.returncode != 0: log.warning("audio reset: %s exited %d (%s)", cmd[0], r.returncode, info["stderr"]) return info except FileNotFoundError as exc: return {"cmd": " ".join(cmd), "returncode": -1, "stderr": f"binary missing: {exc}"} except subprocess.TimeoutExpired: return {"cmd": " ".join(cmd), "returncode": -1, "stderr": "timeout (>5s)"} def _wait_for_pactl(deadline_s: float = 5.0, interval_s: float = 0.2) -> bool: """Poll `pactl info` until it returns 0 or the deadline expires.""" import time as _time end = _time.monotonic() + deadline_s while _time.monotonic() < end: if ad.pactl_available(): return True _time.sleep(interval_s) return False @router.post("/reset") async def reset_audio_subsystem(): """SOFT reset — restart pulseaudio/pipewire-pulse and re-resolve devices. Use when devices look stuck, pactl is unavailable, or the wrong sink is being selected. **Does NOT recover a kernel-side missing USB capture descriptor** — for that symptom use /api/audio/usb-reset. """ if os.geteuid() == 0: raise HTTPException( 403, "Refusing to reset audio as root — Sanad must run as the " "unitree user so the per-user PulseAudio session is reachable.", ) if not _RESET_LOCK.acquire(blocking=False): raise HTTPException(429, "Reset already in progress.") try: _refuse_if_busy() log.info( "audio reset requested (uid=%s PULSE_RUNTIME_PATH=%s XDG_RUNTIME_DIR=%s)", os.geteuid(), os.environ.get("PULSE_RUNTIME_PATH") or "-", os.environ.get("XDG_RUNTIME_DIR") or "-", ) try: from Project.Sanad.main import audio_mgr except Exception: audio_mgr = None def _do() -> dict: before = {"pactl_available": ad.pactl_available(), "selection": ad.current_selection()} # Quiesce AudioManager so the next play_wav rebinds cleanly. pya_closed = False if audio_mgr is not None: play_lock = getattr(audio_mgr, "play_lock", None) acquired = False if play_lock is not None: acquired = play_lock.acquire(timeout=2.0) try: try: audio_mgr.close() pya_closed = True except Exception as exc: log.warning("audio reset: audio_mgr.close failed: %s", exc) finally: if acquired and play_lock is not None: play_lock.release() flavour = _detect_pa_flavour() kill_info = _kill_audio_daemon(flavour) came_back = _wait_for_pactl(deadline_s=5.0) if not came_back and flavour == "pulse": # autospawn may be disabled — try an explicit start. try: subprocess.run(["pulseaudio", "--start"], check=False, capture_output=True, text=True, timeout=3.0) except (FileNotFoundError, subprocess.SubprocessError) as exc: log.warning("audio reset: pulseaudio --start failed: %s", exc) came_back = _wait_for_pactl(deadline_s=2.0) if not came_back: raise HTTPException(500, { "error": "audio daemon did not return within ~7s", "flavour": flavour, "kill": kill_info, }) apply_result: dict = {} try: apply_result = ad.apply_current_selection() or {} except Exception as exc: log.warning("audio reset: apply_current_selection failed: %s", exc) apply_result = {"error": str(exc)} if audio_mgr is not None: try: import pyaudio audio_mgr.pya = pyaudio.PyAudio() audio_mgr.refresh_devices() except Exception as exc: log.error("audio reset: PyAudio re-init failed: %s", exc) raise HTTPException( 500, f"PortAudio re-init failed after daemon restart: {exc}") after_sel = ad.current_selection() or {} detected = ad.detect_plugged_profiles() or [] after = { "pactl_available": ad.pactl_available(), "selection": after_sel, "detected_profiles": [p.get("profile", {}).get("id") for p in detected], } return { "ok": True, "best_effort": True, "flavour": flavour, "kill": kill_info, "pya_reinitialized": pya_closed, "apply_result": apply_result, "input_recovered": bool(after_sel.get("source")), "output_recovered": bool(after_sel.get("sink")), "before": before, "after": after, "hint": ("Soft reset only fixes Pulse-side state. If " "input_recovered is False, try POST /api/audio/usb-reset " "or physically replug the dongle."), } return await asyncio.to_thread(_do) finally: _RESET_LOCK.release() def _find_usb_devices_by_vid_pid(vid: str, pid: str) -> list[str]: """Return sysfs bus-id strings (e.g. '1-3') for every USB device whose idVendor/idProduct match. Empty list when nothing matches. We read /sys/bus/usb/devices/* — every USB *device* (not interface) has idVendor/idProduct files. Interfaces (paths with a colon, e.g. '1-3:1.1') do not, so they're naturally skipped. """ import glob hits: list[str] = [] for path in glob.glob("/sys/bus/usb/devices/*"): name = os.path.basename(path) if ":" in name: continue try: with open(os.path.join(path, "idVendor")) as f: v = f.read().strip().lower() with open(os.path.join(path, "idProduct")) as f: p = f.read().strip().lower() except OSError: continue if v == vid.lower() and p == pid.lower(): hits.append(name) return hits def _snd_usb_interfaces_for_device(bus_id: str) -> list[str]: """For USB device `bus_id` (e.g. '1-3'), return all interface names that are currently bound to the snd-usb-audio driver (e.g. ['1-3:1.0']). Used so we unbind ONLY the audio interfaces and don't touch HID / HUB interfaces on the same composite device. """ import glob bound: list[str] = [] base = f"/sys/bus/usb/devices/{bus_id}" for iface in glob.glob(f"{base}/{bus_id}:*"): driver_link = os.path.join(iface, "driver") if not os.path.islink(driver_link): continue try: driver = os.path.basename(os.readlink(driver_link)) except OSError: continue if driver == "snd-usb-audio": bound.append(os.path.basename(iface)) return bound def _write_sysfs(path: str, value: str) -> tuple[bool, str]: """Write `value` to a sysfs file. Returns (success, error_message). Writes to /sys/bus/usb/drivers/snd-usb-audio/{bind,unbind} usually require root. If permission denied, the caller should fall back to invoking shell_scripts/reset_anker_usb.sh via sudo (one-time sudoers setup documented in that script's header). """ try: with open(path, "w") as f: f.write(value) return True, "" except PermissionError as exc: return False, f"permission denied: {path} ({exc})" except OSError as exc: return False, f"write failed: {path} ({exc})" @router.post("/usb-reset") async def usb_reset_anker(): """HARD reset — unbind+rebind snd-usb-audio for the Anker (VID:PID 291a:3301). Forces the kernel to re-parse the USB Audio Class descriptors, which is the only way to recover a missing capture profile on this Jetson without a physical replug. Tries two paths: 1. Direct sysfs write (no sudo) — works if a udev rule has set `audio` group ownership / world-write on the snd-usb-audio bind files, or if Sanad runs as root (it shouldn't). 2. Fallback to `sudo shell_scripts/reset_anker_usb.sh` — works after a one-time sudoers entry; see that script's header for setup. Refuses while Live Gemini or a record playback is in flight (same guard as the soft reset). """ if not _USB_RESET_LOCK.acquire(blocking=False): raise HTTPException(429, "USB reset already in progress.") try: _refuse_if_busy() # Find candidate Anker USB devices currently enumerated. candidates: list[dict] = [] for tgt in _USB_RESET_TARGETS: for bus_id in _find_usb_devices_by_vid_pid(tgt["vid"], tgt["pid"]): candidates.append({"bus_id": bus_id, **tgt}) if not candidates: wanted = ", ".join( "{}:{}".format(t["vid"], t["pid"]) for t in _USB_RESET_TARGETS ) raise HTTPException( 404, f"No matching USB device found (looked for {wanted}). " "Plug the Anker dongle and try again.", ) log.info("usb reset: candidates=%s", candidates) def _do() -> dict: before_detected = [ p.get("profile", {}).get("id") for p in (ad.detect_plugged_profiles() or []) ] results: list[dict] = [] for cand in candidates: bus = cand["bus_id"] ifaces = _snd_usb_interfaces_for_device(bus) attempt = {"bus_id": bus, "label": cand["label"], "snd_interfaces": ifaces, "method": None, "ok": False, "error": ""} if not ifaces: attempt["error"] = ("no snd-usb-audio interfaces bound " "to this device — already unbound or " "kernel didn't claim it") results.append(attempt) continue # ─── Path 1: direct sysfs write ─── unbind_path = "/sys/bus/usb/drivers/snd-usb-audio/unbind" bind_path = "/sys/bus/usb/drivers/snd-usb-audio/bind" direct_ok = True direct_err = "" for iface in ifaces: ok, err = _write_sysfs(unbind_path, iface) if not ok: direct_ok = False direct_err = err break if direct_ok: import time as _time _time.sleep(0.5) for iface in ifaces: ok, err = _write_sysfs(bind_path, iface) if not ok: direct_ok = False direct_err = err break if direct_ok: attempt.update({"method": "direct-sysfs", "ok": True}) results.append(attempt) continue # ─── Path 2: sudo helper script ─── from pathlib import Path as _Path helper = (_Path(__file__).resolve().parent.parent.parent / "shell_scripts" / "reset_anker_usb.sh") if not helper.exists(): attempt.update({"method": "direct-sysfs", "error": f"{direct_err}; helper not present " f"at {helper}"}) results.append(attempt) continue try: r = subprocess.run( ["sudo", "-n", str(helper), bus], check=False, capture_output=True, text=True, timeout=10.0, ) attempt["method"] = "sudo-helper" if r.returncode == 0: attempt["ok"] = True else: attempt["error"] = ( f"sudo helper exited {r.returncode}: " f"{(r.stderr or r.stdout or '').strip()[:300]}" ) except subprocess.TimeoutExpired: attempt["error"] = "sudo helper timed out (>10s)" except FileNotFoundError as exc: attempt["error"] = f"sudo not available: {exc}" results.append(attempt) # Settle, then re-detect import time as _time _time.sleep(1.0) try: ad.apply_current_selection() except Exception: pass try: from Project.Sanad.main import audio_mgr if audio_mgr is not None and hasattr(audio_mgr, "refresh_devices"): audio_mgr.refresh_devices() except Exception: pass after_detected = [ p.get("profile", {}).get("id") for p in (ad.detect_plugged_profiles() or []) ] any_ok = any(r.get("ok") for r in results) mic_now = any( "anker" in (p.get("profile", {}).get("id") or "").lower() for p in (ad.detect_plugged_profiles() or []) ) return { "ok": any_ok, "candidates": results, "before_detected_profiles": before_detected, "after_detected_profiles": after_detected, "input_recovered": mic_now, "hint": ( "If ok is False, the unbind/rebind path needs sudo. " "Run `bash shell_scripts/reset_anker_usb.sh --setup-sudoers` " "once on the robot to install the sudoers entry, then retry." ) if not any_ok else None, } return await asyncio.to_thread(_do) finally: _USB_RESET_LOCK.release()