jim800121chen 4583406efa feat(api): 推論期切換解析方式與上傳 label 檔
新增 POST /api/devices/:id/inference/options,讓已燒錄的模型不重燒
即可切換 detection/classification 與更換 label。

KL520 一次只能載一個模型(換模型必須重燒),但解析方式與 label 都
只是後處理與顯示層,不碰 NPU,因此可即時生效。

- JSON: {taskType?, labels?};multipart: taskType + labelFile
- 欄位用指標型別以區分「省略 = 不動」與「labels:[] = 清空」
- label 解析器(internal/labelfile):格式 <index> <名稱>
  略過空行與 # 註解、trim CRLF、名稱含空白只 split 第一個空白、
  index 非整數/負數/重複則整檔拒絕並回報行號、稀疏 index 補空字串
- index 上限 4095、檔案上限 256KB,防記憶體耗盡
- 非法 taskType 一律拒絕(含舊別名 detection),因為 bridge 對無法
  辨識的值是靜默 fallback,忽略會回 200 但用錯解析方式
- 走窄能力介面 InferenceOptionsDriver,不動 DeviceDriver 簽章

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:27:16 +08:00

536 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handlers
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"visiona-local/server/internal/api/ws"
"visiona-local/server/internal/device"
"visiona-local/server/internal/driver"
"visiona-local/server/internal/flash"
"visiona-local/server/internal/inference"
"visiona-local/server/internal/labelfile"
"github.com/gin-gonic/gin"
)
// udevRuleInstalled checks if the Kneron udev rule is installed on Linux.
func udevRuleInstalled() bool {
_, err := os.Stat("/etc/udev/rules.d/99-kneron.rules")
return err == nil
}
type DeviceHandler struct {
deviceMgr *device.Manager
flashSvc *flash.Service
inferenceSvc *inference.Service
wsHub *ws.Hub
// fwHandler 提供 firmware 衍生欄位 helperM9-3可為 niltest / 環境
// 無 firmware bundle 時)、此時 4 個 firmware 衍生欄位用 fallback 空值。
fwHandler *FirmwareHandler
}
func NewDeviceHandler(
deviceMgr *device.Manager,
flashSvc *flash.Service,
inferenceSvc *inference.Service,
wsHub *ws.Hub,
) *DeviceHandler {
return &DeviceHandler{
deviceMgr: deviceMgr,
flashSvc: flashSvc,
inferenceSvc: inferenceSvc,
wsHub: wsHub,
}
}
// 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{
// 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() {
resp["udevHint"] = true
}
c.JSON(200, gin.H{"success": true, "data": resp})
}
func (h *DeviceHandler) ListDevices(c *gin.Context) {
devices := h.deviceMgr.ListDevices()
resp := gin.H{
"devices": h.enrichDevices(devices),
}
if runtime.GOOS == "linux" && len(devices) == 0 && !udevRuleInstalled() {
resp["udevHint"] = true
}
c.JSON(200, gin.H{"success": true, "data": resp})
}
func (h *DeviceHandler) GetDevice(c *gin.Context) {
id := c.Param("id")
session, err := h.deviceMgr.GetDevice(id)
if err != nil {
c.JSON(404, gin.H{
"success": false,
"error": gin.H{"code": "DEVICE_NOT_FOUND", "message": err.Error()},
})
return
}
c.JSON(200, gin.H{"success": true, "data": session.Driver.Info()})
}
func (h *DeviceHandler) ConnectDevice(c *gin.Context) {
id := c.Param("id")
// KL520 USB Boot flow now includes mandatory reset + firmware reload on
// first connect (required for inference to work — see kl720_driver.go
// needsReset block). Worst-case path on Windows: Loader-mode reconnect
// retry (16s) + firmware load (~31s) + reboot wait + second reconnect
// (~13s) = ~60-65s. Use 120s to leave headroom and avoid spurious 504s.
ctx, cancel := context.WithTimeout(c.Request.Context(), 120*time.Second)
defer cancel()
errCh := make(chan error, 1)
go func() {
errCh <- h.deviceMgr.Connect(id)
}()
select {
case err := <-errCh:
if err != nil {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "CONNECT_FAILED", "message": err.Error()},
})
return
}
c.JSON(200, gin.H{"success": true})
case <-ctx.Done():
c.JSON(504, gin.H{
"success": false,
"error": gin.H{"code": "CONNECT_TIMEOUT", "message": fmt.Sprintf("device connect timed out after 60s for %s", id)},
})
}
}
func (h *DeviceHandler) DisconnectDevice(c *gin.Context) {
id := c.Param("id")
if err := h.deviceMgr.Disconnect(id); err != nil {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "DISCONNECT_FAILED", "message": err.Error()},
})
return
}
c.JSON(200, gin.H{"success": true})
}
func (h *DeviceHandler) FlashDevice(c *gin.Context) {
id := c.Param("id")
// 燒錄只需要 modelId。推論種類一律用 models.json 宣告的值;要改解析方式
// 走 POST /devices/:id/inference/options推論期即時切換、不必重燒
var req struct {
ModelID string `json:"modelId"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "BAD_REQUEST", "message": "modelId is required"},
})
return
}
taskID, progressCh, err := h.flashSvc.StartFlash(id, req.ModelID)
if err != nil {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "FLASH_FAILED", "message": err.Error()},
})
return
}
// Forward progress to WebSocket, then cleanup task (M2 fix)
go func() {
room := "flash:" + id
for progress := range progressCh {
h.wsHub.BroadcastToRoom(room, progress)
}
h.flashSvc.CleanupTask(taskID)
}()
c.JSON(200, gin.H{"success": true, "data": gin.H{"taskId": taskID}})
}
// InferenceOptionsDriver 是「支援推論期切換解析方式」的 driver 能力介面。
//
// 為什麼不直接加進 driver.DeviceDriver那是所有 driver 都必須實作的最小
// 契約,加一個 Kneron 特有能力進去,三個既有 test fake 全都要跟著改,而它們
// 跟這個功能完全無關。用窄介面 + type assert 是這個 repo 已建立的做法
// (見 firmware.UpgradeDriver / DeviceManagerAdapter.GetUpgradeDriver
type InferenceOptionsDriver interface {
SetInferenceOptions(opts driver.InferenceOptions) error
}
// inferenceOptionsRequest 是 JSON 形式的 request body。
//
// 兩個欄位都是指標,因為必須區分「沒帶這個欄位」與「帶了空值」:
//
// Labels == nil → 不動 label 表
// Labels == &[]string{} → 清空 label 表(回到原始 enum
//
// 用非指標 []string 的話 JSON 的 `null`、`[]` 與「欄位不存在」會全部塌成
// nil「清空」這個合法意圖就永遠表達不出來。
type inferenceOptionsRequest struct {
TaskType *string `json:"taskType"`
Labels *[]string `json:"labels"`
}
// SetInferenceOptions 在不重新燒錄的前提下,更新當前已載入模型的解析方式
// 與 label 表。
//
// POST /api/devices/:id/inference/options
//
// 支援兩種 content type
//
// application/json — {"taskType": "...", "labels": [...]}
// multipart/form-data — taskType 欄位 + labelFile 檔案(`<index> <名稱>`
//
// 刻意不做任何持久化:使用者明確要求「不用記,每次現場傳」。設定只存在於
// 當前 bridge sessiondisconnect / reset / 重新 flash 都會清掉。
func (h *DeviceHandler) SetInferenceOptions(c *gin.Context) {
id := c.Param("id")
session, err := h.deviceMgr.GetDevice(id)
if err != nil {
c.JSON(404, gin.H{
"success": false,
"error": gin.H{"code": "DEVICE_NOT_FOUND", "message": err.Error()},
})
return
}
optsDrv, ok := session.Driver.(InferenceOptionsDriver)
if !ok {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{
"code": "UNSUPPORTED_DEVICE",
"message": "this device driver does not support runtime inference options",
},
})
return
}
opts, labelInfo, apiErr := parseInferenceOptionsRequest(c)
if apiErr != nil {
c.JSON(apiErr.status, gin.H{"success": false, "error": apiErr.body()})
return
}
if err := optsDrv.SetInferenceOptions(opts); err != nil {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "INFERENCE_OPTIONS_FAILED", "message": err.Error()},
})
return
}
data := gin.H{
"deviceId": id,
"taskType": opts.TaskType,
}
if opts.Labels != nil {
data["labelCount"] = labelInfo.namedCount
data["labels"] = opts.Labels
if len(opts.Labels) > 0 {
data["maxIndex"] = len(opts.Labels) - 1
}
}
c.JSON(200, gin.H{"success": true, "data": data})
}
// apiError 讓 parse 階段能同時回「HTTP status + 錯誤碼 + 可選的行號」。
type apiError struct {
status int
code string
message string
// line 為 label 檔解析失敗的行號0 表示與行號無關、不放進回應。
line int
}
func (e *apiError) body() gin.H {
h := gin.H{"code": e.code, "message": e.message}
if e.line > 0 {
h["line"] = e.line
}
return h
}
// labelSummary 帶回 handler 要回報給前端的 label 統計。
type labelSummary struct {
// namedCount 是實際有名稱的筆數(不含稀疏補洞的空字串)。
namedCount int
}
// parseInferenceOptionsRequest 從 JSON 或 multipart 取出設定並完整驗證。
func parseInferenceOptionsRequest(c *gin.Context) (driver.InferenceOptions, labelSummary, *apiError) {
var opts driver.InferenceOptions
var summary labelSummary
contentType := c.ContentType()
if strings.HasPrefix(contentType, "multipart/form-data") {
var err *apiError
opts, summary, err = parseMultipartInferenceOptions(c)
if err != nil {
return opts, summary, err
}
} else {
var req inferenceOptionsRequest
if bindErr := c.ShouldBindJSON(&req); bindErr != nil {
return opts, summary, &apiError{
status: 400,
code: "BAD_REQUEST",
message: "invalid JSON body: " + bindErr.Error(),
}
}
if req.TaskType != nil {
opts.TaskType = *req.TaskType
}
if req.Labels != nil {
// 顯式給了 labels含空陣列→ 一律送出。空陣列 = 清空,
// 必須與「沒帶欄位」區分開。
labels := *req.Labels
if labels == nil {
labels = []string{}
}
if len(labels) > labelfile.MaxIndex+1 {
return opts, summary, &apiError{
status: 400,
code: "LABEL_TOO_LARGE",
message: fmt.Sprintf("labels 筆數 %d 超過上限 %d", len(labels), labelfile.MaxIndex+1),
}
}
opts.Labels = labels
summary.namedCount = countNamedLabels(labels)
}
}
// taskType 值域用 flash.IsValidTaskTypeOverride —— 這裡是目前唯一讓使用者
// 指定解析方式的入口(燒錄時不再選,一律用 models.json 宣告值)。舊別名
// detection 一樣拒絕 —— bridge 收得下,但不讓兩套命名同時出現在 wire 上R-4
if opts.TaskType != "" && !flash.IsValidTaskTypeOverride(opts.TaskType) {
return opts, summary, &apiError{
status: 400,
code: "VALIDATION_ERROR",
message: fmt.Sprintf("invalid taskType %q: must be %s or %s",
opts.TaskType, flash.TaskTypeObjectDetection, flash.TaskTypeClassification),
}
}
// 兩者都沒帶 = 呼叫端沒表達任何意圖。回 200 等於假裝做了事,正是這個
// 功能要防的靜默失敗,所以擋在這裡。
if opts.TaskType == "" && opts.Labels == nil {
return opts, summary, &apiError{
status: 400,
code: "BAD_REQUEST",
message: "至少要提供 taskType 或 labels 其中一項",
}
}
return opts, summary, nil
}
// parseMultipartInferenceOptions 處理 multipart 上傳taskType 欄位 + labelFile 檔案)。
func parseMultipartInferenceOptions(c *gin.Context) (driver.InferenceOptions, labelSummary, *apiError) {
var opts driver.InferenceOptions
var summary labelSummary
// 限制 multipart 在記憶體中的暫存量;超過的部分 gin 會落地成暫存檔,
// 但真正的防線是下方的 header.Size 檢查。
if err := c.Request.ParseMultipartForm(labelfile.MaxFileSize); err != nil {
return opts, summary, &apiError{
status: 400,
code: "BAD_REQUEST",
message: "invalid multipart form: " + err.Error(),
}
}
opts.TaskType = c.PostForm("taskType")
file, header, err := c.Request.FormFile("labelFile")
if err != nil {
// 沒有檔案是合法的(只切 taskType。其他錯誤才算壞請求。
if errors.Is(err, http.ErrMissingFile) {
return opts, summary, nil
}
return opts, summary, &apiError{
status: 400,
code: "BAD_REQUEST",
message: "failed to read labelFile: " + err.Error(),
}
}
defer file.Close()
// 大小上限在讀取「之前」擋,不能讀完再判斷 —— 那時記憶體已經吃掉了。
if header.Size > labelfile.MaxFileSize {
return opts, summary, &apiError{
status: 400,
code: "LABEL_TOO_LARGE",
message: fmt.Sprintf("標籤檔過大(%d bytes上限為 %d bytes",
header.Size, labelfile.MaxFileSize),
}
}
// 副檔名檢查純粹防呆(真正的防線是內容解析)。上傳檔名只用來看副檔名,
// 不參與任何路徑組合 —— 本 endpoint 不落地存檔,沒有路徑穿越面。
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext != "" && ext != ".txt" && ext != ".names" {
return opts, summary, &apiError{
status: 400,
code: "BAD_REQUEST",
message: "標籤檔僅支援 .txt / .names",
}
}
// LimitReader 是 header.Size 之外的第二道防線Content-Length 可以造假,
// 實際串流長度才是真的。多讀 1 byte 用來偵測「宣稱小、其實大」。
data, readErr := io.ReadAll(io.LimitReader(file, labelfile.MaxFileSize+1))
if readErr != nil {
return opts, summary, &apiError{
status: 400,
code: "BAD_REQUEST",
message: "failed to read labelFile: " + readErr.Error(),
}
}
if len(data) > labelfile.MaxFileSize {
return opts, summary, &apiError{
status: 400,
code: "LABEL_TOO_LARGE",
message: fmt.Sprintf("標籤檔過大,上限為 %d bytes", labelfile.MaxFileSize),
}
}
result, parseErr := labelfile.Parse(data)
if parseErr != nil {
var pe *labelfile.ParseError
if errors.As(parseErr, &pe) {
return opts, summary, &apiError{
status: 400,
code: "LABEL_PARSE_ERROR",
message: pe.Error(),
line: pe.Line,
}
}
return opts, summary, &apiError{
status: 400,
code: "LABEL_PARSE_ERROR",
message: parseErr.Error(),
}
}
opts.Labels = result.Labels
summary.namedCount = result.LabelCount
return opts, summary, nil
}
// countNamedLabels 算出實際有名稱的筆數(稀疏補洞的空字串不計)。
func countNamedLabels(labels []string) int {
n := 0
for _, l := range labels {
if strings.TrimSpace(l) != "" {
n++
}
}
return n
}
func (h *DeviceHandler) StartInference(c *gin.Context) {
id := c.Param("id")
resultCh := make(chan *driver.InferenceResult, 10)
if err := h.inferenceSvc.Start(id, resultCh); err != nil {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "INFERENCE_ERROR", "message": err.Error()},
})
return
}
// Forward results to WebSocket, enriching with device ID
go func() {
room := "inference:" + id
for result := range resultCh {
result.DeviceID = id
h.wsHub.BroadcastToRoom(room, result)
}
}()
c.JSON(200, gin.H{"success": true})
}
func (h *DeviceHandler) StopInference(c *gin.Context) {
id := c.Param("id")
if err := h.inferenceSvc.Stop(id); err != nil {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{"code": "INFERENCE_ERROR", "message": err.Error()},
})
return
}
c.JSON(200, gin.H{"success": true})
}