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
This commit is contained in:
Mikkel Georgsen 2026-04-10 06:43:43 +00:00
parent 77bf4ebfd6
commit f5b1d3156c
5 changed files with 336 additions and 0 deletions

2
go.mod
View file

@ -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

4
go.sum
View file

@ -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=

77
internal/usb/device.go Normal file
View file

@ -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
}

111
internal/usb/device_test.go Normal file
View file

@ -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")
}
}

142
internal/usb/enumerate.go Normal file
View file

@ -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
}