Skip to content

fix: BLE page-turner defects + simulated-hardware bench for both remote encodings - #1

Open
ShahriarAhnaf wants to merge 6 commits into
feat-bluetoothfrom
agent/ble-page-turner-fixes-7a1c
Open

fix: BLE page-turner defects + simulated-hardware bench for both remote encodings#1
ShahriarAhnaf wants to merge 6 commits into
feat-bluetoothfrom
agent/ble-page-turner-fixes-7a1c

Conversation

@ShahriarAhnaf

Copy link
Copy Markdown

Found while driving feat-bluetooth end to end against a simulated Xteink X4 with a
firmware-less BLE HID page-turner on the other end of a simulated air interface.
The link, SMP, GATT discovery and HID reports all worked; the page still never
turned, and chasing why turned up five defects.

Fixes

  1. BleButtonMapActivity::onEnter() wiped every mapping. It cleared the whole
    bleKeyMap and saved it before the user did anything, so opening Map Remote
    Buttons
    — including opening it only to read the binding list this very activity
    renders — destroyed all existing mappings, with no confirmation and no undo. It
    also made that list permanently empty. Nothing needed the wipe:
    assignCapturedKey() already drops any other key bound to the action it is
    assigning and reuses the slot of a re-captured key.

  2. Keys arriving during function selection were latched and reused. The host
    synthesizes auto-repeats for a held key, so a repeat landing while the user was
    choosing a function was still in the buffer on return to WaitForKey and got
    consumed as the next button they "pressed".

  3. pollBle() kept the last key of a frame, not the first. When two reports
    arrived in one frame the key the user actually pressed was overwritten.

  4. "BT Connecting…" could hold the book title forever. The reader replaced the
    status-bar title whenever Bluetooth was enabled and not connected, unbounded. A
    bonded remote that has gone to sleep stops advertising until a button is pressed
    on it, so the title was held hostage for a connection that was not in progress.
    Now requires the host to actually be running with a bond, and gives the title
    back after a bounded window. EpubReaderMenuActivity had the mirror bug,
    reporting "connecting" when the lifecycle had deliberately stopped the stack —
    that state is PAUSED.

  5. A remote that has never been mapped is inert. bleKeyMap has no defaults and
    pollBle() drops anything it cannot resolve, so keys were decoded correctly and
    then discarded — which presents as broken hardware rather than an unconfigured
    one. This is what actually blocked the page turn. Standard navigation keys
    (arrows, page up/down, enter, escape) now have out-of-the-box bindings, consulted
    only for keys the user has not bound, so explicit mappings still win. Vendor and
    consumer-page codes carry no portable meaning and remain capture-and-assign.

This also addresses the request in crosspoint-reader#2418 to "auto remap some known keys — mainly
arrows and enter".

Tests

test/sim/ adds a bench that runs the real image against a simulated peer, with
both remote encodings parameterised into every test — they take different routes
through BleKeyboardHost::onReportIngest and only the first was ever exercised:

  • keyboard — 8-byte boot-protocol report carrying a HID usage; standard slot
    path, surfaces as a SpecialKey, so it must now page with no mapping.
  • bitmap — what a Hanlinyue Free 2 actually emits per the teardown posted on
    feat: Adds Bluetooth page turner support (not to be merged) crosspoint-reader/crosspoint-reader#2418: 3-byte reports, top 20 00 00, bottom 04 00 00, release 00 00 00.
    Neither keyboard usages nor consumer usage IDs; reaches the generic fallback where
    the identity is the first non-zero byte.

The Report Map assertion is load-bearing beyond discovery: the map exceeds one ATT
response at the default MTU, so a regression in Read Blob continuation fails there
rather than silently.

Payload and board model are simulator artifacts not in this repo, so the tests skip
in a bare checkout instead of failing.

Not fixed here

The other things this run surfaced, left alone deliberately: leaving the Bluetooth
screen tears the whole NimBLE stack down (BluetoothSettingsActivity is the only
activity with keepsBluetoothAlive()), so the remote drops on every trip to the
home screen; and the 2-3 s press-to-turn latency and the OOM abort()s others
reported on crosspoint-reader#2418 are untouched.

…ing")

Five defects found while driving the branch end-to-end against a simulated
BLE HID page-turner.

1. BleButtonMapActivity::onEnter() wiped the entire bleKeyMap and saved it
   before the user did anything, so opening "Map Remote Buttons" — including
   opening it only to read the binding list this activity renders — destroyed
   every existing mapping, with no confirmation and no undo. It also made that
   list permanently empty, since onEnter had just cleared what it displays.
   Nothing needed the wipe: assignCapturedKey() already drops any other key
   bound to the action being assigned and reuses the slot of a re-captured key,
   so neither a stale action nor a duplicate binding can survive a re-map.

2. Keys arriving during Step::SelectFunction stayed latched in the capture
   buffer. Since the host synthesizes auto-repeats for a held key, a repeat that
   landed while the user was choosing a function was then consumed as the *next*
   button they "pressed". Stale captures are now discarded while selecting.

3. In capture mode pollBle() overwrote the captured key on every iteration of
   its drain loop, so when two reports arrived in one frame the key the user
   actually pressed was replaced by whatever followed it. Keep the first
   unconsumed key instead.

4. The reader's status bar replaced the book title with "BT Connecting..."
   whenever Bluetooth was enabled and not connected, unbounded. A bonded remote
   that has gone to sleep stops advertising until a button is pressed on it, so
   the title could be held hostage indefinitely for a connection that was not in
   progress. It now requires the host to actually be running with a bond, and
   gives the title back after a bounded window. EpubReaderMenuActivity had the
   mirror bug, reporting "connecting" when the lifecycle had deliberately
   stopped the stack; that state is PAUSED.

5. bleKeyMap has no defaults and pollBle() drops any key it cannot resolve, so a
   remote that has never been through the mapping screen is inert — its keys are
   decoded correctly and then discarded, which presents as broken rather than
   unconfigured. Standard navigation keys (arrows, page up/down, enter, escape)
   now have out-of-the-box bindings, consulted only for keys the user has not
   bound, so explicit mappings still win. Vendor and consumer-page codes carry no
   portable meaning and remain capture-and-assign.
Runs the real image on a simulated Xteink X4 and pairs it with a firmware-less
BLE HID peer over a simulated air interface -- real scan, SMP, session key, GATT
discovery and input reports, nothing stubbed between the firmware and the link.

Both remote encodings are parameterised into every test, because they take
different routes through BleKeyboardHost::onReportIngest and only the first was
ever exercised:

  * keyboard -- 8-byte boot-protocol report carrying a HID usage. Takes the
    standard keyboard slot path, surfaces as a SpecialKey, and so must page
    without any mapping now that the default bindings exist.
  * bitmap -- what a Hanlinyue "Free 2" actually emits, per the teardown posted
    on crosspoint-reader#2418: 3-byte reports, top 20 00 00, bottom 04 00 00,
    explicit release 00 00 00. Not keyboard usages and not consumer usage IDs;
    they reach the generic fallback where the identity is the first non-zero
    byte. Vendor codes carry no portable meaning, so these stay
    capture-and-assign -- the test pins the decode, not a default binding.

The Report Map assertion is load-bearing beyond discovery: the map is 49 octets
for the keyboard peer and longer once the bitmap peer adds its consumer
collection, so it cannot be read in one ATT response at the default MTU. A
regression in Read Blob continuation fails there rather than silently.

Payload and board model are simulator artifacts that are not in this repo, so
the tests skip in a bare checkout instead of failing.
@ShahriarAhnaf

Copy link
Copy Markdown
Author

Verification status

Local build: passes. pio run -e default links, and the new symbol is in the image:

$ nm -C .pio/build/default/firmware.elf | grep bleConnectingTitleTakesOver
420df230 T EpubReaderActivity::bleConnectingTitleTakesOver() const

The two red checks are pre-existing on feat-bluetooth, not from this branch.

clang-format — the job's diff spans files this branch never touches:
lib/Epub/Epub/Section.h, lib/GfxRenderer/FontCacheManager.cpp,
src/activities/network/CrossPointWebServerActivity.cpp,
src/activities/settings/FontDownloadActivity.cpp,
src/activities/settings/OtaUpdateActivity.cpp. This branch changes five files, all
under src/, and the blocks it adds are clang-format clean. The base commit shows the
same violations. Running bin/clang-format-fix would reformat a lot of unrelated code
into this diff, so it is deliberately left alone — worth a separate formatting-only PR
against feat-bluetooth.

cppcheck — the CI job did not reach a verdict on the code at all; it died on a
network error pulling PlatformIO packages:

requests.exceptions.ConnectionError: ('Connection aborted.', RemoteDisconnected(...))
##[error]Process completed with exit code 1

Run locally it passes with a single defect, in a file this branch does not touch:

lib/Epub/Epub.h:54: [low:style] Consider using std::accumulate algorithm instead of a raw loop.

Since CI runs pio check --fail-on-defect low, that one pre-existing style hit fails
the job on the base branch too.

unit-tests passes.

What is verified, and what is not

Verified: it compiles and links; the bitmap peer's encoding round-trips through the
real firmware in simulation — pairing, SMP, LL encryption established (STK), and
3-byte reports 200000 / 000000 notified over the encrypted link, which only
happens after the firmware reads the Report Map and writes the report CCC.

Not verified: no hardware. The default-binding path and the mapping-screen fixes are
reasoned from the source and compile clean, but nobody has yet pressed a real remote
against a real X4 with this build. The sim tests in test/sim/ cover the first two
assertions today; the page-turn assertion needs simulator UART autoflush before it can
run, and is skipped until then.

@ShahriarAhnaf

Copy link
Copy Markdown
Author

Correction: CI built this fine

The build job is red, but not because of a compile error. The default (ESP32-C3)
environment — the target this branch is for — built and linked cleanly:

Flash: [========= ]  89.0% (used 5834633 bytes from 6553600 bytes)
Successfully created ESP32C3 image.
default        SUCCESS   00:03:23.276

The job then moved on to the sticky environment (ESP32-S3) and died before compiling
anything:

Processing sticky (board: esp32-s3-devkitc1-n16r8; ...)
Error: Failed to install Python dependencies into penv
Error: Failed to install Python dependencies (exit code: 2)

That is PlatformIO failing to build its own virtualenv, not this code — the same
infrastructure error I hit locally on a first run and which cleared after removing
~/.platformio/penv. Nothing in this branch is board-specific: the changes are five
files under src/, none of which touch board or platform configuration.

So all three red checks are environmental or pre-existing:

Check Cause
build C3 target passed; sticky/S3 env failed at PlatformIO penv install
cppcheck Job died on a network error; locally passes but for one pre-existing lib/Epub/Epub.h:54 style hit that fails the base branch too
clang-format Pre-existing drift across five files this branch never touches

unit-tests passes.

…ote's bytes

The bitmap peer was fitted. BITMAP_TOP = 0x20 and BITMAP_BOTTOM = 0x04 were
constants lifted from one teardown of one remote and held until the bench
matched it, which proves the bench can replay a Free 2 and nothing about
whether the firmware supports HID page-turners in general.

A host does not work that way: it derives a report's layout from the device's
report descriptor, where Report Size and Report Count give the bit geometry and
the Input item's Variable-vs-Array flag decides whether a control is one bit of
a bitfield or a usage code placed in an array slot.

hid_descriptor.py builds the Report Map and the conforming report bytes from a
single declaration, so the bytes follow from the descriptor. Under that
construction the Free 2's 20 00 00 stops being a magic number and becomes bit 5
of a variable input -- button 6 of a 24-bit button bitmap -- and 04 00 00 is
button 3. Both reproduce exactly, as does a boot keyboard's arrow-key report,
without a copied byte anywhere. Changing which button is pressed makes the peer
a different remote.

test_hid_descriptor.py needs no simulator, payload or hardware, so unlike the
rest of test/sim it runs in a bare checkout. Besides pinning the geometry and
the variable-vs-array distinction, it documents two defects in the host's
report-map handling, whose own comment concedes it is "a hint, not a full
descriptor parse":

  * the scan walks bytes rather than HID short items, so the data bytes of a
    legal 2-byte Usage item (0x0A 0x05 0x07, Usage 0x0705) are misread as
    Usage Page (Keyboard);
  * it only matches the 1-byte Usage Page item (0x05 nn), so a vendor-defined
    page declared as 0x06 nn nn is invisible to it.

Both are covered against a correct short-item walk, with a positive control
showing the shortcut agrees on an ordinary keyboard descriptor -- which is why
the defect survived. The downstream heuristics (extractPrimaryCode's
"first non-zero byte" fallback, the gamepad axis quantisation) are patches for
the absent parser, and each new remote shape needs another one.
…perly

Two spec gaps in the peer, both invisible while only one permissive host talked
to it.

HOGP requires the HID Device to expose a single Device Information Service
instance, and requires that instance to include the PnP ID characteristic --
it is how a host identifies vendor and product before reading a single report.
The peer exposed HID and Battery but no DIS at all, so it was not a conformant
HID device even though CrossPoint was happy to pair with it. Added, with the
pid.codes open-source VID so it identifies as a test device rather than
impersonating a vendor.

The sibling import now uses __file__ rather than the working directory, which
needed the ScriptedBlePeer fix in simantic-core: peer scripts previously had no
__file__ and no directory on sys.path, so they could not be split across files
at all.
The peer clicked on a timer and nothing could intervene. With the scripted-peer
command channel in simantic-core, a test drives it directly:

    peer.Command = "click=6"      # press-and-release button 6
    peer.Command = "press=3"      # hold
    peer.Command = "autoclick=0"  # stop the free-running repeat

Which button a name maps to, and what a click even is, lives in this script
rather than in the simulator, so the same peripheral running a different script
is a different BLE slave.
Both surfaced only once commands could actually be sent, which is the point of
having a runtime surface at all.

Commands replaced the pending queue instead of appending to it, so writes
arriving faster than the timer drains them lost all but the last. Driving
click=6, press=3 and release=3 in quick succession delivered one report --
000000 -- because each command overwrote the one before it. A test pressing
twice in a row would have silently lost the first press.

on_timer logged "sending HID report" whether or not anything was subscribed, so
reports that never reached the air appeared in the log as if they had. That is
the same failure that made a dead link read as a working one earlier in this
work; unsubscribed reports are now logged as dropped.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant