286 lines
12 KiB
Python
286 lines
12 KiB
Python
"""`python -m bolt_pet --doctor` — is this install actually going to work?
|
|
|
|
Written after a week of debugging things a preflight would have shown in one
|
|
command: a missing port publish, a wrong reverse-proxy header, a venv whose
|
|
python was a zero-byte file, an OCR engine that was never installed. Every one
|
|
of those presented as "the pet is being weird" and took a conversation to find.
|
|
|
|
Each check is independent and reports one of three things — ok, a warning
|
|
(works, but degraded), or a failure (this will not do what you expect) — and
|
|
says *what to do about it* rather than just what is wrong. Nothing here raises:
|
|
a doctor that crashes on a broken install is diagnosing the wrong patient.
|
|
|
|
Deliberately does not open the mic or call a paid API by default. It checks
|
|
that the door is unlocked, not that the room is furnished; `--deep` is there
|
|
when you want it to actually knock.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import shutil
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from typing import Callable, Optional
|
|
|
|
from . import config
|
|
|
|
OK, WARN, FAIL = "ok", "warn", "fail"
|
|
|
|
_MARKS = {OK: " ok ", WARN: " warn ", FAIL: " FAIL "}
|
|
|
|
|
|
@dataclass
|
|
class Check:
|
|
name: str
|
|
status: str
|
|
detail: str = ""
|
|
fix: str = ""
|
|
|
|
def line(self) -> str:
|
|
text = f"[{_MARKS[self.status]}] {self.name:22} {self.detail}"
|
|
if self.fix and self.status != OK:
|
|
text += f"\n{'':32}→ {self.fix}"
|
|
return text
|
|
|
|
|
|
def _module(name: str) -> bool:
|
|
try:
|
|
importlib.import_module(name)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
# ── the checks ──────────────────────────────────────────────────────────────
|
|
|
|
def check_config() -> Check:
|
|
missing = config.missing_config()
|
|
if missing:
|
|
return Check("server config", FAIL, f"missing {', '.join(missing)}",
|
|
"set them in .env — without these the controller exits at startup")
|
|
return Check("server config", OK, f"{config.SERVER_URL} as {config.SESSION_ID}")
|
|
|
|
|
|
def check_server(deep: bool = False) -> Check:
|
|
if not config.SERVER_URL:
|
|
return Check("server", FAIL, "no BOLT_SERVER_URL",
|
|
"cp .env.example .env and set BOLT_SERVER_URL to your Bolt server")
|
|
if not deep:
|
|
return Check("server", OK, f"{config.SERVER_URL} (not contacted; --deep to try)")
|
|
try:
|
|
from . import server_client
|
|
|
|
health = server_client.check_health(timeout=8)
|
|
return Check("server", OK, f"reachable — {health}")
|
|
except Exception as exc:
|
|
return Check("server", FAIL, f"unreachable: {exc}",
|
|
"check the URL, the key, and that the container is up")
|
|
|
|
|
|
def check_streaming_endpoint(deep: bool = False) -> Check:
|
|
"""The streamed-reply endpoint is newer than some deployed servers."""
|
|
if not config.STREAMING_REPLIES:
|
|
return Check("streamed replies", WARN, "disabled (STREAMING_REPLIES=false)")
|
|
if not deep:
|
|
return Check("streamed replies", OK, "enabled (endpoint not probed)")
|
|
try:
|
|
import requests
|
|
|
|
response = requests.post(
|
|
f"{config.SERVER_URL}/desk/converse_stream",
|
|
json={"session_id": config.SESSION_ID, "text": ""},
|
|
headers={"X-Desk-Api-Key": config.API_KEY}, timeout=8, stream=True,
|
|
)
|
|
if response.status_code == 404:
|
|
return Check("streamed replies", WARN, "server has no /desk/converse_stream",
|
|
"update the server, or set STREAMING_REPLIES=false to skip the probe")
|
|
return Check("streamed replies", OK, f"endpoint answered {response.status_code}")
|
|
except Exception as exc:
|
|
return Check("streamed replies", WARN, f"probe failed: {exc}")
|
|
|
|
|
|
def check_microphone(deep: bool = False) -> Check:
|
|
if not _module("sounddevice"):
|
|
return Check("microphone", FAIL, "sounddevice is not installed",
|
|
"pip install -r requirements.txt")
|
|
try:
|
|
import sounddevice as sd
|
|
|
|
devices = [d for d in sd.query_devices() if d.get("max_input_channels", 0) > 0]
|
|
if not devices:
|
|
return Check("microphone", FAIL, "no input devices",
|
|
"on Linux check PipeWire/PulseAudio is running as your user")
|
|
chosen = config.MIC_DEVICE or "system default"
|
|
if not deep:
|
|
return Check("microphone", OK, f"{len(devices)} input device(s), using {chosen}")
|
|
with sd.InputStream(samplerate=config.SAMPLE_RATE, channels=1, dtype="int16",
|
|
device=config.MIC_DEVICE, blocksize=config.FRAME_LEN):
|
|
pass
|
|
return Check("microphone", OK, f"opened at {config.SAMPLE_RATE} Hz ({chosen})")
|
|
except Exception as exc:
|
|
return Check("microphone", FAIL, f"could not open: {exc}",
|
|
"don't run the pet as root — PortAudio can't reach your PipeWire socket")
|
|
|
|
|
|
def check_wake_model() -> Check:
|
|
from pathlib import Path
|
|
|
|
path = Path(config.WAKE_MODEL_PATH)
|
|
if not path.exists():
|
|
return Check("wake word", FAIL, f"{path.name} is missing",
|
|
"it ships in the project root; check WAKE_MODEL_FILE")
|
|
if not _module("openwakeword"):
|
|
return Check("wake word", FAIL, "openwakeword is not installed",
|
|
"pip install -r requirements.txt")
|
|
return Check("wake word", OK, f"{path.name}, threshold {config.WAKE_WORD_THRESHOLD}")
|
|
|
|
|
|
def check_stt() -> Check:
|
|
"""STT is a websocket relay to the server (/desk/stt) — no local Deepgram
|
|
account, but websocket-client is now load-bearing for transcription to
|
|
work at all, not just the streaming optimisation (there's no separate
|
|
REST fallback any more)."""
|
|
if not config.is_configured():
|
|
return Check("speech-to-text", FAIL, "no BOLT_SERVER_URL/DESK_API_KEY",
|
|
"set them in .env — nothing you say can be transcribed without the server")
|
|
if not _module("websocket"):
|
|
return Check("speech-to-text", FAIL, "websocket-client is missing",
|
|
"pip install -r requirements.txt — /desk/stt is a websocket relay "
|
|
"with no REST fallback")
|
|
mode = "streaming" if config.STT_STREAMING else "one-shot (still via the server relay)"
|
|
return Check("speech-to-text", OK, f"server relay, {mode}")
|
|
|
|
|
|
def check_tts() -> Check:
|
|
"""TTS is the server's /desk/tts — no local ElevenLabs account needed for
|
|
the normal reply voice, just a voice id for it to request."""
|
|
if config.is_configured() and config.ELEVENLABS_VOICE_ID:
|
|
return Check("text-to-speech", OK,
|
|
f"server relay, voice …{config.ELEVENLABS_VOICE_ID[-6:]}")
|
|
if _module("pyttsx3"):
|
|
return Check("text-to-speech", WARN, "server/voice not configured — offline voice only",
|
|
"set BOLT_SERVER_URL/DESK_API_KEY and ELEVENLABS_VOICE_ID for the real voice")
|
|
return Check("text-to-speech", FAIL, "no server/voice config and no pyttsx3 fallback",
|
|
"set BOLT_SERVER_URL/DESK_API_KEY/ELEVENLABS_VOICE_ID, "
|
|
"or pip install pyttsx3 for an offline voice")
|
|
|
|
|
|
def check_dialogue() -> Check:
|
|
if not config.DIALOGUE:
|
|
return Check("multi-voice scenes", WARN, "disabled (DIALOGUE=false)")
|
|
if not config.ELEVENLABS_API_KEY:
|
|
return Check("multi-voice scenes", WARN, "no ELEVENLABS_API_KEY",
|
|
"set it in .env — dialogue scenes are the one feature still calling "
|
|
"ElevenLabs directly, since the server has no equivalent endpoint")
|
|
from . import dialogue
|
|
|
|
cast = dialogue.parse_voice_map(config.DIALOGUE_VOICES)
|
|
if not cast:
|
|
return Check("multi-voice scenes", WARN, "no cast configured — only 'self' works",
|
|
'set DIALOGUE_VOICES=narrator:<id>,villain:<id>')
|
|
return Check("multi-voice scenes", OK, f"{len(cast)} voice(s): {', '.join(sorted(cast))}")
|
|
|
|
|
|
def check_screen_text() -> Check:
|
|
if not config.SCREEN_TEXT:
|
|
return Check("screen reading", WARN, "disabled (SCREEN_TEXT=false)")
|
|
if not _module("mss"):
|
|
return Check("screen reading", WARN, "mss is not installed — petctl read will decline",
|
|
"pip install mss (and note it cannot capture on Wayland)")
|
|
engine = "pytesseract" if _module("pytesseract") else (
|
|
"rapidocr" if _module("rapidocr_onnxruntime") else "")
|
|
if not engine:
|
|
return Check("screen reading", WARN, "no OCR engine",
|
|
"pip install pytesseract && apt install tesseract-ocr, "
|
|
"or pip install rapidocr-onnxruntime")
|
|
if engine == "pytesseract" and not shutil.which("tesseract"):
|
|
return Check("screen reading", WARN, "pytesseract is installed but tesseract is not",
|
|
"apt install tesseract-ocr")
|
|
return Check("screen reading", OK, f"mss + {engine}")
|
|
|
|
|
|
def check_hotkey() -> Check:
|
|
if not _module("pynput"):
|
|
return Check("push-to-talk", WARN, "pynput is not installed",
|
|
"the wake word still works; pip install pynput for the hotkey")
|
|
import os
|
|
|
|
if os.environ.get("WAYLAND_DISPLAY") and not os.environ.get("DISPLAY"):
|
|
return Check("push-to-talk", WARN, "Wayland session — global hotkeys usually blocked",
|
|
"use the wake word, or click the pet")
|
|
return Check("push-to-talk", OK, config.PUSH_TO_TALK_HOTKEY)
|
|
|
|
|
|
def check_sprites() -> Check:
|
|
from pathlib import Path
|
|
|
|
root = Path(__file__).resolve().parent / "assets" / "sprites"
|
|
if not root.exists():
|
|
return Check("sprites", WARN, "no art — the placeholder blob will be drawn",
|
|
"python scripts/generate_bolt_sprites.py")
|
|
counts = {d.name: len(list(d.glob("*.png"))) for d in sorted(root.iterdir()) if d.is_dir()}
|
|
empty = [name for name, count in counts.items() if count == 0]
|
|
if empty:
|
|
return Check("sprites", WARN, f"no frames for: {', '.join(empty)}",
|
|
"python scripts/generate_bolt_sprites.py")
|
|
return Check("sprites", OK, ", ".join(f"{n} {c}" for n, c in counts.items()))
|
|
|
|
|
|
def check_latency() -> Check:
|
|
"""The setting most likely to make it feel slow, and the least obvious."""
|
|
silence = config.SILENCE_END_SEC
|
|
if silence >= 1.5:
|
|
return Check("turn latency", WARN, f"VAD_SILENCE_END_SEC={silence:g}s of dead air per turn",
|
|
"0.8-1.0 feels markedly snappier; it is pure wait before anything starts")
|
|
return Check("turn latency", OK, f"silence timeout {silence:g}s, "
|
|
f"streaming {'on' if config.STREAMING_REPLIES else 'off'}")
|
|
|
|
|
|
CHECKS: tuple[tuple[str, Callable], ...] = (
|
|
("config", check_config),
|
|
("server", check_server),
|
|
("stream", check_streaming_endpoint),
|
|
("mic", check_microphone),
|
|
("wake", check_wake_model),
|
|
("stt", check_stt),
|
|
("tts", check_tts),
|
|
("dialogue", check_dialogue),
|
|
("screen", check_screen_text),
|
|
("hotkey", check_hotkey),
|
|
("sprites", check_sprites),
|
|
("latency", check_latency),
|
|
)
|
|
|
|
|
|
def run(deep: bool = False) -> list[Check]:
|
|
results = []
|
|
for _name, check in CHECKS:
|
|
try:
|
|
try:
|
|
results.append(check(deep))
|
|
except TypeError:
|
|
results.append(check())
|
|
except Exception as exc: # a broken check must not hide the others
|
|
results.append(Check(_name, FAIL, f"the check itself failed: {exc}"))
|
|
return results
|
|
|
|
|
|
def main(argv: Optional[list] = None) -> int:
|
|
argv = list(argv if argv is not None else sys.argv[1:])
|
|
deep = "--deep" in argv
|
|
print(f"Bolt pet preflight{' (deep: contacting the server and opening the mic)' if deep else ''}\n")
|
|
results = run(deep=deep)
|
|
for check in results:
|
|
print(check.line())
|
|
failures = [c for c in results if c.status == FAIL]
|
|
warnings = [c for c in results if c.status == WARN]
|
|
print()
|
|
if failures:
|
|
print(f"{len(failures)} problem(s) will stop this working. Fix those first.")
|
|
elif warnings:
|
|
print(f"Ready. {len(warnings)} thing(s) degraded but working.")
|
|
else:
|
|
print("Everything checks out.")
|
|
return 1 if failures else 0
|