Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 52 additions & 8 deletions src/MappedInputManager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,40 @@ bool MappedInputManager::bleEdge(const bool* arr, const Button button) const {
}

namespace {
// Out-of-the-box bindings for keys whose meaning is unambiguous. Without these a
// remote that has never been through Settings -> Bluetooth -> Map Remote Buttons is
// inert: pollBle() decodes its key correctly, finds no entry in an empty bleKeyMap,
// and drops it, so the device looks broken rather than unconfigured. Consulted ONLY
// for a key that has no explicit binding, so anything the user maps still wins.
// Deliberately limited to the standard navigation keys; vendor and consumer-page
// codes carry no portable meaning and stay capture-and-assign.
struct DefaultBinding {
freeink::SpecialKey key;
MappedInputManager::Button button;
};
constexpr DefaultBinding kDefaultBindings[] = {
{freeink::SpecialKey::Right, MappedInputManager::Button::PageForward},
{freeink::SpecialKey::PageDown, MappedInputManager::Button::PageForward},
{freeink::SpecialKey::Left, MappedInputManager::Button::PageBack},
{freeink::SpecialKey::PageUp, MappedInputManager::Button::PageBack},
{freeink::SpecialKey::Up, MappedInputManager::Button::Up},
{freeink::SpecialKey::Down, MappedInputManager::Button::Down},
{freeink::SpecialKey::Enter, MappedInputManager::Button::Confirm},
{freeink::SpecialKey::Escape, MappedInputManager::Button::Back},
};

// Resolve a decoded identity against the defaults. kind 1 is a raw HID usage or a
// vendor code with no portable meaning, so only kind 0 (SpecialKey) is considered.
bool defaultBindingFor(const uint8_t kind, const uint8_t value, uint8_t& button) {
if (kind != 0) return false;
for (const auto& d : kDefaultBindings) {
if (static_cast<uint8_t>(d.key) != value) continue;
button = static_cast<uint8_t>(d.button);
return true;
}
return false;
}

constexpr float LEFT_EDGE_BACK_GESTURE_FRAC_X = 0.25f;
constexpr float BOTTOM_EDGE_BACK_GESTURE_FRAC_Y = 0.14f;
constexpr float TOP_EDGE_MENU_GESTURE_FRAC_Y = 0.14f;
Expand Down Expand Up @@ -385,21 +419,31 @@ void MappedInputManager::pollBle() {
if (!bleinput::encodeKey(ev, kind, value)) continue;

if (bleCaptureMode) {
bleCapturedKind = kind;
bleCapturedValue = value;
bleHasCaptured = true;
// Keep the FIRST unconsumed key, not the last. Overwriting meant that when a
// remote emitted two reports in one frame (a press plus the host's synthetic
// repeat, or a composite device notifying on two characteristics) the key the
// user actually pressed was replaced by whatever followed it.
if (!bleHasCaptured) {
bleCapturedKind = kind;
bleCapturedValue = value;
bleHasCaptured = true;
}
continue;
}

// Resolve the key identity against the persisted mapping table.
// Resolve the key identity against the persisted mapping table, falling back to
// the standard-navigation defaults when the user has not bound this key.
uint8_t button = 0xFF;
for (const auto& e : SETTINGS.bleKeyMap) {
if (e.button == 0xFF || e.keyKind != kind || e.keyValue != value) continue;
if (e.button < kButtonCount) {
blePressEdge[e.button] = true;
bleActivityThisFrame = true;
}
button = e.button;
break;
}
if (button == 0xFF && !defaultBindingFor(kind, value, button)) continue;
if (button < kButtonCount) {
blePressEdge[button] = true;
bleActivityThisFrame = true;
}
}
}

Expand Down
24 changes: 23 additions & 1 deletion src/activities/reader/EpubReaderActivity.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2079,6 +2079,28 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
}
}

bool EpubReaderActivity::bleConnectingTitleTakesOver() const {
// Nothing to report when BT is off, already linked, or the stack isn't even up
// (the lifecycle stops it outside a reader and under heap pressure — that is "off",
// not "connecting", and claiming otherwise misreads as a stuck connection).
if (!SETTINGS.bluetoothEnabled || !BleHid.isRunning() || BleHid.isConnected()) {
bleConnectingSince = 0;
return false;
}
// With no bond there is no remote to connect to: the host is idle, not connecting.
if (BleHid.pairedCount() == 0) {
bleConnectingSince = 0;
return false;
}
const unsigned long now = millis();
if (bleConnectingSince == 0) bleConnectingSince = now;
// A bonded remote that has gone to sleep stops advertising until the user presses a
// button on it, so the attempt can stay outstanding forever. Give the title back
// rather than holding it hostage; the watcher in loop() restores the placeholder if
// the link state flips again.
return now - bleConnectingSince < BLE_CONNECTING_TITLE_MS;
}

void EpubReaderActivity::renderStatusBar() const {
// Calculate progress in book. Use the estimated total while a giant spine is still building so
// "page X of Y" and the progress bar don't read off the small build watermark.
Expand Down Expand Up @@ -2115,7 +2137,7 @@ void EpubReaderActivity::renderStatusBar() const {
title = epub->getTitle();
}

if (SETTINGS.bluetoothEnabled && !BleHid.isConnected()) {
if (bleConnectingTitleTakesOver()) {
// Take over the title slot entirely while connecting; the watcher in
// loop() redraws the bar on the connect/disconnect flip, restoring the
// chapter/book title.
Expand Down
11 changes: 11 additions & 0 deletions src/activities/reader/EpubReaderActivity.h
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,17 @@ class EpubReaderActivity final : public Activity {
// to the chapter/book title the moment the connection completes (and
// returns on disconnect) instead of waiting for the next page turn.
bool statusBarBleConnected = false;
// When the current "BT Connecting..." status-bar takeover started. The placeholder
// is only honest for as long as a connection attempt is plausibly in flight; past
// that the book title comes back. 0 = not currently claiming the title.
mutable unsigned long bleConnectingSince = 0;
// How long the status bar may show "BT Connecting...". A remote that has gone to
// sleep stops advertising and will not come back until the user presses a button on
// it, so an unbounded placeholder costs the user their book title indefinitely for a
// connection that is not actually in progress.
static constexpr unsigned long BLE_CONNECTING_TITLE_MS = 20000;
// True while the status bar should show "BT Connecting..." instead of the title.
bool bleConnectingTitleTakesOver() const;
// Idle-time glyph prewarm: after a page settles, scan the LIKELY next page
// (scan mode draws nothing) and load its missing glyphs from SD during idle,
// so the next turn's in-render prewarm is a cache hit instead of ~100 ms of
Expand Down
5 changes: 4 additions & 1 deletion src/activities/reader/EpubReaderMenuActivity.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,10 @@ void EpubReaderMenuActivity::render(RenderLock&&) {
return pageTurnLabels[selectedPageTurnOption];
} else if (value == MenuAction::TOGGLE_BLUETOOTH) {
if (SETTINGS.bluetoothEnabled) {
if (!BleHid.isRunning()) return tr(STR_CONNECTING);
// Stack down: the lifecycle stopped it (non-reader activity, WiFi, or the
// heap gate). That is paused, not connecting — reporting "connecting" for
// a host that is not even running reads as a connection stuck forever.
if (!BleHid.isRunning()) return tr(STR_STATE_PAUSED);
return BleHid.isConnected() ? tr(STR_STATE_ON) : tr(STR_CONNECTING);
}
return tr(STR_STATE_OFF);
Expand Down
22 changes: 17 additions & 5 deletions src/activities/settings/BleButtonMapActivity.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,12 @@ void BleButtonMapActivity::onEnter() {
step = Step::WaitForKey;
capturedKind = 0xFF;
functionIndex = 0;
// Start every mapping session from a clean slate: the user re-maps each remote
// button once, so a button can't be left bound to a stale action and there's no
// separate "clear mappings" step to remember.
std::fill(std::begin(SETTINGS.bleKeyMap), std::end(SETTINGS.bleKeyMap), CrossPointSettings::BleKeyMapEntry{});
SETTINGS.saveToFile();
// Existing bindings are kept. Wiping the table here destroyed every mapping the
// moment the user opened this screen — including when they opened it only to read
// the list this activity renders, which was therefore always empty. Nothing needs
// the wipe: assignCapturedKey() already drops any other key bound to the action it
// is assigning, and re-capturing a key reuses that key's existing slot, so neither
// a stale action nor a duplicate binding can survive a re-map.
mappedInput.setBleCaptureMode(true);
requestUpdate();
}
Expand Down Expand Up @@ -98,6 +99,17 @@ void BleButtonMapActivity::loop() {
}

// Step::SelectFunction — pick a logical function for the captured key.
// Drop anything the remote sends while the user is choosing. pollBle() keeps
// latching in capture mode, and the host emits synthetic auto-repeats for a held
// key, so without this the repeat that arrived during selection was still sitting
// in the buffer when we returned to WaitForKey and was consumed as the *next*
// button the user "pressed".
{
uint8_t staleKind = 0xFF;
uint8_t staleValue = 0;
mappedInput.takeCapturedBleKey(staleKind, staleValue);
}

buttonNavigator.onNext([this] {
functionIndex = ButtonNavigator::nextIndex(functionIndex, kFunctionCount);
requestUpdate();
Expand Down
77 changes: 77 additions & 0 deletions test/sim/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# BLE page-turner tests on simulated hardware

These tests run the real CrossPoint image on a simulated Xteink X4 (ESP32-C3) and
pair it with a firmware-less BLE HID page-turner over a simulated air interface.
Nothing is stubbed between the firmware and the link: the scan, the SMP exchange,
the session key, GATT discovery and the HID input reports are all real traffic
between two models.

They exist because the page-turner work in #2418 has failure modes that only show
up end to end, and that a person holding a remote cannot easily distinguish from
each other — "the remote does nothing" is the same symptom whether the key never
arrived, arrived and was dropped, or arrived at a screen with nothing to act on.

## What is covered

| Test | What breaks if it fails |
| --- | --- |
| `test_remote_is_discovered_and_paired` | Active scan across the SCAN_REQ/SCAN_RSP turnaround; SMP; the encrypted link |
| `test_report_map_is_read_and_reports_are_subscribed` | HID service discovery, Report Map read (too long for one ATT response — needs Read Blob continuation), report CCC subscribe |
| `test_arrow_key_turns_a_page_without_being_mapped` | The default bindings. Without them an unmapped remote is inert |

## The two remote encodings

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

- **keyboard** — an 8-byte boot-protocol report carrying a HID usage. Takes the
standard keyboard slot path and surfaces as a `SpecialKey`, so the default
bindings apply and the key works with no mapping.
- **bitmap** — a button bitmap, the shape a Hanlinyue **Free 2** presents. These
are *not* keyboard usages and *not* consumer usage IDs; they fall through to the
generic fallback, where the identity is the first non-zero byte. Because such
vendor codes carry no portable meaning, they stay capture-and-assign — the
bitmap test pins the decode, not a default binding.

### Devices are declared, not copied

`hid_descriptor.py` builds a Report Map and the reports that conform to it from one
declaration, because that is how a host reads a device: 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 in an array slot
([kernel.org](https://docs.kernel.org/hid/hidintro.html)).

Writing it the other way round — copying the bytes one remote happens to emit —
fits the bench to that remote and says nothing about the next one. The Free 2's
`20 00 00` is not a magic number under this construction: it is bit 5 of a variable
input, i.e. **button 6** of a 24-bit button bitmap, and `04 00 00` is button 3.
Changing `PRESS_BUTTON` in the peer makes it a different remote without editing a
single report byte, and `button_bitfield`, `boot_keyboard` and `consumer_array`
cover the three encodings a page-turner can plausibly use.

`test_hid_descriptor.py` pins all of this and needs **no simulator, payload or
hardware** — it runs in a bare checkout.

The same remote emits different encodings in its other power-button-cycled modes,
which is exactly why the capture-then-assign design is right and why hardcoded
per-vendor profiles would be fragile.

## Running

The firmware payload and the board model are build/simulator artifacts that do
not live in this repo, so the tests **skip** in a bare checkout rather than fail.
To run them you need a simulator binary and a rundir containing
`board-featbt-hidpeer.replx` and its helper scripts:

```bash
pytest test/sim/test_ble_page_turner.py -v --sim <path-to-sim>
```

Every assertion reads the firmware's own log. UART capture block-buffers at 8 KB
and this journey emits well under that in 240 virtual seconds, so nothing reaches
the file the harness polls until the process exits. The tests therefore also skip
unless `SIMANTIC_UART_AUTOFLUSH=1` marks a simulator that flushes as it goes.

The scenario spends roughly 110 virtual seconds walking the firmware's own UI to
the Bluetooth screen before BLE matters. That is deliberate — it is the bench that
proves the whole stack — and it is not meant to run on every commit.
Loading