jim800121chen 12b1fe3bad fix(local-agent): Rescan 序號身分比對替換 stale session + 鎖外 Disconnect
- serialIdentity() 三態比對(空/假序號 0x00000000 正規化為無身分、對齊
  ADR-018 R2):Rescan 對既存合成 ID 比對身分——同一顆保留 live session、
  換位/拔插以新 info 替換 session,serial 索引重建即正確
- stale drivers 改釋放鎖後 Disconnect(不再阻塞 GetDevice 路由)+
  Disconnect 錯誤改 WARNING log
- 測試 seam:detectFn/newDriverFn 注入 + newSessionDriverLocked 統一建構
  (Start 行為零變動);新 4 支行為級測試(拔除位移/換位 rebind/
  同序號保 session/無身分回歸)
- evidence:build/vet/test 全綠 + -race ok;review 通過 0C/0M/0Mi/4Sug
  (wp0-serial-routing-review.md「Minor #1 修復審查」章節)
- WP-C 阻擋項解除;觀察項:多裝置實測留意 changed identity log
  (偵測順序抖動時解法在 detector 端排序)

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

277 lines
9.3 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
// detectFn / newDriverFn are seams so Rescan behavior (unplug / slot
// swap of positional synthetic IDs) can be tested without spawning the
// Python bridge. Production wiring (set in NewManager) uses the kneron
// implementations.
detectFn func(scriptPath string) []driver.DeviceInfo
newDriverFn func(info driver.DeviceInfo, scriptPath string) driver.DeviceDriver
}
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,
detectFn: kneron.DetectDevices,
newDriverFn: func(info driver.DeviceInfo, scriptPath string) driver.DeviceDriver {
return kneron.NewKneronDriver(info, 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)
}
}
}
// newSessionDriverLocked builds a driver for a detected device and attaches
// the log broadcaster when the concrete driver supports it. Caller must hold
// m.mu (write lock) — it only reads m.logBroadcaster/scriptPath, but is kept
// locked-only for consistency with its callers.
func (m *Manager) newSessionDriverLocked(info driver.DeviceInfo) driver.DeviceDriver {
d := m.newDriverFn(info, m.scriptPath)
if m.logBroadcaster != nil {
if kd, ok := d.(*kneron.KneronDriver); ok {
kd.SetLogBroadcaster(m.logBroadcaster)
}
}
return d
}
func (m *Manager) Start() {
// Detect real Kneron devices (KL520, KL720, etc.) via Python bridge.
devices := m.detectFn(m.scriptPath)
if len(devices) == 0 {
log.Println("No Kneron devices detected")
return
}
m.mu.Lock()
defer m.mu.Unlock()
for _, info := range devices {
m.sessions[info.ID] = NewSession(m.newSessionDriverLocked(info))
log.Printf("Registered Kneron device: %s (%s, type=%s)", info.Name, info.ID, info.Type)
}
m.rebuildSerialIndexLocked()
}
// serialIdentity normalizes a serial for identity comparison: empty and the
// fake pyusb-fallback serial both mean "no identity" (ADR-018 R2), so they
// compare equal to each other and never equal to a real kn_number.
func serialIdentity(serial string) string {
if serial == fakeSerialNumber {
return ""
}
return serial
}
// Rescan re-detects connected Kneron devices. New devices are registered and
// removed devices are cleaned up. For an existing synthetic ID the session is
// kept only when the detected serial identity matches the session's; on a
// mismatch the session is replaced with a fresh driver built from the
// detected info.
//
// Why the identity check (WP-0 review Minor #1): synthetic IDs are
// positional ("kl520-0" = first KL520 found). With multiple same-chip
// dongles, unplugging or re-ordering shifts which physical device occupies a
// slot. Keeping the old session would make rebuildSerialIndexLocked() index
// a stale serial → the serial route would hit a removed/relocated device.
// The index must always reflect what detection currently sees.
func (m *Manager) Rescan() []driver.DeviceInfo {
detected := m.detectFn(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
}
// Drivers to disconnect after releasing the lock: a real KneronDriver
// Disconnect can take seconds, and holding the write lock would block
// every GetDevice (inference / flash / camera routing) meanwhile.
type staleDriver struct {
id string
drv driver.DeviceDriver
}
var stale []staleDriver
m.mu.Lock()
// Remove devices that are no longer present.
for id, s := range m.sessions {
if _, exists := detectedIDs[id]; !exists {
log.Printf("Device removed: %s", id)
stale = append(stale, staleDriver{id: id, drv: s.Driver})
delete(m.sessions, id)
}
}
// Register newly detected devices and replace sessions whose slot is now
// occupied by a different physical device.
for _, info := range detected {
if s, exists := m.sessions[info.ID]; exists {
old := s.Driver.Info().SerialNumber
if serialIdentity(old) == serialIdentity(info.SerialNumber) {
// Same physical device (or no identity on either side, e.g.
// the single-dongle no-SDK demo) — keep the live session and
// its connection state untouched.
continue
}
log.Printf("Device %s changed identity (serial %q -> %q); replacing session", info.ID, old, info.SerialNumber)
stale = append(stale, staleDriver{id: info.ID, drv: s.Driver})
delete(m.sessions, info.ID)
}
m.sessions[info.ID] = NewSession(m.newSessionDriverLocked(info))
log.Printf("Registered Kneron device: %s (%s, type=%s)", info.Name, info.ID, info.Type)
}
m.rebuildSerialIndexLocked()
// Snapshot current list while still holding the lock.
devices := make([]driver.DeviceInfo, 0, len(m.sessions))
for _, s := range m.sessions {
devices = append(devices, s.Driver.Info())
}
m.mu.Unlock()
for _, sd := range stale {
if err := sd.drv.Disconnect(); err != nil {
log.Printf("WARNING: disconnect of stale device %s failed: %v", sd.id, err)
}
}
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
}