Upload
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
"""Pet state machine — pure logic, no Qt/audio dependencies, so it's cheap
|
||||
to unit test. The UI layer (ui/pet_window.py) reacts to state changes by
|
||||
swapping the active sprite animation; the worker thread (ui/app.py) drives
|
||||
transitions as the mic/wake/converse/tts pipeline progresses.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Callable, Optional
|
||||
|
||||
|
||||
class PetState(str, Enum):
|
||||
IDLE = "idle" # waiting for the wake phrase (or a click)
|
||||
LISTENING = "listening" # actively recording an utterance
|
||||
THINKING = "thinking" # waiting on the server (STT done, converse in flight)
|
||||
TALKING = "talking" # playing back the TTS reply
|
||||
ERROR = "error" # brief flash state on failure, then back to idle
|
||||
|
||||
|
||||
# States it's valid to move to from each state. Keeps ad-hoc bugs (e.g.
|
||||
# firing TALKING before a reply exists) from silently passing through.
|
||||
#
|
||||
# IDLE -> TALKING is legal (not just IDLE -> LISTENING) because of proactive
|
||||
# announcements: the heartbeat poll (controller.py's _maybe_heartbeat) can
|
||||
# make the pet speak unprompted — a reminder firing, a nudge from the server
|
||||
# — without the user having said anything first, so there's no preceding
|
||||
# LISTENING/THINKING leg for that turn.
|
||||
_TRANSITIONS: dict[PetState, set[PetState]] = {
|
||||
PetState.IDLE: {PetState.LISTENING, PetState.TALKING, PetState.ERROR},
|
||||
PetState.LISTENING: {PetState.THINKING, PetState.IDLE, PetState.ERROR},
|
||||
PetState.THINKING: {PetState.TALKING, PetState.IDLE, PetState.ERROR},
|
||||
PetState.TALKING: {PetState.IDLE, PetState.ERROR},
|
||||
PetState.ERROR: {PetState.IDLE},
|
||||
}
|
||||
|
||||
|
||||
class InvalidTransition(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class PetStateMachine:
|
||||
def __init__(self, on_change: Optional[Callable[[PetState, PetState], None]] = None):
|
||||
self._state = PetState.IDLE
|
||||
self._on_change = on_change
|
||||
|
||||
@property
|
||||
def state(self) -> PetState:
|
||||
return self._state
|
||||
|
||||
def transition(self, new_state: PetState) -> None:
|
||||
if new_state == self._state:
|
||||
return
|
||||
allowed = _TRANSITIONS.get(self._state, set())
|
||||
if new_state not in allowed:
|
||||
raise InvalidTransition(f"{self._state} -> {new_state} is not allowed")
|
||||
old_state = self._state
|
||||
self._state = new_state
|
||||
if self._on_change is not None:
|
||||
self._on_change(old_state, new_state)
|
||||
|
||||
def force(self, new_state: PetState) -> None:
|
||||
"""Bypass the transition table — used only for recovering to IDLE
|
||||
from an unexpected/edge-case state (e.g. after an exception mid
|
||||
pipeline). Prefer transition() everywhere else."""
|
||||
old_state = self._state
|
||||
if new_state == old_state:
|
||||
return
|
||||
self._state = new_state
|
||||
if self._on_change is not None:
|
||||
self._on_change(old_state, new_state)
|
||||
Reference in New Issue
Block a user