Procházet zdrojové kódy

Add autohotspot system for plug-and-play WiFi setup

When no known WiFi network is found on boot, the Pi creates a "Dune Weaver"
hotspot. Users connect, configure WiFi via the app (captive portal), and the
Pi reboots into client mode. Targets Pi OS Trixie (NetworkManager-based).

New files:
- wifi/autohotspot: Boot-time script that scans for known WiFi (30s) then
  falls back to hotspot mode with dnsmasq DNS redirect
- wifi/autohotspot.service: systemd unit (after NM, before Docker)
- wifi/dnsmasq-hotspot.conf: DNS hijack config for captive portal detection
- wifi/setup-wifi.sh: One-time installer for dnsmasq, NM hotspot profile
- modules/wifi/: Backend module with WiFi management API endpoints
  (status, networks, connect, forget, saved) via nmcli/nsenter
- frontend WiFiSetupPage: Network scanning, connect form, saved networks

Modified:
- main.py: Mount WiFi API router
- dw: Add `dw wifi` subcommand (status, scan, connect, hotspot, setup)
- setup-pi.sh: Add --no-hotspot flag, integrate autohotspot setup step
- App.tsx: Add /wifi-setup route
- SettingsPage.tsx: Add WiFi accordion section linking to setup page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
tuanchris před 4 měsíci
rodič
revize
795096f781

+ 90 - 0
dw

@@ -15,6 +15,7 @@
 #   shell       Open a shell in the container
 #   checkout    Switch to a branch and pull its Docker images
 #   touch       Manage touch screen app
+#   wifi        Manage WiFi and hotspot
 #   help        Show this help message
 #
 
@@ -369,6 +370,90 @@ cmd_checkout() {
     echo -e "${GREEN}Checkout complete!${NC}"
 }
 
+# WiFi / Hotspot commands
+cmd_wifi() {
+    local subcmd="${1:-help}"
+
+    case "$subcmd" in
+        status)
+            check_installed
+            echo -e "${BLUE}WiFi Status${NC}"
+            echo ""
+
+            # Show current mode
+            local mode="unknown"
+            if [[ -f /tmp/dw-wifi-mode ]]; then
+                mode=$(cat /tmp/dw-wifi-mode)
+            fi
+            echo -e "Mode: ${GREEN}${mode}${NC}"
+
+            # Show connected network
+            local ssid
+            ssid=$(nmcli -t -f GENERAL.CONNECTION dev show wlan0 2>/dev/null | head -1 | cut -d: -f2)
+            if [[ -n "$ssid" && "$ssid" != "--" ]]; then
+                echo -e "Network: ${GREEN}${ssid}${NC}"
+            fi
+
+            # Show IP
+            local ip
+            ip=$(nmcli -t -f IP4.ADDRESS dev show wlan0 2>/dev/null | head -1 | cut -d: -f2 | cut -d/ -f1)
+            if [[ -n "$ip" ]]; then
+                echo -e "IP: ${GREEN}${ip}${NC}"
+            fi
+            ;;
+        scan)
+            echo -e "${BLUE}Scanning for WiFi networks...${NC}"
+            nmcli dev wifi rescan 2>/dev/null || true
+            sleep 2
+            nmcli dev wifi list
+            ;;
+        connect)
+            echo -e "${BLUE}Available WiFi networks:${NC}"
+            nmcli dev wifi rescan 2>/dev/null || true
+            sleep 2
+            nmcli dev wifi list
+            echo ""
+            read -p "Enter SSID to connect to: " ssid
+            if [[ -z "$ssid" ]]; then
+                echo -e "${RED}No SSID provided${NC}"
+                exit 1
+            fi
+            read -sp "Enter password (leave empty for open networks): " password
+            echo ""
+            if [[ -n "$password" ]]; then
+                sudo nmcli dev wifi connect "$ssid" password "$password" ifname wlan0
+            else
+                sudo nmcli dev wifi connect "$ssid" ifname wlan0
+            fi
+            ;;
+        hotspot)
+            echo -e "${BLUE}Switching to hotspot mode...${NC}"
+            sudo nmcli dev disconnect wlan0 2>/dev/null || true
+            sudo nmcli con up DuneWeaver-Hotspot 2>/dev/null
+            echo "hotspot" | sudo tee /tmp/dw-wifi-mode > /dev/null
+            echo -e "${GREEN}Hotspot active!${NC} Connect to the 'Dune Weaver' WiFi network."
+            ;;
+        setup)
+            check_installed
+            cd "$INSTALL_DIR"
+            sudo bash wifi/setup-wifi.sh
+            ;;
+        help|*)
+            echo -e "${GREEN}WiFi Commands${NC}"
+            echo ""
+            echo "Usage: dw wifi <command>"
+            echo ""
+            echo "Commands:"
+            echo "  status      Show current WiFi mode and connection"
+            echo "  scan        Scan for available networks"
+            echo "  connect     Interactive: scan + select + connect"
+            echo "  hotspot     Force hotspot mode"
+            echo "  setup       Run initial autohotspot setup"
+            echo "  help        Show this help message"
+            ;;
+    esac
+}
+
 cmd_help() {
     echo -e "${GREEN}Dune Weaver CLI${NC}"
     echo ""
@@ -385,6 +470,7 @@ cmd_help() {
     echo "  status      Show container status"
     echo "  shell       Open a shell in the container"
     echo "  touch       Manage touch screen app (run 'dw touch help')"
+    echo "  wifi        Manage WiFi and hotspot (run 'dw wifi help')"
     echo "  help        Show this help message"
     echo ""
     if [[ -n "$INSTALL_DIR" ]]; then
@@ -426,6 +512,10 @@ case "${1:-help}" in
         shift
         cmd_touch "$@"
         ;;
+    wifi)
+        shift
+        cmd_wifi "$@"
+        ;;
     help|--help|-h)
         cmd_help
         ;;

+ 2 - 0
frontend/src/App.tsx

@@ -5,6 +5,7 @@ import { PlaylistsPage } from '@/pages/PlaylistsPage'
 import { TableControlPage } from '@/pages/TableControlPage'
 import { LEDPage } from '@/pages/LEDPage'
 import { SettingsPage } from '@/pages/SettingsPage'
+import { WiFiSetupPage } from '@/pages/WiFiSetupPage'
 import { Toaster } from '@/components/ui/sonner'
 import { TableProvider } from '@/contexts/TableContext'
 
@@ -18,6 +19,7 @@ function App() {
           <Route path="table-control" element={<TableControlPage />} />
           <Route path="led" element={<LEDPage />} />
           <Route path="settings" element={<SettingsPage />} />
+          <Route path="wifi-setup" element={<WiFiSetupPage />} />
         </Route>
       </Routes>
       <Toaster position="top-center" richColors closeButton />

+ 32 - 1
frontend/src/pages/SettingsPage.tsx

@@ -1,5 +1,5 @@
 import { useState, useEffect } from 'react'
-import { useSearchParams } from 'react-router-dom'
+import { useSearchParams, useNavigate } from 'react-router-dom'
 import { toast } from 'sonner'
 import { apiClient } from '@/lib/apiClient'
 import { useOnBackendConnected } from '@/hooks/useBackendConnection'
@@ -100,6 +100,7 @@ interface MqttConfig {
 
 export function SettingsPage() {
   const [searchParams, setSearchParams] = useSearchParams()
+  const navigate = useNavigate()
   const sectionParam = searchParams.get('section')
 
   // Connection state
@@ -2359,6 +2360,36 @@ export function SettingsPage() {
           </AccordionContent>
         </AccordionItem>
 
+        {/* WiFi */}
+        <AccordionItem value="wifi" id="section-wifi" className="border rounded-lg px-4 overflow-visible bg-card">
+          <AccordionTrigger className="hover:no-underline">
+            <div className="flex items-center gap-3">
+              <span className="material-icons-outlined text-muted-foreground">
+                wifi
+              </span>
+              <div className="text-left">
+                <div className="font-semibold">WiFi</div>
+                <div className="text-sm text-muted-foreground font-normal">
+                  Network connection settings
+                </div>
+              </div>
+            </div>
+          </AccordionTrigger>
+          <AccordionContent className="pt-4 pb-6 space-y-3">
+            <p className="text-sm text-muted-foreground">
+              Manage WiFi connections, scan for networks, and configure hotspot mode.
+            </p>
+            <Button
+              variant="outline"
+              className="w-full gap-2"
+              onClick={() => navigate('/wifi-setup')}
+            >
+              <span className="material-icons-outlined">settings</span>
+              Open WiFi Setup
+            </Button>
+          </AccordionContent>
+        </AccordionItem>
+
         {/* Software Version */}
         <AccordionItem value="version" id="section-version" className="border rounded-lg px-4 overflow-visible bg-card">
           <AccordionTrigger className="hover:no-underline">

+ 386 - 0
frontend/src/pages/WiFiSetupPage.tsx

@@ -0,0 +1,386 @@
+import { useState, useEffect, useCallback } from 'react'
+import { toast } from 'sonner'
+import { apiClient } from '@/lib/apiClient'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import { Badge } from '@/components/ui/badge'
+import { Separator } from '@/components/ui/separator'
+
+interface WiFiNetwork {
+  ssid: string
+  signal: number
+  security: string
+  saved: boolean
+  active: boolean
+}
+
+interface WiFiStatus {
+  mode: string
+  ssid: string
+  ip: string
+  hostname: string
+}
+
+interface SavedConnection {
+  name: string
+  ssid: string
+}
+
+function SignalIcon({ signal }: { signal: number }) {
+  // Map signal percentage to a descriptive icon
+  const bars = signal >= 75 ? 'signal_wifi_4_bar' :
+               signal >= 50 ? 'network_wifi_3_bar' :
+               signal >= 25 ? 'network_wifi_2_bar' :
+               'network_wifi_1_bar'
+  const color = signal >= 50 ? 'text-green-500' : signal >= 25 ? 'text-yellow-500' : 'text-red-500'
+  return <span className={`material-icons text-lg ${color}`}>{bars}</span>
+}
+
+export function WiFiSetupPage() {
+  const [status, setStatus] = useState<WiFiStatus | null>(null)
+  const [networks, setNetworks] = useState<WiFiNetwork[]>([])
+  const [savedConnections, setSavedConnections] = useState<SavedConnection[]>([])
+  const [selectedNetwork, setSelectedNetwork] = useState<string | null>(null)
+  const [password, setPassword] = useState('')
+  const [showPassword, setShowPassword] = useState(false)
+  const [isScanning, setIsScanning] = useState(false)
+  const [isConnecting, setIsConnecting] = useState(false)
+  const [isRebooting, setIsRebooting] = useState(false)
+
+  const fetchStatus = useCallback(async () => {
+    try {
+      const data = await apiClient.get<WiFiStatus>('/api/wifi/status')
+      setStatus(data)
+    } catch {
+      // WiFi API may not be available (e.g., not on Pi)
+    }
+  }, [])
+
+  const fetchSaved = useCallback(async () => {
+    try {
+      const data = await apiClient.get<SavedConnection[]>('/api/wifi/saved')
+      setSavedConnections(data)
+    } catch {
+      // Silently fail
+    }
+  }, [])
+
+  const scanNetworks = useCallback(async () => {
+    setIsScanning(true)
+    try {
+      const data = await apiClient.get<WiFiNetwork[]>('/api/wifi/networks')
+      setNetworks(data)
+    } catch {
+      toast.error('Failed to scan networks')
+    } finally {
+      setIsScanning(false)
+    }
+  }, [])
+
+  useEffect(() => {
+    fetchStatus()
+    scanNetworks()
+    fetchSaved()
+  }, [fetchStatus, scanNetworks, fetchSaved])
+
+  const handleConnect = async () => {
+    if (!selectedNetwork) return
+
+    const network = networks.find(n => n.ssid === selectedNetwork)
+    const needsPassword = network && network.security !== 'Open' && !network.saved
+
+    setIsConnecting(true)
+    try {
+      const result = await apiClient.post<{ success: boolean; message: string }>('/api/wifi/connect', {
+        ssid: selectedNetwork,
+        password: needsPassword ? password : '',
+      })
+
+      if (result.success) {
+        setIsRebooting(true)
+        toast.success(result.message)
+      }
+    } catch (err) {
+      const message = err instanceof Error ? err.message : 'Connection failed'
+      toast.error(message)
+    } finally {
+      setIsConnecting(false)
+    }
+  }
+
+  const handleForget = async (ssid: string) => {
+    try {
+      await apiClient.post('/api/wifi/forget', { ssid })
+      toast.success(`Forgot '${ssid}'`)
+      fetchSaved()
+      scanNetworks()
+    } catch {
+      toast.error('Failed to forget network')
+    }
+  }
+
+  const isHotspotMode = status?.mode === 'hotspot'
+
+  const selectedNetworkData = networks.find(n => n.ssid === selectedNetwork)
+  const needsPassword = selectedNetworkData &&
+    selectedNetworkData.security !== 'Open' &&
+    !selectedNetworkData.saved
+
+  if (isRebooting) {
+    return (
+      <div className="container max-w-lg mx-auto px-4 py-8">
+        <Card>
+          <CardContent className="pt-6">
+            <div className="text-center space-y-4">
+              <span className="material-icons text-5xl text-blue-500 animate-spin">refresh</span>
+              <h2 className="text-xl font-semibold">Rebooting...</h2>
+              <p className="text-muted-foreground">
+                The system is rebooting to connect to <strong>{selectedNetwork}</strong>.
+              </p>
+              <p className="text-sm text-muted-foreground">
+                Once connected, access Dune Weaver on your home network at:
+              </p>
+              <code className="text-sm bg-muted px-2 py-1 rounded">
+                http://{status?.hostname || 'duneweaver'}.local
+              </code>
+            </div>
+          </CardContent>
+        </Card>
+      </div>
+    )
+  }
+
+  return (
+    <div className="container max-w-lg mx-auto px-4 py-6 space-y-6">
+      {/* Hotspot Welcome Banner */}
+      {isHotspotMode && (
+        <Alert className="border-blue-200 bg-blue-50 dark:bg-blue-950/30 dark:border-blue-800">
+          <span className="material-icons text-blue-500 mr-2">wifi_tethering</span>
+          <AlertDescription>
+            <strong>Welcome to Dune Weaver!</strong>
+            <br />
+            Connect to your home WiFi network below to get started.
+          </AlertDescription>
+        </Alert>
+      )}
+
+      {/* Current Status */}
+      <Card>
+        <CardHeader className="pb-3">
+          <CardTitle className="text-lg flex items-center gap-2">
+            <span className="material-icons-outlined text-muted-foreground">info</span>
+            WiFi Status
+          </CardTitle>
+        </CardHeader>
+        <CardContent>
+          {status ? (
+            <div className="grid grid-cols-2 gap-3 text-sm">
+              <div>
+                <p className="text-muted-foreground">Mode</p>
+                <Badge variant={isHotspotMode ? 'secondary' : 'default'}>
+                  {status.mode === 'hotspot' ? 'Hotspot' :
+                   status.mode === 'client' ? 'Connected' : status.mode}
+                </Badge>
+              </div>
+              {status.ssid && (
+                <div>
+                  <p className="text-muted-foreground">Network</p>
+                  <p className="font-medium">{status.ssid}</p>
+                </div>
+              )}
+              {status.ip && (
+                <div>
+                  <p className="text-muted-foreground">IP Address</p>
+                  <p className="font-mono text-xs">{status.ip}</p>
+                </div>
+              )}
+              <div>
+                <p className="text-muted-foreground">Hostname</p>
+                <p className="font-mono text-xs">{status.hostname}.local</p>
+              </div>
+            </div>
+          ) : (
+            <p className="text-sm text-muted-foreground">Loading status...</p>
+          )}
+        </CardContent>
+      </Card>
+
+      {/* Available Networks */}
+      <Card>
+        <CardHeader className="pb-3">
+          <div className="flex items-center justify-between">
+            <div>
+              <CardTitle className="text-lg flex items-center gap-2">
+                <span className="material-icons-outlined text-muted-foreground">wifi_find</span>
+                Available Networks
+              </CardTitle>
+              <CardDescription>
+                {networks.length > 0
+                  ? `${networks.length} network${networks.length !== 1 ? 's' : ''} found`
+                  : 'Scanning...'}
+              </CardDescription>
+            </div>
+            <Button
+              variant="outline"
+              size="sm"
+              onClick={scanNetworks}
+              disabled={isScanning}
+            >
+              <span className={`material-icons text-sm mr-1 ${isScanning ? 'animate-spin' : ''}`}>
+                refresh
+              </span>
+              Scan
+            </Button>
+          </div>
+        </CardHeader>
+        <CardContent className="space-y-1">
+          {networks.length === 0 && isScanning && (
+            <p className="text-sm text-muted-foreground text-center py-4">
+              Scanning for networks...
+            </p>
+          )}
+          {networks.length === 0 && !isScanning && (
+            <p className="text-sm text-muted-foreground text-center py-4">
+              No networks found. Try scanning again.
+            </p>
+          )}
+          {networks.map((network) => (
+            <button
+              key={network.ssid}
+              onClick={() => {
+                setSelectedNetwork(network.ssid === selectedNetwork ? null : network.ssid)
+                setPassword('')
+                setShowPassword(false)
+              }}
+              className={`w-full flex items-center gap-3 p-3 rounded-lg text-left transition-colors
+                ${selectedNetwork === network.ssid
+                  ? 'bg-primary/10 ring-1 ring-primary'
+                  : 'hover:bg-muted/50'
+                }
+                ${network.active ? 'bg-green-50 dark:bg-green-950/20' : ''}`}
+            >
+              <SignalIcon signal={network.signal} />
+              <div className="flex-1 min-w-0">
+                <p className="font-medium truncate">{network.ssid}</p>
+                <p className="text-xs text-muted-foreground">
+                  {network.security}
+                  {network.saved && ' · Saved'}
+                  {network.active && ' · Connected'}
+                </p>
+              </div>
+              <span className="text-xs text-muted-foreground">{network.signal}%</span>
+              {network.security !== 'Open' && (
+                <span className="material-icons text-sm text-muted-foreground">lock</span>
+              )}
+            </button>
+          ))}
+
+          {/* Connection Form */}
+          {selectedNetwork && (
+            <div className="mt-4 pt-4 border-t space-y-4">
+              <div className="flex items-center gap-2">
+                <span className="material-icons text-primary">wifi</span>
+                <span className="font-medium">{selectedNetwork}</span>
+              </div>
+
+              {needsPassword && (
+                <div className="space-y-2">
+                  <Label htmlFor="wifi-password">Password</Label>
+                  <div className="relative">
+                    <Input
+                      id="wifi-password"
+                      type={showPassword ? 'text' : 'password'}
+                      value={password}
+                      onChange={(e) => setPassword(e.target.value)}
+                      placeholder="Enter WiFi password"
+                      onKeyDown={(e) => {
+                        if (e.key === 'Enter' && password) handleConnect()
+                      }}
+                    />
+                    <button
+                      type="button"
+                      onClick={() => setShowPassword(!showPassword)}
+                      className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
+                    >
+                      <span className="material-icons text-sm">
+                        {showPassword ? 'visibility_off' : 'visibility'}
+                      </span>
+                    </button>
+                  </div>
+                </div>
+              )}
+
+              <Button
+                className="w-full"
+                onClick={handleConnect}
+                disabled={isConnecting || (needsPassword && !password)}
+              >
+                {isConnecting ? (
+                  <>
+                    <span className="material-icons text-sm animate-spin mr-2">refresh</span>
+                    Connecting...
+                  </>
+                ) : (
+                  <>
+                    <span className="material-icons text-sm mr-2">wifi</span>
+                    Connect{isHotspotMode ? ' & Reboot' : ''}
+                  </>
+                )}
+              </Button>
+            </div>
+          )}
+        </CardContent>
+      </Card>
+
+      {/* Saved Networks */}
+      {savedConnections.length > 0 && (
+        <Card>
+          <CardHeader className="pb-3">
+            <CardTitle className="text-lg flex items-center gap-2">
+              <span className="material-icons-outlined text-muted-foreground">bookmark</span>
+              Saved Networks
+            </CardTitle>
+          </CardHeader>
+          <CardContent className="space-y-1">
+            {savedConnections.map((con) => (
+              <div
+                key={con.name}
+                className="flex items-center justify-between p-3 rounded-lg hover:bg-muted/50"
+              >
+                <div className="flex items-center gap-3">
+                  <span className="material-icons text-muted-foreground">wifi</span>
+                  <span className="font-medium">{con.ssid}</span>
+                </div>
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  onClick={() => handleForget(con.ssid)}
+                  className="text-destructive hover:text-destructive"
+                >
+                  <span className="material-icons text-sm">delete</span>
+                </Button>
+              </div>
+            ))}
+          </CardContent>
+        </Card>
+      )}
+
+      {/* Help */}
+      {isHotspotMode && (
+        <>
+          <Separator />
+          <div className="text-center text-sm text-muted-foreground space-y-1">
+            <p>After connecting, the system will reboot.</p>
+            <p>Reconnect to your home WiFi and access Dune Weaver at:</p>
+            <code className="text-xs bg-muted px-2 py-1 rounded">
+              http://{status?.hostname || 'duneweaver'}.local
+            </code>
+          </div>
+        </>
+      )}
+    </div>
+  )
+}

+ 4 - 0
main.py

@@ -23,6 +23,7 @@ from modules.led.idle_timeout_manager import idle_timeout_manager
 from modules.core.cache_manager import get_cache_path, generate_image_preview, get_pattern_metadata
 from modules.core.version_manager import version_manager
 from modules.core.log_handler import init_memory_handler, get_memory_handler
+from modules.wifi.router import router as wifi_router
 import json
 import base64
 import hashlib
@@ -290,6 +291,9 @@ app.add_middleware(
 templates = Jinja2Templates(directory="templates")
 app.mount("/static", StaticFiles(directory="static"), name="static")
 
+# Include WiFi management router
+app.include_router(wifi_router)
+
 # Global semaphore to limit concurrent preview processing
 # Prevents resource exhaustion when loading many previews simultaneously
 # Lazily initialized to avoid "attached to a different loop" errors

+ 1 - 0
modules/wifi/__init__.py

@@ -0,0 +1 @@
+"""WiFi management module for Dune Weaver."""

+ 280 - 0
modules/wifi/manager.py

@@ -0,0 +1,280 @@
+"""
+WiFi management via NetworkManager (nmcli).
+
+Handles scanning, connecting, and managing WiFi connections.
+Supports both Docker (via nsenter) and direct (venv) execution.
+"""
+
+import subprocess
+import os
+import logging
+import asyncio
+import socket
+
+logger = logging.getLogger(__name__)
+
+MODE_FILE = "/tmp/dw-wifi-mode"
+
+
+def is_docker() -> bool:
+    """Check if running inside a Docker container."""
+    return os.path.exists("/.dockerenv") or os.getenv("DOCKER_CONTAINER") == "1"
+
+
+def run_host_command(*args: str, timeout: int = 30) -> subprocess.CompletedProcess:
+    """Run a command on the host, handling Docker vs venv.
+
+    In Docker: uses nsenter to execute in the host's namespaces.
+    In venv: executes directly.
+    """
+    if is_docker():
+        cmd = ["nsenter", "-t", "1", "-m", "-u", "-i", "-n", "-p", "--"] + list(args)
+    else:
+        cmd = list(args)
+
+    logger.debug(f"Running host command: {' '.join(cmd)}")
+    result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
+
+    if result.returncode != 0 and result.stderr:
+        logger.debug(f"Command stderr: {result.stderr.strip()}")
+
+    return result
+
+
+def run_nmcli(*args: str, timeout: int = 30) -> str:
+    """Run nmcli on the host and return stdout."""
+    result = run_host_command("nmcli", *args, timeout=timeout)
+    return result.stdout
+
+
+def get_wifi_mode() -> str:
+    """Get the current WiFi mode from the mode file."""
+    try:
+        if is_docker():
+            result = run_host_command("cat", MODE_FILE)
+            return result.stdout.strip() or "unknown"
+        else:
+            with open(MODE_FILE, "r") as f:
+                return f.read().strip() or "unknown"
+    except (FileNotFoundError, subprocess.TimeoutExpired):
+        return "unknown"
+
+
+def get_current_ssid() -> str:
+    """Get the SSID of the currently connected WiFi network."""
+    try:
+        output = run_nmcli("-t", "-f", "GENERAL.CONNECTION", "dev", "show", "wlan0")
+        for line in output.strip().splitlines():
+            if "GENERAL.CONNECTION" in line:
+                ssid = line.split(":", 1)[1] if ":" in line else ""
+                if ssid and ssid != "--" and ssid != "DuneWeaver-Hotspot":
+                    return ssid
+    except (subprocess.TimeoutExpired, Exception) as e:
+        logger.debug(f"Error getting SSID: {e}")
+    return ""
+
+
+def get_current_ip() -> str:
+    """Get the current IP address of the wlan0 interface."""
+    try:
+        output = run_nmcli("-t", "-f", "IP4.ADDRESS", "dev", "show", "wlan0")
+        for line in output.strip().splitlines():
+            if "IP4.ADDRESS" in line:
+                addr = line.split(":", 1)[1] if ":" in line else ""
+                if addr:
+                    return addr.split("/")[0]
+    except (subprocess.TimeoutExpired, Exception) as e:
+        logger.debug(f"Error getting IP: {e}")
+    return ""
+
+
+def get_hostname() -> str:
+    """Get the system hostname."""
+    try:
+        return socket.gethostname()
+    except Exception:
+        return "duneweaver"
+
+
+def get_wifi_status() -> dict:
+    """Get comprehensive WiFi status."""
+    mode = get_wifi_mode()
+    ssid = get_current_ssid()
+    ip = get_current_ip()
+    hostname = get_hostname()
+
+    return {
+        "mode": mode,
+        "ssid": ssid,
+        "ip": ip,
+        "hostname": hostname,
+    }
+
+
+def scan_networks() -> list[dict]:
+    """Scan for available WiFi networks."""
+    try:
+        # Trigger rescan
+        run_nmcli("dev", "wifi", "rescan", "ifname", "wlan0")
+    except Exception:
+        pass
+
+    # Brief wait for scan results
+    import time
+    time.sleep(2)
+
+    try:
+        output = run_nmcli("-t", "-f", "SSID,SIGNAL,SECURITY,ACTIVE", "dev", "wifi", "list", "ifname", "wlan0")
+    except subprocess.TimeoutExpired:
+        logger.error("WiFi scan timed out")
+        return []
+
+    # Get saved connections for cross-reference
+    saved = get_saved_connections()
+    saved_ssids = {c["ssid"] for c in saved}
+
+    networks = []
+    seen_ssids = set()
+
+    for line in output.strip().splitlines():
+        if not line.strip():
+            continue
+        # nmcli -t uses : as delimiter, but SSID can contain colons
+        # Format: SSID:SIGNAL:SECURITY:ACTIVE
+        # Parse from the right since SSID is the only field that can contain ':'
+        parts = line.rsplit(":", 3)
+        if len(parts) < 4:
+            continue
+
+        ssid = parts[0].strip()
+        if not ssid or ssid in seen_ssids:
+            continue
+        seen_ssids.add(ssid)
+
+        try:
+            signal = int(parts[1])
+        except (ValueError, IndexError):
+            signal = 0
+
+        security = parts[2] if len(parts) > 2 else ""
+        active = parts[3].strip().lower() == "yes" if len(parts) > 3 else False
+
+        networks.append({
+            "ssid": ssid,
+            "signal": signal,
+            "security": security if security and security != "--" else "Open",
+            "saved": ssid in saved_ssids,
+            "active": active,
+        })
+
+    # Sort by signal strength (strongest first)
+    networks.sort(key=lambda n: n["signal"], reverse=True)
+    return networks
+
+
+def get_saved_connections() -> list[dict]:
+    """Get list of saved WiFi connections."""
+    try:
+        output = run_nmcli("-t", "-f", "NAME,TYPE", "con", "show")
+    except subprocess.TimeoutExpired:
+        return []
+
+    connections = []
+    for line in output.strip().splitlines():
+        if "wireless" not in line:
+            continue
+        name = line.split(":")[0]
+        if name == "DuneWeaver-Hotspot":
+            continue
+
+        # Get the SSID for this connection
+        try:
+            detail = run_nmcli("-t", "-f", "802-11-wireless.ssid", "con", "show", name)
+            ssid = ""
+            for detail_line in detail.strip().splitlines():
+                if "802-11-wireless.ssid" in detail_line:
+                    ssid = detail_line.split(":", 1)[1] if ":" in detail_line else name
+                    break
+            if not ssid:
+                ssid = name
+        except Exception:
+            ssid = name
+
+        connections.append({
+            "name": name,
+            "ssid": ssid,
+        })
+
+    return connections
+
+
+async def connect_to_network(ssid: str, password: str) -> dict:
+    """Connect to a WiFi network and schedule reboot."""
+    try:
+        # Try to connect using nmcli
+        if password:
+            result = run_host_command(
+                "nmcli", "dev", "wifi", "connect", ssid, "password", password,
+                "ifname", "wlan0",
+                timeout=30,
+            )
+        else:
+            result = run_host_command(
+                "nmcli", "dev", "wifi", "connect", ssid,
+                "ifname", "wlan0",
+                timeout=30,
+            )
+
+        if result.returncode != 0:
+            error_msg = result.stderr.strip() or "Failed to connect"
+            logger.error(f"WiFi connect failed: {error_msg}")
+            return {"success": False, "message": error_msg}
+
+        logger.info(f"WiFi connection to '{ssid}' successful, scheduling reboot...")
+
+        # Schedule reboot so the response can be sent first
+        asyncio.get_event_loop().call_later(3, _schedule_reboot)
+
+        return {
+            "success": True,
+            "message": f"Connected to '{ssid}'. Rebooting in 3 seconds...",
+        }
+
+    except subprocess.TimeoutExpired:
+        return {"success": False, "message": "Connection timed out"}
+    except Exception as e:
+        logger.error(f"WiFi connect error: {e}")
+        return {"success": False, "message": str(e)}
+
+
+def _schedule_reboot():
+    """Trigger a system reboot."""
+    try:
+        logger.info("Rebooting system for WiFi mode change...")
+        run_host_command("reboot")
+    except Exception as e:
+        logger.error(f"Reboot failed: {e}")
+
+
+def forget_network(ssid: str) -> dict:
+    """Delete a saved WiFi connection by SSID."""
+    saved = get_saved_connections()
+    con_name = None
+    for con in saved:
+        if con["ssid"] == ssid:
+            con_name = con["name"]
+            break
+
+    if not con_name:
+        return {"success": False, "message": f"No saved connection found for '{ssid}'"}
+
+    try:
+        result = run_host_command("nmcli", "con", "delete", con_name, timeout=15)
+        if result.returncode == 0:
+            logger.info(f"Forgot WiFi network '{ssid}' (connection: {con_name})")
+            return {"success": True, "message": f"Forgot '{ssid}'"}
+        else:
+            error_msg = result.stderr.strip() or "Failed to delete connection"
+            return {"success": False, "message": error_msg}
+    except Exception as e:
+        return {"success": False, "message": str(e)}

+ 65 - 0
modules/wifi/router.py

@@ -0,0 +1,65 @@
+"""
+FastAPI router for WiFi management endpoints.
+"""
+
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel
+from typing import Optional
+import logging
+
+from . import manager
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api/wifi", tags=["wifi"])
+
+
+class WiFiConnectRequest(BaseModel):
+    ssid: str
+    password: Optional[str] = ""
+
+
+class WiFiForgetRequest(BaseModel):
+    ssid: str
+
+
+@router.get("/status")
+async def wifi_status():
+    """Get current WiFi mode, connection, and IP."""
+    return manager.get_wifi_status()
+
+
+@router.get("/networks")
+async def wifi_networks():
+    """Scan for available WiFi networks."""
+    return manager.scan_networks()
+
+
+@router.post("/connect")
+async def wifi_connect(req: WiFiConnectRequest):
+    """Connect to a WiFi network and reboot."""
+    if not req.ssid:
+        raise HTTPException(status_code=400, detail="SSID is required")
+
+    result = await manager.connect_to_network(req.ssid, req.password or "")
+    if not result["success"]:
+        raise HTTPException(status_code=400, detail=result["message"])
+    return result
+
+
+@router.post("/forget")
+async def wifi_forget(req: WiFiForgetRequest):
+    """Forget a saved WiFi network."""
+    if not req.ssid:
+        raise HTTPException(status_code=400, detail="SSID is required")
+
+    result = manager.forget_network(req.ssid)
+    if not result["success"]:
+        raise HTTPException(status_code=400, detail=result["message"])
+    return result
+
+
+@router.get("/saved")
+async def wifi_saved():
+    """Get list of saved WiFi connections."""
+    return manager.get_saved_connections()

+ 32 - 0
setup-pi.sh

@@ -13,6 +13,7 @@
 # Options:
 #   --no-docker     Use Python venv instead of Docker
 #   --no-wifi-fix   Skip WiFi stability fix
+#   --no-hotspot    Skip autohotspot setup
 #   --help          Show help
 #
 
@@ -28,6 +29,7 @@ NC='\033[0m' # No Color
 # Default options
 USE_DOCKER=true
 FIX_WIFI=true  # Applied by default for stability
+SETUP_HOTSPOT=true  # Autohotspot for first-time WiFi setup
 INSTALL_DIR="$HOME/dune-weaver"
 REPO_URL="https://github.com/tuanchris/dune-weaver"
 
@@ -42,6 +44,10 @@ while [[ $# -gt 0 ]]; do
             FIX_WIFI=false
             shift
             ;;
+        --no-hotspot)
+            SETUP_HOTSPOT=false
+            shift
+            ;;
         --help|-h)
             echo "Dune Weaver Raspberry Pi Setup Script"
             echo ""
@@ -54,6 +60,7 @@ while [[ $# -gt 0 ]]; do
             echo "Options:"
             echo "  --no-docker     Use Python venv instead of Docker"
             echo "  --no-wifi-fix   Skip WiFi stability fix (applied by default)"
+            echo "  --no-hotspot    Skip autohotspot setup"
             echo "  --help, -h      Show this help message"
             exit 0
             ;;
@@ -292,6 +299,19 @@ EOF
     print_success "Python deployment complete!"
 }
 
+# Setup autohotspot
+setup_autohotspot() {
+    print_step "Setting up autohotspot..."
+
+    if [[ ! -f "$INSTALL_DIR/wifi/setup-wifi.sh" ]]; then
+        print_warning "wifi/setup-wifi.sh not found, skipping autohotspot setup"
+        return
+    fi
+
+    bash "$INSTALL_DIR/wifi/setup-wifi.sh"
+    print_success "Autohotspot setup complete"
+}
+
 # Get IP address
 get_ip_address() {
     # Try multiple methods to get IP
@@ -326,9 +346,17 @@ print_final_instructions() {
     echo "  dw update      Pull latest and restart"
     echo "  dw stop        Stop Dune Weaver"
     echo "  dw status      Show status"
+    echo "  dw wifi help   WiFi and hotspot management"
     echo "  dw help        Show all commands"
     echo ""
 
+    if [[ "$SETUP_HOTSPOT" == "true" ]]; then
+        echo -e "${BLUE}Autohotspot:${NC} If no known WiFi is found on boot,"
+        echo "a 'Dune Weaver' hotspot will be created automatically."
+        echo "Connect to it and open the app to configure WiFi."
+        echo ""
+    fi
+
     if [[ "$DOCKER_GROUP_ADDED" == "true" ]]; then
         print_warning "Please log out and back in for docker group changes to take effect"
     fi
@@ -375,6 +403,10 @@ main() {
         apply_wifi_fix
     fi
 
+    if [[ "$SETUP_HOTSPOT" == "true" ]]; then
+        setup_autohotspot
+    fi
+
     if [[ "$USE_DOCKER" == "true" ]]; then
         install_docker
         deploy_docker

+ 203 - 0
wifi/autohotspot

@@ -0,0 +1,203 @@
+#!/bin/bash
+#
+# Dune Weaver Autohotspot
+#
+# Runs on boot to decide between WiFi client mode and hotspot mode.
+# If a known WiFi network is found, connect to it.
+# If not, create a "Dune Weaver" hotspot so users can configure WiFi via the app.
+#
+# Requires: NetworkManager (Pi Trixie), dnsmasq (for DNS redirect in hotspot mode)
+#
+
+set -euo pipefail
+
+HOTSPOT_CON_NAME="DuneWeaver-Hotspot"
+MODE_FILE="/tmp/dw-wifi-mode"
+DNSMASQ_CONF="/etc/dune-weaver/dnsmasq-hotspot.conf"
+DNSMASQ_PID="/run/dune-weaver-dnsmasq.pid"
+SCAN_TIMEOUT=30
+SCAN_INTERVAL=5
+IFACE="wlan0"
+
+log() {
+    echo "[autohotspot] $*"
+    logger -t autohotspot "$*"
+}
+
+# Wait for NetworkManager to be ready
+wait_for_nm() {
+    local waited=0
+    while [ $waited -lt 10 ]; do
+        if nmcli general status &>/dev/null; then
+            return 0
+        fi
+        sleep 1
+        waited=$((waited + 1))
+    done
+    log "ERROR: NetworkManager not ready after 10s"
+    return 1
+}
+
+# Get list of saved WiFi connection names
+get_saved_wifi() {
+    nmcli -t -f NAME,TYPE con show | grep ':.*wireless' | cut -d: -f1 | grep -v "^${HOTSPOT_CON_NAME}$" || true
+}
+
+# Scan for available networks and check if any match saved connections
+check_for_known_wifi() {
+    # Trigger a fresh scan
+    nmcli dev wifi rescan ifname "$IFACE" 2>/dev/null || true
+    sleep 2
+
+    # Get available SSIDs
+    local available
+    available=$(nmcli -t -f SSID dev wifi list ifname "$IFACE" 2>/dev/null | sort -u | grep -v '^$' || true)
+
+    if [ -z "$available" ]; then
+        return 1
+    fi
+
+    # Get saved connections
+    local saved
+    saved=$(get_saved_wifi)
+
+    if [ -z "$saved" ]; then
+        return 1
+    fi
+
+    # Check if any saved connection matches an available network
+    while IFS= read -r saved_name; do
+        # Get the SSID associated with this saved connection
+        local saved_ssid
+        saved_ssid=$(nmcli -t -f connection.id,802-11-wireless.ssid con show "$saved_name" 2>/dev/null | grep '802-11-wireless.ssid' | cut -d: -f2 || true)
+
+        # If we couldn't get the SSID from connection details, use the connection name as SSID
+        if [ -z "$saved_ssid" ]; then
+            saved_ssid="$saved_name"
+        fi
+
+        if echo "$available" | grep -qxF "$saved_ssid"; then
+            echo "$saved_name"
+            return 0
+        fi
+    done <<< "$saved"
+
+    return 1
+}
+
+# Activate client mode with a known connection
+activate_client_mode() {
+    local con_name="$1"
+    log "Found known network, activating connection: $con_name"
+
+    # Deactivate hotspot if it's active
+    nmcli con down "$HOTSPOT_CON_NAME" 2>/dev/null || true
+
+    # Stop DNS redirect if running
+    stop_dns_redirect
+
+    # Connect
+    if nmcli con up "$con_name" 2>/dev/null; then
+        local ip
+        ip=$(nmcli -t -f IP4.ADDRESS dev show "$IFACE" 2>/dev/null | head -1 | cut -d: -f2 | cut -d/ -f1 || echo "unknown")
+        log "Client mode active. Connected to '$con_name', IP: $ip"
+        echo "client" > "$MODE_FILE"
+        return 0
+    else
+        log "Failed to connect to '$con_name'"
+        return 1
+    fi
+}
+
+# Activate hotspot mode
+activate_hotspot_mode() {
+    log "No known WiFi found. Activating hotspot mode..."
+
+    # Make sure we're not connected to anything
+    nmcli dev disconnect "$IFACE" 2>/dev/null || true
+
+    # Activate the hotspot profile
+    if nmcli con up "$HOTSPOT_CON_NAME" 2>/dev/null; then
+        log "Hotspot active. SSID: $(nmcli -t -f 802-11-wireless.ssid con show "$HOTSPOT_CON_NAME" 2>/dev/null | cut -d: -f2)"
+
+        # Start DNS redirect for captive portal
+        start_dns_redirect
+
+        echo "hotspot" > "$MODE_FILE"
+        return 0
+    else
+        log "ERROR: Failed to activate hotspot"
+        return 1
+    fi
+}
+
+# Start dnsmasq for DNS redirect (captive portal detection)
+start_dns_redirect() {
+    if [ ! -f "$DNSMASQ_CONF" ]; then
+        log "WARNING: dnsmasq config not found at $DNSMASQ_CONF"
+        return 1
+    fi
+
+    # Kill any existing dnsmasq instance we started
+    stop_dns_redirect
+
+    # Start dnsmasq with our config (DNS-only, no DHCP — NM handles DHCP)
+    # Use port 53 and bind to hotspot interface
+    dnsmasq --conf-file="$DNSMASQ_CONF" \
+             --interface="$IFACE" \
+             --bind-interfaces \
+             --pid-file="$DNSMASQ_PID" \
+             --log-facility=/var/log/dune-weaver-dns.log \
+             2>/dev/null
+
+    log "DNS redirect started (captive portal mode)"
+}
+
+# Stop our dnsmasq instance
+stop_dns_redirect() {
+    if [ -f "$DNSMASQ_PID" ]; then
+        local pid
+        pid=$(cat "$DNSMASQ_PID" 2>/dev/null || true)
+        if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
+            kill "$pid" 2>/dev/null || true
+        fi
+        rm -f "$DNSMASQ_PID"
+    fi
+}
+
+# Main logic
+main() {
+    log "Starting autohotspot check..."
+
+    # Wait for NetworkManager
+    if ! wait_for_nm; then
+        log "NetworkManager not available, exiting"
+        exit 1
+    fi
+
+    # Check if hotspot profile exists
+    if ! nmcli con show "$HOTSPOT_CON_NAME" &>/dev/null; then
+        log "Hotspot profile '$HOTSPOT_CON_NAME' not found. Run 'dw wifi setup' first."
+        echo "client" > "$MODE_FILE"
+        exit 0
+    fi
+
+    # Scan for known WiFi with timeout
+    local elapsed=0
+    while [ $elapsed -lt $SCAN_TIMEOUT ]; do
+        local match
+        if match=$(check_for_known_wifi); then
+            activate_client_mode "$match"
+            exit 0
+        fi
+
+        log "No known WiFi found yet, retrying... (${elapsed}s/${SCAN_TIMEOUT}s)"
+        sleep $SCAN_INTERVAL
+        elapsed=$((elapsed + SCAN_INTERVAL))
+    done
+
+    # No known WiFi found after timeout — go to hotspot mode
+    activate_hotspot_mode
+}
+
+main "$@"

+ 12 - 0
wifi/autohotspot.service

@@ -0,0 +1,12 @@
+[Unit]
+Description=Dune Weaver Autohotspot
+After=NetworkManager.service
+Before=docker.service
+
+[Service]
+Type=oneshot
+RemainAfterExit=yes
+ExecStart=/usr/local/bin/autohotspot
+
+[Install]
+WantedBy=multi-user.target

+ 25 - 0
wifi/dnsmasq-hotspot.conf

@@ -0,0 +1,25 @@
+# Dune Weaver - DNS redirect for captive portal detection
+#
+# Used in hotspot mode only. Redirects ALL DNS queries to the Pi
+# so that phones/tablets trigger captive portal detection and
+# open the Dune Weaver app automatically.
+#
+# iOS checks: captive.apple.com
+# Android checks: connectivitycheck.gstatic.com
+# Windows checks: www.msftconnecttest.com
+#
+# By responding with the Pi's IP for all queries, the OS detects
+# a "captive portal" and opens its built-in browser to the Pi.
+
+# Respond to all DNS queries with the hotspot IP
+address=/#/10.42.0.1
+
+# Don't use upstream DNS servers (we want ALL queries to resolve to us)
+no-resolv
+
+# Don't read /etc/hosts
+no-hosts
+
+# Only handle DNS (port 53), not DHCP (NetworkManager handles DHCP)
+no-dhcp-interface=wlan0
+port=53

+ 173 - 0
wifi/setup-wifi.sh

@@ -0,0 +1,173 @@
+#!/bin/bash
+#
+# Dune Weaver - Autohotspot Setup
+#
+# Sets up the autohotspot system so the Pi creates a "Dune Weaver" WiFi
+# hotspot when no known network is available. Users connect to the hotspot
+# and configure WiFi through the Dune Weaver app.
+#
+# Run: bash wifi/setup-wifi.sh
+# Or:  dw wifi setup
+#
+
+set -e
+
+# Colors
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m'
+
+# Determine script and project directories
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
+
+HOTSPOT_CON_NAME="DuneWeaver-Hotspot"
+HOTSPOT_SSID="Dune Weaver"
+HOTSPOT_IP="10.42.0.1/24"
+IFACE="wlan0"
+CONF_DIR="/etc/dune-weaver"
+
+print_step() {
+    echo -e "\n${BLUE}==>${NC} ${GREEN}$1${NC}"
+}
+
+print_success() {
+    echo -e "${GREEN}$1${NC}"
+}
+
+print_warning() {
+    echo -e "${YELLOW}Warning:${NC} $1"
+}
+
+print_error() {
+    echo -e "${RED}Error:${NC} $1"
+}
+
+# Check prerequisites
+check_prereqs() {
+    print_step "Checking prerequisites..."
+
+    # Check for NetworkManager
+    if ! command -v nmcli &>/dev/null; then
+        print_error "NetworkManager (nmcli) not found. This requires Pi OS Trixie or later."
+        exit 1
+    fi
+
+    # Check for wlan0
+    if ! nmcli dev show "$IFACE" &>/dev/null; then
+        print_warning "WiFi interface '$IFACE' not found. Hotspot may not work."
+    fi
+
+    print_success "Prerequisites OK"
+}
+
+# Install dnsmasq for DNS redirect (captive portal)
+install_dnsmasq() {
+    print_step "Installing dnsmasq..."
+
+    if command -v dnsmasq &>/dev/null; then
+        echo "dnsmasq already installed"
+    else
+        sudo apt update
+        sudo DEBIAN_FRONTEND=noninteractive apt install -y dnsmasq
+    fi
+
+    # Disable the default dnsmasq service — we manage it manually in autohotspot
+    sudo systemctl disable --now dnsmasq 2>/dev/null || true
+
+    print_success "dnsmasq installed and default service disabled"
+}
+
+# Create the NetworkManager hotspot connection profile
+create_hotspot_profile() {
+    print_step "Creating hotspot connection profile..."
+
+    # Remove existing profile if present
+    if nmcli con show "$HOTSPOT_CON_NAME" &>/dev/null; then
+        echo "Removing existing hotspot profile..."
+        sudo nmcli con delete "$HOTSPOT_CON_NAME" 2>/dev/null || true
+    fi
+
+    # Read app name from state.json if available
+    local ssid="$HOTSPOT_SSID"
+    local state_file="$PROJECT_DIR/state.json"
+    if [ -f "$state_file" ]; then
+        local app_name
+        app_name=$(python3 -c "import json; print(json.load(open('$state_file')).get('app_name', ''))" 2>/dev/null || true)
+        if [ -n "$app_name" ]; then
+            ssid="$app_name"
+            echo "Using app name from state.json: $ssid"
+        fi
+    fi
+
+    # Create the hotspot profile (open network, no password)
+    sudo nmcli con add type wifi ifname "$IFACE" mode ap con-name "$HOTSPOT_CON_NAME" \
+        ssid "$ssid" autoconnect no \
+        ipv4.method shared ipv4.addresses "$HOTSPOT_IP"
+
+    print_success "Hotspot profile created: SSID='$ssid', IP=${HOTSPOT_IP%/*}"
+}
+
+# Copy configuration files
+install_configs() {
+    print_step "Installing configuration files..."
+
+    # Create config directory
+    sudo mkdir -p "$CONF_DIR"
+
+    # Copy dnsmasq config
+    sudo cp "$SCRIPT_DIR/dnsmasq-hotspot.conf" "$CONF_DIR/dnsmasq-hotspot.conf"
+    echo "Installed $CONF_DIR/dnsmasq-hotspot.conf"
+
+    # Copy autohotspot script
+    sudo cp "$SCRIPT_DIR/autohotspot" /usr/local/bin/autohotspot
+    sudo chmod +x /usr/local/bin/autohotspot
+    echo "Installed /usr/local/bin/autohotspot"
+
+    print_success "Configuration files installed"
+}
+
+# Install and enable the systemd service
+install_service() {
+    print_step "Installing autohotspot service..."
+
+    sudo cp "$SCRIPT_DIR/autohotspot.service" /etc/systemd/system/autohotspot.service
+    sudo systemctl daemon-reload
+    sudo systemctl enable autohotspot.service
+
+    print_success "autohotspot.service installed and enabled"
+}
+
+# Main
+main() {
+    echo -e "${GREEN}Dune Weaver Autohotspot Setup${NC}"
+    echo ""
+
+    check_prereqs
+    install_dnsmasq
+    create_hotspot_profile
+    install_configs
+    install_service
+
+    echo ""
+    echo -e "${GREEN}============================================${NC}"
+    echo -e "${GREEN}   Autohotspot Setup Complete!${NC}"
+    echo -e "${GREEN}============================================${NC}"
+    echo ""
+    echo "On next boot, the Pi will:"
+    echo "  1. Scan for known WiFi networks (30s timeout)"
+    echo "  2. If found → connect normally"
+    echo "  3. If not found → create a '$(nmcli -t -f 802-11-wireless.ssid con show "$HOTSPOT_CON_NAME" 2>/dev/null | cut -d: -f2 || echo "$HOTSPOT_SSID")' hotspot"
+    echo ""
+    echo "Users can connect to the hotspot and open the Dune Weaver app"
+    echo "to configure WiFi credentials."
+    echo ""
+    echo "Commands:"
+    echo "  dw wifi status   — Show current WiFi mode"
+    echo "  dw wifi hotspot  — Manually switch to hotspot mode"
+    echo "  dw wifi scan     — Scan for available networks"
+}
+
+main "$@"