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 { SettingsPage } from '@/pages/SettingsPage'
 import { Toaster } from '@/components/ui/sonner'
+import { TableProvider } from '@/contexts/TableContext'
 
 function App() {
   return (
-    <>
+    <TableProvider>
       <Routes>
         <Route path="/" element={<Layout />}>
           <Route index element={<BrowsePage />} />
@@ -20,7 +21,7 @@ function App() {
         </Route>
       </Routes>
       <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 { Button } from '@/components/ui/button'
 import { cacheAllPreviews } from '@/lib/previewCache'
+import { TableSelector } from '@/components/TableSelector'
+import { useTable } from '@/contexts/TableContext'
+import { apiClient } from '@/lib/apiClient'
 
 const navItems = [
   { path: '/', label: 'Browse', icon: 'grid_view', title: 'Browse Patterns' },
@@ -17,6 +20,10 @@ const DEFAULT_APP_NAME = 'Dune Weaver'
 
 export function Layout() {
   const location = useLocation()
+
+  // Multi-table context - must be called before any hooks that depend on activeTable
+  const { activeTable } = useTable()
+
   const [isDark, setIsDark] = useState(() => {
     if (typeof window !== 'undefined') {
       const saved = localStorage.getItem('theme')
@@ -43,8 +50,7 @@ export function Layout() {
 
   // Fetch app settings
   const fetchAppSettings = () => {
-    fetch('/api/settings')
-      .then((r) => r.json())
+    apiClient.get<{ app?: { name?: string; custom_logo?: string } }>('/api/settings')
       .then((settings) => {
         if (settings.app?.name) {
           setAppName(settings.app.name)
@@ -68,7 +74,8 @@ export function Layout() {
     return () => {
       window.removeEventListener('branding-updated', handleBrandingUpdate)
     }
-  }, [])
+    // Refetch when active table changes
+  }, [activeTable?.id])
 
   // Homing completion countdown timer
   useEffect(() => {
@@ -160,8 +167,7 @@ export function Layout() {
   // Check device connection status via WebSocket
   useEffect(() => {
     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 = () => {
         setIsBackendConnected(true)
@@ -238,7 +244,8 @@ export function Layout() {
         wsRef.current.close()
       }
     }
-  }, [])
+    // Reconnect when active table changes
+  }, [activeTable?.id])
 
   // Connect to logs WebSocket when drawer opens
   useEffect(() => {
@@ -254,11 +261,11 @@ export function Layout() {
     // Fetch initial logs
     const fetchInitialLogs = async () => {
       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
         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)
         setLogs(validLogs.reverse())
@@ -279,8 +286,7 @@ export function Layout() {
     let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
 
     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 = () => {
         console.log('Logs WebSocket connected')
@@ -522,12 +528,8 @@ export function Layout() {
     if (!confirm('Are you sure you want to restart Docker containers?')) return
 
     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 {
       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
 
     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 {
       toast.error('Failed to shutdown system')
     }
@@ -637,8 +635,7 @@ export function Layout() {
     if (isHoming && isBackendConnected) {
       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) => {
         try {
@@ -711,8 +708,7 @@ export function Layout() {
     const connectCacheWebSocket = () => {
       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) => {
         try {
@@ -1148,6 +1144,7 @@ export function Layout() {
             />
           </Link>
           <div className="flex items-center gap-1">
+            <TableSelector />
             <Button
               variant="ghost"
               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_coordinate': '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',
       '/resume_execution': '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',
       '/list_serial_ports': 'http://localhost:8080',
       '/connect': 'http://localhost:8080',
       '/disconnect': 'http://localhost:8080',
-      '/get_speed': 'http://localhost:8080',
+      // Patterns
       '/list_theta_rho_files': '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',
       '/get_playlist': 'http://localhost:8080',
       '/create_playlist': 'http://localhost:8080',
       '/modify_playlist': 'http://localhost:8080',
       '/delete_playlist': 'http://localhost:8080',
       '/rename_playlist': 'http://localhost:8080',
-      '/run_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',
       '/set_led_config': 'http://localhost:8080',
-      '/api': 'http://localhost:8080',
-      '/static': 'http://localhost:8080',
     },
   },
   build: {

+ 105 - 0
main.py

@@ -1,6 +1,7 @@
 from fastapi import FastAPI, UploadFile, File, HTTPException, BackgroundTasks, WebSocket, WebSocketDisconnect, Request
 from fastapi.responses import JSONResponse, FileResponse, Response
 from fastapi.staticfiles import StaticFiles
+from fastapi.middleware.cors import CORSMiddleware
 from fastapi.templating import Jinja2Templates
 from pydantic import BaseModel
 from typing import List, Optional, Tuple, Dict, Any, Union
@@ -33,6 +34,7 @@ import argparse
 import subprocess
 import platform
 from modules.core import process_pool as pool_module
+from modules.core import mdns
 
 # Get log level from environment variable, default to INFO
 log_level_str = os.getenv('LOG_LEVEL', 'INFO').upper()
@@ -265,15 +267,38 @@ async def lifespan(app: FastAPI):
 
     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
 
     # Shutdown
     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
     pool_module.shutdown_pool(wait=True)
 
 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")
 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
     }
 
+# ============================================================================
+# 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)
 # ============================================================================

+ 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 os
 import logging
+import uuid
 
 logger = logging.getLogger(__name__)
 
@@ -101,6 +102,10 @@ class AppState:
         # Application name setting
         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/)
         # Favicon is auto-generated from logo as logo-favicon.ico
         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_minutes": self.dw_led_idle_timeout_minutes,
             "app_name": self.app_name,
+            "table_id": self.table_id,
+            "table_name": self.table_name,
             "custom_logo": self.custom_logo,
             "auto_play_enabled": self.auto_play_enabled,
             "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.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.auto_play_enabled = data.get("auto_play_enabled", False)
         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
 Pillow
 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
 Pillow
 aiohttp
+zeroconf>=0.131.0  # mDNS/Bonjour for multi-table discovery
 # 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