package hid import ( "fmt" "sync" "time" "github.com/sstallion/go-hid" ) // Device represents a connected HID device. type Device struct { dev *hid.Device path string } // DeviceInfo holds device metadata without open handle. type DeviceInfo struct { Path string VendorID uint16 ProductID uint16 RawHID bool } // DevicesInfo lists all connected HID devices. type DevicesInfo struct { Devices []DeviceInfo } var initOnce sync.Once func ensureInit() error { var err error initOnce.Do(func() { err = hid.Init() }) return err } // DiscoverAll returns a list of all connected HID devices with VID/PID. // On platforms that report multiple interfaces per device (e.g. macOS), // only one entry per VID/PID is returned. func DiscoverAll() ([]DeviceInfo, error) { if err := ensureInit(); err != nil { return nil, fmt.Errorf("init hid: %w", err) } seen := make(map[string]bool) var devices []DeviceInfo err := hid.Enumerate(hid.VendorIDAny, hid.ProductIDAny, func(info *hid.DeviceInfo) error { if !isRawHID(info) { return nil } key := fmt.Sprintf("%04x:%04x", info.VendorID, info.ProductID) if seen[key] { return nil } seen[key] = true devices = append(devices, DeviceInfo{ Path: info.Path, VendorID: info.VendorID, ProductID: info.ProductID, RawHID: true, }) return nil }) if err != nil { return nil, fmt.Errorf("enumerate: %w", err) } return devices, nil } // Enumerate visits each HID device with matching VID/PID. // On platforms that report multiple interfaces per device, // only the first match per VID/PID is passed to fn. func Enumerate(vendorID, productID uint16, fn func(info *DeviceInfo) error) error { seen := make(map[string]bool) err := hid.Enumerate(vendorID, productID, func(info *hid.DeviceInfo) error { if !isRawHID(info) { return nil } key := fmt.Sprintf("%04x:%04x", info.VendorID, info.ProductID) if seen[key] { return nil } seen[key] = true return fn(&DeviceInfo{ Path: info.Path, VendorID: info.VendorID, ProductID: info.ProductID, RawHID: true, }) }) if err != nil { return err } return nil } func isRawHID(info *hid.DeviceInfo) bool { if info.UsagePage != 0xff60 { return false } if info.Usage != 0x61 { return false } return true } // Open opens a HID device by VID/PID. func Open(vendorID, productID uint16) (*Device, error) { if err := ensureInit(); err != nil { return nil, fmt.Errorf("init hid: %w", err) } dev, err := hid.Open(vendorID, productID, "") if err != nil { return nil, fmt.Errorf("open device VID=0x%04x PID=0x%04x: %w", vendorID, productID, err) } info, err := dev.GetDeviceInfo() if err != nil { dev.Close() return nil, fmt.Errorf("get device info: %w", err) } return &Device{dev: dev, path: info.Path}, nil } // OpenPath opens a HID device by path. func OpenPath(path string) (*Device, error) { if err := ensureInit(); err != nil { return nil, fmt.Errorf("init hid: %w", err) } dev, err := hid.OpenPath(path) if err != nil { return nil, fmt.Errorf("open path %s: %w", path, err) } return &Device{dev: dev, path: path}, nil } // VID returns the vendor ID. func (d *Device) VID() uint16 { info, err := d.dev.GetDeviceInfo() if err != nil { return 0 } return info.VendorID } // PID returns the product ID. func (d *Device) PID() uint16 { info, err := d.dev.GetDeviceInfo() if err != nil { return 0 } return info.ProductID } // Path returns the device path. func (d *Device) Path() string { return d.path } // SendReport sends a HID report. // report[0] must be the report ID (0 for single-report devices). // Uses Write for output reports (report ID == 0). func (d *Device) SendReport(reportID byte, report []byte) (int, error) { n, err := d.dev.Write(report) if err != nil { return n, fmt.Errorf("write report: %w", err) } return n, nil } // Read reads a response report with a timeout. var hidReadTimeout = 500 * time.Millisecond func (d *Device) Read(buf []byte) (int, error) { n, err := d.dev.ReadWithTimeout(buf, hidReadTimeout) if err == hid.ErrTimeout { return 0, fmt.Errorf("read timeout") } if err != nil { return n, fmt.Errorf("read: %w", err) } return n, nil } // Close closes the device. func (d *Device) Close() error { return d.dev.Close() }