diff --git a/docs/autoflow/04-architecture/TDD.md b/docs/autoflow/04-architecture/TDD.md index a0eec84..e76ed75 100644 --- a/docs/autoflow/04-architecture/TDD.md +++ b/docs/autoflow/04-architecture/TDD.md @@ -582,11 +582,24 @@ interface Device { lastSeenAt?: string; // ISO 8601 lastConnectedAt?: string; - // USB-level(local agent 上報) - status: 'online' | 'offline' | 'unknown'; + // USB-level / driver-level(local agent 上報) + // ⚠️ 型別更正(2026-07-16):舊版 TDD 誤寫為 'online'|'offline'|'unknown', + // 與實際 code 不符。實際型別為 DeviceHardwareStatus(device-store.ts:34-42), + // 以 code 為準: + status: DeviceHardwareStatus; // ... } + +// DeviceHardwareStatus(device-store.ts:34-42)— driver 連線狀態的完整 enum +type DeviceHardwareStatus = + | 'detected' // USB 偵測到、driver 尚未 connect + | 'connecting' // driver 正在 connect(重量級操作進行中) + | 'connected' // driver 已 connect,可 flash / 推論 + | 'flashing' // 正在燒錄韌體(已 connect) + | 'inferencing' // 正在推論(已 connect) + | 'error' // driver 錯誤 + | 'disconnected'; // driver 未連線 ``` 前端顯示邏輯: @@ -594,15 +607,44 @@ interface Device { | 畫面位置 | 顯示依據 | |---------|---------| | Device 卡片主狀態 badge | **`remoteStatus`**(決定「雲端能不能操作它」)| -| Device 卡片副狀態 | `status`(USB 是否插著)— 僅在 `remoteStatus === 'online'` 時顯示有意義 | +| Device 卡片副狀態 | `status`(driver 連線狀態、七值 enum)— 僅在 `remoteStatus === 'online'` 時顯示有意義 | | 「最後上線」時間 | `lastSeenAt`(tunnel 最後心跳) | -| Inference / Flash 按鈕啟用條件 | `remoteStatus === 'online' && status === 'online'` | +| Inference / Flash 按鈕啟用條件 | `remoteStatus === 'online' && status ∈ {connected, flashing, inferencing}`(driver 已 connect;詳見 §10.5.1.1 兩步式 gate) | | 「重連中」loading | `remoteStatus === 'reconnecting'` | | 「離線,請檢查 local-tool」提示 | `remoteStatus === 'offline'` | -邏輯總結:前端永遠先看 `remoteStatus`(雲端是否可達),再看 `status`(USB 是否接著)。兩者獨立更新,前端要訂閱兩種事件: +邏輯總結:前端永遠先看 `remoteStatus`(雲端是否可達),再看 `status`(driver 是否已 connect)。兩者獨立更新: - `remoteStatus` 變化 → `WS /ws/devices/:id/remote-status`(來自 remote-proxy 轉發) -- `status` 變化 → `WS /ws/devices/:id/status`(來自 local agent 的 USB 監聽事件) +- `status` 變化 → 目前**輪詢式**(connect / flash 完成後 `fetchDevice` 重拉)。device events WS(`/ws/devices/events`)end-to-end 尚未打通(backend 為 501 stub,屬 B7),故不依賴 WS 推 driver 狀態。詳見 §10.5.1.1。 + +#### 10.5.1.1 Driver-level connect(兩步式操作,方案 A) + +Kneron 裝置的 flash / 推論都要求 driver 先 `Connect()`(重量級操作,最壞 60–65s:KL520 reset + firmware reload + reconnect)。雲端前端在 device 詳情頁提供獨立「連線 / 斷線」按鈕承載這一步(對齊 local-tool POC 的兩步式:先 connect device → 再載入模型 / 推論)。 + +**driver `status` vs `remoteStatus` 的區別(本次核心)**: + +| 欄位 | 語意 | 值 | 決定什麼 | +|------|------|-----|---------| +| `remoteStatus` | tunnel-level(雲端能不能觸達 local agent) | `online` / `offline` / `reconnecting` / `error` | local agent 在不在線 | +| `status`(driver) | driver-level(Kneron driver 有沒有 `Connect()`) | `DeviceHardwareStatus` 七值 | 能不能 flash / 推論 | + +`remoteStatus === 'online'` 只代表「雲端可下指令給 local agent」;能否 flash / 推論還要看 driver 是否 `connected`(`detected` = USB 偵測到但 driver 未 connect,此時 flash 會被 local agent 擋成 400 `device not connected`)。這是兩步式 connect 的存在理由。 + +- **路由**:connect / disconnect 走 **serial 路由**(ADR-018 / WP-C)。`device-store.connectDevice(serial)` / `disconnectDevice(serial)`,path `POST /api/devices/:serial/connect`(`:id` 段填 serial number / kn_number,非雲端 UUID)。雲端純 proxy、零邏輯。 + +- **gate 三分支**(前提 `remoteStatus === 'online' && serialNumber` 存在): + + | driver `status` | 連線按鈕 | FlashDialog(載入模型) | 開啟工作區 | + |------|------|------|------| + | `detected` / `disconnected` / `error` | 顯示「連線」(primary) | disable(tooltip「請先連線裝置」) | disable | + | `connecting`(或本地 `connectingId`) | loading「連線中…」、disable(防重點) | disable | disable | + | `connected` / `flashing` / `inferencing` | 顯示「斷線」(outline) | enable | 沿用 `flashedModel` gate | + +- **狀態刷新(connect 60s 成本)**:connect 為重量級操作,最壞 60–65s。按下「連線」立即進 loading(本地 `connectingId` 旗標,同步生效),並顯示 hint「首次連線需載入韌體,約 1 分鐘,請勿關閉頁面」承載等待體驗。connect 成功後**主動 `fetchDevice(uuid)` 重拉** `status`(**輪詢式**),使 badge 由 `connecting` → `connected`、按鈕自動切「斷線」、FlashDialog 解鎖。**不依賴 WS 推送**——device events WS(`/ws/devices/events`)雖 local agent 端已實作(connect 成功廣播 `updated`),但 backend proxy 該路由仍是 **501 stub(pending B7)**,事件到不了雲端前端。B7 打通後可改 WS 即時推。 + +- **timeout 鏈**:backend proxy 300s(`proxy.go` `defaultProxyRequestTimeout`)> local agent 120s(`device_handler.go` ConnectDevice)> 實際最壞 65s,層層都夠。前端無需自設 timeout;唯一需自查:`api.post` 全域 timeout 若 < 120s,需為 connect 呼叫放寬(connect 是唯一可能 > 60s 的請求)。 + +- **推論前置**:能進 workspace 的裝置必然已 connect(flash 前置需 connect、開啟工作區前置需 `flashedModel` 有值 = 已 flash 過),故 camera pipeline 拿到的必為 connected driver,**推論前端無需額外 connect gate**。camera_handler `IsConnected()` 早擋為 nice-to-have(動 local agent、非本次範圍)。 ### 10.6 local-tool-only 元件:雛形隱藏(Q6 裁決) 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 d82abe3..18b6000 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 @@ -217,3 +217,143 @@ describe("DeviceDetailClient serial 路由 gating(WP-C / ADR-018)", () => { expect(screen.getByTestId("workspace-no-serial-disabled")).toBeDisabled(); }); }); + +describe("DeviceDetailClient 兩步式連線 gate(方案 A / ADR-018)", () => { + // 未連線裝置基礎(在線 + 有 serial,但 driver status = detected)。 + const detectedDevice: Device = { + ...onlineDevice, + status: "detected", + }; + + it("未連線(detected):顯示「連線」按鈕、flash 觸發鈕 disabled", () => { + useDeviceStore.setState({ selectedDevice: detectedDevice }); + renderDetail(); + + const connectBtn = screen.getByTestId("device-connect-btn"); + expect(connectBtn).toBeEnabled(); + expect(connectBtn).toHaveTextContent("連線"); + // 兩步式:driver 沒 connect → flash 入口 disable(避免按了才收 400)。 + expect(screen.getByTestId("flash-model-trigger")).toBeDisabled(); + // 未連線不顯示斷線按鈕。 + expect(screen.queryByTestId("device-disconnect-btn")).not.toBeInTheDocument(); + // driver 狀態呈現。 + expect(screen.getByTestId("device-driver-status")).toHaveTextContent("已偵測"); + }); + + it("已連線(connected):顯示「斷線」按鈕、flash 觸發鈕 enabled", () => { + // onlineDevice.status === "connected" + renderDetail(); + + const disconnectBtn = screen.getByTestId("device-disconnect-btn"); + expect(disconnectBtn).toBeEnabled(); + expect(disconnectBtn).toHaveTextContent("斷線"); + // 已連線 → flash 入口可用。 + expect(screen.getByTestId("flash-model-trigger")).toBeEnabled(); + expect(screen.queryByTestId("device-connect-btn")).not.toBeInTheDocument(); + expect(screen.getByTestId("device-driver-status")).toHaveTextContent("已連線"); + }); + + it("點「連線」→ 呼叫 connectDevice(serial) → fetchDevice(id) → success toast", async () => { + useDeviceStore.setState({ selectedDevice: detectedDevice }); + const connectSpy = vi + .spyOn(useDeviceStore.getState(), "connectDevice") + .mockResolvedValue(true); + const fetchSpy = vi + .spyOn(useDeviceStore.getState(), "fetchDevice") + .mockResolvedValue(undefined); + renderDetail(); + + fireEvent.click(screen.getByTestId("device-connect-btn")); + + // serial 路由:connect 帶 serialNumber(非 UUID)。 + await waitFor(() => + expect(connectSpy).toHaveBeenCalledWith("KN00123456"), + ); + // C4:connect 後用 UUID 重拉 status。 + expect(fetchSpy).toHaveBeenCalledWith("dev-1"); + expect(toast.success).toHaveBeenCalledOnce(); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it("連線失敗 → toast.error(帶 description),不刷新成功文案", async () => { + useDeviceStore.setState({ selectedDevice: detectedDevice }); + vi.spyOn(useDeviceStore.getState(), "connectDevice").mockResolvedValue(false); + vi.spyOn(useDeviceStore.getState(), "fetchDevice").mockResolvedValue(undefined); + renderDetail(); + + fireEvent.click(screen.getByTestId("device-connect-btn")); + + await waitFor(() => expect(toast.error).toHaveBeenCalledOnce()); + const call = (toast.error as Mock).mock.calls[0]; + expect(call[1].description).toBe("請確認裝置已插上且 local agent 在線"); + expect(toast.success).not.toHaveBeenCalled(); + }); + + it("連線中(connectingId === serial):連線按鈕 disabled + loading,且顯示 60s hint", () => { + useDeviceStore.setState({ + selectedDevice: detectedDevice, + connectingId: "KN00123456", + }); + renderDetail(); + + const connectBtn = screen.getByTestId("device-connect-btn"); + expect(connectBtn).toBeDisabled(); + expect(connectBtn).toHaveTextContent("連線中…"); + // 60s UX 明確 hint。 + expect(screen.getByTestId("device-connecting-hint")).toHaveTextContent( + "約 1 分鐘", + ); + }); + + it("後端上報 connecting(無本地旗標):也視為連線中(loading + hint)", () => { + useDeviceStore.setState({ + selectedDevice: { ...detectedDevice, status: "connecting" }, + connectingId: null, + }); + renderDetail(); + + expect(screen.getByTestId("device-connect-btn")).toBeDisabled(); + expect(screen.getByTestId("device-connecting-hint")).toBeInTheDocument(); + expect(screen.getByTestId("device-driver-status")).toHaveTextContent("連線中"); + }); + + it("點「斷線」→ 呼叫 disconnectDevice(serial) → fetchDevice(id) → toast", async () => { + const disconnectSpy = vi + .spyOn(useDeviceStore.getState(), "disconnectDevice") + .mockResolvedValue(true); + const fetchSpy = vi + .spyOn(useDeviceStore.getState(), "fetchDevice") + .mockResolvedValue(undefined); + renderDetail(); + + fireEvent.click(screen.getByTestId("device-disconnect-btn")); + + await waitFor(() => + expect(disconnectSpy).toHaveBeenCalledWith("KN00123456"), + ); + expect(fetchSpy).toHaveBeenCalledWith("dev-1"); + expect(toast.success).toHaveBeenCalledOnce(); + }); + + it("離線(remoteStatus=offline):不顯示連線/斷線按鈕(操作區整體降級)", () => { + useDeviceStore.setState({ + selectedDevice: { ...detectedDevice, remoteStatus: "offline" }, + }); + renderDetail(); + + expect(screen.queryByTestId("device-connect-btn")).not.toBeInTheDocument(); + expect( + screen.queryByTestId("device-disconnect-btn"), + ).not.toBeInTheDocument(); + }); + + it("flashing / inferencing 也視為已連線(斷線按鈕 + flash 可用)", () => { + useDeviceStore.setState({ + selectedDevice: { ...onlineDevice, status: "inferencing" }, + }); + renderDetail(); + + expect(screen.getByTestId("device-disconnect-btn")).toBeInTheDocument(); + expect(screen.getByTestId("flash-model-trigger")).toBeEnabled(); + }); +}); 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 7757dee..3dbecdb 100644 --- a/visionA-frontend/src/app/devices/[id]/device-detail-client.tsx +++ b/visionA-frontend/src/app/devices/[id]/device-detail-client.tsx @@ -65,10 +65,46 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) { const unpairDevice = useDeviceStore((s) => s.unpairDevice); const isUnpairing = useDeviceStore((s) => s.unpairingId === id); + // 兩步式連線(方案 A / ADR-018 serial 路由):connect/disconnect action + 進行中旗標。 + const connectDevice = useDeviceStore((s) => s.connectDevice); + const disconnectDevice = useDeviceStore((s) => s.disconnectDevice); + // connectingId / disconnectingId 存的是 serialNumber(serial 路由);用當前裝置 serial 比對。 + const serial = selectedDevice?.serialNumber ?? ""; + const isConnectPending = useDeviceStore( + (s) => !!serial && s.connectingId === serial, + ); + const isDisconnectPending = useDeviceStore( + (s) => !!serial && s.disconnectingId === serial, + ); + useEffect(() => { if (id) void fetchDevice(id); }, [id, fetchDevice]); + async function handleConnect() { + // gate 已保證 hasSerial;serialNumber 必有值。 + const ok = await connectDevice(selectedDevice!.serialNumber!); + if (ok) { + // C4:connect 後唯一可靠刷新路徑 — 用 UUID 重拉 status(WS 推送未通)。 + await fetchDevice(id); + toast.success(t("devices.connect.toast.success")); + } else { + toast.error(t("devices.connect.toast.failed"), { + description: t("devices.connect.toast.failedDesc"), + }); + } + } + + async function handleDisconnect() { + const ok = await disconnectDevice(selectedDevice!.serialNumber!); + if (ok) { + await fetchDevice(id); + toast.success(t("devices.connect.toast.disconnected")); + } else { + toast.error(t("devices.connect.toast.disconnectFailed")); + } + } + async function handleRemove() { const result = await unpairDevice(id); if (result.ok) { @@ -125,6 +161,18 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) { // 詳情頁本身(fetchDevice / unpair)維持 UUID,不受 serial 影響。 const hasSerial = !!selectedDevice.serialNumber; + // driver(USB)連線狀態 — 方案 A 兩步式的第一步 gate(C1:status 已 normalize)。 + const usbStatus = selectedDevice.status; + // connected / flashing / inferencing 三態都代表 driver 已 Connect()、可載入模型 / 推論。 + const isDriverConnected = + usbStatus === "connected" || + usbStatus === "flashing" || + usbStatus === "inferencing"; + // 後端上報的 connecting(僅在有 fetchDevice 拿到時才有);與本地 isConnectPending 併判。 + const isConnecting = usbStatus === "connecting"; + // loading 判定:以本地旗標為主(一定會亮),後端 connecting 補「別分頁觸發」場景。 + const isConnectInProgress = isConnectPending || isConnecting; + return (
+ {t("devices.connect.connectingHint")} +
+ )} +