jim800121chen 4902cb5531 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>
2026-04-12 05:25:32 +08:00

269 lines
7.9 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"log"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
"visiona-local/server/internal/api"
"visiona-local/server/internal/api/handlers"
"visiona-local/server/internal/api/ws"
"visiona-local/server/internal/camera"
"visiona-local/server/internal/config"
"visiona-local/server/internal/deps"
"visiona-local/server/internal/device"
"visiona-local/server/internal/inference"
"visiona-local/server/internal/model"
pkglogger "visiona-local/server/pkg/logger"
"visiona-local/server/web"
)
var (
Version = "dev"
BuildTime = "unknown"
)
// baseDir returns the base directory for resolving data/ and scripts/ paths.
// In dev mode (go run), uses the working directory.
// In production (compiled binary), uses the binary's directory so the server
// works correctly regardless of the working directory.
func baseDir(devMode bool) string {
if devMode {
return "."
}
exe, err := os.Executable()
if err != nil {
return "."
}
return filepath.Dir(exe)
}
// resolveBridgeScript finds kneron_bridge.py across different packaging layouts.
//
// Possible locations (tried in order):
// 1. <base>/scripts/kneron_bridge.py — dev mode or flat layout
// 2. <base>/../scripts/kneron_bridge.py — Windows/Linux installer: binary in {app}/bin, scripts in {app}/scripts
// 3. <base>/../Resources/scripts/kneron_bridge.py — macOS app bundle: binary in Contents/MacOS, scripts in Contents/Resources
// 4. ./scripts/kneron_bridge.py — cwd fallback
func resolveBridgeScript(base string) string {
candidates := []string{
filepath.Join(base, "scripts", "kneron_bridge.py"),
filepath.Join(base, "..", "scripts", "kneron_bridge.py"),
filepath.Join(base, "..", "Resources", "scripts", "kneron_bridge.py"),
filepath.Join(".", "scripts", "kneron_bridge.py"),
}
for _, c := range candidates {
abs, err := filepath.Abs(c)
if err != nil {
continue
}
if info, err := os.Stat(abs); err == nil && !info.IsDir() {
return abs
}
}
// Nothing found — return the default so downstream logs a clear error
return filepath.Join(base, "scripts", "kneron_bridge.py")
}
func main() {
cfg := config.Load()
logger := pkglogger.New(cfg.LogLevel)
logger.Info("Starting visionA-local Server %s (built: %s)", Version, BuildTime)
logger.Info("Mock mode: %v, Mock camera: %v, Dev mode: %v, Python mode: %s",
cfg.MockMode, cfg.MockCamera, cfg.DevMode, cfg.PythonMode)
// Check external dependencies
deps.PrintStartupReport(logger)
// Resolve base directory and data directory
base := baseDir(cfg.DevMode)
dataDir := cfg.DataDir
if dataDir == "" {
dataDir = filepath.Join(base, "data")
}
// Initialize model repository (built-in models from JSON)
modelRepo := model.NewRepository(filepath.Join(dataDir, "models.json"))
logger.Info("Loaded %d built-in models", modelRepo.Count())
// Initialize model store (custom uploaded models)
customModelDir := cfg.ModelDir
if customModelDir == "" {
customModelDir = filepath.Join(dataDir, "custom-models")
}
modelStore := model.NewModelStore(customModelDir)
customModels, err := modelStore.LoadCustomModels()
if err != nil {
logger.Warn("Failed to load custom models: %v", err)
}
for _, m := range customModels {
modelRepo.Add(m)
}
if len(customModels) > 0 {
logger.Info("Loaded %d custom models", len(customModels))
}
// Initialize WebSocket hub (before device manager so log broadcaster is ready)
wsHub := ws.NewHub()
go wsHub.Run()
// Initialize log broadcaster for real-time log streaming
logBroadcaster := pkglogger.NewBroadcaster(500, func(entry pkglogger.LogEntry) {
wsHub.BroadcastToRoom("server-logs", entry)
})
logger.SetBroadcaster(logBroadcaster)
// Initialize device manager
registry := device.NewRegistry()
bridgeScript := resolveBridgeScript(base)
logger.Info("Kneron bridge script: %s", bridgeScript)
deviceMgr := device.NewManager(registry, cfg.MockMode, cfg.MockDeviceCount, bridgeScript)
deviceMgr.SetLogBroadcaster(logBroadcaster)
deviceMgr.Start()
// Initialize camera manager
cameraMgr := camera.NewManager(cfg.MockCamera)
// Initialize services
inferenceSvc := inference.NewService(deviceMgr)
// Determine static file system for embedded frontend
var staticFS http.FileSystem
if !cfg.DevMode {
staticFS = web.StaticFS()
logger.Info("Serving embedded frontend static files")
} else {
logger.Info("Dev mode: frontend static serving disabled (use Wails dev server)")
}
// Build HTTP server (needed for graceful shutdown and restart)
var httpServer *http.Server
restartRequested := make(chan struct{}, 1)
shutdownFn := func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
inferenceSvc.StopAll()
cameraMgr.Close()
if httpServer != nil {
_ = httpServer.Shutdown(ctx)
}
}
restartFn := func() {
// Signal the main goroutine to perform exec after server shutdown
select {
case restartRequested <- struct{}{}:
default:
}
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, pythonBinForSystem, restartFn)
// Create router
r := api.NewRouter(modelRepo, modelStore, deviceMgr, cameraMgr, inferenceSvc, wsHub, staticFS, logBroadcaster, systemHandler)
// Configure HTTP server (bind to localhost only)
addr := cfg.Addr()
httpServer = &http.Server{
Addr: addr,
Handler: r,
}
// Handle OS signals for graceful shutdown
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-quit
logger.Info("Received signal %v, shutting down gracefully...", sig)
shutdownFn()
os.Exit(0)
}()
// Kill existing process on the port if occupied
killExistingProcess(addr, logger)
// Start server
logger.Info("Server listening on %s", addr)
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("Failed to start server: %v", err)
}
// If restart was requested, exec the same binary to replace this process
select {
case <-restartRequested:
logger.Info("Performing self-restart via exec...")
exe, err := os.Executable()
if err != nil {
log.Fatalf("Failed to get executable path: %v", err)
}
exe, err = filepath.EvalSymlinks(exe)
if err != nil {
log.Fatalf("Failed to resolve executable symlinks: %v", err)
}
_ = syscall.Exec(exe, os.Args, os.Environ())
log.Fatalf("syscall.Exec failed")
default:
// Normal shutdown, just exit
}
}
// killExistingProcess checks if the port is already in use and kills the
// occupying process so the server can start cleanly.
func killExistingProcess(addr string, logger *pkglogger.Logger) {
// Extract port from addr (e.g. "127.0.0.1:3721" → "3721")
_, port, err := net.SplitHostPort(addr)
if err != nil {
return
}
// Quick check: try to listen — if it works, port is free
ln, err := net.Listen("tcp", addr)
if err == nil {
ln.Close()
return
}
// Port is occupied, find and kill the process
logger.Info("Port %s is in use, killing existing process...", port)
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
cmd = exec.Command("cmd", "/C", fmt.Sprintf("for /f \"tokens=5\" %%a in ('netstat -ano ^| findstr :%s') do taskkill /F /PID %%a", port))
} else {
cmd = exec.Command("sh", "-c", fmt.Sprintf("lsof -ti:%s | xargs kill -9 2>/dev/null", port))
}
output, err := cmd.CombinedOutput()
if err != nil {
logger.Warn("Failed to kill process on port %s: %v (%s)", port, err, strings.TrimSpace(string(output)))
return
}
// Wait briefly for port to be released
time.Sleep(500 * time.Millisecond)
logger.Info("Previous process killed, port %s is now free", port)
}