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

+7
View File
@@ -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
+8
View File
@@ -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
+4 -1
View File
@@ -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.
+23 -2
View File
@@ -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 -3
View File
@@ -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
+12 -9
View File
@@ -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()
+20 -11
View File
@@ -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)
+40 -16
View File
@@ -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:
+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)
+2 -1
View File
@@ -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)
+41 -4
View File
@@ -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
+76 -60
View File
@@ -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 ────────────────────────────────────────────
+28
View File
@@ -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