harden sensor acquisition after audit

This commit is contained in:
Jay
2026-08-17 09:48:05 -04:00
parent f1ac20fb4f
commit b8fe233ba6
11 changed files with 376 additions and 50 deletions
+1 -1
View File
@@ -2,6 +2,6 @@ build/
sdkconfig
captures/
sdkconfig.old
dependencies.lock
managed_components/
__pycache__/
.DS_Store
+34 -9
View File
@@ -6,7 +6,7 @@ accelerometer and L3G4200D gyroscope on a shared I2C bus.
This milestone does four things:
1. Detects and verifies both sensors by their identification registers.
2. Configures each sensor for 100 Hz raw acquisition.
2. Configures each sensor for a nominal 100 Hz raw output rate.
3. Emits timestamped, sensor-native raw readings over the XIAO USB connection.
4. Maps both sensors into a shared enclosure coordinate frame for validation.
@@ -29,10 +29,10 @@ Both breakouts share SDA, SCL, 3V3, and GND. The firmware checks both possible
## Sensor configuration
- ADXL345: 100 Hz output rate, full-resolution mode, +/-8 g. Nominal scale is
- ADXL345: nominal 100 Hz output rate, full-resolution mode, +/-8 g. Nominal scale is
3.9 mg/LSB.
- L3G4200D: 100 Hz output rate, 25 Hz bandwidth, +/-500 dps. Nominal scale is
17.5 mdps/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:
@@ -48,7 +48,13 @@ enclosure Y = -native X
enclosure Z = native Z
```
No calibration, filtering, or sensor fusion is performed yet.
No software calibration, software filtering, or sensor fusion is performed yet.
The ESP32-C3 polls at exactly 100 Hz, 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
buffering milestone.
## Build and flash
@@ -73,9 +79,28 @@ Exit the serial monitor with `Ctrl-]`.
After startup metadata, records use CSV:
```text
sequence,timestamp_us,accel_x_raw,accel_y_raw,accel_z_raw,gyro_x_raw,gyro_y_raw,gyro_z_raw,accel_native_x_raw,accel_native_y_raw,accel_native_z_raw,gyro_native_x_raw,gyro_native_y_raw,gyro_native_z_raw
sequence,poll_timestamp_us,accel_x_raw,accel_y_raw,accel_z_raw,gyro_x_raw,gyro_y_raw,gyro_z_raw,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
```
`timestamp_us` is the ESP32-C3 monotonic microsecond timer since boot. The axes
in the first six sample columns use the enclosure frame above. The appended native
columns are temporary diagnostics for the coordinated orientation test.
`poll_timestamp_us` is the ESP32-C3 monotonic time immediately before the 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. 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 capture tool auto-detects a single `/dev/cu.usbmodem*` device, writes only
validated numeric records to a real CSV, and reports sequence or timing problems:
```sh
python tools/capture_serial.py
```
+56 -6
View File
@@ -3,10 +3,16 @@
#include <stddef.h>
#define ADXL345_REG_DEVID 0x00
#define ADXL345_REG_OFSX 0x1E
#define ADXL345_REG_OFSY 0x1F
#define ADXL345_REG_OFSZ 0x20
#define ADXL345_REG_BW_RATE 0x2C
#define ADXL345_REG_POWER_CTL 0x2D
#define ADXL345_REG_INT_ENABLE 0x2E
#define ADXL345_REG_INT_SOURCE 0x30
#define ADXL345_REG_DATA_FORMAT 0x31
#define ADXL345_REG_DATAX0 0x32
#define ADXL345_REG_FIFO_CTL 0x38
#define ADXL345_DEVID_VALUE 0xE5
#define ADXL345_BW_RATE_100_HZ 0x0A
@@ -46,7 +52,8 @@ esp_err_t adxl345_init(adxl345_t *sensor, i2c_master_bus_handle_t bus, uint32_t
*sensor = (adxl345_t){0};
static const uint8_t candidate_addresses[] = {0x53, 0x1D};
for (size_t i = 0; i < sizeof(candidate_addresses); ++i) {
for (size_t i = 0;
i < sizeof(candidate_addresses) / sizeof(candidate_addresses[0]); ++i) {
const i2c_device_config_t config = {
.dev_addr_length = I2C_ADDR_BIT_LEN_7,
.device_address = candidate_addresses[i],
@@ -74,8 +81,24 @@ esp_err_t adxl345_init(adxl345_t *sensor, i2c_master_bus_handle_t bus, uint32_t
return ESP_ERR_NOT_FOUND;
}
// Configure while in standby, then enter measurement mode.
// The ADXL345 is not reset by an ESP32 warm reboot. Establish every state
// that could otherwise survive from an earlier firmware run.
esp_err_t err = write_register(sensor->device, ADXL345_REG_POWER_CTL, 0x00);
if (err == ESP_OK) {
err = write_register(sensor->device, ADXL345_REG_INT_ENABLE, 0x00);
}
if (err == ESP_OK) {
err = write_register(sensor->device, ADXL345_REG_FIFO_CTL, 0x00);
}
if (err == ESP_OK) {
err = write_register(sensor->device, ADXL345_REG_OFSX, 0x00);
}
if (err == ESP_OK) {
err = write_register(sensor->device, ADXL345_REG_OFSY, 0x00);
}
if (err == ESP_OK) {
err = write_register(sensor->device, ADXL345_REG_OFSZ, 0x00);
}
if (err == ESP_OK) {
err = write_register(sensor->device, ADXL345_REG_DATA_FORMAT, ADXL345_FORMAT_FULL_8G);
}
@@ -95,17 +118,45 @@ esp_err_t adxl345_init(adxl345_t *sensor, i2c_master_bus_handle_t bus, uint32_t
err = verify_register(sensor->device, ADXL345_REG_POWER_CTL, ADXL345_POWER_MEASURE);
}
if (err != ESP_OK) {
adxl345_deinit(sensor);
}
return err;
}
esp_err_t adxl345_read_raw(const adxl345_t *sensor, adxl345_sample_t *sample)
esp_err_t adxl345_deinit(adxl345_t *sensor)
{
if (sensor == NULL || sensor->device == NULL || sample == NULL) {
if (sensor == NULL) {
return ESP_ERR_INVALID_ARG;
}
if (sensor->device == NULL) {
sensor->address = 0;
return ESP_OK;
}
esp_err_t err = i2c_master_bus_rm_device(sensor->device);
if (err == ESP_OK) {
*sensor = (adxl345_t){0};
}
return err;
}
esp_err_t adxl345_read_raw(const adxl345_t *sensor, adxl345_sample_t *sample,
uint8_t *interrupt_source)
{
if (sensor == NULL || sensor->device == NULL || sample == NULL ||
interrupt_source == NULL) {
return ESP_ERR_INVALID_ARG;
}
esp_err_t err = read_registers(sensor->device, ADXL345_REG_INT_SOURCE,
interrupt_source, 1);
if (err != ESP_OK) {
return err;
}
uint8_t data[6] = {0};
esp_err_t err = read_registers(sensor->device, ADXL345_REG_DATAX0, data, sizeof(data));
err = read_registers(sensor->device, ADXL345_REG_DATAX0, data, sizeof(data));
if (err != ESP_OK) {
return err;
}
@@ -120,4 +171,3 @@ uint8_t adxl345_address(const adxl345_t *sensor)
{
return sensor != NULL ? sensor->address : 0;
}
+9 -3
View File
@@ -20,15 +20,21 @@ typedef struct {
int16_t z;
} adxl345_sample_t;
#define ADXL345_INT_SOURCE_DATA_READY 0x80
#define ADXL345_INT_SOURCE_OVERRUN 0x01
/** Detect, identify, and configure an ADXL345 for 100 Hz, full-resolution +/-8 g. */
esp_err_t adxl345_init(adxl345_t *sensor, i2c_master_bus_handle_t bus, uint32_t bus_speed_hz);
/** Read one sensor-native, uncalibrated XYZ sample. */
esp_err_t adxl345_read_raw(const adxl345_t *sensor, adxl345_sample_t *sample);
/** Remove the sensor from its I2C bus. Safe to call on an uninitialized handle. */
esp_err_t adxl345_deinit(adxl345_t *sensor);
/** Read INT_SOURCE followed by one sensor-native, uncalibrated XYZ sample. */
esp_err_t adxl345_read_raw(const adxl345_t *sensor, adxl345_sample_t *sample,
uint8_t *interrupt_source);
uint8_t adxl345_address(const adxl345_t *sensor);
#ifdef __cplusplus
}
#endif
+9 -3
View File
@@ -20,15 +20,21 @@ typedef struct {
int16_t z;
} l3g4200d_sample_t;
#define L3G4200D_STATUS_ZYXDA 0x08
#define L3G4200D_STATUS_ZYXOR 0x80
/** Detect, identify, and configure an L3G4200D for 100 Hz, 25 Hz BW, +/-500 dps. */
esp_err_t l3g4200d_init(l3g4200d_t *sensor, i2c_master_bus_handle_t bus, uint32_t bus_speed_hz);
/** Read one sensor-native, uncalibrated XYZ sample. */
esp_err_t l3g4200d_read_raw(const l3g4200d_t *sensor, l3g4200d_sample_t *sample);
/** Remove the sensor from its I2C bus. Safe to call on an uninitialized handle. */
esp_err_t l3g4200d_deinit(l3g4200d_t *sensor);
/** Read STATUS_REG followed by one sensor-native, uncalibrated XYZ sample. */
esp_err_t l3g4200d_read_raw(const l3g4200d_t *sensor, l3g4200d_sample_t *sample,
uint8_t *status);
uint8_t l3g4200d_address(const l3g4200d_t *sensor);
#ifdef __cplusplus
}
#endif
+40 -6
View File
@@ -8,11 +8,13 @@
#define L3G4200D_REG_CTRL3 0x22
#define L3G4200D_REG_CTRL4 0x23
#define L3G4200D_REG_CTRL5 0x24
#define L3G4200D_REG_STATUS 0x27
#define L3G4200D_REG_OUT_X_L 0x28
#define L3G4200D_WHO_AM_I_VALUE 0xD3
#define L3G4200D_CTRL1_100_HZ 0x1F
#define L3G4200D_CTRL4_500_DPS 0x90
#define L3G4200D_CTRL5_LPF2_OUT 0x02
#define L3G4200D_AUTO_INCREMENT 0x80
#define L3G4200D_TIMEOUT_MS 100
@@ -48,7 +50,8 @@ esp_err_t l3g4200d_init(l3g4200d_t *sensor, i2c_master_bus_handle_t bus, uint32_
*sensor = (l3g4200d_t){0};
static const uint8_t candidate_addresses[] = {0x69, 0x68};
for (size_t i = 0; i < sizeof(candidate_addresses); ++i) {
for (size_t i = 0;
i < sizeof(candidate_addresses) / sizeof(candidate_addresses[0]); ++i) {
const i2c_device_config_t config = {
.dev_addr_length = I2C_ADDR_BIT_LEN_7,
.device_address = candidate_addresses[i],
@@ -89,7 +92,9 @@ esp_err_t l3g4200d_init(l3g4200d_t *sensor, i2c_master_bus_handle_t bus, uint32_
err = write_register(sensor->device, L3G4200D_REG_CTRL4, L3G4200D_CTRL4_500_DPS);
}
if (err == ESP_OK) {
err = write_register(sensor->device, L3G4200D_REG_CTRL5, 0x00);
// Route LPF2 to the output. CTRL1 BW=01 selects its 25 Hz cutoff.
err = write_register(sensor->device, L3G4200D_REG_CTRL5,
L3G4200D_CTRL5_LPF2_OUT);
}
if (err == ESP_OK) {
// 100 Hz ODR, 25 Hz bandwidth, normal mode, all axes enabled.
@@ -101,19 +106,49 @@ esp_err_t l3g4200d_init(l3g4200d_t *sensor, i2c_master_bus_handle_t bus, uint32_
if (err == ESP_OK) {
err = verify_register(sensor->device, L3G4200D_REG_CTRL4, L3G4200D_CTRL4_500_DPS);
}
if (err == ESP_OK) {
err = verify_register(sensor->device, L3G4200D_REG_CTRL5,
L3G4200D_CTRL5_LPF2_OUT);
}
if (err != ESP_OK) {
l3g4200d_deinit(sensor);
}
return err;
}
esp_err_t l3g4200d_read_raw(const l3g4200d_t *sensor, l3g4200d_sample_t *sample)
esp_err_t l3g4200d_deinit(l3g4200d_t *sensor)
{
if (sensor == NULL || sensor->device == NULL || sample == NULL) {
if (sensor == NULL) {
return ESP_ERR_INVALID_ARG;
}
if (sensor->device == NULL) {
sensor->address = 0;
return ESP_OK;
}
esp_err_t err = i2c_master_bus_rm_device(sensor->device);
if (err == ESP_OK) {
*sensor = (l3g4200d_t){0};
}
return err;
}
esp_err_t l3g4200d_read_raw(const l3g4200d_t *sensor, l3g4200d_sample_t *sample,
uint8_t *status)
{
if (sensor == NULL || sensor->device == NULL || sample == NULL || status == NULL) {
return ESP_ERR_INVALID_ARG;
}
esp_err_t err = read_registers(sensor->device, L3G4200D_REG_STATUS, status, 1);
if (err != ESP_OK) {
return err;
}
uint8_t data[6] = {0};
uint8_t start_register = L3G4200D_REG_OUT_X_L | L3G4200D_AUTO_INCREMENT;
esp_err_t err = read_registers(sensor->device, start_register, data, sizeof(data));
err = read_registers(sensor->device, start_register, data, sizeof(data));
if (err != ESP_OK) {
return err;
}
@@ -128,4 +163,3 @@ uint8_t l3g4200d_address(const l3g4200d_t *sensor)
{
return sensor != NULL ? sensor->address : 0;
}
+76
View File
@@ -0,0 +1,76 @@
# Audit Hardening — 2026-08-17
This pass addresses the findings from the independent prototype-v0 audit. It
does not add calibration, BLE, persistent storage, filtering beyond the selected
L3G4200D hardware filter, or sensor fusion.
## Git checkpoints
- `f1ac20f`: exact pre-hardening firmware and axis-validation baseline
- The verified hardening changes are committed separately after this report
No remote repository is configured; these are local history checkpoints.
## Changes
- A clean build now selects `esp32c3` from `sdkconfig.defaults`.
- ADXL345 `INT_SOURCE` and L3G4200D `STATUS_REG` are read immediately before
their corresponding six-byte XYZ reads.
- Raw status bytes and a cumulative loop-overrun count are appended to CSV.
- A delayed acquisition loop resynchronizes rather than issuing catch-up bursts.
- ADXL345 FIFO, interrupt enables, and hardware offset registers are explicitly
reset during initialization so warm MCU resets are deterministic.
- L3G4200D LPF2 is routed to the output with `CTRL_REG5=0x02`, making the
configured 25 Hz cutoff effective.
- Both sensor drivers remove their I2C device handles after an initialization
failure and expose deinitializers.
- Candidate-address loops use an element count rather than byte size.
- The capture utility auto-detects a single USB modem port, writes numeric CSV
only, and reports sequence gaps, resets, timestamp anomalies, malformed
records, and ignored non-record lines.
- Documentation now distinguishes the MCU poll timestamp and nominal scale from
sensor sample timing and measured per-axis scale.
## Build validation
- Normal incremental build: pass
- Clean temporary copy with no `sdkconfig` or `build/`: pass
- Clean configuration selected `CONFIG_IDF_TARGET="esp32c3"` from
`sdkconfig.defaults`
- Hardened binary size: `0x242d0` bytes; 86% of the application partition free
## Hardware validation
The hardened firmware was flashed to the assembled XIAO ESP32-C3 prototype.
Both devices initialized successfully after a USB-triggered warm reset, including
all new register writes and the L3G4200D CTRL5 readback.
The capture `captures/hardening_freshness.csv` contains:
- 3,796 consecutive records
- Sequence gaps: 0
- Sequence resets: 0
- Timestamp anomalies: 0
- Timestamp interval: 10,000 us for all 3,795 intervals
- Rejected records: 0
- Acquisition loop overruns: 0
- ADXL345 DATA_READY clear at status time: 192 records (5.058%)
- ADXL345 overrun set: 0 records
- L3G4200D ZYXDA clear: 0 records
- L3G4200D ZYXOR set: 112 records (2.950%)
The ADXL345 clear events have a coherent 27/28-poll pattern, including adjacent
clear events when a device update likely occurs between the separate status and
data transactions. A clear bit is therefore conservatively treated as
stale/untrusted; a set bit proves unread data existed before the read. The gyro
overrun pattern directly confirms that its internal output clock is faster than
the MCU poll on this unit.
These results validate the audit finding that exact 100 Hz MCU polling does not
mean either sensor is synchronized to that clock. Status labeling is the scoped
prototype fix. Interrupt/FIFO-driven acquisition remains a later buffering task.
A final post-build/post-flash smoke capture added another 1,634 consecutive
records with zero gaps, timestamp anomalies, rejected records, ADXL345 overruns,
or acquisition-loop overruns. It again exposed ADXL345 DATA_READY clear on 81
polls and L3G4200D overrun on 48 polls.
+10 -1
View File
@@ -4,10 +4,19 @@ The enclosure was tested with the battery end treated as the bottom of the
reference orientation. The shared enclosure frame is +X right, +Y toward the
top, and +Z toward the cover.
## Audit clarification
The timing results below validate the ESP32-C3 polling loop, not the independent
sensor sample clocks. A subsequent review of the capture found a coherent repeated
ADXL345 sample approximately every 27 polls, consistent with an actual device ODR
near 96.3 Hz. This does not affect the static six-position axis result. Firmware v2
adds ADXL345 and L3G4200D freshness/overrun status to every record so dynamic data
can be interpreted correctly.
## Capture integrity
- 50,001 consecutive samples over 500.000 seconds
- Effective sample rate: exactly 100 Hz
- Effective MCU polling rate: exactly 100 Hz
- Timestamp interval: 10,000 us for every sample
- Sequence gaps: 0
- Axis-mapping mismatches between mapped and native columns: 0
+26 -9
View File
@@ -84,6 +84,7 @@ void app_main(void)
ESP_LOGE(TAG,
"ADXL345 not found at 0x53 or 0x1D (expected DEVID 0xE5): %s",
esp_err_to_name(err));
i2c_del_master_bus(bus);
return;
}
ESP_LOGI(TAG, "ADXL345 detected at 0x%02X; 100 Hz, +/-8 g, full resolution",
@@ -95,6 +96,8 @@ void app_main(void)
ESP_LOGE(TAG,
"L3G4200D not found at 0x69 or 0x68 (expected WHO_AM_I 0xD3): %s",
esp_err_to_name(err));
adxl345_deinit(&accelerometer);
i2c_del_master_bus(bus);
return;
}
ESP_LOGI(TAG, "L3G4200D detected at 0x%02X; 100 Hz, 25 Hz BW, +/-500 dps",
@@ -103,27 +106,35 @@ void app_main(void)
// Discard the gyroscope's visible startup transient before beginning the stream.
vTaskDelay(pdMS_TO_TICKS(500));
printf("# format=trikke_axis_validation_v1\n");
printf("# accel_scale_g_per_lsb=0.0039,gyro_scale_dps_per_lsb=0.0175\n");
printf("# format=trikke_freshness_validation_v2\n");
printf("# nominal_accel_scale_g_per_lsb=0.0039,"
"nominal_gyro_scale_dps_per_lsb=0.0175\n");
printf("# enclosure_axes=+x:right,+y:top,+z:toward_cover\n");
printf("# mapping=accel(x,y,z)=(native_y,-native_x,native_z);"
"gyro(x,y,z)=(native_x,native_y,native_z)\n");
printf("sequence,timestamp_us,accel_x_raw,accel_y_raw,accel_z_raw,"
printf("# status_masks=accel_data_ready:0x80,accel_overrun:0x01,"
"gyro_data_ready:0x08,gyro_overrun:0x80\n");
printf("sequence,poll_timestamp_us,accel_x_raw,accel_y_raw,accel_z_raw,"
"gyro_x_raw,gyro_y_raw,gyro_z_raw,"
"accel_native_x_raw,accel_native_y_raw,accel_native_z_raw,"
"gyro_native_x_raw,gyro_native_y_raw,gyro_native_z_raw\n");
"gyro_native_x_raw,gyro_native_y_raw,gyro_native_z_raw,"
"accel_int_source,gyro_status,loop_overrun_count\n");
uint32_t sequence = 0;
uint32_t read_error_count = 0;
uint32_t loop_overrun_count = 0;
TickType_t last_wake = xTaskGetTickCount();
while (true) {
adxl345_sample_t accel = {0};
l3g4200d_sample_t gyro = {0};
uint8_t accel_int_source = 0;
uint8_t gyro_status = 0;
const int64_t timestamp_us = esp_timer_get_time();
const esp_err_t accel_err = adxl345_read_raw(&accelerometer, &accel);
const esp_err_t gyro_err = l3g4200d_read_raw(&gyroscope, &gyro);
const esp_err_t accel_err = adxl345_read_raw(&accelerometer, &accel,
&accel_int_source);
const esp_err_t gyro_err = l3g4200d_read_raw(&gyroscope, &gyro, &gyro_status);
if (accel_err == ESP_OK && gyro_err == ESP_OK) {
const trikke_axes_sample_t enclosure_accel = map_accel_to_enclosure(&accel);
@@ -132,12 +143,14 @@ void app_main(void)
printf("%" PRIu32 ",%" PRId64 ",%" PRId32 ",%" PRId32 ",%" PRId32
",%" PRId32 ",%" PRId32 ",%" PRId32
",%" PRId16 ",%" PRId16 ",%" PRId16
",%" PRId16 ",%" PRId16 ",%" PRId16 "\n",
",%" PRId16 ",%" PRId16 ",%" PRId16
",%" PRIu8 ",%" PRIu8 ",%" PRIu32 "\n",
sequence, timestamp_us,
enclosure_accel.x, enclosure_accel.y, enclosure_accel.z,
enclosure_gyro.x, enclosure_gyro.y, enclosure_gyro.z,
accel.x, accel.y, accel.z,
gyro.x, gyro.y, gyro.z);
gyro.x, gyro.y, gyro.z,
accel_int_source, gyro_status, loop_overrun_count);
} else {
++read_error_count;
ESP_LOGE(TAG,
@@ -147,6 +160,10 @@ void app_main(void)
}
++sequence;
xTaskDelayUntil(&last_wake, TRIKKE_SAMPLE_TICKS);
if (xTaskDelayUntil(&last_wake, TRIKKE_SAMPLE_TICKS) == pdFALSE) {
// Do not issue a burst of back-to-back samples after a stalled output path.
++loop_overrun_count;
last_wake = xTaskGetTickCount();
}
}
}
+4 -1
View File
@@ -1,3 +1,7 @@
# Ensure a clean checkout targets the XIAO's ESP32-C3 rather than ESP-IDF's
# default ESP32 target.
CONFIG_IDF_TARGET="esp32c3"
# XIAO ESP32-C3 USB-C connector uses the chip's USB Serial/JTAG console.
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
@@ -6,4 +10,3 @@ CONFIG_FREERTOS_HZ=1000
# The XIAO ESP32-C3 carries 4 MB of flash.
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
+107 -7
View File
@@ -2,6 +2,7 @@
"""Capture Trikke sensor CSV output until interrupted with Ctrl-C."""
import argparse
import glob
import signal
import sys
from datetime import datetime
@@ -9,17 +10,61 @@ from pathlib import Path
import serial
EXPECTED_COLUMNS = [
"sequence",
"poll_timestamp_us",
"accel_x_raw",
"accel_y_raw",
"accel_z_raw",
"gyro_x_raw",
"gyro_y_raw",
"gyro_z_raw",
"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",
]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--port", default="/dev/cu.usbmodem1134101")
parser.add_argument("--baud", type=int, default=115200)
parser.add_argument("--port", help="serial port; auto-detected when omitted")
parser.add_argument(
"--baud",
type=int,
default=115200,
help="serial API baud rate (ignored by USB Serial/JTAG)",
)
parser.add_argument("--output", type=Path)
return parser.parse_args()
def resolve_port(requested_port: str | None) -> str:
if requested_port:
return requested_port
candidates = sorted(glob.glob("/dev/cu.usbmodem*"))
if not candidates:
raise RuntimeError("no /dev/cu.usbmodem* serial device found")
if len(candidates) > 1:
joined = ", ".join(candidates)
raise RuntimeError(f"multiple serial devices found ({joined}); specify --port")
return candidates[0]
def main() -> int:
args = parse_args()
try:
port = resolve_port(args.port)
except RuntimeError as exc:
print(f"Port error: {exc}", file=sys.stderr)
return 2
output = args.output or Path("captures") / datetime.now().strftime(
"motion_%Y%m%d_%H%M%S.csv"
)
@@ -35,21 +80,70 @@ def main() -> int:
signal.signal(signal.SIGTERM, request_stop)
sample_count = 0
print(f"Recording {args.port} to {output}; press Ctrl-C to stop", flush=True)
missing_sequence_count = 0
sequence_reset_count = 0
timing_anomaly_count = 0
ignored_line_count = 0
rejected_record_count = 0
previous_sequence = None
previous_timestamp_us = None
print(f"Recording {port} to {output}; press Ctrl-C to stop", flush=True)
try:
with serial.Serial(args.port, args.baud, timeout=0.25) as sensor, output.open(
with serial.Serial(port, args.baud, timeout=0.25) as sensor, output.open(
"w", encoding="utf-8", newline=""
) as capture:
capture.write(",".join(EXPECTED_COLUMNS) + "\n")
while not stop_requested:
raw_line = sensor.readline()
if not raw_line:
continue
line = raw_line.decode("utf-8", errors="replace").rstrip("\r\n")
capture.write(line + "\n")
fields = line.split(",")
if line and line[0].isdigit() and line.count(",") == 13:
if line.startswith("sequence,"):
if fields != EXPECTED_COLUMNS:
print("Firmware CSV header does not match capture schema", file=sys.stderr)
return 3
continue
if not line or not line[0].isdigit():
ignored_line_count += 1
continue
if len(fields) != len(EXPECTED_COLUMNS):
rejected_record_count += 1
continue
try:
values = [int(field) for field in fields]
except ValueError:
rejected_record_count += 1
continue
sequence = values[0]
timestamp_us = values[1]
if previous_sequence is not None:
expected_sequence = (previous_sequence + 1) & 0xFFFFFFFF
if sequence != expected_sequence:
if sequence > expected_sequence:
missing_sequence_count += sequence - expected_sequence
else:
sequence_reset_count += 1
print(
f" sequence discontinuity: expected {expected_sequence}, got {sequence}",
file=sys.stderr,
flush=True,
)
if (
previous_timestamp_us is not None
and timestamp_us - previous_timestamp_us != 10_000
):
timing_anomaly_count += 1
capture.write(line + "\n")
previous_sequence = sequence
previous_timestamp_us = timestamp_us
sample_count += 1
if sample_count % 500 == 0:
capture.flush()
@@ -58,7 +152,13 @@ def main() -> int:
print(f"Serial error: {exc}", file=sys.stderr)
return 1
print(f"Stopped after {sample_count} samples; saved {output}", flush=True)
print(
f"Stopped after {sample_count} samples; missing={missing_sequence_count}, "
f"resets={sequence_reset_count}, timing_anomalies={timing_anomaly_count}, "
f"rejected_records={rejected_record_count}, ignored_lines={ignored_line_count}; "
f"saved {output}",
flush=True,
)
return 0