- 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
142 lines
4 KiB
Go
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
|
|
}
|