From c7c1f9635d7a2c2866574177ce63a64a1cf133de Mon Sep 17 00:00:00 2001 From: Keegan Ott Date: Mon, 11 May 2026 10:26:56 +0800 Subject: [PATCH 01/10] Introduce DeviceProfile abstraction for multi-device support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure refactor — extract reMarkable 2 hardware constants into a DeviceProfile record. Behavior is unchanged on rM2; all existing tests pass without value adjustments. The pipeline now reads device-specific facts (event-struct layout, device-node paths, pen/touch axis ranges, max tracked contacts) from a profile instead of a static class. Host-target scale (Windows Ink pressure 0-1024, tilt +/-90) moves to InjectionScale because it is not device-specific. EvdevParser is parameterised by EvdevLayout: 32-bit ARM (rM2) uses the 16-byte input_event with HHi at offsets 8/10/12; 64-bit ARM uses 24-byte input_event with HHi at offsets 16/18/20. Adds regression tests for the 24-byte path so the rMPP desync signature reported in Evidlo/remarkable_mouse Issue #92 cannot land here. Phase 1 of docs/IMPLEMENTATION_PLAN_RMPP.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/FEASIBILITY_REMARKABLE_PRO.md | 218 +++++++++++++++ docs/IMPLEMENTATION_PLAN_RMPP.md | 253 ++++++++++++++++++ src/RemarkableTablet.App/App.xaml.cs | 10 +- src/RemarkableTablet.Cli/Program.cs | 12 +- .../Devices/DeviceProfile.cs | 66 +++++ .../Devices/ReMarkable2Profile.cs | 38 +++ .../Evdev/EvdevParser.cs | 44 +-- .../Mapping/CoordinateMapper.cs | 25 +- .../Mapping/TouchCoordinateMapper.cs | 15 +- .../Output/InjectionScale.cs | 15 ++ .../Pipeline/TabletPipeline.cs | 20 +- .../Tablet/ReMarkable2Constants.cs | 67 ----- .../Transport/SshTransport.cs | 13 - .../Output/UinputTouchOutput.cs | 23 +- .../Output/WindowsTouchInjectionOutput.cs | 11 +- .../CoordinateMapperTests.cs | 27 +- .../EvdevParserTests.cs | 91 +++++-- .../TouchCoordinateMapperTests.cs | 28 +- tools/EventDiagnostics/Program.cs | 5 +- 19 files changed, 793 insertions(+), 188 deletions(-) create mode 100644 docs/FEASIBILITY_REMARKABLE_PRO.md create mode 100644 docs/IMPLEMENTATION_PLAN_RMPP.md create mode 100644 src/RemarkableTablet.Core/Devices/DeviceProfile.cs create mode 100644 src/RemarkableTablet.Core/Devices/ReMarkable2Profile.cs create mode 100644 src/RemarkableTablet.Core/Output/InjectionScale.cs delete mode 100644 src/RemarkableTablet.Core/Tablet/ReMarkable2Constants.cs diff --git a/docs/FEASIBILITY_REMARKABLE_PRO.md b/docs/FEASIBILITY_REMARKABLE_PRO.md new file mode 100644 index 0000000..b22c6ab --- /dev/null +++ b/docs/FEASIBILITY_REMARKABLE_PRO.md @@ -0,0 +1,218 @@ +# Feasibility — reMarkable Paper Pro (rMPP) support + +**Date:** 2026-05-11 +**Verdict:** Feasible. The architecture of this tool (SSH → evdev stream → state machine → host-side pointer injection) carries over cleanly. The pen, touchscreen, and root SSH all exist on the rMPP. What changes is mostly numbers — a different CPU bitness, a different display size, different axis ranges — and the community has already mapped most of those numbers in `Evidlo/remarkable_mouse`'s `rmpro` branch (see §2 below), so empirical bring-up is verification, not discovery. + +No host-side change is needed (Win32 / uinput injection is device-agnostic). All the work lives in `RemarkableTablet.Core`, gated behind a device-profile abstraction. + +The polish work that usually dominates a port — reconnect with exponential backoff, pre-reconnect synthetic pen-up / touch-release, pump-task teardown ordering, bounded channels with `DropOldest` backpressure, real touch contact injection on both platforms, pressure-curve shaping — is **already in place** for rM2 and is not pen-or-tablet-specific. The rMPP scope is essentially: parameterize the three things that are currently hard-coded to rM2 (event-struct size, device-node paths, axis ranges) plus one small uinput correctness fix (§3.4) that improves rM2 as well. + +## Prior art (peer projects from `reHackable/awesome-reMarkable`) + +| Project | License | Relevance | +|---|---|---| +| [`Evidlo/remarkable_mouse`](https://github.com/Evidlo/remarkable_mouse) | GPL-3.0 | **Highest value.** The `rmpro` branch (Issue #92) parameterises event-struct size per model and has captured rMPP pen/touch device paths and axis ranges. We use these as a starting baseline. | +| [`DCS-87/remarkable_mouse_winpress`](https://github.com/DCS-87/remarkable_mouse_winpress) | GPL-3.0 | Our README's named predecessor. The Windows Ink injection call sequence — `CreateSyntheticPointerDevice(PT_PEN,1,1)` + `InjectSyntheticPointerInput`, with `PEN_MASK_PRESSURE\|TILT_X\|TILT_Y` — matches what we already do. No rMPP support. | +| [`FreeCap23/reMarkable-tablet-driver`](https://github.com/FreeCap23/reMarkable-tablet-driver) | GPL-2.0, archived 2026 | C/`libssh` rewrite of `LinusCDE/rmTabletDriver`. Hard-codes 16-byte event struct — useful as a counterexample of what *not* to do, plus a clean uinput axis/resolution tuple for the rM2 baseline. | +| [`asivery/rm-appload`](https://github.com/asivery/rm-appload) + XOVI | GPL-3.0 | Architecturally different — runs **on the device**. A v2 alternative if SSH-based streaming becomes maintenance-painful (see §7). | + +**License implication:** the most useful peer projects are GPL-3.0; this project is MIT. We **cannot lift code**. But constants (axis ranges, device paths, struct layouts) and documented Windows API call sequences are facts, not copyrightable expression. We re-implement using these facts as a guide. + +--- + +## 1. What's the same + +| Concern | rM2 | rMPP | Impact | +|---|---|---|---| +| OS | Codex Linux | Codex Linux | None | +| USB-Ethernet address | `10.11.99.1` | `10.11.99.1` | None | +| Root SSH | Yes, password from Settings → Help → Copyrights | Yes, but requires **Developer Mode** toggled on first (Settings → General → Paper Tablet → Software → Advanced) | Doc change + UX hint in the GUI | +| Input subsystem | Linux evdev, `/dev/input/eventN` | Linux evdev | None — same `EV_ABS` / `EV_KEY` / `EV_SYN` semantics | +| Pen reports | Pressure, tilt, distance, hover, eraser, barrel buttons | Same set advertised (12-bit / 4096 pressure levels confirmed) | None at the state-machine level | +| Touchscreen protocol | MT-B (slots + `ABS_MT_TRACKING_ID`) | MT-B (community pattern; unverified on rMPP) | Likely none | +| Pipeline shape | SSH stream → parser → SM → mapper → output | Same | Reuse `RemarkableTablet.Core` end-to-end | + +`RemarkableTablet.Windows` and `RemarkableTablet.Linux` need **zero changes** — they inject pointer/touch events to the host OS and do not care which tablet produced them. + +--- + +## 2. What's different — the breaking changes + +### 2.1 CPU is 64-bit (the single biggest landmine) + +rM2 is armv7l (32-bit, i.MX7D). rMPP is aarch64 (NXP i.MX 8MM "ferrari", Cortex-A53 ×4, kernel `linux-imx-rm` built `ARCH=arm64`). + +**Consequence:** `struct input_event` is **24 bytes** on 64-bit Linux, not 16. The layout becomes: + +``` +[0..7] long tv_sec (was uint32) +[8..15] long tv_usec (was uint32) +[16..17] uint16 type +[18..19] uint16 code +[20..23] int32 value +``` + +This breaks **every** assumption in `EvdevParser` and `ReMarkable2Constants.EventStructSize`. `src/RemarkableTablet.Core/Evdev/EvdevParser.cs:21` hard-codes `EventSize = 16` and reads `type`/`code`/`value` from offsets 8/10/12. None of those offsets are right on the rMPP. + +If you stream a 64-bit event log through the rM2 parser, every frame is interpreted as garbage — but it doesn't crash; it just produces nonsensical absolute coordinates and pressure. A reader expecting 16-byte frames will desync silently and look like "the pen jitters wildly." + +This is **empirically confirmed** in `Evidlo/remarkable_mouse` Issue #92: users running the rM2 codepath on rMPP report `KeyError: 61892` from the evdev decoder, which is exactly what happens when 16-byte slicing misaligns over a 24-byte stream — high bytes of the next frame's seconds field land in the `code` slot. Evidlo's `rmpro` branch fixes it by switching the unpack format from `'2IHHi'` (16 B) to `'I4xI4xHHi'` (24 B with pad-skip). Our equivalent fix is to make `EvdevParser.EventSize` and its field offsets parameters of the `DeviceProfile`. + +### 2.2 Pen technology changed — community-mapped, not yet locally verified + +The rMPP's "Marker Plus" is an **active, battery-powered** stylus (inductively charged). It is **not** Wacom EMR — old reMarkable / LAMY EMR pens do not work on the rMPP. + +Constants from Evidlo's `rmpro` branch (`remarkable_mouse/codes.py`, captured by the maintainer with community help via Issue #92): + +| | rM2 | rMPP | +|---|---|---| +| Pen device path | `/dev/input/event1` | **`/dev/input/event2`** | +| Touch device path | `/dev/input/event2` | **`/dev/input/event3`** | +| Buttons device path | n/a in current pipeline | `/dev/input/event0` (power) + `event1` (pen attach/detach) | +| ABS_X range | 0 – 20966 | **0 – 11180**, resolution 2832 | +| ABS_Y range | 0 – 15725 | **0 – 15340**, resolution 2064 | +| Pen pressure | 0 – 4095 | **0 – 4096** (off-by-one) | + +The very different resolution values on rMPP (`2832` / `2064` vs. rM2's `100`) imply the axes are reported in higher-density units; the geometry rotation that bit us on rM2 must be re-verified empirically. **Tilt range and distance range are not in the public `rmpro` constants** — these still need an on-device `evtest` dump. + +A robust device-node discovery should not hard-code these paths. Evidlo's trick — `readlink -f /dev/input/touchscreen0` over SSH — works on rM1, rM2, and rMPP and survives firmware reshuffles. The pen and button nodes can be resolved similarly by walking `/proc/bus/input/devices` and matching by `Name=` line. + +### 2.3 Display geometry + +| | rM2 | rMPP | +|---|---|---| +| Display | 1404 × 1872 mono | **1620 × 2160** color (E Ink Gallery 3) | +| Aspect | 3:4 | 3:4 (same) | +| PPI | 226 | 229 | + +The touchscreen coordinate range will almost certainly match the display (rM2 `pt_mt` does), so `TouchXMax`/`TouchYMax` must move. Pen-digitizer coordinates may or may not align to display pixels — rM2's pen uses its own much larger raw range (20966 × 15725), independent of display. + +### 2.4 Pen-suppression behavior + +Whether the rMPP suppresses touch while pen is in proximity (the rM2 hardware-level behavior the README and memory call out) is **unverified**. reMarkable markets "palm rejection" but doesn't specify mechanism. If the rMPP filters palm in software *but still emits touch events when the pen is hovering*, the touch pipeline needs a host-side `PenToolGate` after all — exactly the thing we deleted as unnecessary for the rM2. + +### 2.5 Security posture (informational) + +- Disk encryption stays on even in Developer Mode. +- Secure boot enforces signed bootloader/kernel/rootfs. You cannot drop in custom kernel modules without reMarkable's signing key. +- New overlay filesystem layout in OS 3.x. + +None of this blocks an input-streaming tool — we only `cat` files we don't own — but it kills any future idea that would have required custom kernel modules or rootfs writes. + +--- + +## 3. Required code changes + +Everything fits in `RemarkableTablet.Core`. Estimated scope below. + +### 3.1 Introduce a `DeviceProfile` abstraction (mandatory) + +Replace `ReMarkable2Constants` static class with a `DeviceProfile` record carrying: + +- Event struct size (16 vs. 24) +- Field offsets for type/code/value (8/10/12 vs. 16/18/20) +- Pen device path, touch device path +- Pen X/Y/pressure/tilt/distance ranges +- Touch X/Y/pressure ranges, max slots +- Display dimensions (for touch mapping) +- Optional pen-priority quirk flag + +Two concrete profiles: `ReMarkable2Profile`, `ReMarkablePaperProProfile`. Selection by: + +1. CLI flag `--device rm2|rmpp` (explicit) +2. Auto-detect by SSH'ing `uname -m` and `cat /proc/bus/input/devices` once at connect time (preferred — zero user friction) + +**Files that change:** +- `src/RemarkableTablet.Core/Tablet/ReMarkable2Constants.cs` → split into profile + interface (rename or keep as `RM2` profile impl) +- `src/RemarkableTablet.Core/Evdev/EvdevParser.cs` — accept event size + offsets from the profile, drop the `const int EventSize = 16` +- `src/RemarkableTablet.Core/Mapping/CoordinateMapper.cs` — read `PenXMax`/`PenYMax` from profile instead of `ReMarkable2Constants` +- `src/RemarkableTablet.Core/Mapping/TouchCoordinateMapper.cs` — same for touch +- `src/RemarkableTablet.Core/Pipeline/TabletPipeline.cs` — plumb the profile through +- `src/RemarkableTablet.Core/Transport/SshTransport.cs:70` — device path lookup via profile instead of `ReMarkable2Constants.PenDevicePath` +- CLI flag in `src/RemarkableTablet.Cli/Program.cs` +- Settings dropdown in `src/RemarkableTablet.App/SettingsWindow.xaml.cs` + persistence in `AppSettings.cs` + +### 3.2 Variable-size evdev parsing + +`EvdevParser.RunAsync` currently slices `EventSize` (= 16) at a time. Make `EventSize` a parameter and adjust the three `BinaryPrimitives.Read*` offsets accordingly. Add a unit test that feeds a hand-crafted 24-byte aarch64 frame and asserts the decoded `(type, code, value)`. + +### 3.4 Set `input_absinfo.resolution` on the uinput pen device (rM2 fix, applies to rMPP too) + +`UinputOutput.SetAxis` (`src/RemarkableTablet.Linux/Output/UinputOutput.cs:63-67`) currently passes `min, max, fuzz, flat` only — `input_absinfo.resolution` defaults to 0. `FreeCap23/reMarkable-tablet-driver` exists as a fork of `LinusCDE/rmTabletDriver` specifically to set `resolution = 100` on ABS_X/Y so `libinput` (and therefore every Wayland compositor) recognizes the virtual device as a tablet rather than a generic absolute-axis device. + +Fix: add a `resolution` parameter to `SetAxis`, extend `uinput_abs_setup` / `input_absinfo` interop to include the field, and populate it from the device profile (rM2 = 100 per FreeCap23; rMPP would use the per-axis values from Evidlo's `rmpro` constants — 2832 on X, 2064 on Y). This is a one-line interop change plus profile data; it benefits rM2 Wayland users immediately and is required for rMPP from day one. + +### 3.5 Auto-detection helper (optional but worth the small cost) + +A one-shot SSH command on connect: + +```sh +uname -m && ls /dev/input/ && cat /proc/bus/input/devices +``` + +`armv7l` → rM2 profile. `aarch64` → rMPP profile, then walk `/proc/bus/input/devices` to map the pen and touch device nodes by driver name rather than hard-coding `event1`/`event2` (rMPP node indices are not guaranteed to match rM2's). + +This also future-proofs against firmware updates that reshuffle device-node ordering on either device. + +### 3.4 Documentation + +- README quickstart: note Developer Mode requirement on rMPP, link to reMarkable's official article. +- New hardware-details section for rMPP mirroring the existing rM2 one, populated from the on-device `evtest` capture. +- `tools/EventDiagnostics` already does what we need to capture that data — just point it at the rMPP. + +--- + +## 4. Empirical work required before coding + +Most baseline numbers exist in Evidlo's `rmpro` branch (§2.2). The remaining work on real hardware is verification + filling tilt/distance gaps: + +1. **Enable Developer Mode**, obtain root password, confirm SSH at `10.11.99.1`. +2. **Run `evtest` on `/dev/input/event2` (pen) and `event3` (touch)**. Save dumps in `tools/EventDiagnostics/samples/rmpp/`. Cross-check axis ranges against the Evidlo constants in §2.2 — any divergence means our profile values need adjustment, or there are firmware variants to handle. +3. **Capture tilt + distance ranges** for the rMPP pen — not in the community data. Tilt the pen end-to-end across both axes while `evtest` is running; the absmin/absmax reported by `evtest -i` in the device header is what we want. +4. **Confirm event-struct size** with `dd if=/dev/input/event2 bs=24 count=1 | xxd` — the first 8 bytes should be a plausible Unix timestamp (`tv_sec` as a 64-bit little-endian value). If those bytes look like a timestamp the layout is 24-byte; if they look like one timestamp followed by another value, layout differs. +5. **Test pen-suppression**: stream pen + touch simultaneously, hover the pen, watch whether touch events keep flowing. This determines whether we need to bring back the deleted host-side `PenToolGate`. +6. **Confirm touchscreen orientation** matches expectations using the same "touch panel is `INPUT_PROP_DIRECT` ground truth" method we used on rM2. + +Steps 2–6 together are ~30 minutes of hands-on work. + +--- + +## 5. Effort estimate + +Assuming step-4 hardware data in hand: + +| Work | Estimate | +|---|---| +| `DeviceProfile` abstraction + plumb-through | 0.5 day | +| Variable-size evdev parsing + unit tests | 0.5 day | +| `input_absinfo.resolution` field + per-profile values | 0.25 day | +| Auto-detection by `uname -m` + `/proc/bus/input/devices` walk | 0.5 day | +| CLI flag + GUI dropdown + settings persistence | 0.5 day | +| README + rMPP hardware-details doc | 0.5 day | +| Real-hardware bring-up, calibration, regression-test rM2 | 1 day | +| **Total** | **~3.75 days** | + +No work in `RemarkableTablet.Windows` or `RemarkableTablet.Linux`. No new dependencies. + +--- + +## 6. Risks + +- **Touchscreen coordinate orientation may differ from rM2.** The rM2 axis-rotation bug was a 2-day debug; budget contingency for rediscovering the same on rMPP. `tools/EventDiagnostics` plus the already-validated touchscreen-is-ground-truth method (the touch panel is `INPUT_PROP_DIRECT`, so its mapping is empirically known) makes this tractable. +- **Pen-suppression might not hold.** If the rMPP emits touch while the pen hovers, we need a host-side `PenToolGate` (deleted on rM2 as unnecessary). Implementable but adds a day. +- **Pen barrel-button / eraser semantics.** The Marker Plus has different physical inputs than the rM2 Marker Plus. The state machine handles `BTN_TOOL_PEN/RUBBER/STYLUS/STYLUS2` — if the rMPP uses a different code (e.g. `BTN_TOOL_PENCIL`), trivial to add, but worth checking in the `evtest` capture. +- **Firmware drift.** reMarkable has changed axis conventions across firmware versions on the rM2 already. Hard-coded ranges per device profile are a maintenance trap; using values pulled from `/proc/bus/input/devices` at connect time would be more robust but is meaningfully more code. Profile-with-constants is the right tradeoff for v1; add runtime discovery later if it bites. + +--- + +## 7. Recommendation + +Proceed in two phases: + +**Phase 0 (no code):** Acquire / borrow a Paper Pro, run the six empirical steps above. Most numbers are already drafted in §2.2 from community work; bring-up reduces to confirming them, capturing tilt/distance, and validating orientation. + +**Phase 1 (the 3.5-day build):** `DeviceProfile` abstraction + variable-size parser + auto-detection + UX, regression-test rM2, ship a v0.4.0. + +**Future option — on-device daemon (v2 architecture).** `asivery/rm-appload` + XOVI on the rMPP runs arbitrary userland code, including a Unix-socket / TCP sender that reads `/dev/input/event*` natively and ships a fixed normalised wire format to the host. This sidesteps both the `sizeof(time_t)` ambiguity (the daemon, compiled for the device, sees the real `struct input_event` natively) and SSH handshake latency, and gets cleaner reconnect via TCP keepalive. Downsides: sideloading via XOVI is a moving target each firmware update, and we'd be coupled to asivery's toolchain. Right call is to keep SSH as primary, prototype on-device only if SSH overhead or maintenance pain shows up. Don't pre-build it. + +The codebase is already factored well for this — `ReMarkable2Constants` is the only place hardware specifics live, the host outputs are device-agnostic, and `tools/EventDiagnostics` is exactly the bring-up tool we need. The single highest-leverage change is making the evdev parser size-parameterized; everything else is straight mechanical work behind it. diff --git a/docs/IMPLEMENTATION_PLAN_RMPP.md b/docs/IMPLEMENTATION_PLAN_RMPP.md new file mode 100644 index 0000000..9733f4f --- /dev/null +++ b/docs/IMPLEMENTATION_PLAN_RMPP.md @@ -0,0 +1,253 @@ +# Implementation Plan — reMarkable Paper Pro Support + +**Status:** Draft, ready to execute +**Companion docs:** `docs/FEASIBILITY_REMARKABLE_PRO.md` (verdict, prior art, constants); project memory `reference_rmpp_prior_art.md` (Evidlo `rmpro` source-of-truth) +**Target release:** v0.4.0 +**Total effort estimate:** ~3.75 days of code + hardware bring-up time + +--- + +## Goals + +1. The tool runs on a reMarkable Paper Pro with the same UX as on an rM2 (CLI + GUI, pen + touch, all output modes). +2. rM2 behavior is bit-for-bit unchanged. No regressions. +3. Hardware specifics live in **one** place (`DeviceProfile`); adding a hypothetical rM3 / Paper Pro Move later is a single new profile file. +4. The Linux uinput device gets `input_absinfo.resolution` populated correctly — fixes a latent Wayland/libinput issue on rM2 too. + +## Non-goals + +- On-device daemon architecture (XOVI / rm-appload). Future v2 option. +- Pen attach/detach event handling (rMPP `event1`). Not needed for input streaming; defer. +- Power button event handling (rMPP `event0`). Out of scope. +- Encryption-at-rest concerns. We only `cat` files, we don't write to the device. + +--- + +## Phase 0 — Hardware bring-up (~30 min, gates Phase 5) + +Run on a real rMPP. Capture data into `tools/EventDiagnostics/samples/rmpp/`. Does **not** block code work in Phases 1–4. + +1. Enable Developer Mode (Settings → General → Paper Tablet → Software → Advanced); confirm SSH at `10.11.99.1` works with the password from Help → Copyrights. +2. Run `evtest -i /dev/input/event2` (pen) and `event3` (touch). Save device headers to disk. +3. Capture pen axis ranges including **tilt and distance** (not in Evidlo's public constants). Tilt the pen end-to-end on both axes while `evtest` is running. +4. Confirm event-struct size: `dd if=/dev/input/event2 bs=24 count=1 | xxd`. First 8 bytes should be a plausible 64-bit Unix timestamp. +5. Pen-suppression test: open `evtest` on event2 and event3 simultaneously, hover the pen, watch whether touch events keep flowing. Records whether `PenToolGate` needs resurrecting (deleted as unnecessary on rM2; may be required on rMPP). +6. Touchscreen orientation test: press top-left corner, confirm reported coordinates are near (0,0). Mirrors the rM2 verification method. + +**Gate:** Phase 5 cannot ship until Phase 0 data either confirms the Evidlo constants in `ReMarkablePaperProProfile` or replaces them with locally-captured values. + +--- + +## Phase 1 — `DeviceProfile` foundation (~0.5 day, no behavior change) + +Pure refactor. The rM2 profile is extracted from existing constants; nothing about behavior changes. Verifiable purely by running the existing test suite + manual rM2 smoke test. + +### New types + +`src/RemarkableTablet.Core/Devices/DeviceProfile.cs` (new): + +```csharp +public sealed record DeviceProfile +{ + public required string Name { get; init; } // "reMarkable 2", "reMarkable Paper Pro" + public required EvdevLayout EventLayout { get; init; } // 16- or 24-byte struct + public required string PenDevicePath { get; init; } + public required string TouchDevicePath { get; init; } + public required PenAxes Pen { get; init; } + public required TouchAxes Touch { get; init; } + public bool PenSuppressesTouch { get; init; } // rM2 = true; rMPP = TBD +} + +public sealed record EvdevLayout(int StructSize, int TypeOffset, int CodeOffset, int ValueOffset) +{ + public static EvdevLayout Bits32 => new(16, 8, 10, 12); // armv7l: 4+4+2+2+4 + public static EvdevLayout Bits64 => new(24, 16, 18, 20); // aarch64: 8+8+2+2+4 +} + +public sealed record PenAxes( + int XMin, int XMax, int XResolution, // resolution: ticks per mm; 0 = not set + int YMin, int YMax, int YResolution, + int PressureMin, int PressureMax, + int TiltXMin, int TiltXMax, + int TiltYMin, int TiltYMax, + int DistanceMin, int DistanceMax); + +public sealed record TouchAxes( + int XMin, int XMax, + int YMin, int YMax, + int PressureMin, int PressureMax, + int MaxSlots, int MaxTracked); +``` + +`src/RemarkableTablet.Core/Devices/ReMarkable2Profile.cs` (new): single static instance populated verbatim from current `ReMarkable2Constants`. **No new values, no behavior change.** + +### Refactors + +- `src/RemarkableTablet.Core/Evdev/EvdevParser.cs:21` — drop `const int EventSize = 16`; accept `EvdevLayout` as a parameter. Read at the profile's offsets. +- `src/RemarkableTablet.Core/Mapping/CoordinateMapper.cs:23-25,54-60` — read pen ranges from a `DeviceProfile` reference held by the mapper. +- `src/RemarkableTablet.Core/Mapping/TouchCoordinateMapper.cs` — same for touch. +- `src/RemarkableTablet.Core/Pipeline/TabletPipeline.cs:125,151` — replace `ReMarkable2Constants.{Pen,Touch}DevicePath` with profile lookups. +- `src/RemarkableTablet.Core/Tablet/ReMarkable2Constants.cs` — delete, or thin to `[Obsolete]` re-exports during the refactor and remove in the same PR. Prefer outright delete. + +### Tests + +- `tests/RemarkableTablet.Core.Tests/Evdev/EvdevParserTests.cs` — add a test that parses a hand-crafted 16-byte rM2 frame using `EvdevLayout.Bits32` and asserts the existing expected event. Existing tests must still pass without change once they thread the profile through. +- All existing tests pass unchanged. + +### Acceptance + +`dotnet build && dotnet test` green on both target frameworks. Manual rM2 smoke test: pen draws pressure curves identically to v0.3.x. + +--- + +## Phase 2 — rMPP profile + variable-size parsing (~0.5 day) + +`src/RemarkableTablet.Core/Devices/ReMarkablePaperProProfile.cs` (new): + +```csharp +public static class ReMarkablePaperProProfile +{ + public static DeviceProfile Instance { get; } = new() + { + Name = "reMarkable Paper Pro", + EventLayout = EvdevLayout.Bits64, + // Draft values from Evidlo/remarkable_mouse `rmpro` branch (Issue #92). + // Must be confirmed by Phase 0 evtest before v0.4.0 ships. + PenDevicePath = "/dev/input/event2", + TouchDevicePath = "/dev/input/event3", + Pen = new PenAxes( + XMin: 0, XMax: 11180, XResolution: 2832, + YMin: 0, YMax: 15340, YResolution: 2064, + PressureMin: 0, PressureMax: 4096, + TiltXMin: -9000, TiltXMax: 9000, // PLACEHOLDER — capture in Phase 0 + TiltYMin: -9000, TiltYMax: 9000, // PLACEHOLDER — capture in Phase 0 + DistanceMin: 0, DistanceMax: 255), // PLACEHOLDER — capture in Phase 0 + Touch = new TouchAxes( + XMin: 0, XMax: 1619, // PLACEHOLDER — display is 1620×2160 + YMin: 0, YMax: 2159, // PLACEHOLDER + PressureMin: 0, PressureMax: 255, + MaxSlots: 32, MaxTracked: 5), + PenSuppressesTouch = true // ASSUMPTION — verify in Phase 0 + }; +} +``` + +Mark placeholders with `// TODO(rmpp-phase0)` so a grep before release surfaces any unverified value. + +### Tests + +`tests/RemarkableTablet.Core.Tests/Evdev/EvdevParser_Rmpp_Tests.cs` (new): feed a hand-crafted 24-byte aarch64 frame, assert `(type, code, value)` decode correctly using `EvdevLayout.Bits64`. Also feed a 24-byte stream of two concatenated frames and assert no desync. + +### Acceptance + +Tests green. No live-hardware acceptance possible until Phase 0 is done. + +--- + +## Phase 3 — `input_absinfo.resolution` (~0.25 day, applies to both profiles) + +This is the FreeCap23 fix: Wayland/libinput recognizes the virtual uinput device as a tablet only when `resolution > 0`. Currently silently 0 on rM2 — known correctness gap, fixed alongside the rMPP work because both profiles need it. + +- `src/RemarkableTablet.Linux/Interop/UinputStructs.cs` — confirm `input_absinfo` already has a `resolution` field (Linux struct definition includes it); expose it through the managed type if missing. +- `src/RemarkableTablet.Linux/Output/UinputOutput.cs:63-67,150-158` — extend `SetAxis(..., int resolution)` and `uinput_abs_setup` initialization. Read per-axis resolution from the active `DeviceProfile.Pen`. rM2 gets `100` (per FreeCap23); rMPP gets `2832` / `2064` from the Evidlo constants. +- `src/RemarkableTablet.Linux/Output/UinputTouchOutput.cs` — same treatment if it also uses `SetAxis`. Touch resolution is less critical (touchscreens default to `INPUT_PROP_DIRECT`) but populate it for completeness. + +### Acceptance + +On Linux: `udevadm info --query=property --name=/dev/input/` shows `ID_INPUT_TABLET=1`. Manual smoke in Krita on a Wayland session. + +--- + +## Phase 4 — UX (~0.5 day) + +### CLI + +`src/RemarkableTablet.Cli/Program.cs`: + +- New flag `--device ` defaulting to `auto`. +- New flag `--no-detect` to force a specific profile (for diagnostics). + +### Auto-detection (~0.5 day shared with Phase 3.5) + +`src/RemarkableTablet.Core/Devices/DeviceDetector.cs` (new): + +1. Open SSH using existing `SshTransport.ConnectAsync`. +2. Execute `uname -m`. `armv7l` → rM2; `aarch64` → rMPP; anything else → throw with a clear "unsupported device" error. +3. (Optional resilience, defer if time-pressed) Walk `/proc/bus/input/devices` and match pen/touch by `Name=` substring rather than trusting hard-coded `event2`/`event3` indices. Evidlo's `readlink -f /dev/input/touchscreen0` is the simpler proven approach; use that. + +### GUI + +`src/RemarkableTablet.App/SettingsWindow.xaml.cs` + `SettingsWindow.xaml`: + +- New "Device" dropdown: `Auto-detect (recommended)`, `reMarkable 2`, `reMarkable Paper Pro`. +- Persist in `AppSettings.cs` (new `DeviceProfile` string field). +- On `Connect`, if `Auto`, run `DeviceDetector` before building the pipeline. Show the detected device name in the tray tooltip. + +### Acceptance + +Manual: CLI with `--device auto` connects to rM2 and reports "Detected: reMarkable 2" in startup logs. CLI with `--device rmpp` against an rM2 should fail fast with a "wrong profile — expected aarch64, got armv7l" error rather than producing garbage events. + +--- + +## Phase 5 — Docs + release (~0.5 day, gated on Phase 0) + +- README quickstart: Developer Mode note for rMPP, link to reMarkable's official article. +- README compatibility table: add rMPP row. +- New section `## Hardware details — Paper Pro` in README mirroring the existing rM2 one, populated from Phase 0 captures. +- `docs/FEASIBILITY_REMARKABLE_PRO.md` — append a "Verified" note with Phase 0 capture date. +- CHANGELOG: v0.4.0 entry. +- Tag `v0.4.0`, GitHub Actions publishes per-OS artifacts as usual. + +**Ship gate:** All `// TODO(rmpp-phase0)` placeholders replaced with verified values, or the rMPP profile is marked `[Experimental]` in the docs with the known unknowns called out. + +--- + +## Sequencing and parallelism + +``` +Phase 0 (hardware bring-up) ━━━━━━━━━━━━━━━━━━━━━┓ + ▼ +Phase 1 (DeviceProfile) ━━━┓ Phase 5 (release) + ▼ ▲ +Phase 2 (rMPP profile) ━━━┫ │ + ▼ │ +Phase 3 (resolution fix) ━━━┫ │ + ▼ │ +Phase 4 (UX) ━━━┻━━━━━━━━━━━━━━━━━━━━━┛ +``` + +Phase 0 is async with Phases 1–4. Phases 1→2→3→4 must be sequential (each depends on the previous). Phase 5 needs all of them. + +--- + +## Risk register + +| Risk | Likelihood | Mitigation | +|---|---|---| +| Evidlo's rmpp axis values are wrong for our firmware build | Medium | Phase 0 verifies before ship; placeholder TODOs grep-able | +| `PenSuppressesTouch = true` assumption fails | Medium | Phase 0 verifies; if false, resurrect `PenToolGate` (+1 day) | +| Touchscreen axis rotation differs (we hit this on rM2 once) | Medium | Phase 0 corner-tap test; `INPUT_PROP_DIRECT` is ground truth | +| Pen barrel buttons use different evdev codes on rMPP (`BTN_TOOL_PENCIL`?) | Low | `evtest` capture in Phase 0 surfaces this; trivial state-machine extension | +| `uname -m` returns something unexpected (e.g. Buildroot variant) | Low | Detector throws with clear message; user falls back to `--device rmpp` | +| Wayland users on rM2 were relying on resolution-0 behavior | Very low | resolution=100 is the libinput-correct value; no plausible regression | +| dotnet test hangs (per project memory) | Low | Known issue; investigate stale testhost.exe / Pipe deadlocks before retrying | + +--- + +## What this plan deliberately doesn't do + +- **No abstract "future devices" interface.** Two profiles, both concrete, in one folder. If a third device appears, we generalize then — not preemptively. +- **No runtime discovery of axis ranges from `/proc/bus/input/devices` ABS bitmaps.** Possible, but constants-per-profile is simpler and the existing test surface stays small. Add discovery later if firmware drift forces it. +- **No on-device daemon.** Documented as a v2 option in the feasibility doc; not part of this scope. +- **No support for the rMPP power button or pen-attach event.** Out of scope; input-streaming tool doesn't need them. + +--- + +## Done means + +- `dotnet build && dotnet test` green on Windows and Linux. +- Manual: rM2 user upgrading to v0.4.0 sees identical behavior to v0.3.1. +- Manual: rMPP user runs `remtablet --password ` or the GUI, sees pen pressure and tilt in Krita on Windows and Linux. +- Touch gestures work on rMPP if `--gestures touch` is passed (or break loudly with a clear message — touch is optional). +- README documents both devices; rMPP hardware-details section populated from Phase 0 captures. +- No `// TODO(rmpp-phase0)` strings remain. diff --git a/src/RemarkableTablet.App/App.xaml.cs b/src/RemarkableTablet.App/App.xaml.cs index 8f8099b..411489c 100644 --- a/src/RemarkableTablet.App/App.xaml.cs +++ b/src/RemarkableTablet.App/App.xaml.cs @@ -1,5 +1,6 @@ using System.IO; using System.Windows; +using RemarkableTablet.Core.Devices; using RemarkableTablet.Core.Mapping; using RemarkableTablet.Core.Output; using RemarkableTablet.Core.Pipeline; @@ -73,7 +74,8 @@ public void StartPipeline(ConnectionOptions connOpts, MappingOptions mappingOpts { if (_pipeline is not null) return; - var mapper = new CoordinateMapper(mappingOpts, PressureCurve.FromName(pressureCurve)); + var profile = ReMarkable2Profile.Instance; + var mapper = new CoordinateMapper(mappingOpts, profile, PressureCurve.FromName(pressureCurve)); var output = outputMode == OutputModes.Mouse ? (IOutputMode)new MouseOutput() : new WindowsInkOutput(); @@ -82,13 +84,13 @@ public void StartPipeline(ConnectionOptions connOpts, MappingOptions mappingOpts ITouchOutput? touchOutput = null; if (gestures) { - touchMapper = new TouchCoordinateMapper(mappingOpts); - touchOutput = new WindowsTouchInjectionOutput(); + touchMapper = new TouchCoordinateMapper(mappingOpts, profile); + touchOutput = new WindowsTouchInjectionOutput(profile.Touch.MaxTracked); } var transport = new SshTransport(connOpts); - var pipeline = new TabletPipeline(transport, mapper, output, touchMapper, touchOutput); + var pipeline = new TabletPipeline(transport, profile, mapper, output, touchMapper, touchOutput); pipeline.ConnectionStateChanged += OnPipelineStateChanged; pipeline.Error += ex => WriteLog($"Pipeline error: {ex}"); diff --git a/src/RemarkableTablet.Cli/Program.cs b/src/RemarkableTablet.Cli/Program.cs index 9da5fa8..fc68db1 100644 --- a/src/RemarkableTablet.Cli/Program.cs +++ b/src/RemarkableTablet.Cli/Program.cs @@ -1,3 +1,4 @@ +using RemarkableTablet.Core.Devices; using RemarkableTablet.Core.Mapping; using RemarkableTablet.Core.Output; using RemarkableTablet.Core.Pipeline; @@ -79,9 +80,10 @@ _ => Orientation.Portrait }; +var profile = ReMarkable2Profile.Instance; var mappingOpts = MappingOptions.ForScreen(screenW, screenH, orient); var curve = PressureCurve.FromName(pressure); -var mapper = new CoordinateMapper(mappingOpts, curve); +var mapper = new CoordinateMapper(mappingOpts, profile, curve); IOutputMode output; #if WINDOWS_PLATFORM @@ -96,11 +98,11 @@ ITouchOutput? touchOutput = null; if (gestures == "touch") { - touchMapper = new TouchCoordinateMapper(mappingOpts); + touchMapper = new TouchCoordinateMapper(mappingOpts, profile); #if LINUX_PLATFORM - touchOutput = new UinputTouchOutput(screenW, screenH); + touchOutput = new UinputTouchOutput(screenW, screenH, profile.Touch.MaxTracked); #elif WINDOWS_PLATFORM - touchOutput = new WindowsTouchInjectionOutput(); + touchOutput = new WindowsTouchInjectionOutput(profile.Touch.MaxTracked); #endif } else if (gestures == "synth") @@ -122,7 +124,7 @@ Console.ResetColor(); }; -await using var pipeline = new TabletPipeline(transport, mapper, output, touchMapper, touchOutput); +await using var pipeline = new TabletPipeline(transport, profile, mapper, output, touchMapper, touchOutput); pipeline.Error += ex => { Console.ForegroundColor = ConsoleColor.Red; diff --git a/src/RemarkableTablet.Core/Devices/DeviceProfile.cs b/src/RemarkableTablet.Core/Devices/DeviceProfile.cs new file mode 100644 index 0000000..de77a01 --- /dev/null +++ b/src/RemarkableTablet.Core/Devices/DeviceProfile.cs @@ -0,0 +1,66 @@ +namespace RemarkableTablet.Core.Devices; + +/// +/// Hardware-specific facts about a reMarkable model. One instance per +/// supported device; the pipeline reads everything device-dependent from +/// here so adding a new device is one file in this folder. +/// Target-side scale constants (Windows Ink pressure 0–1024, tilt ±90°) +/// are NOT device-specific — see . +/// +public sealed record DeviceProfile +{ + public required string Name { get; init; } + + /// Byte layout of struct input_event on this device's userspace ABI. + public required EvdevLayout EventLayout { get; init; } + + public required string PenDevicePath { get; init; } + public required string TouchDevicePath { get; init; } + + public required PenAxes Pen { get; init; } + public required TouchAxes Touch { get; init; } + + /// + /// True if the firmware suppresses touch events while the pen is in + /// proximity (rM2 hardware-level behavior, verified via evtest). When + /// true, no host-side pen-tool gate is needed to keep gestures from + /// firing during drawing. + /// + public bool PenSuppressesTouch { get; init; } +} + +/// +/// Byte layout of struct input_event. 32-bit ARM userspace uses 16 +/// bytes (8-byte timeval + HHi); 64-bit ARM userspace uses 24 bytes (16-byte +/// timeval + HHi). The HHi tail is identical; only the timeval prefix size +/// and therefore the field offsets differ. +/// +public sealed record EvdevLayout(int StructSize, int TypeOffset, int CodeOffset, int ValueOffset) +{ + public static EvdevLayout Bits32 { get; } = new(StructSize: 16, TypeOffset: 8, CodeOffset: 10, ValueOffset: 12); + public static EvdevLayout Bits64 { get; } = new(StructSize: 24, TypeOffset: 16, CodeOffset: 18, ValueOffset: 20); +} + +/// +/// Pen digitizer axis ranges (raw firmware units). Resolution is "ticks per +/// mm" as defined by input_absinfo — populated for libinput / Wayland +/// tablet recognition; 0 means "not declared." +/// +public sealed record PenAxes( + int XMin, int XMax, int XResolution, + int YMin, int YMax, int YResolution, + int PressureMin, int PressureMax, + int TiltXMin, int TiltXMax, + int TiltYMin, int TiltYMax, + int DistanceMin, int DistanceMax); + +/// +/// Touchscreen axis ranges. MaxSlots is what the kernel reports; +/// MaxTracked is what the host-side state machine actually keeps +/// state for (enough for two-finger gestures plus margin). +/// +public sealed record TouchAxes( + int XMin, int XMax, + int YMin, int YMax, + int PressureMin, int PressureMax, + int MaxSlots, int MaxTracked); diff --git a/src/RemarkableTablet.Core/Devices/ReMarkable2Profile.cs b/src/RemarkableTablet.Core/Devices/ReMarkable2Profile.cs new file mode 100644 index 0000000..de001f0 --- /dev/null +++ b/src/RemarkableTablet.Core/Devices/ReMarkable2Profile.cs @@ -0,0 +1,38 @@ +namespace RemarkableTablet.Core.Devices; + +/// +/// reMarkable 2 (Wacom I2C Digitizer, i.MX7D armv7l). +/// Verified 2026-05-06 via evtest /dev/input/event1 on firmware 1231 +/// (Bus=0018, Vendor=2d1f, Product=0095, Version=1231); touchscreen +/// verified 2026-05-07 via evtest /dev/input/event2 (driver: pt_mt). +/// Pen axis convention (re-verified 2026-05-07 against the touchscreen, +/// which is INPUT_PROP_DIRECT and indisputable): +/// ABS_X is the LONG axis, 0 = top of device, max = USB/bottom (portrait). +/// ABS_Y is the SHORT axis, 0 = right side, max = left side (portrait). +/// Touch panel coordinates are display-aligned (1404 × 1872), MT-B +/// slot protocol. BTN_TOUCH is NOT reported by the touchscreen — contact +/// lifecycle is driven by ABS_MT_TRACKING_ID transitions. +/// +public static class ReMarkable2Profile +{ + public static DeviceProfile Instance { get; } = new() + { + Name = "reMarkable 2", + EventLayout = EvdevLayout.Bits32, + PenDevicePath = "/dev/input/event1", + TouchDevicePath = "/dev/input/event2", + Pen = new PenAxes( + XMin: 0, XMax: 20966, XResolution: 0, + YMin: 0, YMax: 15725, YResolution: 0, + PressureMin: 0, PressureMax: 4095, + TiltXMin: -9000, TiltXMax: 9000, + TiltYMin: -9000, TiltYMax: 9000, + DistanceMin: 0, DistanceMax: 255), + Touch = new TouchAxes( + XMin: 0, XMax: 1403, + YMin: 0, YMax: 1871, + PressureMin: 0, PressureMax: 255, + MaxSlots: 32, MaxTracked: 5), + PenSuppressesTouch = true + }; +} diff --git a/src/RemarkableTablet.Core/Evdev/EvdevParser.cs b/src/RemarkableTablet.Core/Evdev/EvdevParser.cs index 4eab0d6..5deb634 100644 --- a/src/RemarkableTablet.Core/Evdev/EvdevParser.cs +++ b/src/RemarkableTablet.Core/Evdev/EvdevParser.cs @@ -2,40 +2,41 @@ using System.Buffers.Binary; using System.IO.Pipelines; using System.Threading.Channels; +using RemarkableTablet.Core.Devices; namespace RemarkableTablet.Core.Evdev; /// /// Reads the raw evdev byte stream from a PipeReader and writes decoded /// EvdevEvents to a channel. Runs as a long-lived async loop. -/// On 32-bit ARM (rM2 / i.MX7D), input_event is 16 bytes: -/// [0..3] uint32 sec -/// [4..7] uint32 usec -/// [8..9] uint16 type -/// [10..11] uint16 code -/// [12..15] int32 value +/// +/// The byte layout of struct input_event depends on the device's +/// userspace bitness — 16 bytes on 32-bit ARM (rM2), 24 bytes on 64-bit +/// ARM (rMPP). The caller supplies an with +/// the appropriate struct size and field offsets. The HHi tail is +/// identical on both ABIs; only the leading timeval differs. +/// /// All fields are little-endian. /// public static class EvdevParser { - public const int EventSize = 16; - public static async Task RunAsync( PipeReader reader, ChannelWriter output, + EvdevLayout layout, CancellationToken ct) { try { while (!ct.IsCancellationRequested) { - var result = await reader.ReadAtLeastAsync(EventSize, ct); + var result = await reader.ReadAtLeastAsync(layout.StructSize, ct); var buffer = result.Buffer; - while (buffer.Length >= EventSize) + while (buffer.Length >= layout.StructSize) { - var ev = Parse(buffer.Slice(0, EventSize)); - buffer = buffer.Slice(EventSize); + var ev = Parse(buffer.Slice(0, layout.StructSize), layout); + buffer = buffer.Slice(layout.StructSize); await output.WriteAsync(ev, ct); } @@ -59,23 +60,22 @@ public static async Task RunAsync( } } - private static EvdevEvent Parse(ReadOnlySequence slice) + private static EvdevEvent Parse(ReadOnlySequence slice, EvdevLayout layout) { // Hot path — no copy if (slice.IsSingleSegment) - return ParseSpan(slice.FirstSpan); + return ParseSpan(slice.FirstSpan, layout); - Span scratch = stackalloc byte[EventSize]; + Span scratch = stackalloc byte[layout.StructSize]; slice.CopyTo(scratch); - return ParseSpan(scratch); + return ParseSpan(scratch, layout); } - private static EvdevEvent ParseSpan(ReadOnlySpan span) + private static EvdevEvent ParseSpan(ReadOnlySpan span, EvdevLayout layout) { - // Skip sec (0..3) and usec (4..7) — not needed for injection - var type = BinaryPrimitives.ReadUInt16LittleEndian(span[8..]); - var code = BinaryPrimitives.ReadUInt16LittleEndian(span[10..]); - var value = BinaryPrimitives.ReadInt32LittleEndian(span[12..]); + var type = BinaryPrimitives.ReadUInt16LittleEndian(span[layout.TypeOffset..]); + var code = BinaryPrimitives.ReadUInt16LittleEndian(span[layout.CodeOffset..]); + var value = BinaryPrimitives.ReadInt32LittleEndian(span[layout.ValueOffset..]); return new EvdevEvent(type, code, value); } -} \ No newline at end of file +} diff --git a/src/RemarkableTablet.Core/Mapping/CoordinateMapper.cs b/src/RemarkableTablet.Core/Mapping/CoordinateMapper.cs index bd831e0..9f58d30 100644 --- a/src/RemarkableTablet.Core/Mapping/CoordinateMapper.cs +++ b/src/RemarkableTablet.Core/Mapping/CoordinateMapper.cs @@ -1,3 +1,4 @@ +using RemarkableTablet.Core.Devices; using RemarkableTablet.Core.Output; using RemarkableTablet.Core.Tablet; @@ -11,18 +12,20 @@ public sealed class CoordinateMapper { private readonly PressureCurve _curve; private readonly MappingOptions _opts; + private readonly DeviceProfile _profile; - public CoordinateMapper(MappingOptions opts, PressureCurve? curve = null) + public CoordinateMapper(MappingOptions opts, DeviceProfile profile, PressureCurve? curve = null) { _opts = opts; + _profile = profile; _curve = curve ?? PressureCurve.Linear; } public MappedFrame Map(PenFrame frame) { // Normalise raw tablet coords to [0,1] - var nx = frame.X / (double)ReMarkable2Constants.PenXMax; - var ny = frame.Y / (double)ReMarkable2Constants.PenYMax; + var nx = frame.X / (double)_profile.Pen.XMax; + var ny = frame.Y / (double)_profile.Pen.YMax; // Apply orientation transform. // rM2 pen axis layout (empirically aligned with touch panel 2026-05-07): @@ -50,13 +53,13 @@ public MappedFrame Map(PenFrame frame) var sx = _opts.MonitorX + (int)(rx * _opts.MonitorW); var sy = _opts.MonitorY + (int)(ry * _opts.MonitorH); - // Pressure: tablet 0–4095 → normalised → curve → Windows 0–1024 - var normPressure = frame.Pressure / (double)ReMarkable2Constants.PressureMax; - var wPressure = (uint)(_curve.Apply(normPressure) * ReMarkable2Constants.WindowsPressureMax); + // Pressure: tablet raw → normalised → curve → Windows 0–1024 + var normPressure = frame.Pressure / (double)_profile.Pen.PressureMax; + var wPressure = (uint)(_curve.Apply(normPressure) * InjectionScale.PressureMax); - // Tilt: rM2 units → degrees ±90, then rotated to match the position transform. - var tiltX = ScaleTilt(frame.TiltX, ReMarkable2Constants.TiltXMin, ReMarkable2Constants.TiltXMax); - var tiltY = ScaleTilt(frame.TiltY, ReMarkable2Constants.TiltYMin, ReMarkable2Constants.TiltYMax); + // Tilt: firmware units → degrees ±90, then rotated to match the position transform. + var tiltX = ScaleTilt(frame.TiltX, _profile.Pen.TiltXMin, _profile.Pen.TiltXMax); + var tiltY = ScaleTilt(frame.TiltY, _profile.Pen.TiltYMin, _profile.Pen.TiltYMax); (tiltX, tiltY) = RotateTilt(tiltX, tiltY, _opts.Orientation); return new MappedFrame( @@ -76,8 +79,8 @@ private static int ScaleTilt(int raw, int min, int max) { if (max == min) return 0; var norm = (raw - min) / (double)(max - min); // [0,1] - return (int)(norm * (ReMarkable2Constants.WindowsTiltMax - ReMarkable2Constants.WindowsTiltMin) - + ReMarkable2Constants.WindowsTiltMin); + return (int)(norm * (InjectionScale.TiltMax - InjectionScale.TiltMin) + + InjectionScale.TiltMin); } /// diff --git a/src/RemarkableTablet.Core/Mapping/TouchCoordinateMapper.cs b/src/RemarkableTablet.Core/Mapping/TouchCoordinateMapper.cs index ddabe54..b371f8a 100644 --- a/src/RemarkableTablet.Core/Mapping/TouchCoordinateMapper.cs +++ b/src/RemarkableTablet.Core/Mapping/TouchCoordinateMapper.cs @@ -1,3 +1,4 @@ +using RemarkableTablet.Core.Devices; using RemarkableTablet.Core.Output; using RemarkableTablet.Core.Tablet; @@ -17,10 +18,12 @@ namespace RemarkableTablet.Core.Mapping; public sealed class TouchCoordinateMapper { private readonly MappingOptions _opts; + private readonly DeviceProfile _profile; - public TouchCoordinateMapper(MappingOptions opts) + public TouchCoordinateMapper(MappingOptions opts, DeviceProfile profile) { _opts = opts; + _profile = profile; } public MappedTouchFrame Map(TouchFrame frame) @@ -36,8 +39,8 @@ public MappedTouchFrame Map(TouchFrame frame) private MappedTouchContact MapContact(TouchContact c) { // Normalise raw touch coords to [0,1] in the panel's own frame. - var nx = c.X / (double)ReMarkable2Constants.TouchXMax; - var ny = c.Y / (double)ReMarkable2Constants.TouchYMax; + var nx = c.X / (double)_profile.Touch.XMax; + var ny = c.Y / (double)_profile.Touch.YMax; // Touch panel native frame: portrait — short axis = X, long axis = Y, // pen slot at the bottom. So unlike the pen, no axis swap is needed @@ -60,9 +63,9 @@ private MappedTouchContact MapContact(TouchContact c) var sx = _opts.MonitorX + (int)(rx * _opts.MonitorW); var sy = _opts.MonitorY + (int)(ry * _opts.MonitorH); - // Pressure: 0..255 → 0..1024 (Windows Ink scale). - var pressureNorm = c.Pressure / (double)ReMarkable2Constants.TouchPressureMax; - var pressure = (uint)(pressureNorm * ReMarkable2Constants.WindowsPressureMax); + // Pressure: device raw → 0..1024 (Windows Ink scale). + var pressureNorm = c.Pressure / (double)_profile.Touch.PressureMax; + var pressure = (uint)(pressureNorm * InjectionScale.PressureMax); return new MappedTouchContact(c.Slot, c.TrackingId, sx, sy, pressure); } diff --git a/src/RemarkableTablet.Core/Output/InjectionScale.cs b/src/RemarkableTablet.Core/Output/InjectionScale.cs new file mode 100644 index 0000000..e7f1e52 --- /dev/null +++ b/src/RemarkableTablet.Core/Output/InjectionScale.cs @@ -0,0 +1,15 @@ +namespace RemarkableTablet.Core.Output; + +/// +/// Target scale for injected pointer values, independent of source device. +/// Matches Windows Ink expectations (pressure 0–1024, tilt −90 to +90 +/// degrees); the Linux uinput outputs declare these same ranges at +/// UI_ABS_SETUP time so a single works +/// on both platforms without re-scaling. +/// +public static class InjectionScale +{ + public const int PressureMax = 1024; + public const int TiltMin = -90; + public const int TiltMax = 90; +} diff --git a/src/RemarkableTablet.Core/Pipeline/TabletPipeline.cs b/src/RemarkableTablet.Core/Pipeline/TabletPipeline.cs index d6ef114..7e24556 100644 --- a/src/RemarkableTablet.Core/Pipeline/TabletPipeline.cs +++ b/src/RemarkableTablet.Core/Pipeline/TabletPipeline.cs @@ -1,4 +1,5 @@ using System.Threading.Channels; +using RemarkableTablet.Core.Devices; using RemarkableTablet.Core.Evdev; using RemarkableTablet.Core.Mapping; using RemarkableTablet.Core.Output; @@ -25,22 +26,29 @@ public sealed class TabletPipeline : IAsyncDisposable private readonly CancellationTokenSource _cts = new(); private readonly CoordinateMapper _mapper; private readonly IOutputMode _output; + private readonly DeviceProfile _profile; private readonly TouchCoordinateMapper? _touchMapper; private readonly ITouchOutput? _touchOutput; private readonly SshTransport _transport; - public TabletPipeline(SshTransport transport, CoordinateMapper mapper, IOutputMode output) - : this(transport, mapper, output, null, null) { } + public TabletPipeline( + SshTransport transport, + DeviceProfile profile, + CoordinateMapper mapper, + IOutputMode output) + : this(transport, profile, mapper, output, null, null) { } public TabletPipeline( SshTransport transport, + DeviceProfile profile, CoordinateMapper mapper, IOutputMode output, TouchCoordinateMapper? touchMapper, ITouchOutput? touchOutput) { _transport = transport; + _profile = profile; _output = output; _mapper = mapper; _touchMapper = touchMapper; @@ -122,7 +130,7 @@ private async Task RunOnceAsync(CancellationToken ct) { await _transport.ConnectAsync(ct); - var penStream = _transport.OpenStream(ReMarkable2Constants.PenDevicePath, ct); + var penStream = _transport.OpenStream(_profile.PenDevicePath, ct); // Pen channels — evdev events at ~100 Hz; unbounded is cheap and // avoids mid-frame loss that would corrupt the next emitted PenFrame. @@ -141,14 +149,14 @@ private async Task RunOnceAsync(CancellationToken ct) var tasks = new List(6) { - EvdevParser.RunAsync(penStream.Reader, penEvdevChannel.Writer, ct), + EvdevParser.RunAsync(penStream.Reader, penEvdevChannel.Writer, _profile.EventLayout, ct), TabletStateMachine.RunAsync(penEvdevChannel.Reader, penFrameChannel.Writer, ct), PenOutputLoopAsync(penFrameChannel.Reader, ct) }; if (_touchMapper is not null && _touchOutput is not null) { - var touchStream = _transport.OpenStream(ReMarkable2Constants.TouchDevicePath, ct); + var touchStream = _transport.OpenStream(_profile.TouchDevicePath, ct); var touchEvdevChannel = Channel.CreateUnbounded(new UnboundedChannelOptions { @@ -163,7 +171,7 @@ private async Task RunOnceAsync(CancellationToken ct) SingleWriter = true }); - tasks.Add(EvdevParser.RunAsync(touchStream.Reader, touchEvdevChannel.Writer, ct)); + tasks.Add(EvdevParser.RunAsync(touchStream.Reader, touchEvdevChannel.Writer, _profile.EventLayout, ct)); tasks.Add(TouchStateMachine.RunAsync(touchEvdevChannel.Reader, touchFrameChannel.Writer, ct)); tasks.Add(TouchOutputLoopAsync(touchFrameChannel.Reader, ct)); } diff --git a/src/RemarkableTablet.Core/Tablet/ReMarkable2Constants.cs b/src/RemarkableTablet.Core/Tablet/ReMarkable2Constants.cs deleted file mode 100644 index 5c42d64..0000000 --- a/src/RemarkableTablet.Core/Tablet/ReMarkable2Constants.cs +++ /dev/null @@ -1,67 +0,0 @@ -namespace RemarkableTablet.Core.Tablet; - -/// -/// Hardware constants for the reMarkable 2 pen digitizer. -/// Verified 2026-05-06 via evtest /dev/input/event1 on firmware with Wacom I2C Digitizer -/// (Bus=0018, Vendor=2d1f, Product=0095, Version=1231). Struct size confirmed 16 bytes (armv7l). -/// -public static class ReMarkable2Constants -{ - // Pen device path on the tablet - public const string PenDevicePath = "/dev/input/event1"; - - // evdev struct size — 16 bytes on 32-bit ARM (i.MX7D Cortex-A7), confirmed armv7l - public const int EventStructSize = 16; - - // ABS_X / ABS_Y — portrait orientation (USB at bottom, device held tall). - // Pen aligned with the touch panel (verified 2026-05-07 against pt_mt - // touchscreen which produces correct cursor positioning): - // ABS_X is the LONG axis, 0 = top of device, PenXMax = USB/bottom. - // ABS_Y is the SHORT axis, 0 = right side, PenYMax = left side. - public const int PenXMin = 0; - public const int PenXMax = 20966; - - public const int PenYMin = 0; - public const int PenYMax = 15725; - - // ABS_PRESSURE — 12-bit, 4096 levels - public const int PressureMin = 0; - public const int PressureMax = 4095; - - // ABS_DISTANCE — hover distance from surface - public const int DistanceMin = 0; - public const int DistanceMax = 255; - - // ABS_TILT_X / ABS_TILT_Y — range ±9000 (firmware units, not degrees) - public const int TiltXMin = -9000; - public const int TiltXMax = 9000; - public const int TiltYMin = -9000; - public const int TiltYMax = 9000; - - // Windows Ink pressure scale - public const int WindowsPressureMax = 1024; - - // Windows Ink tilt range (degrees) - public const int WindowsTiltMin = -90; - public const int WindowsTiltMax = 90; - - // ── Touchscreen ───────────────────────────────────────────────────────── - // Verified 2026-05-07 via evtest /dev/input/event2 (driver: pt_mt). - // Coordinates are display-aligned (1404 × 1872), MT-B slot protocol. - // BTN_TOUCH is NOT reported by this device — contact lifecycle uses - // ABS_MT_TRACKING_ID transitions only. - public const string TouchDevicePath = "/dev/input/event2"; - - public const int TouchXMin = 0; - public const int TouchXMax = 1403; - public const int TouchYMin = 0; - public const int TouchYMax = 1871; - - public const int TouchPressureMin = 0; - public const int TouchPressureMax = 255; - - // Hardware reports 32 slots; we cap our state machine at 5 (enough for - // two-finger gestures with margin for transient noise contacts). - public const int TouchMaxSlots = 32; - public const int TouchMaxTracked = 5; -} \ No newline at end of file diff --git a/src/RemarkableTablet.Core/Transport/SshTransport.cs b/src/RemarkableTablet.Core/Transport/SshTransport.cs index 24fabcb..d7d667d 100644 --- a/src/RemarkableTablet.Core/Transport/SshTransport.cs +++ b/src/RemarkableTablet.Core/Transport/SshTransport.cs @@ -1,5 +1,4 @@ using System.IO.Pipelines; -using RemarkableTablet.Core.Tablet; using Renci.SshNet; namespace RemarkableTablet.Core.Transport; @@ -59,18 +58,6 @@ public SshDeviceStream OpenStream(string devicePath, CancellationToken ct) return stream; } - /// - /// Backwards-compatible convenience: opens the pen device stream if - /// not already open, and returns its reader. Existing single-stream - /// callers (EventDiagnostics, legacy paths) keep working. - /// - public PipeReader GetReader() - { - if (_streams.Count == 0) - OpenStream(ReMarkable2Constants.PenDevicePath, CancellationToken.None); - return _streams[0].Reader; - } - private SshClient BuildClient() { if (_opts.PrivateKeyPath is not null) diff --git a/src/RemarkableTablet.Linux/Output/UinputTouchOutput.cs b/src/RemarkableTablet.Linux/Output/UinputTouchOutput.cs index 0fda584..6beb69d 100644 --- a/src/RemarkableTablet.Linux/Output/UinputTouchOutput.cs +++ b/src/RemarkableTablet.Linux/Output/UinputTouchOutput.cs @@ -1,7 +1,6 @@ using System.Runtime.InteropServices; using System.Text; using RemarkableTablet.Core.Output; -using RemarkableTablet.Core.Tablet; using RemarkableTablet.Linux.Interop; namespace RemarkableTablet.Linux.Output; @@ -9,14 +8,15 @@ namespace RemarkableTablet.Linux.Output; /// /// Linux output: injects multi-touch contacts via the uinput kernel module /// using the MT-B (slot) protocol. Creates a virtual touchscreen that -/// reports up to -/// concurrent contacts to apps that read the Linux input subsystem. +/// reports up to maxTracked concurrent contacts to apps that read +/// the Linux input subsystem. /// Same uinput permissions apply as . /// public sealed class UinputTouchOutput : ITouchOutput { private readonly int _screenW; private readonly int _screenH; + private readonly int _maxTracked; private int _fd = -1; // Slots active in the previous frame, keyed by slot index. Value is the @@ -25,10 +25,11 @@ public sealed class UinputTouchOutput : ITouchOutput private bool _btnTouchDown; private int _currentEmittedSlot = -1; - public UinputTouchOutput(int screenW, int screenH) + public UinputTouchOutput(int screenW, int screenH, int maxTracked = 5) { _screenW = screenW; _screenH = screenH; + _maxTracked = maxTracked; } public void Initialize() @@ -58,12 +59,12 @@ public void Initialize() SetupDevice(); - SetAxis(AbsCode.ABS_MT_SLOT, min: 0, max: ReMarkable2Constants.TouchMaxTracked - 1, fuzz: 0, flat: 0); - SetAxis(AbsCode.ABS_MT_POSITION_X, min: 0, max: _screenW - 1, fuzz: 0, flat: 0); - SetAxis(AbsCode.ABS_MT_POSITION_Y, min: 0, max: _screenH - 1, fuzz: 0, flat: 0); - SetAxis(AbsCode.ABS_MT_TRACKING_ID, min: 0, max: 65535, fuzz: 0, flat: 0); - SetAxis(AbsCode.ABS_MT_PRESSURE, min: 0, max: ReMarkable2Constants.WindowsPressureMax, fuzz: 0, flat: 0); - SetAxis(AbsCode.ABS_MT_TOUCH_MAJOR, min: 0, max: 255, fuzz: 0, flat: 0); + SetAxis(AbsCode.ABS_MT_SLOT, min: 0, max: _maxTracked - 1, fuzz: 0, flat: 0); + SetAxis(AbsCode.ABS_MT_POSITION_X, min: 0, max: _screenW - 1, fuzz: 0, flat: 0); + SetAxis(AbsCode.ABS_MT_POSITION_Y, min: 0, max: _screenH - 1, fuzz: 0, flat: 0); + SetAxis(AbsCode.ABS_MT_TRACKING_ID, min: 0, max: 65535, fuzz: 0, flat: 0); + SetAxis(AbsCode.ABS_MT_PRESSURE, min: 0, max: InjectionScale.PressureMax, fuzz: 0, flat: 0); + SetAxis(AbsCode.ABS_MT_TOUCH_MAJOR, min: 0, max: 255, fuzz: 0, flat: 0); Ioctl(UinputIoctl.UI_DEV_CREATE); @@ -83,7 +84,7 @@ public void Send(MappedTouchFrame frame) // Cap the slot index to what the virtual device declared. Upstream // is supposed to respect TouchMaxTracked, but defend against an // out-of-range slot from a misbehaving frame. - if (c.Slot < 0 || c.Slot >= ReMarkable2Constants.TouchMaxTracked) continue; + if (c.Slot < 0 || c.Slot >= _maxTracked) continue; seenSlots.Add(c.Slot); SwitchSlot(c.Slot); diff --git a/src/RemarkableTablet.Windows/Output/WindowsTouchInjectionOutput.cs b/src/RemarkableTablet.Windows/Output/WindowsTouchInjectionOutput.cs index 3e459f4..3d06a0c 100644 --- a/src/RemarkableTablet.Windows/Output/WindowsTouchInjectionOutput.cs +++ b/src/RemarkableTablet.Windows/Output/WindowsTouchInjectionOutput.cs @@ -1,7 +1,6 @@ using System.Diagnostics; using System.Runtime.InteropServices; using RemarkableTablet.Core.Output; -using RemarkableTablet.Core.Tablet; using RemarkableTablet.Windows.Interop; namespace RemarkableTablet.Windows.Output; @@ -22,6 +21,7 @@ namespace RemarkableTablet.Windows.Output; /// public sealed class WindowsTouchInjectionOutput : ITouchOutput { + private readonly int _maxTracked; private IntPtr _device = IntPtr.Zero; private uint _frameId; private bool _isFirstFrame = true; @@ -38,11 +38,16 @@ private struct ActiveContact public uint Pressure; } + public WindowsTouchInjectionOutput(int maxTracked = 5) + { + _maxTracked = maxTracked; + } + public void Initialize() { _device = User32.CreateSyntheticPointerDevice( User32.PT_TOUCH, - (uint)ReMarkable2Constants.TouchMaxTracked, + (uint)_maxTracked, User32.POINTER_FEEDBACK_DEFAULT); if (_device == IntPtr.Zero) @@ -75,7 +80,7 @@ public void Send(MappedTouchFrame frame) // Updates / new contacts. foreach (var c in current) { - if (c.Slot < 0 || c.Slot >= ReMarkable2Constants.TouchMaxTracked) continue; + if (c.Slot < 0 || c.Slot >= _maxTracked) continue; var isNew = !_active.ContainsKey(c.Slot); var flags = PointerFlags.InRange | PointerFlags.InContact; diff --git a/tests/RemarkableTablet.Core.Tests/CoordinateMapperTests.cs b/tests/RemarkableTablet.Core.Tests/CoordinateMapperTests.cs index 6e10bc0..7eb3153 100644 --- a/tests/RemarkableTablet.Core.Tests/CoordinateMapperTests.cs +++ b/tests/RemarkableTablet.Core.Tests/CoordinateMapperTests.cs @@ -1,3 +1,4 @@ +using RemarkableTablet.Core.Devices; using RemarkableTablet.Core.Mapping; using RemarkableTablet.Core.Tablet; using Xunit; @@ -6,6 +7,8 @@ namespace RemarkableTablet.Core.Tests; public class CoordinateMapperTests { + private static readonly DeviceProfile Rm2 = ReMarkable2Profile.Instance; + private static CoordinateMapper MakeMapper(Orientation orientation, int monW = 1920, int monH = 1080) { var opts = new MappingOptions @@ -16,7 +19,7 @@ private static CoordinateMapper MakeMapper(Orientation orientation, int monW = 1 MonitorH = monH, Orientation = orientation }; - return new CoordinateMapper(opts, PressureCurve.Linear); + return new CoordinateMapper(opts, Rm2, PressureCurve.Linear); } private static PenFrame MakeFrame( @@ -37,7 +40,7 @@ public void PortraitOrigin_MapsToTopLeft() // Physical top-left in portrait = (ABS_X=0, ABS_Y=PenYMax). // Formula (1-ny, nx): rx=0, ry=0 → screen (0, 0). var mapper = MakeMapper(Orientation.Portrait); - var frame = MakeFrame(0, ReMarkable2Constants.PenYMax); + var frame = MakeFrame(0, Rm2.Pen.YMax); var mapped = mapper.Map(frame); Assert.Equal(0, mapped.ScreenX); Assert.Equal(0, mapped.ScreenY); @@ -47,7 +50,7 @@ public void PortraitOrigin_MapsToTopLeft() public void PortraitCenter_MapsToScreenCenter() { var mapper = MakeMapper(Orientation.Portrait); - var frame = MakeFrame(ReMarkable2Constants.PenXMax / 2, ReMarkable2Constants.PenYMax / 2); + var frame = MakeFrame(Rm2.Pen.XMax / 2, Rm2.Pen.YMax / 2); var mapped = mapper.Map(frame); // Should be approximately center (±2px tolerance for integer rounding) Assert.InRange(mapped.ScreenX, 958, 962); @@ -58,7 +61,7 @@ public void PortraitCenter_MapsToScreenCenter() public void LandscapeOrientation_MapsDirectly() { var mapper = MakeMapper(Orientation.Landscape); - var frame = MakeFrame(ReMarkable2Constants.PenXMax / 2, ReMarkable2Constants.PenYMax / 2); + var frame = MakeFrame(Rm2.Pen.XMax / 2, Rm2.Pen.YMax / 2); var mapped = mapper.Map(frame); Assert.InRange(mapped.ScreenX, 958, 962); Assert.InRange(mapped.ScreenY, 538, 542); @@ -87,7 +90,7 @@ public void PortraitFlippedOrigin_MapsToTopLeft() // Physical top-left = (ABS_X=PenXMax, ABS_Y=0). // Formula (ny, 1-nx): rx=0, ry=0 → screen (0,0). var mapper = MakeMapper(Orientation.PortraitFlipped); - var frame = MakeFrame(ReMarkable2Constants.PenXMax, 0); + var frame = MakeFrame(Rm2.Pen.XMax, 0); var mapped = mapper.Map(frame); Assert.Equal(0, mapped.ScreenX); Assert.Equal(0, mapped.ScreenY); @@ -100,7 +103,7 @@ public void LandscapeFlippedOrigin_MapsToTopLeft() // Physical top-left = (ABS_X=PenXMax, ABS_Y=PenYMax). // Formula (1-nx, 1-ny): rx=0, ry=0 → screen (0,0). var mapper = MakeMapper(Orientation.LandscapeFlipped); - var frame = MakeFrame(ReMarkable2Constants.PenXMax, ReMarkable2Constants.PenYMax); + var frame = MakeFrame(Rm2.Pen.XMax, Rm2.Pen.YMax); var mapped = mapper.Map(frame); Assert.Equal(0, mapped.ScreenX); Assert.Equal(0, mapped.ScreenY); @@ -115,10 +118,10 @@ public void PressureLinearCurve_MapsCorrectly() Assert.Equal(0u, mapper.Map(MakeFrame(0, 0)).Pressure); // Full pressure → 1024 - Assert.Equal(1024u, mapper.Map(MakeFrame(0, 0, ReMarkable2Constants.PressureMax)).Pressure); + Assert.Equal(1024u, mapper.Map(MakeFrame(0, 0, Rm2.Pen.PressureMax)).Pressure); // Half pressure → ~512 - var half = mapper.Map(MakeFrame(0, 0, ReMarkable2Constants.PressureMax / 2)); + var half = mapper.Map(MakeFrame(0, 0, Rm2.Pen.PressureMax / 2)); Assert.InRange(half.Pressure, 510u, 514u); } @@ -130,13 +133,13 @@ public void PressureCurveSoft_BoostsLowPressure() MonitorX = 0, MonitorY = 0, MonitorW = 1920, MonitorH = 1080, Orientation = Orientation.Portrait }; - var softMapper = new CoordinateMapper(opts, PressureCurve.Soft); - var linMapper = new CoordinateMapper(opts, PressureCurve.Linear); + var softMapper = new CoordinateMapper(opts, Rm2, PressureCurve.Soft); + var linMapper = new CoordinateMapper(opts, Rm2, PressureCurve.Linear); // At 25% input, soft curve should produce noticeably higher output than linear. // Linear at t=0.25 ≈ 0.25 → ~256/1024. // Soft at t=0.25 (y1=0.40, y2=0.90) ≈ 0.311 → ~318/1024. - var rawPressure = ReMarkable2Constants.PressureMax / 4; + var rawPressure = Rm2.Pen.PressureMax / 4; var softMapped = softMapper.Map(MakeFrame(0, 0, rawPressure)); var linMapped = linMapper.Map(MakeFrame(0, 0, rawPressure)); @@ -156,7 +159,7 @@ public void PressureCurveSoft_BoostsLowPressure() private static (int X, int Y) TiltAfter(Orientation o) { var mapper = MakeMapper(o); - var f = MakeFrame(0, 0, tiltX: ReMarkable2Constants.TiltXMax, tiltY: 0); + var f = MakeFrame(0, 0, tiltX: Rm2.Pen.TiltXMax, tiltY: 0); var m = mapper.Map(f); return (m.TiltX, m.TiltY); } diff --git a/tests/RemarkableTablet.Core.Tests/EvdevParserTests.cs b/tests/RemarkableTablet.Core.Tests/EvdevParserTests.cs index 917b27b..55a11f1 100644 --- a/tests/RemarkableTablet.Core.Tests/EvdevParserTests.cs +++ b/tests/RemarkableTablet.Core.Tests/EvdevParserTests.cs @@ -1,6 +1,7 @@ using System.Buffers.Binary; using System.IO.Pipelines; using System.Threading.Channels; +using RemarkableTablet.Core.Devices; using RemarkableTablet.Core.Evdev; using Xunit; @@ -8,20 +9,27 @@ namespace RemarkableTablet.Core.Tests; public class EvdevParserTests { + private static readonly EvdevLayout Layout32 = EvdevLayout.Bits32; + private static readonly EvdevLayout Layout64 = EvdevLayout.Bits64; + /// - /// Builds a synthetic 16-byte evdev event. - /// Layout: sec(4) usec(4) type(2) code(2) value(4) — little-endian + /// Builds a synthetic evdev event at either 16- or 24-byte struct size. + /// 32-bit layout: sec(4) usec(4) type(2) code(2) value(4) — little-endian + /// 64-bit layout: sec(8) usec(8) type(2) code(2) value(4) — little-endian /// - private static byte[] MakeEvent(ushort type, ushort code, int value) + private static byte[] MakeEvent(EvdevLayout layout, ushort type, ushort code, int value) { - var buf = new byte[16]; - // sec/usec = 0 - BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(8), type); - BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(10), code); - BinaryPrimitives.WriteInt32LittleEndian(buf.AsSpan(12), value); + var buf = new byte[layout.StructSize]; + // timeval bytes left at 0 + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(layout.TypeOffset), type); + BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(layout.CodeOffset), code); + BinaryPrimitives.WriteInt32LittleEndian(buf.AsSpan(layout.ValueOffset), value); return buf; } + private static byte[] MakeEvent(ushort type, ushort code, int value) + => MakeEvent(Layout32, type, code, value); + [Fact] public async Task ParsesSingleEvent() { @@ -31,7 +39,7 @@ public async Task ParsesSingleEvent() await pipe.Writer.WriteAsync(MakeEvent(EvdevTypes.EV_ABS, EvdevCodes.ABS_X, 12345)); await pipe.Writer.CompleteAsync(); - await EvdevParser.RunAsync(pipe.Reader, channel.Writer, CancellationToken.None); + await EvdevParser.RunAsync(pipe.Reader, channel.Writer, Layout32, CancellationToken.None); var events = new List(); await foreach (var ev in channel.Reader.ReadAllAsync()) @@ -62,7 +70,7 @@ .. MakeEvent(EvdevTypes.EV_SYN, EvdevCodes.SYN_REPORT, 0) await pipe.Writer.WriteAsync(data); await pipe.Writer.CompleteAsync(); - await EvdevParser.RunAsync(pipe.Reader, channel.Writer, CancellationToken.None); + await EvdevParser.RunAsync(pipe.Reader, channel.Writer, Layout32, CancellationToken.None); var events = new List(); await foreach (var ev in channel.Reader.ReadAllAsync()) @@ -91,7 +99,7 @@ public async Task HandlesChunkedReads() await pipe.Writer.CompleteAsync(); - await EvdevParser.RunAsync(pipe.Reader, channel.Writer, CancellationToken.None); + await EvdevParser.RunAsync(pipe.Reader, channel.Writer, Layout32, CancellationToken.None); var events = new List(); await foreach (var ev in channel.Reader.ReadAllAsync()) @@ -101,6 +109,59 @@ public async Task HandlesChunkedReads() Assert.Equal(999, events[0].Value); } + [Fact] + public async Task ParsesBits64FrameCorrectly() + { + // Regression guard for rMPP (aarch64): 24-byte input_event with HHi + // payload at offsets 16/18/20. Same logical event as the 32-bit test + // above, just packed into the 64-bit timeval layout. + var pipe = new Pipe(); + var channel = Channel.CreateUnbounded(); + + await pipe.Writer.WriteAsync(MakeEvent(Layout64, EvdevTypes.EV_ABS, EvdevCodes.ABS_PRESSURE, 4096)); + await pipe.Writer.CompleteAsync(); + + await EvdevParser.RunAsync(pipe.Reader, channel.Writer, Layout64, CancellationToken.None); + + var events = new List(); + await foreach (var ev in channel.Reader.ReadAllAsync()) + events.Add(ev); + + Assert.Single(events); + Assert.Equal(EvdevTypes.EV_ABS, events[0].Type); + Assert.Equal(EvdevCodes.ABS_PRESSURE, events[0].Code); + Assert.Equal(4096, events[0].Value); + } + + [Fact] + public async Task ParsesBits64Stream_NoDesyncAcrossFrames() + { + // Two concatenated 24-byte frames; the parser must advance exactly + // 24 bytes per event. If it slips (e.g. reverts to 16-byte slicing) + // the second event decodes as garbage — exactly the rMPP smoking-gun + // signature reported in Evidlo/remarkable_mouse Issue #92. + var pipe = new Pipe(); + var channel = Channel.CreateUnbounded(); + + byte[] data = + [ + .. MakeEvent(Layout64, EvdevTypes.EV_ABS, EvdevCodes.ABS_X, 1111), + .. MakeEvent(Layout64, EvdevTypes.EV_SYN, EvdevCodes.SYN_REPORT, 0) + ]; + await pipe.Writer.WriteAsync(data); + await pipe.Writer.CompleteAsync(); + + await EvdevParser.RunAsync(pipe.Reader, channel.Writer, Layout64, CancellationToken.None); + + var events = new List(); + await foreach (var ev in channel.Reader.ReadAllAsync()) + events.Add(ev); + + Assert.Equal(2, events.Count); + Assert.Equal(1111, events[0].Value); + Assert.Equal(EvdevCodes.SYN_REPORT, events[1].Code); + } + [Fact] public async Task ParsesFixtureFileIfPresent() { @@ -113,9 +174,9 @@ public async Task ParsesFixtureFileIfPresent() } var bytes = await File.ReadAllBytesAsync(fixturePath); - Assert.True(bytes.Length % EvdevParser.EventSize == 0, - $"Fixture size {bytes.Length} is not a multiple of {EvdevParser.EventSize}. " + - "If it's a multiple of 24, the device runs 64-bit userspace — update EventStructSize."); + Assert.True(bytes.Length % Layout32.StructSize == 0, + $"Fixture size {bytes.Length} is not a multiple of {Layout32.StructSize}. " + + "If it's a multiple of 24, the device runs 64-bit userspace — use EvdevLayout.Bits64."); // Write concurrently with the parser — synchronous pre-write deadlocks for // large fixtures because Pipe's default pauseWriterThreshold is 65536 bytes. @@ -128,7 +189,7 @@ public async Task ParsesFixtureFileIfPresent() await pipe.Writer.CompleteAsync(); }); - await EvdevParser.RunAsync(pipe.Reader, channel.Writer, CancellationToken.None); + await EvdevParser.RunAsync(pipe.Reader, channel.Writer, Layout32, CancellationToken.None); await writeTask; var count = 0; diff --git a/tests/RemarkableTablet.Core.Tests/TouchCoordinateMapperTests.cs b/tests/RemarkableTablet.Core.Tests/TouchCoordinateMapperTests.cs index 3be9630..34899cb 100644 --- a/tests/RemarkableTablet.Core.Tests/TouchCoordinateMapperTests.cs +++ b/tests/RemarkableTablet.Core.Tests/TouchCoordinateMapperTests.cs @@ -1,4 +1,6 @@ +using RemarkableTablet.Core.Devices; using RemarkableTablet.Core.Mapping; +using RemarkableTablet.Core.Output; using RemarkableTablet.Core.Tablet; using Xunit; @@ -6,6 +8,8 @@ namespace RemarkableTablet.Core.Tests; public class TouchCoordinateMapperTests { + private static readonly DeviceProfile Rm2 = ReMarkable2Profile.Instance; + private static TouchContact Contact(int x, int y, int pressure = 100, int slot = 0, int trackingId = 1) => new(slot, trackingId, x, y, pressure, 0, 0, 0, 0); @@ -15,7 +19,7 @@ private static TouchContact Contact(int x, int y, int pressure = 100, int slot = public void Portrait_TopLeftMapsToScreenTopLeft() { var opts = MappingOptions.ForScreen(1920, 1080, Orientation.Portrait); - var mapper = new TouchCoordinateMapper(opts); + var mapper = new TouchCoordinateMapper(opts, Rm2); var f = mapper.Map(Frame(Contact(0, 0))); @@ -27,11 +31,11 @@ public void Portrait_TopLeftMapsToScreenTopLeft() public void Portrait_BottomRightMapsToScreenBottomRight() { var opts = MappingOptions.ForScreen(1920, 1080, Orientation.Portrait); - var mapper = new TouchCoordinateMapper(opts); + var mapper = new TouchCoordinateMapper(opts, Rm2); var f = mapper.Map(Frame(Contact( - ReMarkable2Constants.TouchXMax, - ReMarkable2Constants.TouchYMax))); + Rm2.Touch.XMax, + Rm2.Touch.YMax))); // (int) truncation of 0.999... can land just below max — accept ±1. Assert.InRange(f.Contacts[0].ScreenX, 1918, 1920); @@ -42,7 +46,7 @@ public void Portrait_BottomRightMapsToScreenBottomRight() public void EmptyFrame_PassesThroughEmpty() { var opts = MappingOptions.ForScreen(1920, 1080); - var mapper = new TouchCoordinateMapper(opts); + var mapper = new TouchCoordinateMapper(opts, Rm2); var f = mapper.Map(TouchFrame.Empty); @@ -53,18 +57,18 @@ public void EmptyFrame_PassesThroughEmpty() public void Pressure_ScaledTo0To1024() { var opts = MappingOptions.ForScreen(1920, 1080); - var mapper = new TouchCoordinateMapper(opts); + var mapper = new TouchCoordinateMapper(opts, Rm2); - var f = mapper.Map(Frame(Contact(0, 0, pressure: ReMarkable2Constants.TouchPressureMax))); + var f = mapper.Map(Frame(Contact(0, 0, pressure: Rm2.Touch.PressureMax))); - Assert.Equal((uint)ReMarkable2Constants.WindowsPressureMax, f.Contacts[0].Pressure); + Assert.Equal((uint)InjectionScale.PressureMax, f.Contacts[0].Pressure); } [Fact] public void SlotAndTrackingId_PassedThroughUnchanged() { var opts = MappingOptions.ForScreen(1920, 1080); - var mapper = new TouchCoordinateMapper(opts); + var mapper = new TouchCoordinateMapper(opts, Rm2); var f = mapper.Map(Frame(Contact(100, 100, slot: 7, trackingId: 9999))); @@ -78,7 +82,7 @@ public void LandscapeFlipsAxesAppropriately() // In Landscape, top-left of touch panel (X=0, Y=0) should map to // bottom-left of the screen (the rotated frame). var opts = MappingOptions.ForScreen(1920, 1080, Orientation.Landscape); - var mapper = new TouchCoordinateMapper(opts); + var mapper = new TouchCoordinateMapper(opts, Rm2); var f = mapper.Map(Frame(Contact(0, 0))); @@ -92,11 +96,11 @@ public void LandscapeFlipsAxesAppropriately() public void MultipleContacts_AllMapped() { var opts = MappingOptions.ForScreen(1920, 1080, Orientation.Portrait); - var mapper = new TouchCoordinateMapper(opts); + var mapper = new TouchCoordinateMapper(opts, Rm2); var f = mapper.Map(Frame( Contact(0, 0, slot: 0, trackingId: 1), - Contact(ReMarkable2Constants.TouchXMax, ReMarkable2Constants.TouchYMax, + Contact(Rm2.Touch.XMax, Rm2.Touch.YMax, slot: 1, trackingId: 2))); Assert.Equal(2, f.Contacts.Count); diff --git a/tools/EventDiagnostics/Program.cs b/tools/EventDiagnostics/Program.cs index 5d5448e..24ee026 100644 --- a/tools/EventDiagnostics/Program.cs +++ b/tools/EventDiagnostics/Program.cs @@ -1,4 +1,5 @@ using System.Threading.Channels; +using RemarkableTablet.Core.Devices; using RemarkableTablet.Core.Evdev; using RemarkableTablet.Core.Transport; @@ -57,7 +58,9 @@ FullMode = BoundedChannelFullMode.DropOldest }); -var parseTask = EvdevParser.RunAsync(transport.GetReader(), channel.Writer, cts.Token); +var profile = ReMarkable2Profile.Instance; +var penStream = transport.OpenStream(profile.PenDevicePath, cts.Token); +var parseTask = EvdevParser.RunAsync(penStream.Reader, channel.Writer, profile.EventLayout, cts.Token); // Tracks whether a dot was the last thing written (needs a newline before the next real line) var needsNewline = false; From 68ea1828695610dd7cd2340607047833081923ed Mon Sep 17 00:00:00 2001 From: Keegan Ott Date: Mon, 11 May 2026 10:33:12 +0800 Subject: [PATCH 02/10] Add reMarkable Paper Pro device profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Constants seeded from Evidlo/remarkable_mouse's rmpro branch (Issue #92). Event layout is Bits64 (24-byte input_event) so the shared EvdevParser decodes the device's aarch64 stream correctly — the regression tests added in the previous commit guard against accidental Bits32 fallback. Items not in the public community data (tilt, distance, touch axis ranges, pen-suppression behavior) carry // TODO(rmpp-phase0) markers and must be confirmed via evtest on real hardware before v0.4.0 ships. Phase 2 of docs/IMPLEMENTATION_PLAN_RMPP.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Devices/ReMarkablePaperProProfile.cs | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 src/RemarkableTablet.Core/Devices/ReMarkablePaperProProfile.cs diff --git a/src/RemarkableTablet.Core/Devices/ReMarkablePaperProProfile.cs b/src/RemarkableTablet.Core/Devices/ReMarkablePaperProProfile.cs new file mode 100644 index 0000000..bbdba4a --- /dev/null +++ b/src/RemarkableTablet.Core/Devices/ReMarkablePaperProProfile.cs @@ -0,0 +1,69 @@ +namespace RemarkableTablet.Core.Devices; + +/// +/// reMarkable Paper Pro (NXP i.MX 8MM "ferrari", aarch64 / arm64). +/// +/// Values seeded from Evidlo/remarkable_mouse's rmpro +/// branch (codes.py, captured by the maintainer via Issue #92). +/// Items marked TODO(rmpp-phase0) were not in the public +/// community data and must be confirmed on real hardware before +/// v0.4.0 ships. Grep for that tag before tagging the release. +/// +/// +/// Key fact: aarch64 userspace uses a 24-byte struct input_event +/// (16-byte timeval + HHi), not the 16-byte struct rM2 emits. The +/// layout below is what makes the +/// shared correctly decode this +/// device's stream — running the rM2 (Bits32) layout against an rMPP +/// stream produces silent desync, observable as garbage event codes +/// (e.g. KeyError: 61892 in Issue #92's Python decoder). +/// +/// +public static class ReMarkablePaperProProfile +{ + public static DeviceProfile Instance { get; } = new() + { + Name = "reMarkable Paper Pro", + EventLayout = EvdevLayout.Bits64, + + // Device-node ordering per Issue #92: event0 = power button, + // event1 = pen attach/detach, event2 = pen, event3 = touch. + PenDevicePath = "/dev/input/event2", + TouchDevicePath = "/dev/input/event3", + + Pen = new PenAxes( + // Pen axis ranges and resolutions from Evidlo `rmpro` constants. + // Resolution units are ticks per millimetre per the input_absinfo + // convention; the rMPP's much higher values vs rM2 (100) reflect + // the higher-density active digitizer geometry. + XMin: 0, XMax: 11180, XResolution: 2832, + YMin: 0, YMax: 15340, YResolution: 2064, + PressureMin: 0, PressureMax: 4096, + + // TODO(rmpp-phase0): tilt and distance ranges are not in the + // public rmpro constants. Capture via evtest /dev/input/event2 + // on real hardware. Placeholders assume rM2 conventions + // (firmware units ±9000 mapped to ±90°, hover 0–255). + TiltXMin: -9000, TiltXMax: 9000, + TiltYMin: -9000, TiltYMax: 9000, + DistanceMin: 0, DistanceMax: 255), + + Touch = new TouchAxes( + // TODO(rmpp-phase0): touch axis ranges have not been published + // by anyone. The placeholders match the display (1620×2160, + // INPUT_PROP_DIRECT) on the reasonable assumption the panel is + // display-aligned like the rM2's pt_mt driver. Confirm via + // evtest /dev/input/event3 and corner-tap calibration. + XMin: 0, XMax: 1619, + YMin: 0, YMax: 2159, + PressureMin: 0, PressureMax: 255, + MaxSlots: 32, MaxTracked: 5), + + // TODO(rmpp-phase0): reMarkable markets palm rejection on rMPP but + // does not specify the mechanism. If touch events flow while the + // pen is in proximity, a host-side pen-tool gate is required. + // Verify by streaming event2 + event3 simultaneously and hovering + // the pen. + PenSuppressesTouch = true + }; +} From d52d2cebc100266a3e93e5ea7d2927e8f30c8877 Mon Sep 17 00:00:00 2001 From: Keegan Ott Date: Mon, 11 May 2026 10:34:30 +0800 Subject: [PATCH 03/10] Set input_absinfo.resolution on the virtual uinput pen device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The input_absinfo struct already had a resolution field declared in interop, but UinputOutput.SetAxis never populated it — the value was silently 0 on every axis. libinput uses this field to recognise the virtual device as a tablet on Wayland; with resolution=0 the compositor categorises us as a generic absolute-axis device, which falls back to mouse semantics rather than pen ones. The fix surfaces resolution as a parameter on SetAxis, takes ABS_X/Y values from the active DeviceProfile, and sets rM2 to 100 ticks/mm (the FreeCap23 value). rMPP uses Evidlo's 2832/2064 which it already declares in its profile. ABS_PRESSURE and ABS_TILT limits also move to InjectionScale rather than literals so the host-target scale lives in one place. Phase 3 of docs/IMPLEMENTATION_PLAN_RMPP.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/RemarkableTablet.Cli/Program.cs | 2 +- .../Devices/ReMarkable2Profile.cs | 9 ++++-- .../Output/UinputOutput.cs | 28 +++++++++++++------ 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/RemarkableTablet.Cli/Program.cs b/src/RemarkableTablet.Cli/Program.cs index fc68db1..0d62564 100644 --- a/src/RemarkableTablet.Cli/Program.cs +++ b/src/RemarkableTablet.Cli/Program.cs @@ -89,7 +89,7 @@ #if WINDOWS_PLATFORM output = outputMode == "mouse" ? new MouseOutput() : new WindowsInkOutput(); #elif LINUX_PLATFORM -output = new UinputOutput(screenW, screenH); +output = new UinputOutput(screenW, screenH, profile.Pen); #endif // Touch wiring — currently only `touch` mode and only on Linux. Windows diff --git a/src/RemarkableTablet.Core/Devices/ReMarkable2Profile.cs b/src/RemarkableTablet.Core/Devices/ReMarkable2Profile.cs index de001f0..eb826b6 100644 --- a/src/RemarkableTablet.Core/Devices/ReMarkable2Profile.cs +++ b/src/RemarkableTablet.Core/Devices/ReMarkable2Profile.cs @@ -22,8 +22,13 @@ public static class ReMarkable2Profile PenDevicePath = "/dev/input/event1", TouchDevicePath = "/dev/input/event2", Pen = new PenAxes( - XMin: 0, XMax: 20966, XResolution: 0, - YMin: 0, YMax: 15725, YResolution: 0, + // Resolution = 100 ticks/mm follows FreeCap23/reMarkable-tablet-driver; + // libinput uses this to recognise the virtual uinput device as a + // tablet on Wayland. Prior to this value being set, Linux Wayland + // users sometimes saw the device categorised as generic absolute + // input rather than a tablet. + XMin: 0, XMax: 20966, XResolution: 100, + YMin: 0, YMax: 15725, YResolution: 100, PressureMin: 0, PressureMax: 4095, TiltXMin: -9000, TiltXMax: 9000, TiltYMin: -9000, TiltYMax: 9000, diff --git a/src/RemarkableTablet.Linux/Output/UinputOutput.cs b/src/RemarkableTablet.Linux/Output/UinputOutput.cs index 96a76ed..a98a982 100644 --- a/src/RemarkableTablet.Linux/Output/UinputOutput.cs +++ b/src/RemarkableTablet.Linux/Output/UinputOutput.cs @@ -1,5 +1,6 @@ using System.Runtime.InteropServices; using System.Text; +using RemarkableTablet.Core.Devices; using RemarkableTablet.Core.Output; using RemarkableTablet.Linux.Interop; @@ -20,13 +21,15 @@ public sealed class UinputOutput : IOutputMode { private readonly int _screenW; private readonly int _screenH; + private readonly PenAxes _penAxes; private int _fd = -1; private bool _wasInRange; - public UinputOutput(int screenW, int screenH) + public UinputOutput(int screenW, int screenH, PenAxes penAxes) { _screenW = screenW; _screenH = screenH; + _penAxes = penAxes; } public void Initialize() @@ -59,12 +62,15 @@ public void Initialize() SetupDevice(); - // Axis ranges match MappedFrame units so values inject directly without re-scaling - SetAxis(AbsCode.ABS_X, min: 0, max: _screenW - 1, fuzz: 0, flat: 0); - SetAxis(AbsCode.ABS_Y, min: 0, max: _screenH - 1, fuzz: 0, flat: 0); - SetAxis(AbsCode.ABS_PRESSURE, min: 0, max: 1024, fuzz: 4, flat: 0); - SetAxis(AbsCode.ABS_TILT_X, min: -90, max: 90, fuzz: 0, flat: 0); - SetAxis(AbsCode.ABS_TILT_Y, min: -90, max: 90, fuzz: 0, flat: 0); + // Axis ranges match MappedFrame units so values inject directly without re-scaling. + // ABS_X/Y resolution from the device profile is what makes libinput + // recognise this virtual device as a tablet on Wayland — see FreeCap23/ + // reMarkable-tablet-driver for the original observation. + SetAxis(AbsCode.ABS_X, min: 0, max: _screenW - 1, fuzz: 0, flat: 0, resolution: _penAxes.XResolution); + SetAxis(AbsCode.ABS_Y, min: 0, max: _screenH - 1, fuzz: 0, flat: 0, resolution: _penAxes.YResolution); + SetAxis(AbsCode.ABS_PRESSURE, min: 0, max: InjectionScale.PressureMax, fuzz: 4, flat: 0); + SetAxis(AbsCode.ABS_TILT_X, min: InjectionScale.TiltMin, max: InjectionScale.TiltMax, fuzz: 0, flat: 0); + SetAxis(AbsCode.ABS_TILT_Y, min: InjectionScale.TiltMin, max: InjectionScale.TiltMax, fuzz: 0, flat: 0); Ioctl(UinputIoctl.UI_DEV_CREATE); @@ -147,12 +153,16 @@ private unsafe void SetupDevice() IoctlPtr(UinputIoctl.UI_DEV_SETUP, &setup); } - private unsafe void SetAxis(ushort code, int min, int max, int fuzz, int flat) + private unsafe void SetAxis(ushort code, int min, int max, int fuzz, int flat, int resolution = 0) { var abs = new uinput_abs_setup { code = code, - absinfo = new input_absinfo { minimum = min, maximum = max, fuzz = fuzz, flat = flat } + absinfo = new input_absinfo + { + minimum = min, maximum = max, fuzz = fuzz, flat = flat, + resolution = (uint)resolution + } }; IoctlPtr(UinputIoctl.UI_ABS_SETUP, &abs); } From d6e3f55bb982936f385ed4a37fb17eeface25d27 Mon Sep 17 00:00:00 2001 From: Keegan Ott Date: Mon, 11 May 2026 10:38:25 +0800 Subject: [PATCH 04/10] Add device auto-detection and CLI/GUI device selector DeviceDetector probes the connected reMarkable's CPU architecture via `uname -m`: armv7l maps to the rM2 profile, aarch64/arm64 maps to the Paper Pro profile. SshTransport now exposes RunCommandAsync for the one-shot probe; the streaming pipeline opens its own session afterwards as before. CLI gains --device auto|rm2|rmpp (default auto). GUI gains a Device dropdown at the top of the Connection section, persisted in AppSettings.Device. The App's StartPipeline is now a thin wrapper over StartPipelineAsync so detection can happen off-thread without blocking the UI. Unit tests cover the architecture-to-profile mapping and the short-name lookup. Live SSH detection requires real hardware and is exercised in Phase 0 bring-up rather than in CI. Phase 4 of docs/IMPLEMENTATION_PLAN_RMPP.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/RemarkableTablet.App/App.xaml.cs | 67 +++++++++++++------ src/RemarkableTablet.App/AppSettings.cs | 3 + src/RemarkableTablet.App/SettingsWindow.xaml | 18 +++-- .../SettingsWindow.xaml.cs | 20 +++++- src/RemarkableTablet.Cli/Program.cs | 26 ++++++- .../Devices/DeviceDetector.cs | 46 +++++++++++++ .../Transport/SshTransport.cs | 17 +++++ .../DeviceDetectorTests.cs | 63 +++++++++++++++++ 8 files changed, 231 insertions(+), 29 deletions(-) create mode 100644 src/RemarkableTablet.Core/Devices/DeviceDetector.cs create mode 100644 tests/RemarkableTablet.Core.Tests/DeviceDetectorTests.cs diff --git a/src/RemarkableTablet.App/App.xaml.cs b/src/RemarkableTablet.App/App.xaml.cs index 411489c..0d0832f 100644 --- a/src/RemarkableTablet.App/App.xaml.cs +++ b/src/RemarkableTablet.App/App.xaml.cs @@ -70,32 +70,59 @@ internal static void WriteLog(string message) } public void StartPipeline(ConnectionOptions connOpts, MappingOptions mappingOpts, string outputMode, - bool gestures = false, string? pressureCurve = null) + bool gestures = false, string? pressureCurve = null, string device = "auto") { if (_pipeline is not null) return; + _ = StartPipelineAsync(connOpts, mappingOpts, outputMode, gestures, pressureCurve, device); + } - var profile = ReMarkable2Profile.Instance; - var mapper = new CoordinateMapper(mappingOpts, profile, PressureCurve.FromName(pressureCurve)); - var output = outputMode == OutputModes.Mouse - ? (IOutputMode)new MouseOutput() - : new WindowsInkOutput(); - - TouchCoordinateMapper? touchMapper = null; - ITouchOutput? touchOutput = null; - if (gestures) + private async Task StartPipelineAsync(ConnectionOptions connOpts, MappingOptions mappingOpts, + string outputMode, bool gestures, string? pressureCurve, string device) + { + try { - touchMapper = new TouchCoordinateMapper(mappingOpts, profile); - touchOutput = new WindowsTouchInjectionOutput(profile.Touch.MaxTracked); + var profile = await ResolveProfileAsync(connOpts, device); + WriteLog($"Using profile: {profile.Name}"); + + var mapper = new CoordinateMapper(mappingOpts, profile, PressureCurve.FromName(pressureCurve)); + var output = outputMode == OutputModes.Mouse + ? (IOutputMode)new MouseOutput() + : new WindowsInkOutput(); + + TouchCoordinateMapper? touchMapper = null; + ITouchOutput? touchOutput = null; + if (gestures) + { + touchMapper = new TouchCoordinateMapper(mappingOpts, profile); + touchOutput = new WindowsTouchInjectionOutput(profile.Touch.MaxTracked); + } + + var transport = new SshTransport(connOpts); + var pipeline = new TabletPipeline(transport, profile, mapper, output, touchMapper, touchOutput); + pipeline.ConnectionStateChanged += OnPipelineStateChanged; + pipeline.Error += ex => WriteLog($"Pipeline error: {ex}"); + + _pipeline = pipeline; + await RunPipelineAsync(pipeline); } + catch (Exception ex) + { + WriteLog($"StartPipeline failed: {ex}"); + _pipeline = null; + await Dispatcher.BeginInvoke(() => PipelineStateChanged?.Invoke(ConnectionState.Disconnected)); + } + } - var transport = new SshTransport(connOpts); - - var pipeline = new TabletPipeline(transport, profile, mapper, output, touchMapper, touchOutput); - pipeline.ConnectionStateChanged += OnPipelineStateChanged; - pipeline.Error += ex => WriteLog($"Pipeline error: {ex}"); - - _pipeline = pipeline; - _ = RunPipelineAsync(pipeline); + private static async Task ResolveProfileAsync(ConnectionOptions connOpts, string device) + { + var named = DeviceDetector.ByName(device); + if (named is not null) return named; + + // "auto" or unknown — probe over a short-lived SSH session. The + // streaming pipeline opens its own session afterwards. + await using var probe = new SshTransport(connOpts); + await probe.ConnectAsync(CancellationToken.None); + return await DeviceDetector.DetectAsync(probe, CancellationToken.None); } public void StopPipeline() diff --git a/src/RemarkableTablet.App/AppSettings.cs b/src/RemarkableTablet.App/AppSettings.cs index 2275edd..94f2243 100644 --- a/src/RemarkableTablet.App/AppSettings.cs +++ b/src/RemarkableTablet.App/AppSettings.cs @@ -29,6 +29,9 @@ public sealed class AppSettings /// "linear" (default), "soft" (boost light strokes), or "hard" (suppress light strokes). public string PressureCurve { get; set; } = "linear"; + /// "auto" (probe via uname -m), "rm2", or "rmpp". + public string Device { get; set; } = "auto"; + public static AppSettings Load() { try diff --git a/src/RemarkableTablet.App/SettingsWindow.xaml b/src/RemarkableTablet.App/SettingsWindow.xaml index 7f1c270..38b6ed2 100644 --- a/src/RemarkableTablet.App/SettingsWindow.xaml +++ b/src/RemarkableTablet.App/SettingsWindow.xaml @@ -29,13 +29,21 @@ + -