jim800121chen 44711753ae feat(local-tool): 推論功能完整搬入 — flash 模組 + workspace 推論介面
## 後端(Phase 1)
新增 flash 模組(從 edge-ai-platform 搬入):
- server/internal/flash/service.go:StartFlash + 模型相容性檢查 + 晶片 NEF 解析
- server/internal/flash/progress.go:Flash 進度追蹤器
- server/internal/api/ws/flash_ws.go:WebSocket 推送 flash 進度
- device_handler.go:新增 FlashDevice method + flashSvc 欄位
- router.go:新增 POST /api/devices/:id/flash + WS /ws/devices/:id/flash-progress
- main.go:初始化 flash.NewService 並傳入 router

推論/攝影機/MJPEG/inference WebSocket 之前 M1 已搬好,不需改動。
Python bridge (kneron_bridge.py) 與 edge-ai-platform 完全相同,不需改動。

## 前端 store + hooks(Phase 2)
- stores/flash-store.ts(新):Zustand store — startFlash / updateProgress / retryFlash / reset
- hooks/use-flash-progress.ts(新):WebSocket hook 接收 flash 進度

inference-store / camera-store / inference types / use-inference-stream / use-websocket
之前 M1 已搬好,不需改動。

## 前端 UI 元件(Phase 3)
- components/devices/flash-dialog.tsx(新):模型載入對話框 + 硬體相容性檢查
- components/devices/flash-progress.tsx(新):Flash 進度條 + 錯誤重試

camera-inference-view / camera-feed / camera-overlay / source-selector /
inference-panel / performance-metrics / classification-result / confidence-slider /
video-progress / batch-image-thumbnails 之前 M1 已搬好。

## 前端頁面整合(Phase 4)
- workspace/page.tsx:繁中硬編碼、顯示已載入模型名稱
- workspace/[deviceId]/workspace-client.tsx:加入 FlashDialog 按鈕 + 繁中硬編碼
- devices/[id]/device-detail-client.tsx:加入 FlashDialog + 「進入工作區」按鈕(模型已載入才顯示)
- device-card.tsx:已連線 + 模型已載入時顯示「工作區」快捷按鈕

## 使用者操作流程
裝置列表 → 連線 → 管理 → 載入模型 → 進入工作區 → 選攝影機/圖片/影片 → 開始推論 → 看 bounding box / FPS / latency
或:裝置列表 → 工作區(已有模型)→ 直接推論

## 不搬的東西
- cluster/* 全部不搬(已砍 cluster 功能)
- relay / tunnel 相關不搬

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 20:07:09 +08:00

141 lines
3.8 KiB
Go

package flash
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"visiona-local/server/internal/device"
"visiona-local/server/internal/driver"
"visiona-local/server/internal/model"
)
// isCompatible checks if any of the model's supported hardware types match
// the device type. The match is case-insensitive and also checks if the
// device type string contains the hardware name (e.g. "kneron_kl720" contains "KL720").
func isCompatible(modelHardware []string, deviceType string) bool {
dt := strings.ToUpper(deviceType)
for _, hw := range modelHardware {
if strings.ToUpper(hw) == dt || strings.Contains(dt, strings.ToUpper(hw)) {
return true
}
}
return false
}
// resolveModelPath checks if a chip-specific NEF file exists for the given
// model. For cross-platform models whose filePath points to a KL520 NEF,
// this tries to find the equivalent KL720 NEF (and vice versa).
//
// Resolution: data/nef/kl520/kl520_20001_... → data/nef/kl720/kl720_20001_...
func resolveModelPath(filePath string, deviceType string) string {
if filePath == "" {
return filePath
}
targetChip := ""
if strings.Contains(strings.ToLower(deviceType), "kl720") {
targetChip = "kl720"
} else if strings.Contains(strings.ToLower(deviceType), "kl520") {
targetChip = "kl520"
}
if targetChip == "" {
return filePath
}
// Already points to the target chip directory — use as-is.
if strings.Contains(filePath, "/"+targetChip+"/") {
return filePath
}
// Try to swap chip prefix in both directory and filename.
dir := filepath.Dir(filePath)
base := filepath.Base(filePath)
sourceChip := ""
if strings.Contains(dir, "kl520") {
sourceChip = "kl520"
} else if strings.Contains(dir, "kl720") {
sourceChip = "kl720"
}
if sourceChip != "" && sourceChip != targetChip {
newDir := strings.Replace(dir, sourceChip, targetChip, 1)
newBase := strings.Replace(base, sourceChip, targetChip, 1)
candidate := filepath.Join(newDir, newBase)
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
return filePath
}
type Service struct {
deviceMgr *device.Manager
modelRepo *model.Repository
tracker *ProgressTracker
}
func NewService(deviceMgr *device.Manager, modelRepo *model.Repository) *Service {
return &Service{
deviceMgr: deviceMgr,
modelRepo: modelRepo,
tracker: NewProgressTracker(),
}
}
func (s *Service) StartFlash(deviceID, modelID string) (string, <-chan driver.FlashProgress, error) {
session, err := s.deviceMgr.GetDevice(deviceID)
if err != nil {
return "", nil, fmt.Errorf("device not found: %w", err)
}
if !session.Driver.IsConnected() {
return "", nil, fmt.Errorf("device not connected")
}
m, err := s.modelRepo.GetByID(modelID)
if err != nil {
return "", nil, fmt.Errorf("model not found: %w", err)
}
// Check hardware compatibility
deviceInfo := session.Driver.Info()
if !isCompatible(m.SupportedHardware, deviceInfo.Type) {
return "", nil, fmt.Errorf("model not compatible with device type %s", deviceInfo.Type)
}
// Use the model's .nef file path if available, otherwise fall back to modelID.
modelPath := m.FilePath
if modelPath == "" {
modelPath = modelID
}
// Resolve chip-specific NEF (e.g. KL520 path → KL720 equivalent).
modelPath = resolveModelPath(modelPath, deviceInfo.Type)
taskID := fmt.Sprintf("flash-%s-%s", deviceID, modelID)
task := s.tracker.Create(taskID, deviceID, modelID)
go func() {
defer func() {
task.Done = true
close(task.ProgressCh)
}()
// Brief pause to allow the WebSocket client to connect before
// progress messages start flowing.
time.Sleep(500 * time.Millisecond)
if err := session.Driver.Flash(modelPath, task.ProgressCh); err != nil {
task.ProgressCh <- driver.FlashProgress{
Percent: -1,
Stage: "error",
Error: err.Error(),
}
}
}()
return taskID, task.ProgressCh, nil
}