From 44711753ae385a36bd01ba44371fca75a912fb5c Mon Sep 17 00:00:00 2001 From: jim800121chen Date: Sun, 12 Apr 2026 20:07:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(local-tool):=20=E6=8E=A8=E8=AB=96=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=AE=8C=E6=95=B4=E6=90=AC=E5=85=A5=20=E2=80=94=20fla?= =?UTF-8?q?sh=20=E6=A8=A1=E7=B5=84=20+=20workspace=20=E6=8E=A8=E8=AB=96?= =?UTF-8?q?=E4=BB=8B=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 後端(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) --- .../app/devices/[id]/device-detail-client.tsx | 10 +- .../workspace/[deviceId]/workspace-client.tsx | 38 ++--- .../frontend/src/app/workspace/page.tsx | 16 +- .../src/components/devices/device-card.tsx | 7 + .../src/components/devices/flash-dialog.tsx | 140 ++++++++++++++++++ .../src/components/devices/flash-progress.tsx | 60 ++++++++ .../frontend/src/hooks/use-flash-progress.ts | 68 +++++++++ local-tool/frontend/src/stores/flash-store.ts | 64 ++++++++ .../internal/api/handlers/device_handler.go | 37 +++++ local-tool/server/internal/api/router.go | 6 +- local-tool/server/internal/api/ws/flash_ws.go | 39 +++++ local-tool/server/internal/flash/progress.go | 51 +++++++ local-tool/server/internal/flash/service.go | 140 ++++++++++++++++++ local-tool/server/main.go | 4 +- 14 files changed, 649 insertions(+), 31 deletions(-) create mode 100644 local-tool/frontend/src/components/devices/flash-dialog.tsx create mode 100644 local-tool/frontend/src/components/devices/flash-progress.tsx create mode 100644 local-tool/frontend/src/hooks/use-flash-progress.ts create mode 100644 local-tool/frontend/src/stores/flash-store.ts create mode 100644 local-tool/server/internal/api/ws/flash_ws.go create mode 100644 local-tool/server/internal/flash/progress.go create mode 100644 local-tool/server/internal/flash/service.go diff --git a/local-tool/frontend/src/app/devices/[id]/device-detail-client.tsx b/local-tool/frontend/src/app/devices/[id]/device-detail-client.tsx index 930486b..8319aeb 100644 --- a/local-tool/frontend/src/app/devices/[id]/device-detail-client.tsx +++ b/local-tool/frontend/src/app/devices/[id]/device-detail-client.tsx @@ -7,6 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Separator } from '@/components/ui/separator'; import { DeviceStatusBadge } from '@/components/devices/device-status'; +import { FlashDialog } from '@/components/devices/flash-dialog'; import { DeviceHealthCard } from '@/components/devices/device-health-card'; import { DeviceConnectionLog } from '@/components/devices/device-connection-log'; import { DeviceSettingsCard } from '@/components/devices/device-settings-card'; @@ -57,9 +58,12 @@ export default function DeviceDetailClient() {
{isConnected ? ( <> - - - + + {selectedDevice.flashedModel && ( + + + + )} diff --git a/local-tool/frontend/src/app/workspace/[deviceId]/workspace-client.tsx b/local-tool/frontend/src/app/workspace/[deviceId]/workspace-client.tsx index 0d6e662..d911bb9 100644 --- a/local-tool/frontend/src/app/workspace/[deviceId]/workspace-client.tsx +++ b/local-tool/frontend/src/app/workspace/[deviceId]/workspace-client.tsx @@ -5,16 +5,15 @@ import Link from 'next/link'; import { Button } from '@/components/ui/button'; import { CameraInferenceView } from '@/components/camera/camera-inference-view'; import { InferencePanel } from '@/components/inference/inference-panel'; +import { FlashDialog } from '@/components/devices/flash-dialog'; import { useDeviceStore } from '@/stores/device-store'; import { useInferenceStore } from '@/stores/inference-store'; import { useInferenceStream } from '@/hooks/use-inference-stream'; import { useCameraStore } from '@/stores/camera-store'; import { useResolvedParams } from '@/hooks/use-resolved-params'; import { api } from '@/lib/api'; -import { useTranslation } from '@/lib/i18n'; export default function WorkspaceClient() { - const { t } = useTranslation(); const { deviceId } = useResolvedParams(); const { selectedDevice, fetchDevice } = useDeviceStore(); const { isRunning, setRunning, reset } = useInferenceStore(); @@ -60,26 +59,29 @@ export default function WorkspaceClient() {
- +

- {t('inference.workspace') + ':'} {selectedDevice?.name || deviceId} + {'工作區:'} {selectedDevice?.name || deviceId}

- {/* Only show manual inference controls in camera mode */} - {!isMediaMode && ( -
- {isRunning ? ( - - ) : ( - - )} -
- )} +
+ + {/* Only show manual inference controls in camera mode */} + {!isMediaMode && ( + <> + {isRunning ? ( + + ) : ( + + )} + + )} +
diff --git a/local-tool/frontend/src/app/workspace/page.tsx b/local-tool/frontend/src/app/workspace/page.tsx index 8743385..141b1a2 100644 --- a/local-tool/frontend/src/app/workspace/page.tsx +++ b/local-tool/frontend/src/app/workspace/page.tsx @@ -1,15 +1,12 @@ 'use client'; -// TODO: M2 redesign workspace landing (device picker, empty state) import Link from 'next/link'; import { useEffect } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { useDeviceStore } from '@/stores/device-store'; -import { useTranslation } from '@/lib/i18n'; export default function WorkspaceIndexPage() { - const { t } = useTranslation(); const { devices, fetchDevices } = useDeviceStore(); useEffect(() => { @@ -23,21 +20,21 @@ export default function WorkspaceIndexPage() { return (
-

{t('workspace.title')}

-

{t('workspace.subtitle')}

+

工作區

+

選擇已連線的裝置開始推論

{connected.length === 0 ? ( - {t('workspace.noConnectedDevice')} + 沒有已連線的裝置

- {t('workspace.noConnectedDeviceDesc')} + 請先前往裝置頁面連接裝置,再回到工作區開始推論。

- +
@@ -51,6 +48,9 @@ export default function WorkspaceIndexPage() {

{d.type}

+ {d.flashedModel && ( +

{d.flashedModel}

+ )}
diff --git a/local-tool/frontend/src/components/devices/device-card.tsx b/local-tool/frontend/src/components/devices/device-card.tsx index 4f88bec..5d1607e 100644 --- a/local-tool/frontend/src/components/devices/device-card.tsx +++ b/local-tool/frontend/src/components/devices/device-card.tsx @@ -63,6 +63,13 @@ export function DeviceCard({ device, isFirstCard }: DeviceCardProps) { {t('common.manage')} + {device.flashedModel && ( + + + + )} + + + + Flash Model to Device + +
+ {!isFlashing && !progress && !error ? ( + <> + + + {selectedModelId && !compatible && ( +
+
+ +
+

Hardware Incompatible

+

+ This model may not be compatible with {device ? getHardwareType(device.type) : 'this device'}. +

+
+
+
+ )} + + + + ) : ( + + )} + {((progress && progress.percent >= 100) || error) && ( + + )} +
+
+ + ); +} diff --git a/local-tool/frontend/src/components/devices/flash-progress.tsx b/local-tool/frontend/src/components/devices/flash-progress.tsx new file mode 100644 index 0000000..796002f --- /dev/null +++ b/local-tool/frontend/src/components/devices/flash-progress.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { Progress } from '@/components/ui/progress'; +import { Button } from '@/components/ui/button'; +import { XCircle } from 'lucide-react'; +import type { FlashProgress as FlashProgressType } from '@/types/device'; + +interface FlashProgressProps { + progress: FlashProgressType | null; + error?: string | null; + onRetry?: () => void; +} + +export function FlashProgress({ progress, error, onRetry }: FlashProgressProps) { + if (error) { + return ( +
+
+
+ +
+

Flash Failed

+

{error}

+
+
+
+ {onRetry && ( + + )} +
+ ); + } + + if (!progress) { + return ( +
+
+ Preparing flash... +
+ +
+ ); + } + + return ( +
+
+ {progress.stage} + {progress.percent}% +
+ +

{progress.message}

+ {progress.percent >= 100 && ( +

Flash Complete!

+ )} +
+ ); +} diff --git a/local-tool/frontend/src/hooks/use-flash-progress.ts b/local-tool/frontend/src/hooks/use-flash-progress.ts new file mode 100644 index 0000000..2c24270 --- /dev/null +++ b/local-tool/frontend/src/hooks/use-flash-progress.ts @@ -0,0 +1,68 @@ +'use client'; + +import { useEffect, useRef, useCallback } from 'react'; +import { createWebSocket } from '@/lib/ws'; +import { useFlashStore } from '@/stores/flash-store'; +import type { FlashProgress } from '@/types/device'; + +/** + * Manages flash progress WebSocket. + * Returns a `connectAndWait` callback that creates the WebSocket and + * returns a promise that resolves once the WS is open. + */ +export function useFlashProgress(deviceId: string) { + const updateProgress = useFlashStore((s) => s.updateProgress); + const wsRef = useRef | null>(null); + + // Cleanup on unmount + useEffect(() => { + return () => { + wsRef.current?.close(); + wsRef.current = null; + }; + }, [deviceId]); + + /** + * Creates the WebSocket connection and returns a promise that resolves + * once the connection is open. This is called imperatively (not via + * useEffect) to avoid React render-cycle timing issues. + */ + const connectAndWait = useCallback( + () => + new Promise((resolve) => { + // Close any existing connection + wsRef.current?.close(); + + let resolved = false; + const doResolve = () => { + if (!resolved) { + resolved = true; + resolve(); + } + }; + + const ws = createWebSocket( + `/ws/devices/${deviceId}/flash-progress`, + (data) => { + updateProgress(data as FlashProgress); + }, + () => { + doResolve(); + }, + ); + wsRef.current = ws; + + // Safety timeout — don't block forever + setTimeout(doResolve, 3000); + }), + [deviceId, updateProgress], + ); + + /** Close the WebSocket connection. */ + const disconnect = useCallback(() => { + wsRef.current?.close(); + wsRef.current = null; + }, []); + + return { connectAndWait, disconnect }; +} diff --git a/local-tool/frontend/src/stores/flash-store.ts b/local-tool/frontend/src/stores/flash-store.ts new file mode 100644 index 0000000..227300d --- /dev/null +++ b/local-tool/frontend/src/stores/flash-store.ts @@ -0,0 +1,64 @@ +import { create } from 'zustand'; +import { api } from '@/lib/api'; +import type { FlashProgress } from '@/types/device'; +import { showApiError } from '@/lib/toast'; +import { useActivityStore } from './activity-store'; + +interface FlashState { + isFlashing: boolean; + progress: FlashProgress | null; + error: string | null; + lastFlashParams: { deviceId: string; modelId: string } | null; + startFlash: (deviceId: string, modelId: string) => Promise; + updateProgress: (progress: FlashProgress) => void; + setError: (error: string) => void; + retryFlash: () => Promise; + reset: () => void; +} + +export const useFlashStore = create((set, get) => ({ + isFlashing: false, + progress: null, + error: null, + lastFlashParams: null, + + startFlash: async (deviceId, modelId) => { + set({ isFlashing: true, progress: null, error: null, lastFlashParams: { deviceId, modelId } }); + const res = await api.post(`/devices/${deviceId}/flash`, { modelId }); + if (!res.success) { + const errorMsg = res.error?.message || 'Flash failed'; + showApiError(res.error); + set({ isFlashing: false, error: errorMsg }); + useActivityStore.getState().addActivity('flash_error', `Flash failed: ${errorMsg}`); + } else { + useActivityStore.getState().addActivity('flash_start', 'Flash started'); + } + }, + + updateProgress: (progress) => { + if (progress.error) { + set({ isFlashing: false, error: progress.error }); + useActivityStore.getState().addActivity('flash_error', `Flash failed: ${progress.error}`); + return; + } + set({ progress }); + if (progress.percent >= 100) { + set({ isFlashing: false }); + useActivityStore.getState().addActivity('flash_complete', 'Flash completed'); + } + }, + + setError: (error) => { + set({ error, isFlashing: false }); + }, + + retryFlash: async () => { + const { lastFlashParams } = get(); + if (!lastFlashParams) return; + await get().startFlash(lastFlashParams.deviceId, lastFlashParams.modelId); + }, + + reset: () => { + set({ isFlashing: false, progress: null, error: null, lastFlashParams: null }); + }, +})); diff --git a/local-tool/server/internal/api/handlers/device_handler.go b/local-tool/server/internal/api/handlers/device_handler.go index 7677360..24253cc 100644 --- a/local-tool/server/internal/api/handlers/device_handler.go +++ b/local-tool/server/internal/api/handlers/device_handler.go @@ -8,6 +8,7 @@ import ( "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" "github.com/gin-gonic/gin" @@ -15,17 +16,20 @@ import ( type DeviceHandler struct { deviceMgr *device.Manager + flashSvc *flash.Service inferenceSvc *inference.Service wsHub *ws.Hub } 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, } @@ -107,6 +111,39 @@ func (h *DeviceHandler) DisconnectDevice(c *gin.Context) { c.JSON(200, gin.H{"success": true}) } +func (h *DeviceHandler) FlashDevice(c *gin.Context) { + id := c.Param("id") + 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 + go func() { + room := "flash:" + id + for progress := range progressCh { + h.wsHub.BroadcastToRoom(room, progress) + } + }() + + c.JSON(200, gin.H{"success": true, "data": gin.H{"taskId": taskID}}) +} + func (h *DeviceHandler) StartInference(c *gin.Context) { id := c.Param("id") resultCh := make(chan *driver.InferenceResult, 10) diff --git a/local-tool/server/internal/api/router.go b/local-tool/server/internal/api/router.go index 124b251..f710c6d 100644 --- a/local-tool/server/internal/api/router.go +++ b/local-tool/server/internal/api/router.go @@ -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/flash" "visiona-local/server/internal/inference" "visiona-local/server/internal/model" "visiona-local/server/pkg/logger" @@ -23,6 +24,7 @@ func NewRouter( modelStore *model.ModelStore, deviceMgr *device.Manager, cameraMgr *camera.Manager, + flashSvc *flash.Service, inferenceSvc *inference.Service, wsHub *ws.Hub, staticFS http.FileSystem, @@ -38,7 +40,7 @@ func NewRouter( modelHandler := handlers.NewModelHandler(modelRepo) modelUploadHandler := handlers.NewModelUploadHandler(modelRepo, modelStore) - deviceHandler := handlers.NewDeviceHandler(deviceMgr, inferenceSvc, wsHub) + deviceHandler := handlers.NewDeviceHandler(deviceMgr, flashSvc, inferenceSvc, wsHub) cameraHandler := handlers.NewCameraHandler(cameraMgr, deviceMgr, inferenceSvc, wsHub) api := r.Group("/api") @@ -63,6 +65,7 @@ func NewRouter( api.GET("/devices/:id", deviceHandler.GetDevice) api.POST("/devices/:id/connect", deviceHandler.ConnectDevice) api.POST("/devices/:id/disconnect", deviceHandler.DisconnectDevice) + api.POST("/devices/:id/flash", deviceHandler.FlashDevice) api.POST("/devices/:id/inference/start", deviceHandler.StartInference) api.POST("/devices/:id/inference/stop", deviceHandler.StopInference) @@ -83,6 +86,7 @@ func NewRouter( // WebSocket r.GET("/ws/devices/events", ws.DeviceEventsHandler(wsHub, deviceMgr)) + r.GET("/ws/devices/:id/flash-progress", ws.FlashProgressHandler(wsHub)) r.GET("/ws/devices/:id/inference", ws.InferenceHandler(wsHub, inferenceSvc)) r.GET("/ws/server-logs", ws.ServerLogsHandler(wsHub, logBroadcaster)) diff --git a/local-tool/server/internal/api/ws/flash_ws.go b/local-tool/server/internal/api/ws/flash_ws.go new file mode 100644 index 0000000..712aafb --- /dev/null +++ b/local-tool/server/internal/api/ws/flash_ws.go @@ -0,0 +1,39 @@ +package ws + +import ( + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +func FlashProgressHandler(hub *Hub) gin.HandlerFunc { + return func(c *gin.Context) { + deviceID := c.Param("id") + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + return + } + defer conn.Close() + + client := &Client{Conn: conn, Send: make(chan []byte, 20)} + room := "flash:" + deviceID + sub := &Subscription{Client: client, Room: room} + hub.RegisterSync(sub) + defer hub.Unregister(sub) + + // Read pump — drain incoming messages (ping/pong, close frames) + go func() { + defer conn.Close() + for { + if _, _, err := conn.ReadMessage(); err != nil { + break + } + } + }() + + for msg := range client.Send { + if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil { + return + } + } + } +} diff --git a/local-tool/server/internal/flash/progress.go b/local-tool/server/internal/flash/progress.go new file mode 100644 index 0000000..bd9e3c1 --- /dev/null +++ b/local-tool/server/internal/flash/progress.go @@ -0,0 +1,51 @@ +package flash + +import ( + "visiona-local/server/internal/driver" + "sync" +) + +type FlashTask struct { + ID string + DeviceID string + ModelID string + ProgressCh chan driver.FlashProgress + Done bool +} + +type ProgressTracker struct { + tasks map[string]*FlashTask + mu sync.RWMutex +} + +func NewProgressTracker() *ProgressTracker { + return &ProgressTracker{ + tasks: make(map[string]*FlashTask), + } +} + +func (pt *ProgressTracker) Create(taskID, deviceID, modelID string) *FlashTask { + pt.mu.Lock() + defer pt.mu.Unlock() + task := &FlashTask{ + ID: taskID, + DeviceID: deviceID, + ModelID: modelID, + ProgressCh: make(chan driver.FlashProgress, 20), + } + pt.tasks[taskID] = task + return task +} + +func (pt *ProgressTracker) Get(taskID string) (*FlashTask, bool) { + pt.mu.RLock() + defer pt.mu.RUnlock() + t, ok := pt.tasks[taskID] + return t, ok +} + +func (pt *ProgressTracker) Remove(taskID string) { + pt.mu.Lock() + defer pt.mu.Unlock() + delete(pt.tasks, taskID) +} diff --git a/local-tool/server/internal/flash/service.go b/local-tool/server/internal/flash/service.go new file mode 100644 index 0000000..4826bbe --- /dev/null +++ b/local-tool/server/internal/flash/service.go @@ -0,0 +1,140 @@ +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 +} diff --git a/local-tool/server/main.go b/local-tool/server/main.go index ef46d0d..87fd4c9 100644 --- a/local-tool/server/main.go +++ b/local-tool/server/main.go @@ -23,6 +23,7 @@ import ( "visiona-local/server/internal/config" "visiona-local/server/internal/deps" "visiona-local/server/internal/device" + "visiona-local/server/internal/flash" "visiona-local/server/internal/inference" "visiona-local/server/internal/model" pkglogger "visiona-local/server/pkg/logger" @@ -138,6 +139,7 @@ func main() { cameraMgr := camera.NewManager(cfg.MockCamera) // Initialize services + flashSvc := flash.NewService(deviceMgr, modelRepo) inferenceSvc := inference.NewService(deviceMgr) // Determine static file system for embedded frontend @@ -183,7 +185,7 @@ func main() { systemHandler := handlers.NewSystemHandler(Version, BuildTime, pythonBinForSystem, restartFn) // Create router - r := api.NewRouter(modelRepo, modelStore, deviceMgr, cameraMgr, inferenceSvc, wsHub, staticFS, logBroadcaster, systemHandler) + r := api.NewRouter(modelRepo, modelStore, deviceMgr, cameraMgr, flashSvc, inferenceSvc, wsHub, staticFS, logBroadcaster, systemHandler) // Configure HTTP server (bind to localhost only) addr := cfg.Addr()