"""Headless runtime: wake word → master → speaker, without Qt. Used on displayless satellites (Raspberry Pi + mic + speaker) via ``python -m javis_slave --headless``. Reuses :class:`AudioEngine`, :class:`MasterClient` and the MCP control HTTP server exactly like the GUI app; state and errors go to the log instead of the screen. """ from __future__ import annotations import asyncio import signal import sys from typing import Optional from .api_client import MasterClient, MasterError from .audio_engine import AudioEngine from .config import SlaveConfig, load_config, save_config from .http_server import build_http_app from .logging_setup import get_logger log = get_logger("slave.headless") # Re-try the initial engine start (mic not ready at boot, master down) this often. _START_RETRY_S = 10.0 async def pair_cli(cfg: SlaveConfig, client: MasterClient, host: str, port: int, code: str, name: str) -> None: """Pair from the command line and persist credentials.""" if not await client.check_health(host, port): raise MasterError( f"Master at {host}:{port} not reachable — is api.enabled: true in its config.yaml?") result = await client.pair(host, port, code, name) cfg.master_host = host cfg.master_port = port cfg.api_key = result.api_key cfg.device_name = name cfg.room = result.room cfg.wake_word = result.wake_word save_config(cfg) log.info(f"paired with master {host}:{port} as {name!r} (room={result.room!r})") async def run_headless_async(cfg: SlaveConfig, client: MasterClient) -> int: if not cfg.paired: log.error("device is not paired. Run: python -m javis_slave --pair HOST:PORT CODE --name NAME") return 2 engine: Optional[AudioEngine] = None engine_task: Optional[asyncio.Task] = None state = {"value": "idle", "error": None} stop = asyncio.Event() def _on_state(s: str) -> None: if s != state["value"]: state["value"] = s log.info(f"state -> {s}") def _on_error(msg: str) -> None: state["error"] = msg log.error(msg) def _on_turn(user: str, reply: str) -> None: log.info(f"user: {user}") log.info(f"javis: {reply}") def _on_room(room) -> None: log.info(f"room: {room or '(none)'}") def _on_connection(ok: bool, retry: int, backoff: float) -> None: if not ok: log.warning(f"master unreachable (attempt {retry}, next try in {backoff:.0f}s)") async def _start_engine() -> None: nonlocal engine, engine_task while not stop.is_set(): eng = AudioEngine(cfg, client) eng.on_state = _on_state eng.on_error = _on_error eng.on_turn = _on_turn eng.on_room = _on_room eng.on_connection = _on_connection try: await eng.start() except Exception as exc: # noqa: BLE001 log.error(f"audio engine failed to start ({exc}); retrying in {_START_RETRY_S:.0f}s") try: await asyncio.wait_for(stop.wait(), timeout=_START_RETRY_S) except asyncio.TimeoutError: continue return engine = eng engine_task = asyncio.create_task(eng.run()) log.info(f"listening for wake word '{cfg.wake_word.model}' (room={cfg.room!r})") return async def _stop_engine() -> None: nonlocal engine, engine_task if engine_task is not None: engine_task.cancel() try: await engine_task except asyncio.CancelledError: pass engine_task = None if engine is not None: await engine.stop() engine = None async def _restart() -> None: await _stop_engine() await _start_engine() # MCP control server (same endpoints as the GUI app). http_task: Optional[asyncio.Task] = None server = None try: from uvicorn import Config as UvConfig, Server http_app = build_http_app( cfg, lambda: engine, lambda: state["value"], lambda: state["error"], lambda: (engine.last_rms, engine.last_score) if engine else (0.0, 0.0), on_restart=_restart, master_client_getter=lambda: client, ) server = Server(UvConfig(app=http_app, host=cfg.api_host, port=cfg.api_port, log_level="warning", loop="none", lifespan="off")) http_task = asyncio.create_task(server.serve()) log.info(f"MCP control server on http://{cfg.api_host}:{cfg.api_port}") except Exception as exc: # noqa: BLE001 log.error(f"HTTP server not started: {exc}") loop = asyncio.get_running_loop() for sig in (signal.SIGINT, signal.SIGTERM): try: loop.add_signal_handler(sig, stop.set) except (NotImplementedError, RuntimeError): pass # Windows: fall back to KeyboardInterrupt await _start_engine() try: await stop.wait() except (KeyboardInterrupt, asyncio.CancelledError): # Windows has no add_signal_handler: asyncio.run cancels us on Ctrl+C. # Swallow it so the teardown below still runs. pass log.info("shutting down …") await _stop_engine() if server is not None: server.should_exit = True if http_task is not None: try: await asyncio.wait_for(http_task, timeout=5.0) except (asyncio.TimeoutError, asyncio.CancelledError, Exception): # noqa: BLE001 http_task.cancel() await client.aclose() return 0 def run_headless(pair: Optional[tuple[str, int, str, str]] = None) -> int: cfg = load_config() save_config(cfg) client = MasterClient(cfg) async def _main() -> int: if pair is not None: host, port, code, name = pair try: await pair_cli(cfg, client, host, port, code, name) except MasterError as exc: log.error(f"pairing failed: {exc}") await client.aclose() return 1 return await run_headless_async(cfg, client) try: return asyncio.run(_main()) except KeyboardInterrupt: return 0 if __name__ == "__main__": sys.exit(run_headless())