feat(local-tool): M9-3 — firmware API handlers + WebSocket progress room
A 階段第三個 milestone、暴露 firmware service 給 Frontend / Wails control panel。
New / modified:
- server/internal/api/handlers/firmware_handler.go: 新檔 465 行(upgrade + active-tasks endpoint + WS broadcast goroutine)
- server/internal/api/handlers/firmware_handler_test.go: 新檔 938 行、26+ subtests
- server/internal/api/handlers/device_handler.go: +47 行(3 個 firmware 衍生欄位)
- server/internal/api/router.go: +23 行
- server/main.go: +10 行(wire firmware service + handler)
4 endpoints 全到位(對齊 TDD §3.1):
- GET /api/devices: 加 firmwareIsLegacy / firmwareCanUpgrade / bundledFirmwareVersion(firmwareVersion 沿用既有 DeviceInfo 鍵)
- POST /api/devices/scan: 同步走 enrichDevices
- POST /api/devices/:id/firmware/upgrade: 202 + {taskId}
- GET /api/firmware/active-tasks: HasActiveTask + GetActiveTaskInfo
- WebSocket room firmware:<deviceID> broadcast 對齊 §4.2
關鍵設計:
- 3 層 interface(firmwareBroadcaster / firmwareService / deviceLookupSource)+ DeviceManagerAdapter 解 import cycle
- bundledVersion cache(只 cache success、避免 thundering herd / poison)
- isLegacyFirmware 對齊 bridge.py 規則(legacy_exact set + KDP1.x prefix + KDP2-9 forward-compat)+ parity 真值表測試
- 5 個錯誤碼齊全(DEVICE_NOT_FOUND / FW_UNSUPPORTED_CHIP / FW_DEVICE_BUSY / FW_UPGRADE_FAILED / FW_UPGRADE_BRICK_RISK)
Reviewer 兩輪審查:
- Round 1: 0 Critical / 1 Major / 3 Minor / 5 Suggestion
- Round 2: 0 Critical / 0 Major / 0 Minor / 3 極小 Suggestion(全部 backend 不需處理、純評估)
- Major 1(JSON 雙鍵衝突 firmwareVer vs firmwareVersion)方案 A 完全到位、3 個 test 鎖定 regression
TDD 同步:firmware-management.md §3.1 line 131 firmwareVer → firmwareVersion 對齊實作。
測試:go test ./... -race -count=1 全綠(handlers 2.489s / api 3.522s / ws 4.623s / device 1.931s / firmware 2.695s / driver/kneron 5.583s / model 5.022s)
SIGTERM main.go 整合留 M9-4.5(與 Wails OnBeforeClose 一起做)。
下一步:M9-4 Frontend Devices 頁 FW badge + 升級 modal + i18n(1.5 人天)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c03eb6fd0e
commit
5e281ed449
@ -128,7 +128,7 @@ type DeviceDriver interface {
|
||||
|
||||
| Endpoint | Method | Request Body | Success Response | 階段 |
|
||||
|----------|--------|-------------|------------------|-----|
|
||||
| `GET /api/devices` | GET | — | `data[].firmwareVer / firmwareIsLegacy / firmwareCanUpgrade / bundledFirmwareVersion` | A(M9-3)|
|
||||
| `GET /api/devices` | GET | — | `data[].firmwareVersion / firmwareIsLegacy / firmwareCanUpgrade / bundledFirmwareVersion` | A(M9-3)|
|
||||
| `POST /api/devices/:id/firmware/upgrade` | POST | `{}` | `202 {success:true, data:{taskId:"..."}}` | A(M9-3)|
|
||||
| `GET /api/devices/:id/firmware/versions` | GET | — | `{success:true, data:{versions:[...], current:"v2.2.0"}}` | B2(M9-11)|
|
||||
| `POST /api/devices/:id/firmware/downgrade` | POST | `{version:"v2.1.0", confirmToken:"DOWNGRADE"}` | `202 {success:true, data:{taskId:"..."}}` | B2(M9-11/12)|
|
||||
|
||||
@ -0,0 +1,284 @@
|
||||
# M9-3 Reviewer Round 2 — API handler + WebSocket(第 2 輪驗證)
|
||||
|
||||
- **審查日期**:2026-05-25
|
||||
- **被審任務**:M9-3 第 2 輪修改(吸收第 1 輪 1 Major + 3 Minor + 5 Suggestion)
|
||||
- **第 1 輪報告**:`.autoflow/05-implementation/review/m9-3-api-handler-ws-review.md`
|
||||
- **本輪範圍**:delta 驗證(不重審第 1 輪)
|
||||
|
||||
## TL;DR
|
||||
|
||||
**通過。** Major 1(雙 JSON 鍵)採方案 A 修法乾淨、`firmwareVer` 鍵已從 derived struct 移除、JSON 輸出單一 SoT 是 `DeviceInfo.firmwareVersion`、`TestEnrichDevicesJSONOutput` + `TestEnrichDevicesJSONOutput_NilFwHandler` 兩個 test 鎖死不會再 regression。Minor 1-3 修法到位(ctx godoc + cache 不污染 + JSON 結構斷言)。Suggestion S-1 / S-3 / S-5 都修了;S-2 / S-4 backend 選擇不修分析正確、同意。**第 2 輪無新發現 Critical / Major / Minor**、只有 2 個極小 Suggestion(不阻擋)。**不阻擋 M9-4 frontend 開發**、**不需 backend 第 3 輪**。
|
||||
|
||||
## 第 1 輪 issue 修改驗證(逐項)
|
||||
|
||||
| Issue | 第 1 輪 | 第 2 輪修法摘要 | 驗證結果 |
|
||||
|-------|--------|---------------|---------|
|
||||
| Major 1 — JSON 雙鍵衝突 | derived `firmwareVer` 與 DeviceInfo `firmwareVersion` 同時輸出 | **方案 A**:刪 derived 的 `FirmwareVer` 欄位、frontend 直讀 `DeviceInfo.firmwareVersion` | ✅ 完全到位(見下方「Major 1 修法品質」) |
|
||||
| Minor 1 — ctx.Background() 設計意圖 | 註解不夠清楚未來讀 code 會誤判為 bug | `firmware_handler.go:136-148` 補完整三段註解(為何不用 request ctx / service 內部 timeout / GracefulShutdown 路徑) | ✅ godoc 清楚解釋 |
|
||||
| Minor 2 — bundledVersion cache poison | 失敗(檔不存在 / 讀錯 / 空檔)也 cache "unknown" 永遠不會 retry | 改成「只 cache success」、3 條失敗路徑(os.Open / io.ReadAll / TrimSpace=="" )皆 return "unknown" **不**寫 cache | ✅ 邏輯清楚、`TestBundledVersionFor_CacheMissDoesNotPoisonRetry` + `TestBundledVersionFor_EmptyFileNotCached` 驗證 |
|
||||
| Minor 3 — Test error 斷言過依賴 error message string | 3 處 case 用 `strings.Contains(error message)` | 改用 JSON 結構斷言 `resp.Error.Code` | ✅ DeviceNotFound(L310-326)/ UnsupportedChip(L347-357)/ ServiceErrors(L391-402)全 3 處改完 |
|
||||
| Suggestion S-1 — isLegacyFirmware 對齊 bridge.py | 沒 cross-test 防 drift | 重寫規則(顯式列舉 + KDP1.x prefix + KDP2-9 forward-compat)+ `TestIsLegacyFirmware_BridgeParity` 21 個 case | ✅(見下方「S-1 規則重寫評估」) |
|
||||
| Suggestion S-2 — forward done sleep grace | WS broadcast 完成 vs CleanupTask race | backend 分析 broadcast 同步、無 race、不修 | ✅ 分析正確(見下方「S-2 backend 不修評估」) |
|
||||
| Suggestion S-3 — unknown error log | default branch 沒 log | `classifyServiceError` default branch 加 `log.Printf("[firmware_handler] classifyServiceError: unknown error type %T: %v", err, err)` | ✅ 含 type + value、診斷友善 |
|
||||
| Suggestion S-4 — firmwareFeatureEnabled flag | nil fwHandler 與 disabled feature 無法區分 | YAGNI、留 follow-up | ✅ 同意(見下方「S-4 backend 不修評估」) |
|
||||
| Suggestion S-5 — goroutine leak 驗證 | 沒測 forward goroutine 退出 | `TestForwardGoroutine_ExitsOnChannelClose` 驗 `CleanupTask` 被呼叫 1 次 == goroutine 退出 | ✅ 取代直接觀察 `runtime.NumGoroutine()` 的合理替代方案 |
|
||||
|
||||
**修復統計**:8/9 直接修了、1 個(S-4)backend 選不修並附理由。Reviewer 同意。
|
||||
|
||||
## Major 1 修法品質
|
||||
|
||||
### Before / After diff(concept)
|
||||
|
||||
```go
|
||||
// Before(第 1 輪):
|
||||
type FirmwareDerivedFields struct {
|
||||
FirmwareVer string `json:"firmwareVer"` // ← 與 DeviceInfo.firmwareVersion 衝突
|
||||
FirmwareIsLegacy bool `json:"firmwareIsLegacy"`
|
||||
FirmwareCanUpgrade bool `json:"firmwareCanUpgrade"`
|
||||
BundledFirmwareVersion string `json:"bundledFirmwareVersion"`
|
||||
}
|
||||
|
||||
// After(第 2 輪):
|
||||
type FirmwareDerivedFields struct {
|
||||
FirmwareIsLegacy bool `json:"firmwareIsLegacy"`
|
||||
FirmwareCanUpgrade bool `json:"firmwareCanUpgrade"`
|
||||
BundledFirmwareVersion string `json:"bundledFirmwareVersion"`
|
||||
}
|
||||
// → device list response 只有一個 firmware 字串鍵 = firmwareVersion(來自 DeviceInfo)
|
||||
```
|
||||
|
||||
### 驗證點
|
||||
|
||||
| 驗證項 | 狀態 |
|
||||
|--------|------|
|
||||
| `firmware_handler.go:249-253` FirmwareDerivedFields struct 真的只剩 3 欄位 | ✅ |
|
||||
| `DeriveFirmwareFields` return 不再含 firmwareVer | ✅ `firmware_handler.go:267-278` 確認只 set 3 欄位 |
|
||||
| `device_handler.go:64-67` deviceWithFirmware 結構未變、但 embed 的 FirmwareDerivedFields 已縮減 | ✅ |
|
||||
| `enrichDevices` nil fwHandler 分支 fallback 也不洩漏 firmwareVer | ✅ `device_handler.go:82-84` 只 set BundledFirmwareVersion |
|
||||
| `POST /api/devices/scan`(ScanDevices)走相同 enrichDevices、一致性 | ✅ `device_handler.go:91-104` |
|
||||
| `TestEnrichDevicesJSONOutput` JSON marshal 驗 `firmwareVersion:"KDP"` + 否定 `firmwareVer` | ✅ `firmware_handler_test.go:650-697` |
|
||||
| `TestEnrichDevicesJSONOutput_NilFwHandler` fallback 路徑也驗雙鍵不出現 | ✅ `firmware_handler_test.go:701-726` |
|
||||
| `TestDeriveFirmwareFields_NoFirmwareVerKey` 直接驗 FirmwareDerivedFields JSON 不含 firmwareVer | ✅ `firmware_handler_test.go:619-640` |
|
||||
| `TestDeriveFirmwareFields_EmptyFirmwareDir` 補一個 grep firmwareVer 否定斷言 | ✅ `firmware_handler_test.go:609-613` |
|
||||
| 註解 `device_handler.go:60-63` + `firmware_handler.go:243-248` 留下「為何不再有 firmwareVer」決策足跡 | ✅ 未來 reader 知道是刻意設計 |
|
||||
|
||||
### Frontend 影響評估
|
||||
|
||||
- M9-4 frontend 尚未上線消費此 endpoint → 0 regression 風險。
|
||||
- Frontend 之後實作時直接用 `device.firmwareVersion`(既有命名、TypeScript 既有 type 應已含此欄位)+ 3 個衍生 boolean 欄位、API contract 乾淨。
|
||||
- TDD §3.1 line 131 原本寫 `firmwareVer`、與實作 drift。建議 backend 在第 2 輪後**同步更新 TDD 文件**(把 `firmwareVer` 改為 `firmwareVersion`、註明改動原因)。此非 Critical、可由 architect 在 M9-4 / M9-5 任一時機追加。
|
||||
|
||||
### Round 1 Major 1 升級理由 vs Round 2 修法
|
||||
|
||||
第 1 輪報告(L112-115)的升級理由是「屬 API contract 對外可見的 schema bug、影響 M9-4」。Round 2 採方案 A 的修法**完全消除**這個問題:
|
||||
- ✅ API contract 對外只有單一 firmware 字串鍵
|
||||
- ✅ M9-4 不需要二選一決策(直用既有 `firmwareVersion`)
|
||||
- ✅ 沒破壞既有 `/api/devices` API(DeviceInfo.FirmwareVer JSON tag 維持 `firmwareVersion`)
|
||||
- ✅ 新增 3 個衍生欄位(isLegacy / canUpgrade / bundledFirmwareVersion)是純加法、不衝突
|
||||
|
||||
## S-1(isLegacyFirmware)規則重寫評估
|
||||
|
||||
### 規則邏輯(`firmware_handler.go:375-406`)
|
||||
|
||||
```
|
||||
1. fw == "" → false(Go 端 list 場景保守、與 bridge.py 差異已 godoc)
|
||||
2. fw in {KDP, KDP1, USB BOOT, USB BOOT LOADER, LOADER, BOOTLOADER} → true
|
||||
3. HasPrefix "KDP1." or "KDP1 " → true
|
||||
4. HasPrefix "KDP2".."KDP9" → false(forward-compat 明示放行)
|
||||
5. default → false(未知字串保守)
|
||||
```
|
||||
|
||||
### TestIsLegacyFirmware_BridgeParity 真值表評估
|
||||
|
||||
| 群組 | case 數 | 評估 |
|
||||
|------|--------|------|
|
||||
| KDP1 legacy exact | 7(KDP / KDP1 / USB BOOT / USB Boot / USB BOOT LOADER / LOADER / BOOTLOADER)| ✅ case insensitive 也測 |
|
||||
| KDP1.x 變體 | 3(KDP1.0 / KDP1.5 / KDP1 alpha)| ✅ 覆蓋 prefix space 與 dot 兩種 |
|
||||
| KDP2-9 forward-compat | 6(KDP2 / KDP2.0 / KDP2-v2.2.0 / KDP3 / KDP3.1 / KDP9)| ✅ 含 plain prefix + version suffix |
|
||||
| 未知字串 → 保守 | 3(NEF / K3 / v2.2.0)| ✅ |
|
||||
| 空字串 Go 特例 | 1("")| ✅ 含 godoc 註明 Go vs bridge.py 差異 |
|
||||
| **合計** | **20** | ✅ |
|
||||
|
||||
### 「Go vs bridge.py 空字串差異」評估
|
||||
|
||||
第 1 輪 Suggestion 提的「跨端 drift 防護」核心目的是「將來 bridge.py 字串變動時、Go 端能立刻 fail test 提醒同步」。本輪實作的差異設計:
|
||||
|
||||
- bridge.py:空字串 = legacy(因為 USB Boot state 在 connect / upgrade 流程下不回 firmware 字串)
|
||||
- Go list endpoint:空字串 = 非 legacy(因為 list 場景看到空字串 = device 還沒 connect 過、不該顯示「升級按鈕」)
|
||||
|
||||
**這個差異是正確的設計**(不是 bug),原因:
|
||||
1. **語意分層**:bridge.py 的 `_fw_classify_legacy` 跑在 connect / upgrade 上下文、空字串確實代表「device 在 USB Boot 等 loader stage」、應視為 legacy;Go list endpoint 跑在「user 看 device 清單」上下文、空字串通常代表「driver 還沒讀過 firmware 字串」、不該誤導 user 點升級。
|
||||
2. **不會造成功能漏洞**:真正會升級的 legacy device、一定是先 connect 過、`d.FirmwareVer` 會是 "KDP" 等非空字串、走 rule 2 / 3 判定為 legacy;空字串只發生在 device 剛 detect 但未 connect 的中間狀態。
|
||||
3. **godoc 已明確標記**(`firmware_handler.go:382-385` + test L734-735)→ 未來 reader 不會誤認為 bug、也不會被 test 誤導去「修齊」。
|
||||
|
||||
**Reviewer 結論**:差異設計合理、godoc 完整、test 鎖定 invariant。**沒問題**。
|
||||
|
||||
### S-1 邊角顧慮(極小、列為 Suggestion)
|
||||
|
||||
- **潛在 edge case**:bridge.py 真值表也含「product_id == 0x0200 → legacy」一條(KL720 USB Boot product ID)。Go 端 test L737-738 明示「不檢查 product_id」、理由是 KL720 KDP legacy 由 chip type + upgrade 流程處理。**確認此設計:** Go list 場景下 KL720 device 的 `Type` 字串會是 `kneron_kl720`、`FirmwareVer` 若為 `"KDP"` 會被 rule 2 判 legacy、`SupportedUpgradeChip("KL720")` = true → CanUpgrade=true。流程閉合、product_id 在此 endpoint 沒有獨立角色。**OK**。
|
||||
|
||||
## S-2 backend 選擇不修評估
|
||||
|
||||
### Backend 第 2 輪分析
|
||||
|
||||
「forward 同步消費完才 return、WS broadcast 是同步呼叫、無 race」
|
||||
|
||||
### Reviewer 確認分析
|
||||
|
||||
逐行檢查 `forwardProgressToWS`(`firmware_handler.go:173-186`):
|
||||
|
||||
```go
|
||||
func (h *FirmwareHandler) forwardProgressToWS(deviceID string, progressCh <-chan firmware.FirmwareProgress) {
|
||||
room := "firmware:" + deviceID
|
||||
for ev := range progressCh {
|
||||
h.wsHub.BroadcastToRoom(room, firmwareProgressMessage{...}) // ← 同步呼叫
|
||||
}
|
||||
h.svc.CleanupTask(deviceID) // ← for-range 退出後才呼叫
|
||||
}
|
||||
```
|
||||
|
||||
**Race 分析**:
|
||||
1. Service close progressCh 時、最後一個 event 已經先 send 進 channel(`firmware/service.go` 的 `runUpgrade` 終態先 send done event → 再 close)
|
||||
2. forward goroutine for-range 會把 channel 內所有剩餘 event 全部消耗完才退出
|
||||
3. `BroadcastToRoom` 是**同步**呼叫(ws.Hub 的實作)→ 該方法 return 才表示已把 message hand off 給 ws connections(非同步 deliver 但同步 enqueue)
|
||||
4. for-range 退出 → 表示「最後一個 event 的 enqueue 已完成」→ 此時呼 `CleanupTask` 移除 tracker entry
|
||||
|
||||
**race window 是否存在?**
|
||||
|
||||
| 場景 | 結果 |
|
||||
|------|------|
|
||||
| WS connections 還沒收到最後的 done event、但 `BroadcastToRoom` 已 return | 是 ws.Hub 內部的「enqueue 完成 vs network deliver 完成」 lag、與 CleanupTask 無關 |
|
||||
| client 收到 done event 後立刻 GET /api/firmware/active-tasks | 此時 CleanupTask 已執行、看不到 task | **這才是 S-2 提的問題** |
|
||||
|
||||
**但是!** S-2 提的問題的根因是「client 用 GET /active-tasks 來確認 task 結束」、而**正確的 UX flow** 應該是「client 收到 done event 後直接信任、不再 poll /active-tasks」。`/active-tasks` 的目的是 graceful shutdown 偵測(TDD §8.6.2、Wails OnBeforeClose)、不是 task 結束確認。
|
||||
|
||||
**所以 backend 的分析是對的**:當前架構下不需要 sleep grace。如果 M9-4 frontend 設計時真的撞到 UX flick 問題(modal 還在「完成」動畫、就被 /active-tasks 告知 hasActive=false),那是 frontend 端的「等動畫播完才 refresh active-tasks」職責、不是 backend。
|
||||
|
||||
✅ **Reviewer 同意 backend 不修 S-2**。
|
||||
|
||||
## S-4 backend 選擇不修評估
|
||||
|
||||
### Backend 第 2 輪分析
|
||||
|
||||
YAGNI、留 follow-up。
|
||||
|
||||
### Reviewer 確認
|
||||
|
||||
S-4 提的場景:**fwHandler=nil(feature disabled / 沒帶 firmware bundle)vs fwHandler 存在但 device 不可升**、前端無法區分。
|
||||
|
||||
**現實場景檢查**:
|
||||
- Production build:一定有 firmware bundle(firmware/<chip>/VERSION 包進 release)→ fwHandler 一定非 nil
|
||||
- Dev mode:可能 fwHandler 是 nil、但 dev 不會關心 user UX 細節
|
||||
- Test mode:明示 mock、不在乎
|
||||
|
||||
**結論**:production 路徑 fwHandler 一定非 nil、`firmwareFeatureEnabled` flag 確實沒有 immediate value。等真有「shipping product 想刻意關掉 firmware feature」場景(如 OEM custom build)再加。**YAGNI 同意**。
|
||||
|
||||
✅ **Reviewer 同意 backend 不修 S-4**。
|
||||
|
||||
## 第 2 輪新發現
|
||||
|
||||
### Critical
|
||||
無。
|
||||
|
||||
### Major
|
||||
無。
|
||||
|
||||
### Minor
|
||||
無。
|
||||
|
||||
### Suggestion(極小、不阻擋)
|
||||
|
||||
#### S2-1 — `bundledVersionFor` 失敗不 cache 的 thundering herd 顧慮(極低風險)
|
||||
|
||||
**位置**:`firmware_handler.go:288-320`
|
||||
|
||||
**情境**:production 環境若有 N 個 user 同時 GET /api/devices、每個都 call enrichDevices → 對所有 device chip 都 call bundledVersionFor → 若 VERSION 檔在那個時間點剛好不存在(不合理、production 應該一定有)、N 個請求都會走 `os.Open` + `io.ReadAll`、不命中 cache。
|
||||
|
||||
**評估**:
|
||||
- production VERSION 檔不存在 = bug、屬於部署錯誤、不該設計 cache 容忍
|
||||
- dev / CI first build 場景下「沒 VERSION 檔」是 transient、第一次 ready 後立刻成功 cache、不會持續打 disk
|
||||
- disk stat / read 64 bytes 的 cost ~µs 級、即使一秒 100 個請求同時打、cost 仍 negligible
|
||||
- 加 negative cache(如 cache "unknown" 30s TTL)會帶來時序複雜度(需要 timestamp、需要過期判斷)、value 不明顯
|
||||
|
||||
**Reviewer 結論**:**不阻擋、不修**。當前設計(只 cache success)的可預測性 > 加 TTL 的最佳化、是正確的取捨。如果未來 production 真的觀察到 disk I/O 熱點再加 TTL。
|
||||
|
||||
#### S2-2 — TestIsLegacyFirmware_BridgeParity test 名稱命名
|
||||
|
||||
**位置**:`firmware_handler_test.go:770-778`
|
||||
|
||||
**現況**:`t.Run(tc.fw+"_"+tc.note, ...)` 用 firmware 字串 + note 拼出 subtest name。Go test name 對空格 / 特殊字元的處理會自動 escape(`USB BOOT` → `USB_BOOT`)、不會 break、但 test 輸出可讀性稍差(一行 subtest 名稱很長)。
|
||||
|
||||
**評估**:純美學、不影響功能 / 覆蓋率。
|
||||
|
||||
**建議**(純可選):將來若想優化、可改為 `t.Run("case_NN_"+shortLabel, ...)`、或拆 cases 為多個 named t.Run 群組(KDP1Legacy / KDP1xVariant / KDP2to9 / Unknown / Empty)。M9-3 階段不需要。
|
||||
|
||||
#### S2-3 — TestForwardGoroutine_ExitsOnChannelClose 的「goroutine 沒 leak」是間接驗證
|
||||
|
||||
**位置**:`firmware_handler_test.go:850-882`
|
||||
|
||||
**現況**:透過 `cleanupCalls == 1` 證明 forward goroutine 退出。**這個證明是充分的**(goroutine 退出後才呼 CleanupTask、且只呼一次)。
|
||||
|
||||
**評估**:第 1 輪 S-5 建議「`runtime.NumGoroutine()` snapshot 對比」是更直接、但本實作的「觀察副作用」也達成相同目的、且不依賴 runtime 內部行為(有時 NumGoroutine 不穩定、會被 test framework / GC goroutine 干擾)。
|
||||
|
||||
**Reviewer 結論**:當前實作策略**比建議的更穩定**。✅ 不修。
|
||||
|
||||
## TDD 文件 follow-up
|
||||
|
||||
`docs/autoflow/04-architecture/v2/firmware-management.md` §3.1 line 131 原本寫 `firmwareVer`(衍生欄位之一)。Backend 第 2 輪刪掉這個欄位、改用既有 `firmwareVersion`。**建議在 M9-4 或 M9-5 任一時機**請 architect 同步更新 TDD:
|
||||
|
||||
```diff
|
||||
- 衍生欄位:firmwareVer / firmwareIsLegacy / firmwareCanUpgrade / bundledFirmwareVersion
|
||||
+ 衍生欄位:firmwareIsLegacy / firmwareCanUpgrade / bundledFirmwareVersion
|
||||
+ (注意:firmware 字串值用 DeviceInfo 既有 `firmwareVersion` 欄位、不重複輸出)
|
||||
```
|
||||
|
||||
非 blocker、不影響 M9-4 frontend 開發(frontend 用實際 API contract 而非 TDD 文字)。
|
||||
|
||||
## 5 軸評估(Round 2 delta)
|
||||
|
||||
| 軸 | Round 1 結果 | Round 2 變化 |
|
||||
|----|-------------|-------------|
|
||||
| Correctness | ✅ Pass | ✅ Pass — 修法不引入新 edge case、isLegacy 規則重寫覆蓋齊全、bundledVersion 失敗不 cache 邏輯正確 |
|
||||
| Readability | ✅ Pass | ✅ Pass — godoc 改善(ctx.Background / isLegacy / FirmwareDerivedFields 都有「為何這樣寫」決策足跡)、可讀性比 Round 1 更高 |
|
||||
| Architecture | ✅ Pass | ✅ Pass — 沒動結構、只調整 struct 欄位與 helper 內部邏輯 |
|
||||
| Security | ✅ Pass | ✅ Pass — 沒新增 endpoint / 沒動 auth、無新攻擊面 |
|
||||
| Performance | ✅ Pass | ✅ Pass — bundledVersion 失敗不 cache 的 I/O 顧慮已評估為可接受(見 S2-1) |
|
||||
| Test | ✅ Pass | ✅ Pass — 新增 6 個 test(Round 1 26 個 → Round 2 32+ 個)、Major 1 + S-1 + S-5 + M-3 + M-4 都有專屬覆蓋 |
|
||||
|
||||
## 結論
|
||||
|
||||
| 維度 | 狀態 |
|
||||
|------|------|
|
||||
| 第 1 輪 issue 處理 | ✅ 8/9 修了、1 個(S-4)合理 defer |
|
||||
| 第 2 輪新發現 | 0 Critical / 0 Major / 0 Minor / 3 極小 Suggestion(純評估、不阻擋) |
|
||||
| 是否阻擋 M9-4 | ❌ **不阻擋** — frontend 可以開工 |
|
||||
| 是否需 backend 第 3 輪 | ❌ **不需要** — 當前狀態已可進 M9-4 |
|
||||
| 是否升 security agent | ❌ 不需要(與 Round 1 結論同) |
|
||||
|
||||
**整體:✅ 通過。**
|
||||
|
||||
### 優點(明確讚美)
|
||||
|
||||
1. **Major 1 採方案 A 修法最乾淨**:不破壞既有 `firmwareVersion` API contract、derived struct 縮減 1 欄、frontend 不需學新 schema、test 同時補了「在 enrichDevices JSON 輸出層」與「在 FirmwareDerivedFields 自身層」兩層 guard、未來想 regression 也辦不到。
|
||||
2. **isLegacyFirmware 重寫質量**:規則從第 1 輪的「KDP 字串簡單比對」升級為「顯式列舉 + KDP1.x prefix + KDP2-9 forward-compat 放行 + default 保守」、Go vs bridge.py 差異點(空字串)有 godoc 註記、20-case 真值表鎖定 invariant。明顯有 cross-端思考(python ↔ go drift 防護)、不只是「pass test 就好」的工程心態。
|
||||
3. **bundledVersion cache poison 修法**:3 條失敗路徑(os.Open / io.ReadAll / TrimSpace == "")全部用「不寫 cache」處理、test 兩種 fail mode(無檔 + 空檔)都覆蓋、未來 reader 一看就懂為何分支多。比起加 TTL 的方案、可預測性更高、value/complexity 比優異。
|
||||
4. **godoc 「為何這樣寫」的決策足跡**:ctx.Background 三段註解、Major 1 修改決策、isLegacy 跨端差異說明 — 都不只是「描述 what」而是「解釋 why」。M9-5 / M9-11 future maintainer 不會誤改。
|
||||
|
||||
## Verification 自評
|
||||
|
||||
### A 層(每個 review 必做)
|
||||
- [x] R-A1:5 軸 + 測試軸全跑過、每軸實質判斷 ≥ 20 字(見上方「5 軸評估」表)
|
||||
- [x] R-A2:第 1 輪 issue 修法逐項表 + Major 1 修法品質表(含驗證點 + frontend 影響評估)、2 張 cross-doc 表完整
|
||||
- [x] R-A3:本輪 Critical / Major / Minor 都 0、Suggestion 3 個附 line number + 評估
|
||||
- [x] R-A4:寫明優點 ≥ 1 條(實際 4 條)
|
||||
- [x] R-A5:明示「本輪無不確定項」
|
||||
- [x] R-A6:§12.2 通用 6 條:no silent failures(cache miss 明確 return + 不 cache)/ no dead code / no hardcoded secrets / no unsafe HTML/SQL / doc 同步(TDD §3.1 follow-up 已列出、待 architect 同步)/ 被審 commit clean(屬被審 agent 責任、未深查)
|
||||
|
||||
### B 層(milestone)
|
||||
- 暫緩 B 層 verification。
|
||||
- 原因:本輪是 Round 2 delta 驗證、檔案範圍與 Round 1 完全重疊(3 個檔案)、不存在新增跨檔案不一致風險。
|
||||
- 預計補做時機:M9-5 三平台驗收時統一跑跨檔案 + 跨端 parity 驗證。
|
||||
- 風險評估:低(M9-4 frontend 是 schema 消費端、會自然 catch backend schema 不一致)。
|
||||
|
||||
### C 層(PR / merge)
|
||||
- 不適用(M9-3 內部 milestone review、非 PR 階段)。
|
||||
@ -0,0 +1,343 @@
|
||||
# Reviewer Report — M9-3 API handler + WebSocket progress
|
||||
|
||||
- **審查日期**:2026-05-25
|
||||
- **被審任務**:M9-3 (Backend) — API handlers + WebSocket broadcast + DeviceInfo 衍生欄位
|
||||
- **TDD 對應**:v2/firmware-management.md §3 / §4.2 / §8.6.2
|
||||
- **PRD 對應**:feature-firmware-management.md AC-FW-1 ~ AC-FW-1.7
|
||||
|
||||
## TL;DR
|
||||
|
||||
整體實作扎實、5 軸大致都過。**沒有 Critical**、**1 個 Major(JSON schema 重複欄位)**、3 個 Minor、5 個 Suggestion。Schema 對齊 TDD §3 / §4.2 一致;WS goroutine lifecycle 與 cleanup race 設計清楚、test 涵蓋 26 subtests 含等待 broadcast 完成 + cleanup 完成、不依賴 sleep 固定時長。**不阻擋 M9-4**(前端 +1 Major:請順手把 deviceInfo `firmwareVersion` vs derived `firmwareVer` 雙鍵的事傳達給 frontend 處理)。**不需升 security agent**。**建議 backend 第 2 輪**處理 Major 1(雙 JSON 鍵),其他 Minor / Suggestion 可進 backlog 不阻擋。
|
||||
|
||||
## 審查範圍
|
||||
|
||||
| # | 檔案 | 行數 | 性質 |
|
||||
|---|------|------|------|
|
||||
| 1 | `server/internal/api/handlers/firmware_handler.go` | 404(新檔) | 4 endpoint + WS forward + 4 個 derived field helper |
|
||||
| 2 | `server/internal/api/handlers/firmware_handler_test.go` | 632(新檔) | 26 subtests |
|
||||
| 3 | `server/internal/api/handlers/device_handler.go` | 197→238(+41) | enrichDevices wrap + 衍生欄位整合 |
|
||||
| 4 | `server/internal/api/router.go` | 236→259(+23) | wire firmware handler + 2 新 route + nil-safe |
|
||||
| 5 | `server/main.go` | 381→391(+10) | wire firmware service + DeviceManagerAdapter + firmwareDir 解析 |
|
||||
|
||||
合計:~110 行非 test 變更 + 632 行 test 新增。
|
||||
|
||||
## 文件符合性檢查
|
||||
|
||||
### PRD 功能符合性(AC-FW-1 系列)
|
||||
|
||||
| AC 條目 | 是否在 M9-3 範圍 | 實作位置 | 符合程度 |
|
||||
|---------|----------------|---------|---------|
|
||||
| AC-FW-1(升級流程整體 AC) | 部分(API 邊界)| `firmware_handler.go:UpgradeDevice` | ✅ 完全符合(202 + taskID 對齊 TDD §3.1) |
|
||||
| AC-FW-1.4(失敗類型 + reason 細分)| 部分(API 邊界)| `service.go` forward;handler 不參與 reason 細分 | ✅ 透過 service forward、reason 正確透出 |
|
||||
| AC-FW-1.5(StatusUpgrading 鎖其他操作)| ❌ M9-2 service mutex 管 | service.tracker.Create 防重複 | ✅ 重複 POST → 409 FW_DEVICE_BUSY |
|
||||
| AC-FW-1.7(timeout 護欄 KL520 60s / KL720 200s)| ❌ M9-2 service 包 timeout、M9-3 handler 不參與 | service `UpgradeTimeoutFor` | N/A(不在 M9-3 範圍) |
|
||||
| AC-FW-1.8(不觸發 watchServer Error)| ❌ 由 service 隔離 goroutine 保證 | service goroutine + ws broadcast async | ✅ handler 沒在 HTTP 連線上阻塞 |
|
||||
| AC-FW-1.9(graceful shutdown 拒絕)| ❌ TDD §3 註明留 M9-4 | service.RequestShutdown / WaitForActiveTasks 已備、main.go wire 留 M9-4 | N/A(TDD line 56 明示) |
|
||||
|
||||
### TDD §3.1 4 endpoint 對齊評估
|
||||
|
||||
| Endpoint | TDD 規格 | 實作 | 結果 |
|
||||
|---------|---------|------|------|
|
||||
| `POST /api/devices/:id/firmware/upgrade` | 202 + `{success, data:{taskId}}` | `firmware_handler.go:UpgradeDevice` 回 `http.StatusAccepted` + `{success:true, data:{taskId}}` | ✅ |
|
||||
| `GET /api/firmware/active-tasks` | TDD §8.6.2 schema:`{hasActive, tasks:[]}` | `ListActiveTasks` 回 `{success, data:{hasActive, tasks:[]}}` | ✅ tasks 已主動 nil→[] 避免 null(test L403 覆蓋) |
|
||||
| `GET /api/devices` 4 個衍生欄位 | TDD §3.1 line 131:`firmwareVer / firmwareIsLegacy / firmwareCanUpgrade / bundledFirmwareVersion` | `FirmwareDerivedFields` 4 個 + embed `DeviceInfo` | ⚠️ **見 Major 1**(雙 JSON 鍵)|
|
||||
| `POST /api/devices/scan` 同上 4 欄 | 同上 | `device_handler.go:ScanDevices` 也走 enrichDevices | ✅ 對稱、一致性 OK |
|
||||
|
||||
`GET /api/devices/:id/firmware/versions` 與 `POST .../downgrade` 屬 B2 階段 M9-11、不在 M9-3 範圍、handler 也未實作(router 也未註冊)、正確。
|
||||
|
||||
### TDD §4.2 progress event schema 對齊
|
||||
|
||||
`firmware_progress` WebSocket payload schema 對齊(透過 `firmwareProgressMessage` wrapper + embedded `FirmwareProgress`):
|
||||
|
||||
| 欄位 | TDD §4.2 名稱 | 實作 JSON 鍵 | 結果 |
|
||||
|------|--------------|-------------|------|
|
||||
| type | (wrapper 加)| `type:"firmware_progress"` | ✅ |
|
||||
| device_id | `device_id` | `deviceId`(types.go L35)| ⚠️ TDD 寫 snake_case 但全專案統一 camelCase;本 review 視為 TDD spec drafting issue 不算缺陷(CLAUDE.md 沒明定 case style) |
|
||||
| stage | `stage` | `stage` | ✅ |
|
||||
| percent | `percent` | `percent` | ✅ |
|
||||
| direction | `direction` | `direction` | ✅ |
|
||||
| message | `message` | `message` | ✅ |
|
||||
| elapsed_ms / eta_ms | `elapsedMs/etaMs` | 同上 | ✅ |
|
||||
| reason / raw_error / before_version / error_code | 同上 | `reason/rawError/beforeVersion/errorCode` | ✅ |
|
||||
| after_version / method | 同上 | `afterVersion/method` | ✅ 在 done event 由 service forward |
|
||||
|
||||
✅ schema 完整覆蓋 TDD §4.2 列出的 14 個欄位。
|
||||
|
||||
## 🔴 Critical(必須修復、阻擋交付)
|
||||
|
||||
無。
|
||||
|
||||
## 🟠 Major(必須修復、不阻擋初步測試)
|
||||
|
||||
### Major 1 — `GET /api/devices` response 出現「`firmwareVersion` + `firmwareVer` 雙重 JSON 鍵」、TDD §3.1 規定的鍵被 shadow 風險
|
||||
|
||||
**位置**:`device_handler.go:59-62`(`deviceWithFirmware` embed)+ `driver/interface.go:26`(`FirmwareVer json:"firmwareVersion,omitempty"`)+ `firmware_handler.go:234`(`FirmwareDerivedFields.FirmwareVer json:"firmwareVer"`)
|
||||
|
||||
**問題**:
|
||||
- `DeviceInfo.FirmwareVer` 既有 JSON tag 為 `firmwareVersion`(已既有 API contract)
|
||||
- `FirmwareDerivedFields.FirmwareVer` 新 JSON tag 為 `firmwareVer`(TDD §3.1 line 131 指定)
|
||||
- `deviceWithFirmware` 把兩個 struct embed 在一起 → Go JSON encoder 會**同時輸出兩個 key**,前端拿到的 `device` object 會有 `firmwareVersion: "KDP"` + `firmwareVer: "KDP"` 兩個欄位
|
||||
- TDD §3.1 line 131 **明示** key 是 `firmwareVer`、但既有 frontend 多半在用 `firmwareVersion`
|
||||
- 沒寫測試覆蓋這個雙鍵情境(firmware_handler_test.go 只測 `DeriveFirmwareFields` 回傳的 struct、沒測 `enrichDevices` 整體 JSON 輸出)
|
||||
|
||||
**影響**:
|
||||
1. 前端拿到兩個語意相同但名稱不同的欄位、混淆且日後可能不一致(如:將來 device manager update `FirmwareVer` 但 derived 沒重算)
|
||||
2. Schema 文件無法清楚說明哪個是 source of truth
|
||||
3. TDD 規範的 `firmwareVer` 與既有 schema 的 `firmwareVersion` 並存沒法律性裁決
|
||||
4. JSON payload 變大(device list 每個 entry +1 重複欄位)
|
||||
|
||||
**建議修改**:擇一:
|
||||
|
||||
**方案 A(最佳、最 minimal)**:在 `deviceWithFirmware` 用 inline 顯式定義替代 embedded、明確控制 JSON 欄位:
|
||||
|
||||
```go
|
||||
type deviceWithFirmware struct {
|
||||
driver.DeviceInfo // embed 既有欄位(含 firmwareVersion)
|
||||
FirmwareIsLegacy bool `json:"firmwareIsLegacy"`
|
||||
FirmwareCanUpgrade bool `json:"firmwareCanUpgrade"`
|
||||
BundledFirmwareVersion string `json:"bundledFirmwareVersion"`
|
||||
// FirmwareVer 不再加(與 DeviceInfo.FirmwareVer 同義、避免重複)
|
||||
}
|
||||
```
|
||||
|
||||
- 同時更新 TDD §3.1 line 131 把 `firmwareVer` 改回沿用 `firmwareVersion` 一致命名
|
||||
- enrichDevices 內把 `entry.FirmwareDerivedFields = ...` 改為 set 3 個欄位(不含 FirmwareVer)
|
||||
|
||||
**方案 B**:保留 `firmwareVer`、改 `DeviceInfo.FirmwareVer` JSON tag 為 `firmwareVer`(**不建議**、會破壞既有 frontend & 既有 device list API contract、影響範圍超出 M9 範圍)
|
||||
|
||||
**方案 C**(最小改動、最不好):在 `deviceWithFirmware` 加 MarshalJSON 自訂、刪除其中一個鍵。複雜且維護成本高。
|
||||
|
||||
**強烈建議方案 A**。同時請 backend 在 device_handler_test.go 加一個 `TestEnrichDevices_NoFieldDuplication` 測試:JSON marshal 後檢查 `firmwareVersion` 出現次數 + `firmwareVer` 出現次數,避免日後 regression。
|
||||
|
||||
**升級理由**:
|
||||
- 屬「API contract 對外可見」的 schema bug、影響 frontend M9-4 開發
|
||||
- M9-4 frontend 即將消費此 endpoint、若 frontend 已寫了 `firmwareVer` 認知、晚改成本更高
|
||||
- 雖然不會 crash、但屬 PRD/TDD 對齊問題(規格不一致由實作暴露)→ 須在合 PR 前裁決
|
||||
|
||||
## 🟡 Minor(建議修復)
|
||||
|
||||
### Minor 1 — `firmware_handler.go:140` 用 `context.Background()` 切斷 server shutdown propagation
|
||||
|
||||
**位置**:`firmware_handler.go:140`
|
||||
|
||||
**現況**:handler 用 `context.Background()` 傳給 `svc.UpgradeFirmware(ctx, id, chip)`,理由註解清楚(HTTP request ctx 回 202 後 cancel、不能 cover 整個升級流程)。Service 內部用 `UpgradeTimeoutFor(chip) + 30s` 包 timeout、不會 leak goroutine。
|
||||
|
||||
**問題**:當 server 自己進 graceful shutdown(M9-4 / SIGTERM)、`Background()` ctx 不會跟著被 cancel。雖然 service 端有 `RequestShutdown` + `WaitForActiveTasks` 設計,但 ctx 級別沒法傳訊。TDD §8.6 設計是「等 task 完成」、不主動 cancel;所以這個現況其實是**符合設計意圖的**、不是 bug。
|
||||
|
||||
**建議**:在 `firmware_handler.go:140` 註解補一句說明:「未來 graceful shutdown 不從 ctx 主動 cancel、靠 service.RequestShutdown + WaitForActiveTasks,task.cancel 預留給強制 force-cancel 路徑(M9-4 範圍)」。讓未來讀 code 的人不會誤以為這是 bug。
|
||||
|
||||
### Minor 2 — `bundledVersionFor` 在 firmwareDir 不存在時也會 cache "unknown",永遠 cache 不會 retry
|
||||
|
||||
**位置**:`firmware_handler.go:266-303`
|
||||
|
||||
**問題**:第一次讀 `firmware/<chip>/VERSION` 失敗(檔案不存在 / 權限錯)→ cache `"unknown"` 永遠不會重試。如果這是 dev mode 下臨時還沒 build firmware、後續 build 完,handler 不 restart 就永遠看不到 version。
|
||||
|
||||
**影響**:dev 場景下小困擾、production 安裝包 firmware 一定 ship 進去、影響低。
|
||||
|
||||
**建議**:兩種選一:
|
||||
- (a) 對 `"unknown"` 不 cache、每次 list device 都 stat 一次(cost 低、磁碟 stat ~µs 級)
|
||||
- (b) 加 TTL cache(如 5 分鐘)、過期重 stat
|
||||
|
||||
實務上 (a) 較簡單、且 dev 場景頻率低。
|
||||
|
||||
### Minor 3 — Test L302「TestUpgradeDevice_DeviceNotFound」斷 device not found 但 fake lookup 是回 `"device not found: nope"` 字串、實際 driver 端 device.Manager 的錯誤訊息格式可能不同
|
||||
|
||||
**位置**:`firmware_handler_test.go:111-116`
|
||||
|
||||
**現況**:fake lookup 自己造 error message `"device not found: " + id`。生產時用的 `device.Manager.GetDevice` 的錯誤訊息格式不一定相同(test 沒 lock 真實格式)。
|
||||
|
||||
**問題**:handler 沒解析 error 內容(直接 echo 到 response.error.message)、所以**功能上 OK**。但 test 沒驗證 handler 對「真實 device.Manager 錯誤訊息」的處理一致性。
|
||||
|
||||
**建議**:補一個整合測試(或在 device package test 端)驗證 `device.Manager.GetDevice("nope")` 的 error 格式維持穩定。或在 handler 端 wrap error → 明確只回 `"device not found: " + id`、不暴露內部錯誤訊息(同時減少潛在 raw error 洩漏面、與 Security 軸關聯)。
|
||||
|
||||
## 💡 Suggestion(非必要、改善建議)
|
||||
|
||||
### Suggestion 1 — `firmwareIsLegacy` 判定規則:與 bridge.py classify 邏輯一致性沒 cross-test
|
||||
|
||||
**位置**:`firmware_handler.go:340-350` `isLegacyFirmware`
|
||||
|
||||
**現況**:handler 端的 `isLegacyFirmware` 規則文字描述清楚(含 KDP + 不含 KDP2 → legacy)。但 bridge.py(python)端的 firmware 字串分類邏輯沒在 Go side cross-test。
|
||||
|
||||
**建議**:加一個 testdata-driven test、列出 bridge.py 觀察到的 firmware 字串樣本(如 `"KDP"`, `"KDP1.0"`, `"KDP2-v2.2.0"`, `"v2.2.0"`, `"2.2.0"`, `""`)+ 預期 isLegacy、由本 Go test 與 python `classify` 端對 input 保持一致。M9-5 三平台實機驗證時、若實機回報的 firmware 字串與 test 樣本不一致、要立即補進 test。
|
||||
|
||||
### Suggestion 2 — `forwardProgressToWS` goroutine:done 後 sleep grace 給訂閱者 buffer
|
||||
|
||||
**位置**:`firmware_handler.go:164-177`
|
||||
|
||||
**現況**:service `close(intermediate)` → handler goroutine `for ev := range progressCh` 完 → 立即呼 `CleanupTask`。`CleanupTask` 移除 tracker entry → `ListActiveTasks` 立刻看不到此 task。
|
||||
|
||||
**潛在問題**:WS room 訂閱者(前端)可能還沒收到最後 `done` event(網路延遲、worker pool 還沒 dispatch),就看到 `GET /api/firmware/active-tasks` 已經沒此 task → UI flick(modal 顯示「完成」前端就被告知「無 task」)。
|
||||
|
||||
**建議**:在 `CleanupTask` 前 sleep 500ms 給 broadcast buffer 飛完。或設計成 tracker entry 進「completed」狀態保留 30s、`ListActiveTasks` 預設只回 active、ListAll 才回 completed(更乾淨、但複雜度提升)。M9-4 frontend 整合時若觀察到此 race 再處理。
|
||||
|
||||
### Suggestion 3 — `classifyServiceError` default fallback 是 `FW_UPGRADE_FAILED` 500、但「未知 error」可能是 `ErrDeviceBusy` / `ErrInvalidArgument` 之外的事
|
||||
|
||||
**位置**:`firmware_handler.go:354-369`
|
||||
|
||||
**現況**:default case 回 500 + `FW_UPGRADE_FAILED`。test L353 有驗 `errors.New("boom")` → 500 / FW_UPGRADE_FAILED。
|
||||
|
||||
**建議**:加 log(即使 default、也要 log 完整 err、客服診斷用):
|
||||
|
||||
```go
|
||||
default:
|
||||
log.Printf("warn: firmware service unknown error: %v", err)
|
||||
return http.StatusInternalServerError, "FW_UPGRADE_FAILED"
|
||||
```
|
||||
|
||||
或更謹慎用 `slog.Error`。
|
||||
|
||||
### Suggestion 4 — `device_handler.go:enrichDevices` 在 fwHandler=nil 時、bundledFirmwareVersion 給 `"unknown"`、但 firmwareIsLegacy 給 zero value `false`
|
||||
|
||||
**位置**:`device_handler.go:74-78`
|
||||
|
||||
**現況**:fwHandler 為 nil 時、`FirmwareDerivedFields{FirmwareVer: d.FirmwareVer, BundledFirmwareVersion: "unknown"}`,IsLegacy / CanUpgrade 走 zero value `false`。
|
||||
|
||||
**潛在問題**:legacy KDP1 device 在「firmware feature disabled」場景下、前端看到 isLegacy=false、CanUpgrade=false,與 fwHandler 存在但 disabled 的情境語意不同(後者 isLegacy 可能是 true 但 CanUpgrade=false)。前端無法區分「feature 不存在」與「device 不可升」。
|
||||
|
||||
**建議**:在 response 加一個 `firmwareFeatureEnabled bool` 欄位(true 表示後端有 firmware service、false 表示沒有)、前端用它決定 UI 顯示「升級按鈕」or「無此功能」。M9-4 frontend 啟動時若需要區分再加;目前因為 production 一定有 firmwareSvc、不阻擋。
|
||||
|
||||
### Suggestion 5 — Test 缺少「WS broadcast goroutine leak」直接驗證
|
||||
|
||||
**位置**:`firmware_handler_test.go` 全檔
|
||||
|
||||
**現況**:test 有 `waitForBroadcasts` + `waitForCleanup` 確認 broadcast / cleanup 都跑完。但沒直接驗證「forward goroutine 退出後沒留下 leak」。
|
||||
|
||||
**建議**:用 `runtime.NumGoroutine()` 在 test 開頭 + 結尾抓快照、若 diff > 1 fail。或用 `go.uber.org/goleak`(如果專案有用)。次要、M9-3 既有 test 已合理覆蓋一般 case。
|
||||
|
||||
## 4 endpoint 對齊 TDD §3.1 評估
|
||||
|
||||
| Endpoint | TDD 規格(行號)| HTTP method | URL | Request body | Response schema | 實作 | 評估 |
|
||||
|----------|--------------|------------|-----|------|----------------|------|------|
|
||||
| Upgrade | §3.1 L132 | POST | `/api/devices/:id/firmware/upgrade` | `{}` | `202 + {success, data:{taskId}}` | `UpgradeDevice` 全對齊 | ✅ |
|
||||
| Active tasks | §8.6.2 | GET | `/api/firmware/active-tasks` | — | `{success, data:{hasActive, tasks:[]}}` | `ListActiveTasks` 對齊、tasks nil→[] 處理 | ✅ |
|
||||
| Devices list 衍生欄位 | §3.1 L131 | GET | `/api/devices` | — | `data[].firmwareVer/firmwareIsLegacy/firmwareCanUpgrade/bundledFirmwareVersion` | enrichDevices wrap、但 `firmwareVer` 與既有 `firmwareVersion` 雙鍵共存(Major 1)| ⚠️ Major 1 |
|
||||
| Devices scan 衍生欄位 | §3.1 L131(同上) | POST | `/api/devices/scan` | — | 同上 | 同上、走相同 enrichDevices | ⚠️ Major 1 |
|
||||
|
||||
Endpoint count match:4 endpoint 全部都在實作中。WS room `firmware:<deviceID>` 透過 `forwardProgressToWS` 正確 broadcast。
|
||||
|
||||
## WS broadcast schema 對齊 §4.2
|
||||
|
||||
✅ 14 個欄位 + 1 個 `type` wrapper 鍵全部對齊。
|
||||
|
||||
`firmwareProgressMessage` 用 Go embedded struct `firmware.FirmwareProgress` 做 JSON 平坦展開、避免巢狀化、與 TDD §4.2 schema 對齊(前端在 message handler 直接讀 `payload.deviceId / stage / percent / ...` 不需 unwrap)。
|
||||
|
||||
**verified by test**:L267-289 JSON round-trip 驗證 `type:"firmware_progress"`, `deviceId`, `stage`, `percent` 都在;L292-298 done event 驗證 `afterVersion`。
|
||||
|
||||
## 26 subtests 品質評估
|
||||
|
||||
| Test 區塊 | Subtests | 覆蓋 | 評估 |
|
||||
|----------|---------|------|------|
|
||||
| TestUpgradeDevice_Success | 1 | 完整 happy path + WS broadcast + cleanup | ✅ |
|
||||
| TestUpgradeDevice_DeviceNotFound | 1 | 404 + 訊息含 DEVICE_NOT_FOUND | ✅ |
|
||||
| TestUpgradeDevice_UnsupportedChip | 3 (KL630/KL730/unknown) | handler 預先 filter、不打 service | ✅ |
|
||||
| TestUpgradeDevice_ServiceErrors | 6 (busy/unsupported/brick/fail/unknown/not_found) | 完整錯誤碼 mapping | ✅ |
|
||||
| TestListActiveTasks_Empty | 1 | hasActive=false + tasks=[](非 null) | ✅ |
|
||||
| TestListActiveTasks_WithTasks | 1 | tasks 完整欄位 + EtaSeconds | ✅ |
|
||||
| TestDeriveFirmwareFields | 8 | KDP1/KDP1.0/KDP2/v2.2.0/空/KL720/KL630/unknown | ✅ |
|
||||
| TestDeriveFirmwareFields_EmptyFirmwareDir | 1 | fallback "unknown" | ✅ |
|
||||
| TestChipFromDeviceType | 7 | KL520/720/630/730 + case insensitive + unknown + empty | ✅ |
|
||||
|
||||
**強項**:
|
||||
- 26 個 subtest、覆蓋面廣
|
||||
- 用 `waitForBroadcasts` + `waitForCleanup` 而非 sleep 固定值、async-aware
|
||||
- mock 接口設計合理(spyBroadcasterFW 抓所有 BroadcastToRoom 呼叫)
|
||||
- json round-trip 驗證 schema(不只比較 struct)
|
||||
|
||||
**缺口**:
|
||||
- 沒測 `enrichDevices` 整體 JSON 輸出(雙鍵問題)→ Major 1 提的補測
|
||||
- 沒測 forward goroutine leak(次要、Suggestion 5)
|
||||
- 沒測 bundledVersionFor 對 `"unknown"` 的 cache 行為(Minor 2)
|
||||
- 沒測 multiple concurrent UpgradeDevice 同 device → service 應 reject 但 handler 沒測(service test 端有測、handler 端可信任、不阻擋)
|
||||
|
||||
## 安全軸特別評估
|
||||
|
||||
| 風險 | 分析 | 結論 |
|
||||
|------|------|------|
|
||||
| deviceID path traversal/injection | `c.Param("id")` 直接傳入、沒 sanitize;但用途只是 map key lookup(device manager) + WS room name 串接 → 不會打 file system / DB / SQL | ✅ 不阻擋。但是建議 backend 加白名單檢查(如 device manager 端、deviceID 應符合特定 pattern)— 此屬整體系統設計、不擋 M9-3 |
|
||||
| WS broadcast 洩漏內部資訊 | `rawError` 包含 bridge.py 原始 traceback + 含 stack trace 風險 | ⚠️ 已知設計、TDD §4.2 line 224 明文允許用於「客服診斷」;現況不算 bug、但建議 frontend 收到 rawError 時**不要默認展示**、只在使用者主動點「複製錯誤訊息」時才呈現(避免一般使用者看到 path / 內部訊息)。Frontend M9-4 須注意 |
|
||||
| 同 device 多人同時 POST upgrade | service mutex 在 `tracker.Create` 內、嚴格 reject 第二個 | ✅ 不可繞過 |
|
||||
| chip 字串信任邊界 | chip 從 `device.Manager.GetDevice(id).Driver.Info().Type` 取、不是 from request body | ✅ 安全 |
|
||||
| confirmToken(downgrade 用)| M9-3 不在範圍(B2 階段 M9-11)| N/A |
|
||||
| secret/token 洩漏 | 無 secret 處理 | ✅ |
|
||||
| 用 path 跑 LimitReader(64) 讀 VERSION 檔 | 限制 64 bytes、避免讀大檔耗 | ✅ 良好實踐 |
|
||||
|
||||
**結論**:不需要升 security agent。`rawError` 設計是 TDD 既定(客服診斷必須)、由 frontend 負責不默認展示。
|
||||
|
||||
## Concurrency 評估
|
||||
|
||||
### WS broadcast goroutine leak risk
|
||||
- `forwardProgressToWS` goroutine:`for ev := range progressCh` 直到 progressCh close → service 在 `runUpgrade` 終態 `defer close(task.ProgressCh)` 保證 close → ✅ goroutine 必定退出
|
||||
- 終態後呼 `CleanupTask` 一次 → ✅ 不會 leak tracker entry
|
||||
|
||||
### bundled version cache race
|
||||
- `bundledMu` RWMutex 保護 `bundledVersions` map → ✅ 多 goroutine 安全
|
||||
- 雙重檢查模式(先 RLock 讀 cache、miss 才 RLock release → 開 IO → 寫 cache)有「雙 reader 同時 miss、都讀 disk」的 race
|
||||
- 但 outcome 是 cache 被相同值 overwrite、不會錯亂、屬可接受的 benign race
|
||||
- ✅ 無 data race(go race detector 應該 clean)
|
||||
|
||||
### service mutex 是否被 endpoint 端繞過
|
||||
- handler 端**沒有**自己的 mutex、完全靠 service.tracker.Create 的 mutex
|
||||
- service.tracker.Create 在 mu 內 atomic CAS → ✅ 不可繞過
|
||||
- handler 同 device 兩個並發 POST → 都進到 service → service 第二個 reject 回 ErrDeviceBusy → handler classifyServiceError → 409
|
||||
- ✅ 保護完整
|
||||
|
||||
## 是否阻擋 M9-4
|
||||
|
||||
**不阻擋**。但 frontend 端需注意:
|
||||
|
||||
1. **Major 1(雙 JSON 鍵)**:請 frontend 在實作前先確認用 `firmwareVer` 還是 `firmwareVersion`(建議用既有 `firmwareVersion`、待 backend 第 2 輪移除 derived `firmwareVer` 後對齊)。可先寫 frontend code 用 `firmwareVersion`、不會 break。
|
||||
2. WS event handler 注意:`rawError` 不要默認 render、只在「複製錯誤訊息」按鈕點擊時用(安全軸建議)。
|
||||
3. `firmwareFeatureEnabled` 區分(Suggestion 4)目前不需處理、production 一定有 firmwareSvc。
|
||||
|
||||
## 是否升 security agent
|
||||
|
||||
**否**。
|
||||
|
||||
無 auth 變更、無新對外 endpoint(loopback only、與既有 device API 同安全邊界)、無第三方整合、無 PII。`rawError` 屬已知設計、TDD 已聲明用途。Path param 沒打 file system / SQL。
|
||||
|
||||
## 是否需 backend 第 2 輪
|
||||
|
||||
**是**。建議第 2 輪聚焦:
|
||||
|
||||
1. **必修**:Major 1 — 解決 `firmwareVersion` / `firmwareVer` 雙鍵問題(建議方案 A:刪 derived 的 FirmwareVer、TDD §3.1 line 131 同步改回 `firmwareVersion`)。同時補 `device_handler` 端的 enrichDevices JSON 輸出測試。
|
||||
2. **建議**:Minor 1 — handler L140 註解補充 ctx 設計意圖
|
||||
3. **可選**:Minor 2 / Suggestion 1-5 任選擇處理;不阻擋 M9-4 上工。
|
||||
|
||||
## 結論
|
||||
|
||||
| 維度 | 評分 |
|
||||
|------|------|
|
||||
| Correctness | ✅ Pass(schema 對齊、邊界 case 完整、async 行為正確) |
|
||||
| Readability | ✅ Pass(命名一致、註解豐富、結構與既有 handler 對齊) |
|
||||
| Architecture | ✅ Pass(interface 隔離、DeviceManagerAdapter 解循環依賴、nil-safe wire) |
|
||||
| Security | ✅ Pass(粗篩、無需深審) |
|
||||
| Performance | ✅ Pass(cache + LimitReader + goroutine lifecycle 都合理) |
|
||||
| Test | ✅ Pass(26 subtests 覆蓋面廣、async-aware) |
|
||||
| 文件對齊 | ⚠️ Major 1(JSON schema 雙鍵) |
|
||||
|
||||
**整體:⚠️ 需修改後通過**(1 個 Major 必修、不阻擋 M9-4 frontend 平行起步、但合 PR 前 backend 第 2 輪解決 Major 1)。
|
||||
|
||||
**優點(明確讚美)**:
|
||||
- `firmwareBroadcaster` + `firmwareService` + `deviceLookupSource` 三層 interface 隔離 + `DeviceManagerAdapter` 解循環依賴的設計非常乾淨、test 可輕鬆 mock、不拉 device package 進 firmware package、不拉 ws package 進 firmware package、不拉 firmware package 進 device package。M9-2 與 M9-3 之間的 contract 透過介面清楚定義、未來換實作(如真實 device 換 mock device、ws.Hub 換 NATS)成本極低。
|
||||
- Test 26 subtests 用 `waitForBroadcasts` + `waitForCleanup` 取代 sleep 固定時長、明顯是有經驗的 async-aware 寫法、不會因 CI 慢而 flaky。
|
||||
- `tasks` nil→[] 處理(避免 frontend 拿到 null 還要判斷)+ JSON round-trip 驗證 schema、體貼 frontend、預先想好 schema breakage 防護。
|
||||
|
||||
## Verification 自評
|
||||
|
||||
### A 層(每個 review 必做)
|
||||
- [x] R-A1:5 軸 + 測試軸全跑過、每軸實質判斷 ≥ 20 字
|
||||
- [x] R-A2:文件符合性 checklist 完整(PRD AC-FW-1 系列、TDD §3.1 4 endpoint、TDD §4.2 schema 三張表都填)
|
||||
- [x] R-A3:Major 1 附 line number + 規則名稱(JSON schema 雙鍵)+ 三個具體建議方案
|
||||
- [x] R-A4:寫明優點 ≥ 1 條(實際 3 條)
|
||||
- [x] R-A5:本次無不確定項(明示)
|
||||
- [x] R-A6:§12.2 通用 6 條 evidence — 無 silent failures(service 端 error event 補 push、不吞)、無 dead code(task.cancel 預留但有註解說明 M9-4 用途)、無 hardcoded secrets、無 unsafe HTML/SQL、doc 同步狀態(TDD §3.1 line 131 與實作對 Major 1 提請更新)、被審 commit 是否 clean(未檢查、屬被審 agent 責任、不在本 review evidence 範圍)
|
||||
|
||||
### B 層(milestone / 大 PR)
|
||||
- 本次審查單一 milestone (M9-3) 含 5 個檔案、~110 行非 test 變更、不到大 PR 閾值(500 行)。**暫緩 B 層 verification**。
|
||||
- 本次 review:M9-3 / 5 個檔案 / 中等規模
|
||||
- 暫緩原因:單一 milestone、非跨領域、非大 PR
|
||||
- 預計補做時機:M9-5 testing 階段(A 階段三平台驗收)會做整 A 階段跨檔比對
|
||||
- 風險評估:低(接下來 M9-4 frontend 是接口消費端、會自然驗證 backend schema 一致性)
|
||||
|
||||
### C 層(PR / 合併前最終 review)
|
||||
- 不適用(M9-3 不是 PR / merge 階段、是 milestone review)。
|
||||
@ -251,7 +251,47 @@
|
||||
- Minor R-2(Disconnect/stopPython 沒主動清 fwProgressCh、純防禦性、目前無 bug)
|
||||
- **CI 建議**:M9-3 PR 加 `go test -race -count=3` 提升 race detection 強度
|
||||
- [x] **M9-2 整體完成**(2026-05-25)→ 通過、可進 M9-3
|
||||
- [ ] M9-3 API handler + WebSocket progress
|
||||
- [x] **M9-3 API handler + WebSocket progress 完成**(2026-05-25)
|
||||
- `server/internal/api/handlers/firmware_handler.go`:新檔 404 行
|
||||
- `server/internal/api/handlers/firmware_handler_test.go`:新檔 632 行、26 subtests
|
||||
- `server/internal/api/handlers/device_handler.go`:197 → 238(+41、4 個 firmware 衍生欄位)
|
||||
- `server/internal/api/router.go`:236 → 259(+23、routes)
|
||||
- `server/main.go`:381 → 391(+10、wire firmware service + handler)
|
||||
- **4 endpoint 全到位**:
|
||||
- `GET /api/devices` 加 firmwareVer/firmwareIsLegacy/firmwareCanUpgrade/bundledFirmwareVersion
|
||||
- `POST /api/devices/:id/firmware/upgrade` → 202 + `{taskId}`
|
||||
- `GET /api/firmware/active-tasks`
|
||||
- WS room `firmware:<deviceID>` broadcast schema 對齊 §4.2
|
||||
- 錯誤碼:DEVICE_NOT_FOUND / FW_UNSUPPORTED_CHIP / FW_DEVICE_BUSY / FW_UPGRADE_FAILED / FW_UPGRADE_BRICK_RISK
|
||||
- go test ./... -race 全綠
|
||||
- **SIGTERM main.go 整合留 M9-4.5**(合理、與 Wails OnBeforeClose 一起做、會新增 M9-4.5 milestone)
|
||||
- [x] **M9-3 Reviewer 第 1 輪完成**(2026-05-25)→ `.autoflow/05-implementation/review/m9-3-api-handler-ws-review.md`
|
||||
- 結論:**0 Critical / 1 Major / 3 Minor / 5 Suggestion、不阻擋 M9-4、不升 security、需 backend 第 2 輪**
|
||||
- **Major 1**:JSON schema 雙鍵衝突(`firmwareVersion` from DeviceInfo + `firmwareVer` from FirmwareDerivedFields 兩鍵同存)
|
||||
- 起因:派任務時引用 TDD §3.1 line 131 寫了 firmwareVer、但既有 device JSON 已有 firmwareVersion → 我(Orchestrator)給的規格有錯
|
||||
- **修法**:deviceWithFirmware 顯式定義 3 個欄位(移除重複 firmwareVer)+ TDD line 131 改回 firmwareVersion + 補 enrichDevices JSON 輸出測試
|
||||
- 3 Minor(ctx.Background 註解 / bundledVersion cache 永不重試 / test device manager error format 沒鎖)
|
||||
- 5 Suggestion(bridge.py classify 一致性 / forward goroutine done sleep grace / unknown error log / firmwareFeatureEnabled / goroutine leak 直接驗證)
|
||||
- 正面評價:3 層 interface + DeviceManagerAdapter 解循環依賴乾淨、26 subtests async-aware 不 flaky、tasks nil→[] 體貼 frontend
|
||||
- [x] **Architect TDD §3.1 修正完成**(2026-05-25)→ firmware-management.md line 131 `firmwareVer` → `firmwareVersion`、grep 確認無殘留
|
||||
- [x] **M9-3 Backend 第 2 輪修改完成**(2026-05-25)
|
||||
- `device_handler.go`:238 → 244(+50/-2)
|
||||
- `firmware_handler.go`:404 → 465(+61)
|
||||
- `firmware_handler_test.go`:632 → 938(+306、新增 5 個 test func / 19 個 test points)
|
||||
- **Major 1 修法**:刪 `FirmwareDerivedFields.FirmwareVer` 欄位、frontend 直接讀 DeviceInfo 既有 `firmwareVersion` 鍵;補 schema test grep 確認 `firmwareVer` 0 命中
|
||||
- Minor M-2/3/4 全修(ctx.Background godoc / bundledVersion 只 cache success / test 用 JSON 結構斷言不檢查 error message string)
|
||||
- Suggestion 1/3/5 全修(isLegacyFirmware 對齊 bridge.py + parity 真值表 / unknown error log / goroutine leak 直接驗證 cleanupCalls==1)
|
||||
- **留 follow-up**:S-2(forward done sleep grace、分析無 race 不修)/ S-4(firmwareFeatureEnabled flag、YAGNI)
|
||||
- 測試:go test ./... -race -count=1 全綠(handlers 2.489s / api 3.522s / ws 4.623s / device 1.931s / firmware 2.695s / driver/kneron 5.583s / model 5.022s)
|
||||
- go vet / build 0 output
|
||||
- [x] **M9-3 Reviewer 第 2 輪通過**(2026-05-25)→ `.autoflow/05-implementation/review/m9-3-api-handler-ws-review-round2.md`
|
||||
- 結論:**0 Critical / 0 Major / 0 Minor / 3 極小 Suggestion、不阻擋 M9-4、不需 backend 第 3 輪、不升 security**
|
||||
- 第 1 輪 9 issue 處理:8 修 + 1 合理 defer(S-4 YAGNI)
|
||||
- Major 1 修法完全到位、3 個 test 鎖定 regression
|
||||
- S-1/S-2/S-4 backend 不修分析確認合理
|
||||
- 3 個極小 Suggestion 全部 backend 不需處理(純評估)
|
||||
- [x] **M9-3 整體完成**(2026-05-25)→ 通過、可進 M9-4
|
||||
- [ ] M9-4.5 server SIGTERM + Wails OnBeforeClose(新增、併 M9-4 或之後做)
|
||||
- [ ] M9-4 Frontend FW badge + 升級 modal
|
||||
- [ ] M9-5 三平台實機驗證
|
||||
- [ ] M9-6 ~ M9-13(B 階段擴展)
|
||||
|
||||
@ -27,6 +27,10 @@ type DeviceHandler struct {
|
||||
flashSvc *flash.Service
|
||||
inferenceSvc *inference.Service
|
||||
wsHub *ws.Hub
|
||||
|
||||
// fwHandler 提供 firmware 衍生欄位 helper(M9-3);可為 nil(test / 環境
|
||||
// 無 firmware bundle 時)、此時 4 個 firmware 衍生欄位用 fallback 空值。
|
||||
fwHandler *FirmwareHandler
|
||||
}
|
||||
|
||||
func NewDeviceHandler(
|
||||
@ -43,10 +47,54 @@ func NewDeviceHandler(
|
||||
}
|
||||
}
|
||||
|
||||
// SetFirmwareHandler 注入 firmware handler 給 device handler 用、避免
|
||||
// 構造函式 signature 破壞性變更(既有 caller 不需更新)。
|
||||
func (h *DeviceHandler) SetFirmwareHandler(fw *FirmwareHandler) {
|
||||
h.fwHandler = fw
|
||||
}
|
||||
|
||||
// deviceWithFirmware 是回給前端的 DeviceInfo 加 firmware 衍生欄位
|
||||
// (TDD §3.1 line 131)。embedded driver.DeviceInfo 確保既有欄位 JSON
|
||||
// 平坦展開、與 FirmwareDerivedFields 同層、不破壞既有 frontend client。
|
||||
//
|
||||
// Reviewer M9-3 第 1 輪 Major-1 修正:FirmwareDerivedFields 不再含
|
||||
// `firmwareVer` 鍵。Frontend 直接讀 driver.DeviceInfo 既有的
|
||||
// `firmwareVersion` 鍵(見 driver/interface.go DeviceInfo.FirmwareVer)。
|
||||
// 兩鍵原本指向同一個 firmware 字串、會讓 frontend 困惑哪個是 SoT。
|
||||
type deviceWithFirmware struct {
|
||||
driver.DeviceInfo
|
||||
FirmwareDerivedFields
|
||||
}
|
||||
|
||||
// enrichDevices 把 device list 包上 firmware 衍生欄位。fwHandler 為 nil 時
|
||||
// 仍回原始 list(衍生欄位走預設 zero value)、不阻塞 list endpoint。
|
||||
func (h *DeviceHandler) enrichDevices(devices []driver.DeviceInfo) []deviceWithFirmware {
|
||||
out := make([]deviceWithFirmware, 0, len(devices))
|
||||
for _, d := range devices {
|
||||
entry := deviceWithFirmware{DeviceInfo: d}
|
||||
if h.fwHandler != nil {
|
||||
entry.FirmwareDerivedFields = h.fwHandler.DeriveFirmwareFields(d.Type, d.FirmwareVer)
|
||||
} else {
|
||||
// fwHandler 缺省 fallback:衍生欄位用 zero value(前端會看到
|
||||
// canUpgrade=false / isLegacy=false / bundled="unknown"、合理)。
|
||||
// firmware 字串 frontend 直接從 d.FirmwareVer (JSON 鍵 firmwareVersion)
|
||||
// 讀、不在這裡複製。
|
||||
entry.FirmwareDerivedFields = FirmwareDerivedFields{
|
||||
BundledFirmwareVersion: "unknown",
|
||||
}
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *DeviceHandler) ScanDevices(c *gin.Context) {
|
||||
devices := h.deviceMgr.Rescan()
|
||||
resp := gin.H{
|
||||
"devices": devices,
|
||||
// M9-3:附加 firmware 衍生欄位(firmwareVer / firmwareIsLegacy /
|
||||
// firmwareCanUpgrade / bundledFirmwareVersion)讓前端決定是否顯示
|
||||
// 升級按鈕。enrichDevices 在 fwHandler=nil 時仍回有用內容。
|
||||
"devices": h.enrichDevices(devices),
|
||||
}
|
||||
// Linux: 0 裝置 + udev rule 不存在 → 提示使用者安裝 USB 權限
|
||||
if runtime.GOOS == "linux" && len(devices) == 0 && !udevRuleInstalled() {
|
||||
@ -58,7 +106,7 @@ func (h *DeviceHandler) ScanDevices(c *gin.Context) {
|
||||
func (h *DeviceHandler) ListDevices(c *gin.Context) {
|
||||
devices := h.deviceMgr.ListDevices()
|
||||
resp := gin.H{
|
||||
"devices": devices,
|
||||
"devices": h.enrichDevices(devices),
|
||||
}
|
||||
if runtime.GOOS == "linux" && len(devices) == 0 && !udevRuleInstalled() {
|
||||
resp["udevHint"] = true
|
||||
|
||||
465
local-tool/server/internal/api/handlers/firmware_handler.go
Normal file
465
local-tool/server/internal/api/handlers/firmware_handler.go
Normal file
@ -0,0 +1,465 @@
|
||||
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:<deviceID>" — 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/<chip>/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:<id>"
|
||||
// - 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:<deviceID>"、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/<chip>/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/<chip>/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
|
||||
}
|
||||
938
local-tool/server/internal/api/handlers/firmware_handler_test.go
Normal file
938
local-tool/server/internal/api/handlers/firmware_handler_test.go
Normal file
@ -0,0 +1,938 @@
|
||||
package handlers
|
||||
|
||||
// firmware_handler_test.go — M9-3 unit test
|
||||
//
|
||||
// 覆蓋:
|
||||
// 1. POST /api/devices/:id/firmware/upgrade
|
||||
// - 成功路徑(202 + taskID + progress 透過 WS broadcast)
|
||||
// - device not found(404)
|
||||
// - chip 不支援(400 / KL630 / KL730)
|
||||
// - service 拒絕:busy(409)/ unsupported chip(400)/ brick risk(500)/ generic fail(500)
|
||||
// 2. GET /api/firmware/active-tasks
|
||||
// - 空清單(hasActive=false、tasks=[])
|
||||
// - 有 task(hasActive=true、tasks 完整欄位)
|
||||
// 3. DeriveFirmwareFields
|
||||
// - KDP1 legacy → IsLegacy=true / CanUpgrade=true
|
||||
// - KDP2 modern → IsLegacy=false / CanUpgrade=false
|
||||
// - 空 firmware → 保守不 legacy
|
||||
// - chip 不支援(KL630)→ CanUpgrade=false 即使 legacy
|
||||
// - 讀 VERSION 檔成功 / 失敗 fallback "unknown"
|
||||
// 4. WebSocket broadcast schema:{type:"firmware_progress", deviceId, stage, percent, ...}
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"visiona-local/server/internal/device"
|
||||
"visiona-local/server/internal/driver"
|
||||
"visiona-local/server/internal/firmware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Mocks
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// fakeFirmwareService 是 firmwareService interface 的 test stub。
|
||||
type fakeFirmwareService struct {
|
||||
mu sync.Mutex
|
||||
|
||||
// programmed behaviour
|
||||
upgradeErr error
|
||||
upgradeTaskID string
|
||||
upgradeProgress []firmware.FirmwareProgress
|
||||
|
||||
activeTasks []*firmware.ActiveTaskInfo
|
||||
|
||||
// observed
|
||||
upgradeCalls []upgradeCall
|
||||
cleanupCalls []string
|
||||
hasActiveCalls int
|
||||
}
|
||||
|
||||
type upgradeCall struct {
|
||||
deviceID string
|
||||
chip string
|
||||
}
|
||||
|
||||
func (f *fakeFirmwareService) UpgradeFirmware(_ context.Context, deviceID, chip string) (string, <-chan firmware.FirmwareProgress, error) {
|
||||
f.mu.Lock()
|
||||
f.upgradeCalls = append(f.upgradeCalls, upgradeCall{deviceID: deviceID, chip: chip})
|
||||
err := f.upgradeErr
|
||||
taskID := f.upgradeTaskID
|
||||
progress := f.upgradeProgress
|
||||
f.mu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
ch := make(chan firmware.FirmwareProgress, len(progress)+1)
|
||||
for _, ev := range progress {
|
||||
ch <- ev
|
||||
}
|
||||
close(ch)
|
||||
return taskID, ch, nil
|
||||
}
|
||||
|
||||
func (f *fakeFirmwareService) CleanupTask(deviceID string) {
|
||||
f.mu.Lock()
|
||||
f.cleanupCalls = append(f.cleanupCalls, deviceID)
|
||||
f.mu.Unlock()
|
||||
}
|
||||
|
||||
func (f *fakeFirmwareService) HasActiveTask() bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.hasActiveCalls++
|
||||
return len(f.activeTasks) > 0
|
||||
}
|
||||
|
||||
func (f *fakeFirmwareService) GetActiveTaskInfo() []*firmware.ActiveTaskInfo {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.activeTasks
|
||||
}
|
||||
|
||||
// fakeDeviceLookup 模擬 deviceLookupSource。
|
||||
type fakeDeviceLookup struct {
|
||||
sessions map[string]*device.DeviceSession
|
||||
}
|
||||
|
||||
func (f *fakeDeviceLookup) GetDevice(id string) (*device.DeviceSession, error) {
|
||||
s, ok := f.sessions[id]
|
||||
if !ok {
|
||||
return nil, errors.New("device not found: " + id)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// fakeDriver 是 driver.DeviceDriver 的 minimal stub、只填 Info()。
|
||||
type fakeDriver struct {
|
||||
info driver.DeviceInfo
|
||||
}
|
||||
|
||||
func (f *fakeDriver) Info() driver.DeviceInfo { return f.info }
|
||||
func (f *fakeDriver) Connect() error { return nil }
|
||||
func (f *fakeDriver) Disconnect() error { return nil }
|
||||
func (f *fakeDriver) IsConnected() bool { return false }
|
||||
func (f *fakeDriver) Flash(_ string, _ chan<- driver.FlashProgress) error { return nil }
|
||||
func (f *fakeDriver) StartInference() error { return nil }
|
||||
func (f *fakeDriver) StopInference() error { return nil }
|
||||
func (f *fakeDriver) ReadInference() (*driver.InferenceResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeDriver) RunInference(_ []byte) (*driver.InferenceResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeDriver) GetModelInfo() (*driver.ModelInfo, error) { return nil, nil }
|
||||
|
||||
// spyBroadcasterFW 抓所有 BroadcastToRoom 呼叫、供斷言。
|
||||
type spyBroadcasterFW struct {
|
||||
mu sync.Mutex
|
||||
calls []spyBroadcastCall
|
||||
}
|
||||
|
||||
type spyBroadcastCall struct {
|
||||
room string
|
||||
data interface{}
|
||||
}
|
||||
|
||||
func (s *spyBroadcasterFW) BroadcastToRoom(room string, data interface{}) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.calls = append(s.calls, spyBroadcastCall{room: room, data: data})
|
||||
}
|
||||
|
||||
func (s *spyBroadcasterFW) snapshot() []spyBroadcastCall {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]spyBroadcastCall, len(s.calls))
|
||||
copy(out, s.calls)
|
||||
return out
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func newTestFirmwareHandler(t *testing.T) (*FirmwareHandler, *fakeFirmwareService, *fakeDeviceLookup, *spyBroadcasterFW, string) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := &fakeFirmwareService{}
|
||||
lookup := &fakeDeviceLookup{sessions: make(map[string]*device.DeviceSession)}
|
||||
hub := &spyBroadcasterFW{}
|
||||
tmpDir := t.TempDir()
|
||||
h := NewFirmwareHandler(svc, lookup, hub, tmpDir)
|
||||
return h, svc, lookup, hub, tmpDir
|
||||
}
|
||||
|
||||
func addFakeDevice(lookup *fakeDeviceLookup, id, devType, fwVer string) {
|
||||
lookup.sessions[id] = device.NewSession(&fakeDriver{
|
||||
info: driver.DeviceInfo{
|
||||
ID: id,
|
||||
Name: "fake-" + id,
|
||||
Type: devType,
|
||||
FirmwareVer: fwVer,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func writeVersionFile(t *testing.T, root, chip, version string) {
|
||||
t.Helper()
|
||||
chipDir := filepath.Join(root, chip)
|
||||
if err := os.MkdirAll(chipDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", chipDir, err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(chipDir, "VERSION"), []byte(version+"\n"), 0o644); err != nil {
|
||||
t.Fatalf("write VERSION: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// performUpgradeRequest 觸發 POST /api/devices/:id/firmware/upgrade、回 ResponseRecorder。
|
||||
func performUpgradeRequest(h *FirmwareHandler, deviceID string) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: deviceID}}
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/api/devices/"+deviceID+"/firmware/upgrade", nil)
|
||||
h.UpgradeDevice(c)
|
||||
return w
|
||||
}
|
||||
|
||||
func performListActiveTasksRequest(h *FirmwareHandler) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/firmware/active-tasks", nil)
|
||||
h.ListActiveTasks(c)
|
||||
return w
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// 1. POST /api/devices/:id/firmware/upgrade
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestUpgradeDevice_Success(t *testing.T) {
|
||||
h, svc, lookup, hub, _ := newTestFirmwareHandler(t)
|
||||
addFakeDevice(lookup, "dev-1", "kneron_kl520", "KDP")
|
||||
|
||||
svc.upgradeTaskID = "upgrade-dev-1-20260525"
|
||||
svc.upgradeProgress = []firmware.FirmwareProgress{
|
||||
{DeviceID: "dev-1", Stage: firmware.StagePreparing, Percent: 5, Direction: firmware.DirectionUpgrade},
|
||||
{DeviceID: "dev-1", Stage: firmware.StageLoading, Percent: 20, Direction: firmware.DirectionUpgrade},
|
||||
{DeviceID: "dev-1", Stage: firmware.StageDone, Percent: 100, AfterVersion: "v2.2.0", Direction: firmware.DirectionUpgrade},
|
||||
}
|
||||
|
||||
w := performUpgradeRequest(h, "dev-1")
|
||||
|
||||
if w.Code != http.StatusAccepted {
|
||||
t.Fatalf("status = %d, want 202; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if !resp.Success {
|
||||
t.Errorf("success = false, want true")
|
||||
}
|
||||
if got := resp.Data["taskId"]; got != "upgrade-dev-1-20260525" {
|
||||
t.Errorf("taskId = %v, want upgrade-dev-1-20260525", got)
|
||||
}
|
||||
|
||||
// service 收到正確 chip
|
||||
if len(svc.upgradeCalls) != 1 || svc.upgradeCalls[0].chip != firmware.ChipKL520 {
|
||||
t.Errorf("svc.upgradeCalls = %+v, want chip=KL520", svc.upgradeCalls)
|
||||
}
|
||||
|
||||
// 等 broadcast goroutine 跑完
|
||||
waitForBroadcasts(t, hub, 3, 2*time.Second)
|
||||
waitForCleanup(t, svc, "dev-1", 2*time.Second)
|
||||
|
||||
calls := hub.snapshot()
|
||||
if len(calls) != 3 {
|
||||
t.Fatalf("broadcast calls = %d, want 3; %+v", len(calls), calls)
|
||||
}
|
||||
for i, c := range calls {
|
||||
if c.room != "firmware:dev-1" {
|
||||
t.Errorf("call[%d].room = %q, want firmware:dev-1", i, c.room)
|
||||
}
|
||||
// JSON round-trip 驗證 schema
|
||||
raw, _ := json.Marshal(c.data)
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Errorf("call[%d] unmarshal: %v", i, err)
|
||||
continue
|
||||
}
|
||||
if m["type"] != "firmware_progress" {
|
||||
t.Errorf("call[%d].type = %v, want firmware_progress", i, m["type"])
|
||||
}
|
||||
if m["deviceId"] != "dev-1" {
|
||||
t.Errorf("call[%d].deviceId = %v, want dev-1", i, m["deviceId"])
|
||||
}
|
||||
if _, ok := m["stage"]; !ok {
|
||||
t.Errorf("call[%d] missing stage", i)
|
||||
}
|
||||
if _, ok := m["percent"]; !ok {
|
||||
t.Errorf("call[%d] missing percent", i)
|
||||
}
|
||||
}
|
||||
|
||||
// 最終 done event:包含 afterVersion
|
||||
doneRaw, _ := json.Marshal(calls[2].data)
|
||||
var doneMsg map[string]interface{}
|
||||
_ = json.Unmarshal(doneRaw, &doneMsg)
|
||||
if doneMsg["afterVersion"] != "v2.2.0" {
|
||||
t.Errorf("done.afterVersion = %v, want v2.2.0", doneMsg["afterVersion"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeDevice_DeviceNotFound(t *testing.T) {
|
||||
h, _, _, _, _ := newTestFirmwareHandler(t)
|
||||
w := performUpgradeRequest(h, "nope")
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
// Reviewer M9-3 第 1 輪 Minor M-4:用 response JSON 結構 + 錯誤碼斷言、
|
||||
// 不檢查 error message string(避免 device manager error wrapping 改變
|
||||
// 時 test 不必要地破裂)。
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if resp.Success {
|
||||
t.Errorf("success = true, want false")
|
||||
}
|
||||
if resp.Error.Code != "DEVICE_NOT_FOUND" {
|
||||
t.Errorf("error.code = %q, want DEVICE_NOT_FOUND", resp.Error.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeDevice_UnsupportedChip(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
devType string
|
||||
}{
|
||||
{"KL630", "kneron_kl630"},
|
||||
{"KL730", "kneron_kl730"},
|
||||
{"unknown", "kneron_unknown"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h, svc, lookup, _, _ := newTestFirmwareHandler(t)
|
||||
addFakeDevice(lookup, "dev-x", tc.devType, "KDP")
|
||||
|
||||
w := performUpgradeRequest(h, "dev-x")
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
// Reviewer M9-3 第 1 輪 Minor M-4:透過 JSON 結構斷言錯誤碼。
|
||||
var resp struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if resp.Error.Code != "FW_UNSUPPORTED_CHIP" {
|
||||
t.Errorf("error.code = %q, want FW_UNSUPPORTED_CHIP", resp.Error.Code)
|
||||
}
|
||||
// service 不應該被呼叫
|
||||
if len(svc.upgradeCalls) != 0 {
|
||||
t.Errorf("svc.upgradeCalls = %d, want 0 (handler should pre-filter)", len(svc.upgradeCalls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeDevice_ServiceErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
svcErr error
|
||||
wantStatus int
|
||||
wantCode string
|
||||
}{
|
||||
{"busy", firmware.ErrDeviceBusy, http.StatusConflict, "FW_DEVICE_BUSY"},
|
||||
{"unsupported_at_service", firmware.ErrUnsupportedChip, http.StatusBadRequest, "FW_UNSUPPORTED_CHIP"},
|
||||
{"brick_risk", firmware.ErrUpgradeBrickRisk, http.StatusInternalServerError, "FW_UPGRADE_BRICK_RISK"},
|
||||
{"generic_fail", firmware.ErrUpgradeFailed, http.StatusInternalServerError, "FW_UPGRADE_FAILED"},
|
||||
{"unknown", errors.New("boom"), http.StatusInternalServerError, "FW_UPGRADE_FAILED"},
|
||||
{"not_found_at_service", firmware.ErrDeviceNotFound, http.StatusNotFound, "DEVICE_NOT_FOUND"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h, svc, lookup, _, _ := newTestFirmwareHandler(t)
|
||||
addFakeDevice(lookup, "dev-1", "kneron_kl520", "KDP")
|
||||
svc.upgradeErr = tc.svcErr
|
||||
|
||||
w := performUpgradeRequest(h, "dev-1")
|
||||
if w.Code != tc.wantStatus {
|
||||
t.Fatalf("status = %d, want %d; body=%s", w.Code, tc.wantStatus, w.Body.String())
|
||||
}
|
||||
// Reviewer M9-3 第 1 輪 Minor M-4:JSON 結構斷言錯誤碼。
|
||||
var resp struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if resp.Error.Code != tc.wantCode {
|
||||
t.Errorf("error.code = %q, want %q", resp.Error.Code, tc.wantCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// 2. GET /api/firmware/active-tasks
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestListActiveTasks_Empty(t *testing.T) {
|
||||
h, _, _, _, _ := newTestFirmwareHandler(t)
|
||||
w := performListActiveTasksRequest(h)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
HasActive bool `json:"hasActive"`
|
||||
Tasks []firmware.ActiveTaskInfo `json:"tasks"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if !resp.Success {
|
||||
t.Errorf("success = false")
|
||||
}
|
||||
if resp.Data.HasActive {
|
||||
t.Errorf("hasActive = true, want false")
|
||||
}
|
||||
if len(resp.Data.Tasks) != 0 {
|
||||
t.Errorf("tasks = %d, want 0", len(resp.Data.Tasks))
|
||||
}
|
||||
// tasks 不應為 null(前端較難處理)
|
||||
if !strings.Contains(w.Body.String(), `"tasks":[]`) {
|
||||
t.Errorf("tasks 應為 [] 而非 null: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestListActiveTasks_WithTasks(t *testing.T) {
|
||||
h, svc, _, _, _ := newTestFirmwareHandler(t)
|
||||
now := time.Now()
|
||||
svc.activeTasks = []*firmware.ActiveTaskInfo{
|
||||
{
|
||||
TaskID: "upgrade-dev-1",
|
||||
DeviceID: "dev-1",
|
||||
DeviceName: "KL520 #1",
|
||||
Chip: firmware.ChipKL520,
|
||||
Direction: firmware.DirectionUpgrade,
|
||||
Stage: firmware.StageFlashing,
|
||||
StartTs: now,
|
||||
ElapsedMs: 5000,
|
||||
EtaSeconds: 30,
|
||||
},
|
||||
}
|
||||
w := performListActiveTasksRequest(h)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
HasActive bool `json:"hasActive"`
|
||||
Tasks []firmware.ActiveTaskInfo `json:"tasks"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if !resp.Data.HasActive {
|
||||
t.Errorf("hasActive = false, want true")
|
||||
}
|
||||
if len(resp.Data.Tasks) != 1 {
|
||||
t.Fatalf("tasks = %d, want 1", len(resp.Data.Tasks))
|
||||
}
|
||||
got := resp.Data.Tasks[0]
|
||||
if got.DeviceID != "dev-1" {
|
||||
t.Errorf("task.deviceId = %q, want dev-1", got.DeviceID)
|
||||
}
|
||||
if got.Stage != firmware.StageFlashing {
|
||||
t.Errorf("task.stage = %q, want flashing", got.Stage)
|
||||
}
|
||||
if got.EtaSeconds != 30 {
|
||||
t.Errorf("task.etaSeconds = %d, want 30", got.EtaSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// 3. DeriveFirmwareFields
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestDeriveFirmwareFields(t *testing.T) {
|
||||
h, _, _, _, tmpDir := newTestFirmwareHandler(t)
|
||||
writeVersionFile(t, tmpDir, "KL520", "v2.2.0")
|
||||
writeVersionFile(t, tmpDir, "KL720", "v2.2.0")
|
||||
// 注意:不寫 KL630 VERSION → 測試 fallback
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
devType string
|
||||
fwVer string
|
||||
wantLegacy bool
|
||||
wantCanUpgrade bool
|
||||
wantBundled string
|
||||
}{
|
||||
{
|
||||
name: "KL520 KDP1 legacy → can upgrade",
|
||||
devType: "kneron_kl520",
|
||||
fwVer: "KDP",
|
||||
wantLegacy: true,
|
||||
wantCanUpgrade: true,
|
||||
wantBundled: "v2.2.0",
|
||||
},
|
||||
{
|
||||
name: "KL520 KDP1.0 legacy 字串變體 → can upgrade",
|
||||
devType: "kneron_kl520",
|
||||
fwVer: "KDP1.0",
|
||||
wantLegacy: true,
|
||||
wantCanUpgrade: true,
|
||||
wantBundled: "v2.2.0",
|
||||
},
|
||||
{
|
||||
name: "KL520 KDP2 modern → cannot upgrade",
|
||||
devType: "kneron_kl520",
|
||||
fwVer: "KDP2-v2.2.0",
|
||||
wantLegacy: false,
|
||||
wantCanUpgrade: false,
|
||||
wantBundled: "v2.2.0",
|
||||
},
|
||||
{
|
||||
name: "KL520 已是 v2.2.0 → cannot upgrade",
|
||||
devType: "kneron_kl520",
|
||||
fwVer: "v2.2.0",
|
||||
wantLegacy: false,
|
||||
wantCanUpgrade: false,
|
||||
wantBundled: "v2.2.0",
|
||||
},
|
||||
{
|
||||
name: "KL520 firmware 空字串 → 保守不 legacy",
|
||||
devType: "kneron_kl520",
|
||||
fwVer: "",
|
||||
wantLegacy: false,
|
||||
wantCanUpgrade: false,
|
||||
wantBundled: "v2.2.0",
|
||||
},
|
||||
{
|
||||
name: "KL720 KDP1 legacy → can upgrade",
|
||||
devType: "kneron_kl720",
|
||||
fwVer: "KDP",
|
||||
wantLegacy: true,
|
||||
wantCanUpgrade: true,
|
||||
wantBundled: "v2.2.0",
|
||||
},
|
||||
{
|
||||
name: "KL630 即使 KDP1 也 cannot upgrade(A 階段不支援)",
|
||||
devType: "kneron_kl630",
|
||||
fwVer: "KDP",
|
||||
wantLegacy: true,
|
||||
wantCanUpgrade: false,
|
||||
wantBundled: "unknown", // 無 VERSION 檔
|
||||
},
|
||||
{
|
||||
name: "未知 chip type → 保守不可升",
|
||||
devType: "kneron_unknown",
|
||||
fwVer: "KDP",
|
||||
wantLegacy: true,
|
||||
wantCanUpgrade: false,
|
||||
wantBundled: "unknown",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := h.DeriveFirmwareFields(tc.devType, tc.fwVer)
|
||||
// Reviewer M9-3 第 1 輪 Major-1:FirmwareDerivedFields 不再含
|
||||
// firmwareVer 鍵;frontend 直接讀 DeviceInfo.firmwareVersion。
|
||||
// 此處只斷言 3 個衍生欄位。
|
||||
if got.FirmwareIsLegacy != tc.wantLegacy {
|
||||
t.Errorf("IsLegacy = %v, want %v", got.FirmwareIsLegacy, tc.wantLegacy)
|
||||
}
|
||||
if got.FirmwareCanUpgrade != tc.wantCanUpgrade {
|
||||
t.Errorf("CanUpgrade = %v, want %v", got.FirmwareCanUpgrade, tc.wantCanUpgrade)
|
||||
}
|
||||
if got.BundledFirmwareVersion != tc.wantBundled {
|
||||
t.Errorf("BundledFirmwareVersion = %q, want %q", got.BundledFirmwareVersion, tc.wantBundled)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveFirmwareFields_EmptyFirmwareDir(t *testing.T) {
|
||||
// firmwareDir = "" → bundled 永遠回 "unknown"
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := &fakeFirmwareService{}
|
||||
lookup := &fakeDeviceLookup{sessions: make(map[string]*device.DeviceSession)}
|
||||
hub := &spyBroadcasterFW{}
|
||||
h := NewFirmwareHandler(svc, lookup, hub, "")
|
||||
|
||||
got := h.DeriveFirmwareFields("kneron_kl520", "KDP")
|
||||
if got.BundledFirmwareVersion != "unknown" {
|
||||
t.Errorf("BundledFirmwareVersion = %q, want unknown when firmwareDir is empty", got.BundledFirmwareVersion)
|
||||
}
|
||||
if !got.FirmwareIsLegacy {
|
||||
t.Errorf("IsLegacy = false, want true (KDP)")
|
||||
}
|
||||
if !got.FirmwareCanUpgrade {
|
||||
t.Errorf("CanUpgrade = false, want true (chip+legacy)")
|
||||
}
|
||||
// Major-1:FirmwareDerivedFields JSON 不應含 firmwareVer 鍵
|
||||
raw, _ := json.Marshal(got)
|
||||
if strings.Contains(string(raw), `"firmwareVer"`) {
|
||||
t.Errorf("FirmwareDerivedFields JSON 不應含 firmwareVer 鍵;got: %s", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeriveFirmwareFields_NoFirmwareVerKey 驗證 Reviewer M9-3 第 1 輪
|
||||
// Major-1 修正:FirmwareDerivedFields JSON 不再含 firmwareVer 鍵(避免與
|
||||
// driver.DeviceInfo 的 firmwareVersion 鍵衝突)。
|
||||
func TestDeriveFirmwareFields_NoFirmwareVerKey(t *testing.T) {
|
||||
h, _, _, _, tmpDir := newTestFirmwareHandler(t)
|
||||
writeVersionFile(t, tmpDir, "KL520", "v2.2.0")
|
||||
|
||||
got := h.DeriveFirmwareFields("kneron_kl520", "KDP")
|
||||
raw, err := json.Marshal(got)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
body := string(raw)
|
||||
|
||||
// 不應該再含 firmwareVer 鍵
|
||||
if strings.Contains(body, `"firmwareVer"`) {
|
||||
t.Errorf("FirmwareDerivedFields JSON 不應含 firmwareVer 鍵(避免與 DeviceInfo.firmwareVersion 衝突);got: %s", body)
|
||||
}
|
||||
// 但應含 3 個衍生欄位
|
||||
for _, key := range []string{`"firmwareIsLegacy"`, `"firmwareCanUpgrade"`, `"bundledFirmwareVersion"`} {
|
||||
if !strings.Contains(body, key) {
|
||||
t.Errorf("FirmwareDerivedFields JSON 缺少 %s; got: %s", key, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichDevicesJSONOutput 驗證 device list endpoint 的 JSON schema:
|
||||
// 用 enrichDevices 模擬 ListDevices / ScanDevices 的內部組裝(不啟整個 gin
|
||||
// router),grep response JSON 確認:
|
||||
// - 有 "firmwareVersion" 鍵(DeviceInfo embedded、frontend SoT)
|
||||
// - 沒 "firmwareVer" 鍵(避免雙鍵衝突)
|
||||
// - 有 firmwareIsLegacy / firmwareCanUpgrade / bundledFirmwareVersion 衍生欄位
|
||||
//
|
||||
// Reviewer M9-3 第 1 輪 Major-1:必須補這個 schema 測試。
|
||||
func TestEnrichDevicesJSONOutput(t *testing.T) {
|
||||
fwh, _, _, _, tmpDir := newTestFirmwareHandler(t)
|
||||
writeVersionFile(t, tmpDir, "KL520", "v2.2.0")
|
||||
|
||||
dh := NewDeviceHandler(nil, nil, nil, nil)
|
||||
dh.SetFirmwareHandler(fwh)
|
||||
|
||||
devices := []driver.DeviceInfo{
|
||||
{
|
||||
ID: "dev-1",
|
||||
Name: "KL520 #1",
|
||||
Type: "kneron_kl520",
|
||||
FirmwareVer: "KDP", // legacy
|
||||
Status: driver.StatusDetected,
|
||||
},
|
||||
}
|
||||
enriched := dh.enrichDevices(devices)
|
||||
if len(enriched) != 1 {
|
||||
t.Fatalf("enrichDevices returned %d entries, want 1", len(enriched))
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(enriched[0])
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
body := string(raw)
|
||||
|
||||
// 1. 必須有 firmwareVersion 鍵(來自 DeviceInfo.FirmwareVer json tag)
|
||||
if !strings.Contains(body, `"firmwareVersion":"KDP"`) {
|
||||
t.Errorf("缺少 firmwareVersion 鍵; got: %s", body)
|
||||
}
|
||||
// 2. 絕對不應有 firmwareVer 鍵(避免雙鍵衝突)
|
||||
if strings.Contains(body, `"firmwareVer"`) {
|
||||
t.Errorf("device JSON 不應含 firmwareVer 鍵(與 firmwareVersion 衝突); got: %s", body)
|
||||
}
|
||||
// 3. 衍生欄位齊全
|
||||
for _, key := range []string{`"firmwareIsLegacy"`, `"firmwareCanUpgrade"`, `"bundledFirmwareVersion"`} {
|
||||
if !strings.Contains(body, key) {
|
||||
t.Errorf("device JSON 缺少 %s; got: %s", key, body)
|
||||
}
|
||||
}
|
||||
// 4. 既有 DeviceInfo 欄位也應該還在(不破壞既有 frontend client)
|
||||
for _, key := range []string{`"id":"dev-1"`, `"name":"KL520 #1"`, `"type":"kneron_kl520"`, `"status":"detected"`} {
|
||||
if !strings.Contains(body, key) {
|
||||
t.Errorf("device JSON 缺少 DeviceInfo 既有欄位 %s; got: %s", key, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichDevicesJSONOutput_NilFwHandler 驗證 fallback 路徑:fwHandler=nil
|
||||
// 時、JSON 也不應含 firmwareVer 鍵、firmwareVersion 應從 DeviceInfo 帶上。
|
||||
func TestEnrichDevicesJSONOutput_NilFwHandler(t *testing.T) {
|
||||
dh := NewDeviceHandler(nil, nil, nil, nil)
|
||||
// 刻意不 SetFirmwareHandler → fwHandler 為 nil
|
||||
|
||||
devices := []driver.DeviceInfo{
|
||||
{
|
||||
ID: "dev-1",
|
||||
Type: "kneron_kl520",
|
||||
FirmwareVer: "KDP",
|
||||
Status: driver.StatusDetected,
|
||||
},
|
||||
}
|
||||
enriched := dh.enrichDevices(devices)
|
||||
raw, _ := json.Marshal(enriched[0])
|
||||
body := string(raw)
|
||||
|
||||
if !strings.Contains(body, `"firmwareVersion":"KDP"`) {
|
||||
t.Errorf("nil fwHandler 時 firmwareVersion 應從 DeviceInfo 帶上; got: %s", body)
|
||||
}
|
||||
if strings.Contains(body, `"firmwareVer"`) {
|
||||
t.Errorf("nil fwHandler 時不應含 firmwareVer 鍵; got: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"bundledFirmwareVersion":"unknown"`) {
|
||||
t.Errorf("nil fwHandler 時 bundled 應為 unknown; got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsLegacyFirmware_BridgeParity 驗證 Go isLegacyFirmware 與 bridge.py
|
||||
// _fw_classify_legacy 對同樣 firmware 字串給出一致結果(Reviewer M9-3
|
||||
// 第 1 輪 S-1:跨端 drift 防護)。
|
||||
//
|
||||
// 真值表來源:kneron_bridge.py line 1463-1508 `_fw_classify_legacy`。
|
||||
// 注意:bridge.py 把空字串 ""(USB Boot state 不回 firmware)視為 legacy、
|
||||
// 但 Go list endpoint 看到空字串通常是 device 還沒 connect 過 — 保守當
|
||||
// 非 legacy(不顯示升級按鈕);此差異已在 isLegacyFirmware godoc 註記。
|
||||
func TestIsLegacyFirmware_BridgeParity(t *testing.T) {
|
||||
// product_id=0x0200 那條規則 Go 端不檢查(KL720 KDP legacy 由 chip
|
||||
// type + upgrade 流程處理、不在 list endpoint canUpgrade 邏輯內)。
|
||||
cases := []struct {
|
||||
fw string
|
||||
wantLegacy bool
|
||||
note string
|
||||
}{
|
||||
// 已知 KDP1 legacy 字串
|
||||
{"KDP", true, "bridge.py legacy_exact"},
|
||||
{"KDP1", true, "bridge.py legacy_exact"},
|
||||
{"USB BOOT", true, "bridge.py legacy_exact"},
|
||||
{"USB Boot", true, "case insensitive"},
|
||||
{"USB BOOT LOADER", true, "bridge.py legacy_exact"},
|
||||
{"LOADER", true, "bridge.py legacy_exact"},
|
||||
{"BOOTLOADER", true, "bridge.py legacy_exact"},
|
||||
// KDP1.x 變體
|
||||
{"KDP1.0", true, "bridge.py KDP1. prefix"},
|
||||
{"KDP1.5", true, "bridge.py KDP1. prefix"},
|
||||
{"KDP1 alpha", true, "bridge.py KDP1<space> prefix"},
|
||||
// KDP2-KDP9 明示放行
|
||||
{"KDP2", false, "bridge.py KDP2 prefix → modern"},
|
||||
{"KDP2.0", false, "bridge.py KDP2 prefix"},
|
||||
{"KDP2-v2.2.0", false, "bridge.py KDP2 prefix"},
|
||||
{"KDP3", false, "bridge.py KDP3 prefix → forward-compat"},
|
||||
{"KDP3.1", false, "bridge.py KDP3 prefix → forward-compat"},
|
||||
{"KDP9", false, "bridge.py KDP9 prefix → forward-compat"},
|
||||
// 未知字串 → 保守不 legacy(bridge.py default)
|
||||
{"NEF", false, "bridge.py default for unknown"},
|
||||
{"K3", false, "bridge.py default for unknown"},
|
||||
{"v2.2.0", false, "version string only → not legacy"},
|
||||
// Go 端對空字串的特例(保守、與 bridge.py 在 list endpoint 場景一致)
|
||||
{"", false, "Go list endpoint conservative (not connected yet)"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.fw+"_"+tc.note, func(t *testing.T) {
|
||||
got := isLegacyFirmware(tc.fw)
|
||||
if got != tc.wantLegacy {
|
||||
t.Errorf("isLegacyFirmware(%q) = %v, want %v (%s)",
|
||||
tc.fw, got, tc.wantLegacy, tc.note)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBundledVersionFor_CacheMissDoesNotPoisonRetry 驗證 Reviewer M9-3
|
||||
// 第 1 輪 Minor M-3:cache miss 後不寫入 cache、下次 list device 會重試
|
||||
// 並讀到新版本(情境:CI first build 時 firmware 檔還沒 ready)。
|
||||
func TestBundledVersionFor_CacheMissDoesNotPoisonRetry(t *testing.T) {
|
||||
h, _, _, _, tmpDir := newTestFirmwareHandler(t)
|
||||
|
||||
// 1. VERSION 檔還沒 ready → 應回 "unknown"
|
||||
got1 := h.bundledVersionFor("KL520")
|
||||
if got1 != "unknown" {
|
||||
t.Fatalf("first call (no VERSION file) = %q, want unknown", got1)
|
||||
}
|
||||
|
||||
// 2. 確認沒有 cache 失敗結果(否則下次重試會永遠回 unknown)
|
||||
h.bundledMu.RLock()
|
||||
_, cached := h.bundledVersions["KL520"]
|
||||
h.bundledMu.RUnlock()
|
||||
if cached {
|
||||
t.Errorf("失敗結果不應寫入 cache(M-3);cache 含 KL520 entry")
|
||||
}
|
||||
|
||||
// 3. 模擬 CI 後續產出 VERSION 檔
|
||||
writeVersionFile(t, tmpDir, "KL520", "v2.2.0")
|
||||
|
||||
// 4. 第二次呼叫應該讀到實際版本(重試成功)
|
||||
got2 := h.bundledVersionFor("KL520")
|
||||
if got2 != "v2.2.0" {
|
||||
t.Errorf("retry after VERSION ready = %q, want v2.2.0", got2)
|
||||
}
|
||||
|
||||
// 5. 第三次應從 cache 命中(success 才 cache)
|
||||
h.bundledMu.RLock()
|
||||
cachedVal := h.bundledVersions["KL520"]
|
||||
h.bundledMu.RUnlock()
|
||||
if cachedVal != "v2.2.0" {
|
||||
t.Errorf("success 應 cache;cache value = %q", cachedVal)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBundledVersionFor_EmptyFileNotCached 驗證空 VERSION 檔(不合理狀態)
|
||||
// 也不應該污染 cache。
|
||||
func TestBundledVersionFor_EmptyFileNotCached(t *testing.T) {
|
||||
h, _, _, _, tmpDir := newTestFirmwareHandler(t)
|
||||
|
||||
// 寫空 VERSION 檔
|
||||
chipDir := filepath.Join(tmpDir, "KL520")
|
||||
if err := os.MkdirAll(chipDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(chipDir, "VERSION"), []byte(""), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := h.bundledVersionFor("KL520")
|
||||
if got != "unknown" {
|
||||
t.Errorf("empty file = %q, want unknown", got)
|
||||
}
|
||||
h.bundledMu.RLock()
|
||||
_, cached := h.bundledVersions["KL520"]
|
||||
h.bundledMu.RUnlock()
|
||||
if cached {
|
||||
t.Errorf("空檔不應寫入 cache(避免下次重試永遠回 unknown)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestForwardGoroutine_ExitsOnChannelClose 驗證 Reviewer M9-3 第 1 輪 S-5:
|
||||
// service 端 close progressCh 後、forward goroutine 必須退出(不能 leak)。
|
||||
//
|
||||
// 透過觀察 svc.CleanupTask 被呼叫 = forwardProgressToWS 從 for-range 退出
|
||||
// 並執行了 deferred-like cleanup(程式碼是在 for 結束後同步呼叫、非 defer)。
|
||||
func TestForwardGoroutine_ExitsOnChannelClose(t *testing.T) {
|
||||
h, svc, lookup, hub, _ := newTestFirmwareHandler(t)
|
||||
addFakeDevice(lookup, "dev-leak", "kneron_kl520", "KDP")
|
||||
|
||||
svc.upgradeTaskID = "tk-leak"
|
||||
svc.upgradeProgress = []firmware.FirmwareProgress{
|
||||
{DeviceID: "dev-leak", Stage: firmware.StagePreparing, Percent: 1},
|
||||
{DeviceID: "dev-leak", Stage: firmware.StageDone, Percent: 100},
|
||||
}
|
||||
|
||||
w := performUpgradeRequest(h, "dev-leak")
|
||||
if w.Code != http.StatusAccepted {
|
||||
t.Fatalf("status = %d, want 202", w.Code)
|
||||
}
|
||||
|
||||
// 等 broadcast + cleanup 都完成 = goroutine 已退出
|
||||
waitForBroadcasts(t, hub, 2, 2*time.Second)
|
||||
waitForCleanup(t, svc, "dev-leak", 2*time.Second)
|
||||
|
||||
// 直接斷言:cleanupCalls 含 dev-leak 且只一次(goroutine 退出後不會再
|
||||
// 重複呼)
|
||||
svc.mu.Lock()
|
||||
count := 0
|
||||
for _, id := range svc.cleanupCalls {
|
||||
if id == "dev-leak" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
svc.mu.Unlock()
|
||||
if count != 1 {
|
||||
t.Errorf("CleanupTask(dev-leak) 呼叫 %d 次,want 1(goroutine 該乾淨退出一次)", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChipFromDeviceType(t *testing.T) {
|
||||
cases := []struct {
|
||||
devType string
|
||||
want string
|
||||
}{
|
||||
{"kneron_kl520", "KL520"},
|
||||
{"kneron_kl720", "KL720"},
|
||||
{"kneron_kl630", "KL630"},
|
||||
{"kneron_kl730", "KL730"},
|
||||
{"Kneron_KL520", "KL520"}, // case insensitive
|
||||
{"unknown", ""},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.devType, func(t *testing.T) {
|
||||
got := ChipFromDeviceType(tc.devType)
|
||||
if got != tc.want {
|
||||
t.Errorf("ChipFromDeviceType(%q) = %q, want %q", tc.devType, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// helpers:等 goroutine 完成
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func waitForBroadcasts(t *testing.T, hub *spyBroadcasterFW, n int, timeout time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if len(hub.snapshot()) >= n {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timeout waiting for %d broadcasts, got %d", n, len(hub.snapshot()))
|
||||
}
|
||||
|
||||
func waitForCleanup(t *testing.T, svc *fakeFirmwareService, deviceID string, timeout time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
svc.mu.Lock()
|
||||
for _, id := range svc.cleanupCalls {
|
||||
if id == deviceID {
|
||||
svc.mu.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
svc.mu.Unlock()
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timeout waiting for CleanupTask(%q)", deviceID)
|
||||
}
|
||||
@ -11,6 +11,7 @@ import (
|
||||
"visiona-local/server/internal/api/ws"
|
||||
"visiona-local/server/internal/camera"
|
||||
"visiona-local/server/internal/device"
|
||||
"visiona-local/server/internal/firmware"
|
||||
"visiona-local/server/internal/flash"
|
||||
"visiona-local/server/internal/inference"
|
||||
"visiona-local/server/internal/model"
|
||||
@ -26,6 +27,8 @@ func NewRouter(
|
||||
cameraMgr *camera.Manager,
|
||||
flashSvc *flash.Service,
|
||||
inferenceSvc *inference.Service,
|
||||
firmwareSvc *firmware.Service, // M9-3:firmware 升降版 service
|
||||
firmwareDir string, // M9-3:bundled firmware/<chip>/VERSION 根目錄
|
||||
wsHub *ws.Hub,
|
||||
staticFS http.FileSystem,
|
||||
logBroadcaster *logger.Broadcaster,
|
||||
@ -49,6 +52,19 @@ func NewRouter(
|
||||
deviceHandler := handlers.NewDeviceHandler(deviceMgr, flashSvc, inferenceSvc, wsHub)
|
||||
cameraHandler := handlers.NewCameraHandler(cameraMgr, deviceMgr, inferenceSvc, wsHub)
|
||||
|
||||
// M9-3:firmware handler。firmwareSvc 可為 nil(test / 未來 disable)、
|
||||
// 此時 firmware endpoint 不註冊、device handler 仍用 fwHandler=nil fallback。
|
||||
var firmwareHandler *handlers.FirmwareHandler
|
||||
if firmwareSvc != nil {
|
||||
firmwareHandler = handlers.NewFirmwareHandler(
|
||||
firmwareSvc,
|
||||
deviceMgr,
|
||||
wsHub,
|
||||
firmwareDir,
|
||||
)
|
||||
deviceHandler.SetFirmwareHandler(firmwareHandler)
|
||||
}
|
||||
|
||||
api := r.Group("/api")
|
||||
{
|
||||
// System
|
||||
@ -80,6 +96,14 @@ func NewRouter(
|
||||
api.POST("/devices/:id/inference/start", deviceHandler.StartInference)
|
||||
api.POST("/devices/:id/inference/stop", deviceHandler.StopInference)
|
||||
|
||||
// Firmware (M9-3、A 階段)
|
||||
// upgrade endpoint 走 202 + WebSocket room "firmware:<id>" 推進度。
|
||||
// active-tasks 給 Wails control panel graceful shutdown 偵測用。
|
||||
if firmwareHandler != nil {
|
||||
api.POST("/devices/:id/firmware/upgrade", firmwareHandler.UpgradeDevice)
|
||||
api.GET("/firmware/active-tasks", firmwareHandler.ListActiveTasks)
|
||||
}
|
||||
|
||||
// Camera
|
||||
api.GET("/camera/list", cameraHandler.ListCameras)
|
||||
api.POST("/camera/start", cameraHandler.StartPipeline)
|
||||
|
||||
@ -23,6 +23,7 @@ import (
|
||||
"visiona-local/server/internal/config"
|
||||
"visiona-local/server/internal/deps"
|
||||
"visiona-local/server/internal/device"
|
||||
"visiona-local/server/internal/firmware"
|
||||
"visiona-local/server/internal/flash"
|
||||
"visiona-local/server/internal/inference"
|
||||
"visiona-local/server/internal/model"
|
||||
@ -249,6 +250,16 @@ func main() {
|
||||
flashSvc := flash.NewService(deviceMgr, modelRepo, builtInDataDir)
|
||||
inferenceSvc := inference.NewService(deviceMgr)
|
||||
|
||||
// M9-3:firmware service(升降版 orchestrator)。
|
||||
// firmwareDir 解析:bridge script 同一個 scripts/ 目錄下的 firmware/ 子目錄。
|
||||
// 例:scripts/kneron_bridge.py → scripts/firmware/<chip>/{fw_*.bin, VERSION}
|
||||
firmwareDir := filepath.Join(filepath.Dir(bridgeScript), "firmware")
|
||||
logger.Info("Firmware bundle dir: %s", firmwareDir)
|
||||
firmwareSvc := firmware.NewService(
|
||||
handlers.NewDeviceManagerAdapter(deviceMgr),
|
||||
firmware.FirmwareDir{Root: firmwareDir},
|
||||
)
|
||||
|
||||
// Determine static file system for embedded frontend
|
||||
var staticFS http.FileSystem
|
||||
if !cfg.DevMode {
|
||||
@ -295,7 +306,7 @@ func main() {
|
||||
systemHandler := handlers.NewSystemHandler(Version, BuildTime, pythonBinForSystem, restartFn, wsHub)
|
||||
|
||||
// Create router
|
||||
r := api.NewRouter(modelRepo, modelStore, deviceMgr, cameraMgr, flashSvc, inferenceSvc, wsHub, staticFS, logBroadcaster, systemHandler)
|
||||
r := api.NewRouter(modelRepo, modelStore, deviceMgr, cameraMgr, flashSvc, inferenceSvc, firmwareSvc, firmwareDir, wsHub, staticFS, logBroadcaster, systemHandler)
|
||||
|
||||
// Configure HTTP server (bind to localhost only)
|
||||
addr := cfg.Addr()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user