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>
260 lines
9.0 KiB
Go
260 lines
9.0 KiB
Go
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-3:firmware 升降版 service
|
||
firmwareDir string, // M9-3:bundled 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 log(TDD v2/server-lifecycle.md §9.1a)。
|
||
// 瀏覽器每 10s poll 一次 boot-id;business 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-3:firmware handler。firmwareSvc 可為 nil(test / 未來 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 的 endpoint(M8-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-id,health 被業務 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)
|
||
}
|
||
}
|