diff --git a/local-tool/frontend/src/app/devices/page.tsx b/local-tool/frontend/src/app/devices/page.tsx index dda0ed1..e12ac4f 100644 --- a/local-tool/frontend/src/app/devices/page.tsx +++ b/local-tool/frontend/src/app/devices/page.tsx @@ -1,16 +1,24 @@ 'use client'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useDeviceStore } from '@/stores/device-store'; import { useDeviceEvents } from '@/hooks/use-device-events'; import { DeviceList } from '@/components/devices/device-list'; import { Button } from '@/components/ui/button'; -import { RefreshCw } from 'lucide-react'; +import { RefreshCw, Usb } from 'lucide-react'; import { useTranslation } from '@/lib/i18n'; +import { api } from '@/lib/api'; +import { toast } from 'sonner'; + +function isWindows(): boolean { + if (typeof navigator === 'undefined') return false; + return /Windows/i.test(navigator.userAgent) || /Win32|Win64/i.test(navigator.platform); +} export default function DevicesPage() { const { t } = useTranslation(); const { devices, loading, scanning, fetchDevices, scanDevices } = useDeviceStore(); + const [installingDriver, setInstallingDriver] = useState(false); useDeviceEvents(); @@ -18,6 +26,22 @@ export default function DevicesPage() { fetchDevices(); }, [fetchDevices]); + async function handleInstallDriver() { + setInstallingDriver(true); + try { + const res = await api.post<{ message: string }>('/system/install-driver'); + if (res.success) { + toast.success(res.data?.message || 'WinUSB driver 安裝完成,請重新點擊連線。'); + } else { + toast.error(res.error?.message || 'driver 安裝失敗'); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } finally { + setInstallingDriver(false); + } + } + return (
@@ -25,10 +49,23 @@ export default function DevicesPage() {

{t('devices.title')}

{t('devices.subtitle')}

- +
+ {isWindows() && ( + + )} + +
diff --git a/local-tool/frontend/src/stores/device-store.ts b/local-tool/frontend/src/stores/device-store.ts index b30843c..c931664 100644 --- a/local-tool/frontend/src/stores/device-store.ts +++ b/local-tool/frontend/src/stores/device-store.ts @@ -72,7 +72,19 @@ export const useDeviceStore = create((set, get) => ({ const name = get().devices.find((d) => d.id === id)?.name || id; useActivityStore.getState().addActivity('device_connect', `Device connected: ${name}`); } else { - showApiError(res.error); + const msg = res.error?.message || ''; + // 偵測 Kneron WinUSB driver 未安裝的特徵錯誤訊息(error 28 / KP_ERROR_CONNECT_FAILED) + // 若命中,給使用者明確指引去點「安裝 USB Driver」按鈕,而不只是顯示原始錯誤 + if (/winusb|error.{0,5}code.{0,5}28|KP_ERROR_CONNECT_FAILED/i.test(msg)) { + showApiError({ + code: 'DRIVER_NOT_INSTALLED', + message: + '連線失敗:Kneron WinUSB driver 尚未安裝。\n' + + '請點擊右上角「安裝 USB Driver」按鈕(需要系統管理員權限),安裝完成後重新點選連線。', + }); + } else { + showApiError(res.error); + } } get().fetchDevices(); }, diff --git a/local-tool/installer/windows/visiona-local.iss b/local-tool/installer/windows/visiona-local.iss index 0ef90fe..9d06cbc 100644 --- a/local-tool/installer/windows/visiona-local.iss +++ b/local-tool/installer/windows/visiona-local.iss @@ -92,11 +92,13 @@ Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}" Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon [Run] -; 安裝 WinUSB driver(pnputil 為 Windows 內建) -Filename: "{sys}\pnputil.exe"; \ - Parameters: "/add-driver ""{app}\drivers\kneron_winusb.inf"" /install"; \ - StatusMsg: "正在安裝 Kneron WinUSB driver..."; \ - Flags: runhidden waituntilterminated +; 注意:WinUSB driver 安裝**不在 installer 階段做**。 +; 原因:inf-based 安裝需要 .cat + Microsoft attestation signing,未簽章的 .inf 會被 +; Windows 10/11 的 driver signing enforcement 擋掉。 +; 改為:Wails app 首次啟動時(或使用者手動點「安裝 driver」按鈕時)透過 +; KneronPLUS SDK 的 kp.core.install_driver_for_windows() 呼叫 libwdi 完成, +; libwdi 會自動處理臨時自簽憑證,不需要 .cat 檔。 +; 需要 UAC elevation,Wails app 會彈提權對話框。 ; 安裝完畢選擇性啟動 app Filename: "{app}\{#MyAppExeName}"; \ diff --git a/local-tool/server/internal/api/handlers/system_driver_other.go b/local-tool/server/internal/api/handlers/system_driver_other.go new file mode 100644 index 0000000..3f45608 --- /dev/null +++ b/local-tool/server/internal/api/handlers/system_driver_other.go @@ -0,0 +1,11 @@ +//go:build !windows + +package handlers + +import "fmt" + +// installKneronWinUSBDriverHandler 在非 Windows 平台不做事(stub)。 +// InstallDriver handler 會先判斷 runtime.GOOS 在這之前回錯,所以這個 stub 不應被呼叫。 +func installKneronWinUSBDriverHandler(_ string) error { + return fmt.Errorf("only Windows needs WinUSB driver installation") +} diff --git a/local-tool/server/internal/api/handlers/system_driver_windows.go b/local-tool/server/internal/api/handlers/system_driver_windows.go new file mode 100644 index 0000000..faf6e2d --- /dev/null +++ b/local-tool/server/internal/api/handlers/system_driver_windows.go @@ -0,0 +1,85 @@ +//go:build windows + +package handlers + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" +) + +// installKneronWinUSBDriverHandler 呼叫 KneronPLUS SDK 的 libwdi wrapper 安裝 WinUSB driver。 +// +// 實作參考 edge-ai-platform installer/platform_windows.go 的 installKneronDriverViaSDK: +// - 組 Python script 呼叫 kp.core.install_driver_for_windows(pid) 對三種 PID +// - PowerShell Start-Process -Verb RunAs 提權(libwdi 要求 admin) +// - 結果寫到 temp file 讓 Go 讀回來 +func installKneronWinUSBDriverHandler(pythonBin string) error { + if _, err := os.Stat(pythonBin); err != nil { + return fmt.Errorf("python interpreter not found at %s: %w", pythonBin, err) + } + + resultPath := filepath.Join(os.TempDir(), "visiona-local-driver-result.txt") + _ = os.Remove(resultPath) + + // venv 根目錄 + venvRoot := filepath.Dir(filepath.Dir(pythonBin)) + + pyScript := fmt.Sprintf(` +import sys, os +result_path = r'%s' +try: + kp_lib = os.path.join(r'%s', 'Lib', 'site-packages', 'kp', 'lib') + if os.path.isdir(kp_lib): + os.environ['PATH'] = kp_lib + ';' + os.environ.get('PATH', '') + os.add_dll_directory(kp_lib) + import kp + results = [] + for pid in [kp.ProductId.KP_DEVICE_KL520, kp.ProductId.KP_DEVICE_KL720, kp.ProductId.KP_DEVICE_KL720_LEGACY]: + try: + kp.core.install_driver_for_windows(pid) + results.append(f"OK: {pid.name}") + except Exception as e: + results.append(f"SKIP: {pid.name}: {e}") + with open(result_path, 'w') as f: + f.write('\n'.join(results) + '\nDONE\n') +except ImportError as e: + with open(result_path, 'w') as f: + f.write(f'ERROR: kp module not available: {e}\n') +except Exception as e: + with open(result_path, 'w') as f: + f.write(f'ERROR: {e}\n') +`, resultPath, venvRoot) + + scriptPath := filepath.Join(os.TempDir(), "visiona-local-install-usb-driver.py") + if err := os.WriteFile(scriptPath, []byte(pyScript), 0o644); err != nil { + return fmt.Errorf("write driver install script: %w", err) + } + defer os.Remove(scriptPath) + + elevateCmd := fmt.Sprintf( + `Start-Process -FilePath '%s' -ArgumentList '"%s"' -Verb RunAs -Wait -WindowStyle Hidden`, + pythonBin, scriptPath, + ) + cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", elevateCmd) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("driver 安裝需要系統管理員權限(UAC 可能被拒絕):%s", strings.TrimSpace(string(out))) + } + + resultData, err := os.ReadFile(resultPath) + _ = os.Remove(resultPath) + if err != nil { + return fmt.Errorf("driver 安裝已執行但無法讀取結果,請在裝置管理員確認或用 Zadig 手動安裝 https://zadig.akeo.ie/") + } + + result := strings.TrimSpace(string(resultData)) + if strings.Contains(result, "ERROR:") { + return fmt.Errorf("driver 安裝失敗:%s", result) + } + + return nil +} diff --git a/local-tool/server/internal/api/handlers/system_handler.go b/local-tool/server/internal/api/handlers/system_handler.go index 0264cad..643694d 100644 --- a/local-tool/server/internal/api/handlers/system_handler.go +++ b/local-tool/server/internal/api/handlers/system_handler.go @@ -14,15 +14,17 @@ type SystemHandler struct { startTime time.Time version string buildTime string + pythonBin string // 由 main.go 傳入,InstallDriver 會用到 shutdownFn func() depsCache []deps.Dependency } -func NewSystemHandler(version, buildTime string, shutdownFn func()) *SystemHandler { +func NewSystemHandler(version, buildTime, pythonBin string, shutdownFn func()) *SystemHandler { return &SystemHandler{ startTime: time.Now(), version: version, buildTime: buildTime, + pythonBin: pythonBin, shutdownFn: shutdownFn, depsCache: deps.CheckAll(), } @@ -73,6 +75,46 @@ func (h *SystemHandler) Deps(c *gin.Context) { }) } +// InstallDriver 安裝 Kneron WinUSB driver(僅 Windows 有意義)。 +// 呼叫 platform-specific 實作(見 system_driver_windows.go / system_driver_other.go)。 +func (h *SystemHandler) InstallDriver(c *gin.Context) { + if runtime.GOOS != "windows" { + c.JSON(400, gin.H{ + "success": false, + "error": gin.H{ + "code": "DRIVER_NOT_NEEDED", + "message": runtime.GOOS + " 不需要安裝 WinUSB driver", + }, + }) + return + } + pyBin := h.pythonBin + if pyBin == "" { + c.JSON(500, gin.H{ + "success": false, + "error": gin.H{ + "code": "PYTHON_NOT_AVAILABLE", + "message": "Python interpreter 未就緒,請確認 bundled Python runtime 初始化完成", + }, + }) + return + } + if err := installKneronWinUSBDriverHandler(pyBin); err != nil { + c.JSON(500, gin.H{ + "success": false, + "error": gin.H{ + "code": "DRIVER_INSTALL_FAILED", + "message": err.Error(), + }, + }) + return + } + c.JSON(200, gin.H{ + "success": true, + "data": gin.H{"message": "WinUSB driver 安裝完成,請重新插拔裝置或直接點擊連線。"}, + }) +} + func (h *SystemHandler) Restart(c *gin.Context) { c.JSON(200, gin.H{"success": true, "data": gin.H{"message": "restarting"}}) if f, ok := c.Writer.(http.Flusher); ok { diff --git a/local-tool/server/internal/api/router.go b/local-tool/server/internal/api/router.go index 41ba9fb..15fbf45 100644 --- a/local-tool/server/internal/api/router.go +++ b/local-tool/server/internal/api/router.go @@ -49,6 +49,7 @@ func NewRouter( api.GET("/system/metrics", systemHandler.Metrics) api.GET("/system/deps", systemHandler.Deps) api.POST("/system/restart", systemHandler.Restart) + api.POST("/system/install-driver", systemHandler.InstallDriver) // Models api.GET("/models", modelHandler.ListModels) diff --git a/local-tool/server/main.go b/local-tool/server/main.go index 0206f17..ef46d0d 100644 --- a/local-tool/server/main.go +++ b/local-tool/server/main.go @@ -172,8 +172,15 @@ func main() { shutdownFn() } + // Resolve python bin (used by InstallDriver handler on Windows). + // Priority: VISIONA_PYTHON env var (set by Wails shell) → cfg.PythonBin (--python flag) + pythonBinForSystem := os.Getenv("VISIONA_PYTHON") + if pythonBinForSystem == "" { + pythonBinForSystem = cfg.PythonBin + } + // Create system handler with injected version and restart function - systemHandler := handlers.NewSystemHandler(Version, BuildTime, restartFn) + systemHandler := handlers.NewSystemHandler(Version, BuildTime, pythonBinForSystem, restartFn) // Create router r := api.NewRouter(modelRepo, modelStore, deviceMgr, cameraMgr, inferenceSvc, wsHub, staticFS, logBroadcaster, systemHandler) diff --git a/local-tool/visiona-local/app.go b/local-tool/visiona-local/app.go index 7d4ca65..dede8e2 100644 --- a/local-tool/visiona-local/app.go +++ b/local-tool/visiona-local/app.go @@ -310,6 +310,31 @@ func (a *App) OpenBrowser(url string) error { return openBrowser(url) } +// InstallKneronDriver 呼叫 KneronPLUS SDK 的 libwdi wrapper 安裝 WinUSB driver。 +// +// Windows 下需要 UAC 提權;macOS / Linux 下會直接回錯誤訊息(用不到)。 +// 前端在使用者點「安裝 Kneron USB driver」按鈕、或偵測到 connect 失敗 + error 28 時呼叫。 +// +// 呼叫前 Python venv 必須已就緒(ensureBundledPython 完成 + kp wheel 已裝)。 +// 此 binding 會自動嘗試重新解析 python 路徑。 +func (a *App) InstallKneronDriver() error { + a.mu.Lock() + pyBin := a.pythonBin + a.mu.Unlock() + + if pyBin == "" { + // 可能使用者手動觸發、server 還沒啟動或 ensurePythonRuntime 失敗 + // 嘗試重新 resolve + resolved, _, err := a.ensurePythonRuntime(a.pythonMode) + if err != nil { + return fmt.Errorf("無法取得 Python interpreter:%w", err) + } + pyBin = resolved + } + + return installKneronWinUSBDriver(pyBin) +} + // ----------------------------------------------------------------------- // Server 子行程管理 // ----------------------------------------------------------------------- diff --git a/local-tool/visiona-local/platform_darwin.go b/local-tool/visiona-local/platform_darwin.go index c1bb4f8..5745c62 100644 --- a/local-tool/visiona-local/platform_darwin.go +++ b/local-tool/visiona-local/platform_darwin.go @@ -3,6 +3,7 @@ package main import ( + "fmt" "os" "os/exec" "path/filepath" @@ -11,6 +12,11 @@ import ( // configureSysProcAttr 在 macOS 上不需要特殊設定。 func configureSysProcAttr(_ *exec.Cmd) {} +// installKneronWinUSBDriver 在 macOS 上是 no-op(macOS 不需要 driver,kernel 會直接給 kext/IOKit 權限)。 +func installKneronWinUSBDriver(_ string) error { + return fmt.Errorf("macOS 不需要安裝 WinUSB driver — Kneron SDK 會直接使用 IOKit") +} + // platformDataDir 回傳 macOS 的應用程式資料目錄。 // ~/Library/Application Support/visiona-local func platformDataDir() string { diff --git a/local-tool/visiona-local/platform_linux.go b/local-tool/visiona-local/platform_linux.go index 064b195..bf60b5d 100644 --- a/local-tool/visiona-local/platform_linux.go +++ b/local-tool/visiona-local/platform_linux.go @@ -3,6 +3,7 @@ package main import ( + "fmt" "os" "os/exec" "path/filepath" @@ -11,6 +12,12 @@ import ( // configureSysProcAttr 在 Linux 上不需要特殊設定。 func configureSysProcAttr(_ *exec.Cmd) {} +// installKneronWinUSBDriver 在 Linux 上不需要 WinUSB — 改用 udev rule(installer 已放到 /etc/udev/rules.d) +// 這個 stub 只為了跨平台 Go build 能通過。 +func installKneronWinUSBDriver(_ string) error { + return fmt.Errorf("Linux 不需要安裝 WinUSB driver — 請確認 /etc/udev/rules.d/99-kneron.rules 已放置並執行 udevadm reload") +} + // platformDataDir 回傳 Linux 的應用程式資料目錄(遵循 XDG)。 // $XDG_DATA_HOME/visiona-local 或 ~/.local/share/visiona-local func platformDataDir() string { diff --git a/local-tool/visiona-local/platform_windows.go b/local-tool/visiona-local/platform_windows.go index 0a32974..b5ec6de 100644 --- a/local-tool/visiona-local/platform_windows.go +++ b/local-tool/visiona-local/platform_windows.go @@ -3,9 +3,11 @@ package main import ( + "fmt" "os" "os/exec" "path/filepath" + "strings" "syscall" ) @@ -30,3 +32,88 @@ func platformDataDir() string { } return filepath.Join(appdata, appName) } + +// installKneronWinUSBDriver 呼叫 KneronPLUS SDK 的 libwdi wrapper 安裝 WinUSB driver。 +// +// 為什麼需要: +// - Kneron USB 裝置預設沒有綁定 WinUSB driver,KneronPLUS SDK 無法打開 handle → connect 失敗(error 28) +// - inf-based pnputil 安裝需要 .cat 簽章,我們沒有;libwdi 會自己用臨時自簽憑證解決 +// +// 實作參考 edge-ai-platform installer/platform_windows.go 的 installKneronDriverViaSDK: +// - 組一段 Python script 呼叫 kp.core.install_driver_for_windows(pid) 對 KL520/KL720/KL720_LEGACY 三種 PID +// - 用 PowerShell Start-Process -Verb RunAs 提權執行(libwdi 要求 admin) +// - 結果寫到 temp 檔讓 Go 這邊讀回來 +// +// 呼叫者:前端「安裝 Kneron USB driver」按鈕 → App.InstallKneronDriver() binding → 這個函式 +// 需要:venv 已建好(ensureBundledPython 完成)且 kp 模組已 import +func installKneronWinUSBDriver(pythonBin string) error { + if pythonBin == "" { + return fmt.Errorf("python interpreter not available — 請確認 bundled Python runtime 已完成初始化") + } + if _, err := os.Stat(pythonBin); err != nil { + return fmt.Errorf("python interpreter not found at %s: %w", pythonBin, err) + } + + resultPath := filepath.Join(os.TempDir(), "visiona-local-driver-result.txt") + _ = os.Remove(resultPath) + + // venv 根目錄(python.exe 在 venv\Scripts\python.exe 底下) + venvRoot := filepath.Dir(filepath.Dir(pythonBin)) + + pyScript := fmt.Sprintf(` +import sys, os +result_path = r'%s' +try: + # 讓 kp 模組能找到 native DLL + kp_lib = os.path.join(r'%s', 'Lib', 'site-packages', 'kp', 'lib') + if os.path.isdir(kp_lib): + os.environ['PATH'] = kp_lib + ';' + os.environ.get('PATH', '') + os.add_dll_directory(kp_lib) + import kp + results = [] + for pid in [kp.ProductId.KP_DEVICE_KL520, kp.ProductId.KP_DEVICE_KL720, kp.ProductId.KP_DEVICE_KL720_LEGACY]: + try: + kp.core.install_driver_for_windows(pid) + results.append(f"OK: {pid.name}") + except Exception as e: + results.append(f"SKIP: {pid.name}: {e}") + with open(result_path, 'w') as f: + f.write('\n'.join(results) + '\nDONE\n') +except ImportError as e: + with open(result_path, 'w') as f: + f.write(f'ERROR: kp module not available: {e}\n') +except Exception as e: + with open(result_path, 'w') as f: + f.write(f'ERROR: {e}\n') +`, resultPath, venvRoot) + + scriptPath := filepath.Join(os.TempDir(), "visiona-local-install-usb-driver.py") + if err := os.WriteFile(scriptPath, []byte(pyScript), 0o644); err != nil { + return fmt.Errorf("write driver install script: %w", err) + } + defer os.Remove(scriptPath) + + // 用 PowerShell Start-Process -Verb RunAs 觸發 UAC,libwdi 要求 admin + elevateCmd := fmt.Sprintf( + `Start-Process -FilePath '%s' -ArgumentList '"%s"' -Verb RunAs -Wait -WindowStyle Hidden`, + pythonBin, scriptPath, + ) + cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", elevateCmd) + configureSysProcAttr(cmd) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("driver 安裝需要系統管理員權限:%s", strings.TrimSpace(string(out))) + } + + resultData, err := os.ReadFile(resultPath) + _ = os.Remove(resultPath) + if err != nil { + return fmt.Errorf("driver 安裝已執行但無法讀取結果(請在裝置管理員確認,或使用 Zadig 手動安裝 https://zadig.akeo.ie/)") + } + + result := strings.TrimSpace(string(resultData)) + if strings.Contains(result, "ERROR:") { + return fmt.Errorf("driver 安裝失敗:%s(可改用 Zadig 手動安裝 https://zadig.akeo.ie/)", result) + } + + return nil +}