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>
This commit is contained in:
parent
3d30fdc580
commit
b9ee184586
@ -47,6 +47,46 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### POST `/api/pairing/exchange`(agent → 雲端;public、不走 AuthMiddleware)
|
||||||
|
|
||||||
|
> 呼叫方是 **local agent**(不是瀏覽器前端):agent 拿 Pairing Token 換 Session Token 時本身還沒有登入身份,故此端點註冊在 engine 層級、不套 auth。行為主文件見 `../visiona-agent-tdd.md` §4.3 / §7.1;本節記錄 contract 形狀與 WP-0(ADR-018)擴充。
|
||||||
|
|
||||||
|
- **Request**(實作對齊 `visionA-backend/internal/api/pairing.go` `PairingExchangeRequest` 與 agent 端 `internal/tunnel/pairing.go` `exchangeRequest`):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"pairing_token": "vAc_<32 hex>",
|
||||||
|
"devices": [
|
||||||
|
{
|
||||||
|
"serial_number": "0x1A2B3C4D",
|
||||||
|
"device_type": "KL520",
|
||||||
|
"firmware": "1.2.3"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `pairing_token`(**required**):一次性 Pairing Token。
|
||||||
|
- `devices`(**optional**、WP-0 / migration 0004 新增):agent 在 exchange 前撈本地 `GET /api/devices` 上報的實體 USB 清單(含 Kneron kn_number 序號)。陣列因應「一 agent 多 USB」;`device_type` / `firmware` 為 optional 欄位(`omitempty`)。
|
||||||
|
- **Response**(200,通用 envelope 的 `data`):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"session_token": "vAs_...",
|
||||||
|
"account": "demo@visionA.local",
|
||||||
|
"relay_url": "wss://relay.visionA.cloud",
|
||||||
|
"expires_at": "2026-07-21T00:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **錯誤碼**(走 `error.code`,對齊 TDD §7.1 四種 case):`INVALID_PAIRING_TOKEN` / `PAIRING_TOKEN_EXPIRED` / `PAIRING_TOKEN_USED` / `PAIRING_TOKEN_REVOKED`。
|
||||||
|
|
||||||
|
**`devices` 欄位的雲端行為(WP-0 序號地基,決策見 `../adr/adr-018-agent-device-model.md`)**:
|
||||||
|
|
||||||
|
| 規則 | 行為 |
|
||||||
|
|------|------|
|
||||||
|
| R1(最小落地) | exchange 仍只落**一筆** device 記錄:從上報清單挑**第一顆序號可用**的裝置,把序號填進 `devices.serial_number`(agent 有帶 `device_type` 時一併填入)。多顆 USB 的完整模型(一 agent N device)屬 WP-B / migration 0005 範疇 |
|
||||||
|
| R2(假序號) | 假序號 `0x00000000`(agent 端 pyusb fallback、無 Kneron SDK 時寫死上報)與空字串序號**視同無序號**、跳過不用 → `serial_number` 寫 NULL |
|
||||||
|
| R4(同序號重配) | 同 owner 已存在同 serial 的未刪除 device → **復用既有 `device_id`**(只更新 `paired_at` / `updated_at`),不新建,避免撞 partial unique index `uq_devices_owner_serial_active` |
|
||||||
|
|
||||||
|
**向下相容**:`devices` 欄位缺省(舊 agent、local server 未起、撈不到清單、0 顆裝置)→ 行為與舊版**完全一致**:自建一筆 `serial_number = NULL` 的 device。序號是加值資訊,配對本身不因撈不到 USB 而失敗。
|
||||||
|
|
||||||
### GET `/api/pairing/status`
|
### GET `/api/pairing/status`
|
||||||
- 查詢當前 user 的 tunnel 連線狀態
|
- 查詢當前 user 的 tunnel 連線狀態
|
||||||
- Response:
|
- Response:
|
||||||
|
|||||||
@ -10,9 +10,26 @@ import (
|
|||||||
"visiona-agent/server/pkg/logger"
|
"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 {
|
type Manager struct {
|
||||||
registry *DriverRegistry
|
registry *DriverRegistry
|
||||||
sessions map[string]*DeviceSession
|
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
|
eventBus chan DeviceEvent
|
||||||
scriptPath string
|
scriptPath string
|
||||||
logBroadcaster *logger.Broadcaster
|
logBroadcaster *logger.Broadcaster
|
||||||
@ -23,6 +40,7 @@ func NewManager(registry *DriverRegistry, scriptPath string) *Manager {
|
|||||||
return &Manager{
|
return &Manager{
|
||||||
registry: registry,
|
registry: registry,
|
||||||
sessions: make(map[string]*DeviceSession),
|
sessions: make(map[string]*DeviceSession),
|
||||||
|
serialToLocalID: make(map[string]string),
|
||||||
eventBus: make(chan DeviceEvent, 100),
|
eventBus: make(chan DeviceEvent, 100),
|
||||||
scriptPath: scriptPath,
|
scriptPath: scriptPath,
|
||||||
}
|
}
|
||||||
@ -60,6 +78,7 @@ func (m *Manager) Start() {
|
|||||||
m.sessions[info.ID] = NewSession(d)
|
m.sessions[info.ID] = NewSession(d)
|
||||||
log.Printf("Registered Kneron device: %s (%s, type=%s)", info.Name, info.ID, info.Type)
|
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,
|
// Rescan re-detects connected Kneron devices. New devices are registered,
|
||||||
@ -97,6 +116,8 @@ func (m *Manager) Rescan() []driver.DeviceInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m.rebuildSerialIndexLocked()
|
||||||
|
|
||||||
// Return current list.
|
// Return current list.
|
||||||
devices := make([]driver.DeviceInfo, 0, len(m.sessions))
|
devices := make([]driver.DeviceInfo, 0, len(m.sessions))
|
||||||
for _, s := range m.sessions {
|
for _, s := range m.sessions {
|
||||||
@ -105,6 +126,31 @@ func (m *Manager) Rescan() []driver.DeviceInfo {
|
|||||||
return devices
|
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 {
|
func (m *Manager) ListDevices() []driver.DeviceInfo {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
@ -115,15 +161,27 @@ func (m *Manager) ListDevices() []driver.DeviceInfo {
|
|||||||
return devices
|
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) {
|
func (m *Manager) GetDevice(id string) (*DeviceSession, error) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
s, ok := m.sessions[id]
|
if s, ok := m.sessions[id]; ok {
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("device not found: %s", id)
|
|
||||||
}
|
|
||||||
return s, nil
|
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 {
|
func (m *Manager) Connect(id string) error {
|
||||||
s, err := m.GetDevice(id)
|
s, err := m.GetDevice(id)
|
||||||
|
|||||||
@ -12,8 +12,16 @@ type testDriver struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *testDriver) Info() driver.DeviceInfo { return d.info }
|
func (d *testDriver) Info() driver.DeviceInfo { return d.info }
|
||||||
func (d *testDriver) Connect() error { d.connected = true; d.info.Status = driver.StatusConnected; return nil }
|
func (d *testDriver) Connect() error {
|
||||||
func (d *testDriver) Disconnect() error { d.connected = false; d.info.Status = driver.StatusDisconnected; return nil }
|
d.connected = true
|
||||||
|
d.info.Status = driver.StatusConnected
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (d *testDriver) Disconnect() error {
|
||||||
|
d.connected = false
|
||||||
|
d.info.Status = driver.StatusDisconnected
|
||||||
|
return nil
|
||||||
|
}
|
||||||
func (d *testDriver) IsConnected() bool { return d.connected }
|
func (d *testDriver) IsConnected() bool { return d.connected }
|
||||||
func (d *testDriver) Flash(_ string, _ chan<- driver.FlashProgress) error { return nil }
|
func (d *testDriver) Flash(_ string, _ chan<- driver.FlashProgress) error { return nil }
|
||||||
func (d *testDriver) StartInference() error { return nil }
|
func (d *testDriver) StartInference() error { return nil }
|
||||||
@ -92,3 +100,114 @@ func TestManager_Connect(t *testing.T) {
|
|||||||
t.Error("Connect() did not connect device")
|
t.Error("Connect() did not connect device")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Serial routing (ADR-018 WP-0): serialToLocalID + GetDevice dual lookup
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// seedDevice registers a test driver session and rebuilds the serial index,
|
||||||
|
// mimicking what Start()/Rescan() do after registration.
|
||||||
|
func seedDevice(mgr *Manager, info driver.DeviceInfo) *testDriver {
|
||||||
|
td := &testDriver{info: info}
|
||||||
|
mgr.mu.Lock()
|
||||||
|
mgr.sessions[info.ID] = NewSession(td)
|
||||||
|
mgr.rebuildSerialIndexLocked()
|
||||||
|
mgr.mu.Unlock()
|
||||||
|
return td
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_GetDevice_BySerial(t *testing.T) {
|
||||||
|
mgr := NewManager(NewRegistry(), "")
|
||||||
|
seedDevice(mgr, driver.DeviceInfo{ID: "kl520-0", SerialNumber: "0x1A2B3C4D"})
|
||||||
|
|
||||||
|
t.Run("serial hits the physical device", func(t *testing.T) {
|
||||||
|
s, err := mgr.GetDevice("0x1A2B3C4D")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDevice(serial) error = %v", err)
|
||||||
|
}
|
||||||
|
if got := s.Driver.Info().ID; got != "kl520-0" {
|
||||||
|
t.Errorf("GetDevice(serial) resolved ID = %q, want kl520-0", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("local synthetic id still works (backward compat)", func(t *testing.T) {
|
||||||
|
s, err := mgr.GetDevice("kl520-0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDevice(localID) error = %v", err)
|
||||||
|
}
|
||||||
|
if s == nil {
|
||||||
|
t.Fatal("GetDevice(localID) returned nil session")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unknown id (e.g. cloud UUID) still misses", func(t *testing.T) {
|
||||||
|
if _, err := mgr.GetDevice("d76718a9-cf15-4795-914e-df5ae46ee536"); err == nil {
|
||||||
|
t.Error("GetDevice(UUID) expected error, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_GetDevice_MultipleSerials(t *testing.T) {
|
||||||
|
mgr := NewManager(NewRegistry(), "")
|
||||||
|
mgr.mu.Lock()
|
||||||
|
mgr.sessions["kl520-0"] = NewSession(&testDriver{info: driver.DeviceInfo{ID: "kl520-0", SerialNumber: "0xAAAA0001"}})
|
||||||
|
mgr.sessions["kl720-0"] = NewSession(&testDriver{info: driver.DeviceInfo{ID: "kl720-0", SerialNumber: "0xBBBB0002"}})
|
||||||
|
mgr.rebuildSerialIndexLocked()
|
||||||
|
mgr.mu.Unlock()
|
||||||
|
|
||||||
|
s1, err := mgr.GetDevice("0xAAAA0001")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDevice(0xAAAA0001) error = %v", err)
|
||||||
|
}
|
||||||
|
if got := s1.Driver.Info().ID; got != "kl520-0" {
|
||||||
|
t.Errorf("serial 0xAAAA0001 resolved to %q, want kl520-0", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
s2, err := mgr.GetDevice("0xBBBB0002")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDevice(0xBBBB0002) error = %v", err)
|
||||||
|
}
|
||||||
|
if got := s2.Driver.Info().ID; got != "kl720-0" {
|
||||||
|
t.Errorf("serial 0xBBBB0002 resolved to %q, want kl720-0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_SerialIndex_SkipsEmptyAndFakeSerial(t *testing.T) {
|
||||||
|
mgr := NewManager(NewRegistry(), "")
|
||||||
|
mgr.mu.Lock()
|
||||||
|
// Empty serial (bridge could not read kn_number).
|
||||||
|
mgr.sessions["kl520-0"] = NewSession(&testDriver{info: driver.DeviceInfo{ID: "kl520-0"}})
|
||||||
|
// Fake serial from pyusb fallback (no SDK) — must not be routable.
|
||||||
|
mgr.sessions["kl720-0"] = NewSession(&testDriver{info: driver.DeviceInfo{ID: "kl720-0", SerialNumber: fakeSerialNumber}})
|
||||||
|
mgr.rebuildSerialIndexLocked()
|
||||||
|
mgr.mu.Unlock()
|
||||||
|
|
||||||
|
if _, err := mgr.GetDevice(""); err == nil {
|
||||||
|
t.Error("GetDevice(\"\") expected error (empty serial must not be indexed)")
|
||||||
|
}
|
||||||
|
if _, err := mgr.GetDevice(fakeSerialNumber); err == nil {
|
||||||
|
t.Errorf("GetDevice(%s) expected error (fake serial must not be routable)", fakeSerialNumber)
|
||||||
|
}
|
||||||
|
// Devices themselves remain reachable by local id.
|
||||||
|
if _, err := mgr.GetDevice("kl520-0"); err != nil {
|
||||||
|
t.Errorf("GetDevice(kl520-0) error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := mgr.GetDevice("kl720-0"); err != nil {
|
||||||
|
t.Errorf("GetDevice(kl720-0) error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_SerialIndex_RemovedDeviceUnroutable(t *testing.T) {
|
||||||
|
mgr := NewManager(NewRegistry(), "")
|
||||||
|
seedDevice(mgr, driver.DeviceInfo{ID: "kl520-0", SerialNumber: "0x1A2B3C4D"})
|
||||||
|
|
||||||
|
// Simulate Rescan removing the device: delete session + rebuild index.
|
||||||
|
mgr.mu.Lock()
|
||||||
|
delete(mgr.sessions, "kl520-0")
|
||||||
|
mgr.rebuildSerialIndexLocked()
|
||||||
|
mgr.mu.Unlock()
|
||||||
|
|
||||||
|
if _, err := mgr.GetDevice("0x1A2B3C4D"); err == nil {
|
||||||
|
t.Error("GetDevice(serial) expected error after device removal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -20,6 +20,12 @@ type DeviceInfo struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Port string `json:"port"`
|
Port string `json:"port"`
|
||||||
|
// SerialNumber is the Kneron kn_number reported by the Python bridge
|
||||||
|
// (formatted "0x%08X"). Empty when the bridge cannot read it (e.g. the
|
||||||
|
// pyusb fallback reports the fake value 0x00000000, which callers should
|
||||||
|
// treat as "no serial"). Used as the stable cross-layer routing key
|
||||||
|
// (cloud devices.serial_number <-> local sessions key), see ADR-018.
|
||||||
|
SerialNumber string `json:"serialNumber,omitempty"`
|
||||||
VendorID uint16 `json:"vendorId,omitempty"`
|
VendorID uint16 `json:"vendorId,omitempty"`
|
||||||
ProductID uint16 `json:"productId,omitempty"`
|
ProductID uint16 `json:"productId,omitempty"`
|
||||||
Status DeviceStatus `json:"status"`
|
Status DeviceStatus `json:"status"`
|
||||||
|
|||||||
@ -198,6 +198,24 @@ func DetectDevices(scriptPath string) []driver.DeviceInfo {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return parseScanDevices(devicesRaw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseScanDevices converts the raw `devices` array from the Python bridge
|
||||||
|
// scan response into driver.DeviceInfo entries.
|
||||||
|
//
|
||||||
|
// Extracted from DetectDevices so the parsing logic is unit-testable without
|
||||||
|
// spawning the Python bridge subprocess.
|
||||||
|
//
|
||||||
|
// Serial number (kn_number) handling:
|
||||||
|
// - The bridge reports kn_number formatted "0x%08X" (SDK scan branch).
|
||||||
|
// - A missing or non-string kn_number yields an empty SerialNumber; the
|
||||||
|
// device is still registered (serial is additive metadata — a device must
|
||||||
|
// never be dropped because its serial could not be read).
|
||||||
|
// - The synthetic ID ("kl520-0") stays untouched: it remains the local
|
||||||
|
// sessions routing key (flash/inference depend on it). The serial travels
|
||||||
|
// in the new SerialNumber field only (ADR-018 serial routing).
|
||||||
|
func parseScanDevices(devicesRaw []interface{}) []driver.DeviceInfo {
|
||||||
// Track per-chip counters for naming (e.g. "KL520 #1", "KL720 #1").
|
// Track per-chip counters for naming (e.g. "KL520 #1", "KL720 #1").
|
||||||
chipCount := map[string]int{}
|
chipCount := map[string]int{}
|
||||||
|
|
||||||
@ -223,6 +241,11 @@ func DetectDevices(scriptPath string) []driver.DeviceInfo {
|
|||||||
productID = p
|
productID = p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
serial := ""
|
||||||
|
if s, ok := dev["kn_number"].(string); ok {
|
||||||
|
serial = strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
|
||||||
chip, devType := chipFromProductID(productID)
|
chip, devType := chipFromProductID(productID)
|
||||||
chipCount[chip]++
|
chipCount[chip]++
|
||||||
idx := chipCount[chip]
|
idx := chipCount[chip]
|
||||||
@ -232,6 +255,7 @@ func DetectDevices(scriptPath string) []driver.DeviceInfo {
|
|||||||
Name: fmt.Sprintf("Kneron %s #%d", chip, idx),
|
Name: fmt.Sprintf("Kneron %s #%d", chip, idx),
|
||||||
Type: devType,
|
Type: devType,
|
||||||
Port: port,
|
Port: port,
|
||||||
|
SerialNumber: serial,
|
||||||
VendorID: KneronVendorID,
|
VendorID: KneronVendorID,
|
||||||
Status: driver.StatusDetected,
|
Status: driver.StatusDetected,
|
||||||
FirmwareVer: fw,
|
FirmwareVer: fw,
|
||||||
|
|||||||
105
local-agent/server/internal/driver/kneron/detector_test.go
Normal file
105
local-agent/server/internal/driver/kneron/detector_test.go
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
package kneron
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestParseScanDevices_WithKnNumber verifies the serial number (kn_number)
|
||||||
|
// reported by the Python bridge is preserved into DeviceInfo.SerialNumber
|
||||||
|
// while the synthetic local ID stays untouched (ADR-018 serial routing, WP-0).
|
||||||
|
func TestParseScanDevices_WithKnNumber(t *testing.T) {
|
||||||
|
raw := []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"port": "1-2",
|
||||||
|
"firmware": "KDP",
|
||||||
|
"kn_number": "0x1A2B3C4D",
|
||||||
|
"product_id": "0x0100",
|
||||||
|
},
|
||||||
|
map[string]interface{}{
|
||||||
|
"port": "1-3",
|
||||||
|
"firmware": "KDP2",
|
||||||
|
"kn_number": "0x0E5F6071",
|
||||||
|
"product_id": "0x0720",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
devices := parseScanDevices(raw)
|
||||||
|
if len(devices) != 2 {
|
||||||
|
t.Fatalf("parseScanDevices() = %d devices, want 2", len(devices))
|
||||||
|
}
|
||||||
|
|
||||||
|
if devices[0].ID != "kl520-0" {
|
||||||
|
t.Errorf("devices[0].ID = %q, want kl520-0 (synthetic ID must not change)", devices[0].ID)
|
||||||
|
}
|
||||||
|
if devices[0].SerialNumber != "0x1A2B3C4D" {
|
||||||
|
t.Errorf("devices[0].SerialNumber = %q, want 0x1A2B3C4D", devices[0].SerialNumber)
|
||||||
|
}
|
||||||
|
if devices[1].ID != "kl720-0" {
|
||||||
|
t.Errorf("devices[1].ID = %q, want kl720-0", devices[1].ID)
|
||||||
|
}
|
||||||
|
if devices[1].SerialNumber != "0x0E5F6071" {
|
||||||
|
t.Errorf("devices[1].SerialNumber = %q, want 0x0E5F6071", devices[1].SerialNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseScanDevices_MissingKnNumber verifies a payload without kn_number
|
||||||
|
// still yields the full device list (serial empty, no panic, no device drop).
|
||||||
|
func TestParseScanDevices_MissingKnNumber(t *testing.T) {
|
||||||
|
raw := []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"port": "1-2",
|
||||||
|
"firmware": "KDP",
|
||||||
|
"product_id": "0x0100",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
devices := parseScanDevices(raw)
|
||||||
|
if len(devices) != 1 {
|
||||||
|
t.Fatalf("parseScanDevices() = %d devices, want 1 (device must not be dropped)", len(devices))
|
||||||
|
}
|
||||||
|
if devices[0].SerialNumber != "" {
|
||||||
|
t.Errorf("SerialNumber = %q, want empty when kn_number absent", devices[0].SerialNumber)
|
||||||
|
}
|
||||||
|
if devices[0].ID != "kl520-0" {
|
||||||
|
t.Errorf("ID = %q, want kl520-0", devices[0].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseScanDevices_NonStringKnNumber verifies a non-string kn_number
|
||||||
|
// (e.g. raw number instead of formatted hex string) does not panic and the
|
||||||
|
// device is still registered with an empty serial.
|
||||||
|
func TestParseScanDevices_NonStringKnNumber(t *testing.T) {
|
||||||
|
raw := []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"port": "1-2",
|
||||||
|
"kn_number": float64(123456), // JSON number decodes to float64
|
||||||
|
"product_id": "0x0200",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
devices := parseScanDevices(raw)
|
||||||
|
if len(devices) != 1 {
|
||||||
|
t.Fatalf("parseScanDevices() = %d devices, want 1", len(devices))
|
||||||
|
}
|
||||||
|
if devices[0].SerialNumber != "" {
|
||||||
|
t.Errorf("SerialNumber = %q, want empty for non-string kn_number", devices[0].SerialNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseScanDevices_WhitespaceTrimmed verifies kn_number whitespace is trimmed.
|
||||||
|
func TestParseScanDevices_WhitespaceTrimmed(t *testing.T) {
|
||||||
|
raw := []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"kn_number": " 0x1A2B3C4D ",
|
||||||
|
"product_id": "0x0100",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
devices := parseScanDevices(raw)
|
||||||
|
if len(devices) != 1 {
|
||||||
|
t.Fatalf("parseScanDevices() = %d devices, want 1", len(devices))
|
||||||
|
}
|
||||||
|
if devices[0].SerialNumber != "0x1A2B3C4D" {
|
||||||
|
t.Errorf("SerialNumber = %q, want trimmed 0x1A2B3C4D", devices[0].SerialNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -189,6 +189,7 @@ func NewApp() *App {
|
|||||||
// startup 由 Wails 在 app 啟動時呼叫。
|
// startup 由 Wails 在 app 啟動時呼叫。
|
||||||
//
|
//
|
||||||
// M8-4b:整合 6 階段啟動 pipeline。流程:
|
// M8-4b:整合 6 階段啟動 pipeline。流程:
|
||||||
|
//
|
||||||
// stage 1(init Wails console)→ seedUserDataDir 完成後 CompleteStage(1)
|
// stage 1(init Wails console)→ seedUserDataDir 完成後 CompleteStage(1)
|
||||||
// stage 2(Python runtime) ┐
|
// stage 2(Python runtime) ┐
|
||||||
// stage 3(spawn server) ├─ 由 startServerV2 內部 hook
|
// stage 3(spawn server) ├─ 由 startServerV2 內部 hook
|
||||||
@ -413,6 +414,11 @@ func (a *App) tryStartTunnel() {
|
|||||||
exchanger := tunnel.NewHTTPPairingExchanger(cloudAPIURL)
|
exchanger := tunnel.NewHTTPPairingExchanger(cloudAPIURL)
|
||||||
exchanger.MockMode = mockMode
|
exchanger.MockMode = mockMode
|
||||||
exchanger.InsecureSkipTLSVerify = insecureSkipTLSVerify
|
exchanger.InsecureSkipTLSVerify = insecureSkipTLSVerify
|
||||||
|
// WP-0(ADR-018 序號地基):exchange 前撈本地 /api/devices 把 USB 序號
|
||||||
|
// (kn_number)塞進 payload,雲端據此填 devices.serial_number。
|
||||||
|
// 撈失敗不會中斷配對(見 tunnel.DeviceLister 註解)。port 在上方已確認 > 0。
|
||||||
|
exchanger.DeviceLister = tunnel.NewLocalDeviceLister(port)
|
||||||
|
exchanger.Logf = a.appLog
|
||||||
|
|
||||||
// Startup log 明確標示當前模式;mock=true 時用 WARN 級字眼讓 dev 一眼看到。
|
// Startup log 明確標示當前模式;mock=true 時用 WARN 級字眼讓 dev 一眼看到。
|
||||||
if mockMode {
|
if mockMode {
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@ -110,10 +111,39 @@ type ExchangeResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// exchangeRequest 對應 TDD §4.3 定義的 request body。
|
// exchangeRequest 對應 TDD §4.3 定義的 request body。
|
||||||
|
//
|
||||||
|
// Devices 為 WP-0(ADR-018 序號地基)新增的 optional 欄位:exchange 前向本地
|
||||||
|
// server 撈 `GET /api/devices` 取得實體 USB 清單(含 Kneron kn_number 序號),
|
||||||
|
// 讓雲端把序號填進 devices.serial_number(serial 路由的資料來源)。
|
||||||
|
// `omitempty` 保證舊行為相容:撈不到清單(local server 未起 / timeout / 0 裝置)
|
||||||
|
// 時不送此欄位,雲端走現行「自建 serial=NULL device」路徑。
|
||||||
type exchangeRequest struct {
|
type exchangeRequest struct {
|
||||||
PairingToken string `json:"pairing_token"`
|
PairingToken string `json:"pairing_token"`
|
||||||
|
Devices []exchangeDevice `json:"devices,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// exchangeDevice 是 exchange payload 中的單顆實體 USB 裝置。
|
||||||
|
// 用陣列因應「一 agent 多 USB」(WP-0 只保證序號送達,多顆的完整模型是 WP-B)。
|
||||||
|
type exchangeDevice struct {
|
||||||
|
SerialNumber string `json:"serial_number"`
|
||||||
|
DeviceType string `json:"device_type,omitempty"`
|
||||||
|
Firmware string `json:"firmware,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalDevice 是 DeviceLister 回傳的本地 USB 裝置摘要(exchange payload 的來源)。
|
||||||
|
type LocalDevice struct {
|
||||||
|
SerialNumber string
|
||||||
|
DeviceType string
|
||||||
|
Firmware string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceLister 回傳本地 server 目前偵測到的 USB 裝置清單。
|
||||||
|
//
|
||||||
|
// 由 app.go 組裝時注入(NewLocalDeviceLister(port)),Exchanger 不自己猜 port。
|
||||||
|
// 回傳 error 或 nil 清單都不會讓 exchange 失敗——序號是加值資訊,配對本身
|
||||||
|
// 不能因為撈不到 USB 而中斷(fallback 到不帶 devices 的現行行為)。
|
||||||
|
type DeviceLister func() ([]LocalDevice, error)
|
||||||
|
|
||||||
// exchangeResponse 對齊雲端 /api/pairing/exchange 的成功 envelope(api/errors.go WriteSuccess):
|
// exchangeResponse 對齊雲端 /api/pairing/exchange 的成功 envelope(api/errors.go WriteSuccess):
|
||||||
//
|
//
|
||||||
// {
|
// {
|
||||||
@ -200,6 +230,14 @@ type HTTPPairingExchanger struct {
|
|||||||
// 僅在 Client == nil(走預設 client 建構)時生效;若呼叫者注入自訂 Client,
|
// 僅在 Client == nil(走預設 client 建構)時生效;若呼叫者注入自訂 Client,
|
||||||
// 以該 Client 自帶的 Transport 為準。
|
// 以該 Client 自帶的 Transport 為準。
|
||||||
InsecureSkipTLSVerify bool
|
InsecureSkipTLSVerify bool
|
||||||
|
|
||||||
|
// DeviceLister 供 exchange 前撈本地 USB 清單(含序號)塞進 payload。
|
||||||
|
// nil 或回傳失敗 → payload 不帶 devices(exchange 照常進行,見 DeviceLister 註解)。
|
||||||
|
DeviceLister DeviceLister
|
||||||
|
|
||||||
|
// Logf 為 optional 的 log 函式(app.go 注入 appLog)。nil 時 fallback 到
|
||||||
|
// 標準 log.Printf。撈裝置清單失敗屬「可恢復、不中斷」情況,必須留下紀錄。
|
||||||
|
Logf func(format string, args ...interface{})
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHTTPPairingExchanger 建立一個生產預設實例。
|
// NewHTTPPairingExchanger 建立一個生產預設實例。
|
||||||
@ -262,7 +300,10 @@ func (e *HTTPPairingExchanger) exchangeReal(pairingToken string) (ExchangeResult
|
|||||||
client = &c
|
client = &c
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := json.Marshal(exchangeRequest{PairingToken: pairingToken})
|
body, err := json.Marshal(exchangeRequest{
|
||||||
|
PairingToken: pairingToken,
|
||||||
|
Devices: e.collectLocalDevices(),
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ExchangeResult{}, err
|
return ExchangeResult{}, err
|
||||||
}
|
}
|
||||||
@ -310,6 +351,94 @@ func (e *HTTPPairingExchanger) exchangeReal(pairingToken string) (ExchangeResult
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// collectLocalDevices 在 exchange 前撈本地 USB 清單、轉成 payload 形狀。
|
||||||
|
//
|
||||||
|
// 不弄壞守則(device-serial-task1-mapping.md §2 段 4a):撈清單失敗(local
|
||||||
|
// server 沒起 / timeout / 0 裝置)**不可讓 exchange 失敗**——回 nil 讓 payload
|
||||||
|
// 省略 devices 欄位(`omitempty`),雲端走現行「serial=NULL」路徑。
|
||||||
|
func (e *HTTPPairingExchanger) collectLocalDevices() []exchangeDevice {
|
||||||
|
if e.DeviceLister == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
devs, err := e.DeviceLister()
|
||||||
|
if err != nil {
|
||||||
|
e.logf("pairing: list local devices failed (exchange continues without serials): %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(devs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]exchangeDevice, 0, len(devs))
|
||||||
|
for _, d := range devs {
|
||||||
|
out = append(out, exchangeDevice{
|
||||||
|
SerialNumber: strings.TrimSpace(d.SerialNumber),
|
||||||
|
DeviceType: d.DeviceType,
|
||||||
|
Firmware: d.Firmware,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// logf 走注入的 Logf(app.go 的 appLog);未注入時 fallback 標準 log。
|
||||||
|
func (e *HTTPPairingExchanger) logf(format string, args ...interface{}) {
|
||||||
|
if e.Logf != nil {
|
||||||
|
e.Logf(format, args...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf(format, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// localDeviceListTimeout 是撈本地 /api/devices 的 timeout。
|
||||||
|
// 比照 server_control.go probe 的 2 秒——序號是加值資訊,不能拖慢配對。
|
||||||
|
const localDeviceListTimeout = 2 * time.Second
|
||||||
|
|
||||||
|
// localDevicesEnvelope 對齊 local server `GET /api/devices` 的回應 envelope:
|
||||||
|
//
|
||||||
|
// { "success": true, "data": { "devices": [ { "id", "serialNumber", "type", "firmwareVersion", ... } ] } }
|
||||||
|
//
|
||||||
|
// 只解需要的欄位(serialNumber / type / firmwareVersion)。
|
||||||
|
type localDevicesEnvelope struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Data struct {
|
||||||
|
Devices []struct {
|
||||||
|
SerialNumber string `json:"serialNumber"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
FirmwareVersion string `json:"firmwareVersion"`
|
||||||
|
} `json:"devices"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLocalDeviceLister 建立一個向本地 server(127.0.0.1:port)撈
|
||||||
|
// `GET /api/devices` 的 DeviceLister。port 由 app.go 從 ServerController 取得
|
||||||
|
// 後注入(Exchanger 不自己猜 port)。
|
||||||
|
func NewLocalDeviceLister(port int) DeviceLister {
|
||||||
|
client := &http.Client{Timeout: localDeviceListTimeout}
|
||||||
|
url := fmt.Sprintf("http://127.0.0.1:%d/api/devices", port)
|
||||||
|
return func() ([]LocalDevice, error) {
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("local device list: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("local device list: http %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var env localDevicesEnvelope
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
|
||||||
|
return nil, fmt.Errorf("local device list: decode: %w", err)
|
||||||
|
}
|
||||||
|
out := make([]LocalDevice, 0, len(env.Data.Devices))
|
||||||
|
for _, d := range env.Data.Devices {
|
||||||
|
out = append(out, LocalDevice{
|
||||||
|
SerialNumber: d.SerialNumber,
|
||||||
|
DeviceType: d.Type,
|
||||||
|
Firmware: d.FirmwareVersion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// mapExchangeErrorCode 把雲端錯誤 envelope 的 error.code 映射成 agent 內部 sentinel error。
|
// mapExchangeErrorCode 把雲端錯誤 envelope 的 error.code 映射成 agent 內部 sentinel error。
|
||||||
//
|
//
|
||||||
// code 為雲端的大寫常數(見 visionA-backend api.ErrCodeInvalidPairingToken 等):
|
// code 為雲端的大寫常數(見 visionA-backend api.ErrCodeInvalidPairingToken 等):
|
||||||
|
|||||||
@ -4,8 +4,11 @@ package tunnel
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@ -374,3 +377,178 @@ func TestExchangeReal404HintsMockMode(t *testing.T) {
|
|||||||
t.Errorf("err = %v — should hint mock_mode or AB11", err)
|
t.Errorf("err = %v — should hint mock_mode or AB11", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// WP-0(ADR-018 序號地基):exchange payload 帶本地 USB 裝置清單
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// TestExchangeReal_PayloadIncludesDevices 驗證 DeviceLister 有清單時,
|
||||||
|
// exchange request body 帶 devices(含 serial_number),序號得以上雲。
|
||||||
|
func TestExchangeReal_PayloadIncludesDevices(t *testing.T) {
|
||||||
|
var gotBody map[string]interface{}
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(exchangeResponse{
|
||||||
|
Success: true,
|
||||||
|
Data: exchangeResponseData{SessionToken: "vAs_" + strings.Repeat("a", 64)},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ex := NewHTTPPairingExchanger(srv.URL)
|
||||||
|
ex.DeviceLister = func() ([]LocalDevice, error) {
|
||||||
|
return []LocalDevice{
|
||||||
|
{SerialNumber: "0x1A2B3C4D", DeviceType: "kneron_kl520", Firmware: "KDP"},
|
||||||
|
{SerialNumber: " 0x0E5F6071 ", DeviceType: "kneron_kl720"},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef"); err != nil {
|
||||||
|
t.Fatalf("Exchange: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
devices, ok := gotBody["devices"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("request body 缺 devices 欄位:%v", gotBody)
|
||||||
|
}
|
||||||
|
if len(devices) != 2 {
|
||||||
|
t.Fatalf("devices 長度 = %d, want 2", len(devices))
|
||||||
|
}
|
||||||
|
d0 := devices[0].(map[string]interface{})
|
||||||
|
if d0["serial_number"] != "0x1A2B3C4D" {
|
||||||
|
t.Errorf("devices[0].serial_number = %v, want 0x1A2B3C4D", d0["serial_number"])
|
||||||
|
}
|
||||||
|
if d0["device_type"] != "kneron_kl520" {
|
||||||
|
t.Errorf("devices[0].device_type = %v, want kneron_kl520", d0["device_type"])
|
||||||
|
}
|
||||||
|
d1 := devices[1].(map[string]interface{})
|
||||||
|
if d1["serial_number"] != "0x0E5F6071" {
|
||||||
|
t.Errorf("devices[1].serial_number = %v(應 trim 空白), want 0x0E5F6071", d1["serial_number"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExchangeReal_DeviceListerFails_ExchangeStillSucceeds 驗證不弄壞守則:
|
||||||
|
// 撈本地清單失敗時 exchange 照常成功(payload 無 devices),且失敗有留 log。
|
||||||
|
func TestExchangeReal_DeviceListerFails_ExchangeStillSucceeds(t *testing.T) {
|
||||||
|
var gotBody map[string]interface{}
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(exchangeResponse{
|
||||||
|
Success: true,
|
||||||
|
Data: exchangeResponseData{SessionToken: "vAs_" + strings.Repeat("b", 64)},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
var logged []string
|
||||||
|
ex := NewHTTPPairingExchanger(srv.URL)
|
||||||
|
ex.DeviceLister = func() ([]LocalDevice, error) {
|
||||||
|
return nil, errors.New("local server unreachable")
|
||||||
|
}
|
||||||
|
ex.Logf = func(format string, args ...interface{}) {
|
||||||
|
logged = append(logged, format)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef"); err != nil {
|
||||||
|
t.Fatalf("Exchange 不應因撈清單失敗而失敗:%v", err)
|
||||||
|
}
|
||||||
|
if _, has := gotBody["devices"]; has {
|
||||||
|
t.Errorf("撈清單失敗時 payload 不應帶 devices:%v", gotBody["devices"])
|
||||||
|
}
|
||||||
|
if len(logged) == 0 {
|
||||||
|
t.Error("撈清單失敗應留 log(no silent failures)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExchangeReal_NoDeviceLister_NoDevicesField 驗證未注入 DeviceLister(舊行為)
|
||||||
|
// 時 payload 完全不帶 devices 欄位(omitempty 相容性)。
|
||||||
|
func TestExchangeReal_NoDeviceLister_NoDevicesField(t *testing.T) {
|
||||||
|
var rawBody []byte
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
buf := new(strings.Builder)
|
||||||
|
_, _ = io.Copy(buf, r.Body)
|
||||||
|
rawBody = []byte(buf.String())
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(exchangeResponse{
|
||||||
|
Success: true,
|
||||||
|
Data: exchangeResponseData{SessionToken: "vAs_" + strings.Repeat("c", 64)},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ex := NewHTTPPairingExchanger(srv.URL)
|
||||||
|
if _, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef"); err != nil {
|
||||||
|
t.Fatalf("Exchange: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(rawBody), "devices") {
|
||||||
|
t.Errorf("無 DeviceLister 時 payload 不應含 devices 欄位:%s", rawBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewLocalDeviceLister_ParsesEnvelope 驗證 NewLocalDeviceLister 能解析
|
||||||
|
// local server GET /api/devices 的 envelope(success + data.devices)。
|
||||||
|
func TestNewLocalDeviceLister_ParsesEnvelope(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/devices" {
|
||||||
|
w.WriteHeader(404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"devices": [
|
||||||
|
{"id":"kl520-0","name":"Kneron KL520 #1","type":"kneron_kl520","serialNumber":"0x1A2B3C4D","firmwareVersion":"KDP","status":"detected"},
|
||||||
|
{"id":"kl720-0","name":"Kneron KL720 #1","type":"kneron_kl720","status":"detected"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
port := portFromTestServerURL(t, srv.URL)
|
||||||
|
lister := NewLocalDeviceLister(port)
|
||||||
|
devs, err := lister()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lister: %v", err)
|
||||||
|
}
|
||||||
|
if len(devs) != 2 {
|
||||||
|
t.Fatalf("devices = %d, want 2", len(devs))
|
||||||
|
}
|
||||||
|
if devs[0].SerialNumber != "0x1A2B3C4D" || devs[0].DeviceType != "kneron_kl520" || devs[0].Firmware != "KDP" {
|
||||||
|
t.Errorf("devs[0] = %+v", devs[0])
|
||||||
|
}
|
||||||
|
if devs[1].SerialNumber != "" {
|
||||||
|
t.Errorf("devs[1].SerialNumber = %q, want empty(serialNumber omitempty)", devs[1].SerialNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewLocalDeviceLister_Unreachable 驗證 local server 不可達時回 error
|
||||||
|
// (由 collectLocalDevices 轉為「不帶 devices、exchange 照常」)。
|
||||||
|
func TestNewLocalDeviceLister_Unreachable(t *testing.T) {
|
||||||
|
// 先開再關,拿一個幾乎必然沒人聽的 port。
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||||
|
port := portFromTestServerURL(t, srv.URL)
|
||||||
|
srv.Close()
|
||||||
|
|
||||||
|
lister := NewLocalDeviceLister(port)
|
||||||
|
if _, err := lister(); err == nil {
|
||||||
|
t.Error("lister 對不可達的 local server 應回 error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// portFromTestServerURL 從 httptest server URL(http://127.0.0.1:PORT)解出 port。
|
||||||
|
func portFromTestServerURL(t *testing.T, rawURL string) int {
|
||||||
|
t.Helper()
|
||||||
|
u, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse url %q: %v", rawURL, err)
|
||||||
|
}
|
||||||
|
port, err := strconv.Atoi(u.Port())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse port from %q: %v", rawURL, err)
|
||||||
|
}
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
|||||||
@ -336,8 +336,14 @@ func registerPairingPublicRoutes(r gin.IRouter, deps Deps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PairingExchangeRequest 是 POST /api/pairing/exchange 的 request body。
|
// PairingExchangeRequest 是 POST /api/pairing/exchange 的 request body。
|
||||||
|
//
|
||||||
|
// Devices 為 WP-0(ADR-018 序號地基)新增的 optional 欄位:agent 在 exchange 前
|
||||||
|
// 撈本地 USB 清單(含 Kneron kn_number 序號)上報,雲端據此填
|
||||||
|
// devices.serial_number。舊 agent 不送此欄位 → 行為與現行完全一致
|
||||||
|
// (自建 serial=NULL device)。
|
||||||
type PairingExchangeRequest struct {
|
type PairingExchangeRequest struct {
|
||||||
PairingToken string `json:"pairing_token" binding:"required"`
|
PairingToken string `json:"pairing_token" binding:"required"`
|
||||||
|
Devices []ExchangeDeviceInput `json:"devices,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PairingExchangeResponse 是 POST /api/pairing/exchange 成功時的 data payload。
|
// PairingExchangeResponse 是 POST /api/pairing/exchange 成功時的 data payload。
|
||||||
@ -439,7 +445,7 @@ func pairingExchangeHandler(deps Deps) gin.HandlerFunc {
|
|||||||
deviceID string
|
deviceID string
|
||||||
)
|
)
|
||||||
if deps.PairingExchanger != nil {
|
if deps.PairingExchanger != nil {
|
||||||
res, exErr := deps.PairingExchanger.Provision(ctx, info.UserID, info.TokenHash, auth.SessionTokenTTL)
|
res, exErr := deps.PairingExchanger.Provision(ctx, info.UserID, info.TokenHash, auth.SessionTokenTTL, req.Devices)
|
||||||
if exErr != nil {
|
if exErr != nil {
|
||||||
logOrDefault(deps.Logger).Error("pairing exchange: provision device+session failed",
|
logOrDefault(deps.Logger).Error("pairing exchange: provision device+session failed",
|
||||||
"error", exErr,
|
"error", exErr,
|
||||||
|
|||||||
@ -30,8 +30,10 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@ -53,11 +55,41 @@ const (
|
|||||||
|
|
||||||
// ExchangeProvisionResult 回報 exchange 自建 device + 建 session token 的結果。
|
// ExchangeProvisionResult 回報 exchange 自建 device + 建 session token 的結果。
|
||||||
type ExchangeProvisionResult struct {
|
type ExchangeProvisionResult struct {
|
||||||
DeviceID string // 本次自建的 device id
|
DeviceID string // 本次自建(或依 serial 復用)的 device id
|
||||||
SessionPlaintext string // 新 session token 原文(caller 只此一次能拿到)
|
SessionPlaintext string // 新 session token 原文(caller 只此一次能拿到)
|
||||||
SessionInfo *auth.SessionToken // session token 儲存層表示(含 ExpiresAt)
|
SessionInfo *auth.SessionToken // session token 儲存層表示(含 ExpiresAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExchangeDeviceInput 是 exchange payload 中 agent 上報的單顆實體 USB 裝置
|
||||||
|
// (WP-0 / ADR-018 序號地基,對齊 agent 端 tunnel.exchangeDevice 的 JSON)。
|
||||||
|
type ExchangeDeviceInput struct {
|
||||||
|
SerialNumber string `json:"serial_number"`
|
||||||
|
DeviceType string `json:"device_type,omitempty"`
|
||||||
|
Firmware string `json:"firmware,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeSerialNumber 是 agent 端 pyusb fallback(無 Kneron SDK,如 macOS 缺 dylib)
|
||||||
|
// 寫死上報的假序號。多顆無 SDK 裝置會撞同一個值,不可當唯一鍵 / 路由鍵——
|
||||||
|
// 視同「無序號」寫 NULL(ADR-018 §2.2 / task-1 mapping R2)。
|
||||||
|
const fakeSerialNumber = "0x00000000"
|
||||||
|
|
||||||
|
// firstUsableSerialDevice 從 agent 上報清單挑第一顆「序號可用」的裝置。
|
||||||
|
//
|
||||||
|
// WP-0 最小落地(task-1 mapping R1):exchange 仍只落一筆 device,多顆 USB 的
|
||||||
|
// 完整模型(一 agent N device)是 WP-B / migration 0005 的範疇。這裡取第一顆
|
||||||
|
// 有效序號填入;空序號與假序號(0x00000000)跳過。
|
||||||
|
func firstUsableSerialDevice(devices []ExchangeDeviceInput) (ExchangeDeviceInput, bool) {
|
||||||
|
for _, d := range devices {
|
||||||
|
serial := strings.TrimSpace(d.SerialNumber)
|
||||||
|
if serial == "" || strings.EqualFold(serial, fakeSerialNumber) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
d.SerialNumber = serial
|
||||||
|
return d, true
|
||||||
|
}
|
||||||
|
return ExchangeDeviceInput{}, false
|
||||||
|
}
|
||||||
|
|
||||||
// PairingExchanger 把「自建 device + 建 session token」包成一個原子(Postgres tx)或
|
// PairingExchanger 把「自建 device + 建 session token」包成一個原子(Postgres tx)或
|
||||||
// 一致(in-memory 依序)操作。
|
// 一致(in-memory 依序)操作。
|
||||||
//
|
//
|
||||||
@ -67,14 +99,26 @@ type PairingExchanger interface {
|
|||||||
// Provision 自建一筆 device(owner = userID)並建一筆綁該 device 的 session token。
|
// Provision 自建一筆 device(owner = userID)並建一筆綁該 device 的 session token。
|
||||||
//
|
//
|
||||||
// parentTokenHash 為來源 pairing token 的 hash(稽核鏈,寫進 session_tokens.parent_token_hash)。
|
// parentTokenHash 為來源 pairing token 的 hash(稽核鏈,寫進 session_tokens.parent_token_hash)。
|
||||||
Provision(ctx context.Context, userID, parentTokenHash string, ttl time.Duration) (ExchangeProvisionResult, error)
|
//
|
||||||
|
// devices 為 agent 上報的實體 USB 清單(WP-0 序號地基,可為 nil = 舊 agent /
|
||||||
|
// 撈不到清單,行為與現行完全一致)。序號可用時:
|
||||||
|
// - 同 owner 已有同 serial 的未刪除 device → 復用既有 device_id(防
|
||||||
|
// uq_devices_owner_serial_active 23505 炸裂;「同序號重配 = 復用」,R4)。
|
||||||
|
// - 否則新建 device 並填 serial_number。
|
||||||
|
Provision(ctx context.Context, userID, parentTokenHash string, ttl time.Duration, devices []ExchangeDeviceInput) (ExchangeProvisionResult, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Postgres 後端 ─────────────────────────────────────────────────────────────
|
// ── Postgres 後端 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// pgDeviceSaver 是 device 在 tx 內 upsert 的能力(由 device.PostgresRepository 滿足)。
|
// pgDeviceSaver 是 device 在 tx 內 upsert + 依 serial 查詢的能力
|
||||||
|
// (由 device.PostgresRepository 滿足)。
|
||||||
|
//
|
||||||
|
// GetBySerial 用於 WP-0 序號防炸:serial 有值時 exchange 先查同 owner 是否已有
|
||||||
|
// 同 serial 的未刪除 device,有則復用、不再自建(避免撞 partial unique
|
||||||
|
// uq_devices_owner_serial_active → 23505 → exchange 500)。
|
||||||
type pgDeviceSaver interface {
|
type pgDeviceSaver interface {
|
||||||
SaveTx(ctx context.Context, q db.Querier, d *device.Device) error
|
SaveTx(ctx context.Context, q db.Querier, d *device.Device) error
|
||||||
|
GetBySerial(ctx context.Context, ownerUserID, serial string) (*device.Device, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// pgSessionTokenCreator 是「在 tx 內建 session token」的能力(由 auth.PostgresSessionTokenStore 滿足)。
|
// pgSessionTokenCreator 是「在 tx 內建 session token」的能力(由 auth.PostgresSessionTokenStore 滿足)。
|
||||||
@ -105,23 +149,28 @@ func NewPostgresPairingExchanger(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provision 在單一交易內:自建 device → 建綁該 device 的 session token。
|
// Provision 在單一交易內:自建(或依 serial 復用)device → 建綁該 device 的 session token。
|
||||||
//
|
//
|
||||||
// 任一步失敗整筆 rollback(device 不會「已建但沒 token」殘留在 DB)。
|
// 任一步失敗整筆 rollback(device 不會「已建但沒 token」殘留在 DB)。
|
||||||
|
//
|
||||||
|
// WP-0 序號地基(ADR-018):agent 上報清單有可用序號時,先 GetBySerial 查同
|
||||||
|
// owner 是否已有同 serial 的未刪除 device——有則復用既有 device_id(同序號重配
|
||||||
|
// = 復用,R4),沒有才新建並填 serial_number。已知限制:GetBySerial 走 pool
|
||||||
|
// (非 tx 內),與並發 exchange 之間有極小 race window;撞到時 SaveTx 會被
|
||||||
|
// partial unique index 擋下(整筆 rollback、不產生重複 serial),同一顆 agent
|
||||||
|
// 的配對操作實務上是序列的,可接受。
|
||||||
func (e *pgPairingExchanger) Provision(
|
func (e *pgPairingExchanger) Provision(
|
||||||
ctx context.Context, userID, parentTokenHash string, ttl time.Duration,
|
ctx context.Context, userID, parentTokenHash string, ttl time.Duration, devices []ExchangeDeviceInput,
|
||||||
) (ExchangeProvisionResult, error) {
|
) (ExchangeProvisionResult, error) {
|
||||||
var res ExchangeProvisionResult
|
var res ExchangeProvisionResult
|
||||||
deviceID := uuid.NewString()
|
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
|
||||||
err := db.WithTx(ctx, e.pool, func(q db.Querier) error {
|
|
||||||
dev := &device.Device{
|
dev := &device.Device{
|
||||||
ID: deviceID,
|
ID: uuid.NewString(),
|
||||||
OwnerUserID: userID,
|
OwnerUserID: userID,
|
||||||
Name: defaultPairedDeviceName,
|
Name: defaultPairedDeviceName,
|
||||||
DeviceType: defaultPairedDeviceType,
|
DeviceType: defaultPairedDeviceType,
|
||||||
// serial_number 留空(agent 未帶):SaveTx 把空 serial 寫成 SQL NULL,
|
// serial_number 預設留空(agent 未帶):SaveTx 把空 serial 寫成 SQL NULL,
|
||||||
// 故同 owner 多次 exchange 各建一筆 serial=NULL 的 distinct device,不撞
|
// 故同 owner 多次 exchange 各建一筆 serial=NULL 的 distinct device,不撞
|
||||||
// partial unique uq_devices_owner_serial_active(每個 NULL 互不相等)。
|
// partial unique uq_devices_owner_serial_active(每個 NULL 互不相等)。
|
||||||
RemoteStatus: device.RemoteStatusOffline,
|
RemoteStatus: device.RemoteStatusOffline,
|
||||||
@ -130,15 +179,36 @@ func (e *pgPairingExchanger) Provision(
|
|||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if input, ok := firstUsableSerialDevice(devices); ok {
|
||||||
|
existing, gErr := e.devices.GetBySerial(ctx, userID, input.SerialNumber)
|
||||||
|
switch {
|
||||||
|
case gErr == nil:
|
||||||
|
// 復用既有 device:保留既有欄位,只更新配對時間。
|
||||||
|
dev = existing
|
||||||
|
dev.PairedAt = &now
|
||||||
|
dev.UpdatedAt = now
|
||||||
|
case errors.Is(gErr, device.ErrNotFound):
|
||||||
|
// 新建 device,填入序號(+ agent 上報的 device type 若有)。
|
||||||
|
dev.SerialNumber = input.SerialNumber
|
||||||
|
if input.DeviceType != "" {
|
||||||
|
dev.DeviceType = input.DeviceType
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return ExchangeProvisionResult{}, fmt.Errorf("exchange: get device by serial: %w", gErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := db.WithTx(ctx, e.pool, func(q db.Querier) error {
|
||||||
if saveErr := e.devices.SaveTx(ctx, q, dev); saveErr != nil {
|
if saveErr := e.devices.SaveTx(ctx, q, dev); saveErr != nil {
|
||||||
return fmt.Errorf("exchange: save device: %w", saveErr)
|
return fmt.Errorf("exchange: save device: %w", saveErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
plaintext, info, createErr := e.sessionToken.CreateTx(ctx, q, userID, deviceID, parentTokenHash, ttl)
|
plaintext, info, createErr := e.sessionToken.CreateTx(ctx, q, userID, dev.ID, parentTokenHash, ttl)
|
||||||
if createErr != nil {
|
if createErr != nil {
|
||||||
return fmt.Errorf("exchange: create session token: %w", createErr)
|
return fmt.Errorf("exchange: create session token: %w", createErr)
|
||||||
}
|
}
|
||||||
res.DeviceID = deviceID
|
res.DeviceID = dev.ID
|
||||||
res.SessionPlaintext = plaintext
|
res.SessionPlaintext = plaintext
|
||||||
res.SessionInfo = info
|
res.SessionInfo = info
|
||||||
return nil
|
return nil
|
||||||
@ -176,15 +246,15 @@ func NewInMemoryPairingExchanger(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provision 自建 device 後建綁該 device 的 session token(依序,非交易)。
|
// Provision 自建(或依 serial 復用)device 後建綁該 device 的 session token
|
||||||
|
// (依序,非交易)。serial 處理邏輯與 pgPairingExchanger 對齊(WP-0)。
|
||||||
func (e *memPairingExchanger) Provision(
|
func (e *memPairingExchanger) Provision(
|
||||||
ctx context.Context, userID, parentTokenHash string, ttl time.Duration,
|
ctx context.Context, userID, parentTokenHash string, ttl time.Duration, devices []ExchangeDeviceInput,
|
||||||
) (ExchangeProvisionResult, error) {
|
) (ExchangeProvisionResult, error) {
|
||||||
deviceID := uuid.NewString()
|
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
|
||||||
dev := &device.Device{
|
dev := &device.Device{
|
||||||
ID: deviceID,
|
ID: uuid.NewString(),
|
||||||
OwnerUserID: userID,
|
OwnerUserID: userID,
|
||||||
Name: defaultPairedDeviceName,
|
Name: defaultPairedDeviceName,
|
||||||
DeviceType: defaultPairedDeviceType,
|
DeviceType: defaultPairedDeviceType,
|
||||||
@ -194,16 +264,34 @@ func (e *memPairingExchanger) Provision(
|
|||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if input, ok := firstUsableSerialDevice(devices); ok {
|
||||||
|
existing, gErr := e.devices.GetBySerial(ctx, userID, input.SerialNumber)
|
||||||
|
switch {
|
||||||
|
case gErr == nil:
|
||||||
|
dev = existing
|
||||||
|
dev.PairedAt = &now
|
||||||
|
dev.UpdatedAt = now
|
||||||
|
case errors.Is(gErr, device.ErrNotFound):
|
||||||
|
dev.SerialNumber = input.SerialNumber
|
||||||
|
if input.DeviceType != "" {
|
||||||
|
dev.DeviceType = input.DeviceType
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return ExchangeProvisionResult{}, fmt.Errorf("exchange: get device by serial: %w", gErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := e.devices.Save(ctx, dev); err != nil {
|
if err := e.devices.Save(ctx, dev); err != nil {
|
||||||
return ExchangeProvisionResult{}, fmt.Errorf("exchange: save device: %w", err)
|
return ExchangeProvisionResult{}, fmt.Errorf("exchange: save device: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
plaintext, info, err := e.sessionToken.Create(ctx, userID, deviceID, parentTokenHash, ttl)
|
plaintext, info, err := e.sessionToken.Create(ctx, userID, dev.ID, parentTokenHash, ttl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ExchangeProvisionResult{}, fmt.Errorf("exchange: create session token: %w", err)
|
return ExchangeProvisionResult{}, fmt.Errorf("exchange: create session token: %w", err)
|
||||||
}
|
}
|
||||||
return ExchangeProvisionResult{
|
return ExchangeProvisionResult{
|
||||||
DeviceID: deviceID,
|
DeviceID: dev.ID,
|
||||||
SessionPlaintext: plaintext,
|
SessionPlaintext: plaintext,
|
||||||
SessionInfo: info,
|
SessionInfo: info,
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
@ -58,7 +58,7 @@ func TestPGExchange_ProvisionCreatesDeviceAndSession(t *testing.T) {
|
|||||||
tdb, exchanger, devRepo, sessions, owner := pgExchangeFixture(t)
|
tdb, exchanger, devRepo, sessions, owner := pgExchangeFixture(t)
|
||||||
|
|
||||||
parentHash := auth.HashToken("vAc_" + uuid.NewString()[:32])
|
parentHash := auth.HashToken("vAc_" + uuid.NewString()[:32])
|
||||||
res, err := exchanger.Provision(ctx, owner, parentHash, auth.SessionTokenTTL)
|
res, err := exchanger.Provision(ctx, owner, parentHash, auth.SessionTokenTTL, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotEmpty(t, res.DeviceID, "應自建一筆 device")
|
require.NotEmpty(t, res.DeviceID, "應自建一筆 device")
|
||||||
require.NotEmpty(t, res.SessionPlaintext, "應建一個 session token")
|
require.NotEmpty(t, res.SessionPlaintext, "應建一個 session token")
|
||||||
@ -94,7 +94,7 @@ func TestPGExchange_Provision_RollbackOnBadOwner(t *testing.T) {
|
|||||||
tdb, exchanger, _, _, _ := pgExchangeFixture(t)
|
tdb, exchanger, _, _, _ := pgExchangeFixture(t)
|
||||||
|
|
||||||
badOwner := uuid.NewString() // 不在 users 表
|
badOwner := uuid.NewString() // 不在 users 表
|
||||||
_, err := exchanger.Provision(ctx, badOwner, "", auth.SessionTokenTTL)
|
_, err := exchanger.Provision(ctx, badOwner, "", auth.SessionTokenTTL, nil)
|
||||||
require.Error(t, err, "owner 不存在 → device.owner_user_id FK violation")
|
require.Error(t, err, "owner 不存在 → device.owner_user_id FK violation")
|
||||||
|
|
||||||
// device 不應殘留(整筆交易 rollback)
|
// device 不應殘留(整筆交易 rollback)
|
||||||
@ -108,11 +108,79 @@ func TestPGExchange_Provision_MultipleCreatesDistinctDevices(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
_, exchanger, _, _, owner := pgExchangeFixture(t)
|
_, exchanger, _, _, owner := pgExchangeFixture(t)
|
||||||
|
|
||||||
res1, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL)
|
res1, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
res2, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL)
|
res2, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.NotEqual(t, res1.DeviceID, res2.DeviceID, "兩次 exchange 應各自建不同 device")
|
assert.NotEqual(t, res1.DeviceID, res2.DeviceID, "兩次 exchange 應各自建不同 device")
|
||||||
assert.NotEqual(t, res1.SessionPlaintext, res2.SessionPlaintext, "兩次 session token 應不同")
|
assert.NotEqual(t, res1.SessionPlaintext, res2.SessionPlaintext, "兩次 session token 應不同")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// WP-0(ADR-018 序號地基):exchange 收 agent 上報序號(真 DB)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// TestPGExchange_Provision_FillsSerialNumber 驗證序號真的寫進 devices.serial_number
|
||||||
|
// (非 NULL),且 device_type 取 agent 上報值。
|
||||||
|
func TestPGExchange_Provision_FillsSerialNumber(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
tdb, exchanger, devRepo, _, owner := pgExchangeFixture(t)
|
||||||
|
|
||||||
|
res, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL,
|
||||||
|
[]ExchangeDeviceInput{{SerialNumber: "0x1A2B3C4D", DeviceType: "kneron_kl520"}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
dev, err := devRepo.Get(ctx, res.DeviceID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "0x1A2B3C4D", dev.SerialNumber)
|
||||||
|
assert.Equal(t, "kneron_kl520", dev.DeviceType)
|
||||||
|
|
||||||
|
// 直接查 DB 確認 serial_number 非 NULL(不是空字串寫入)
|
||||||
|
var serialIsNull bool
|
||||||
|
require.NoError(t, tdb.Pool.QueryRow(ctx,
|
||||||
|
`SELECT serial_number IS NULL FROM devices WHERE id = $1`, res.DeviceID).Scan(&serialIsNull))
|
||||||
|
assert.False(t, serialIsNull, "devices.serial_number 不應為 NULL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPGExchange_Provision_SameSerialReusesDevice 驗證同序號重複 exchange 不撞
|
||||||
|
// partial unique uq_devices_owner_serial_active(23505)——復用既有 device、
|
||||||
|
// 不 500、devices 表不長出第二筆。
|
||||||
|
func TestPGExchange_Provision_SameSerialReusesDevice(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
tdb, exchanger, _, sessions, owner := pgExchangeFixture(t)
|
||||||
|
input := []ExchangeDeviceInput{{SerialNumber: "0x1A2B3C4D"}}
|
||||||
|
|
||||||
|
res1, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
res2, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, input)
|
||||||
|
require.NoError(t, err, "同序號重配不可撞 23505 炸 500(防炸守則)")
|
||||||
|
|
||||||
|
assert.Equal(t, res1.DeviceID, res2.DeviceID, "同序號重配應復用既有 device")
|
||||||
|
assert.Equal(t, 1, tdb.CountRows(t, "devices"), "同序號重配不應多建 device")
|
||||||
|
|
||||||
|
// 兩個 session token 都存在、都綁同一顆 device
|
||||||
|
tok2, err := sessions.Get(ctx, res2.SessionPlaintext)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, res1.DeviceID, tok2.DeviceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPGExchange_Provision_FakeSerialWritesNull 驗證假序號 0x00000000 視同無序號
|
||||||
|
// → serial_number 寫 NULL、不進復用分支(NULL 互不相等,各建 distinct device)。
|
||||||
|
func TestPGExchange_Provision_FakeSerialWritesNull(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
tdb, exchanger, _, _, owner := pgExchangeFixture(t)
|
||||||
|
input := []ExchangeDeviceInput{{SerialNumber: "0x00000000"}}
|
||||||
|
|
||||||
|
res1, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
res2, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, input)
|
||||||
|
require.NoError(t, err, "假序號寫 NULL、NULL 互不相等 → 不撞 unique")
|
||||||
|
|
||||||
|
assert.NotEqual(t, res1.DeviceID, res2.DeviceID)
|
||||||
|
|
||||||
|
var serialIsNull bool
|
||||||
|
require.NoError(t, tdb.Pool.QueryRow(ctx,
|
||||||
|
`SELECT serial_number IS NULL FROM devices WHERE id = $1`, res1.DeviceID).Scan(&serialIsNull))
|
||||||
|
assert.True(t, serialIsNull, "假序號應寫 NULL")
|
||||||
|
}
|
||||||
|
|||||||
@ -22,7 +22,7 @@ func TestMemExchange_ProvisionCreatesDeviceAndSession(t *testing.T) {
|
|||||||
sessions := auth.NewInMemorySessionTokenStore()
|
sessions := auth.NewInMemorySessionTokenStore()
|
||||||
exchanger := NewInMemoryPairingExchanger(devRepo, sessions)
|
exchanger := NewInMemoryPairingExchanger(devRepo, sessions)
|
||||||
|
|
||||||
res, err := exchanger.Provision(ctx, "owner-1", "parent-hash", auth.SessionTokenTTL)
|
res, err := exchanger.Provision(ctx, "owner-1", "parent-hash", auth.SessionTokenTTL, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotEmpty(t, res.DeviceID)
|
require.NotEmpty(t, res.DeviceID)
|
||||||
require.NotEmpty(t, res.SessionPlaintext)
|
require.NotEmpty(t, res.SessionPlaintext)
|
||||||
@ -49,11 +49,96 @@ func TestMemExchange_Provision_DistinctDevices(t *testing.T) {
|
|||||||
exchanger := NewInMemoryPairingExchanger(
|
exchanger := NewInMemoryPairingExchanger(
|
||||||
device.NewInMemoryRepository(), auth.NewInMemorySessionTokenStore())
|
device.NewInMemoryRepository(), auth.NewInMemorySessionTokenStore())
|
||||||
|
|
||||||
res1, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL)
|
res1, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
res2, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL)
|
res2, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.NotEqual(t, res1.DeviceID, res2.DeviceID)
|
assert.NotEqual(t, res1.DeviceID, res2.DeviceID)
|
||||||
assert.NotEqual(t, res1.SessionPlaintext, res2.SessionPlaintext)
|
assert.NotEqual(t, res1.SessionPlaintext, res2.SessionPlaintext)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// WP-0(ADR-018 序號地基):exchange 收 agent 上報序號
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// TestMemExchange_Provision_FillsSerialNumber 驗證 agent 上報序號時,自建 device
|
||||||
|
// 的 serial_number 有值(+ device_type 若有上報)。
|
||||||
|
func TestMemExchange_Provision_FillsSerialNumber(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
devRepo := device.NewInMemoryRepository()
|
||||||
|
exchanger := NewInMemoryPairingExchanger(devRepo, auth.NewInMemorySessionTokenStore())
|
||||||
|
|
||||||
|
res, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL,
|
||||||
|
[]ExchangeDeviceInput{{SerialNumber: "0x1A2B3C4D", DeviceType: "kneron_kl520", Firmware: "KDP"}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
dev, err := devRepo.Get(ctx, res.DeviceID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "0x1A2B3C4D", dev.SerialNumber, "serial_number 應填入 agent 上報值")
|
||||||
|
assert.Equal(t, "kneron_kl520", dev.DeviceType, "device_type 有上報時應覆蓋預設")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMemExchange_Provision_SameSerialReusesDevice 驗證同序號重複 exchange
|
||||||
|
// 復用既有 device(防唯一約束炸裂;「同序號重配 = 復用」,R4)——不新建、
|
||||||
|
// 不報錯、session token 各自獨立。
|
||||||
|
func TestMemExchange_Provision_SameSerialReusesDevice(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
devRepo := device.NewInMemoryRepository()
|
||||||
|
exchanger := NewInMemoryPairingExchanger(devRepo, auth.NewInMemorySessionTokenStore())
|
||||||
|
input := []ExchangeDeviceInput{{SerialNumber: "0x1A2B3C4D"}}
|
||||||
|
|
||||||
|
res1, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL, input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
res2, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL, input)
|
||||||
|
require.NoError(t, err, "同序號重配不可失敗(防 23505 的行為對齊)")
|
||||||
|
|
||||||
|
assert.Equal(t, res1.DeviceID, res2.DeviceID, "同序號重配應復用既有 device")
|
||||||
|
assert.NotEqual(t, res1.SessionPlaintext, res2.SessionPlaintext, "session token 應各自獨立")
|
||||||
|
|
||||||
|
devices, err := devRepo.List(ctx, "owner-1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, devices, 1, "同序號重配不應多建 device")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMemExchange_Provision_FakeSerialTreatedAsEmpty 驗證假序號 0x00000000
|
||||||
|
// (macOS 無 SDK 的 pyusb fallback)視同無序號:serial 留空、行為與不帶序號一致
|
||||||
|
// (每次 exchange 各建一筆 distinct device)。
|
||||||
|
func TestMemExchange_Provision_FakeSerialTreatedAsEmpty(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
devRepo := device.NewInMemoryRepository()
|
||||||
|
exchanger := NewInMemoryPairingExchanger(devRepo, auth.NewInMemorySessionTokenStore())
|
||||||
|
input := []ExchangeDeviceInput{{SerialNumber: "0x00000000"}}
|
||||||
|
|
||||||
|
res1, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL, input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
res2, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL, input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
dev1, err := devRepo.Get(ctx, res1.DeviceID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, dev1.SerialNumber, "假序號應視同無序號(寫 NULL)")
|
||||||
|
assert.NotEqual(t, res1.DeviceID, res2.DeviceID, "假序號不進復用分支,各建 distinct device")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMemExchange_Provision_PicksFirstUsableSerial 驗證多顆上報時取第一顆
|
||||||
|
// 可用序號(空序號 / 假序號跳過)——WP-0 最小落地(R1:多顆完整模型是 WP-B)。
|
||||||
|
func TestMemExchange_Provision_PicksFirstUsableSerial(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
devRepo := device.NewInMemoryRepository()
|
||||||
|
exchanger := NewInMemoryPairingExchanger(devRepo, auth.NewInMemorySessionTokenStore())
|
||||||
|
|
||||||
|
res, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL,
|
||||||
|
[]ExchangeDeviceInput{
|
||||||
|
{SerialNumber: ""}, // 空序號跳過
|
||||||
|
{SerialNumber: "0x00000000"}, // 假序號跳過
|
||||||
|
{SerialNumber: " 0x0E5F6071 ", DeviceType: "kneron_kl720"},
|
||||||
|
{SerialNumber: "0xFFFF0001"},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
dev, err := devRepo.Get(ctx, res.DeviceID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "0x0E5F6071", dev.SerialNumber, "應取第一顆可用序號(trim 空白)")
|
||||||
|
assert.Equal(t, "kneron_kl720", dev.DeviceType)
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user