mdns_discovery.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. """mDNS (zeroconf) advertisement and discovery of Dune Weaver tables.
  2. Each backend advertises itself as a `_dune-weaver._tcp.local.` service and
  3. browses for peers on the LAN. Discovered peers are exposed to the frontend
  4. via GET /api/discovered-tables so users never have to type IP addresses.
  5. Discovery is best-effort: if the zeroconf package is missing or the network
  6. doesn't support multicast, the app runs normally without it (graceful
  7. degradation, same pattern as the optional LED libraries).
  8. """
  9. import asyncio
  10. import logging
  11. import re
  12. import socket
  13. from typing import Dict, List, Optional
  14. logger = logging.getLogger(__name__)
  15. SERVICE_TYPE = "_dune-weaver._tcp.local."
  16. # FluidNC boards advertise `_http._tcp` with TXT `api=sandtable/1`,
  17. # `model=dune-weaver` (same records dune-weaver-mobile browses for).
  18. HTTP_SERVICE_TYPE = "_http._tcp.local."
  19. RESOLVE_TIMEOUT_MS = 3000
  20. try:
  21. from zeroconf import IPVersion, ServiceInfo, ServiceStateChange
  22. from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf
  23. ZEROCONF_AVAILABLE = True
  24. except ImportError:
  25. ZEROCONF_AVAILABLE = False
  26. def _get_local_ip() -> Optional[str]:
  27. """Best-effort LAN IP detection (no packets are actually sent)."""
  28. sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  29. try:
  30. sock.connect(("8.8.8.8", 80))
  31. return sock.getsockname()[0]
  32. except OSError:
  33. return None
  34. finally:
  35. sock.close()
  36. def _decode_properties(properties: Dict) -> Dict[str, str]:
  37. """Zeroconf TXT records arrive as bytes; decode keys/values to str."""
  38. decoded = {}
  39. for key, value in (properties or {}).items():
  40. if isinstance(key, bytes):
  41. key = key.decode("utf-8", errors="replace")
  42. if isinstance(value, bytes):
  43. value = value.decode("utf-8", errors="replace")
  44. if value is not None:
  45. decoded[key] = value
  46. return decoded
  47. def service_info_to_table(info) -> Optional[dict]:
  48. """Convert a resolved ServiceInfo into a table dict for the API.
  49. Returns None if the service lacks the fields needed to reach it
  50. (an address, a port, and a table id in its TXT records).
  51. """
  52. props = _decode_properties(info.properties)
  53. table_id = props.get("id")
  54. addresses = [a for a in info.parsed_addresses() if ":" not in a] # IPv4 only
  55. if not table_id or not addresses or not info.port:
  56. return None
  57. host = addresses[0]
  58. port = info.port
  59. url = f"http://{host}" if port == 80 else f"http://{host}:{port}"
  60. return {
  61. "id": table_id,
  62. "name": props.get("name") or "Dune Weaver",
  63. "url": url,
  64. "host": host,
  65. "port": port,
  66. "version": props.get("version") or None,
  67. }
  68. def _looks_like_board(label: str, server: str, props: Dict[str, str]) -> bool:
  69. """Does a resolved _http._tcp service look like a Dune Weaver board?"""
  70. model = (props.get("model") or "").lower()
  71. api = (props.get("api") or "").lower()
  72. if model == "dune-weaver" or "sandtable" in api:
  73. return True
  74. # Fallback for firmware builds without TXT records: match the firmware's
  75. # default hostname / service name (same heuristic as the mobile app).
  76. hay = f"{label} {server}".lower()
  77. return bool(re.search(r"fluidnc|dune[-\s]?weaver|sand[-\s]?table", hay))
  78. def service_info_to_board(info) -> Optional[dict]:
  79. """Convert a resolved _http._tcp ServiceInfo into a board dict for the API.
  80. Returns None for services that don't look like a Dune Weaver board or
  81. that lack an IPv4 address.
  82. """
  83. props = _decode_properties(info.properties)
  84. # "DWMP._http._tcp.local." -> "DWMP" (the firmware hostname = table name)
  85. label = info.name.split("._")[0] if "._" in info.name else info.name
  86. server = (info.server or "").rstrip(".")
  87. if not _looks_like_board(label, server, props):
  88. return None
  89. addresses = [a for a in info.parsed_addresses() if ":" not in a] # IPv4 only
  90. if not addresses or not info.port:
  91. return None
  92. host = addresses[0]
  93. port = info.port
  94. hostname = server[: -len(".local")] if server.endswith(".local") else server
  95. return {
  96. "name": label or hostname or host,
  97. "hostname": hostname or None,
  98. "host": host,
  99. "port": port,
  100. "url": f"http://{host}" if port == 80 else f"http://{host}:{port}",
  101. # Stable hardware identity (fw > v0.1.7); None on older firmware.
  102. "mac": (props.get("mac") or "").lower() or None,
  103. }
  104. class TableDiscovery:
  105. """Advertises this table via mDNS and tracks peer tables on the LAN."""
  106. def __init__(self):
  107. self._aiozc = None
  108. self._browser = None
  109. self._board_browser = None
  110. self._service_info = None
  111. self._own_id: Optional[str] = None
  112. # Keyed by mDNS service name so Removed events can evict entries
  113. self._discovered: Dict[str, dict] = {}
  114. self._boards: Dict[str, dict] = {}
  115. @property
  116. def is_running(self) -> bool:
  117. return self._aiozc is not None
  118. def get_tables(self) -> List[dict]:
  119. """Currently visible peer tables (excludes this table)."""
  120. return list(self._discovered.values())
  121. def get_boards(self) -> List[dict]:
  122. """Currently visible FluidNC controller boards (candidates for /connect)."""
  123. return list(self._boards.values())
  124. async def start(self, table_id: str, table_name: str, port: int, version: Optional[str] = None):
  125. if not ZEROCONF_AVAILABLE:
  126. logger.warning("zeroconf package not installed - mDNS table discovery disabled")
  127. return
  128. if self._aiozc:
  129. return
  130. local_ip = _get_local_ip()
  131. if not local_ip:
  132. logger.warning("Could not determine LAN IP - mDNS table discovery disabled")
  133. return
  134. self._own_id = table_id
  135. self._aiozc = AsyncZeroconf(ip_version=IPVersion.V4Only)
  136. # Instance names must be unique per network; the table id suffix
  137. # keeps two tables both named "Dune Weaver" from colliding.
  138. instance = f"{self._instance_label(table_name)}-{table_id[:8]}.{SERVICE_TYPE}"
  139. self._service_info = ServiceInfo(
  140. SERVICE_TYPE,
  141. instance,
  142. addresses=[socket.inet_aton(local_ip)],
  143. port=port,
  144. properties=self._properties(table_id, table_name, version),
  145. server=f"dune-weaver-{table_id[:8]}.local.",
  146. )
  147. try:
  148. await self._aiozc.async_register_service(self._service_info)
  149. self._browser = AsyncServiceBrowser(
  150. self._aiozc.zeroconf, SERVICE_TYPE, handlers=[self._on_service_state_change]
  151. )
  152. self._board_browser = AsyncServiceBrowser(
  153. self._aiozc.zeroconf, HTTP_SERVICE_TYPE, handlers=[self._on_board_state_change]
  154. )
  155. logger.info(f"mDNS: advertising '{table_name}' at {local_ip}:{port} and browsing for peer tables and boards")
  156. except Exception as e:
  157. logger.warning(f"mDNS discovery failed to start: {e}")
  158. await self.stop()
  159. async def update_name(self, table_name: str):
  160. """Re-advertise with a new display name after the table is renamed."""
  161. if not (self._aiozc and self._service_info):
  162. return
  163. try:
  164. # Keep the instance name stable; only the TXT records change
  165. self._service_info = ServiceInfo(
  166. SERVICE_TYPE,
  167. self._service_info.name,
  168. addresses=self._service_info.addresses,
  169. port=self._service_info.port,
  170. properties=self._properties(self._own_id, table_name, self._decoded_own_version()),
  171. server=self._service_info.server,
  172. )
  173. await self._aiozc.async_update_service(self._service_info)
  174. except Exception as e:
  175. logger.warning(f"mDNS: failed to update advertised name: {e}")
  176. async def stop(self):
  177. if not self._aiozc:
  178. return
  179. try:
  180. if self._browser:
  181. await self._browser.async_cancel()
  182. if self._board_browser:
  183. await self._board_browser.async_cancel()
  184. if self._service_info:
  185. await self._aiozc.async_unregister_service(self._service_info)
  186. await self._aiozc.async_close()
  187. except Exception as e:
  188. logger.debug(f"mDNS shutdown error: {e}")
  189. finally:
  190. self._aiozc = None
  191. self._browser = None
  192. self._board_browser = None
  193. self._service_info = None
  194. self._discovered.clear()
  195. self._boards.clear()
  196. @staticmethod
  197. def _properties(table_id: str, table_name: str, version: Optional[str]) -> Dict[str, str]:
  198. return {"id": table_id or "", "name": table_name or "Dune Weaver", "version": version or ""}
  199. def _decoded_own_version(self) -> Optional[str]:
  200. props = _decode_properties(self._service_info.properties) if self._service_info else {}
  201. return props.get("version") or None
  202. @staticmethod
  203. def _instance_label(table_name: str) -> str:
  204. # mDNS instance names allow most characters, but dots would be parsed
  205. # as label separators - keep it to a safe subset.
  206. label = re.sub(r"[^A-Za-z0-9 _-]", "", table_name or "").strip() or "Dune Weaver"
  207. return label[:40]
  208. def _on_service_state_change(self, zeroconf, service_type, name, state_change):
  209. """Sync callback from AsyncServiceBrowser (runs on the event loop)."""
  210. if state_change is ServiceStateChange.Removed:
  211. removed = self._discovered.pop(name, None)
  212. if removed:
  213. logger.info(f"mDNS: table '{removed['name']}' left the network")
  214. return
  215. # Added/Updated: resolve the service asynchronously
  216. asyncio.ensure_future(self._resolve_service(zeroconf, service_type, name))
  217. async def _resolve_service(self, zeroconf, service_type, name):
  218. try:
  219. info = AsyncServiceInfo(service_type, name)
  220. if not await info.async_request(zeroconf, RESOLVE_TIMEOUT_MS):
  221. return
  222. table = service_info_to_table(info)
  223. if not table or table["id"] == self._own_id:
  224. return
  225. is_new = name not in self._discovered
  226. self._discovered[name] = table
  227. if is_new:
  228. logger.info(f"mDNS: discovered table '{table['name']}' at {table['url']}")
  229. except Exception as e:
  230. logger.debug(f"mDNS: failed to resolve {name}: {e}")
  231. def _on_board_state_change(self, zeroconf, service_type, name, state_change):
  232. """Browser callback for _http._tcp (FluidNC controller boards)."""
  233. if state_change is ServiceStateChange.Removed:
  234. removed = self._boards.pop(name, None)
  235. if removed:
  236. logger.info(f"mDNS: board '{removed['name']}' left the network")
  237. return
  238. asyncio.ensure_future(self._resolve_board(zeroconf, service_type, name))
  239. async def _resolve_board(self, zeroconf, service_type, name):
  240. try:
  241. info = AsyncServiceInfo(service_type, name)
  242. if not await info.async_request(zeroconf, RESOLVE_TIMEOUT_MS):
  243. return
  244. board = service_info_to_board(info)
  245. if not board:
  246. return
  247. is_new = name not in self._boards
  248. self._boards[name] = board
  249. if is_new:
  250. logger.info(f"mDNS: discovered board '{board['name']}' at {board['url']}")
  251. except Exception as e:
  252. logger.debug(f"mDNS: failed to resolve board {name}: {e}")
  253. # Module-level singleton, mirroring how other core services are exposed
  254. discovery = TableDiscovery()