Procházet zdrojové kódy

Add multi-table control support with mDNS discovery

Backend:
- Add CORS middleware to enable cross-origin requests from other frontends
- Add table identity endpoints (GET/PATCH /api/table-info) with UUID and name
- Add mDNS advertisement using zeroconf (_duneweaver._tcp.local.)
- Add discovery endpoint (GET /api/discover-tables) to find tables on LAN

Frontend:
- Add centralized API client (apiClient.ts) with configurable base URL
- Add TableContext for multi-table state management with localStorage persistence
- Add TableSelector component in header for switching between tables
- Update WebSocket connections to use apiClient for dynamic URL targeting
- Refactor key fetch calls to use apiClient

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
tuanchris před 3 týdny
rodič
revize
629a8dcbf3

+ 3 - 2
frontend/src/App.tsx

@@ -6,10 +6,11 @@ import { TableControlPage } from '@/pages/TableControlPage'
 import { LEDPage } from '@/pages/LEDPage'
 import { LEDPage } from '@/pages/LEDPage'
 import { SettingsPage } from '@/pages/SettingsPage'
 import { SettingsPage } from '@/pages/SettingsPage'
 import { Toaster } from '@/components/ui/sonner'
 import { Toaster } from '@/components/ui/sonner'
+import { TableProvider } from '@/contexts/TableContext'
 
 
 function App() {
 function App() {
   return (
   return (
-    <>
+    <TableProvider>
       <Routes>
       <Routes>
         <Route path="/" element={<Layout />}>
         <Route path="/" element={<Layout />}>
           <Route index element={<BrowsePage />} />
           <Route index element={<BrowsePage />} />
@@ -20,7 +21,7 @@ function App() {
         </Route>
         </Route>
       </Routes>
       </Routes>
       <Toaster position="top-center" richColors closeButton />
       <Toaster position="top-center" richColors closeButton />
-    </>
+    </TableProvider>
   )
   )
 }
 }
 
 

+ 307 - 0
frontend/src/components/TableSelector.tsx

@@ -0,0 +1,307 @@
+/**
+ * TableSelector - Header component for switching between sand tables
+ *
+ * Displays the current table and provides a dropdown to switch between
+ * discovered tables or add new ones manually.
+ */
+
+import { useState } from 'react'
+import { useTable, type Table } from '@/contexts/TableContext'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Badge } from '@/components/ui/badge'
+import {
+  Popover,
+  PopoverContent,
+  PopoverTrigger,
+} from '@/components/ui/popover'
+import {
+  Dialog,
+  DialogContent,
+  DialogHeader,
+  DialogTitle,
+  DialogFooter,
+} from '@/components/ui/dialog'
+import { toast } from 'sonner'
+import {
+  Layers,
+  RefreshCw,
+  Plus,
+  Check,
+  Wifi,
+  WifiOff,
+  Pencil,
+  Trash2,
+  ChevronDown,
+} from 'lucide-react'
+
+export function TableSelector() {
+  const {
+    tables,
+    activeTable,
+    isDiscovering,
+    setActiveTable,
+    discoverTables,
+    addTable,
+    removeTable,
+    updateTableName,
+  } = useTable()
+
+  const [isOpen, setIsOpen] = useState(false)
+  const [showAddDialog, setShowAddDialog] = useState(false)
+  const [showRenameDialog, setShowRenameDialog] = useState(false)
+  const [newTableUrl, setNewTableUrl] = useState('')
+  const [newTableName, setNewTableName] = useState('')
+  const [renameTable, setRenameTable] = useState<Table | null>(null)
+  const [renameValue, setRenameValue] = useState('')
+  const [isAdding, setIsAdding] = useState(false)
+
+  const handleSelectTable = (table: Table) => {
+    if (table.id !== activeTable?.id) {
+      setActiveTable(table)
+      toast.success(`Switched to ${table.name}`)
+    }
+    setIsOpen(false)
+  }
+
+  const handleDiscover = async () => {
+    await discoverTables()
+    toast.success('Table discovery complete')
+  }
+
+  const handleAddTable = async () => {
+    if (!newTableUrl.trim()) {
+      toast.error('Please enter a URL')
+      return
+    }
+
+    setIsAdding(true)
+    try {
+      // Ensure URL has protocol
+      let url = newTableUrl.trim()
+      if (!url.startsWith('http://') && !url.startsWith('https://')) {
+        url = `http://${url}`
+      }
+
+      const table = await addTable(url, newTableName.trim() || undefined)
+      if (table) {
+        toast.success(`Added ${table.name}`)
+        setShowAddDialog(false)
+        setNewTableUrl('')
+        setNewTableName('')
+      } else {
+        toast.error('Failed to add table. Check the URL and try again.')
+      }
+    } finally {
+      setIsAdding(false)
+    }
+  }
+
+  const handleRename = async () => {
+    if (!renameTable || !renameValue.trim()) return
+
+    await updateTableName(renameTable.id, renameValue.trim())
+    toast.success('Table renamed')
+    setShowRenameDialog(false)
+    setRenameTable(null)
+    setRenameValue('')
+  }
+
+  const handleRemove = (table: Table) => {
+    if (table.isCurrent) {
+      toast.error("Can't remove the current table")
+      return
+    }
+    removeTable(table.id)
+    toast.success(`Removed ${table.name}`)
+  }
+
+  const openRenameDialog = (table: Table) => {
+    setRenameTable(table)
+    setRenameValue(table.name)
+    setShowRenameDialog(true)
+  }
+
+  // Always show if there are tables or discovering
+  // This allows users to manually add tables even with just one
+
+  return (
+    <>
+      <Popover open={isOpen} onOpenChange={setIsOpen}>
+        <PopoverTrigger asChild>
+          <Button
+            variant="ghost"
+            size="sm"
+            className="gap-2 h-9 px-3"
+          >
+            <Layers className="h-4 w-4" />
+            <span className="hidden sm:inline max-w-[120px] truncate">
+              {activeTable?.name || 'Select Table'}
+            </span>
+            <ChevronDown className="h-3 w-3 opacity-50" />
+          </Button>
+        </PopoverTrigger>
+        <PopoverContent className="w-72 p-2" align="end">
+          <div className="space-y-2">
+            {/* Header */}
+            <div className="flex items-center justify-between px-2 py-1">
+              <span className="text-sm font-medium">Sand Tables</span>
+              <Button
+                variant="ghost"
+                size="sm"
+                onClick={handleDiscover}
+                disabled={isDiscovering}
+                className="h-7 px-2"
+              >
+                <RefreshCw className={`h-3.5 w-3.5 ${isDiscovering ? 'animate-spin' : ''}`} />
+              </Button>
+            </div>
+
+            {/* Table list */}
+            <div className="space-y-1">
+              {tables.map(table => (
+                <div
+                  key={table.id}
+                  className={`flex items-center gap-2 px-2 py-2 rounded-md cursor-pointer hover:bg-accent group ${
+                    activeTable?.id === table.id ? 'bg-accent' : ''
+                  }`}
+                  onClick={() => handleSelectTable(table)}
+                >
+                  {/* Status indicator */}
+                  {table.isOnline ? (
+                    <Wifi className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
+                  ) : (
+                    <WifiOff className="h-3.5 w-3.5 text-red-500 flex-shrink-0" />
+                  )}
+
+                  {/* Name and info */}
+                  <div className="flex-1 min-w-0">
+                    <div className="flex items-center gap-2">
+                      <span className="text-sm truncate">{table.name}</span>
+                      {table.isCurrent && (
+                        <Badge variant="secondary" className="text-[10px] px-1 py-0">
+                          This
+                        </Badge>
+                      )}
+                    </div>
+                    <span className="text-xs text-muted-foreground truncate block">
+                      {table.host || new URL(table.url).hostname}
+                    </span>
+                  </div>
+
+                  {/* Selected indicator */}
+                  {activeTable?.id === table.id && (
+                    <Check className="h-4 w-4 text-primary flex-shrink-0" />
+                  )}
+
+                  {/* Actions (shown on hover) */}
+                  <div className="hidden group-hover:flex items-center gap-1">
+                    <Button
+                      variant="ghost"
+                      size="sm"
+                      className="h-6 w-6 p-0"
+                      onClick={e => {
+                        e.stopPropagation()
+                        openRenameDialog(table)
+                      }}
+                    >
+                      <Pencil className="h-3 w-3" />
+                    </Button>
+                    {!table.isCurrent && (
+                      <Button
+                        variant="ghost"
+                        size="sm"
+                        className="h-6 w-6 p-0 text-destructive hover:text-destructive"
+                        onClick={e => {
+                          e.stopPropagation()
+                          handleRemove(table)
+                        }}
+                      >
+                        <Trash2 className="h-3 w-3" />
+                      </Button>
+                    )}
+                  </div>
+                </div>
+              ))}
+            </div>
+
+            {/* Add table button */}
+            <Button
+              variant="outline"
+              size="sm"
+              className="w-full gap-2"
+              onClick={() => setShowAddDialog(true)}
+            >
+              <Plus className="h-3.5 w-3.5" />
+              Add Table Manually
+            </Button>
+          </div>
+        </PopoverContent>
+      </Popover>
+
+      {/* Add Table Dialog */}
+      <Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
+        <DialogContent>
+          <DialogHeader>
+            <DialogTitle>Add Table Manually</DialogTitle>
+          </DialogHeader>
+          <div className="space-y-4 py-4">
+            <div className="space-y-2">
+              <label className="text-sm font-medium">Table URL</label>
+              <Input
+                placeholder="192.168.1.100:8080 or http://..."
+                value={newTableUrl}
+                onChange={e => setNewTableUrl(e.target.value)}
+                onKeyDown={e => e.key === 'Enter' && handleAddTable()}
+              />
+              <p className="text-xs text-muted-foreground">
+                Enter the IP address and port of the table's backend
+              </p>
+            </div>
+            <div className="space-y-2">
+              <label className="text-sm font-medium">Name (optional)</label>
+              <Input
+                placeholder="Living Room Table"
+                value={newTableName}
+                onChange={e => setNewTableName(e.target.value)}
+                onKeyDown={e => e.key === 'Enter' && handleAddTable()}
+              />
+            </div>
+          </div>
+          <DialogFooter>
+            <Button variant="outline" onClick={() => setShowAddDialog(false)}>
+              Cancel
+            </Button>
+            <Button onClick={handleAddTable} disabled={isAdding}>
+              {isAdding ? 'Adding...' : 'Add Table'}
+            </Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+
+      {/* Rename Dialog */}
+      <Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
+        <DialogContent>
+          <DialogHeader>
+            <DialogTitle>Rename Table</DialogTitle>
+          </DialogHeader>
+          <div className="py-4">
+            <Input
+              placeholder="Table name"
+              value={renameValue}
+              onChange={e => setRenameValue(e.target.value)}
+              onKeyDown={e => e.key === 'Enter' && handleRename()}
+              autoFocus
+            />
+          </div>
+          <DialogFooter>
+            <Button variant="outline" onClick={() => setShowRenameDialog(false)}>
+              Cancel
+            </Button>
+            <Button onClick={handleRename}>Save</Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
+    </>
+  )
+}

+ 24 - 27
frontend/src/components/layout/Layout.tsx

@@ -4,6 +4,9 @@ import { toast } from 'sonner'
 import { NowPlayingBar } from '@/components/NowPlayingBar'
 import { NowPlayingBar } from '@/components/NowPlayingBar'
 import { Button } from '@/components/ui/button'
 import { Button } from '@/components/ui/button'
 import { cacheAllPreviews } from '@/lib/previewCache'
 import { cacheAllPreviews } from '@/lib/previewCache'
+import { TableSelector } from '@/components/TableSelector'
+import { useTable } from '@/contexts/TableContext'
+import { apiClient } from '@/lib/apiClient'
 
 
 const navItems = [
 const navItems = [
   { path: '/', label: 'Browse', icon: 'grid_view', title: 'Browse Patterns' },
   { path: '/', label: 'Browse', icon: 'grid_view', title: 'Browse Patterns' },
@@ -17,6 +20,10 @@ const DEFAULT_APP_NAME = 'Dune Weaver'
 
 
 export function Layout() {
 export function Layout() {
   const location = useLocation()
   const location = useLocation()
+
+  // Multi-table context - must be called before any hooks that depend on activeTable
+  const { activeTable } = useTable()
+
   const [isDark, setIsDark] = useState(() => {
   const [isDark, setIsDark] = useState(() => {
     if (typeof window !== 'undefined') {
     if (typeof window !== 'undefined') {
       const saved = localStorage.getItem('theme')
       const saved = localStorage.getItem('theme')
@@ -43,8 +50,7 @@ export function Layout() {
 
 
   // Fetch app settings
   // Fetch app settings
   const fetchAppSettings = () => {
   const fetchAppSettings = () => {
-    fetch('/api/settings')
-      .then((r) => r.json())
+    apiClient.get<{ app?: { name?: string; custom_logo?: string } }>('/api/settings')
       .then((settings) => {
       .then((settings) => {
         if (settings.app?.name) {
         if (settings.app?.name) {
           setAppName(settings.app.name)
           setAppName(settings.app.name)
@@ -68,7 +74,8 @@ export function Layout() {
     return () => {
     return () => {
       window.removeEventListener('branding-updated', handleBrandingUpdate)
       window.removeEventListener('branding-updated', handleBrandingUpdate)
     }
     }
-  }, [])
+    // Refetch when active table changes
+  }, [activeTable?.id])
 
 
   // Homing completion countdown timer
   // Homing completion countdown timer
   useEffect(() => {
   useEffect(() => {
@@ -160,8 +167,7 @@ export function Layout() {
   // Check device connection status via WebSocket
   // Check device connection status via WebSocket
   useEffect(() => {
   useEffect(() => {
     const connectWebSocket = () => {
     const connectWebSocket = () => {
-      const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
-      const ws = new WebSocket(`${protocol}//${window.location.host}/ws/status`)
+      const ws = new WebSocket(apiClient.getWebSocketUrl('/ws/status'))
 
 
       ws.onopen = () => {
       ws.onopen = () => {
         setIsBackendConnected(true)
         setIsBackendConnected(true)
@@ -238,7 +244,8 @@ export function Layout() {
         wsRef.current.close()
         wsRef.current.close()
       }
       }
     }
     }
-  }, [])
+    // Reconnect when active table changes
+  }, [activeTable?.id])
 
 
   // Connect to logs WebSocket when drawer opens
   // Connect to logs WebSocket when drawer opens
   useEffect(() => {
   useEffect(() => {
@@ -254,11 +261,11 @@ export function Layout() {
     // Fetch initial logs
     // Fetch initial logs
     const fetchInitialLogs = async () => {
     const fetchInitialLogs = async () => {
       try {
       try {
-        const response = await fetch('/api/logs?limit=200')
-        const data = await response.json()
+        type LogEntry = { timestamp: string; level: string; logger: string; message: string }
+        const data = await apiClient.get<{ logs: LogEntry[] }>('/api/logs?limit=200')
         // Filter out empty/invalid log entries
         // Filter out empty/invalid log entries
         const validLogs = (data.logs || []).filter(
         const validLogs = (data.logs || []).filter(
-          (log: { message?: string }) => log && log.message && log.message.trim() !== ''
+          (log) => log && log.message && log.message.trim() !== ''
         )
         )
         // API returns newest first, reverse to show oldest first (newest at bottom)
         // API returns newest first, reverse to show oldest first (newest at bottom)
         setLogs(validLogs.reverse())
         setLogs(validLogs.reverse())
@@ -279,8 +286,7 @@ export function Layout() {
     let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
     let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
 
 
     const connectLogsWebSocket = () => {
     const connectLogsWebSocket = () => {
-      const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
-      const ws = new WebSocket(`${protocol}//${window.location.host}/ws/logs`)
+      const ws = new WebSocket(apiClient.getWebSocketUrl('/ws/logs'))
 
 
       ws.onopen = () => {
       ws.onopen = () => {
         console.log('Logs WebSocket connected')
         console.log('Logs WebSocket connected')
@@ -522,12 +528,8 @@ export function Layout() {
     if (!confirm('Are you sure you want to restart Docker containers?')) return
     if (!confirm('Are you sure you want to restart Docker containers?')) return
 
 
     try {
     try {
-      const response = await fetch('/api/system/restart', { method: 'POST' })
-      if (response.ok) {
-        toast.success('Docker containers are restarting...')
-      } else {
-        throw new Error('Restart failed')
-      }
+      await apiClient.post('/api/system/restart')
+      toast.success('Docker containers are restarting...')
     } catch {
     } catch {
       toast.error('Failed to restart Docker containers')
       toast.error('Failed to restart Docker containers')
     }
     }
@@ -537,12 +539,8 @@ export function Layout() {
     if (!confirm('Are you sure you want to shutdown the system?')) return
     if (!confirm('Are you sure you want to shutdown the system?')) return
 
 
     try {
     try {
-      const response = await fetch('/api/system/shutdown', { method: 'POST' })
-      if (response.ok) {
-        toast.success('System is shutting down...')
-      } else {
-        throw new Error('Shutdown failed')
-      }
+      await apiClient.post('/api/system/shutdown')
+      toast.success('System is shutting down...')
     } catch {
     } catch {
       toast.error('Failed to shutdown system')
       toast.error('Failed to shutdown system')
     }
     }
@@ -637,8 +635,7 @@ export function Layout() {
     if (isHoming && isBackendConnected) {
     if (isHoming && isBackendConnected) {
       addLog('INFO', 'Homing started...')
       addLog('INFO', 'Homing started...')
 
 
-      const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
-      const ws = new WebSocket(`${protocol}//${window.location.host}/ws/logs`)
+      const ws = new WebSocket(apiClient.getWebSocketUrl('/ws/logs'))
 
 
       ws.onmessage = (event) => {
       ws.onmessage = (event) => {
         try {
         try {
@@ -711,8 +708,7 @@ export function Layout() {
     const connectCacheWebSocket = () => {
     const connectCacheWebSocket = () => {
       if (cacheWsRef.current) return
       if (cacheWsRef.current) return
 
 
-      const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
-      const ws = new WebSocket(`${protocol}//${window.location.host}/ws/cache-progress`)
+      const ws = new WebSocket(apiClient.getWebSocketUrl('/ws/cache-progress'))
 
 
       ws.onmessage = (event) => {
       ws.onmessage = (event) => {
         try {
         try {
@@ -1148,6 +1144,7 @@ export function Layout() {
             />
             />
           </Link>
           </Link>
           <div className="flex items-center gap-1">
           <div className="flex items-center gap-1">
+            <TableSelector />
             <Button
             <Button
               variant="ghost"
               variant="ghost"
               size="icon"
               size="icon"

+ 350 - 0
frontend/src/contexts/TableContext.tsx

@@ -0,0 +1,350 @@
+/**
+ * TableContext - Multi-table state management
+ *
+ * Manages discovered tables, active table selection, and persistence.
+ * When the active table changes, the API client's base URL is updated
+ * and components can react to reconnect WebSockets.
+ */
+
+import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'
+import { apiClient } from '@/lib/apiClient'
+
+export interface Table {
+  id: string
+  name: string
+  url: string
+  host?: string
+  port?: number
+  version?: string
+  isOnline?: boolean
+  isCurrent?: boolean // True if this is the backend serving the frontend
+}
+
+interface TableContextType {
+  // State
+  tables: Table[]
+  activeTable: Table | null
+  isDiscovering: boolean
+  lastDiscovery: Date | null
+
+  // Actions
+  setActiveTable: (table: Table) => void
+  discoverTables: () => Promise<void>
+  addTable: (url: string, name?: string) => Promise<Table | null>
+  removeTable: (id: string) => void
+  updateTableName: (id: string, name: string) => Promise<void>
+  refreshTableStatus: (table: Table) => Promise<boolean>
+}
+
+const TableContext = createContext<TableContextType | null>(null)
+
+const STORAGE_KEY = 'duneweaver_tables'
+const ACTIVE_TABLE_KEY = 'duneweaver_active_table'
+
+interface StoredTableData {
+  tables: Table[]
+  activeTableId: string | null
+}
+
+export function TableProvider({ children }: { children: React.ReactNode }) {
+  const [tables, setTables] = useState<Table[]>([])
+  const [activeTable, setActiveTableState] = useState<Table | null>(null)
+  const [isDiscovering, setIsDiscovering] = useState(false)
+  const [lastDiscovery, setLastDiscovery] = useState<Date | null>(null)
+  const initializedRef = useRef(false)
+
+  // Load saved tables from localStorage on mount
+  useEffect(() => {
+    if (initializedRef.current) return
+    initializedRef.current = true
+
+    try {
+      const stored = localStorage.getItem(STORAGE_KEY)
+      const activeId = localStorage.getItem(ACTIVE_TABLE_KEY)
+
+      if (stored) {
+        const data: StoredTableData = JSON.parse(stored)
+        setTables(data.tables || [])
+
+        // Restore active table
+        if (activeId && data.tables) {
+          const active = data.tables.find(t => t.id === activeId)
+          if (active) {
+            setActiveTableState(active)
+            apiClient.setBaseUrl(active.url === `http://localhost:${window.location.port || 8080}` ? '' : active.url)
+          }
+        }
+      }
+
+      // Auto-discover on first load if no tables saved
+      if (!stored) {
+        discoverTables()
+      }
+    } catch (e) {
+      console.error('Failed to load saved tables:', e)
+    }
+  }, [])
+
+  // Save tables to localStorage when they change
+  useEffect(() => {
+    if (!initializedRef.current) return
+
+    try {
+      const data: StoredTableData = {
+        tables,
+        activeTableId: activeTable?.id || null,
+      }
+      localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
+      if (activeTable) {
+        localStorage.setItem(ACTIVE_TABLE_KEY, activeTable.id)
+      } else {
+        localStorage.removeItem(ACTIVE_TABLE_KEY)
+      }
+    } catch (e) {
+      console.error('Failed to save tables:', e)
+    }
+  }, [tables, activeTable])
+
+  // Set active table and update API client
+  const setActiveTable = useCallback((table: Table) => {
+    setActiveTableState(table)
+
+    // Update API client base URL
+    // If the table is the current one (serving this frontend), use relative URLs
+    if (table.isCurrent || table.url === window.location.origin) {
+      apiClient.setBaseUrl('')
+    } else {
+      apiClient.setBaseUrl(table.url)
+    }
+  }, [])
+
+  // Discover tables via mDNS
+  const discoverTables = useCallback(async () => {
+    setIsDiscovering(true)
+
+    try {
+      // Call the discovery endpoint on the current backend
+      const response = await fetch('/api/discover-tables?timeout=3')
+      if (!response.ok) {
+        throw new Error('Discovery failed')
+      }
+
+      const data = await response.json()
+      const discoveredTables: Table[] = (data.tables || []).map((t: {
+        id: string
+        name: string
+        url: string
+        host?: string
+        port?: number
+        version?: string
+        is_current?: boolean
+      }) => ({
+        id: t.id,
+        name: t.name,
+        url: t.url,
+        host: t.host,
+        port: t.port,
+        version: t.version,
+        isOnline: true,
+        isCurrent: t.is_current || false,
+      }))
+
+      // Merge with existing tables (keep manual additions)
+      setTables(prev => {
+        const merged = [...discoveredTables]
+
+        // Add any manually added tables that weren't discovered
+        prev.forEach(existing => {
+          if (!merged.find(t => t.id === existing.id)) {
+            merged.push({ ...existing, isOnline: false })
+          }
+        })
+
+        return merged
+      })
+
+      // If no active table, select the current one
+      if (!activeTable) {
+        const currentTable = discoveredTables.find(t => t.isCurrent)
+        if (currentTable) {
+          setActiveTable(currentTable)
+        }
+      }
+
+      setLastDiscovery(new Date())
+    } catch (e) {
+      console.error('Table discovery failed:', e)
+
+      // If discovery fails and we have no tables, add the current backend
+      if (tables.length === 0) {
+        try {
+          const infoResponse = await fetch('/api/table-info')
+          if (infoResponse.ok) {
+            const info = await infoResponse.json()
+            const currentTable: Table = {
+              id: info.id,
+              name: info.name,
+              url: window.location.origin,
+              version: info.version,
+              isOnline: true,
+              isCurrent: true,
+            }
+            setTables([currentTable])
+            setActiveTable(currentTable)
+          }
+        } catch {
+          // Ignore
+        }
+      }
+    } finally {
+      setIsDiscovering(false)
+    }
+  }, [activeTable, tables.length, setActiveTable])
+
+  // Add a table manually by URL
+  const addTable = useCallback(async (url: string, name?: string): Promise<Table | null> => {
+    try {
+      // Normalize URL
+      const normalizedUrl = url.replace(/\/$/, '')
+
+      // Check if already exists
+      if (tables.find(t => t.url === normalizedUrl)) {
+        return null
+      }
+
+      // Fetch table info from the URL
+      const response = await fetch(`${normalizedUrl}/api/table-info`)
+      if (!response.ok) {
+        throw new Error('Failed to fetch table info')
+      }
+
+      const info = await response.json()
+      const newTable: Table = {
+        id: info.id,
+        name: name || info.name,
+        url: normalizedUrl,
+        version: info.version,
+        isOnline: true,
+        isCurrent: false,
+      }
+
+      setTables(prev => [...prev, newTable])
+      return newTable
+    } catch (e) {
+      console.error('Failed to add table:', e)
+      return null
+    }
+  }, [tables])
+
+  // Remove a table
+  const removeTable = useCallback((id: string) => {
+    setTables(prev => prev.filter(t => t.id !== id))
+
+    // If removing active table, switch to another
+    if (activeTable?.id === id) {
+      const remaining = tables.filter(t => t.id !== id)
+      if (remaining.length > 0) {
+        setActiveTable(remaining[0])
+      } else {
+        setActiveTableState(null)
+        apiClient.setBaseUrl('')
+      }
+    }
+  }, [activeTable, tables, setActiveTable])
+
+  // Update table name (on the backend)
+  const updateTableName = useCallback(async (id: string, name: string) => {
+    const table = tables.find(t => t.id === id)
+    if (!table) return
+
+    try {
+      const baseUrl = table.isCurrent ? '' : table.url
+      const response = await fetch(`${baseUrl}/api/table-info`, {
+        method: 'PATCH',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ name }),
+      })
+
+      if (response.ok) {
+        setTables(prev =>
+          prev.map(t => (t.id === id ? { ...t, name } : t))
+        )
+
+        // Update active table if it's the one being renamed
+        if (activeTable?.id === id) {
+          setActiveTableState(prev => prev ? { ...prev, name } : null)
+        }
+      }
+    } catch (e) {
+      console.error('Failed to update table name:', e)
+    }
+  }, [tables, activeTable])
+
+  // Check if a table is online
+  const refreshTableStatus = useCallback(async (table: Table): Promise<boolean> => {
+    try {
+      const baseUrl = table.isCurrent ? '' : table.url
+      const response = await fetch(`${baseUrl}/api/table-info`, {
+        signal: AbortSignal.timeout(3000),
+      })
+      const isOnline = response.ok
+
+      setTables(prev =>
+        prev.map(t => (t.id === table.id ? { ...t, isOnline } : t))
+      )
+
+      return isOnline
+    } catch {
+      setTables(prev =>
+        prev.map(t => (t.id === table.id ? { ...t, isOnline: false } : t))
+      )
+      return false
+    }
+  }, [])
+
+  return (
+    <TableContext.Provider
+      value={{
+        tables,
+        activeTable,
+        isDiscovering,
+        lastDiscovery,
+        setActiveTable,
+        discoverTables,
+        addTable,
+        removeTable,
+        updateTableName,
+        refreshTableStatus,
+      }}
+    >
+      {children}
+    </TableContext.Provider>
+  )
+}
+
+export function useTable() {
+  const context = useContext(TableContext)
+  if (!context) {
+    throw new Error('useTable must be used within a TableProvider')
+  }
+  return context
+}
+
+// Hook for subscribing to active table changes (for WebSocket reconnection)
+export function useActiveTableChange(callback: (table: Table | null) => void) {
+  const { activeTable } = useTable()
+  const callbackRef = useRef(callback)
+  const prevTableRef = useRef<Table | null>(null)
+
+  callbackRef.current = callback
+
+  useEffect(() => {
+    // Only call on actual changes, not initial render
+    if (prevTableRef.current !== null || activeTable !== null) {
+      if (prevTableRef.current?.id !== activeTable?.id) {
+        callbackRef.current(activeTable)
+      }
+    }
+    prevTableRef.current = activeTable
+  }, [activeTable])
+}

+ 195 - 0
frontend/src/lib/apiClient.ts

@@ -0,0 +1,195 @@
+/**
+ * Centralized API client for multi-table support.
+ *
+ * This module provides a single point for all API and WebSocket communications,
+ * allowing easy switching between different backend instances.
+ */
+
+type RequestMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE' | 'PUT'
+
+interface RequestOptions {
+  method?: RequestMethod
+  body?: unknown
+  headers?: Record<string, string>
+  signal?: AbortSignal
+}
+
+class ApiClient {
+  private _baseUrl: string = ''
+  private _listeners: Set<(url: string) => void> = new Set()
+
+  /**
+   * Get the current base URL.
+   * Empty string means use the current origin (relative URLs).
+   */
+  get baseUrl(): string {
+    return this._baseUrl
+  }
+
+  /**
+   * Set the base URL for all API requests.
+   * @param url - The base URL (e.g., 'http://192.168.1.100:8080') or empty for relative URLs
+   */
+  setBaseUrl(url: string): void {
+    // Remove trailing slash
+    this._baseUrl = url.replace(/\/$/, '')
+    // Notify listeners
+    this._listeners.forEach(listener => listener(this._baseUrl))
+  }
+
+  /**
+   * Subscribe to base URL changes.
+   * @param listener - Callback when base URL changes
+   * @returns Unsubscribe function
+   */
+  onBaseUrlChange(listener: (url: string) => void): () => void {
+    this._listeners.add(listener)
+    return () => this._listeners.delete(listener)
+  }
+
+  /**
+   * Build full URL for an endpoint.
+   */
+  private buildUrl(endpoint: string): string {
+    // Ensure endpoint starts with /
+    const path = endpoint.startsWith('/') ? endpoint : `/${endpoint}`
+    return `${this._baseUrl}${path}`
+  }
+
+  /**
+   * Make an HTTP request.
+   */
+  async request<T = unknown>(endpoint: string, options: RequestOptions = {}): Promise<T> {
+    const { method = 'GET', body, headers = {}, signal } = options
+
+    const url = this.buildUrl(endpoint)
+
+    const fetchOptions: RequestInit = {
+      method,
+      headers: {
+        'Content-Type': 'application/json',
+        ...headers,
+      },
+      signal,
+    }
+
+    if (body !== undefined) {
+      fetchOptions.body = JSON.stringify(body)
+    }
+
+    const response = await fetch(url, fetchOptions)
+
+    if (!response.ok) {
+      const errorText = await response.text()
+      throw new Error(`HTTP ${response.status}: ${errorText}`)
+    }
+
+    // Handle empty responses
+    const text = await response.text()
+    if (!text) {
+      return {} as T
+    }
+
+    return JSON.parse(text) as T
+  }
+
+  /**
+   * GET request
+   */
+  async get<T = unknown>(endpoint: string, signal?: AbortSignal): Promise<T> {
+    return this.request<T>(endpoint, { method: 'GET', signal })
+  }
+
+  /**
+   * POST request
+   */
+  async post<T = unknown>(endpoint: string, body?: unknown, signal?: AbortSignal): Promise<T> {
+    return this.request<T>(endpoint, { method: 'POST', body, signal })
+  }
+
+  /**
+   * PATCH request
+   */
+  async patch<T = unknown>(endpoint: string, body?: unknown, signal?: AbortSignal): Promise<T> {
+    return this.request<T>(endpoint, { method: 'PATCH', body, signal })
+  }
+
+  /**
+   * DELETE request
+   */
+  async delete<T = unknown>(endpoint: string, signal?: AbortSignal): Promise<T> {
+    return this.request<T>(endpoint, { method: 'DELETE', signal })
+  }
+
+  /**
+   * Build WebSocket URL for an endpoint.
+   */
+  getWebSocketUrl(endpoint: string): string {
+    // Ensure endpoint starts with /
+    const path = endpoint.startsWith('/') ? endpoint : `/${endpoint}`
+
+    if (this._baseUrl) {
+      // Parse the base URL to get host
+      const url = new URL(this._baseUrl)
+      const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
+      return `${protocol}//${url.host}${path}`
+    } else {
+      // Use current page's host for relative URLs
+      const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
+      return `${protocol}//${window.location.host}${path}`
+    }
+  }
+
+  /**
+   * Build URL for static assets (like pattern previews).
+   */
+  getAssetUrl(path: string): string {
+    // Ensure path starts with /
+    const assetPath = path.startsWith('/') ? path : `/${path}`
+    return `${this._baseUrl}${assetPath}`
+  }
+
+  /**
+   * Upload a file via POST.
+   */
+  async uploadFile(
+    endpoint: string,
+    file: File,
+    fieldName: string = 'file',
+    additionalData?: Record<string, string>
+  ): Promise<unknown> {
+    const url = this.buildUrl(endpoint)
+    const formData = new FormData()
+    formData.append(fieldName, file)
+
+    if (additionalData) {
+      Object.entries(additionalData).forEach(([key, value]) => {
+        formData.append(key, value)
+      })
+    }
+
+    const response = await fetch(url, {
+      method: 'POST',
+      body: formData,
+      // Don't set Content-Type - let browser set it with boundary
+    })
+
+    if (!response.ok) {
+      const errorText = await response.text()
+      throw new Error(`HTTP ${response.status}: ${errorText}`)
+    }
+
+    const text = await response.text()
+    if (!text) {
+      return {}
+    }
+
+    return JSON.parse(text)
+  }
+}
+
+// Export singleton instance
+export const apiClient = new ApiClient()
+
+// Export class for testing
+export { ApiClient }

+ 23 - 13
frontend/vite.config.ts

@@ -55,40 +55,50 @@ export default defineConfig({
           })
           })
         },
         },
       },
       },
-      // API endpoints - proxy all backend routes
+      // All /api endpoints
+      '/api': 'http://localhost:8080',
+      // Static assets
+      '/static': 'http://localhost:8080',
+      // Preview images
+      '/preview': 'http://localhost:8080',
+      // Legacy root-level API endpoints (for backwards compatibility)
+      // Pattern execution
       '/send_home': 'http://localhost:8080',
       '/send_home': 'http://localhost:8080',
       '/send_coordinate': 'http://localhost:8080',
       '/send_coordinate': 'http://localhost:8080',
       '/stop_execution': 'http://localhost:8080',
       '/stop_execution': 'http://localhost:8080',
-      '/move_to_center': 'http://localhost:8080',
-      '/move_to_perimeter': 'http://localhost:8080',
-      '/set_speed': 'http://localhost:8080',
-      '/run_theta_rho': 'http://localhost:8080',
       '/pause_execution': 'http://localhost:8080',
       '/pause_execution': 'http://localhost:8080',
       '/resume_execution': 'http://localhost:8080',
       '/resume_execution': 'http://localhost:8080',
       '/skip_pattern': 'http://localhost:8080',
       '/skip_pattern': 'http://localhost:8080',
+      '/run_theta_rho': 'http://localhost:8080',
+      '/run_playlist': 'http://localhost:8080',
+      // Movement
+      '/move_to_center': 'http://localhost:8080',
+      '/move_to_perimeter': 'http://localhost:8080',
+      // Speed
+      '/set_speed': 'http://localhost:8080',
+      '/get_speed': 'http://localhost:8080',
+      // Connection
       '/serial_status': 'http://localhost:8080',
       '/serial_status': 'http://localhost:8080',
       '/list_serial_ports': 'http://localhost:8080',
       '/list_serial_ports': 'http://localhost:8080',
       '/connect': 'http://localhost:8080',
       '/connect': 'http://localhost:8080',
       '/disconnect': 'http://localhost:8080',
       '/disconnect': 'http://localhost:8080',
-      '/get_speed': 'http://localhost:8080',
+      // Patterns
       '/list_theta_rho_files': 'http://localhost:8080',
       '/list_theta_rho_files': 'http://localhost:8080',
       '/list_theta_rho_files_with_metadata': 'http://localhost:8080',
       '/list_theta_rho_files_with_metadata': 'http://localhost:8080',
+      '/preview_thr': 'http://localhost:8080',
+      '/preview_thr_batch': 'http://localhost:8080',
+      '/get_theta_rho_coordinates': 'http://localhost:8080',
+      // Playlists
       '/list_all_playlists': 'http://localhost:8080',
       '/list_all_playlists': 'http://localhost:8080',
       '/get_playlist': 'http://localhost:8080',
       '/get_playlist': 'http://localhost:8080',
       '/create_playlist': 'http://localhost:8080',
       '/create_playlist': 'http://localhost:8080',
       '/modify_playlist': 'http://localhost:8080',
       '/modify_playlist': 'http://localhost:8080',
       '/delete_playlist': 'http://localhost:8080',
       '/delete_playlist': 'http://localhost:8080',
       '/rename_playlist': 'http://localhost:8080',
       '/rename_playlist': 'http://localhost:8080',
-      '/run_playlist': 'http://localhost:8080',
       '/add_to_playlist': 'http://localhost:8080',
       '/add_to_playlist': 'http://localhost:8080',
-      '/preview_thr': 'http://localhost:8080',
-      '/preview_thr_batch': 'http://localhost:8080',
-      '/preview': 'http://localhost:8080',
-      '/get_theta_rho_coordinates': 'http://localhost:8080',
+      // LED
       '/get_led_config': 'http://localhost:8080',
       '/get_led_config': 'http://localhost:8080',
       '/set_led_config': 'http://localhost:8080',
       '/set_led_config': 'http://localhost:8080',
-      '/api': 'http://localhost:8080',
-      '/static': 'http://localhost:8080',
     },
     },
   },
   },
   build: {
   build: {

+ 105 - 0
main.py

@@ -1,6 +1,7 @@
 from fastapi import FastAPI, UploadFile, File, HTTPException, BackgroundTasks, WebSocket, WebSocketDisconnect, Request
 from fastapi import FastAPI, UploadFile, File, HTTPException, BackgroundTasks, WebSocket, WebSocketDisconnect, Request
 from fastapi.responses import JSONResponse, FileResponse, Response
 from fastapi.responses import JSONResponse, FileResponse, Response
 from fastapi.staticfiles import StaticFiles
 from fastapi.staticfiles import StaticFiles
+from fastapi.middleware.cors import CORSMiddleware
 from fastapi.templating import Jinja2Templates
 from fastapi.templating import Jinja2Templates
 from pydantic import BaseModel
 from pydantic import BaseModel
 from typing import List, Optional, Tuple, Dict, Any, Union
 from typing import List, Optional, Tuple, Dict, Any, Union
@@ -33,6 +34,7 @@ import argparse
 import subprocess
 import subprocess
 import platform
 import platform
 from modules.core import process_pool as pool_module
 from modules.core import process_pool as pool_module
+from modules.core import mdns
 
 
 # Get log level from environment variable, default to INFO
 # Get log level from environment variable, default to INFO
 log_level_str = os.getenv('LOG_LEVEL', 'INFO').upper()
 log_level_str = os.getenv('LOG_LEVEL', 'INFO').upper()
@@ -265,15 +267,38 @@ async def lifespan(app: FastAPI):
 
 
     asyncio.create_task(idle_timeout_monitor())
     asyncio.create_task(idle_timeout_monitor())
 
 
+    # Start mDNS advertisement for multi-table discovery
+    try:
+        await mdns.start_mdns_advertisement()
+    except Exception as e:
+        logger.warning(f"Failed to start mDNS advertisement: {e}")
+
     yield  # This separates startup from shutdown code
     yield  # This separates startup from shutdown code
 
 
     # Shutdown
     # Shutdown
     logger.info("Shutting down Dune Weaver application...")
     logger.info("Shutting down Dune Weaver application...")
 
 
+    # Stop mDNS advertisement
+    try:
+        await mdns.stop_mdns_advertisement()
+    except Exception as e:
+        logger.warning(f"Error stopping mDNS: {e}")
+
     # Shutdown process pool
     # Shutdown process pool
     pool_module.shutdown_pool(wait=True)
     pool_module.shutdown_pool(wait=True)
 
 
 app = FastAPI(lifespan=lifespan)
 app = FastAPI(lifespan=lifespan)
+
+# Add CORS middleware to allow cross-origin requests from other Dune Weaver frontends
+# This enables multi-table control from a single frontend
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=["*"],  # Allow all origins for local network access
+    allow_credentials=True,
+    allow_methods=["*"],
+    allow_headers=["*"],
+)
+
 templates = Jinja2Templates(directory="templates")
 templates = Jinja2Templates(directory="templates")
 app.mount("/static", StaticFiles(directory="static"), name="static")
 app.mount("/static", StaticFiles(directory="static"), name="static")
 
 
@@ -883,6 +908,86 @@ async def update_settings(settings_update: SettingsUpdate):
         "led_reinit_needed": led_reinit_needed
         "led_reinit_needed": led_reinit_needed
     }
     }
 
 
+# ============================================================================
+# Multi-Table Identity Endpoints
+# ============================================================================
+
+class TableInfoUpdate(BaseModel):
+    name: Optional[str] = None
+
+@app.get("/api/table-info", tags=["multi-table"])
+async def get_table_info():
+    """
+    Get table identity information for multi-table discovery.
+
+    Returns the table's unique ID, name, and version.
+    """
+    return {
+        "id": state.table_id,
+        "name": state.table_name,
+        "version": version_manager.get_version()
+    }
+
+@app.patch("/api/table-info", tags=["multi-table"])
+async def update_table_info(update: TableInfoUpdate):
+    """
+    Update table identity information.
+
+    Currently only the table name can be updated.
+    The table ID is immutable after generation.
+    """
+    if update.name is not None:
+        state.table_name = update.name.strip() or "My Sand Table"
+        state.save()
+        logger.info(f"Table name updated to: {state.table_name}")
+
+    return {
+        "success": True,
+        "id": state.table_id,
+        "name": state.table_name
+    }
+
+@app.get("/api/discover-tables", tags=["multi-table"])
+async def discover_tables(timeout: float = 3.0):
+    """
+    Discover other Dune Weaver tables on the local network.
+
+    Uses mDNS/Bonjour to find tables advertising the _duneweaver._tcp service.
+
+    Args:
+        timeout: Discovery timeout in seconds (default 3.0, max 10.0)
+
+    Returns:
+        List of discovered tables with their id, name, host, port, and url
+    """
+    # Clamp timeout to reasonable range
+    timeout = min(max(timeout, 0.5), 10.0)
+
+    tables = await mdns.discover_tables(timeout=timeout)
+
+    # Also include this table in the list
+    local_table = {
+        "id": state.table_id,
+        "name": state.table_name,
+        "host": "localhost",
+        "port": state.server_port or 8080,
+        "version": version_manager.get_version(),
+        "url": f"http://localhost:{state.server_port or 8080}",
+        "is_current": True
+    }
+
+    # Mark discovered tables as not current
+    for table in tables:
+        table["is_current"] = False
+
+    # Filter out self if discovered via mDNS
+    tables = [t for t in tables if t.get("id") != state.table_id]
+
+    return {
+        "tables": [local_table] + tables,
+        "count": len(tables) + 1
+    }
+
 # ============================================================================
 # ============================================================================
 # Individual Settings Endpoints (Deprecated - use /api/settings instead)
 # Individual Settings Endpoints (Deprecated - use /api/settings instead)
 # ============================================================================
 # ============================================================================

+ 245 - 0
modules/core/mdns.py

@@ -0,0 +1,245 @@
+"""mDNS advertisement and discovery for multi-table support.
+
+This module provides:
+- Service advertisement: Allows this table to be discovered by other frontends
+- Service discovery: Finds other Dune Weaver tables on the local network
+"""
+
+import asyncio
+import logging
+import socket
+from typing import List, Dict, Optional
+from zeroconf import ServiceInfo, Zeroconf, ServiceBrowser, ServiceListener
+from zeroconf.asyncio import AsyncZeroconf, AsyncServiceBrowser
+
+logger = logging.getLogger(__name__)
+
+# Service type for Dune Weaver tables
+SERVICE_TYPE = "_duneweaver._tcp.local."
+
+
+class DuneWeaverServiceListener(ServiceListener):
+    """Listener for discovered Dune Weaver services."""
+
+    def __init__(self):
+        self.discovered_tables: Dict[str, Dict] = {}
+
+    def add_service(self, zc: Zeroconf, type_: str, name: str) -> None:
+        """Called when a new service is discovered."""
+        info = zc.get_service_info(type_, name)
+        if info:
+            self._process_service_info(name, info)
+
+    def update_service(self, zc: Zeroconf, type_: str, name: str) -> None:
+        """Called when an existing service is updated."""
+        info = zc.get_service_info(type_, name)
+        if info:
+            self._process_service_info(name, info)
+
+    def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None:
+        """Called when a service is removed."""
+        if name in self.discovered_tables:
+            del self.discovered_tables[name]
+            logger.debug(f"Table removed: {name}")
+
+    def _process_service_info(self, name: str, info: ServiceInfo) -> None:
+        """Extract table information from service info."""
+        try:
+            # Get properties
+            properties = {}
+            if info.properties:
+                for key, value in info.properties.items():
+                    if isinstance(value, bytes):
+                        properties[key.decode() if isinstance(key, bytes) else key] = value.decode()
+                    else:
+                        properties[key if isinstance(key, str) else key.decode()] = str(value)
+
+            # Get addresses
+            addresses = info.parsed_addresses()
+            host = addresses[0] if addresses else None
+            port = info.port
+
+            if host and port:
+                self.discovered_tables[name] = {
+                    "id": properties.get("id", ""),
+                    "name": properties.get("name", name.replace(f".{SERVICE_TYPE}", "")),
+                    "host": host,
+                    "port": port,
+                    "version": properties.get("version", "unknown"),
+                    "url": f"http://{host}:{port}"
+                }
+                logger.debug(f"Discovered table: {self.discovered_tables[name]}")
+        except Exception as e:
+            logger.warning(f"Error processing service info for {name}: {e}")
+
+
+class MDNSManager:
+    """Manages mDNS advertisement and discovery for Dune Weaver."""
+
+    def __init__(self):
+        self._zeroconf: Optional[AsyncZeroconf] = None
+        self._service_info: Optional[ServiceInfo] = None
+        self._browser: Optional[AsyncServiceBrowser] = None
+        self._listener: Optional[DuneWeaverServiceListener] = None
+        self._advertised = False
+
+    def _get_local_ip(self) -> str:
+        """Get the local IP address of this machine."""
+        try:
+            # Create a socket to determine our IP
+            s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+            try:
+                # Doesn't need to be reachable
+                s.connect(("10.255.255.255", 1))
+                ip = s.getsockname()[0]
+            except Exception:
+                ip = "127.0.0.1"
+            finally:
+                s.close()
+            return ip
+        except Exception:
+            return "127.0.0.1"
+
+    async def start_advertisement(self, table_id: str, table_name: str, version: str, port: int = 8080) -> bool:
+        """
+        Start advertising this table on the network.
+
+        Args:
+            table_id: Unique identifier for this table
+            table_name: Human-readable name for this table
+            version: Software version
+            port: HTTP port the server is running on
+
+        Returns:
+            True if advertisement started successfully
+        """
+        try:
+            if self._advertised:
+                await self.stop_advertisement()
+
+            local_ip = self._get_local_ip()
+            hostname = socket.gethostname()
+
+            # Create service info
+            # Service name must be unique on the network
+            service_name = f"{table_name.replace(' ', '_')}_{table_id[:8]}.{SERVICE_TYPE}"
+
+            self._service_info = ServiceInfo(
+                SERVICE_TYPE,
+                service_name,
+                addresses=[socket.inet_aton(local_ip)],
+                port=port,
+                properties={
+                    "id": table_id,
+                    "name": table_name,
+                    "version": version,
+                    "hostname": hostname
+                },
+                server=f"{hostname}.local."
+            )
+
+            # Start zeroconf and register service
+            self._zeroconf = AsyncZeroconf()
+            await self._zeroconf.async_register_service(self._service_info)
+            self._advertised = True
+
+            logger.info(f"mDNS: Advertising table '{table_name}' at {local_ip}:{port}")
+            return True
+
+        except Exception as e:
+            logger.error(f"Failed to start mDNS advertisement: {e}")
+            return False
+
+    async def stop_advertisement(self) -> None:
+        """Stop advertising this table."""
+        try:
+            if self._service_info and self._zeroconf:
+                await self._zeroconf.async_unregister_service(self._service_info)
+            if self._zeroconf:
+                await self._zeroconf.async_close()
+            self._advertised = False
+            self._service_info = None
+            self._zeroconf = None
+            logger.info("mDNS: Stopped advertising")
+        except Exception as e:
+            logger.warning(f"Error stopping mDNS advertisement: {e}")
+
+    async def discover_tables(self, timeout: float = 3.0) -> List[Dict]:
+        """
+        Discover Dune Weaver tables on the local network.
+
+        Args:
+            timeout: How long to wait for discovery (seconds)
+
+        Returns:
+            List of discovered tables with their info
+        """
+        discovered = []
+
+        try:
+            # Create a temporary zeroconf instance for discovery
+            zc = Zeroconf()
+            listener = DuneWeaverServiceListener()
+
+            # Start browsing for services
+            browser = ServiceBrowser(zc, SERVICE_TYPE, listener)
+
+            # Wait for discovery
+            await asyncio.sleep(timeout)
+
+            # Collect results
+            discovered = list(listener.discovered_tables.values())
+
+            # Cleanup
+            browser.cancel()
+            zc.close()
+
+            logger.info(f"mDNS: Discovered {len(discovered)} table(s)")
+
+        except Exception as e:
+            logger.error(f"Error during mDNS discovery: {e}")
+
+        return discovered
+
+    async def update_advertisement(self, table_name: str) -> None:
+        """Update the advertised table name."""
+        if self._advertised and self._service_info:
+            # Get current info
+            from modules.core.state import state
+            from modules.core.version_manager import version_manager
+
+            # Restart advertisement with new name
+            await self.stop_advertisement()
+            await self.start_advertisement(
+                table_id=state.table_id,
+                table_name=table_name,
+                version=version_manager.get_version(),
+                port=state.server_port or 8080
+            )
+
+
+# Singleton instance
+mdns_manager = MDNSManager()
+
+
+async def start_mdns_advertisement():
+    """Start mDNS advertisement using current state."""
+    from modules.core.state import state
+    from modules.core.version_manager import version_manager
+
+    await mdns_manager.start_advertisement(
+        table_id=state.table_id,
+        table_name=state.table_name,
+        version=version_manager.get_version(),
+        port=state.server_port or 8080
+    )
+
+
+async def stop_mdns_advertisement():
+    """Stop mDNS advertisement."""
+    await mdns_manager.stop_advertisement()
+
+
+async def discover_tables(timeout: float = 3.0) -> List[Dict]:
+    """Discover Dune Weaver tables on the network."""
+    return await mdns_manager.discover_tables(timeout)

+ 12 - 0
modules/core/state.py

@@ -3,6 +3,7 @@ import threading
 import json
 import json
 import os
 import os
 import logging
 import logging
+import uuid
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -101,6 +102,10 @@ class AppState:
         # Application name setting
         # Application name setting
         self.app_name = "Dune Weaver"  # Default app name
         self.app_name = "Dune Weaver"  # Default app name
 
 
+        # Multi-table identity (for network discovery)
+        self.table_id = str(uuid.uuid4())  # UUID generated on first run, persistent across restarts
+        self.table_name = "My Sand Table"  # User-customizable table name
+
         # Custom branding settings (filenames only, files stored in static/custom/)
         # Custom branding settings (filenames only, files stored in static/custom/)
         # Favicon is auto-generated from logo as logo-favicon.ico
         # Favicon is auto-generated from logo as logo-favicon.ico
         self.custom_logo = None  # Custom logo filename (e.g., "logo-abc123.png")
         self.custom_logo = None  # Custom logo filename (e.g., "logo-abc123.png")
@@ -279,6 +284,8 @@ class AppState:
             "dw_led_idle_timeout_enabled": self.dw_led_idle_timeout_enabled,
             "dw_led_idle_timeout_enabled": self.dw_led_idle_timeout_enabled,
             "dw_led_idle_timeout_minutes": self.dw_led_idle_timeout_minutes,
             "dw_led_idle_timeout_minutes": self.dw_led_idle_timeout_minutes,
             "app_name": self.app_name,
             "app_name": self.app_name,
+            "table_id": self.table_id,
+            "table_name": self.table_name,
             "custom_logo": self.custom_logo,
             "custom_logo": self.custom_logo,
             "auto_play_enabled": self.auto_play_enabled,
             "auto_play_enabled": self.auto_play_enabled,
             "auto_play_playlist": self.auto_play_playlist,
             "auto_play_playlist": self.auto_play_playlist,
@@ -366,6 +373,11 @@ class AppState:
         self.dw_led_idle_timeout_minutes = data.get('dw_led_idle_timeout_minutes', 30)
         self.dw_led_idle_timeout_minutes = data.get('dw_led_idle_timeout_minutes', 30)
 
 
         self.app_name = data.get("app_name", "Dune Weaver")
         self.app_name = data.get("app_name", "Dune Weaver")
+        # Load or generate table_id (UUID persisted once generated)
+        self.table_id = data.get("table_id", None)
+        if self.table_id is None:
+            self.table_id = str(uuid.uuid4())
+        self.table_name = data.get("table_name", "My Sand Table")
         self.custom_logo = data.get("custom_logo", None)
         self.custom_logo = data.get("custom_logo", None)
         self.auto_play_enabled = data.get("auto_play_enabled", False)
         self.auto_play_enabled = data.get("auto_play_enabled", False)
         self.auto_play_playlist = data.get("auto_play_playlist", None)
         self.auto_play_playlist = data.get("auto_play_playlist", None)

+ 1 - 0
requirements-nonrpi.txt

@@ -22,3 +22,4 @@ websockets>=11.0.3  # Required for FastAPI WebSocket support
 requests>=2.31.0
 requests>=2.31.0
 Pillow
 Pillow
 aiohttp
 aiohttp
+zeroconf>=0.131.0  

+ 1 - 0
requirements.txt

@@ -13,6 +13,7 @@ websockets>=11.0.3  # Required for FastAPI WebSocket support
 requests>=2.31.0
 requests>=2.31.0
 Pillow
 Pillow
 aiohttp
 aiohttp
+zeroconf>=0.131.0  # mDNS/Bonjour for multi-table discovery
 # GPIO/NeoPixel support for DW LEDs and Desert Compass
 # 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
 # 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
 # Do NOT install both RPi.GPIO and rpi-lgpio - they conflict