diff --git a/client/state.py b/client/state.py new file mode 100644 index 0000000..ff0465e --- /dev/null +++ b/client/state.py @@ -0,0 +1,70 @@ +"""Assistant client state machine. + +States flow: IDLE -> WAKE_PENDING -> LISTENING <-> ASSISTANT_SPEAKING + \\ / + session_ended + \\ + IDLE_PENDING -> IDLE +""" +from enum import Enum, auto + +from client.log import get_logger + +_log = get_logger("state") + + +class State(Enum): + IDLE = auto() + WAKE_PENDING = auto() + LISTENING = auto() + ASSISTANT_SPEAKING = auto() + IDLE_PENDING = auto() + + +class StateMachine: + def __init__(self) -> None: + self._state = State.IDLE + + @property + def state(self) -> State: + return self._state + + @property + def wakeword_enabled(self) -> bool: + return self._state is State.IDLE + + @property + def uplink_enabled(self) -> bool: + return self._state is State.LISTENING + + def _transition(self, target: State) -> None: + if target is self._state: + return + _log.info("state %s -> %s", self._state.name, target.name) + self._state = target + + def wake_fired(self) -> bool: + if self._state is not State.IDLE: + return False + self._transition(State.WAKE_PENDING) + return True + + def session_started(self) -> None: + if self._state in (State.WAKE_PENDING, State.IDLE): + self._transition(State.LISTENING) + + def assistant_audio_arrived(self) -> None: + if self._state is State.LISTENING: + self._transition(State.ASSISTANT_SPEAKING) + + def assistant_done(self) -> None: + if self._state is State.ASSISTANT_SPEAKING: + self._transition(State.LISTENING) + + def session_ended(self) -> None: + if self._state in (State.LISTENING, State.ASSISTANT_SPEAKING, State.WAKE_PENDING): + self._transition(State.IDLE_PENDING) + + def sleep_played(self) -> None: + if self._state is State.IDLE_PENDING: + self._transition(State.IDLE) diff --git a/client/tests/test_state.py b/client/tests/test_state.py new file mode 100644 index 0000000..8a5e88b --- /dev/null +++ b/client/tests/test_state.py @@ -0,0 +1,102 @@ +from client.state import State, StateMachine + + +def test_starts_idle_with_wakeword_enabled_and_uplink_disabled(): + sm = StateMachine() + assert sm.state is State.IDLE + assert sm.wakeword_enabled + assert not sm.uplink_enabled + + +def test_wake_fired_goes_idle_to_wake_pending_and_disables_wakeword(): + sm = StateMachine() + assert sm.wake_fired() is True + assert sm.state is State.WAKE_PENDING + assert not sm.wakeword_enabled + assert not sm.uplink_enabled + + +def test_wake_fired_is_idempotent_when_not_idle(): + sm = StateMachine() + sm.wake_fired() + assert sm.wake_fired() is False + assert sm.state is State.WAKE_PENDING + + +def test_session_started_goes_wake_pending_to_listening_and_unmutes_uplink(): + sm = StateMachine() + sm.wake_fired() + sm.session_started() + assert sm.state is State.LISTENING + assert sm.uplink_enabled + assert not sm.wakeword_enabled + + +def test_assistant_audio_arrived_mutes_uplink(): + sm = StateMachine() + sm.wake_fired() + sm.session_started() + sm.assistant_audio_arrived() + assert sm.state is State.ASSISTANT_SPEAKING + assert not sm.uplink_enabled + + +def test_assistant_audio_arrived_is_noop_outside_listening(): + sm = StateMachine() + sm.assistant_audio_arrived() + assert sm.state is State.IDLE # ignored from IDLE + sm.wake_fired() + sm.assistant_audio_arrived() + assert sm.state is State.WAKE_PENDING # ignored from WAKE_PENDING + + +def test_assistant_done_returns_to_listening_from_speaking(): + sm = StateMachine() + sm.wake_fired() + sm.session_started() + sm.assistant_audio_arrived() + sm.assistant_done() + assert sm.state is State.LISTENING + assert sm.uplink_enabled + + +def test_assistant_done_is_noop_when_not_speaking(): + sm = StateMachine() + sm.wake_fired() + sm.session_started() # LISTENING; never went to ASSISTANT_SPEAKING + sm.assistant_done() + assert sm.state is State.LISTENING + + +def test_session_ended_routes_through_idle_pending_then_idle(): + sm = StateMachine() + sm.wake_fired() + sm.session_started() + sm.session_ended() + assert sm.state is State.IDLE_PENDING + assert not sm.wakeword_enabled + assert not sm.uplink_enabled + sm.sleep_played() + assert sm.state is State.IDLE + assert sm.wakeword_enabled + + +def test_session_ended_works_from_assistant_speaking(): + sm = StateMachine() + sm.wake_fired() + sm.session_started() + sm.assistant_audio_arrived() + sm.session_ended() + assert sm.state is State.IDLE_PENDING + + +def test_session_ended_from_idle_is_noop(): + sm = StateMachine() + sm.session_ended() + assert sm.state is State.IDLE + + +def test_sleep_played_outside_idle_pending_is_noop(): + sm = StateMachine() + sm.sleep_played() + assert sm.state is State.IDLE