/** * Device Store — visionA Cloud * * 對齊: * - `.autoflow/04-architecture/api/api-spec.md` §3 Devices(`/api/devices` / `/api/devices/:id` / connect / disconnect) * - `.autoflow/03-design/flows/flow-offline-handling.md` §2(Device 新增 `remoteStatus`) * - `.autoflow/04-architecture/TDD.md` §10.1(stores) * * 職責: * - 保存當前使用者可見的裝置列表 + 當前選取裝置詳情 * - 接 `GET /api/devices` / `GET /api/devices/:id`(雛形後端透過 tunnel forward) * - 提供 connect / disconnect 呼叫的 wrapper(純 API 呼叫;UI 實際狀態變更由 WS 推送) * * F6 範圍(雛形): * - 不做主動 WS 訂閱 — 待 F7/F8 接 `/ws/devices/events` * - 後端 501 NOT_IMPLEMENTED 時 fallback 為空 list,不打擾 UI * - `remoteStatus` 欄位若後端未提供,默認 `unknown`(避免誤顯示為 offline) * * 雛形安全/穩定性備註: * - 所有錯誤以 `error: string` 記錄,不 throw,讓 UI 能用 `{ isLoading, error }` 處理 * - unmount 時不主動 abort(雛形保持簡單;F7/F8 改用 TanStack Query 或 SWR 時再處理) */ "use client"; import { create } from "zustand"; import { ApiError, api } from "@/lib/api"; /* -------------------------------------------------------------------------- */ /* Types — 對齊 api-spec.md §3 + flow-offline-handling.md §2 */ /* -------------------------------------------------------------------------- */ /** 既有 USB / 硬體層級狀態(由 local agent 回報) */ export type DeviceHardwareStatus = | "detected" | "connecting" | "connected" | "flashing" | "inferencing" | "error" | "disconnected"; /** 遠端 tunnel 層級狀態(local agent ↔ 雲端) */ export type RemoteStatus = "online" | "offline" | "reconnecting" | "error" | "unknown"; /** 列表用的精簡裝置資訊 */ export interface DeviceSummary { id: string; 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; /** 遠端 tunnel 狀態(flow-offline-handling.md §2 新增) */ remoteStatus: RemoteStatus; /** ISO 8601,最後心跳時間 */ lastSeenAt?: string | null; firmwareVersion?: string | null; flashedModel?: string | null; } /** 裝置詳情(詳細頁用,比 Summary 多欄) */ export interface Device extends DeviceSummary { port?: string | null; hostName?: string | null; pairedAt?: string | null; errorMessage?: string | null; } /* -------------------------------------------------------------------------- */ /* 後端 snake_case ⇄ 前端 camelCase 正規化 */ /* -------------------------------------------------------------------------- */ /** * 把後端回傳的裝置物件正規化成前端型別。 * - 接受 snake_case / camelCase 兩種形狀 * - 對缺欄位寬容:`remoteStatus` 預設 `unknown`(而非 offline,避免誤判) * * remoteStatus 決策順序(bug fix:裝置恆離線): * 1. 後端 `tunnel_online === true` → 覆蓋為 `online` * 後端 `/api/devices` 即時查 tunnel session 算出 `tunnel_online`(devices.go), * 是「當下 tunnel 是否活著」的真實狀態;而 `remote_status` 是 DB 靜態值, * exchange 建立 device 時寫死 `offline` 之後永不更新,直接讀它會恆顯示離線。 * 2. 否則 fallback 回原本 `remote_status` / `remoteStatus`(守住既有行為)。 * * ⚠️ 已知限制(技術債,只求單裝置 demo 正確): * 後端 `tunnel_online` 由 `resolveTunnelStatus` 以寬鬆比對算出——session 未帶 * UserID/DeviceID 時(UserID=="")也會 match,故單裝置 demo 正確、多裝置場景會誤判 * (某台的 tunnel 活著可能讓另一台也判為 online)。多裝置的正解是後端 session * backfill UserID/DeviceID(architect R3 / Phase 1),不在本次前端修法範圍內。 */ function normalizeDevice(raw: unknown): Device { const r = (raw ?? {}) as Record; const pick = (...keys: string[]): T | undefined => { for (const k of keys) { if (r[k] !== undefined && r[k] !== null) return r[k] as T; } return undefined; }; // tunnel_online 為 boolean(snake_case / camelCase 皆容),true 時覆蓋 remoteStatus。 const tunnelOnline = pick("tunnel_online", "tunnelOnline"); const rawRemoteStatus = pick("remote_status", "remoteStatus") as | RemoteStatus | undefined; // serial_number 為 omitempty:缺欄位 → null;防禦性把空字串/純空白也視為 null // (空字串與空白皆無法路由,語意上等同「未回報序號」)。先 trim 再判定, // 避免純空白(" ")誤判為 truthy 而以空白 serial 組出必失敗的請求路由。 const rawSerial = pick("serial_number", "serialNumber"); const serial = rawSerial != null ? String(rawSerial).trim() : ""; return { id: String(pick("id") ?? ""), name: String(pick("name") ?? pick("device_name") ?? ""), alias: pick("alias") ?? undefined, serialNumber: serial !== "" ? serial : null, type: String(pick("type", "device_type") ?? ""), status: (pick("status") as DeviceHardwareStatus) ?? "disconnected", remoteStatus: tunnelOnline === true ? "online" : (rawRemoteStatus ?? "unknown"), lastSeenAt: pick("last_seen_at", "lastSeenAt") ?? null, firmwareVersion: pick("firmware_version", "firmwareVersion") ?? null, flashedModel: pick("flashed_model", "flashedModel") ?? null, port: pick("port") ?? null, hostName: pick("host_name", "hostName") ?? null, pairedAt: pick("paired_at", "pairedAt") ?? null, errorMessage: pick("error_message", "errorMessage") ?? null, }; } /* -------------------------------------------------------------------------- */ /* Store */ /* -------------------------------------------------------------------------- */ /** * unpair(移除裝置)action 的回傳:成功 / 失敗(帶 i18n code 給 UI 顯示 toast)。 * * 與 model-store deleteModel 回傳 boolean 的差異:unpair 是破壞性操作,UI 需要對 * 「沒有權限(403)/ 裝置不存在(404)/ 其他」分流顯示不同文案,故回 code 而非 boolean。 * code 由 ApiError.code 直接帶出(backend api-spec §11 錯誤碼),UI 以 `devices.remove.error.*` * 對應 i18n、找不到對應 key 時退化到 unknown 文案(對齊 model download 的 toast 慣例)。 */ export type UnpairResult = | { ok: true } | { ok: false; code: string; message: string }; interface DeviceState { devices: DeviceSummary[]; selectedDevice: Device | null; isLoading: boolean; /** 連線中的裝置識別值(serial 路由後為 serialNumber;UI 顯示 button spinner);不使用就是 null */ connectingId: string | null; disconnectingId: string | null; /** 移除(unpair)中的裝置 id(UI 顯示 button spinner / disable 確認鈕);不使用就是 null */ unpairingId: string | null; error: string | null; /** 呼叫 `GET /api/devices` */ fetchDevices: () => Promise; /** 呼叫 `GET /api/devices/:id` */ fetchDevice: (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 */ _setDevices: (devices: DeviceSummary[]) => void; /** 測試 / 雛形用:直接塞 selected */ _setSelected: (device: Device | null) => void; } export const useDeviceStore = create()((set) => ({ devices: [], selectedDevice: null, isLoading: false, connectingId: null, disconnectingId: null, unpairingId: null, error: null, fetchDevices: async () => { set({ isLoading: true, error: null }); try { const raw = await api.get("/api/devices"); const devices = Array.isArray(raw) ? raw.map(normalizeDevice) : []; set({ devices, isLoading: false }); } catch (err) { // 雛形後端 501 / 無 tunnel 時不當嚴重錯誤,給空 list if (err instanceof ApiError && (err.code === "NOT_IMPLEMENTED" || err.code === "TUNNEL_DISCONNECTED")) { set({ devices: [], isLoading: false, error: null }); return; } const message = err instanceof Error ? err.message : String(err); set({ isLoading: false, error: message }); } }, fetchDevice: async (id) => { set({ isLoading: true, error: null }); try { const raw = await api.get(`/api/devices/${encodeURIComponent(id)}`); set({ selectedDevice: normalizeDevice(raw), isLoading: false }); } catch (err) { if (err instanceof ApiError && err.code === "NOT_IMPLEMENTED") { // 雛形後端未實作 — UI 退回 list 上的最後一筆或顯示 "離線 cache" set({ isLoading: false, error: null }); return; } const message = err instanceof Error ? err.message : String(err); set({ isLoading: false, error: message }); } }, // serial 路由(ADR-018):path 帶 serialNumber(kn_number),local agent 端以 // serialToLocalID 反查 sessions;帶 UUID 會 "device not found"。 // // ⚠️ timeout 覆寫(方案 A 自查項):connect 是唯一可能 > 60s 的請求 // (KL520 Loader-mode reconnect + firmware reload + reboot + 二次 reconnect, // local agent 端最壞 ~65s、ctx 120s)。api client 全域預設 timeout 只有 30s // (api.ts DEFAULT_TIMEOUT_MS),若不放寬會在後端還在跑時就前端 abort → 誤判失敗。 // 這裡覆寫為 130s(> local agent 120s ctx,確保由後端決定成敗、前端不搶先 timeout)。 connectDevice: async (serialNumber) => { set({ connectingId: serialNumber, error: null }); try { await api.post( `/api/devices/${encodeURIComponent(serialNumber)}/connect`, undefined, { timeoutMs: 130_000 }, ); set({ connectingId: null }); return true; } catch (err) { const message = err instanceof Error ? err.message : String(err); set({ connectingId: null, error: message }); return false; } }, disconnectDevice: async (serialNumber) => { set({ disconnectingId: serialNumber, error: null }); try { await api.post(`/api/devices/${encodeURIComponent(serialNumber)}/disconnect`); set({ disconnectingId: null }); return true; } catch (err) { const message = err instanceof Error ? err.message : String(err); set({ disconnectingId: null, error: message }); return false; } }, unpairDevice: async (id) => { set({ unpairingId: id, error: null }); try { // backend 回 envelope { success, data: { id, unpaired: true } };api.post 已 unwrap data。 // 這裡不需用回傳值(成功與否由有無 throw 判定),故不取 response。 await api.post(`/api/devices/${encodeURIComponent(id)}/unpair`); // 成功後從本地 list 移除該裝置(避免 refetch 延遲);若 selected 是它也清掉。 set((state) => ({ devices: state.devices.filter((d) => d.id !== id), selectedDevice: state.selectedDevice?.id === id ? null : state.selectedDevice, unpairingId: null, })); return { ok: true }; } catch (err) { // ApiError 帶 backend error code(FORBIDDEN / NOT_FOUND / …)給 UI 分流 toast; // 其他例外(網路層)退化成 unknown。 const message = err instanceof Error ? err.message : String(err); const code = err instanceof ApiError ? err.code : "unknown"; set({ unpairingId: null, error: message }); return { ok: false, code, message }; } }, _setDevices: (devices) => set({ devices }), _setSelected: (selectedDevice) => set({ selectedDevice }), }));