File size: 7,727 Bytes
99f80f8 b8cfa60 99f80f8 b8cfa60 5fb657a b8cfa60 9a11a2a b8cfa60 99f80f8 b8cfa60 99f80f8 b8cfa60 9a11a2a b8cfa60 99f80f8 b8cfa60 9a11a2a b8cfa60 9a11a2a b8cfa60 99f80f8 5fb657a 99f80f8 6601767 99f80f8 5fb657a 99f80f8 5fb657a 99f80f8 5fb657a 99f80f8 5fb657a 99f80f8 5fb657a 99f80f8 5fb657a 99f80f8 5fb657a 99f80f8 5fb657a 99f80f8 5fb657a 99f80f8 5fb657a 99f80f8 5fb657a 99f80f8 5fb657a 99f80f8 5fb657a 99f80f8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 | """Runs mDNS zeroconf services for Home Assistant and Sendspin discovery."""
import asyncio
import logging
import socket
from typing import Any, Callable, Coroutine, Optional
from .util import get_mac
_LOGGER = logging.getLogger(__name__)
try:
from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf, AsyncServiceBrowser
ZEROCONF_AVAILABLE = True
except ImportError:
_LOGGER.fatal("pip install zeroconf")
raise
MDNS_TARGET_IP = "224.0.0.251"
# Sendspin mDNS service type
SENDSPIN_SERVICE_TYPE = "_sendspin-server._tcp.local."
SENDSPIN_DEFAULT_PATH = "/sendspin"
def get_local_ip() -> str:
"""Get local IP address for mDNS."""
test_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
test_sock.setblocking(False)
try:
test_sock.connect((MDNS_TARGET_IP, 1))
return test_sock.getsockname()[0]
except Exception:
return "127.0.0.1"
finally:
test_sock.close()
class HomeAssistantZeroconf:
"""Zeroconf service for Home Assistant discovery."""
def __init__(
self, port: int, name: Optional[str] = None, host: Optional[str] = None
) -> None:
self.port = port
self.name = name or f"reachy-mini-{get_mac()[:6]}"
if not host:
host = get_local_ip()
_LOGGER.debug("Detected IP: %s", host)
assert host
self.host = host
self._aiozc = AsyncZeroconf()
async def register_server(self) -> None:
mac_address = get_mac()
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": mac_address,
"board": "reachy_mini",
"platform": "REACHY_MINI",
"network": "ethernet",
},
server=f"{self.name}.local.",
)
await self._aiozc.async_register_service(service_info)
_LOGGER.debug("Zeroconf discovery enabled: %s", service_info)
async def unregister_server(self) -> None:
await self._aiozc.async_close()
class SendspinDiscovery:
"""mDNS discovery for Sendspin servers.
Discovers Sendspin servers on the local network and notifies via callback
when a server is found.
"""
def __init__(self, on_server_found: Callable[[str], "Coroutine[Any, Any, None]"]) -> None:
"""Initialize Sendspin discovery.
Args:
on_server_found: Async callback called with server URL when discovered.
"""
self._on_server_found = on_server_found
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._zeroconf: Optional[AsyncZeroconf] = None
self._browser: Optional["AsyncServiceBrowser"] = None
self._discovery_task: Optional[asyncio.Task] = None
self._running = False
@property
def is_running(self) -> bool:
"""Check if discovery is running."""
return self._running
async def start(self) -> None:
"""Start mDNS discovery for Sendspin servers."""
if self._running:
_LOGGER.debug("Sendspin discovery already running")
return
_LOGGER.info("Starting Sendspin server discovery...")
self._loop = asyncio.get_running_loop()
self._running = True
self._discovery_task = asyncio.create_task(self._discover_loop())
async def _discover_loop(self) -> None:
"""Background task to discover Sendspin servers."""
try:
self._zeroconf = AsyncZeroconf()
await self._zeroconf.__aenter__()
listener = _SendspinServiceListener(self)
self._browser = AsyncServiceBrowser(
self._zeroconf.zeroconf,
SENDSPIN_SERVICE_TYPE,
listener,
)
_LOGGER.info("Sendspin discovery started, waiting for servers...")
# Keep running until stopped
while self._running:
await asyncio.sleep(60)
except asyncio.CancelledError:
_LOGGER.debug("Sendspin discovery cancelled")
except Exception as e:
_LOGGER.error("Sendspin discovery error: %s", e)
finally:
await self._cleanup()
async def _cleanup(self) -> None:
"""Clean up discovery resources."""
if self._browser:
await self._browser.async_cancel()
self._browser = None
if self._zeroconf:
await self._zeroconf.__aexit__(None, None, None)
self._zeroconf = None
self._running = False
async def stop(self) -> None:
"""Stop Sendspin discovery."""
self._running = False
if self._discovery_task is not None:
self._discovery_task.cancel()
try:
await self._discovery_task
except asyncio.CancelledError:
pass
self._discovery_task = None
await self._cleanup()
self._loop = None
_LOGGER.info("Sendspin discovery stopped")
async def _handle_service_found(self, url: str) -> None:
"""Handle discovered service."""
try:
await self._on_server_found(url)
except Exception as e:
_LOGGER.error("Error in Sendspin server callback: %s", e)
class _SendspinServiceListener:
"""Listener for Sendspin server mDNS advertisements."""
def __init__(self, discovery: SendspinDiscovery) -> None:
self._discovery = discovery
def _build_url(self, host: str, port: int, properties: dict) -> str:
"""Build WebSocket URL from service info."""
path_raw = properties.get(b"path")
path = path_raw.decode("utf-8", "ignore") if isinstance(path_raw, bytes) else SENDSPIN_DEFAULT_PATH
if not path:
path = SENDSPIN_DEFAULT_PATH
if not path.startswith("/"):
path = "/" + path
host_fmt = f"[{host}]" if ":" in host else host
return f"ws://{host_fmt}:{port}{path}"
def add_service(self, zeroconf, service_type: str, name: str) -> None:
"""Called when a Sendspin server is discovered."""
if self._discovery._loop is None:
return
asyncio.run_coroutine_threadsafe(
self._process_service(zeroconf, service_type, name),
self._discovery._loop,
)
def update_service(self, zeroconf, service_type: str, name: str) -> None:
"""Called when a Sendspin server is updated."""
self.add_service(zeroconf, service_type, name)
def remove_service(self, zeroconf, service_type: str, name: str) -> None:
"""Called when a Sendspin server goes offline."""
_LOGGER.info("Sendspin server removed: %s", name)
async def _process_service(self, zeroconf, service_type: str, name: str) -> None:
"""Process discovered service and notify callback."""
try:
azc = AsyncZeroconf(zc=zeroconf)
info = await azc.async_get_service_info(service_type, name)
if info is None or info.port is None:
return
addresses = info.parsed_addresses()
if not addresses:
return
host = addresses[0]
url = self._build_url(host, info.port, info.properties)
_LOGGER.info("Discovered Sendspin server: %s at %s", name, url)
# Notify via callback
await self._discovery._handle_service_found(url)
except Exception as e:
_LOGGER.warning("Error processing Sendspin service %s: %s", name, e)
|