Files
javis/javis-slave/src/javis_slave/config.py
T
2026-10-04 15:31:44 +02:00

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)