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}
)}
-
+
{t("workspace.inference.start")}
+ {!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 && (
{t("devices.openWorkspace")}
)}
+ {isOnline && device.flashedModel && !hasSerial && (
+
+
+
+
+ {t("devices.openWorkspace")}
+
+
+
+ {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) {
- {!selectedModelId
- ? t("devices.flash.selectModel")
- : !compatible
- ? t("devices.flash.incompatibleCannotFlash")
- : t("devices.flash.startFlash")}
+ {!serialNumber
+ ? t("devices.serial.missing")
+ : !selectedModelId
+ ? t("devices.flash.selectModel")
+ : !compatible
+ ? t("devices.flash.incompatibleCannotFlash")
+ : t("devices.flash.startFlash")}
>
) : (
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: () => {