Key lessons from implementing Maple Bus communication on the nRF52840 at 2Mbps. These apply broadly to any high-speed GPIO protocol on a microcontroller.
At 2Mbps, each bit lasts 500ns. Trying to detect edges and make decisions within that window is fragile — any function call, branch mispredict, or interrupt can miss data.
Solution: Capture raw GPIO samples into a large static buffer as fast as possible, then decode offline. This separates "is the signal there?" from "can we decode it?" and makes debugging much easier.
Bulk sampling runs at the measured ~7.9 Msamples/s, giving ~4 samples per 500ns bit period — enough oversampling to recover edges for post-processing.
A single rprintln!() call costs ~15-20µs via RTT — that's 30-40 bits lost. The controller responds within ~100µs of a request, so any debug logging between TX and RX will miss the entire response.
Rule: Zero logging in the TX→RX→decode path. Log before TX and after decode, never in between.
Returning from a wait_for_start() function and then calling bulk_sample() introduced enough delay to miss the first clock edges. Even a handful of nanoseconds matters when the controller starts transmitting immediately after the start pattern.
Solution: Combine detection and sampling in a single function. The moment the start pattern is detected, begin sampling inline — no function returns, no setup overhead.
Allocating the sample buffer on the stack ([u32; 24576] = 24,576 u32 samples = 96 KiB) takes measurable time. By the time the stack frame is set up, the controller's response is already in progress.
Solution: Use static mut buffers — pre-allocated at link time, zero runtime cost.
Maple Bus alternates which line is clock and which is data:
- Phase 1: A falls → sample B
- Phase 2: B falls → sample A
If the first detected edge is B falling instead of A falling, every subsequent bit is shifted by one position. The result looks almost right but every byte is wrong.
Solution: After detecting the start pattern, skip any B edges until the first A fall. This guarantees correct phase alignment.
Embassy's Flex::set_high() / set_low() are #[inline] wrappers around single OUTSET/OUTCLR register writes. In a release build, these inline to ~1 instruction each. In a debug build, #[inline] is ignored — each pin toggle becomes 4+ nested function calls, destroying TX timing. The controller won't recognize the malformed request.
# Correct — TX timing works:
cargo embed --release --no-default-features --features board-xiao
# Broken — controller won't respond:
cargo embed --no-default-features --features board-xiaoThis was the root cause of the XIAO board not working — the DK happened to always be built with --release.
Before any communication, read both pins as inputs:
Expected idle: A=1, B=0
A=0, B=1 → Wires are swapped
A=0, B=0 → Controller not powered or not connected
A=1, B=1 → Pull-up problem or pin short
Both SDCKA and SDCKB need external pull-ups to 3.3V. 10kΩ works reliably and saves a bit of current over 4.7kΩ. Without external pull-ups, floating lines cause false edge detection and unreliable communication. Internal pull-ups (~13kΩ) are too weak and vary with temperature.
Dreamcast controllers won't respond at all unless the GND/Sense pin (green wire) is connected to ground. This catches people every time.
Flux residue or solder bridges (especially under castellated-pad boards like the XIAO) can short pins to power rails. Symptoms:
Initial state A=1 B=1(B should be 0)- Board resets when grounding a data wire (shorted directly to 3.3V, not through pull-up)
Diagnostic: Short each data wire to GND one at a time. Board stays alive + pin reads LOW = correct (current through pull-up). Board resets = pin shorted to power rail.
The XIAO's 3.3V regulator can't supply enough current for the boost converter + controller (~200mA+). Feeding the Pololu VIN from the 3.3V rail causes brownouts. The battery must feed the boost converter directly.
The controller state flows from Maple Bus poll → Signal → BLE notify task. Embassy's Signal is last-writer-wins (only holds one value), which matches the industry standard for gamepad HID — every major controller (Xbox, PlayStation, Switch Pro, BlueRetro) sends state snapshots, not event queues.
A Channel (FIFO queue) was considered but rejected: analog stick jitter fills the queue every poll, and the BLE task would process stale queued states, adding 32-64ms of latency for stick movements. Channels solve a problem that doesn't exist for gamepads.
Optimizations applied (in order of impact):
- Only signal on change — reduces the window where a button press can be overwritten by identical idle-state polls. Analog jitter no longer constantly overwrites the signal.
- Wake BLE task immediately on state change — use
selectbetween timer and signal instead of fixed 8ms sleep, so the BLE task reads new state within ~1ms instead of up to 8ms.
Future option if needed: Button edge accumulation — track a bitmask of buttons pressed since the last BLE read, OR it into the signaled state. This catches press+release within one poll cycle, but adds complexity and only helps when the BLE task is delayed past 16ms (rare with optimizations #1 and #2). The Maple Bus poll at ~60 Hz (~16-17 ms loop) already means sub-16ms taps are missed at the hardware level, matching original Dreamcast behavior.
A 10-point LiPo discharge curve lookup table with linear interpolation is the industry standard for embedded gamepads. Xbox controllers only report 4 levels. Meshtastic uses an 11-point table nearly identical to ours.
Key findings from discharge testing (500mAh LiPo, FNB58 meter):
- Linear voltage mapping is misleading — battery sat at "57%" for over an hour during the 3.7-3.9V plateau
- The protection circuit cuts off at ~3300mV under load, not 3.0V — the battery never reaches the theoretical minimum
- Voltage recovery after sleep or load removal causes readings to temporarily increase, confusing users
Solutions applied:
- Lookup table calibrated to measured cutoff (0% = 3300mV)
- 8x hardware oversampling (SAADC) for noise reduction
- Monotonic decrease constraint — percentage never goes up unless charging is detected
What we didn't do (and why):
- Fuel gauge IC (MAX17048) — adds hardware complexity, only +/-5% vs our +/-10-15%, not worth it without a custom PCB
- Rolling average — the monotonic decrease constraint handles the main noise source (voltage bounce) without the complexity
- Reading during radio idle — marginal benefit, the 8x oversampling handles transients
panic-reset silently resets with no diagnostic information. Writing the panic message to a dedicated flash page before reset costs almost nothing (a few microseconds of NVMC writes) and provides critical debugging data on the next boot.
Implementation notes:
- Must use raw NVMC register writes — the SoftDevice async flash API is not available in a panic handler
- Use a fixed flash page well away from SoftDevice and application data (0xF1000; originally 0xFC000, moved 2026-08-04 below the bootloader)
- Always erase the page after reading to prevent stale data and limit flash wear
- nRF52840 flash is rated for ~10,000 erase cycles per page — not a concern
The poll cadence (~15 ms, device crystal) and the BLE connection interval (~15 ms, host clock) are nominally equal, so their relative phase slides at ppm-scale drift — and the doubled-interval fraction (a poll's notify missing its connection event) swings with that phase. Measured on one binary, within one connection, in 5 s windows over 180 s: 2.5% to 18.5%, wandering on a ~30-60 s quasi-period (crystal drift is temperature-wandering, so the beat is not a crisp frequency). The user-visible symptom is the issue #5 one: a 30 ms gap at ~2.5 rot/s skips an octant.
Consequences:
- A 10 s capture samples one random slice of the beat and cannot compare builds — an afternoon of A/B ladder flashes (v228-v232, 2026-08-10) produced "differences" from 2.9% to 12.2% that were entirely this process. Validation captures need ≥120 s, and the number that matters is the windowed timeline, not one aggregate.
- The radio-quiet gate reduces collisions but has a recurring adverse-phase band it cannot dodge by skipping alone. Candidate fixes: re-phase the poll anchor when the skip rate runs hot, or detune the poll period off the connection interval so the adverse band sweeps through quickly instead of lingering.
- Confounders priced during the same session: back cover on ≈ +1.5-2.5 points
(RF attenuation, and it is the shipping config), VMU docked ≈ +1.3 points
(LCD animation traffic). The 1.3-4.0% "baseline band" printed by
hid_capture.pypredates all three effects and reads as cover-off, clean-phase luck.
| Problem | Symptom | Solution |
|---|---|---|
| Debug prints in hot path | Garbage data, missed bits | Remove all logging between TX and RX |
| Stack allocation delay | First edge missed | Use static buffer |
| Function return delay | First edge is B not A | Combine wait + sample in one function |
| Wrong phase start | Bytes shifted by 1 bit | Skip B edges until first A fall |
| Debug build | Controller never responds | Always use --release |
| Wires swapped | Initial state A=0, B=1 | Check Red→SDCKA, White→SDCKB |
| No pull-ups | False edges, unreliable reads | 10kΩ external pull-ups to 3.3V |
| Sense pin floating | Zero response from controller | Connect GND/Sense to ground |
| Pin short on perfboard | A=1, B=1 or board resets | Test each data wire to GND individually |
Initial bus state (as inputs): A=1 B=0
TX: DeviceInfoRequest
RX: Frame=0x0500201C cmd=0x05 len=28
RX: OK!
Controller detected!
Functions: 0x00000001
- A=1, B=0 = correct idle state
- cmd=0x05 = Device Info Response
- len=28 = full 28-word payload (standard controller)
- Functions: 0x00000001 = controller function code