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 }