package handlers // firmware_handler.go — M9-3:把 firmware service 暴露給 HTTP / WebSocket 層。 // // 提供三個 endpoint(TDD §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:" — 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-11(B2 階段) // - 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 API:handler + deps // ────────────────────────────────────────────────────────────────────── // firmwareBroadcaster 把 ws.Hub.BroadcastToRoom 抽象出來、test 可注入 spy。 // 採與 SystemHandler 相同 pattern(shutdownNotifyBroadcaster)、避免 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//VERSION 讀進來的版本字串、 // 避免每次 ListDevices 都打 disk I/O(A 階段檔案不變)。 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:" // - 404:device 不存在 // - 400:chip 不支援(A 階段 only KL520/KL720) // - 409:device 已有 active firmware task / server shutting down // - 500:service 拒絕(不可恢復錯誤) // // 注意:本 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/KL720(chip=" + chip + ")", }, }) return } // 2. 呼 service 啟動升級 // // 刻意用 context.Background() 而非 c.Request.Context(): // // - HTTP request ctx 在我們回 202 後會立即 cancel(gin 結束 handler // 即釋放 connection),若沿用 request ctx 會把背景 goroutine 也 cancel // 掉、升級流程立刻中斷、device brick 風險極高。 // - Service 內部自己包 timeout(runUpgrade → 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:"、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 struct:FirmwareProgress 的所有 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-1(JSON 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 誤判。 // - FirmwareCanUpgrade:chip 在 A 階段支援清單(KL520/KL720)+ IsLegacy=true // - BundledFirmwareVersion:讀 firmware//VERSION(cache) // // 注意:本 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//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() } // ────────────────────────────────────────────────────────────────────── // helpers(exported / 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_id(DeviceInfo.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 把 "" 視為 legacy(USB 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.x(KDP1.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-3:unknown 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 }