jim800121chen c03eb6fd0e feat(local-tool): M9-2 — Go driver UpgradeFirmware + firmware service module
A 階段第二個 milestone、銜接 M9-1 bridge.py、暴露 service layer 給 M9-3 API/WebSocket。

New module `server/internal/firmware/`:
- types.go: 123 行(FirmwareVersion / FirmwareProgress / ActiveTaskInfo / UpgradeDriver interface / 8 reason const)
- progress.go: 147 行(仿 flash pattern 的 Tracker、Task.cancel 預留 SIGTERM force-cancel godoc)
- service.go: 373 行(核心 service:UpgradeFirmware / HasActiveTask / GetActiveTaskInfo / RequestShutdown / WaitForActiveTasks / ListBundledVersions / GetCurrentVersion)
- service_test.go: 676 行、13 個 test 含 MultiDeviceParallel

Driver layer:
- kl720_driver.go: 697 → 1054 行(+357、新 UpgradeFirmware method + tryRouteFirmwareEvent + sendCommandForUpgrade snapshot pattern)
- kl720_driver_test.go: 360 行、11 個 test(含 InfoNotBlockedDuringUpgrade / CtxCancelReleasesBridge / StderrEventAfterCtxCancel 100 round stress)

關鍵設計:
- flash 與 firmware 模組分離(不 import flash)
- UpgradeDriver interface 隔離 driver 細節、DeviceLookup interface 隔離 device manager
- 中介 channel pattern(service ↔ driver)方便 service 補欄位(DeviceID / Direction / BeforeVersion)
- timeout 雙保險:chip timeout + 30s margin
- 8 reason enum 對齊 bridge.py、stage 採 Design 命名

Concurrency race 修復(M9-2 Reviewer round 1 → round 2):
- Major 1(mutex deadlock):新 fwUpgradeMu 獨立鎖 + sendCommandForUpgrade snapshot stdin/stdout pattern、避開 d.mu field-level race + 升級期間 Info/Disconnect 不被卡 + timeout 路徑無死鎖
- Major 2(close-channel race):tryRouteFirmwareEvent 持 fwMu 整段、配合 defer setFirmwareProgressCh(nil) 提供 happen-before、絕無 send on closed channel panic

Reviewer 兩輪審查:
- Round 1: 0 Critical / 2 Major / 5 Minor / 5 Suggestion
- Round 2: 0 Critical / 0 Major / 2 Minor / 2 Suggestion(11/12 issue 修到位、Suggestion 4 留 follow-up)

M9-1 follow-up 順手清:
- m5(test 死碼 _firmware_upgrade_start_ts 殘留兩行)已清
- s5(test 註解 idempotent shape 說明)已加

測試:
- go test ./... -race -count=1: 全綠(28s、無 regression)
- Python: 36 tests + 22 subtests 全綠(0.31s)
- go vet / build: 0 output

下一步:M9-3 API handler + WebSocket progress(CI 建議 `go test -race -count=3` 提升 race 偵測強度)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 11:27:36 +08:00

374 lines
14 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 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_BUSYHTTP 409同 device 已有未完成
// 的 firmware task、或 device status 在不可升級狀態。
ErrDeviceBusy = errors.New("firmware: device busy")
// ErrDeviceNotFound 對應 FW_VERSION_NOT_FOUNDHTTP 404deviceID
// 在 device manager 找不到。
ErrDeviceNotFound = errors.New("firmware: device not found")
// ErrUnsupportedChip 對應 400A 階段只接受 KL520 / KL720。
ErrUnsupportedChip = errors.New("firmware: unsupported chip")
// ErrUpgradeFailed 對應 FW_UPGRADE_FAILEDHTTP 500bridge.py 回
// error 但 device 仍可用recoverable
ErrUpgradeFailed = errors.New("firmware: upgrade failed")
// ErrUpgradeBrickRisk 對應 FW_UPGRADE_BRICK_RISKHTTP 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 視為 brickKL520 不會走到這個情境)、不在
// service 層直接歸類。Service 保持 vendor-agnostic、只負責 forward。
var brickRiskReasons = map[string]struct{}{
ReasonVerifyMismatch: {},
ReasonDisconnectDuringOp: {},
}
// UpgradeDriver 是 firmware service 對 driver 層的介面 contract。
//
// 實作方為 kneron.KneronDriverM9-2 內加 methodtest 用 mock 實作。
// 介面保持小:只暴露 service 需要的 method、不引入 device manager 依賴。
type UpgradeDriver interface {
// Info 回傳 driver 認知的 device 基本資訊chipType / firmware 字串 /
// status 等。service 不直接靠 Info().Status 判斷 busydevice 層
// status 可能與 firmware task 狀態不同步)、是用 ProgressTracker。
Info() driver.DeviceInfo
// UpgradeFirmware 觸發 bridge.py firmware_upgrade、把 stderr 上來的
// firmware_progress JSON 轉成 FirmwareProgress 推到 progressCh。
//
// ctxservice 端的 timeout / cancel context、driver 應在 ctx.Done()
// 時主動 kill bridge subprocess、避免 goroutine leak。
// chip必為 KL520 / KL720A 階段、TDD §6.1)。
// progressChservice 提供、driver 只寫不 closeclose 由 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/ 的絕對路徑、由 callermain.go注入。
Root string
}
// Service 是 firmware 升降版的中央 orchestratorTDD §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.UpgradeFirmwarectx 帶 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。
//
// callerservice必須在 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 taskTDD §8.6.1)。
// graceful shutdown handlerM9-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/<chip>/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 3lightly 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
}