Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e9d92b0ba2 | |||
| 843f52c507 |
@@ -26,7 +26,11 @@
|
||||
"Bash(.venv/bin/python *)",
|
||||
"Bash(python *)",
|
||||
"Bash(QT_QPA_PLATFORM=offscreen .venv/bin/python -m pytest tests/test_controller_features.py -q -p no:cacheprovider)",
|
||||
"Bash(QT_QPA_PLATFORM=offscreen .venv/bin/python -c ' *)"
|
||||
"Bash(QT_QPA_PLATFORM=offscreen .venv/bin/python -c ' *)",
|
||||
"Bash(git push *)",
|
||||
"Bash(git remote *)",
|
||||
"Bash(grep -v '^$')",
|
||||
"Bash(.venv/bin/pip install *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+53
-5
@@ -40,6 +40,17 @@ ELEVENLABS_VOICE_ID=
|
||||
#VAD_SILENCE_END_SEC=1.2
|
||||
#VAD_MAX_UTTERANCE_SECONDS=15
|
||||
#VAD_MIN_UTTERANCE_SECONDS=0.4
|
||||
#VAD_GRACE_SECONDS=4 # how long to wait for you to start talking
|
||||
|
||||
# ── Follow-up listening (optional) ──────────────────────────────────────────
|
||||
# When a reply ends on a question, the pet keeps listening for your answer
|
||||
# instead of dropping back to idle and making you say the wake word again.
|
||||
# FOLLOW_UP_MAX_TURNS caps how many question-and-answer rounds can chain
|
||||
# without you re-triggering it (0 = no cap) — a stop on runaway loops if the
|
||||
# server ends every reply with "?" and the mic keeps feeding it noise.
|
||||
#FOLLOW_UP_LISTEN=true
|
||||
#FOLLOW_UP_MAX_TURNS=3
|
||||
#FOLLOW_UP_GRACE_SECONDS=7 # longer than VAD_GRACE_SECONDS: you were asked something
|
||||
|
||||
# ── Pet window (optional) ───────────────────────────────────────────────────
|
||||
#PET_SIZE=160
|
||||
@@ -63,12 +74,36 @@ ELEVENLABS_VOICE_ID=
|
||||
#PET_EDGE_SNAP=true
|
||||
#PET_SNAP_MARGIN=48
|
||||
|
||||
# ── Barge-in (optional) — talk over the pet to cut it off ───────────────────
|
||||
# Threshold defaults to 4x VAD_RMS_THRESHOLD because the mic also hears the
|
||||
# pet's own voice out of the speakers. Raise it if playback self-interrupts.
|
||||
# ── Barge-in (optional) — interrupt the pet mid-sentence ────────────────────
|
||||
# BARGE_IN_MODE decides what counts as an interruption:
|
||||
# wake — only the wake word cuts playback (default). Background noise,
|
||||
# coughs and the TV can't stop it mid-sentence.
|
||||
# energy — any sustained noise above BARGE_IN_RMS_THRESHOLD does. Faster to
|
||||
# trigger, but interrupts on anything loud. That threshold defaults
|
||||
# to 4x VAD_RMS_THRESHOLD because the mic also hears the pet's own
|
||||
# voice out of the speakers; raise it if playback self-interrupts.
|
||||
#BARGE_IN=true
|
||||
#BARGE_IN_RMS_THRESHOLD=1200
|
||||
#BARGE_IN_FRAMES=4
|
||||
#BARGE_IN_MODE=wake
|
||||
#BARGE_IN_RMS_THRESHOLD=1200 # energy mode only
|
||||
#BARGE_IN_FRAMES=4 # energy mode only
|
||||
# Wake mode only. Blank tracks the live WAKE_WORD_THRESHOLD (tray tuner);
|
||||
# set a number to make interrupting harder than waking the pet from idle,
|
||||
# e.g. if Bolt's own voice occasionally trips the model.
|
||||
#BARGE_IN_WAKE_THRESHOLD=0.6
|
||||
|
||||
# ── Auto-update (optional) ──────────────────────────────────────────────────
|
||||
# Watches the Gitea releases page for a tag newer than bolt_pet.__version__,
|
||||
# then `git checkout`s it and restarts — only ever between turns, never
|
||||
# mid-conversation. Requires the install to be a git clone; a working tree
|
||||
# with local changes is skipped (never stashed), and any failure after the
|
||||
# checkout rolls back to the ref that was live before.
|
||||
#AUTO_UPDATE=true
|
||||
#UPDATE_REPO_API=https://git.themajesticnetwork.com/api/v1/repos/TheMajesticNetwork/Bolt-Pet
|
||||
#UPDATE_CHECK_INTERVAL_SECONDS=3600
|
||||
#UPDATE_GIT_REMOTE=origin
|
||||
#UPDATE_INSTALL_DEPS=true
|
||||
# Only needed if the repo is private (a Gitea token with read:repository).
|
||||
#UPDATE_TOKEN=
|
||||
|
||||
# ── Streaming TTS (optional) — starts talking on the first chunk ────────────
|
||||
#TTS_STREAMING=true
|
||||
@@ -104,6 +139,19 @@ ELEVENLABS_VOICE_ID=
|
||||
#WAKE_NEAR_MISS_MARGIN=0.2
|
||||
#WAKE_NEAR_MISS_LIMIT=40
|
||||
|
||||
# ── sudo password prompts (optional) ────────────────────────────────────────
|
||||
# The pet has no terminal, so a server-relayed `sudo` would block forever on
|
||||
# a tty nobody is watching. With this on, bare `sudo` becomes `sudo -A` and
|
||||
# the password is collected in a desktop dialog you have to answer — a real
|
||||
# askpass binary if one is installed, otherwise a generated zenity/kdialog
|
||||
# wrapper in ~/.cache/bolt-pet/askpass.sh.
|
||||
# Set it to false if you'd rather Bolt never be able to ask for root: sudo
|
||||
# commands then just fail. Read the dialogs — that box is the only thing
|
||||
# between "Bolt decided to run sudo" and it running.
|
||||
#SUDO_ASKPASS_PROMPT=true
|
||||
#SUDO_ASKPASS_HELPER= # blank = auto-detect
|
||||
#SUDO_COMMAND_TIMEOUT_SECONDS=180 # long enough for a human to answer
|
||||
|
||||
# ── Misc (optional) ──────────────────────────────────────────────────────────
|
||||
#COMMAND_TIMEOUT_SECONDS=30
|
||||
#HEARTBEAT_INTERVAL_SECONDS=60
|
||||
|
||||
@@ -3,3 +3,5 @@ __pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.pytest_cache/
|
||||
.claude
|
||||
CLAUDE.md
|
||||
|
||||
@@ -79,11 +79,16 @@ logs a missing-config message and exits its thread instead of starting.
|
||||
`play_stream()` start playback on the first chunk; `chunks_to_int16()`
|
||||
carries odd bytes across HTTP chunk boundaries, without which everything
|
||||
after the first split sample plays as static — falling back to whole-clip
|
||||
PCM then offline `pyttsx3`), `barge_in.py` (`BargeInDetector`: N consecutive
|
||||
loud mic frames while the pet is talking cuts playback and starts the next
|
||||
turn; threshold is deliberately ~4x the VAD one because the mic hears the
|
||||
pet's own voice). Each accepts an injectable stream/model/protocol so tests
|
||||
don't need real audio hardware or a display.
|
||||
PCM then offline `pyttsx3`), `barge_in.py` (two detectors behind one
|
||||
`reset()`/`check()` shape, chosen by `BARGE_IN_MODE` via `make_detector`:
|
||||
**wake** (default) scores every frame with the same openWakeWord model the
|
||||
idle listener uses, so only the wake phrase cuts playback; **energy** is the
|
||||
original N-consecutive-loud-frames rule, threshold ~4x the VAD one because
|
||||
the mic hears the pet's own voice. Wake mode shares `_default_model` with
|
||||
the idle listener — the two never run concurrently — and `reset()`s it on
|
||||
detection so the tail of one reply can't count toward the next). Each
|
||||
accepts an injectable stream/model/protocol so tests don't need real audio
|
||||
hardware or a display.
|
||||
- **`pet_actions.py`** — `petctl` pseudo-commands (`petctl move top-left`,
|
||||
`petctl emote wave`, `say`/`wander`/`nap`). The desk API has no "move the
|
||||
pet" payload type and this repo can't change the server, so these ride the
|
||||
@@ -103,6 +108,33 @@ logs a missing-config message and exits its thread instead of starting.
|
||||
`dbus-monitor`, parses Notify calls (pure `iter_notifications()`), filters
|
||||
and rate-limits them (`NotificationGate`), and the controller forwards
|
||||
survivors through `converse()`. Off by default — each one is a round trip.
|
||||
- **`sudo_askpass.py`** — makes server-relayed `sudo` usable from a process
|
||||
with no terminal, by pointing sudo's `SUDO_ASKPASS` at a GUI helper and
|
||||
rewriting bare `sudo` to `sudo -A` (`add_askpass_flag`, a conservative regex
|
||||
that skips anything already carrying a flag and anything inside quotes).
|
||||
Prefers a real askpass binary and falls back to generating a
|
||||
zenity/kdialog wrapper in `~/.cache/bolt-pet/askpass.sh`. Resolution order
|
||||
is injectable (`is_executable`/`which`) so it's testable on a machine with a
|
||||
different set installed. See the security notes — the dialog is the boundary.
|
||||
- **`updater.py`** — self-update from the Gitea releases API. Polls
|
||||
`<UPDATE_REPO_API>/releases/latest` for a tag newer than
|
||||
`bolt_pet.__version__` and moves the checkout to it with
|
||||
`git fetch --tags` + `git checkout tags/<tag>`, so "downloading an update"
|
||||
is just git and rolling back is one command. Three safety rules: a **dirty
|
||||
working tree is skipped, never stashed** (silently discarding your
|
||||
work-in-progress beats running an old version); everything after the
|
||||
checkout — dependency install, then an **import smoke test in a
|
||||
subprocess** (this process still has the old modules loaded, so importing
|
||||
in-process would prove nothing) — is guarded, and any failure rolls back to
|
||||
the exact ref that was live before, branch name or SHA; and the restart only
|
||||
happens once the new code imports, so a broken release costs a log line
|
||||
rather than a pet that won't start. Git goes through an injectable
|
||||
`run(args) -> (code, output)` callable so apply/rollback is unit-tested
|
||||
against a fake git; version comparison and release parsing are pure.
|
||||
`controller._maybe_update` drives it from the wake-listener tick (so the pet
|
||||
is IDLE and between turns by construction) and the actual `os.execv` happens
|
||||
in `ui/app.py` *after* `app.exec()` returns — that ordering is what
|
||||
guarantees the mic is released before the new process opens it.
|
||||
- **`history.py`** — rolling transcript (`HISTORY_LIMIT` turns) behind the
|
||||
tray's History window and click-to-copy on the bubble.
|
||||
- **`hotkey.py`** — global push-to-talk via `pynput`; soft-fails with a logged
|
||||
@@ -113,7 +145,13 @@ logs a missing-config message and exits its thread instead of starting.
|
||||
covered) strips markdown, emoji, URLs and stray symbols the voice would read
|
||||
literally ("asterisk asterisk"), turns bullet lists into full sentences, and
|
||||
words a few symbols (`&` → "and"). `for_display()` is the looser version for
|
||||
the speech bubble — markdown syntax gone, emoji kept. Pure string logic, no
|
||||
the speech bubble — markdown syntax gone, emoji kept. `is_question()` decides
|
||||
whether a reply leaves the pet waiting on an answer: it tests the *spoken*
|
||||
form (so a '?' inside a stripped code block or URL doesn't count) and only a
|
||||
trailing one counts, since a question asked in passing isn't awaiting a
|
||||
reply. `controller._should_follow_up` uses it to keep listening without the
|
||||
wake word, capped by `FOLLOW_UP_MAX_TURNS` so a server that ends every reply
|
||||
with a question can't loop forever off mic noise. Pure string logic, no
|
||||
Qt/audio imports.
|
||||
- **`ui/`** — `app.py` wires `QApplication` + `PetWindow` + `PetTray` + the
|
||||
history/tuner windows + the push-to-talk hotkey + the controller thread
|
||||
@@ -155,6 +193,22 @@ detection. Swap `WAKE_MODEL_FILE` to point at a differently-trained `.onnx`
|
||||
model to change the wake phrase — everything downstream (STT, server call,
|
||||
TTS) is unaffected.
|
||||
|
||||
**openwakeword's `Model.reset()` is not enough to forget a detection.** It
|
||||
clears the *prediction* buffer only; the rolling audio window the classifier
|
||||
actually scores lives in `model.preprocessor` (`raw_data_buffer` — 10s of raw
|
||||
audio — plus `melspectrogram_buffer` and a ~120-frame `feature_buffer`) and
|
||||
`AudioFeatures` has no reset method at all. So after a detection the wake
|
||||
phrase is still in the window, and the next frame fed to the model re-fires on
|
||||
it. Symptom when this bites: the pet cuts itself off a word into every reply,
|
||||
because wake-mode barge-in resumes feeding the model and instantly matches the
|
||||
"thunderbolt" that *started* the turn. `wake_word.hard_reset(model)` restores
|
||||
the preprocessor to its as-constructed (silence) state and is what both
|
||||
`listen_for_wake_word` and `WakeWordBargeIn.reset()` call — use it, not
|
||||
`reset()`, anywhere a detection needs to be genuinely forgotten. The blank
|
||||
state is cached on the preprocessor object (not in an `id()`-keyed dict —
|
||||
CPython reuses ids after GC), since rebuilding it costs an ONNX pass over 10s
|
||||
of silence.
|
||||
|
||||
The threshold is tunable at runtime: the tray's **Wake word tuning…** window
|
||||
(`ui/wake_tuner.py`) shows the peak score seen and a rolling list of near
|
||||
misses (frames within `WAKE_NEAR_MISS_MARGIN` *below* the threshold — i.e.
|
||||
@@ -191,6 +245,23 @@ matches the trust model of the server repo's other desk clients. Keep
|
||||
`DESK_API_KEY` private and don't expose the desk API port to the open
|
||||
internet.
|
||||
|
||||
`sudo_askpass.py` widens that further, by design: with `SUDO_ASKPASS_PROMPT`
|
||||
on (the default), a relayed bare `sudo` is rewritten to `sudo -A` and the
|
||||
password is collected in a desktop dialog, so commands can escalate to root
|
||||
instead of hanging on a tty the pet doesn't have. The dialog is the security
|
||||
boundary — it's the only thing between the server deciding to run `sudo` and
|
||||
it running, so the prompt is deliberately not suppressible per-command and
|
||||
those commands get their own longer timeout (`SUDO_COMMAND_TIMEOUT_SECONDS`)
|
||||
rather than being made non-interactive. Set `SUDO_ASKPASS_PROMPT=false` to
|
||||
take the capability away entirely; sudo commands then fail. Note that
|
||||
`sudo -n` / `sudo -A` / `sudo -u …` in a relayed command are never rewritten,
|
||||
so an explicit non-interactive sudo stays non-interactive.
|
||||
|
||||
**Don't run the pet as root.** It needs no privileges of its own, PortAudio
|
||||
can't reach the user's PipeWire socket from a root session (raw ALSA devices
|
||||
reject the 16 kHz capture rate — `paInvalidSampleRate`), and every relayed
|
||||
command would run unconstrained.
|
||||
|
||||
Two newer features widen what leaves this machine, both switchable in `.env`:
|
||||
`SCREEN_CONTEXT` appends the focused window's *title* to each utterance
|
||||
(titles often contain file paths, document names, or subject lines), and
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Bolt desktop pet.
|
||||
|
||||
__version__ is what the auto-updater compares against the newest tag on the
|
||||
Gitea releases page (see updater.py), so bump it in the same commit you tag.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
+149
-21
@@ -1,26 +1,52 @@
|
||||
"""Barge-in: notice that the user started talking *while the pet is talking*
|
||||
so playback can be cut short mid-sentence.
|
||||
"""Barge-in: notice that the user wants to interrupt *while the pet is
|
||||
talking* so playback can be cut short mid-sentence.
|
||||
|
||||
Deliberately dumber than the utterance VAD in mic.py. The mic hears the pet's
|
||||
own voice coming back out of the speakers, so a single loud frame proves
|
||||
nothing — this requires several consecutive frames well above the normal
|
||||
speech threshold (BARGE_IN_RMS_THRESHOLD defaults to 4x VAD_RMS_THRESHOLD).
|
||||
Takes the same injectable stream shape as mic.record_utterance, so tests feed
|
||||
it fake frames instead of real audio hardware.
|
||||
Two detectors, picked by BARGE_IN_MODE:
|
||||
|
||||
- **wake** (default) — the interruption has to be the wake word. Every mic
|
||||
frame goes through the same openWakeWord model the idle listener uses, so
|
||||
a sneeze, a door, or the TV can't cut Bolt off mid-sentence; only saying
|
||||
"thunderbolt" does.
|
||||
- **energy** — the original behaviour: N consecutive frames above
|
||||
BARGE_IN_RMS_THRESHOLD. Faster to trigger and needs no model inference,
|
||||
but it fires on any sustained noise. Deliberately dumber than the
|
||||
utterance VAD in mic.py, since the mic hears the pet's own voice coming
|
||||
back out of the speakers, so the threshold defaults to 4x the VAD one.
|
||||
|
||||
Both take the same injectable stream shape as mic.record_utterance and expose
|
||||
the same reset()/check() pair, so tests feed them fake frames instead of real
|
||||
audio hardware and controller.py doesn't care which one it holds.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Optional, Union
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .. import config
|
||||
from .mic import AudioStream, rms
|
||||
from .wake_word import WakeModel, _default_model, hard_reset
|
||||
|
||||
|
||||
def _read_frame(stream: AudioStream, frame_len: int) -> Optional[np.ndarray]:
|
||||
"""One mono frame, or None if the mic hiccuped or gave us nothing. Never
|
||||
raises: a bad frame mid-playback should mean "no barge-in this frame",
|
||||
not a dead reply."""
|
||||
try:
|
||||
chunk, _ = stream.read(frame_len)
|
||||
except Exception:
|
||||
return None
|
||||
frame = np.asarray(chunk)
|
||||
if frame.ndim > 1:
|
||||
frame = frame[:, 0]
|
||||
return frame if frame.size else None
|
||||
|
||||
|
||||
class BargeInDetector:
|
||||
"""Poll-driven: call check() repeatedly while audio plays. Each call
|
||||
consumes exactly one mic frame (80ms at the default frame length), which
|
||||
is also what paces the playback loop's polling."""
|
||||
"""Energy mode. Poll-driven: call check() repeatedly while audio plays.
|
||||
Each call consumes exactly one mic frame (80ms at the default frame
|
||||
length), which is also what paces the playback loop's polling."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -44,19 +70,121 @@ class BargeInDetector:
|
||||
|
||||
def check(self) -> bool:
|
||||
"""True once the user has been loud for long enough to count as an
|
||||
interruption. Never raises: a mic hiccup mid-playback should not kill
|
||||
the reply, it should just mean "no barge-in this frame"."""
|
||||
try:
|
||||
chunk, _ = self._stream.read(self._frame_len)
|
||||
except Exception:
|
||||
return False
|
||||
frame = np.asarray(chunk)
|
||||
if frame.ndim > 1:
|
||||
frame = frame[:, 0]
|
||||
if frame.size == 0:
|
||||
interruption."""
|
||||
frame = _read_frame(self._stream, self._frame_len)
|
||||
if frame is None:
|
||||
return False
|
||||
if rms(frame) >= self._threshold:
|
||||
self._loud_frames += 1
|
||||
else:
|
||||
self._loud_frames = 0 # a single thump/cough shouldn't count
|
||||
return self._loud_frames >= self._required
|
||||
|
||||
|
||||
class WakeWordBargeIn:
|
||||
"""Wake-word mode: only "thunderbolt" interrupts.
|
||||
|
||||
Same per-frame predict() loop as listen_for_wake_word, just driven by the
|
||||
playback poll instead of its own read loop. The model instance is shared
|
||||
with the idle listener by default — the two never run at the same time
|
||||
(the pipeline is either speaking or listening), and reusing it avoids
|
||||
loading a second copy of the ONNX graph.
|
||||
|
||||
Two wrinkles the energy detector doesn't have:
|
||||
|
||||
- The mic hears the pet's own voice, so the model is scoring Bolt's
|
||||
speech too. That's harmless unless Bolt says its own wake word, which
|
||||
is why the threshold can be raised independently
|
||||
(BARGE_IN_WAKE_THRESHOLD) without desensitizing the idle listener.
|
||||
- reset() has to be a *hard* reset. openwakeword keeps ~10s of audio
|
||||
history in its preprocessor, so the "thunderbolt" that started this
|
||||
turn is still in the model's window when playback begins — feed it one
|
||||
new frame and it fires on the old phrase, cutting the reply off a word
|
||||
in. Clearing that window is what makes wake-mode barge-in work at all.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
stream: AudioStream,
|
||||
model: Optional[WakeModel] = None,
|
||||
threshold: Union[float, Callable[[], float], None] = None,
|
||||
frame_len: int = config.FRAME_LEN,
|
||||
on_score: Optional[Callable[[float, float], None]] = None,
|
||||
):
|
||||
self._stream = stream
|
||||
self._model = model if model is not None else _default_model
|
||||
if threshold is None:
|
||||
threshold = config.BARGE_IN_WAKE_THRESHOLD or config.WAKE_WORD_THRESHOLD
|
||||
self._resolve_threshold = threshold if callable(threshold) else (lambda: threshold)
|
||||
self._frame_len = frame_len
|
||||
self._on_score = on_score
|
||||
self._frames = 0
|
||||
self._peak = 0.0
|
||||
self._last = 0.0
|
||||
self._last_threshold = 0.0
|
||||
|
||||
# Scoring history for the current reply. Without this an interruption is
|
||||
# indistinguishable from a crash in the logs — you can't tell a genuine
|
||||
# "thunderbolt" from the model firing on Bolt's own voice, or on the first
|
||||
# frame (a stale window) versus halfway through (something it heard).
|
||||
@property
|
||||
def frames_checked(self) -> int:
|
||||
return self._frames
|
||||
|
||||
@property
|
||||
def seconds_checked(self) -> float:
|
||||
return self._frames * self._frame_len / config.SAMPLE_RATE
|
||||
|
||||
@property
|
||||
def peak_score(self) -> float:
|
||||
return self._peak
|
||||
|
||||
@property
|
||||
def last_score(self) -> float:
|
||||
return self._last
|
||||
|
||||
@property
|
||||
def last_threshold(self) -> float:
|
||||
return self._last_threshold
|
||||
|
||||
def reset(self) -> None:
|
||||
hard_reset(self._model) # never raises
|
||||
self._frames = 0
|
||||
self._peak = 0.0
|
||||
self._last = 0.0
|
||||
|
||||
def check(self) -> bool:
|
||||
frame = _read_frame(self._stream, self._frame_len)
|
||||
if frame is None:
|
||||
return False
|
||||
try:
|
||||
scores = self._model.predict(frame)
|
||||
except Exception:
|
||||
return False # same contract as a mic hiccup: no barge-in, no crash
|
||||
threshold = self._resolve_threshold()
|
||||
best = max(scores.values()) if scores else 0.0
|
||||
self._frames += 1
|
||||
self._last = best
|
||||
self._peak = max(self._peak, best)
|
||||
self._last_threshold = threshold
|
||||
if self._on_score is not None:
|
||||
self._on_score(best, threshold)
|
||||
if scores and best >= threshold:
|
||||
self.reset()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def make_detector(
|
||||
stream: AudioStream,
|
||||
mode: str = None,
|
||||
wake_threshold: Union[float, Callable[[], float], None] = None,
|
||||
on_score: Optional[Callable[[float, float], None]] = None,
|
||||
):
|
||||
"""Build whichever detector BARGE_IN_MODE asks for. An unrecognized mode
|
||||
falls back to energy rather than raising — a typo in .env shouldn't stop
|
||||
the pet from starting."""
|
||||
mode = (config.BARGE_IN_MODE if mode is None else mode).strip().lower()
|
||||
if mode in ("wake", "wakeword", "wake_word"):
|
||||
return WakeWordBargeIn(stream, threshold=wake_threshold, on_score=on_score)
|
||||
return BargeInDetector(stream)
|
||||
|
||||
@@ -44,6 +44,7 @@ def record_utterance(
|
||||
silence_end_sec: float = None,
|
||||
max_utterance_s: float = None,
|
||||
min_utterance_s: float = None,
|
||||
grace_s: float = None,
|
||||
frame_len: int = config.FRAME_LEN,
|
||||
sample_rate: int = config.SAMPLE_RATE,
|
||||
) -> Optional[np.ndarray]:
|
||||
@@ -53,18 +54,24 @@ def record_utterance(
|
||||
*should_continue* is polled each frame so a caller can cancel recording
|
||||
(e.g. the pet window was closed) without needing threading primitives
|
||||
baked into this function.
|
||||
|
||||
*grace_s* is how long to wait for speech to *begin* before giving up.
|
||||
The controller stretches it for follow-up questions, where you're being
|
||||
asked something and need a moment to think rather than having just said
|
||||
the wake word on purpose.
|
||||
"""
|
||||
rms_threshold = config.RMS_THRESHOLD if rms_threshold is None else rms_threshold
|
||||
silence_end_sec = config.SILENCE_END_SEC if silence_end_sec is None else silence_end_sec
|
||||
max_utterance_s = config.MAX_UTTERANCE_S if max_utterance_s is None else max_utterance_s
|
||||
min_utterance_s = config.MIN_UTTERANCE_S if min_utterance_s is None else min_utterance_s
|
||||
grace_s = config.GRACE_SECONDS if grace_s is None else grace_s
|
||||
|
||||
frames: list[np.ndarray] = []
|
||||
started = False
|
||||
silence_frames = 0
|
||||
silence_limit = int(silence_end_sec * sample_rate / frame_len)
|
||||
max_frames = int(max_utterance_s * sample_rate / frame_len)
|
||||
grace_frames = int(4.0 * sample_rate / frame_len) # wait up to 4s for speech to begin
|
||||
grace_frames = int(grace_s * sample_rate / frame_len) # how long to wait for speech to begin
|
||||
waited = 0
|
||||
|
||||
while should_continue():
|
||||
|
||||
@@ -64,6 +64,65 @@ class NearMissLog:
|
||||
self._peak = 0.0
|
||||
|
||||
|
||||
# Where the cached blank state is stashed — on the preprocessor itself
|
||||
# rather than in a dict keyed by id(), which CPython reuses after garbage
|
||||
# collection and would hand one model another's buffers.
|
||||
_BLANK_STATE_ATTR = "_bolt_blank_state"
|
||||
|
||||
|
||||
def _blank_state(preprocessor) -> Optional[tuple]:
|
||||
"""(feature_buffer, melspectrogram_buffer) as they are on a freshly
|
||||
constructed model — i.e. "having heard nothing". Computing it costs an
|
||||
ONNX pass over 10s of silence, so it's cached on the preprocessor and
|
||||
copied from thereafter. Returns None if openwakeword's internals don't
|
||||
look the way we expect, in which case callers leave the state alone
|
||||
rather than corrupting it."""
|
||||
cached = getattr(preprocessor, _BLANK_STATE_ATTR, None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
try:
|
||||
# Same call the AudioFeatures constructor uses to prime the buffer.
|
||||
state = (preprocessor._get_embeddings(np.zeros(160000).astype(np.int16)), np.ones((76, 32)))
|
||||
setattr(preprocessor, _BLANK_STATE_ATTR, state)
|
||||
except Exception:
|
||||
return None
|
||||
return state
|
||||
|
||||
|
||||
def hard_reset(model) -> None:
|
||||
"""Make the model forget the audio it has already heard — not just its
|
||||
predictions.
|
||||
|
||||
openwakeword's ``Model.reset()`` clears the *prediction* buffer only.
|
||||
The rolling audio window the classifier actually scores lives in
|
||||
``model.preprocessor`` (raw_data_buffer / melspectrogram_buffer /
|
||||
feature_buffer, ~10s of history) and has no reset method of its own. So
|
||||
after a detection the wake word is still sitting in that window, and the
|
||||
next frame fed to the model re-fires on it — which is exactly what made
|
||||
the pet interrupt itself a word into every reply: the "thunderbolt" that
|
||||
started the turn was still in the buffer when barge-in resumed feeding it.
|
||||
|
||||
Never raises. A model whose internals don't match (a fake in tests, a
|
||||
future openwakeword release) just gets the plain reset()."""
|
||||
try:
|
||||
model.reset()
|
||||
except Exception:
|
||||
pass
|
||||
preprocessor = getattr(model, "preprocessor", None)
|
||||
if preprocessor is None:
|
||||
return
|
||||
blank = _blank_state(preprocessor)
|
||||
try:
|
||||
if blank is not None:
|
||||
features, melspectrogram = blank
|
||||
preprocessor.feature_buffer = features.copy()
|
||||
preprocessor.melspectrogram_buffer = melspectrogram.copy()
|
||||
preprocessor.raw_data_buffer.clear()
|
||||
preprocessor.accumulated_samples = 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _construct_model(model_cls, model_path: str):
|
||||
"""openwakeword's Model() constructor keyword has drifted across
|
||||
releases (wakeword_models -> wakeword_model_paths) and some builds
|
||||
@@ -112,6 +171,17 @@ class _OpenWakeWordModel:
|
||||
self._model = _construct_model(Model, config.WAKE_MODEL_PATH)
|
||||
return self._model
|
||||
|
||||
@property
|
||||
def preprocessor(self):
|
||||
"""Proxy the wrapped model's audio-feature buffers.
|
||||
|
||||
Without this, hard_reset() sees a wrapper with no `preprocessor` and
|
||||
silently degrades to openwakeword's shallow reset() — which leaves the
|
||||
previous detection sitting in the audio window, i.e. exactly the bug
|
||||
hard_reset exists to fix. Returns None before the model is loaded, so
|
||||
a reset that happens first is a no-op rather than a load."""
|
||||
return getattr(self._model, "preprocessor", None)
|
||||
|
||||
def predict(self, frame: np.ndarray) -> dict:
|
||||
return self._ensure_model().predict(frame)
|
||||
|
||||
@@ -136,8 +206,9 @@ def listen_for_wake_word(
|
||||
|
||||
Feeds every frame to *model* (the thunderbolt openWakeWord model by
|
||||
default) and treats any class score >= *threshold* as a detection,
|
||||
resetting the model's internal state afterward so the next call starts
|
||||
clean — same pattern as desk_client/bolt_desk.py's main loop.
|
||||
clearing the model's internal state (audio window included, see
|
||||
hard_reset) afterward so the next call starts clean — same pattern as
|
||||
desk_client/bolt_desk.py's main loop.
|
||||
|
||||
*threshold* may be a number or a zero-argument callable. The callable
|
||||
form exists because this function blocks for minutes at a time: the
|
||||
@@ -180,6 +251,9 @@ def listen_for_wake_word(
|
||||
on_score(best, current_threshold)
|
||||
|
||||
if scores and best >= current_threshold:
|
||||
model.reset()
|
||||
# hard_reset, not reset: the phrase has to leave the model's audio
|
||||
# window too, or the very next frame we feed it re-fires on the
|
||||
# same "thunderbolt" (see hard_reset's docstring).
|
||||
hard_reset(model)
|
||||
return True
|
||||
return False
|
||||
|
||||
+65
-5
@@ -78,19 +78,58 @@ RMS_THRESHOLD = int(os.environ.get("VAD_RMS_THRESHOLD", "300"))
|
||||
SILENCE_END_SEC = float(os.environ.get("VAD_SILENCE_END_SEC", "1.2"))
|
||||
MAX_UTTERANCE_S = float(os.environ.get("VAD_MAX_UTTERANCE_SECONDS", "15"))
|
||||
MIN_UTTERANCE_S = float(os.environ.get("VAD_MIN_UTTERANCE_SECONDS", "0.4"))
|
||||
# How long to wait for you to *start* talking before giving up on a turn.
|
||||
GRACE_SECONDS = float(os.environ.get("VAD_GRACE_SECONDS", "4"))
|
||||
|
||||
# ── follow-up listening ─────────────────────────────────────────────────────
|
||||
# When a reply ends on a question, the pet keeps listening for the answer
|
||||
# instead of dropping back to idle and making you say the wake word again.
|
||||
# The grace period is longer than a normal turn's because you were asked
|
||||
# something and may need a beat to think. FOLLOW_UP_MAX_TURNS caps how many
|
||||
# question-and-answer rounds can chain without you re-triggering it — a stop
|
||||
# on runaway loops if the server ends every reply with a question and the mic
|
||||
# keeps feeding it noise. 0 means no cap.
|
||||
|
||||
FOLLOW_UP_LISTEN = os.environ.get("FOLLOW_UP_LISTEN", "true").lower() in ("1", "true", "yes", "on")
|
||||
FOLLOW_UP_MAX_TURNS = int(os.environ.get("FOLLOW_UP_MAX_TURNS", "3"))
|
||||
FOLLOW_UP_GRACE_SECONDS = float(os.environ.get("FOLLOW_UP_GRACE_SECONDS", "7"))
|
||||
|
||||
COMMAND_TIMEOUT_SECONDS = int(os.environ.get("COMMAND_TIMEOUT_SECONDS", "30"))
|
||||
|
||||
# ── sudo password prompts ───────────────────────────────────────────────────
|
||||
# The pet has no terminal, so a relayed `sudo` would block on a tty nobody is
|
||||
# watching. With this on, bare `sudo` is rewritten to `sudo -A` and the
|
||||
# password is collected in a desktop dialog (a real askpass binary if one is
|
||||
# installed, otherwise a generated zenity/kdialog wrapper). Turn it off and
|
||||
# sudo commands simply fail, which is the safer default if you'd rather Bolt
|
||||
# never be able to ask for root at all.
|
||||
SUDO_ASKPASS_PROMPT = os.environ.get("SUDO_ASKPASS_PROMPT", "true").lower() in ("1", "true", "yes", "on")
|
||||
SUDO_ASKPASS_HELPER = os.environ.get("SUDO_ASKPASS_HELPER", "") # blank = auto-detect
|
||||
# Longer than COMMAND_TIMEOUT_SECONDS because a person has to notice the
|
||||
# dialog, read it, and type — 30s is nowhere near enough for that.
|
||||
SUDO_COMMAND_TIMEOUT_SECONDS = int(os.environ.get("SUDO_COMMAND_TIMEOUT_SECONDS", "180"))
|
||||
HEARTBEAT_INTERVAL_SECONDS = float(os.environ.get("HEARTBEAT_INTERVAL_SECONDS", "60"))
|
||||
|
||||
# ── barge-in (interrupt playback by talking over it) ────────────────────────
|
||||
# The mic stays live while the pet talks; sustained loud frames cut playback
|
||||
# short. The threshold is deliberately well above VAD_RMS_THRESHOLD because
|
||||
# the mic also hears the pet's own voice through the speakers — raise it
|
||||
# further (or set BARGE_IN=false) if playback keeps interrupting itself.
|
||||
# ── barge-in (interrupt playback while the pet is talking) ──────────────────
|
||||
# The mic stays live while the pet talks. BARGE_IN_MODE decides what counts
|
||||
# as an interruption:
|
||||
# wake — only the wake word cuts playback (default). Immune to coughs,
|
||||
# doors, and the TV, at the cost of ~a word of extra latency.
|
||||
# energy — any sustained noise above BARGE_IN_RMS_THRESHOLD does. Faster,
|
||||
# but interrupts on background noise. That threshold is well above
|
||||
# VAD_RMS_THRESHOLD because the mic also hears the pet's own voice
|
||||
# through the speakers — raise it further if playback keeps
|
||||
# interrupting itself.
|
||||
# Set BARGE_IN=false to make playback uninterruptible either way.
|
||||
|
||||
BARGE_IN = os.environ.get("BARGE_IN", "true").lower() in ("1", "true", "yes", "on")
|
||||
BARGE_IN_MODE = os.environ.get("BARGE_IN_MODE", "wake")
|
||||
BARGE_IN_RMS_THRESHOLD = int(os.environ.get("BARGE_IN_RMS_THRESHOLD", str(RMS_THRESHOLD * 4)))
|
||||
BARGE_IN_FRAMES = int(os.environ.get("BARGE_IN_FRAMES", "4")) # consecutive loud frames (80ms each)
|
||||
# Wake-mode sensitivity. Blank means "track the live WAKE_WORD_THRESHOLD from
|
||||
# the tray tuner"; set a number to make interrupting deliberately harder than
|
||||
# waking the pet from idle (useful if Bolt's own voice trips the model).
|
||||
BARGE_IN_WAKE_THRESHOLD = float(os.environ.get("BARGE_IN_WAKE_THRESHOLD") or 0) or None
|
||||
|
||||
# ── streaming TTS ───────────────────────────────────────────────────────────
|
||||
# ElevenLabs' /stream endpoint + chunked playback: the pet starts talking
|
||||
@@ -140,6 +179,27 @@ PUSH_TO_TALK_HOTKEY = os.environ.get("PUSH_TO_TALK_HOTKEY", "ctrl+alt+space")
|
||||
WAKE_NEAR_MISS_MARGIN = float(os.environ.get("WAKE_NEAR_MISS_MARGIN", "0.2"))
|
||||
WAKE_NEAR_MISS_LIMIT = int(os.environ.get("WAKE_NEAR_MISS_LIMIT", "40"))
|
||||
|
||||
# ── auto-update ─────────────────────────────────────────────────────────────
|
||||
# Watches the Gitea releases API for a tag newer than bolt_pet.__version__,
|
||||
# then `git checkout`s it in place and restarts (see updater.py). The install
|
||||
# has to be a git clone with a clean working tree — a dirty tree is skipped
|
||||
# rather than stashed, so local edits are never thrown away. Any failure
|
||||
# after checkout rolls back to the ref that was checked out before.
|
||||
|
||||
AUTO_UPDATE = os.environ.get("AUTO_UPDATE", "true").lower() in ("1", "true", "yes", "on")
|
||||
UPDATE_REPO_API = os.environ.get(
|
||||
"UPDATE_REPO_API",
|
||||
"https://git.themajesticnetwork.com/api/v1/repos/TheMajesticNetwork/Bolt-Pet",
|
||||
).rstrip("/")
|
||||
UPDATE_CHECK_INTERVAL_SECONDS = float(os.environ.get("UPDATE_CHECK_INTERVAL_SECONDS", "3600"))
|
||||
UPDATE_GIT_REMOTE = os.environ.get("UPDATE_GIT_REMOTE", "origin")
|
||||
# Only needed if the repo is private — releases on a public repo read fine
|
||||
# anonymously. A Gitea access token with read:repository.
|
||||
UPDATE_TOKEN = os.environ.get("UPDATE_TOKEN", "")
|
||||
# Reinstall requirements.txt when an update changes it. Off means a release
|
||||
# that adds a dependency will roll straight back on the import smoke test.
|
||||
UPDATE_INSTALL_DEPS = os.environ.get("UPDATE_INSTALL_DEPS", "true").lower() in ("1", "true", "yes", "on")
|
||||
|
||||
SAMPLE_RATE = 16000 # mic capture / STT rate
|
||||
FRAME_LEN = 1280 # 80ms @ 16kHz — matches bolt_desk.py's chunking
|
||||
|
||||
|
||||
+137
-4
@@ -19,7 +19,10 @@ from typing import Optional
|
||||
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
from . import config, history as history_mod, notifications, pet_actions, quiet, screen_context, server_client, speech_text
|
||||
from . import (
|
||||
config, history as history_mod, notifications, pet_actions, quiet,
|
||||
screen_context, server_client, speech_text, updater,
|
||||
)
|
||||
from .audio import barge_in, mic, stt, tts, wake_word
|
||||
from .state import PetState, PetStateMachine
|
||||
|
||||
@@ -34,6 +37,7 @@ class PetController(QObject):
|
||||
log = Signal(str)
|
||||
action = Signal(dict) # parsed petctl action for the UI to perform
|
||||
napping = Signal(bool) # quiet hours / fullscreen do-not-disturb
|
||||
restart_requested = Signal(str) # version we just updated to
|
||||
finished = Signal()
|
||||
|
||||
def __init__(self):
|
||||
@@ -59,6 +63,16 @@ class PetController(QObject):
|
||||
self._nap_forced: Optional[bool] = None # petctl nap on/off overrides the schedule
|
||||
self._last_nap_check = 0.0
|
||||
|
||||
# When a reply ends on a question the pet keeps listening for the
|
||||
# answer. _follow_ups counts how many have chained without you
|
||||
# re-triggering, so a server that ends every reply with "?" can't
|
||||
# loop forever off mic noise.
|
||||
self._pending_follow_up = False
|
||||
self._follow_ups = 0
|
||||
|
||||
self._last_update_check = 0.0
|
||||
self._update_pending = False # applied on disk, waiting for the restart
|
||||
|
||||
self._notification_watcher: Optional[notifications.NotificationWatcher] = None
|
||||
self._notification_gate = notifications.NotificationGate(
|
||||
config.NOTIFICATION_FILTER, config.NOTIFICATION_MIN_INTERVAL_SECONDS
|
||||
@@ -124,7 +138,19 @@ class PetController(QObject):
|
||||
return
|
||||
|
||||
if config.BARGE_IN:
|
||||
self._barge_in = barge_in.BargeInDetector(self._stream)
|
||||
# In wake mode the detector shares the idle listener's model and
|
||||
# its live threshold, so the tray tuner's slider applies to
|
||||
# interrupting as well as waking (unless BARGE_IN_WAKE_THRESHOLD
|
||||
# pins it to a fixed, stricter number).
|
||||
self._barge_in = barge_in.make_detector(
|
||||
self._stream,
|
||||
wake_threshold=(
|
||||
config.BARGE_IN_WAKE_THRESHOLD
|
||||
if config.BARGE_IN_WAKE_THRESHOLD is not None
|
||||
else self.wake_threshold
|
||||
),
|
||||
)
|
||||
self.log.emit(f"Barge-in: {config.BARGE_IN_MODE} mode.")
|
||||
|
||||
with self._stream:
|
||||
try:
|
||||
@@ -181,9 +207,21 @@ class PetController(QObject):
|
||||
self._near_misses.observe(score, threshold, time.time())
|
||||
|
||||
def _handle_conversation_turn(self) -> None:
|
||||
# A turn you started yourself ends any follow-up chain in progress.
|
||||
following_up, self._pending_follow_up = self._pending_follow_up, False
|
||||
if not following_up:
|
||||
self._follow_ups = 0
|
||||
|
||||
self._state.transition(PetState.LISTENING)
|
||||
pcm = mic.record_utterance(self._stream, should_continue=self._should_continue)
|
||||
pcm = mic.record_utterance(
|
||||
self._stream,
|
||||
should_continue=self._should_continue,
|
||||
# Answering a question deserves longer than saying the wake word
|
||||
# on purpose does — you were just asked something.
|
||||
grace_s=config.FOLLOW_UP_GRACE_SECONDS if following_up else None,
|
||||
)
|
||||
if pcm is None:
|
||||
self._follow_ups = 0 # silence ends the chain
|
||||
self._state.transition(PetState.IDLE)
|
||||
return
|
||||
|
||||
@@ -251,11 +289,63 @@ class PetController(QObject):
|
||||
on_error=lambda exc: self.log.emit(f"TTS failed: {exc}"),
|
||||
should_stop=should_stop,
|
||||
)
|
||||
# Read the scoring history *before* resetting, or the log reports the
|
||||
# blank counters instead of what actually fired.
|
||||
detail = self._barge_in_detail()
|
||||
if self._barge_in is not None:
|
||||
# Playback fed the pet's own voice into the wake model's rolling
|
||||
# window. Clear it before the idle listener starts scoring again,
|
||||
# or Bolt's last sentence is still in there being re-scored.
|
||||
self._barge_in.reset()
|
||||
if not completed:
|
||||
# You talked over it — take that as the start of the next turn
|
||||
# rather than making you say the wake word again.
|
||||
self.log.emit("Interrupted — listening.")
|
||||
self.log.emit(f"Interrupted — listening. {detail}")
|
||||
self._follow_ups = 0 # you're clearly engaged; start the count over
|
||||
self._talk_now.set()
|
||||
elif self._should_follow_up(text):
|
||||
self._follow_ups += 1
|
||||
cap = config.FOLLOW_UP_MAX_TURNS
|
||||
self.log.emit(
|
||||
f"Asked a question — listening for your answer "
|
||||
f"({self._follow_ups}{'/' + str(cap) if cap > 0 else ''})."
|
||||
)
|
||||
self._pending_follow_up = True
|
||||
self._talk_now.set()
|
||||
|
||||
def _should_follow_up(self, text: str) -> bool:
|
||||
"""Whether *text* leaves the pet waiting on an answer.
|
||||
|
||||
Muted is excluded because mute means "don't listen to me" — an
|
||||
automatic turn would walk straight past it. Napping isn't: quiet
|
||||
hours suppress the pet *starting* something, and a question is only
|
||||
ever asked in reply to you."""
|
||||
if not config.FOLLOW_UP_LISTEN or self._muted:
|
||||
return False
|
||||
if not speech_text.is_question(text):
|
||||
return False
|
||||
# Only worth mentioning the cap on a reply that would otherwise have
|
||||
# kept listening, or it fires on every statement the pet makes.
|
||||
if config.FOLLOW_UP_MAX_TURNS > 0 and self._follow_ups >= config.FOLLOW_UP_MAX_TURNS:
|
||||
self.log.emit("Follow-up limit reached — say the wake word to keep going.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _barge_in_detail(self) -> str:
|
||||
"""Why the interruption fired, for the log. How far into playback it
|
||||
happened is the tell: frame 1 means the detector was still holding
|
||||
audio from before this reply started, whereas a hit several seconds
|
||||
in is something the mic actually heard."""
|
||||
detector = self._barge_in
|
||||
if isinstance(detector, barge_in.WakeWordBargeIn):
|
||||
return (
|
||||
f"(wake score {detector.last_score:.3f} >= {detector.last_threshold:.2f}, "
|
||||
f"peak {detector.peak_score:.3f}, at frame {detector.frames_checked} / "
|
||||
f"{detector.seconds_checked:.1f}s into playback)"
|
||||
)
|
||||
if isinstance(detector, barge_in.BargeInDetector):
|
||||
return f"(loud frames {detector.loud_frames}, threshold {config.BARGE_IN_RMS_THRESHOLD})"
|
||||
return ""
|
||||
|
||||
# ── quiet hours / do-not-disturb ─────────────────────────────────────
|
||||
|
||||
@@ -323,10 +413,53 @@ class PetController(QObject):
|
||||
self._speak(reply)
|
||||
self._state.transition(PetState.IDLE)
|
||||
|
||||
# ── auto-update ──────────────────────────────────────────────────────
|
||||
|
||||
def _maybe_update(self) -> None:
|
||||
"""Poll the Gitea releases page and, if there's a newer tag, apply it
|
||||
and ask the UI to restart.
|
||||
|
||||
Only ever runs from the wake-listener's tick, so the pet is IDLE and
|
||||
between turns by construction — an update can't land mid-sentence.
|
||||
Failures are logged and the interval resets, so a server that's down
|
||||
(or a release that rolls back) costs one log line an hour, not a
|
||||
retry storm."""
|
||||
if not config.AUTO_UPDATE or self._update_pending:
|
||||
return
|
||||
now = time.monotonic()
|
||||
if now - self._last_update_check < config.UPDATE_CHECK_INTERVAL_SECONDS:
|
||||
return
|
||||
self._last_update_check = now
|
||||
try:
|
||||
release = updater.check_for_update()
|
||||
except updater.UpdateError as exc:
|
||||
self.log.emit(f"Update check failed: {exc}")
|
||||
return
|
||||
if release is None:
|
||||
return
|
||||
|
||||
self.log.emit(f"Update available: {release.tag} — applying.")
|
||||
try:
|
||||
previous = updater.apply_update(release.tag, on_log=self.log.emit)
|
||||
except updater.UpdateError as exc:
|
||||
self.log.emit(f"Update to {release.tag} failed: {exc}")
|
||||
return
|
||||
|
||||
self._update_pending = True
|
||||
self.log.emit(f"Updated {previous} -> {release.tag}; restarting.")
|
||||
if not self._napping:
|
||||
# Napping means no proactive noise, so a silent restart it is.
|
||||
self._speak(f"Updating to {release.tag}. Back in a second.")
|
||||
self._state.transition(PetState.IDLE)
|
||||
self.restart_requested.emit(release.tag)
|
||||
|
||||
# ── heartbeat ────────────────────────────────────────────────────────
|
||||
|
||||
def _maybe_heartbeat(self) -> None:
|
||||
self._refresh_nap_state()
|
||||
self._maybe_update()
|
||||
if self._update_pending:
|
||||
return # on the way out — don't start a conversation now
|
||||
now = time.monotonic()
|
||||
if now - self._last_heartbeat < config.HEARTBEAT_INTERVAL_SECONDS:
|
||||
return
|
||||
|
||||
@@ -20,7 +20,7 @@ from typing import Callable, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from . import config
|
||||
from . import config, sudo_askpass
|
||||
|
||||
_MAX_RELAY_HOPS = 16
|
||||
|
||||
@@ -42,12 +42,25 @@ def check_health(timeout: float = 10.0) -> dict:
|
||||
def run_local_command(command: str, timeout: int = None) -> str:
|
||||
"""Execute a command relayed by the server, exactly as bolt_desk.py does —
|
||||
"full desktop control" for things like "open firefox" or "how full is my
|
||||
disk". Runs as the current desktop user. See README security notes."""
|
||||
disk". Runs as the current desktop user. See README security notes.
|
||||
|
||||
`sudo` gets special handling: the pet has no terminal, so sudo would sit
|
||||
waiting on a tty that nobody is looking at. With SUDO_ASKPASS_PROMPT on,
|
||||
bare `sudo` becomes `sudo -A` and the password is collected in a desktop
|
||||
dialog you have to answer — which also gives those commands a longer
|
||||
timeout, since a human has to notice the window and type."""
|
||||
env = None
|
||||
if config.SUDO_ASKPASS_PROMPT and sudo_askpass.needs_password_prompt(command):
|
||||
helper = sudo_askpass.find_helper()
|
||||
if helper:
|
||||
env = sudo_askpass.environment(helper)
|
||||
command = sudo_askpass.add_askpass_flag(command)
|
||||
timeout = timeout or config.SUDO_COMMAND_TIMEOUT_SECONDS
|
||||
timeout = timeout or config.COMMAND_TIMEOUT_SECONDS
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command, shell=True, capture_output=True, text=True,
|
||||
timeout=timeout, cwd=str(Path.home()),
|
||||
timeout=timeout, cwd=str(Path.home()), env=env,
|
||||
)
|
||||
output = (completed.stdout or "") + (completed.stderr or "")
|
||||
return f"[exit {completed.returncode}]\n{output}"[:6000]
|
||||
|
||||
@@ -118,6 +118,22 @@ def for_speech(text: str) -> str:
|
||||
return text.strip()
|
||||
|
||||
|
||||
def is_question(text: str) -> bool:
|
||||
"""True if the reply *ends* by asking the user something — the cue for
|
||||
the pet to keep listening instead of making you say the wake word again.
|
||||
|
||||
Deliberately only looks at the end. A reply that asks something in
|
||||
passing ("What time is it? It's 7:15.") isn't waiting on an answer,
|
||||
whereas one that finishes on a question mark is. The test runs on the
|
||||
spoken form, so a '?' that only exists inside a stripped code block or a
|
||||
URL doesn't count, and trailing decoration (emoji, quotes, brackets) is
|
||||
peeled off first so "Ready to go? 🚀" still reads as a question."""
|
||||
spoken = for_speech(text)
|
||||
while spoken and not (spoken[-1].isalnum() or spoken[-1] == "?"):
|
||||
spoken = spoken[:-1]
|
||||
return spoken.endswith("?")
|
||||
|
||||
|
||||
def for_display(text: str) -> str:
|
||||
"""What the speech bubble shows: markdown syntax removed (the bubble
|
||||
can't render it) but emoji and layout-ish punctuation left alone."""
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Graphical password prompts for server-relayed `sudo` commands.
|
||||
|
||||
The pet has no terminal. When the server relays something like
|
||||
`sudo apt update`, sudo tries to read a password from a tty, finds none (or
|
||||
finds the terminal the pet was launched from, which you're not looking at),
|
||||
and the command fails with no way to answer it.
|
||||
|
||||
sudo's own answer to this is SUDO_ASKPASS: with `-A`, it runs a helper
|
||||
program and reads the password from the helper's stdout instead of a tty.
|
||||
Any GUI prompt that prints what was typed works, so this module finds a real
|
||||
askpass binary if one is installed and otherwise generates a one-line wrapper
|
||||
around zenity/kdialog, which every desktop has one of.
|
||||
|
||||
Worth being clear about what this changes: the prompt is a *feature*, not
|
||||
just plumbing. Server-relayed commands already run as your desktop user (see
|
||||
server_client.run_local_command); this lets them ask to run as root, and the
|
||||
dialog is the only thing standing between "Bolt decided to run sudo" and it
|
||||
happening. Leave SUDO_ASKPASS_PROMPT on, and read the dialogs.
|
||||
|
||||
The parts that decide *what* to run are pure functions so they're tested
|
||||
without a display, a password, or a working sudo.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from . import config
|
||||
|
||||
# Real askpass binaries, in preference order. These are purpose-built for
|
||||
# this (they grab the keyboard, hide the input, and don't leave the password
|
||||
# in a process argument), so they win over a generated wrapper.
|
||||
_KNOWN_HELPERS = (
|
||||
"/usr/bin/ssh-askpass",
|
||||
"/usr/lib/ssh/ssh-askpass",
|
||||
"/usr/lib/openssh/gnome-ssh-askpass3",
|
||||
"/usr/lib/openssh/gnome-ssh-askpass",
|
||||
"/usr/libexec/openssh/ssh-askpass",
|
||||
"/usr/bin/ksshaskpass",
|
||||
"/usr/bin/lxqt-openssh-askpass",
|
||||
)
|
||||
|
||||
# Dialog tools we can wrap when no askpass binary exists. Each must print the
|
||||
# typed password to stdout and nothing else.
|
||||
_WRAPPABLE = {
|
||||
"zenity": '{tool} --password --title="Bolt" --text="$1" 2>/dev/null',
|
||||
"kdialog": '{tool} --password "$1" --title "Bolt" 2>/dev/null',
|
||||
}
|
||||
|
||||
# `sudo` at the start of the command or right after a shell separator, not
|
||||
# already carrying a flag. Deliberately conservative: a `sudo` inside a
|
||||
# quoted string or a heredoc is left alone, because rewriting it could change
|
||||
# what the command means.
|
||||
_SUDO = re.compile(r"(^|[;&|]\s*|\n\s*)(sudo)(\s+)(?!-)")
|
||||
|
||||
|
||||
def add_askpass_flag(command: str) -> str:
|
||||
"""Insert `-A` after each bare `sudo`, so it prompts through the helper
|
||||
instead of a tty. Commands that already pass a flag (`sudo -n`, `sudo -A`,
|
||||
`sudo -u bob`) are left exactly as they are — the caller was explicit."""
|
||||
return _SUDO.sub(r"\1\2 -A\3", command or "")
|
||||
|
||||
|
||||
def needs_password_prompt(command: str) -> bool:
|
||||
"""True if *command* has a `sudo` that might sit waiting on a dialog.
|
||||
Used to give those commands a longer timeout — 30 seconds is fine for a
|
||||
shell command and nowhere near enough for a human to notice a window,
|
||||
read it, and type a password."""
|
||||
return bool(_SUDO.search(command or ""))
|
||||
|
||||
|
||||
def helper_script(tool_path: str) -> str:
|
||||
"""The wrapper script for a dialog *tool_path*. sudo passes its prompt
|
||||
("[sudo] password for maji:") as $1, which is worth showing — it names
|
||||
the user the password is for."""
|
||||
name = Path(tool_path).name
|
||||
body = _WRAPPABLE[name].format(tool=tool_path)
|
||||
return f"#!/bin/sh\n# Generated by bolt-pet. Prints the typed password on stdout for sudo -A.\n{body}\n"
|
||||
|
||||
|
||||
def _default_cache_dir() -> Path:
|
||||
base = os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")
|
||||
return Path(base) / "bolt-pet"
|
||||
|
||||
|
||||
def find_helper(
|
||||
configured: str = None,
|
||||
is_executable: Callable[[str], bool] = None,
|
||||
which: Callable[[str], Optional[str]] = None,
|
||||
cache_dir: Path = None,
|
||||
write: bool = True,
|
||||
) -> Optional[str]:
|
||||
"""Path to an askpass helper, or None if the desktop has nothing we can
|
||||
use. Order: whatever SUDO_ASKPASS_HELPER names, then a real askpass
|
||||
binary, then a generated wrapper around zenity/kdialog.
|
||||
|
||||
The lookups are injectable so the resolution order is testable on a box
|
||||
with a different set of these installed than yours."""
|
||||
configured = config.SUDO_ASKPASS_HELPER if configured is None else configured
|
||||
is_executable = is_executable or (lambda path: os.path.isfile(path) and os.access(path, os.X_OK))
|
||||
which = which or shutil.which
|
||||
|
||||
if configured:
|
||||
return configured if is_executable(configured) else None
|
||||
|
||||
for candidate in _KNOWN_HELPERS:
|
||||
if is_executable(candidate):
|
||||
return candidate
|
||||
|
||||
for tool in _WRAPPABLE:
|
||||
tool_path = which(tool)
|
||||
if not tool_path:
|
||||
continue
|
||||
if not write:
|
||||
return tool_path
|
||||
return _write_wrapper(tool_path, cache_dir or _default_cache_dir())
|
||||
return None
|
||||
|
||||
|
||||
def _write_wrapper(tool_path: str, cache_dir: Path) -> Optional[str]:
|
||||
"""Drop the wrapper script somewhere sudo can execute it. Mode 0700: it
|
||||
isn't secret, but it's a thing that pops up a password box, so nobody
|
||||
else on the machine gets to edit it."""
|
||||
try:
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
script = cache_dir / "askpass.sh"
|
||||
source = helper_script(tool_path)
|
||||
if not script.exists() or script.read_text(encoding="utf-8") != source:
|
||||
script.write_text(source, encoding="utf-8")
|
||||
script.chmod(stat.S_IRWXU)
|
||||
return str(script)
|
||||
except Exception:
|
||||
return None # no prompt is better than a crashed command relay
|
||||
|
||||
|
||||
def environment(helper: str, base: dict = None) -> dict:
|
||||
"""The subprocess environment with SUDO_ASKPASS pointed at *helper*."""
|
||||
env = dict(os.environ if base is None else base)
|
||||
env["SUDO_ASKPASS"] = helper
|
||||
return env
|
||||
+23
-2
@@ -13,7 +13,7 @@ import sys
|
||||
from PySide6.QtCore import QThread
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from .. import config
|
||||
from .. import config, updater
|
||||
from ..controller import PetController
|
||||
from ..hotkey import GlobalHotkey
|
||||
from ..state import PetState
|
||||
@@ -91,6 +91,19 @@ def run() -> int:
|
||||
elif hotkey.running:
|
||||
_log(f"Push-to-talk: {config.PUSH_TO_TALK_HOTKEY}")
|
||||
|
||||
# The updater has already moved the checkout by the time this fires; all
|
||||
# that's left is to let Qt tear down cleanly (so the mic and the tray
|
||||
# icon are released) and then exec the new code. Doing the exec after
|
||||
# app.exec() returns, rather than from the controller thread, is what
|
||||
# guarantees the audio device is free before the new process opens it.
|
||||
pending_restart = {"tag": None}
|
||||
|
||||
def _handle_restart(tag: str) -> None:
|
||||
pending_restart["tag"] = tag
|
||||
app.quit()
|
||||
|
||||
controller.restart_requested.connect(_handle_restart)
|
||||
|
||||
def _shutdown() -> None:
|
||||
hotkey.stop()
|
||||
controller.stop()
|
||||
@@ -100,4 +113,12 @@ def run() -> int:
|
||||
app.aboutToQuit.connect(_shutdown)
|
||||
|
||||
thread.start()
|
||||
return app.exec()
|
||||
status = app.exec()
|
||||
|
||||
if pending_restart["tag"]:
|
||||
_log(f"Restarting into {pending_restart['tag']}…")
|
||||
try:
|
||||
updater.restart() # never returns
|
||||
except Exception as exc:
|
||||
_log(f"Couldn't restart automatically ({exc}) — start the pet again by hand.")
|
||||
return status
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
"""Self-update from the Gitea releases page.
|
||||
|
||||
Polls `<UPDATE_REPO_API>/releases/latest` for a tag newer than
|
||||
``bolt_pet.__version__`` and, if there is one, moves the checkout to that tag
|
||||
and restarts the pet. The install is expected to be a git clone (which is how
|
||||
it's deployed), so "download the update" is just `git fetch` + `git checkout`
|
||||
— atomic, and the previous ref is one command away if anything goes wrong.
|
||||
|
||||
Safety rules, in the order they're enforced:
|
||||
|
||||
1. **A dirty working tree is never touched.** Local edits are skipped over,
|
||||
not stashed — the pet silently discarding your work-in-progress would be
|
||||
far worse than running an old version.
|
||||
2. **Everything after checkout is guarded.** Dependency install and an import
|
||||
smoke test both run before the restart; if either fails, the checkout is
|
||||
rolled back to the exact ref that was live before (branch name if we were
|
||||
on one, otherwise the commit) and the deps reinstalled from it.
|
||||
3. **The restart only happens once the new code imports.** So a broken
|
||||
release costs you a rollback and a log line, not a pet that won't start.
|
||||
|
||||
The git side goes through an injectable *run* callable — ``(args) ->
|
||||
(returncode, output)`` — so the whole apply/rollback dance is unit-tested
|
||||
against a fake git rather than a real repo. Version comparison and release
|
||||
parsing are pure functions for the same reason.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
from . import config
|
||||
|
||||
# (returncode, combined stdout+stderr)
|
||||
GitResult = Tuple[int, str]
|
||||
GitRunner = Callable[[list], GitResult]
|
||||
|
||||
_GIT_TIMEOUT_SECONDS = 300
|
||||
|
||||
|
||||
class UpdateError(Exception):
|
||||
"""Raised when an update can't be applied. If it's raised *after* the
|
||||
checkout moved, the rollback has already run."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Release:
|
||||
tag: str
|
||||
name: str
|
||||
body: str
|
||||
prerelease: bool
|
||||
|
||||
|
||||
# ── pure logic ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def parse_version(tag: str) -> tuple:
|
||||
"""``"v1.2.3"`` -> ``(1, 2, 3)``. Leading "v" optional; a trailing
|
||||
suffix ends the parse (``"1.2.3-beta1"`` -> ``(1, 2, 3)``), so a
|
||||
prerelease of a version compares equal to it rather than sorting
|
||||
randomly. Junk parses to ``()``, which is never newer than anything."""
|
||||
parts: list[int] = []
|
||||
for chunk in (tag or "").strip().lstrip("vV").split("."):
|
||||
digits = ""
|
||||
for char in chunk:
|
||||
if not char.isdigit():
|
||||
break
|
||||
digits += char
|
||||
if not digits:
|
||||
break
|
||||
parts.append(int(digits))
|
||||
return tuple(parts)
|
||||
|
||||
|
||||
def is_newer(candidate: str, current: str) -> bool:
|
||||
"""True if *candidate* is a strictly newer version than *current*.
|
||||
Compares zero-padded, so 1.2 == 1.2.0 and 1.2.1 > 1.2."""
|
||||
new, old = parse_version(candidate), parse_version(current)
|
||||
if not new:
|
||||
return False
|
||||
width = max(len(new), len(old))
|
||||
return new + (0,) * (width - len(new)) > old + (0,) * (width - len(old))
|
||||
|
||||
|
||||
def release_from_payload(payload: dict) -> Optional[Release]:
|
||||
"""Gitea's release JSON -> Release, or None if it's a draft or has no
|
||||
tag. /releases/latest already excludes drafts and prereleases, but the
|
||||
same parser is used for the full list."""
|
||||
if not isinstance(payload, dict) or payload.get("draft"):
|
||||
return None
|
||||
tag = str(payload.get("tag_name") or "").strip()
|
||||
if not tag:
|
||||
return None
|
||||
return Release(
|
||||
tag=tag,
|
||||
name=str(payload.get("name") or tag),
|
||||
body=str(payload.get("body") or ""),
|
||||
prerelease=bool(payload.get("prerelease")),
|
||||
)
|
||||
|
||||
|
||||
# ── talking to Gitea ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def fetch_latest_release(api_url: str = None, token: str = None, timeout: float = 15.0) -> Optional[Release]:
|
||||
"""Newest published release, or None if the repo has no releases yet
|
||||
(a fresh repo 404s here, which is not an error worth logging every hour).
|
||||
Raises UpdateError if the server is unreachable or answers with junk."""
|
||||
api_url = (api_url if api_url is not None else config.UPDATE_REPO_API).rstrip("/")
|
||||
if not api_url:
|
||||
raise UpdateError("UPDATE_REPO_API is not set")
|
||||
token = config.UPDATE_TOKEN if token is None else token
|
||||
headers = {"Authorization": f"token {token}"} if token else {}
|
||||
try:
|
||||
response = requests.get(f"{api_url}/releases/latest", headers=headers, timeout=timeout)
|
||||
except Exception as exc:
|
||||
raise UpdateError(f"couldn't reach the releases API: {exc}") from exc
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
try:
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
except Exception as exc:
|
||||
raise UpdateError(f"bad response from the releases API: {exc}") from exc
|
||||
return release_from_payload(payload)
|
||||
|
||||
|
||||
def check_for_update(current_version: str = None, **kwargs) -> Optional[Release]:
|
||||
"""The whole "is there anything new?" question in one call. Returns the
|
||||
Release to move to, or None if we're already current."""
|
||||
from . import __version__
|
||||
|
||||
current = __version__ if current_version is None else current_version
|
||||
release = fetch_latest_release(**kwargs)
|
||||
if release is None or not is_newer(release.tag, current):
|
||||
return None
|
||||
return release
|
||||
|
||||
|
||||
# ── git ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def git_runner(repo: Path = None) -> GitRunner:
|
||||
repo = Path(repo or config.HERE)
|
||||
|
||||
def run(args: list) -> GitResult:
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["git", *args], cwd=str(repo), capture_output=True,
|
||||
text=True, timeout=_GIT_TIMEOUT_SECONDS,
|
||||
)
|
||||
except Exception as exc:
|
||||
return 1, f"git {' '.join(args)} failed to start: {exc}"
|
||||
return completed.returncode, ((completed.stdout or "") + (completed.stderr or "")).strip()
|
||||
|
||||
return run
|
||||
|
||||
|
||||
def is_git_clone(run: GitRunner) -> bool:
|
||||
return run(["rev-parse", "--git-dir"])[0] == 0
|
||||
|
||||
|
||||
def working_tree_dirty(run: GitRunner) -> bool:
|
||||
code, output = run(["status", "--porcelain"])
|
||||
return code != 0 or bool(output.strip())
|
||||
|
||||
|
||||
def current_ref(run: GitRunner) -> str:
|
||||
"""The branch name if we're on one, else the commit SHA — i.e. whatever
|
||||
`git checkout` needs to put things back exactly as they were."""
|
||||
code, output = run(["symbolic-ref", "--quiet", "--short", "HEAD"])
|
||||
if code == 0 and output.strip():
|
||||
return output.strip()
|
||||
code, output = run(["rev-parse", "HEAD"])
|
||||
if code != 0 or not output.strip():
|
||||
raise UpdateError("couldn't work out the current git ref")
|
||||
return output.strip()
|
||||
|
||||
|
||||
def already_at_tag(run: GitRunner, tag: str) -> bool:
|
||||
"""True if HEAD is already the commit *tag* points at.
|
||||
|
||||
Guards the loop you get when a release is cut without bumping
|
||||
__version__ in the tagged commit: the checkout succeeds (it's a no-op),
|
||||
the pet restarts, reads the same old __version__, sees the same "newer"
|
||||
tag, and does it again — a restart every check, forever."""
|
||||
code, head = run(["rev-parse", "HEAD"])
|
||||
if code != 0 or not head.strip():
|
||||
return False
|
||||
code, target = run(["rev-parse", f"tags/{tag}^{{commit}}"])
|
||||
if code != 0 or not target.strip():
|
||||
return False # tag isn't known locally yet, so we're certainly not on it
|
||||
return head.strip() == target.strip()
|
||||
|
||||
|
||||
def _requirements_changed(run: GitRunner, before: str, after: str) -> bool:
|
||||
code, output = run(["diff", "--name-only", before, after, "--", "requirements.txt"])
|
||||
return code == 0 and bool(output.strip())
|
||||
|
||||
|
||||
def _install_deps(repo: Path) -> None:
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "-r", "requirements.txt"],
|
||||
cwd=str(repo), capture_output=True, text=True, timeout=_GIT_TIMEOUT_SECONDS,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise UpdateError(f"pip install failed: {(completed.stderr or '')[-500:]}")
|
||||
|
||||
|
||||
def _smoke_test(repo: Path) -> None:
|
||||
"""Import the freshly checked-out package in a *subprocess* — this one
|
||||
still has the old modules loaded, so importing here would prove nothing.
|
||||
Catches the common broken release (syntax error, missing dependency)
|
||||
before we hand the session over to it."""
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-c", "import bolt_pet; import bolt_pet.controller"],
|
||||
cwd=str(repo), capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise UpdateError(f"the new version failed to import: {(completed.stderr or '')[-500:]}")
|
||||
|
||||
|
||||
def apply_update(
|
||||
tag: str,
|
||||
run: GitRunner = None,
|
||||
repo: Path = None,
|
||||
on_log: Callable[[str], None] = lambda _msg: None,
|
||||
install_deps: bool = None,
|
||||
verify: Callable[[Path], None] = None,
|
||||
) -> str:
|
||||
"""Move the checkout to *tag*, rolling back to where it was if anything
|
||||
downstream of the checkout fails. Returns the ref we came from (handy for
|
||||
logging / a manual `git checkout` back). Raises UpdateError otherwise."""
|
||||
repo = Path(repo or config.HERE)
|
||||
run = run or git_runner(repo)
|
||||
install_deps = config.UPDATE_INSTALL_DEPS if install_deps is None else install_deps
|
||||
verify = _smoke_test if verify is None else verify
|
||||
|
||||
if not is_git_clone(run):
|
||||
raise UpdateError("not a git clone — auto-update only works on a git checkout")
|
||||
if working_tree_dirty(run):
|
||||
raise UpdateError("working tree has local changes — skipping (nothing was touched)")
|
||||
|
||||
previous = current_ref(run)
|
||||
code, output = run(["fetch", "--tags", "--prune", config.UPDATE_GIT_REMOTE])
|
||||
if code != 0:
|
||||
raise UpdateError(f"git fetch failed: {output}")
|
||||
|
||||
if already_at_tag(run, tag):
|
||||
from . import __version__
|
||||
|
||||
raise UpdateError(
|
||||
f"already checked out {tag}, but __version__ still reads {__version__} — "
|
||||
f"bump it in the tagged commit, or every check re-applies the same release"
|
||||
)
|
||||
|
||||
code, output = run(["checkout", "--force", f"tags/{tag}"])
|
||||
if code != 0:
|
||||
raise UpdateError(f"git checkout {tag} failed: {output}")
|
||||
on_log(f"Checked out {tag} (was {previous}).")
|
||||
|
||||
# Past this point every failure has to put the checkout back.
|
||||
try:
|
||||
if install_deps and _requirements_changed(run, previous, f"tags/{tag}"):
|
||||
on_log("requirements.txt changed — installing.")
|
||||
_install_deps(repo)
|
||||
verify(repo)
|
||||
except UpdateError as exc:
|
||||
_rollback(run, previous, repo, on_log, install_deps)
|
||||
raise UpdateError(f"{exc} — rolled back to {previous}") from exc
|
||||
except Exception as exc: # a verify() that blows up is still a failed update
|
||||
_rollback(run, previous, repo, on_log, install_deps)
|
||||
raise UpdateError(f"update failed ({exc}) — rolled back to {previous}") from exc
|
||||
|
||||
return previous
|
||||
|
||||
|
||||
def _rollback(
|
||||
run: GitRunner,
|
||||
previous: str,
|
||||
repo: Path,
|
||||
on_log: Callable[[str], None],
|
||||
install_deps: bool,
|
||||
) -> None:
|
||||
"""Best-effort return to *previous*. Never raises — it's already running
|
||||
inside a failure path, and the caller's UpdateError is the thing worth
|
||||
surfacing. A rollback that itself fails gets its own loud log line,
|
||||
because that's the one case needing a human."""
|
||||
code, output = run(["checkout", "--force", previous])
|
||||
if code != 0:
|
||||
on_log(f"ROLLBACK FAILED — the checkout is stranded. Run: git checkout {previous} ({output})")
|
||||
return
|
||||
on_log(f"Rolled back to {previous}.")
|
||||
if install_deps:
|
||||
try:
|
||||
_install_deps(repo)
|
||||
except Exception as exc:
|
||||
on_log(f"Rolled back, but reinstalling the old requirements failed: {exc}")
|
||||
|
||||
|
||||
# ── restart ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def restart() -> None:
|
||||
"""Replace this process with a fresh `python -m bolt_pet`.
|
||||
|
||||
execv rather than spawn-and-exit so there's no window with two pets
|
||||
holding the same mic, and no orphan if the parent dies first. Never
|
||||
returns when it works; callers should have shut the Qt app and released
|
||||
the audio device before calling it.
|
||||
|
||||
chdir first because `-m bolt_pet` resolves the package from the working
|
||||
directory: the pet may well have been launched from somewhere else
|
||||
(autostart entry, run.sh invoked by path), and the new process has to
|
||||
land on the checkout the update was just applied to."""
|
||||
os.chdir(str(config.HERE))
|
||||
os.execv(sys.executable, [sys.executable, "-m", "bolt_pet"])
|
||||
+155
-2
@@ -1,4 +1,5 @@
|
||||
"""Barge-in detection, driven by a fake mic stream (no audio hardware)."""
|
||||
"""Barge-in detection, driven by a fake mic stream and a fake wake model
|
||||
(no audio hardware, no ONNX runtime)."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -7,7 +8,7 @@ import numpy as np
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet.audio.barge_in import BargeInDetector
|
||||
from bolt_pet.audio.barge_in import BargeInDetector, WakeWordBargeIn, make_detector
|
||||
|
||||
|
||||
class FakeStream:
|
||||
@@ -63,3 +64,155 @@ def test_a_mic_error_mid_playback_is_not_fatal():
|
||||
|
||||
detector = BargeInDetector(BrokenStream(), threshold=1000, required_frames=1)
|
||||
assert detector.check() is False
|
||||
|
||||
|
||||
# ── wake-word mode ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class FakeModel:
|
||||
"""Scores frames from a canned list, mimicking openWakeWord's
|
||||
{class_name: score} return. Records reset() calls."""
|
||||
|
||||
def __init__(self, scores):
|
||||
self._scores = list(scores)
|
||||
self.resets = 0
|
||||
|
||||
def predict(self, frame):
|
||||
score = self._scores.pop(0) if self._scores else 0.0
|
||||
return {"thunderbolt": score}
|
||||
|
||||
def reset(self):
|
||||
self.resets += 1
|
||||
|
||||
|
||||
def _wake_detector(scores, threshold=0.5, amplitudes=None):
|
||||
stream = FakeStream(amplitudes if amplitudes is not None else [500] * len(scores))
|
||||
return WakeWordBargeIn(stream, model=FakeModel(scores), threshold=threshold), stream
|
||||
|
||||
|
||||
def test_loud_noise_alone_does_not_interrupt_in_wake_mode():
|
||||
"""The whole point of wake mode: a slammed door is deafening and scores
|
||||
nothing, so the pet keeps talking."""
|
||||
detector, _ = _wake_detector([0.01] * 6, amplitudes=[30000] * 6)
|
||||
assert not any(detector.check() for _ in range(6))
|
||||
|
||||
|
||||
def test_the_wake_word_interrupts():
|
||||
detector, _ = _wake_detector([0.1, 0.2, 0.9])
|
||||
assert [detector.check() for _ in range(3)] == [False, False, True]
|
||||
|
||||
|
||||
def test_a_single_frame_is_enough_when_it_clears_the_threshold():
|
||||
detector, _ = _wake_detector([0.55])
|
||||
assert detector.check() is True
|
||||
|
||||
|
||||
def test_scores_just_under_the_threshold_do_not_fire():
|
||||
detector, _ = _wake_detector([0.49, 0.499], threshold=0.5)
|
||||
assert not any(detector.check() for _ in range(2))
|
||||
|
||||
|
||||
def test_detecting_resets_the_model_so_the_tail_is_not_reused():
|
||||
model = FakeModel([0.9])
|
||||
detector = WakeWordBargeIn(FakeStream([500]), model=model, threshold=0.5)
|
||||
assert detector.check() is True
|
||||
assert model.resets == 1
|
||||
|
||||
|
||||
def test_reset_clears_the_models_audio_window_not_just_predictions():
|
||||
"""The regression that made the pet interrupt itself a word into every
|
||||
reply: openwakeword's reset() clears only the prediction buffer, so the
|
||||
"thunderbolt" that started the turn was still in the preprocessor's
|
||||
rolling window when playback began, and the first frame fed to the model
|
||||
re-fired on it."""
|
||||
|
||||
class FakePreprocessor:
|
||||
def __init__(self):
|
||||
self.raw_data_buffer = [1, 2, 3]
|
||||
self.feature_buffer = np.ones((120, 96))
|
||||
self.melspectrogram_buffer = np.zeros((76, 32))
|
||||
self.accumulated_samples = 4096
|
||||
|
||||
def _get_embeddings(self, audio):
|
||||
return np.zeros((120, 96))
|
||||
|
||||
model = FakeModel([0.9])
|
||||
model.preprocessor = FakePreprocessor()
|
||||
|
||||
WakeWordBargeIn(FakeStream([500]), model=model, threshold=0.5).reset()
|
||||
|
||||
assert model.preprocessor.raw_data_buffer == []
|
||||
assert model.preprocessor.accumulated_samples == 0
|
||||
assert not model.preprocessor.feature_buffer.any() # blank, not the old audio
|
||||
assert model.preprocessor.melspectrogram_buffer.all() # restored to ones
|
||||
|
||||
|
||||
def test_the_lazy_wrapper_exposes_its_preprocessor():
|
||||
"""The pet holds _default_model — a lazy *wrapper* around openwakeword's
|
||||
Model. If the wrapper stops proxying .preprocessor, hard_reset() finds
|
||||
nothing to clear and silently degrades to the shallow reset that leaves
|
||||
the last detection in the audio window. That failure is invisible: no
|
||||
exception, no log, the pet just interrupts itself again."""
|
||||
from bolt_pet.audio.wake_word import _OpenWakeWordModel
|
||||
|
||||
wrapper = _OpenWakeWordModel()
|
||||
assert hasattr(wrapper, "preprocessor")
|
||||
assert wrapper.preprocessor is None # not loaded yet: a no-op, not a load
|
||||
|
||||
class FakeInner:
|
||||
preprocessor = object()
|
||||
|
||||
def reset(self):
|
||||
pass
|
||||
|
||||
wrapper._model = FakeInner()
|
||||
assert wrapper.preprocessor is FakeInner.preprocessor
|
||||
|
||||
|
||||
def test_a_model_without_a_preprocessor_still_resets():
|
||||
"""Fakes in tests, and any future openwakeword whose internals moved."""
|
||||
model = FakeModel([0.0])
|
||||
WakeWordBargeIn(FakeStream([500]), model=model, threshold=0.5).reset()
|
||||
assert model.resets == 1
|
||||
|
||||
|
||||
def test_a_callable_threshold_is_read_every_frame():
|
||||
"""The tray tuner's slider has to apply mid-playback, not just mid-idle."""
|
||||
threshold = {"value": 0.9}
|
||||
detector = WakeWordBargeIn(
|
||||
FakeStream([500] * 2), model=FakeModel([0.6, 0.6]),
|
||||
threshold=lambda: threshold["value"],
|
||||
)
|
||||
assert detector.check() is False
|
||||
threshold["value"] = 0.5
|
||||
assert detector.check() is True
|
||||
|
||||
|
||||
def test_a_model_that_blows_up_mid_playback_is_not_fatal():
|
||||
class BrokenModel:
|
||||
def predict(self, frame):
|
||||
raise RuntimeError("onnx session died")
|
||||
|
||||
def reset(self):
|
||||
raise RuntimeError("still dead")
|
||||
|
||||
detector = WakeWordBargeIn(FakeStream([500]), model=BrokenModel(), threshold=0.5)
|
||||
assert detector.check() is False
|
||||
detector.reset() # must not raise either
|
||||
|
||||
|
||||
def test_a_mic_error_is_not_fatal_in_wake_mode():
|
||||
class BrokenStream:
|
||||
def read(self, frames):
|
||||
raise OSError("device disappeared")
|
||||
|
||||
detector = WakeWordBargeIn(BrokenStream(), model=FakeModel([0.9]), threshold=0.5)
|
||||
assert detector.check() is False
|
||||
|
||||
|
||||
def test_make_detector_picks_the_mode():
|
||||
stream = FakeStream([0])
|
||||
assert isinstance(make_detector(stream, mode="wake"), WakeWordBargeIn)
|
||||
assert isinstance(make_detector(stream, mode="energy"), BargeInDetector)
|
||||
# A typo in .env shouldn't stop the pet from starting.
|
||||
assert isinstance(make_detector(stream, mode="waek"), BargeInDetector)
|
||||
|
||||
@@ -81,6 +81,44 @@ def test_petctl_nap_also_flips_the_controller_state(ctrl):
|
||||
|
||||
# ── barge-in ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_the_interrupt_log_reports_what_fired_not_the_reset_counters(monkeypatch, ctrl):
|
||||
"""_speak resets the detector after playback so the pet's own voice
|
||||
doesn't linger in the wake model's window. Reading the stats after that
|
||||
reset reports 0.000 at frame 0 for every interruption, which is worse
|
||||
than no instrumentation — it looks like hard evidence and isn't."""
|
||||
from bolt_pet.audio import barge_in as barge_in_mod
|
||||
|
||||
class Detector(barge_in_mod.WakeWordBargeIn):
|
||||
def __init__(self):
|
||||
self._frames, self._peak, self._last, self._last_threshold = 0, 0.0, 0.0, 0.5
|
||||
self._frame_len = 1280
|
||||
self.reset_calls = 0
|
||||
|
||||
def reset(self):
|
||||
self.reset_calls += 1
|
||||
self._frames, self._peak, self._last = 0, 0.0, 0.0
|
||||
|
||||
detector = Detector()
|
||||
ctrl._barge_in = detector
|
||||
logs = _capture(ctrl.log)
|
||||
|
||||
def interrupted_playback(text, on_error=None, should_stop=None):
|
||||
# What really happens: frames get scored during playback, then one
|
||||
# clears the threshold and playback aborts.
|
||||
detector._frames, detector._peak, detector._last = 7, 0.81, 0.81
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(controller_mod.tts, "speak", interrupted_playback)
|
||||
|
||||
ctrl._speak("a very long explanation")
|
||||
|
||||
interrupted = next(m for m in logs if "Interrupted" in m)
|
||||
assert "0.810" in interrupted and "frame 7" in interrupted
|
||||
# Once before playback (clear the window) and once after (drop the pet's
|
||||
# own voice) — the point is that the *read* happens between them.
|
||||
assert detector.reset_calls == 2
|
||||
|
||||
|
||||
def test_interrupted_playback_queues_an_immediate_next_turn(monkeypatch, ctrl):
|
||||
logs = _capture(ctrl.log)
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
@@ -99,6 +137,110 @@ def test_uninterrupted_playback_does_not_queue_a_turn(monkeypatch, ctrl):
|
||||
assert not ctrl._talk_now.is_set()
|
||||
|
||||
|
||||
# ── follow-up listening ─────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def spoke(monkeypatch):
|
||||
"""Playback that always completes, so only the follow-up rule decides
|
||||
whether another turn is queued."""
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None: True)
|
||||
|
||||
|
||||
def test_a_reply_ending_in_a_question_keeps_listening(spoke, ctrl):
|
||||
logs = _capture(ctrl.log)
|
||||
|
||||
ctrl._speak("You're still in ~/Documents/bolt-pet. Ready to run a command?")
|
||||
|
||||
assert ctrl._talk_now.is_set() # no wake word needed for the answer
|
||||
assert ctrl._pending_follow_up # and the next turn knows it's an answer
|
||||
assert any("listening for your answer" in message for message in logs)
|
||||
|
||||
|
||||
def test_a_statement_does_not_keep_listening(spoke, ctrl):
|
||||
ctrl._speak("It's 7:15 AM on July 23, 2026.")
|
||||
assert not ctrl._talk_now.is_set()
|
||||
assert not ctrl._pending_follow_up
|
||||
|
||||
|
||||
def test_a_question_in_passing_does_not_count(spoke, ctrl):
|
||||
"""Only a reply that *ends* on a question is waiting for an answer."""
|
||||
ctrl._speak("What time is it? It's 7:15 AM.")
|
||||
assert not ctrl._talk_now.is_set()
|
||||
|
||||
|
||||
def test_follow_ups_stop_at_the_cap(spoke, monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "FOLLOW_UP_MAX_TURNS", 2)
|
||||
logs = _capture(ctrl.log)
|
||||
|
||||
for _ in range(2):
|
||||
ctrl._speak("Want me to keep going?")
|
||||
ctrl._talk_now.clear()
|
||||
assert ctrl._follow_ups == 2
|
||||
|
||||
ctrl._speak("Want me to keep going?")
|
||||
|
||||
assert not ctrl._talk_now.is_set() # chain broken until you re-trigger it
|
||||
assert any("Follow-up limit reached" in message for message in logs)
|
||||
|
||||
|
||||
def test_the_cap_is_not_announced_on_ordinary_replies(spoke, monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "FOLLOW_UP_MAX_TURNS", 1)
|
||||
ctrl._follow_ups = 1
|
||||
logs = _capture(ctrl.log)
|
||||
|
||||
ctrl._speak("Done — the file is saved.")
|
||||
|
||||
assert not any("Follow-up limit" in message for message in logs)
|
||||
|
||||
|
||||
def test_starting_a_turn_yourself_resets_the_chain(spoke, monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.mic, "record_utterance",
|
||||
lambda *a, **kw: None) # you said nothing
|
||||
ctrl._follow_ups = 3
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
assert ctrl._follow_ups == 0
|
||||
|
||||
|
||||
def test_an_answered_question_gets_a_longer_grace_period(spoke, monkeypatch, ctrl):
|
||||
"""You were just asked something — you get longer to think than when you
|
||||
deliberately said the wake word."""
|
||||
grace = []
|
||||
monkeypatch.setattr(controller_mod.mic, "record_utterance",
|
||||
lambda *a, **kw: grace.append(kw.get("grace_s")) or None)
|
||||
|
||||
ctrl._handle_conversation_turn() # you started this one
|
||||
ctrl._pending_follow_up = True
|
||||
ctrl._handle_conversation_turn() # this one answers a question
|
||||
|
||||
assert grace == [None, controller_mod.config.FOLLOW_UP_GRACE_SECONDS]
|
||||
|
||||
|
||||
def test_muting_stops_follow_ups(spoke, ctrl):
|
||||
ctrl._muted = True
|
||||
ctrl._speak("Shall I continue?")
|
||||
assert not ctrl._talk_now.is_set() # mute means don't listen, question or not
|
||||
|
||||
|
||||
def test_follow_up_can_be_turned_off(spoke, monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "FOLLOW_UP_LISTEN", False)
|
||||
ctrl._speak("Shall I continue?")
|
||||
assert not ctrl._talk_now.is_set()
|
||||
|
||||
|
||||
def test_being_interrupted_restarts_the_chain(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None: False)
|
||||
ctrl._follow_ups = 3
|
||||
|
||||
ctrl._speak("a very long explanation")
|
||||
|
||||
assert ctrl._follow_ups == 0 # you're clearly engaged
|
||||
assert ctrl._talk_now.is_set()
|
||||
|
||||
|
||||
def test_speech_is_recorded_in_the_history(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None: True)
|
||||
|
||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet.speech_text import for_display, for_speech
|
||||
from bolt_pet.speech_text import for_display, for_speech, is_question
|
||||
|
||||
|
||||
def test_bold_markers_are_not_spoken():
|
||||
@@ -57,3 +57,23 @@ def test_blank_and_symbol_only_input():
|
||||
def test_display_keeps_emoji_but_drops_markdown():
|
||||
assert for_display("**Done** ✅") == "Done ✅"
|
||||
assert for_display("* one\n* two") == "• one • two"
|
||||
|
||||
|
||||
def test_is_question_only_fires_on_a_trailing_question():
|
||||
assert is_question("Ready to run a command or start a project?")
|
||||
assert is_question("It's 7:15 AM. Want me to set a timer?")
|
||||
assert not is_question("It's 7:15 AM on July 23, 2026.")
|
||||
assert not is_question("What time is it? It's 7:15 AM.") # asked in passing
|
||||
|
||||
|
||||
def test_is_question_ignores_trailing_decoration():
|
||||
assert is_question("Ready to go? 🚀")
|
||||
assert is_question('Shall I continue?"')
|
||||
assert is_question("Want me to fix it? **")
|
||||
|
||||
|
||||
def test_is_question_ignores_question_marks_that_are_not_spoken():
|
||||
# The '?' here is inside a URL query string, which for_speech strips.
|
||||
assert not is_question("Docs are at https://example.com/x?y=1")
|
||||
assert not is_question("")
|
||||
assert not is_question(None)
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Graphical sudo prompts: command rewriting and helper resolution.
|
||||
Pure logic — no display, no sudo, no password."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet import sudo_askpass
|
||||
|
||||
|
||||
# ── rewriting ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command,expected",
|
||||
[
|
||||
("sudo apt update", "sudo -A apt update"),
|
||||
("sudo apt update", "sudo -A apt update"), # spacing preserved
|
||||
("apt update && sudo apt upgrade", "apt update && sudo -A apt upgrade"),
|
||||
("ls; sudo reboot", "ls; sudo -A reboot"),
|
||||
("echo hi | sudo tee /etc/motd", "echo hi | sudo -A tee /etc/motd"),
|
||||
("sudo systemctl restart x\nsudo systemctl status x",
|
||||
"sudo -A systemctl restart x\nsudo -A systemctl status x"),
|
||||
],
|
||||
)
|
||||
def test_bare_sudo_gets_the_askpass_flag(command, expected):
|
||||
assert sudo_askpass.add_askpass_flag(command) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command",
|
||||
[
|
||||
"sudo -n apt update", # explicitly non-interactive
|
||||
"sudo -A apt update", # already asking
|
||||
"sudo -u bob whoami", # the caller was explicit
|
||||
"ls -la", # no sudo at all
|
||||
"echo 'run sudo later'", # inside a quoted string
|
||||
"pseudo --version", # not the word sudo
|
||||
],
|
||||
)
|
||||
def test_commands_that_must_not_be_rewritten(command):
|
||||
assert sudo_askpass.add_askpass_flag(command) == command
|
||||
|
||||
|
||||
def test_blank_input():
|
||||
assert sudo_askpass.add_askpass_flag("") == ""
|
||||
assert sudo_askpass.add_askpass_flag(None) == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command,expected",
|
||||
[
|
||||
("sudo apt update", True),
|
||||
("ls && sudo reboot", True),
|
||||
("ls -la", False),
|
||||
("echo 'sudo'", False),
|
||||
("", False),
|
||||
],
|
||||
)
|
||||
def test_which_commands_get_the_longer_timeout(command, expected):
|
||||
assert sudo_askpass.needs_password_prompt(command) is expected
|
||||
|
||||
|
||||
# ── helper resolution ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_a_configured_helper_wins():
|
||||
found = sudo_askpass.find_helper(
|
||||
configured="/opt/my-askpass", is_executable=lambda p: p == "/opt/my-askpass",
|
||||
)
|
||||
assert found == "/opt/my-askpass"
|
||||
|
||||
|
||||
def test_a_configured_helper_that_is_not_executable_is_not_silently_replaced():
|
||||
"""Better to have sudo fail than to quietly prompt with something the
|
||||
user didn't choose."""
|
||||
assert sudo_askpass.find_helper(
|
||||
configured="/opt/typo", is_executable=lambda p: False, which=lambda t: "/usr/bin/zenity",
|
||||
) is None
|
||||
|
||||
|
||||
def test_a_real_askpass_binary_beats_a_generated_wrapper():
|
||||
found = sudo_askpass.find_helper(
|
||||
configured="", is_executable=lambda p: p == "/usr/bin/ksshaskpass",
|
||||
which=lambda tool: "/usr/bin/zenity",
|
||||
)
|
||||
assert found == "/usr/bin/ksshaskpass"
|
||||
|
||||
|
||||
def test_falls_back_to_wrapping_a_dialog_tool(tmp_path):
|
||||
found = sudo_askpass.find_helper(
|
||||
configured="", is_executable=lambda p: False,
|
||||
which=lambda tool: "/usr/bin/zenity" if tool == "zenity" else None,
|
||||
cache_dir=tmp_path,
|
||||
)
|
||||
script = tmp_path / "askpass.sh"
|
||||
assert found == str(script)
|
||||
assert "zenity --password" in script.read_text()
|
||||
assert script.stat().st_mode & 0o777 == 0o700 # nobody else edits the password box
|
||||
|
||||
|
||||
def test_no_helper_available_at_all():
|
||||
assert sudo_askpass.find_helper(
|
||||
configured="", is_executable=lambda p: False, which=lambda tool: None,
|
||||
) is None
|
||||
|
||||
|
||||
def test_the_wrapper_passes_sudos_prompt_through():
|
||||
"""sudo hands the helper its prompt as $1 — it names the account the
|
||||
password is for, which is worth showing in the dialog."""
|
||||
script = sudo_askpass.helper_script("/usr/bin/zenity")
|
||||
assert script.startswith("#!/bin/sh")
|
||||
assert '"$1"' in script
|
||||
|
||||
|
||||
def test_environment_points_sudo_at_the_helper():
|
||||
env = sudo_askpass.environment("/tmp/askpass.sh", base={"PATH": "/usr/bin"})
|
||||
assert env["SUDO_ASKPASS"] == "/tmp/askpass.sh"
|
||||
assert env["PATH"] == "/usr/bin" # the rest of the environment survives
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Auto-updater: version comparison, release parsing, and the apply/rollback
|
||||
dance driven by a fake git (no network, no real repo, nothing checked out)."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet import updater
|
||||
|
||||
|
||||
# ── version comparison ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tag,expected",
|
||||
[
|
||||
("v1.2.3", (1, 2, 3)),
|
||||
("1.2.3", (1, 2, 3)),
|
||||
("V0.1.0", (0, 1, 0)),
|
||||
("1.2", (1, 2)),
|
||||
("1.2.3-beta1", (1, 2, 3)), # suffix ends the parse
|
||||
("", ()),
|
||||
("nightly", ()),
|
||||
],
|
||||
)
|
||||
def test_parse_version(tag, expected):
|
||||
assert updater.parse_version(tag) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"candidate,current",
|
||||
[("0.2.0", "0.1.0"), ("1.0.0", "0.9.9"), ("0.1.1", "0.1"), ("v2.0", "1.9.9")],
|
||||
)
|
||||
def test_is_newer_accepts_newer_versions(candidate, current):
|
||||
assert updater.is_newer(candidate, current)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"candidate,current",
|
||||
[
|
||||
("0.1.0", "0.1.0"),
|
||||
("0.1.0", "0.2.0"),
|
||||
("0.1", "0.1.0"), # zero-padded: equal, not newer
|
||||
("", "0.1.0"),
|
||||
("nightly", "0.1.0"), # unparseable is never newer
|
||||
],
|
||||
)
|
||||
def test_is_newer_rejects_same_or_older(candidate, current):
|
||||
assert not updater.is_newer(candidate, current)
|
||||
|
||||
|
||||
def test_release_parsing_skips_drafts_and_untagged():
|
||||
assert updater.release_from_payload({"tag_name": "v1.0.0", "draft": True}) is None
|
||||
assert updater.release_from_payload({"name": "no tag"}) is None
|
||||
release = updater.release_from_payload({"tag_name": "v1.0.0", "name": "One", "prerelease": True})
|
||||
assert (release.tag, release.name, release.prerelease) == ("v1.0.0", "One", True)
|
||||
|
||||
|
||||
# ── fake git ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class FakeGit:
|
||||
"""Records every git invocation and answers from a canned script.
|
||||
*failures* maps a leading-args tuple to the (code, output) it should
|
||||
return, so a test can make exactly one command fail."""
|
||||
|
||||
def __init__(self, ref="main", dirty=False, failures=None, requirements_changed=False,
|
||||
head="abc1234", tag_commit="def5678"):
|
||||
self.calls = []
|
||||
self._ref = ref
|
||||
self._dirty = dirty
|
||||
self._failures = failures or {}
|
||||
self._requirements_changed = requirements_changed
|
||||
self._head = head
|
||||
self._tag_commit = tag_commit # equal to head = "already on this tag"
|
||||
|
||||
def __call__(self, args):
|
||||
self.calls.append(list(args))
|
||||
for prefix, result in self._failures.items():
|
||||
if tuple(args[: len(prefix)]) == prefix:
|
||||
return result
|
||||
head = args[0]
|
||||
if head == "rev-parse" and args[1] == "--git-dir":
|
||||
return 0, ".git"
|
||||
if head == "status":
|
||||
return 0, " M bolt_pet/config.py" if self._dirty else ""
|
||||
if head == "symbolic-ref":
|
||||
return (0, self._ref) if self._ref else (1, "")
|
||||
if head == "rev-parse":
|
||||
return 0, self._tag_commit if args[1].startswith("tags/") else self._head
|
||||
if head == "diff":
|
||||
return 0, "requirements.txt" if self._requirements_changed else ""
|
||||
return 0, ""
|
||||
|
||||
def commands(self):
|
||||
"""Just the verbs, for asserting on the sequence."""
|
||||
return [call[0] for call in self.calls]
|
||||
|
||||
|
||||
def test_apply_update_checks_out_the_tag(tmp_path):
|
||||
git = FakeGit()
|
||||
previous = updater.apply_update(
|
||||
"v1.0.0", run=git, repo=tmp_path, install_deps=False, verify=lambda repo: None
|
||||
)
|
||||
assert previous == "main"
|
||||
assert ["fetch", "--tags", "--prune", "origin"] in git.calls
|
||||
assert ["checkout", "--force", "tags/v1.0.0"] in git.calls
|
||||
|
||||
|
||||
def test_a_dirty_working_tree_is_left_completely_alone(tmp_path):
|
||||
git = FakeGit(dirty=True)
|
||||
with pytest.raises(updater.UpdateError, match="local changes"):
|
||||
updater.apply_update("v1.0.0", run=git, repo=tmp_path, install_deps=False)
|
||||
assert "fetch" not in git.commands()
|
||||
assert "checkout" not in git.commands()
|
||||
|
||||
|
||||
def test_a_non_git_install_refuses_before_touching_anything(tmp_path):
|
||||
git = FakeGit(failures={("rev-parse", "--git-dir"): (128, "not a repository")})
|
||||
with pytest.raises(updater.UpdateError, match="not a git clone"):
|
||||
updater.apply_update("v1.0.0", run=git, repo=tmp_path, install_deps=False)
|
||||
assert "checkout" not in git.commands()
|
||||
|
||||
|
||||
def test_a_failed_smoke_test_rolls_back_to_the_previous_ref(tmp_path):
|
||||
git = FakeGit(ref="main")
|
||||
|
||||
def broken(repo):
|
||||
raise updater.UpdateError("the new version failed to import: boom")
|
||||
|
||||
with pytest.raises(updater.UpdateError, match="rolled back to main"):
|
||||
updater.apply_update("v1.0.0", run=git, repo=tmp_path, install_deps=False, verify=broken)
|
||||
|
||||
checkouts = [call for call in git.calls if call[0] == "checkout"]
|
||||
assert checkouts == [["checkout", "--force", "tags/v1.0.0"], ["checkout", "--force", "main"]]
|
||||
|
||||
|
||||
def test_rollback_targets_the_commit_when_head_is_detached(tmp_path):
|
||||
# No branch to go back to (symbolic-ref fails) — the SHA is the ref.
|
||||
git = FakeGit(ref="")
|
||||
|
||||
with pytest.raises(updater.UpdateError):
|
||||
updater.apply_update(
|
||||
"v1.0.0", run=git, repo=tmp_path, install_deps=False,
|
||||
verify=lambda repo: (_ for _ in ()).throw(RuntimeError("nope")),
|
||||
)
|
||||
assert ["checkout", "--force", "abc1234"] in git.calls
|
||||
|
||||
|
||||
def test_a_verify_that_raises_something_unexpected_still_rolls_back(tmp_path):
|
||||
git = FakeGit()
|
||||
|
||||
def exploding(repo):
|
||||
raise ValueError("not even an UpdateError")
|
||||
|
||||
with pytest.raises(updater.UpdateError, match="rolled back"):
|
||||
updater.apply_update(
|
||||
"v1.0.0", run=git, repo=tmp_path, install_deps=False, verify=exploding
|
||||
)
|
||||
assert ["checkout", "--force", "main"] in git.calls
|
||||
|
||||
|
||||
def test_a_release_tagged_without_bumping_the_version_does_not_loop(tmp_path):
|
||||
"""Cut a release but forget to bump __version__ in the tagged commit and
|
||||
every check would see the same "newer" tag: check out (a no-op), restart,
|
||||
read the old version, repeat — a restart loop every check interval."""
|
||||
git = FakeGit(head="same1234", tag_commit="same1234")
|
||||
|
||||
with pytest.raises(updater.UpdateError, match="bump it in the tagged commit"):
|
||||
updater.apply_update("v0.2.1", run=git, repo=tmp_path, install_deps=False,
|
||||
verify=lambda repo: None)
|
||||
|
||||
assert "checkout" not in git.commands() # nothing moved, so nothing to restart into
|
||||
|
||||
|
||||
def test_an_unknown_tag_is_not_mistaken_for_being_already_on_it(tmp_path):
|
||||
git = FakeGit(failures={("rev-parse", "tags/"): (128, "unknown revision")})
|
||||
# The failure key matches by prefix, so make it explicit that a tag we
|
||||
# can't resolve means "not there yet" rather than "already applied".
|
||||
assert updater.already_at_tag(git, "v9.9.9") is False
|
||||
|
||||
|
||||
def test_a_failed_fetch_never_moves_the_checkout(tmp_path):
|
||||
git = FakeGit(failures={("fetch",): (1, "could not resolve host")})
|
||||
with pytest.raises(updater.UpdateError, match="git fetch failed"):
|
||||
updater.apply_update("v1.0.0", run=git, repo=tmp_path, install_deps=False)
|
||||
assert "checkout" not in git.commands()
|
||||
|
||||
|
||||
def test_a_stranded_checkout_is_logged_loudly(tmp_path):
|
||||
"""Rollback itself failing is the one case a human has to fix by hand."""
|
||||
git = FakeGit(failures={("checkout", "--force", "main"): (1, "index locked")})
|
||||
logs = []
|
||||
|
||||
with pytest.raises(updater.UpdateError):
|
||||
updater.apply_update(
|
||||
"v1.0.0", run=git, repo=tmp_path, install_deps=False, on_log=logs.append,
|
||||
verify=lambda repo: (_ for _ in ()).throw(updater.UpdateError("bad build")),
|
||||
)
|
||||
assert any("ROLLBACK FAILED" in line and "git checkout main" in line for line in logs)
|
||||
|
||||
|
||||
def test_deps_are_only_reinstalled_when_requirements_actually_changed(tmp_path, monkeypatch):
|
||||
installs = []
|
||||
monkeypatch.setattr(updater, "_install_deps", lambda repo: installs.append(repo))
|
||||
|
||||
updater.apply_update(
|
||||
"v1.0.0", run=FakeGit(requirements_changed=False), repo=tmp_path,
|
||||
install_deps=True, verify=lambda repo: None,
|
||||
)
|
||||
assert installs == []
|
||||
|
||||
updater.apply_update(
|
||||
"v1.0.0", run=FakeGit(requirements_changed=True), repo=tmp_path,
|
||||
install_deps=True, verify=lambda repo: None,
|
||||
)
|
||||
assert installs == [tmp_path]
|
||||
|
||||
|
||||
# ── release checking ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_check_for_update_returns_nothing_when_current(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
updater, "fetch_latest_release",
|
||||
lambda **kwargs: updater.Release("v0.1.0", "0.1.0", "", False),
|
||||
)
|
||||
assert updater.check_for_update(current_version="0.1.0") is None
|
||||
assert updater.check_for_update(current_version="0.0.9").tag == "v0.1.0"
|
||||
|
||||
|
||||
def test_no_releases_yet_is_not_an_error(monkeypatch):
|
||||
monkeypatch.setattr(updater, "fetch_latest_release", lambda **kwargs: None)
|
||||
assert updater.check_for_update(current_version="0.1.0") is None
|
||||
Reference in New Issue
Block a user