import { create } from 'zustand'; import { api } from '@/lib/api'; import { showApiError } from '@/lib/toast'; import { useActivityStore } from './activity-store'; import type { FirmwareProgressEvent, FirmwareStage, FirmwareReason, FirmwareActiveTask, } from '@/types/device'; // FirmwareUpgradeState 描述目前螢幕上 firmware modal 的狀態。 // per-device 隔離(activeDeviceId 機制、對齊 flash-store M4 fix)— // 避免多 dongle 升級時、Frontend WS 訊息互相蓋掉。 // // 設計參考:design-spec v2.2 §8 狀態機。本期 M9-4 A 階段只實作 // upgrade direction(downgrade B2 階段 M9-12 才開)。 interface FirmwareState { // 當前正在升級的 device。null = 沒有升級在進行中。 activeDeviceId: string | null; // last task — 升級啟動時 backend 回的 taskId。診斷用。 activeTaskId: string | null; // confirming:升級確認 modal 顯示中、尚未送 API。 // upgrading:API 已送出 / WS 正在推進度。 // success:完成、toast 已出。 // error:失敗、error modal 顯示中。 // idle:modal 關閉、無工作中。 phase: 'idle' | 'confirming' | 'upgrading' | 'success' | 'error'; // 當前最新的 progress event。 progress: FirmwareProgressEvent | null; // 升級開始時的版本字串(讓 success toast 顯示 from→to)。 beforeVersion: string | null; // 升級開始時的目標版本(bundled)字串。 targetVersion: string | null; // 升級開始的 wall clock(ms)— 用來算 toast duration。 startedAt: number | null; // ─── Actions ─── startConfirm: (deviceId: string, beforeVersion: string, targetVersion: string) => void; cancelConfirm: () => void; startUpgrade: (deviceId: string) => Promise<{ ok: boolean; taskId?: string; error?: string }>; handleEvent: (ev: FirmwareProgressEvent) => void; /** 把 store 重設到 idle(modal 關閉、可開新升級)。 */ reset: () => void; } export const useFirmwareStore = create((set, get) => ({ activeDeviceId: null, activeTaskId: null, phase: 'idle', progress: null, beforeVersion: null, targetVersion: null, startedAt: null, startConfirm: (deviceId, beforeVersion, targetVersion) => { set({ activeDeviceId: deviceId, phase: 'confirming', progress: null, beforeVersion, targetVersion, startedAt: null, activeTaskId: null, }); }, cancelConfirm: () => { // 只允許在 confirming 階段取消、進度中不可取消(R-FW-11 緩解)。 if (get().phase !== 'confirming') return; set({ activeDeviceId: null, phase: 'idle', progress: null, beforeVersion: null, targetVersion: null, startedAt: null, activeTaskId: null, }); }, startUpgrade: async (deviceId) => { set({ phase: 'upgrading', startedAt: Date.now(), progress: null }); type StartResp = { taskId: string }; const res = await api.post(`/devices/${deviceId}/firmware/upgrade`); if (!res.success || !res.data) { const msg = res.error?.message || 'Firmware upgrade failed to start'; showApiError(res.error); // 啟動失敗:直接進 error phase、給 modal 顯示 set({ phase: 'error', progress: { type: 'firmware_progress', deviceId, stage: 'error', percent: -1, elapsedMs: 0, error: msg, errorCode: res.error?.code, }, }); useActivityStore.getState().addActivity('flash_error', `Firmware upgrade start failed: ${msg}`); return { ok: false, error: msg }; } set({ activeTaskId: res.data.taskId }); useActivityStore.getState().addActivity('flash_start', `Firmware upgrade started: ${deviceId}`); return { ok: true, taskId: res.data.taskId }; }, handleEvent: (ev) => { // 只處理當前 active device 的 event(多裝置同時升級時也只更新對應的)。 const active = get().activeDeviceId; if (active !== null && active !== ev.deviceId) { return; } // 記錄事件本身 set({ progress: ev }); if (ev.stage === 'done') { set({ phase: 'success' }); useActivityStore.getState().addActivity('flash_complete', `Firmware upgraded: ${ev.deviceId}`); return; } if (ev.stage === 'error') { set({ phase: 'error' }); useActivityStore .getState() .addActivity('flash_error', `Firmware upgrade failed: ${ev.error || ev.reason || 'unknown'}`); return; } // preparing / loading / flashing / verifying — phase 維持 upgrading }, reset: () => { set({ activeDeviceId: null, activeTaskId: null, phase: 'idle', progress: null, beforeVersion: null, targetVersion: null, startedAt: null, }); }, })); // ─── Pure helpers(無 React 依賴、可直接 import)─── /** 從 stage + reason 推回顯示的 friendly message i18n key(對齊 Design §7.1 + §9.8 namespace)。 */ export function errorMessageKeyFor(stage: FirmwareStage, reason?: FirmwareReason): string { if (reason === 'timeout') return 'settings.firmware.error.message.timeout'; if (reason === 'disconnect_during_op') return 'settings.firmware.error.message.disconnect'; if (reason === 'scan_not_found') return 'settings.firmware.error.message.scanNotFound'; if (reason === 'connect_failed') return 'settings.firmware.error.message.connectFailed'; if (reason === 'loader_write_failed') return 'settings.firmware.error.message.loaderWriteFailed'; if (reason === 'upgrade_mid_failed') return 'settings.firmware.error.message.upgradeMidFailed'; if (reason === 'verify_mismatch') return 'settings.firmware.error.message.verifyMismatch'; if (reason === 'verify_not_found') return 'settings.firmware.error.message.verifyNotFound'; // fallback by stage switch (stage) { case 'preparing': return 'settings.firmware.error.message.connectFailed'; case 'loading': return 'settings.firmware.error.message.loaderWriteFailed'; case 'flashing': return 'settings.firmware.error.message.upgradeMidFailed'; case 'verifying': return 'settings.firmware.error.message.verifyMismatch'; default: return 'settings.firmware.error.message.upgradeMidFailed'; } } /** * canRetry 判定某個 reason 是否可重試(Design §7、安全考量)。 * disconnect / verify_mismatch / verify_not_found 三種屬於 destructive 失敗 * (可能 brick)、不提供 Retry 按鈕、只能 ContactSupport。 */ export function canRetryReason(reason?: FirmwareReason): boolean { if (!reason) return true; return ( reason !== 'disconnect_during_op' && reason !== 'verify_mismatch' && reason !== 'verify_not_found' ); } /** 從 reason 決定主要建議動作的 i18n key(對齊 Design §9.8 namespace)。 */ export function primaryActionKeyFor(reason?: FirmwareReason): string { if (reason === 'scan_not_found' || reason === 'disconnect_during_op') { return 'settings.firmware.error.action.replugRetry'; } if (reason === 'verify_mismatch' || reason === 'verify_not_found') { return 'settings.firmware.error.action.rescan'; } if (reason === 'timeout') { return 'settings.firmware.error.action.rescan'; } return 'settings.firmware.error.action.retry'; } /** * 從 chip type 推估升級時間(AC-FW-1.7)。 * KL520 ~ 30s(實測)、KL720 ~ 180s(實測)。 * * Fallback 邏輯(M9-4 v2 對齊 Reviewer M3 釐清): * - `undefined` deviceType(caller 沒拿到 chip 資訊)→ 保守值 60s(介於 KL520 與 KL720 之間、避免過度樂觀) * - 有 deviceType 字串但 toLowerCase 後不含 kl720 → 預設走 KL520 路徑(30s) * * 兩種 fallback 數值不同是刻意的:「不知道有沒有 type」和「有 type 但認不出」資訊量不同。 */ export function estimatedDurationSeconds(deviceType?: string): number { if (!deviceType) return 60; const low = deviceType.toLowerCase(); if (low.includes('kl720')) return 180; return 30; } /** 從後端 stage 算「階段 n / total」— KDP1→KDP2 = 4 階段、KDP2→KDP2 = 3 階段。 */ export function stageOrdinal( stage: FirmwareStage, isLegacyUpgrade: boolean, ): { n: number; total: number } { const total = isLegacyUpgrade ? 4 : 3; if (isLegacyUpgrade) { switch (stage) { case 'preparing': return { n: 1, total }; case 'loading': return { n: 2, total }; case 'flashing': return { n: 3, total }; case 'verifying': return { n: 4, total }; default: return { n: total, total }; } } switch (stage) { case 'preparing': return { n: 1, total }; case 'flashing': return { n: 2, total }; case 'verifying': return { n: 3, total }; default: return { n: total, total }; } } // ─── Active tasks API(給 M9-4.5 Wails OnBeforeClose 銜接用)─── /** * 查詢 backend 目前有沒有進行中的 firmware task。 * 主要呼叫時機:Wails 控制台 OnBeforeClose(拒絕關閉時帶 task 清單給 UI)。 * Frontend 一般 UI 流程不需要 polling 這個 endpoint、用 WS 即時更新就夠。 * * 刻意設計為 module-level helper、不放 store action: * - 此函數無 reactive state(不更新 zustand store、純 fetch + return) * - 呼叫端是 Wails callback / 一次性查詢、不需 subscribe * - 放 store 會造成 hook + action 介面雜訊(store 該專注 modal state machine) */ export async function fetchActiveFirmwareTasks(): Promise<{ hasActive: boolean; tasks: FirmwareActiveTask[]; }> { type Resp = { hasActive: boolean; tasks: FirmwareActiveTask[] }; const res = await api.get('/firmware/active-tasks'); if (res.success && res.data) { return { hasActive: !!res.data.hasActive, tasks: res.data.tasks || [] }; } return { hasActive: false, tasks: [] }; }