"""Voice satellite protocol.""" import asyncio import hashlib import logging import posixpath import shutil import threading import time from collections.abc import Iterable from typing import Dict, List, Optional, Set, Union from urllib.parse import urlparse, urlunparse from urllib.request import urlopen # pylint: disable=no-name-in-module from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined] AuthenticationRequest, DeviceInfoRequest, DeviceInfoResponse, ListEntitiesDoneResponse, ListEntitiesRequest, MediaPlayerCommandRequest, NumberCommandRequest, SelectCommandRequest, SubscribeHomeAssistantStatesRequest, SwitchCommandRequest, VoiceAssistantAnnounceFinished, VoiceAssistantAnnounceRequest, VoiceAssistantAudio, VoiceAssistantConfigurationRequest, VoiceAssistantConfigurationResponse, VoiceAssistantEventResponse, VoiceAssistantExternalWakeWord, VoiceAssistantRequest, VoiceAssistantSetConfiguration, VoiceAssistantTimerEventResponse, VoiceAssistantWakeWord, ) from aioesphomeapi.core import MESSAGE_TYPE_TO_PROTO from aioesphomeapi.model import ( VoiceAssistantEventType, VoiceAssistantFeature, VoiceAssistantTimerEventType, ) from google.protobuf import message from pymicro_wakeword import MicroWakeWord from pyopen_wakeword import OpenWakeWord from .api_server import APIServer from .entity import ( MediaPlayerEntity, MicSettingEntity, MuteSwitchEntity, StopWordSensitivityNumberEntity, ThinkingSoundEntity, WakeWord1SensitivityNumberEntity, WakeWord2SensitivityNumberEntity, ) from .models import AvailableWakeWord, ServerState, WakeWordType from .util import call_all _LOGGER = logging.getLogger(__name__) PROTO_TO_MESSAGE_TYPE = {v: k for k, v in MESSAGE_TYPE_TO_PROTO.items()} _HAS_AUDIO_DATA2 = "data2" in {f.name for f in VoiceAssistantAudio.DESCRIPTOR.fields} class VoiceSatelliteProtocol(APIServer): def __init__(self, state: ServerState) -> None: super().__init__(state.name) self.state = state self.state.satellite = self self.state.connected = False # Report capabilities appropriately if state.output_only: _LOGGER.debug("Output only features") self.supported_features = VoiceAssistantFeature.API_AUDIO | VoiceAssistantFeature.ANNOUNCE else: _LOGGER.debug("Voice assistant features") self.supported_features = ( VoiceAssistantFeature.VOICE_ASSISTANT | VoiceAssistantFeature.API_AUDIO | VoiceAssistantFeature.ANNOUNCE | VoiceAssistantFeature.START_CONVERSATION | VoiceAssistantFeature.TIMERS ) # Channel 1 carries echo-reference audio; advertise SPEAKER so HA knows to use it for server-side AEC. if state.audio_input_channels >= 2: self.supported_features |= VoiceAssistantFeature.MULTI_CHANNEL_AUDIO # pylint: disable=no-member existing_media_players = [entity for entity in self.state.entities if isinstance(entity, MediaPlayerEntity)] if existing_media_players: # Keep the first instance and remove any extras. self.state.media_player_entity = existing_media_players[0] for extra in existing_media_players[1:]: self.state.entities.remove(extra) existing_mute_switches = [entity for entity in self.state.entities if isinstance(entity, MuteSwitchEntity)] if existing_mute_switches: self.state.mute_switch_entity = existing_mute_switches[0] # type: ignore for extra in existing_mute_switches[1:]: # type: ignore self.state.entities.remove(extra) if self.state.media_player_entity is None: self.state.media_player_entity = MediaPlayerEntity( server=self, key=len(state.entities), name="Media Player", object_id="linux_voice_assistant_media_player", music_player=state.music_player, announce_player=state.tts_player, initial_volume=state.volume, ) self.state.entities.append(self.state.media_player_entity) elif self.state.media_player_entity not in self.state.entities: self.state.entities.append(self.state.media_player_entity) self.state.media_player_entity.server = self self.state.media_player_entity.volume = state.volume self.state.media_player_entity.previous_volume = state.volume # Add/update mute switch entity (like ESPHome Voice PE) mute_switch = self.state.mute_switch_entity if mute_switch is None: mute_switch = MuteSwitchEntity( server=self, key=len(state.entities), name="Mute", object_id="mute", get_muted=lambda: self.state.muted, set_muted=self._set_muted, ) self.state.entities.append(mute_switch) self.state.mute_switch_entity = mute_switch elif mute_switch not in self.state.entities: self.state.entities.append(mute_switch) mute_switch.server = self mute_switch.update_get_muted(lambda: self.state.muted) mute_switch.update_set_muted(self._set_muted) mute_switch.sync_with_state() existing_thinking_sound_switches = [entity for entity in self.state.entities if isinstance(entity, ThinkingSoundEntity)] if existing_thinking_sound_switches: self.state.thinking_sound_entity = existing_thinking_sound_switches[0] # type: ignore for extra in existing_thinking_sound_switches[1:]: # type: ignore self.state.entities.remove(extra) # Add/update thinking sound entity thinking_sound_switch = self.state.thinking_sound_entity if thinking_sound_switch is None: thinking_sound_switch = ThinkingSoundEntity( server=self, key=len(state.entities), name="Thinking Sound", object_id="thinking_sound", get_thinking_sound_enabled=lambda: self.state.thinking_sound_enabled, set_thinking_sound_enabled=self._set_thinking_sound_enabled, ) self.state.entities.append(thinking_sound_switch) self.state.thinking_sound_entity = thinking_sound_switch elif thinking_sound_switch not in self.state.entities: self.state.entities.append(thinking_sound_switch) # Load thinking sound enabled state from preferences (default to False if not set or unknown) if hasattr(self.state.preferences, "thinking_sound") and self.state.preferences.thinking_sound in (0, 1): self.state.thinking_sound_enabled = bool(self.state.preferences.thinking_sound) else: self.state.thinking_sound_enabled = False thinking_sound_switch.server = self thinking_sound_switch.update_get_thinking_sound_enabled(lambda: self.state.thinking_sound_enabled) thinking_sound_switch.update_set_thinking_sound_enabled(self._set_thinking_sound_enabled) thinking_sound_switch.sync_with_state() # Add/update Wake Word 1 sensitivity number entity sensitivity_1_entity = self.state.sensitivity_1_number_entity if sensitivity_1_entity is None: sensitivity_1_entity = WakeWord1SensitivityNumberEntity( server=self, key=len(state.entities), name="Wake Word 1 Sensitivity", object_id="wake_word_1_sensitivity", get_sensitivity=lambda: self.state.wake_word_1_threshold, set_sensitivity=self._set_sensitivity_1, initial_value=self.state.wake_word_1_threshold, ) self.state.entities.append(sensitivity_1_entity) self.state.sensitivity_1_number_entity = sensitivity_1_entity elif sensitivity_1_entity not in self.state.entities: self.state.entities.append(sensitivity_1_entity) sensitivity_1_entity.server = self sensitivity_1_entity.update_get_sensitivity(lambda: self.state.wake_word_1_threshold) sensitivity_1_entity.update_set_sensitivity(self._set_sensitivity_1) sensitivity_1_entity.sync_with_state() _LOGGER.debug("INIT: Wake Word 1 entity initialized with value %.3f", sensitivity_1_entity.value) # Add/update Wake Word 2 sensitivity number entity sensitivity_2_entity = self.state.sensitivity_2_number_entity if sensitivity_2_entity is None: sensitivity_2_entity = WakeWord2SensitivityNumberEntity( server=self, key=len(state.entities), name="Wake Word 2 Sensitivity", object_id="wake_word_2_sensitivity", get_sensitivity=lambda: self.state.wake_word_2_threshold, set_sensitivity=self._set_sensitivity_2, initial_value=self.state.wake_word_2_threshold, ) self.state.entities.append(sensitivity_2_entity) self.state.sensitivity_2_number_entity = sensitivity_2_entity elif sensitivity_2_entity not in self.state.entities: self.state.entities.append(sensitivity_2_entity) sensitivity_2_entity.server = self sensitivity_2_entity.update_get_sensitivity(lambda: self.state.wake_word_2_threshold) sensitivity_2_entity.update_set_sensitivity(self._set_sensitivity_2) sensitivity_2_entity.sync_with_state() # Add/update Stop Word sensitivity number entity stop_sensitivity_entity = self.state.stop_sensitivity_number_entity if stop_sensitivity_entity is None: stop_sensitivity_entity = StopWordSensitivityNumberEntity( server=self, key=len(state.entities), name="Stop Word Sensitivity", object_id="stop_word_sensitivity", get_sensitivity=lambda: self.state.stop_word_threshold, set_sensitivity=self._set_stop_sensitivity, initial_value=self.state.stop_word_threshold, ) self.state.entities.append(stop_sensitivity_entity) self.state.stop_sensitivity_number_entity = stop_sensitivity_entity elif stop_sensitivity_entity not in self.state.entities: self.state.entities.append(stop_sensitivity_entity) stop_sensitivity_entity.server = self stop_sensitivity_entity.update_get_sensitivity(lambda: self.state.stop_word_threshold) stop_sensitivity_entity.update_set_sensitivity(self._set_stop_sensitivity) stop_sensitivity_entity.sync_with_state() # Mic Gain if self.state.mic_gain_entity is None: self.state.mic_gain_entity = MicSettingEntity( server=self, key=len(self.state.entities), name="Mic Auto Gain", object_id="mic_gain", min_value=0.0, max_value=31.0, get_value=lambda: float(self.state.mic_auto_gain), set_value=lambda val: self.state.persist_mic_gain(float(val)), icon="mdi:microphone-plus", ) self.state.entities.append(self.state.mic_gain_entity) elif self.state.mic_gain_entity not in self.state.entities: self.state.entities.append(self.state.mic_gain_entity) self.state.mic_gain_entity.server = self self.state.mic_gain_entity.update_get_value(lambda: float(self.state.mic_auto_gain)) self.state.mic_gain_entity.update_set_value(lambda val: self.state.persist_mic_gain(float(val))) # type: ignore[arg-type] self.state.mic_gain_entity.sync_with_state() # Mic Noise Suppression _NOISE_OPTIONS = ["Off", "Low", "Medium", "High", "Max"] _NOISE_TO_INT = {label: i for i, label in enumerate(_NOISE_OPTIONS)} def _get_noise_label() -> str: return _NOISE_OPTIONS[max(0, min(4, self.state.mic_noise_suppression))] def _set_noise_label(label: Union[float, str]) -> None: self.state.persist_mic_noise(float(_NOISE_TO_INT.get(str(label), 0))) if self.state.mic_noise_suppression_entity is None: self.state.mic_noise_suppression_entity = MicSettingEntity( server=self, key=len(self.state.entities), name="Mic Noise Suppression", object_id="mic_noise", options=_NOISE_OPTIONS, get_value=_get_noise_label, set_value=_set_noise_label, icon="mdi:waveform", ) self.state.entities.append(self.state.mic_noise_suppression_entity) elif self.state.mic_noise_suppression_entity not in self.state.entities: self.state.entities.append(self.state.mic_noise_suppression_entity) self.state.mic_noise_suppression_entity.server = self self.state.mic_noise_suppression_entity.update_get_value(_get_noise_label) self.state.mic_noise_suppression_entity.update_set_value(_set_noise_label) self.state.mic_noise_suppression_entity.sync_with_state() # Mic Volume if self.state.mic_volume_entity is None: self.state.mic_volume_entity = MicSettingEntity( server=self, key=len(self.state.entities), name="Mic Volume", object_id="mic_volume", min_value=1.0, max_value=100.0, get_value=lambda: float(self.state.mic_volume), set_value=lambda val: self.state.persist_mic_volume(float(val)), icon="mdi:microphone-settings", ) self.state.entities.append(self.state.mic_volume_entity) elif self.state.mic_volume_entity not in self.state.entities: self.state.entities.append(self.state.mic_volume_entity) self.state.mic_volume_entity.server = self self.state.mic_volume_entity.update_get_value(lambda: float(self.state.mic_volume)) self.state.mic_volume_entity.update_set_value(lambda val: self.state.persist_mic_volume(float(val))) # ---- Instance variables ---- self._is_streaming_audio = False self._tts_url: Optional[str] = None self._tts_played = False self._continue_conversation = False self._timer_finished = False self._timer_ring_start: Optional[float] = None self._processing = False self._pipeline_active = False self._external_wake_words: Dict[str, VoiceAssistantExternalWakeWord] = {} self._disconnect_event = asyncio.Event() def _set_thinking_sound_enabled(self, new_state: bool) -> None: self.state.thinking_sound_enabled = bool(new_state) self.state.preferences.thinking_sound = 1 if self.state.thinking_sound_enabled else 0 if self.state.thinking_sound_enabled: _LOGGER.debug("Thinking sound enabled") else: _LOGGER.debug("Thinking sound disabled") pass self.state.save_preferences() def _set_sensitivity_1(self, new_value: float) -> None: self.state.wake_word_1_threshold = float(new_value) self.state.preferences.wake_word_1_sensitivity = float(new_value) self.state.save_preferences() _LOGGER.debug("Wake Word 1 Sensitivity value set to: %s", new_value) # Sync entity state if self.state.sensitivity_1_number_entity is not None: self.state.sensitivity_1_number_entity.sync_with_state() def _set_sensitivity_2(self, new_value: float) -> None: self.state.wake_word_2_threshold = float(new_value) self.state.preferences.wake_word_2_sensitivity = float(new_value) self.state.save_preferences() _LOGGER.debug("Wake Word 2 Sensitivity value set to: %s", new_value) # Sync entity state if self.state.sensitivity_2_number_entity is not None: self.state.sensitivity_2_number_entity.sync_with_state() def _set_stop_sensitivity(self, new_value: float) -> None: self.state.stop_word_threshold = float(new_value) self.state.preferences.stop_word_sensitivity = float(new_value) self.state.save_preferences() _LOGGER.debug("Stop Word Sensitivity value set to: %s", new_value) # Sync entity state if self.state.stop_sensitivity_number_entity is not None: self.state.stop_sensitivity_number_entity.sync_with_state() def _set_muted(self, new_state: bool) -> None: self.state.muted = bool(new_state) if self.state.muted: # voice_assistant.stop behavior _LOGGER.debug("Muting voice assistant (voice_assistant.stop)") self._is_streaming_audio = False self.state.tts_player.stop() # Stop any ongoing voice processing self.state.stop_word.is_active = False # type: ignore self.state.tts_player.play(self.state.mute_sound) else: # voice_assistant.start_continuous behavior _LOGGER.debug("Unmuting voice assistant (voice_assistant.start_continuous)") self.state.tts_player.play(self.state.unmute_sound) # Resume normal operation - wake word detection will be active again pass def handle_voice_event(self, event_type: VoiceAssistantEventType, data: Dict[str, str]) -> None: _LOGGER.debug("Voice event: type=%s, data=%s", event_type.name, data) if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START: self._tts_url = data.get("url") self._tts_played = False self._continue_conversation = False self._pipeline_active = True elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_START and self.state.thinking_sound_enabled: # Play short "thinking/processing" sound if configured processing = getattr(self.state, "processing_sound", None) if processing: _LOGGER.debug("Playing processing sound: %s", processing) self.state.stop_word.is_active = True # type: ignore self._processing = True self.duck() self.state.tts_player.play(self.state.processing_sound) elif event_type in ( VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_END, 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": # Start streaming early self.play_tts() elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: if data.get("continue_conversation") == "1": self._continue_conversation = True elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: self._tts_url = data.get("url") self.play_tts() elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END: self._is_streaming_audio = False if not self._tts_played: self._pipeline_active = False self._tts_finished() # When TTS is playing, keep _pipeline_active = True to block # false wake word detections from speaker audio feedback. # _tts_finished() callback will clear it when playback ends. self._tts_played = False # TODO: handle error def handle_timer_event( self, event_type: VoiceAssistantTimerEventType, msg: VoiceAssistantTimerEventResponse, ) -> None: _LOGGER.debug("Timer event: type=%s", event_type.name) if event_type == VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_FINISHED: if not self._timer_finished: self.state.active_wake_words.add(self.state.stop_word.id) self._timer_finished = True self._timer_ring_start = time.monotonic() self.duck() self._play_timer_finished() def handle_message(self, msg: message.Message) -> Iterable[message.Message]: if isinstance(msg, VoiceAssistantEventResponse): # Pipeline event data: Dict[str, str] = {} for arg in msg.data: data[arg.name] = arg.value self.handle_voice_event(VoiceAssistantEventType(msg.event_type), data) # assist_satellite.announce HERE 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) urls.append(msg.media_id) self.state.active_wake_words.add(self.state.stop_word.id) self._continue_conversation = msg.start_conversation self.duck() self.state.tts_player.play(urls, done_callback=self._tts_finished) elif isinstance(msg, VoiceAssistantTimerEventResponse): self.handle_timer_event(VoiceAssistantTimerEventType(msg.event_type), msg) elif isinstance(msg, DeviceInfoRequest): _LOGGER.debug("Device info request") yield DeviceInfoResponse( uses_password=False, name=self.state.name, friendly_name=self.state.friendly_name, project_name="Open Home Foundation.Linux Voice Assistant", project_version=self.state.version, esphome_version=self.state.esphome_version, mac_address=self.state.mac_address, manufacturer="Open Home Foundation", model="Linux Voice Assistant", voice_assistant_feature_flags=self.supported_features, ) elif isinstance( msg, (ListEntitiesRequest, SubscribeHomeAssistantStatesRequest, MediaPlayerCommandRequest, SwitchCommandRequest, NumberCommandRequest, SelectCommandRequest), ): for entity in self.state.entities: yield from entity.handle_message(msg) if isinstance(msg, ListEntitiesRequest): yield ListEntitiesDoneResponse() elif isinstance(msg, VoiceAssistantConfigurationRequest): _LOGGER.debug("✅ Received VoiceAssistantConfigurationRequest from Home Assistant") _LOGGER.debug(" -> Request contains %d external wake words", len(msg.external_wake_words)) available_wake_words = [ VoiceAssistantWakeWord( id=ww.id, wake_word=ww.wake_word, trained_languages=ww.trained_languages, ) for ww in self.state.available_wake_words.values() ] # Log available internal wake words first internal_ww_count = len(self.state.available_wake_words) _LOGGER.debug(" -> Found %d internal available wake words", internal_ww_count) for ww in available_wake_words: _LOGGER.debug(" - %s: '%s' (langs: %s)", ww.id, ww.wake_word, ww.trained_languages) for eww in msg.external_wake_words: _LOGGER.debug(" -> Processing external wake word: id=%s, word='%s', type=%s", eww.id, eww.wake_word, eww.model_type) if eww.model_type != "micro": _LOGGER.debug(" → Skipping: not micro model type") continue _LOGGER.debug(" → Adding to available wake words") available_wake_words.append( VoiceAssistantWakeWord( id=eww.id, wake_word=eww.wake_word, trained_languages=eww.trained_languages, ) ) self._external_wake_words[eww.id] = eww _LOGGER.debug(" → Stored in external wake words cache") active_ww_ids = [ww.id for ww in self.state.wake_words.values() if ww.id in self.state.active_wake_words] _LOGGER.debug(" -> Active wake word IDs: %s", active_ww_ids) yield VoiceAssistantConfigurationResponse( available_wake_words=available_wake_words, active_wake_words=active_ww_ids, max_active_wake_words=2, ) _LOGGER.info("✅ Connected to Home Assistant - Configuration handshake completed") _LOGGER.debug("✅ VoiceAssistantConfigurationResponse sent successfully") elif isinstance(msg, VoiceAssistantSetConfiguration): # Change active wake words active_wake_words: Set[str] = set() new_wake_words: List[Optional[str]] = [None, None] # Get old positions before modification old_positions: Dict[str, int] = {} for idx, ww_id in enumerate(self.state.preferences.active_wake_words): if ww_id is not None and idx < 2: old_positions[ww_id] = idx # Process new active wake words for wake_word_id in msg.active_wake_words: if wake_word_id in self.state.wake_words: # Already active active_wake_words.add(wake_word_id) else: model_info = self.state.available_wake_words.get(wake_word_id) if not model_info: # Check external wake words (may require download) external_wake_word = self._external_wake_words.get(wake_word_id) if not external_wake_word: continue model_info = self._download_external_wake_word(external_wake_word) if not model_info: continue self.state.available_wake_words[wake_word_id] = model_info _LOGGER.debug("Loading wake word: %s", model_info.wake_word_path) self.state.wake_words[wake_word_id] = model_info.load() _LOGGER.info("Wake word set: %s", wake_word_id) active_wake_words.add(wake_word_id) # Keep old positions remaining_ww = list(active_wake_words) placed = set() # First, place Wake Words in their old positions. for ww_id in remaining_ww: if ww_id in old_positions: pos = old_positions[ww_id] if pos < 2: new_wake_words[pos] = ww_id placed.add(ww_id) # Add remaining wake words to free slots free_slots = [i for i in range(2) if new_wake_words[i] is None] for ww_id in remaining_ww: if ww_id not in placed and free_slots: pos = free_slots.pop(0) new_wake_words[pos] = ww_id placed.add(ww_id) # If only one wake word is left and it was at position 1, position 0 remains None # Position 2 automatically stays None if not occupied self.state.active_wake_words = active_wake_words _LOGGER.debug("Active wake words: %s", active_wake_words) _LOGGER.debug("Wake word positions: [0]=%s, [1]=%s", new_wake_words[0], new_wake_words[1]) self.state.preferences.active_wake_words = new_wake_words self.state.save_preferences() self.state.wake_words_changed = True # handle_audio — both channels in ONE message def handle_audio(self, audio_chunk: bytes, audio_chunk_2: Optional[bytes] = None) -> None: if not self._is_streaming_audio or self.state.muted: return if _HAS_AUDIO_DATA2 and audio_chunk_2 is not None: self.send_messages([VoiceAssistantAudio(data=audio_chunk, data2=audio_chunk_2)]) else: self.send_messages([VoiceAssistantAudio(data=audio_chunk)]) def wakeup(self, wake_word: Union[MicroWakeWord, OpenWakeWord]) -> None: if self._timer_finished: # Stop the ringing timer, then start a normal wake-up after a short # delay so the transition doesn't feel abrupt. self._timer_finished = False self._timer_ring_start = None self.state.active_wake_words.discard(self.state.stop_word.id) self.unduck() self.state.tts_player.stop() _LOGGER.debug("Stopping timer finished sound; will wake up in 1 s") wake_word_phrase = wake_word.wake_word # type: ignore def _delayed_wakeup() -> None: if self.state.muted or self._pipeline_active: _LOGGER.debug("Delayed wakeup skipped (muted=%s, pipeline_active=%s)", self.state.muted, self._pipeline_active) return _LOGGER.debug("Delayed wakeup: playing wakeup sound for %s", wake_word_phrase) self._pipeline_active = True self.duck() self.state.tts_player.play( self.state.wakeup_sound, done_callback=lambda: self._on_wakeup_sound_finished(wake_word_phrase), ) threading.Timer(0.1, _delayed_wakeup).start() return if self.state.muted: # Don't respond to wake words when muted (voice_assistant.stop behavior) return if self._pipeline_active: _LOGGER.debug("Ignoring wake word - pipeline already active") return wake_word_phrase = wake_word.wake_word # type: ignore _LOGGER.debug("Detected wake word: %s", wake_word_phrase) self._pipeline_active = True self.duck() self.state.tts_player.play( self.state.wakeup_sound, done_callback=lambda: self._on_wakeup_sound_finished(wake_word_phrase), ) def _on_wakeup_sound_finished(self, wake_word_phrase: str) -> None: """Callback invoked when the wakeup sound finishes playing.""" _LOGGER.debug("Wakeup sound finished, starting audio streaming with wake word: %s", wake_word_phrase) self.send_messages( [VoiceAssistantRequest(start=True, wake_word_phrase=wake_word_phrase)], ) self._is_streaming_audio = True def stop(self) -> None: self.state.active_wake_words.discard(self.state.stop_word.id) self._pipeline_active = False if self._timer_finished: self._timer_finished = False self._timer_ring_start = None self.unduck() self.state.tts_player.stop() _LOGGER.debug("Stopping timer finished sound") else: # tts_player.stop() invokes the done_callback (_tts_finished), # so we don't call _tts_finished() again explicitly. self.state.tts_player.stop() _LOGGER.debug("TTS response stopped manually") def play_tts(self) -> None: if (not self._tts_url) or self._tts_played: return self._tts_played = True _LOGGER.debug("Playing TTS response: %s", self._tts_url) self.state.active_wake_words.add(self.state.stop_word.id) self.state.tts_player.play(self._tts_url, done_callback=self._tts_finished) def duck(self) -> None: _LOGGER.debug("Ducking music") self.state.music_player.duck() def unduck(self) -> None: _LOGGER.debug("Unducking music") self.state.music_player.unduck() def _tts_finished(self) -> None: self._pipeline_active = False self.state.active_wake_words.discard(self.state.stop_word.id) self.send_messages([VoiceAssistantAnnounceFinished()]) if self._continue_conversation: self._continue_conversation = False # Keep pipeline active during the settle delay so the mic stays closed # and does not capture the tail end of the TTS audio from the speaker. self._pipeline_active = True _LOGGER.debug("Continuing conversation after %.2fs settle delay", self.state.continue_conversation_delay) def _start_continued_conversation() -> None: if self.state.muted: _LOGGER.debug("Skipping continued conversation: muted") self._pipeline_active = False self.unduck() return self.send_messages([VoiceAssistantRequest(start=True)]) self._is_streaming_audio = True _LOGGER.debug("Continued conversation started") threading.Timer(self.state.continue_conversation_delay, _start_continued_conversation).start() else: self._continue_conversation = False self.unduck() _LOGGER.debug("TTS response finished") def _play_timer_finished(self) -> None: if not self._timer_finished: _LOGGER.debug("Timer finished sound stopped") self.unduck() self._timer_ring_start = None return # Auto-stop after timer_max_ring_seconds if self._timer_ring_start is not None: elapsed = time.monotonic() - self._timer_ring_start if elapsed >= self.state.timer_max_ring_seconds: _LOGGER.info( "Timer auto-stopped after %.0f seconds (max=%.0f)", elapsed, self.state.timer_max_ring_seconds, ) self._timer_finished = False self._timer_ring_start = None self.state.active_wake_words.discard(self.state.stop_word.id) self.unduck() return self.state.tts_player.play( self.state.timer_finished_sound, done_callback=lambda: call_all( lambda: time.sleep(1.0), self._play_timer_finished, ), ) def connection_lost(self, exc): super().connection_lost(exc) self._disconnect_event.set() self._is_streaming_audio = False self._tts_url = None self._tts_played = False self._continue_conversation = False self._timer_finished = False self._pipeline_active = False # Stop any ongoing audio playback and wake/stop word processing. try: self.state.music_player.stop() except Exception: # pragma: no cover - defensive safety net _LOGGER.exception("Failed to stop music player during disconnect") try: self.state.tts_player.stop() except Exception: # pragma: no cover - defensive safety net _LOGGER.exception("Failed to stop TTS player during disconnect") self.state.stop_word.is_active = False self.state.connected = False if self.state.satellite is self: self.state.satellite = None if self.state.mute_switch_entity is not None: self.state.mute_switch_entity.sync_with_state() if self.state.mic_gain_entity is not None: self.state.mic_gain_entity.sync_with_state() if self.state.mic_noise_suppression_entity is not None: self.state.mic_noise_suppression_entity.sync_with_state() if self.state.mic_volume_entity is not None: self.state.mic_volume_entity.sync_with_state() _LOGGER.info("Disconnected from Home Assistant; waiting for reconnection") def process_packet(self, msg_type: int, packet_data: bytes) -> None: super().process_packet(msg_type, packet_data) if msg_type == PROTO_TO_MESSAGE_TYPE[AuthenticationRequest]: self.state.connected = True _LOGGER.debug("Authentication successful, connected to Home Assistant") # Send states after connect states: List[message.Message] = [] _LOGGER.debug("Found %d entities in state", len(self.state.entities)) for i, entity in enumerate(self.state.entities): entity_states = list(entity.handle_message(SubscribeHomeAssistantStatesRequest())) states.extend(entity_states) _LOGGER.debug("Entity %d (%s) returned %d state messages", i, type(entity).__name__, len(entity_states)) _LOGGER.debug("Total state messages to send: %d", len(states)) self.send_messages(states) for i, msg in enumerate(states): _LOGGER.debug("Sent state message %d: %s", i, type(msg).__name__) _LOGGER.debug("All entity states sent after connect") def _download_external_wake_word(self, external_wake_word: VoiceAssistantExternalWakeWord) -> Optional[AvailableWakeWord]: eww_dir = self.state.download_dir / "external_wake_words" eww_dir.mkdir(parents=True, exist_ok=True) config_path = eww_dir / f"{external_wake_word.id}.json" should_download_config = not config_path.exists() # Check if we need to download the model file model_path = eww_dir / f"{external_wake_word.id}.tflite" should_download_model = True if model_path.exists(): model_size = model_path.stat().st_size if model_size == external_wake_word.model_size: with open(model_path, "rb") as model_file: model_hash = hashlib.sha256(model_file.read()).hexdigest() if model_hash == external_wake_word.model_hash: should_download_model = False _LOGGER.debug( "Model size and hash match for %s. Skipping download.", external_wake_word.id, ) if should_download_config or should_download_model: # Download config _LOGGER.debug("Downloading %s to %s", external_wake_word.url, config_path) with urlopen(external_wake_word.url) as request: if request.status != 200: _LOGGER.warning( "Failed to download: %s, status=%s", external_wake_word.url, request.status, ) return None with open(config_path, "wb") as model_file: shutil.copyfileobj(request, model_file) if should_download_model: # Download model file parsed_url = urlparse(external_wake_word.url) parsed_url = parsed_url._replace( path=posixpath.join(posixpath.dirname(parsed_url.path), model_path.name), ) model_url = urlunparse(parsed_url) _LOGGER.debug("Downloading %s to %s", model_url, model_path) with urlopen(model_url) as request: if request.status != 200: _LOGGER.warning("Failed to download: %s, status=%s", model_url, request.status) return None with open(model_path, "wb") as model_file: shutil.copyfileobj(request, model_file) return AvailableWakeWord( id=external_wake_word.id, type=WakeWordType.MICRO_WAKE_WORD, wake_word=external_wake_word.wake_word, trained_languages=external_wake_word.trained_languages, wake_word_path=config_path, )