hidapi: process hid report descriptors to identify devices

This commit is contained in:
Peter F. Patel-Schneider 2022-10-04 11:45:45 -04:00
parent afada652e8
commit 3e90c3bc8a
9 changed files with 79 additions and 36 deletions

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "python-hid-parser"]
path = python-hid-parser
url = https://github.com/usb-tools/python-hid-parser

View File

@ -32,6 +32,9 @@ in Fedora you need `gtk3` and `python3-gobject`.
You may have to install `gcc` and the Python development package (`python3-dev` or `python3-devel`, You may have to install `gcc` and the Python development package (`python3-dev` or `python3-devel`,
depending on your distribution). depending on your distribution).
Solaar also uses the hidtools library from the Python hid-tools project.
To install this library use `pip install --user hid-tools`.
If you are running a version of Python different from the system version, If you are running a version of Python different from the system version,
you may need to use pip to install projects that provide the above Python packages. you may need to use pip to install projects that provide the above Python packages.

1
lib/hid_parser Symbolic link
View File

@ -0,0 +1 @@
../python-hid-parser/hid_parser

View File

@ -26,15 +26,18 @@ necessary.
import errno as _errno import errno as _errno
import os as _os import os as _os
import warnings as _warnings
# the tuple object we'll expose when enumerating devices # the tuple object we'll expose when enumerating devices
from collections import namedtuple from collections import namedtuple
from logging import DEBUG as _DEBUG from logging import INFO as _INFO
from logging import getLogger from logging import getLogger
from select import select as _select from select import select as _select
from time import sleep from time import sleep
from time import time as _timestamp from time import time as _timestamp
from hid_parser import ReportDescriptor as _ReportDescriptor
from hid_parser import Usage as _Usage
from pyudev import Context as _Context from pyudev import Context as _Context
from pyudev import Device as _Device from pyudev import Device as _Device
from pyudev import DeviceNotFoundError from pyudev import DeviceNotFoundError
@ -43,8 +46,8 @@ from pyudev import Monitor as _Monitor
_log = getLogger(__name__) _log = getLogger(__name__)
del getLogger del getLogger
native_implementation = 'udev' native_implementation = 'udev'
fileopen = open
DeviceInfo = namedtuple( DeviceInfo = namedtuple(
'DeviceInfo', [ 'DeviceInfo', [
@ -59,6 +62,8 @@ DeviceInfo = namedtuple(
'driver', 'driver',
'bus_id', 'bus_id',
'isDevice', 'isDevice',
'hidpp_short',
'hidpp_long',
] ]
) )
del namedtuple del namedtuple
@ -92,17 +97,38 @@ def exit():
# with the required hid_driver and usb_interface and whether this is a receiver or device. # with the required hid_driver and usb_interface and whether this is a receiver or device.
def _match(action, device, filterfn): def _match(action, device, filterfn):
hid_device = device.find_parent('hid') hid_device = device.find_parent('hid')
if not hid_device: if not hid_device: # only HID devices are of interest to Solaar
return return
hid_id = hid_device.get('HID_ID') hid_id = hid_device.get('HID_ID')
if not hid_id: if not hid_id:
return # there are reports that sometimes the id isn't set up right so be defensive return # there are reports that sometimes the id isn't set up right so be defensive
bid, vid, pid = hid_id.split(':') bid, vid, pid = hid_id.split(':')
filter = filterfn(int(bid, 16), int(vid, 16), int(pid, 16)) try: # if report descriptor does not indicate HID++ capabilities then this device is not of interest to Solaar
hidpp_short = hidpp_long = False
devfile = '/sys' + hid_device.get('DEVPATH') + '/report_descriptor'
with fileopen(devfile, 'rb') as fd:
with _warnings.catch_warnings():
_warnings.simplefilter('ignore')
rd = _ReportDescriptor(fd.read())
hidpp_short = 0x10 in rd.input_report_ids and 6 * 8 == int(
rd.get_input_report_size(0x10)
) and _Usage(0xFF00, 0x0001) in rd.get_input_items(0x10)[0].usages
hidpp_long = 0x11 in rd.input_report_ids and 19 * 8 == int(
rd.get_input_report_size(0x11)
) and _Usage(0xFF00, 0x0002) in rd.get_input_items(0x11)[0].usages
if not hidpp_short and not hidpp_long:
return
except Exception as e: # if can't process report descriptor fall back to old scheme
hidpp_short = hidpp_long = None
_log.warn('Report Descriptor not processed for BID %s VID %s PID %s: %s', bid, vid, pid, e)
hid_hid_device = hid_device.find_parent('hid')
if hid_hid_device:
return # these are devices connected through a receiver so don't pick them up here
filter = filterfn(int(bid, 16), int(vid, 16), int(pid, 16), hidpp_short, hidpp_long)
if not filter: if not filter:
return return
hid_driver = filter.get('hid_driver') hid_driver = filter.get('hid_driver')
interface_number = filter.get('usb_interface') interface_number = filter.get('usb_interface')
isDevice = filter.get('isDevice') isDevice = filter.get('isDevice')
@ -118,13 +144,14 @@ def _match(action, device, filterfn):
return return
intf_device = device.find_parent('usb', 'usb_interface') intf_device = device.find_parent('usb', 'usb_interface')
# print ("*** usb interface", action, device, "usb_interface:", intf_device)
usb_interface = None if intf_device is None else intf_device.attributes.asint('bInterfaceNumber') usb_interface = None if intf_device is None else intf_device.attributes.asint('bInterfaceNumber')
if _log.isEnabledFor(_DEBUG): # print('*** usb interface', action, device, 'usb_interface:', intf_device, usb_interface, interface_number)
_log.debug( if _log.isEnabledFor(_INFO):
'Found device BID %s VID %s PID %s INTERFACE %s FILTER %s', bid, vid, pid, usb_interface, interface_number _log.info(
'Found device BID %s VID %s PID %s HID++ %s %s USB %s %s', bid, vid, pid, hidpp_short, hidpp_long,
usb_interface, interface_number
) )
if not (interface_number is None or interface_number == usb_interface): if not (hidpp_short or hidpp_long or interface_number is None or interface_number == usb_interface):
return return
attrs = intf_device.attributes if intf_device else None attrs = intf_device.attributes if intf_device else None
@ -139,7 +166,9 @@ def _match(action, device, filterfn):
serial=hid_device.get('HID_UNIQ'), serial=hid_device.get('HID_UNIQ'),
release=attrs.get('bcdDevice') if attrs else None, release=attrs.get('bcdDevice') if attrs else None,
manufacturer=attrs.get('manufacturer') if attrs else None, manufacturer=attrs.get('manufacturer') if attrs else None,
product=attrs.get('product') if attrs else None product=attrs.get('product') if attrs else None,
hidpp_short=hidpp_short,
hidpp_long=hidpp_long,
) )
return d_info return d_info
@ -157,7 +186,9 @@ def _match(action, device, filterfn):
serial=None, serial=None,
release=None, release=None,
manufacturer=None, manufacturer=None,
product=None product=None,
hidpp_short=None,
hidpp_long=None,
) )
return d_info return d_info

View File

@ -106,7 +106,7 @@ def match(record, bus_id, vendor_id, product_id):
and (record.get('product_id') is None or record.get('product_id') == product_id)) and (record.get('product_id') is None or record.get('product_id') == product_id))
def filter_receivers(bus_id, vendor_id, product_id): def filter_receivers(bus_id, vendor_id, product_id, hidpp_short=False, hidpp_long=False):
"""Check that this product is a Logitech receiver and if so return the receiver record for further checking""" """Check that this product is a Logitech receiver and if so return the receiver record for further checking"""
for record in _RECEIVER_USB_IDS: # known receivers for record in _RECEIVER_USB_IDS: # known receivers
if match(record, bus_id, vendor_id, product_id): if match(record, bus_id, vendor_id, product_id):
@ -118,26 +118,28 @@ def receivers():
yield from _hid.enumerate(filter_receivers) yield from _hid.enumerate(filter_receivers)
def filter_devices(bus_id, vendor_id, product_id): def filter(bus_id, vendor_id, product_id, hidpp_short=False, hidpp_long=False):
"""Check that this product is of interest and if so return the device record for further checking""" """Check that this product is of interest and if so return the device record for further checking"""
for record in _RECEIVER_USB_IDS: # known receivers
if match(record, bus_id, vendor_id, product_id):
return record
for record in _DEVICE_IDS: # known devices for record in _DEVICE_IDS: # known devices
if match(record, bus_id, vendor_id, product_id): if match(record, bus_id, vendor_id, product_id):
return record return record
return _other_device_check(bus_id, vendor_id, product_id) # USB and BT devices unknown to Solaar if hidpp_short or hidpp_long: # unknown devices that use HID++
return {'vendor_id': vendor_id, 'product_id': product_id, 'bus_id': bus_id, 'isDevice': True}
elif hidpp_short is None and hidpp_long is None: # unknown devices in correct range of IDs
return _other_device_check(bus_id, vendor_id, product_id)
def wired_devices(): def receivers_and_devices():
"""Enumerate all the USB-connected and Bluetooth devices attached to the machine.""" """Enumerate all the receivers and devices directly attached to the machine."""
yield from _hid.enumerate(filter_devices) yield from _hid.enumerate(filter)
def filter_either(bus_id, vendor_id, product_id):
return filter_receivers(bus_id, vendor_id, product_id) or filter_devices(bus_id, vendor_id, product_id)
def notify_on_receivers_glib(callback): def notify_on_receivers_glib(callback):
"""Watch for matching devices and notifies the callback on the GLib thread.""" """Watch for matching devices and notifies the callback on the GLib thread."""
return _hid.monitor_glib(callback, filter_either) return _hid.monitor_glib(callback, filter)
# #

View File

@ -42,6 +42,8 @@ class Device:
self.path = path self.path = path
self.handle = handle self.handle = handle
self.product_id = None self.product_id = None
self.hidpp_short = info.hidpp_short if info else None
self.hidpp_long = info.hidpp_long if info else None
if receiver: if receiver:
assert number > 0 and number <= 15 # some receivers have devices past their max # of devices assert number > 0 and number <= 15 # some receivers have devices past their max # of devices
@ -172,7 +174,9 @@ class Device:
@property @property
def protocol(self): def protocol(self):
if not self._protocol and self.online: if not self._protocol and self.online:
self._protocol = _base.ping(self.handle or self.receiver.handle, self.number, long_message=self.bluetooth) self._protocol = _base.ping(
self.handle or self.receiver.handle, self.number, long_message=self.bluetooth or self.hidpp_short is False
)
# if the ping failed, the peripheral is (almost) certainly offline # if the ping failed, the peripheral is (almost) certainly offline
self.online = self._protocol is not None self.online = self._protocol is not None
@ -430,7 +434,7 @@ class Device:
request_id, request_id,
*params, *params,
no_reply=no_reply, no_reply=no_reply,
long_message=self.bluetooth or self.protocol >= 2.0, long_message=self.bluetooth or self.hidpp_short is False or self.protocol >= 2.0,
protocol=self.protocol protocol=self.protocol
) )

View File

@ -114,14 +114,15 @@ def _receivers(dev_path=None):
_sys.exit('%s: error: %s' % (NAME, str(e))) _sys.exit('%s: error: %s' % (NAME, str(e)))
def _wired_devices(dev_path=None): def _receivers_and_devices(dev_path=None):
from logitech_receiver import Device from logitech_receiver import Device
from logitech_receiver.base import wired_devices from logitech_receiver import Receiver
for dev_info in wired_devices(): from logitech_receiver.base import receivers_and_devices
for dev_info in receivers_and_devices():
if dev_path is not None and dev_path != dev_info.path: if dev_path is not None and dev_path != dev_info.path:
continue continue
try: try:
d = Device.open(dev_info) d = Device.open(dev_info) if dev_info.isDevice else Receiver.open(dev_info)
if _log.isEnabledFor(_DEBUG): if _log.isEnabledFor(_DEBUG):
_log.debug('[%s] => %s', dev_info.path, d) _log.debug('[%s] => %s', dev_info.path, d)
if d is not None: if d is not None:
@ -198,13 +199,12 @@ def run(cli_args=None, hidraw_path=None):
assert action in actions assert action in actions
try: try:
c = list(_receivers(hidraw_path))
if action == 'show' or action == 'probe' or action == 'config': if action == 'show' or action == 'probe' or action == 'config':
c += list(_wired_devices(hidraw_path)) c = list(_receivers_and_devices(hidraw_path))
else:
c = list(_receivers(hidraw_path))
if not c: if not c:
raise Exception('No devices found') raise Exception('No devices found')
from importlib import import_module from importlib import import_module
m = import_module('.' + action, package=__name__) m = import_module('.' + action, package=__name__)
m.run(c, args, _find_receiver, _find_device) m.run(c, args, _find_receiver, _find_device)

View File

@ -309,9 +309,7 @@ def start_all():
if _log.isEnabledFor(_INFO): if _log.isEnabledFor(_INFO):
_log.info('starting receiver listening threads') _log.info('starting receiver listening threads')
for device_info in _base.receivers(): for device_info in _base.receivers_and_devices():
_process_receiver_event('add', device_info)
for device_info in _base.wired_devices():
_process_receiver_event('add', device_info) _process_receiver_event('add', device_info)

1
python-hid-parser Submodule

@ -0,0 +1 @@
Subproject commit 4b7944f4999e152c678cd7fa76278b7e2535c3ff