Merge pull request #33 from imonlinux/normalize-satellite-state-machine

Normalize satellite state machine
This commit is contained in:
imonlinux
2025-11-17 15:31:06 -08:00
committed by GitHub
3 changed files with 134 additions and 76 deletions
+22 -15
View File
@@ -47,9 +47,8 @@ class LedController(EventHandler):
# Universal Hardware Initialization # Universal Hardware Initialization
if led_type == "neopixel": if led_type == "neopixel":
if interface == "spi": if interface == "spi":
# --- THIS IS THE FIX ---
import busio 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") _LOGGER.debug(f"Initializing {self.num_leds} NeoPixel LEDs on hardware SPI")
spi = busio.SPI(board.SCLK, MOSI=board.MOSI) spi = busio.SPI(board.SCLK, MOSI=board.MOSI)
self.leds = neopixel_spi.NeoPixel_SPI(spi, self.num_leds, auto_write=False) self.leds = neopixel_spi.NeoPixel_SPI(spi, self.num_leds, auto_write=False)
@@ -76,7 +75,6 @@ class LedController(EventHandler):
except Exception: except Exception:
_LOGGER.exception("Failed to initialize LED controller. LEDs will be disabled.") _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: def run_action(self, action_method_name: str, *args: Any) -> None:
if not self._is_ready: return if not self._is_ready: return
if self.current_task and not self.current_task.done(): self.current_task.cancel() if self.current_task and not self.current_task.done(): self.current_task.cancel()
@@ -92,6 +90,7 @@ class LedController(EventHandler):
if publish_state: if publish_state:
config["state_name"] = state_name config["state_name"] = state_name
self.state.event_bus.publish("publish_state_to_mqtt", config) 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 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 off(self, color, brightness): self.leds.fill(_OFF); self.leds.show()
async def solid(self, color: Tuple[int, int, int], brightness: float): 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) 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() 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 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 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, 0.008) 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): async def _base_blink(self, color: Tuple[int, int, int], brightness: float, speed: float):
try: try:
@@ -134,27 +133,35 @@ class LedController(EventHandler):
while True: while True:
self.leds.fill(_OFF); self.leds[i % self.num_leds] = bright_color; self.leds.show(); i += 1; await asyncio.sleep(speed) 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() except asyncio.CancelledError: self.leds.fill(_OFF); self.leds.show()
# --- MODIFIED STATE SUBSCRIPTIONS ---
@subscribe @subscribe
def ha_connected(self, data: dict): self._apply_state_effect("idle") def voice_idle(self, data: dict): self._apply_state_effect("idle")
@subscribe @subscribe
def voice_wakeword(self, data: dict): self._apply_state_effect("listening") def voice_listen(self, data: dict): self._apply_state_effect("listening")
@subscribe @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 @subscribe
def voice_vad_start(self, data: dict): pass def voice_responding(self, data: dict): self._apply_state_effect("responding")
@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")
@subscribe @subscribe
def voice_error(self, data: dict): self._apply_state_effect("error") 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 @subscribe
def mic_muted(self, data: dict): self.run_action("solid", _DIM_RED, 1.0) def mic_muted(self, data: dict): self.run_action("solid", _DIM_RED, 1.0)
@subscribe @subscribe
def mic_unmuted(self, data: dict): self._apply_state_effect("idle") 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): def _update_config(self, state_name: str, data: dict, apply: bool):
config = self.configs[state_name]; changed = False; is_retained = data.get("retained", False) config = self.configs[state_name]; changed = False; is_retained = data.get("retained", False)
new_effect = data.get("effect") new_effect = data.get("effect")
+10 -2
View File
@@ -21,6 +21,15 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__) _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): class WakeWordType(str, Enum):
MICRO_WAKE_WORD = "micro" MICRO_WAKE_WORD = "micro"
OPEN_WAKE_WORD = "openWakeWord" OPEN_WAKE_WORD = "openWakeWord"
@@ -60,7 +69,7 @@ class AvailableWakeWord:
class Preferences: class Preferences:
active_wake_words: List[str] = field(default_factory=list) active_wake_words: List[str] = field(default_factory=list)
volume_level: float = 1.0 volume_level: float = 1.0
num_leds: int = 3 # THIS LINE WAS MISSING num_leds: int = 3
@dataclass @dataclass
@@ -86,7 +95,6 @@ class ServerState:
oww_embedding_path: Path oww_embedding_path: Path
# --- Fields WITH default values --- # --- Fields WITH default values ---
media_player_entity: "Optional[MediaPlayerEntity]" = None
satellite: "Optional[VoiceSatelliteProtocol]" = None satellite: "Optional[VoiceSatelliteProtocol]" = None
wake_words_changed: bool = False wake_words_changed: bool = False
refractory_seconds: float = 2.0 refractory_seconds: float = 2.0
+102 -59
View File
@@ -34,7 +34,7 @@ from google.protobuf import message
from .api_server import APIServer from .api_server import APIServer
from .entity import MediaPlayerEntity from .entity import MediaPlayerEntity
from .microwakeword import MicroWakeWord from .microwakeword import MicroWakeWord
from .models import ServerState from .models import ServerState, SatelliteState
from .openwakeword import OpenWakeWord from .openwakeword import OpenWakeWord
from .util import call_all from .util import call_all
@@ -48,25 +48,53 @@ class VoiceSatelliteProtocol(APIServer):
self.state = state self.state = state
self.state.satellite = self self.state.satellite = self
if self.state.media_player_entity is None: self.media_player_entity = MediaPlayerEntity(
self.state.media_player_entity = MediaPlayerEntity( server=self,
server=self, state=state,
state=state, key=len(state.entities),
key=len(state.entities), name="Media Player",
name="Media Player", object_id="linux_voice_assistant_media_player",
object_id="linux_voice_assistant_media_player", music_player=state.music_player,
music_player=state.music_player, announce_player=state.tts_player,
announce_player=state.tts_player, )
) self.state.entities.append(self.media_player_entity)
self.state.entities.append(self.state.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_url: Optional[str] = None
self._tts_played = False self._continue_conversation: bool = False
self._continue_conversation = False self._timer_finished: bool = False
self._timer_finished = False
self._is_speaking = False # Flags set by events, processed in _determine_final_state
self._run_finished = False 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( def handle_voice_event(
self, event_type: VoiceAssistantEventType, data: Dict[str, str] 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) _LOGGER.debug("Voice event: type=%s, data=%s", event_type.name, data)
if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START: 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_url = data.get("url")
self._tts_played = False
self._continue_conversation = 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: 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: elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_START:
self.state.event_bus.publish("voice_vad_start", data) self.state.event_bus.publish("voice_vad_start", data)
elif event_type in (
VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_END, elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END:
VoiceAssistantEventType.VOICE_ASSISTANT_STT_END,
):
self.state.event_bus.publish("voice_stt_end", data)
self._is_streaming_audio = False self._is_streaming_audio = False
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS: # THIS is when we move to THINKING
if data.get("tts_start_streaming") == "1": self._set_state(SatelliteState.THINKING)
self.play_tts()
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: elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END:
if data.get("continue_conversation") == "1": if data.get("continue_conversation") == "1":
self._continue_conversation = True 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: elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END:
self._tts_url = data.get("url") 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: elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END:
self._run_finished = True self._run_end_received = True
if not self._is_speaking: # If TTS is already done, or we're not speaking, we can finalize.
if self._state != SatelliteState.RESPONDING:
self._determine_final_state() self._determine_final_state()
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR: elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR:
self.state.event_bus.publish("voice_error", data) self._set_state(SatelliteState.ERROR)
self._determine_final_state() # 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( def handle_timer_event(
self, self,
@@ -128,7 +169,6 @@ class VoiceSatelliteProtocol(APIServer):
self.handle_voice_event(VoiceAssistantEventType(msg.event_type), data) self.handle_voice_event(VoiceAssistantEventType(msg.event_type), data)
elif isinstance(msg, VoiceAssistantAnnounceRequest): elif isinstance(msg, VoiceAssistantAnnounceRequest):
_LOGGER.debug("Announcing: %s", msg.text) _LOGGER.debug("Announcing: %s", msg.text)
assert self.state.media_player_entity is not None
urls = [] urls = []
if msg.preannounce_media_id: if msg.preannounce_media_id:
urls.append(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.state.stop_word.is_active = True
self._continue_conversation = msg.start_conversation self._continue_conversation = msg.start_conversation
self.duck() 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 urls, announcement=True, done_callback=self._tts_finished
) )
elif isinstance(msg, VoiceAssistantTimerEventResponse): elif isinstance(msg, VoiceAssistantTimerEventResponse):
@@ -183,6 +224,7 @@ class VoiceSatelliteProtocol(APIServer):
max_active_wake_words=2, max_active_wake_words=2,
) )
_LOGGER.info("Connected to Home Assistant") _LOGGER.info("Connected to Home Assistant")
self._set_state(SatelliteState.IDLE) # Connected, move to idle
self.state.event_bus.publish("ha_connected") self.state.event_bus.publish("ha_connected")
elif isinstance(msg, VoiceAssistantSetConfiguration): elif isinstance(msg, VoiceAssistantSetConfiguration):
active_wake_words: Set[str] = set() active_wake_words: Set[str] = set()
@@ -213,45 +255,41 @@ class VoiceSatelliteProtocol(APIServer):
self.send_messages([VoiceAssistantAudio(data=audio_chunk)]) self.send_messages([VoiceAssistantAudio(data=audio_chunk)])
def wakeup(self, wake_word: Union[MicroWakeWord, OpenWakeWord]) -> None: 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: if self._timer_finished:
self._timer_finished = False self._timer_finished = False
self.state.tts_player.stop() self.state.tts_player.stop()
_LOGGER.debug("Stopping timer finished sound") _LOGGER.debug("Stopping timer finished sound")
return return
wake_word_phrase = wake_word.wake_word wake_word_phrase = wake_word.wake_word
_LOGGER.debug("Detected wake word: %s", wake_word_phrase) _LOGGER.debug("Detected wake word: %s", wake_word_phrase)
self.send_messages( self.send_messages(
[VoiceAssistantRequest(start=True, wake_word_phrase=wake_word_phrase)] [VoiceAssistantRequest(start=True, wake_word_phrase=wake_word_phrase)]
) )
self.duck() self._set_state(SatelliteState.LISTENING)
self._is_streaming_audio = True self._is_streaming_audio = True
self.state.tts_player.play(self.state.wakeup_sound) self.state.tts_player.play(self.state.wakeup_sound)
def stop(self) -> None: def stop(self) -> None:
self.state.stop_word.is_active = False
self.state.tts_player.stop() self.state.tts_player.stop()
if self._timer_finished: if self._timer_finished:
self._timer_finished = False self._timer_finished = False
_LOGGER.debug("Stopping timer finished sound") _LOGGER.debug("Stopping timer finished sound")
else: else:
_LOGGER.debug("TTS response stopped manually") _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: def play_tts(self) -> None:
if (not self._tts_url) or self._tts_played: if not self._tts_url:
return 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) _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.state.tts_player.play(self._tts_url, done_callback=self._tts_finished)
self._tts_url = None # Clear after starting playback
def duck(self) -> None: def duck(self) -> None:
_LOGGER.debug("Ducking music") _LOGGER.debug("Ducking music")
@@ -263,28 +301,32 @@ class VoiceSatelliteProtocol(APIServer):
def _determine_final_state(self) -> None: def _determine_final_state(self) -> None:
"""Called when both TTS and the pipeline run are finished.""" """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: if self._continue_conversation:
self.send_messages([VoiceAssistantRequest(start=True)]) self.send_messages([VoiceAssistantRequest(start=True)])
self._is_streaming_audio = True self._is_streaming_audio = True
_LOGGER.debug("Continuing conversation") _LOGGER.debug("Continuing conversation")
self.state.event_bus.publish("voice_listen") self._set_state(SatelliteState.LISTENING)
else: else:
self.unduck() self._set_state(SatelliteState.IDLE)
self.state.event_bus.publish("voice_run_end")
self.state.event_bus.publish("voice_idle")
_LOGGER.debug("Final state determined") _LOGGER.debug("Final state determined")
def _tts_finished(self) -> None: def _tts_finished(self) -> None:
self._is_speaking = False """Callback when TTS audio finishes playing."""
self.send_messages([VoiceAssistantAnnounceFinished()]) self.send_messages([VoiceAssistantAnnounceFinished()])
_LOGGER.debug("TTS audio playback finished") _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() 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: def _play_timer_finished(self) -> None:
if not self._timer_finished: if not self._timer_finished:
@@ -299,4 +341,5 @@ class VoiceSatelliteProtocol(APIServer):
def connection_lost(self, exc): def connection_lost(self, exc):
super().connection_lost(exc) super().connection_lost(exc)
self._set_state(SatelliteState.ERROR)
_LOGGER.info("Disconnected from Home Assistant") _LOGGER.info("Disconnected from Home Assistant")