visionA/visionA-frontend/src/hooks/use-flash-progress.ts
jim800121chen 12f5bf6c47 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>
2026-07-10 09:58:52 +08:00

111 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* useFlashProgress — flash 進度 WS 訂閱 + 順序保證的 POST 觸發flash UI
*
* 移植自 POC edge-ai-platform/frontend/src/hooks/use-flash-progress.ts唯讀參考、非搬 code
*
* ⚠️ 最關鍵的契約差異(見 flash-model-load-mapping.md §2.3-B採「選項 2」
* - POC `useFlashProgress` 提供 imperative `connectAndWait(): Promise<void>`
* dialog 先 `await connectAndWait()`(開 WS 並等 open**再** `startFlash()`POST
* - visionA `useWebSocket(path, { enabled, onMessage, onOpen })` 是 **declarative**
* (靠 `enabled` false→true 觸發連線,無法回傳「已 open」的 promise
* 若 `setEnabled(true)` 後立刻 POST → racePOST 已送、WS 還沒 open漏早期 progress
*
* **本 hook 的解法(選項 2複用最多、無 race**
* 用 `useWebSocket` 的 `onOpen` callback 觸發 POST。呼叫端 `beginFlash(modelId)` 只設
* `enabled=true` + 記下 modelIdWS 真正 open 後才在 `onOpen` 內 `startFlash(serialNumber, modelId)`
* 天然保證「先 open WS 再 POST」的順序flash 很快就送 progress順序錯會漏早期 percent
*
* **重連防重複 POST`hasStartedRef`**
* `onOpen` 在**每次連線成功**(含斷線重連)都會觸發。若不防護,重連會重複 POST flash。
* 故用 `hasStartedRef` 記錄「本次 flash 已 POST 過」,重連時的 onOpen 不再 POST只續收 progress。
*
* 認證same-origin cookievisiona_session HttpOnly自動帶**不放 token 到 URL**
* (對齊 use-websocket.ts 安全決策 + security 對 token-in-URL 的 Critical 否決)。
*/
"use client";
import { useCallback, useRef, useState } from "react";
import { useWebSocket } from "@/hooks/use-websocket";
import { type FlashProgress, useFlashStore } from "@/stores/flash-store";
interface UseFlashProgress {
/**
* 開始 flash啟用 WS`enabled=true`WS open 後才 POST flash順序保證
* @param modelId 要 flash 的模型 id
*/
beginFlash: (modelId: string) => void;
/** 停止:關閉 WS、重置狀態不再重連、不再 POST。 */
stop: () => void;
/** 目前是否已啟用 WS供除錯 / 測試觀察)。 */
isActive: boolean;
}
/**
* @param serialNumber 目標裝置的硬體序號kn_number
*
* ⚠️ ADR-018 serial 路由WS path 與 flash POST 的識別值都必須用 serialNumber
* 兩者要一致——local agent 端 flash 進度是 broadcast 到以「請求帶入的識別值」
* 為 key 的 roomPOST 帶 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);
// enabled 用 state 讓 useWebSocket 收到 false→true 變化 → 觸發連線。
const [enabled, setEnabled] = useState(false);
// 本次 flash 要送的 modelIdbeginFlash 設定、onOpen 讀取)。
const modelIdRef = useRef<string | null>(null);
// 防重連重複 POST本次 flash 已 POST 過就不再送。
const hasStartedRef = useRef(false);
const handleOpen = useCallback(() => {
// 只在「本次 flash 尚未 POST 過」時觸發 POST重連時的 onOpen 會跳過)。
if (hasStartedRef.current) return;
const modelId = modelIdRef.current;
if (!modelId) return;
hasStartedRef.current = true;
void startFlash(serialNumber, modelId);
}, [serialNumber, startFlash]);
const handleMessage = useCallback(
(data: unknown) => {
const p = data as Partial<FlashProgress>;
// 防呆非預期形狀percent 非數字且無 error直接忽略不污染 store。
if (typeof p?.percent !== "number" && typeof p?.error !== "string") {
return;
}
updateProgress({
percent: typeof p.percent === "number" ? p.percent : 0,
stage: typeof p.stage === "string" ? p.stage : "",
message: typeof p.message === "string" ? p.message : undefined,
error: typeof p.error === "string" ? p.error : undefined,
});
},
[updateProgress],
);
useWebSocket(`/ws/devices/${encodeURIComponent(serialNumber)}/flash-progress`, {
enabled,
onOpen: handleOpen,
onMessage: handleMessage,
});
const beginFlash = useCallback((modelId: string) => {
modelIdRef.current = modelId;
hasStartedRef.current = false; // 新一輪 flash重置「已 POST」旗標
setEnabled(true);
}, [setEnabled]);
const stop = useCallback(() => {
modelIdRef.current = null;
hasStartedRef.current = false;
setEnabled(false);
}, [setEnabled]);
return { beginFlash, stop, isActive: enabled };
}