Update satellite.py

Implements non-blocking downloads.
This commit is contained in:
imonlinux
2025-11-19 18:47:37 -08:00
committed by GitHub
parent cf82a3158d
commit 6f5444237c
+88 -50
View File
@@ -1,5 +1,7 @@
"""Voice satellite protocol.""" """Voice satellite protocol."""
import asyncio
import functools
import hashlib import hashlib
import logging import logging
import posixpath import posixpath
@@ -7,7 +9,7 @@ import shutil
import time import time
from collections.abc import Iterable from collections.abc import Iterable
from pathlib import Path from pathlib import Path
from typing import Dict, Optional, Set, Union from typing import Dict, Optional, Set, Union, List
from urllib.parse import urlparse, urlunparse from urllib.parse import urlparse, urlunparse
from urllib.request import urlopen from urllib.request import urlopen
@@ -51,7 +53,7 @@ class VoiceSatelliteProtocol(APIServer):
def __init__(self, state: ServerState) -> None: def __init__(self, state: ServerState) -> None:
super().__init__(state.name) super().__init__(state.name)
self.state = state self.state = state
self.state.satellite = self # <-- ADDED BACK self.state.satellite = self
self.media_player_entity = MediaPlayerEntity( self.media_player_entity = MediaPlayerEntity(
server=self, server=self,
@@ -159,11 +161,16 @@ class VoiceSatelliteProtocol(APIServer):
self._play_timer_finished() self._play_timer_finished()
def handle_message(self, msg: message.Message) -> Iterable[message.Message]: def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
"""
Handles incoming messages from Home Assistant.
Note: This method is synchronous. Long-running operations must be offloaded to Tasks.
"""
if isinstance(msg, VoiceAssistantEventResponse): if isinstance(msg, VoiceAssistantEventResponse):
data: Dict[str, str] = {} data: Dict[str, str] = {}
for arg in msg.data: for arg in msg.data:
data[arg.name] = arg.value data[arg.name] = arg.value
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)
urls = [] urls = []
@@ -177,8 +184,10 @@ class VoiceSatelliteProtocol(APIServer):
yield from self.media_player_entity.play( 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):
self.handle_timer_event(VoiceAssistantTimerEventType(msg.event_type), msg) self.handle_timer_event(VoiceAssistantTimerEventType(msg.event_type), msg)
elif isinstance(msg, DeviceInfoRequest): elif isinstance(msg, DeviceInfoRequest):
yield DeviceInfoResponse( yield DeviceInfoResponse(
uses_password=False, uses_password=False,
@@ -192,6 +201,7 @@ class VoiceSatelliteProtocol(APIServer):
| VoiceAssistantFeature.TIMERS | VoiceAssistantFeature.TIMERS
), ),
) )
elif isinstance( elif isinstance(
msg, msg,
( (
@@ -205,6 +215,7 @@ class VoiceSatelliteProtocol(APIServer):
if isinstance(msg, ListEntitiesRequest): if isinstance(msg, ListEntitiesRequest):
yield ListEntitiesDoneResponse() yield ListEntitiesDoneResponse()
elif isinstance(msg, VoiceAssistantConfigurationRequest): elif isinstance(msg, VoiceAssistantConfigurationRequest):
available_wake_words = [ available_wake_words = [
VoiceAssistantWakeWord( VoiceAssistantWakeWord(
@@ -242,40 +253,49 @@ class VoiceSatelliteProtocol(APIServer):
_LOGGER.info("Connected to Home Assistant") _LOGGER.info("Connected to Home Assistant")
self._set_state(SatelliteState.IDLE) self._set_state(SatelliteState.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() # FIX: Offload to background task to prevent blocking the loop during download
for wake_word_id in msg.active_wake_words: self.state.loop.create_task(self._handle_set_configuration_task(msg))
if wake_word_id in self.state.wake_words: # Yield nothing immediately; response will be sent asynchronously
active_wake_words.add(wake_word_id)
continue
model_info = self.state.available_wake_words.get(wake_word_id)
if not model_info:
external_wake_word = self._external_wake_words.get(wake_word_id)
if not external_wake_word:
_LOGGER.warning("Unknown wake word: %s", wake_word_id)
continue
model_info = self._download_external_wake_word(external_wake_word) async def _handle_set_configuration_task(self, msg: VoiceAssistantSetConfiguration) -> None:
if not model_info: """Asynchronous handler for SetConfiguration to avoid blocking I/O."""
continue active_wake_words: Set[str] = set()
self.state.available_wake_words[wake_word_id] = model_info for wake_word_id in msg.active_wake_words:
if wake_word_id in self.state.wake_words:
_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) active_wake_words.add(wake_word_id)
break continue
model_info = self.state.available_wake_words.get(wake_word_id)
self.state.active_wake_words = active_wake_words if not model_info:
_LOGGER.debug("Active wake words: %s", active_wake_words) external_wake_word = self._external_wake_words.get(wake_word_id)
if not external_wake_word:
_LOGGER.warning("Unknown wake word: %s", wake_word_id)
continue
# Await the non-blocking download
model_info = await 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()
self.state.preferences.active_wake_words = list(active_wake_words) _LOGGER.info("Wake word set: %s", wake_word_id)
self.state.save_preferences() active_wake_words.add(wake_word_id)
self.state.wake_words_changed = True break
self.state.active_wake_words = active_wake_words
_LOGGER.debug("Active wake words: %s", active_wake_words)
self.state.preferences.active_wake_words = list(active_wake_words)
self.state.save_preferences()
self.state.wake_words_changed = True
def handle_audio(self, audio_chunk: bytes) -> None: def handle_audio(self, audio_chunk: bytes) -> None:
if self._is_streaming_audio: if self._is_streaming_audio:
@@ -361,9 +381,19 @@ class VoiceSatelliteProtocol(APIServer):
), ),
) )
def _download_external_wake_word( async def _download_external_wake_word(
self, external_wake_word: VoiceAssistantExternalWakeWord self, external_wake_word: VoiceAssistantExternalWakeWord
) -> Optional[AvailableWakeWord]: ) -> Optional[AvailableWakeWord]:
"""Wrapper to run the blocking download in a thread executor."""
return await self.state.loop.run_in_executor(
None,
functools.partial(self._download_external_wake_word_sync, external_wake_word)
)
def _download_external_wake_word_sync(
self, external_wake_word: VoiceAssistantExternalWakeWord
) -> Optional[AvailableWakeWord]:
"""Blocking download logic, intended to run in an executor."""
eww_dir = self.state.download_dir / "external_wake_words" eww_dir = self.state.download_dir / "external_wake_words"
eww_dir.mkdir(parents=True, exist_ok=True) eww_dir.mkdir(parents=True, exist_ok=True)
@@ -387,17 +417,21 @@ 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)
with urlopen(external_wake_word.url) as request: try:
if request.status != 200: with urlopen(external_wake_word.url, timeout=10) as request:
_LOGGER.warning( if request.status != 200:
"Failed to download: %s, status=%s", _LOGGER.warning(
external_wake_word.url, "Failed to download: %s, status=%s",
request.status, external_wake_word.url,
) request.status,
return None )
return None
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:
_LOGGER.error("Exception downloading config: %s", e)
return None
if should_download_model: if should_download_model:
parsed_url = urlparse(external_wake_word.url) parsed_url = urlparse(external_wake_word.url)
@@ -407,15 +441,19 @@ class VoiceSatelliteProtocol(APIServer):
model_url = urlunparse(parsed_url) model_url = urlunparse(parsed_url)
_LOGGER.debug("Downloading %s to %s", model_url, model_path) _LOGGER.debug("Downloading %s to %s", model_url, model_path)
with urlopen(model_url) as request: try:
if request.status != 200: with urlopen(model_url, timeout=10) as request:
_LOGGER.warning( if request.status != 200:
"Failed to download: %s, status=%s", model_url, request.status _LOGGER.warning(
) "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:
_LOGGER.error("Exception downloading model: %s", e)
return None
return AvailableWakeWord( return AvailableWakeWord(
id=external_wake_word.id, id=external_wake_word.id,