From 0f1ceefc5ca678b73b08daf8202c64edec29df51 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Mon, 17 Nov 2025 15:05:35 -0800 Subject: [PATCH 1/3] Update models.py Goal: satellite.py currently uses a set of boolean flags (_is_streaming_audio, _tts_played, _run_finished, _is_speaking) to figure out what it's doing. This is hard to read and easy to break. Replaced this with a clean, formal state machine (using an Enum). This makes the logic explicit and much easier to manage. added the SatelliteState Enum at the top and removed the media_player_entity from ServerState as it's better managed by the satellite itself --- linux_voice_assistant/models.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/linux_voice_assistant/models.py b/linux_voice_assistant/models.py index 7e832ef..c8e7608 100644 --- a/linux_voice_assistant/models.py +++ b/linux_voice_assistant/models.py @@ -21,6 +21,15 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) +class SatelliteState(str, Enum): + """Voice satellite state.""" + IDLE = "idle" + LISTENING = "listening" # Wake word detected + THINKING = "thinking" # VAD, waiting for STT/intent + RESPONDING = "responding" # Playing TTS + ERROR = "error" + + class WakeWordType(str, Enum): MICRO_WAKE_WORD = "micro" OPEN_WAKE_WORD = "openWakeWord" @@ -60,7 +69,7 @@ class AvailableWakeWord: class Preferences: active_wake_words: List[str] = field(default_factory=list) volume_level: float = 1.0 - num_leds: int = 3 # THIS LINE WAS MISSING + num_leds: int = 3 @dataclass @@ -86,7 +95,6 @@ class ServerState: oww_embedding_path: Path # --- Fields WITH default values --- - media_player_entity: "Optional[MediaPlayerEntity]" = None satellite: "Optional[VoiceSatelliteProtocol]" = None wake_words_changed: bool = False refractory_seconds: float = 2.0 From f1137232e0ded00fb30215ba52773a3bdd560829 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Mon, 17 Nov 2025 15:08:10 -0800 Subject: [PATCH 2/3] Update satellite.py This is the major change. All the _is_speaking, _run_finished, etc. flags are gone, replaced by a single self._state variable and a _set_state method. The logic is now much cleaner. --- linux_voice_assistant/satellite.py | 161 ++++++++++++++++++----------- 1 file changed, 102 insertions(+), 59 deletions(-) diff --git a/linux_voice_assistant/satellite.py b/linux_voice_assistant/satellite.py index 48b9a9a..9648111 100644 --- a/linux_voice_assistant/satellite.py +++ b/linux_voice_assistant/satellite.py @@ -34,7 +34,7 @@ from google.protobuf import message from .api_server import APIServer from .entity import MediaPlayerEntity from .microwakeword import MicroWakeWord -from .models import ServerState +from .models import ServerState, SatelliteState from .openwakeword import OpenWakeWord from .util import call_all @@ -48,25 +48,53 @@ class VoiceSatelliteProtocol(APIServer): self.state = state self.state.satellite = self - if self.state.media_player_entity is None: - self.state.media_player_entity = MediaPlayerEntity( - server=self, - state=state, - key=len(state.entities), - name="Media Player", - object_id="linux_voice_assistant_media_player", - music_player=state.music_player, - announce_player=state.tts_player, - ) - self.state.entities.append(self.state.media_player_entity) + self.media_player_entity = MediaPlayerEntity( + server=self, + state=state, + key=len(state.entities), + name="Media Player", + object_id="linux_voice_assistant_media_player", + music_player=state.music_player, + announce_player=state.tts_player, + ) + self.state.entities.append(self.media_player_entity) - self._is_streaming_audio = False + # State machine + self._state: SatelliteState = SatelliteState.IDLE + self._is_streaming_audio: bool = False # Is HA requesting audio? + + # Announce/TTS state self._tts_url: Optional[str] = None - self._tts_played = False - self._continue_conversation = False - self._timer_finished = False - self._is_speaking = False - self._run_finished = False + self._continue_conversation: bool = False + self._timer_finished: bool = False + + # Flags set by events, processed in _determine_final_state + self._run_end_received: bool = False + self._tts_end_received: bool = False + + def _set_state(self, new_state: SatelliteState): + """Manages state transitions and publishes events.""" + if self._state == new_state: + return + + _LOGGER.debug(f"State transition: {self._state} -> {new_state}") + self._state = new_state + + # Publish events for LEDs, etc. + if new_state == SatelliteState.IDLE: + self.unduck() + self.state.stop_word.is_active = False + self.state.event_bus.publish("voice_idle") + elif new_state == SatelliteState.LISTENING: + self.duck() + self.state.event_bus.publish("voice_listen") + elif new_state == SatelliteState.THINKING: + self.state.event_bus.publish("voice_thinking") + elif new_state == SatelliteState.RESPONDING: + self.state.stop_word.is_active = True + self.state.event_bus.publish("voice_responding") + elif new_state == SatelliteState.ERROR: + self.state.event_bus.publish("voice_error") def handle_voice_event( self, event_type: VoiceAssistantEventType, data: Dict[str, str] @@ -74,38 +102,51 @@ class VoiceSatelliteProtocol(APIServer): _LOGGER.debug("Voice event: type=%s, data=%s", event_type.name, data) if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START: + self._run_end_received = False + self._tts_end_received = False self._tts_url = data.get("url") - self._tts_played = False self._continue_conversation = False - self._run_finished = False + # DO NOT transition to THINKING here. Stay in LISTENING. + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_START: - self.state.event_bus.publish("voice_stt_start", data) + # This confirms we are in the LISTENING state + self._set_state(SatelliteState.LISTENING) + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_START: self.state.event_bus.publish("voice_vad_start", data) - elif event_type in ( - VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_END, - VoiceAssistantEventType.VOICE_ASSISTANT_STT_END, - ): - self.state.event_bus.publish("voice_stt_end", data) + + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END: self._is_streaming_audio = False - elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS: - if data.get("tts_start_streaming") == "1": - self.play_tts() + # THIS is when we move to THINKING + self._set_state(SatelliteState.THINKING) + + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_END: + # We wait for STT_END before moving to THINKING + pass + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: if data.get("continue_conversation") == "1": self._continue_conversation = True - # --- MODIFIED: Removed TTS_START handler from here --- + + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: + self._set_state(SatelliteState.RESPONDING) + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: self._tts_url = data.get("url") - self.play_tts() + self._tts_end_received = True + self.play_tts() # Play the final TTS URL + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END: - self._run_finished = True - if not self._is_speaking: + self._run_end_received = True + # If TTS is already done, or we're not speaking, we can finalize. + if self._state != SatelliteState.RESPONDING: self._determine_final_state() elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR: - self.state.event_bus.publish("voice_error", data) - self._determine_final_state() + self._set_state(SatelliteState.ERROR) + # Use a timer to reset to idle after error + self.state.loop.call_later(5.0, self._set_state, SatelliteState.IDLE) + def handle_timer_event( self, @@ -128,7 +169,6 @@ class VoiceSatelliteProtocol(APIServer): self.handle_voice_event(VoiceAssistantEventType(msg.event_type), data) elif isinstance(msg, VoiceAssistantAnnounceRequest): _LOGGER.debug("Announcing: %s", msg.text) - assert self.state.media_player_entity is not None urls = [] if msg.preannounce_media_id: urls.append(msg.preannounce_media_id) @@ -136,7 +176,8 @@ class VoiceSatelliteProtocol(APIServer): self.state.stop_word.is_active = True self._continue_conversation = msg.start_conversation self.duck() - yield from self.state.media_player_entity.play( + self._set_state(SatelliteState.RESPONDING) # Announce is a response + yield from self.media_player_entity.play( urls, announcement=True, done_callback=self._tts_finished ) elif isinstance(msg, VoiceAssistantTimerEventResponse): @@ -183,6 +224,7 @@ class VoiceSatelliteProtocol(APIServer): max_active_wake_words=2, ) _LOGGER.info("Connected to Home Assistant") + self._set_state(SatelliteState.IDLE) # Connected, move to idle self.state.event_bus.publish("ha_connected") elif isinstance(msg, VoiceAssistantSetConfiguration): active_wake_words: Set[str] = set() @@ -213,45 +255,41 @@ class VoiceSatelliteProtocol(APIServer): self.send_messages([VoiceAssistantAudio(data=audio_chunk)]) def wakeup(self, wake_word: Union[MicroWakeWord, OpenWakeWord]) -> None: - self.state.event_bus.publish("voice_wakeword") + if self._state != SatelliteState.IDLE: + return # Already awake + if self._timer_finished: self._timer_finished = False self.state.tts_player.stop() _LOGGER.debug("Stopping timer finished sound") return + wake_word_phrase = wake_word.wake_word _LOGGER.debug("Detected wake word: %s", wake_word_phrase) self.send_messages( [VoiceAssistantRequest(start=True, wake_word_phrase=wake_word_phrase)] ) - self.duck() + self._set_state(SatelliteState.LISTENING) self._is_streaming_audio = True self.state.tts_player.play(self.state.wakeup_sound) def stop(self) -> None: - self.state.stop_word.is_active = False self.state.tts_player.stop() if self._timer_finished: self._timer_finished = False _LOGGER.debug("Stopping timer finished sound") else: _LOGGER.debug("TTS response stopped manually") - self._tts_finished() + self._tts_finished() # Manually trigger TTS finished logic - # --- MODIFIED: Added event publish to sync with audio start --- def play_tts(self) -> None: - if (not self._tts_url) or self._tts_played: + if not self._tts_url: return - # Only publish the start event the first time we begin speaking - if not self._is_speaking: - self.state.event_bus.publish("voice_tts_start", {}) - - self._is_speaking = True - self._tts_played = True _LOGGER.debug("Playing TTS response: %s", self._tts_url) - self.state.stop_word.is_active = True + self._set_state(SatelliteState.RESPONDING) self.state.tts_player.play(self._tts_url, done_callback=self._tts_finished) + self._tts_url = None # Clear after starting playback def duck(self) -> None: _LOGGER.debug("Ducking music") @@ -263,28 +301,32 @@ class VoiceSatelliteProtocol(APIServer): def _determine_final_state(self) -> None: """Called when both TTS and the pipeline run are finished.""" - self._is_streaming_audio = False - self.state.stop_word.is_active = False - if self._continue_conversation: self.send_messages([VoiceAssistantRequest(start=True)]) self._is_streaming_audio = True _LOGGER.debug("Continuing conversation") - self.state.event_bus.publish("voice_listen") + self._set_state(SatelliteState.LISTENING) else: - self.unduck() - self.state.event_bus.publish("voice_run_end") - self.state.event_bus.publish("voice_idle") + self._set_state(SatelliteState.IDLE) _LOGGER.debug("Final state determined") def _tts_finished(self) -> None: - self._is_speaking = False + """Callback when TTS audio finishes playing.""" self.send_messages([VoiceAssistantAnnounceFinished()]) _LOGGER.debug("TTS audio playback finished") - if self._run_finished: + # If the run already ended, we can move to the final state. + # Otherwise, we wait for the RUN_END event. + if self._run_end_received: self._determine_final_state() + else: + # We've finished speaking but the pipeline is still running + # (e.g., waiting for 'continue_conversation') + # Move to THINKING state + if self._state == SatelliteState.RESPONDING: + self._set_state(SatelliteState.THINKING) + def _play_timer_finished(self) -> None: if not self._timer_finished: @@ -299,4 +341,5 @@ class VoiceSatelliteProtocol(APIServer): def connection_lost(self, exc): super().connection_lost(exc) + self._set_state(SatelliteState.ERROR) _LOGGER.info("Disconnected from Home Assistant") From a1c1336ba270e5a26a35a21ec8490128643072ef Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Mon, 17 Nov 2025 15:09:36 -0800 Subject: [PATCH 3/3] Update led_controller.py Update led_controller.py to subscribe to the new, normalized state events being published by satellite.py --- linux_voice_assistant/led_controller.py | 37 +++++++++++++++---------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/linux_voice_assistant/led_controller.py b/linux_voice_assistant/led_controller.py index a14b03f..ce20c8d 100644 --- a/linux_voice_assistant/led_controller.py +++ b/linux_voice_assistant/led_controller.py @@ -47,9 +47,8 @@ class LedController(EventHandler): # Universal Hardware Initialization if led_type == "neopixel": if interface == "spi": - # --- THIS IS THE FIX --- import busio - import neopixel_spi # The module name is just 'neopixel_spi' + import neopixel_spi _LOGGER.debug(f"Initializing {self.num_leds} NeoPixel LEDs on hardware SPI") spi = busio.SPI(board.SCLK, MOSI=board.MOSI) self.leds = neopixel_spi.NeoPixel_SPI(spi, self.num_leds, auto_write=False) @@ -76,7 +75,6 @@ class LedController(EventHandler): except Exception: _LOGGER.exception("Failed to initialize LED controller. LEDs will be disabled.") - # ... (The rest of the file is identical and does not need to be changed) def run_action(self, action_method_name: str, *args: Any) -> None: if not self._is_ready: return if self.current_task and not self.current_task.done(): self.current_task.cancel() @@ -92,6 +90,7 @@ class LedController(EventHandler): if publish_state: config["state_name"] = state_name self.state.event_bus.publish("publish_state_to_mqtt", config) + async def startup_sequence(self, color=None, brightness=None): await self.blink(_GREEN, 1.0) async def off(self, color, brightness): self.leds.fill(_OFF); self.leds.show() async def solid(self, color: Tuple[int, int, int], brightness: float): @@ -112,8 +111,8 @@ class LedController(EventHandler): self.leds.fill((int(r*mod), int(g*mod), int(b*mod))); self.leds.show(); await asyncio.sleep(speed) except asyncio.CancelledError: self.leds.fill(_OFF); self.leds.show() async def slow_pulse(self, color, brightness): await self._base_pulse(color, brightness, 0.05) - async def medium_pulse(self, color, brightness): await self._base_pulse(color, 0.02) - async def fast_pulse(self, color, brightness): await self._base_pulse(color, 0.008) + async def medium_pulse(self, color, brightness): await self._base_pulse(color, brightness, 0.02) + async def fast_pulse(self, color, brightness): await self._base_pulse(color, brightness, 0.008) async def _base_blink(self, color: Tuple[int, int, int], brightness: float, speed: float): try: @@ -134,27 +133,35 @@ class LedController(EventHandler): while True: self.leds.fill(_OFF); self.leds[i % self.num_leds] = bright_color; self.leds.show(); i += 1; await asyncio.sleep(speed) except asyncio.CancelledError: self.leds.fill(_OFF); self.leds.show() + + # --- MODIFIED STATE SUBSCRIPTIONS --- + @subscribe - def ha_connected(self, data: dict): self._apply_state_effect("idle") + def voice_idle(self, data: dict): self._apply_state_effect("idle") + @subscribe - def voice_wakeword(self, data: dict): self._apply_state_effect("listening") + def voice_listen(self, data: dict): self._apply_state_effect("listening") + @subscribe - def voice_stt_start(self, data: dict): self._apply_state_effect("listening") + def voice_thinking(self, data: dict): self._apply_state_effect("thinking") + @subscribe - def voice_vad_start(self, data: dict): pass - @subscribe - def voice_stt_end(self, data: dict): self._apply_state_effect("thinking") - @subscribe - def voice_tts_start(self, data: dict): self._apply_state_effect("responding") + def voice_responding(self, data: dict): self._apply_state_effect("responding") + @subscribe def voice_error(self, data: dict): self._apply_state_effect("error") - @subscribe - def voice_run_end(self, data: dict): self._apply_state_effect("idle") + + # --- REMOVED REDUNDANT SUBSCRIPTIONS --- + # (e.g., voice_wakeword, voice_stt_end, etc.) + @subscribe def mic_muted(self, data: dict): self.run_action("solid", _DIM_RED, 1.0) + @subscribe def mic_unmuted(self, data: dict): self._apply_state_effect("idle") + # --- MQTT CONFIG SUBSCRIPTIONS (Unchanged) --- + def _update_config(self, state_name: str, data: dict, apply: bool): config = self.configs[state_name]; changed = False; is_retained = data.get("retained", False) new_effect = data.get("effect")