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:
parent
77bf4ebfd6
commit
f5b1d3156c
5 changed files with 336 additions and 0 deletions
2
go.mod
2
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
|
||||
|
|
|
|||
4
go.sum
4
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=
|
||||
|
|
|
|||
77
internal/usb/device.go
Normal file
77
internal/usb/device.go
Normal 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
111
internal/usb/device_test.go
Normal 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
142
internal/usb/enumerate.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue