135 lines
4.5 KiB
Python
135 lines
4.5 KiB
Python
"""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)
|