From 12f5bf6c47c58d48fa4d3935fd336a75dd7c8516 Mon Sep 17 00:00:00 2001 From: jim800121chen Date: Fri, 10 Jul 2026 09:58:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20WP-C=20serial=20=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E8=90=BD=E5=9C=B0=EF=BC=88ADR-018=20FE-A=20=E6=B7=B7?= =?UTF-8?q?=E5=90=88=E6=A8=A1=E5=9E=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DeviceSummary 加 serialNumber(serial_number/serialNumber 雙 key 容錯、 缺欄/空字串 → null) - 五個 proxy 操作識別值換 serial:connect/disconnect、flash POST+進度 WS (單一派生點保 room key 一致)、inference WS、camera start/stop+WS、 media 三 upload - serial 為空全面 disable + 提示(workspace banner/media 占位/flash-dialog/ device-card tooltip/device-detail 兩鈕+序號 InfoRow/選擇頁);i18n 兩語系 - UUID 組維持:fetchDevices/fetchDevice/unpair/詳情頁路由 - C5 偏差(路由段維持 UUID):與 FE-A 定案一致、reviewer 獨立驗證成立 (API 層無 GetBySerial、帶 serial deep-link 必 404);文件回填另派 - evidence:tsc 0 / eslint 0 / next build 15 route / 觸及 11 測試檔 99 passed - review:通過 0C/0M/3Mi/2Sug(.autoflow/05-implementation/review/ wp-c-frontend-serial-routing-review.md) Co-Authored-By: Claude Fable 5 --- .../[id]/device-detail-client.test.tsx | 49 ++++- .../app/devices/[id]/device-detail-client.tsx | 48 ++++- .../[deviceId]/workspace-client.test.tsx | 68 ++++++- .../workspace/[deviceId]/workspace-client.tsx | 172 ++++++++++++------ visionA-frontend/src/app/workspace/page.tsx | 35 +++- .../components/devices/device-card.test.tsx | 65 +++++++ .../src/components/devices/device-card.tsx | 23 ++- .../components/devices/flash-dialog.test.tsx | 16 ++ .../src/components/devices/flash-dialog.tsx | 31 +++- .../src/hooks/use-flash-progress.ts | 18 +- .../src/hooks/use-inference-stream.ts | 11 +- .../src/lib/i18n/dictionaries/en.ts | 9 + .../src/lib/i18n/dictionaries/zh-Hant.ts | 9 + visionA-frontend/src/lib/media.ts | 20 +- .../src/stores/device-store.test.ts | 41 +++++ visionA-frontend/src/stores/device-store.ts | 43 +++-- .../src/stores/flash-store.test.ts | 20 +- visionA-frontend/src/stores/flash-store.ts | 32 ++-- 18 files changed, 592 insertions(+), 118 deletions(-) create mode 100644 visionA-frontend/src/components/devices/device-card.test.tsx diff --git a/visionA-frontend/src/app/devices/[id]/device-detail-client.test.tsx b/visionA-frontend/src/app/devices/[id]/device-detail-client.test.tsx index 7c261f6..d82abe3 100644 --- a/visionA-frontend/src/app/devices/[id]/device-detail-client.test.tsx +++ b/visionA-frontend/src/app/devices/[id]/device-detail-client.test.tsx @@ -19,6 +19,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest"; +import { TooltipProvider } from "@/components/ui/tooltip"; import { LocaleProvider } from "@/lib/i18n/context"; import { useDeviceStore, type Device } from "@/stores/device-store"; @@ -52,12 +53,16 @@ const onlineDevice: Device = { remoteStatus: "online", lastSeenAt: "2026-04-21T00:00:00Z", firmwareVersion: "2.3.1", + // WP-C(ADR-018):路由類操作(flash / 工作區)需要 serial + serialNumber: "KN00123456", }; function renderDetail(id = "dev-1") { return render( - + + + , ); } @@ -170,3 +175,45 @@ describe("DeviceDetailClient 移除裝置按鈕", () => { expect(btn).toHaveTextContent("移除中"); }); }); + +describe("DeviceDetailClient serial 路由 gating(WP-C / ADR-018)", () => { + it("serial 有值:flash 觸發鈕正常、序號欄顯示序號", () => { + renderDetail(); + expect(screen.getByTestId("flash-model-trigger")).toBeEnabled(); + expect(screen.getByText("KN00123456")).toBeInTheDocument(); + }); + + it("serial 有值 + 已載入模型 + 在線:顯示開啟工作區連結(href 用 UUID 路由段)", () => { + useDeviceStore.setState({ + selectedDevice: { ...onlineDevice, flashedModel: "yolo-v5" }, + }); + renderDetail(); + const link = screen.getByRole("link", { name: "開啟工作區" }); + expect(link).toHaveAttribute("href", "/workspace/dev-1"); + }); + + it("serial 為空:flash 換成 disabled 按鈕、序號欄顯示未回報", () => { + useDeviceStore.setState({ + selectedDevice: { ...onlineDevice, serialNumber: null }, + }); + renderDetail(); + expect(screen.queryByTestId("flash-model-trigger")).not.toBeInTheDocument(); + expect(screen.getByTestId("flash-no-serial-disabled")).toBeDisabled(); + expect(screen.getByText("尚未回報序號")).toBeInTheDocument(); + }); + + it("serial 為空 + 已載入模型 + 在線:工作區入口 disabled(不出現連結)", () => { + useDeviceStore.setState({ + selectedDevice: { + ...onlineDevice, + serialNumber: null, + flashedModel: "yolo-v5", + }, + }); + renderDetail(); + expect( + screen.queryByRole("link", { name: "開啟工作區" }), + ).not.toBeInTheDocument(); + expect(screen.getByTestId("workspace-no-serial-disabled")).toBeDisabled(); + }); +}); diff --git a/visionA-frontend/src/app/devices/[id]/device-detail-client.tsx b/visionA-frontend/src/app/devices/[id]/device-detail-client.tsx index 2820380..7757dee 100644 --- a/visionA-frontend/src/app/devices/[id]/device-detail-client.tsx +++ b/visionA-frontend/src/app/devices/[id]/device-detail-client.tsx @@ -121,6 +121,9 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) { const displayName = selectedDevice.alias || selectedDevice.name; const isOnline = selectedDevice.remoteStatus === "online"; const isOffline = selectedDevice.remoteStatus === "offline"; + // WP-C(ADR-018):serial 為空 → 路由類操作(flash / 工作區)無法路由,disable。 + // 詳情頁本身(fetchDevice / unpair)維持 UUID,不受 serial 影響。 + const hasSerial = !!selectedDevice.serialNumber; return (
@@ -166,15 +169,41 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) {
{/* flash(載入模型):裝置在線才可 flash;離線時 disable 觸發鈕。 + WP-C(ADR-018):flash 走 serial 路由,serial 為空時同樣 disable + tooltip。 flash 完成後 dialog 內部呼叫 fetchDevice → flashedModel 有值 → 下方 「開啟工作區」按鈕自動出現(gate 解鎖,無需改 gate 邏輯)。 */} - + {hasSerial ? ( + + ) : ( + + + + + + + {t("devices.serial.missingHint")} + + )} - {isOnline && selectedDevice.flashedModel && ( + {isOnline && selectedDevice.flashedModel && hasSerial && ( )} + {isOnline && selectedDevice.flashedModel && !hasSerial && ( + + + + + + + {t("devices.serial.missingHint")} + + )} {!isOnline && ( @@ -253,6 +282,21 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) { {selectedDevice.id}} /> + {/* WP-C(ADR-018):顯示硬體序號(kn_number);未回報時提示需重新配對 */} + + {selectedDevice.serialNumber} + + ) : ( + + {t("devices.serial.missing")} + + ) + } + /> ["selectedDevice"], + isLoading: false, + }); +} + +/** WP-C:seed 一台線上但「未回報序號」的裝置(舊資料 / 假序號 → null)。 */ +function seedOnlineDeviceWithoutSerial() { + useDeviceStore.setState({ + selectedDevice: { + id: "dev-1", + name: "KL520", + remoteStatus: "online", + lastSeenAt: null, + serialNumber: null, } as ReturnType["selectedDevice"], isLoading: false, }); @@ -181,8 +197,11 @@ describe("WorkspaceClient — 切 tab 時 camera 串流 / WS 正確關閉(S-1 await switchTab("圖片"); // handleTabChange 應自動停掉 camera(POST /api/camera/stop) + // WP-C(ADR-018):body 的 deviceId 欄位帶 serialNumber(非雲端 UUID) await waitFor(() => { - expect(post).toHaveBeenCalledWith("/api/camera/stop", { deviceId: "dev-1" }); + expect(post).toHaveBeenCalledWith("/api/camera/stop", { + deviceId: "KN00123456", + }); }); // camera 串流已清(isRunning=false)→ camera 的 useInferenceStream enabled 變 false(不殘留連線) await waitFor(() => { @@ -214,3 +233,50 @@ describe("WorkspaceClient — 切 tab 時 camera 串流 / WS 正確關閉(S-1 expect(lastCameraWsEnabled()).toBe(false); }); }); + +describe("WorkspaceClient — serial 路由(WP-C / ADR-018)", () => { + it("camera start 的 body deviceId 帶 serialNumber、camera WS 識別值也是 serialNumber", async () => { + post.mockResolvedValue({ streamUrl: "/api/camera/stream", sourceType: "camera" }); + renderClient(); + + fireEvent.click(screen.getByText("開始推論")); + await waitFor(() => { + expect(post).toHaveBeenCalledWith("/api/camera/start", { + deviceId: "KN00123456", + }); + }); + + // camera WS 的第一個參數(識別值)= serial,且 enabled=true + const wsCall = vi.mocked(useInferenceStream).mock.calls.at(-1); + expect(wsCall?.[0]).toBe("KN00123456"); + expect(wsCall?.[1]).toBe(true); + }); + + it("serial 為空:開始推論 disabled + 顯示 no-serial banner、不打 camera start", () => { + seedOnlineDeviceWithoutSerial(); + renderClient(); + + const startBtn = screen.getByText("開始推論").closest("button"); + expect(startBtn).toBeDisabled(); + expect(screen.getByTestId("workspace-no-serial-banner")).toBeInTheDocument(); + + fireEvent.click(screen.getByText("開始推論")); + expect(post).not.toHaveBeenCalled(); + }); + + it("serial 為空:media tab 顯示 no-serial 提示、不出現上傳區", async () => { + seedOnlineDeviceWithoutSerial(); + renderClient(); + + await switchTab("圖片"); + expect(screen.getByTestId("media-no-serial")).toBeInTheDocument(); + expect(screen.queryByTestId("media-uploader-image")).not.toBeInTheDocument(); + }); + + it("serial 有值:media tab 正常顯示上傳區", async () => { + renderClient(); + await switchTab("圖片"); + expect(screen.getByTestId("media-uploader-image")).toBeInTheDocument(); + expect(screen.queryByTestId("media-no-serial")).not.toBeInTheDocument(); + }); +}); diff --git a/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.tsx b/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.tsx index c71a53f..d7d1114 100644 --- a/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.tsx +++ b/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.tsx @@ -20,6 +20,14 @@ * * 重要:雲端版 Workspace 對「裝置離線」極敏感 — * 任何時刻若收到 remoteStatus != online 都要立刻顯示 offline 遮罩並停止串流。 + * + * ADR-018 serial 路由(WP-C): + * - 路由到 local agent 的操作(camera start/stop、media upload、inference WS) + * 識別值一律帶 `device.serialNumber`(kn_number),不是雲端 UUID。 + * - 路由段 `[deviceId]` 仍是雲端 UUID(fetchDevice 是純 DB 操作、FE-A 混合模型), + * serial 從 fetch 回來的 selectedDevice 取。 + * - serial 為空(舊資料未回報 / 假序號)→ 無法路由:顯示提示 banner、 + * 「開始推論」disable、media tab 顯示提示不給上傳。 */ import { useCallback, useEffect, useRef, useState } from "react"; @@ -101,6 +109,11 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) { // 塊 2:裝置線上性(hooks 必須無條件呼叫,故在此處先算,早退前) const isOnline = selectedDevice?.remoteStatus === "online"; + // WP-C(ADR-018):路由到 local agent 的操作一律用 serial 當識別值。 + // serial 為空(舊資料 / 未回報序號)→ 操作全部 disable(無法路由)。 + const serialNumber = selectedDevice?.serialNumber ?? null; + const hasSerial = !!serialNumber; + // 塊 3:切換 tab。離開 camera tab 時若正在推論 → 停掉(避免 camera 與 media 兩套 WS 同時灌 store)。 const handleTabChange = useCallback( (value: string) => { @@ -112,25 +125,32 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) { [isRunning], ); - // 塊 2/3:訂閱 camera WS `inference:`(只在 camera tab + 推論中 + 線上時連線)。 + // 塊 2/3:訂閱 camera WS `inference:`(只在 camera tab + 推論中 + 線上 + 有 serial 時連線)。 // media tab 有自己的 useInferenceStream(在 MediaTab 內),兩者靠 activeTab 互斥。 - useInferenceStream(deviceId, activeTab === "camera" && isRunning && isOnline); + // WP-C:WS 識別值用 serial(與 camera start 的 body deviceId 一致,room key 才對得上)。 + useInferenceStream( + serialNumber ?? "", + activeTab === "camera" && isRunning && isOnline && hasSerial, + ); // 塊 2:最新結果的偵測框(供 overlay 繪製) const liveResult = useInferenceStore((s) => s.result); const confidenceThreshold = useInferenceStore((s) => s.confidenceThreshold); async function handleStart() { + // WP-C:serial 為空無法路由(按鈕已 disable;此為防禦性 guard)。 + if (!serialNumber) return; setBusy(true); try { // 契約修正(評估 §3.1 R-C1):camera pipeline 入口為 POST /api/camera/start // body 帶 deviceId;後端回傳 { streamUrl, sourceType }(camera_handler.go:119-125) + // WP-C(ADR-018):deviceId 欄位帶 serial(欄位名不動、值換 serialNumber)。 const data = await api.post("/api/camera/start", { - deviceId, + deviceId: serialNumber, }); const url = data?.streamUrl ?? "/api/camera/stream"; // cache-bust:每次 start 換一個值,避免瀏覽器重用已結束的 MJPEG 連線 - setStreamUrl(buildStreamUrl(url, `${deviceId}-${Date.now()}`)); + setStreamUrl(buildStreamUrl(url, `${serialNumber}-${Date.now()}`)); setIsRunning(true); } catch (err) { // 後端尚未接上 proxy 時可能 501;不當致命錯誤,僅提示 @@ -147,7 +167,11 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) { async function handleStop() { setBusy(true); try { - await api.post("/api/camera/stop", { deviceId }); + // WP-C(ADR-018):與 start 一致,deviceId 欄位帶 serial。 + // serial 為空時不可能已 start(handleStart 有 guard),直接清本地串流即可。 + if (serialNumber) { + await api.post("/api/camera/stop", { deviceId: serialNumber }); + } } catch (err) { if (!(err instanceof ApiError && err.code === "NOT_IMPLEMENTED")) { toast.error(err instanceof Error ? err.message : String(err)); @@ -179,6 +203,20 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) { // 用 render 期衍生而非 effect + setState,避免 cascading render。 const effectiveStreamUrl = isOnline ? streamUrl : ""; + // WP-C:media tab 在 serial 為空時的占位提示(無法上傳——上傳的識別值就是 serial)。 + const noSerialNotice = ( + + +
+

{t("workspace.noSerial.title")}

+

+ {t("workspace.noSerial.description")} +

+
+
+
+ ); + return (
@@ -206,13 +244,30 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) { {t("workspace.inference.stop")} ) : ( - )}
+ {/* WP-C(ADR-018):serial 為空 → 無法路由到 local agent,說明原因與解法 */} + {device && !hasSerial && ( +
+
+

{t("workspace.noSerial.title")}

+

+ {t("workspace.noSerial.description")} +

+
+
+ )} + {t("workspace.tabs.camera")} @@ -266,59 +321,72 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
- {/* 塊 3:圖片 / 影片 / 批次 — 共用 MediaTab(上傳 → CameraFeed + overlay + panel) */} + {/* 塊 3:圖片 / 影片 / 批次 — 共用 MediaTab(上傳 → CameraFeed + overlay + panel) + WP-C(ADR-018):上傳識別值帶 serial;serial 為空 → 顯示提示、不給上傳。 */} - - files[0] ? validateImageFile(files[0]) : { code: "EMPTY" } - } - upload={async (files, ctx) => { - return uploadImage(deviceId, files[0]!, { - onProgress: ctx.onProgress, - signal: ctx.signal, - }); - }} - /> + {!hasSerial ? ( + noSerialNotice + ) : ( + + files[0] ? validateImageFile(files[0]) : { code: "EMPTY" } + } + upload={async (files, ctx) => { + return uploadImage(serialNumber!, files[0]!, { + onProgress: ctx.onProgress, + signal: ctx.signal, + }); + }} + /> + )} - - files[0] ? validateVideoFile(files[0]) : { code: "EMPTY" } - } - upload={async (files, ctx) => { - return uploadVideo(deviceId, files[0]!, { - onProgress: ctx.onProgress, - signal: ctx.signal, - timeoutMs: VIDEO_UPLOAD_TIMEOUT_MS, - }); - }} - /> + {!hasSerial ? ( + noSerialNotice + ) : ( + + files[0] ? validateVideoFile(files[0]) : { code: "EMPTY" } + } + upload={async (files, ctx) => { + return uploadVideo(serialNumber!, files[0]!, { + onProgress: ctx.onProgress, + signal: ctx.signal, + timeoutMs: VIDEO_UPLOAD_TIMEOUT_MS, + }); + }} + /> + )} - { - return uploadBatchImages(deviceId, files, { - onProgress: ctx.onProgress, - signal: ctx.signal, - }); - }} - /> + {!hasSerial ? ( + noSerialNotice + ) : ( + { + return uploadBatchImages(serialNumber!, files, { + onProgress: ctx.onProgress, + signal: ctx.signal, + }); + }} + /> + )} diff --git a/visionA-frontend/src/app/workspace/page.tsx b/visionA-frontend/src/app/workspace/page.tsx index d9acb26..9ef0cb8 100644 --- a/visionA-frontend/src/app/workspace/page.tsx +++ b/visionA-frontend/src/app/workspace/page.tsx @@ -53,9 +53,18 @@ export default function WorkspaceIndexPage() { className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3" data-testid="workspace-device-grid" > - {online.map((d) => ( - - + {online.map((d) => { + // WP-C(ADR-018):serial 為空的裝置無法路由(推論類操作全不可用), + // 卡片不可點、按鈕 disable、顯示原因。 + const hasSerial = !!d.serialNumber; + const card = ( +
@@ -76,13 +85,27 @@ export default function WorkspaceIndexPage() { {d.flashedModel}

)} - + {!hasSerial && ( +

+ {t("devices.serial.missingHint")} +

+ )} - - ))} + ); + return hasSerial ? ( + + {card} + + ) : ( +
+ {card} +
+ ); + })}
)} diff --git a/visionA-frontend/src/components/devices/device-card.test.tsx b/visionA-frontend/src/components/devices/device-card.test.tsx new file mode 100644 index 0000000..7d43502 --- /dev/null +++ b/visionA-frontend/src/components/devices/device-card.test.tsx @@ -0,0 +1,65 @@ +/** + * DeviceCard 測試 — WP-C(ADR-018)serial 路由 gating + * + * 覆蓋: + * - online + 已載入模型 + 有 serial → 顯示「開啟工作區」連結(href 用 UUID 路由段) + * - online + 已載入模型 + 無 serial → 工作區入口 disabled(不出現連結)+ 可理解提示 + * - 離線 → 維持既有行為(不顯示工作區入口) + */ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { TooltipProvider } from "@/components/ui/tooltip"; +import { LocaleProvider } from "@/lib/i18n/context"; +import type { DeviceSummary } from "@/stores/device-store"; + +import { DeviceCard } from "./device-card"; + +const baseDevice: DeviceSummary = { + id: "dev-1", + name: "KL520 Dev Kit", + type: "kl520", + status: "connected", + remoteStatus: "online", + flashedModel: "yolo-v5", + serialNumber: "KN00123456", +}; + +function renderCard(device: DeviceSummary) { + return render( + + + + + , + ); +} + +describe("DeviceCard — serial 路由 gating(WP-C / ADR-018)", () => { + it("online + 已載入模型 + 有 serial → 顯示工作區連結(路由段用 UUID)", () => { + renderCard(baseDevice); + const link = screen.getByRole("link", { name: "開啟工作區" }); + expect(link).toHaveAttribute("href", "/workspace/dev-1"); + expect( + screen.queryByTestId("workspace-no-serial-disabled"), + ).not.toBeInTheDocument(); + }); + + it("online + 已載入模型 + 無 serial → 工作區入口 disabled、不出現連結", () => { + renderCard({ ...baseDevice, serialNumber: null }); + expect( + screen.queryByRole("link", { name: "開啟工作區" }), + ).not.toBeInTheDocument(); + expect(screen.getByTestId("workspace-no-serial-disabled")).toBeDisabled(); + }); + + it("離線 → 不顯示工作區入口(既有行為不變)", () => { + renderCard({ ...baseDevice, remoteStatus: "offline" }); + expect( + screen.queryByRole("link", { name: "開啟工作區" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("workspace-no-serial-disabled"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/visionA-frontend/src/components/devices/device-card.tsx b/visionA-frontend/src/components/devices/device-card.tsx index b517ef5..69a4bb9 100644 --- a/visionA-frontend/src/components/devices/device-card.tsx +++ b/visionA-frontend/src/components/devices/device-card.tsx @@ -14,6 +14,8 @@ * - 右上角狀態徽章改為 `RemoteDeviceBadge`(雲端版語意) * - 離線(remoteStatus != online)時卡片 opacity-75 + 操作按鈕 disabled * - 「工作區」按鈕只在 remoteStatus=online 時顯示(flow-offline-handling §4.1) + * - WP-C(ADR-018):工作區入口另需 `hasSerial`——serial 為空的裝置無法路由到 + * local agent(推論 / camera / 上傳全不可用),入口改 disabled + tooltip 說明 * - 移除 local-tool 的 connect/disconnect 按鈕(雲端版這些走 `/devices/[id]` 詳情頁操作) * - 卡片本體包成 Link 到 `/devices/[id]`(design-review M3 統一 hover 規格) */ @@ -23,6 +25,11 @@ import Link from "next/link"; import { RemoteDeviceBadge } from "@/components/cloud/remote-device-badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { useT } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import type { DeviceSummary } from "@/stores/device-store"; @@ -35,6 +42,8 @@ export function DeviceCard({ device }: DeviceCardProps) { const t = useT(); const displayName = device.alias || device.name; const isOnline = device.remoteStatus === "online"; + // WP-C(ADR-018):serial 為空 → 工作區(推論類操作)無法路由,入口 disable。 + const hasSerial = !!device.serialNumber; return ( - {isOnline && device.flashedModel && ( + {isOnline && device.flashedModel && hasSerial && ( )} + {isOnline && device.flashedModel && !hasSerial && ( + + + + + + + {t("devices.serial.missingHint")} + + )} diff --git a/visionA-frontend/src/components/devices/flash-dialog.test.tsx b/visionA-frontend/src/components/devices/flash-dialog.test.tsx index 5211bdf..b822dd5 100644 --- a/visionA-frontend/src/components/devices/flash-dialog.test.tsx +++ b/visionA-frontend/src/components/devices/flash-dialog.test.tsx @@ -42,6 +42,8 @@ const device: Device = { status: "connected", remoteStatus: "online", flashedModel: null, + // WP-C(ADR-018):flash POST / 進度 WS 的識別值 = serialNumber + serialNumber: "KN00123456", }; function renderDialog(props?: { disabled?: boolean }) { @@ -106,6 +108,20 @@ describe("FlashDialog", () => { expect(mockStop).toHaveBeenCalled(); }); + it("serial 為空(WP-C)→ 開啟 dialog 後開始按鈕 disabled 且顯示未回報序號", () => { + useDeviceStore.setState({ + selectedDevice: { ...device, serialNumber: null }, + }); + renderDialog(); + fireEvent.click(screen.getByTestId("flash-model-trigger")); + + const startBtn = screen.getByTestId("flash-start-btn"); + expect(startBtn).toBeDisabled(); + expect(startBtn).toHaveTextContent("尚未回報序號"); + fireEvent.click(startBtn); + expect(mockBeginFlash).not.toHaveBeenCalled(); + }); + it("flash 失敗 → 顯示錯誤 + 關閉按鈕;點關閉不 fetchDevice", () => { const fetchDevice = vi.fn(); useDeviceStore.setState({ fetchDevice }); diff --git a/visionA-frontend/src/components/devices/flash-dialog.tsx b/visionA-frontend/src/components/devices/flash-dialog.tsx index f6891f1..0ebb84e 100644 --- a/visionA-frontend/src/components/devices/flash-dialog.tsx +++ b/visionA-frontend/src/components/devices/flash-dialog.tsx @@ -50,6 +50,11 @@ import { useFlashStore } from "@/stores/flash-store"; import { useModelStore } from "@/stores/model-store"; interface FlashDialogProps { + /** + * 雲端 device UUID——用於 selectedDevice 比對與 flash 後 fetchDevice(DB 操作、 + * FE-A 混合模型維持 UUID)。flash POST 與進度 WS 的識別值則用裝置的 + * serialNumber(ADR-018 serial 路由),由本元件從 selectedDevice 取。 + */ deviceId: string; /** 裝置離線時不可 flash,disable 觸發鈕(呼叫端傳入)。 */ disabled?: boolean; @@ -72,10 +77,15 @@ export function FlashDialog({ deviceId, disabled }: FlashDialogProps) { const selectedDevice = useDeviceStore((s) => s.selectedDevice); const fetchDevice = useDeviceStore((s) => s.fetchDevice); - const { beginFlash, stop } = useFlashProgress(deviceId); - // device 從 selectedDevice 取(detail 頁已 fetch);防呆用 id 比對。 const device = selectedDevice?.id === deviceId ? selectedDevice : null; + // WP-C(ADR-018):flash POST + 進度 WS 的識別值用 serial(兩者必須一致, + // local agent 端 room key 才對得上)。serial 為空 → 不可 flash(下方按鈕 disable; + // 呼叫端 device-detail 也已 disable 觸發鈕,此為雙重防護)。 + const serialNumber = device?.serialNumber ?? null; + + const { beginFlash, stop } = useFlashProgress(serialNumber ?? ""); + const selectedModel = models.find((m) => m.id === selectedModelId); const compatible = useMemo(() => { @@ -102,7 +112,8 @@ export function FlashDialog({ deviceId, disabled }: FlashDialogProps) { const done = (progress && progress.percent >= 100) || error !== null; const handleFlash = () => { - if (!selectedModelId || !compatible) return; + // WP-C:serial 為空無法路由(按鈕已 disable;此為防禦性 guard)。 + if (!selectedModelId || !compatible || !serialNumber) return; // 契約差異 B:只呼叫 beginFlash(設 enabled + onOpen 觸發 POST),不在此直接 POST。 beginFlash(selectedModelId); }; @@ -183,15 +194,17 @@ export function FlashDialog({ deviceId, disabled }: FlashDialogProps) { ) : ( diff --git a/visionA-frontend/src/hooks/use-flash-progress.ts b/visionA-frontend/src/hooks/use-flash-progress.ts index 640e871..8a267a5 100644 --- a/visionA-frontend/src/hooks/use-flash-progress.ts +++ b/visionA-frontend/src/hooks/use-flash-progress.ts @@ -12,7 +12,7 @@ * * **本 hook 的解法(選項 2,複用最多、無 race)**: * 用 `useWebSocket` 的 `onOpen` callback 觸發 POST。呼叫端 `beginFlash(modelId)` 只設 - * `enabled=true` + 記下 modelId;WS 真正 open 後才在 `onOpen` 內 `startFlash(deviceId, modelId)`, + * `enabled=true` + 記下 modelId;WS 真正 open 後才在 `onOpen` 內 `startFlash(serialNumber, modelId)`, * 天然保證「先 open WS 再 POST」的順序(flash 很快就送 progress,順序錯會漏早期 percent)。 * * **重連防重複 POST(`hasStartedRef`)**: @@ -42,7 +42,15 @@ interface UseFlashProgress { isActive: boolean; } -export function useFlashProgress(deviceId: string): UseFlashProgress { +/** + * @param serialNumber 目標裝置的硬體序號(kn_number)。 + * + * ⚠️ ADR-018 serial 路由:WS path 與 flash POST 的識別值都必須用 serialNumber, + * 兩者要一致——local agent 端 flash 進度是 broadcast 到以「請求帶入的識別值」 + * 為 key 的 room,POST 帶 serial、WS 帶 UUID 會收不到進度。 + * serial 為空的裝置不可 beginFlash(呼叫端 FlashDialog disable)。 + */ +export function useFlashProgress(serialNumber: string): UseFlashProgress { const updateProgress = useFlashStore((s) => s.updateProgress); const startFlash = useFlashStore((s) => s.startFlash); @@ -60,8 +68,8 @@ export function useFlashProgress(deviceId: string): UseFlashProgress { const modelId = modelIdRef.current; if (!modelId) return; hasStartedRef.current = true; - void startFlash(deviceId, modelId); - }, [deviceId, startFlash]); + void startFlash(serialNumber, modelId); + }, [serialNumber, startFlash]); const handleMessage = useCallback( (data: unknown) => { @@ -80,7 +88,7 @@ export function useFlashProgress(deviceId: string): UseFlashProgress { [updateProgress], ); - useWebSocket(`/ws/devices/${encodeURIComponent(deviceId)}/flash-progress`, { + useWebSocket(`/ws/devices/${encodeURIComponent(serialNumber)}/flash-progress`, { enabled, onOpen: handleOpen, onMessage: handleMessage, diff --git a/visionA-frontend/src/hooks/use-inference-stream.ts b/visionA-frontend/src/hooks/use-inference-stream.ts index 03f715a..21cfa5b 100644 --- a/visionA-frontend/src/hooks/use-inference-stream.ts +++ b/visionA-frontend/src/hooks/use-inference-stream.ts @@ -24,14 +24,17 @@ import { useInferenceStore } from "@/stores/inference-store"; import type { InferenceResult } from "@/types/inference"; /** - * @param deviceId 目標裝置 - * @param enabled false 時不連線(例如尚未開始推論 / 裝置離線) + * @param serialNumber 目標裝置的硬體序號(kn_number)。 + * ⚠️ ADR-018 serial 路由:識別值必須與 camera start / media upload 帶的 + * `deviceId`(= serial)一致——local agent 端 room key 是 `inference:<請求識別值>`, + * 兩邊不一致會收不到結果。serial 為空時呼叫端必須傳 enabled=false。 + * @param enabled false 時不連線(例如尚未開始推論 / 裝置離線 / 無 serial) */ -export function useInferenceStream(deviceId: string, enabled = false): void { +export function useInferenceStream(serialNumber: string, enabled = false): void { const addResult = useInferenceStore((s) => s.addResult); const addBatchResult = useInferenceStore((s) => s.addBatchResult); - useWebSocket(`/ws/devices/${encodeURIComponent(deviceId)}/inference`, { + useWebSocket(`/ws/devices/${encodeURIComponent(serialNumber)}/inference`, { enabled, onMessage: (data) => { // pipeline 完成事件(batch/video 跑完)— 目前不需特別處理,忽略非結果訊息 diff --git a/visionA-frontend/src/lib/i18n/dictionaries/en.ts b/visionA-frontend/src/lib/i18n/dictionaries/en.ts index d3221b0..d9cd2c3 100644 --- a/visionA-frontend/src/lib/i18n/dictionaries/en.ts +++ b/visionA-frontend/src/lib/i18n/dictionaries/en.ts @@ -160,6 +160,12 @@ export const en: Dictionary = { "devices.status.error": "Error", "devices.status.disconnected": "Disconnected", + // ── Devices: serial number (serial routing, ADR-018 / WP-C) ── + "devices.serial.label": "Serial number", + "devices.serial.missing": "Serial not reported yet", + "devices.serial.missingHint": + "This device hasn't reported its serial number, so inference-related actions are unavailable. Re-pair it once from local agent to report the serial.", + // ── Devices: remove (unpair) ── "devices.remove.action": "Remove device", "devices.remove.removing": "Removing…", @@ -317,6 +323,9 @@ export const en: Dictionary = { "workspace.offline.description": "The connection to {deviceName} was lost; inference has been stopped.", "workspace.offline.backToList": "Back to devices", + "workspace.noSerial.title": "This device hasn't reported a serial number", + "workspace.noSerial.description": + "Inference, camera, and media upload need the device serial to route to local agent. Re-pair the device once from local agent; these actions unlock after the serial is reported.", "workspace.tabs.camera": "Camera", "workspace.tabs.image": "Image", "workspace.tabs.video": "Video", diff --git a/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts b/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts index 15705e5..276efb5 100644 --- a/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts +++ b/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts @@ -161,6 +161,12 @@ export const zhHant: Dictionary = { "devices.status.error": "錯誤", "devices.status.disconnected": "未連接", + // ── Devices: 序號(serial 路由,ADR-018 / WP-C) ── + "devices.serial.label": "序號", + "devices.serial.missing": "尚未回報序號", + "devices.serial.missingHint": + "此裝置尚未回報序號,無法執行推論相關操作。請在 local agent 重新配對一次,序號回報後即可使用。", + // ── Devices: 移除裝置(unpair) ── "devices.remove.action": "移除裝置", "devices.remove.removing": "移除中…", @@ -306,6 +312,9 @@ export const zhHant: Dictionary = { "workspace.offline.title": "裝置已離線", "workspace.offline.description": "與 {deviceName} 的連線中斷,推論已自動停止", "workspace.offline.backToList": "返回裝置列表", + "workspace.noSerial.title": "此裝置尚未回報序號", + "workspace.noSerial.description": + "推論、攝影機與媒體上傳需要裝置序號才能路由到 local agent。請在 local agent 重新配對一次,序號回報後即可操作。", "workspace.tabs.camera": "Camera", "workspace.tabs.image": "圖片", "workspace.tabs.video": "影片", diff --git a/visionA-frontend/src/lib/media.ts b/visionA-frontend/src/lib/media.ts index a26e4d9..a9dc981 100644 --- a/visionA-frontend/src/lib/media.ts +++ b/visionA-frontend/src/lib/media.ts @@ -202,38 +202,46 @@ function postMultipart( }); } +/* + * ⚠️ ADR-018 serial 路由:下方三個 upload 函式的裝置識別參數是 + * `serialNumber`(kn_number),不是雲端 device UUID——local agent 端 + * `camera_handler.go` 以 body 的 `deviceId` 查 sessions(serial 反查),帶 UUID 會 + * "device not found"。FormData 欄位名維持 `deviceId`(傳輸結構不動、只換值來源)。 + * serial 為空的裝置不可上傳(workspace 端 disable)。 + */ + /** 上傳單張圖片 → 開始推論;回傳含 streamUrl 的 response。 */ export function uploadImage( - deviceId: string, + serialNumber: string, file: File, options?: UploadMediaOptions, ): Promise { const form = new FormData(); - form.append("deviceId", deviceId); + form.append("deviceId", serialNumber); form.append("file", file); return postMultipart(MEDIA_UPLOAD_IMAGE_PATH, form, options); } /** 上傳單支影片 → 開始逐 frame 推論;回傳含 streamUrl / totalFrames / durationSeconds。 */ export function uploadVideo( - deviceId: string, + serialNumber: string, file: File, options?: UploadMediaOptions, ): Promise { const form = new FormData(); - form.append("deviceId", deviceId); + form.append("deviceId", serialNumber); form.append("file", file); return postMultipart(MEDIA_UPLOAD_VIDEO_PATH, form, options); } /** 上傳多張圖(batch)→ 逐張推論;回傳含 batchId / totalImages / images[]。 */ export function uploadBatchImages( - deviceId: string, + serialNumber: string, files: File[], options?: UploadMediaOptions, ): Promise { const form = new FormData(); - form.append("deviceId", deviceId); + form.append("deviceId", serialNumber); for (const f of files) { form.append("files", f); } diff --git a/visionA-frontend/src/stores/device-store.test.ts b/visionA-frontend/src/stores/device-store.test.ts index 0775e55..b996fda 100644 --- a/visionA-frontend/src/stores/device-store.test.ts +++ b/visionA-frontend/src/stores/device-store.test.ts @@ -175,6 +175,31 @@ describe("useDeviceStore", () => { }); }); + it("serial_number 正規化:snake_case / camelCase 都吃、缺欄位與空字串 → null(WP-C)", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + jsonResponse({ + success: true, + data: [ + // snake_case(後端實際形狀,omitempty) + { id: "dev-1", name: "A", type: "kl520", status: "connected", serial_number: "KN00123456" }, + // camelCase 容錯 + { id: "dev-2", name: "B", type: "kl720", status: "connected", serialNumber: "KN99887766" }, + // 缺欄位(舊資料 / 未回報)→ null + { id: "dev-3", name: "C", type: "kl520", status: "connected" }, + // 空字串(防禦性;無法路由)→ null + { id: "dev-4", name: "D", type: "kl520", status: "connected", serial_number: "" }, + ], + }), + ); + + await useDeviceStore.getState().fetchDevices(); + const { devices } = useDeviceStore.getState(); + expect(devices[0]?.serialNumber).toBe("KN00123456"); + expect(devices[1]?.serialNumber).toBe("KN99887766"); + expect(devices[2]?.serialNumber).toBeNull(); + expect(devices[3]?.serialNumber).toBeNull(); + }); + it("fetchDevices 遇到 501 NOT_IMPLEMENTED 時視為空 list,不記錯誤", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( jsonResponse( @@ -228,6 +253,22 @@ describe("useDeviceStore", () => { expect(useDeviceStore.getState().connectingId).toBeNull(); }); + it("connect / disconnect 的 path 識別值 = 傳入的 serialNumber(WP-C serial 路由)", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(jsonResponse({ success: true, data: {} })); + + await useDeviceStore.getState().connectDevice("KN00123456"); + expect(String(fetchSpy.mock.calls[0]?.[0])).toContain( + "/api/devices/KN00123456/connect", + ); + + await useDeviceStore.getState().disconnectDevice("KN00123456"); + expect(String(fetchSpy.mock.calls[1]?.[0])).toContain( + "/api/devices/KN00123456/disconnect", + ); + }); + it("connectDevice 失敗時回傳 false 且寫入 error", async () => { const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( jsonResponse( diff --git a/visionA-frontend/src/stores/device-store.ts b/visionA-frontend/src/stores/device-store.ts index 0c72132..ed03cf1 100644 --- a/visionA-frontend/src/stores/device-store.ts +++ b/visionA-frontend/src/stores/device-store.ts @@ -50,6 +50,14 @@ export interface DeviceSummary { name: string; /** 使用者自訂別名(後端或本機偏好皆可) */ alias?: string; + /** + * Kneron 硬體序號(kn_number)— ADR-018 serial 路由的識別值。 + * 後端 JSON key 為 `serial_number`(omitempty):舊資料 / 未串通 / 假序號 + * `0x00000000` 皆為缺省 → null。null 時「路由到 local agent 的操作」 + * (camera / media / inference WS / flash / connect / disconnect)必須 disable + * (FE-A 混合模型;DB 操作如列表 / 詳情 / unpair 仍用 UUID `id`)。 + */ + serialNumber?: string | null; type: string; /** USB 層級狀態;離線時可能來自 cache */ status: DeviceHardwareStatus; @@ -104,10 +112,14 @@ function normalizeDevice(raw: unknown): Device { const rawRemoteStatus = pick("remote_status", "remoteStatus") as | RemoteStatus | undefined; + // serial_number 為 omitempty:缺欄位 → null;防禦性把空字串也視為 null + // (空字串無法路由,語意上等同「未回報序號」)。 + const rawSerial = pick("serial_number", "serialNumber"); return { id: String(pick("id") ?? ""), name: String(pick("name") ?? pick("device_name") ?? ""), alias: pick("alias") ?? undefined, + serialNumber: rawSerial ? String(rawSerial) : null, type: String(pick("type", "device_type") ?? ""), status: (pick("status") as DeviceHardwareStatus) ?? "disconnected", remoteStatus: @@ -143,7 +155,7 @@ interface DeviceState { devices: DeviceSummary[]; selectedDevice: Device | null; isLoading: boolean; - /** 連線中的裝置 id(UI 顯示 button spinner);不使用就是 null */ + /** 連線中的裝置識別值(serial 路由後為 serialNumber;UI 顯示 button spinner);不使用就是 null */ connectingId: string | null; disconnectingId: string | null; /** 移除(unpair)中的裝置 id(UI 顯示 button spinner / disable 確認鈕);不使用就是 null */ @@ -154,10 +166,17 @@ interface DeviceState { fetchDevices: () => Promise; /** 呼叫 `GET /api/devices/:id` */ fetchDevice: (id: string) => Promise; - /** 呼叫 `POST /api/devices/:id/connect` */ - connectDevice: (id: string) => Promise; - /** 呼叫 `POST /api/devices/:id/disconnect` */ - disconnectDevice: (id: string) => Promise; + /** + * 呼叫 `POST /api/devices/:serialNumber/connect`。 + * + * ⚠️ ADR-018 serial 路由:connect 是純 proxy 操作(雲端零邏輯、透傳 local agent, + * 見 wp-c-connect-disconnect-routing-verification.md §1),path 識別值必須帶 + * `device.serialNumber`(kn_number),不是雲端 UUID。serial 為空的裝置不可呼叫 + * (呼叫端按鈕 disable)。 + */ + connectDevice: (serialNumber: string) => Promise; + /** 呼叫 `POST /api/devices/:serialNumber/disconnect`(serial 路由,同 connectDevice)。 */ + disconnectDevice: (serialNumber: string) => Promise; /** 呼叫 `POST /api/devices/:id/unpair`(軟刪裝置 + cascade 撤銷 pairing/session token) */ unpairDevice: (id: string) => Promise; /** 測試 / 雛形用:直接塞 list */ @@ -208,10 +227,12 @@ export const useDeviceStore = create()((set) => ({ } }, - connectDevice: async (id) => { - set({ connectingId: id, error: null }); + // serial 路由(ADR-018):path 帶 serialNumber(kn_number),local agent 端以 + // serialToLocalID 反查 sessions;帶 UUID 會 "device not found"。 + connectDevice: async (serialNumber) => { + set({ connectingId: serialNumber, error: null }); try { - await api.post(`/api/devices/${encodeURIComponent(id)}/connect`); + await api.post(`/api/devices/${encodeURIComponent(serialNumber)}/connect`); set({ connectingId: null }); return true; } catch (err) { @@ -221,10 +242,10 @@ export const useDeviceStore = create()((set) => ({ } }, - disconnectDevice: async (id) => { - set({ disconnectingId: id, error: null }); + disconnectDevice: async (serialNumber) => { + set({ disconnectingId: serialNumber, error: null }); try { - await api.post(`/api/devices/${encodeURIComponent(id)}/disconnect`); + await api.post(`/api/devices/${encodeURIComponent(serialNumber)}/disconnect`); set({ disconnectingId: null }); return true; } catch (err) { diff --git a/visionA-frontend/src/stores/flash-store.test.ts b/visionA-frontend/src/stores/flash-store.test.ts index 0fde7ea..03c30e4 100644 --- a/visionA-frontend/src/stores/flash-store.test.ts +++ b/visionA-frontend/src/stores/flash-store.test.ts @@ -47,17 +47,21 @@ describe("flash-store", () => { expect(s.error).toBeNull(); }); - it("startFlash 成功:POST 帶 modelId、記 lastFlashParams、加 flash_start activity", async () => { + it("startFlash 成功:POST path 帶 serial、body 帶 modelId、記 lastFlashParams、加 flash_start activity", async () => { mockPost.mockResolvedValueOnce({ taskId: "t-1" }); - await useFlashStore.getState().startFlash("dev-1", "model-9"); + // WP-C(ADR-018):識別值是 serialNumber(kn_number),path 形狀不變 + await useFlashStore.getState().startFlash("KN00123456", "model-9"); - expect(mockPost).toHaveBeenCalledWith("/api/devices/dev-1/flash", { + expect(mockPost).toHaveBeenCalledWith("/api/devices/KN00123456/flash", { modelId: "model-9", }); const s = useFlashStore.getState(); expect(s.isFlashing).toBe(true); // 觸發成功,等 WS 推完成 expect(s.error).toBeNull(); - expect(s.lastFlashParams).toEqual({ deviceId: "dev-1", modelId: "model-9" }); + expect(s.lastFlashParams).toEqual({ + serialNumber: "KN00123456", + modelId: "model-9", + }); expect(useActivityStore.getState().activities[0]?.type).toBe("flash_start"); }); @@ -73,7 +77,7 @@ describe("flash-store", () => { expect(useActivityStore.getState().activities[0]?.type).toBe("flash_error"); }); - it("startFlash: deviceId 特殊字元 encode", async () => { + it("startFlash: serialNumber 特殊字元 encode", async () => { mockPost.mockResolvedValueOnce({}); await useFlashStore.getState().startFlash("a/b", "m1"); expect(mockPost).toHaveBeenCalledWith("/api/devices/a%2Fb/flash", { @@ -82,7 +86,7 @@ describe("flash-store", () => { }); it("updateProgress: error 有值 → 失敗態", () => { - useFlashStore.setState({ isFlashing: true, lastFlashParams: { deviceId: "d", modelId: "m" } }); + useFlashStore.setState({ isFlashing: true, lastFlashParams: { serialNumber: "d", modelId: "m" } }); useFlashStore.getState().updateProgress({ percent: 40, stage: "loading", error: "boom" }); const s = useFlashStore.getState(); expect(s.isFlashing).toBe(false); @@ -99,7 +103,7 @@ describe("flash-store", () => { }); it("updateProgress: percent>=100 → 完成、加 flash_complete activity", () => { - useFlashStore.setState({ isFlashing: true, lastFlashParams: { deviceId: "d", modelId: "m" } }); + useFlashStore.setState({ isFlashing: true, lastFlashParams: { serialNumber: "d", modelId: "m" } }); useFlashStore.getState().updateProgress({ percent: 100, stage: "done" }); const s = useFlashStore.getState(); expect(s.isFlashing).toBe(false); @@ -128,7 +132,7 @@ describe("flash-store", () => { isFlashing: true, progress: { percent: 50, stage: "x" }, error: "e", - lastFlashParams: { deviceId: "d", modelId: "m" }, + lastFlashParams: { serialNumber: "d", modelId: "m" }, }); useFlashStore.getState().reset(); const s = useFlashStore.getState(); diff --git a/visionA-frontend/src/stores/flash-store.ts b/visionA-frontend/src/stores/flash-store.ts index 8484df0..a02013f 100644 --- a/visionA-frontend/src/stores/flash-store.ts +++ b/visionA-frontend/src/stores/flash-store.ts @@ -43,7 +43,7 @@ export interface FlashProgress { error?: string; } -/** `POST /api/devices/:id/flash` 回傳(envelope 已由 api.post 解開,剩 data)。 */ +/** `POST /api/devices/:serialNumber/flash` 回傳(envelope 已由 api.post 解開,剩 data)。 */ interface FlashResponse { taskId?: string; } @@ -52,10 +52,17 @@ interface FlashState { isFlashing: boolean; progress: FlashProgress | null; error: string | null; - lastFlashParams: { deviceId: string; modelId: string } | null; + lastFlashParams: { serialNumber: string; modelId: string } | null; - /** 觸發 flash:`POST /api/devices/:id/flash { modelId }`(throw-based)。 */ - startFlash: (deviceId: string, modelId: string) => Promise; + /** + * 觸發 flash:`POST /api/devices/:serialNumber/flash { modelId }`(throw-based)。 + * + * ⚠️ ADR-018 serial 路由:flash 是純 proxy 操作、path 識別值必須帶 + * `device.serialNumber`(kn_number)而非雲端 UUID——local agent 端以 + * serialToLocalID 反查 sessions,帶 UUID 會 "device not found"。 + * serial 為空的裝置不可呼叫(FlashDialog 端 disable)。 + */ + startFlash: (serialNumber: string, modelId: string) => Promise; /** 消費 WS 推來的進度(error / 完成 / 更新)。 */ updateProgress: (progress: FlashProgress) => void; /** 直接設定錯誤(例如 WS 斷線 / 前端層錯誤)。 */ @@ -72,23 +79,24 @@ export const useFlashStore = create()((set, get) => ({ error: null, lastFlashParams: null, - startFlash: async (deviceId, modelId) => { + startFlash: async (serialNumber, modelId) => { set({ isFlashing: true, progress: null, error: null, - lastFlashParams: { deviceId, modelId }, + lastFlashParams: { serialNumber, modelId }, }); try { await api.post( - `/api/devices/${encodeURIComponent(deviceId)}/flash`, + `/api/devices/${encodeURIComponent(serialNumber)}/flash`, { modelId }, ); // POST 成功只代表「觸發成功」,實際進度 / 完成由 WS 推送。 + // activity 的 deviceId 欄位是「裝置參考字串」(純顯示 / log),帶 serial 即可。 useActivityStore.getState().addActivity({ type: "flash_start", message: "Flash started", - deviceId, + deviceId: serialNumber, modelId, }); } catch (err) { @@ -103,7 +111,7 @@ export const useFlashStore = create()((set, get) => ({ useActivityStore.getState().addActivity({ type: "flash_error", message: `Flash failed: ${message}`, - deviceId, + deviceId: serialNumber, modelId, }); } @@ -116,7 +124,7 @@ export const useFlashStore = create()((set, get) => ({ useActivityStore.getState().addActivity({ type: "flash_error", message: `Flash failed: ${progress.error}`, - deviceId: lastFlashParams?.deviceId, + deviceId: lastFlashParams?.serialNumber, modelId: lastFlashParams?.modelId, }); return; @@ -128,7 +136,7 @@ export const useFlashStore = create()((set, get) => ({ useActivityStore.getState().addActivity({ type: "flash_complete", message: "Flash completed", - deviceId: lastFlashParams?.deviceId, + deviceId: lastFlashParams?.serialNumber, modelId: lastFlashParams?.modelId, }); } @@ -141,7 +149,7 @@ export const useFlashStore = create()((set, get) => ({ retryFlash: async () => { const { lastFlashParams } = get(); if (!lastFlashParams) return; - await get().startFlash(lastFlashParams.deviceId, lastFlashParams.modelId); + await get().startFlash(lastFlashParams.serialNumber, lastFlashParams.modelId); }, reset: () => {