Initial commit: Javis project

This commit is contained in:
Javis committed 2026-09-21 21:44:00 +02:00
commit 435c645f36
164 files changed
+35279

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
"""javis-slave — touch-optimized satellite companion for Javis."""
__version__ = "0.1.0"
+128
View File
@@ -0,0 +1,128 @@
"""javis-slave entry point."""
from __future__ import annotations
import argparse
import sys
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
p = argparse.ArgumentParser(
prog="javis-slave",
description="Javis satellite: local wake word, remote brain.",
epilog=(
"Examples:\n"
" javis-slave # touch GUI (needs the [gui] extra)\n"
" javis-slave --list-devices # find your mic/speaker indices\n"
" javis-slave --headless --pair 192.168.1.10:8765 123456 --name Kueche\n"
" javis-slave --headless # Raspberry Pi service mode"
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
p.add_argument("--headless", action="store_true",
help="Run without a window (Raspberry Pi / systemd). Logs to stderr.")
p.add_argument("--fullscreen", action="store_true",
help="Start the GUI fullscreen (F11 toggles, Esc leaves).")
p.add_argument("--kiosk", action="store_true",
help="Fullscreen with hidden mouse cursor for touch panels.")
p.add_argument("--pair", nargs=2, metavar=("HOST:PORT", "CODE"),
help="Pair with the master using a code from its Devices tab, then start.")
p.add_argument("--name", default=None,
help="Device name shown on the master (default: hostname). Used with --pair.")
p.add_argument("--list-devices", action="store_true",
help="Print audio devices with their indices and exit.")
p.add_argument("--input-device", type=int, default=None, metavar="N",
help="Persist microphone device index N (see --list-devices).")
p.add_argument("--output-device", type=int, default=None, metavar="N",
help="Persist speaker device index N (see --list-devices).")
p.add_argument("--input-gain", type=float, default=None, metavar="G",
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).")
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)):
return
from .config import load_config, save_config
cfg = load_config()
if args.input_device is not None:
cfg.input_device = args.input_device
if args.output_device is not None:
cfg.output_device = args.output_device
if args.input_gain is not None:
cfg.input_gain = args.input_gain
if args.api_port is not None:
cfg.api_port = args.api_port
save_config(cfg)
def _parse_pair(args: argparse.Namespace):
if not args.pair:
return None
import socket
hostport, code = args.pair
host, _, port_s = hostport.rpartition(":")
if not host or not port_s.isdigit():
print(f"--pair expects HOST:PORT, got {hostport!r}", file=sys.stderr)
sys.exit(2)
name = args.name or socket.gethostname()
return host, int(port_s), code.strip(), name
def main(argv: list[str] | None = None) -> int:
args = _parse_args(argv)
if args.list_devices:
from .audio_io.stream import list_audio_devices
print(list_audio_devices())
return 0
_apply_device_overrides(args)
pair = _parse_pair(args)
if args.headless:
from .headless import run_headless
return run_headless(pair)
try:
import PySide6 # noqa: F401
except ImportError:
print(
"PySide6 is not installed. Either install the GUI extra "
"(pip install -e '.[gui]') or run with --headless.",
file=sys.stderr,
)
return 1
if pair is not None:
# 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 .headless import pair_cli
async def _do_pair() -> int:
cfg = load_config()
save_config(cfg)
client = MasterClient(cfg)
try:
await pair_cli(cfg, client, *pair)
return 0
except MasterError as exc:
print(f"pairing failed: {exc}", file=sys.stderr)
return 1
finally:
await client.aclose()
rc = asyncio.run(_do_pair())
if rc != 0:
return rc
from .app import run_app
return run_app(fullscreen=args.fullscreen, kiosk=args.kiosk)
if __name__ == "__main__":
sys.exit(main())
+286
View File
@@ -0,0 +1,286 @@
"""HTTP client for the Javis master API."""
from __future__ import annotations
import asyncio
import base64
from dataclasses import dataclass
from pathlib import Path
from typing import Awaitable, Callable, Optional
import httpx
from .config import SlaveConfig, WakeWordSettings
from .logging_setup import get_logger
log = get_logger("api")
class MasterError(Exception):
"""Raised when the master rejects a request or is unreachable."""
@dataclass
class PairResult:
api_key: str
room: Optional[str]
wake_word: WakeWordSettings
@dataclass
class TurnResult:
user_text: str
reply_text: str
audio_wav: Optional[bytes] # decoded WAV bytes (16-bit PCM)
sample_rate: Optional[int]
error: Optional[str] = None # master-side failure; reply_text is the spoken error phrase
def _http_detail(r: httpx.Response) -> str:
"""Best-effort human-readable detail from a FastAPI error body."""
try:
detail = r.json().get("detail")
if detail:
return f" — {detail}"
except Exception: # noqa: BLE001
pass
return ""
def _wake_word_from(payload: dict) -> WakeWordSettings:
ww = payload.get("wake_word") or {}
return WakeWordSettings(
model=str(ww.get("model", "hey_jarvis")),
threshold=float(ww.get("threshold", 0.5)),
vad_threshold=float(ww.get("vad_threshold", 0.5)),
silence_ms=int(ww.get("silence_ms", 800)),
)
class MasterClient:
"""Async client for the master's ``/api/v1`` endpoints."""
def __init__(self, cfg: SlaveConfig):
self.cfg = cfg
self._client = httpx.AsyncClient(timeout=httpx.Timeout(10.0, read=180.0))
# Connection health tracking (Alexa-style connection monitoring)
self._connection_ok: bool = True
self._retry_count: int = 0
self._max_retries: int = 10
self._backoff_base: float = 1.0 # start at 1s
self._backoff_max: float = 30.0 # cap at 30s
self._pending_reconnect: bool = False
async def aclose(self) -> None:
"""Close the underlying HTTP client (release the connection pool)."""
await self._client.aclose()
@property
def connected(self) -> bool:
"""Current connection health status."""
return self._connection_ok
@property
def retry_count(self) -> int:
"""Number of consecutive failed attempts."""
return self._retry_count
@property
def backoff_seconds(self) -> float:
"""Current exponential backoff duration in seconds."""
return min(self._backoff_base * (2 ** self._retry_count), self._backoff_max)
def mark_connected(self) -> None:
"""Reset retry state on successful connection."""
if not self._connection_ok:
log.info("connection restored")
self._connection_ok = True
self._retry_count = 0
def mark_disconnected(self, exc: Exception) -> None:
"""Record a failed attempt with exponential backoff."""
self._connection_ok = False
self._retry_count += 1
log.warning(f"connection lost (attempt {self._retry_count}/{self._max_retries}); "
f"reconnect in {self.backoff_seconds:.0f}s: {exc}")
def should_reconnect(self) -> bool:
"""Whether another reconnection attempt is warranted."""
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")
return False
def _headers(self) -> dict:
return {
"X-Device-Id": self.cfg.device_id,
"Authorization": f"Bearer {self.cfg.api_key}",
}
async def _retryable_request(
self,
request: Callable[[], Awaitable[httpx.Response]],
retries: Optional[int] = None,
) -> httpx.Response:
"""Execute an HTTP request with exponential backoff reconnection.
``request`` is a zero-arg callable that produces a *fresh* awaitable
(e.g. ``lambda: self._client.post(...)``). A new request is built on
every attempt so a failed attempt never re-awaits a closed coroutine.
Args:
request: Zero-arg callable producing the request awaitable.
retries: Max number of retries (default ``self._max_retries``).
"""
if retries is None:
retries = self._max_retries
last_exc: Optional[Exception] = None
for attempt in range(retries + 1):
try:
r = await request()
self.mark_connected()
return r
except httpx.HTTPError as e:
last_exc = e
self.mark_disconnected(e)
if attempt >= retries:
raise MasterError(
f"Master nach {retries + 1} Versuchen 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})")
await asyncio.sleep(wait)
raise last_exc # type: ignore[misc]
async def pair(self, host: str, port: int, pairing_code: str,
device_name: str) -> PairResult:
try:
# Pairing codes are single-use: one retry is enough, and a wrong
# IP must fail in seconds, not after the full backoff ladder.
r = await self._retryable_request(
lambda: self._client.post(
f"http://{host}:{port}/api/v1/pair",
json={
"pairing_code": pairing_code,
"device_id": self.cfg.device_id,
"device_name": device_name,
},
),
retries=1,
)
except MasterError as e:
raise e
if r.status_code == 401:
raise MasterError("Pairing-Code ungültig oder abgelaufen.")
if r.status_code == 429:
raise MasterError("Zu viele Versuche — bitte kurz warten.")
if r.status_code != 200:
raise MasterError(f"Pairing fehlgeschlagen (HTTP {r.status_code}){_http_detail(r)}.")
data = r.json()
return PairResult(
api_key=str(data["api_key"]),
room=data.get("room"),
wake_word=_wake_word_from(data),
)
async def fetch_config(self) -> tuple[Optional[str], WakeWordSettings]:
"""Return ``(room, wake_word)`` from the master."""
try:
r = await self._retryable_request(
lambda: self._client.get(
f"{self.cfg.base_url}/api/v1/config", headers=self._headers(), timeout=10.0,
)
)
except MasterError as e:
raise e
if r.status_code == 401:
raise MasterError("Gerät nicht (mehr) autorisiert — bitte neu koppeln.")
if r.status_code != 200:
raise MasterError(f"Config-Abruf fehlgeschlagen (HTTP {r.status_code}).")
data = r.json()
return data.get("room"), _wake_word_from(data)
async def list_slaves(self) -> list[dict]:
"""Return the master's slave list (``GET /api/v1/slaves``) as raw dicts.
Uses this device's own pairing credentials, so the caller (e.g. the
MCP pass-through endpoints) need not know the master's address or the
auth header format. Bounded to a single attempt (``retries=0``) so a
down master fails fast instead of running the full 10-retry backoff —
an HTTP/MCP query must not block for minutes.
"""
try:
r = await self._retryable_request(
lambda: self._client.get(
f"{self.cfg.base_url}/api/v1/slaves",
headers=self._headers(), timeout=10.0,
),
retries=0,
)
except MasterError as e:
raise e
if r.status_code != 200:
raise MasterError(f"Slave-list query failed (HTTP {r.status_code}).")
data = r.json()
return data if isinstance(data, list) else []
async def send_turn(self, wav_bytes: bytes) -> TurnResult:
"""Upload a recorded utterance; block until the master's reply."""
try:
# The turn POST runs a full STT+LLM+TTS on the master and is NOT
# idempotent — limit retries so a flaky network can't re-execute
# (and duplicate) the same utterance many times (see review A-2).
r = await self._retryable_request(
lambda: self._client.post(
f"{self.cfg.base_url}/api/v1/turn",
headers=self._headers(),
files={"audio": ("utterance.wav", wav_bytes, "audio/wav")},
),
retries=1,
)
except MasterError as e:
raise e
if r.status_code == 401:
raise MasterError("Gerät nicht (mehr) autorisiert — bitte neu koppeln.")
if r.status_code != 200:
raise MasterError(f"Anfrage fehlgeschlagen (HTTP {r.status_code}){_http_detail(r)}.")
data = r.json()
audio_b64 = data.get("tts_audio_b64")
return TurnResult(
user_text=str(data.get("user_text", "")),
reply_text=str(data.get("reply_text", "")),
audio_wav=base64.b64decode(audio_b64) if audio_b64 else None,
sample_rate=data.get("sample_rate"),
error=data.get("error") or None,
)
async def check_health(self, host: str, port: int) -> bool:
"""Unauthenticated reachability probe (``GET /api/v1/health``), single attempt."""
try:
r = await self._client.get(f"http://{host}:{port}/api/v1/health", timeout=5.0)
return r.status_code == 200
except httpx.HTTPError:
return False
async def download_wake_model(self, dest: "Path") -> bool:
"""Fetch the master's custom wake-word ``.onnx`` into *dest*.
Returns False when the master uses a built-in model (HTTP 404) or is
unreachable; never raises.
"""
try:
r = await self._client.get(
f"{self.cfg.base_url}/api/v1/wake_word/model",
headers=self._headers(), timeout=60.0,
)
except httpx.HTTPError as exc:
log.warning(f"wake model download failed: {exc}")
return False
if r.status_code != 200 or not r.content:
return False
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_suffix(".onnx.part")
tmp.write_bytes(r.content)
tmp.replace(dest)
log.info(f"downloaded custom wake model from master → {dest} ({len(r.content)} bytes)")
return True
+282
View File
@@ -0,0 +1,282 @@
"""App wiring: qasync event loop, pairing flow, engine ↔ UI bridge."""
from __future__ import annotations
import asyncio
import sys
from typing import Optional
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 .http_server import build_http_app
log = get_logger("slave.app")
# Re-try starting the audio engine (mic not ready / unplugged) this often.
_ENGINE_RETRY_S = 10.0
def run_app(*, fullscreen: bool = False, kiosk: bool = False) -> int:
from PySide6 import QtWidgets
import qasync
from .main_window import SlaveWindow
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv)
app.setApplicationName("Javis Slave")
app.setStyle("Fusion")
loop = qasync.QEventLoop(app)
asyncio.set_event_loop(loop)
cfg = load_config()
save_config(cfg) # persist generated device_id on first run
client = MasterClient(cfg)
window = SlaveWindow()
engine: Optional[AudioEngine] = None
engine_task: Optional[asyncio.Task] = None
http_task: Optional[asyncio.Task] = None
http_server = None
retry_task: Optional[asyncio.Task] = None
last_state: str = "idle"
last_error: Optional[str] = None
# ── HTTP server state getters ─────────────────────────────────────────
def _get_engine() -> Optional[AudioEngine]:
return engine
def _get_state() -> str:
return last_state
def _get_error() -> Optional[str]:
return last_error
def _get_mic_level() -> tuple[float, float]:
if engine is None:
return 0.0, 0.0
# Owned by the engine (set in _wait_for_wake), exposed publicly (A-11).
return engine.last_rms, engine.last_score
def _get_master_client() -> MasterClient:
"""Provide the shared MasterClient to the HTTP server for pass-throughs."""
return client
async def _restart_engine() -> None:
"""Stop and restart the audio engine (wired to ``POST /mcp/v1/restart``).
Defined *after* ``_stop_engine`` / ``_start_engine`` below so we can
reference them via closure; the handler in http_server awaits it and
converts exceptions into a 500-style ``ControlResponse(success=False)``.
"""
await _stop_engine()
await _start_engine()
# ── HTTP server lifecycle ─────────────────────────────────────────────
async def _start_http_server() -> None:
nonlocal http_task, http_server
if http_task is not None:
return # idempotent: don't start twice
try:
from uvicorn import Server, Config
http_app = build_http_app(
cfg,
_get_engine, _get_state, _get_error, _get_mic_level,
on_restart=_restart_engine,
master_client_getter=_get_master_client,
)
# Bind to the configured host/port (A-6: was 127.0.0.1:8766, so
# remote MCP controllers couldn't reach it). Distinct from master:8765.
# loop="none"/lifespan="off": the qasync loop is already running.
config = Config(app=http_app, host=cfg.api_host, port=cfg.api_port,
log_level="warning", loop="none", lifespan="off")
http_server = Server(config)
http_task = asyncio.create_task(http_server.serve())
log.info(f"HTTP server started on {cfg.api_host}:{cfg.api_port} for MCP control")
except Exception as e: # noqa: BLE001
log.exception(f"failed to start HTTP server: {e}")
async def _stop_http_server() -> None:
nonlocal http_task, http_server
if http_task is not None:
if http_server is not None:
http_server.should_exit = True
try:
await asyncio.wait_for(http_task, timeout=5.0)
except (asyncio.TimeoutError, asyncio.CancelledError, Exception): # noqa: BLE001
http_task.cancel()
http_task = None
http_server = None
# ── engine lifecycle ──────────────────────────────────────────────────
async def _start_engine() -> None:
nonlocal engine, engine_task, last_state, last_error
last_error = None
engine = AudioEngine(cfg, client)
# Capture mic level in engine for HTTP access
def _on_mic_level(rms: float, score: float) -> None:
window.engineMicLevel.emit(rms, score)
def _on_state(state: str) -> None:
nonlocal last_state
last_state = state
window.engineState.emit(state)
def _on_error(msg: str) -> None:
nonlocal last_error
last_error = msg
window.engineError.emit(msg)
engine.on_state = _on_state
engine.on_mic_level = _on_mic_level
engine.on_turn = window.engineTurn.emit
engine.on_error = _on_error
engine.on_connection = window.engineConnection.emit
engine.on_room = window.engineRoom.emit
try:
await engine.start()
except Exception as e: # noqa: BLE001
log.exception(f"audio engine failed to start: {e}")
window.engineError.emit(
f"Audio-Start fehlgeschlagen: {e} — neuer Versuch in {_ENGINE_RETRY_S:.0f}s")
engine = None
_schedule_engine_retry()
return
engine_task = asyncio.create_task(engine.run())
window.engineRoom.emit(cfg.room)
window.show_main()
def _schedule_engine_retry() -> None:
"""Retry the engine start until it succeeds (mic plugged in late, USB re-enumeration)."""
nonlocal retry_task
if retry_task is not None and not retry_task.done():
return
async def _retry() -> None:
await asyncio.sleep(_ENGINE_RETRY_S)
if engine is None and cfg.paired:
await _start_engine()
retry_task = asyncio.ensure_future(_retry())
async def _stop_engine() -> None:
nonlocal engine, engine_task, last_state, retry_task
last_state = "idle"
if retry_task is not None:
retry_task.cancel()
retry_task = None
# The MCP control server is independent of pairing state — it keeps
# running across re-pairs, so it is NOT stopped here (only at shutdown).
if engine_task is not None:
engine_task.cancel()
try:
await engine_task
except asyncio.CancelledError:
pass
engine_task = None
if engine is not None:
await engine.stop()
engine = None
# ── pairing flow ──────────────────────────────────────────────────────
def _on_pair_requested(host: str, port: int, code: str, name: str) -> None:
async def _do_pair():
import socket
name_final = name or socket.gethostname()
if not await client.check_health(host, port):
window.setup_screen.show_error(
f"Master unter {host}:{port} nicht erreichbar — IP/Port prüfen und "
"sicherstellen, dass dort api.enabled: true gesetzt ist.")
return
try:
result = await client.pair(host, port, code, name_final)
except MasterError as e:
window.setup_screen.show_error(str(e))
return
cfg.master_host = host
cfg.master_port = port
cfg.api_key = result.api_key
cfg.device_name = name_final
cfg.room = result.room
cfg.wake_word = result.wake_word
save_config(cfg)
log.info(f"paired with master {host}:{port}")
await _start_engine()
asyncio.ensure_future(_do_pair())
window.setup_screen.pairRequested.connect(_on_pair_requested)
# ── re-pair (settings) ────────────────────────────────────────────────
def _on_settings() -> None:
def _repair():
async def _do():
await _stop_engine()
cfg.api_key = ""
save_config(cfg)
window.setup_screen.pair_btn.setEnabled(True)
window.setup_screen.status_lbl.setText("")
window.show_setup()
asyncio.ensure_future(_do())
window.show_settings_dialog(cfg.device_id, cfg.device_name, _repair)
window.main_screen.settingsRequested.connect(_on_settings)
# ── mute toggle ───────────────────────────────────────────────────────
def _on_mute_toggled(muted: bool) -> None:
if engine is None:
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())
log.info(f"mute toggled: {muted}")
window.main_screen.muteToggled.connect(_on_mute_toggled)
# ── startup ───────────────────────────────────────────────────────────
# Choose the initial page *before* showing the window so the setup screen
# doesn't flash for a frame (A-17).
if cfg.paired:
window.show_main()
else:
window.show_setup()
if kiosk:
window.set_kiosk(True)
elif fullscreen:
window.showFullScreen()
else:
window.show()
# 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())
if cfg.paired:
asyncio.ensure_future(_start_engine())
async def _shutdown() -> None:
await _stop_engine()
await _stop_http_server()
await client.aclose()
with loop:
loop.run_forever()
# The Qt app has quit; finish the async teardown so the mic/HTTP
# server are released instead of being lost as a fire-and-forget task.
try:
loop.run_until_complete(_shutdown())
except Exception: # noqa: BLE001
log.exception("shutdown failed")
return 0
+395
View File
@@ -0,0 +1,395 @@
"""Wake-word → record → upload → playback loop.
Uses javis-slave's own vendored audio stack (``javis_slave.audio_io``): a
self-contained copy of MicStream, WakeWordDetector, Silero VAD and
``record_utterance`` so this app has no dependency on the parent ``javis``
package and can be deployed standalone to a satellite device.
"""
from __future__ import annotations
import asyncio
import io
import time
from pathlib import Path
from typing import Callable, Optional
import numpy as np
from .audio_io.capture import record_utterance
from .audio_io.stream import MicStream, play_pcm
from .audio_io.vad import make_vad
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
log = get_logger("slave.audio")
# Refresh room/wake-word settings from the master this often.
_CONFIG_POLL_S = 60.0
# Re-enter the wake loop if the mic yields nothing for this long.
_WAKE_STALE_S = 60.0
# Stop an in-progress recording if no audio arrives for this long (mic disconnect).
_RECORD_STALE_S = 30.0
# After this many consecutive silent wake-loop exits, reopen the mic device.
_MIC_RESTART_AFTER_STALE = 2
def _encode_wav(audio: np.ndarray, sample_rate: int) -> bytes:
import soundfile as sf
buf = io.BytesIO()
sf.write(buf, np.asarray(audio, dtype=np.float32), sample_rate,
format="WAV", subtype="PCM_16")
return buf.getvalue()
def _tones(sr: int, freqs: list[float], dur: float, amp: float) -> np.ndarray:
"""Concatenate short sine tones with a click-free fade-out (matches the master's chimes)."""
out = []
n = int(sr * dur)
fade_n = max(1, int(sr * 0.008))
for f in freqs:
t = np.linspace(0, dur, n, endpoint=False, dtype=np.float32)
w = np.sin(2 * np.pi * f * t) * amp
w[-fade_n:] *= np.linspace(1.0, 0.0, fade_n, dtype=np.float32)
out.append(w.astype(np.float32))
return np.concatenate(out)
class AudioEngine:
"""Runs the satellite listen loop and reports to the UI via callbacks.
All callbacks are plain sync callables invoked from the asyncio loop —
the Qt layer bridges them to signals.
"""
def __init__(self, cfg: SlaveConfig, client: MasterClient):
self.cfg = cfg
self.client = client
self._stop = asyncio.Event()
self.mic: Optional[MicStream] = None
self.wake: Optional[WakeWordDetector] = None
self.vad = None
self._config_task: Optional[asyncio.Task] = None
# Set when last response ended with '?'; next recording will use 3x silence timeout
self._last_response_is_question: bool = False
# UI callbacks (assigned by the app layer).
self.on_state: Callable[[str], None] = lambda s: None
self.on_mic_level: Callable[[float, float], None] = lambda rms, score: None
self.on_turn: Callable[[str, str], None] = lambda user, reply: None
self.on_error: Callable[[str], None] = lambda msg: None
self.on_connection: Callable[[bool, int, float], None] = lambda ok, retry, backoff: None
self.on_room: Callable[[Optional[str]], None] = lambda room: None
self.muted: bool = False # Alexa-style mute state
# Serialise mute/unmute so a check-then-act race can't leave the mic
# paused while the engine believes it's unmuted (review A-3).
self._mute_lock = asyncio.Lock()
# Set while unmuted so waiters wake immediately instead of polling (A-4).
self._unmuted = asyncio.Event()
self._unmuted.set()
# Latest mic level / wake score — read by the UI (public, no _last_*).
self.last_rms: float = 0.0
self.last_score: float = 0.0
# ---------------- lifecycle ----------------
async def start(self) -> None:
self.mic = MicStream(
sample_rate=self.cfg.sample_rate,
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)
log.info(f"audio engine ready (wake word: '{self.cfg.wake_word.model}')")
@staticmethod
def _wake_model_dir() -> "Path":
return config_dir() / "models"
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()
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"):
raise RuntimeError(
f"custom wake word model '{name}' not available from master — "
f"copy {name}.onnx to {model_dir} manually")
return await asyncio.to_thread(WakeWordDetector, name, threshold, [model_dir])
async def stop(self) -> None:
self._stop.set()
self._unmuted.set() # wake any waiters so they exit
if self.mic is not None:
await self.mic.stop()
if self._config_task is not None:
self._config_task.cancel()
# Release native models / free the GPU so the process can exit cleanly.
self.wake = None
self.vad = None
self._config_task = None
self.mic = None
# ---------------- mute ----------------
async def mute(self) -> None:
"""Mute the mic: suspend capture and stop wake/turn processing."""
async with self._mute_lock:
self.muted = True
self._unmuted.clear()
if self.mic is not None:
self.mic.pause()
self.on_state("muted")
async def unmute(self) -> None:
"""Unmute: re-enable capture and resume the wake loop."""
async with self._mute_lock:
self.muted = False
if self.mic is not None:
self.mic.resume()
self._unmuted.set()
self.on_state("idle")
async def toggle_mute(self) -> bool:
"""Toggle mute; returns the new muted state."""
if self.muted:
await self.unmute()
else:
await self.mute()
return self.muted
async def _wait_unmuted_or_stop(self) -> None:
"""Block until either the engine is unmuted or a stop is requested.
Waits on the ``_unmuted`` event (set by :meth:`unmute`/:meth:`stop`)
instead of busy-polling, so there's no 0.1s wakeup latency. If a stop
is requested while muted, this returns immediately so the outer loop
can exit.
"""
unmut = asyncio.ensure_future(self._unmuted.wait())
stopp = asyncio.ensure_future(self._stop.wait())
try:
done, _ = await asyncio.wait(
{unmut, stopp}, return_when=asyncio.FIRST_COMPLETED)
finally:
unmut.cancel()
stopp.cancel()
if self._stop.is_set():
return
async def run(self) -> None:
"""Main loop: wake → record → upload → play. Never raises."""
self._config_task = asyncio.ensure_future(self._config_poll_loop())
# Bounded backoff so a persistently-failing turn loop doesn't hot-spin,
# but stays responsive once things recover (review A-13).
consecutive_errors = 0
stale_exits = 0
try:
while not self._stop.is_set():
if self.muted:
# Idle until the API/user unmutes (or we're told to stop).
await self._wait_unmuted_or_stop()
continue
try:
woke = await self._wait_for_wake()
if not woke:
if self._stop.is_set() or self.muted:
continue
# The mic yielded nothing for _WAKE_STALE_S: USB device
# unplugged / driver stalled. Reopen instead of looping forever.
stale_exits += 1
if stale_exits >= _MIC_RESTART_AFTER_STALE and self.mic is not None:
log.warning("mic silent for too long — reopening device")
self.on_error("Mikrofon liefert keine Daten — Gerät wird neu geöffnet …")
await self.mic.restart()
stale_exits = 0
continue
stale_exits = 0
await self._handle_turn()
consecutive_errors = 0
except asyncio.CancelledError:
raise
except Exception as e: # noqa: BLE001
consecutive_errors += 1
log.exception(f"slave turn failed: {e}")
self.on_error(str(e))
await self._play_error_tone()
self.on_state("idle")
await asyncio.sleep(min(0.5 * (2 ** (consecutive_errors - 1)), 30.0))
except asyncio.CancelledError:
pass
finally:
if self._config_task is not None:
self._config_task.cancel()
self._config_task = None
# ---------------- phases ----------------
async def _wait_for_wake(self) -> bool:
assert self.wake is not None and self.mic is not None
self.on_state("idle")
self.wake.reset()
self.mic.drain()
last_pub = 0.0
async for chunk in self.mic.chunks(stale_timeout=_WAKE_STALE_S):
if self._stop.is_set():
return False
if self.muted:
# Muted mid-loop: drop stale audio and exit the wake phase;
# the outer loop will re-enter _wait_for_wake, where the
# callback is a no-op while paused so the loop idles.
self.mic.drain()
self.on_state("muted")
return False
score = self.wake.feed(chunk)
now = time.monotonic()
if now - last_pub > 0.1:
last_pub = now
rms = float(np.sqrt(np.mean(chunk.astype(np.float32) ** 2))) if len(chunk) else 0.0
self.last_rms = rms
self.last_score = float(score)
self.on_mic_level(rms, float(score))
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))
return True
if self._stop.is_set():
return False
return False
async def _play_chime(self, freqs: list[float], dur: float, amp: float) -> None:
"""Fire-and-forget feedback tone; never raises (a screenless Pi relies on these)."""
try:
await play_pcm(_tones(self.cfg.sample_rate, freqs, dur, amp),
self.cfg.sample_rate, output_device=self.cfg.output_device)
except Exception as exc: # noqa: BLE001
log.debug(f"chime failed (non-critical): {exc}")
async def _play_error_tone(self) -> None:
"""Low descending two-tone: "something went wrong"."""
await self._play_chime([440.0, 330.0], 0.12, 0.2)
async def _handle_turn(self) -> None:
assert self.mic is not None
self.on_state("listening")
# If last response was a question, triple the listening timeout
silence_override = None
if self._last_response_is_question:
base_silence = self.cfg.wake_word.silence_ms
silence_override = base_silence * 3
log.debug(f"last response was a question; extending silence timeout to {silence_override}ms (was {base_silence}ms)")
self._last_response_is_question = False # reset flag for next turn
audio = await record_utterance(
self.cfg.sample_rate, self.cfg.wake_word.silence_ms, self.mic, self.vad,
silence_ms_override=silence_override,
stale_timeout=_RECORD_STALE_S)
if audio is None or len(audio) < 1600:
log.info("no utterance captured")
self.on_state("idle")
return
self.on_state("thinking")
wav = _encode_wav(audio, self.cfg.sample_rate)
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)
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)
self.on_error(f"Verbindungsfehler — Wiederverbindung in {self.client.backoff_seconds:.0f}s …")
else:
self.on_error(str(e))
await self._play_error_tone()
self.on_state("idle")
return
if result.error:
# Master completed the request but the turn itself failed; it
# already put the spoken error phrase into reply_text/audio.
log.warning(f"master reported turn error: {result.error}")
self.on_error(result.error)
if not result.audio_wav:
await self._play_error_tone()
if result.user_text or result.reply_text:
self.on_turn(result.user_text, result.reply_text)
# Check if response ended with '?' to trigger extended listening on next turn
if result.reply_text.rstrip().endswith("?"):
log.debug("assistant response ends with '?'; will extend listening timeout on next turn")
self._last_response_is_question = True
if result.audio_wav and result.sample_rate:
self.on_state("speaking")
try:
import soundfile as sf
samples, sr = sf.read(io.BytesIO(result.audio_wav),
dtype="float32", always_2d=False)
if samples.ndim > 1:
samples = samples.mean(axis=1)
await play_pcm(samples, int(sr),
output_device=self.cfg.output_device,
cancel_event=self._stop)
except Exception as e: # noqa: BLE001
log.warning(f"reply playback failed: {e}")
self.on_state("idle")
# ---------------- config refresh ----------------
async def _config_poll_loop(self) -> None:
"""Periodically pull room + wake-word settings from the master."""
while not self._stop.is_set():
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)
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.")
except Exception: # noqa: BLE001
log.exception("config refresh failed unexpectedly")
try:
await asyncio.wait_for(self._stop.wait(), timeout=_CONFIG_POLL_S)
except asyncio.TimeoutError:
pass
def _apply_master_settings(self, room: Optional[str], ww: WakeWordSettings) -> bool:
changed = False
if room != self.cfg.room:
self.cfg.room = room
self.on_room(room)
changed = True
old = self.cfg.wake_word
if (ww.model, ww.threshold, ww.vad_threshold, ww.silence_ms) != (
old.model, old.threshold, old.vad_threshold, old.silence_ms):
log.info(f"wake-word settings updated by master: {ww}")
rebuild_model = ww.model != old.model
self.cfg.wake_word = ww
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))
else:
self.wake.threshold = ww.threshold
self.vad = make_vad(threshold=ww.vad_threshold)
changed = True
return changed
async def _swap_wake_model(self, name: str, threshold: float) -> None:
try:
self.wake = await self._load_wake(name, threshold)
except Exception as e: # noqa: BLE001
log.error(f"failed to load wake model '{name}': {e}")
self.on_error(f"Wake-Word-Modell '{name}' konnte nicht geladen werden.")
@@ -0,0 +1,6 @@
"""javis-slave's own copy of the Javis audio I/O stack.
Vendored (not imported from the parent ``javis`` package) so this subproject
can be deployed standalone to a satellite device without the rest of the
Javis repo present.
"""
@@ -0,0 +1,83 @@
"""Utterance capture: VAD-segments speech from a mic stream.
Vendored from ``javis.audio_io.capture`` — decoupled from the master's
``Config`` dataclass so this module has no dependency on the ``javis``
package; takes plain ``sample_rate``/``silence_ms`` instead.
"""
from __future__ import annotations
from typing import Optional
import numpy as np
from ..logging_setup import get_logger
from .stream import MicStream
from .vad import VAD
log = get_logger("audio.capture")
async def record_utterance(
sample_rate: int,
silence_ms: int,
mic: MicStream,
vad: VAD,
max_seconds: float = 20.0,
initial_grace_ms: int = 1500,
silence_ms_override: Optional[int] = None,
stale_timeout: Optional[float] = None,
) -> Optional[np.ndarray]:
"""Capture audio until trailing silence (*silence_ms*) is observed.
Args:
sample_rate: Audio sample rate in Hz.
silence_ms: Silence timeout (ms) before stopping recording.
mic: Microphone stream.
vad: Voice Activity Detector.
max_seconds: Maximum recording duration in seconds.
initial_grace_ms: Grace period before speech is expected (ms).
silence_ms_override: Override the silence timeout (ms).
When set, this value is used instead of the silence_ms parameter.
stale_timeout: If set, recording stops early after this many seconds
of silence (mic disconnect guard).
Returns the recorded float32 mono numpy array at the mic's sample rate, or
None if no speech was detected within the grace period.
"""
# Use override if provided, otherwise use the passed silence_ms
effective_silence_ms = silence_ms_override if silence_ms_override is not None else silence_ms
chunk_ms = mic.chunk_ms
silence_chunks_needed = max(1, effective_silence_ms // chunk_ms)
grace_chunks = max(1, initial_grace_ms // chunk_ms)
max_chunks = int(max_seconds * 1000 / chunk_ms)
vad.reset()
collected: list[np.ndarray] = []
silent_run = 0
seen_speech = False
chunks_seen = 0
async for chunk in mic.chunks(stale_timeout=stale_timeout):
chunks_seen += 1
collected.append(chunk)
result = vad.process(chunk)
if result.is_speech:
seen_speech = True
silent_run = 0
else:
silent_run += 1
if seen_speech and silent_run >= silence_chunks_needed:
break
if not seen_speech and chunks_seen >= grace_chunks:
log.debug("no speech during grace window")
return None
if chunks_seen >= max_chunks:
log.info("max utterance length reached")
break
audio = np.concatenate(collected) if collected else np.zeros(0, dtype=np.float32)
log.info(f"utterance: {len(audio)/sample_rate:.2f}s")
return audio
@@ -0,0 +1,404 @@
"""Async microphone capture and speaker playback.
Vendored from ``javis.audio_io.stream`` — decoupled from the master's
``Config`` dataclass so this module has no dependency on the ``javis``
package.
"""
from __future__ import annotations
import asyncio
import time
from typing import AsyncIterator, Optional
import numpy as np
from ..logging_setup import get_logger
log = get_logger("audio.stream")
# Warn at most once per 10 seconds when chunks are being dropped.
_DROP_WARN_INTERVAL_S = 10.0
class _StreamResampler:
"""Block-wise resampler that carries filter state and phase across calls.
``scipy.signal.resample_poly`` is stateless and smears its filter edges
over every 30 ms block (for 44.1→16 kHz the filter is longer than the
block itself), so it is unusable on a live mic. This does a stateful FIR
low-pass (when decimating) followed by linear interpolation with the
fractional read position preserved between blocks — click-free and cheap
enough for a Pi Zero.
"""
def __init__(self, src_rate: int, dst_rate: int):
from scipy.signal import firwin
self.src, self.dst = src_rate, dst_rate
self._step = src_rate / dst_rate
self._phase = 0.0
self._last = 0.0
self._taps = None
self._zi = None
if src_rate > dst_rate:
# Cut at 90 % of the target Nyquist, normalised to the source Nyquist.
cutoff = min(0.99, 0.9 * (dst_rate / 2) / (src_rate / 2))
self._taps = firwin(63, cutoff).astype(np.float32)
self._zi = np.zeros(len(self._taps) - 1, dtype=np.float32)
def process(self, x: np.ndarray) -> np.ndarray:
if self._taps is not None:
from scipy.signal import lfilter
x, self._zi = lfilter(self._taps, [1.0], x, zi=self._zi)
n = len(x)
if n == 0:
return x.astype(np.float32)
# Only positions up to the last real sample (n-1) can be interpolated
# now; anything in (n-1, n) needs the next block's first sample and is
# carried over as a negative phase (between ``_last`` and x[0]).
last_idx = n - 1
if self._phase <= last_idx:
count = int((last_idx - self._phase) // self._step) + 1
pos = self._phase + self._step * np.arange(count)
pos = pos[pos <= last_idx]
else:
pos = np.empty(0)
xp = np.arange(-1, n)
fp = np.concatenate(([self._last], x))
out = np.interp(pos, xp, fp).astype(np.float32)
self._phase = (pos[-1] + self._step - n) if len(pos) else (self._phase - n)
self._last = float(x[-1])
return out
class MicStream:
"""Async iterator over fixed-size mic chunks (mono float32).
Always yields 16 kHz (``sample_rate``) audio. If the device refuses that
rate (common for USB mics on Raspberry Pi / ALSA, which only expose 44.1 or
48 kHz), the stream is opened at the device's native rate and resampled.
"""
_SENTINEL = object()
def __init__(self, sample_rate: int = 16000, input_device: Optional[int] = None,
input_gain: float = 1.0, chunk_ms: int = 30):
self.sr = sample_rate
self.device = input_device
self.gain = input_gain
self.chunk_ms = chunk_ms
self.chunk_samples = int(self.sr * chunk_ms / 1000)
self._queue: asyncio.Queue = asyncio.Queue(maxsize=128)
self._stream = None
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._paused: bool = False
# Native device rate when it differs from ``sr`` (resampling active).
self._device_sr: int = sample_rate
self._resampler: Optional[_StreamResampler] = None
# Drop-warning rate limiter.
self._drop_count: int = 0
self._last_drop_warn: float = 0.0
@property
def paused(self) -> bool:
"""Whether capture is temporarily suspended (mic muted)."""
return self._paused
def _cb(self, indata, frames, time_info, status): # noqa: D401
if status:
log.debug(f"mic status: {status}")
if self._paused:
return
# Stereo-only devices: average to mono rather than taking one channel,
# so a mic wired to the right input still works.
if indata.shape[1] > 1:
chunk = indata.mean(axis=1, dtype=np.float32)
else:
chunk = indata[:, 0].copy().astype(np.float32)
if self._resampler is not None:
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)
def _enqueue(self, chunk: np.ndarray) -> None:
# Drop oldest to keep mic latency bounded when the loop is busy.
if self._queue.full():
try:
self._queue.get_nowait()
except asyncio.QueueEmpty:
pass
self._drop_count += 1
now = time.monotonic()
if now - self._last_drop_warn >= _DROP_WARN_INTERVAL_S:
log.warning(f"MicStream: dropped {self._drop_count} chunk(s) (queue full)")
self._drop_count = 0
self._last_drop_warn = now
try:
self._queue.put_nowait(chunk)
except asyncio.QueueFull:
pass
async def start(self) -> None:
import sounddevice as sd
self._loop = asyncio.get_running_loop()
self._paused = False
native = _device_default_rate(self.device, input=True)
max_ch = _device_max_channels(self.device, input=True)
# Preferred first; then the device's native rate / channel count. ALSA
# "hw:" devices on a Pi frequently accept only one exact combination.
attempts: list[tuple[int, int]] = [(self.sr, 1)]
if native and native != self.sr:
attempts.append((native, 1))
if max_ch and max_ch > 1:
attempts.append((self.sr, max_ch))
if native and native != self.sr:
attempts.append((native, max_ch))
last_exc: Optional[Exception] = None
for rate, channels in attempts:
try:
stream = sd.InputStream(
samplerate=rate,
channels=channels,
dtype="float32",
blocksize=int(rate * self.chunk_ms / 1000),
device=self.device,
callback=self._cb,
)
stream.start()
except Exception as exc: # noqa: BLE001
last_exc = exc
log.debug(f"mic open failed @ {rate} Hz / {channels} ch: {exc}")
continue
self._stream = stream
self._device_sr = rate
self._resampler = _StreamResampler(rate, self.sr) if rate != self.sr else None
if (rate, channels) != (self.sr, 1):
log.warning(f"mic does not support {self.sr} Hz mono; capturing at "
f"{rate} Hz / {channels} ch and converting")
log.info(f"mic started @ {rate}Hz→{self.sr}Hz, chunk={self.chunk_ms}ms, device={self.device}")
return
raise RuntimeError(f"could not open microphone (device={self.device}): {last_exc}")
def drain(self) -> None:
"""Discard all buffered chunks. Call before starting a fresh listen session
to avoid processing stale audio accumulated during upload/playback."""
drained = 0
while True:
try:
self._queue.get_nowait()
drained += 1
except asyncio.QueueEmpty:
break
if drained:
log.debug(f"MicStream: drained {drained} stale chunk(s)")
def pause(self) -> None:
"""Suspend capture (mute). The PortAudio stream keeps running, but the
callback drops incoming samples so no audio is enqueued or processed.
Buffered chunks are discarded so a stale wake-word can't fire on resume."""
self._paused = True
self.drain()
log.info("MicStream: capture paused (muted)")
def resume(self) -> None:
"""Re-enable capture after :meth:`pause`. Any audio buffered while
paused is dropped first so playback isn't re-processed."""
self.drain()
self._paused = False
log.info("MicStream: capture resumed")
def _drain_and_sentinel(self) -> None:
"""Drain queue then enqueue the sentinel so chunks() always unblocks.
The sentinel is *guaranteed* to be enqueued: if the queue is full the
oldest chunk is discarded and we retry, so a chunks() consumer can never
block forever waiting for it.
"""
while True:
try:
self._queue.get_nowait()
except asyncio.QueueEmpty:
break
while True:
try:
self._queue.put_nowait(self._SENTINEL)
return
except asyncio.QueueFull:
try:
self._queue.get_nowait()
except asyncio.QueueEmpty:
break
async def stop(self, *, sentinel: bool = True) -> None:
if self._stream:
try:
self._stream.stop()
self._stream.close()
except Exception as exc: # noqa: BLE001
# A hot-unplugged USB device raises here; nothing left to close.
log.debug(f"mic close raised (ignored): {exc}")
self._stream = None
log.info("mic stopped")
# Drain first so the sentinel is never dropped when the queue is full.
if sentinel and self._loop and not self._loop.is_closed():
self._loop.call_soon_threadsafe(self._drain_and_sentinel)
async def restart(self) -> None:
"""Reopen the device (USB re-plug / driver stall) without ending active consumers."""
await self.stop(sentinel=False)
self.drain()
await self.start()
async def chunks(
self, stale_timeout: Optional[float] = None
) -> AsyncIterator[np.ndarray]:
"""Yield mic chunks. If *stale_timeout* is given and the queue stays
empty for that many seconds (possible mic disconnect), the generator
exits after logging a warning instead of blocking forever."""
while True:
try:
if stale_timeout is not None:
chunk = await asyncio.wait_for(
self._queue.get(), timeout=stale_timeout
)
else:
chunk = await self._queue.get()
except asyncio.TimeoutError:
log.warning(
f"MicStream: no audio for {stale_timeout:.0f}s — "
"possible microphone disconnect; re-entering wake loop"
)
return
if chunk is self._SENTINEL:
return
yield chunk
def _output_channels(output_device) -> int:
"""Return the channel count to use when opening an OutputStream.
Professional audio interfaces refuse ``channels=1`` on Windows MME with
PortAudio error -9998. We query the device's actual max_output_channels
and cap at 2 (stereo); mono audio is tiled to fill.
"""
import sounddevice as sd
try:
dev_idx = output_device if output_device is not None else sd.default.device[1]
info = sd.query_devices(dev_idx)
return min(max(1, int(info["max_output_channels"])), 2)
except Exception:
return 1
def _device_default_rate(device, *, input: bool) -> Optional[int]:
"""Native sample rate reported by PortAudio for *device* (None if unknown)."""
import sounddevice as sd
try:
idx = device if device is not None else sd.default.device[0 if input else 1]
return int(round(float(sd.query_devices(idx)["default_samplerate"])))
except Exception: # noqa: BLE001
return None
def _device_max_channels(device, *, input: bool) -> Optional[int]:
import sounddevice as sd
try:
idx = device if device is not None else sd.default.device[0 if input else 1]
key = "max_input_channels" if input else "max_output_channels"
return int(sd.query_devices(idx)[key])
except Exception: # noqa: BLE001
return None
def _resample_ratio(src: int, dst: int) -> tuple[int, int]:
"""Reduced (up, down) integers for ``scipy.signal.resample_poly``."""
from math import gcd
g = gcd(src, dst)
return dst // g, src // g
def list_audio_devices() -> str:
"""Human-readable device table (``--list-devices``)."""
import sounddevice as sd
return str(sd.query_devices())
async def play_pcm(samples: np.ndarray, sample_rate: int,
output_device: Optional[int] = None,
cancel_event: Optional[asyncio.Event] = None) -> None:
"""Play a float32 mono numpy array. Awaitable + cancellable via event.
Writes happen on a worker thread so the asyncio loop stays responsive.
Cancellation aborts the device stream immediately rather than draining
the buffer.
"""
import sounddevice as sd
samples = np.ascontiguousarray(np.asarray(samples, dtype=np.float32))
if samples.ndim == 1:
samples = samples[:, np.newaxis]
n_ch = _output_channels(output_device)
if samples.shape[1] < n_ch:
samples = np.repeat(samples, n_ch, axis=1)
# ~20 ms blocks: short enough to react to interruption, long enough to
# avoid underruns on Windows WASAPI.
block = max(256, sample_rate // 50)
try:
stream = sd.OutputStream(
samplerate=sample_rate,
channels=n_ch,
dtype="float32",
device=output_device,
blocksize=block,
)
except Exception as exc: # noqa: BLE001
# Pi HDMI / cheap USB DACs often only accept 44.1/48 kHz.
native = _device_default_rate(output_device, input=False)
if native is None or native == sample_rate:
raise
log.warning(f"speaker does not support {sample_rate} Hz ({exc}); resampling to {native} Hz")
from scipy.signal import resample_poly
up, down = _resample_ratio(sample_rate, native)
samples = np.ascontiguousarray(resample_poly(samples, up, down, axis=0).astype(np.float32))
sample_rate = native
block = max(256, sample_rate // 50)
stream = sd.OutputStream(
samplerate=sample_rate,
channels=n_ch,
dtype="float32",
device=output_device,
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
try:
await asyncio.to_thread(_writer)
cancelled = bool(cancel_event is not None and cancel_event.is_set())
finally:
try:
if cancelled:
stream.abort()
else:
stream.stop()
except Exception: # noqa: BLE001
pass
try:
stream.close()
except Exception: # noqa: BLE001
pass
if cancelled:
log.info("playback cancelled")
@@ -0,0 +1,96 @@
"""Voice activity detection using silero-vad (with graceful fallback).
Vendored from ``javis.audio_io.vad`` — decoupled from the master's hardware
diagnostics helper so this module has no dependency on the ``javis`` package.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
import numpy as np
from ..logging_setup import get_logger
log = get_logger("audio.vad")
@dataclass
class VADResult:
is_speech: bool
probability: float
class VAD(Protocol):
sample_rate: int
threshold: float
def reset(self) -> None: ...
def process(self, chunk: np.ndarray) -> VADResult: ...
class NullVAD:
"""Fallback VAD used when torch/silero is unavailable.
Uses a simple RMS energy gate so the satellite still works (with reduced
accuracy) instead of refusing to start.
"""
def __init__(self, sample_rate: int = 16000, threshold: float = 0.5):
self.sample_rate = sample_rate
# Re-interpret the silero threshold (0..1) as an RMS gate.
self.threshold = max(0.005, min(0.05, threshold * 0.02))
log.warning("Using NullVAD (RMS energy gate) — silero-vad/torch unavailable")
def reset(self) -> None:
return
def process(self, chunk: np.ndarray) -> VADResult:
if chunk.size == 0:
return VADResult(False, 0.0)
rms = float(np.sqrt(np.mean(np.square(chunk.astype(np.float32)))))
return VADResult(is_speech=rms >= self.threshold, probability=rms)
def make_vad(sample_rate: int = 16000, threshold: float = 0.5) -> VAD:
"""Construct a VAD, falling back to NullVAD if torch/silero fails to load."""
try:
return SileroVAD(sample_rate=sample_rate, threshold=threshold)
except Exception as e: # noqa: BLE001
log.error(f"SileroVAD unavailable: {e}")
return NullVAD(sample_rate=sample_rate, threshold=threshold)
class SileroVAD:
"""Wraps silero-vad. Expects 16 kHz mono float32 chunks of 30 or 32 ms."""
def __init__(self, sample_rate: int = 16000, threshold: float = 0.5):
import torch
from silero_vad import load_silero_vad
self.sample_rate = sample_rate
self.threshold = threshold
self.model = load_silero_vad()
self._window_samples = 512 if sample_rate == 16000 else 256
# Pre-allocated tensor to avoid per-call allocations on the hot path.
self._buf = torch.zeros(self._window_samples, dtype=torch.float32)
log.info(f"silero-vad loaded (sr={sample_rate})")
def reset(self) -> None:
try:
self.model.reset_states()
except Exception:
pass
def process(self, chunk: np.ndarray) -> VADResult:
"""Process a single window; returns speech probability."""
import torch
w = self._window_samples
n = len(chunk)
if n >= w:
self._buf.copy_(torch.from_numpy(np.ascontiguousarray(chunk[:w], dtype=np.float32)))
else:
self._buf[:n].copy_(torch.from_numpy(np.ascontiguousarray(chunk, dtype=np.float32)))
self._buf[n:].zero_()
with torch.inference_mode():
prob = float(self.model(self._buf, self.sample_rate).item())
return VADResult(is_speech=prob >= self.threshold, probability=prob)
@@ -0,0 +1,109 @@
"""Wake word detection using openWakeWord.
Vendored from ``javis.audio_io.wake_word`` (only the logger import differs)
so javis-slave has no dependency on the parent ``javis`` package.
"""
from __future__ import annotations
from pathlib import Path
from typing import Iterable, Optional
import numpy as np
from ..logging_setup import get_logger
log = get_logger("audio.wake")
def pretrained_names() -> set[str]:
try:
import openwakeword
return set(openwakeword.MODELS.keys())
except Exception: # noqa: BLE001
return {"alexa", "hey_mycroft", "hey_jarvis", "hey_rhasspy", "timer", "weather"}
def resolve_wake_model(name: str, search_dirs: Iterable[Path] = ()) -> tuple[str, str, Optional[Path]]:
"""Map a model name to ``(model_arg, prediction_key, custom_path)``.
Built-in names pass through; a custom ``.onnx`` (path or stem found in
*search_dirs*) is keyed by its filename stem, as openWakeWord does.
"""
name = (name or "hey_jarvis").strip()
p = Path(name).expanduser()
if p.suffix.lower() == ".onnx" and p.is_file():
return str(p), p.stem, p
stem = p.stem if p.suffix else name
for d in search_dirs:
cand = Path(d) / f"{stem}.onnx"
if cand.is_file():
return str(cand), stem, cand
if name not in pretrained_names():
raise FileNotFoundError(
f"wake word model '{name}' is not built-in and no {stem}.onnx was found in "
f"{[str(d) for d in search_dirs]}")
return name, name, None
class WakeWordDetector:
"""openWakeWord wrapper. Feeds 16 kHz int16 frames; emits scores per chunk."""
OWW_FRAME_SAMPLES = 1280 # openWakeWord expects 80 ms @ 16 kHz
def __init__(self, model_name: str = "hey_jarvis", threshold: float = 0.5,
search_dirs: Iterable[Path] = ()):
from openwakeword.model import Model
from openwakeword.utils import download_models
# Ensure ONNX models are present (tflite_runtime has no Windows wheels).
try:
download_models()
except Exception as e: # noqa: BLE001
log.warning(f"openWakeWord model download skipped: {e}")
model_arg, key, custom = resolve_wake_model(model_name, search_dirs)
self.model = Model(
wakeword_models=[model_arg],
inference_framework="onnx",
)
self.model_name = key
self.custom_path: Optional[Path] = custom
self.threshold = threshold
# Single pre-allocated 80 ms int16 frame + fill index — avoids per-chunk
# list churn and np.concatenate calls in the wake-word hot path.
self._frame = np.empty(self.OWW_FRAME_SAMPLES, dtype=np.int16)
self._fill = 0
log.info(f"openWakeWord loaded ({model_arg}, key={key}, threshold={threshold})")
def reset(self) -> None:
self._fill = 0
try:
self.model.reset()
except Exception:
pass
def feed(self, chunk_f32: np.ndarray) -> float:
"""Feed an arbitrary-length mono float32 chunk; return max score across complete frames."""
# Convert to int16 in one allocation (oww expectation).
scaled = chunk_f32 * 32768.0
np.clip(scaled, -32768.0, 32767.0, out=scaled)
i16 = scaled.astype(np.int16, copy=False)
max_score = 0.0
pos = 0
n = len(i16)
frame_size = self.OWW_FRAME_SAMPLES
while pos < n:
free = frame_size - self._fill
take = min(free, n - pos)
self._frame[self._fill:self._fill + take] = i16[pos:pos + take]
self._fill += take
pos += take
if self._fill == frame_size:
preds = self.model.predict(self._frame)
score = float(preds.get(self.model_name, 0.0))
if score > max_score:
max_score = score
self._fill = 0
return max_score
def triggered(self, score: float) -> bool:
return score >= self.threshold
+90
View File
@@ -0,0 +1,90 @@
"""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 os
import uuid
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Optional
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
@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 load_config() -> SlaveConfig:
path = config_path()
cfg = SlaveConfig()
if path.exists():
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
return cfg
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")
tmp.replace(path)
+183
View File
@@ -0,0 +1,183 @@
"""Headless runtime: wake word → master → speaker, without Qt.
Used on displayless satellites (Raspberry Pi + mic + speaker) via
``python -m javis_slave --headless``. Reuses :class:`AudioEngine`,
:class:`MasterClient` and the MCP control HTTP server exactly like the GUI
app; state and errors go to the log instead of the screen.
"""
from __future__ import annotations
import asyncio
import signal
import sys
from typing import Optional
from .api_client import MasterClient, MasterError
from .audio_engine import AudioEngine
from .config import SlaveConfig, load_config, save_config
from .http_server import build_http_app
from .logging_setup import get_logger
log = get_logger("slave.headless")
# Re-try the initial engine start (mic not ready at boot, master down) this often.
_START_RETRY_S = 10.0
async def pair_cli(cfg: SlaveConfig, client: MasterClient, host: str, port: int,
code: str, name: str) -> None:
"""Pair from the command line and persist credentials."""
if not await client.check_health(host, port):
raise MasterError(
f"Master at {host}:{port} not reachable — is api.enabled: true in its config.yaml?")
result = await client.pair(host, port, code, name)
cfg.master_host = host
cfg.master_port = port
cfg.api_key = result.api_key
cfg.device_name = name
cfg.room = result.room
cfg.wake_word = result.wake_word
save_config(cfg)
log.info(f"paired with master {host}:{port} as {name!r} (room={result.room!r})")
async def run_headless_async(cfg: SlaveConfig, client: MasterClient) -> int:
if not cfg.paired:
log.error("device is not paired. Run: python -m javis_slave --pair HOST:PORT CODE --name NAME")
return 2
engine: Optional[AudioEngine] = None
engine_task: Optional[asyncio.Task] = None
state = {"value": "idle", "error": None}
stop = asyncio.Event()
def _on_state(s: str) -> None:
if s != state["value"]:
state["value"] = s
log.info(f"state -> {s}")
def _on_error(msg: str) -> None:
state["error"] = msg
log.error(msg)
def _on_turn(user: str, reply: str) -> None:
log.info(f"user: {user}")
log.info(f"javis: {reply}")
def _on_room(room) -> None:
log.info(f"room: {room or '(none)'}")
def _on_connection(ok: bool, retry: int, backoff: float) -> None:
if not ok:
log.warning(f"master unreachable (attempt {retry}, next try in {backoff:.0f}s)")
async def _start_engine() -> None:
nonlocal engine, engine_task
while not stop.is_set():
eng = AudioEngine(cfg, client)
eng.on_state = _on_state
eng.on_error = _on_error
eng.on_turn = _on_turn
eng.on_room = _on_room
eng.on_connection = _on_connection
try:
await eng.start()
except Exception as exc: # noqa: BLE001
log.error(f"audio engine failed to start ({exc}); retrying in {_START_RETRY_S:.0f}s")
try:
await asyncio.wait_for(stop.wait(), timeout=_START_RETRY_S)
except asyncio.TimeoutError:
continue
return
engine = eng
engine_task = asyncio.create_task(eng.run())
log.info(f"listening for wake word '{cfg.wake_word.model}' (room={cfg.room!r})")
return
async def _stop_engine() -> None:
nonlocal engine, engine_task
if engine_task is not None:
engine_task.cancel()
try:
await engine_task
except asyncio.CancelledError:
pass
engine_task = None
if engine is not None:
await engine.stop()
engine = None
async def _restart() -> None:
await _stop_engine()
await _start_engine()
# MCP control server (same endpoints as the GUI app).
http_task: Optional[asyncio.Task] = None
server = None
try:
from uvicorn import Config as UvConfig, Server
http_app = build_http_app(
cfg,
lambda: engine, lambda: state["value"], lambda: state["error"],
lambda: (engine.last_rms, engine.last_score) if engine else (0.0, 0.0),
on_restart=_restart,
master_client_getter=lambda: client,
)
server = Server(UvConfig(app=http_app, host=cfg.api_host, port=cfg.api_port,
log_level="warning", loop="none", lifespan="off"))
http_task = asyncio.create_task(server.serve())
log.info(f"MCP control server on http://{cfg.api_host}:{cfg.api_port}")
except Exception as exc: # noqa: BLE001
log.error(f"HTTP server not started: {exc}")
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(sig, stop.set)
except (NotImplementedError, RuntimeError):
pass # Windows: fall back to KeyboardInterrupt
await _start_engine()
try:
await stop.wait()
except (KeyboardInterrupt, asyncio.CancelledError):
# Windows has no add_signal_handler: asyncio.run cancels us on Ctrl+C.
# Swallow it so the teardown below still runs.
pass
log.info("shutting down …")
await _stop_engine()
if server is not None:
server.should_exit = True
if http_task is not None:
try:
await asyncio.wait_for(http_task, timeout=5.0)
except (asyncio.TimeoutError, asyncio.CancelledError, Exception): # noqa: BLE001
http_task.cancel()
await client.aclose()
return 0
def run_headless(pair: Optional[tuple[str, int, str, str]] = None) -> int:
cfg = load_config()
save_config(cfg)
client = MasterClient(cfg)
async def _main() -> int:
if pair is not None:
host, port, code, name = pair
try:
await pair_cli(cfg, client, host, port, code, name)
except MasterError as exc:
log.error(f"pairing failed: {exc}")
await client.aclose()
return 1
return await run_headless_async(cfg, client)
try:
return asyncio.run(_main())
except KeyboardInterrupt:
return 0
if __name__ == "__main__":
sys.exit(run_headless())
+444
View File
@@ -0,0 +1,444 @@
"""HTTP server for remote control of the slave device via MCP.
Exposes endpoints for:
- Device status and info
- Audio control (mute, volume)
- Wake-word and room settings
- Audio engine state
"""
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING, Awaitable, Callable, Optional
from fastapi import FastAPI, HTTPException, Query
from pydantic import BaseModel
if TYPE_CHECKING:
from .audio_engine import AudioEngine # I-6: was .app (circular import risk)
from .config import SlaveConfig
from .logging_setup import get_logger # I-7: route through the javis_slave root logger
from .system_control import (
get_brightness_controller,
get_power_controller,
get_volume_controller,
)
log = get_logger("http")
# ---- Models ----
class SlaveStatus(BaseModel):
"""Current status of the slave device."""
device_id: str
device_name: str
room: Optional[str] = None
paired: bool
engine_running: bool
audio_state: str # "idle", "listening", "thinking", "speaking", "muted", "error"
muted: bool = False
mic_level: float = 0.0
wake_word_score: float = 0.0
last_error: Optional[str] = None
class DeviceInfo(BaseModel):
"""Static device information."""
device_id: str
device_name: str
sample_rate: int
input_device: Optional[int] = None
output_device: Optional[int] = None
wake_word_model: str
class AudioState(BaseModel):
"""Audio system state."""
mic_enabled: bool
output_enabled: bool
muted: bool = False
mic_level: float
wake_word_score: float
class RoomInfo(BaseModel):
"""Room/location info."""
room: Optional[str] = None
device_name: str
class ControlResponse(BaseModel):
"""Response for control operations."""
success: bool
message: str = ""
class VolumeResponse(BaseModel):
"""Volume control response."""
success: bool
message: str = ""
volume: Optional[float] = None # 0.0-1.0
class BrightnessResponse(BaseModel):
"""Brightness control response."""
success: bool
message: str = ""
brightness: Optional[int] = None # 0-100
class PowerResponse(BaseModel):
"""Power control response."""
success: bool
message: str = ""
class HealthResponse(BaseModel):
"""Health check response."""
ok: bool
device_id: str
engine_running: bool
class MasterSlaveInfo(BaseModel):
"""A slave device as reported by the master (master-side auth)."""
device_id: str
name: str
room: Optional[str] = None
ip_address: Optional[str] = None
last_seen: Optional[str] = None
last_seen_ip: Optional[str] = None
created_at: Optional[str] = None
class MasterSlaveIp(BaseModel):
"""IP info for a single slave, proxied from the master."""
device_id: str
ip_address: Optional[str] = None
last_seen_ip: Optional[str] = None
last_seen: Optional[str] = None
# ---- Factory ----
def build_http_app(
cfg: SlaveConfig,
engine_getter: Callable[[], Optional[AudioEngine]],
state_getter: Callable[[], str],
error_getter: Callable[[], Optional[str]],
mic_level_getter: Callable[[], tuple[float, float]], # (rms, score)
on_restart: Optional[Callable[[], Awaitable[None]]] = None,
master_client_getter: Optional[Callable[[], object]] = None,
) -> FastAPI:
"""Create FastAPI app with slave control endpoints.
Args:
cfg: Slave configuration (device_id, pairing info, etc.)
engine_getter: Callable that returns the current AudioEngine or None
state_getter: Callable that returns current audio state string
error_getter: Callable that returns last error message or None
mic_level_getter: Callable that returns (rms, wake_word_score) tuple
on_restart: Optional async callback invoked by ``POST /mcp/v1/restart``
to perform a real stop+start of the audio engine. When omitted,
the endpoint returns a 501 to make the "not wired" state explicit
instead of a misleading 200.
master_client_getter: Optional callable returning a ``MasterClient``
used by the ``/mcp/v1/master/*`` pass-through endpoints to query
the master with this slave's own credentials. When omitted, those
endpoints return 501.
"""
app = FastAPI(title="Javis Slave Control")
# ---- Status Endpoints ----
@app.get("/mcp/v1/status", response_model=SlaveStatus)
async def get_status() -> SlaveStatus:
"""Get current device status."""
engine = engine_getter()
rms, score = mic_level_getter()
return SlaveStatus(
device_id=cfg.device_id,
device_name=cfg.device_name,
room=cfg.room,
paired=cfg.paired,
engine_running=engine is not None,
audio_state=state_getter(),
muted=bool(engine is not None and engine.muted),
mic_level=rms,
wake_word_score=score,
last_error=error_getter(),
)
@app.get("/mcp/v1/device-info", response_model=DeviceInfo)
async def get_device_info() -> DeviceInfo:
"""Get static device information."""
return DeviceInfo(
device_id=cfg.device_id,
device_name=cfg.device_name,
sample_rate=cfg.sample_rate,
input_device=cfg.input_device,
output_device=cfg.output_device,
wake_word_model=cfg.wake_word.model,
)
@app.get("/mcp/v1/room", response_model=RoomInfo)
async def get_room() -> RoomInfo:
"""Get room/location information."""
return RoomInfo(room=cfg.room, device_name=cfg.device_name)
@app.get("/mcp/v1/audio-state", response_model=AudioState)
async def get_audio_state() -> AudioState:
"""Get audio system state."""
engine = engine_getter()
rms, score = mic_level_getter()
muted = bool(engine is not None and engine.muted)
return AudioState(
mic_enabled=engine is not None and not muted,
output_enabled=engine is not None,
muted=muted,
mic_level=rms,
wake_word_score=score,
)
# ---- Control Endpoints ----
@app.post("/mcp/v1/mic/mute", response_model=ControlResponse)
async def mute_mic() -> ControlResponse:
"""Mute the microphone."""
engine = engine_getter()
if engine is None or engine.mic is None:
raise HTTPException(status_code=400, detail="Engine not running")
try:
await engine.mute()
return ControlResponse(success=True, message="Microphone muted")
except Exception as e:
return ControlResponse(success=False, message=str(e))
@app.post("/mcp/v1/mic/unmute", response_model=ControlResponse)
async def unmute_mic() -> ControlResponse:
"""Unmute the microphone."""
engine = engine_getter()
if engine is None or engine.mic is None:
raise HTTPException(status_code=400, detail="Engine not running")
try:
await engine.unmute()
return ControlResponse(success=True, message="Microphone unmuted")
except Exception as e:
return ControlResponse(success=False, message=str(e))
@app.post("/mcp/v1/mic/toggle", response_model=ControlResponse)
async def toggle_mic() -> ControlResponse:
"""Toggle microphone on/off."""
engine = engine_getter()
if engine is None or engine.mic is None:
raise HTTPException(status_code=400, detail="Engine not running")
try:
now_muted = await engine.toggle_mute()
if now_muted:
return ControlResponse(success=True, message="Microphone muted")
return ControlResponse(success=True, message="Microphone unmuted")
except Exception as e:
return ControlResponse(success=False, message=str(e))
@app.post("/mcp/v1/restart", response_model=ControlResponse)
async def restart_audio_engine() -> ControlResponse:
"""Restart the audio engine (for recovery from errors).
Delegates to the app's ``on_restart`` callback (stop + start). If the
app did not wire a callback, return 501 so callers don't receive a
misleading 200 for a restart that will not happen.
"""
if on_restart is None:
raise HTTPException(status_code=501, detail="Engine restart not available")
log.info("Audio engine restart requested via HTTP")
try:
await on_restart()
return ControlResponse(success=True, message="Audio engine restarted")
except Exception as e: # noqa: BLE001
return ControlResponse(success=False, message=str(e))
# ---- Master Pass-Through Endpoints ----
# These proxy the master's /api/v1/slaves endpoints so an MCP client can
# query the master using THIS slave's own credentials. This removes the
# MCP's need to hardcode master host + auth (it used to call :8765 with
# no headers and always get 401).
@app.get("/mcp/v1/master/slaves", response_model=list[MasterSlaveInfo])
async def master_list_slaves() -> list[MasterSlaveInfo]:
"""List all slave devices known to the master (authenticated pass-through)."""
if master_client_getter is None:
raise HTTPException(status_code=501, detail="Master query not available")
client = master_client_getter()
try:
data = await client.list_slaves()
except Exception as e: # noqa: BLE001
raise HTTPException(status_code=502, detail=f"Master query failed: {e}")
return [MasterSlaveInfo(**item) for item in data]
@app.get("/mcp/v1/master/slaves/{device_id}/ip", response_model=MasterSlaveIp)
async def master_slave_ip(device_id: str) -> MasterSlaveIp:
"""Return the IP info for a specific slave device (authenticated pass-through)."""
if master_client_getter is None:
raise HTTPException(status_code=501, detail="Master query not available")
client = master_client_getter()
try:
data = await client.list_slaves()
except Exception as e: # noqa: BLE001
raise HTTPException(status_code=502, detail=f"Master query failed: {e}")
for item in data:
if item.get("device_id") == device_id:
return MasterSlaveIp(
device_id=device_id,
ip_address=item.get("ip_address"),
last_seen_ip=item.get("last_seen_ip"),
last_seen=item.get("last_seen"),
)
raise HTTPException(status_code=404, detail=f"Slave '{device_id}' not found on master")
# ---- Volume Control Endpoints ----
# Controllers shell out (pactl/amixer/systemctl on Linux) — run them off
# the event loop so a slow call can't stall wake-word processing.
@app.get("/mcp/v1/volume", response_model=VolumeResponse)
async def get_volume() -> VolumeResponse:
"""Get current master volume level (0.0-1.0)."""
volume = await asyncio.to_thread(get_volume_controller().get_volume)
if volume is not None:
return VolumeResponse(success=True, volume=volume, message=f"Volume: {volume * 100:.1f}%")
return VolumeResponse(success=False, message="Failed to get volume")
@app.post("/mcp/v1/volume/set", response_model=VolumeResponse)
async def set_volume(
level: float = Query(..., ge=0.0, le=1.0),
) -> VolumeResponse:
"""Set master volume level (0.0-1.0)."""
if await asyncio.to_thread(get_volume_controller().set_volume, level):
return VolumeResponse(success=True, volume=level, message=f"Volume set to {level * 100:.1f}%")
return VolumeResponse(success=False, message="Failed to set volume")
@app.post("/mcp/v1/volume/increase", response_model=VolumeResponse)
async def increase_volume(
amount: float = Query(0.05, ge=0.0, le=1.0),
) -> VolumeResponse:
"""Increase master volume by *amount* (0.0–1.0)."""
new_volume = await asyncio.to_thread(get_volume_controller().increase_volume, amount)
if new_volume is not None:
return VolumeResponse(success=True, volume=new_volume, message=f"Volume increased to {new_volume * 100:.1f}%")
return VolumeResponse(success=False, message="Failed to increase volume")
@app.post("/mcp/v1/volume/decrease", response_model=VolumeResponse)
async def decrease_volume(
amount: float = Query(0.05, ge=0.0, le=1.0),
) -> VolumeResponse:
"""Decrease master volume by *amount* (0.0–1.0)."""
new_volume = await asyncio.to_thread(get_volume_controller().decrease_volume, amount)
if new_volume is not None:
return VolumeResponse(success=True, volume=new_volume, message=f"Volume decreased to {new_volume * 100:.1f}%")
return VolumeResponse(success=False, message="Failed to decrease volume")
@app.post("/mcp/v1/volume/mute", response_model=VolumeResponse)
async def mute_volume() -> VolumeResponse:
"""Mute master audio output."""
if await asyncio.to_thread(get_volume_controller().set_mute, True):
return VolumeResponse(success=True, message="Audio muted")
return VolumeResponse(success=False, message="Failed to mute audio")
@app.post("/mcp/v1/volume/unmute", response_model=VolumeResponse)
async def unmute_volume() -> VolumeResponse:
"""Unmute master audio output."""
if await asyncio.to_thread(get_volume_controller().set_mute, False):
return VolumeResponse(success=True, message="Audio unmuted")
return VolumeResponse(success=False, message="Failed to unmute audio")
# ---- Brightness Control Endpoints ----
@app.get("/mcp/v1/brightness", response_model=BrightnessResponse)
async def get_brightness() -> BrightnessResponse:
"""Get current display brightness (0-100)."""
brightness = await asyncio.to_thread(get_brightness_controller().get_brightness)
if brightness is not None:
return BrightnessResponse(success=True, brightness=brightness, message=f"Brightness: {brightness}%")
return BrightnessResponse(success=False, message="Failed to get brightness")
@app.post("/mcp/v1/brightness/set", response_model=BrightnessResponse)
async def set_brightness(
level: int = Query(..., ge=0, le=100),
) -> BrightnessResponse:
"""Set display brightness (0-100)."""
if await asyncio.to_thread(get_brightness_controller().set_brightness, level):
return BrightnessResponse(success=True, brightness=level, message=f"Brightness set to {level}%")
return BrightnessResponse(success=False, message="Failed to set brightness")
@app.post("/mcp/v1/brightness/increase", response_model=BrightnessResponse)
async def increase_brightness(
amount: int = Query(5, ge=0, le=100),
) -> BrightnessResponse:
"""Increase display brightness by *amount* percentage points."""
new_brightness = await asyncio.to_thread(get_brightness_controller().increase_brightness, amount)
if new_brightness is not None:
return BrightnessResponse(success=True, brightness=new_brightness, message=f"Brightness increased to {new_brightness}%")
return BrightnessResponse(success=False, message="Failed to increase brightness")
@app.post("/mcp/v1/brightness/decrease", response_model=BrightnessResponse)
async def decrease_brightness(
amount: int = Query(5, ge=0, le=100),
) -> BrightnessResponse:
"""Decrease display brightness by *amount* percentage points."""
new_brightness = await asyncio.to_thread(get_brightness_controller().decrease_brightness, amount)
if new_brightness is not None:
return BrightnessResponse(success=True, brightness=new_brightness, message=f"Brightness decreased to {new_brightness}%")
return BrightnessResponse(success=False, message="Failed to decrease brightness")
# ---- Power Control Endpoints ----
@app.post("/mcp/v1/power/sleep", response_model=PowerResponse)
async def sleep_system() -> PowerResponse:
"""Put system to sleep."""
if await asyncio.to_thread(get_power_controller().sleep):
return PowerResponse(success=True, message="Sleep command sent")
return PowerResponse(success=False, message="Failed to send sleep command")
@app.post("/mcp/v1/power/shutdown", response_model=PowerResponse)
async def shutdown_system(
delay: int = Query(0, ge=0, le=3600),
) -> PowerResponse:
"""Shutdown system. *delay* is seconds (0 = immediate, max 1h)."""
if await asyncio.to_thread(get_power_controller().shutdown, delay):
if delay > 0:
return PowerResponse(success=True, message=f"System will shutdown in {delay} seconds")
return PowerResponse(success=True, message="Shutdown command sent")
return PowerResponse(success=False, message="Failed to send shutdown command")
@app.post("/mcp/v1/power/restart", response_model=PowerResponse)
async def restart_system(
delay: int = Query(0, ge=0, le=3600),
) -> PowerResponse:
"""Restart system. *delay* is seconds (0 = immediate, max 1h)."""
if await asyncio.to_thread(get_power_controller().restart, delay):
if delay > 0:
return PowerResponse(success=True, message=f"System will restart in {delay} seconds")
return PowerResponse(success=True, message="Restart command sent")
return PowerResponse(success=False, message="Failed to send restart command")
@app.post("/mcp/v1/power/cancel", response_model=PowerResponse)
async def cancel_shutdown() -> PowerResponse:
"""Cancel pending shutdown or restart."""
if await asyncio.to_thread(get_power_controller().cancel_shutdown):
return PowerResponse(success=True, message="Shutdown/restart cancelled")
return PowerResponse(success=False, message="No pending shutdown/restart to cancel")
@app.get("/mcp/v1/health", response_model=HealthResponse)
async def health() -> HealthResponse:
"""Health check endpoint."""
engine = engine_getter()
return HealthResponse(
ok=True,
device_id=cfg.device_id,
engine_running=engine is not None,
)
return app
@@ -0,0 +1,46 @@
"""Minimal stdlib logging helper (kept dependency-free from the javis package).
Logs go to stderr and to a rotating file under ``<config dir>/logs/slave.log``
so tablet/Pi problems can be diagnosed after the fact.
"""
from __future__ import annotations
import logging
import sys
from logging.handlers import RotatingFileHandler
_configured = False
_FMT = logging.Formatter(
"%(asctime)s | %(levelname)-7s | %(name)s - %(message)s", datefmt="%H:%M:%S",
)
def _configure_once() -> None:
global _configured
if _configured:
return
root = logging.getLogger("javis_slave")
root.setLevel(logging.INFO)
root.propagate = False
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(_FMT)
root.addHandler(handler)
try:
from .config import config_dir
log_dir = config_dir() / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
fh = RotatingFileHandler(log_dir / "slave.log", maxBytes=2_000_000,
backupCount=3, encoding="utf-8")
fh.setFormatter(logging.Formatter(
"%(asctime)s | %(levelname)-7s | %(name)s - %(message)s"))
root.addHandler(fh)
except Exception as exc: # noqa: BLE001 (read-only FS etc. — stderr still works)
root.warning(f"file logging disabled: {exc}")
_configured = True
def get_logger(name: str) -> logging.Logger:
_configure_once()
return logging.getLogger(f"javis_slave.{name}")
+584
View File
@@ -0,0 +1,584 @@
"""Touch-optimized PySide6 UI for javis-slave (Surface Pro 7 kiosk style).
Same VS Code Dark Modern palette as the Javis master GUI, with all touch
targets ≥ 48 px.
"""
from __future__ import annotations
from typing import Callable, Optional
from PySide6 import QtCore, QtGui, QtWidgets
from PySide6.QtCore import QTimer
# ── palette (mirrors javis/gui/main_window.py) ────────────────────────────────
CL_BG = "#1e1e1e"
CL_SURFACE = "#252526"
CL_SURFACE2 = "#2d2d2d"
CL_INPUT = "#3c3c3c"
CL_ACCENT = "#DA7756"
CL_ACCENT2 = "#e0886a"
CL_TEXT = "#cccccc"
CL_MUTED = "#858585"
CL_BORDER = "#3c3c3c"
# Gain applied to raw mic RMS before driving the orb pulse (A-12: was magic 8.0).
# Typical speech RMS is ~0.01–0.1, so ×8 maps it into the visible 0..1 range.
_RMS_GAIN = 8.0
STATE_COLORS = {
"idle": "#3c3c3c",
"listening": "#4ec994",
"thinking": CL_ACCENT,
"speaking": "#4ec994",
"muted": "#f14c4c",
"error": "#f14c4c",
}
STATE_LABELS = {
"idle": "Sag das Wake-Word …",
"listening": "Ich höre zu …",
"thinking": "Einen Moment …",
"speaking": "Antwort wird abgespielt …",
"muted": "Stumm geschaltet",
"error": "Fehler",
}
_INPUT_QSS = (
f"QLineEdit {{ background: {CL_INPUT}; color: {CL_TEXT}; border: 1px solid {CL_BORDER};"
f" border-radius: 12px; padding: 14px 18px; font-size: 16pt; }}"
f"QLineEdit:focus {{ border: 2px solid {CL_ACCENT}; }}"
)
_PRIMARY_BTN_QSS = (
f"QPushButton {{ background: {CL_ACCENT}; color: #1e1e1e; font-weight: 700;"
f" border: 0; border-radius: 14px; padding: 16px 32px; font-size: 16pt; }}"
f"QPushButton:pressed {{ background: {CL_ACCENT2}; }}"
f"QPushButton:disabled {{ background: {CL_INPUT}; color: {CL_MUTED}; }}"
)
_GHOST_BTN_QSS = (
f"QPushButton {{ background: transparent; border: 1px solid {CL_BORDER}; color: {CL_MUTED};"
f" padding: 12px 24px; border-radius: 12px; font-size: 13pt; }}"
f"QPushButton:pressed {{ background: {CL_SURFACE2}; color: {CL_TEXT}; }}"
)
class StateOrb(QtWidgets.QWidget):
"""Large animated circle reflecting the agent state.
Alexa-style visual feedback:
- Color indicates device state (idle/listening/thinking/speaking/error)
- Pulse ring shows mic activity level in real-time
- Mute overlay (red X) when microphone is disabled
- Retry indicator (spinning dots) during reconnection attempts
"""
def __init__(self, parent=None):
super().__init__(parent)
self._state = "idle"
self._level = 0.0 # mic RMS 0..1 (pulse while idle/listening)
self._muted = False # explicit mute state overlay
self._reconnecting = False # showing retry indicator
self.setMinimumSize(260, 260)
def set_state(self, state: str) -> None:
self._state = state
self.update()
def set_level(self, level: float) -> None:
self._level = max(0.0, min(1.0, level))
self.update()
def set_muted(self, muted: bool) -> None:
"""Set the mute overlay state (Alexa-style mute indicator)."""
self._muted = muted
self.update()
def set_reconnecting(self, reconnecting: bool) -> None:
"""Show/hide reconnection retry indicator."""
self._reconnecting = reconnecting
self.update()
def is_muted(self) -> bool:
return self._muted
def paintEvent(self, event): # noqa: N802
p = QtGui.QPainter(self)
p.setRenderHint(QtGui.QPainter.Antialiasing)
rect = self.rect()
size = min(rect.width(), rect.height())
base_r = size * 0.32
pulse = base_r * (1.0 + 0.35 * self._level)
center = rect.center()
color = QtGui.QColor(STATE_COLORS.get(self._state, CL_INPUT))
# Outer pulse ring.
ring = QtGui.QColor(color)
ring.setAlpha(60)
p.setBrush(ring)
p.setPen(QtCore.Qt.NoPen)
p.drawEllipse(center, pulse, pulse)
# Core.
p.setBrush(color)
p.drawEllipse(center, base_r, base_r)
# Icon glyph — dark on bright cores, light on the dark idle core so it stays visible.
bright_core = self._state in ("listening", "thinking", "speaking", "muted", "error")
p.setPen(QtGui.QPen(QtGui.QColor("#1e1e1e" if bright_core else CL_TEXT), 3))
font = p.font()
font.setPointSizeF(size * 0.11)
p.setFont(font)
glyph = {"idle": "🎤", "listening": "🎤", "thinking": "…",
"speaking": "🔊", "muted": "🔇", "error": "!"}.get(self._state, "")
p.drawText(QtCore.QRectF(rect), QtCore.Qt.AlignCenter, glyph)
# Mute overlay (Alexa-style red X indicator).
if self._muted:
mute_r = base_r * 0.5
mute_center = QtCore.QPointF(center.x() + size * 0.28,
center.y() - size * 0.28)
p.setPen(QtGui.QPen(QtGui.QColor("#f14c4c"), max(2, size // 80)))
p.drawLine(
QtCore.QPointF(mute_center.x() - mute_r, mute_center.y() - mute_r),
QtCore.QPointF(mute_center.x() + mute_r, mute_center.y() + mute_r))
p.drawLine(
QtCore.QPointF(mute_center.x() + mute_r, mute_center.y() - mute_r),
QtCore.QPointF(mute_center.x() - mute_r, mute_center.y() + mute_r))
# Reconnecting indicator (pulsing outer ring).
if self._reconnecting:
p.setPen(QtGui.QPen(QtGui.QColor("#f59e0b"), max(2, size // 60)))
ring_r = base_r * 1.35
p.drawEllipse(center, ring_r, ring_r)
p.end()
class SetupScreen(QtWidgets.QWidget):
"""First-run pairing: master address + pairing code."""
pairRequested = QtCore.Signal(str, int, str, str) # host, port, code, name
def __init__(self, parent=None):
super().__init__(parent)
self.setStyleSheet(f"background: {CL_BG};")
outer = QtWidgets.QVBoxLayout(self)
outer.setAlignment(QtCore.Qt.AlignCenter)
card = QtWidgets.QFrame()
card.setMaximumWidth(620)
card.setStyleSheet(
f"QFrame {{ background: {CL_SURFACE}; border: 1px solid {CL_BORDER};"
f" border-radius: 18px; }}")
v = QtWidgets.QVBoxLayout(card)
v.setContentsMargins(40, 36, 40, 36)
v.setSpacing(18)
title = QtWidgets.QLabel("Javis Slave koppeln")
title.setStyleSheet(
f"color: {CL_TEXT}; font-size: 24pt; font-weight: 800; border: 0;")
title.setAlignment(QtCore.Qt.AlignCenter)
v.addWidget(title)
hint = QtWidgets.QLabel(
"Erzeuge auf dem Javis-Master im Tab „Devices“ einen Pairing-Code\n"
"und trage hier die angezeigte Adresse und den Code ein.")
hint.setStyleSheet(f"color: {CL_MUTED}; font-size: 12pt; border: 0;")
hint.setAlignment(QtCore.Qt.AlignCenter)
v.addWidget(hint)
self.host_edit = QtWidgets.QLineEdit()
self.host_edit.setPlaceholderText("Master-IP (z. B. 192.168.1.20)")
self.host_edit.setStyleSheet(_INPUT_QSS)
v.addWidget(self.host_edit)
self.port_edit = QtWidgets.QLineEdit("8765")
self.port_edit.setPlaceholderText("Port")
self.port_edit.setValidator(QtGui.QIntValidator(1, 65535, self))
self.port_edit.setStyleSheet(_INPUT_QSS)
v.addWidget(self.port_edit)
self.code_edit = QtWidgets.QLineEdit()
self.code_edit.setPlaceholderText("Pairing-Code")
self.code_edit.setMaxLength(6)
self.code_edit.setStyleSheet(_INPUT_QSS + "QLineEdit { letter-spacing: 6px; }")
v.addWidget(self.code_edit)
self.name_edit = QtWidgets.QLineEdit()
self.name_edit.setPlaceholderText("Gerätename (z. B. Tablet Küche)")
self.name_edit.setStyleSheet(_INPUT_QSS)
v.addWidget(self.name_edit)
self.status_lbl = QtWidgets.QLabel("")
self.status_lbl.setStyleSheet("color: #f14c4c; font-size: 12pt; border: 0;")
self.status_lbl.setAlignment(QtCore.Qt.AlignCenter)
self.status_lbl.setWordWrap(True)
v.addWidget(self.status_lbl)
self.pair_btn = QtWidgets.QPushButton("Koppeln")
self.pair_btn.setStyleSheet(_PRIMARY_BTN_QSS)
self.pair_btn.setCursor(QtCore.Qt.PointingHandCursor)
self.pair_btn.clicked.connect(self._submit)
v.addWidget(self.pair_btn)
outer.addWidget(card)
def _submit(self) -> None:
host = self.host_edit.text().strip()
port = int(self.port_edit.text() or "8765")
code = self.code_edit.text().strip()
name = self.name_edit.text().strip()
if not host or not code:
self.status_lbl.setText("Bitte Master-IP und Pairing-Code eingeben.")
return
self.status_lbl.setStyleSheet(f"color: {CL_MUTED}; font-size: 12pt; border: 0;")
self.status_lbl.setText("Verbinde …")
self.pair_btn.setEnabled(False)
self.pairRequested.emit(host, port, code, name)
def show_error(self, message: str) -> None:
self.status_lbl.setStyleSheet("color: #f14c4c; font-size: 12pt; border: 0;")
self.status_lbl.setText(message)
self.pair_btn.setEnabled(True)
class MainScreen(QtWidgets.QWidget):
"""Paired mode: state orb, transcript card, status badges.
Alexa-style UX features:
- Mute toggle button on the orb
- Connection health monitoring with retry countdown
- Transcript history (scrollable conversation log)
- Visual mute indicator overlay
"""
settingsRequested = QtCore.Signal()
muteToggled = QtCore.Signal(bool) # new signal for mute toggle
def __init__(self, parent=None):
super().__init__(parent)
self.setStyleSheet(f"background: {CL_BG};")
outer = QtWidgets.QVBoxLayout(self)
outer.setContentsMargins(28, 20, 28, 24)
outer.setSpacing(16)
# ── top bar: room badge · connection health · mute · settings ──────
top = QtWidgets.QHBoxLayout()
self.room_lbl = QtWidgets.QLabel("Raum: —")
self.room_lbl.setStyleSheet(
f"color: {CL_TEXT}; background: {CL_SURFACE2}; border-radius: 14px;"
f"padding: 10px 22px; font-size: 14pt; font-weight: 600;")
top.addWidget(self.room_lbl)
top.addStretch(1)
# Connection status with retry countdown
self.conn_lbl = QtWidgets.QLabel("● verbunden")
self.conn_lbl.setStyleSheet(
"color: #4ec994; font-size: 13pt; padding: 10px;")
top.addWidget(self.conn_lbl)
# Mute toggle button (Alexa-style red ring when muted)
self.mute_btn = QtWidgets.QPushButton("🎤")
self.mute_btn.setFixedSize(56, 56)
self.mute_btn.setCursor(QtCore.Qt.PointingHandCursor)
self.mute_btn.setStyleSheet(
f"QPushButton {{ background: {CL_SURFACE2}; color: {CL_TEXT}; border: 0;"
f" border-radius: 14px; font-size: 18pt; }}"
f"QPushButton:checked {{ background: #f14c4c; color: white; }}"
f"QPushButton:pressed {{ background: {CL_INPUT}; color: {CL_TEXT}; }}"
f"QPushButton:disabled {{ background: {CL_SURFACE}; color: {CL_MUTED}; }}")
self.mute_btn.setCheckable(True) # Alexa-style toggle state
self.mute_btn.clicked.connect(self._on_mute_toggled)
top.addWidget(self.mute_btn)
gear = QtWidgets.QPushButton("⚙")
gear.setFixedSize(56, 56)
gear.setCursor(QtCore.Qt.PointingHandCursor)
gear.setStyleSheet(
f"QPushButton {{ background: {CL_SURFACE2}; color: {CL_MUTED}; border: 0;"
f" border-radius: 14px; font-size: 18pt; }}"
f"QPushButton:pressed {{ background: {CL_INPUT}; color: {CL_TEXT}; }}")
gear.clicked.connect(self.settingsRequested.emit)
top.addWidget(gear)
outer.addLayout(top)
# Retry countdown label (hidden by default, shown during reconnection)
self.retry_lbl = QtWidgets.QLabel("")
self.retry_lbl.setStyleSheet(
f"color: #f59e0b; font-size: 12pt; font-weight: 600; border: 0;")
self.retry_lbl.setAlignment(QtCore.Qt.AlignCenter)
self.retry_lbl.setVisible(False)
outer.addWidget(self.retry_lbl)
# ── state orb + label ─────────────────────────────────────────────
self.orb = StateOrb()
outer.addWidget(self.orb, 2)
self.state_lbl = QtWidgets.QLabel(STATE_LABELS["idle"])
self.state_lbl.setAlignment(QtCore.Qt.AlignCenter)
self.state_lbl.setStyleSheet(f"color: {CL_MUTED}; font-size: 16pt;")
outer.addWidget(self.state_lbl)
# ── transcript card (scrollable conversation history) ──────────────
card = QtWidgets.QFrame()
card.setStyleSheet(
f"QFrame {{ background: {CL_SURFACE}; border: 1px solid {CL_BORDER};"
f" border-radius: 18px; }}")
cv = QtWidgets.QVBoxLayout(card)
cv.setContentsMargins(28, 22, 28, 22)
cv.setSpacing(10)
# Header label
history_header = QtWidgets.QLabel("Konversation")
history_header.setStyleSheet(f"color: {CL_MUTED}; font-size: 11pt; border: 0;")
cv.addWidget(history_header)
# Transcript history (replaces single-turn display).
# NOTE: this layout must belong to *exactly one* parent — we set it on
# ``transcript_container`` (inside the scroll area) below, so it must
# NOT also be added to ``cv`` (Qt raises RuntimeError: "Attempting to
# set QLayout ... when the QLayout already has a parent").
self.transcript_layout = QtWidgets.QVBoxLayout()
self.transcript_layout.setSpacing(8)
# Scroll area for transcript
self.transcript_scroll = QtWidgets.QScrollArea()
self.transcript_scroll.setWidgetResizable(True)
self.transcript_scroll.setFrameShape(QtWidgets.QFrame.NoFrame)
self.transcript_scroll.setStyleSheet("background: transparent; border: 0;")
self.transcript_container = QtWidgets.QWidget()
self.transcript_container.setLayout(self.transcript_layout)
self.transcript_scroll.setWidget(self.transcript_container)
QtWidgets.QScroller.grabGesture(
self.transcript_scroll.viewport(), QtWidgets.QScroller.TouchGesture)
cv.addWidget(self.transcript_scroll, 1)
outer.addWidget(card, 3)
# Track last state for change detection
self._last_state = "idle"
def _on_mute_toggled(self) -> None:
"""Handle mute button toggle."""
is_muted = self.mute_btn.isChecked()
self.muteToggled.emit(is_muted)
# ── slots (called via Qt signals from the engine bridge) ──────────────
def set_state(self, state: str) -> None:
self.orb.set_state(state)
self.orb.set_muted(state == "muted") # sync mute overlay with state
label = STATE_LABELS.get(state, state)
if label:
self.state_lbl.setText(label)
# Mute toggle stays in place (enabled/disabled) so the top bar never
# jumps while the state cycles; it is only inactive mid-turn.
interactive_states = ("idle", "listening", "speaking", "muted")
self.mute_btn.setEnabled(state in interactive_states)
# Sync the button's checked state to match the engine's mute state.
want_checked = (state == "muted")
if self.mute_btn.isChecked() != want_checked:
self.mute_btn.blockSignals(True)
self.mute_btn.setChecked(want_checked)
self.mute_btn.blockSignals(False)
def set_mic_level(self, rms: float, score: float) -> None:
self.orb.set_level(min(1.0, rms * _RMS_GAIN))
def _ensure_transcript_spacer(self) -> None:
"""Remove existing spacer (if any) and add a fresh one at the end."""
# Walk backwards to find and remove any existing spacer items
while self.transcript_layout.count() > 0:
item = self.transcript_layout.itemAt(self.transcript_layout.count() - 1)
if isinstance(item, QtWidgets.QSpacerItem):
self.transcript_layout.removeItem(item)
else:
break
spacer = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum,
QtWidgets.QSizePolicy.Expanding)
self.transcript_layout.addSpacerItem(spacer)
def _prune_transcript(self, max_bubbles: int = 40) -> None:
"""Keep at most *max_bubbles* bubbles in the transcript layout (oldest first)."""
count = self.transcript_layout.count()
bubbles = count
if count > 0 and isinstance(self.transcript_layout.itemAt(count - 1), QtWidgets.QSpacerItem):
bubbles -= 1
excess = bubbles - max_bubbles
for _ in range(max(0, excess)):
item = self.transcript_layout.takeAt(0)
if item is not None and item.widget():
item.widget().deleteLater()
def set_turn(self, user_text: str, reply_text: str) -> None:
"""Add a new conversation turn to the transcript history (Alexa-style)."""
# User message bubble
user_bubble = QtWidgets.QLabel(f"👤 {user_text}")
user_bubble.setStyleSheet(
f"background: {CL_SURFACE2}; color: {CL_TEXT}; border-radius: 12px;"
f"padding: 12px 18px; font-size: 14pt;")
user_bubble.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)
user_bubble.setWordWrap(True)
self.transcript_layout.addWidget(user_bubble)
# Assistant message bubble (if any)
if reply_text:
reply_bubble = QtWidgets.QLabel(f"🤖 {reply_text}")
reply_bubble.setStyleSheet(
f"background: {CL_ACCENT}; color: #1e1e1e; border-radius: 12px;"
f"padding: 12px 18px; font-size: 14pt; font-weight: 500;")
reply_bubble.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter)
reply_bubble.setWordWrap(True)
self.transcript_layout.addWidget(reply_bubble)
self._prune_transcript()
# Spacer to push content to bottom (like Alexa's conversation history)
self._ensure_transcript_spacer()
# Auto-scroll to bottom
QTimer.singleShot(50, self._scroll_transcript_to_bottom)
def _scroll_transcript_to_bottom(self) -> None:
"""Auto-scroll transcript to show latest messages."""
vbar = self.transcript_scroll.verticalScrollBar()
vbar.setValue(vbar.maximum())
def set_connection(self, ok: bool, retry_count: int = 0, backoff: float = 0.0) -> None:
if ok:
self.conn_lbl.setText("● verbunden")
self.conn_lbl.setStyleSheet("color: #4ec994; font-size: 13pt; padding: 10px;")
self.retry_lbl.setVisible(False)
self.orb.set_reconnecting(False)
else:
if retry_count > 0 and backoff > 0:
# Show retry countdown (Alexa-style progressive feedback)
text = f"○ trennung · versuch {retry_count} in {backoff:.0f}s"
self.conn_lbl.setText(text)
self.conn_lbl.setStyleSheet("color: #f59e0b; font-size: 13pt; padding: 10px;")
self.retry_lbl.setVisible(True)
self.retry_lbl.setText(f"Wiederverbindungsversuch in {backoff:.0f}s …")
self.orb.set_reconnecting(True)
else:
self.conn_lbl.setText("● getrennt")
self.conn_lbl.setStyleSheet("color: #f14c4c; font-size: 13pt; padding: 10px;")
def set_room(self, room: Optional[str]) -> None:
self.room_lbl.setText(f"Raum: {room}" if room else "Raum: —")
def show_error(self, message: str) -> None:
"""Show a recoverable error to the user (Alexa-style progressive disclosure)."""
self._prune_transcript()
error_bubble = QtWidgets.QLabel(f"⚠ {message}")
error_bubble.setStyleSheet(
f"color: #f14c4c; font-size: 14pt; border: 0; padding: 12px;")
error_bubble.setWordWrap(True)
self.transcript_layout.addWidget(error_bubble)
self._ensure_transcript_spacer()
QTimer.singleShot(50, self._scroll_transcript_to_bottom)
class SlaveWindow(QtWidgets.QMainWindow):
"""Top-level window switching between setup and main screens.
Wired to engine callbacks that bridge asyncio events to Qt signals:
- engineState: device state changes (idle/listening/thinking/speaking/error)
- engineMicLevel: real-time audio level for orb pulse animation
- engineTurn: conversation turn results (user text + assistant reply)
- engineError: error messages from the engine
- engineConnection: connection health updates with retry countdown
- engineRoom: room assignment changes
"""
# Bridged engine events (emitted from asyncio, consumed on the Qt side).
engineState = QtCore.Signal(str)
engineMicLevel = QtCore.Signal(float, float)
engineTurn = QtCore.Signal(str, str)
engineError = QtCore.Signal(str)
engineConnection = QtCore.Signal(bool, int, float) # (ok, retry_count, backoff)
engineRoom = QtCore.Signal(object)
def __init__(self):
super().__init__()
self.setWindowTitle("Javis Slave")
self.resize(1368, 912) # Surface Pro 7 logical resolution @150 %
self.setStyleSheet(f"background: {CL_BG};")
self.stack = QtWidgets.QStackedWidget()
self.setup_screen = SetupScreen()
self.main_screen = MainScreen()
self.stack.addWidget(self.setup_screen)
self.stack.addWidget(self.main_screen)
self.setCentralWidget(self.stack)
self.engineState.connect(self.main_screen.set_state)
self.engineMicLevel.connect(self.main_screen.set_mic_level)
self.engineTurn.connect(self.main_screen.set_turn)
self.engineError.connect(self.main_screen.show_error)
self.engineConnection.connect(self.main_screen.set_connection)
self.engineRoom.connect(lambda r: self.main_screen.set_room(r))
QtGui.QShortcut(QtGui.QKeySequence("F11"), self, activated=self._toggle_fullscreen)
# Kiosk: fullscreen with a hidden cursor (touch panels); Esc leaves fullscreen.
self._kiosk = False
QtGui.QShortcut(QtGui.QKeySequence("Esc"), self, activated=self._leave_fullscreen)
def set_kiosk(self, enabled: bool) -> None:
self._kiosk = enabled
if enabled:
self.showFullScreen()
QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.CursorShape.BlankCursor)
def _leave_fullscreen(self) -> None:
if self.isFullScreen():
self.showNormal()
if self._kiosk:
QtWidgets.QApplication.restoreOverrideCursor()
def show_setup(self) -> None:
self.stack.setCurrentWidget(self.setup_screen)
def show_main(self) -> None:
self.stack.setCurrentWidget(self.main_screen)
def _toggle_fullscreen(self) -> None:
if self.isFullScreen():
self.showNormal()
else:
self.showFullScreen()
def show_settings_dialog(self, device_id: str, device_name: str,
on_repair: Callable[[], None]) -> None:
dlg = QtWidgets.QDialog(self)
dlg.setWindowTitle("Einstellungen")
dlg.setStyleSheet(f"background: {CL_SURFACE}; color: {CL_TEXT};")
v = QtWidgets.QVBoxLayout(dlg)
v.setContentsMargins(28, 24, 28, 24)
v.setSpacing(14)
info = QtWidgets.QLabel(
f"Gerät: {device_name or '—'}\nGeräte-ID: {device_id}")
info.setStyleSheet(f"color: {CL_MUTED}; font-size: 12pt;")
info.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse)
v.addWidget(info)
btn_fs = QtWidgets.QPushButton("Vollbild umschalten (F11)")
btn_fs.setStyleSheet(_GHOST_BTN_QSS)
btn_fs.clicked.connect(lambda: (self._toggle_fullscreen(), dlg.accept()))
v.addWidget(btn_fs)
btn_repair = QtWidgets.QPushButton("Neu koppeln …")
btn_repair.setStyleSheet(_GHOST_BTN_QSS)
def _do_repair():
ret = QtWidgets.QMessageBox.question(
dlg, "Neu koppeln",
"Verbindung zum Master trennen und neu koppeln?")
if ret == QtWidgets.QMessageBox.Yes:
dlg.accept()
on_repair()
btn_repair.clicked.connect(_do_repair)
v.addWidget(btn_repair)
btn_close = QtWidgets.QPushButton("Schließen")
btn_close.setStyleSheet(_PRIMARY_BTN_QSS)
btn_close.clicked.connect(dlg.accept)
v.addWidget(btn_close)
dlg.exec()
@@ -0,0 +1,558 @@
"""System control module for Javis Slave (volume, brightness, power).
Windows: pycaw for audio, screen_brightness_control for display, ``shutdown``
for power. Linux / Raspberry Pi: ``pactl`` (PipeWire/PulseAudio) or ``amixer``
(ALSA) for audio, ``/sys/class/backlight`` for display, ``systemctl`` for power.
The HTTP layer only uses the ``get_*_controller()`` factories, which pick the
backend for the current platform.
"""
import platform
import shutil
import subprocess
from pathlib import Path
from typing import Optional
from .logging_setup import get_logger
logger = get_logger("system")
_IS_LINUX = platform.system() == "Linux"
class WindowsControlError(Exception):
"""Exception for Windows control operations."""
pass
class VolumeController:
"""Control master volume on Windows."""
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._pycaw_available = True
logger.info("Pycaw audio control initialized")
except Exception as e:
logger.warning(f"Failed to initialize pycaw: {e}")
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:
return None
try:
return self._interface.GetMasterVolumeLevelScalar()
except Exception as e:
logger.error(f"Failed to get volume: {e}")
return None
def set_volume(self, level: float) -> bool:
"""Set master volume to level (0.0 to 1.0).
Args:
level: Volume level 0.0-1.0.
Returns:
True if successful, False otherwise.
"""
if not self._pycaw_available or not self._interface:
return False
level = max(0.0, min(1.0, level))
try:
self._interface.SetMasterVolumeLevelScalar(level, None)
logger.info(f"Set volume to {level * 100:.1f}%")
return True
except Exception as e:
logger.error(f"Failed to set volume: {e}")
return False
def is_muted(self) -> Optional[bool]:
"""Check if audio is muted.
Returns:
True if muted, False if not, None if unavailable.
"""
if not self._pycaw_available or not self._interface:
return None
try:
return self._interface.GetMute()
except Exception as e:
logger.error(f"Failed to get mute state: {e}")
return None
def set_mute(self, muted: bool) -> bool:
"""Set mute state.
Args:
muted: True to mute, False to unmute.
Returns:
True if successful, False otherwise.
"""
if not self._pycaw_available or not self._interface:
return False
try:
self._interface.SetMute(muted, None)
state = "muted" if muted else "unmuted"
logger.info(f"Audio {state}")
return True
except Exception as e:
logger.error(f"Failed to set mute: {e}")
return False
def increase_volume(self, amount: float = 0.05) -> Optional[float]:
"""Increase volume by amount.
Args:
amount: Amount to increase (0.0-1.0).
Returns:
New volume level, or None if failed.
"""
current = self.get_volume()
if current is None:
return None
new_level = min(1.0, current + amount)
if self.set_volume(new_level):
return new_level
return None
def decrease_volume(self, amount: float = 0.05) -> Optional[float]:
"""Decrease volume by amount.
Args:
amount: Amount to decrease (0.0-1.0).
Returns:
New volume level, or None if failed.
"""
current = self.get_volume()
if current is None:
return None
new_level = max(0.0, current - amount)
if self.set_volume(new_level):
return new_level
return None
class BrightnessController:
"""Control display brightness on Windows."""
def __init__(self):
"""Initialize brightness controller."""
self._available = False
if platform.system() != "Windows":
logger.warning("Brightness control only available on Windows")
return
try:
import screen_brightness_control
self._brightness = screen_brightness_control
self._available = True
logger.info("Screen brightness control initialized")
except Exception as e:
logger.warning(f"Failed to initialize brightness control: {e}")
def get_brightness(self) -> Optional[int]:
"""Get current display brightness (0-100).
Returns:
Brightness percentage 0-100, or None if unavailable.
"""
if not self._available:
return None
try:
brightness = self._brightness.get_brightness()
# get_brightness() returns a list if multiple displays
if isinstance(brightness, list):
return int(brightness[0])
return int(brightness)
except Exception as e:
logger.error(f"Failed to get brightness: {e}")
return None
def set_brightness(self, level: int) -> bool:
"""Set display brightness (0-100).
Args:
level: Brightness percentage 0-100.
Returns:
True if successful, False otherwise.
"""
if not self._available:
return False
level = max(0, min(100, level))
try:
self._brightness.set_brightness(level)
logger.info(f"Set brightness to {level}%")
return True
except Exception as e:
logger.error(f"Failed to set brightness: {e}")
return False
def increase_brightness(self, amount: int = 5) -> Optional[int]:
"""Increase brightness by amount.
Args:
amount: Amount to increase (percentage points).
Returns:
New brightness level, or None if failed.
"""
current = self.get_brightness()
if current is None:
return None
new_level = min(100, current + amount)
if self.set_brightness(new_level):
return new_level
return None
def decrease_brightness(self, amount: int = 5) -> Optional[int]:
"""Decrease brightness by amount.
Args:
amount: Amount to decrease (percentage points).
Returns:
New brightness level, or None if failed.
"""
current = self.get_brightness()
if current is None:
return None
new_level = max(0, current - amount)
if self.set_brightness(new_level):
return new_level
return None
class PowerController:
"""Control power settings on Windows."""
@staticmethod
def sleep() -> bool:
"""Put system to sleep.
Returns:
True if command sent, False on error.
"""
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)
logger.info("Sent sleep command")
return True
except Exception as e:
logger.error(f"Failed to sleep: {e}")
return False
@staticmethod
def shutdown(delay: int = 0) -> bool:
"""Shutdown system.
Args:
delay: Delay in seconds before shutdown.
Returns:
True if command sent, 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
@staticmethod
def restart(delay: int = 0) -> bool:
"""Restart system.
Args:
delay: Delay in seconds before restart.
Returns:
True if command sent, 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
@staticmethod
def cancel_shutdown() -> bool:
"""Cancel pending shutdown/restart.
Returns:
True if command sent, False on error.
"""
if platform.system() != "Windows":
logger.error("Power control only available on Windows")
return False
try:
subprocess.run(["shutdown", "/a"], check=False)
logger.info("Cancelled pending shutdown/restart")
return True
except Exception as e:
logger.error(f"Failed to cancel shutdown: {e}")
return False
# ── Linux / Raspberry Pi backends ────────────────────────────────────────────
def _run(cmd: list[str], timeout: float = 5.0) -> Optional[str]:
"""Run *cmd*; return stdout on success, None on any failure."""
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False)
except (OSError, subprocess.TimeoutExpired) as exc:
logger.error(f"{cmd[0]} failed: {exc}")
return None
if proc.returncode != 0:
logger.error(f"{' '.join(cmd)} exited {proc.returncode}: {proc.stderr.strip()}")
return None
return proc.stdout
class LinuxVolumeController:
"""Master volume via ``pactl`` (PipeWire/PulseAudio) with ``amixer`` (ALSA) fallback."""
def __init__(self, mixer_control: str = "Master"):
self._pactl = shutil.which("pactl")
self._amixer = shutil.which("amixer")
self._control = mixer_control
if not (self._pactl or self._amixer):
logger.warning("Neither pactl nor amixer found — volume control unavailable")
else:
logger.info(f"Linux volume control via {'pactl' if self._pactl else 'amixer'}")
def _amixer_control(self) -> str:
# Pi HDMI/headphone cards expose "PCM" or "Headphone" instead of "Master".
out = _run([self._amixer, "scontrols"]) or ""
names = [line.split("'")[1] for line in out.splitlines() if "'" in line]
for cand in (self._control, "Master", "PCM", "Headphone", "Speaker", "Digital"):
if cand in names:
return cand
return names[0] if names else self._control
def get_volume(self) -> Optional[float]:
if self._pactl:
out = _run([self._pactl, "get-sink-volume", "@DEFAULT_SINK@"])
if out:
for tok in out.replace("/", " ").split():
if tok.endswith("%") and tok[:-1].isdigit():
return min(1.0, int(tok[:-1]) / 100.0)
if self._amixer:
out = _run([self._amixer, "get", self._amixer_control()])
if out:
import re
m = re.search(r"\[(\d+)%\]", out)
if m:
return int(m.group(1)) / 100.0
return None
def set_volume(self, level: float) -> bool:
level = max(0.0, min(1.0, level))
pct = f"{int(round(level * 100))}%"
if self._pactl and _run([self._pactl, "set-sink-volume", "@DEFAULT_SINK@", pct]) is not None:
logger.info(f"Set volume to {pct}")
return True
if self._amixer and _run([self._amixer, "set", self._amixer_control(), pct]) is not None:
logger.info(f"Set volume to {pct}")
return True
return False
def is_muted(self) -> Optional[bool]:
if self._pactl:
out = _run([self._pactl, "get-sink-mute", "@DEFAULT_SINK@"])
if out:
return "yes" in out.lower()
if self._amixer:
out = _run([self._amixer, "get", self._amixer_control()])
if out:
return "[off]" in out
return None
def set_mute(self, muted: bool) -> bool:
if self._pactl and _run([self._pactl, "set-sink-mute", "@DEFAULT_SINK@", "1" if muted else "0"]) is not None:
return True
if self._amixer and _run([self._amixer, "set", self._amixer_control(), "mute" if muted else "unmute"]) is not None:
return True
return False
def increase_volume(self, amount: float = 0.05) -> Optional[float]:
cur = self.get_volume()
if cur is None:
return None
new = min(1.0, cur + amount)
return new if self.set_volume(new) else None
def decrease_volume(self, amount: float = 0.05) -> Optional[float]:
cur = self.get_volume()
if cur is None:
return None
new = max(0.0, cur - amount)
return new if self.set_volume(new) else None
class LinuxBrightnessController:
"""Backlight via ``/sys/class/backlight/*`` (official Pi touch display, DSI/eDP panels).
Writing needs permission: add the user to the ``video`` group and install
the udev rule from RASPBERRY_PI.md, or run as root.
"""
def __init__(self):
self._dev: Optional[Path] = None
for cand in sorted(Path("/sys/class/backlight").glob("*")):
if (cand / "brightness").exists() and (cand / "max_brightness").exists():
self._dev = cand
break
if self._dev is None:
logger.warning("No /sys/class/backlight device — brightness control unavailable")
else:
logger.info(f"Linux brightness control via {self._dev}")
def _max(self) -> int:
return max(1, int((self._dev / "max_brightness").read_text().strip())) # type: ignore[operator]
def get_brightness(self) -> Optional[int]:
if self._dev is None:
return None
try:
cur = int((self._dev / "brightness").read_text().strip())
return int(round(cur * 100 / self._max()))
except (OSError, ValueError) as exc:
logger.error(f"Failed to read brightness: {exc}")
return None
def set_brightness(self, level: int) -> bool:
if self._dev is None:
return False
level = max(0, min(100, level))
raw = int(round(level * self._max() / 100))
try:
(self._dev / "brightness").write_text(str(raw))
logger.info(f"Set brightness to {level}%")
return True
except OSError as exc:
logger.error(f"Failed to set brightness (permissions? see RASPBERRY_PI.md): {exc}")
return False
def increase_brightness(self, amount: int = 5) -> Optional[int]:
cur = self.get_brightness()
if cur is None:
return None
new = min(100, cur + amount)
return new if self.set_brightness(new) else None
def decrease_brightness(self, amount: int = 5) -> Optional[int]:
cur = self.get_brightness()
if cur is None:
return None
new = max(0, cur - amount)
return new if self.set_brightness(new) else None
class LinuxPowerController:
"""Power via ``systemctl`` / ``shutdown`` (needs polkit rights or passwordless sudo — see RASPBERRY_PI.md)."""
@staticmethod
def _priv(cmd: list[str]) -> list[str]:
import os
if os.geteuid() == 0 or not shutil.which("sudo"):
return cmd
return ["sudo", "-n", *cmd]
def sleep(self) -> bool:
return _run(self._priv(["systemctl", "suspend"])) is not None
def shutdown(self, delay: int = 0) -> bool:
when = f"+{max(1, (delay + 59) // 60)}" if delay > 0 else "now"
return _run(self._priv(["shutdown", "-h", when])) is not None
def restart(self, delay: int = 0) -> bool:
when = f"+{max(1, (delay + 59) // 60)}" if delay > 0 else "now"
return _run(self._priv(["shutdown", "-r", when])) is not None
def cancel_shutdown(self) -> bool:
return _run(self._priv(["shutdown", "-c"])) is not None
# Global controllers — one backend per platform.
if _IS_LINUX:
_volume = LinuxVolumeController()
_brightness = LinuxBrightnessController()
_power = LinuxPowerController()
else:
_volume = VolumeController()
_brightness = BrightnessController()
_power = PowerController()
def get_volume_controller() -> VolumeController:
"""Get global volume controller."""
return _volume
def get_brightness_controller() -> BrightnessController:
"""Get global brightness controller."""
return _brightness
def get_power_controller() -> PowerController:
"""Get global power controller."""
return _power
@@ -0,0 +1,558 @@
"""System control module for Javis Slave (volume, brightness, power).
Windows: pycaw for audio, screen_brightness_control for display, ``shutdown``
for power. Linux / Raspberry Pi: ``pactl`` (PipeWire/PulseAudio) or ``amixer``
(ALSA) for audio, ``/sys/class/backlight`` for display, ``systemctl`` for power.
The HTTP layer only uses the ``get_*_controller()`` factories, which pick the
backend for the current platform.
"""
import logging
import platform
import shutil
import subprocess
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
_IS_WINDOWS = platform.system() == "Windows"
_IS_LINUX = platform.system() == "Linux"
class WindowsControlError(Exception):
"""Exception for Windows control operations."""
pass
class VolumeController:
"""Control master volume on Windows."""
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._pycaw_available = True
logger.info("Pycaw audio control initialized")
except Exception as e:
logger.warning(f"Failed to initialize pycaw: {e}")
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:
return None
try:
return self._interface.GetMasterVolumeLevelScalar()
except Exception as e:
logger.error(f"Failed to get volume: {e}")
return None
def set_volume(self, level: float) -> bool:
"""Set master volume to level (0.0 to 1.0).
Args:
level: Volume level 0.0-1.0.
Returns:
True if successful, False otherwise.
"""
if not self._pycaw_available or not self._interface:
return False
level = max(0.0, min(1.0, level))
try:
self._interface.SetMasterVolumeLevelScalar(level, None)
logger.info(f"Set volume to {level * 100:.1f}%")
return True
except Exception as e:
logger.error(f"Failed to set volume: {e}")
return False
def is_muted(self) -> Optional[bool]:
"""Check if audio is muted.
Returns:
True if muted, False if not, None if unavailable.
"""
if not self._pycaw_available or not self._interface:
return None
try:
return self._interface.GetMute()
except Exception as e:
logger.error(f"Failed to get mute state: {e}")
return None
def set_mute(self, muted: bool) -> bool:
"""Set mute state.
Args:
muted: True to mute, False to unmute.
Returns:
True if successful, False otherwise.
"""
if not self._pycaw_available or not self._interface:
return False
try:
self._interface.SetMute(muted, None)
state = "muted" if muted else "unmuted"
logger.info(f"Audio {state}")
return True
except Exception as e:
logger.error(f"Failed to set mute: {e}")
return False
def increase_volume(self, amount: float = 0.05) -> Optional[float]:
"""Increase volume by amount.
Args:
amount: Amount to increase (0.0-1.0).
Returns:
New volume level, or None if failed.
"""
current = self.get_volume()
if current is None:
return None
new_level = min(1.0, current + amount)
if self.set_volume(new_level):
return new_level
return None
def decrease_volume(self, amount: float = 0.05) -> Optional[float]:
"""Decrease volume by amount.
Args:
amount: Amount to decrease (0.0-1.0).
Returns:
New volume level, or None if failed.
"""
current = self.get_volume()
if current is None:
return None
new_level = max(0.0, current - amount)
if self.set_volume(new_level):
return new_level
return None
class BrightnessController:
"""Control display brightness on Windows."""
def __init__(self):
"""Initialize brightness controller."""
self._available = False
if platform.system() != "Windows":
logger.warning("Brightness control only available on Windows")
return
try:
import screen_brightness_control
self._brightness = screen_brightness_control
self._available = True
logger.info("Screen brightness control initialized")
except Exception as e:
logger.warning(f"Failed to initialize brightness control: {e}")
def get_brightness(self) -> Optional[int]:
"""Get current display brightness (0-100).
Returns:
Brightness percentage 0-100, or None if unavailable.
"""
if not self._available:
return None
try:
brightness = self._brightness.get_brightness()
# get_brightness() returns a list if multiple displays
if isinstance(brightness, list):
return int(brightness[0])
return int(brightness)
except Exception as e:
logger.error(f"Failed to get brightness: {e}")
return None
def set_brightness(self, level: int) -> bool:
"""Set display brightness (0-100).
Args:
level: Brightness percentage 0-100.
Returns:
True if successful, False otherwise.
"""
if not self._available:
return False
level = max(0, min(100, level))
try:
self._brightness.set_brightness(level)
logger.info(f"Set brightness to {level}%")
return True
except Exception as e:
logger.error(f"Failed to set brightness: {e}")
return False
def increase_brightness(self, amount: int = 5) -> Optional[int]:
"""Increase brightness by amount.
Args:
amount: Amount to increase (percentage points).
Returns:
New brightness level, or None if failed.
"""
current = self.get_brightness()
if current is None:
return None
new_level = min(100, current + amount)
if self.set_brightness(new_level):
return new_level
return None
def decrease_brightness(self, amount: int = 5) -> Optional[int]:
"""Decrease brightness by amount.
Args:
amount: Amount to decrease (percentage points).
Returns:
New brightness level, or None if failed.
"""
current = self.get_brightness()
if current is None:
return None
new_level = max(0, current - amount)
if self.set_brightness(new_level):
return new_level
return None
class PowerController:
"""Control power settings on Windows."""
@staticmethod
def sleep() -> bool:
"""Put system to sleep.
Returns:
True if command sent, False on error.
"""
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)
logger.info("Sent sleep command")
return True
except Exception as e:
logger.error(f"Failed to sleep: {e}")
return False
@staticmethod
def shutdown(delay: int = 0) -> bool:
"""Shutdown system.
Args:
delay: Delay in seconds before shutdown.
Returns:
True if command sent, 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
@staticmethod
def restart(delay: int = 0) -> bool:
"""Restart system.
Args:
delay: Delay in seconds before restart.
Returns:
True if command sent, 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
@staticmethod
def cancel_shutdown() -> bool:
"""Cancel pending shutdown/restart.
Returns:
True if command sent, False on error.
"""
if platform.system() != "Windows":
logger.error("Power control only available on Windows")
return False
try:
subprocess.run(["shutdown", "/a"], check=False)
logger.info("Cancelled pending shutdown/restart")
return True
except Exception as e:
logger.error(f"Failed to cancel shutdown: {e}")
return False
# ── Linux / Raspberry Pi backends ────────────────────────────────────────────
def _run(cmd: list[str], timeout: float = 5.0) -> Optional[str]:
"""Run *cmd*; return stdout on success, None on any failure."""
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False)
except (OSError, subprocess.TimeoutExpired) as exc:
logger.error(f"{cmd[0]} failed: {exc}")
return None
if proc.returncode != 0:
logger.error(f"{' '.join(cmd)} exited {proc.returncode}: {proc.stderr.strip()}")
return None
return proc.stdout
class LinuxVolumeController:
"""Master volume via ``pactl`` (PipeWire/PulseAudio) with ``amixer`` (ALSA) fallback."""
def __init__(self, mixer_control: str = "Master"):
self._pactl = shutil.which("pactl")
self._amixer = shutil.which("amixer")
self._control = mixer_control
if not (self._pactl or self._amixer):
logger.warning("Neither pactl nor amixer found — volume control unavailable")
else:
logger.info(f"Linux volume control via {'pactl' if self._pactl else 'amixer'}")
def _amixer_control(self) -> str:
# Pi HDMI/headphone cards expose "PCM" or "Headphone" instead of "Master".
out = _run([self._amixer, "scontrols"]) or ""
names = [line.split("'")[1] for line in out.splitlines() if "'" in line]
for cand in (self._control, "Master", "PCM", "Headphone", "Speaker", "Digital"):
if cand in names:
return cand
return names[0] if names else self._control
def get_volume(self) -> Optional[float]:
if self._pactl:
out = _run([self._pactl, "get-sink-volume", "@DEFAULT_SINK@"])
if out:
for tok in out.replace("/", " ").split():
if tok.endswith("%") and tok[:-1].isdigit():
return min(1.0, int(tok[:-1]) / 100.0)
if self._amixer:
out = _run([self._amixer, "get", self._amixer_control()])
if out:
import re
m = re.search(r"\[(\d+)%\]", out)
if m:
return int(m.group(1)) / 100.0
return None
def set_volume(self, level: float) -> bool:
level = max(0.0, min(1.0, level))
pct = f"{int(round(level * 100))}%"
if self._pactl and _run([self._pactl, "set-sink-volume", "@DEFAULT_SINK@", pct]) is not None:
logger.info(f"Set volume to {pct}")
return True
if self._amixer and _run([self._amixer, "set", self._amixer_control(), pct]) is not None:
logger.info(f"Set volume to {pct}")
return True
return False
def is_muted(self) -> Optional[bool]:
if self._pactl:
out = _run([self._pactl, "get-sink-mute", "@DEFAULT_SINK@"])
if out:
return "yes" in out.lower()
if self._amixer:
out = _run([self._amixer, "get", self._amixer_control()])
if out:
return "[off]" in out
return None
def set_mute(self, muted: bool) -> bool:
if self._pactl and _run([self._pactl, "set-sink-mute", "@DEFAULT_SINK@", "1" if muted else "0"]) is not None:
return True
if self._amixer and _run([self._amixer, "set", self._amixer_control(), "mute" if muted else "unmute"]) is not None:
return True
return False
def increase_volume(self, amount: float = 0.05) -> Optional[float]:
cur = self.get_volume()
if cur is None:
return None
new = min(1.0, cur + amount)
return new if self.set_volume(new) else None
def decrease_volume(self, amount: float = 0.05) -> Optional[float]:
cur = self.get_volume()
if cur is None:
return None
new = max(0.0, cur - amount)
return new if self.set_volume(new) else None
class LinuxBrightnessController:
"""Backlight via ``/sys/class/backlight/*`` (official Pi touch display, DSI/eDP panels).
Writing needs permission: add the user to the ``video`` group and install
the udev rule from RASPBERRY_PI.md, or run as root.
"""
def __init__(self):
self._dev: Optional[Path] = None
for cand in sorted(Path("/sys/class/backlight").glob("*")):
if (cand / "brightness").exists() and (cand / "max_brightness").exists():
self._dev = cand
break
if self._dev is None:
logger.warning("No /sys/class/backlight device — brightness control unavailable")
else:
logger.info(f"Linux brightness control via {self._dev}")
def _max(self) -> int:
return max(1, int((self._dev / "max_brightness").read_text().strip())) # type: ignore[operator]
def get_brightness(self) -> Optional[int]:
if self._dev is None:
return None
try:
cur = int((self._dev / "brightness").read_text().strip())
return int(round(cur * 100 / self._max()))
except (OSError, ValueError) as exc:
logger.error(f"Failed to read brightness: {exc}")
return None
def set_brightness(self, level: int) -> bool:
if self._dev is None:
return False
level = max(0, min(100, level))
raw = int(round(level * self._max() / 100))
try:
(self._dev / "brightness").write_text(str(raw))
logger.info(f"Set brightness to {level}%")
return True
except OSError as exc:
logger.error(f"Failed to set brightness (permissions? see RASPBERRY_PI.md): {exc}")
return False
def increase_brightness(self, amount: int = 5) -> Optional[int]:
cur = self.get_brightness()
if cur is None:
return None
new = min(100, cur + amount)
return new if self.set_brightness(new) else None
def decrease_brightness(self, amount: int = 5) -> Optional[int]:
cur = self.get_brightness()
if cur is None:
return None
new = max(0, cur - amount)
return new if self.set_brightness(new) else None
class LinuxPowerController:
"""Power via ``systemctl`` / ``shutdown`` (needs polkit rights or passwordless sudo — see RASPBERRY_PI.md)."""
@staticmethod
def _priv(cmd: list[str]) -> list[str]:
import os
if os.geteuid() == 0 or not shutil.which("sudo"):
return cmd
return ["sudo", "-n", *cmd]
def sleep(self) -> bool:
return _run(self._priv(["systemctl", "suspend"])) is not None
def shutdown(self, delay: int = 0) -> bool:
when = f"+{max(1, (delay + 59) // 60)}" if delay > 0 else "now"
return _run(self._priv(["shutdown", "-h", when])) is not None
def restart(self, delay: int = 0) -> bool:
when = f"+{max(1, (delay + 59) // 60)}" if delay > 0 else "now"
return _run(self._priv(["shutdown", "-r", when])) is not None
def cancel_shutdown(self) -> bool:
return _run(self._priv(["shutdown", "-c"])) is not None
# Global controllers — one backend per platform.
if _IS_LINUX:
_volume = LinuxVolumeController()
_brightness = LinuxBrightnessController()
_power = LinuxPowerController()
else:
_volume = VolumeController()
_brightness = BrightnessController()
_power = PowerController()
def get_volume_controller() -> VolumeController:
"""Get global volume controller."""
return _volume
def get_brightness_controller() -> BrightnessController:
"""Get global brightness controller."""
return _brightness
def get_power_controller() -> PowerController:
"""Get global power controller."""
return _power