Skip to content

Latest commit

 

History

History
253 lines (171 loc) · 18.2 KB

File metadata and controls

253 lines (171 loc) · 18.2 KB

Architecture

ESP-IDF v5.4 application running FreeRTOS tasks on an ESP32-S3.

Tasks

Task Core Priority Stack Role
flight_task 1 (pinned) 5 4096 Sensor polling, state machine, launch detect
log_write_task 0 (pinned) 5 4096 SPIFFS writes (decoupled from FIFO reads)
video_task 0 (pinned) 4 4096 Camera capture → AVI on SD card (optional)
serial_cmd_task any 3 4096 Serial command handler (stdin/stdout)

flight_task, log_write_task, and video_task only run in flight mode. video_task requires CONFIG_FORCE4_CAMERA. serial_cmd_task runs in both modes.

Modules

main.c            Boot sequence, GPIO mode select, I2C/SPI bus init, task creation
adxl375.c/.h      ADXL375 sensor driver (I2C, 400 kHz, addr 0x53; see reference/ADXL375.md)
flight_logger.c/.h  State machine + ring buffer + CSV recording
storage.c/.h      SPIFFS mount, flight file lifecycle, binary record writing
serial_cmd.c/.h   Command dispatch, response framing
led.c/.h          LEDC PWM patterns (breathe, flash, transfer, blink)
sdcard.c/.h       SD card support (ifdef CONFIG_FORCE4_SD_CARD; see below)
camera.c/.h       OV3660 camera support (ifdef CONFIG_FORCE4_CAMERA; see below)
video.c/.h        MJPEG AVI video recording during flight (ifdef CONFIG_FORCE4_CAMERA)

State machine

IDLE --(|a| > 3g for 50ms)--> LOGGING --(60s)--> COOLDOWN --(3s)--> IDLE
  |  \--("trigger" cmd)----->/                                        |
  |                  "transfer" cmd (from any IDLE state)             |
  +----------- "resume" cmd or 30s timeout <-- TRANSFER <------------+

Flight recording pipeline

  1. flight_task (Core 1) drains ADXL375 FIFO (up to 32 samples/read via I2C burst)
  2. In IDLE: samples go into a 1600-entry pre-trigger ring buffer (2s at 800 Hz)
  3. On launch detect: pre-trigger buffer + live samples are pushed into a 16,000-entry PSRAM ring buffer (s_log_ring)
  4. log_write_task (Core 0) drains s_log_ring → binary records on SPIFFS. Flash erase stalls only Core 0; flight_task keeps reading the FIFO uninterrupted
  5. After 60s: flight_task signals flush, log_write_task closes the file, 3s cooldown, return to IDLE

Serial protocol

Bidirectional over USB Serial/JTAG controller (not USB-OTG). Requires usb_serial_jtag_driver_install() + usb_serial_jtag_vfs_use_driver() for printf/getchar to work.

  • Boot marker: FORCE4:READY\n (printed at startup for diagnostics; mission-control does not wait for it)
  • Response framing: ---BEGIN---\n ... ---END---\n around every command response
  • Commands: ls, cat <file>, rm <file>, format, status, trigger, transfer, resume, ping, go, abort, help (plus ls --sd, cat --sd <file>, rm --sd <file>, format --sd, sdtest [N], sdinfo when SD enabled; photo, vidtest when camera enabled)
  • Two-step binary transfer protocol (receiver-initiated, inspired by XModem):
    1. cat <file> (or cat --sd <file>) prepares the transfer and responds with ready size:N\n inside the normal ---BEGIN---/---END--- framing. No binary is sent yet.
    2. go — sent by the host when ready — streams exactly N raw bytes with no framing whatsoever. LF→CRLF conversion is disabled before and restored after. go returns immediately (no ---BEGIN---/---END---).
    3. abort cancels any pending transfer and returns ok\n inside normal framing.
    • This eliminates the race where stale JPEG/binary bytes remain in the USB hardware FIFO from a previous session and corrupt the next command's response. The device never streams binary until the host explicitly requests it.
    • mission-control uses Device.read_binary(cmd) → calls send(cmd) to get the ready size:N response, then _recv_raw(N) which flushes the kernel RX buffer, writes go\r\n, and reads exactly N bytes. exit_transfer() sends a best-effort abort before resume to clear any leftover pending state.
    • USB packet fragmentation: even when the device writes 512-byte chunks via fwrite, the host receives them as many small os.read returns (2–64 bytes each). This is normal USB Serial/JTAG behavior — the USB FIFO is 64 bytes per packet at Full Speed. The receiver loop must accumulate reads until len(data) == nbytes.
    • First transfer after firmware flash may stall briefly (>5 s idle gap) while the device finishes initializing post-boot. Allow ~12 s after power-on before issuing binary transfers; subsequent transfers on a warm device are reliable.

Partition layout

Name Type Offset Size
nvs data 0x9000 24K
phy_init data 0xF000 4K
factory app 0x10000 1.5M
storage SPIFFS 0x190000 ~6.4M

ADXL375 configuration

Register Value Purpose
BW_RATE (0x2C) 0x0D 800 Hz ODR
POWER_CTL (0x2D) 0x08 Measurement mode
DATA_FORMAT (0x31) 0x0B Full resolution, right-justified
FIFO_CTL (0x38) 0x90 Stream mode, watermark=16

Scale: 49 mg/LSB (raw * 0.049 = g). Timestamps estimated backward from read time at 1250us intervals.

General ADXL375 notes (activity detection, register reference): reference/ADXL375.md.

Connection recovery

I2C can have stuck-bus conditions if the ESP32 resets mid-transaction while the ADXL375 keeps power — the sensor may hold SDA low. adxl375_reinit() calls i2c_master_bus_reset() + 50 ms delay to clock out the stuck state, then re-probes. main.c retries every 5 s for up to 5 minutes. See reference/I2C.md for details.

Interrupt-driven idle

ADXL375 INT1 → GPIO4 drives flight_task via FreeRTOS task notification instead of polling.

INT1 requires its own wire in addition to SDA, SCL, 3V3, and GND. Without INT1 connected, the 50 ms safety timeout exceeds the FIFO overflow window (32 samples at 800 Hz = 40 ms), causing sample loss and degraded data quality (~505 Hz effective rate). With INT1, the watermark interrupt wakes the task at ~20 ms intervals, well before overflow.

State INT1 source Behavior
IDLE sleeping Activity Task blocked; wakes on accel change > 2.34g every 2s
IDLE active Watermark Polls FIFO, fills pre-buffer, detects launch
LOGGING Watermark Blocks until 16 samples ready (20ms at 800 Hz)
TRANSFER (any) FIFO drained, interrupts ignored

After 5 seconds of quiet (< 2g) in active IDLE, flight_task reconfigures for activity interrupt and blocks again.

Activity detection uses AC-coupled mode (ACT_INACT_CTL bit 7 = 1): the chip measures change from a baseline, not absolute acceleration. This ignores gravity in any orientation. The baseline is captured when INT_SOURCE is read, which also re-arms the detector. Both adxl375_config_activity_int() and the 2s polling fallback read INT_SOURCE, so the baseline is always taken from a resting state.

Edge-trigger race: INT1 is POSEDGE-triggered. If the pin goes high between arming and ulTaskNotifyTake, the edge is missed. The 2s timeout path reads INT_SOURCE as a fallback — if the activity bit is set, active polling is entered regardless of whether the ISR fired.

flight_logger_enter_transfer() calls xTaskNotifyGive() to wake flight_task if it is blocked waiting for an interrupt.

The ISR handler (int1_isr_handler) is IRAM-resident and only calls vTaskNotifyGiveFromISR.

Flash I/O and data gaps

SPIFFS sector erases (200–400 ms) stall both CPU cores by default. At 800 Hz the ADXL375 FIFO (32 samples = 40 ms) overflows during every erase, causing ~320-sample gaps repeating every ~1 s. Three fixes are all required:

  1. CONFIG_SPI_FLASH_AUTO_SUSPEND=y — the MSPI controller suspends the erase when the CPU needs a cache fill, so Core 1 is not frozen for the full erase duration. See reference/XIAO-ESP32S3.md.

  2. Dual-task architectureflight_task (Core 1) only reads the FIFO into s_log_ring; log_write_task (Core 0) handles all SPIFFS writes. Even if log_write_task blocks during an erase, flight_task keeps draining the FIFO uninterrupted.

  3. PSRAM ring buffer — binary records are 20 bytes/sample, so 800 Hz requires ~16 KB/s, well within SPIFFS's ~21 KB/s sustained throughput. s_log_ring holds 16,000 entries (~20 s at 800 Hz) allocated from PSRAM via heap_caps_malloc(MALLOC_CAP_SPIRAM), absorbing erase stalls (200–400 ms each) rather than compensating for a throughput deficit.

Do not write to SPIFFS from flight_task. Any synchronous flash call (including fflush) from the FIFO-reading task reintroduces gaps.

PSRAM and AUTO_SUSPEND compatibility

CONFIG_SPIRAM=y and CONFIG_SPI_FLASH_AUTO_SUSPEND=y are compatible on ESP32-S3 and must both be enabled. Do not use EXT_RAM_BSS_ATTR to place large arrays in PSRAM — the BSS zero-fill that runs during startup (before app_main() and before the USB Serial/JTAG driver is installed) conflicts with early boot and causes a silent crash with no serial output. Use heap_caps_malloc(MALLOC_CAP_SPIRAM) inside flight_task instead.

Flight file lifecycle

  • A zero-length "ready" file (flight_NNN) is pre-opened at boot and after each cooldown — no file-open latency at launch detection.
  • Flight files contain packed binary records (flight_record_t, 20 bytes each: int64_t timestamp_us, float ax_g, float ay_g, float az_g). Python struct format: '<qfff'.
  • mission-control pull fetches raw binary via cat and converts to CSV locally. With no argument it pulls all non-zero files that don't already have a local .csv; a filename argument pulls that file specifically.
  • Flight number is persisted in NVS (namespace force4, key flight_num) and survives SPIFFS wipes.
  • Numbers wrap 999 → 000.
  • On boot, if the NVS counter points to a non-empty file (crash mid-flight), the counter is advanced to preserve the partial recording.
  • flight_logger_enter_transfer() closes the ready file before serial_cmd can delete files.

Code conventions

  • Large arrays use static buffers (not task stack) — see s_samples in flight_logger.c. Exception: s_log_ring is heap-allocated from PSRAM at task startup; see PSRAM note above.
  • Task stack size is 4096 bytes — be conservative with stack allocations; avoid large local arrays.
  • ESP_LOGI/ESP_LOGE share the USB serial with printf. The ---BEGIN---/---END--- framing in command responses separates command output from log noise.
  • flight_state_t is volatile — set atomically from any task, read only from flight_task.

General XIAO ESP32-S3 notes (USB Serial/JTAG setup, flash erase stalls): reference/XIAO-ESP32S3.md.

SD card (optional)

Enabled by CONFIG_FORCE4_SD_CARD in main/Kconfig.projbuild (default off). All SD code is behind #ifdef; when disabled, sdcard.h provides no-op inline stubs.

SPI bus

SPI2_HOST is initialized in main.c only when SD card support is enabled. The ADXL375 uses I2C on a separate bus, so the SD card owns SPI2_HOST exclusively.

Mount and filesystem

SD card is mounted at /sd as FAT via esp_vfs_fat_sdspi_mount(). If no card is inserted, sdcard_init() logs a warning and all SD operations return errors gracefully.

FAT uppercases filenames. test_sd is stored and returned as TEST_SD. Host code that searches for files by name must use case-insensitive comparison; mission-control sdtest does this with f.lower().

Use uint64_t for space arithmetic. size_t is 32-bit on ESP32-S3 (max ~4 GiB). A 32 GB card has ~30 GiB of usable space — the product free_clusters * sectors_per_cluster * 512 overflows silently, producing a wrong result (~1.8 GB reported instead of ~29.7 GiB). sdcard_print_info() uses uint64_t throughout.

sys/statvfs.h is not available in ESP-IDF's newlib. Use the FATFS API directly: f_getfree("0:", &free_clust, &fs) where "0:" is the first mounted FAT volume. fs->csize gives sectors per cluster; sector size is 512 bytes when FF_MIN_SS == FF_MAX_SS == 512 (ESP-IDF default).

Observed layout on the test 32 GB card:

Field Value
Card capacity 62,333,952 sectors × 512 B = 29.72 GiB
FAT cluster size 32 sectors × 512 B = 16 KiB
FAT total clusters 1,946,888

Commands

Command Description
ls --sd List files on SD card
cat --sd <file> Prepare SD file for transfer; returns ready size:N
rm --sd <file> Delete file from SD card
format --sd Format SD card as FAT (requires TRANSFER state)
sdtest [N] Write N-byte cycling pattern to test_sd on SD
sdinfo Print mount status, card capacity, and FATFS cluster info

mission-control supports --sd on ls, cat, rm, pull, df, wipe, format, plus sdtest and sdinfo subcommands.

format (no --sd) reformats the SPIFFS storage partition via esp_spiffs_format(), which unmounts, erases, and remounts the filesystem. NVS (flight counter) is in a separate partition and is unaffected. Requires TRANSFER state.

Camera (optional)

Enabled by CONFIG_FORCE4_CAMERA in main/Kconfig.projbuild (depends on CONFIG_FORCE4_SD_CARD; default off). All camera code is behind #ifdef. Added via espressif/esp32-camera IDF component (main/idf_component.yml).

Hardware

OV3660 camera on XIAO ESP32-S3 Sense board, connected via parallel DVP interface (I2C/SCCB for configuration, 8-bit parallel data bus for pixel data). Does not share the SPI2_HOST bus.

Signal GPIO Signal GPIO
XCLK 10 VSYNC 38
SIOD 40 HREF 47
SIOC 39 PCLK 13
D7 (Y9) 48 D3 (Y5) 16
D6 (Y8) 11 D2 (Y4) 18
D5 (Y7) 12 D1 (Y3) 17
D4 (Y6) 14 D0 (Y2) 15

LEDC conflict: led.c uses LEDC_TIMER_0/LEDC_CHANNEL_0 for PWM. Camera XCLK uses LEDC_TIMER_1/LEDC_CHANNEL_1.

Configuration

Boot: QXGA (2048×1536), JPEG quality 12, 1 frame buffer, CAMERA_GRAB_WHEN_EMPTY. Camera is reconfigured at runtime for video (see below) and restored to photo mode after each flight.

Frame discard: camera_init() discards 5 frames after sensor configuration so AEC/AWB can converge (adds ~1 s to boot). camera_capture_to_sd() discards one stale buffered frame before each capture so the saved image reflects the current scene, not whatever was buffered previously.

Commands

Command Description
photo Capture JPEG frame, save to /sd/PHOTO.JPG on SD
vidtest Benchmark video capture for 5 s (fps, frame sizes)

mission-control photo captures and downloads PHOTO.JPG in one step, verifying the JPEG magic bytes (FFD8FF).

Video recording (optional)

Automatically records MJPEG AVI video to the SD card during each flight. Enabled when CONFIG_FORCE4_CAMERA is set (implies SD card). Resolution is configurable via CONFIG_FORCE4_VIDEO_RESOLUTION in Kconfig:

Setting Resolution Benchmark fps Flight fps 60 s file
FORCE4_VIDEO_SVGA 800×600 ~27 fps ~3 fps ~5 MB
FORCE4_VIDEO_QXGA 2048×1536 ~11 fps ~0.5 fps ~4 MB

Flight fps is lower than benchmark because log_write_task (priority 5) preempts video_task (priority 4) on Core 0. SVGA is the default and recommended setting.

Pipeline

  1. On IDLE → LOGGING transition, flight_logger calls video_start(flight_num) which is non-blocking — it just stores the flight number and sends a task notification.
  2. video_task (Core 0, priority 4) wakes, calls camera_configure_video() to reconfigure to video resolution (2 frame buffers, CAMERA_GRAB_LATEST via esp_camera_reconfigure()), opens the AVI file, and enters a capture loop: esp_camera_fb_get() → write AVI frame chunk → esp_camera_fb_return().
  3. On LOGGING → COOLDOWN, flight_logger calls video_stop() (non-blocking, sets a flag). After sensor data is flushed, video_wait() blocks until video_task finalizes the AVI and calls camera_configure_photo() to restore QXGA for photos.

All blocking camera and file I/O operations run in video_task on Core 0, so flight_task on Core 1 is never stalled.

AVI format

Standard RIFF/AVI container with MJPEG codec. Playable in VLC, ffmpeg, etc.

  • Header (224 bytes): written as a placeholder at file open; fixed up after recording with actual frame count, timing, and file size via fseek(0).
  • Frame chunks: 00dc + size + JPEG data + pad byte (RIFF word alignment).
  • Index (idx1): one 16-byte entry per frame, buffered in a PSRAM array during recording and appended after the last frame. Capped at 1200 entries.
  • File naming: /sd/FLT_NNN.AVI (8.3 FAT compliant), matching the accelerometer file flight_NNN on SPIFFS.

Task interaction

video_task runs on Core 0 at priority 4, below log_write_task (priority 5). Sensor data writes to SPIFFS (internal flash) always preempt video writes to SD (SPI2_HOST) — different buses, no I/O contention. Camera DMA runs in hardware; esp_camera_fb_get() blocks on a semaphore and releases the CPU. CAMERA_GRAB_LATEST drops stale frames if the SD writer falls behind, so the AVI simply has fewer frames rather than stalling.