feat(local-tool): Kneron WinUSB driver 透過 KneronPLUS SDK libwdi 安裝

根因:
  KP_ERROR_CONNECT_FAILED (error code 28) — Kneron USB 裝置預設沒綁定
  WinUSB driver,SDK 無法開 handle。原 .iss 的 pnputil /add-driver 做法
  需要 .cat 簽章(Windows 10/11 driver signing enforcement),我們沒有。

參考 edge-ai-platform/installer/platform_windows.go 的 installKneronDriverViaSDK:
  KneronPLUS SDK 內建 libwdi wrapper — kp.core.install_driver_for_windows(pid)
  libwdi 會自動用臨時自簽憑證,不需要 .cat 檔,只需要 UAC 提權。

實作:
- server/internal/api/handlers/system_driver_windows.go(新):
  組 Python script → kp.core.install_driver_for_windows 對 KL520/KL720/KL720_LEGACY →
  PowerShell Start-Process -Verb RunAs 提權執行 → 結果寫 temp 檔讀回
- server/internal/api/handlers/system_driver_other.go(新):非 Windows stub
- system_handler.go: NewSystemHandler 新增 pythonBin 參數 + InstallDriver handler
  先判斷 runtime.GOOS==windows 才執行
- router.go: 新增 POST /system/install-driver
- main.go: 解析 pythonBin(VISIONA_PYTHON env var → cfg.PythonBin)傳入

前端:
- frontend/src/app/devices/page.tsx:Windows only 多一個「安裝 USB Driver」按鈕
  (用 navigator.userAgent 判斷平台)
- frontend/src/stores/device-store.ts:connect 失敗訊息偵測 winusb / error 28 /
  KP_ERROR_CONNECT_FAILED 特徵字串,回一條明確的繁中提示引導使用者去點按鈕

Wails app 端:
- visiona-local/app.go: 新增 InstallKneronDriver() binding 作為備用入口(目前前端沒用到,
  因為前端跑在 http://127.0.0.1 不是 wails://,但保留給未來 splash 階段觸發用)
- visiona-local/platform_{windows,darwin,linux}.go: installKneronWinUSBDriver 平台實作
  / 跨平台 stub

.iss:
- 移除 pnputil /add-driver 的 [Run] entry(它是 silent-fail 的 dead code,
  因為沒 .cat 簽章)
- 新增註解說明:driver 安裝改由 app 端呼叫 libwdi 完成

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-04-12 05:25:32 +08:00
parent f8533a6b04
commit 4902cb5531
12 changed files with 336 additions and 14 deletions

View File

@ -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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
@ -25,10 +49,23 @@ export default function DevicesPage() {
<h1 className="text-2xl font-bold">{t('devices.title')}</h1>
<p className="text-muted-foreground">{t('devices.subtitle')}</p>
</div>
<Button onClick={scanDevices} disabled={scanning} variant="outline" data-tour-id="scan-devices-btn">
<RefreshCw className={`mr-2 h-4 w-4 ${scanning ? 'animate-spin' : ''}`} />
{scanning ? t('devices.scanning') : t('devices.scan')}
</Button>
<div className="flex items-center gap-2">
{isWindows() && (
<Button
onClick={handleInstallDriver}
disabled={installingDriver}
variant="outline"
title="若連線失敗出現 WinUSB 相關錯誤,點此安裝 driver需要系統管理員權限"
>
<Usb className={`mr-2 h-4 w-4 ${installingDriver ? 'animate-pulse' : ''}`} />
{installingDriver ? '安裝中...' : '安裝 USB Driver'}
</Button>
)}
<Button onClick={scanDevices} disabled={scanning} variant="outline" data-tour-id="scan-devices-btn">
<RefreshCw className={`mr-2 h-4 w-4 ${scanning ? 'animate-spin' : ''}`} />
{scanning ? t('devices.scanning') : t('devices.scan')}
</Button>
</div>
</div>
<DeviceList devices={devices} loading={loading} />
</div>

View File

@ -72,7 +72,19 @@ export const useDeviceStore = create<DeviceState>((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();
},

View File

@ -92,11 +92,13 @@ Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Run]
; 安裝 WinUSB driverpnputil 為 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 elevationWails app 會彈提權對話框。
; 安裝完畢選擇性啟動 app
Filename: "{app}\{#MyAppExeName}"; \

View File

@ -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")
}

View File

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

View File

@ -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 {

View File

@ -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)

View File

@ -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)

View File

@ -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 子行程管理
// -----------------------------------------------------------------------

View File

@ -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-opmacOS 不需要 driverkernel 會直接給 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 {

View File

@ -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 ruleinstaller 已放到 /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 {

View File

@ -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 driverKneronPLUS 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 觸發 UAClibwdi 要求 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
}