Skip to content
Merged
Show file tree
Hide file tree
Changes from 30 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
41 changes: 40 additions & 1 deletion docs/host-relay-loopback.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ This validates the path:

`Pi virtual input device -> bluetooth_2_usb relay -> USB HID gadget -> host HID device`

By default, run only the lower-risk scenarios: `keyboard`, `mouse`,
`mouse_fast`, `combo`, and `consumer`. These avoid left, right, middle,
forward, back, and task mouse button down events. Run `mouse_buttons_intrusive`
only when you
intentionally want full live button-bit validation and can tolerate possible
host UI interaction.

## Preconditions

- the Pi is connected to the host through the OTG-capable data path
Expand Down Expand Up @@ -127,7 +134,39 @@ 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
side/extra mouse button bits press/release

For mouse wheel and horizontal wheel steps, the injector emits paired low-res
and high-res evdev events in the same `SYN_REPORT` frame. The host capture
expects the relay to emit one equivalent USB HID wheel or pan step.

The `mouse_fast` scenario emits large relative X/Y movement plus fast vertical
wheel and horizontal pan deltas that require multiple USB HID reports. Use it to
stress high-speed mouse movement and scrolling forwarding:

```bash
./scripts/loopback-capture.sh --scenario mouse_fast
sudo /opt/bluetooth_2_usb/scripts/loopback-inject.sh --scenario mouse_fast
```

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

To validate all eight button bits, run the explicit intrusive button scenario:

```bash
./scripts/loopback-capture.sh --scenario mouse_buttons_intrusive
sudo /opt/bluetooth_2_usb/scripts/loopback-inject.sh --scenario mouse_buttons_intrusive
```
Comment thread
quaxalber marked this conversation as resolved.

On Windows, Raw Input does not expose all eight extended mouse button bits used
by `mouse_buttons_intrusive`. The Windows capture backend rejects that scenario
with `EXIT_PREREQUISITE` when it includes `BTN_FORWARD`, `BTN_BACK`, or
`BTN_TASK`; use the default `mouse` or `combo` scenario there, or run intrusive
button validation on a backend that can surface every button bit.

## 4. Success criteria

Expand Down
41 changes: 26 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,22 @@ 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, float, float]:
input_event: InputEvent = event.event
x, y, mwheel = 0, 0, 0
x, y, mwheel, pan = 0, 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_WHEEL_HI_RES:
mwheel = input_event.value / 120.0
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.0
return x, y, mwheel, pan


@lru_cache(maxsize=1)
Expand All @@ -1295,16 +1307,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 +1432,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
87 changes: 87 additions & 0 deletions src/bluetooth_2_usb/extended_mouse.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
from __future__ import annotations

from .logging import get_logger

_logger = get_logger()


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._wheel_remainder = 0.0
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: float = 0, pan: float = 0) -> None:
wheel_total = self._wheel_remainder + wheel
wheel = int(wheel_total)
self._wheel_remainder = wheel_total - wheel
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
_logger.debug(
"Sending mouse movement to gadget: buttons=0x%02x x=%s y=%s "
"wheel=%s pan=%s report=%s",
self.report[0],
partial_x,
partial_y,
partial_wheel,
partial_pan,
self.report.hex(" "),
)
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
Loading
Loading