Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
f1271f6
Forward extra mouse buttons
s117 Feb 1, 2026
8d9a5fe
Forward mouse pan using the extended report format
s117 Feb 2, 2026
102c15b
feat: finalize extended mouse forwarding
quaxalber Apr 26, 2026
5844755
fix: avoid empty mouse interrupt packets
quaxalber Apr 26, 2026
a194cd0
docs: explain mouse report length padding
quaxalber Apr 26, 2026
29bbfe7
docs: clarify mouse report length workaround
quaxalber Apr 26, 2026
5382d8c
refactor: share extended mouse button constants
quaxalber Apr 26, 2026
dc927f5
refactor: remove redundant mouse button type cache
quaxalber Apr 26, 2026
ccc095a
fix: address extended mouse review findings
quaxalber Apr 26, 2026
1a34b36
refactor: remove unused mouse relay wrapper
quaxalber Apr 26, 2026
0f3e2e6
style: keep AC Pan HID item grouped
quaxalber Apr 26, 2026
b40a8d6
fix: handle Windows raw mouse buttons
quaxalber Apr 26, 2026
2da9114
fix: dedupe high-res horizontal wheel events
quaxalber Apr 26, 2026
39b0e72
feat: support high-resolution vertical wheel
quaxalber Apr 26, 2026
90a6c9a
test: add fast mouse loopback harness
quaxalber Apr 26, 2026
d167337
docs: require PR head install for Pi validation
quaxalber Apr 26, 2026
5ae5995
docs: clarify rsynced Pi validation installs
quaxalber Apr 26, 2026
6e39e52
docs: defer rsync validation workflow
quaxalber Apr 26, 2026
aa6212d
fix: keep fast mouse harness steps distinct
quaxalber Apr 26, 2026
fbabb40
feat: advertise high-resolution mouse scrolling
quaxalber Apr 26, 2026
985b467
test: limit default mouse button presses
quaxalber Apr 26, 2026
b8113f6
docs: mark intrusive loopback scenario opt-in
quaxalber Apr 26, 2026
fdb0275
fix: handle combined chunked mouse reports
quaxalber Apr 26, 2026
014c70e
test: log mouse gadget movement reports
quaxalber Apr 26, 2026
a11a4b7
test: log normalized mouse rel inputs
quaxalber Apr 26, 2026
f578110
fix: address mouse harness review findings
quaxalber Apr 26, 2026
f23258c
fix: preserve raw input mouse packet ordering
quaxalber Apr 26, 2026
97c8ff8
fix: align hi-res mouse contracts
quaxalber Apr 26, 2026
6b75854
fix: accept compact unnumbered mouse pan reports
quaxalber Apr 26, 2026
f404b35
fix: keep mouse harness reports canonical
quaxalber Apr 26, 2026
eac464c
fix: validate mouse button state on motion reports
quaxalber Apr 26, 2026
93a670d
fix: document mouse descriptor bounds in windows capture
quaxalber Apr 26, 2026
84d00d5
test: harden harness review tests
quaxalber Apr 26, 2026
ff3b104
style: indent HID descriptor hierarchy
quaxalber Apr 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/host-relay-loopback.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,13 @@ The injector creates temporary virtual devices named:
and emits this deterministic sequence:

- keyboard: `KEY_F13`, `KEY_F14`, `KEY_F15` down/up
- mouse: `REL_X +1`, `REL_X -1`, `REL_Y +1`, `REL_Y -1`
- mouse: `REL_X +1`, `REL_X -1`, `REL_Y +1`, `REL_Y -1`,
`REL_WHEEL +1`, `REL_WHEEL -1`, `REL_HWHEEL +1`, `REL_HWHEEL -1`,
one coalesced `REL_X +2` / `REL_Y -3` / `REL_HWHEEL +1` frame, then all
eight mouse button bits press/release

The mouse gadget report uses one button byte, signed 16-bit relative X/Y, and
signed 8-bit vertical wheel and horizontal pan.

## 4. Success criteria

Expand Down
39 changes: 24 additions & 15 deletions src/bluetooth_2_usb/evdev.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
from importlib import import_module
from typing import Any

from .extended_mouse import ExtendedMouse
from .logging import get_logger

try:
from evdev import InputEvent, KeyEvent, RelEvent
except ModuleNotFoundError:
Expand All @@ -16,8 +19,6 @@ class RelEvent:
pass


from .logging import get_logger

_logger = get_logger()


Expand Down Expand Up @@ -1206,6 +1207,11 @@ class ecodes:
ecodes.BTN_LEFT,
ecodes.BTN_RIGHT,
ecodes.BTN_MIDDLE,
ecodes.BTN_SIDE,
ecodes.BTN_EXTRA,
ecodes.BTN_FORWARD,
ecodes.BTN_BACK,
ecodes.BTN_TASK,
)
)
"""Mouse button ecodes"""
Expand Down Expand Up @@ -1261,7 +1267,7 @@ def _get_hid_code_type(
if is_consumer_key(event):
return _consumer_control_code_type()
if is_mouse_button(event):
return _mouse_button_type()
return ExtendedMouse
return _keycode_type()


Expand All @@ -1273,16 +1279,20 @@ def is_consumer_key(event: KeyEvent) -> bool:
return event.scancode in _CONSUMER_KEYS


def get_mouse_movement(event: RelEvent) -> tuple[int, int, int]:
def get_mouse_movement(event: RelEvent) -> tuple[int, int, int, float]:
input_event: InputEvent = event.event
x, y, mwheel = 0, 0, 0
x, y, mwheel, pan = 0, 0, 0, 0.0
if input_event.code == ecodes.REL_X:
x = input_event.value
elif input_event.code == ecodes.REL_Y:
y = input_event.value
elif input_event.code == ecodes.REL_WHEEL:
mwheel = input_event.value
return x, y, mwheel
elif input_event.code == ecodes.REL_HWHEEL:
pan = input_event.value
elif input_event.code == ecodes.REL_HWHEEL_HI_RES:
pan = input_event.value / 120
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
return x, y, mwheel, pan


@lru_cache(maxsize=1)
Expand All @@ -1295,16 +1305,10 @@ def _keycode_type():
return import_module("adafruit_hid.keycode").Keycode


@lru_cache(maxsize=1)
def _mouse_button_type():
return import_module("adafruit_hid.keycode").MouseButton


@lru_cache(maxsize=1)
def _evdev_to_usb_hid_map() -> dict[int, int]:
ConsumerControlCode = _consumer_control_code_type()
Keycode = _keycode_type()
MouseButton = _mouse_button_type()
return {
ecodes.KEY_A: Keycode.A,
ecodes.KEY_B: Keycode.B,
Expand Down Expand Up @@ -1426,9 +1430,14 @@ def _evdev_to_usb_hid_map() -> dict[int, int]:
ecodes.KEY_RIGHTSHIFT: Keycode.RIGHT_SHIFT,
ecodes.KEY_RIGHTALT: Keycode.RIGHT_ALT,
ecodes.KEY_RIGHTMETA: Keycode.RIGHT_GUI,
ecodes.BTN_LEFT: MouseButton.LEFT,
ecodes.BTN_RIGHT: MouseButton.RIGHT,
ecodes.BTN_MIDDLE: MouseButton.MIDDLE,
ecodes.BTN_LEFT: ExtendedMouse.LEFT,
ecodes.BTN_RIGHT: ExtendedMouse.RIGHT,
ecodes.BTN_MIDDLE: ExtendedMouse.MIDDLE,
ecodes.BTN_SIDE: ExtendedMouse.SIDE,
ecodes.BTN_EXTRA: ExtendedMouse.EXTRA,
ecodes.BTN_FORWARD: ExtendedMouse.FORWARD,
ecodes.BTN_BACK: ExtendedMouse.BACK,
ecodes.BTN_TASK: ExtendedMouse.TASK,
ecodes.KEY_POWER: ConsumerControlCode.POWER,
ecodes.KEY_RESTART: ConsumerControlCode.RESET,
ecodes.KEY_SLEEP: ConsumerControlCode.SLEEP,
Expand Down
69 changes: 69 additions & 0 deletions src/bluetooth_2_usb/extended_mouse.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
from __future__ import annotations


def _clamp_hid_i8(value: int) -> int:
return min(127, max(-127, value))


def _clamp_hid_i16(value: int) -> int:
return min(32767, max(-32767, value))


class ExtendedMouse:
"""Small mouse report writer with horizontal pan support."""

LEFT = LEFT_BUTTON = 0x01
RIGHT = RIGHT_BUTTON = 0x02
MIDDLE = MIDDLE_BUTTON = 0x04
SIDE = SIDE_BUTTON = 0x08
EXTRA = EXTRA_BUTTON = 0x10
FORWARD = FORWARD_BUTTON = 0x20
BACK = BACK_BUTTON = 0x40
TASK = TASK_BUTTON = 0x80

def __init__(self, devices) -> None:
from adafruit_hid import find_device

self._mouse_device = find_device(devices, usage_page=0x1, usage=0x02)
if not self._mouse_device:
raise ValueError("Could not find matching mouse HID device.")
self.report = bytearray(7)
self._pan_remainder = 0.0

def __str__(self):
return str(self._mouse_device)

def press(self, buttons: int) -> None:
self.report[0] |= buttons
self._send_no_move()

def release(self, buttons: int) -> None:
self.report[0] &= ~buttons
self._send_no_move()

def release_all(self) -> None:
self.report[0] = 0
self._send_no_move()

def move(self, x: int = 0, y: int = 0, wheel: int = 0, pan: float = 0) -> None:
pan_total = self._pan_remainder + pan
pan = int(pan_total)
self._pan_remainder = pan_total - pan
while x != 0 or y != 0 or wheel != 0 or pan != 0:
partial_x = _clamp_hid_i16(x)
partial_y = _clamp_hid_i16(y)
partial_wheel = _clamp_hid_i8(wheel)
partial_pan = _clamp_hid_i8(pan)
self.report[1:3] = partial_x.to_bytes(2, "little", signed=True)
self.report[3:5] = partial_y.to_bytes(2, "little", signed=True)
self.report[5] = partial_wheel & 0xFF
self.report[6] = partial_pan & 0xFF
self._mouse_device.send_report(self.report)
x -= partial_x
y -= partial_y
wheel -= partial_wheel
pan -= partial_pan

def _send_no_move(self) -> None:
self.report[1:7] = b"\x00" * 6
self._mouse_device.send_report(self.report)
3 changes: 2 additions & 1 deletion src/bluetooth_2_usb/gadget_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,8 @@ def rebuild_gadget(layout: GadgetLayout) -> tuple[GadgetHidDevice, ...]:
device_root.mkdir(parents=True, exist_ok=True)
_write_text(device_root / "protocol", str(device.protocol))
_write_text(device_root / "subclass", str(device.subclass))
_write_text(device_root / "report_length", str(device.in_report_lengths[0]))
report_length = device.configfs_report_length or device.in_report_lengths[0]
_write_text(device_root / "report_length", str(report_length))
(device_root / "report_desc").write_bytes(bytes(device.descriptor))
_maybe_write_wakeup_on_write(device_root, device.wakeup_on_write)
(config_root / f"hid.usb{device.function_index}").symlink_to(device_root)
Expand Down
121 changes: 114 additions & 7 deletions src/bluetooth_2_usb/hid_layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,89 @@

import usb_hid

DEFAULT_KEYBOARD_DESCRIPTOR = bytes.fromhex(
"05010906a101050719e029e715002501750195088102950175088101"
"95037501050819012903910295057501910195067508150026ff0005"
"0719002aff008100c0"
# Keep descriptor bytes in two-byte rows to match the Adafruit HID descriptor
# style and make HID item boundaries easier to review.
# fmt: off
DEFAULT_KEYBOARD_DESCRIPTOR = bytes(
(
0x05, 0x01, # Usage Page (Generic Desktop)
0x09, 0x06, # Usage (Keyboard)
0xA1, 0x01, # Collection (Application)
0x05, 0x07, # Usage Page (Keyboard)
0x19, 0xE0, # Usage Minimum (Keyboard LeftControl)
0x29, 0xE7, # Usage Maximum (Keyboard Right GUI)
0x15, 0x00, # Logical Minimum (0)
0x25, 0x01, # Logical Maximum (1)
0x75, 0x01, # Report Size (1)
0x95, 0x08, # Report Count (8)
0x81, 0x02, # Input (Data, Variable, Absolute)
0x95, 0x01, # Report Count (1)
0x75, 0x08, # Report Size (8)
0x81, 0x01, # Input (Constant)
0x95, 0x03, # Report Count (3)
0x75, 0x01, # Report Size (1)
0x05, 0x08, # Usage Page (LEDs)
0x19, 0x01, # Usage Minimum (Num Lock)
0x29, 0x03, # Usage Maximum (Scroll Lock)
0x91, 0x02, # Output (Data, Variable, Absolute)
0x95, 0x05, # Report Count (5)
0x75, 0x01, # Report Size (1)
0x91, 0x01, # Output (Constant)
0x95, 0x06, # Report Count (6)
0x75, 0x08, # Report Size (8)
0x15, 0x00, # Logical Minimum (0)
0x26, 0xFF, # Logical Maximum (255)
0x00, 0x05, # Logical Maximum continuation, Usage Page
0x07, 0x19, # Usage Page continuation, Usage Minimum
0x00, 0x2A, # Usage Minimum continuation, Usage Maximum
0xFF, 0x00, # Usage Maximum continuation
0x81, 0x00, # Input (Data, Array)
0xC0, # End Collection
)
)

DEFAULT_MOUSE_DESCRIPTOR = bytes(
(
0x05, 0x01, # Usage Page (Generic Desktop)
0x09, 0x02, # Usage (Mouse)
0xA1, 0x01, # Collection (Application)
0x09, 0x01, # Usage (Pointer)
0xA1, 0x00, # Collection (Physical)
0x05, 0x09, # Usage Page (Button)
0x19, 0x01, # Usage Minimum (Button 1)
0x29, 0x08, # Usage Maximum (Button 8)
0x15, 0x00, # Logical Minimum (0)
0x25, 0x01, # Logical Maximum (1)
0x95, 0x08, # Report Count (8)
0x75, 0x01, # Report Size (1)
0x81, 0x02, # Input (Data, Variable, Absolute)
0x05, 0x01, # Usage Page (Generic Desktop)
0x09, 0x30, # Usage (X)
0x09, 0x31, # Usage (Y)
0x16, 0x01, # Logical Minimum (-32767)
0x80, 0x26, # Logical Minimum continuation, Logical Maximum
0xFF, 0x7F, # Logical Maximum continuation (32767)
0x75, 0x10, # Report Size (16)
0x95, 0x02, # Report Count (2)
0x81, 0x06, # Input (Data, Variable, Relative)
0x09, 0x38, # Usage (Wheel)
0x15, 0x81, # Logical Minimum (-127)
0x25, 0x7F, # Logical Maximum (127)
0x75, 0x08, # Report Size (8)
0x95, 0x01, # Report Count (1)
0x81, 0x06, # Input (Data, Variable, Relative)
0x05, 0x0C, # Usage Page (Consumer)
0x0A, 0x38, # Usage (AC Pan)
0x02, 0x15, # Usage continuation, Logical Minimum
0x81, 0x25, # Logical Minimum continuation, Logical Maximum
0x7F, 0x75, # Logical Maximum continuation, Report Size
0x08, 0x95, # Report Size continuation, Report Count
0x01, 0x81, # Report Count continuation, Input
0x06, 0xC0, # Input continuation, End Collection
0xC0, # End Collection
)
)
# fmt: on


class GadgetHidDevice(usb_hid.Device):
Expand All @@ -27,6 +105,7 @@ def __init__(
function_index: int,
protocol: int,
subclass: int,
configfs_report_length: int | None = None,
wakeup_on_write: bool = False,
) -> None:
init_kwargs = {
Expand All @@ -51,6 +130,7 @@ def __init__(
self.function_index = function_index
self.protocol = protocol
self.subclass = subclass
self.configfs_report_length = configfs_report_length
self.wakeup_on_write = wakeup_on_write

@classmethod
Expand All @@ -63,6 +143,10 @@ def from_existing(
subclass: int,
descriptor: bytes | None = None,
name: str | None = None,
report_ids: Sequence[int] | None = None,
in_report_lengths: Sequence[int] | None = None,
out_report_lengths: Sequence[int] | None = None,
configfs_report_length: int | None = None,
wakeup_on_write: bool | None = None,
) -> GadgetHidDevice:
return cls(
Expand All @@ -71,13 +155,26 @@ def from_existing(
),
usage_page=base_device.usage_page,
usage=base_device.usage,
report_ids=tuple(base_device.report_ids),
in_report_lengths=tuple(base_device.in_report_lengths),
out_report_lengths=tuple(base_device.out_report_lengths),
report_ids=(
tuple(base_device.report_ids)
if report_ids is None
else tuple(report_ids)
),
in_report_lengths=(
tuple(base_device.in_report_lengths)
if in_report_lengths is None
else tuple(in_report_lengths)
),
out_report_lengths=(
tuple(base_device.out_report_lengths)
if out_report_lengths is None
else tuple(out_report_lengths)
),
name=base_device.name if name is None else name,
function_index=function_index,
protocol=protocol,
subclass=subclass,
configfs_report_length=configfs_report_length,
wakeup_on_write=(
getattr(base_device, "wakeup_on_write", False)
if wakeup_on_write is None
Expand Down Expand Up @@ -121,6 +218,16 @@ def build_default_layout() -> GadgetLayout:
function_index=1,
protocol=0,
subclass=0,
descriptor=DEFAULT_MOUSE_DESCRIPTOR,
name="mouse gadget",
report_ids=(0,),
in_report_lengths=(7,),
out_report_lengths=(0,),
# Keep the HID input report at 7 bytes, but make the configfs
# request size larger so each write is a short packet. On Pi
# dwc2 this avoids an extra empty interrupt-IN completion after
# every full-size mouse report.
configfs_report_length=8,
),
GadgetHidDevice.from_existing(
usb_hid.Device.CONSUMER_CONTROL,
Expand Down
Loading
Loading