v1.0
This commit is contained in:
1 parent
0246cbb6ba
commit
0ca4b4bf7a
89 files changed
+7042
-2480
No files matched your search
@@ -103,6 +103,11 @@ you can say *"mach die Küche leiser"*. On Linux the endpoints map to:
|
||||
| `/mcp/v1/power/*` | `systemctl suspend`, `shutdown` via a scoped sudoers entry |
|
||||
| `/mcp/v1/mic/*`, `/status`, `/restart` | Engine mute / status / restart |
|
||||
|
||||
Every endpoint except `/mcp/v1/health` needs the slave's control token
|
||||
(`Authorization: Bearer …`) unless the caller runs on the Pi itself. Print it
|
||||
with `javis-slave --show-control-token` and give it to javis-slave-mcp as
|
||||
`JAVIS_SLAVE_TOKEN` (see its README).
|
||||
|
||||
## CLI reference
|
||||
|
||||
```
|
||||
@@ -112,6 +117,8 @@ javis-slave --pair HOST:PORT CODE --name N pair (works with and without --headle
|
||||
javis-slave --list-devices print sounddevice table
|
||||
javis-slave --input-device N --output-device N --input-gain G --api-port P
|
||||
persist settings and start
|
||||
javis-slave --show-control-token print the control-API token and exit
|
||||
javis-slave --control-token TOKEN persist your own token (>= 16 chars)
|
||||
```
|
||||
|
||||
Environment: `JAVIS_SLAVE_CONFIG_DIR` overrides the config folder
|
||||
|
||||
@@ -72,6 +72,8 @@ javis-slave --headless no window (services, Pi)
|
||||
javis-slave --pair 192.168.1.10:8765 123456 --name Kueche
|
||||
javis-slave --list-devices show mic/speaker indices
|
||||
javis-slave --input-device 1 --output-device 0 --input-gain 1.5 --api-port 8766
|
||||
javis-slave --show-control-token print the control-API token
|
||||
javis-slave --control-token <TOKEN> set your own token (≥ 16 chars)
|
||||
```
|
||||
|
||||
Settings are stored in `%LOCALAPPDATA%\JavisSlave\slave_config.json`
|
||||
@@ -81,6 +83,12 @@ with `JAVIS_SLAVE_CONFIG_DIR`. A rotating log is written to `<config dir>/logs/s
|
||||
## Notes
|
||||
|
||||
- Transport is plain HTTP — use on a trusted LAN only.
|
||||
- The control API on port 8766 (volume, power, mic, …) requires
|
||||
authentication: `Authorization: Bearer <token>` from other machines, or the
|
||||
header `X-Javis-Control: 1` from the same machine (loopback). The token is
|
||||
generated on first start and stored in `slave_config.json` (mode 0600 on
|
||||
Linux); show it with `--show-control-token` or in the GUI settings.
|
||||
Only `GET /mcp/v1/health` is public.
|
||||
- Microphones/speakers that don't support 16 kHz are opened at their native
|
||||
rate and resampled automatically (common for USB audio on Linux).
|
||||
- System control endpoints (volume / brightness / power, see
|
||||
|
||||
@@ -14,7 +14,10 @@ dependencies = [
|
||||
"httpx>=0.27",
|
||||
# HTTP server for MCP control (plain uvicorn: no uvloop/httptools builds on small ARM boards)
|
||||
"fastapi>=0.104",
|
||||
"uvicorn>=0.24",
|
||||
# >=0.29: serve() captures SIGINT/SIGTERM and re-raises them after shutdown,
|
||||
# so the headless loop's own signal handlers still fire (older versions
|
||||
# replaced them and systemd stops hung until SIGKILL).
|
||||
"uvicorn>=0.29",
|
||||
"pydantic>=2.0",
|
||||
# Windows system control (volume, brightness, power) — Linux uses amixer/pactl,
|
||||
# /sys/class/backlight and systemctl, no extra packages.
|
||||
|
||||
@@ -38,12 +38,17 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
help="Persist microphone gain multiplier (1.0 = unchanged).")
|
||||
p.add_argument("--api-port", type=int, default=None, metavar="PORT",
|
||||
help="Persist the local MCP control server port (default 8766).")
|
||||
p.add_argument("--control-token", default=None, metavar="TOKEN",
|
||||
help="Persist the Bearer token remote MCP controllers must send.")
|
||||
p.add_argument("--show-control-token", action="store_true",
|
||||
help="Print the control API token (generating one if needed) and exit.")
|
||||
return p.parse_args(argv)
|
||||
|
||||
|
||||
def _apply_device_overrides(args: argparse.Namespace) -> None:
|
||||
"""Persist CLI-provided audio settings so services can be configured non-interactively."""
|
||||
if all(v is None for v in (args.input_device, args.output_device, args.input_gain, args.api_port)):
|
||||
if all(v is None for v in (args.input_device, args.output_device, args.input_gain,
|
||||
args.api_port, args.control_token)):
|
||||
return
|
||||
from .config import load_config, save_config
|
||||
cfg = load_config()
|
||||
@@ -55,6 +60,12 @@ def _apply_device_overrides(args: argparse.Namespace) -> None:
|
||||
cfg.input_gain = args.input_gain
|
||||
if args.api_port is not None:
|
||||
cfg.api_port = args.api_port
|
||||
if args.control_token is not None:
|
||||
token = args.control_token.strip()
|
||||
if len(token) < 16:
|
||||
print("--control-token must be at least 16 characters", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
cfg.control_token = token
|
||||
save_config(cfg)
|
||||
|
||||
|
||||
@@ -80,6 +91,15 @@ def main(argv: list[str] | None = None) -> int:
|
||||
return 0
|
||||
|
||||
_apply_device_overrides(args)
|
||||
|
||||
if args.show_control_token:
|
||||
from .config import ensure_control_token, load_config, save_config
|
||||
cfg = load_config()
|
||||
if ensure_control_token(cfg):
|
||||
save_config(cfg)
|
||||
print(cfg.control_token)
|
||||
return 0
|
||||
|
||||
pair = _parse_pair(args)
|
||||
|
||||
if args.headless:
|
||||
@@ -100,11 +120,12 @@ def main(argv: list[str] | None = None) -> int:
|
||||
# Pair first (no window needed), then launch the GUI already paired.
|
||||
import asyncio
|
||||
from .api_client import MasterClient, MasterError
|
||||
from .config import load_config, save_config
|
||||
from .config import ensure_control_token, load_config, save_config
|
||||
from .headless import pair_cli
|
||||
|
||||
async def _do_pair() -> int:
|
||||
cfg = load_config()
|
||||
ensure_control_token(cfg)
|
||||
save_config(cfg)
|
||||
client = MasterClient(cfg)
|
||||
try:
|
||||
|
||||
@@ -14,6 +14,10 @@ from .logging_setup import get_logger
|
||||
|
||||
log = get_logger("api")
|
||||
|
||||
# Errors raised before the request reached the master: safe to retry even for
|
||||
# non-idempotent calls (a read timeout means the master may already be working).
|
||||
_CONNECT_ERRORS = (httpx.ConnectError, httpx.ConnectTimeout)
|
||||
|
||||
|
||||
class MasterError(Exception):
|
||||
"""Raised when the master rejects a request or is unreachable."""
|
||||
@@ -108,7 +112,7 @@ class MasterClient:
|
||||
if not self._connection_ok and self._retry_count < self._max_retries:
|
||||
return True
|
||||
if self._retry_count >= self._max_retries:
|
||||
log.error("max reconnection attempts reached; requires manual intervention")
|
||||
log.error("master unreachable for a long time; still polling every minute")
|
||||
return False
|
||||
|
||||
def _headers(self) -> dict:
|
||||
@@ -121,6 +125,7 @@ class MasterClient:
|
||||
self,
|
||||
request: Callable[[], Awaitable[httpx.Response]],
|
||||
retries: Optional[int] = None,
|
||||
retry_on: "tuple[type[Exception], ...]" = (httpx.HTTPError,),
|
||||
) -> httpx.Response:
|
||||
"""Execute an HTTP request with exponential backoff reconnection.
|
||||
|
||||
@@ -131,6 +136,8 @@ class MasterClient:
|
||||
Args:
|
||||
request: Zero-arg callable producing the request awaitable.
|
||||
retries: Max number of retries (default ``self._max_retries``).
|
||||
retry_on: Exception types that may be retried; other transport
|
||||
errors fail immediately.
|
||||
"""
|
||||
if retries is None:
|
||||
retries = self._max_retries
|
||||
@@ -143,9 +150,11 @@ class MasterClient:
|
||||
except httpx.HTTPError as e:
|
||||
last_exc = e
|
||||
self.mark_disconnected(e)
|
||||
if attempt >= retries:
|
||||
if isinstance(e, httpx.TimeoutException) and not isinstance(e, retry_on):
|
||||
raise MasterError("Master antwortet nicht (Zeitüberschreitung).") from e
|
||||
if attempt >= retries or not isinstance(e, retry_on):
|
||||
raise MasterError(
|
||||
f"Master nach {retries + 1} Versuchen nicht erreichbar."
|
||||
f"Master nach {attempt + 1} Versuch(en) nicht erreichbar."
|
||||
) from e
|
||||
wait = min(self._backoff_base * (2 ** attempt), self._backoff_max)
|
||||
log.info(f"retrying in {wait:.0f}s (attempt {attempt + 1}/{retries})")
|
||||
@@ -167,6 +176,7 @@ class MasterClient:
|
||||
},
|
||||
),
|
||||
retries=1,
|
||||
retry_on=_CONNECT_ERRORS,
|
||||
)
|
||||
except MasterError as e:
|
||||
raise e
|
||||
@@ -237,6 +247,7 @@ class MasterClient:
|
||||
files={"audio": ("utterance.wav", wav_bytes, "audio/wav")},
|
||||
),
|
||||
retries=1,
|
||||
retry_on=_CONNECT_ERRORS,
|
||||
)
|
||||
except MasterError as e:
|
||||
raise e
|
||||
|
||||
@@ -9,8 +9,9 @@ from .logging_setup import get_logger
|
||||
|
||||
from .api_client import MasterClient, MasterError
|
||||
from .audio_engine import AudioEngine
|
||||
from .config import SlaveConfig, load_config, save_config
|
||||
from .config import ensure_control_token, load_config, save_config
|
||||
from .http_server import build_http_app
|
||||
from .tasks import spawn
|
||||
|
||||
log = get_logger("slave.app")
|
||||
|
||||
@@ -32,7 +33,8 @@ def run_app(*, fullscreen: bool = False, kiosk: bool = False) -> int:
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
cfg = load_config()
|
||||
save_config(cfg) # persist generated device_id on first run
|
||||
ensure_control_token(cfg)
|
||||
save_config(cfg) # persist generated device_id / control token on first run
|
||||
client = MasterClient(cfg)
|
||||
window = SlaveWindow()
|
||||
engine: Optional[AudioEngine] = None
|
||||
@@ -164,7 +166,7 @@ def run_app(*, fullscreen: bool = False, kiosk: bool = False) -> int:
|
||||
if engine is None and cfg.paired:
|
||||
await _start_engine()
|
||||
|
||||
retry_task = asyncio.ensure_future(_retry())
|
||||
retry_task = spawn(_retry())
|
||||
|
||||
async def _stop_engine() -> None:
|
||||
nonlocal engine, engine_task, last_state, retry_task
|
||||
@@ -211,7 +213,7 @@ def run_app(*, fullscreen: bool = False, kiosk: bool = False) -> int:
|
||||
log.info(f"paired with master {host}:{port}")
|
||||
await _start_engine()
|
||||
|
||||
asyncio.ensure_future(_do_pair())
|
||||
spawn(_do_pair())
|
||||
|
||||
window.setup_screen.pairRequested.connect(_on_pair_requested)
|
||||
|
||||
@@ -226,9 +228,10 @@ def run_app(*, fullscreen: bool = False, kiosk: bool = False) -> int:
|
||||
window.setup_screen.pair_btn.setEnabled(True)
|
||||
window.setup_screen.status_lbl.setText("")
|
||||
window.show_setup()
|
||||
asyncio.ensure_future(_do())
|
||||
spawn(_do())
|
||||
|
||||
window.show_settings_dialog(cfg.device_id, cfg.device_name, _repair)
|
||||
window.show_settings_dialog(cfg.device_id, cfg.device_name, _repair,
|
||||
control_token=cfg.control_token, api_port=cfg.api_port)
|
||||
|
||||
window.main_screen.settingsRequested.connect(_on_settings)
|
||||
|
||||
@@ -239,7 +242,7 @@ def run_app(*, fullscreen: bool = False, kiosk: bool = False) -> int:
|
||||
return
|
||||
# Route through the engine so capture is actually paused/resumed and the
|
||||
# state callbacks fire (I-3: was a raw `engine.muted = muted` write).
|
||||
asyncio.ensure_future(engine.mute() if muted else engine.unmute())
|
||||
spawn(engine.mute() if muted else engine.unmute())
|
||||
log.info(f"mute toggled: {muted}")
|
||||
|
||||
window.main_screen.muteToggled.connect(_on_mute_toggled)
|
||||
@@ -261,10 +264,10 @@ def run_app(*, fullscreen: bool = False, kiosk: bool = False) -> int:
|
||||
|
||||
# Start the MCP control server regardless of pairing state (A-7: it was
|
||||
# only started after a successful engine start, so unpaired slaves had none).
|
||||
asyncio.ensure_future(_start_http_server())
|
||||
spawn(_start_http_server())
|
||||
|
||||
if cfg.paired:
|
||||
asyncio.ensure_future(_start_engine())
|
||||
spawn(_start_engine())
|
||||
|
||||
async def _shutdown() -> None:
|
||||
await _stop_engine()
|
||||
|
||||
@@ -22,6 +22,7 @@ from .audio_io.wake_word import WakeWordDetector, pretrained_names
|
||||
from .api_client import MasterClient, MasterError
|
||||
from .config import SlaveConfig, WakeWordSettings, config_dir, save_config
|
||||
from .logging_setup import get_logger
|
||||
from .tasks import spawn
|
||||
|
||||
log = get_logger("slave.audio")
|
||||
|
||||
@@ -100,9 +101,15 @@ class AudioEngine:
|
||||
input_device=self.cfg.input_device,
|
||||
input_gain=self.cfg.input_gain,
|
||||
)
|
||||
await self.mic.start()
|
||||
self.vad = make_vad(threshold=self.cfg.wake_word.vad_threshold)
|
||||
self.wake = await self._load_wake(self.cfg.wake_word.model, self.cfg.wake_word.threshold)
|
||||
try:
|
||||
await self.mic.start()
|
||||
self.vad = make_vad(threshold=self.cfg.wake_word.vad_threshold)
|
||||
self.wake = await self._load_wake(self.cfg.wake_word.model, self.cfg.wake_word.threshold)
|
||||
except BaseException:
|
||||
# The caller retries with a fresh engine; an open stream left behind
|
||||
# would keep ALSA hw: devices busy for every later attempt.
|
||||
await self.stop()
|
||||
raise
|
||||
log.info(f"audio engine ready (wake word: '{self.cfg.wake_word.model}')")
|
||||
|
||||
@staticmethod
|
||||
@@ -112,6 +119,9 @@ class AudioEngine:
|
||||
async def _load_wake(self, name: str, threshold: float) -> WakeWordDetector:
|
||||
"""Build the detector; fetch a custom model from the master when missing locally."""
|
||||
model_dir = self._wake_model_dir()
|
||||
# The name comes from the master; never let it address a path.
|
||||
if not name or Path(name).name != name or name in (".", "..") or "\\" in name:
|
||||
raise RuntimeError(f"invalid wake word model name from master: {name!r}")
|
||||
if name not in pretrained_names() and not (model_dir / f"{name}.onnx").is_file():
|
||||
log.info(f"wake word '{name}' is custom — fetching model from master")
|
||||
if not await self.client.download_wake_model(model_dir / f"{name}.onnx"):
|
||||
@@ -254,7 +264,7 @@ class AudioEngine:
|
||||
if self.wake.triggered(score):
|
||||
log.info(f"wake! score={score:.2f}")
|
||||
# Same ascending two-tone as the master so the cue is familiar.
|
||||
asyncio.ensure_future(self._play_chime([880.0, 1320.0], 0.06, 0.18))
|
||||
spawn(self._play_chime([880.0, 1320.0], 0.06, 0.18))
|
||||
return True
|
||||
if self._stop.is_set():
|
||||
return False
|
||||
@@ -296,9 +306,8 @@ class AudioEngine:
|
||||
try:
|
||||
result = await self.client.send_turn(wav)
|
||||
self.on_connection(True, 0, 0.0) # (ok=True, retry_count=0, backoff=0)
|
||||
self.client.mark_connected()
|
||||
except MasterError as e:
|
||||
self.client.mark_disconnected(e)
|
||||
# send_turn already did the retry bookkeeping (mark_disconnected).
|
||||
if self.client.should_reconnect():
|
||||
# Emit connection health with retry countdown (Alexa-style)
|
||||
self.on_connection(False, self.client.retry_count, self.client.backoff_seconds)
|
||||
@@ -347,17 +356,17 @@ class AudioEngine:
|
||||
try:
|
||||
room, ww = await self.client.fetch_config()
|
||||
self.on_connection(True, 0, 0.0)
|
||||
self.client.mark_connected()
|
||||
changed = self._apply_master_settings(room, ww)
|
||||
if changed:
|
||||
save_config(self.cfg)
|
||||
self.on_room(room)
|
||||
except MasterError as e:
|
||||
self.client.mark_disconnected(e)
|
||||
except MasterError:
|
||||
if self.client.should_reconnect():
|
||||
self.on_connection(False, self.client.retry_count, self.client.backoff_seconds)
|
||||
else:
|
||||
self.on_error("Maximale Wiederverbindungsversuche erreicht. Bitte manuell neu koppeln.")
|
||||
self.on_connection(False, self.client.retry_count, 0.0)
|
||||
self.on_error("Master seit längerer Zeit nicht erreichbar — "
|
||||
"es wird weiter jede Minute versucht.")
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("config refresh failed unexpectedly")
|
||||
try:
|
||||
@@ -380,7 +389,7 @@ class AudioEngine:
|
||||
if self.wake is not None:
|
||||
if rebuild_model:
|
||||
# Loading may download the model — do it off the poll loop.
|
||||
asyncio.ensure_future(self._swap_wake_model(ww.model, ww.threshold))
|
||||
spawn(self._swap_wake_model(ww.model, ww.threshold))
|
||||
else:
|
||||
self.wake.threshold = ww.threshold
|
||||
self.vad = make_vad(threshold=ww.vad_threshold)
|
||||
|
||||
@@ -7,6 +7,7 @@ package.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
from typing import AsyncIterator, Optional
|
||||
|
||||
@@ -80,8 +81,9 @@ class MicStream:
|
||||
|
||||
_SENTINEL = object()
|
||||
|
||||
# 32 ms = 512 samples @ 16 kHz: exactly one Silero VAD window.
|
||||
def __init__(self, sample_rate: int = 16000, input_device: Optional[int] = None,
|
||||
input_gain: float = 1.0, chunk_ms: int = 30):
|
||||
input_gain: float = 1.0, chunk_ms: int = 32):
|
||||
self.sr = sample_rate
|
||||
self.device = input_device
|
||||
self.gain = input_gain
|
||||
@@ -118,8 +120,12 @@ class MicStream:
|
||||
chunk = self._resampler.process(chunk)
|
||||
if self.gain != 1.0:
|
||||
chunk *= self.gain
|
||||
if self._loop and not self._loop.is_closed():
|
||||
self._loop.call_soon_threadsafe(self._enqueue, chunk)
|
||||
loop = self._loop
|
||||
if loop is not None and not loop.is_closed():
|
||||
try:
|
||||
loop.call_soon_threadsafe(self._enqueue, chunk)
|
||||
except RuntimeError:
|
||||
pass # loop closed between the check and the call (shutdown)
|
||||
|
||||
def _enqueue(self, chunk: np.ndarray) -> None:
|
||||
# Drop oldest to keep mic latency bounded when the loop is busy.
|
||||
@@ -324,6 +330,35 @@ def list_audio_devices() -> str:
|
||||
return str(sd.query_devices())
|
||||
|
||||
|
||||
async def write_pcm_blocks(stream, samples: np.ndarray, block: int,
|
||||
cancel_event: Optional[asyncio.Event] = None) -> bool:
|
||||
"""Write *samples* to a started OutputStream in *block*-frame slices on a worker thread.
|
||||
|
||||
Returns True when *cancel_event* interrupted playback. If the awaiting task
|
||||
is cancelled, the writer thread is stopped and joined before re-raising so
|
||||
the caller can safely abort/close the stream.
|
||||
"""
|
||||
stop = threading.Event()
|
||||
|
||||
def _writer() -> bool:
|
||||
i, total = 0, len(samples)
|
||||
while i < total:
|
||||
if stop.is_set() or (cancel_event is not None and cancel_event.is_set()):
|
||||
return True
|
||||
end = min(i + block, total)
|
||||
stream.write(samples[i:end])
|
||||
i = end
|
||||
return False
|
||||
|
||||
fut = asyncio.ensure_future(asyncio.to_thread(_writer))
|
||||
try:
|
||||
return await asyncio.shield(fut)
|
||||
except asyncio.CancelledError:
|
||||
stop.set()
|
||||
await asyncio.wait({fut}, timeout=2.0)
|
||||
raise
|
||||
|
||||
|
||||
async def play_pcm(samples: np.ndarray, sample_rate: int,
|
||||
output_device: Optional[int] = None,
|
||||
cancel_event: Optional[asyncio.Event] = None) -> None:
|
||||
@@ -373,21 +408,10 @@ async def play_pcm(samples: np.ndarray, sample_rate: int,
|
||||
blocksize=block,
|
||||
)
|
||||
|
||||
def _writer() -> None:
|
||||
i = 0
|
||||
total = len(samples)
|
||||
while i < total:
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
return
|
||||
end = min(i + block, total)
|
||||
stream.write(samples[i:end])
|
||||
i = end
|
||||
|
||||
stream.start()
|
||||
cancelled = False
|
||||
cancelled = True # stays True if this task is cancelled mid-write
|
||||
try:
|
||||
await asyncio.to_thread(_writer)
|
||||
cancelled = bool(cancel_event is not None and cancel_event.is_set())
|
||||
cancelled = await write_pcm_blocks(stream, samples, block, cancel_event)
|
||||
finally:
|
||||
try:
|
||||
if cancelled:
|
||||
|
||||
@@ -7,12 +7,17 @@ Persisted to ``%LOCALAPPDATA%/JavisSlave/slave_config.json`` (or
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from dataclasses import asdict, dataclass, field, fields
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Plain stdlib logger: logging_setup imports this module (config_dir).
|
||||
_log = logging.getLogger("javis_slave.config")
|
||||
|
||||
|
||||
def config_dir() -> Path:
|
||||
override = os.environ.get("JAVIS_SLAVE_CONFIG_DIR")
|
||||
@@ -54,6 +59,9 @@ class SlaveConfig:
|
||||
# unreachable by remote controllers). 0.0.0.0 lets other devices reach it.
|
||||
api_host: str = "0.0.0.0"
|
||||
api_port: int = 8766
|
||||
# Bearer token required by the control API for non-loopback callers.
|
||||
# Generated on first start (ensure_control_token); see --show-control-token.
|
||||
control_token: str = ""
|
||||
|
||||
@property
|
||||
def paired(self) -> bool:
|
||||
@@ -64,21 +72,51 @@ class SlaveConfig:
|
||||
return f"http://{self.master_host}:{self.master_port}"
|
||||
|
||||
|
||||
def ensure_control_token(cfg: SlaveConfig) -> bool:
|
||||
"""Generate a control-API token if none is set; returns True when one was created."""
|
||||
if cfg.control_token:
|
||||
return False
|
||||
cfg.control_token = secrets.token_urlsafe(24)
|
||||
return True
|
||||
|
||||
|
||||
def _assign(target, raw: dict) -> None:
|
||||
"""Copy known keys from *raw*, skipping values whose type doesn't match the default."""
|
||||
for f in fields(target):
|
||||
if f.name not in raw or f.name == "wake_word":
|
||||
continue
|
||||
val, cur = raw[f.name], getattr(target, f.name)
|
||||
if cur is not None and val is not None and not isinstance(val, type(cur)):
|
||||
if isinstance(cur, float) and isinstance(val, int) and not isinstance(val, bool):
|
||||
val = float(val)
|
||||
else:
|
||||
_log.warning(f"config: ignoring {f.name}={val!r} (expected {type(cur).__name__})")
|
||||
continue
|
||||
setattr(target, f.name, val)
|
||||
|
||||
|
||||
def load_config() -> SlaveConfig:
|
||||
path = config_path()
|
||||
cfg = SlaveConfig()
|
||||
if path.exists():
|
||||
if not path.exists():
|
||||
return cfg
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("top level is not an object")
|
||||
except (OSError, ValueError) as exc:
|
||||
# Keep the broken file: silently starting fresh would mint a new
|
||||
# device_id and the next save would wipe the pairing for good.
|
||||
backup = path.with_name(path.name + ".corrupt")
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
ww_raw = raw.pop("wake_word", {}) or {}
|
||||
for key, val in raw.items():
|
||||
if hasattr(cfg, key) and key != "wake_word":
|
||||
setattr(cfg, key, val)
|
||||
for key, val in ww_raw.items():
|
||||
if hasattr(cfg.wake_word, key):
|
||||
setattr(cfg.wake_word, key, val)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
path.replace(backup)
|
||||
except OSError:
|
||||
backup = path
|
||||
_log.error(f"config {path} unreadable ({exc}); kept as {backup} — starting unpaired")
|
||||
return cfg
|
||||
_assign(cfg, raw)
|
||||
if isinstance(raw.get("wake_word"), dict):
|
||||
_assign(cfg.wake_word, raw["wake_word"])
|
||||
return cfg
|
||||
|
||||
|
||||
@@ -86,5 +124,11 @@ def save_config(cfg: SlaveConfig) -> None:
|
||||
path = config_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(asdict(cfg), indent=2), encoding="utf-8")
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
f.write(json.dumps(asdict(cfg), indent=2))
|
||||
f.flush()
|
||||
# SD cards + power loss: make the data durable before the rename.
|
||||
os.fsync(f.fileno())
|
||||
if os.name == "posix":
|
||||
os.chmod(tmp, 0o600) # holds the master API key and control token
|
||||
tmp.replace(path)
|
||||
@@ -14,7 +14,7 @@ from typing import Optional
|
||||
|
||||
from .api_client import MasterClient, MasterError
|
||||
from .audio_engine import AudioEngine
|
||||
from .config import SlaveConfig, load_config, save_config
|
||||
from .config import SlaveConfig, ensure_control_token, load_config, save_config
|
||||
from .http_server import build_http_app
|
||||
from .logging_setup import get_logger
|
||||
|
||||
@@ -159,6 +159,7 @@ async def run_headless_async(cfg: SlaveConfig, client: MasterClient) -> int:
|
||||
|
||||
def run_headless(pair: Optional[tuple[str, int, str, str]] = None) -> int:
|
||||
cfg = load_config()
|
||||
ensure_control_token(cfg)
|
||||
save_config(cfg)
|
||||
client = MasterClient(cfg)
|
||||
|
||||
|
||||
@@ -9,9 +9,12 @@ Exposes endpoints for:
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hmac
|
||||
import ipaddress
|
||||
from typing import TYPE_CHECKING, Awaitable, Callable, Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi import FastAPI, HTTPException, Query, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -27,6 +30,31 @@ from .system_control import (
|
||||
|
||||
log = get_logger("http")
|
||||
|
||||
# Header a local caller must send instead of the token. Browsers cannot add
|
||||
# custom headers cross-origin without a CORS preflight, which this server
|
||||
# never grants — so a web page can't forge control requests (CSRF).
|
||||
CONTROL_HEADER = "X-Javis-Control"
|
||||
_PUBLIC_PATHS = {"/mcp/v1/health"}
|
||||
|
||||
|
||||
def _is_loopback(host: str) -> bool:
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
return False
|
||||
mapped = getattr(ip, "ipv4_mapped", None)
|
||||
return ip.is_loopback or bool(mapped and mapped.is_loopback)
|
||||
|
||||
|
||||
def is_authorized(request: Request, token: str) -> bool:
|
||||
"""Bearer *token* from anywhere, or loopback + ``X-Javis-Control: 1``."""
|
||||
auth = request.headers.get("authorization", "")
|
||||
if token and auth.startswith("Bearer "):
|
||||
if hmac.compare_digest(auth[7:].strip().encode(), token.encode()):
|
||||
return True
|
||||
host = request.client.host if request.client else ""
|
||||
return _is_loopback(host) and request.headers.get(CONTROL_HEADER) == "1"
|
||||
|
||||
|
||||
# ---- Models ----
|
||||
|
||||
@@ -151,7 +179,16 @@ def build_http_app(
|
||||
the master with this slave's own credentials. When omitted, those
|
||||
endpoints return 501.
|
||||
"""
|
||||
app = FastAPI(title="Javis Slave Control")
|
||||
app = FastAPI(title="Javis Slave Control", docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
@app.middleware("http")
|
||||
async def _require_auth(request: Request, call_next):
|
||||
if request.url.path in _PUBLIC_PATHS or is_authorized(request, cfg.control_token):
|
||||
return await call_next(request)
|
||||
log.warning(f"rejected unauthenticated {request.method} {request.url.path} "
|
||||
f"from {request.client.host if request.client else '?'}")
|
||||
return JSONResponse({"detail": "unauthorized"}, status_code=401,
|
||||
headers={"WWW-Authenticate": "Bearer"})
|
||||
|
||||
# ---- Status Endpoints ----
|
||||
|
||||
@@ -276,7 +313,7 @@ def build_http_app(
|
||||
try:
|
||||
data = await client.list_slaves()
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise HTTPException(status_code=502, detail=f"Master query failed: {e}")
|
||||
raise HTTPException(status_code=502, detail=f"Master query failed: {e}") from e
|
||||
return [MasterSlaveInfo(**item) for item in data]
|
||||
|
||||
@app.get("/mcp/v1/master/slaves/{device_id}/ip", response_model=MasterSlaveIp)
|
||||
@@ -288,7 +325,7 @@ def build_http_app(
|
||||
try:
|
||||
data = await client.list_slaves()
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise HTTPException(status_code=502, detail=f"Master query failed: {e}")
|
||||
raise HTTPException(status_code=502, detail=f"Master query failed: {e}") from e
|
||||
for item in data:
|
||||
if item.get("device_id") == device_id:
|
||||
return MasterSlaveIp(
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -31,37 +31,48 @@ class VolumeController:
|
||||
def __init__(self):
|
||||
"""Initialize volume controller."""
|
||||
self._pycaw_available = False
|
||||
self._device = None
|
||||
self._interface = None
|
||||
|
||||
|
||||
if platform.system() != "Windows":
|
||||
logger.warning("Volume control only available on Windows")
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume
|
||||
|
||||
devices = AudioUtilities.GetSpeakers()
|
||||
self._device = devices.Activate(
|
||||
IAudioEndpointVolume._iid_, 0, None
|
||||
)
|
||||
self._interface = self._device
|
||||
self._endpoint()
|
||||
self._pycaw_available = True
|
||||
logger.info("Pycaw audio control initialized")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to initialize pycaw: {e}")
|
||||
|
||||
@staticmethod
|
||||
def _endpoint():
|
||||
"""IAudioEndpointVolume of the *current* default speakers.
|
||||
|
||||
Resolved per call: follows default-device changes (USB/Bluetooth
|
||||
speakers) and never shares a COM pointer across worker threads.
|
||||
"""
|
||||
from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume
|
||||
|
||||
dev = AudioUtilities.GetSpeakers()
|
||||
ep = getattr(dev, "EndpointVolume", None) # pycaw >= 2024: AudioDevice wrapper
|
||||
if ep is not None:
|
||||
return ep
|
||||
from ctypes import POINTER, cast
|
||||
|
||||
from comtypes import CLSCTX_ALL
|
||||
iface = dev.Activate(IAudioEndpointVolume._iid_, CLSCTX_ALL, None) # older pycaw
|
||||
return cast(iface, POINTER(IAudioEndpointVolume))
|
||||
|
||||
def get_volume(self) -> Optional[float]:
|
||||
"""Get current master volume (0.0 to 1.0).
|
||||
|
||||
Returns:
|
||||
Volume level 0.0-1.0, or None if unavailable.
|
||||
"""
|
||||
if not self._pycaw_available or not self._interface:
|
||||
if not self._pycaw_available:
|
||||
return None
|
||||
|
||||
|
||||
try:
|
||||
return self._interface.GetMasterVolumeLevelScalar()
|
||||
return float(self._endpoint().GetMasterVolumeLevelScalar())
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get volume: {e}")
|
||||
return None
|
||||
@@ -75,13 +86,13 @@ class VolumeController:
|
||||
Returns:
|
||||
True if successful, False otherwise.
|
||||
"""
|
||||
if not self._pycaw_available or not self._interface:
|
||||
if not self._pycaw_available:
|
||||
return False
|
||||
|
||||
|
||||
level = max(0.0, min(1.0, level))
|
||||
|
||||
|
||||
try:
|
||||
self._interface.SetMasterVolumeLevelScalar(level, None)
|
||||
self._endpoint().SetMasterVolumeLevelScalar(level, None)
|
||||
logger.info(f"Set volume to {level * 100:.1f}%")
|
||||
return True
|
||||
except Exception as e:
|
||||
@@ -94,11 +105,11 @@ class VolumeController:
|
||||
Returns:
|
||||
True if muted, False if not, None if unavailable.
|
||||
"""
|
||||
if not self._pycaw_available or not self._interface:
|
||||
if not self._pycaw_available:
|
||||
return None
|
||||
|
||||
|
||||
try:
|
||||
return self._interface.GetMute()
|
||||
return bool(self._endpoint().GetMute())
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get mute state: {e}")
|
||||
return None
|
||||
@@ -112,11 +123,11 @@ class VolumeController:
|
||||
Returns:
|
||||
True if successful, False otherwise.
|
||||
"""
|
||||
if not self._pycaw_available or not self._interface:
|
||||
if not self._pycaw_available:
|
||||
return False
|
||||
|
||||
|
||||
try:
|
||||
self._interface.SetMute(muted, None)
|
||||
self._endpoint().SetMute(int(muted), None)
|
||||
state = "muted" if muted else "unmuted"
|
||||
logger.info(f"Audio {state}")
|
||||
return True
|
||||
@@ -271,9 +282,12 @@ class PowerController:
|
||||
if platform.system() != "Windows":
|
||||
logger.error("Power control only available on Windows")
|
||||
return False
|
||||
|
||||
|
||||
try:
|
||||
subprocess.run(["rundll32.exe", "powrprof.dll,SetSuspendState", "0", "1", "0"], check=False)
|
||||
# SetSuspendState only returns after resume — don't wait for it,
|
||||
# or the HTTP reply would be held until the machine wakes up.
|
||||
subprocess.Popen(["rundll32.exe", "powrprof.dll,SetSuspendState", "0", "1", "0"],
|
||||
creationflags=_NO_WINDOW)
|
||||
logger.info("Sent sleep command")
|
||||
return True
|
||||
except Exception as e:
|
||||
@@ -288,23 +302,16 @@ class PowerController:
|
||||
delay: Delay in seconds before shutdown.
|
||||
|
||||
Returns:
|
||||
True if command sent, False on error.
|
||||
True if Windows accepted the command, False on error.
|
||||
"""
|
||||
if platform.system() != "Windows":
|
||||
logger.error("Power control only available on Windows")
|
||||
return False
|
||||
|
||||
try:
|
||||
if delay > 0:
|
||||
subprocess.run(["shutdown", "/s", "/t", str(delay)], check=False)
|
||||
logger.info(f"Scheduled shutdown in {delay} seconds")
|
||||
else:
|
||||
subprocess.run(["shutdown", "/s", "/t", "0"], check=False)
|
||||
logger.info("Sent shutdown command")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to shutdown: {e}")
|
||||
return False
|
||||
|
||||
ok = _run_win(["shutdown", "/s", "/t", str(max(0, delay))])
|
||||
if ok:
|
||||
logger.info(f"Scheduled shutdown in {delay} seconds" if delay > 0 else "Sent shutdown command")
|
||||
return ok
|
||||
|
||||
@staticmethod
|
||||
def restart(delay: int = 0) -> bool:
|
||||
@@ -314,42 +321,51 @@ class PowerController:
|
||||
delay: Delay in seconds before restart.
|
||||
|
||||
Returns:
|
||||
True if command sent, False on error.
|
||||
True if Windows accepted the command, False on error.
|
||||
"""
|
||||
if platform.system() != "Windows":
|
||||
logger.error("Power control only available on Windows")
|
||||
return False
|
||||
|
||||
try:
|
||||
if delay > 0:
|
||||
subprocess.run(["shutdown", "/r", "/t", str(delay)], check=False)
|
||||
logger.info(f"Scheduled restart in {delay} seconds")
|
||||
else:
|
||||
subprocess.run(["shutdown", "/r", "/t", "0"], check=False)
|
||||
logger.info("Sent restart command")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to restart: {e}")
|
||||
return False
|
||||
|
||||
ok = _run_win(["shutdown", "/r", "/t", str(max(0, delay))])
|
||||
if ok:
|
||||
logger.info(f"Scheduled restart in {delay} seconds" if delay > 0 else "Sent restart command")
|
||||
return ok
|
||||
|
||||
@staticmethod
|
||||
def cancel_shutdown() -> bool:
|
||||
"""Cancel pending shutdown/restart.
|
||||
|
||||
Returns:
|
||||
True if command sent, False on error.
|
||||
True if cancelled (or nothing was pending), False on error.
|
||||
"""
|
||||
if platform.system() != "Windows":
|
||||
logger.error("Power control only available on Windows")
|
||||
return False
|
||||
|
||||
try:
|
||||
subprocess.run(["shutdown", "/a"], check=False)
|
||||
|
||||
# 1116 = ERROR_NO_SHUTDOWN_IN_PROGRESS: nothing to cancel is not a failure.
|
||||
ok = _run_win(["shutdown", "/a"], ok_codes=(0, 1116))
|
||||
if ok:
|
||||
logger.info("Cancelled pending shutdown/restart")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to cancel shutdown: {e}")
|
||||
return False
|
||||
return ok
|
||||
|
||||
|
||||
_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
|
||||
|
||||
def _run_win(cmd: list[str], ok_codes: tuple[int, ...] = (0,), timeout: float = 10.0) -> bool:
|
||||
"""Run a Windows command without a console window; True if its exit code is in *ok_codes*."""
|
||||
try:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, errors="replace",
|
||||
timeout=timeout, check=False, creationflags=_NO_WINDOW)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
logger.error(f"{cmd[0]} failed: {exc}")
|
||||
return False
|
||||
if proc.returncode not in ok_codes:
|
||||
detail = (proc.stderr or proc.stdout or "").strip()
|
||||
logger.error(f"{' '.join(cmd)} exited {proc.returncode}: {detail}")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# ── Linux / Raspberry Pi backends ────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Fire-and-forget task helper (the event loop only keeps weak references to tasks)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Coroutine, Optional
|
||||
|
||||
from .logging_setup import get_logger
|
||||
|
||||
log = get_logger("tasks")
|
||||
|
||||
_tasks: "set[asyncio.Task[Any]]" = set()
|
||||
|
||||
|
||||
def _on_done(task: "asyncio.Task[Any]") -> None:
|
||||
_tasks.discard(task)
|
||||
if not task.cancelled() and task.exception() is not None:
|
||||
exc = task.exception()
|
||||
log.error(f"background task {task.get_name()!r} failed: {exc!r}", exc_info=exc)
|
||||
|
||||
|
||||
def spawn(coro: Coroutine[Any, Any, Any], *, name: Optional[str] = None) -> "asyncio.Task[Any]":
|
||||
"""Schedule *coro*, keep it alive until done and log its failure."""
|
||||
task = asyncio.ensure_future(coro)
|
||||
if name:
|
||||
task.set_name(name)
|
||||
_tasks.add(task)
|
||||
task.add_done_callback(_on_done)
|
||||
return task
|
||||
Reference in new issue
Block a user