"""Slave-local configuration: master address, credentials, audio devices. Persisted to ``%LOCALAPPDATA%/JavisSlave/slave_config.json`` (or ``~/.javis-slave/slave_config.json`` on non-Windows). Set ``JAVIS_SLAVE_CONFIG_DIR`` to override (useful for systemd services). """ from __future__ import annotations import json import logging import os import secrets import uuid 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") if override: return Path(override).expanduser() base = os.environ.get("LOCALAPPDATA") if base: return Path(base) / "JavisSlave" return Path.home() / ".javis-slave" def config_path() -> Path: return config_dir() / "slave_config.json" @dataclass class WakeWordSettings: """Wake-word block received from the master.""" model: str = "hey_jarvis" threshold: float = 0.5 vad_threshold: float = 0.5 silence_ms: int = 800 @dataclass class SlaveConfig: device_id: str = field(default_factory=lambda: str(uuid.uuid4())) device_name: str = "" master_host: str = "" master_port: int = 8765 api_key: str = "" room: Optional[str] = None # cached; refreshed from master input_device: Optional[int] = None output_device: Optional[int] = None input_gain: float = 1.0 sample_rate: int = 16000 wake_word: WakeWordSettings = field(default_factory=WakeWordSettings) # Local MCP control server bind (review A-6: was hardcoded to 127.0.0.1, # 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: return bool(self.master_host and self.api_key) @property def base_url(self) -> str: 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 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: 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 def save_config(cfg: SlaveConfig) -> None: path = config_path() path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(".tmp") 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)