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`
|
||||
- 查詢當前 user 的 tunnel 連線狀態
|
||||
- Response:
|
||||
|
||||
@ -10,21 +10,39 @@ import (
|
||||
"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
|
||||
eventBus chan DeviceEvent
|
||||
scriptPath string
|
||||
logBroadcaster *logger.Broadcaster
|
||||
mu sync.RWMutex
|
||||
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),
|
||||
eventBus: make(chan DeviceEvent, 100),
|
||||
scriptPath: scriptPath,
|
||||
registry: registry,
|
||||
sessions: make(map[string]*DeviceSession),
|
||||
serialToLocalID: make(map[string]string),
|
||||
eventBus: make(chan DeviceEvent, 100),
|
||||
scriptPath: scriptPath,
|
||||
}
|
||||
}
|
||||
|
||||
@ -60,6 +78,7 @@ func (m *Manager) Start() {
|
||||
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,
|
||||
@ -97,6 +116,8 @@ func (m *Manager) Rescan() []driver.DeviceInfo {
|
||||
}
|
||||
}
|
||||
|
||||
m.rebuildSerialIndexLocked()
|
||||
|
||||
// Return current list.
|
||||
devices := make([]driver.DeviceInfo, 0, len(m.sessions))
|
||||
for _, s := range m.sessions {
|
||||
@ -105,6 +126,31 @@ func (m *Manager) Rescan() []driver.DeviceInfo {
|
||||
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()
|
||||
@ -115,14 +161,26 @@ func (m *Manager) ListDevices() []driver.DeviceInfo {
|
||||
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()
|
||||
s, ok := m.sessions[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("device not found: %s", id)
|
||||
if s, ok := m.sessions[id]; ok {
|
||||
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 {
|
||||
|
||||
@ -11,9 +11,17 @@ type testDriver struct {
|
||||
connected bool
|
||||
}
|
||||
|
||||
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) Disconnect() error { d.connected = false; d.info.Status = driver.StatusDisconnected; return nil }
|
||||
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) Disconnect() error {
|
||||
d.connected = false
|
||||
d.info.Status = driver.StatusDisconnected
|
||||
return nil
|
||||
}
|
||||
func (d *testDriver) IsConnected() bool { return d.connected }
|
||||
func (d *testDriver) Flash(_ string, _ chan<- driver.FlashProgress) 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")
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,10 +16,16 @@ type DeviceDriver interface {
|
||||
}
|
||||
|
||||
type DeviceInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Port string `json:"port"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
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"`
|
||||
ProductID uint16 `json:"productId,omitempty"`
|
||||
Status DeviceStatus `json:"status"`
|
||||
|
||||
@ -89,8 +89,8 @@ const KneronVendorID uint16 = 0x3231
|
||||
|
||||
// Known Kneron product IDs.
|
||||
const (
|
||||
ProductIDKL520 = "0x0100"
|
||||
ProductIDKL720 = "0x0200"
|
||||
ProductIDKL520 = "0x0100"
|
||||
ProductIDKL720 = "0x0200"
|
||||
ProductIDKL720Alt = "0x0720"
|
||||
)
|
||||
|
||||
@ -198,6 +198,24 @@ func DetectDevices(scriptPath string) []driver.DeviceInfo {
|
||||
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").
|
||||
chipCount := map[string]int{}
|
||||
|
||||
@ -223,18 +241,24 @@ func DetectDevices(scriptPath string) []driver.DeviceInfo {
|
||||
productID = p
|
||||
}
|
||||
|
||||
serial := ""
|
||||
if s, ok := dev["kn_number"].(string); ok {
|
||||
serial = strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
chip, devType := chipFromProductID(productID)
|
||||
chipCount[chip]++
|
||||
idx := chipCount[chip]
|
||||
|
||||
info := driver.DeviceInfo{
|
||||
ID: fmt.Sprintf("%s-%d", strings.ToLower(chip), idx-1),
|
||||
Name: fmt.Sprintf("Kneron %s #%d", chip, idx),
|
||||
Type: devType,
|
||||
Port: port,
|
||||
VendorID: KneronVendorID,
|
||||
Status: driver.StatusDetected,
|
||||
FirmwareVer: fw,
|
||||
ID: fmt.Sprintf("%s-%d", strings.ToLower(chip), idx-1),
|
||||
Name: fmt.Sprintf("Kneron %s #%d", chip, idx),
|
||||
Type: devType,
|
||||
Port: port,
|
||||
SerialNumber: serial,
|
||||
VendorID: KneronVendorID,
|
||||
Status: driver.StatusDetected,
|
||||
FirmwareVer: fw,
|
||||
}
|
||||
devices = append(devices, info)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -56,9 +56,9 @@ const (
|
||||
// 乾淨 Windows 環境總和常常超過 60 秒,180 秒涵蓋 99% 情境。日常啟動
|
||||
// server 幾百毫秒就能回應,放寬上限不影響正常情境;配合 pipeline pause
|
||||
// 機制也不會讓 soft/hard timeout 誤觸。
|
||||
healthCheckTimeout = 180 * time.Second
|
||||
shutdownGracePeriod = 5 * time.Second
|
||||
appName = "visiona-agent"
|
||||
healthCheckTimeout = 180 * time.Second
|
||||
shutdownGracePeriod = 5 * time.Second
|
||||
appName = "visiona-agent"
|
||||
)
|
||||
|
||||
// PythonMode 決定 Python runtime 的選擇策略。
|
||||
@ -189,12 +189,13 @@ func NewApp() *App {
|
||||
// startup 由 Wails 在 app 啟動時呼叫。
|
||||
//
|
||||
// M8-4b:整合 6 階段啟動 pipeline。流程:
|
||||
// stage 1(init Wails console)→ seedUserDataDir 完成後 CompleteStage(1)
|
||||
// stage 2(Python runtime) ┐
|
||||
// stage 3(spawn server) ├─ 由 startServerV2 內部 hook
|
||||
// stage 4(device probe) ┘
|
||||
// stage 5(open browser) → ctrl.Start() return 後處理
|
||||
// stage 6(wait WebSocket) → watcher goroutine poll sentinel file
|
||||
//
|
||||
// stage 1(init Wails console)→ seedUserDataDir 完成後 CompleteStage(1)
|
||||
// stage 2(Python runtime) ┐
|
||||
// stage 3(spawn server) ├─ 由 startServerV2 內部 hook
|
||||
// stage 4(device probe) ┘
|
||||
// stage 5(open browser) → ctrl.Start() return 後處理
|
||||
// stage 6(wait WebSocket) → watcher goroutine poll sentinel file
|
||||
//
|
||||
// pipeline 失敗時不再呼叫 reportFatal(會直接結束程式),而是進 Error state,
|
||||
// 讓使用者看到 Wails 控制台的 Retry 按鈕。
|
||||
@ -413,6 +414,11 @@ func (a *App) tryStartTunnel() {
|
||||
exchanger := tunnel.NewHTTPPairingExchanger(cloudAPIURL)
|
||||
exchanger.MockMode = mockMode
|
||||
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 一眼看到。
|
||||
if mockMode {
|
||||
|
||||
@ -21,6 +21,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
@ -110,10 +111,39 @@ type ExchangeResult struct {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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):
|
||||
//
|
||||
// {
|
||||
@ -200,6 +230,14 @@ type HTTPPairingExchanger struct {
|
||||
// 僅在 Client == nil(走預設 client 建構)時生效;若呼叫者注入自訂 Client,
|
||||
// 以該 Client 自帶的 Transport 為準。
|
||||
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 建立一個生產預設實例。
|
||||
@ -262,7 +300,10 @@ func (e *HTTPPairingExchanger) exchangeReal(pairingToken string) (ExchangeResult
|
||||
client = &c
|
||||
}
|
||||
|
||||
body, err := json.Marshal(exchangeRequest{PairingToken: pairingToken})
|
||||
body, err := json.Marshal(exchangeRequest{
|
||||
PairingToken: pairingToken,
|
||||
Devices: e.collectLocalDevices(),
|
||||
})
|
||||
if err != nil {
|
||||
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。
|
||||
//
|
||||
// code 為雲端的大寫常數(見 visionA-backend api.ErrCodeInvalidPairingToken 等):
|
||||
|
||||
@ -4,8 +4,11 @@ package tunnel
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@ -374,3 +377,178 @@ func TestExchangeReal404HintsMockMode(t *testing.T) {
|
||||
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。
|
||||
//
|
||||
// Devices 為 WP-0(ADR-018 序號地基)新增的 optional 欄位:agent 在 exchange 前
|
||||
// 撈本地 USB 清單(含 Kneron kn_number 序號)上報,雲端據此填
|
||||
// devices.serial_number。舊 agent 不送此欄位 → 行為與現行完全一致
|
||||
// (自建 serial=NULL device)。
|
||||
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。
|
||||
@ -439,7 +445,7 @@ func pairingExchangeHandler(deps Deps) gin.HandlerFunc {
|
||||
deviceID string
|
||||
)
|
||||
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 {
|
||||
logOrDefault(deps.Logger).Error("pairing exchange: provision device+session failed",
|
||||
"error", exErr,
|
||||
|
||||
@ -30,8 +30,10 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@ -53,11 +55,41 @@ const (
|
||||
|
||||
// ExchangeProvisionResult 回報 exchange 自建 device + 建 session token 的結果。
|
||||
type ExchangeProvisionResult struct {
|
||||
DeviceID string // 本次自建的 device id
|
||||
DeviceID string // 本次自建(或依 serial 復用)的 device id
|
||||
SessionPlaintext string // 新 session token 原文(caller 只此一次能拿到)
|
||||
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)或
|
||||
// 一致(in-memory 依序)操作。
|
||||
//
|
||||
@ -67,14 +99,26 @@ type PairingExchanger interface {
|
||||
// Provision 自建一筆 device(owner = userID)並建一筆綁該 device 的 session token。
|
||||
//
|
||||
// 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 後端 ─────────────────────────────────────────────────────────────
|
||||
|
||||
// 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 {
|
||||
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 滿足)。
|
||||
@ -105,40 +149,66 @@ func NewPostgresPairingExchanger(
|
||||
}
|
||||
}
|
||||
|
||||
// Provision 在單一交易內:自建 device → 建綁該 device 的 session token。
|
||||
// Provision 在單一交易內:自建(或依 serial 復用)device → 建綁該 device 的 session token。
|
||||
//
|
||||
// 任一步失敗整筆 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(
|
||||
ctx context.Context, userID, parentTokenHash string, ttl time.Duration,
|
||||
ctx context.Context, userID, parentTokenHash string, ttl time.Duration, devices []ExchangeDeviceInput,
|
||||
) (ExchangeProvisionResult, error) {
|
||||
var res ExchangeProvisionResult
|
||||
deviceID := uuid.NewString()
|
||||
now := time.Now().UTC()
|
||||
|
||||
err := db.WithTx(ctx, e.pool, func(q db.Querier) error {
|
||||
dev := &device.Device{
|
||||
ID: deviceID,
|
||||
OwnerUserID: userID,
|
||||
Name: defaultPairedDeviceName,
|
||||
DeviceType: defaultPairedDeviceType,
|
||||
// serial_number 留空(agent 未帶):SaveTx 把空 serial 寫成 SQL NULL,
|
||||
// 故同 owner 多次 exchange 各建一筆 serial=NULL 的 distinct device,不撞
|
||||
// partial unique uq_devices_owner_serial_active(每個 NULL 互不相等)。
|
||||
RemoteStatus: device.RemoteStatusOffline,
|
||||
Status: device.USBStatusUnknown,
|
||||
PairedAt: &now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
dev := &device.Device{
|
||||
ID: uuid.NewString(),
|
||||
OwnerUserID: userID,
|
||||
Name: defaultPairedDeviceName,
|
||||
DeviceType: defaultPairedDeviceType,
|
||||
// serial_number 預設留空(agent 未帶):SaveTx 把空 serial 寫成 SQL NULL,
|
||||
// 故同 owner 多次 exchange 各建一筆 serial=NULL 的 distinct device,不撞
|
||||
// partial unique uq_devices_owner_serial_active(每個 NULL 互不相等)。
|
||||
RemoteStatus: device.RemoteStatusOffline,
|
||||
Status: device.USBStatusUnknown,
|
||||
PairedAt: &now,
|
||||
CreatedAt: 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 {
|
||||
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 {
|
||||
return fmt.Errorf("exchange: create session token: %w", createErr)
|
||||
}
|
||||
res.DeviceID = deviceID
|
||||
res.DeviceID = dev.ID
|
||||
res.SessionPlaintext = plaintext
|
||||
res.SessionInfo = info
|
||||
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(
|
||||
ctx context.Context, userID, parentTokenHash string, ttl time.Duration,
|
||||
ctx context.Context, userID, parentTokenHash string, ttl time.Duration, devices []ExchangeDeviceInput,
|
||||
) (ExchangeProvisionResult, error) {
|
||||
deviceID := uuid.NewString()
|
||||
now := time.Now().UTC()
|
||||
|
||||
dev := &device.Device{
|
||||
ID: deviceID,
|
||||
ID: uuid.NewString(),
|
||||
OwnerUserID: userID,
|
||||
Name: defaultPairedDeviceName,
|
||||
DeviceType: defaultPairedDeviceType,
|
||||
@ -194,16 +264,34 @@ func (e *memPairingExchanger) Provision(
|
||||
CreatedAt: 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 {
|
||||
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 {
|
||||
return ExchangeProvisionResult{}, fmt.Errorf("exchange: create session token: %w", err)
|
||||
}
|
||||
return ExchangeProvisionResult{
|
||||
DeviceID: deviceID,
|
||||
DeviceID: dev.ID,
|
||||
SessionPlaintext: plaintext,
|
||||
SessionInfo: info,
|
||||
}, nil
|
||||
|
||||
@ -58,7 +58,7 @@ func TestPGExchange_ProvisionCreatesDeviceAndSession(t *testing.T) {
|
||||
tdb, exchanger, devRepo, sessions, owner := pgExchangeFixture(t)
|
||||
|
||||
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.NotEmpty(t, res.DeviceID, "應自建一筆 device")
|
||||
require.NotEmpty(t, res.SessionPlaintext, "應建一個 session token")
|
||||
@ -94,7 +94,7 @@ func TestPGExchange_Provision_RollbackOnBadOwner(t *testing.T) {
|
||||
tdb, exchanger, _, _, _ := pgExchangeFixture(t)
|
||||
|
||||
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")
|
||||
|
||||
// device 不應殘留(整筆交易 rollback)
|
||||
@ -108,11 +108,79 @@ func TestPGExchange_Provision_MultipleCreatesDistinctDevices(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, 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)
|
||||
res2, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL)
|
||||
res2, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEqual(t, res1.DeviceID, res2.DeviceID, "兩次 exchange 應各自建不同 device")
|
||||
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()
|
||||
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.NotEmpty(t, res.DeviceID)
|
||||
require.NotEmpty(t, res.SessionPlaintext)
|
||||
@ -49,11 +49,96 @@ func TestMemExchange_Provision_DistinctDevices(t *testing.T) {
|
||||
exchanger := NewInMemoryPairingExchanger(
|
||||
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)
|
||||
res2, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL)
|
||||
res2, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEqual(t, res1.DeviceID, res2.DeviceID)
|
||||
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