This commit is contained in:
Javis committed 2026-10-04 15:31:44 +02:00
1 parent 0246cbb6ba
commit 0ca4b4bf7a
89 files changed
+7042 -2480

No files matched your search

+57 -13
View File
@@ -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)