diff --git a/CHANGELOG.md b/CHANGELOG.md index fcd2098..b791720 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Add zeroconf/mDNS discovery - Support openWakeWord - Support multiple wake words - Save active wake words to preferences JSON file diff --git a/linux_voice_assistant/__main__.py b/linux_voice_assistant/__main__.py index f49c431..8ec3e72 100644 --- a/linux_voice_assistant/__main__.py +++ b/linux_voice_assistant/__main__.py @@ -18,6 +18,7 @@ from .mpv_player import MpvMediaPlayer from .openwakeword import OpenWakeWord, OpenWakeWordFeatures from .satellite import VoiceSatelliteProtocol from .util import get_mac, is_arm +from .zeroconf import HomeAssistantZeroconf _LOGGER = logging.getLogger(__name__) _MODULE_DIR = Path(__file__).parent @@ -204,6 +205,10 @@ async def main() -> None: lambda: VoiceSatelliteProtocol(state), host=args.host, port=args.port ) + # Auto discovery (zeroconf, mDNS) + discovery = HomeAssistantZeroconf(port=args.port, name=args.name) + await discovery.register_server() + try: _LOGGER.debug("Opening audio input device: %s", args.audio_input_device) with sd.RawInputStream( @@ -254,7 +259,7 @@ def process_audio(state: ServerState): if (not wake_words) or (state.wake_words_changed and state.wake_words): # Update list of wake word models to process state.wake_words_changed = False - wake_words = list(state.wake_words.values()) + wake_words = [ww for ww in state.wake_words.values() if ww.is_active] has_oww = False for wake_word in wake_words: @@ -286,13 +291,10 @@ def process_audio(state: ServerState): oww_inputs.extend(oww_features.process_streaming(audio_chunk)) for wake_word in wake_words: - if not wake_word.is_active: - continue - activated = False if isinstance(wake_word, MicroWakeWord): - for oww_input in oww_inputs: - if wake_word.process_streaming(oww_input): + for micro_input in micro_inputs: + if wake_word.process_streaming(micro_input): activated = True elif isinstance(wake_word, OpenWakeWord): for oww_input in oww_inputs: diff --git a/linux_voice_assistant/satellite.py b/linux_voice_assistant/satellite.py index e43b168..8b48e6d 100644 --- a/linux_voice_assistant/satellite.py +++ b/linux_voice_assistant/satellite.py @@ -176,7 +176,9 @@ class VoiceSatelliteProtocol(APIServer): ) for ww in self.state.available_wake_words.values() ], - active_wake_words=list(self.state.wake_words.keys()), + active_wake_words=[ + ww.id for ww in self.state.wake_words.values() if ww.is_active + ], max_active_wake_words=2, ) _LOGGER.info("Connected to Home Assistant") diff --git a/linux_voice_assistant/zeroconf.py b/linux_voice_assistant/zeroconf.py new file mode 100644 index 0000000..2477f0c --- /dev/null +++ b/linux_voice_assistant/zeroconf.py @@ -0,0 +1,64 @@ +"""Runs mDNS zeroconf service for Home Assistant discovery.""" + +import logging +import socket +import uuid +from typing import Optional + +_LOGGER = logging.getLogger(__name__) + +try: + from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf +except ImportError: + _LOGGER.fatal("pip install zeroconf") + raise + +MDNS_TARGET_IP = "224.0.0.251" + + +class HomeAssistantZeroconf: + def __init__( + self, port: int, name: Optional[str] = None, host: Optional[str] = None + ) -> None: + self.port = port + self.name = name or _get_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) + + 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.", + addresses=[socket.inet_aton(self.host)], + port=self.port, + properties={ + "version": "2025.9.0", + "mac": _get_mac_address(), + "board": "host", + "platform": "HOST", + "network": "ethernet", # or "wifi" + }, + 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 + ] + ) diff --git a/pyproject.toml b/pyproject.toml index 092085c..ca54bd1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "numpy>=2,<3", "pymicro-features==1.0.0", "python-mpv>=1,<2", + "zeroconf", ] [project.optional-dependencies]