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>
374 lines
14 KiB
Go
374 lines
14 KiB
Go
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/<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 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
|
||
}
|