From 843f52c50799da7db7386259e4aeba53016c72c9 Mon Sep 17 00:00:00 2001 From: themajesticmagician Date: Thu, 23 Jul 2026 07:30:08 -0600 Subject: [PATCH] Wake-word barge-in, Gitea auto-updater, hard_reset fix --- .claude/settings.local.json | 5 +- .env.example | 34 +++- .gitignore | 2 + CLAUDE.md | 50 +++++- bolt_pet/__init__.py | 7 + bolt_pet/audio/barge_in.py | 170 +++++++++++++++++--- bolt_pet/audio/wake_word.py | 80 +++++++++- bolt_pet/config.py | 42 ++++- bolt_pet/controller.py | 89 ++++++++++- bolt_pet/ui/app.py | 25 ++- bolt_pet/updater.py | 299 ++++++++++++++++++++++++++++++++++++ tests/test_barge_in.py | 157 ++++++++++++++++++- tests/test_updater.py | 214 ++++++++++++++++++++++++++ 13 files changed, 1127 insertions(+), 47 deletions(-) create mode 100644 bolt_pet/updater.py create mode 100644 tests/test_updater.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d5c2937..b6c36e9 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -26,7 +26,10 @@ "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 '^$')" ] } } diff --git a/.env.example b/.env.example index 6ec5050..ea57cc7 100644 --- a/.env.example +++ b/.env.example @@ -63,12 +63,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 diff --git a/.gitignore b/.gitignore index f6664e3..10f47fd 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ __pycache__/ *.pyc .env .pytest_cache/ +.claude +CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md index 4fd785c..830423b 100644 --- a/CLAUDE.md +++ b/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,25 @@ 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. +- **`updater.py`** — self-update from the Gitea releases API. Polls + `/releases/latest` for a tag newer than + `bolt_pet.__version__` and moves the checkout to it with + `git fetch --tags` + `git checkout tags/`, 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 @@ -155,6 +179,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. diff --git a/bolt_pet/__init__.py b/bolt_pet/__init__.py index e69de29..9948179 100644 --- a/bolt_pet/__init__.py +++ b/bolt_pet/__init__.py @@ -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" diff --git a/bolt_pet/audio/barge_in.py b/bolt_pet/audio/barge_in.py index bcb01a7..e487dca 100644 --- a/bolt_pet/audio/barge_in.py +++ b/bolt_pet/audio/barge_in.py @@ -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) diff --git a/bolt_pet/audio/wake_word.py b/bolt_pet/audio/wake_word.py index 7fb82d0..6e333a0 100644 --- a/bolt_pet/audio/wake_word.py +++ b/bolt_pet/audio/wake_word.py @@ -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 diff --git a/bolt_pet/config.py b/bolt_pet/config.py index 2090e94..fd9ae1a 100644 --- a/bolt_pet/config.py +++ b/bolt_pet/config.py @@ -82,15 +82,26 @@ MIN_UTTERANCE_S = float(os.environ.get("VAD_MIN_UTTERANCE_SECONDS", "0.4")) COMMAND_TIMEOUT_SECONDS = int(os.environ.get("COMMAND_TIMEOUT_SECONDS", "30")) 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 +151,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 diff --git a/bolt_pet/controller.py b/bolt_pet/controller.py index 75dc008..d7b4748 100644 --- a/bolt_pet/controller.py +++ b/bolt_pet/controller.py @@ -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,9 @@ class PetController(QObject): self._nap_forced: Optional[bool] = None # petctl nap on/off overrides the schedule self._last_nap_check = 0.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 +131,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: @@ -251,12 +270,33 @@ class PetController(QObject): on_error=lambda exc: self.log.emit(f"TTS failed: {exc}"), should_stop=should_stop, ) + 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. {self._barge_in_detail()}") self._talk_now.set() + 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 ───────────────────────────────────── def _apply_nap_state(self, napping: bool) -> None: @@ -323,10 +363,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 diff --git a/bolt_pet/ui/app.py b/bolt_pet/ui/app.py index 51f53c5..3bf96e3 100644 --- a/bolt_pet/ui/app.py +++ b/bolt_pet/ui/app.py @@ -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 diff --git a/bolt_pet/updater.py b/bolt_pet/updater.py new file mode 100644 index 0000000..da77bbc --- /dev/null +++ b/bolt_pet/updater.py @@ -0,0 +1,299 @@ +"""Self-update from the Gitea releases page. + +Polls `/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 _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}") + + 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"]) diff --git a/tests/test_barge_in.py b/tests/test_barge_in.py index 4e76962..9ad2ffe 100644 --- a/tests/test_barge_in.py +++ b/tests/test_barge_in.py @@ -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) diff --git a/tests/test_updater.py b/tests/test_updater.py new file mode 100644 index 0000000..b7ac47a --- /dev/null +++ b/tests/test_updater.py @@ -0,0 +1,214 @@ +"""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): + self.calls = [] + self._ref = ref + self._dirty = dirty + self._failures = failures or {} + self._requirements_changed = requirements_changed + + 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, "abc1234" + 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_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