package hid import ( "fmt" "os" "strings" "syscall" "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 ) // Device represents a connected HID device. 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 } // DeviceInfo holds device metadata without open handle. type DeviceInfo struct { Path string VendorID uint16 ProductID uint16 } // 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 } // 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 } // 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 } // Read reads a response report. func (d *Device) Read(buf []byte) (int, error) { 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) } // 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") if err != nil { return nil, fmt.Errorf("read /dev: %w", err) } var devices []DeviceInfo for _, entry := range entries { if !strings.HasPrefix(entry.Name(), "hidraw") { continue } hidrawPath := "/dev/" + entry.Name() sysEventPath := "/sys/class/hidraw/" + entry.Name() + "/device/uevent" data, err := os.ReadFile(sysEventPath) if err != nil { continue } vid, pid := parseUEVENT(string(data)) if vid == 0 || pid == 0 { continue } devices = append(devices, DeviceInfo{ Path: hidrawPath, VendorID: vid, ProductID: pid, }) } return devices, nil } 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 } if p, e := parseHex(data[colon+1:]); e == nil { pid = p } } } } return vid, pid } 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) } } if current != "" { lines = append(lines, current) } return lines } 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 }