diff --git a/.gitignore b/.gitignore index 79c2e04..96c6735 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,6 @@ build/ sdkconfig captures/ sdkconfig.old -dependencies.lock managed_components/ +__pycache__/ .DS_Store diff --git a/README.md b/README.md index 8b9c225..7cd854c 100644 --- a/README.md +++ b/README.md @@ -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 +``` diff --git a/components/adxl345/adxl345.c b/components/adxl345/adxl345.c index e29d355..c589871 100644 --- a/components/adxl345/adxl345.c +++ b/components/adxl345/adxl345.c @@ -3,10 +3,16 @@ #include #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; } - diff --git a/components/adxl345/include/adxl345.h b/components/adxl345/include/adxl345.h index c151a2f..d61eecb 100644 --- a/components/adxl345/include/adxl345.h +++ b/components/adxl345/include/adxl345.h @@ -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 - diff --git a/components/l3g4200d/include/l3g4200d.h b/components/l3g4200d/include/l3g4200d.h index 2609ed7..54d61ae 100644 --- a/components/l3g4200d/include/l3g4200d.h +++ b/components/l3g4200d/include/l3g4200d.h @@ -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 - diff --git a/components/l3g4200d/l3g4200d.c b/components/l3g4200d/l3g4200d.c index 82f4424..d650103 100644 --- a/components/l3g4200d/l3g4200d.c +++ b/components/l3g4200d/l3g4200d.c @@ -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; } - diff --git a/docs/audit-hardening-2026-08-17.md b/docs/audit-hardening-2026-08-17.md new file mode 100644 index 0000000..b664c30 --- /dev/null +++ b/docs/audit-hardening-2026-08-17.md @@ -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. diff --git a/docs/axis-validation-2026-08-16.md b/docs/axis-validation-2026-08-16.md index 07f510b..bf06155 100644 --- a/docs/axis-validation-2026-08-16.md +++ b/docs/axis-validation-2026-08-16.md @@ -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 diff --git a/main/trikke_sensor_main.c b/main/trikke_sensor_main.c index a9b81c1..5249ace 100644 --- a/main/trikke_sensor_main.c +++ b/main/trikke_sensor_main.c @@ -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(); + } } } diff --git a/sdkconfig.defaults b/sdkconfig.defaults index d88d576..74cd29c 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -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 - diff --git a/tools/capture_serial.py b/tools/capture_serial.py index d07965c..b9152af 100644 --- a/tools/capture_serial.py +++ b/tools/capture_serial.py @@ -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,30 +80,85 @@ 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: - sample_count += 1 - if sample_count % 500 == 0: - capture.flush() - print(f" {sample_count} samples captured", flush=True) + 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() + print(f" {sample_count} samples captured", flush=True) except serial.SerialException as exc: 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