package firmware import ( "context" "errors" "fmt" "os" "path/filepath" "strings" "sync" "time" "visiona-local/server/internal/driver" ) // 已預期的錯誤型別(caller 可走 errors.Is 識別、後續 M9-3 API handler 把 // 這些對應到 HTTP 錯誤碼、見 TDD §3.3)。 var ( // ErrDeviceBusy 對應 FW_DEVICE_BUSY(HTTP 409):同 device 已有未完成 // 的 firmware task、或 device status 在不可升級狀態。 ErrDeviceBusy = errors.New("firmware: device busy") // ErrDeviceNotFound 對應 FW_VERSION_NOT_FOUND(HTTP 404):deviceID // 在 device manager 找不到。 ErrDeviceNotFound = errors.New("firmware: device not found") // ErrUnsupportedChip 對應 400:A 階段只接受 KL520 / KL720。 ErrUnsupportedChip = errors.New("firmware: unsupported chip") // ErrUpgradeFailed 對應 FW_UPGRADE_FAILED(HTTP 500):bridge.py 回 // error 但 device 仍可用(recoverable)。 ErrUpgradeFailed = errors.New("firmware: upgrade failed") // ErrUpgradeBrickRisk 對應 FW_UPGRADE_BRICK_RISK(HTTP 500):升級 // 期間 device disconnect 或 verify 失敗、可能損壞(unrecoverable)。 ErrUpgradeBrickRisk = errors.New("firmware: upgrade brick risk") ) // brickRiskReasons 是「升到一半失敗、可能損壞」的 reason 集合(TDD §3.4 // 對應表第 5 / 7 種失敗)。其他 reason 視為 recoverable。 // // Minor 1 註:ReasonTimeout 是否歸 brick 由 API handler M9-3 依 chip + elapsed // 判斷(例:KL720 elapsed > 180s 視為 brick;KL520 不會走到這個情境)、不在 // service 層直接歸類。Service 保持 vendor-agnostic、只負責 forward。 var brickRiskReasons = map[string]struct{}{ ReasonVerifyMismatch: {}, ReasonDisconnectDuringOp: {}, } // UpgradeDriver 是 firmware service 對 driver 層的介面 contract。 // // 實作方為 kneron.KneronDriver(M9-2 內加 method);test 用 mock 實作。 // 介面保持小:只暴露 service 需要的 method、不引入 device manager 依賴。 type UpgradeDriver interface { // Info 回傳 driver 認知的 device 基本資訊(chipType / firmware 字串 / // status 等)。service 不直接靠 Info().Status 判斷 busy(device 層 // status 可能與 firmware task 狀態不同步)、是用 ProgressTracker。 Info() driver.DeviceInfo // UpgradeFirmware 觸發 bridge.py firmware_upgrade、把 stderr 上來的 // firmware_progress JSON 轉成 FirmwareProgress 推到 progressCh。 // // ctx:service 端的 timeout / cancel context、driver 應在 ctx.Done() // 時主動 kill bridge subprocess、避免 goroutine leak。 // chip:必為 KL520 / KL720(A 階段、TDD §6.1)。 // progressCh:service 提供、driver 只寫不 close(close 由 service 負責)。 // // 回傳 error 表示終態失敗(recoverable 或 brick)。成功時 driver 應該 // 已經把 done event 推到 progressCh、回 nil。 UpgradeFirmware(ctx context.Context, chip string, progressCh chan<- FirmwareProgress) error } // DeviceLookup 是 firmware service 對 device manager 的介面 contract。 // 測試時 mock 之、避免拉整個 device.Manager 依賴鏈進來。 type DeviceLookup interface { GetUpgradeDriver(deviceID string) (UpgradeDriver, error) } // FirmwareDir 描述 bundled firmware 檔的根目錄、供 ListBundledVersions 用。 // // A 階段(M9-2)目錄結構:firmware/KL520/fw_*.bin、firmware/KL720/fw_*.bin //(扁平結構、無 CURRENT_VERSION)。B2 階段(M9-11)會升級成多版本。 type FirmwareDir struct { // Root 是 server/scripts/firmware/ 的絕對路徑、由 caller(main.go)注入。 Root string } // Service 是 firmware 升降版的中央 orchestrator(TDD §6.1)。 // // 職責: // 1. 收 API 層的 UpgradeFirmware 呼叫、查 device、呼叫 driver、廣播 progress // 2. 拒絕同 device 重複 task、graceful shutdown 拒絕(§8.6) // 3. timeout 護欄(外層、避免 driver 卡死) // 4. 列舉 bundled firmware 版本(A 階段最簡實作) // // 不負責:HTTP handler / WebSocket broadcast — M9-3 串接。 type Service struct { deviceLookup DeviceLookup tracker *ProgressTracker fwDir FirmwareDir // shutdownMu 保護「shutdown 進行中時不再接新 task」。 shutdownMu sync.RWMutex shutdownReq bool // taskWg 追蹤所有進行中的 task goroutine、graceful shutdown 時 Wait。 taskWg sync.WaitGroup } // NewService 建立 firmware service。 func NewService(deviceLookup DeviceLookup, fwDir FirmwareDir) *Service { return &Service{ deviceLookup: deviceLookup, tracker: NewProgressTracker(), fwDir: fwDir, } } // UpgradeFirmware 啟動 device 的 firmware 升級流程(A 階段:自動 KDP1 → KDP2、 // KL520 / KL720)。 // // 流程(TDD §5.1): // 1. 驗 chip / device 存在 / 沒有重複 task / 沒在 shutdown 中 // 2. 在 tracker 註冊 task // 3. spawn goroutine:呼叫 driver.UpgradeFirmware(ctx 帶 timeout) // ├── driver 推 progress events 到 task.ProgressCh // ├── service 同步把 events 廣播給訂閱者(M9-3 wire 到 WebSocket) // └── 終態 → markDone、close channel、taskWg.Done // 4. 立即回 taskID + progressCh 給 caller // // 注意:本 method 不阻塞、回後立刻接受下一個請求;實際升級在 goroutine // 跑。caller 應拿 progressCh 消費完所有 events 再呼叫 CleanupTask。 func (s *Service) UpgradeFirmware(ctx context.Context, deviceID, chip string) (string, <-chan FirmwareProgress, error) { // 1. shutdown 中拒絕 s.shutdownMu.RLock() if s.shutdownReq { s.shutdownMu.RUnlock() return "", nil, fmt.Errorf("%w: server is shutting down", ErrDeviceBusy) } s.shutdownMu.RUnlock() // 2. chip 驗證(A 階段 only KL520 / KL720) if !SupportedUpgradeChip(chip) { return "", nil, fmt.Errorf("%w: %q (A 階段僅支援 KL520 / KL720)", ErrUnsupportedChip, chip) } // 3. device 查找 drv, err := s.deviceLookup.GetUpgradeDriver(deviceID) if err != nil { return "", nil, fmt.Errorf("%w: %s: %v", ErrDeviceNotFound, deviceID, err) } info := drv.Info() // 4. tracker 註冊(同 device 重複 task → 拒絕) task := s.tracker.Create(deviceID, info.Name, chip, DirectionUpgrade) if task == nil { return "", nil, fmt.Errorf("%w: device %s already has an active firmware task", ErrDeviceBusy, deviceID) } // 5. 建外層 ctx with timeout(內層 driver 各自還有自己的 SDK timeout、 // 這是雙保險、避免 driver bug 導致 goroutine 永遠卡住) chipTimeout := UpgradeTimeoutFor(chip) taskCtx, cancel := context.WithTimeout(ctx, chipTimeout+30*time.Second) // +30s margin 給 verify 階段 task.cancel = cancel s.taskWg.Add(1) go s.runUpgrade(taskCtx, cancel, task, drv, chip, info.FirmwareVer) return task.ID, task.ProgressCh, nil } // runUpgrade 是 task goroutine、由 UpgradeFirmware spawn。 // // - driver.UpgradeFirmware 是 blocking call、內部會推 progress events 到 // intermediateCh、service 把它們轉成 FirmwareProgress(補 deviceID) // 後寫到 task.ProgressCh。 // - driver 回 error 時、若終態 event 還沒推(例如 ctx 超時被 service // 強制 cancel)、service 在這裡補一個 error event。 // - 終態(done / error)後 close task.ProgressCh、markDone、taskWg.Done。 // // caller(service)必須在 close 後才允許讀者繼續消費既有 buffered events // 再呼叫 CleanupTask 移除 tracker entry。 func (s *Service) runUpgrade( ctx context.Context, cancel context.CancelFunc, task *Task, drv UpgradeDriver, chip string, beforeVersion string, ) { defer s.taskWg.Done() defer cancel() // 確保 context 不洩漏 defer task.markDone() defer close(task.ProgressCh) // 結論:保留中介 channel pattern、清晰勝過 micro-optimization。 // // 推論:driver 推 event 時已填 Stage / Percent / Message / ElapsedMs / EtaMs; // DeviceID / Direction / BeforeVersion 由 service 在 forward loop 補(driver // 不知道 service 層的 deviceID 命名、Direction 由 task 決定)。原本可以讓 // driver 直接寫 task.ProgressCh 省一道 channel 複製、但補欄位邏輯會散到 // driver 端、不乾淨;中介 channel 讓 service 集中補欄位、勝過 micro-opt。 intermediate := make(chan FirmwareProgress, 32) driverDone := make(chan error, 1) go func() { driverDone <- drv.UpgradeFirmware(ctx, chip, intermediate) close(intermediate) }() // forward loop:補欄位 + 更新 task.stage、轉到 task.ProgressCh // // Suggestion 1 修法:對 StageDone 去重——driver 端「sendCommand 成功補 // done event」與 bridge stderr 推的 done event 會雙保險、可能重複。 // 重複 done 雖然對 service 無害、但傳到前端可能跑兩次 cleanup、改在這裡 // 一次清掉、單一真相來源。 var lastStage string var seenDone bool for ev := range intermediate { if ev.Stage == StageDone && seenDone { // 已 forward 過 done、忽略後續重複 done(典型情境:stderr push 完 // done 後 sendCommand 成功又補一發 done、見 kl720_driver.go 925-937) continue } // 補欄位 ev.DeviceID = task.DeviceID if ev.Direction == "" { ev.Direction = task.Direction } if ev.BeforeVersion == "" { ev.BeforeVersion = beforeVersion } if ev.ElapsedMs == 0 { ev.ElapsedMs = time.Since(task.StartTs).Milliseconds() } task.setStage(ev.Stage) lastStage = ev.Stage if ev.Stage == StageDone { seenDone = true } task.ProgressCh <- ev } // driver goroutine 終態 err := <-driverDone // driver 沒推終態 event(例如 ctx 超時 / panic)→ 補一個 error event if err != nil && lastStage != StageDone && lastStage != StageError { reason := ReasonUpgradeMidFailed if errors.Is(err, context.DeadlineExceeded) { reason = ReasonTimeout } else if errors.Is(err, context.Canceled) { reason = ReasonDisconnectDuringOp } task.setStage(StageError) task.ProgressCh <- FirmwareProgress{ DeviceID: task.DeviceID, Stage: StageError, Percent: -1, Direction: task.Direction, Error: err.Error(), Reason: reason, RawError: err.Error(), BeforeVersion: beforeVersion, ElapsedMs: time.Since(task.StartTs).Milliseconds(), } } } // CleanupTask 在 caller 消費完 progressCh 後移除 tracker entry。 func (s *Service) CleanupTask(deviceID string) { s.tracker.Remove(deviceID) } // HasActiveTask 回傳是否有任一 device 在進行 firmware task(TDD §8.6.1)。 // graceful shutdown handler(M9-2 範圍外、由 main.go signal handler 串接) // 用此判斷是否延遲 SIGTERM。 func (s *Service) HasActiveTask() bool { return len(s.tracker.ActiveTasks()) > 0 } // GetActiveTaskInfo 回傳所有進行中 task 的快照、給 control panel modal 顯示 // 「韌體切換進行中、device X、剩 Y 秒」(TDD §8.6.2)。 // // 注意:回傳 slice 是 snapshot copy、caller 可安全持有、不會被後續變動。 func (s *Service) GetActiveTaskInfo() []*ActiveTaskInfo { return s.tracker.ActiveTasks() } // RequestShutdown 設定 shutdown 旗標、之後新 task 都會被拒絕。caller 接著 // 應呼叫 WaitForActiveTasks 等到既有 task 結束(或 timeout)。 func (s *Service) RequestShutdown() { s.shutdownMu.Lock() s.shutdownReq = true s.shutdownMu.Unlock() } // WaitForActiveTasks 等所有進行中的 task 結束、最多等 maxWait。 // 回傳 true 表示乾淨結束、false 表示 timeout 仍有 task 卡住(caller 應強制 // 走 shutdown、log 警告、TDD §8.6.1)。 func (s *Service) WaitForActiveTasks(maxWait time.Duration) bool { done := make(chan struct{}) go func() { s.taskWg.Wait() close(done) }() select { case <-done: return true case <-time.After(maxWait): return false } } // ListBundledVersions 列出 chip 對應的 bundled firmware 版本(TDD §4.4)。 // // A 階段(M9-2)扁平結構:fwDir//fw_*.bin、只有單一版本、且不含 // CURRENT_VERSION metadata。本實作回傳「current」單一版本(IsCurrent=true、 // Version="current"、Notes="A 階段扁平結構")、避免 M9-3 API handler 端 // 還要為「列舉空」做特例處理。 // // B2 階段(M9-11)會擴展成讀 CURRENT_VERSION + 列舉子目錄。 func (s *Service) ListBundledVersions(chip string) ([]FirmwareVersion, error) { if !SupportedUpgradeChip(chip) { return nil, fmt.Errorf("%w: %q", ErrUnsupportedChip, chip) } // A 階段:只要 firmware 檔在、就視為有 current 版本可升 // (不檢查具體檔案存在性、那是 bridge.py 在升級時的事) if s.fwDir.Root == "" { return nil, fmt.Errorf("firmware directory not configured") } chipDir := filepath.Join(s.fwDir.Root, chip) // Suggestion 3:lightly check chip 目錄存在、區分「chip 不支援」vs // 「chip 支援但 firmware 漏 build」。後者 API handler 應回 500 或在 // 安裝包 release notes 標記、不該無聲回 current bundled。 if _, err := os.Stat(chipDir); err != nil { if os.IsNotExist(err) { return nil, fmt.Errorf("firmware not bundled for chip %q (missing %s)", chip, chipDir) } return nil, fmt.Errorf("firmware dir stat failed for chip %q: %w", chip, err) } return []FirmwareVersion{ { Version: "current", DisplayName: chip + " current (A 階段 bundled)", IsCurrent: true, IsBundled: true, Notes: "A 階段扁平結構、firmware path: " + chipDir, }, }, nil } // GetCurrentVersion 回傳該 device 目前回報的 firmware 版本(取自 driver.Info)。 // 注意這是「device 上實際跑的」版本、不是「bundled current」版本。 // M9-3 API handler 可用這個 + ListBundledVersions 對照、決定 firmwareIsLegacy / // firmwareCanUpgrade 衍生欄位。 func (s *Service) GetCurrentVersion(deviceID string) (FirmwareVersion, error) { drv, err := s.deviceLookup.GetUpgradeDriver(deviceID) if err != nil { return FirmwareVersion{}, fmt.Errorf("%w: %s: %v", ErrDeviceNotFound, deviceID, err) } info := drv.Info() ver := strings.TrimSpace(info.FirmwareVer) if ver == "" { ver = "unknown" } return FirmwareVersion{ Version: ver, DisplayName: ver, IsCurrent: true, IsBundled: false, }, nil }