ソースを参照

feat: mDNS auto-discovery of tables

Each backend advertises itself as _dune-weaver._tcp.local. and browses
for peers (modules/core/mdns_discovery.py, best-effort if zeroconf is
missing). The frontend merges live peers into the table selector: an
mDNS announcement marks a saved table online and refreshes its URL
(heals DHCP address changes), and switching to a discovered table
persists it to known-tables first. Clearer error when the entered
address is a controller board rather than a Dune Weaver server.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tuanchris 2 週間 前
コミット
fa9b74d937

+ 8 - 1
frontend/src/components/TableSelector.tsx

@@ -83,7 +83,9 @@ export function TableSelector({ children }: TableSelectorProps) {
         setNewTableUrl('')
         setNewTableName('')
       } else {
-        toast.error('Failed to add table. Check the URL and try again.')
+        toast.error(
+          "Couldn't reach a Dune Weaver server at that address. Use the address of the table's server (Raspberry Pi), not the controller board — tables on your network appear here automatically."
+        )
       }
     } finally {
       setIsAdding(false)
@@ -184,6 +186,11 @@ export function TableSelector({ children }: TableSelectorProps) {
                           This
                         </Badge>
                       )}
+                      {table.isDiscovered && (
+                        <Badge variant="outline" className="text-[10px] px-1 py-0">
+                          Discovered
+                        </Badge>
+                      )}
                     </div>
                     <span className="text-xs text-muted-foreground truncate block">
                       {table.host || new URL(table.url).hostname}

+ 56 - 3
frontend/src/contexts/TableContext.tsx

@@ -19,6 +19,7 @@ export interface Table {
   version?: string
   isOnline?: boolean
   isCurrent?: boolean // True if this is the backend serving the frontend
+  isDiscovered?: boolean // True if found via mDNS but not yet saved to known-tables
   customLogo?: string // Custom logo filename if set (e.g., "logo_abc123.png")
 }
 
@@ -127,7 +128,29 @@ export function TableProvider({ children }: { children: React.ReactNode }) {
   }, [tables, activeTable])
 
   // Set active table - saves to localStorage and reloads page for clean state
-  const setActiveTable = useCallback((table: Table) => {
+  const setActiveTable = useCallback(async (table: Table) => {
+    // Persist mDNS-discovered tables to known-tables so they remain listed
+    // (and reachable at their last address) even when not advertising
+    if (table.isDiscovered) {
+      try {
+        await fetch('/api/known-tables', {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({
+            id: table.id,
+            name: table.name,
+            url: table.url,
+            host: table.host,
+            port: table.port,
+            version: table.version,
+          }),
+        })
+      } catch (e) {
+        console.error('Failed to persist discovered table:', e)
+        // Continue anyway - switching still works for this session
+      }
+    }
+
     // Save to localStorage before reload
     try {
       const currentTables = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}')
@@ -158,11 +181,12 @@ export function TableProvider({ children }: { children: React.ReactNode }) {
     setIsDiscovering(true)
 
     try {
-      // Fetch table info, settings, and known tables in parallel
-      const [infoResponse, settingsResponse, knownTablesResponse] = await Promise.all([
+      // Fetch table info, settings, known tables, and mDNS-discovered tables in parallel
+      const [infoResponse, settingsResponse, knownTablesResponse, discoveredResponse] = await Promise.all([
         fetch('/api/table-info'),
         fetch('/api/settings').catch(() => null),
         fetch('/api/known-tables').catch(() => null),
+        fetch('/api/discovered-tables').catch(() => null),
       ])
 
       if (!infoResponse.ok) {
@@ -173,6 +197,8 @@ export function TableProvider({ children }: { children: React.ReactNode }) {
       const settings = settingsResponse?.ok ? await settingsResponse.json() : null
       const knownTablesData = knownTablesResponse?.ok ? await knownTablesResponse.json() : null
       const knownTables: Array<{ id: string; name: string; url: string; host?: string; port?: number; version?: string }> = knownTablesData?.tables || []
+      const discoveredData = discoveredResponse?.ok ? await discoveredResponse.json() : null
+      const discoveredTables: Array<{ id: string; name: string; url: string; host?: string; port?: number; version?: string }> = discoveredData?.tables || []
 
       const currentTable: Table = {
         id: info.id,
@@ -205,6 +231,33 @@ export function TableProvider({ children }: { children: React.ReactNode }) {
           }
         })
 
+        // Merge in tables auto-discovered via mDNS. A live mDNS announcement
+        // is both an online signal and the table's *current* address, so it
+        // wins over a possibly stale saved URL (DHCP reassignment).
+        discoveredTables.forEach(found => {
+          if (found.id === currentTable.id) return
+          const existing = merged.find(t => t.id === found.id)
+          if (existing) {
+            existing.isOnline = true
+            existing.url = found.url
+            existing.host = found.host
+            existing.port = found.port
+            if (found.version) existing.version = found.version
+          } else {
+            merged.push({
+              id: found.id,
+              name: found.name,
+              url: found.url,
+              host: found.host,
+              port: found.port,
+              version: found.version,
+              isOnline: true,
+              isCurrent: false,
+              isDiscovered: true,
+            })
+          }
+        })
+
         return merged
       })
 

+ 214 - 0
modules/core/mdns_discovery.py

@@ -0,0 +1,214 @@
+"""mDNS (zeroconf) advertisement and discovery of Dune Weaver tables.
+
+Each backend advertises itself as a `_dune-weaver._tcp.local.` service and
+browses for peers on the LAN. Discovered peers are exposed to the frontend
+via GET /api/discovered-tables so users never have to type IP addresses.
+
+Discovery is best-effort: if the zeroconf package is missing or the network
+doesn't support multicast, the app runs normally without it (graceful
+degradation, same pattern as the optional LED libraries).
+"""
+
+import asyncio
+import logging
+import re
+import socket
+from typing import Dict, List, Optional
+
+logger = logging.getLogger(__name__)
+
+SERVICE_TYPE = "_dune-weaver._tcp.local."
+RESOLVE_TIMEOUT_MS = 3000
+
+try:
+    from zeroconf import IPVersion, ServiceInfo, ServiceStateChange
+    from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf
+    ZEROCONF_AVAILABLE = True
+except ImportError:
+    ZEROCONF_AVAILABLE = False
+
+
+def _get_local_ip() -> Optional[str]:
+    """Best-effort LAN IP detection (no packets are actually sent)."""
+    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+    try:
+        sock.connect(("8.8.8.8", 80))
+        return sock.getsockname()[0]
+    except OSError:
+        return None
+    finally:
+        sock.close()
+
+
+def _decode_properties(properties: Dict) -> Dict[str, str]:
+    """Zeroconf TXT records arrive as bytes; decode keys/values to str."""
+    decoded = {}
+    for key, value in (properties or {}).items():
+        if isinstance(key, bytes):
+            key = key.decode("utf-8", errors="replace")
+        if isinstance(value, bytes):
+            value = value.decode("utf-8", errors="replace")
+        if value is not None:
+            decoded[key] = value
+    return decoded
+
+
+def service_info_to_table(info) -> Optional[dict]:
+    """Convert a resolved ServiceInfo into a table dict for the API.
+
+    Returns None if the service lacks the fields needed to reach it
+    (an address, a port, and a table id in its TXT records).
+    """
+    props = _decode_properties(info.properties)
+    table_id = props.get("id")
+    addresses = [a for a in info.parsed_addresses() if ":" not in a]  # IPv4 only
+    if not table_id or not addresses or not info.port:
+        return None
+
+    host = addresses[0]
+    port = info.port
+    url = f"http://{host}" if port == 80 else f"http://{host}:{port}"
+    return {
+        "id": table_id,
+        "name": props.get("name") or "Dune Weaver",
+        "url": url,
+        "host": host,
+        "port": port,
+        "version": props.get("version") or None,
+    }
+
+
+class TableDiscovery:
+    """Advertises this table via mDNS and tracks peer tables on the LAN."""
+
+    def __init__(self):
+        self._aiozc = None
+        self._browser = None
+        self._service_info = None
+        self._own_id: Optional[str] = None
+        # Keyed by mDNS service name so Removed events can evict entries
+        self._discovered: Dict[str, dict] = {}
+
+    @property
+    def is_running(self) -> bool:
+        return self._aiozc is not None
+
+    def get_tables(self) -> List[dict]:
+        """Currently visible peer tables (excludes this table)."""
+        return list(self._discovered.values())
+
+    async def start(self, table_id: str, table_name: str, port: int, version: Optional[str] = None):
+        if not ZEROCONF_AVAILABLE:
+            logger.warning("zeroconf package not installed - mDNS table discovery disabled")
+            return
+        if self._aiozc:
+            return
+
+        local_ip = _get_local_ip()
+        if not local_ip:
+            logger.warning("Could not determine LAN IP - mDNS table discovery disabled")
+            return
+
+        self._own_id = table_id
+        self._aiozc = AsyncZeroconf(ip_version=IPVersion.V4Only)
+
+        # Instance names must be unique per network; the table id suffix
+        # keeps two tables both named "Dune Weaver" from colliding.
+        instance = f"{self._instance_label(table_name)}-{table_id[:8]}.{SERVICE_TYPE}"
+        self._service_info = ServiceInfo(
+            SERVICE_TYPE,
+            instance,
+            addresses=[socket.inet_aton(local_ip)],
+            port=port,
+            properties=self._properties(table_id, table_name, version),
+            server=f"dune-weaver-{table_id[:8]}.local.",
+        )
+
+        try:
+            await self._aiozc.async_register_service(self._service_info)
+            self._browser = AsyncServiceBrowser(
+                self._aiozc.zeroconf, SERVICE_TYPE, handlers=[self._on_service_state_change]
+            )
+            logger.info(f"mDNS: advertising '{table_name}' at {local_ip}:{port} and browsing for peer tables")
+        except Exception as e:
+            logger.warning(f"mDNS discovery failed to start: {e}")
+            await self.stop()
+
+    async def update_name(self, table_name: str):
+        """Re-advertise with a new display name after the table is renamed."""
+        if not (self._aiozc and self._service_info):
+            return
+        try:
+            # Keep the instance name stable; only the TXT records change
+            self._service_info = ServiceInfo(
+                SERVICE_TYPE,
+                self._service_info.name,
+                addresses=self._service_info.addresses,
+                port=self._service_info.port,
+                properties=self._properties(self._own_id, table_name, self._decoded_own_version()),
+                server=self._service_info.server,
+            )
+            await self._aiozc.async_update_service(self._service_info)
+        except Exception as e:
+            logger.warning(f"mDNS: failed to update advertised name: {e}")
+
+    async def stop(self):
+        if not self._aiozc:
+            return
+        try:
+            if self._browser:
+                await self._browser.async_cancel()
+            if self._service_info:
+                await self._aiozc.async_unregister_service(self._service_info)
+            await self._aiozc.async_close()
+        except Exception as e:
+            logger.debug(f"mDNS shutdown error: {e}")
+        finally:
+            self._aiozc = None
+            self._browser = None
+            self._service_info = None
+            self._discovered.clear()
+
+    @staticmethod
+    def _properties(table_id: str, table_name: str, version: Optional[str]) -> Dict[str, str]:
+        return {"id": table_id or "", "name": table_name or "Dune Weaver", "version": version or ""}
+
+    def _decoded_own_version(self) -> Optional[str]:
+        props = _decode_properties(self._service_info.properties) if self._service_info else {}
+        return props.get("version") or None
+
+    @staticmethod
+    def _instance_label(table_name: str) -> str:
+        # mDNS instance names allow most characters, but dots would be parsed
+        # as label separators - keep it to a safe subset.
+        label = re.sub(r"[^A-Za-z0-9 _-]", "", table_name or "").strip() or "Dune Weaver"
+        return label[:40]
+
+    def _on_service_state_change(self, zeroconf, service_type, name, state_change):
+        """Sync callback from AsyncServiceBrowser (runs on the event loop)."""
+        if state_change is ServiceStateChange.Removed:
+            removed = self._discovered.pop(name, None)
+            if removed:
+                logger.info(f"mDNS: table '{removed['name']}' left the network")
+            return
+        # Added/Updated: resolve the service asynchronously
+        asyncio.ensure_future(self._resolve_service(zeroconf, service_type, name))
+
+    async def _resolve_service(self, zeroconf, service_type, name):
+        try:
+            info = AsyncServiceInfo(service_type, name)
+            if not await info.async_request(zeroconf, RESOLVE_TIMEOUT_MS):
+                return
+            table = service_info_to_table(info)
+            if not table or table["id"] == self._own_id:
+                return
+            is_new = name not in self._discovered
+            self._discovered[name] = table
+            if is_new:
+                logger.info(f"mDNS: discovered table '{table['name']}' at {table['url']}")
+        except Exception as e:
+            logger.debug(f"mDNS: failed to resolve {name}: {e}")
+
+
+# Module-level singleton, mirroring how other core services are exposed
+discovery = TableDiscovery()

+ 1 - 0
requirements.txt

@@ -14,6 +14,7 @@ requests>=2.31.0
 Pillow
 aiohttp
 pyyaml>=6.0
+zeroconf>=0.131.0  # mDNS auto-discovery of other Dune Weaver tables
 # GPIO/NeoPixel support for DW LEDs and Desert Compass
 # Note: rpi-lgpio is a drop-in replacement for RPi.GPIO that works on Pi 5
 # Do NOT install both RPi.GPIO and rpi-lgpio - they conflict

ファイルの差分が大きいため隠しています
+ 0 - 0
static/dist/assets/index-BdHxEIen.js


ファイルの差分が大きいため隠しています
+ 0 - 0
static/dist/assets/index-Djn5LR-N.js


+ 1 - 1
static/dist/index.html

@@ -58,7 +58,7 @@
           .catch(function() {});
       })();
     </script>
-    <script type="module" crossorigin src="/assets/index-BdHxEIen.js"></script>
+    <script type="module" crossorigin src="/assets/index-Djn5LR-N.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-CHzltdTQ.css">
   <script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
   <body>

ファイルの差分が大きいため隠しています
+ 0 - 0
static/dist/sw.js


+ 90 - 0
tests/unit/test_mdns_discovery.py

@@ -0,0 +1,90 @@
+"""Unit tests for mDNS table discovery (modules/core/mdns_discovery)."""
+
+from modules.core.mdns_discovery import TableDiscovery, service_info_to_table
+
+
+class FakeServiceInfo:
+    """Stub of zeroconf ServiceInfo exposing only what service_info_to_table reads."""
+
+    def __init__(self, properties=None, addresses=None, port=8080):
+        self.properties = properties or {}
+        self._addresses = addresses if addresses is not None else ["192.168.68.130"]
+        self.port = port
+
+    def parsed_addresses(self):
+        return self._addresses
+
+
+class TestServiceInfoToTable:
+    def test_basic_conversion(self):
+        info = FakeServiceInfo(
+            properties={b"id": b"abc-123", b"name": b"Living Room", b"version": b"1.2.3"},
+            addresses=["192.168.68.130"],
+            port=8080,
+        )
+        table = service_info_to_table(info)
+        assert table == {
+            "id": "abc-123",
+            "name": "Living Room",
+            "url": "http://192.168.68.130:8080",
+            "host": "192.168.68.130",
+            "port": 8080,
+            "version": "1.2.3",
+        }
+
+    def test_port_80_omitted_from_url(self):
+        info = FakeServiceInfo(properties={b"id": b"abc"}, port=80)
+        table = service_info_to_table(info)
+        assert table["url"] == "http://192.168.68.130"
+
+    def test_missing_id_returns_none(self):
+        info = FakeServiceInfo(properties={b"name": b"No Id"})
+        assert service_info_to_table(info) is None
+
+    def test_ipv6_only_returns_none(self):
+        info = FakeServiceInfo(properties={b"id": b"abc"}, addresses=["fe80::1"])
+        assert service_info_to_table(info) is None
+
+    def test_ipv4_preferred_over_ipv6(self):
+        info = FakeServiceInfo(
+            properties={b"id": b"abc"},
+            addresses=["fe80::1", "192.168.68.130"],
+        )
+        assert service_info_to_table(info)["host"] == "192.168.68.130"
+
+    def test_empty_name_defaults(self):
+        info = FakeServiceInfo(properties={b"id": b"abc", b"name": b""})
+        assert service_info_to_table(info)["name"] == "Dune Weaver"
+
+    def test_string_properties_accepted(self):
+        # zeroconf usually yields bytes, but tolerate str keys/values
+        info = FakeServiceInfo(properties={"id": "abc", "name": "Str Table"})
+        table = service_info_to_table(info)
+        assert table["id"] == "abc"
+        assert table["name"] == "Str Table"
+
+
+class TestTableDiscovery:
+    def test_starts_empty_and_not_running(self):
+        discovery = TableDiscovery()
+        assert discovery.get_tables() == []
+        assert not discovery.is_running
+
+    def test_removed_service_is_evicted(self):
+        discovery = TableDiscovery()
+        discovery._discovered["svc-name"] = {"id": "abc", "name": "T", "url": "http://x"}
+
+        try:
+            from zeroconf import ServiceStateChange
+        except ImportError:
+            return  # zeroconf not installed; eviction path can't run anyway
+
+        discovery._on_service_state_change(
+            None, "_dune-weaver._tcp.local.", "svc-name", ServiceStateChange.Removed
+        )
+        assert discovery.get_tables() == []
+
+    def test_instance_label_sanitizes(self):
+        assert TableDiscovery._instance_label("My.Table!") == "MyTable"
+        assert TableDiscovery._instance_label("") == "Dune Weaver"
+        assert len(TableDiscovery._instance_label("x" * 100)) == 40

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません