Update satellite.py
Fix ESPHome media_player persistence and entity key stability
This commit is contained in:
@@ -55,16 +55,50 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
self.state = state
|
self.state = state
|
||||||
self.state.satellite = self
|
self.state.satellite = self
|
||||||
|
|
||||||
self.media_player_entity = MediaPlayerEntity(
|
# --- Ensure exactly one MediaPlayerEntity with a stable key ---
|
||||||
server=self,
|
if self.state.entities:
|
||||||
state=state,
|
existing = self.state.entities[0]
|
||||||
key=len(state.entities),
|
if not isinstance(existing, MediaPlayerEntity):
|
||||||
name="Media Player",
|
_LOGGER.warning(
|
||||||
object_id="linux_voice_assistant_media_player",
|
"First ESPHome entity is not MediaPlayerEntity (%r). "
|
||||||
music_player=state.music_player,
|
"Replacing it with a new MediaPlayerEntity.",
|
||||||
announce_player=state.tts_player,
|
type(existing),
|
||||||
)
|
)
|
||||||
self.state.entities.append(self.media_player_entity)
|
self.media_player_entity = MediaPlayerEntity(
|
||||||
|
server=self,
|
||||||
|
state=state,
|
||||||
|
key=0,
|
||||||
|
name="Media Player",
|
||||||
|
object_id="linux_voice_assistant_media_player",
|
||||||
|
music_player=state.music_player,
|
||||||
|
announce_player=state.tts_player,
|
||||||
|
)
|
||||||
|
self.state.entities[0] = self.media_player_entity
|
||||||
|
else:
|
||||||
|
# Reuse existing entity but bind it to the current protocol
|
||||||
|
self.media_player_entity = existing
|
||||||
|
self.media_player_entity.server = self
|
||||||
|
self.media_player_entity.key = 0
|
||||||
|
else:
|
||||||
|
# First connection in this process: create the media player once
|
||||||
|
self.media_player_entity = MediaPlayerEntity(
|
||||||
|
server=self,
|
||||||
|
state=state,
|
||||||
|
key=0,
|
||||||
|
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)
|
||||||
|
|
||||||
|
# If more entities somehow accumulated, prune them to avoid confusing HA
|
||||||
|
if len(self.state.entities) > 1:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Pruning %d extra ESPHome entities; keeping only the first.",
|
||||||
|
len(self.state.entities) - 1,
|
||||||
|
)
|
||||||
|
del self.state.entities[1:]
|
||||||
|
|
||||||
# State machine
|
# State machine
|
||||||
self._state: SatelliteState = SatelliteState.STARTING
|
self._state: SatelliteState = SatelliteState.STARTING
|
||||||
@@ -76,7 +110,8 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
|
|
||||||
self._run_end_received: bool = False
|
self._run_end_received: bool = False
|
||||||
self._tts_end_received: bool = False
|
self._tts_end_received: bool = False
|
||||||
self._is_announcement: bool = False # <--- FIX: Track if current audio is an announcement
|
# Track if current audio is an announcement
|
||||||
|
self._is_announcement: bool = False
|
||||||
|
|
||||||
self._external_wake_words: Dict[str, VoiceAssistantExternalWakeWord] = {}
|
self._external_wake_words: Dict[str, VoiceAssistantExternalWakeWord] = {}
|
||||||
|
|
||||||
@@ -84,7 +119,7 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
if self._state == new_state:
|
if self._state == new_state:
|
||||||
return
|
return
|
||||||
|
|
||||||
_LOGGER.debug(f"State transition: {self._state} -> {new_state}")
|
_LOGGER.debug("State transition: %s -> %s", self._state, new_state)
|
||||||
self._state = new_state
|
self._state = new_state
|
||||||
|
|
||||||
if new_state == SatelliteState.IDLE:
|
if new_state == SatelliteState.IDLE:
|
||||||
@@ -110,7 +145,7 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START:
|
if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START:
|
||||||
self._run_end_received = False
|
self._run_end_received = False
|
||||||
self._tts_end_received = False
|
self._tts_end_received = False
|
||||||
self._is_announcement = False # <--- Reset announcement flag on new run
|
self._is_announcement = False
|
||||||
self._tts_url = data.get("url")
|
self._tts_url = data.get("url")
|
||||||
self._continue_conversation = False
|
self._continue_conversation = False
|
||||||
|
|
||||||
@@ -125,6 +160,7 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
self._set_state(SatelliteState.THINKING)
|
self._set_state(SatelliteState.THINKING)
|
||||||
|
|
||||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_END:
|
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_END:
|
||||||
|
# No-op for now; could be used for LED cues
|
||||||
pass
|
pass
|
||||||
|
|
||||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END:
|
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END:
|
||||||
@@ -148,7 +184,6 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
self._set_state(SatelliteState.ERROR)
|
self._set_state(SatelliteState.ERROR)
|
||||||
self.state.loop.call_later(5.0, self._set_state, SatelliteState.IDLE)
|
self.state.loop.call_later(5.0, self._set_state, SatelliteState.IDLE)
|
||||||
|
|
||||||
|
|
||||||
def handle_timer_event(
|
def handle_timer_event(
|
||||||
self,
|
self,
|
||||||
event_type: VoiceAssistantTimerEventType,
|
event_type: VoiceAssistantTimerEventType,
|
||||||
@@ -175,12 +210,12 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
|
|
||||||
elif isinstance(msg, VoiceAssistantAnnounceRequest):
|
elif isinstance(msg, VoiceAssistantAnnounceRequest):
|
||||||
_LOGGER.debug("Announcing: %s", msg.text)
|
_LOGGER.debug("Announcing: %s", msg.text)
|
||||||
urls = []
|
urls: List[str] = []
|
||||||
if msg.preannounce_media_id:
|
if msg.preannounce_media_id:
|
||||||
urls.append(msg.preannounce_media_id)
|
urls.append(msg.preannounce_media_id)
|
||||||
urls.append(msg.media_id)
|
urls.append(msg.media_id)
|
||||||
|
|
||||||
self._is_announcement = True # <--- FIX: Mark this as an announcement
|
self._is_announcement = True
|
||||||
self.state.active_wake_words.add(self.state.stop_word.id)
|
self.state.active_wake_words.add(self.state.stop_word.id)
|
||||||
self._continue_conversation = msg.start_conversation
|
self._continue_conversation = msg.start_conversation
|
||||||
self.duck()
|
self.duck()
|
||||||
@@ -221,7 +256,7 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
yield ListEntitiesDoneResponse()
|
yield ListEntitiesDoneResponse()
|
||||||
|
|
||||||
elif isinstance(msg, VoiceAssistantConfigurationRequest):
|
elif isinstance(msg, VoiceAssistantConfigurationRequest):
|
||||||
available_wake_words = [
|
available_wake_words: List[VoiceAssistantWakeWord] = [
|
||||||
VoiceAssistantWakeWord(
|
VoiceAssistantWakeWord(
|
||||||
id=ww.id,
|
id=ww.id,
|
||||||
wake_word=ww.wake_word,
|
wake_word=ww.wake_word,
|
||||||
@@ -230,10 +265,15 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
for ww in self.state.available_wake_words.values()
|
for ww in self.state.available_wake_words.values()
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Reset external wake words cache and add new ones
|
||||||
self._external_wake_words.clear()
|
self._external_wake_words.clear()
|
||||||
for eww in msg.external_wake_words:
|
for eww in msg.external_wake_words:
|
||||||
if eww.model_type != "micro":
|
if eww.model_type != "micro":
|
||||||
_LOGGER.warning("Skipping external wake word %s (type=%s)", eww.id, eww.model_type)
|
_LOGGER.warning(
|
||||||
|
"Skipping external wake word %s (type=%s)",
|
||||||
|
eww.id,
|
||||||
|
eww.model_type,
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
available_wake_words.append(
|
available_wake_words.append(
|
||||||
@@ -248,7 +288,8 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
yield VoiceAssistantConfigurationResponse(
|
yield VoiceAssistantConfigurationResponse(
|
||||||
available_wake_words=available_wake_words,
|
available_wake_words=available_wake_words,
|
||||||
active_wake_words=[
|
active_wake_words=[
|
||||||
ww.id for ww in self.state.wake_words.values()
|
ww.id
|
||||||
|
for ww in self.state.wake_words.values()
|
||||||
if ww.id in self.state.active_wake_words
|
if ww.id in self.state.active_wake_words
|
||||||
],
|
],
|
||||||
max_active_wake_words=2,
|
max_active_wake_words=2,
|
||||||
@@ -259,11 +300,13 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
self.state.event_bus.publish("ha_connected")
|
self.state.event_bus.publish("ha_connected")
|
||||||
|
|
||||||
elif isinstance(msg, VoiceAssistantSetConfiguration):
|
elif isinstance(msg, VoiceAssistantSetConfiguration):
|
||||||
# FIX: Offload to background task to prevent blocking the loop during download
|
# Offload to background task to prevent blocking the loop during download
|
||||||
self.state.loop.create_task(self._handle_set_configuration_task(msg))
|
self.state.loop.create_task(self._handle_set_configuration_task(msg))
|
||||||
# Yield nothing immediately; response will be sent asynchronously
|
# Yield nothing immediately; response will be sent asynchronously
|
||||||
|
|
||||||
async def _handle_set_configuration_task(self, msg: VoiceAssistantSetConfiguration) -> None:
|
async def _handle_set_configuration_task(
|
||||||
|
self, msg: VoiceAssistantSetConfiguration
|
||||||
|
) -> None:
|
||||||
"""Asynchronous handler for SetConfiguration to avoid blocking I/O."""
|
"""Asynchronous handler for SetConfiguration to avoid blocking I/O."""
|
||||||
active_wake_words: Set[str] = set()
|
active_wake_words: Set[str] = set()
|
||||||
|
|
||||||
@@ -367,8 +410,8 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
self.send_messages([VoiceAssistantAnnounceFinished()])
|
self.send_messages([VoiceAssistantAnnounceFinished()])
|
||||||
_LOGGER.debug("TTS audio playback finished")
|
_LOGGER.debug("TTS audio playback finished")
|
||||||
|
|
||||||
# FIX: If this was just an announcement, we are done.
|
# If this was just an announcement, we are done,
|
||||||
# Or if we received the official Run End.
|
# or if we received the official Run End.
|
||||||
if self._is_announcement or self._run_end_received:
|
if self._is_announcement or self._run_end_received:
|
||||||
self._determine_final_state()
|
self._determine_final_state()
|
||||||
self._is_announcement = False
|
self._is_announcement = False
|
||||||
@@ -376,7 +419,6 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
if self._state == SatelliteState.RESPONDING:
|
if self._state == SatelliteState.RESPONDING:
|
||||||
self._set_state(SatelliteState.THINKING)
|
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:
|
||||||
self.unduck()
|
self.unduck()
|
||||||
@@ -394,7 +436,7 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
"""Wrapper to run the blocking download in a thread executor."""
|
"""Wrapper to run the blocking download in a thread executor."""
|
||||||
return await self.state.loop.run_in_executor(
|
return await self.state.loop.run_in_executor(
|
||||||
None,
|
None,
|
||||||
functools.partial(self._download_external_wake_word_sync, external_wake_word)
|
functools.partial(self._download_external_wake_word_sync, external_wake_word),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _download_external_wake_word_sync(
|
def _download_external_wake_word_sync(
|
||||||
@@ -423,7 +465,9 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if should_download_config or should_download_model:
|
if should_download_config or should_download_model:
|
||||||
_LOGGER.debug("Downloading %s to %s", external_wake_word.url, config_path)
|
_LOGGER.debug(
|
||||||
|
"Downloading %s to %s", external_wake_word.url, config_path
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
with urlopen(external_wake_word.url, timeout=10) as request:
|
with urlopen(external_wake_word.url, timeout=10) as request:
|
||||||
if request.status != 200:
|
if request.status != 200:
|
||||||
@@ -436,9 +480,9 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
|
|
||||||
with open(config_path, "wb") as model_file:
|
with open(config_path, "wb") as model_file:
|
||||||
shutil.copyfileobj(request, model_file)
|
shutil.copyfileobj(request, model_file)
|
||||||
except Exception as e:
|
except Exception as exc: # noqa: BLE001
|
||||||
_LOGGER.error("Exception downloading config: %s", e)
|
_LOGGER.error("Exception downloading config: %s", exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if should_download_model:
|
if should_download_model:
|
||||||
parsed_url = urlparse(external_wake_word.url)
|
parsed_url = urlparse(external_wake_word.url)
|
||||||
@@ -452,15 +496,17 @@ class VoiceSatelliteProtocol(APIServer):
|
|||||||
with urlopen(model_url, timeout=10) as request:
|
with urlopen(model_url, timeout=10) as request:
|
||||||
if request.status != 200:
|
if request.status != 200:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"Failed to download: %s, status=%s", model_url, request.status
|
"Failed to download: %s, status=%s",
|
||||||
|
model_url,
|
||||||
|
request.status,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
with open(model_path, "wb") as model_file:
|
with open(model_path, "wb") as model_file:
|
||||||
shutil.copyfileobj(request, model_file)
|
shutil.copyfileobj(request, model_file)
|
||||||
except Exception as e:
|
except Exception as exc: # noqa: BLE001
|
||||||
_LOGGER.error("Exception downloading model: %s", e)
|
_LOGGER.error("Exception downloading model: %s", exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return AvailableWakeWord(
|
return AvailableWakeWord(
|
||||||
id=external_wake_word.id,
|
id=external_wake_word.id,
|
||||||
|
|||||||
Reference in New Issue
Block a user