Files
picoclaw/pkg/devices/sources/usb_linux.go

199 lines
4.6 KiB
Go

//go:build linux
package sources
import (
"bufio"
"context"
"fmt"
"os/exec"
"strings"
"sync"
"github.com/sipeed/picoclaw/pkg/devices/events"
"github.com/sipeed/picoclaw/pkg/logger"
)
var usbClassToCapability = map[string]string{
"00": "Interface Definition (by interface)",
"01": "Audio",
"02": "CDC Communication (Network Card/Modem)",
"03": "HID (Keyboard/Mouse/Gamepad)",
"05": "Physical Interface",
"06": "Image (Scanner/Camera)",
"07": "Printer",
"08": "Mass Storage (USB Flash Drive/Hard Disk)",
"09": "USB Hub",
"0a": "CDC Data",
"0b": "Smart Card",
"0e": "Video (Camera)",
"dc": "Diagnostic Device",
"e0": "Wireless Controller (Bluetooth)",
"ef": "Miscellaneous",
"fe": "Application Specific",
"ff": "Vendor Specific",
}
type USBMonitor struct {
cmd *exec.Cmd
cancel context.CancelFunc
mu sync.Mutex
}
func NewUSBMonitor() *USBMonitor {
return &USBMonitor{}
}
func (m *USBMonitor) Kind() events.Kind {
return events.KindUSB
}
func (m *USBMonitor) Start(ctx context.Context) (<-chan *events.DeviceEvent, error) {
m.mu.Lock()
defer m.mu.Unlock()
// udevadm monitor outputs: UDEV/KERNEL [timestamp] action devpath (subsystem)
// Followed by KEY=value lines, empty line separates events
// Use -s/--subsystem-match (eudev) or --udev-subsystem-match (systemd udev)
cmd := exec.CommandContext(ctx, "udevadm", "monitor", "--property", "--subsystem-match=usb")
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("udevadm stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("udevadm start: %w (is udevadm installed?)", err)
}
m.cmd = cmd
eventCh := make(chan *events.DeviceEvent, 16)
go func() {
defer close(eventCh)
scanner := bufio.NewScanner(stdout)
var props map[string]string
var action string
isUdev := false // Only UDEV events have complete info (ID_VENDOR, ID_MODEL); KERNEL events come first with less info
for scanner.Scan() {
line := scanner.Text()
if line == "" {
// End of event block - only process UDEV events (skip KERNEL to avoid duplicate/incomplete notifications)
if isUdev && props != nil && (action == "add" || action == "remove") {
if ev := parseUSBEvent(action, props); ev != nil {
select {
case eventCh <- ev:
case <-ctx.Done():
return
}
}
}
props = nil
action = ""
isUdev = false
continue
}
idx := strings.Index(line, "=")
// First line of block: "UDEV [ts] action devpath" or "KERNEL[ts] action devpath" - no KEY=value
if idx <= 0 {
isUdev = strings.HasPrefix(strings.TrimSpace(line), "UDEV")
continue
}
// Parse KEY=value
key := line[:idx]
val := line[idx+1:]
if props == nil {
props = make(map[string]string)
}
props[key] = val
if key == "ACTION" {
action = val
}
}
if err := scanner.Err(); err != nil {
logger.ErrorCF("devices", "udevadm scan error", map[string]interface{}{"error": err.Error()})
}
cmd.Wait()
}()
return eventCh, nil
}
func (m *USBMonitor) Stop() error {
m.mu.Lock()
defer m.mu.Unlock()
if m.cmd != nil && m.cmd.Process != nil {
m.cmd.Process.Kill()
m.cmd = nil
}
return nil
}
func parseUSBEvent(action string, props map[string]string) *events.DeviceEvent {
// Only care about add/remove for physical devices (not interfaces)
subsystem := props["SUBSYSTEM"]
if subsystem != "usb" {
return nil
}
// Skip interface events - we want device-level only to avoid duplicates
devType := props["DEVTYPE"]
if devType == "usb_interface" {
return nil
}
// Prefer usb_device, but accept if DEVTYPE not set (varies by udev version)
if devType != "" && devType != "usb_device" {
return nil
}
ev := &events.DeviceEvent{
Raw: props,
}
switch action {
case "add":
ev.Action = events.ActionAdd
case "remove":
ev.Action = events.ActionRemove
default:
return nil
}
ev.Kind = events.KindUSB
ev.Vendor = props["ID_VENDOR"]
if ev.Vendor == "" {
ev.Vendor = props["ID_VENDOR_ID"]
}
if ev.Vendor == "" {
ev.Vendor = "Unknown Vendor"
}
ev.Product = props["ID_MODEL"]
if ev.Product == "" {
ev.Product = props["ID_MODEL_ID"]
}
if ev.Product == "" {
ev.Product = "Unknown Device"
}
ev.Serial = props["ID_SERIAL_SHORT"]
ev.DeviceID = props["DEVPATH"]
if bus := props["BUSNUM"]; bus != "" {
if dev := props["DEVNUM"]; dev != "" {
ev.DeviceID = bus + ":" + dev
}
}
// Map USB class to capability
if class := props["ID_USB_CLASS"]; class != "" {
ev.Capabilities = usbClassToCapability[strings.ToLower(class)]
}
if ev.Capabilities == "" {
ev.Capabilities = "USB Device"
}
return ev
}