184 lines
6.3 KiB
Python
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())
|