|
@@ -1,36 +1,17 @@
|
|
|
package hid
|
|
package hid
|
|
|
|
|
|
|
|
import (
|
|
import (
|
|
|
- "bytes"
|
|
|
|
|
"fmt"
|
|
"fmt"
|
|
|
- "os"
|
|
|
|
|
- "strings"
|
|
|
|
|
- "syscall"
|
|
|
|
|
|
|
+ "sync"
|
|
|
"time"
|
|
"time"
|
|
|
- "unsafe"
|
|
|
|
|
-)
|
|
|
|
|
-
|
|
|
|
|
-// HID device access via /dev/hidraw* using pure Go syscalls.
|
|
|
|
|
-// No cgo, no libudev.
|
|
|
|
|
|
|
|
|
|
-const (
|
|
|
|
|
- ioctlRDGET = 0x80000000 + 1
|
|
|
|
|
- ioctlWRSET = 0x40000000 + 2
|
|
|
|
|
- ioctlGET = 0x30000000 + 3
|
|
|
|
|
- ioctlSET = 0x40000000 + 4
|
|
|
|
|
|
|
+ "github.com/sstallion/go-hid"
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
// Device represents a connected HID device.
|
|
// Device represents a connected HID device.
|
|
|
type Device struct {
|
|
type Device struct {
|
|
|
- fd int
|
|
|
|
|
- path string
|
|
|
|
|
- vendorID uint16
|
|
|
|
|
- productID uint16
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// Devices lists all connected HID devices with VID/PID.
|
|
|
|
|
-type DevicesInfo struct {
|
|
|
|
|
- Devices []DeviceInfo
|
|
|
|
|
|
|
+ dev *hid.Device
|
|
|
|
|
+ path string
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// DeviceInfo holds device metadata without open handle.
|
|
// DeviceInfo holds device metadata without open handle.
|
|
@@ -41,187 +22,173 @@ type DeviceInfo struct {
|
|
|
RawHID bool
|
|
RawHID bool
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// OpenPath opens a HID device by path (e.g. "/dev/hidraw0").
|
|
|
|
|
-func OpenPath(path string) (*Device, error) {
|
|
|
|
|
- fd, err := syscall.Open(path, syscall.O_RDWR, 0)
|
|
|
|
|
- if err != nil {
|
|
|
|
|
- return nil, fmt.Errorf("open %s: %w", path, err)
|
|
|
|
|
- }
|
|
|
|
|
- return &Device{fd: fd, path: path}, nil
|
|
|
|
|
|
|
+// DevicesInfo lists all connected HID devices.
|
|
|
|
|
+type DevicesInfo struct {
|
|
|
|
|
+ Devices []DeviceInfo
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// VID returns the vendor ID.
|
|
|
|
|
-func (d *Device) VID() uint16 { return d.vendorID }
|
|
|
|
|
-
|
|
|
|
|
-// PID returns the product ID.
|
|
|
|
|
-func (d *Device) PID() uint16 { return d.productID }
|
|
|
|
|
|
|
+var initOnce sync.Once
|
|
|
|
|
|
|
|
-// Path returns the device file path.
|
|
|
|
|
-func (d *Device) Path() string { return d.path }
|
|
|
|
|
-
|
|
|
|
|
-// SendReport sends an HID report.
|
|
|
|
|
-// report[0] must be the report ID (0 for single-report devices).
|
|
|
|
|
-func (d *Device) SendReport(reportID byte, report []byte) (int, error) {
|
|
|
|
|
- buf := make([]byte, 1+len(report))
|
|
|
|
|
- buf[0] = reportID
|
|
|
|
|
- copy(buf[1:], report)
|
|
|
|
|
-
|
|
|
|
|
- n, _, errno := syscall.Syscall(syscall.SYS_WRITE, uintptr(d.fd), uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)))
|
|
|
|
|
- if errno != 0 {
|
|
|
|
|
- return 0, fmt.Errorf("write: %w", errno)
|
|
|
|
|
- }
|
|
|
|
|
- return int(n), nil
|
|
|
|
|
|
|
+func ensureInit() error {
|
|
|
|
|
+ var err error
|
|
|
|
|
+ initOnce.Do(func() {
|
|
|
|
|
+ err = hid.Init()
|
|
|
|
|
+ })
|
|
|
|
|
+ return err
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// Read reads a response report.
|
|
|
|
|
-var hidReadTimeout = 500 * time.Millisecond
|
|
|
|
|
-
|
|
|
|
|
-func (d *Device) Read(buf []byte) (int, error) {
|
|
|
|
|
- if len(buf) == 0 {
|
|
|
|
|
- return 0, nil
|
|
|
|
|
|
|
+// 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)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- var readSet syscall.FdSet
|
|
|
|
|
- readSet.Bits[d.fd/64] |= 1 << uint(d.fd%64)
|
|
|
|
|
- timeout := syscall.Timeval{
|
|
|
|
|
- Sec: int64(hidReadTimeout / time.Second),
|
|
|
|
|
- Usec: int64((hidReadTimeout % time.Second) / time.Microsecond),
|
|
|
|
|
- }
|
|
|
|
|
- ready, err := syscall.Select(d.fd+1, &readSet, nil, nil, &timeout)
|
|
|
|
|
|
|
+ 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 {
|
|
if err != nil {
|
|
|
- return 0, fmt.Errorf("select: %w", err)
|
|
|
|
|
- }
|
|
|
|
|
- if ready == 0 {
|
|
|
|
|
- return 0, fmt.Errorf("read timeout")
|
|
|
|
|
|
|
+ return nil, fmt.Errorf("enumerate: %w", err)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- n, _, errno := syscall.Syscall(syscall.SYS_READ, uintptr(d.fd), uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)))
|
|
|
|
|
- if errno != 0 {
|
|
|
|
|
- return 0, fmt.Errorf("read: %w", errno)
|
|
|
|
|
- }
|
|
|
|
|
- return int(n), nil
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// Close closes the device.
|
|
|
|
|
-func (d *Device) Close() error {
|
|
|
|
|
- return syscall.Close(d.fd)
|
|
|
|
|
|
|
+ return devices, nil
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// DiscoverAll returns a list of HID devices with their VID/PID.
|
|
|
|
|
-// It iterates /dev/hidraw* and reads VID/PID from /sys/class/hidraw/*/device/uevent.
|
|
|
|
|
-func DiscoverAll() ([]DeviceInfo, error) {
|
|
|
|
|
- entries, err := os.ReadDir("/dev")
|
|
|
|
|
|
|
+// 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 {
|
|
if err != nil {
|
|
|
- return nil, fmt.Errorf("read /dev: %w", err)
|
|
|
|
|
|
|
+ return err
|
|
|
}
|
|
}
|
|
|
|
|
+ return nil
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- var devices []DeviceInfo
|
|
|
|
|
|
|
+func isRawHID(info *hid.DeviceInfo) bool {
|
|
|
|
|
+ if info.UsagePage != 0xff60 {
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+ if info.Usage != 0x61 {
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+ return true
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- for _, entry := range entries {
|
|
|
|
|
- if !strings.HasPrefix(entry.Name(), "hidraw") {
|
|
|
|
|
- continue
|
|
|
|
|
- }
|
|
|
|
|
|
|
+// 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)
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- hidrawPath := "/dev/" + entry.Name()
|
|
|
|
|
- sysEventPath := "/sys/class/hidraw/" + entry.Name() + "/device/uevent"
|
|
|
|
|
|
|
+ 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)
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- data, err := os.ReadFile(sysEventPath)
|
|
|
|
|
- if err != nil {
|
|
|
|
|
- fmt.Fprintf(os.Stderr, "DEBUG: %s: uevent read err: %v\n", entry.Name(), err)
|
|
|
|
|
- continue
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ info, err := dev.GetDeviceInfo()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ dev.Close()
|
|
|
|
|
+ return nil, fmt.Errorf("get device info: %w", err)
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- vid, pid := parseUEVENT(string(data))
|
|
|
|
|
- if vid == 0 || pid == 0 {
|
|
|
|
|
- fmt.Fprintf(os.Stderr, "DEBUG: %s: vid=0x%04X pid=0x%04X (zero!)\n", entry.Name(), vid, pid)
|
|
|
|
|
- continue
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ return &Device{dev: dev, path: info.Path}, nil
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- descriptor, err := os.ReadFile("/sys/class/hidraw/" + entry.Name() + "/device/report_descriptor")
|
|
|
|
|
- if err != nil {
|
|
|
|
|
- fmt.Fprintf(os.Stderr, "DEBUG: %s: report descriptor read err: %v\n", entry.Name(), err)
|
|
|
|
|
- continue
|
|
|
|
|
- }
|
|
|
|
|
|
|
+// 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)
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- devices = append(devices, DeviceInfo{
|
|
|
|
|
- Path: hidrawPath,
|
|
|
|
|
- VendorID: vid,
|
|
|
|
|
- ProductID: pid,
|
|
|
|
|
- RawHID: isRawHIDDescriptor(descriptor),
|
|
|
|
|
- })
|
|
|
|
|
|
|
+ dev, err := hid.OpenPath(path)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, fmt.Errorf("open path %s: %w", path, err)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- fmt.Fprintf(os.Stderr, "DEBUG: DiscoverAll done, %d devices\n", len(devices))
|
|
|
|
|
- return devices, nil
|
|
|
|
|
|
|
+ return &Device{dev: dev, path: path}, nil
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-func isRawHIDDescriptor(data []byte) bool {
|
|
|
|
|
- inputReport := []byte{
|
|
|
|
|
- 0x09, 0x62, 0x15, 0x00, 0x26, 0xff, 0x00, 0x95, 0x20, 0x75, 0x08, 0x81, 0x02,
|
|
|
|
|
- }
|
|
|
|
|
- outputReport := []byte{
|
|
|
|
|
- 0x09, 0x63, 0x15, 0x00, 0x26, 0xff, 0x00, 0x95, 0x20, 0x75, 0x08, 0x91, 0x02,
|
|
|
|
|
|
|
+// VID returns the vendor ID.
|
|
|
|
|
+func (d *Device) VID() uint16 {
|
|
|
|
|
+ info, err := d.dev.GetDeviceInfo()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return 0
|
|
|
}
|
|
}
|
|
|
- usage := []byte{0x06, 0x60, 0xff, 0x09, 0x61, 0xa1, 0x01}
|
|
|
|
|
|
|
+ return info.VendorID
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- return bytes.Contains(data, usage) && bytes.Contains(data, inputReport) && bytes.Contains(data, outputReport)
|
|
|
|
|
|
|
+// PID returns the product ID.
|
|
|
|
|
+func (d *Device) PID() uint16 {
|
|
|
|
|
+ info, err := d.dev.GetDeviceInfo()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return 0
|
|
|
|
|
+ }
|
|
|
|
|
+ return info.ProductID
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-func parseUEVENT(s string) (vid, pid uint16) {
|
|
|
|
|
- for _, line := range splitLines(s) {
|
|
|
|
|
- if len(line) >= 25 && line[:12] == "HID_ID=0003:" {
|
|
|
|
|
- data := line[12:]
|
|
|
|
|
- colon := -1
|
|
|
|
|
- for i, c := range data {
|
|
|
|
|
- if c == ':' {
|
|
|
|
|
- colon = i
|
|
|
|
|
- break
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- if colon > 0 && colon+1 < len(data) {
|
|
|
|
|
- if v, e := parseHex(data[:colon]); e == nil {
|
|
|
|
|
- vid = v & 0xFFFF
|
|
|
|
|
- }
|
|
|
|
|
- if p, e := parseHex(data[colon+1:]); e == nil {
|
|
|
|
|
- pid = p & 0xFFFF
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
|
|
+// 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 vid, pid
|
|
|
|
|
|
|
+ return n, nil
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-func splitLines(s string) []string {
|
|
|
|
|
- var lines []string
|
|
|
|
|
- var current string
|
|
|
|
|
- for _, c := range s {
|
|
|
|
|
- if c == '\n' {
|
|
|
|
|
- lines = append(lines, current)
|
|
|
|
|
- current = ""
|
|
|
|
|
- } else {
|
|
|
|
|
- current += string(c)
|
|
|
|
|
- }
|
|
|
|
|
|
|
+// 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 current != "" {
|
|
|
|
|
- lines = append(lines, current)
|
|
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return n, fmt.Errorf("read: %w", err)
|
|
|
}
|
|
}
|
|
|
- return lines
|
|
|
|
|
|
|
+ return n, nil
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-func parseHex(s string) (uint16, error) {
|
|
|
|
|
- var val uint16
|
|
|
|
|
- for _, c := range s {
|
|
|
|
|
- val <<= 4
|
|
|
|
|
- switch {
|
|
|
|
|
- case c >= '0' && c <= '9':
|
|
|
|
|
- val |= uint16(c - '0')
|
|
|
|
|
- case c >= 'a' && c <= 'f':
|
|
|
|
|
- val |= uint16(c - 'a' + 10)
|
|
|
|
|
- case c >= 'A' && c <= 'F':
|
|
|
|
|
- val |= uint16(c - 'A' + 10)
|
|
|
|
|
- default:
|
|
|
|
|
- return 0, fmt.Errorf("invalid hex char: %c", c)
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- return val, nil
|
|
|
|
|
|
|
+// Close closes the device.
|
|
|
|
|
+func (d *Device) Close() error {
|
|
|
|
|
+ return d.dev.Close()
|
|
|
}
|
|
}
|