feat(frontend): WP-C serial 路由落地(ADR-018 FE-A 混合模型)

- 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 <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-07-10 09:58:52 +08:00
parent 12b1fe3bad
commit 12f5bf6c47
18 changed files with 592 additions and 118 deletions

View File

@ -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-CADR-018路由類操作flash / 工作區)需要 serial
serialNumber: "KN00123456",
};
function renderDetail(id = "dev-1") {
return render(
<LocaleProvider>
<DeviceDetailClient id={id} />
<TooltipProvider>
<DeviceDetailClient id={id} />
</TooltipProvider>
</LocaleProvider>,
);
}
@ -170,3 +175,45 @@ describe("DeviceDetailClient 移除裝置按鈕", () => {
expect(btn).toHaveTextContent("移除中");
});
});
describe("DeviceDetailClient serial 路由 gatingWP-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();
});
});

View File

@ -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-CADR-018serial 為空 → 路由類操作flash / 工作區無法路由disable。
// 詳情頁本身fetchDevice / unpair維持 UUID不受 serial 影響。
const hasSerial = !!selectedDevice.serialNumber;
return (
<div className="mx-auto max-w-5xl space-y-6 px-6 py-8">
@ -166,15 +169,41 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) {
</div>
<div className="flex items-center gap-2">
{/* flash flash disable
WP-CADR-018flash serial serial disable + tooltip
flash dialog fetchDevice flashedModel
gate gate */}
<FlashDialog deviceId={selectedDevice.id} disabled={!isOnline} />
{hasSerial ? (
<FlashDialog deviceId={selectedDevice.id} disabled={!isOnline} />
) : (
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button disabled data-testid="flash-no-serial-disabled">
{t("devices.flash.flashModel")}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>{t("devices.serial.missingHint")}</TooltipContent>
</Tooltip>
)}
{isOnline && selectedDevice.flashedModel && (
{isOnline && selectedDevice.flashedModel && hasSerial && (
<Link href={`/workspace/${selectedDevice.id}`}>
<Button>{t("devices.openWorkspace")}</Button>
</Link>
)}
{isOnline && selectedDevice.flashedModel && !hasSerial && (
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button disabled data-testid="workspace-no-serial-disabled">
{t("devices.openWorkspace")}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>{t("devices.serial.missingHint")}</TooltipContent>
</Tooltip>
)}
{!isOnline && (
<Tooltip>
<TooltipTrigger asChild>
@ -253,6 +282,21 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) {
</CardHeader>
<CardContent className="space-y-3">
<InfoRow label={t("devices.detail.id")} value={<span className="font-mono text-sm">{selectedDevice.id}</span>} />
{/* WP-CADR-018顯示硬體序號kn_number未回報時提示需重新配對 */}
<InfoRow
label={t("devices.serial.label")}
value={
selectedDevice.serialNumber ? (
<span className="font-mono text-sm">
{selectedDevice.serialNumber}
</span>
) : (
<span className="text-muted-foreground">
{t("devices.serial.missing")}
</span>
)
}
/>
<InfoRow label={t("devices.detail.type")} value={selectedDevice.type || "—"} />
<InfoRow
label={t("devices.detail.firmware")}

View File

@ -60,6 +60,22 @@ function seedOnlineDevice() {
name: "KL520",
remoteStatus: "online",
lastSeenAt: null,
// WP-CADR-018camera/media/WS 操作識別值 = serialNumber
serialNumber: "KN00123456",
} as ReturnType<typeof useDeviceStore.getState>["selectedDevice"],
isLoading: false,
});
}
/** WP-Cseed 一台線上但「未回報序號」的裝置(舊資料 / 假序號 → null。 */
function seedOnlineDeviceWithoutSerial() {
useDeviceStore.setState({
selectedDevice: {
id: "dev-1",
name: "KL520",
remoteStatus: "online",
lastSeenAt: null,
serialNumber: null,
} as ReturnType<typeof useDeviceStore.getState>["selectedDevice"],
isLoading: false,
});
@ -181,8 +197,11 @@ describe("WorkspaceClient — 切 tab 時 camera 串流 / WS 正確關閉S-1
await switchTab("圖片");
// handleTabChange 應自動停掉 cameraPOST /api/camera/stop
// WP-CADR-018body 的 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();
});
});

View File

@ -20,6 +20,14 @@
*
* Workspace
* remoteStatus != online offline
*
* ADR-018 serial WP-C
* - local agent camera start/stopmedia uploadinference WS
* `device.serialNumber`kn_number UUID
* - `[deviceId]` UUIDfetchDevice DB FE-A
* serial fetch selectedDevice
* - serial / banner
* disablemedia 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-CADR-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:<deviceId>`(只在 camera tab + 推論中 + 線上時連線)。
// 塊 2/3訂閱 camera WS `inference:<serial>`(只在 camera tab + 推論中 + 線上 + 有 serial 時連線)。
// media tab 有自己的 useInferenceStream在 MediaTab 內),兩者靠 activeTab 互斥。
useInferenceStream(deviceId, activeTab === "camera" && isRunning && isOnline);
// WP-CWS 識別值用 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-Cserial 為空無法路由(按鈕已 disable此為防禦性 guard
if (!serialNumber) return;
setBusy(true);
try {
// 契約修正(評估 §3.1 R-C1camera pipeline 入口為 POST /api/camera/start
// body 帶 deviceId後端回傳 { streamUrl, sourceType }camera_handler.go:119-125
// WP-CADR-018deviceId 欄位帶 serial欄位名不動、值換 serialNumber
const data = await api.post<MediaUploadResponse>("/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-CADR-018與 start 一致deviceId 欄位帶 serial。
// serial 為空時不可能已 starthandleStart 有 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-Cmedia tab 在 serial 為空時的占位提示(無法上傳——上傳的識別值就是 serial
const noSerialNotice = (
<Card className="min-h-[60vh]">
<CardContent className="grid h-full min-h-[60vh] place-items-center p-6">
<div className="max-w-md space-y-2 text-center" data-testid="media-no-serial">
<p className="text-sm font-medium">{t("workspace.noSerial.title")}</p>
<p className="text-muted-foreground text-sm">
{t("workspace.noSerial.description")}
</p>
</div>
</CardContent>
</Card>
);
return (
<div className="mx-auto max-w-7xl space-y-4 px-6 py-8">
<div className="flex flex-wrap items-center justify-between gap-3">
@ -206,13 +244,30 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
{t("workspace.inference.stop")}
</Button>
) : (
<Button onClick={handleStart} disabled={busy || !isOnline}>
// WP-Cserial 為空無法路由 → disable原因見下方 no-serial banner
<Button onClick={handleStart} disabled={busy || !isOnline || !hasSerial}>
{t("workspace.inference.start")}
</Button>
)}
</div>
</div>
{/* WP-CADR-018serial 為空 → 無法路由到 local agent說明原因與解法 */}
{device && !hasSerial && (
<div
role="alert"
className="bg-warning-subtle border-warning text-warning-foreground flex items-start gap-3 rounded-lg border p-4"
data-testid="workspace-no-serial-banner"
>
<div className="flex-1 space-y-1">
<p className="text-sm font-medium">{t("workspace.noSerial.title")}</p>
<p className="text-xs opacity-90">
{t("workspace.noSerial.description")}
</p>
</div>
</div>
)}
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList>
<TabsTrigger value="camera">{t("workspace.tabs.camera")}</TabsTrigger>
@ -266,59 +321,72 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
</div>
</TabsContent>
{/* 塊 3圖片 / 影片 / 批次 — 共用 MediaTab上傳 → CameraFeed + overlay + panel */}
{/* 塊 3圖片 / / MediaTab CameraFeed + overlay + panel
WP-CADR-018 serialserial */}
<TabsContent value="image" className="space-y-4">
<MediaTab
deviceId={deviceId}
isOnline={!!isOnline}
sourceType="image"
accept={IMAGE_ACCEPT}
validate={(files) =>
files[0] ? validateImageFile(files[0]) : { code: "EMPTY" }
}
upload={async (files, ctx) => {
return uploadImage(deviceId, files[0]!, {
onProgress: ctx.onProgress,
signal: ctx.signal,
});
}}
/>
{!hasSerial ? (
noSerialNotice
) : (
<MediaTab
deviceId={serialNumber!}
isOnline={!!isOnline}
sourceType="image"
accept={IMAGE_ACCEPT}
validate={(files) =>
files[0] ? validateImageFile(files[0]) : { code: "EMPTY" }
}
upload={async (files, ctx) => {
return uploadImage(serialNumber!, files[0]!, {
onProgress: ctx.onProgress,
signal: ctx.signal,
});
}}
/>
)}
</TabsContent>
<TabsContent value="video" className="space-y-4">
<MediaTab
deviceId={deviceId}
isOnline={!!isOnline}
sourceType="video"
accept={VIDEO_ACCEPT}
validate={(files) =>
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
) : (
<MediaTab
deviceId={serialNumber!}
isOnline={!!isOnline}
sourceType="video"
accept={VIDEO_ACCEPT}
validate={(files) =>
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,
});
}}
/>
)}
</TabsContent>
<TabsContent value="batch" className="space-y-4">
<MediaTab
deviceId={deviceId}
isOnline={!!isOnline}
sourceType="batch_image"
accept={IMAGE_ACCEPT}
multiple
validate={validateBatchFiles}
upload={async (files, ctx) => {
return uploadBatchImages(deviceId, files, {
onProgress: ctx.onProgress,
signal: ctx.signal,
});
}}
/>
{!hasSerial ? (
noSerialNotice
) : (
<MediaTab
deviceId={serialNumber!}
isOnline={!!isOnline}
sourceType="batch_image"
accept={IMAGE_ACCEPT}
multiple
validate={validateBatchFiles}
upload={async (files, ctx) => {
return uploadBatchImages(serialNumber!, files, {
onProgress: ctx.onProgress,
signal: ctx.signal,
});
}}
/>
)}
</TabsContent>
</Tabs>

View File

@ -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) => (
<Link key={d.id} href={`/workspace/${d.id}`}>
<Card className="hover:bg-accent cursor-pointer transition-colors">
{online.map((d) => {
// WP-CADR-018serial 為空的裝置無法路由(推論類操作全不可用),
// 卡片不可點、按鈕 disable、顯示原因。
const hasSerial = !!d.serialNumber;
const card = (
<Card
className={
hasSerial
? "hover:bg-accent cursor-pointer transition-colors"
: "opacity-75"
}
>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<CardTitle className="truncate text-base">
@ -76,13 +85,27 @@ export default function WorkspaceIndexPage() {
{d.flashedModel}
</p>
)}
<Button size="sm" className="mt-3">
<Button size="sm" className="mt-3" disabled={!hasSerial}>
{t("workspace.inference.start")}
</Button>
{!hasSerial && (
<p className="text-muted-foreground mt-2 text-xs">
{t("devices.serial.missingHint")}
</p>
)}
</CardContent>
</Card>
</Link>
))}
);
return hasSerial ? (
<Link key={d.id} href={`/workspace/${d.id}`}>
{card}
</Link>
) : (
<div key={d.id} data-testid="workspace-card-no-serial">
{card}
</div>
);
})}
</div>
)}
</div>

View File

@ -0,0 +1,65 @@
/**
* DeviceCard WP-CADR-018serial 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(
<LocaleProvider>
<TooltipProvider>
<DeviceCard device={device} />
</TooltipProvider>
</LocaleProvider>,
);
}
describe("DeviceCard — serial 路由 gatingWP-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();
});
});

View File

@ -14,6 +14,8 @@
* - `RemoteDeviceBadge`
* - remoteStatus != online opacity-75 + disabled
* - remoteStatus=online flow-offline-handling §4.1
* - WP-CADR-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-CADR-018serial 為空 → 工作區(推論類操作)無法路由,入口 disable。
const hasSerial = !!device.serialNumber;
return (
<Card
@ -84,11 +93,23 @@ export function DeviceCard({ device }: DeviceCardProps) {
{t("common.manage")}
</Button>
</Link>
{isOnline && device.flashedModel && (
{isOnline && device.flashedModel && hasSerial && (
<Link href={`/workspace/${device.id}`}>
<Button size="sm">{t("devices.openWorkspace")}</Button>
</Link>
)}
{isOnline && device.flashedModel && !hasSerial && (
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button size="sm" disabled data-testid="workspace-no-serial-disabled">
{t("devices.openWorkspace")}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>{t("devices.serial.missingHint")}</TooltipContent>
</Tooltip>
)}
</div>
</CardContent>
</Card>

View File

@ -42,6 +42,8 @@ const device: Device = {
status: "connected",
remoteStatus: "online",
flashedModel: null,
// WP-CADR-018flash 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 });

View File

@ -50,6 +50,11 @@ import { useFlashStore } from "@/stores/flash-store";
import { useModelStore } from "@/stores/model-store";
interface FlashDialogProps {
/**
* device UUID selectedDevice flash fetchDeviceDB
* FE-A UUIDflash POST WS
* serialNumberADR-018 serial selectedDevice
*/
deviceId: string;
/** 裝置離線時不可 flashdisable 觸發鈕(呼叫端傳入)。 */
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-CADR-018flash 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-Cserial 為空無法路由(按鈕已 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) {
<Button
onClick={handleFlash}
disabled={!selectedModelId || !compatible}
disabled={!selectedModelId || !compatible || !serialNumber}
className="w-full"
data-testid="flash-start-btn"
>
{!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")}
</Button>
</>
) : (

View File

@ -12,7 +12,7 @@
*
* ** hook 2 race**
* `useWebSocket` `onOpen` callback POST `beginFlash(modelId)`
* `enabled=true` + modelIdWS open `onOpen` `startFlash(deviceId, modelId)`
* `enabled=true` + modelIdWS open `onOpen` `startFlash(serialNumber, modelId)`
* open WS POSTflash 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 roomPOST serialWS 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,

View File

@ -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`= seriallocal 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 跑完)— 目前不需特別處理,忽略非結果訊息

View File

@ -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",

View File

@ -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": "影片",

View File

@ -202,38 +202,46 @@ function postMultipart(
});
}
/*
* ADR-018 serial upload
* `serialNumber`kn_number device UUIDlocal agent
* `camera_handler.go` body `deviceId` sessionsserial UUID
* "device not found"FormData `deviceId`
* serial workspace disable
*/
/** 上傳單張圖片 → 開始推論;回傳含 streamUrl 的 response。 */
export function uploadImage(
deviceId: string,
serialNumber: string,
file: File,
options?: UploadMediaOptions,
): Promise<MediaUploadResponse> {
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<MediaUploadResponse> {
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<MediaUploadResponse> {
const form = new FormData();
form.append("deviceId", deviceId);
form.append("deviceId", serialNumber);
for (const f of files) {
form.append("files", f);
}

View File

@ -175,6 +175,31 @@ describe("useDeviceStore", () => {
});
});
it("serial_number 正規化snake_case / camelCase 都吃、缺欄位與空字串 → nullWP-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 識別值 = 傳入的 serialNumberWP-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(

View File

@ -50,6 +50,14 @@ export interface DeviceSummary {
name: string;
/** 使用者自訂別名(後端或本機偏好皆可) */
alias?: string;
/**
* Kneron kn_number ADR-018 serial
* JSON key `serial_number`omitempty / /
* `0x00000000` nullnull 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<string>("remote_status", "remoteStatus") as
| RemoteStatus
| undefined;
// serial_number 為 omitempty缺欄位 → null防禦性把空字串也視為 null
// (空字串無法路由,語意上等同「未回報序號」)。
const rawSerial = pick<string>("serial_number", "serialNumber");
return {
id: String(pick<string>("id") ?? ""),
name: String(pick<string>("name") ?? pick<string>("device_name") ?? ""),
alias: pick<string>("alias") ?? undefined,
serialNumber: rawSerial ? String(rawSerial) : null,
type: String(pick<string>("type", "device_type") ?? ""),
status: (pick<string>("status") as DeviceHardwareStatus) ?? "disconnected",
remoteStatus:
@ -143,7 +155,7 @@ interface DeviceState {
devices: DeviceSummary[];
selectedDevice: Device | null;
isLoading: boolean;
/** 連線中的裝置 idUI 顯示 button spinner不使用就是 null */
/** 連線中的裝置識別值serial 路由後為 serialNumberUI 顯示 button spinner不使用就是 null */
connectingId: string | null;
disconnectingId: string | null;
/** 移除unpair中的裝置 idUI 顯示 button spinner / disable 確認鈕);不使用就是 null */
@ -154,10 +166,17 @@ interface DeviceState {
fetchDevices: () => Promise<void>;
/** 呼叫 `GET /api/devices/:id` */
fetchDevice: (id: string) => Promise<void>;
/** 呼叫 `POST /api/devices/:id/connect` */
connectDevice: (id: string) => Promise<boolean>;
/** 呼叫 `POST /api/devices/:id/disconnect` */
disconnectDevice: (id: string) => Promise<boolean>;
/**
* `POST /api/devices/:serialNumber/connect`
*
* ADR-018 serial connect proxy local agent
* wp-c-connect-disconnect-routing-verification.md §1path
* `device.serialNumber`kn_number UUIDserial
* disable
*/
connectDevice: (serialNumber: string) => Promise<boolean>;
/** 呼叫 `POST /api/devices/:serialNumber/disconnect`serial 路由,同 connectDevice。 */
disconnectDevice: (serialNumber: string) => Promise<boolean>;
/** 呼叫 `POST /api/devices/:id/unpair`(軟刪裝置 + cascade 撤銷 pairing/session token */
unpairDevice: (id: string) => Promise<UnpairResult>;
/** 測試 / 雛形用:直接塞 list */
@ -208,10 +227,12 @@ export const useDeviceStore = create<DeviceState>()((set) => ({
}
},
connectDevice: async (id) => {
set({ connectingId: id, error: null });
// serial 路由ADR-018path 帶 serialNumberkn_numberlocal 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<DeviceState>()((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) {

View File

@ -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-CADR-018識別值是 serialNumberkn_numberpath 形狀不變
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();

View File

@ -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<void>;
/**
* flash`POST /api/devices/:serialNumber/flash { modelId }`throw-based
*
* ADR-018 serial flash proxy path
* `device.serialNumber`kn_number UUIDlocal agent
* serialToLocalID sessions UUID "device not found"
* serial FlashDialog disable
*/
startFlash: (serialNumber: string, modelId: string) => Promise<void>;
/** 消費 WS 推來的進度error / 完成 / 更新)。 */
updateProgress: (progress: FlashProgress) => void;
/** 直接設定錯誤(例如 WS 斷線 / 前端層錯誤)。 */
@ -72,23 +79,24 @@ export const useFlashStore = create<FlashState>()((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<FlashResponse>(
`/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<FlashState>()((set, get) => ({
useActivityStore.getState().addActivity({
type: "flash_error",
message: `Flash failed: ${message}`,
deviceId,
deviceId: serialNumber,
modelId,
});
}
@ -116,7 +124,7 @@ export const useFlashStore = create<FlashState>()((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<FlashState>()((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<FlashState>()((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: () => {