diff --git a/linux_voice_assistant/__main__.py b/linux_voice_assistant/__main__.py index 764e2be..707fc89 100644 --- a/linux_voice_assistant/__main__.py +++ b/linux_voice_assistant/__main__.py @@ -4,25 +4,25 @@ import asyncio import json import logging import sys -import threading -import time from dataclasses import dataclass 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 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 .led_controller import LedController from .mqtt_controller import MqttController from .models import AvailableWakeWord, Preferences, ServerState, WakeWordType from .mpv_player import MpvMediaPlayer from .satellite import VoiceSatelliteProtocol -from .util import get_mac +from .util import get_mac_address, format_mac from .zeroconf import HomeAssistantZeroconf _LOGGER = logging.getLogger(__name__) @@ -73,6 +73,13 @@ class MicMuteHandler(EventHandler): is_muted = data.get("state", False) if 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) if self.mqtt_controller: @@ -120,13 +127,9 @@ async def main() -> None: # --- 7. Initialize Controllers --- _init_controllers(loop, event_bus, state, config, preferences) - # --- 8. Start Audio Thread --- - audio_thread = threading.Thread( - target=_process_audio, - args=(state, mic, config.audio.input_block_size), - daemon=True - ) - audio_thread.start() + # --- 8. Start Audio Engine --- + audio_engine = AudioEngine(state, mic, config.audio.input_block_size) + audio_engine.start() # --- 9. Run Server --- try: @@ -134,8 +137,7 @@ async def main() -> None: finally: # --- 10. Cleanup --- _LOGGER.debug("Shutting down...") - state.shutdown = True # Signal audio thread to stop - audio_thread.join() + audio_engine.stop() if hasattr(state, "mqtt_controller") and state.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") args = parser.parse_args() - # --- List devices and exit --- if args.list_input_devices: print("Input devices\n" + "=" * 13) - for idx, mic in enumerate(sc.all_microphones(include_loopback=False)): - print(f"[{idx}]", mic.name) + try: + 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) 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) sys.exit(1) - # --- Load config --- config_path = args.config if not config_path.is_absolute(): config_path = _REPO_DIR / config_path @@ -184,10 +187,12 @@ def _init_basics() -> Tuple[Config, asyncio.AbstractEventLoop, EventBus]: if args.debug: config.app.debug = True - # --- Setup logging --- - logging.basicConfig(level=logging.DEBUG if config.app.debug else logging.INFO) + logging.basicConfig( + 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.debug("Configuration loaded: %s", config) loop = asyncio.get_running_loop() event_bus = EventBus() @@ -220,7 +225,7 @@ def _get_microphone(config: Config): mic = sc.default_microphone() if mic is None: - _LOGGER.critical("No microphone found. Use --list-input-devices to see options.") + _LOGGER.critical("No microphone found.") sys.exit(1) _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: """Loads all available and active wake word models.""" - if not config.wake_word.directories: config.wake_word.directories = ["wakewords", "wakewords/openWakeWord"] download_dir = _REPO_DIR / config.wake_word.download_dir 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.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", []), wake_word_path=wake_word_path, ) - _LOGGER.debug("Available wake words: %s", list(sorted(available.keys()))) active: Set[str] = set() models: Dict[str, Union[MicroWakeWord, OpenWakeWord]] = {} @@ -272,14 +274,12 @@ def _load_wake_words(config: Config, preferences: Preferences) -> WakeWordData: if preferences.active_wake_words: for ww_id in preferences.active_wake_words: if ww_id in available: - _LOGGER.debug("Loading wake model (from preferences): %s", ww_id) models[ww_id] = available[ww_id].load() active.add(ww_id) if not models: ww_id = config.wake_word.model if ww_id in available: - _LOGGER.debug("Loading wake model (from config): %s", ww_id) models[ww_id] = available[ww_id].load() 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: stop_config_path = _REPO_DIR / ww_dir_str / f"{config.wake_word.stop_model}.json" if stop_config_path.exists(): - _LOGGER.debug("Loading stop model: %s", stop_config_path) stop_model = MicroWakeWord.from_config(stop_config_path) break 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: """Initializes the music and TTS media players.""" - _LOGGER.debug("Initializing media players") music_player = MpvMediaPlayer( loop=loop, device=config.audio.output_device, @@ -318,10 +316,10 @@ def _create_server_state( media_players: MediaPlayers, ) -> ServerState: """Creates the global ServerState object.""" - _LOGGER.debug("Creating server state") + stable_mac = format_mac(get_mac_address()) return ServerState( name=config.app.name, - mac_address=get_mac(), + mac_address=stable_mac, event_bus=event_bus, loop=loop, entities=[], @@ -347,8 +345,6 @@ def _init_controllers( preferences: Preferences, ): """Initializes all decoupled controllers.""" - _LOGGER.debug("Initializing controllers") - led_controller = LedController( loop=loop, event_bus=event_bus, @@ -377,7 +373,6 @@ def _init_controllers( async def _run_server(state: ServerState, config: Config): """Starts the ESPHome server and ZeroConf discovery.""" - _LOGGER.debug("Starting ESPHome server") server = await state.loop.create_server( lambda: VoiceSatelliteProtocol(state), host=config.esphome.host, @@ -391,116 +386,5 @@ async def _run_server(state: ServerState, config: Config): 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(" 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__": - print("--- __main__ block executing ---") asyncio.run(main()) diff --git a/linux_voice_assistant/audio_engine.py b/linux_voice_assistant/audio_engine.py new file mode 100644 index 0000000..546b91e --- /dev/null +++ b/linux_voice_assistant/audio_engine.py @@ -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(" 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) diff --git a/linux_voice_assistant/models.py b/linux_voice_assistant/models.py index 2114ca6..8ae13d6 100644 --- a/linux_voice_assistant/models.py +++ b/linux_voice_assistant/models.py @@ -3,6 +3,7 @@ import asyncio import json import logging +import threading from dataclasses import asdict, dataclass, field from enum import Enum from pathlib import Path @@ -13,7 +14,7 @@ from .event_bus import EventBus if TYPE_CHECKING: from pymicro_wakeword import MicroWakeWord from pyopen_wakeword import OpenWakeWord - from .entity import ESPHomeEntity, MediaPlayerEntity + from .entity import ESPHomeEntity from .mpv_player import MpvMediaPlayer from .satellite import VoiceSatelliteProtocol @@ -92,7 +93,19 @@ class ServerState: refractory_seconds: float = 2.0 mic_muted: 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: """Save preferences as JSON.""" _LOGGER.debug("Saving preferences: %s", self.preferences_path) diff --git a/linux_voice_assistant/satellite.py b/linux_voice_assistant/satellite.py index e9bb763..6e081a9 100644 --- a/linux_voice_assistant/satellite.py +++ b/linux_voice_assistant/satellite.py @@ -1,5 +1,7 @@ """Voice satellite protocol.""" +import asyncio +import functools import hashlib import logging import posixpath @@ -7,7 +9,7 @@ import shutil import time from collections.abc import Iterable 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.request import urlopen @@ -51,7 +53,7 @@ class VoiceSatelliteProtocol(APIServer): def __init__(self, state: ServerState) -> None: super().__init__(state.name) self.state = state - self.state.satellite = self # <-- ADDED BACK + self.state.satellite = self self.media_player_entity = MediaPlayerEntity( server=self, @@ -159,11 +161,16 @@ class VoiceSatelliteProtocol(APIServer): self._play_timer_finished() 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): data: Dict[str, str] = {} for arg in msg.data: data[arg.name] = arg.value self.handle_voice_event(VoiceAssistantEventType(msg.event_type), data) + elif isinstance(msg, VoiceAssistantAnnounceRequest): _LOGGER.debug("Announcing: %s", msg.text) urls = [] @@ -177,8 +184,10 @@ class VoiceSatelliteProtocol(APIServer): yield from self.media_player_entity.play( urls, announcement=True, done_callback=self._tts_finished ) + elif isinstance(msg, VoiceAssistantTimerEventResponse): self.handle_timer_event(VoiceAssistantTimerEventType(msg.event_type), msg) + elif isinstance(msg, DeviceInfoRequest): yield DeviceInfoResponse( uses_password=False, @@ -192,6 +201,7 @@ class VoiceSatelliteProtocol(APIServer): | VoiceAssistantFeature.TIMERS ), ) + elif isinstance( msg, ( @@ -205,6 +215,7 @@ class VoiceSatelliteProtocol(APIServer): if isinstance(msg, ListEntitiesRequest): yield ListEntitiesDoneResponse() + elif isinstance(msg, VoiceAssistantConfigurationRequest): available_wake_words = [ VoiceAssistantWakeWord( @@ -242,40 +253,49 @@ class VoiceSatelliteProtocol(APIServer): _LOGGER.info("Connected to Home Assistant") self._set_state(SatelliteState.IDLE) self.state.event_bus.publish("ha_connected") + elif isinstance(msg, VoiceAssistantSetConfiguration): - active_wake_words: Set[str] = set() - for wake_word_id in msg.active_wake_words: - if wake_word_id in self.state.wake_words: - 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 + # FIX: Offload to background task to prevent blocking the loop during download + self.state.loop.create_task(self._handle_set_configuration_task(msg)) + # Yield nothing immediately; response will be sent asynchronously - 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() - - _LOGGER.info("Wake word set: %s", wake_word_id) + async def _handle_set_configuration_task(self, msg: VoiceAssistantSetConfiguration) -> None: + """Asynchronous handler for SetConfiguration to avoid blocking I/O.""" + active_wake_words: Set[str] = set() + + for wake_word_id in msg.active_wake_words: + if wake_word_id in self.state.wake_words: 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 - _LOGGER.debug("Active wake words: %s", active_wake_words) + 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 + + # 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) - self.state.save_preferences() - self.state.wake_words_changed = True + _LOGGER.info("Wake word set: %s", wake_word_id) + active_wake_words.add(wake_word_id) + 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: 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 ) -> 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.mkdir(parents=True, exist_ok=True) @@ -387,17 +417,21 @@ class VoiceSatelliteProtocol(APIServer): if should_download_config or should_download_model: _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 + try: + with urlopen(external_wake_word.url, timeout=10) 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) + with open(config_path, "wb") as model_file: + shutil.copyfileobj(request, model_file) + except Exception as e: + _LOGGER.error("Exception downloading config: %s", e) + return None if should_download_model: parsed_url = urlparse(external_wake_word.url) @@ -407,15 +441,19 @@ class VoiceSatelliteProtocol(APIServer): 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 + try: + with urlopen(model_url, timeout=10) 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) + with open(model_path, "wb") as model_file: + shutil.copyfileobj(request, model_file) + except Exception as e: + _LOGGER.error("Exception downloading model: %s", e) + return None return AvailableWakeWord( id=external_wake_word.id, diff --git a/linux_voice_assistant/util.py b/linux_voice_assistant/util.py index 31e184b..a55f0a3 100644 --- a/linux_voice_assistant/util.py +++ b/linux_voice_assistant/util.py @@ -1,15 +1,76 @@ """Utility methods.""" +import logging import uuid from collections.abc import Callable +from pathlib import Path from typing import Optional +_LOGGER = logging.getLogger(__name__) +_CACHED_MAC: Optional[str] = None -def get_mac() -> str: - """Get MAC address as a string.""" - return ":".join( - f"{((uuid.getnode() >> i) & 0xFF):02x}" for i in range(0, 8 * 6, 8) - )[::-1] + +def get_mac_address() -> str: + """ + Get the MAC address as a hex string (lowercase, no colons). + 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: diff --git a/linux_voice_assistant/zeroconf.py b/linux_voice_assistant/zeroconf.py index 2477f0c..3987872 100644 --- a/linux_voice_assistant/zeroconf.py +++ b/linux_voice_assistant/zeroconf.py @@ -2,9 +2,10 @@ import logging import socket -import uuid from typing import Optional +from .util import get_mac_address + _LOGGER = logging.getLogger(__name__) try: @@ -21,21 +22,27 @@ class HomeAssistantZeroconf: self, port: int, name: Optional[str] = None, host: Optional[str] = None ) -> None: 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: - test_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - test_sock.setblocking(False) - test_sock.connect((MDNS_TARGET_IP, 1)) - host = test_sock.getsockname()[0] - _LOGGER.debug("Detected IP: %s", host) + try: + test_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + test_sock.setblocking(False) + test_sock.connect((MDNS_TARGET_IP, 1)) + 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 self.host = host self._aiozc = AsyncZeroconf() async def register_server(self) -> None: - service_info = AsyncServiceInfo( "_esphomelib._tcp.local.", f"{self.name}._esphomelib._tcp.local.", @@ -43,22 +50,12 @@ class HomeAssistantZeroconf: port=self.port, properties={ "version": "2025.9.0", - "mac": _get_mac_address(), + "mac": self.mac_address, "board": "host", "platform": "HOST", - "network": "ethernet", # or "wifi" + "network": "ethernet", }, server=f"{self.name}.local.", ) await self._aiozc.async_register_service(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 - ] - )