jim800121chen b9ee184586 feat(device): WP-0 序號串通 + serial 路由(ADR-018 走向 A' 第一階段)
- local-agent/server:detector 保留 kn_number(parseScanDevices 可測化、
  合成 ID 語意不動)+ DeviceInfo.SerialNumber + Manager serialToLocalID
  反查表 + GetDevice 雙查(sessions 先、serial 後,純加法)
- visiona-agent:pairing exchange 帶本地裝置清單(DeviceLister 失敗不中斷
  配對、timeout 2s、omitempty 舊版相容)
- visionA-backend:exchange 收 devices —— R1 取第一顆可用序號、R2 假序號
  0x00000000 寫 NULL、R4 同 owner 同序號復用既有 device_id(防 23505)、
  pg+mem 兩實作對齊
- 五個 proxy 操作(flash/inference/camera/connect/disconnect)收斂於
  GetDevice 單一入口,serial 路由一處涵蓋
- docs:api-spec.md §2 增補 POST /api/pairing/exchange(schema + R1/R2/R4)
- 測試:行為級四環節鏈 + dbtest 130 實跑 6/6 + DBOn 回歸 4/4;
  三 module build/vet/test 全綠
- review:通過 0C/0M/6Mi/5Sug(.autoflow/05-implementation/review/
  wp0-serial-routing-review.md);Minor #1 Rescan stale session 掛 WP-C 前置

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 09:31:51 +08:00

213 lines
6.4 KiB
Go

package device
import (
"fmt"
"log"
"sync"
"visiona-agent/server/internal/driver"
"visiona-agent/server/internal/driver/kneron"
"visiona-agent/server/pkg/logger"
)
// fakeSerialNumber is the placeholder kn_number reported by the Python
// bridge pyusb fallback when the Kneron SDK is unavailable (e.g. macOS
// without the dylib). It is not a real hardware serial, so it must never be
// used as a routing key (multiple devices could collide on it). Treated the
// same as "no serial" (ADR-018 §2.2 / R2).
const fakeSerialNumber = "0x00000000"
type Manager struct {
registry *DriverRegistry
sessions map[string]*DeviceSession
// serialToLocalID maps a device's hardware serial (Kneron kn_number,
// e.g. "0x1A2B3C4D") to its local synthetic session key ("kl520-0").
//
// Why it exists (ADR-018 serial routing): cloud-side requests arrive with
// an identifier that never matched the local sessions key (the cloud used
// its own DB UUID). The serial is the only identifier stable across both
// layers, so GetDevice does a dual lookup: sessions[id] first (backward
// compatible with local synthetic IDs), then serialToLocalID[id].
// Empty and fake ("0x00000000") serials are never indexed.
serialToLocalID map[string]string
eventBus chan DeviceEvent
scriptPath string
logBroadcaster *logger.Broadcaster
mu sync.RWMutex
}
func NewManager(registry *DriverRegistry, scriptPath string) *Manager {
return &Manager{
registry: registry,
sessions: make(map[string]*DeviceSession),
serialToLocalID: make(map[string]string),
eventBus: make(chan DeviceEvent, 100),
scriptPath: scriptPath,
}
}
// SetLogBroadcaster attaches a log broadcaster so that Kneron driver
// and bridge logs are forwarded to the frontend.
func (m *Manager) SetLogBroadcaster(b *logger.Broadcaster) {
m.logBroadcaster = b
// Also set on any already-registered kneron drivers.
m.mu.RLock()
defer m.mu.RUnlock()
for _, s := range m.sessions {
if kd, ok := s.Driver.(*kneron.KneronDriver); ok {
kd.SetLogBroadcaster(b)
}
}
}
func (m *Manager) Start() {
// Detect real Kneron devices (KL520, KL720, etc.) via Python bridge.
devices := kneron.DetectDevices(m.scriptPath)
if len(devices) == 0 {
log.Println("No Kneron devices detected")
return
}
m.mu.Lock()
defer m.mu.Unlock()
for _, info := range devices {
d := kneron.NewKneronDriver(info, m.scriptPath)
if m.logBroadcaster != nil {
d.SetLogBroadcaster(m.logBroadcaster)
}
m.sessions[info.ID] = NewSession(d)
log.Printf("Registered Kneron device: %s (%s, type=%s)", info.Name, info.ID, info.Type)
}
m.rebuildSerialIndexLocked()
}
// Rescan re-detects connected Kneron devices. New devices are registered,
// removed devices are cleaned up, and existing devices are left untouched.
func (m *Manager) Rescan() []driver.DeviceInfo {
detected := kneron.DetectDevices(m.scriptPath)
// Build a set of detected device IDs.
detectedIDs := make(map[string]driver.DeviceInfo, len(detected))
for _, info := range detected {
detectedIDs[info.ID] = info
}
m.mu.Lock()
defer m.mu.Unlock()
// Remove devices that are no longer present.
for id, s := range m.sessions {
if _, exists := detectedIDs[id]; !exists {
log.Printf("Device removed: %s", id)
s.Driver.Disconnect()
delete(m.sessions, id)
}
}
// Add newly detected devices.
for _, info := range detected {
if _, exists := m.sessions[info.ID]; !exists {
d := kneron.NewKneronDriver(info, m.scriptPath)
if m.logBroadcaster != nil {
d.SetLogBroadcaster(m.logBroadcaster)
}
m.sessions[info.ID] = NewSession(d)
log.Printf("Registered Kneron device: %s (%s, type=%s)", info.Name, info.ID, info.Type)
}
}
m.rebuildSerialIndexLocked()
// Return current list.
devices := make([]driver.DeviceInfo, 0, len(m.sessions))
for _, s := range m.sessions {
devices = append(devices, s.Driver.Info())
}
return devices
}
// rebuildSerialIndexLocked rebuilds serialToLocalID from the current
// sessions. Caller must hold m.mu (write lock).
//
// Rebuilding (instead of incremental add/delete) keeps the index trivially
// consistent with sessions across Start/Rescan, including device removal.
// Empty serials are skipped; the fake serial "0x00000000" is skipped because
// multiple SDK-less devices report the same value (routing would be
// ambiguous). Real kn_numbers are unique per physical dongle; should a
// duplicate ever appear it is logged and only one entry wins.
func (m *Manager) rebuildSerialIndexLocked() {
idx := make(map[string]string, len(m.sessions))
for id, s := range m.sessions {
serial := s.Driver.Info().SerialNumber
if serial == "" || serial == fakeSerialNumber {
continue
}
if prev, dup := idx[serial]; dup {
log.Printf("WARNING: duplicate device serial %s (devices %s and %s); serial routing keeps one entry only", serial, prev, id)
continue
}
idx[serial] = id
}
m.serialToLocalID = idx
}
func (m *Manager) ListDevices() []driver.DeviceInfo {
m.mu.RLock()
defer m.mu.RUnlock()
devices := make([]driver.DeviceInfo, 0, len(m.sessions))
for _, s := range m.sessions {
devices = append(devices, s.Driver.Info())
}
return devices
}
// GetDevice resolves a device session by identifier with a dual lookup
// (ADR-018 serial routing, additive and backward compatible):
//
// 1. sessions[id] — the local synthetic key ("kl520-0"); every existing
// caller keeps working unchanged.
// 2. serialToLocalID[id] — the hardware serial (kn_number, "0x1A2B3C4D");
// lets cloud-proxied requests (flash / inference / camera / connect /
// disconnect all converge here) address a physical dongle by serial.
func (m *Manager) GetDevice(id string) (*DeviceSession, error) {
m.mu.RLock()
defer m.mu.RUnlock()
if s, ok := m.sessions[id]; ok {
return s, nil
}
if localID, ok := m.serialToLocalID[id]; ok {
if s, ok := m.sessions[localID]; ok {
return s, nil
}
}
return nil, fmt.Errorf("device not found: %s", id)
}
func (m *Manager) Connect(id string) error {
s, err := m.GetDevice(id)
if err != nil {
return err
}
if err := s.Driver.Connect(); err != nil {
return err
}
m.eventBus <- DeviceEvent{Event: "updated", Device: s.Driver.Info()}
return nil
}
func (m *Manager) Disconnect(id string) error {
s, err := m.GetDevice(id)
if err != nil {
return err
}
if err := s.Driver.Disconnect(); err != nil {
return err
}
m.eventBus <- DeviceEvent{Event: "updated", Device: s.Driver.Info()}
return nil
}
func (m *Manager) Events() <-chan DeviceEvent {
return m.eventBus
}