Skip to content

feat: add generic digitizer relay support#198

Draft
quaxalber wants to merge 6 commits into
stagingfrom
feat/generic-digitizers
Draft

feat: add generic digitizer relay support#198
quaxalber wants to merge 6 commits into
stagingfrom
feat/generic-digitizers

Conversation

@quaxalber
Copy link
Copy Markdown
Owner

@quaxalber quaxalber commented May 16, 2026

Summary

Adds generic HID digitizer relay support for touchpads/tablet-touch, tablet pens, and tablet pads while preserving the existing keyboard, mouse, and consumer-control relay behavior.

Key points:

  • Adds a combined digitizer HID function with touch, pen, and pad report IDs so the Pi stays within the observed hid.usb0 through hid.usb3 gadget limit.
  • Classifies source input devices into keyboard/mouse, touchpad/tablet-touch, tablet pen, and tablet pad profiles before dispatch.
  • Relays absolute touch, pen, and pad state through generic HID reports, including wrapped evdev EV_ABS/EV_MSC/EV_SYN events and BTN_TOUCH contact lifetime fallback.
  • Adds a digitizer loopback scenario that validates touch, pen, pad, and mouse reports together, including overlapping mouse/digitizer button codes.
  • Updates docs to describe generic digitizer support and the explicit non-goal for Windows Precision Touchpad feature reports.

Validation

Local:

  • venv/bin/black --check src tests
  • venv/bin/ruff check src tests
  • venv/bin/python -m compileall src tests
  • venv/bin/python -m unittest discover -s tests -v (467 tests)
  • venv/bin/python -m build --wheel
  • venv/bin/bluetooth_2_usb --help
  • venv/bin/bluetooth_2_usb --version
  • venv/bin/bluetooth_2_usb --validate-env || test $? -eq 3
  • venv/bin/yamllint .github/workflows/ci.yml

Pi/live:

  • Reinstalled branch build on pi4b.
  • sudo bluetooth_2_usb smoketest --verbose passed with the expected warning that no relayable physical input devices were attached.
  • Host enumerated 4 HID interfaces for serial pi4b, including digitizer interface 1-2.1.2:1.3.
  • loopback node-discovery passed.
  • loopback combo passed: 250 keyboard, 8 mouse rel, 16 mouse buttons, 26 consumer.
  • loopback digitizer passed: 2 mouse rel, 4 mouse buttons, 6 digitizer reports.
  • sudo bluetooth_2_usb debug --duration 10 completed and wrote /var/log/bluetooth_2_usb/debug_20260516_214019.md.
  • bluetoothctl show and btmgmt info succeeded.
  • Post-debug service state remained active, UDC remained configured, and host still saw 4 pi4b HID interfaces.

Summary by CodeRabbit

  • New Features

    • Added support for relaying Bluetooth touchpads and drawing tablet digitizers (pens, pads) to USB HID, enabling multitouch contacts, pressure sensitivity, tilt, and button input.
  • Documentation

    • Expanded README to document touchpad and tablet digitizer support.
    • Updated architecture and testing documentation to describe digitizer relay functionality, USB re-enumeration behavior, and device capture procedures.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 16, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8f536548-d220-47fc-b8ec-9a44b03bfa1f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/generic-digitizers

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread src/bluetooth_2_usb/gadgets/manager.py Outdated
Comment thread src/bluetooth_2_usb/hid/descriptors.py Outdated
Comment thread src/bluetooth_2_usb/hid/dispatch.py Outdated
Comment thread src/bluetooth_2_usb/hid/dispatch.py Outdated
Comment thread src/bluetooth_2_usb/hid/dispatch.py Outdated
Comment thread src/bluetooth_2_usb/hid/dispatch.py Outdated
Comment thread src/bluetooth_2_usb/hid/dispatch.py
Comment thread src/bluetooth_2_usb/hid/dispatch.py
Comment thread src/bluetooth_2_usb/hid/mouse_delta.py Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/bluetooth_2_usb/loopback/capture.py (1)

724-747: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Deduplicate multi-collection digitizers before checking candidate count.

The new HID function intentionally combines both touch and tablet top-level collections in a single USB interface. Since hidapi enumerates each collection as a separate entry, both will match as "digitizer" role and get appended to digitizer_nodes, causing the check at line 765-768 to fail with "Multiple digitizer HID devices matched" even though they're the same physical interface.

Deduplicate by raw_path or node before the length validation to handle this expected multi-collection scenario.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/bluetooth_2_usb/loopback/capture.py` around lines 724 - 747, The
digitizer_nodes list currently collects separate hidapi entries for different
top-level collections of the same physical interface; before performing the
candidate-count checks and before constructing GadgetNodeCandidates, deduplicate
digitizer_nodes by a stable identifier (preferably info.raw_path, falling back
to info.node) so multiple collections from the same device collapse to one
entry; replace the current digitizer_nodes list with the deduped sequence
(preserving order or sorting by node as done later) so the later validation that
raises "Multiple digitizer HID devices matched" and the
GadgetNodeCandidates(...) creation operate on unique devices.
🧹 Nitpick comments (1)
tests/test_gadgets.py (1)

142-153: ⚡ Quick win

Add failed-enable reset assertions for touch and tablet.

_enable_with_fakes() now initializes digitizer writers, but the rebuild-failure test still only verifies keyboard/mouse/consumer refs are cleared. Add touch/tablet assertions there to prevent stale digitizer writers after failed enable.

Suggested test addition
     self.assertIsNone(hid_gadgets.keyboard)
     self.assertIsNone(hid_gadgets.mouse)
     self.assertIsNone(hid_gadgets.consumer)
+    self.assertIsNone(hid_gadgets.touch)
+    self.assertIsNone(hid_gadgets.tablet)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_gadgets.py` around lines 142 - 153, The rebuild-failure test
doesn't assert that digitizer writers are cleared after a failed enable; update
the test that calls _enable_with_fakes to also verify that both touch and tablet
writer references are reset (cleared) on failure by adding assertions for the
touch and tablet objects (the same way keyboard/mouse/consumer refs are
checked), ensuring any stale _FakeDigitizer instances are not left behind;
locate the verification block in the rebuild-failure test that currently checks
keyboard/mouse/consumer and add analogous assertions for touch and tablet.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@README.md`:
- Line 160: The sentence "Touchpad behavior is not claimed; it needs required
HID feature-report handling" uses a double modal ("needs required"); update the
README sentence to use a single verb (e.g., "requires HID feature-report
handling" or "needs HID feature-report handling") so the phrasing is standard
and unambiguous—locate the sentence in README.md and replace the phrase "needs
required HID feature-report handling" accordingly.

In `@src/bluetooth_2_usb/gadgets/manager.py`:
- Around line 167-168: Touch and Tablet digitizers each create their own
asyncio.Lock but both write to the same HID digitizer device, causing
interleaved writes; update the gadget creation in Manager so both TouchDigitizer
and TabletDigitizer share a single asyncio.Lock instance (or refactor into a
combined Digitizer wrapper) by creating one lock (e.g., shared_digitizer_lock)
and passing it into the TouchDigitizer and TabletDigitizer constructors (or
using a combined class that serializes writes) so writes to the shared hidg node
are serialized and report ordering is preserved.

In `@src/bluetooth_2_usb/hid/absolute.py`:
- Around line 338-343: PadAccumulator currently leaves the internal _wheel value
latched across flushes and release_all calls, causing stale relative wheel
deltas to be resent; update both the release_all method and the flush logic to
clear self._wheel (set to 0) when emitting/resetting reports, and ensure the
returned PadReport uses wheel=0 so the accumulator no longer preserves stale
wheel state; reference the PadAccumulator class, its _wheel attribute, and the
release_all and flush methods when making the changes.

In `@src/bluetooth_2_usb/hid/dispatch.py`:
- Around line 3-5: The code currently maps BrokenPipeError to
_handle_broken_pipe() but lets OSError(ENODEV) fall through; update the
exception handling in dispatch.py so OSError with errno.ENODEV is treated the
same as BrokenPipeError (call _handle_broken_pipe() / suspend writes). Import
errno at top, and in the try/except blocks that handle BrokenPipeError (the
blocks around _handle_broken_pipe in dispatch.py, including the one covering the
dispatch loop at the ~253-266 region), add an except OSError as e: if e.errno ==
errno.ENODEV: return _handle_broken_pipe() (or otherwise invoke the same
suspension logic) and re-raise or handle other OSErrors unchanged.

In `@src/bluetooth_2_usb/loopback/result.py`:
- Around line 16-24: The to_dict method currently omits the "digitizer_node" key
when self.digitizer_node is None, causing inconsistent node shapes; change to
always include "digitizer_node" in the returned nodes mapping (just assign
nodes["digitizer_node"] = self.digitizer_node alongside keyboard_node,
mouse_node, consumer_node) and remove the conditional so the serialized dict
always contains "digitizer_node": None when absent.

In `@src/bluetooth_2_usb/loopback/scenarios.py`:
- Around line 333-342: SCENARIO_DIGITIZER is being exposed while Windows capture
(run_capture()/Raw Input backend) doesn't support digitizer reports yet; either
prevent registering/publishing SCENARIO_DIGITIZER on Windows or add a fast-fail
prerequisite in run_capture() when the requested scenario == SCENARIO_DIGITIZER
and platform is Windows. Locate SCENARIO_DIGITIZER (ScenarioDefinition) and the
run_capture() code path that dispatches scenarios to the Raw Input backend, and
implement a platform guard (e.g., if sys.platform == "win32" then skip
registration or raise a clear error) so invoking "loopback capture --scenario
digitizer" on Windows returns a descriptive prerequisite error instead of
exposing a broken scenario.

---

Outside diff comments:
In `@src/bluetooth_2_usb/loopback/capture.py`:
- Around line 724-747: The digitizer_nodes list currently collects separate
hidapi entries for different top-level collections of the same physical
interface; before performing the candidate-count checks and before constructing
GadgetNodeCandidates, deduplicate digitizer_nodes by a stable identifier
(preferably info.raw_path, falling back to info.node) so multiple collections
from the same device collapse to one entry; replace the current digitizer_nodes
list with the deduped sequence (preserving order or sorting by node as done
later) so the later validation that raises "Multiple digitizer HID devices
matched" and the GadgetNodeCandidates(...) creation operate on unique devices.

---

Nitpick comments:
In `@tests/test_gadgets.py`:
- Around line 142-153: The rebuild-failure test doesn't assert that digitizer
writers are cleared after a failed enable; update the test that calls
_enable_with_fakes to also verify that both touch and tablet writer references
are reset (cleared) on failure by adding assertions for the touch and tablet
objects (the same way keyboard/mouse/consumer refs are checked), ensuring any
stale _FakeDigitizer instances are not left behind; locate the verification
block in the rebuild-failure test that currently checks keyboard/mouse/consumer
and add analogous assertions for touch and tablet.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 269ecf77-08ed-4bb6-980d-6862ad989e3b

📥 Commits

Reviewing files that changed from the base of the PR and between d93ebae and 4dc494e.

📒 Files selected for processing (31)
  • README.md
  • docs/device-capture.md
  • docs/runtime-architecture.md
  • docs/testing-strategy.md
  • src/bluetooth_2_usb/evdev/__init__.py
  • src/bluetooth_2_usb/evdev/types.py
  • src/bluetooth_2_usb/gadgets/layout.py
  • src/bluetooth_2_usb/gadgets/manager.py
  • src/bluetooth_2_usb/hid/absolute.py
  • src/bluetooth_2_usb/hid/constants.py
  • src/bluetooth_2_usb/hid/consumer.py
  • src/bluetooth_2_usb/hid/descriptors.py
  • src/bluetooth_2_usb/hid/dispatch.py
  • src/bluetooth_2_usb/hid/keyboard.py
  • src/bluetooth_2_usb/hid/mouse.py
  • src/bluetooth_2_usb/hid/mouse_delta.py
  • src/bluetooth_2_usb/hid/tablet.py
  • src/bluetooth_2_usb/hid/touch.py
  • src/bluetooth_2_usb/inputs/inventory.py
  • src/bluetooth_2_usb/inputs/profile.py
  • src/bluetooth_2_usb/loopback/capture.py
  • src/bluetooth_2_usb/loopback/inject.py
  • src/bluetooth_2_usb/loopback/result.py
  • src/bluetooth_2_usb/loopback/scenarios.py
  • src/bluetooth_2_usb/relay/input.py
  • tests/test_evdev.py
  • tests/test_gadgets.py
  • tests/test_hid.py
  • tests/test_inputs.py
  • tests/test_loopback.py
  • tests/test_relay.py
💤 Files with no reviewable changes (1)
  • src/bluetooth_2_usb/hid/mouse_delta.py

Comment thread README.md Outdated
Comment thread src/bluetooth_2_usb/gadgets/manager.py Outdated
Comment thread src/bluetooth_2_usb/hid/absolute.py
Comment thread src/bluetooth_2_usb/hid/dispatch.py
Comment thread src/bluetooth_2_usb/loopback/result.py
Comment on lines +333 to +342
SCENARIO_DIGITIZER: ScenarioDefinition(
name=SCENARIO_DIGITIZER,
keyboard_steps=(),
mouse_rel_steps=DIGITIZER_MOUSE_REL_STEPS,
mouse_button_steps=DIGITIZER_MOUSE_BUTTON_STEPS,
consumer_steps=(),
digitizer_report_ids=(1, 1, 2, 2, 3, 3),
default_post_delay_ms=DIGITIZER_POST_DELAY_MS,
default_capture_timeout_sec=DIGITIZER_CAPTURE_TIMEOUT_SEC,
),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid publishing the digitizer scenario on Windows before the backend can capture it.

SCENARIO_DIGITIZER is now public, but this slice only adds digitizer matching to the hidapi capture path. run_capture() still sends every win32 scenario through the Raw Input backend, so loopback capture --scenario digitizer is exposed without a working Windows capture implementation. Gate this scenario by platform or fail fast with a prerequisite error until the Windows backend understands digitizer reports.

🧰 Tools
🪛 GitHub Actions: CI / validate

[warning] dwc2 module not present in your kernel. did you insmod it?


[warning] libcomposite module not present in your kernel. did you insmod it?


[warning] Bluetooth rfkill rfkill0 is hard-blocked; leaving it unchanged.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/bluetooth_2_usb/loopback/scenarios.py` around lines 333 - 342,
SCENARIO_DIGITIZER is being exposed while Windows capture (run_capture()/Raw
Input backend) doesn't support digitizer reports yet; either prevent
registering/publishing SCENARIO_DIGITIZER on Windows or add a fast-fail
prerequisite in run_capture() when the requested scenario == SCENARIO_DIGITIZER
and platform is Windows. Locate SCENARIO_DIGITIZER (ScenarioDefinition) and the
run_capture() code path that dispatches scenarios to the Raw Input backend, and
implement a platform guard (e.g., if sys.platform == "win32" then skip
registration or raise a clear error) so invoking "loopback capture --scenario
digitizer" on Windows returns a descriptive prerequisite error instead of
exposing a broken scenario.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/bluetooth_2_usb/hid/tablet.py`:
- Around line 71-88: release_all() currently calls send_pen() and send_pad()
separately but each of those releases _report_lock, allowing interleaving; to
fix, acquire the shared async lock (_report_lock) once inside release_all (e.g.,
async with self._report_lock or explicit acquire()/release()) and call
send_pen(...) and send_pad(...) while holding that lock, then release it only
after both reports are sent so no other digitizer task can interleave between
the two reports.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1186ed94-e326-4c7e-9a3a-f8b44d5fee84

📥 Commits

Reviewing files that changed from the base of the PR and between 4dc494e and dd8ab05.

📒 Files selected for processing (13)
  • README.md
  • src/bluetooth_2_usb/gadgets/manager.py
  • src/bluetooth_2_usb/hid/absolute.py
  • src/bluetooth_2_usb/hid/dispatch.py
  • src/bluetooth_2_usb/hid/tablet.py
  • src/bluetooth_2_usb/hid/touch.py
  • src/bluetooth_2_usb/loopback/capture.py
  • src/bluetooth_2_usb/loopback/capture_windows.py
  • src/bluetooth_2_usb/loopback/result.py
  • tests/test_gadgets.py
  • tests/test_hid.py
  • tests/test_loopback.py
  • tests/test_relay.py
🚧 Files skipped from review as they are similar to previous changes (8)
  • src/bluetooth_2_usb/loopback/result.py
  • src/bluetooth_2_usb/gadgets/manager.py
  • src/bluetooth_2_usb/hid/dispatch.py
  • src/bluetooth_2_usb/hid/absolute.py
  • tests/test_gadgets.py
  • src/bluetooth_2_usb/hid/touch.py
  • src/bluetooth_2_usb/loopback/capture.py
  • tests/test_hid.py

Comment thread src/bluetooth_2_usb/hid/tablet.py Outdated
Comment on lines +71 to +88
async def release_all(self) -> None:
await self.send_pen(
PenReport(
in_range=False,
tip=False,
eraser=False,
barrel=False,
barrel2=False,
x=0,
y=0,
pressure=0,
distance=0,
tilt_x=0,
tilt_y=0,
serial=0,
)
)
await self.send_pad(PadReport(buttons=0, wheel=0))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep release_all() atomic on the shared report lock.

send_pen() and send_pad() each release _report_lock before the next call, so another digitizer task can slip a report between the two releases. During disconnect cleanup that can leave stale pad state pressed until the queue drains; hold the shared lock across both release reports.

🔒 Proposed fix
+    async def _send_pen_unlocked(self, pen_report: PenReport) -> None:
+        report = bytearray(TABLET_PEN_IN_REPORT_LENGTH)
+        report[0] = (
+            (0x01 if pen_report.in_range else 0)
+            | (0x02 if pen_report.tip else 0)
+            | (0x04 if pen_report.eraser else 0)
+            | (0x08 if pen_report.barrel else 0)
+            | (0x10 if pen_report.barrel2 else 0)
+        )
+        report[1:3] = pen_report.x.to_bytes(2, "little")
+        report[3:5] = pen_report.y.to_bytes(2, "little")
+        report[5:7] = pen_report.pressure.to_bytes(2, "little")
+        report[7:9] = pen_report.distance.to_bytes(2, "little")
+        report[9] = pen_report.tilt_x.to_bytes(1, "little", signed=True)[0]
+        report[10] = pen_report.tilt_y.to_bytes(1, "little", signed=True)[0]
+        report[11:15] = pen_report.serial.to_bytes(4, "little")
+        logger.debug("Sending tablet pen report: %s", report.hex(" "))
+        await self._send_report(report, TABLET_PEN_REPORT_ID)
+
+    async def _send_pad_unlocked(self, pad_report: PadReport) -> None:
+        report = bytearray(TABLET_PAD_IN_REPORT_LENGTH)
+        report[0:2] = pad_report.buttons.to_bytes(2, "little")
+        report[2] = pad_report.wheel.to_bytes(1, "little", signed=True)[0]
+        logger.debug("Sending tablet pad report: %s", report.hex(" "))
+        await self._send_report(report, TABLET_PAD_REPORT_ID)
+
     async def send_pen(self, pen_report: PenReport) -> None:
         async with self._report_lock:
-            report = bytearray(TABLET_PEN_IN_REPORT_LENGTH)
-            ...
-            await self._send_report(report, TABLET_PEN_REPORT_ID)
+            await self._send_pen_unlocked(pen_report)

     async def send_pad(self, pad_report: PadReport) -> None:
         async with self._report_lock:
-            report = bytearray(TABLET_PAD_IN_REPORT_LENGTH)
-            ...
-            await self._send_report(report, TABLET_PAD_REPORT_ID)
+            await self._send_pad_unlocked(pad_report)

     async def release_all(self) -> None:
-        await self.send_pen(...)
-        await self.send_pad(...)
+        async with self._report_lock:
+            await self._send_pen_unlocked(...)
+            await self._send_pad_unlocked(...)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/bluetooth_2_usb/hid/tablet.py` around lines 71 - 88, release_all()
currently calls send_pen() and send_pad() separately but each of those releases
_report_lock, allowing interleaving; to fix, acquire the shared async lock
(_report_lock) once inside release_all (e.g., async with self._report_lock or
explicit acquire()/release()) and call send_pen(...) and send_pad(...) while
holding that lock, then release it only after both reports are sent so no other
digitizer task can interleave between the two reports.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant