916 lines
35 KiB
Python
916 lines
35 KiB
Python
## Copyright (C) 2012-2013 Daniel Pavel
|
||
##
|
||
## This program is free software; you can redistribute it and/or modify
|
||
## it under the terms of the GNU General Public License as published by
|
||
## the Free Software Foundation; either version 2 of the License, or
|
||
## (at your option) any later version.
|
||
##
|
||
## This program is distributed in the hope that it will be useful,
|
||
## but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
## GNU General Public License for more details.
|
||
##
|
||
## You should have received a copy of the GNU General Public License along
|
||
## with this program; if not, write to the Free Software Foundation, Inc.,
|
||
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||
|
||
"""Base low-level functions as API for upper layers."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import dataclasses
|
||
import logging
|
||
import platform
|
||
import struct
|
||
import threading
|
||
import typing
|
||
|
||
from contextlib import contextmanager
|
||
from random import getrandbits
|
||
from time import time
|
||
from typing import Any
|
||
from typing import Callable
|
||
|
||
from . import base_usb
|
||
from . import common
|
||
from . import descriptors
|
||
from . import exceptions
|
||
from .common import LOGITECH_VENDOR_ID
|
||
from .common import BusID
|
||
from .hidpp10_constants import ErrorCode as Hidpp10ErrorCode
|
||
from .hidpp20_constants import ErrorCode as Hidpp20ErrorCode
|
||
|
||
if typing.TYPE_CHECKING:
|
||
import gi
|
||
|
||
from hidapi.common import DeviceInfo
|
||
|
||
gi.require_version("Gdk", "3.0")
|
||
from gi.repository import GLib # NOQA: E402
|
||
|
||
if platform.system() == "Linux":
|
||
import hidapi.udev_impl as hidapi
|
||
else:
|
||
import hidapi.hidapi_impl as hidapi
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class HIDProtocol(typing.Protocol):
|
||
def find_paired_node_wpid(self, receiver_path: str, index: int):
|
||
...
|
||
|
||
def find_paired_node(self, receiver_path: str, index: int, timeout: int):
|
||
...
|
||
|
||
def open(self, vendor_id, product_id, serial=None):
|
||
...
|
||
|
||
def open_path(self, path) -> int:
|
||
...
|
||
|
||
def enumerate(self, filter_func: Callable[[int, int, int, bool, bool], dict[str, typing.Any]]) -> DeviceInfo:
|
||
...
|
||
|
||
def monitor_glib(
|
||
self, glib: GLib, callback: Callable, filter_func: Callable[[int, int, int, bool, bool], dict[str, typing.Any]]
|
||
) -> None:
|
||
...
|
||
|
||
def read(self, device_handle, bytes_count, timeout_ms):
|
||
...
|
||
|
||
def write(self, device_handle: int, data: bytes) -> int:
|
||
...
|
||
|
||
def close(self, device_handle) -> None:
|
||
...
|
||
|
||
|
||
SHORT_MESSAGE_SIZE = 7
|
||
_LONG_MESSAGE_SIZE = 20
|
||
_MEDIUM_MESSAGE_SIZE = 15
|
||
_MAX_READ_SIZE = 32
|
||
|
||
HIDPP_SHORT_MESSAGE_ID = 0x10
|
||
HIDPP_LONG_MESSAGE_ID = 0x11
|
||
DJ_MESSAGE_ID = 0x20
|
||
|
||
# Centurion transport (used by PRO X 2 LIGHTSPEED headset and similar)
|
||
# Two variants exist, distinguished by report ID:
|
||
# 0x51 (PRO X 2): [0x51, cpl_length, flags, feat_idx, func_sw, params..., pad]
|
||
# 0x50 (G522): [0x50, device_addr, cpl_length, flags, feat_idx, func_sw, params..., pad]
|
||
# The 0x50 variant adds a device_addr byte at position [1], shifting all CPL fields by +1.
|
||
# cpl_length = number of bytes from flags to end of meaningful data (includes flags byte).
|
||
# The device_index byte from standard HID++ is NOT present in Centurion framing.
|
||
CENTURION_REPORT_ID = 0x51
|
||
CENTURION_ADDRESSED_REPORT_ID = 0x50 # addressed variant with device_addr byte at frame[1] (G522 etc.)
|
||
CENTURION_FRAME_SIZE = 64 # 1 byte report ID + 63 bytes payload
|
||
_CENTURION_MSG_SIZE = 63 # max reconstructed message size after unwrapping (2 + 61 payload bytes)
|
||
|
||
|
||
@dataclasses.dataclass
|
||
class CenturionHandleState:
|
||
"""Per-handle state for Centurion devices."""
|
||
|
||
report_id: int = CENTURION_REPORT_ID # 0x50 or 0x51
|
||
device_addr: int | None = None # learned from first RX (0x50 only)
|
||
protocol_version: tuple[int, int] | None = None # from ping response
|
||
|
||
|
||
# All centurion per-handle state in a single dict.
|
||
# Membership test (ihandle in _centurion_handles) gates centurion-specific code paths.
|
||
_centurion_handles: dict[int, CenturionHandleState] = {}
|
||
|
||
|
||
"""Default timeout on read (in seconds)."""
|
||
DEFAULT_TIMEOUT = 4
|
||
# the receiver itself should reply very fast, within 500ms
|
||
_RECEIVER_REQUEST_TIMEOUT = 0.9
|
||
# devices may reply a lot slower, as the call has to go wireless to them and come back
|
||
_DEVICE_REQUEST_TIMEOUT = DEFAULT_TIMEOUT
|
||
# when pinging, be extra patient (no longer)
|
||
_PING_TIMEOUT = DEFAULT_TIMEOUT
|
||
|
||
hidapi = typing.cast(HIDProtocol, hidapi)
|
||
|
||
request_lock = threading.Lock() # serialize all requests
|
||
handles_lock = {}
|
||
|
||
|
||
@dataclasses.dataclass
|
||
class HIDPPNotification:
|
||
report_id: int
|
||
devnumber: int
|
||
sub_id: int
|
||
address: int
|
||
data: bytes
|
||
|
||
def __str__(self):
|
||
text_as_hex = common.strhex(self.data)
|
||
return f"Notification({self.report_id:02x},{self.devnumber},{self.sub_id:02X},{self.address:02X},{text_as_hex})"
|
||
|
||
|
||
def _usb_device(product_id: int, usb_interface: int) -> dict[str, Any]:
|
||
return {
|
||
"vendor_id": LOGITECH_VENDOR_ID,
|
||
"product_id": product_id,
|
||
"bus_id": BusID.USB,
|
||
"usb_interface": usb_interface,
|
||
"isDevice": True,
|
||
}
|
||
|
||
|
||
def _bluetooth_device(product_id: int) -> dict[str, Any]:
|
||
return {"vendor_id": LOGITECH_VENDOR_ID, "product_id": product_id, "bus_id": BusID.BLUETOOTH, "isDevice": True}
|
||
|
||
|
||
KNOWN_DEVICE_IDS = []
|
||
|
||
for _ignore, d in descriptors.DEVICES.items():
|
||
if d.usbid:
|
||
usb_interface = d.interface if d.interface else 2
|
||
KNOWN_DEVICE_IDS.append(_usb_device(d.usbid, usb_interface))
|
||
if d.btid:
|
||
KNOWN_DEVICE_IDS.append(_bluetooth_device(d.btid))
|
||
|
||
|
||
def product_information(usb_id: int) -> dict[str, Any]:
|
||
"""Returns hardcoded information from USB receiver."""
|
||
return base_usb.get_receiver_info(usb_id)
|
||
|
||
|
||
def receivers():
|
||
"""Enumerate all the receivers attached to the machine."""
|
||
yield from hidapi.enumerate(get_known_receiver_info)
|
||
|
||
|
||
def filter_products_of_interest(
|
||
bus_id: int, vendor_id: int, product_id: int, hidpp_short: bool = False, hidpp_long: bool = False
|
||
) -> dict[str, Any] | None:
|
||
"""Check that this product is of interest and if so return the device record for further checking"""
|
||
|
||
recv = get_known_receiver_info(bus_id, vendor_id, product_id, hidpp_short, hidpp_long)
|
||
if recv: # known or unknown receiver
|
||
return recv
|
||
|
||
device = get_known_device_info(bus_id, vendor_id, product_id)
|
||
if device:
|
||
return device
|
||
|
||
if hidpp_short or hidpp_long:
|
||
return get_unknown_hid_device_info(bus_id, vendor_id, product_id)
|
||
|
||
if hidpp_short is None and hidpp_long is None:
|
||
return get_unknown_logitech_device_info(bus_id, vendor_id, product_id)
|
||
return None
|
||
|
||
|
||
def get_known_device_info(bus_id: int, vendor_id: int, product_id: int) -> dict[str, Any]:
|
||
for recv in KNOWN_DEVICE_IDS:
|
||
if _match_device(recv, bus_id, vendor_id, product_id):
|
||
return recv
|
||
|
||
|
||
def get_unknown_hid_device_info(bus_id: int, vendor_id: int, product_id: int) -> dict[str, Any]:
|
||
return {"vendor_id": vendor_id, "product_id": product_id, "bus_id": bus_id, "isDevice": True}
|
||
|
||
|
||
def get_unknown_logitech_device_info(bus_id: int, vendor_id: int, product_id: int) -> dict[str, Any] | None:
|
||
"""Get info from unknown device in Logitech product range.
|
||
|
||
Check whether product is a Logitech USB-connected or Bluetooth
|
||
device based on bus, vendor, and product ID. This allows Solaar to
|
||
support receiverless HID++ 2.0 devices that it knows nothing about.
|
||
"""
|
||
if vendor_id != LOGITECH_VENDOR_ID:
|
||
return None
|
||
|
||
if bus_id == BusID.USB.value and (0xC07D <= product_id <= 0xC094 or 0xC32B <= product_id <= 0xC344):
|
||
device_info = _usb_device(product_id, 2)
|
||
return device_info
|
||
elif bus_id == BusID.BLUETOOTH.value and (0xB012 <= product_id <= 0xB0FF or 0xB317 <= product_id <= 0xB3FF):
|
||
device_info = _bluetooth_device(product_id)
|
||
return device_info
|
||
|
||
return None
|
||
|
||
|
||
def _match_device(record: dict[str, Any], bus_id: int, vendor_id: int, product_id: int):
|
||
return (
|
||
(record.get("bus_id") is None or record.get("bus_id") == bus_id)
|
||
and (record.get("vendor_id") is None or record.get("vendor_id") == vendor_id)
|
||
and (record.get("product_id") is None or record.get("product_id") == product_id)
|
||
)
|
||
|
||
|
||
def get_known_receiver_info(
|
||
bus_id: int, vendor_id: int, product_id: int, _hidpp_short: bool = False, _hidpp_long: bool = False
|
||
) -> dict[str, Any]:
|
||
"""Check that this product is a Logitech receiver and return it.
|
||
|
||
Filters based on bus_id, vendor_id and product_id.
|
||
|
||
If so return the receiver record for further checking.
|
||
"""
|
||
try:
|
||
record = base_usb.get_receiver_info(product_id)
|
||
if _match_device(record, bus_id, vendor_id, product_id):
|
||
return record
|
||
except ValueError:
|
||
pass
|
||
|
||
if vendor_id == LOGITECH_VENDOR_ID and 0xC500 <= product_id <= 0xC5FF: # unknown receiver
|
||
return {"vendor_id": vendor_id, "product_id": product_id, "bus_id": bus_id, "isDevice": False}
|
||
return None
|
||
|
||
|
||
def receivers_and_devices():
|
||
"""Enumerate all the receivers and devices directly attached to the machine."""
|
||
yield from hidapi.enumerate(filter_products_of_interest)
|
||
|
||
|
||
def notify_on_receivers_glib(glib: GLib, callback: Callable):
|
||
"""Watch for matching devices and notifies the callback on the GLib thread.
|
||
|
||
Parameters
|
||
----------
|
||
glib
|
||
GLib instance.
|
||
"""
|
||
return hidapi.monitor_glib(glib, callback, filter_products_of_interest)
|
||
|
||
|
||
def open_path(path) -> int:
|
||
"""Checks if the given Linux device path points to the right UR device.
|
||
|
||
:param path: the Linux device path.
|
||
|
||
The UR physical device may expose multiple linux devices with the same
|
||
interface, so we have to check for the right one. At this moment the only
|
||
way to distinguish betheen them is to do a test ping on an invalid
|
||
(attached) device number (i.e., 0), expecting a 'ping failed' reply.
|
||
|
||
:returns: an open receiver handle if this is the right Linux device, or
|
||
``None``.
|
||
"""
|
||
return hidapi.open_path(path)
|
||
|
||
|
||
def open():
|
||
"""Opens the first Logitech Unifying Receiver found attached to the machine.
|
||
|
||
:returns: An open file handle for the found receiver, or ``None``.
|
||
"""
|
||
for rawdevice in receivers():
|
||
handle = open_path(rawdevice.path)
|
||
if handle:
|
||
return handle
|
||
|
||
|
||
def close(handle):
|
||
"""Closes a HID device handle."""
|
||
if handle:
|
||
try:
|
||
if isinstance(handle, int):
|
||
_centurion_handles.pop(handle, None)
|
||
hidapi.close(handle)
|
||
else:
|
||
handle.close()
|
||
return True
|
||
except Exception:
|
||
pass
|
||
|
||
return False
|
||
|
||
|
||
def _centurion_frame_header(state: CenturionHandleState, cpl_length: int, flags: int) -> bytes:
|
||
"""Build the fixed prefix of a centurion frame.
|
||
|
||
0x51: [0x51, cpl_length, flags] (3 bytes)
|
||
0x50: [0x50, device_addr, cpl_length, flags] (4 bytes)
|
||
"""
|
||
if state.report_id == CENTURION_ADDRESSED_REPORT_ID:
|
||
device_addr = state.device_addr if state.device_addr is not None else 0x00
|
||
return struct.pack("!BBBB", CENTURION_ADDRESSED_REPORT_ID, device_addr, cpl_length, flags)
|
||
return struct.pack("!BBB", CENTURION_REPORT_ID, cpl_length, flags)
|
||
|
||
|
||
_CENTURION_REPORT_IDS = (CENTURION_REPORT_ID, CENTURION_ADDRESSED_REPORT_ID)
|
||
|
||
# Per-candidate read timeout (ms) for the device_addr probe.
|
||
# USB round-trip is <1ms; 5ms gives 5x margin.
|
||
_CENTURION_PROBE_PER_ADDR_TIMEOUT_MS = 5
|
||
|
||
|
||
def probe_centurion_device_addr(handle, state: CenturionHandleState) -> bool:
|
||
"""Brute-force probe the device address byte for a 0x50-variant Centurion handle.
|
||
|
||
Sends a ROOT.GetProtocolVersion request for each candidate device_addr
|
||
(0x00–0xFF), reading briefly after each write. The dongle silently ignores
|
||
wrong addresses and responds only to the correct one. Stops on first hit.
|
||
|
||
Worst case (no response): 256 × 5ms = ~1.3s.
|
||
Typical G522 (addr=0x23): 36 × 5ms = ~180ms.
|
||
|
||
No-op for 0x51 (no device_addr byte) or when an address is already known.
|
||
Returns True if the address was learned.
|
||
"""
|
||
if state.report_id != CENTURION_ADDRESSED_REPORT_ID or state.device_addr is not None:
|
||
return False
|
||
ihandle = int(handle)
|
||
logger.debug("(%s) probing centurion device_addr: scanning 0x00-0xFF", handle)
|
||
|
||
# ROOT.GetProtocolVersion: feat_idx=0x00, func=0x10, 3 zero param bytes
|
||
payload = bytes([0x00, 0x10, 0x00, 0x00, 0x00])
|
||
cpl_length = len(payload) + 1 # +1 for flags byte
|
||
write_errors = 0
|
||
|
||
for addr in range(256):
|
||
frame = struct.pack("!BBBB", CENTURION_ADDRESSED_REPORT_ID, addr, cpl_length, 0x00) + payload
|
||
frame = frame + b"\x00" * (CENTURION_FRAME_SIZE - len(frame))
|
||
try:
|
||
hidapi.write(ihandle, frame)
|
||
except Exception:
|
||
write_errors += 1
|
||
if write_errors > 3:
|
||
logger.debug("(%s) centurion device_addr probe: too many write failures, aborting", handle)
|
||
return False
|
||
continue
|
||
try:
|
||
data = hidapi.read(ihandle, CENTURION_FRAME_SIZE, _CENTURION_PROBE_PER_ADDR_TIMEOUT_MS)
|
||
except Exception as reason:
|
||
logger.debug("(%s) centurion device_addr probe read failed at addr 0x%02X: %s", handle, addr, reason)
|
||
return False
|
||
if data and len(data) >= 2 and ord(data[:1]) == state.report_id:
|
||
state.device_addr = ord(data[1:2])
|
||
logger.debug(
|
||
"(%s) probed centurion device addr 0x%02X (after %d candidates)",
|
||
handle,
|
||
state.device_addr,
|
||
addr + 1,
|
||
)
|
||
return True
|
||
|
||
logger.debug("(%s) centurion device_addr probe: no response from any of 256 candidates", handle)
|
||
return False
|
||
|
||
|
||
def _unwrap_centurion_frame(data: bytes, ihandle: int, handle) -> bytes:
|
||
"""Unwrap a Centurion CPL frame (0x50 or 0x51) into a standard HID++ long message.
|
||
|
||
Auto-detects the variant from the raw report ID byte (self-describing),
|
||
matching how _read() handles 0x10 vs 0x11.
|
||
|
||
For 0x50, learns the device address from byte[1] on first receive.
|
||
"""
|
||
raw_report_id = ord(data[:1])
|
||
if raw_report_id == CENTURION_ADDRESSED_REPORT_ID:
|
||
# 0x50: [report_id, device_addr, cpl_length, flags, feat_idx, func_sw, data...]
|
||
device_addr = ord(data[1:2])
|
||
state = _centurion_handles.get(ihandle)
|
||
if state is not None and state.device_addr is None:
|
||
state.device_addr = device_addr
|
||
if logger.isEnabledFor(logging.DEBUG):
|
||
logger.debug("(%s) learned centurion device addr 0x%02X", handle, device_addr)
|
||
cpl_length = ord(data[2:3])
|
||
inner_payload = data[4 : 3 + cpl_length] # cpl_length - 1 bytes (skip flags)
|
||
elif raw_report_id == CENTURION_REPORT_ID:
|
||
# 0x51: [report_id, cpl_length, flags, feat_idx, func_sw, data...]
|
||
cpl_length = ord(data[1:2])
|
||
inner_payload = data[3 : 2 + cpl_length] # cpl_length - 1 bytes (skip flags)
|
||
else:
|
||
return data # not a centurion frame
|
||
|
||
data = bytes([HIDPP_LONG_MESSAGE_ID, 0xFF]) + inner_payload
|
||
# Pad to a valid message size: standard long (20) or Centurion extended (63)
|
||
if len(data) <= _LONG_MESSAGE_SIZE:
|
||
data = data + b"\x00" * (_LONG_MESSAGE_SIZE - len(data))
|
||
elif len(data) <= _CENTURION_MSG_SIZE:
|
||
data = data + b"\x00" * (_CENTURION_MSG_SIZE - len(data))
|
||
else:
|
||
data = data[:_CENTURION_MSG_SIZE]
|
||
return data
|
||
|
||
|
||
def write(handle, devnumber, data, long_message=False):
|
||
"""Writes some data to the receiver, addressed to a certain device.
|
||
|
||
:param handle: an open UR handle.
|
||
:param devnumber: attached device number.
|
||
:param data: data to send, up to 5 bytes.
|
||
|
||
The first two (required) bytes of data must be the SubId and address.
|
||
|
||
:raises NoReceiver: if the receiver is no longer available, i.e. has
|
||
been physically removed from the machine, or the kernel driver has been
|
||
unloaded. The handle will be closed automatically.
|
||
"""
|
||
# the data is padded to either 5 or 18 bytes
|
||
assert data is not None
|
||
assert isinstance(data, bytes), (repr(data), type(data))
|
||
|
||
if long_message or len(data) > SHORT_MESSAGE_SIZE - 2 or data[:1] == b"\x82":
|
||
wdata = struct.pack("!BB18s", HIDPP_LONG_MESSAGE_ID, devnumber, data)
|
||
else:
|
||
wdata = struct.pack("!BB5s", HIDPP_SHORT_MESSAGE_ID, devnumber, data)
|
||
|
||
ihandle = int(handle)
|
||
if ihandle in _centurion_handles:
|
||
# Centurion CPL framing — strip device_index from HID++ and wrap in CPL header.
|
||
# cpl_length = len(meaningful_payload) + 1 (the +1 counts the flags byte).
|
||
state = _centurion_handles[ihandle]
|
||
payload = wdata[2:] # skip report_id and devnumber from standard frame
|
||
cpl_length = len(data) + 1 # data is the unpadded payload; +1 for flags byte
|
||
wdata = _centurion_frame_header(state, cpl_length, 0x00) + payload
|
||
wdata = wdata + b"\x00" * (CENTURION_FRAME_SIZE - len(wdata))
|
||
|
||
if logger.isEnabledFor(logging.DEBUG):
|
||
logger.debug(
|
||
"(%s) <= w[%02X %02X %s %s]",
|
||
handle,
|
||
ord(wdata[:1]),
|
||
devnumber,
|
||
common.strhex(wdata[2:4]),
|
||
common.strhex(wdata[4:]),
|
||
)
|
||
|
||
try:
|
||
hidapi.write(ihandle, wdata)
|
||
except Exception as reason:
|
||
logger.error("write failed, assuming handle %r no longer available", handle)
|
||
close(handle)
|
||
raise exceptions.NoReceiver(reason=reason) from reason
|
||
|
||
|
||
def write_centurion_cpl(handle, layer3_payload, flags=0x00):
|
||
"""Send a Centurion CPL frame with the given Layer 3+ payload.
|
||
|
||
Builds the appropriate header for the handle's report ID variant:
|
||
0x51: [0x51, cpl_length, flags, layer3_payload..., pad to 64]
|
||
0x50: [0x50, device_addr, cpl_length, flags, layer3_payload..., pad to 64]
|
||
where cpl_length = len(layer3_payload) + 1 (the +1 counts the flags byte).
|
||
|
||
For multi-fragment sends, flags encodes fragment index and continuation:
|
||
flags = (fragment_index << 1) | (1 if more_fragments else 0)
|
||
Single-frame messages use flags=0x00 (default).
|
||
"""
|
||
ihandle = int(handle)
|
||
if ihandle not in _centurion_handles:
|
||
raise ValueError("write_centurion_cpl called on non-Centurion handle")
|
||
state = _centurion_handles[ihandle]
|
||
cpl_length = len(layer3_payload) + 1 # +1 for flags byte
|
||
header = _centurion_frame_header(state, cpl_length, flags)
|
||
wdata = header + layer3_payload
|
||
wdata = wdata + b"\x00" * (CENTURION_FRAME_SIZE - len(wdata))
|
||
if logger.isEnabledFor(logging.DEBUG):
|
||
logger.debug("(%s) <= centurion_cpl[%s]", handle, common.strhex(wdata[: len(header) + cpl_length - 1]))
|
||
try:
|
||
hidapi.write(ihandle, wdata)
|
||
except Exception as reason:
|
||
logger.error("write failed, assuming handle %r no longer available", handle)
|
||
close(handle)
|
||
raise exceptions.NoReceiver(reason=reason) from reason
|
||
|
||
|
||
def read(handle, timeout=DEFAULT_TIMEOUT):
|
||
"""Read some data from the receiver. Usually called after a write (feature
|
||
call), to get the reply.
|
||
|
||
:param: handle open handle to the receiver
|
||
:param: timeout how long to wait for a reply, in seconds
|
||
|
||
:returns: a tuple of (devnumber, message data), or `None`
|
||
|
||
:raises NoReceiver: if the receiver is no longer available, i.e. has
|
||
been physically removed from the machine, or the kernel driver has been
|
||
unloaded. The handle will be closed automatically.
|
||
"""
|
||
reply = _read(handle, timeout)
|
||
if reply:
|
||
return reply
|
||
|
||
|
||
def _is_relevant_message(data: bytes) -> bool:
|
||
"""Checks if given id is a HID++ or DJ message.
|
||
|
||
Applies sanity checks on message report ID and message size.
|
||
"""
|
||
assert isinstance(data, bytes), (repr(data), type(data))
|
||
|
||
# mapping from report_id to accepted message lengths
|
||
report_lengths = {
|
||
HIDPP_SHORT_MESSAGE_ID: (SHORT_MESSAGE_SIZE,),
|
||
HIDPP_LONG_MESSAGE_ID: (_LONG_MESSAGE_SIZE, _CENTURION_MSG_SIZE),
|
||
DJ_MESSAGE_ID: (_MEDIUM_MESSAGE_SIZE,),
|
||
0x21: (_MAX_READ_SIZE,),
|
||
}
|
||
|
||
report_id = ord(data[:1])
|
||
if report_id in report_lengths:
|
||
if len(data) in report_lengths[report_id]:
|
||
return True
|
||
else:
|
||
logger.warning(f"unexpected message size: report_id {report_id:02X} message {common.strhex(data)}")
|
||
return False
|
||
|
||
|
||
def _read(handle, timeout) -> tuple[int, int, bytes]:
|
||
"""Read an incoming packet from the receiver.
|
||
|
||
:returns: a tuple of (report_id, devnumber, data), or `None`.
|
||
|
||
:raises NoReceiver: if the receiver is no longer available, i.e. has
|
||
been physically removed from the machine, or the kernel driver has been
|
||
unloaded. The handle will be closed automatically.
|
||
"""
|
||
ihandle = int(handle)
|
||
is_centurion = ihandle in _centurion_handles
|
||
read_size = CENTURION_FRAME_SIZE if is_centurion else _MAX_READ_SIZE
|
||
try:
|
||
# convert timeout to milliseconds, the hidapi expects it
|
||
timeout = int(timeout * 1000)
|
||
data = hidapi.read(ihandle, read_size, timeout)
|
||
except Exception as reason:
|
||
logger.warning("read failed, assuming handle %r no longer available", handle)
|
||
close(handle)
|
||
raise exceptions.NoReceiver(reason=reason) from reason
|
||
|
||
if data and is_centurion and ord(data[:1]) in _CENTURION_REPORT_IDS:
|
||
data = _unwrap_centurion_frame(data, ihandle, handle)
|
||
|
||
if data and _is_relevant_message(data): # ignore messages that fail check
|
||
report_id = ord(data[:1])
|
||
devnumber = ord(data[1:2])
|
||
|
||
if logger.isEnabledFor(logging.DEBUG) and (
|
||
report_id != DJ_MESSAGE_ID or ord(data[2:3]) > 0x10
|
||
): # ignore DJ input messages
|
||
logger.debug(
|
||
"(%s) => r[%02X %02X %s %s]",
|
||
handle,
|
||
report_id,
|
||
devnumber,
|
||
common.strhex(data[2:4]),
|
||
common.strhex(data[4:]),
|
||
)
|
||
|
||
return report_id, devnumber, data[2:]
|
||
|
||
|
||
def make_notification(report_id: int, devnumber: int, data: bytes) -> HIDPPNotification | None:
|
||
"""Guess if this is a notification (and not just a request reply), and
|
||
return a Notification if it is."""
|
||
|
||
sub_id = ord(data[:1])
|
||
if sub_id & 0x80 == 0x80:
|
||
# this is either a HID++1.0 register r/w, or an error reply
|
||
return None
|
||
|
||
# DJ input records are not notifications
|
||
if report_id == DJ_MESSAGE_ID and (sub_id < 0x10):
|
||
return None
|
||
|
||
address = ord(data[1:2])
|
||
if sub_id == 0x00 and (address & 0x0F == 0x00):
|
||
# this is a no-op notification - don't do anything with it
|
||
return None
|
||
|
||
if (
|
||
# standard HID++ 1.0 notification, SubId may be 0x40 - 0x7F
|
||
(sub_id >= 0x40) # noqa: E131
|
||
or
|
||
# custom HID++1.0 battery events, where SubId is 0x07/0x0D
|
||
(sub_id in (0x07, 0x0D) and len(data) == 5 and data[4:5] == b"\x00")
|
||
or
|
||
# custom HID++1.0 illumination event, where SubId is 0x17
|
||
(sub_id == 0x17 and len(data) == 5)
|
||
or
|
||
# HID++ 2.0 feature notifications have the SoftwareID 0
|
||
(address & 0x0F == 0x00)
|
||
): # noqa: E129
|
||
return HIDPPNotification(report_id, devnumber, sub_id, address, data[2:])
|
||
return None
|
||
|
||
|
||
def handle_lock(handle):
|
||
with request_lock:
|
||
if handles_lock.get(handle) is None:
|
||
if logger.isEnabledFor(logging.INFO):
|
||
logger.info("New lock %s", repr(handle))
|
||
handles_lock[handle] = threading.Lock() # Serialize requests on the handle
|
||
return handles_lock[handle]
|
||
|
||
|
||
# context manager for locks with a timeout
|
||
@contextmanager
|
||
def acquire_timeout(lock, handle, timeout):
|
||
result = lock.acquire(timeout=timeout)
|
||
try:
|
||
if not result:
|
||
logger.error("lock on handle %d not acquired, probably due to timeout", int(handle))
|
||
yield result
|
||
finally:
|
||
if result:
|
||
lock.release()
|
||
|
||
|
||
def find_paired_node(receiver_path: str, index: int, timeout: int):
|
||
"""Find the node of a device paired with a receiver."""
|
||
return hidapi.find_paired_node(receiver_path, index, timeout)
|
||
|
||
|
||
def find_paired_node_wpid(receiver_path: str, index: int):
|
||
"""Find the node of a device paired with a receiver.
|
||
|
||
Get wpid from udev.
|
||
"""
|
||
return hidapi.find_paired_node_wpid(receiver_path, index)
|
||
|
||
|
||
# a very few requests (e.g., host switching) do not expect a reply, but use no_reply=True with extreme caution
|
||
def request(
|
||
handle,
|
||
devnumber,
|
||
request_id: int,
|
||
*params,
|
||
no_reply: bool = False,
|
||
return_error: bool = False,
|
||
long_message: bool = False,
|
||
protocol: float = 1.0,
|
||
):
|
||
"""Makes a feature call to a device and waits for a matching reply.
|
||
:param handle: an open UR handle.
|
||
:param devnumber: attached device number.
|
||
:param request_id: a 16-bit integer.
|
||
:param params: parameters for the feature call, 3 to 16 bytes.
|
||
:returns: the reply data, or ``None`` if some error occurred. or no reply expected
|
||
"""
|
||
with acquire_timeout(handle_lock(handle), handle, 10.0):
|
||
assert isinstance(request_id, int)
|
||
if (devnumber != 0xFF or protocol >= 2.0) and request_id < 0x8000:
|
||
# Always set the most significant bit (8) in SoftwareId,
|
||
# to make notifications easier to distinguish from request replies.
|
||
# This only applies to peripheral requests, ofc.
|
||
sw_id = _get_next_sw_id()
|
||
request_id = (request_id & 0xFFF0) | sw_id # was 0x08 | getrandbits(3)
|
||
|
||
timeout = _RECEIVER_REQUEST_TIMEOUT if devnumber == 0xFF else _DEVICE_REQUEST_TIMEOUT
|
||
# be extra patient on long register read
|
||
if request_id & 0xFF00 == 0x8300:
|
||
timeout *= 2
|
||
|
||
if params:
|
||
params = b"".join(struct.pack("B", p) if isinstance(p, int) else p for p in params)
|
||
else:
|
||
params = b""
|
||
request_data = struct.pack("!H", request_id) + params
|
||
|
||
ihandle = int(handle)
|
||
notifications_hook = getattr(handle, "notifications_hook", None)
|
||
try:
|
||
_read_input_buffer(handle, ihandle, notifications_hook)
|
||
except exceptions.NoReceiver:
|
||
logger.warning("device or receiver disconnected")
|
||
return None
|
||
write(ihandle, devnumber, request_data, long_message)
|
||
|
||
if no_reply:
|
||
return None
|
||
|
||
# we consider timeout from this point
|
||
request_started = time()
|
||
delta = 0
|
||
|
||
while delta < timeout:
|
||
reply = _read(handle, timeout)
|
||
if reply:
|
||
report_id, reply_devnumber, reply_data = reply
|
||
if reply_devnumber == devnumber or reply_devnumber == devnumber ^ 0xFF: # BT device returning 0x00
|
||
if (
|
||
report_id == HIDPP_SHORT_MESSAGE_ID
|
||
and reply_data[:1] == b"\x8f"
|
||
and reply_data[1:3] == request_data[:2]
|
||
):
|
||
error = ord(reply_data[3:4])
|
||
|
||
if logger.isEnabledFor(logging.DEBUG):
|
||
logger.debug(
|
||
"(%s) device 0x%02X error on request {%04X}: %d = %s",
|
||
handle,
|
||
devnumber,
|
||
request_id,
|
||
error,
|
||
Hidpp10ErrorCode(error),
|
||
)
|
||
return Hidpp10ErrorCode(error) if return_error else None
|
||
if reply_data[:1] == b"\xff" and reply_data[1:3] == request_data[:2]:
|
||
# a HID++ 2.0 feature call returned with an error
|
||
error = ord(reply_data[3:4])
|
||
try:
|
||
error_name = Hidpp20ErrorCode(error)
|
||
except ValueError:
|
||
error_name = f"unknown:{error:02X}"
|
||
logger.error(
|
||
"(%s) device %d error on feature request {%04X}: %d = %s",
|
||
handle,
|
||
devnumber,
|
||
request_id,
|
||
error,
|
||
error_name,
|
||
)
|
||
raise exceptions.FeatureCallError(
|
||
number=devnumber,
|
||
request=request_id,
|
||
error=error,
|
||
params=params,
|
||
)
|
||
|
||
if reply_data[:2] == request_data[:2]:
|
||
if devnumber == 0xFF:
|
||
if request_id == 0x83B5 or request_id == 0x81F1:
|
||
# these replies have to match the first parameter as well
|
||
if reply_data[2:3] == params[:1]:
|
||
return reply_data[2:]
|
||
else:
|
||
# hm, not matching my request, and certainly not a notification
|
||
continue
|
||
else:
|
||
return reply_data[2:]
|
||
else:
|
||
return reply_data[2:]
|
||
else:
|
||
# a reply was received, but did not match our request in any way
|
||
# reset the timeout starting point
|
||
request_started = time()
|
||
|
||
if notifications_hook:
|
||
n = make_notification(report_id, reply_devnumber, reply_data)
|
||
if n:
|
||
notifications_hook(n)
|
||
delta = time() - request_started
|
||
|
||
logger.warning(
|
||
"timeout (%0.2f/%0.2f) on device %d request {%04X} params [%s]",
|
||
delta,
|
||
timeout,
|
||
devnumber,
|
||
request_id,
|
||
common.strhex(params),
|
||
)
|
||
# raise DeviceUnreachable(number=devnumber, request=request_id)
|
||
|
||
|
||
def ping(handle, devnumber, long_message: bool = False):
|
||
"""Check if a device is connected to the receiver.
|
||
:returns: The HID protocol supported by the device, as a floating point number, if the device is active.
|
||
"""
|
||
if logger.isEnabledFor(logging.DEBUG):
|
||
logger.debug("(%s) pinging device %d", handle, devnumber)
|
||
with acquire_timeout(handle_lock(handle), handle, 10.0):
|
||
notifications_hook = getattr(handle, "notifications_hook", None)
|
||
try:
|
||
_read_input_buffer(handle, int(handle), notifications_hook)
|
||
except exceptions.NoReceiver:
|
||
logger.warning("device or receiver disconnected")
|
||
return
|
||
|
||
# randomize the mark byte to be able to identify the ping reply
|
||
sw_id = _get_next_sw_id()
|
||
request_id = 0x0010 | sw_id # was 0x0018 | getrandbits(3)
|
||
request_data = struct.pack("!HBBB", request_id, 0, 0, getrandbits(8))
|
||
write(int(handle), devnumber, request_data, long_message)
|
||
|
||
request_started = time() # we consider timeout from this point
|
||
delta = 0
|
||
while delta < _PING_TIMEOUT:
|
||
reply = _read(handle, _PING_TIMEOUT)
|
||
if reply:
|
||
report_id, reply_devnumber, reply_data = reply
|
||
if reply_devnumber == devnumber or reply_devnumber == devnumber ^ 0xFF: # BT device returning 0x00
|
||
is_centurion = int(handle) in _centurion_handles
|
||
mark_ok = is_centurion or reply_data[4:5] == request_data[-1:]
|
||
if reply_data[:2] == request_data[:2] and mark_ok:
|
||
# HID++ 2.0+ device, currently connected
|
||
major = ord(reply_data[2:3])
|
||
minor = ord(reply_data[3:4])
|
||
if is_centurion:
|
||
_centurion_handles[int(handle)].protocol_version = (major, minor)
|
||
return major + minor / 10.0
|
||
|
||
if (
|
||
report_id == HIDPP_SHORT_MESSAGE_ID
|
||
and reply_data[:1] == b"\x8f"
|
||
and reply_data[1:3] == request_data[:2]
|
||
): # error response
|
||
error = ord(reply_data[3:4])
|
||
if error == Hidpp10ErrorCode.INVALID_SUB_ID_COMMAND:
|
||
# a valid reply from a HID++ 1.0 device
|
||
return 1.0
|
||
if error in [Hidpp10ErrorCode.RESOURCE_ERROR, Hidpp10ErrorCode.CONNECTION_REQUEST_FAILED]:
|
||
return # device unreachable
|
||
if error == Hidpp10ErrorCode.UNKNOWN_DEVICE: # no device with that number currently accessible
|
||
logger.info("(%s) device %d error on ping request: unknown device", handle, devnumber)
|
||
raise exceptions.NoSuchDevice(number=devnumber, request=request_id)
|
||
|
||
if notifications_hook:
|
||
n = make_notification(report_id, reply_devnumber, reply_data)
|
||
if n:
|
||
notifications_hook(n)
|
||
|
||
delta = time() - request_started
|
||
|
||
logger.warning("(%s) timeout (%0.2f/%0.2f) on device %d ping", handle, delta, _PING_TIMEOUT, devnumber)
|
||
|
||
|
||
def _read_input_buffer(handle, ihandle, notifications_hook):
|
||
"""Consume anything already in the input buffer.
|
||
|
||
Used by request() and ping() before their write.
|
||
"""
|
||
is_centurion = ihandle in _centurion_handles
|
||
read_size = CENTURION_FRAME_SIZE if is_centurion else _MAX_READ_SIZE
|
||
|
||
while True:
|
||
try:
|
||
# read whatever is already in the buffer, if any
|
||
data = hidapi.read(ihandle, read_size, 0)
|
||
except Exception as reason:
|
||
logger.error("read failed, assuming receiver %s no longer available", handle)
|
||
close(handle)
|
||
raise exceptions.NoReceiver(reason=reason) from reason
|
||
|
||
if data:
|
||
if is_centurion and ord(data[:1]) in _CENTURION_REPORT_IDS:
|
||
data = _unwrap_centurion_frame(data, ihandle, handle)
|
||
if _is_relevant_message(data): # only process messages that pass check
|
||
# report_id = ord(data[:1])
|
||
if notifications_hook:
|
||
n = make_notification(ord(data[:1]), ord(data[1:2]), data[2:])
|
||
if n:
|
||
notifications_hook(n)
|
||
else:
|
||
# nothing in the input buffer, we're done
|
||
return
|
||
|
||
|
||
# HID++ Software ID claimed by Solaar. Fixed (not rotated) so cooperative
|
||
# userspace HID++ clients sharing the same device can pick a different value
|
||
# and reliably filter Solaar's traffic out of their reply stream.
|
||
#
|
||
# Known values in use by other tools at the time of writing:
|
||
#
|
||
# 0x07 OpenRGB
|
||
# 0x0A LGSTrayEx
|
||
# 0x0D Logitech G HUB (host-side)
|
||
# 0x0F Logitech firmware (sub-device self-enumeration on wired transports)
|
||
#
|
||
# 0x0B avoids those and keeps the high bit set so notifications (sw_id=0)
|
||
# remain trivially distinguishable from replies.
|
||
SOLAAR_SOFTWARE_ID = 0x0B
|
||
|
||
|
||
def _get_next_sw_id() -> int:
|
||
"""Return Solaar's HID++ Software ID (fixed, see SOLAAR_SOFTWARE_ID)."""
|
||
return SOLAAR_SOFTWARE_ID
|