浏览代码

Add Flash Firmware UI to Setup page

- Add FirmwareFlasher component to SetupPage with table type and
  serial port selection, live output console, and success/error status
- Add backend endpoints: /api/firmware/tables, /api/firmware/flash,
  /api/firmware/flash-status for non-interactive flash script execution
- Auto-disconnect from controller before flashing to free serial port
- Add --table and --port CLI args to flash-fluidnc.sh for non-interactive mode
- Fix FluidNC Configuration section not collapsing (remove forceMount)
- Only auto-expand Calibration Wizard section by default

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
tuanchris 4 月之前
父节点
当前提交
65f02efdb6

+ 112 - 84
flash-fluidnc.sh

@@ -2,17 +2,29 @@
 #
 # Flash FluidNC firmware onto a connected ESP32 board
 #
-# Usage: bash flash-fluidnc.sh
+# Usage:
+#   bash flash-fluidnc.sh                              # Interactive mode
+#   bash flash-fluidnc.sh --table <type> --port <port> # Non-interactive mode
 #
-# Interactive script that:
-#   1. Lets user choose FluidNC version (v3.9.5 for all tables, v3.8.3 for Mini Dune Weaver)
-#   2. Lets user select a serial port
-#   3. Downloads, extracts, and flashes the firmware
-#   4. Confirms success by checking esptool output
+# Non-interactive options:
+#   --table <type>  Table type: dune_weaver, dune_weaver_pro, dune_weaver_gold,
+#                   dune_weaver_mini_pro, dune_weaver_mini
+#   --port <port>   Serial port (e.g. /dev/ttyUSB0)
 #
 
 set -e
 
+# Parse CLI arguments for non-interactive mode
+CLI_TABLE=""
+CLI_PORT=""
+while [[ $# -gt 0 ]]; do
+    case "$1" in
+        --table) CLI_TABLE="$2"; shift 2 ;;
+        --port)  CLI_PORT="$2"; shift 2 ;;
+        *)       echo "Unknown option: $1"; exit 1 ;;
+    esac
+done
+
 # Warn if running as root/sudo — venv and pip won't work correctly
 if [[ $EUID -eq 0 ]]; then
     echo -e "\033[1;33mWarning:\033[0m Running as root is not recommended."
@@ -60,47 +72,52 @@ echo "  ║         for Dune Weaver               ║"
 echo "  ╚═══════════════════════════════════════╝"
 echo -e "${NC}"
 
-echo -e "${BOLD}Select your table type:${NC}"
-echo ""
-echo -e "  ${GREEN}1)${NC} Dune Weaver Pro"
-echo -e "  ${GREEN}2)${NC} Dune Weaver Mini Pro"
-echo -e "  ${GREEN}3)${NC} Dune Weaver Gold"
-echo -e "  ${GREEN}4)${NC} Dune Weaver"
-echo -e "  ${GREEN}5)${NC} Dune Weaver Mini"
-echo ""
-read -p "Enter choice [1-5]: " table_choice
-
-case "$table_choice" in
-    1)
-        TABLE_NAME="Dune Weaver Pro"
-        CONFIG_DIR="dune_weaver_pro"
-        VERSION="$VERSION_ALL"
-        ;;
-    2)
-        TABLE_NAME="Dune Weaver Mini Pro"
-        CONFIG_DIR="dune_weaver_mini_pro"
-        VERSION="$VERSION_ALL"
-        ;;
-    3)
-        TABLE_NAME="Dune Weaver Gold"
-        CONFIG_DIR="dune_weaver_gold"
-        VERSION="$VERSION_ALL"
-        ;;
-    4)
-        TABLE_NAME="Dune Weaver"
-        CONFIG_DIR="dune_weaver"
-        VERSION="$VERSION_ALL"
-        ;;
-    5)
-        TABLE_NAME="Dune Weaver Mini"
-        CONFIG_DIR="dune_weaver_mini"
-        VERSION="$VERSION_MINI"
-        ;;
-    *)
-        echo -e "${RED}Invalid choice. Exiting.${NC}"
-        exit 1
-        ;;
-esac
+# Map table type string to name and version
+resolve_table() {
+    case "$1" in
+        dune_weaver_pro)
+            TABLE_NAME="Dune Weaver Pro"; CONFIG_DIR="dune_weaver_pro"; VERSION="$VERSION_ALL" ;;
+        dune_weaver_mini_pro)
+            TABLE_NAME="Dune Weaver Mini Pro"; CONFIG_DIR="dune_weaver_mini_pro"; VERSION="$VERSION_ALL" ;;
+        dune_weaver_gold)
+            TABLE_NAME="Dune Weaver Gold"; CONFIG_DIR="dune_weaver_gold"; VERSION="$VERSION_ALL" ;;
+        dune_weaver)
+            TABLE_NAME="Dune Weaver"; CONFIG_DIR="dune_weaver"; VERSION="$VERSION_ALL" ;;
+        dune_weaver_mini)
+            TABLE_NAME="Dune Weaver Mini"; CONFIG_DIR="dune_weaver_mini"; VERSION="$VERSION_MINI" ;;
+        *)
+            echo -e "${RED}Unknown table type: $1${NC}"
+            echo "Valid types: dune_weaver_pro, dune_weaver_mini_pro, dune_weaver_gold, dune_weaver, dune_weaver_mini"
+            exit 1 ;;
+    esac
+}
+
+if [[ -n "$CLI_TABLE" ]]; then
+    # Non-interactive mode
+    resolve_table "$CLI_TABLE"
+else
+    # Interactive mode
+    echo -e "${BOLD}Select your table type:${NC}"
+    echo ""
+    echo -e "  ${GREEN}1)${NC} Dune Weaver Pro"
+    echo -e "  ${GREEN}2)${NC} Dune Weaver Mini Pro"
+    echo -e "  ${GREEN}3)${NC} Dune Weaver Gold"
+    echo -e "  ${GREEN}4)${NC} Dune Weaver"
+    echo -e "  ${GREEN}5)${NC} Dune Weaver Mini"
+    echo ""
+    read -p "Enter choice [1-5]: " table_choice
+
+    case "$table_choice" in
+        1) resolve_table "dune_weaver_pro" ;;
+        2) resolve_table "dune_weaver_mini_pro" ;;
+        3) resolve_table "dune_weaver_gold" ;;
+        4) resolve_table "dune_weaver" ;;
+        5) resolve_table "dune_weaver_mini" ;;
+        *)
+            echo -e "${RED}Invalid choice. Exiting.${NC}"
+            exit 1 ;;
+    esac
+fi
 
 echo -e "\nSelected: ${GREEN}${TABLE_NAME}${NC} (FluidNC v${VERSION})"
 
@@ -114,52 +131,63 @@ echo -e "Config: ${GREEN}${CONFIG_FILE}${NC}"
 
 # ─── Step 2: Detect and select serial port ─────────────────────────────────
 
-echo ""
-echo -e "${BOLD}Detecting serial ports...${NC}"
-
-# Collect available serial ports (Linux + macOS patterns)
-PORTS=()
-for port in /dev/ttyUSB* /dev/ttyACM* /dev/cu.usbserial* /dev/cu.wchusbserial* /dev/cu.SLAB_USBtoUART*; do
-    if [[ -e "$port" ]]; then
-        PORTS+=("$port")
-    fi
-done
-
-if [[ ${#PORTS[@]} -eq 0 ]]; then
-    echo -e "${RED}No serial ports found!${NC}"
-    echo ""
-    echo "Make sure your ESP32 board is connected via USB."
-    echo "On Linux, you may need to add your user to the 'dialout' group:"
-    echo "  sudo usermod -a -G dialout \$USER"
-    exit 1
-fi
-
-if [[ ${#PORTS[@]} -eq 1 ]]; then
-    PORT="${PORTS[0]}"
-    echo -e "Found port: ${GREEN}${PORT}${NC}"
-    read -p "Press Enter to use this port (or 'n' to cancel): " confirm
-    if [[ "$confirm" =~ ^[Nn] ]]; then
-        echo -e "${RED}Exiting.${NC}"
+if [[ -n "$CLI_PORT" ]]; then
+    # Non-interactive mode
+    PORT="$CLI_PORT"
+    if [[ ! -e "$PORT" ]]; then
+        echo -e "${RED}Port not found: ${PORT}${NC}"
         exit 1
     fi
+    echo -e "Using port: ${GREEN}${PORT}${NC}"
 else
-    echo -e "Found ${GREEN}${#PORTS[@]}${NC} serial ports:"
+    # Interactive mode
     echo ""
-    for i in "${!PORTS[@]}"; do
-        echo -e "  ${GREEN}$((i + 1)))${NC} ${PORTS[$i]}"
+    echo -e "${BOLD}Detecting serial ports...${NC}"
+
+    # Collect available serial ports (Linux + macOS patterns)
+    PORTS=()
+    for port in /dev/ttyUSB* /dev/ttyACM* /dev/cu.usbserial* /dev/cu.wchusbserial* /dev/cu.SLAB_USBtoUART*; do
+        if [[ -e "$port" ]]; then
+            PORTS+=("$port")
+        fi
     done
-    echo ""
-    read -p "Select port [1-${#PORTS[@]}]: " port_choice
 
-    if [[ "$port_choice" -ge 1 && "$port_choice" -le ${#PORTS[@]} ]] 2>/dev/null; then
-        PORT="${PORTS[$((port_choice - 1))]}"
-    else
-        echo -e "${RED}Invalid choice. Exiting.${NC}"
+    if [[ ${#PORTS[@]} -eq 0 ]]; then
+        echo -e "${RED}No serial ports found!${NC}"
+        echo ""
+        echo "Make sure your ESP32 board is connected via USB."
+        echo "On Linux, you may need to add your user to the 'dialout' group:"
+        echo "  sudo usermod -a -G dialout \$USER"
         exit 1
     fi
-fi
 
-echo -e "Using port: ${GREEN}${PORT}${NC}"
+    if [[ ${#PORTS[@]} -eq 1 ]]; then
+        PORT="${PORTS[0]}"
+        echo -e "Found port: ${GREEN}${PORT}${NC}"
+        read -p "Press Enter to use this port (or 'n' to cancel): " confirm
+        if [[ "$confirm" =~ ^[Nn] ]]; then
+            echo -e "${RED}Exiting.${NC}"
+            exit 1
+        fi
+    else
+        echo -e "Found ${GREEN}${#PORTS[@]}${NC} serial ports:"
+        echo ""
+        for i in "${!PORTS[@]}"; do
+            echo -e "  ${GREEN}$((i + 1)))${NC} ${PORTS[$i]}"
+        done
+        echo ""
+        read -p "Select port [1-${#PORTS[@]}]: " port_choice
+
+        if [[ "$port_choice" -ge 1 && "$port_choice" -le ${#PORTS[@]} ]] 2>/dev/null; then
+            PORT="${PORTS[$((port_choice - 1))]}"
+        else
+            echo -e "${RED}Invalid choice. Exiting.${NC}"
+            exit 1
+        fi
+    fi
+
+    echo -e "Using port: ${GREEN}${PORT}${NC}"
+fi
 
 # ─── Step 3: Check for esptool ─────────────────────────────────────────────
 

+ 216 - 3
frontend/src/pages/SetupPage.tsx

@@ -1,4 +1,4 @@
-import { useState, useCallback } from 'react'
+import { useState, useCallback, useEffect, useRef } from 'react'
 import { useNavigate } from 'react-router-dom'
 import { toast } from 'sonner'
 import { apiClient } from '@/lib/apiClient'
@@ -10,6 +10,13 @@ import { Switch } from '@/components/ui/switch'
 import { Alert, AlertDescription } from '@/components/ui/alert'
 import { Badge } from '@/components/ui/badge'
 import { Separator } from '@/components/ui/separator'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
 import {
   Accordion,
   AccordionContent,
@@ -838,6 +845,195 @@ function ConfigEditor() {
   )
 }
 
+// ─── Firmware Flasher ────────────────────────────────────────────────────────
+
+interface TableType {
+  id: string
+  name: string
+  config_exists: boolean
+}
+
+function FirmwareFlasher() {
+  const [tables, setTables] = useState<TableType[]>([])
+  const [selectedTable, setSelectedTable] = useState('')
+  const [ports, setPorts] = useState<string[]>([])
+  const [selectedPort, setSelectedPort] = useState('')
+  const [flashing, setFlashing] = useState(false)
+  const [output, setOutput] = useState<string[]>([])
+  const [finished, setFinished] = useState(false)
+  const [flashSuccess, setFlashSuccess] = useState(false)
+  const outputRef = useRef<HTMLDivElement>(null)
+  const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
+
+  // Load tables and ports on mount
+  useEffect(() => {
+    apiClient.get<{ tables: TableType[] }>('/api/firmware/tables').then((res) => {
+      setTables(res.tables)
+    }).catch(() => toast.error('Failed to load table types'))
+
+    apiClient.get<string[]>('/list_serial_ports').then((res) => {
+      setPorts(res)
+      if (res.length === 1) setSelectedPort(res[0])
+    }).catch(() => toast.error('Failed to list serial ports'))
+  }, [])
+
+  // Auto-scroll output
+  useEffect(() => {
+    if (outputRef.current) {
+      outputRef.current.scrollTop = outputRef.current.scrollHeight
+    }
+  }, [output])
+
+  // Cleanup polling on unmount
+  useEffect(() => {
+    return () => {
+      if (pollRef.current) clearInterval(pollRef.current)
+    }
+  }, [])
+
+  const startFlash = async () => {
+    if (!selectedTable || !selectedPort) {
+      toast.error('Select a table type and serial port')
+      return
+    }
+
+    setFlashing(true)
+    setFinished(false)
+    setFlashSuccess(false)
+    setOutput([])
+
+    try {
+      await apiClient.post('/api/firmware/flash', {
+        table_type: selectedTable,
+        port: selectedPort,
+      })
+
+      // Poll for output
+      let lastLineCount = 0
+      pollRef.current = setInterval(async () => {
+        try {
+          const status = await apiClient.get<{
+            running: boolean
+            output: string[]
+            line_count: number
+          }>('/api/firmware/flash-status')
+
+          if (status.line_count > lastLineCount) {
+            setOutput(status.output)
+            lastLineCount = status.line_count
+          }
+
+          if (!status.running) {
+            if (pollRef.current) clearInterval(pollRef.current)
+            pollRef.current = null
+            setFlashing(false)
+            setFinished(true)
+            const hasSuccess = status.output.some(
+              (l) => l.includes('setup complete') || l.includes('flash successful')
+            )
+            setFlashSuccess(hasSuccess)
+          }
+        } catch {
+          // Ignore poll errors
+        }
+      }, 1000)
+    } catch (err) {
+      toast.error(`Flash failed: ${err instanceof Error ? err.message : 'Unknown error'}`)
+      setFlashing(false)
+    }
+  }
+
+  // Strip ANSI color codes for display
+  const stripAnsi = (str: string) => str.replace(/\x1b\[[0-9;]*m/g, '')
+
+  return (
+    <div className="space-y-4">
+      <Alert>
+        <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
+        <AlertDescription>
+          Flash the FluidNC filesystem and upload the correct config for your table type.
+          This does not require an active connection — the flasher uses the serial port directly.
+        </AlertDescription>
+      </Alert>
+
+      <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
+        <div className="space-y-2">
+          <Label>Table Type</Label>
+          <Select value={selectedTable} onValueChange={setSelectedTable} disabled={flashing}>
+            <SelectTrigger>
+              <SelectValue placeholder="Select your table..." />
+            </SelectTrigger>
+            <SelectContent>
+              {tables.map((t) => (
+                <SelectItem key={t.id} value={t.id}>
+                  {t.name}
+                </SelectItem>
+              ))}
+            </SelectContent>
+          </Select>
+        </div>
+        <div className="space-y-2">
+          <Label>Serial Port</Label>
+          <Select value={selectedPort} onValueChange={setSelectedPort} disabled={flashing}>
+            <SelectTrigger>
+              <SelectValue placeholder="Select port..." />
+            </SelectTrigger>
+            <SelectContent>
+              {ports.length === 0 ? (
+                <SelectItem value="_none" disabled>
+                  No ports found
+                </SelectItem>
+              ) : (
+                ports.map((p) => (
+                  <SelectItem key={p} value={p}>
+                    {p}
+                  </SelectItem>
+                ))
+              )}
+            </SelectContent>
+          </Select>
+        </div>
+      </div>
+
+      <Button
+        onClick={startFlash}
+        disabled={flashing || !selectedTable || !selectedPort}
+      >
+        {flashing ? (
+          <span className="material-icons-outlined animate-spin mr-2 text-base">sync</span>
+        ) : (
+          <span className="material-icons-outlined mr-2 text-base">memory</span>
+        )}
+        {flashing ? 'Flashing...' : 'Flash Firmware'}
+      </Button>
+
+      {output.length > 0 && (
+        <div
+          ref={outputRef}
+          className="bg-black text-green-400 font-mono text-xs p-4 rounded-lg max-h-64 overflow-y-auto whitespace-pre-wrap"
+        >
+          {output.map((line, i) => (
+            <div key={i}>{stripAnsi(line)}</div>
+          ))}
+        </div>
+      )}
+
+      {finished && (
+        <Alert variant={flashSuccess ? 'default' : 'destructive'}>
+          <span className={`material-icons-outlined text-base mr-2 shrink-0 ${flashSuccess ? 'text-green-500' : ''}`}>
+            {flashSuccess ? 'check_circle' : 'error'}
+          </span>
+          <AlertDescription>
+            {flashSuccess
+              ? 'Firmware flashed and config uploaded successfully! The board is restarting.'
+              : 'Flash completed with errors. Check the output above for details.'}
+          </AlertDescription>
+        </Alert>
+      )}
+    </div>
+  )
+}
+
 // ─── Setup Page ──────────────────────────────────────────────────────────────
 
 export function SetupPage() {
@@ -868,7 +1064,7 @@ export function SetupPage() {
       </Alert>
 
       {/* Main content */}
-      <Accordion type="multiple" defaultValue={['calibration', 'config']}>
+      <Accordion type="multiple" defaultValue={['calibration']}>
         <AccordionItem value="calibration" className="border rounded-lg px-4 bg-card">
           <AccordionTrigger className="hover:no-underline">
             <div className="flex items-center gap-3">
@@ -898,7 +1094,7 @@ export function SetupPage() {
               </div>
             </div>
           </AccordionTrigger>
-          <AccordionContent className="pt-4 pb-6" forceMount>
+          <AccordionContent className="pt-4 pb-6">
             <Alert className="mb-4">
               <span className="material-icons-outlined text-base mr-2 shrink-0">warning</span>
               <AlertDescription>
@@ -908,6 +1104,23 @@ export function SetupPage() {
             <ConfigEditor />
           </AccordionContent>
         </AccordionItem>
+
+        <AccordionItem value="flash" className="border rounded-lg px-4 mt-2 bg-card">
+          <AccordionTrigger className="hover:no-underline">
+            <div className="flex items-center gap-3">
+              <span className="material-icons-outlined text-muted-foreground">memory</span>
+              <div className="text-left">
+                <div className="font-semibold">Flash Firmware</div>
+                <div className="text-sm text-muted-foreground font-normal">
+                  Flash FluidNC filesystem and upload table config
+                </div>
+              </div>
+            </div>
+          </AccordionTrigger>
+          <AccordionContent className="pt-4 pb-6">
+            <FirmwareFlasher />
+          </AccordionContent>
+        </AccordionItem>
       </Accordion>
     </div>
   )

+ 104 - 0
main.py

@@ -4237,6 +4237,110 @@ async def fluidnc_config_write(update: FluidNCConfigUpdate):
         raise HTTPException(status_code=500, detail=str(e))
 
 
+# ─── Firmware Flash Endpoints ────────────────────────────────────────────────
+
+FIRMWARE_TABLES = {
+    "dune_weaver_pro": "Dune Weaver Pro",
+    "dune_weaver_mini_pro": "Dune Weaver Mini Pro",
+    "dune_weaver_gold": "Dune Weaver Gold",
+    "dune_weaver": "Dune Weaver",
+    "dune_weaver_mini": "Dune Weaver Mini",
+}
+
+_flash_process: Optional[asyncio.subprocess.Process] = None
+_flash_output: list[str] = []
+_flash_running = False
+
+
+@app.get("/api/firmware/tables")
+async def list_firmware_tables():
+    """List available table types and their config files."""
+    base = os.path.dirname(os.path.abspath(__file__))
+    tables = []
+    for key, name in FIRMWARE_TABLES.items():
+        config_path = os.path.join(base, "firmware", key, "config.yaml")
+        tables.append({
+            "id": key,
+            "name": name,
+            "config_exists": os.path.exists(config_path),
+        })
+    return {"tables": tables}
+
+
+class FlashRequest(BaseModel):
+    table_type: str
+    port: str
+
+
+@app.post("/api/firmware/flash")
+async def flash_firmware(request: FlashRequest):
+    """Run the flash script in non-interactive mode."""
+    global _flash_process, _flash_output, _flash_running
+
+    if _flash_running:
+        raise HTTPException(status_code=409, detail="Flash already in progress")
+
+    if request.table_type not in FIRMWARE_TABLES:
+        raise HTTPException(status_code=400, detail=f"Unknown table type: {request.table_type}")
+
+    if not os.path.exists(request.port):
+        raise HTTPException(status_code=400, detail=f"Serial port not found: {request.port}")
+
+    base = os.path.dirname(os.path.abspath(__file__))
+    script = os.path.join(base, "flash-fluidnc.sh")
+
+    if not os.path.exists(script):
+        raise HTTPException(status_code=500, detail="Flash script not found")
+
+    # Disconnect from the serial port if currently connected, so the flash script can use it
+    if state.conn and state.conn.is_connected():
+        try:
+            await asyncio.to_thread(connection_manager.disconnect)
+            logger.info("Disconnected from controller for firmware flash")
+        except Exception as e:
+            logger.warning(f"Failed to disconnect before flash: {e}")
+
+    _flash_output.clear()
+    _flash_running = True
+
+    async def run_flash():
+        global _flash_process, _flash_running
+        try:
+            _flash_process = await asyncio.create_subprocess_exec(
+                "bash", script, "--table", request.table_type, "--port", request.port,
+                stdout=asyncio.subprocess.PIPE,
+                stderr=asyncio.subprocess.STDOUT,
+                env={**os.environ, "TERM": "dumb"},
+            )
+            while True:
+                line = await _flash_process.stdout.readline()
+                if not line:
+                    break
+                decoded = line.decode("utf-8", errors="replace").rstrip()
+                _flash_output.append(decoded)
+                logger.debug(f"[flash] {decoded}")
+            await _flash_process.wait()
+        except Exception as e:
+            _flash_output.append(f"ERROR: {e}")
+            logger.error(f"Flash process error: {e}")
+        finally:
+            _flash_running = False
+            _flash_process = None
+
+    asyncio.create_task(run_flash())
+    return {"success": True, "message": "Flash started"}
+
+
+@app.get("/api/firmware/flash-status")
+async def flash_status():
+    """Get the current flash progress."""
+    return {
+        "running": _flash_running,
+        "output": _flash_output,
+        "line_count": len(_flash_output),
+    }
+
+
 def entrypoint():
     import uvicorn
     logger.info("Starting FastAPI server on port 8080...")

文件差异内容过多而无法显示
+ 0 - 0
static/dist/assets/index-D7c9cGUr.css


文件差异内容过多而无法显示
+ 0 - 0
static/dist/assets/index-iGL2FzQP.css


文件差异内容过多而无法显示
+ 0 - 0
static/dist/assets/index-xZylhrh7.js


+ 2 - 2
static/dist/index.html

@@ -58,8 +58,8 @@
           .catch(function() {});
       })();
     </script>
-    <script type="module" crossorigin src="/assets/index-bDuzJAeR.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-iGL2FzQP.css">
+    <script type="module" crossorigin src="/assets/index-xZylhrh7.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-D7c9cGUr.css">
   <script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
   <body>
     <div id="root"></div>

文件差异内容过多而无法显示
+ 0 - 0
static/dist/sw.js


部分文件因为文件数量过多而无法显示