Initial commit: Javis project
This commit is contained in:
commit
435c645f36
164 files changed
+35279
No files matched your search
@@ -0,0 +1,183 @@
|
||||
"""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())
|
||||
Reference in new issue
Block a user