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

245 lines
7.1 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 handlers
import (
"context"
"fmt"
"os"
"runtime"
"time"
"visiona-local/server/internal/api/ws"
"visiona-local/server/internal/device"
"visiona-local/server/internal/driver"
"visiona-local/server/internal/flash"
"visiona-local/server/internal/inference"
"github.com/gin-gonic/gin"
)
// udevRuleInstalled checks if the Kneron udev rule is installed on Linux.
func udevRuleInstalled() bool {
_, err := os.Stat("/etc/udev/rules.d/99-kneron.rules")
return err == nil
}
type DeviceHandler struct {
deviceMgr *device.Manager
flashSvc *flash.Service
inferenceSvc *inference.Service
wsHub *ws.Hub
// fwHandler 提供 firmware 衍生欄位 helperM9-3可為 niltest / 環境
// 無 firmware bundle 時)、此時 4 個 firmware 衍生欄位用 fallback 空值。
fwHandler *FirmwareHandler
}
func NewDeviceHandler(
deviceMgr *device.Manager,
flashSvc *flash.Service,
inferenceSvc *inference.Service,
wsHub *ws.Hub,
) *DeviceHandler {
return &DeviceHandler{
deviceMgr: deviceMgr,
flashSvc: flashSvc,
inferenceSvc: inferenceSvc,
wsHub: wsHub,
}
}
// SetFirmwareHandler 注入 firmware handler 給 device handler 用、避免
// 構造函式 signature 破壞性變更(既有 caller 不需更新)。
func (h *DeviceHandler) SetFirmwareHandler(fw *FirmwareHandler) {
h.fwHandler = fw
}
// deviceWithFirmware 是回給前端的 DeviceInfo 加 firmware 衍生欄位
// TDD §3.1 line 131。embedded driver.DeviceInfo 確保既有欄位 JSON
// 平坦展開、與 FirmwareDerivedFields 同層、不破壞既有 frontend client。
//
// Reviewer M9-3 第 1 輪 Major-1 修正FirmwareDerivedFields 不再含
// `firmwareVer` 鍵。Frontend 直接讀 driver.DeviceInfo 既有的
// `firmwareVersion` 鍵(見 driver/interface.go DeviceInfo.FirmwareVer
// 兩鍵原本指向同一個 firmware 字串、會讓 frontend 困惑哪個是 SoT。
type deviceWithFirmware struct {
driver.DeviceInfo
FirmwareDerivedFields
}
// enrichDevices 把 device list 包上 firmware 衍生欄位。fwHandler 為 nil 時
// 仍回原始 list衍生欄位走預設 zero value、不阻塞 list endpoint。
func (h *DeviceHandler) enrichDevices(devices []driver.DeviceInfo) []deviceWithFirmware {
out := make([]deviceWithFirmware, 0, len(devices))
for _, d := range devices {
entry := deviceWithFirmware{DeviceInfo: d}
if h.fwHandler != nil {
entry.FirmwareDerivedFields = h.fwHandler.DeriveFirmwareFields(d.Type, d.FirmwareVer)
} else {
// fwHandler 缺省 fallback衍生欄位用 zero value前端會看到
// canUpgrade=false / isLegacy=false / bundled="unknown"、合理)。
// firmware 字串 frontend 直接從 d.FirmwareVer (JSON 鍵 firmwareVersion)
// 讀、不在這裡複製。
entry.FirmwareDerivedFields = FirmwareDerivedFields{
BundledFirmwareVersion: "unknown",
}
}
out = append(out, entry)
}
return out
}
func (h *DeviceHandler) ScanDevices(c *gin.Context) {
devices := h.deviceMgr.Rescan()
resp := gin.H{
// M9-3附加 firmware 衍生欄位firmwareVer / firmwareIsLegacy /
// firmwareCanUpgrade / bundledFirmwareVersion讓前端決定是否顯示
// 升級按鈕。enrichDevices 在 fwHandler=nil 時仍回有用內容。
"devices": h.enrichDevices(devices),
}
// Linux: 0 裝置 + udev rule 不存在 → 提示使用者安裝 USB 權限
if runtime.GOOS == "linux" && len(devices) == 0 && !udevRuleInstalled() {
resp["udevHint"] = true
}
c.JSON(200, gin.H{"success": true, "data": resp})
}
func (h *DeviceHandler) ListDevices(c *gin.Context) {
devices := h.deviceMgr.ListDevices()
resp := gin.H{
"devices": h.enrichDevices(devices),
}
if runtime.GOOS == "linux" && len(devices) == 0 && !udevRuleInstalled() {
resp["udevHint"] = true
}
c.JSON(200, gin.H{"success": true, "data": resp})
}
func (h *DeviceHandler) GetDevice(c *gin.Context) {
id := c.Param("id")
session, err := h.deviceMgr.GetDevice(id)
if err != nil {
c.JSON(404, gin.H{
"success": false,
"error": gin.H{"code": "DEVICE_NOT_FOUND", "message": err.Error()},
})
return
}
c.JSON(200, gin.H{"success": true, "data": session.Driver.Info()})
}
func (h *DeviceHandler) ConnectDevice(c *gin.Context) {
id := c.Param("id")
// KL520 USB Boot flow now includes mandatory reset + firmware reload on
// first connect (required for inference to work — see kl720_driver.go
// needsReset block). Worst-case path on Windows: Loader-mode reconnect
// retry (16s) + firmware load (~31s) + reboot wait + second reconnect
// (~13s) = ~60-65s. Use 120s to leave headroom and avoid spurious 504s.
ctx, cancel := context.WithTimeout(c.Request.Context(), 120*time.Second)
defer cancel()
errCh := make(chan error, 1)
go func() {
errCh <- h.deviceMgr.Connect(id)
}()
select {
case err := <-errCh:
if err != nil {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "CONNECT_FAILED", "message": err.Error()},
})
return
}
c.JSON(200, gin.H{"success": true})
case <-ctx.Done():
c.JSON(504, gin.H{
"success": false,
"error": gin.H{"code": "CONNECT_TIMEOUT", "message": fmt.Sprintf("device connect timed out after 60s for %s", id)},
})
}
}
func (h *DeviceHandler) DisconnectDevice(c *gin.Context) {
id := c.Param("id")
if err := h.deviceMgr.Disconnect(id); err != nil {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "DISCONNECT_FAILED", "message": err.Error()},
})
return
}
c.JSON(200, gin.H{"success": true})
}
func (h *DeviceHandler) FlashDevice(c *gin.Context) {
id := c.Param("id")
var req struct {
ModelID string `json:"modelId"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "BAD_REQUEST", "message": "modelId is required"},
})
return
}
taskID, progressCh, err := h.flashSvc.StartFlash(id, req.ModelID)
if err != nil {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "FLASH_FAILED", "message": err.Error()},
})
return
}
// Forward progress to WebSocket, then cleanup task (M2 fix)
go func() {
room := "flash:" + id
for progress := range progressCh {
h.wsHub.BroadcastToRoom(room, progress)
}
h.flashSvc.CleanupTask(taskID)
}()
c.JSON(200, gin.H{"success": true, "data": gin.H{"taskId": taskID}})
}
func (h *DeviceHandler) StartInference(c *gin.Context) {
id := c.Param("id")
resultCh := make(chan *driver.InferenceResult, 10)
if err := h.inferenceSvc.Start(id, resultCh); err != nil {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "INFERENCE_ERROR", "message": err.Error()},
})
return
}
// Forward results to WebSocket, enriching with device ID
go func() {
room := "inference:" + id
for result := range resultCh {
result.DeviceID = id
h.wsHub.BroadcastToRoom(room, result)
}
}()
c.JSON(200, gin.H{"success": true})
}
func (h *DeviceHandler) StopInference(c *gin.Context) {
id := c.Param("id")
if err := h.inferenceSvc.Stop(id); err != nil {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "INFERENCE_ERROR", "message": err.Error()},
})
return
}
c.JSON(200, gin.H{"success": true})
}