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

466 lines
19 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
// firmware_handler.go — M9-3把 firmware service 暴露給 HTTP / WebSocket 層。
//
// 提供三個 endpointTDD §3.1
// 1. POST /api/devices/:id/firmware/upgrade — 啟動升級、202 + taskID
// 2. GET /api/firmware/active-tasks — 給 Wails control panel
// graceful shutdown 偵測用§8.6.2
// 3. WebSocket room "firmware:<deviceID>" — progress event broadcast
//
// device_handler 端的 `firmwareVer / firmwareIsLegacy / firmwareCanUpgrade /
// bundledFirmwareVersion` 衍生欄位由本檔提供的 helper 計算、再由 device handler
// 在 ListDevices / ScanDevices response 套用(見 device_handler.go FirmwareInfo
// helper
//
// 不在 M9-3 範圍:
// - GET /api/devices/:id/firmware/versions — M9-11B2 階段)
// - POST /api/devices/:id/firmware/downgrade — M9-11/12
// - SIGTERM graceful shutdown 整合 main.go — 留 M9-4 或更後
// - Frontend UI — M9-4
import (
"context"
"errors"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"visiona-local/server/internal/device"
"visiona-local/server/internal/firmware"
"github.com/gin-gonic/gin"
)
// ──────────────────────────────────────────────────────────────────────
// Public APIhandler + deps
// ──────────────────────────────────────────────────────────────────────
// firmwareBroadcaster 把 ws.Hub.BroadcastToRoom 抽象出來、test 可注入 spy。
// 採與 SystemHandler 相同 patternshutdownNotifyBroadcaster、避免 handler
// 直接綁死 *ws.Hub。
type firmwareBroadcaster interface {
BroadcastToRoom(room string, data interface{})
}
// firmwareService 是 firmware.Service 的 minimum surface、給 handler 用。
// 抽介面是為了 unit test 不用啟 Python bridge / 真的 driver。
type firmwareService interface {
UpgradeFirmware(ctx context.Context, deviceID, chip string) (string, <-chan firmware.FirmwareProgress, error)
CleanupTask(deviceID string)
HasActiveTask() bool
GetActiveTaskInfo() []*firmware.ActiveTaskInfo
}
// FirmwareHandler 處理 firmware 相關 HTTP endpoint + WebSocket broadcast。
type FirmwareHandler struct {
svc firmwareService
deviceMgr deviceLookupSource
wsHub firmwareBroadcaster
// bundledVersions cache 從 firmware/<chip>/VERSION 讀進來的版本字串、
// 避免每次 ListDevices 都打 disk I/OA 階段檔案不變)。
bundledMu sync.RWMutex
bundledVersions map[string]string // chip → "v2.2.0"
firmwareDir string // server/scripts/firmware/ 絕對路徑caller 注入)
}
// deviceLookupSource 是 device manager 對 handler 的最小介面、test 可 mock。
type deviceLookupSource interface {
GetDevice(id string) (*device.DeviceSession, error)
}
// NewFirmwareHandler 建立 handler。firmwareDir 是 server/scripts/firmware/ 的
// 絕對路徑(含 KL520/、KL720/ 子目錄);空字串時 bundledFirmwareVersion
// 衍生欄位會回 "unknown"。
func NewFirmwareHandler(
svc firmwareService,
deviceMgr deviceLookupSource,
wsHub firmwareBroadcaster,
firmwareDir string,
) *FirmwareHandler {
return &FirmwareHandler{
svc: svc,
deviceMgr: deviceMgr,
wsHub: wsHub,
bundledVersions: make(map[string]string),
firmwareDir: firmwareDir,
}
}
// ──────────────────────────────────────────────────────────────────────
// 1. POST /api/devices/:id/firmware/upgrade
// ──────────────────────────────────────────────────────────────────────
// UpgradeDevice 啟動 firmware 升級流程。
//
// Response
// - 202 Accepted + {success:true, data:{taskId}}goroutine 啟動、progress
// event 走 WebSocket room "firmware:<id>"
// - 404device 不存在
// - 400chip 不支援A 階段 only KL520/KL720
// - 409device 已有 active firmware task / server shutting down
// - 500service 拒絕(不可恢復錯誤)
//
// 注意:本 handler 不阻塞 HTTP 連線、立即回 202、實際升級在 goroutine 跑。
// 客戶端應 subscribe WebSocket room 接收進度。
func (h *FirmwareHandler) UpgradeDevice(c *gin.Context) {
id := c.Param("id")
// 1. 找 device、取 chip
session, err := h.deviceMgr.GetDevice(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": gin.H{"code": "DEVICE_NOT_FOUND", "message": err.Error()},
})
return
}
info := session.Driver.Info()
chip := ChipFromDeviceType(info.Type)
if !firmware.SupportedUpgradeChip(chip) {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": gin.H{
"code": "FW_UNSUPPORTED_CHIP",
"message": "A 階段僅支援 KL520/KL720chip=" + chip + "",
},
})
return
}
// 2. 呼 service 啟動升級
//
// 刻意用 context.Background() 而非 c.Request.Context()
//
// - HTTP request ctx 在我們回 202 後會立即 cancelgin 結束 handler
// 即釋放 connection若沿用 request ctx 會把背景 goroutine 也 cancel
// 掉、升級流程立刻中斷、device brick 風險極高。
// - Service 內部自己包 timeoutrunUpgrade → UpgradeTimeoutFor + margin、
// 見 service.go runUpgrade、不會永久 hang。
// - Graceful shutdown 透過 service.GracefulShutdown() 主動觸發、不靠
// 外層 ctx cancel見 service.go GracefulShutdown
//
// 結論Background 在這裡是刻意設計、非疏忽。
taskID, progressCh, err := h.svc.UpgradeFirmware(context.Background(), id, chip)
if err != nil {
status, code := classifyServiceError(err)
c.JSON(status, gin.H{
"success": false,
"error": gin.H{"code": code, "message": err.Error()},
})
return
}
// 3. spawn forward goroutine消費 progressCh、broadcast 到 WS room
go h.forwardProgressToWS(id, progressCh)
c.JSON(http.StatusAccepted, gin.H{
"success": true,
"data": gin.H{"taskId": taskID},
})
}
// forwardProgressToWS 把 service 的 progress events 廣播到 WebSocket room
// "firmware:<deviceID>"、Frontend modal subscribe 該 room 接進度。
//
// progressCh 由 service 在終態done/error後 close、本 goroutine 退出時
// 呼叫 CleanupTask 移除 tracker entry。
func (h *FirmwareHandler) forwardProgressToWS(deviceID string, progressCh <-chan firmware.FirmwareProgress) {
room := "firmware:" + deviceID
for ev := range progressCh {
// schema 對齊 TDD §4.2、外掛 type 標籤讓 client 統一 dispatch。
// 也保留 deviceId / direction / stage / percent / elapsedMs / etaMs 等
// FirmwareProgress 既有欄位json marshal 直接展開)。
h.wsHub.BroadcastToRoom(room, firmwareProgressMessage{
Type: "firmware_progress",
FirmwareProgress: ev,
})
}
// progressCh closed → service 已 markDone、可移除 tracker entry。
h.svc.CleanupTask(deviceID)
}
// firmwareProgressMessage 是 WebSocket payload wrapper、加 type 欄位讓
// 前端可在同一 room 內 dispatch 不同訊息(雖然目前 firmware: room 只有
// firmware_progress、保留結構彈性
//
// 使用 embedded structFirmwareProgress 的所有 json field 會直接展開到
// 同層、不會多一層巢狀。
type firmwareProgressMessage struct {
Type string `json:"type"`
firmware.FirmwareProgress
}
// ──────────────────────────────────────────────────────────────────────
// 2. GET /api/firmware/active-tasks
// ──────────────────────────────────────────────────────────────────────
// ListActiveTasks 回傳所有進行中 firmware task 的 snapshot、給 Wails control
// panel 在 OnBeforeClose 偵測「是否有任務不可中斷」用TDD §8.6.2)。
//
// Response
//
// {
// "success": true,
// "data": {
// "hasActive": true|false,
// "tasks": [
// {"taskId":..., "deviceId":..., "deviceName":..., "chip":...,
// "direction":..., "stage":..., "elapsedMs":..., "etaSeconds":...}
// ]
// }
// }
func (h *FirmwareHandler) ListActiveTasks(c *gin.Context) {
tasks := h.svc.GetActiveTaskInfo()
// 用 nil → []JSON encode 空 slice 為 [] 而非 null、Frontend 更好處理
if tasks == nil {
tasks = []*firmware.ActiveTaskInfo{}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"hasActive": len(tasks) > 0,
"tasks": tasks,
},
})
}
// ──────────────────────────────────────────────────────────────────────
// 3. DeviceInfo 衍生欄位 helper給 device_handler 用)
// ──────────────────────────────────────────────────────────────────────
// FirmwareDerivedFields 是 device list / scan response 上的 firmware 衍生
// 欄位TDD §3.1 line 131
//
// 由 device handler 在 ListDevices / ScanDevices 套用到每個 device entry
// 上、Frontend 用這些欄位決定是否顯示「升級韌體」按鈕。
//
// Reviewer M9-3 第 1 輪 Major-1JSON schema 雙鍵衝突修正):
// 原本還有 `firmwareVer string` 欄位、但 device list response 的
// driver.DeviceInfo 已內建 `firmwareVersion`(見 driver/interface.go line 26
// 兩鍵指向同一個 firmware 字串會讓 frontend 混淆。改採顯式 3 欄位、frontend
// 直接讀 device entry 既有的 `firmwareVersion` 鍵。WS broadcast schema 不變
// (已用 `beforeVersion` / `afterVersion`、與此處不衝突)。
type FirmwareDerivedFields struct {
FirmwareIsLegacy bool `json:"firmwareIsLegacy"`
FirmwareCanUpgrade bool `json:"firmwareCanUpgrade"`
BundledFirmwareVersion string `json:"bundledFirmwareVersion"`
}
// DeriveFirmwareFields 從 DeviceInfo 計算衍生欄位(不打 service、純字串
// 判斷、適合塞 list endpoint 每筆 entry
//
// 規則:
// - FirmwareIsLegacy對齊 bridge.py `_fw_classify_legacy`kneron_bridge.py
// line 1463— 顯式列舉 KDP1 字串變體 + KDP2-KDP9 prefix 放行、避免
// 對未來 firmware 誤判。
// - FirmwareCanUpgradechip 在 A 階段支援清單KL520/KL720+ IsLegacy=true
// - BundledFirmwareVersion讀 firmware/<chip>/VERSIONcache
//
// 注意:本 helper 不再回 firmwareVer 欄位、caller 直接使用 DeviceInfo 既有
// `firmwareVersion` 鍵;參數 firmwareVer 只供 IsLegacy 判定用。
func (h *FirmwareHandler) DeriveFirmwareFields(deviceType, firmwareVer string) FirmwareDerivedFields {
chip := ChipFromDeviceType(deviceType)
bundled := h.bundledVersionFor(chip)
isLegacy := isLegacyFirmware(firmwareVer)
canUpgrade := firmware.SupportedUpgradeChip(chip) && isLegacy
return FirmwareDerivedFields{
FirmwareIsLegacy: isLegacy,
FirmwareCanUpgrade: canUpgrade,
BundledFirmwareVersion: bundled,
}
}
// bundledVersionFor 讀 firmware/<chip>/VERSION、cache 結果。第一次 miss
// 時讀 disk、後續直接回 cache。讀檔失敗回 "unknown"(不 panic、不阻塞
// device list
//
// Reviewer M9-3 第 1 輪 Minor M-3只 cache success。失敗檔不存在 / 讀
// 錯 / 空檔)不寫 cache、下次 list device 會重試。情境CI 第一次 build
// 時 firmware 檔還沒 ready、若 cache 空字串將永遠回 "unknown";改成不 cache
// 失敗、檔準備好後第一次 list 就能讀到新版本。
func (h *FirmwareHandler) bundledVersionFor(chip string) string {
if h.firmwareDir == "" || chip == "" {
return "unknown"
}
h.bundledMu.RLock()
v, ok := h.bundledVersions[chip]
h.bundledMu.RUnlock()
if ok {
return v
}
versionPath := filepath.Join(h.firmwareDir, chip, "VERSION")
f, err := os.Open(versionPath)
if err != nil {
// 沒 VERSION 檔(如 CI first build→ 不 cache、下次重試
return "unknown"
}
defer f.Close()
buf, err := io.ReadAll(io.LimitReader(f, 64)) // VERSION 應只是一行版本字串
if err != nil {
// 讀錯 → 不 cache、下次重試
return "unknown"
}
parsed := strings.TrimSpace(string(buf))
if parsed == "" {
// 空檔 → 不 cache、下次重試
return "unknown"
}
// 只 cache 成功讀到的版本字串
h.cacheBundledVersion(chip, parsed)
return parsed
}
func (h *FirmwareHandler) cacheBundledVersion(chip, version string) {
h.bundledMu.Lock()
h.bundledVersions[chip] = version
h.bundledMu.Unlock()
}
// ──────────────────────────────────────────────────────────────────────
// helpersexported / unexported
// ──────────────────────────────────────────────────────────────────────
// ChipFromDeviceType 把 driver.DeviceInfo.Type 字串轉成 firmware chip
// 識別字串("KL520" / "KL720")。對應 detector.go:chipFromProductID
// 的反向轉換、未識別時回空字串。
//
// 注意刻意公開、device_handler 也可能直接用(雖然目前透過
// DeriveFirmwareFields 走)。
func ChipFromDeviceType(deviceType string) string {
low := strings.ToLower(deviceType)
switch {
case strings.Contains(low, "kl520"):
return firmware.ChipKL520
case strings.Contains(low, "kl720"):
return firmware.ChipKL720
case strings.Contains(low, "kl630"):
return "KL630"
case strings.Contains(low, "kl730"):
return "KL730"
default:
return ""
}
}
// isLegacyFirmware 判斷 firmware 字串是否為 KDP1 legacy。
//
// 對齊 bridge.py `_fw_classify_legacy`kneron_bridge.py line 1463的判定
// 規則、與 Python 端保持一致行為Reviewer M9-3 第 1 輪 S-1跨端 drift 防護)。
//
// 規則差異(與 bridge.py 對應):
//
// 1. 已知 KDP1 字串顯式列舉(明示比對、不靠 substring
// "KDP", "KDP1", "USB BOOT", "USB BOOT LOADER", "LOADER", "BOOTLOADER"
// 2. KDP1.x 變體("KDP1.0", "KDP1.5", "KDP1 alpha" 等)→ legacy
// 3. KDP2-KDP9 prefix 明示放行forward-compat 未來 firmware、避免 substring
// match 對 "KDP3" 之類字串誤判 legacy
// 4. 未知 firmware 字串(如 "NEF" / "K3")→ 保守 default = 不 legacy
// (避免誤觸 loader stage brick device若實際為 legacy、verify 階段
// 會 detect verify_mismatch、不致 brick
//
// 注意Go 端不接收 product_idDeviceInfo.ProductID 雖有、但 ListDevices
// response 是 driver.DeviceInfo embedded、product_id 對應 KL720 KDP legacy
// 判定走 chip type 即可、不需要在這裡覆蓋。bridge.py 端的 product_id ==
// 0x0200 短路在 connect / upgrade 流程內部處理、不影響 list endpoint
// canUpgrade 判定。
func isLegacyFirmware(firmwareVer string) bool {
fw := strings.ToUpper(strings.TrimSpace(firmwareVer))
// 1. 已知 KDP1 legacy firmware 字串完整列舉
// 注意bridge.py 把 "" 視為 legacyUSB Boot state 不回 firmware
// string但 Go 端 list endpoint 看到空 firmware 字串通常是 device
// 還沒 connect 過 — 保守不視為 legacy不顯示升級按鈕與 bridge.py
// 的 connect 流程語意分離connect 流程進入 loader 是另一條路徑)。
if fw == "" {
return false
}
switch fw {
case "KDP", "KDP1", "USB BOOT", "USB BOOT LOADER", "LOADER", "BOOTLOADER":
return true
}
// 2. KDP1.xKDP1.0 / KDP1.5 等)
if strings.HasPrefix(fw, "KDP1.") || strings.HasPrefix(fw, "KDP1 ") {
return true
}
// 3. 明示放行 KDP2 / KDP3+forward-compat、避免 substring match 對未來
// firmware 誤判)
for _, prefix := range []string{"KDP2", "KDP3", "KDP4", "KDP5", "KDP6", "KDP7", "KDP8", "KDP9"} {
if strings.HasPrefix(fw, prefix) {
return false
}
}
// 4. 未知 firmware 字串 → 保守不 legacy與 bridge.py 一致)
return false
}
// classifyServiceError 把 firmware.Service 的 error 對應到 HTTP status 和
// 錯誤碼TDD §3.3)。
//
// Reviewer M9-3 第 1 輪 S-3unknown error 路徑加 log方便除錯非預期錯誤
// 類型——未來 service 新增 sentinel error 但忘了在這裡掛上時、log 能即時
// 揭露問題。
func classifyServiceError(err error) (int, string) {
switch {
case errors.Is(err, firmware.ErrDeviceNotFound):
return http.StatusNotFound, "DEVICE_NOT_FOUND"
case errors.Is(err, firmware.ErrUnsupportedChip):
return http.StatusBadRequest, "FW_UNSUPPORTED_CHIP"
case errors.Is(err, firmware.ErrDeviceBusy):
return http.StatusConflict, "FW_DEVICE_BUSY"
case errors.Is(err, firmware.ErrUpgradeBrickRisk):
return http.StatusInternalServerError, "FW_UPGRADE_BRICK_RISK"
case errors.Is(err, firmware.ErrUpgradeFailed):
return http.StatusInternalServerError, "FW_UPGRADE_FAILED"
default:
log.Printf("[firmware_handler] classifyServiceError: unknown error type %T: %v", err, err)
return http.StatusInternalServerError, "FW_UPGRADE_FAILED"
}
}
// ──────────────────────────────────────────────────────────────────────
// device.Manager → firmware.DeviceLookup adapter
// ──────────────────────────────────────────────────────────────────────
// DeviceManagerAdapter 把 *device.Manager 包成 firmware.DeviceLookup、
// 讓 main.go 在 NewService 時可以直接傳給 firmware 模組、不破壞 device.Manager
// 既有 API。
//
// 公開出來、供 main.go 在 wire 階段使用。
type DeviceManagerAdapter struct {
mgr *device.Manager
}
// NewDeviceManagerAdapter 建立 adapter。
func NewDeviceManagerAdapter(mgr *device.Manager) *DeviceManagerAdapter {
return &DeviceManagerAdapter{mgr: mgr}
}
// GetUpgradeDriver 實作 firmware.DeviceLookup。
//
// 把 device session 內的 driver*kneron.KneronDriver轉成
// firmware.UpgradeDriver 介面。KneronDriver 已內建 Info() +
// UpgradeFirmware(ctx, chip, progressCh) 兩個 method、直接 type-assert。
func (a *DeviceManagerAdapter) GetUpgradeDriver(deviceID string) (firmware.UpgradeDriver, error) {
session, err := a.mgr.GetDevice(deviceID)
if err != nil {
return nil, err
}
drv, ok := session.Driver.(firmware.UpgradeDriver)
if !ok {
return nil, errors.New("driver does not implement firmware.UpgradeDriver")
}
return drv, nil
}