jim800121chen 5e281ed449 feat(local-tool): M9-3 — firmware API handlers + WebSocket progress room
A 階段第三個 milestone、暴露 firmware service 給 Frontend / Wails control panel。

New / modified:
- server/internal/api/handlers/firmware_handler.go: 新檔 465 行(upgrade + active-tasks endpoint + WS broadcast goroutine)
- server/internal/api/handlers/firmware_handler_test.go: 新檔 938 行、26+ subtests
- server/internal/api/handlers/device_handler.go: +47 行(3 個 firmware 衍生欄位)
- server/internal/api/router.go: +23 行
- server/main.go: +10 行(wire firmware service + handler)

4 endpoints 全到位(對齊 TDD §3.1):
- GET /api/devices: 加 firmwareIsLegacy / firmwareCanUpgrade / bundledFirmwareVersion(firmwareVersion 沿用既有 DeviceInfo 鍵)
- POST /api/devices/scan: 同步走 enrichDevices
- POST /api/devices/:id/firmware/upgrade: 202 + {taskId}
- GET /api/firmware/active-tasks: HasActiveTask + GetActiveTaskInfo
- WebSocket room firmware:<deviceID> broadcast 對齊 §4.2

關鍵設計:
- 3 層 interface(firmwareBroadcaster / firmwareService / deviceLookupSource)+ DeviceManagerAdapter 解 import cycle
- bundledVersion cache(只 cache success、避免 thundering herd / poison)
- isLegacyFirmware 對齊 bridge.py 規則(legacy_exact set + KDP1.x prefix + KDP2-9 forward-compat)+ parity 真值表測試
- 5 個錯誤碼齊全(DEVICE_NOT_FOUND / FW_UNSUPPORTED_CHIP / FW_DEVICE_BUSY / FW_UPGRADE_FAILED / FW_UPGRADE_BRICK_RISK)

Reviewer 兩輪審查:
- Round 1: 0 Critical / 1 Major / 3 Minor / 5 Suggestion
- Round 2: 0 Critical / 0 Major / 0 Minor / 3 極小 Suggestion(全部 backend 不需處理、純評估)
- Major 1(JSON 雙鍵衝突 firmwareVer vs firmwareVersion)方案 A 完全到位、3 個 test 鎖定 regression

TDD 同步:firmware-management.md §3.1 line 131 firmwareVer → firmwareVersion 對齊實作。

測試:go test ./... -race -count=1 全綠(handlers 2.489s / api 3.522s / ws 4.623s / device 1.931s / firmware 2.695s / driver/kneron 5.583s / model 5.022s)

SIGTERM main.go 整合留 M9-4.5(與 Wails OnBeforeClose 一起做)。

下一步:M9-4 Frontend Devices 頁 FW badge + 升級 modal + i18n(1.5 人天)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:05:42 +08:00

260 lines
9.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package api
import (
"fmt"
"io"
"net/http"
"strings"
"time"
"visiona-local/server/internal/api/handlers"
"visiona-local/server/internal/api/ws"
"visiona-local/server/internal/camera"
"visiona-local/server/internal/device"
"visiona-local/server/internal/firmware"
"visiona-local/server/internal/flash"
"visiona-local/server/internal/inference"
"visiona-local/server/internal/model"
"visiona-local/server/pkg/logger"
"github.com/gin-gonic/gin"
)
func NewRouter(
modelRepo *model.Repository,
modelStore *model.ModelStore,
deviceMgr *device.Manager,
cameraMgr *camera.Manager,
flashSvc *flash.Service,
inferenceSvc *inference.Service,
firmwareSvc *firmware.Service, // M9-3firmware 升降版 service
firmwareDir string, // M9-3bundled firmware/<chip>/VERSION 根目錄
wsHub *ws.Hub,
staticFS http.FileSystem,
logBroadcaster *logger.Broadcaster,
systemHandler *handlers.SystemHandler,
) *gin.Engine {
// Use gin.New() instead of gin.Default() to replace the default logger
// with one that also pushes to the WebSocket broadcaster.
r := gin.New()
r.Use(gin.Recovery())
// M8-4跳過高頻輪詢 endpoint 的 access logTDD v2/server-lifecycle.md §9.1a)。
// 瀏覽器每 10s poll 一次 boot-idbusiness code 也會定期輪詢 health
// 若每次都寫 access log 會把 business log 淹沒。
//
// 注意broadcasterLogger 是我們自製的 middleware不會直接套用 gin.LoggerConfig
// 因此 skip 邏輯在 broadcasterLogger 內部手動實作(見下方)。
r.Use(broadcasterLogger(logBroadcaster))
r.Use(CORSMiddleware())
modelHandler := handlers.NewModelHandler(modelRepo)
modelUploadHandler := handlers.NewModelUploadHandler(modelRepo, modelStore)
deviceHandler := handlers.NewDeviceHandler(deviceMgr, flashSvc, inferenceSvc, wsHub)
cameraHandler := handlers.NewCameraHandler(cameraMgr, deviceMgr, inferenceSvc, wsHub)
// M9-3firmware handler。firmwareSvc 可為 niltest / 未來 disable
// 此時 firmware endpoint 不註冊、device handler 仍用 fwHandler=nil fallback。
var firmwareHandler *handlers.FirmwareHandler
if firmwareSvc != nil {
firmwareHandler = handlers.NewFirmwareHandler(
firmwareSvc,
deviceMgr,
wsHub,
firmwareDir,
)
deviceHandler.SetFirmwareHandler(firmwareHandler)
}
api := r.Group("/api")
{
// System
api.GET("/system/health", systemHandler.HealthCheck)
api.GET("/system/info", systemHandler.Info)
api.GET("/system/metrics", systemHandler.Metrics)
api.GET("/system/deps", systemHandler.Deps)
api.GET("/system/boot-id", systemHandler.BootID) // M8-4瀏覽器 tab 用於偵測 server 重啟
api.POST("/system/restart", systemHandler.Restart)
api.POST("/system/install-driver", systemHandler.InstallDriver)
api.POST("/system/install-udev", systemHandler.InstallUdevRule) // Linux udev rule 安裝
// MAJ-4 補丁Wails shutdown / Restart 前廣播 server:shutdown-imminent
// 到 /ws/system讓瀏覽器 tab 立即顯示 Offline Overlay。
api.POST("/system/shutdown-notify", systemHandler.ShutdownNotify)
// Models
api.GET("/models", modelHandler.ListModels)
api.GET("/models/:id", modelHandler.GetModel)
api.POST("/models/upload", modelUploadHandler.UploadModel)
api.DELETE("/models/:id", modelUploadHandler.DeleteModel)
// Devices
api.GET("/devices", deviceHandler.ListDevices)
api.POST("/devices/scan", deviceHandler.ScanDevices)
api.GET("/devices/:id", deviceHandler.GetDevice)
api.POST("/devices/:id/connect", deviceHandler.ConnectDevice)
api.POST("/devices/:id/disconnect", deviceHandler.DisconnectDevice)
api.POST("/devices/:id/flash", deviceHandler.FlashDevice)
api.POST("/devices/:id/inference/start", deviceHandler.StartInference)
api.POST("/devices/:id/inference/stop", deviceHandler.StopInference)
// Firmware (M9-3、A 階段)
// upgrade endpoint 走 202 + WebSocket room "firmware:<id>" 推進度。
// active-tasks 給 Wails control panel graceful shutdown 偵測用。
if firmwareHandler != nil {
api.POST("/devices/:id/firmware/upgrade", firmwareHandler.UpgradeDevice)
api.GET("/firmware/active-tasks", firmwareHandler.ListActiveTasks)
}
// Camera
api.GET("/camera/list", cameraHandler.ListCameras)
api.POST("/camera/start", cameraHandler.StartPipeline)
api.POST("/camera/stop", cameraHandler.StopPipeline)
api.GET("/camera/stream", cameraHandler.StreamMJPEG)
// Media
api.POST("/media/upload/image", cameraHandler.UploadImage)
api.POST("/media/upload/video", cameraHandler.UploadVideo)
api.POST("/media/upload/batch-images", cameraHandler.UploadBatchImages)
api.GET("/media/batch-images/:index", cameraHandler.GetBatchImageFrame)
api.POST("/media/seek", cameraHandler.SeekVideo)
}
// WebSocket
r.GET("/ws/devices/events", ws.DeviceEventsHandler(wsHub, deviceMgr))
r.GET("/ws/devices/:id/flash-progress", ws.FlashProgressHandler(wsHub))
r.GET("/ws/devices/:id/inference", ws.InferenceHandler(wsHub, inferenceSvc))
r.GET("/ws/server-logs", ws.ServerLogsHandler(wsHub, logBroadcaster))
// MAJ-4 補丁:/ws/system — server:shutdown-imminent 事件訂閱
r.GET("/ws/system", ws.SystemEventsHandler(wsHub))
// Embedded frontend static file serving (production mode)
if staticFS != nil {
fileServer := http.FileServer(staticFS)
// Serve Next.js-style static assets
r.GET("/_next/*filepath", func(c *gin.Context) {
fileServer.ServeHTTP(c.Writer, c.Request)
})
r.GET("/favicon.ico", func(c *gin.Context) {
fileServer.ServeHTTP(c.Writer, c.Request)
})
// SPA fallback for all other routes (client-side routing)
r.NoRoute(spaFallback(staticFS))
}
return r
}
// broadcasterLoggerSkipPaths 列出不寫 access log 的 endpointM8-4 TDD §9.1a)。
// 這些 endpoint 被瀏覽器或業務 code 高頻輪詢,每次都寫 log 會把 log 噴滿。
var broadcasterLoggerSkipPaths = map[string]struct{}{
"/api/system/boot-id": {},
"/api/system/health": {},
}
// broadcasterLogger is a Gin middleware that logs HTTP requests to both
// stdout (like gin.Logger) and the WebSocket log broadcaster so that
// request logs are visible in the frontend Settings page.
//
// M8-4對 broadcasterLoggerSkipPaths 裡列出的 endpoint 不寫 log
// 避免把 access log 淹沒(瀏覽器每 10s poll boot-idhealth 被業務 code 高頻輪詢)。
func broadcasterLogger(b *logger.Broadcaster) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
raw := c.Request.URL.RawQuery
c.Next()
// M8-4跳過高頻輪詢 endpoint比對只看 path不含 query
if _, skip := broadcasterLoggerSkipPaths[path]; skip {
return
}
latency := time.Since(start)
status := c.Writer.Status()
method := c.Request.Method
if raw != "" {
path = path + "?" + raw
}
msg := fmt.Sprintf("%3d | %13v | %-7s %s",
status, latency, method, path)
// Write to stdout (original Gin behaviour)
fmt.Printf("[GIN] %s\n", msg)
// Push to broadcaster for WebSocket streaming
if b != nil {
level := "INFO"
if status >= 500 {
level = "ERROR"
} else if status >= 400 {
level = "WARN"
}
b.Push(level, fmt.Sprintf("[GIN] %s", msg))
}
}
}
// spaFallback tries to serve the exact file from the embedded FS.
// If the file doesn't exist, it finds the best matching route shell HTML
// for Next.js static export client-side routing.
//
// Next.js static export with generateStaticParams creates:
// /models/index.html — static page
// /models/_/index.html — dynamic route shell (placeholder param '_')
//
// For a request like /models/yolov5-face-detection:
// 1. Try exact file → not found
// 2. Try /models/_/index.html → found → serve it (Next.js CSR picks up real param from URL)
// 3. Fall back to /index.html (root)
func spaFallback(staticFS http.FileSystem) gin.HandlerFunc {
return func(c *gin.Context) {
path := c.Request.URL.Path
// Don't serve index.html for API or WebSocket routes
if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/ws/") {
c.Status(http.StatusNotFound)
return
}
// Try to serve the exact file
if f, err := staticFS.Open(path); err == nil {
f.Close()
http.FileServer(staticFS).ServeHTTP(c.Writer, c.Request)
return
}
// Try Next.js dynamic route shell: replace last path segment with '_'
// e.g. /models/yolov5 → /models/_/index.html
// /devices/kl520-0 → /devices/_/index.html
// /workspace/kl520-0 → /workspace/_/index.html
segments := strings.Split(strings.TrimRight(path, "/"), "/")
if len(segments) >= 2 {
segments[len(segments)-1] = "_"
shellPath := strings.Join(segments, "/") + "/index.html"
if f, err := staticFS.Open(shellPath); err == nil {
defer f.Close()
c.Header("Content-Type", "text/html; charset=utf-8")
c.Status(http.StatusOK)
_, _ = io.Copy(c.Writer, f)
return
}
}
// Final fallback: root index.html
index, err := staticFS.Open("/index.html")
if err != nil {
c.Status(http.StatusInternalServerError)
return
}
defer index.Close()
c.Header("Content-Type", "text/html; charset=utf-8")
c.Status(http.StatusOK)
_, _ = io.Copy(c.Writer, index)
}
}