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 // UsagePage and Usage are the HID collection this entry describes. A // keyboard reports itself as several collections over one path, one per // top-level collection in its descriptor: a keyboard, a consumer control, a // vendor page. macOS reports every one of them separately, Linux one // interface at a time, and the QMK Raw HID collection is the one with this // pair. They travel with the entry because an entry that was not taken is // only explainable by the pair it was rejected for. UsagePage uint16 Usage uint16 // ProductString is the USB product string, which is where a keyboard states // its own model name. Firmware chooses it, so it can be empty. ProductString string } // rawHIDUsagePage and rawHIDUsage are the QMK Raw HID collection: the signature // qmk/qmk_udev matches on, and the only one this tool drives. A board that // declares a different pair is a board this tool cannot address, and saying so // is the point of reporting the pair a rejected device came with. const ( rawHIDUsagePage uint16 = 0xff60 rawHIDUsage uint16 = 0x61 ) // 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 } if seen[info.Path] { return nil } seen[info.Path] = true devices = append(devices, DeviceInfo{ Path: info.Path, VendorID: info.VendorID, ProductID: info.ProductID, ProductString: info.ProductStr, RawHID: true, UsagePage: info.UsagePage, Usage: info.Usage, }) return nil }) if err != nil { return nil, fmt.Errorf("enumerate: %w", err) } return devices, nil } // DiscoverEvery returns every HID collection the system reports, the one this // tool drives included. It exists so that a device which was not taken can be // explained: no keyboard found and a keyboard whose collection is not the QMK // Raw HID one look identical from outside, and only the second is worth telling // a user about. // // A collection is the unit here, not a device. One path appears once per usage // pair, so a keyboard with a keyboard collection and a vendor collection is two // entries, and each carries the pair it was rejected for. Order is the // enumeration order, which nothing may sort: these entries carry no number a user // could pass to a command, so they have no order to promise. func DiscoverEvery() ([]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 { // The pair belongs in the key as well as the path: a keyboard reports // several collections on one path, and the same collection listed twice // is one entry. key := fmt.Sprintf("%s/%04X/%04X", info.Path, info.UsagePage, info.Usage) if seen[key] { return nil } seen[key] = true devices = append(devices, DeviceInfo{ Path: info.Path, VendorID: info.VendorID, ProductID: info.ProductID, ProductString: info.ProductStr, RawHID: isRawHID(info), UsagePage: info.UsagePage, Usage: info.Usage, }) 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 } if seen[info.Path] { return nil } seen[info.Path] = true return fn(&DeviceInfo{ Path: info.Path, VendorID: info.VendorID, ProductID: info.ProductID, ProductString: info.ProductStr, RawHID: true, UsagePage: info.UsagePage, Usage: info.Usage, }) }) if err != nil { return err } return nil } // isRawHID reports whether a HID collection is the QMK Raw HID one, the signature // qmk/qmk_udev matches on. It is an exact pair on purpose: a vendor page that // happens to be near it is not a raw HID interface, and a board that puts its // lighting on another page is a board this tool cannot address rather than one it // may guess at. func isRawHID(info *hid.DeviceInfo) bool { return info.UsagePage == rawHIDUsagePage && info.Usage == rawHIDUsage } // 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() }