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 衍生欄位 helper(M9-3);可為 nil(test / 環境 // 無 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 檔案(` <名稱>`) // // 刻意不做任何持久化:使用者明確要求「不用記,每次現場傳」。設定只存在於 // 當前 bridge session,disconnect / 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}) }