ESP-IDF v5.4 application running FreeRTOS tasks on an ESP32-S3.
| 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.
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)
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_task(Core 1) drains ADXL375 FIFO (up to 32 samples/read via I2C burst)- In IDLE: samples go into a 1600-entry pre-trigger ring buffer (2s at 800 Hz)
- On launch detect: pre-trigger buffer + live samples are pushed into a 16,000-entry PSRAM ring buffer (
s_log_ring) log_write_task(Core 0) drainss_log_ring→ binary records on SPIFFS. Flash erase stalls only Core 0;flight_taskkeeps reading the FIFO uninterrupted- After 60s:
flight_tasksignals flush,log_write_taskcloses the file, 3s cooldown, return to IDLE
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---\naround every command response - Commands:
ls,cat <file>,rm <file>,format,status,trigger,transfer,resume,ping,go,abort,help(plusls --sd,cat --sd <file>,rm --sd <file>,format --sd,sdtest [N],sdinfowhen SD enabled;photo,vidtestwhen camera enabled) - Two-step binary transfer protocol (receiver-initiated, inspired by XModem):
cat <file>(orcat --sd <file>) prepares the transfer and responds withready size:N\ninside the normal---BEGIN---/---END---framing. No binary is sent yet.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.goreturns immediately (no---BEGIN---/---END---).abortcancels any pending transfer and returnsok\ninside 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-controlusesDevice.read_binary(cmd)→ callssend(cmd)to get theready size:Nresponse, then_recv_raw(N)which flushes the kernel RX buffer, writesgo\r\n, and reads exactly N bytes.exit_transfer()sends a best-effortabortbeforeresumeto clear any leftover pending state.- USB packet fragmentation: even when the device writes 512-byte chunks via
fwrite, the host receives them as many smallos.readreturns (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 untillen(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.
| Name | Type | Offset | Size |
|---|---|---|---|
| nvs | data | 0x9000 | 24K |
| phy_init | data | 0xF000 | 4K |
| factory | app | 0x10000 | 1.5M |
| storage | SPIFFS | 0x190000 | ~6.4M |
| 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.
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.
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.
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:
-
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. Seereference/XIAO-ESP32S3.md. -
Dual-task architecture —
flight_task(Core 1) only reads the FIFO intos_log_ring;log_write_task(Core 0) handles all SPIFFS writes. Even iflog_write_taskblocks during an erase,flight_taskkeeps draining the FIFO uninterrupted. -
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_ringholds 16,000 entries (~20 s at 800 Hz) allocated from PSRAM viaheap_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.
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.
- 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 pullfetches raw binary viacatand 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, keyflight_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 beforeserial_cmdcan delete files.
- Large arrays use static buffers (not task stack) — see
s_samplesinflight_logger.c. Exception:s_log_ringis 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_LOGEshare the USB serial withprintf. The---BEGIN---/---END---framing in command responses separates command output from log noise.flight_state_tisvolatile— set atomically from any task, read only fromflight_task.
General XIAO ESP32-S3 notes (USB Serial/JTAG setup, flash erase stalls): reference/XIAO-ESP32S3.md.
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.
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.
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 |
| 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.
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).
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.
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.
| 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).
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.
- On IDLE → LOGGING transition,
flight_loggercallsvideo_start(flight_num)which is non-blocking — it just stores the flight number and sends a task notification. video_task(Core 0, priority 4) wakes, callscamera_configure_video()to reconfigure to video resolution (2 frame buffers,CAMERA_GRAB_LATESTviaesp_camera_reconfigure()), opens the AVI file, and enters a capture loop:esp_camera_fb_get()→ write AVI frame chunk →esp_camera_fb_return().- On LOGGING → COOLDOWN,
flight_loggercallsvideo_stop()(non-blocking, sets a flag). After sensor data is flushed,video_wait()blocks untilvideo_taskfinalizes the AVI and callscamera_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.
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 fileflight_NNNon SPIFFS.
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.