From f5b1d3156c3d726f0adb3bfc1f448886478e2813 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Fri, 10 Apr 2026 06:43:43 +0000 Subject: [PATCH] feat(04-01): USB device types, KnownDevices registry, VID/PID enumeration - DeviceSpec, DeviceRole, DeviceState, DeviceEvent, Command types - KnownDevices registry with PRT Qutie placeholder (0525:a4a7) - enumerateConnected() with system_profiler JSON parsing + injectable test seams - ParseVIDPID() helper with error on malformed input - All 5 device_test.go tests pass - go.bug.st/serial v1.6.4 added to go.mod --- go.mod | 2 + go.sum | 4 + internal/usb/device.go | 77 +++++++++++++++++++ internal/usb/device_test.go | 111 ++++++++++++++++++++++++++++ internal/usb/enumerate.go | 142 ++++++++++++++++++++++++++++++++++++ 5 files changed, 336 insertions(+) create mode 100644 internal/usb/device.go create mode 100644 internal/usb/device_test.go create mode 100644 internal/usb/enumerate.go diff --git a/go.mod b/go.mod index 610f5b0..b06c2f1 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/creack/goselect v0.1.2 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect @@ -24,6 +25,7 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect + go.bug.st/serial v1.6.4 // indirect go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/sys v0.29.0 // indirect diff --git a/go.sum b/go.sum index ec22579..37ae9fd 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0= +github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= @@ -58,6 +60,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A= +go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= diff --git a/internal/usb/device.go b/internal/usb/device.go new file mode 100644 index 0000000..4520682 --- /dev/null +++ b/internal/usb/device.go @@ -0,0 +1,77 @@ +package usb + +import ( + "errors" + "strings" +) + +// DeviceRole classifies what a USB device is used for in HWLab. +type DeviceRole int + +const ( + RolePrinter DeviceRole = iota + RoleCableTester // reserved for Phase 5 + RoleUnknown +) + +// DeviceSpec describes a known USB peripheral by VID and PID. +type DeviceSpec struct { + VID string // 4-hex-digit vendor ID, e.g. "0525" + PID string // 4-hex-digit product ID, e.g. "a4a7" + Name string // human-readable label for logs + Role DeviceRole + BaudRate int // serial baud rate; 0 means use 9600 default +} + +// VIDPID returns the canonical "VID:PID" key string. +func (s DeviceSpec) VIDPID() string { return s.VID + ":" + s.PID } + +// String returns "VID:PID (Name)" format for logging. +func (s DeviceSpec) String() string { return s.VIDPID() + " (" + s.Name + ")" } + +// KnownDevices maps "VID:PID" to DeviceSpec for all peripherals HWLab manages. +// Update VID/PID values after hardware characterization on 2026-04-13. +var KnownDevices = map[string]DeviceSpec{ + "0525:a4a7": {VID: "0525", PID: "a4a7", Name: "PRT Qutie", Role: RolePrinter, BaudRate: 9600}, + // Phase 5: Treedix testers will be added here after characterization +} + +// DeviceState represents the current connection status of a managed device. +type DeviceState int + +const ( + StateDisconnected DeviceState = iota + StateConnected +) + +// DeviceEvent is emitted on the Manager.Events() channel for any state change. +type DeviceEvent struct { + VIDPID string + Spec DeviceSpec + State DeviceState +} + +// Command is sent to a device goroutine via its command channel. +type Command struct { + Type CommandType + Payload []byte + Reply chan<- error // caller closes or receives result +} + +// CommandType discriminates Command variants. +type CommandType int + +const ( + CmdWrite CommandType = iota + CmdClose +) + +// ParseVIDPID splits a "VID:PID" string into its constituent parts. +// Returns an error if the format is not exactly "xxxx:xxxx". +func ParseVIDPID(s string) (vid, pid string, err error) { + parts := strings.SplitN(s, ":", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", errors.New("invalid VID:PID format: expected \"xxxx:xxxx\", got " + s) + } + return parts[0], parts[1], nil +} diff --git a/internal/usb/device_test.go b/internal/usb/device_test.go new file mode 100644 index 0000000..c8c23ab --- /dev/null +++ b/internal/usb/device_test.go @@ -0,0 +1,111 @@ +package usb + +import ( + "testing" +) + +// Test 1: KnownDevices registry contains PRT Qutie with correct role +func TestKnownDevicesContainsPRTQutie(t *testing.T) { + spec, ok := KnownDevices["0525:a4a7"] + if !ok { + t.Fatal("KnownDevices missing entry for 0525:a4a7 (PRT Qutie)") + } + if spec.Role != RolePrinter { + t.Errorf("expected RolePrinter, got %v", spec.Role) + } + if spec.VID != "0525" { + t.Errorf("expected VID=0525, got %s", spec.VID) + } + if spec.PID != "a4a7" { + t.Errorf("expected PID=a4a7, got %s", spec.PID) + } +} + +// Test 2: enumerateConnected with mock sysProfilerCmd returns only known VID/PID ports +func TestEnumerateConnectedMockOutput(t *testing.T) { + // Mock system_profiler JSON that contains a device matching PRT Qutie VID/PID + // and a port path in the serial port list. + mockJSON := `{ + "SPUSBDataType": [ + { + "_name": "USB31Bus", + "_items": [ + { + "_name": "PRT Qutie", + "vendor_id": "0x0525", + "product_id": "0xa4a7", + "bsd_name": "cu.usbmodem12345" + }, + { + "_name": "Unknown Device", + "vendor_id": "0x1234", + "product_id": "0xabcd", + "bsd_name": "cu.usbmodem99999" + } + ] + } + ] + }` + + // Override the injectable command function + orig := sysProfilerCmd + defer func() { sysProfilerCmd = orig }() + sysProfilerCmd = func() ([]byte, error) { + return []byte(mockJSON), nil + } + + // Override serial port list + origPorts := serialPortsListCmd + defer func() { serialPortsListCmd = origPorts }() + serialPortsListCmd = func() ([]string, error) { + return []string{"/dev/cu.usbmodem12345", "/dev/cu.usbmodem99999"}, nil + } + + result, err := enumerateConnected() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should only contain known device (PRT Qutie), not the unknown one + if len(result) != 1 { + t.Errorf("expected 1 device, got %d: %v", len(result), result) + } + path, ok := result["0525:a4a7"] + if !ok { + t.Error("expected 0525:a4a7 in result") + } + if path != "/dev/cu.usbmodem12345" { + t.Errorf("expected path /dev/cu.usbmodem12345, got %s", path) + } +} + +// Test 3: DeviceSpec.String() returns "VID:PID (Name)" format +func TestDeviceSpecString(t *testing.T) { + spec := DeviceSpec{VID: "0525", PID: "a4a7", Name: "PRT Qutie", Role: RolePrinter} + expected := "0525:a4a7 (PRT Qutie)" + if got := spec.String(); got != expected { + t.Errorf("expected %q, got %q", expected, got) + } +} + +// Test 4: ParseVIDPID("0525:a4a7") returns correct values +func TestParseVIDPIDValid(t *testing.T) { + vid, pid, err := ParseVIDPID("0525:a4a7") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if vid != "0525" { + t.Errorf("expected vid=0525, got %s", vid) + } + if pid != "a4a7" { + t.Errorf("expected pid=a4a7, got %s", pid) + } +} + +// Test 5: ParseVIDPID("badvalue") returns error +func TestParseVIDPIDInvalid(t *testing.T) { + _, _, err := ParseVIDPID("badvalue") + if err == nil { + t.Error("expected error for invalid VID:PID format, got nil") + } +} diff --git a/internal/usb/enumerate.go b/internal/usb/enumerate.go new file mode 100644 index 0000000..3dcb2ff --- /dev/null +++ b/internal/usb/enumerate.go @@ -0,0 +1,142 @@ +package usb + +import ( + "encoding/json" + "log" + "os/exec" + "strings" + + "go.bug.st/serial" +) + +// sysProfilerCmd is injectable for tests; defaults to running system_profiler. +var sysProfilerCmd func() ([]byte, error) = func() ([]byte, error) { + return exec.Command("system_profiler", "SPUSBDataType", "-json").Output() +} + +// serialPortsListCmd is injectable for tests; defaults to serial.GetPortsList(). +var serialPortsListCmd func() ([]string, error) = func() ([]string, error) { + return serial.GetPortsList() +} + +// spUSBRoot is the top-level structure returned by system_profiler SPUSBDataType -json. +type spUSBRoot struct { + SPUSBDataType []spUSBBus `json:"SPUSBDataType"` +} + +// spUSBBus is a USB bus entry (e.g. "USB31Bus"). +type spUSBBus struct { + Name string `json:"_name"` + Items []spUSBEntry `json:"_items"` +} + +// spUSBEntry is a USB device entry within a bus. +type spUSBEntry struct { + Name string `json:"_name"` + VendorID string `json:"vendor_id"` + ProductID string `json:"product_id"` + BSDName string `json:"bsd_name"` + Items []spUSBEntry `json:"_items"` // nested hubs +} + +// normalizeHexID strips "0x" prefix and lowercases a VID/PID string from system_profiler. +// e.g. "0x0525" → "0525", "0xA4A7" → "a4a7". +func normalizeHexID(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + s = strings.TrimPrefix(s, "0x") + return s +} + +// collectEntries recursively flattens nested USB device entries. +func collectEntries(items []spUSBEntry) []spUSBEntry { + var result []spUSBEntry + for _, item := range items { + result = append(result, item) + if len(item.Items) > 0 { + result = append(result, collectEntries(item.Items)...) + } + } + return result +} + +// enumerateConnected returns a map[VIDPID]portPath for all currently connected +// devices whose VID:PID appears in KnownDevices. +// +// On macOS it shells out to system_profiler for VID/PID discovery, then +// cross-references with serial.GetPortsList() to find the matching /dev/cu.* +// path. +// +// On Linux (CI) or when system_profiler is unavailable, it returns an empty +// map with no error (graceful degradation). +func enumerateConnected() (map[string]string, error) { + result := make(map[string]string) + + // Get current serial port list from OS. + portPaths, err := serialPortsListCmd() + if err != nil { + log.Printf("usb: failed to list serial ports: %v", err) + return result, nil + } + + // Build a quick-lookup set of base names from the port paths. + // system_profiler reports "bsd_name" without the /dev/ prefix. + portByBaseName := make(map[string]string, len(portPaths)) + for _, p := range portPaths { + // Strip leading /dev/ for matching against system_profiler bsd_name. + base := p + if idx := strings.LastIndex(p, "/"); idx >= 0 { + base = p[idx+1:] + } + portByBaseName[base] = p + } + + // Get USB device info from system_profiler. + spJSON, err := sysProfilerCmd() + if err != nil { + // system_profiler unavailable (Linux CI or non-macOS) — return empty map. + log.Printf("usb: system_profiler unavailable, skipping enumeration: %v", err) + return result, nil + } + + var root spUSBRoot + if err := json.Unmarshal(spJSON, &root); err != nil { + log.Printf("usb: failed to parse system_profiler output: %v", err) + return result, nil + } + + // Collect all device entries from all buses. + var allEntries []spUSBEntry + for _, bus := range root.SPUSBDataType { + allEntries = append(allEntries, collectEntries(bus.Items)...) + } + + // Match device entries to KnownDevices. + for _, entry := range allEntries { + vid := normalizeHexID(entry.VendorID) + pid := normalizeHexID(entry.ProductID) + if vid == "" || pid == "" { + continue + } + vidpid := vid + ":" + pid + if _, known := KnownDevices[vidpid]; !known { + continue + } + + // Find the matching serial port path. + if entry.BSDName == "" { + continue + } + portPath, ok := portByBaseName[entry.BSDName] + if !ok { + // Try with /dev/ prefix in bsd_name (some macOS versions include it). + portPath, ok = portByBaseName[strings.TrimPrefix(entry.BSDName, "/dev/")] + if !ok { + continue + } + } + + result[vidpid] = portPath + } + + return result, nil +}