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
+}