Files
javis/javis-slave/src/javis_slave/headless.py
T
2026-09-21 21:44:00 +02:00

184 lines
6.3 KiB
Python

"""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())