homelabby/internal/usb/enumerate.go
Mikkel Georgsen f5b1d3156c 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
2026-04-10 06:43:43 +00:00

142 lines
4 KiB
Go

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
}