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

179 lines
5.3 KiB
Go

package api
import (
"fmt"
"io"
"net/http"
"strings"
"time"
"visiona-local/server/internal/api/handlers"
"visiona-local/server/internal/api/ws"
"visiona-local/server/internal/camera"
"visiona-local/server/internal/device"
"visiona-local/server/internal/inference"
"visiona-local/server/internal/model"
"visiona-local/server/pkg/logger"
"github.com/gin-gonic/gin"
)
func NewRouter(
modelRepo *model.Repository,
modelStore *model.ModelStore,
deviceMgr *device.Manager,
cameraMgr *camera.Manager,
inferenceSvc *inference.Service,
wsHub *ws.Hub,
staticFS http.FileSystem,
logBroadcaster *logger.Broadcaster,
systemHandler *handlers.SystemHandler,
) *gin.Engine {
// Use gin.New() instead of gin.Default() to replace the default logger
// with one that also pushes to the WebSocket broadcaster.
r := gin.New()
r.Use(gin.Recovery())
r.Use(broadcasterLogger(logBroadcaster))
r.Use(CORSMiddleware())
modelHandler := handlers.NewModelHandler(modelRepo)
modelUploadHandler := handlers.NewModelUploadHandler(modelRepo, modelStore)
deviceHandler := handlers.NewDeviceHandler(deviceMgr, inferenceSvc, wsHub)
cameraHandler := handlers.NewCameraHandler(cameraMgr, deviceMgr, inferenceSvc, wsHub)
api := r.Group("/api")
{
// System
api.GET("/system/health", systemHandler.HealthCheck)
api.GET("/system/info", systemHandler.Info)
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)
api.GET("/models/:id", modelHandler.GetModel)
api.POST("/models/upload", modelUploadHandler.UploadModel)
api.DELETE("/models/:id", modelUploadHandler.DeleteModel)
// Devices
api.GET("/devices", deviceHandler.ListDevices)
api.POST("/devices/scan", deviceHandler.ScanDevices)
api.GET("/devices/:id", deviceHandler.GetDevice)
api.POST("/devices/:id/connect", deviceHandler.ConnectDevice)
api.POST("/devices/:id/disconnect", deviceHandler.DisconnectDevice)
api.POST("/devices/:id/inference/start", deviceHandler.StartInference)
api.POST("/devices/:id/inference/stop", deviceHandler.StopInference)
// Camera
api.GET("/camera/list", cameraHandler.ListCameras)
api.POST("/camera/start", cameraHandler.StartPipeline)
api.POST("/camera/stop", cameraHandler.StopPipeline)
api.GET("/camera/stream", cameraHandler.StreamMJPEG)
// Media
api.POST("/media/upload/image", cameraHandler.UploadImage)
api.POST("/media/upload/video", cameraHandler.UploadVideo)
api.POST("/media/upload/batch-images", cameraHandler.UploadBatchImages)
api.GET("/media/batch-images/:index", cameraHandler.GetBatchImageFrame)
api.POST("/media/url", cameraHandler.StartFromURL)
api.POST("/media/seek", cameraHandler.SeekVideo)
}
// WebSocket
r.GET("/ws/devices/events", ws.DeviceEventsHandler(wsHub, deviceMgr))
r.GET("/ws/devices/:id/inference", ws.InferenceHandler(wsHub, inferenceSvc))
r.GET("/ws/server-logs", ws.ServerLogsHandler(wsHub, logBroadcaster))
// Embedded frontend static file serving (production mode)
if staticFS != nil {
fileServer := http.FileServer(staticFS)
// Serve Next.js-style static assets
r.GET("/_next/*filepath", func(c *gin.Context) {
fileServer.ServeHTTP(c.Writer, c.Request)
})
r.GET("/favicon.ico", func(c *gin.Context) {
fileServer.ServeHTTP(c.Writer, c.Request)
})
// SPA fallback for all other routes (client-side routing)
r.NoRoute(spaFallback(staticFS))
}
return r
}
// broadcasterLogger is a Gin middleware that logs HTTP requests to both
// stdout (like gin.Logger) and the WebSocket log broadcaster so that
// request logs are visible in the frontend Settings page.
func broadcasterLogger(b *logger.Broadcaster) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
raw := c.Request.URL.RawQuery
c.Next()
latency := time.Since(start)
status := c.Writer.Status()
method := c.Request.Method
if raw != "" {
path = path + "?" + raw
}
msg := fmt.Sprintf("%3d | %13v | %-7s %s",
status, latency, method, path)
// Write to stdout (original Gin behaviour)
fmt.Printf("[GIN] %s\n", msg)
// Push to broadcaster for WebSocket streaming
if b != nil {
level := "INFO"
if status >= 500 {
level = "ERROR"
} else if status >= 400 {
level = "WARN"
}
b.Push(level, fmt.Sprintf("[GIN] %s", msg))
}
}
}
// spaFallback tries to serve the exact file from the embedded FS.
// If the file doesn't exist, it serves index.html for client-side routing.
func spaFallback(staticFS http.FileSystem) gin.HandlerFunc {
return func(c *gin.Context) {
path := c.Request.URL.Path
// Don't serve index.html for API or WebSocket routes
if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/ws/") {
c.Status(http.StatusNotFound)
return
}
// Try to serve the exact file (e.g., /models/index.html)
f, err := staticFS.Open(path)
if err == nil {
f.Close()
http.FileServer(staticFS).ServeHTTP(c.Writer, c.Request)
return
}
// Fall back to root index.html for SPA routing
index, err := staticFS.Open("/index.html")
if err != nil {
c.Status(http.StatusInternalServerError)
return
}
defer index.Close()
c.Header("Content-Type", "text/html; charset=utf-8")
c.Status(http.StatusOK)
_, _ = io.Copy(c.Writer, index)
}
}