/** * 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; 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,避免誤判) */ 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; }; return { id: String(pick("id") ?? ""), name: String(pick("name") ?? pick("device_name") ?? ""), alias: pick("alias") ?? undefined, type: String(pick("type", "device_type") ?? ""), status: (pick("status") as DeviceHardwareStatus) ?? "disconnected", remoteStatus: (pick("remote_status", "remoteStatus") as RemoteStatus) ?? "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 */ /* -------------------------------------------------------------------------- */ interface DeviceState { devices: DeviceSummary[]; selectedDevice: Device | null; isLoading: boolean; /** 連線中的裝置 id(UI 顯示 button spinner);不使用就是 null */ connectingId: string | null; disconnectingId: string | null; error: string | null; /** 呼叫 `GET /api/devices` */ 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; /** 測試 / 雛形用:直接塞 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, 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 }); } }, connectDevice: async (id) => { set({ connectingId: id, error: null }); try { await api.post(`/api/devices/${encodeURIComponent(id)}/connect`); 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 (id) => { set({ disconnectingId: id, error: null }); try { await api.post(`/api/devices/${encodeURIComponent(id)}/disconnect`); set({ disconnectingId: null }); return true; } catch (err) { const message = err instanceof Error ? err.message : String(err); set({ disconnectingId: null, error: message }); return false; } }, _setDevices: (devices) => set({ devices }), _setSelected: (selectedDevice) => set({ selectedDevice }), }));