Merge branch 'main' into main
This commit is contained in:
6 files changed
+127
-15
No files matched your search
@@ -63,6 +63,11 @@ async def main() -> None:
|
||||
"--wake-model", default="okay_nabu", help="Id of active wake model"
|
||||
)
|
||||
parser.add_argument("--stop-model", default="stop", help="Id of stop model")
|
||||
parser.add_argument(
|
||||
"--download-dir",
|
||||
default=_REPO_DIR / "local",
|
||||
help="Directory to download custom wake word models, etc.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--refractory-seconds",
|
||||
default=2.0,
|
||||
@@ -120,6 +125,9 @@ async def main() -> None:
|
||||
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
|
||||
_LOGGER.debug(args)
|
||||
|
||||
args.download_dir = Path(args.download_dir)
|
||||
args.download_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Resolve microphone
|
||||
if args.audio_input_device is not None:
|
||||
try:
|
||||
@@ -133,6 +141,7 @@ async def main() -> None:
|
||||
|
||||
# Load available wake words
|
||||
wake_word_dirs = [Path(ww_dir) for ww_dir in args.wake_word_dir]
|
||||
wake_word_dirs.append(args.download_dir / "external_wake_words")
|
||||
available_wake_words: Dict[str, AvailableWakeWord] = {}
|
||||
|
||||
for wake_word_dir in wake_word_dirs:
|
||||
@@ -227,6 +236,7 @@ async def main() -> None:
|
||||
preferences=preferences,
|
||||
preferences_path=preferences_path,
|
||||
refractory_seconds=args.refractory_seconds,
|
||||
download_dir=args.download_dir,
|
||||
)
|
||||
|
||||
if args.enable_thinking_sound:
|
||||
|
||||
@@ -9,8 +9,8 @@ from typing import TYPE_CHECKING, List, Optional
|
||||
# pylint: disable=no-name-in-module
|
||||
from aioesphomeapi._frame_helper.packets import make_plain_text_packets
|
||||
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
|
||||
ConnectRequest,
|
||||
ConnectResponse,
|
||||
AuthenticationRequest,
|
||||
AuthenticationResponse,
|
||||
DisconnectRequest,
|
||||
DisconnectResponse,
|
||||
HelloRequest,
|
||||
@@ -57,8 +57,8 @@ class APIServer(asyncio.Protocol):
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(msg_inst, ConnectRequest):
|
||||
self.send_messages([ConnectResponse()])
|
||||
if isinstance(msg_inst, AuthenticationRequest):
|
||||
self.send_messages([AuthenticationResponse()])
|
||||
elif isinstance(msg_inst, DisconnectRequest):
|
||||
self.send_messages([DisconnectResponse()])
|
||||
_LOGGER.debug("Disconnect requested")
|
||||
|
||||
@@ -72,6 +72,7 @@ class ServerState:
|
||||
timer_finished_sound: str
|
||||
preferences: Preferences
|
||||
preferences_path: Path
|
||||
download_dir: Path
|
||||
|
||||
media_player_entity: "Optional[MediaPlayerEntity]" = None
|
||||
satellite: "Optional[VoiceSatelliteProtocol]" = None
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
"""Voice satellite protocol."""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import posixpath
|
||||
import shutil
|
||||
import time
|
||||
from collections.abc import Iterable
|
||||
from typing import Dict, 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]
|
||||
@@ -19,6 +24,7 @@ from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
|
||||
VoiceAssistantConfigurationRequest,
|
||||
VoiceAssistantConfigurationResponse,
|
||||
VoiceAssistantEventResponse,
|
||||
VoiceAssistantExternalWakeWord,
|
||||
VoiceAssistantRequest,
|
||||
VoiceAssistantSetConfiguration,
|
||||
VoiceAssistantTimerEventResponse,
|
||||
@@ -34,9 +40,8 @@ from pymicro_wakeword import MicroWakeWord
|
||||
from pyopen_wakeword import OpenWakeWord
|
||||
|
||||
from .api_server import APIServer
|
||||
from .entity import MediaPlayerEntity
|
||||
from .entity import MediaPlayerEntity, ThinkingSoundEntity
|
||||
from .models import ServerState
|
||||
from .models import AvailableWakeWord, ServerState, WakeWordType
|
||||
from .util import call_all
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -104,6 +109,7 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
self._continue_conversation = False
|
||||
self._timer_finished = False
|
||||
self._processing = False
|
||||
self._external_wake_words: Dict[str, VoiceAssistantExternalWakeWord] = {}
|
||||
|
||||
def _set_thinking_sound_enabled(self, new_state: bool) -> None:
|
||||
self.state.thinking_sound_enabled = bool(new_state)
|
||||
@@ -115,6 +121,7 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
_LOGGER.debug("Thinking sound disabled")
|
||||
pass
|
||||
self.state.save_preferences()
|
||||
|
||||
|
||||
def handle_voice_event(
|
||||
self, event_type: VoiceAssistantEventType, data: Dict[str, str]
|
||||
@@ -226,15 +233,31 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
if isinstance(msg, ListEntitiesRequest):
|
||||
yield ListEntitiesDoneResponse()
|
||||
elif isinstance(msg, VoiceAssistantConfigurationRequest):
|
||||
yield VoiceAssistantConfigurationResponse(
|
||||
available_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()
|
||||
]
|
||||
|
||||
for eww in msg.external_wake_words:
|
||||
if eww.model_type != "micro":
|
||||
continue
|
||||
|
||||
available_wake_words.append(
|
||||
VoiceAssistantWakeWord(
|
||||
id=ww.id,
|
||||
wake_word=ww.wake_word,
|
||||
trained_languages=ww.trained_languages,
|
||||
id=eww.id,
|
||||
wake_word=eww.wake_word,
|
||||
trained_languages=eww.trained_languages,
|
||||
)
|
||||
for ww in self.state.available_wake_words.values()
|
||||
],
|
||||
)
|
||||
|
||||
self._external_wake_words[eww.id] = eww
|
||||
|
||||
yield VoiceAssistantConfigurationResponse(
|
||||
available_wake_words=available_wake_words,
|
||||
active_wake_words=[
|
||||
ww.id
|
||||
for ww in self.state.wake_words.values()
|
||||
@@ -255,7 +278,16 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
|
||||
model_info = self.state.available_wake_words.get(wake_word_id)
|
||||
if not model_info:
|
||||
continue
|
||||
# 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()
|
||||
@@ -352,3 +384,70 @@ class VoiceSatelliteProtocol(APIServer):
|
||||
def connection_lost(self, exc):
|
||||
super().connection_lost(exc)
|
||||
_LOGGER.info("Disconnected from Home Assistant")
|
||||
|
||||
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,
|
||||
)
|
||||
Reference in new issue
Block a user