discovery.py 3.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  1. """mDNS discovery for Dune Weaver sand tables.
  2. The firmware advertises ``_http._tcp.local`` with TXT records identifying a
  3. sand table (``model=dune-weaver``, ``api=sandtable/1``, ``ws=<port>``). We
  4. browse for those services and hand back a list the UI can pick from. Manual IP
  5. entry remains the fallback (and works even when zeroconf is unavailable).
  6. """
  7. from __future__ import annotations
  8. import asyncio
  9. import logging
  10. from dataclasses import dataclass
  11. logger = logging.getLogger("DuneWeaver.Discovery")
  12. try:
  13. from zeroconf import ServiceStateChange
  14. from zeroconf.asyncio import AsyncServiceBrowser, AsyncZeroconf
  15. ZEROCONF_AVAILABLE = True
  16. except Exception: # pragma: no cover - optional dependency
  17. ZEROCONF_AVAILABLE = False
  18. @dataclass
  19. class DiscoveredTable:
  20. name: str # friendly service name
  21. host: str # e.g. "dunetable.local"
  22. address: str # numeric IP
  23. port: int # HTTP port
  24. ws_port: int # webui-v3 websocket port (from TXT), 0 if absent
  25. @property
  26. def base_url(self) -> str:
  27. # Prefer the numeric address (reliable on kiosks without working mDNS
  28. # resolution); fall back to the .local hostname.
  29. host = self.address or self.host
  30. if self.port and self.port != 80:
  31. return f"http://{host}:{self.port}"
  32. return f"http://{host}"
  33. def _txt_get(info, key: str) -> str:
  34. try:
  35. raw = info.properties.get(key.encode())
  36. return raw.decode() if raw else ""
  37. except Exception:
  38. return ""
  39. async def discover_tables(timeout: float = 3.0) -> list[DiscoveredTable]:
  40. """Browse ``_http._tcp`` for ~``timeout`` seconds; return sand tables only."""
  41. if not ZEROCONF_AVAILABLE:
  42. logger.warning("zeroconf not installed - mDNS discovery unavailable")
  43. return []
  44. results: dict[str, DiscoveredTable] = {}
  45. aiozc = AsyncZeroconf()
  46. async def resolve(zeroconf, service_type, name):
  47. from zeroconf.asyncio import AsyncServiceInfo
  48. info = AsyncServiceInfo(service_type, name)
  49. try:
  50. ok = await info.async_request(zeroconf, 2500)
  51. except Exception as exc:
  52. logger.debug(f"resolve failed for {name}: {exc}")
  53. return
  54. if not ok:
  55. return
  56. # Keep only advertisements that declare themselves a sand table.
  57. if _txt_get(info, "model") != "dune-weaver":
  58. return
  59. addresses = info.parsed_scoped_addresses() if hasattr(info, "parsed_scoped_addresses") else []
  60. address = addresses[0] if addresses else ""
  61. host = (info.server or "").rstrip(".")
  62. ws_txt = _txt_get(info, "ws")
  63. table = DiscoveredTable(
  64. name=name.split(".")[0],
  65. host=host,
  66. address=address,
  67. port=info.port or 80,
  68. ws_port=int(ws_txt) if ws_txt.isdigit() else 0,
  69. )
  70. results[table.base_url] = table
  71. logger.info(f"Discovered table: {table.name} @ {table.base_url}")
  72. pending = []
  73. def on_change(zeroconf, service_type, name, state_change):
  74. if state_change is ServiceStateChange.Added:
  75. pending.append(asyncio.ensure_future(resolve(zeroconf, service_type, name)))
  76. browser = AsyncServiceBrowser(
  77. aiozc.zeroconf, "_http._tcp.local.", handlers=[on_change]
  78. )
  79. try:
  80. await asyncio.sleep(timeout)
  81. finally:
  82. if pending:
  83. await asyncio.gather(*pending, return_exceptions=True)
  84. await browser.async_cancel()
  85. await aiozc.async_close()
  86. return list(results.values())