Merge pull request #37 from imonlinux/refactor-audio_engine

Core Refactor: Stability, Identity Persistence, and Audio Engine Optimization
This commit is contained in:
imonlinux
2025-11-19 18:51:40 -08:00
committed by GitHub
6 changed files with 379 additions and 223 deletions
+31 -147
View File
@@ -4,25 +4,25 @@ import asyncio
import json import json
import logging import logging
import sys import sys
import threading
import time
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple, Union from typing import Dict, Optional, Set, Tuple, Union
import numpy as np import numpy as np
import soundcard as sc import soundcard as sc
from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures
from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures
from .config import Config, LedConfig, MqttConfig, load_config_from_json from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures
from pyopen_wakeword import OpenWakeWord
from .audio_engine import AudioEngine
from .config import Config, load_config_from_json
from .event_bus import EventBus, EventHandler, subscribe from .event_bus import EventBus, EventHandler, subscribe
from .led_controller import LedController from .led_controller import LedController
from .mqtt_controller import MqttController from .mqtt_controller import MqttController
from .models import AvailableWakeWord, Preferences, ServerState, WakeWordType from .models import AvailableWakeWord, Preferences, ServerState, WakeWordType
from .mpv_player import MpvMediaPlayer from .mpv_player import MpvMediaPlayer
from .satellite import VoiceSatelliteProtocol from .satellite import VoiceSatelliteProtocol
from .util import get_mac from .util import get_mac_address, format_mac
from .zeroconf import HomeAssistantZeroconf from .zeroconf import HomeAssistantZeroconf
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -73,6 +73,13 @@ class MicMuteHandler(EventHandler):
is_muted = data.get("state", False) is_muted = data.get("state", False)
if self.state.mic_muted != is_muted: if self.state.mic_muted != is_muted:
self.state.mic_muted = is_muted self.state.mic_muted = is_muted
# Synchronize the threading event for the audio loop
if is_muted:
self.state.mic_muted_event.clear() # Pauses audio thread
else:
self.state.mic_muted_event.set() # Resumes audio thread
_LOGGER.debug("Mic muted = %s", is_muted) _LOGGER.debug("Mic muted = %s", is_muted)
if self.mqtt_controller: if self.mqtt_controller:
@@ -120,13 +127,9 @@ async def main() -> None:
# --- 7. Initialize Controllers --- # --- 7. Initialize Controllers ---
_init_controllers(loop, event_bus, state, config, preferences) _init_controllers(loop, event_bus, state, config, preferences)
# --- 8. Start Audio Thread --- # --- 8. Start Audio Engine ---
audio_thread = threading.Thread( audio_engine = AudioEngine(state, mic, config.audio.input_block_size)
target=_process_audio, audio_engine.start()
args=(state, mic, config.audio.input_block_size),
daemon=True
)
audio_thread.start()
# --- 9. Run Server --- # --- 9. Run Server ---
try: try:
@@ -134,8 +137,7 @@ async def main() -> None:
finally: finally:
# --- 10. Cleanup --- # --- 10. Cleanup ---
_LOGGER.debug("Shutting down...") _LOGGER.debug("Shutting down...")
state.shutdown = True # Signal audio thread to stop audio_engine.stop()
audio_thread.join()
if hasattr(state, "mqtt_controller") and state.mqtt_controller: if hasattr(state, "mqtt_controller") and state.mqtt_controller:
_LOGGER.debug("Stopping MQTT controller...") _LOGGER.debug("Stopping MQTT controller...")
@@ -158,11 +160,13 @@ def _init_basics() -> Tuple[Config, asyncio.AbstractEventLoop, EventBus]:
parser.add_argument("--list-output-devices", action="store_true", help="List audio output devices") parser.add_argument("--list-output-devices", action="store_true", help="List audio output devices")
args = parser.parse_args() args = parser.parse_args()
# --- List devices and exit ---
if args.list_input_devices: if args.list_input_devices:
print("Input devices\n" + "=" * 13) print("Input devices\n" + "=" * 13)
for idx, mic in enumerate(sc.all_microphones(include_loopback=False)): try:
print(f"[{idx}]", mic.name) for idx, mic in enumerate(sc.all_microphones(include_loopback=False)):
print(f"[{idx}]", mic.name)
except Exception as e:
_LOGGER.error("Error listing input devices (ensure audio backend is working): %s", e)
sys.exit(0) sys.exit(0)
if args.list_output_devices: if args.list_output_devices:
@@ -175,7 +179,6 @@ def _init_basics() -> Tuple[Config, asyncio.AbstractEventLoop, EventBus]:
_LOGGER.error("Failed to list output devices: %s", e) _LOGGER.error("Failed to list output devices: %s", e)
sys.exit(1) sys.exit(1)
# --- Load config ---
config_path = args.config config_path = args.config
if not config_path.is_absolute(): if not config_path.is_absolute():
config_path = _REPO_DIR / config_path config_path = _REPO_DIR / config_path
@@ -184,10 +187,12 @@ def _init_basics() -> Tuple[Config, asyncio.AbstractEventLoop, EventBus]:
if args.debug: if args.debug:
config.app.debug = True config.app.debug = True
# --- Setup logging --- logging.basicConfig(
logging.basicConfig(level=logging.DEBUG if config.app.debug else logging.INFO) level=logging.DEBUG if config.app.debug else logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
_LOGGER.info("Loading configuration from: %s", config_path) _LOGGER.info("Loading configuration from: %s", config_path)
_LOGGER.debug("Configuration loaded: %s", config)
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
event_bus = EventBus() event_bus = EventBus()
@@ -220,7 +225,7 @@ def _get_microphone(config: Config):
mic = sc.default_microphone() mic = sc.default_microphone()
if mic is None: if mic is None:
_LOGGER.critical("No microphone found. Use --list-input-devices to see options.") _LOGGER.critical("No microphone found.")
sys.exit(1) sys.exit(1)
_LOGGER.info("Using audio input device: %s", mic.name) _LOGGER.info("Using audio input device: %s", mic.name)
@@ -228,13 +233,11 @@ def _get_microphone(config: Config):
def _load_wake_words(config: Config, preferences: Preferences) -> WakeWordData: def _load_wake_words(config: Config, preferences: Preferences) -> WakeWordData:
"""Loads all available and active wake word models.""" """Loads all available and active wake word models."""
if not config.wake_word.directories: if not config.wake_word.directories:
config.wake_word.directories = ["wakewords", "wakewords/openWakeWord"] config.wake_word.directories = ["wakewords", "wakewords/openWakeWord"]
download_dir = _REPO_DIR / config.wake_word.download_dir download_dir = _REPO_DIR / config.wake_word.download_dir
download_dir.mkdir(parents=True, exist_ok=True) download_dir.mkdir(parents=True, exist_ok=True)
_LOGGER.debug("Download directory: %s", download_dir)
wake_word_dirs = [_REPO_DIR / d for d in config.wake_word.directories] wake_word_dirs = [_REPO_DIR / d for d in config.wake_word.directories]
wake_word_dirs.append(download_dir / "external_wake_words") wake_word_dirs.append(download_dir / "external_wake_words")
@@ -264,7 +267,6 @@ def _load_wake_words(config: Config, preferences: Preferences) -> WakeWordData:
trained_languages=model_config.get("trained_languages", []), trained_languages=model_config.get("trained_languages", []),
wake_word_path=wake_word_path, wake_word_path=wake_word_path,
) )
_LOGGER.debug("Available wake words: %s", list(sorted(available.keys())))
active: Set[str] = set() active: Set[str] = set()
models: Dict[str, Union[MicroWakeWord, OpenWakeWord]] = {} models: Dict[str, Union[MicroWakeWord, OpenWakeWord]] = {}
@@ -272,14 +274,12 @@ def _load_wake_words(config: Config, preferences: Preferences) -> WakeWordData:
if preferences.active_wake_words: if preferences.active_wake_words:
for ww_id in preferences.active_wake_words: for ww_id in preferences.active_wake_words:
if ww_id in available: if ww_id in available:
_LOGGER.debug("Loading wake model (from preferences): %s", ww_id)
models[ww_id] = available[ww_id].load() models[ww_id] = available[ww_id].load()
active.add(ww_id) active.add(ww_id)
if not models: if not models:
ww_id = config.wake_word.model ww_id = config.wake_word.model
if ww_id in available: if ww_id in available:
_LOGGER.debug("Loading wake model (from config): %s", ww_id)
models[ww_id] = available[ww_id].load() models[ww_id] = available[ww_id].load()
active.add(ww_id) active.add(ww_id)
@@ -287,7 +287,6 @@ def _load_wake_words(config: Config, preferences: Preferences) -> WakeWordData:
for ww_dir_str in config.wake_word.directories: for ww_dir_str in config.wake_word.directories:
stop_config_path = _REPO_DIR / ww_dir_str / f"{config.wake_word.stop_model}.json" stop_config_path = _REPO_DIR / ww_dir_str / f"{config.wake_word.stop_model}.json"
if stop_config_path.exists(): if stop_config_path.exists():
_LOGGER.debug("Loading stop model: %s", stop_config_path)
stop_model = MicroWakeWord.from_config(stop_config_path) stop_model = MicroWakeWord.from_config(stop_config_path)
break break
assert stop_model is not None, "Stop model not found" assert stop_model is not None, "Stop model not found"
@@ -296,7 +295,6 @@ def _load_wake_words(config: Config, preferences: Preferences) -> WakeWordData:
def _init_media_players(loop: asyncio.AbstractEventLoop, config: Config, preferences: Preferences) -> MediaPlayers: def _init_media_players(loop: asyncio.AbstractEventLoop, config: Config, preferences: Preferences) -> MediaPlayers:
"""Initializes the music and TTS media players.""" """Initializes the music and TTS media players."""
_LOGGER.debug("Initializing media players")
music_player = MpvMediaPlayer( music_player = MpvMediaPlayer(
loop=loop, loop=loop,
device=config.audio.output_device, device=config.audio.output_device,
@@ -318,10 +316,10 @@ def _create_server_state(
media_players: MediaPlayers, media_players: MediaPlayers,
) -> ServerState: ) -> ServerState:
"""Creates the global ServerState object.""" """Creates the global ServerState object."""
_LOGGER.debug("Creating server state") stable_mac = format_mac(get_mac_address())
return ServerState( return ServerState(
name=config.app.name, name=config.app.name,
mac_address=get_mac(), mac_address=stable_mac,
event_bus=event_bus, event_bus=event_bus,
loop=loop, loop=loop,
entities=[], entities=[],
@@ -347,8 +345,6 @@ def _init_controllers(
preferences: Preferences, preferences: Preferences,
): ):
"""Initializes all decoupled controllers.""" """Initializes all decoupled controllers."""
_LOGGER.debug("Initializing controllers")
led_controller = LedController( led_controller = LedController(
loop=loop, loop=loop,
event_bus=event_bus, event_bus=event_bus,
@@ -377,7 +373,6 @@ def _init_controllers(
async def _run_server(state: ServerState, config: Config): async def _run_server(state: ServerState, config: Config):
"""Starts the ESPHome server and ZeroConf discovery.""" """Starts the ESPHome server and ZeroConf discovery."""
_LOGGER.debug("Starting ESPHome server")
server = await state.loop.create_server( server = await state.loop.create_server(
lambda: VoiceSatelliteProtocol(state), lambda: VoiceSatelliteProtocol(state),
host=config.esphome.host, host=config.esphome.host,
@@ -391,116 +386,5 @@ async def _run_server(state: ServerState, config: Config):
await server.serve_forever() await server.serve_forever()
def _process_audio(state: ServerState, mic, block_size: int):
"""Main audio processing loop. Runs in a separate thread."""
micro_features: MicroWakeWordFeatures = MicroWakeWordFeatures()
oww_features: Optional[OpenWakeWordFeatures] = None
wake_words: List[Union[MicroWakeWord, OpenWakeWord]] = []
micro_inputs: List[np.ndarray] = []
oww_inputs: List[np.ndarray] = []
has_oww = False
last_active: Optional[float] = None
try:
while True:
# --- THIS IS THE FIX ---
# Wait while muted. This loop will spin until unmuted.
if state.mic_muted:
_LOGGER.debug("Audio thread muted.")
while state.mic_muted:
if state.shutdown:
_LOGGER.debug("Shutdown signal received, stopping audio thread.")
return # Exit thread
time.sleep(0.1)
# --- Just unmuted ---
_LOGGER.debug("Audio thread unmuted, clearing buffers.")
micro_features.reset()
if oww_features is not None:
oww_features.reset()
last_active = time.monotonic() # Start refractory period
# Re-open microphone to flush OS buffer
_LOGGER.debug("Re-opening microphone to flush OS buffers...")
with mic.recorder(samplerate=16000, channels=1, blocksize=block_size) as mic_in:
mic_in.flush() # Discard any stale data
_LOGGER.debug("Microphone flushed.")
# Fall through to the main recording loop
# --- END FIX ---
_LOGGER.debug("Opening audio input device: %s", mic.name)
with mic.recorder(samplerate=16000, channels=1, blocksize=block_size) as mic_in:
# --- This is now the main recording loop ---
while not state.mic_muted:
if state.shutdown:
_LOGGER.debug("Shutdown signal received, stopping audio thread.")
return # Exit thread
audio_chunk_array = mic_in.record(block_size).reshape(-1)
audio_chunk = (
(np.clip(audio_chunk_array, -1.0, 1.0) * 32767.0)
.astype("<i2")
.tobytes()
)
if state.satellite is None:
time.sleep(0.01)
continue
if (not wake_words) or (state.wake_words_changed):
state.wake_words_changed = False
wake_words = [
ww for ww in state.wake_words.values()
if ww.id in state.active_wake_words
]
has_oww = any(isinstance(ww, OpenWakeWord) for ww in wake_words)
if has_oww and (oww_features is None):
_LOGGER.debug("Initializing OpenWakeWord features...")
oww_features = OpenWakeWordFeatures.from_builtin()
try:
micro_inputs.clear(); micro_inputs.extend(micro_features.process_streaming(audio_chunk))
if has_oww:
assert oww_features is not None
oww_inputs.clear(); oww_inputs.extend(oww_features.process_streaming(audio_chunk))
for wake_word in wake_words:
activated = False
if isinstance(wake_word, MicroWakeWord):
if any(wake_word.process_streaming(mi) for mi in micro_inputs): activated = True
elif isinstance(wake_word, OpenWakeWord):
if any(p > 0.5 for oi in oww_inputs for p in wake_word.process_streaming(oi)): activated = True
if activated:
now = time.monotonic()
if (last_active is None) or ((now - last_active) > state.refractory_seconds):
state.loop.call_soon_threadsafe(state.satellite.wakeup, wake_word)
last_active = now
stopped = False
for micro_input in micro_inputs:
if state.stop_word.process_streaming(micro_input):
stopped = True
if stopped and (state.stop_word.id in state.active_wake_words):
state.loop.call_soon_threadsafe(state.satellite.stop)
state.loop.call_soon_threadsafe(state.satellite.handle_audio, audio_chunk)
except Exception:
_LOGGER.exception("Unexpected error handling audio")
except Exception as e:
_LOGGER.critical("A soundcard error occurred: %s", e)
state.loop.call_soon_threadsafe(state.loop.stop)
# -----------------------------------------------------------------------------
if __name__ == "__main__": if __name__ == "__main__":
print("--- __main__ block executing ---")
asyncio.run(main()) asyncio.run(main())
+163
View File
@@ -0,0 +1,163 @@
"""Audio processing engine."""
import asyncio
import logging
import threading
import time
import warnings
from typing import List, Optional, Union
import numpy as np
import soundcard as sc
from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures
from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures
from .models import ServerState
_LOGGER = logging.getLogger(__name__)
# Suppress the "log(0)" warning from pymicro_wakeword
warnings.filterwarnings("ignore", category=RuntimeWarning, module="pymicro_wakeword")
class AudioEngine:
def __init__(self, state: ServerState, mic, block_size: int):
self.state = state
self.mic = mic
self.block_size = block_size
self._thread: Optional[threading.Thread] = None
def start(self):
"""Starts the audio processing thread."""
self._thread = threading.Thread(
target=self._process_audio,
daemon=True,
name="AudioEngineThread"
)
self._thread.start()
def stop(self):
"""Stops the audio processing thread."""
self.state.shutdown = True
# Ensure thread wakes up if waiting
self.state.mic_muted_event.set()
if self._thread:
self._thread.join()
def _process_audio(self):
"""Main audio processing loop."""
_LOGGER.debug("Audio engine started.")
micro_features = MicroWakeWordFeatures()
oww_features: Optional[OpenWakeWordFeatures] = None
wake_words: List[Union[MicroWakeWord, OpenWakeWord]] = []
micro_inputs: List[np.ndarray] = []
oww_inputs: List[np.ndarray] = []
has_oww = False
last_active: Optional[float] = None
try:
while True:
# --- Handle Mute State ---
if not self.state.mic_muted_event.is_set():
_LOGGER.debug("Audio thread muted. Waiting...")
self.state.mic_muted_event.wait()
if self.state.shutdown:
return
# Unmuted: Clear buffers and prepare
_LOGGER.debug("Audio thread unmuted. Flushing...")
micro_features.reset()
if oww_features is not None:
oww_features.reset()
# FIX: Allow immediate activation (don't set to 'now')
last_active = None
# Flush hardware buffer
try:
with self.mic.recorder(samplerate=16000, channels=1, blocksize=self.block_size) as mic_in:
mic_in.flush()
except Exception as e:
_LOGGER.warning("Error flushing mic: %s", e)
if self.state.shutdown:
return
# --- Main Recording Loop ---
_LOGGER.debug("Opening audio input device: %s", self.mic.name)
with self.mic.recorder(samplerate=16000, channels=1, blocksize=self.block_size) as mic_in:
while self.state.mic_muted_event.is_set():
if self.state.shutdown:
return
audio_chunk_array = mic_in.record(self.block_size).reshape(-1)
audio_chunk = (
(np.clip(audio_chunk_array, -1.0, 1.0) * 32767.0)
.astype("<i2")
.tobytes()
)
if self.state.satellite is None:
time.sleep(0.01)
continue
# Update active wake words if changed
if (not wake_words) or (self.state.wake_words_changed):
self.state.wake_words_changed = False
wake_words = [
ww for ww in self.state.wake_words.values()
if ww.id in self.state.active_wake_words
]
has_oww = any(isinstance(ww, OpenWakeWord) for ww in wake_words)
if has_oww and (oww_features is None):
_LOGGER.debug("Initializing OpenWakeWord features...")
oww_features = OpenWakeWordFeatures.from_builtin()
try:
# Feature extraction
micro_inputs.clear()
micro_inputs.extend(micro_features.process_streaming(audio_chunk))
if has_oww and oww_features:
oww_inputs.clear()
oww_inputs.extend(oww_features.process_streaming(audio_chunk))
# Detection
for wake_word in wake_words:
activated = False
if isinstance(wake_word, MicroWakeWord):
if any(wake_word.process_streaming(mi) for mi in micro_inputs):
activated = True
elif isinstance(wake_word, OpenWakeWord):
if any(p > 0.5 for oi in oww_inputs for p in wake_word.process_streaming(oi)):
activated = True
if activated:
now = time.monotonic()
if (last_active is None) or ((now - last_active) > self.state.refractory_seconds):
self.state.loop.call_soon_threadsafe(self.state.satellite.wakeup, wake_word)
last_active = now
# Stop word detection
stopped = False
for micro_input in micro_inputs:
if self.state.stop_word.process_streaming(micro_input):
stopped = True
if stopped and (self.state.stop_word.id in self.state.active_wake_words):
self.state.loop.call_soon_threadsafe(self.state.satellite.stop)
# Pass audio to satellite (for voice streaming)
self.state.loop.call_soon_threadsafe(self.state.satellite.handle_audio, audio_chunk)
except Exception:
_LOGGER.exception("Unexpected error handling audio")
except Exception as e:
_LOGGER.critical("A soundcard error occurred: %s", e)
self.state.loop.call_soon_threadsafe(self.state.loop.stop)
+14 -1
View File
@@ -3,6 +3,7 @@
import asyncio import asyncio
import json import json
import logging import logging
import threading
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
@@ -13,7 +14,7 @@ from .event_bus import EventBus
if TYPE_CHECKING: 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 ESPHomeEntity
from .mpv_player import MpvMediaPlayer from .mpv_player import MpvMediaPlayer
from .satellite import VoiceSatelliteProtocol from .satellite import VoiceSatelliteProtocol
@@ -92,7 +93,19 @@ class ServerState:
refractory_seconds: float = 2.0 refractory_seconds: float = 2.0
mic_muted: bool = False mic_muted: bool = False
shutdown: bool = False shutdown: bool = False
# Threading event to pause the audio thread efficiently when muted
# set() = Mic is ON (Audio processing running)
# clear() = Mic is OFF (Audio processing paused)
mic_muted_event: threading.Event = field(default_factory=threading.Event)
def __post_init__(self):
"""Ensure the threading event matches the boolean state on init."""
if not self.mic_muted:
self.mic_muted_event.set()
else:
self.mic_muted_event.clear()
def save_preferences(self) -> None: def save_preferences(self) -> None:
"""Save preferences as JSON.""" """Save preferences as JSON."""
_LOGGER.debug("Saving preferences: %s", self.preferences_path) _LOGGER.debug("Saving preferences: %s", self.preferences_path)
+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,
+66 -5
View File
@@ -1,15 +1,76 @@
"""Utility methods.""" """Utility methods."""
import logging
import uuid import uuid
from collections.abc import Callable from collections.abc import Callable
from pathlib import Path
from typing import Optional from typing import Optional
_LOGGER = logging.getLogger(__name__)
_CACHED_MAC: Optional[str] = None
def get_mac() -> str:
"""Get MAC address as a string.""" def get_mac_address() -> str:
return ":".join( """
f"{((uuid.getnode() >> i) & 0xFF):02x}" for i in range(0, 8 * 6, 8) Get the MAC address as a hex string (lowercase, no colons).
)[::-1] Example: "b827eb123456"
This method prioritizes physical Linux interfaces to ensure stability
across reboots, avoiding uuid.getnode()'s random fallback.
"""
global _CACHED_MAC
if _CACHED_MAC:
return _CACHED_MAC
# 1. Try reading from sysfs (Linux specific, most stable)
# We look for common interface names first.
interfaces = ["wlan0", "eth0", "end0", "enp", "wlp"]
sys_net = Path("/sys/class/net")
if sys_net.exists():
# Sort to prefer 'wlan0' or 'eth0' over others
found_devs = sorted([p.name for p in sys_net.iterdir()])
# Reorder to prioritize preferred interfaces
target_devs = []
for pref in interfaces:
for dev in found_devs:
if dev.startswith(pref):
target_devs.append(dev)
# Add any remaining devices (excluding loopback)
for dev in found_devs:
if dev not in target_devs and dev != "lo":
target_devs.append(dev)
for dev_name in target_devs:
try:
addr_path = sys_net / dev_name / "address"
if addr_path.exists():
mac_str = addr_path.read_text().strip().replace(":", "").lower()
if len(mac_str) == 12:
_LOGGER.debug(f"Found MAC address from {dev_name}: {mac_str}")
_CACHED_MAC = mac_str
return _CACHED_MAC
except Exception:
continue
# 2. Fallback to uuid.getnode()
_LOGGER.warning("Could not find MAC from sysfs, falling back to uuid.getnode()")
node = uuid.getnode()
# Check if the multicast bit is set (indicates a random MAC)
if (node >> 40) & 1:
_LOGGER.warning("Generated MAC address is RANDOM/MULTICAST. Discovery identity will change on reboot.")
mac_hex = f"{node:012x}"
_CACHED_MAC = mac_hex
return _CACHED_MAC
def format_mac(mac: str) -> str:
"""Format a hex MAC string with colons (e.g., aa:bb:cc...)."""
return ":".join(mac[i : i + 2] for i in range(0, 12, 2))
def call_all(*callables: Optional[Callable[[], None]]) -> None: def call_all(*callables: Optional[Callable[[], None]]) -> None:
+17 -20
View File
@@ -2,9 +2,10 @@
import logging import logging
import socket import socket
import uuid
from typing import Optional from typing import Optional
from .util import get_mac_address
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
try: try:
@@ -21,21 +22,27 @@ class HomeAssistantZeroconf:
self, port: int, name: Optional[str] = None, host: Optional[str] = None self, port: int, name: Optional[str] = None, host: Optional[str] = None
) -> None: ) -> None:
self.port = port self.port = port
self.name = name or _get_mac_address() # Use the stable MAC address
self.mac_address = get_mac_address()
self.name = name or self.mac_address
if not host: if not host:
test_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try:
test_sock.setblocking(False) test_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
test_sock.connect((MDNS_TARGET_IP, 1)) test_sock.setblocking(False)
host = test_sock.getsockname()[0] test_sock.connect((MDNS_TARGET_IP, 1))
_LOGGER.debug("Detected IP: %s", host) host = test_sock.getsockname()[0]
test_sock.close()
_LOGGER.debug("Detected IP: %s", host)
except Exception:
_LOGGER.warning("Could not detect IP address, mDNS might fail.")
host = "127.0.0.1"
assert host assert host
self.host = host self.host = host
self._aiozc = AsyncZeroconf() self._aiozc = AsyncZeroconf()
async def register_server(self) -> None: async def register_server(self) -> None:
service_info = AsyncServiceInfo( service_info = AsyncServiceInfo(
"_esphomelib._tcp.local.", "_esphomelib._tcp.local.",
f"{self.name}._esphomelib._tcp.local.", f"{self.name}._esphomelib._tcp.local.",
@@ -43,22 +50,12 @@ class HomeAssistantZeroconf:
port=self.port, port=self.port,
properties={ properties={
"version": "2025.9.0", "version": "2025.9.0",
"mac": _get_mac_address(), "mac": self.mac_address,
"board": "host", "board": "host",
"platform": "HOST", "platform": "HOST",
"network": "ethernet", # or "wifi" "network": "ethernet",
}, },
server=f"{self.name}.local.", server=f"{self.name}.local.",
) )
await self._aiozc.async_register_service(service_info) await self._aiozc.async_register_service(service_info)
_LOGGER.debug("Zeroconf discovery enabled: %s", service_info) _LOGGER.debug("Zeroconf discovery enabled: %s", service_info)
def _get_mac_address() -> str:
"""Return MAC address formatted as hex with no colons."""
return "".join(
# pylint: disable=consider-using-f-string
["{:02x}".format((uuid.getnode() >> ele) & 0xFF) for ele in range(0, 8 * 6, 8)][
::-1
]
)