12 KiB
Trikke Motion Telemetry Logger
Prototype v0 firmware for a Seeed Studio XIAO ESP32-C3 with an ADXL345 accelerometer and L3G4200D gyroscope on a shared I2C bus.
This milestone does four things:
- Detects and verifies both sensors by their identification registers.
- Configures each sensor for a nominal 100 Hz raw output rate.
- Emits framed, timestamped binary readings over reliable BLE, with the audited direct-USB path retained as a build option.
- Maps both sensors into a shared enclosure frame and carries the metadata needed to derive calibrated readings without replacing raw data.
The wired sensor path and reliable BLE transport are proven. The repository now also contains an Android 12+ prototype recorder that stores the authoritative binary stream durably before acknowledging each frame. The macOS reference client remains available for transport diagnosis.
Wiring
| Signal | XIAO pin | ESP32-C3 GPIO |
|---|---|---|
| SDA | D4 | GPIO6 |
| SCL | D5 | GPIO7 |
| Sensor power | 3V3 | — |
| Sensor ground | GND | — |
Both breakouts share SDA, SCL, 3V3, and GND. The firmware checks both possible 7-bit I2C addresses for each device:
- ADXL345:
0x53or0x1D; expectedDEVIDis0xE5. - L3G4200D:
0x69or0x68; expectedWHO_AM_Iis0xD3.
The XIAO ESP32-C3 external antenna is installed for the BLE transport.
Sensor configuration
- ADXL345: nominal 100 Hz output rate, full-resolution mode, +/-8 g. Nominal scale is 3.9 mg/LSB.
- L3G4200D: nominal 100 Hz output rate, LPF2 selected with a 25 Hz cutoff, +/-500 dps. Nominal scale is 17.5 mdps/LSB.
The enclosure coordinate frame is:
- +X points right in the reference photograph.
- +Y points toward the top of the enclosure.
- +Z points out of the board toward the enclosure cover.
The L3G4200D already matches that frame. The ADXL345 mapping is:
enclosure X = native Y
enclosure Y = -native X
enclosure Z = native Z
Host-side software calibration is applied after enclosure-axis mapping. Accelerometer offset and per-axis scale were measured with a six-face enclosure test. Gyroscope zero-rate bias and polarity were measured; its 17.5 mdps/LSB scale remains the nominal datasheet value. Mapped raw counts are stored directly, and sensor-native counts are reconstructed losslessly from the documented mapping. No software filtering or sensor fusion is performed yet.
The BLE reconnect fixture measured scheduling jitter without drift: among 2,575
contiguous sample intervals, 1,293 differed from exactly 10 ms; absolute timing
deviation had a 4 us median, 160 us p95, 170 us p99, and 780 us maximum, while
cumulative error was only 152 us over 25.75 seconds. Bluetooth tasks can preempt
the polling task, but every record carries its actual acquisition timestamp.
Future fusion, integration, and filtering must derive each dt from those
timestamps rather than assume a uniform 10 ms interval. Sensor DRDY/FIFO
acquisition remains the later refinement for reducing the jitter itself.
The 200-second Android acceptance capture provides the current loaded-system characterization. Of 19,983 contiguous intervals, 8,745 (43.8%) were off-grid. Across all intervals, absolute deviation was 0 us median, 40 us p95, 240 us p99, and 1,630 us maximum; among off-grid intervals it was 10 us median, 60 us p95, and 750 us p99. Cumulative error was -726 us. These timestamp statistics are a lower bound on acquisition disturbance because firmware timestamps the poll before performing both I2C reads. Six ADXL345 overrun flags show that preemption between the timestamp and the physical read occasionally crossed a sensor sample boundary even though the recorded timestamp delta did not expose the full delay. The loss is rare (0.03%), explicitly flagged, and reinforces the planned data-ready/FIFO acquisition refinement.
After the BLE session handshake, the ESP32-C3 schedules nominal 100 Hz polling in a dedicated acquisition task, but each sensor has an independent internal sample clock. The status registers are read immediately before each XYZ read so a consumer can distinguish a fresh sample from a repeated poll and identify gyro overruns. Hardware data-ready interrupts and FIFO acquisition are deferred to the later sensor-side acquisition refinement.
Completed samples enter a statically reserved 3072-record RAM queue, providing
30.72 seconds of transport-outage tolerance at 100 Hz when the transport reports
backpressure or failure accurately. A failed write retains and retries its
packet while this queue accumulates the backlog. A lower-priority output task
batches up to eight records into versioned TRK1 frames, isolating acquisition
from brief transport stalls. CRC, packet and sample sequences, timestamps, and
cumulative loss/overrun counters make permanent loss detectable by the receiver.
The 30.72-second depth is based on end-to-end phone behavior rather than the
visible Bluetooth-off interval alone. A five-second Android Bluetooth outage
produced roughly 12.5 seconds of effective transport interruption and overflowed
the prior 10.24-second queue by 223 samples. Repeating the same test with the
3,072-record queue delivered all 19,984 samples contiguously; one replay was
durably deduplicated and all loss counters remained zero. The capture and its
completed Android sidecar are preserved under tests/fixtures/.
The static queue occupies 96 KiB. The BLE build leaves 132,389 bytes of DRAM for task stacks, NimBLE runtime allocation, and future features; the 200-second acceptance capture proves the present configuration, but another large buffer or memory-heavy feature requires a fresh runtime and build-time memory review.
The default build exposes a custom NimBLE GATT service named TrikkeSensor.
Acquisition remains stopped until a valid session-token handshake establishes a
clean capture boundary. Each unchanged TRK1 frame is then fragmented as needed,
persisted by the receiver, and acknowledged by exact packet sequence. A missing
ACK causes a replay; disconnect or subscription loss retains the same frame and
restarts it from byte zero after a same-token reconnection. The receiver
deduplicates these deliberate replays.
The preserved USB telemetry option uses ESP-IDF's interrupt-driven USB Serial/JTAG driver behind a transport-neutral state machine. A complete frame is submitted atomically to the driver ring and remains pending across bounded drain timeouts; firmware does not resubmit it ambiguously or dequeue another frame. The 3072-sample queue therefore also protects a connected endpoint that temporarily stops draining.
USB drain confirms that bytes left the device endpoint, not that the capture
application persisted them. Packet/sample sequences and CRC expose loss after
the fact. BLE COMPLETE instead means the receiver acknowledged the exact frame
after persistence.
Measured end-to-end framing overhead is about 2.47 kB/s at 100 Hz, or 8.47 MiB/hour before BLE link overhead.
Build and flash
ESP-IDF 6.0.2 is installed at:
/Users/jay/.espressif/v6.0.2/esp-idf
For each new terminal:
source /Users/jay/.espressif/v6.0.2/esp-idf/export.sh
idf.py build
idf.py -p /dev/cu.usbmodem1134101 flash
BLE is the default. idf.py menuconfig -> Telemetry transport can select the
preserved direct USB transport for wired regression work. A separate build tree
can verify that selection without disturbing the normal BLE configuration:
idf.py -B build-usb -D SDKCONFIG=build-usb/sdkconfig \
-D 'SDKCONFIG_DEFAULTS=sdkconfig.defaults;sdkconfig.usb.defaults' build
Install the reference host dependencies and capture BLE telemetry with:
python3 -m pip install -r requirements.txt
python3 tools/capture_ble.py
The client scans for TrikkeSensor, stores only complete CRC-valid TRK1
frames, flushes the binary and CSV outputs, and only then writes the application
ACK. See the BLE transport specification.
Android recorder
The android/ project is the prototype ride recorder. Each new recording sends
an idempotent session token that gives the C3 a repeatable software session
boundary with empty queues, reset counters, and acquisition starting only after
the phone is ready. Reconnects reuse that token and preserve the outage backlog.
The app reassembles and validates complete frames, appends each new frame to
app-private storage, calls fsync, and only then sends ACK1. Exact replays are
acknowledged without being appended twice. Recording runs in a connected-device
foreground service with a partial wake lock so it can continue while the screen
is off.
Build its debug APK with:
cd android
./gradlew testDebugUnitTest lintDebug assembleDebug
See the Android recorder guide for installation, capture, export, and the first coordinated phone test.
TRK1 output and USB validation
After readable startup metadata, the device emits framed binary. Each sample is a 20-byte record containing mapped raw sensor counts, timing, sequence, and the two raw status bytes. See the complete wire-format specification.
Binary is the authoritative capture format. The host tools render it back to the same diagnostic CSV schema used during calibration:
sequence,poll_timestamp_us,accel_x_raw,accel_y_raw,accel_z_raw,gyro_x_raw,gyro_y_raw,gyro_z_raw,accel_x_mg,accel_y_mg,accel_z_mg,gyro_x_mdps,gyro_y_mdps,gyro_z_mdps,accel_native_x_raw,accel_native_y_raw,accel_native_z_raw,gyro_native_x_raw,gyro_native_y_raw,gyro_native_z_raw,accel_int_source,gyro_status,loop_overrun_count
poll_timestamp_us is reconstructed from each frame's base timestamp and 10 us
record deltas. It represents the ESP32-C3 monotonic time immediately before status
and data reads. It is not the sensors' physical sample time. The axes in the first
six sample columns use the enclosure frame above. Calibrated acceleration is in
integer milligravity (mg), and bias-corrected angular rate is in integer
millidegrees per second (mdps). Native columns remain available for diagnostics.
Status bits:
accel_int_source & 0x80: an unread ADXL345 sample existed when status was checked. If clear, treat the following sample as stale/untrusted; a sample can arrive in the short interval between the status and data transactions.accel_int_source & 0x01: ADXL345 unread data was overwritten.gyro_status & 0x08: L3G4200D sample is fresh.gyro_status & 0x80: L3G4200D data overran before it was read.loop_overrun_count: cumulative acquisition deadlines missed; the loop resynchronizes after a miss instead of issuing catch-up bursts.
The binary capture tool auto-detects a single /dev/cu.usbmodem* device, stores
only CRC-valid frames, renders CSV, and reports packet, sample, timing, status,
drop, overrun, timestamp-saturation, and trailing-partial-byte totals. An
optional --wire path preserves every received byte, including startup text and
damaged or partial frames, for forensic comparison:
python tools/capture_binary.py --reset --wire captures/session.wire
--reset normalizes the USB DTR/RTS state, clears bytes from the prior session,
and resets the C3 while the new capture is already open. Omit it when attaching
to an intentionally uninterrupted stream.
An existing .trk stream can be decoded again without hardware:
python tools/decode_binary.py captures/session.trk captures/session.csv
Live capture and offline decoding use the same integrity tracker, including wrap-aware packet/sample gap classification.
tools/capture_serial.py remains available only for decoding captures from the
older CSV-v3 firmware snapshots.