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

148 lines
4.1 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 (
"sync"
"time"
)
// Task 代表一個進行中的 firmware 升降版 taskTDD §6 + §8.6.1)。
// service 用 ProgressTracker 維護 deviceID → *Task、用來
// 1. 拒絕同一 device 同時兩個 firmware task§3.3 FW_DEVICE_BUSY
// 2. 給 graceful shutdown handler 查 HasActiveTask§8.6.1
// 3. 給 control panel modal 顯示 active task 資訊§8.6.2
type Task struct {
ID string
DeviceID string
DeviceName string
Chip string
Direction string
StartTs time.Time
ProgressCh chan FirmwareProgress
mu sync.Mutex
stage string // 目前推到的 stage、tracker 內部維護
done bool
// cancel 是 task 的 context.CancelFunc、由 service.UpgradeFirmware 寫入。
//
// 目前 service 只在 runUpgrade 的 defer 內呼叫一次(避免 ctx leak
// 沒有外部 reader 主動呼叫。預留給未來 SIGTERM force-cancel 流程
// TDD §8.6.3使用graceful shutdown 等不到 task 結束時、可逐
// task 呼叫 cancel() 提前釋放 driver 資源。
cancel func()
}
// Stage 回傳該 task 最後一次廣播的 stagethread-safe
func (t *Task) Stage() string {
t.mu.Lock()
defer t.mu.Unlock()
return t.stage
}
func (t *Task) setStage(s string) {
t.mu.Lock()
t.stage = s
t.mu.Unlock()
}
// Done 回傳 task 是否完成thread-safe
func (t *Task) Done() bool {
t.mu.Lock()
defer t.mu.Unlock()
return t.done
}
func (t *Task) markDone() {
t.mu.Lock()
t.done = true
t.mu.Unlock()
}
// ProgressTracker 仿 flash.ProgressTracker 的 pattern、map[deviceID]*Task。
//
// 注意key 是 deviceID 而非 taskID每 device 同時只能有 1 個 firmware
// task、不像 flash 可以同 device 不同 model
type ProgressTracker struct {
tasks map[string]*Task
mu sync.Mutex
}
// NewProgressTracker 建立空 tracker。
func NewProgressTracker() *ProgressTracker {
return &ProgressTracker{tasks: make(map[string]*Task)}
}
// Create 嘗試建立新 task。若同 deviceID 已有未完成的 task、回傳 nil
// caller 應回 FW_DEVICE_BUSY 給上層)。
func (pt *ProgressTracker) Create(deviceID, deviceName, chip, direction string) *Task {
pt.mu.Lock()
defer pt.mu.Unlock()
if existing, ok := pt.tasks[deviceID]; ok && !existing.Done() {
return nil
}
taskID := direction + "-" + deviceID + "-" + time.Now().UTC().Format("20060102T150405.000")
t := &Task{
ID: taskID,
DeviceID: deviceID,
DeviceName: deviceName,
Chip: chip,
Direction: direction,
StartTs: time.Now(),
ProgressCh: make(chan FirmwareProgress, 32),
stage: StagePreparing,
}
pt.tasks[deviceID] = t
return t
}
// Get 取得指定 device 的 taskthread-safe、沒有時回 nil。
func (pt *ProgressTracker) Get(deviceID string) *Task {
pt.mu.Lock()
defer pt.mu.Unlock()
return pt.tasks[deviceID]
}
// Remove 移除指定 device 的 task entrycaller 應在 progressCh 已 close
// 且讀者已完成消費後呼叫、否則前端可能讀不到尾端事件)。
func (pt *ProgressTracker) Remove(deviceID string) {
pt.mu.Lock()
defer pt.mu.Unlock()
delete(pt.tasks, deviceID)
}
// ActiveTasks 列出所有未完成 task 的快照thread-safe、給 graceful
// shutdown handler 用TDD §8.6.1)。
func (pt *ProgressTracker) ActiveTasks() []*ActiveTaskInfo {
pt.mu.Lock()
defer pt.mu.Unlock()
out := make([]*ActiveTaskInfo, 0, len(pt.tasks))
for _, t := range pt.tasks {
if t.Done() {
continue
}
elapsed := time.Since(t.StartTs).Milliseconds()
info := &ActiveTaskInfo{
TaskID: t.ID,
DeviceID: t.DeviceID,
DeviceName: t.DeviceName,
Chip: t.Chip,
Direction: t.Direction,
Stage: t.Stage(),
StartTs: t.StartTs,
ElapsedMs: elapsed,
}
// ETA粗估「該 chip 該 stage 還剩多少秒」、用 UpgradeTimeoutFor
// 減去 elapsed 作為上界不精確、Design §9.6 文案已用 "~{n}s")。
timeout := UpgradeTimeoutFor(t.Chip)
remaining := int((timeout.Milliseconds() - elapsed) / 1000)
if remaining < 0 {
remaining = 0
}
info.EtaSeconds = remaining
out = append(out, info)
}
return out
}