Merge branch 'main' into issue_128

This commit is contained in:
Omar Amin authored and GitHub committed 2026-02-10 20:39:06 +02:00
commit 72dfc6db35
4 files changed
+71 -43

No files matched your search

+21 -9
View File
@@ -33,7 +33,7 @@ _SOUNDS_DIR = _REPO_DIR / "sounds"
async def main() -> None: async def main() -> None:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--name", required=True) parser.add_argument("--name")
parser.add_argument( parser.add_argument(
"--audio-input-device", "--audio-input-device",
help="soundcard name for input device (see --list-input-devices)", help="soundcard name for input device (see --list-input-devices)",
@@ -82,16 +82,19 @@ async def main() -> None:
"--timer-finished-sound", default=str(_SOUNDS_DIR / "timer_finished.flac") "--timer-finished-sound", default=str(_SOUNDS_DIR / "timer_finished.flac")
) )
parser.add_argument( parser.add_argument(
"--processing-sound", default=str(_SOUNDS_DIR / "processing.wav"), "--processing-sound",
help="Short sound to play while assistant is processing (thinking)" default=str(_SOUNDS_DIR / "processing.wav"),
help="Short sound to play while assistant is processing (thinking)",
) )
parser.add_argument( parser.add_argument(
"--mute-sound", default=str(_SOUNDS_DIR / "mute_switch_on.flac"), "--mute-sound",
help="Sound to play when muting the assistant" default=str(_SOUNDS_DIR / "mute_switch_on.flac"),
help="Sound to play when muting the assistant",
) )
parser.add_argument( parser.add_argument(
"--unmute-sound", default=str(_SOUNDS_DIR / "mute_switch_off.flac"), "--unmute-sound",
help="Sound to play when unmuting the assistant" default=str(_SOUNDS_DIR / "mute_switch_off.flac"),
help="Sound to play when unmuting the assistant",
) )
# #
parser.add_argument("--preferences-file", default=_REPO_DIR / "preferences.json") parser.add_argument("--preferences-file", default=_REPO_DIR / "preferences.json")
@@ -105,7 +108,9 @@ async def main() -> None:
"--port", type=int, default=6053, help="Port for ESPHome server (default: 6053)" "--port", type=int, default=6053, help="Port for ESPHome server (default: 6053)"
) )
parser.add_argument( parser.add_argument(
"--enable-thinking-sound", action="store_true", help="Enable thinking sound on startup" "--enable-thinking-sound",
action="store_true",
help="Enable thinking sound on startup",
) )
parser.add_argument( parser.add_argument(
"--debug", action="store_true", help="Print DEBUG messages to console" "--debug", action="store_true", help="Print DEBUG messages to console"
@@ -133,6 +138,9 @@ async def main() -> None:
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO) logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
_LOGGER.debug(args) _LOGGER.debug(args)
if not args.name:
parser.error("the following arguments are required: --name")
args.download_dir = Path(args.download_dir) args.download_dir = Path(args.download_dir)
args.download_dir.mkdir(parents=True, exist_ok=True) args.download_dir.mkdir(parents=True, exist_ok=True)
@@ -370,7 +378,11 @@ def process_audio(state: ServerState, mic, block_size: int):
if state.stop_word.process_streaming(micro_input): if state.stop_word.process_streaming(micro_input):
stopped = True stopped = True
if stopped and (state.stop_word.id in state.active_wake_words) and not state.muted: if (
stopped
and (state.stop_word.id in state.active_wake_words)
and not state.muted
):
state.satellite.stop() state.satellite.stop()
except Exception: except Exception:
_LOGGER.exception("Unexpected error handling audio") _LOGGER.exception("Unexpected error handling audio")
+16 -6
View File
@@ -8,17 +8,17 @@ from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
ListEntitiesMediaPlayerResponse, ListEntitiesMediaPlayerResponse,
ListEntitiesRequest, ListEntitiesRequest,
ListEntitiesSwitchResponse, ListEntitiesSwitchResponse,
SwitchCommandRequest,
SwitchStateResponse,
MediaPlayerCommandRequest, MediaPlayerCommandRequest,
MediaPlayerStateResponse, MediaPlayerStateResponse,
SubscribeHomeAssistantStatesRequest, SubscribeHomeAssistantStatesRequest,
SwitchCommandRequest,
SwitchStateResponse,
) )
from aioesphomeapi.model import ( from aioesphomeapi.model import (
EntityCategory,
MediaPlayerCommand, MediaPlayerCommand,
MediaPlayerEntityFeature, MediaPlayerEntityFeature,
MediaPlayerState, MediaPlayerState,
EntityCategory,
) )
from google.protobuf import message from google.protobuf import message
@@ -36,6 +36,7 @@ SUPPORTED_MEDIA_PLAYER_FEATURES = (
| MediaPlayerEntityFeature.MEDIA_ANNOUNCE | MediaPlayerEntityFeature.MEDIA_ANNOUNCE
) )
class ESPHomeEntity: class ESPHomeEntity:
def __init__(self, server: APIServer) -> None: def __init__(self, server: APIServer) -> None:
self.server = server self.server = server
@@ -196,8 +197,10 @@ class MediaPlayerEntity(ESPHomeEntity):
muted=self.muted, muted=self.muted,
) )
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class MuteSwitchEntity(ESPHomeEntity): class MuteSwitchEntity(ESPHomeEntity):
def __init__( def __init__(
self, self,
@@ -215,7 +218,9 @@ class MuteSwitchEntity(ESPHomeEntity):
self.object_id = object_id self.object_id = object_id
self._get_muted = get_muted self._get_muted = get_muted
self._set_muted = set_muted self._set_muted = set_muted
self._switch_state = self._get_muted() # Sync internal state with actual muted value on init self._switch_state = (
self._get_muted()
) # Sync internal state with actual muted value on init
def update_set_muted(self, set_muted: Callable[[bool], None]) -> None: def update_set_muted(self, set_muted: Callable[[bool], None]) -> None:
# Update the callback used to change the mute state. # Update the callback used to change the mute state.
@@ -250,6 +255,7 @@ class MuteSwitchEntity(ESPHomeEntity):
self.sync_with_state() self.sync_with_state()
yield SwitchStateResponse(key=self.key, state=self._switch_state) yield SwitchStateResponse(key=self.key, state=self._switch_state)
class ThinkingSoundEntity(ESPHomeEntity): class ThinkingSoundEntity(ESPHomeEntity):
def __init__( def __init__(
self, self,
@@ -269,11 +275,15 @@ class ThinkingSoundEntity(ESPHomeEntity):
self._set_thinking_sound_enabled = set_thinking_sound_enabled self._set_thinking_sound_enabled = set_thinking_sound_enabled
self._switch_state = self._get_thinking_sound_enabled() # Sync internal state self._switch_state = self._get_thinking_sound_enabled() # Sync internal state
def update_get_thinking_sound_enabled(self, get_thinking_sound_enabled: Callable[[], bool]) -> None: def update_get_thinking_sound_enabled(
self, get_thinking_sound_enabled: Callable[[], bool]
) -> None:
# Update the callback used to read the thinking sound enabled state. # Update the callback used to read the thinking sound enabled state.
self._get_thinking_sound_enabled = get_thinking_sound_enabled self._get_thinking_sound_enabled = get_thinking_sound_enabled
def update_set_thinking_sound_enabled(self, set_thinking_sound_enabled: Callable[[bool], None]) -> None: def update_set_thinking_sound_enabled(
self, set_thinking_sound_enabled: Callable[[bool], None]
) -> None:
# Update the callback used to change the thinking sound enabled state. # Update the callback used to change the thinking sound enabled state.
self._set_thinking_sound_enabled = set_thinking_sound_enabled self._set_thinking_sound_enabled = set_thinking_sound_enabled
+7 -2
View File
@@ -12,8 +12,12 @@ if TYPE_CHECKING:
from pymicro_wakeword import MicroWakeWord from pymicro_wakeword import MicroWakeWord
from pyopen_wakeword import OpenWakeWord from pyopen_wakeword import OpenWakeWord
from .entity import ESPHomeEntity, MediaPlayerEntity from .entity import (
from .entity import ESPHomeEntity, MediaPlayerEntity, MuteSwitchEntity, ThinkingSoundEntity ESPHomeEntity,
MediaPlayerEntity,
MuteSwitchEntity,
ThinkingSoundEntity,
)
from .mpv_player import MpvMediaPlayer from .mpv_player import MpvMediaPlayer
from .satellite import VoiceSatelliteProtocol from .satellite import VoiceSatelliteProtocol
@@ -55,6 +59,7 @@ class Preferences:
active_wake_words: List[str] = field(default_factory=list) active_wake_words: List[str] = field(default_factory=list)
thinking_sound: int = 0 # 0 = disabled, 1 = enabled thinking_sound: int = 0 # 0 = disabled, 1 = enabled
@dataclass @dataclass
class ServerState: class ServerState:
name: str name: str
+27 -26
View File
@@ -1,10 +1,10 @@
"""Voice satellite protocol.""" """Voice satellite protocol."""
import asyncio import asyncio
import re
import hashlib import hashlib
import logging import logging
import posixpath import posixpath
import re
import shutil import shutil
import time import time
from collections.abc import Iterable from collections.abc import Iterable
@@ -14,6 +14,7 @@ from urllib.request import urlopen
# pylint: disable=no-name-in-module # pylint: disable=no-name-in-module
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined] from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
AuthenticationRequest,
DeviceInfoRequest, DeviceInfoRequest,
DeviceInfoResponse, DeviceInfoResponse,
ListEntitiesDoneResponse, ListEntitiesDoneResponse,
@@ -32,7 +33,6 @@ from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
VoiceAssistantSetConfiguration, VoiceAssistantSetConfiguration,
VoiceAssistantTimerEventResponse, VoiceAssistantTimerEventResponse,
VoiceAssistantWakeWord, VoiceAssistantWakeWord,
AuthenticationRequest,
) )
from aioesphomeapi.core import MESSAGE_TYPE_TO_PROTO from aioesphomeapi.core import MESSAGE_TYPE_TO_PROTO
from aioesphomeapi.model import ( from aioesphomeapi.model import (
@@ -53,6 +53,7 @@ _LOGGER = logging.getLogger(__name__)
PROTO_TO_MESSAGE_TYPE = {v: k for k, v in MESSAGE_TYPE_TO_PROTO.items()} PROTO_TO_MESSAGE_TYPE = {v: k for k, v in MESSAGE_TYPE_TO_PROTO.items()}
class VoiceSatelliteProtocol(APIServer): class VoiceSatelliteProtocol(APIServer):
def __init__(self, state: ServerState) -> None: def __init__(self, state: ServerState) -> None:
@@ -146,14 +147,22 @@ class VoiceSatelliteProtocol(APIServer):
self.state.entities.append(thinking_sound_switch) self.state.entities.append(thinking_sound_switch)
# Load thinking sound enabled state from preferences (default to False if not set or unknown) # 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): if hasattr(
self.state.thinking_sound_enabled = bool(self.state.preferences.thinking_sound) 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: else:
self.state.thinking_sound_enabled = False self.state.thinking_sound_enabled = False
thinking_sound_switch.server = self thinking_sound_switch.server = self
thinking_sound_switch.update_get_thinking_sound_enabled(lambda: self.state.thinking_sound_enabled) thinking_sound_switch.update_get_thinking_sound_enabled(
thinking_sound_switch.update_set_thinking_sound_enabled(self._set_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() thinking_sound_switch.sync_with_state()
self._is_streaming_audio = False self._is_streaming_audio = False
@@ -168,7 +177,9 @@ class VoiceSatelliteProtocol(APIServer):
def _set_thinking_sound_enabled(self, new_state: bool) -> None: def _set_thinking_sound_enabled(self, new_state: bool) -> None:
self.state.thinking_sound_enabled = bool(new_state) self.state.thinking_sound_enabled = bool(new_state)
self.state.preferences.thinking_sound = 1 if self.state.thinking_sound_enabled else 0 self.state.preferences.thinking_sound = (
1 if self.state.thinking_sound_enabled else 0
)
if self.state.thinking_sound_enabled: if self.state.thinking_sound_enabled:
_LOGGER.debug("Thinking sound enabled") _LOGGER.debug("Thinking sound enabled")
@@ -204,7 +215,10 @@ class VoiceSatelliteProtocol(APIServer):
self._tts_url = data.get("url") self._tts_url = data.get("url")
self._tts_played = False self._tts_played = False
self._continue_conversation = False self._continue_conversation = False
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_START and self.state.thinking_sound_enabled: elif (
event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_START
and self.state.thinking_sound_enabled
):
# Play short "thinking/processing" sound if configured # Play short "thinking/processing" sound if configured
processing = getattr(self.state, "processing_sound", None) processing = getattr(self.state, "processing_sound", None)
if processing: if processing:
@@ -273,14 +287,12 @@ class VoiceSatelliteProtocol(APIServer):
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.state.tts_player.play(urls, 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):
# Compute dynamic device name # Compute dynamic device name
base_name = re.sub(r'[\s-]+', '-', self.state.name.lower()).strip('-') base_name = re.sub(r"[\s-]+", "-", self.state.name.lower()).strip("-")
mac_no_colon = self.state.mac_address.replace(":", "").lower() mac_no_colon = self.state.mac_address.replace(":", "").lower()
mac_last6 = mac_no_colon[-6:] mac_last6 = mac_no_colon[-6:]
device_name = f"{base_name}-{mac_last6}" device_name = f"{base_name}-{mac_last6}"
@@ -505,10 +517,11 @@ class VoiceSatelliteProtocol(APIServer):
# Send states after connect # Send states after connect
states = [] states = []
for entity in self.state.entities: for entity in self.state.entities:
states.extend(entity.handle_message(SubscribeHomeAssistantStatesRequest())) states.extend(
entity.handle_message(SubscribeHomeAssistantStatesRequest())
)
self.send_messages(states) self.send_messages(states)
_LOGGER.debug("Sent entity states after connect") _LOGGER.debug("Sent entity states after connect")
_LOGGER.info("Disconnected from Home Assistant")
def _download_external_wake_word( def _download_external_wake_word(
self, external_wake_word: VoiceAssistantExternalWakeWord self, external_wake_word: VoiceAssistantExternalWakeWord
@@ -576,15 +589,3 @@ class VoiceSatelliteProtocol(APIServer):
trained_languages=external_wake_word.trained_languages, trained_languages=external_wake_word.trained_languages,
wake_word_path=config_path, wake_word_path=config_path,
) )
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
# Send states after connect
states = []
for entity in self.state.entities:
states.extend(entity.handle_message(SubscribeHomeAssistantStatesRequest()))
self.send_messages(states)
_LOGGER.debug("Sent entity states after connect")