A long Saturday that started with a dodgy JST connector and ended at 1am watching an ESP32-C6 read two simulated gear sensors simultaneously over Bluetooth, printing a rock-solid ratio of 2.000 to the Serial monitor. In between: a data viewer that became a serious tool, an algorithm that emerged from real ride data, and a hardware architecture decision that changes everything.
The data viewer
Everything that happened today was made possible by a tool that didn't exist yesterday. The GearSense ride data viewer started as a simple chart — three stacked plots of crank Hz, wheel Hz, gear ratio, and cumulative pulses, all synced and zoomable. It became something more.
The marquee selection tool lets you drag a bounding box over any region of any chart and toggle those points as "ignored" — removing sensor noise, EMI spikes, and transition artefacts from the visible data without destroying the underlying log. It filters by both time and value, so you can surgically remove the ratio-18 outliers at 775 seconds without touching the ratio-0.9 points that sit at the same timestamp. Ignored points don't break the line — the chart bridges across them so the underlying shape stays readable.
On top of that: a fit-line mode. Draw a marquee in fit mode and instead of ignoring the points, you get a linear regression line across them — mean value, slope, and point count. Multiple segments coexist in different colours. Everything saves to named views, which can be exported as JSON and reloaded later. Undo works across every operation.
The important thing the viewer revealed: manual data cleaning is not a workaround. It is the work. Every threshold in the algorithm that follows — every parameter, every filter — traces back to a specific judgment call made visually in this tool. The viewer is where the algorithm lives before it gets written in C.
What three rides taught us
Ride 1 (13.6 min, mostly unassisted) was the source of truth. After manual cleanup in the viewer, two fit segments emerged cleanly: mean 0.864 (n=18) and mean 1.102 (n=17), both with slopes indistinguishable from zero. Genuinely flat gear plateaus. This is what GearSense is supposed to detect.
Ride 2 (mostly motor-assisted) produced 13 stable segments offline but zero on-device — a BLE delivery bug, the algorithm itself was fine. A diagnostic counter added in v0.4.0 will confirm this on the next ride.
Ride 3 produced nothing. The offline detector found zero stable segments. The gear_events counter stayed at 0 for the entire 16 minutes. For a moment this looked like a firmware bug — but it wasn't. The wheel sensor was reading 66.6 Hz continuously from the very first second, before the bike even moved. A physically-inspected spoke magnet had drifted oblique to the KY-003 sensor. A JST connector that "felt a bit dodgy" when plugged in was probably intermittent. The hardware had failed, not the firmware. The algorithm was correctly reporting: there is no useful data here.
The algorithm — derived from real data
Running the Python reference implementation against all three rides produced something concrete. Three stages, each validated against real ride data rather than theoretical assumptions.
-
01
Validity filter Reject samples where wheel_hz ≥ 66.0 (the motor EMI plateau), crank_hz < 0.5 (not pedalling), or wheel_hz < 0.3 (not rolling). These thresholds came directly from inspecting the data — the 66.6 Hz ceiling is a real feature of this specific motor controller's switching frequency. After filtering, ride 1 goes from 3,257 samples to 936 valid ones.
-
02
Adaptive stable-run detector For each valid sample, extend the current run if the ratio stays within BAND=0.10 of the running mean. If a sample breaks the band and the run was ≥ MIN_RUN=10 samples long, record it as a stable gear segment. On ride 1, this found 20 stable segments — two of which matched the manually-identified fit segments within 0.012 and 0.025 of their mean ratio, without being told where to look.
-
03
Cross-ride clustering Cluster the segment means with tolerance 0.07. From ride 1 alone: 4 confirmed gears (0.254, 0.716, 0.883, 1.032) with support 3–6 each, plus 3 tentative single-segment gears. For a 7-speed Shimano, that's most of the ladder from a single 13-minute ride.
The live gear ladder — v0.4.0
The on-device firmware now builds a gear ladder in real time as you ride. As soon as a stable run reaches MIN_RUN samples, its mean ratio is folded into the ladder — either matching an existing entry (within LADDER_TOL=0.07) or starting a new one. The big green gear-number box on the display shows the 1-based rank of whichever gear you're currently in, live, the first time that gear is confirmed.
The numbering can shift mid-ride as new lower gears are discovered. This is expected behaviour — the ladder is a live, growing view of "distinct gears seen so far," not a fixed pre-calibrated table.
The hardware problem — and its solution
Ride 3 crystallised something that had been building across several sessions: the KY-003 Hall-effect sensor with a spoke magnet is the weakest link in the system. Not conceptually wrong — mechanically fragile. The magnet drifts. The JST connector feels dodgy. Under motor load, EMI overwhelms the signal entirely.
The solution: off-the-shelf BLE cycling speed and cadence sensors. The Magene S3+ uses a geomagnetic sensor — a 3-axis magnetometer — to detect rotation. As the sensor rotates with the crank arm or wheel hub, the Earth's own magnetic field traces a circle in the sensor's reference frame. Each full rotation is one full cycle. No external magnet. No alignment to maintain. No wire running near the motor controller.
Mounting the wheel speed sensor on the front fork eliminates the motor EMI problem entirely. The rear hub motor and the front fork are separated by the full length of the cargo bike — roughly 1.5 metres. At that distance, the Earth's field dominates comfortably over the motor's field.
And because these sensors use the standard Bluetooth SIG Cycling Speed and Cadence profile (UUID 0x1816), GearSense works with sensors from any manufacturer that implements the spec. Garmin. Wahoo. Magene. The sensor you already have on your bike. BYO sensors — zero additional hardware purchase for that customer segment.
KY-003 Hall sensor, spoke magnet, JST connector, wire routed near motor. Prone to magnet drift, connector intermittency, EMI saturation under load. Complex install.
Two Magene S3+ sensors. One clips to crank arm (cadence). One clips to front fork (speed, away from motor). No magnets. No wiring. Standard BLE. Trivial install on any bike.
The new architecture — one wire
Removing the sensor wires forces a rethink of everything else. With cadence and speed arriving over BLE, the only physical connections GearSense needs are to the bike is power and inject throttle signals. The bike's existing throttle connector provides exactly that: a 4.65V rail that can power the ESP32 via a small MT3608 boost converter stepping it up to a stable 5V. The same connector will carry the throttle injection signal when Phase 3 is ready.
Mark III: one connection. One M6 connector. Power in, throttle injection out, sensors over Bluetooth. The LiPo battery is gone. Nothing to charge. Nothing to forget to charge.
The C6 — a better chip for the job
The new board is the Waveshare ESP32-C6-Touch-LCD-1.47. The C6 has a dual-core RISC-V architecture — a high-performance core at 160 MHz and a separate low-power core — which makes it a natural fit for running BLE scanning and LVGL display code simultaneously. It also has a built-in TF card slot, which means the SD card storage tier (ride logs, persistent gear ladders, privacy-conscious local data) is included at no extra hardware cost.
The validation — tonight at 1am
Two LOLIN32 boards were flashed as CSC sensor simulators — one advertising as "GS_Cadence" at 75 RPM, one as "GS_Speed" at 150 RPM. The C6 was flashed as BLE central using NimBLE-Arduino. After some API version fixes and enabling USB CDC on boot, the Serial monitor showed:
# Seeking: 'GS_Cadence' and 'GS_Speed'
# Found: GS_Speed
# Found: GS_Cadence
# Subscribed to GS_Speed
# Subscribed to GS_Cadence
crank_hz=1.000 wheel_hz=2.000 ratio=2.000
crank_hz=1.000 wheel_hz=2.000 ratio=2.000
crank_hz=1.000 wheel_hz=2.000 ratio=2.000
# Status: cadence=OK(1.000Hz) speed=OK(2.000Hz) ratio=2.000
crank_hz=1.000 wheel_hz=2.000 ratio=2.000
crank_hz=1.000 wheel_hz=2.000 ratio=2.000
1.000 Hz crank (75 RPM ÷ 60). 2.000 Hz wheel (120 RPM ÷ 60). Ratio exactly 2.000. Rock solid. Not a single dropout across twenty minutes. The contrast with ride 3 — where the wheel sensor was saturated at 66.6 Hz before the bike even moved — is everything. The BLE CSC architecture solves, at a stroke, the three failure modes that have been plaguing the hardware: connector reliability, magnet alignment, and motor EMI interference.
Loading…