Skip to content

Build and development environment

Toolchain and dependencies

Pinned ESP-IDF v6.1, tag commit fff9895c82d744c7237be8847347bdd1b07c6643, pinned from the v6.1 release and the v6.1 ESP32 documentation.

Use ESP-IDF Installation Manager (EIM) to install v6.1 with toolchains for the required esp32 and/or esp32s3 targets; select that installation in the ESP-IDF VS Code extension. Build from its activated ESP-IDF terminal. No project-local SDK, PlatformIO, Arduino, or M5Unified is needed. .idf-version records the SDK release, the component manifest constrains IDF to 6.1.0, and CMake checks the exact release string before compiling. A compile-time check also rejects other semantic IDF versions.

Direct managed dependencies are pinned in main/idf_component.yml:

Component Version Purpose
Espressif led_strip 3.0.1 Boot matrix output
Espressif button 4.2.1 GPIO debouncing and click events
Espressif cjson 1.7.19~2 Bounded settings/status JSON
Espressif esp-dsp 1.8.2 Optional PDM spectral features
Espressif ble_conn_mgr 1.3.4 Single BLE host and secured CoC
Espressif ble_services 1.5.0~1 Standard Object Transfer Service

Commit both dependencies.lock (ESP32) and dependencies.esp32s3.lock: they record resolved direct/transitive versions, hashes and target, following Espressif's recommendation. BLE local control, catalogue transfer and authenticated OTA share one IDF NimBLE peripheral host owned by conn_mgr; there are no application scan calls. LE Secure Connections with numeric comparison and bonding replaces the generated verifier requirement. No Arduino or RemoteXY component remains. The iOS client in ios/ pins SwiftProtobuf and uses native OS bonding; see the app notes. Do not edit managed manifests or remove transitive dependencies manually. managed_components/, build output and generated sdkconfig remain ignored. Review the lock during deliberate upgrades.

Upgrades require updating the SDK pin, manifest, compile guard and documentation together, reviewing the official migration guide, rebuilding affected targets and LED backends, and repeating hardware checks. See BLE integration; there is no longer an upstream OTA profile patch to maintain.

led_strip 3.0.1's SPI source uses heap-capability APIs without directly including esp_heap_caps.h, which fails to compile under IDF v6.1. Top-level CMake force-includes that header for only led_strip_spi_dev.c. The managed component is unmodified. No warnings are suppressed; the workaround covers a vendor source compiled even when ear output uses the project-owned packed SPI DMA backend or RMT. Recheck/remove this small workaround when upgrading the component.

ble_conn_mgr 1.3.4 also references a hidden bond-capacity Kconfig symbol in an unused whitelist helper when SMP is disabled. Root CMake defines zero capacity only for that component in no-SMP builds; managed sources remain unmodified. The control transport uses the pinned IDF private transport ABI and two narrowly scoped registration/store hooks; see the integration contract.

Build, flash, monitor

From the EIM / VS Code v6.1 ESP-IDF terminal:

# Activate your EIM-managed ESP-IDF v6.1 environment; run from the repository root.
idf.py --version
idf.py --preset normal build

idf.py --version must report ESP-IDF v6.1. Select a preset for the board; use --preset atoms3r-normal for the AtomS3R v1 upgrade, --preset s3-normal for AtomS3 Lite, --preset custom-s3-normal for custom legacy S3 boards, and --preset v2-normal for the experimental WROOM-1-N16R8 integration build, or --preset v2-8mb for v2 with 8 MB flash, PDM enabled, contact capture/PSRAM disabled by default. V2 currently keeps lighting black until the source/current service has fresh status and an explicitly qualified set of operating limits/reserves; the default v2 configuration deliberately leaves those limits unqualified. Also see its status and limitations. Presets isolate configuration/build output without set-target or regenerating an existing config. The project defaults to classic ESP32 at 240 MHz, DIO flash at 40 MHz, 4 MB flash, and IDF's built-in Two large size OTA partitions layout (CONFIG_PARTITION_TABLE_TWO_OTA_LARGE). It provides ota_0 and ota_1, 1700 KiB each, plus 24 KiB NVS, 8 KiB OTA metadata, and 4 KiB PHY data. Legacy S3 profiles assume at least 8 MB and use a custom layout with two 3072 KiB OTA slots plus a reserved 1920 KiB recovery test-app slot. The recovery image is built separately with s3-recovery, custom-s3-recovery, v2-recovery or v2-8mb-recovery. Normal native flash/OTA plans exclude it. The S3 web flasher uses recovery-only USB bootstraps with the native five-second GPIO hold enabled; its optional preserve plan keeps existing A/B, OTA selection and NVS. See the flash layout and migration guide. NVS keeps its original offset and size. With erased OTA metadata, the bootloader selects ota_0; future OTA writes the inactive slot. No separate factory image is needed, and USB remains the recovery path. Native rollback is enabled. On a pending OTA boot, input/render health must pass within five seconds; missing IMU input alone is an allowed degraded boot. Flash the whole project over USB once to install this bootloader and partition table before using app-only OTA. No secure-boot/anti-rollback eFuses are programmed.

The ESP32 CPU frequency is selected in sdkconfig.defaults.esp32, which IDF loads alongside shared defaults. Existing sdkconfig* files retain their choice: select Component config → ESP System Settings → CPU frequency → 240 MHz in each preset's menuconfig. Do not delete those files just to change clocks; they can contain private AP/control credentials. With dynamic frequency scaling disabled, IDF's boot log should report cpu freq: 240000000 Hz.

240 MHz is the PICO-D4's supported maximum, rather than an overclock. It gives CPU work more headroom for rendering and radio traffic; it does not shorten WS2812 transmission time or guarantee 500 Hz output during Wi-Fi association. Expect increased controller power consumption and check mounted temperature/battery runtime during hardware tuning. This CPU setting does not change flash/LED clocks, brightness/current limits or dither fallback.

Find the Atom's USB serial device (on macOS, typically /dev/cu.usbserial-*):

ls /dev/cu.*
idf.py --preset normal -p /dev/cu.YOUR_ATOM_PORT -b 1500000 flash monitor

Use the same preset you built and replace the port with the actual device (for example /dev/ttyUSB0 on Linux). Don't select an unrelated serial device. Flashing replaces firmware. Monitor runs at 115200; exit with Ctrl-]. For monitoring without flashing:

idf.py --preset normal -p /dev/cu.YOUR_ATOM_PORT monitor

Use 115200 as a fallback if the USB adapter can't sustain 1500000 baud. Pass -b explicitly: the preset's flash baud setting controls the VS Code extension, not idf.py's own default.

Boot logs include firmware/build/IDF versions, reset reason, both LED GPIOs, counts/directions/colour orders, build-config source, brightness/current budget, and mode. The asynchronous IMU task logs readiness for the selected chip and sensor failures; its driver rejects an unexpected chip identity.

CMake presets and formatting

In VS Code, run ESP-IDF: Select Project Configuration and choose normal, s3-normal, custom-s3-normal or experimental v2-normal, then build using the extension. Firmware presets build under build/firmware/<preset>, retaining their existing root-level SDK configuration files. See hardware profiles for board defaults and configuration. Existing menuconfig changes remain in those files; selecting a preset doesn't reset them to defaults. LED diagnostics are available in the ordinary firmware.

For a terminal build, activate IDF v6.1 first and run from the project root:

cmake --preset normal
cmake --build --preset normal

ESP-IDF v6.1 selects the first (normal) preset when none is specified, so plain idf.py build uses the same nested directory. Select S3/custom presets explicitly. Build outputs are grouped by type:

build/
  firmware/<preset>/
  tests/host/
  tests/hardware-profiles/<target>/<case>/
  artifacts/legacy/<old-build-directory>/

The artifacts directory preserves firmware/NVS backups, logs, screenshots and scratch baselines from older builds. CMake caches contain absolute paths; configure/rebuild in the new directories instead of moving a configured build tree. Root sdkconfig* and private credentials stay outside build directories. Swift/Gradle caches and Xcode DerivedData retain their native project locations.

The independent host-test project has its own presets under tests/. Resolve managed dependencies with one idf.py reconfigure first; host compilation then needs only CMake, native C/C++ compilers and Python, without an activated IDF environment. The spectral tests compile the managed ESP-DSP ANSI FFT/window sources unchanged and compare them with a separate Python direct DFT:

cd tests # from the repository root
cmake --preset host-tests
cmake --build --preset host-tests
ctest --preset host-tests

Commit CMakePresets.json and tests/CMakePresets.json. Personal overrides belong in ignored CMakeUserPresets.json files. Point clangd at the selected preset's build directory, for example build/firmware/normal/compile_commands.json for the original Atom.

The live .vscode/settings.json is local and ignored. ESP-IDF writes installation paths into it, and VS Code has no native settings.local.json overlay. Keep the extension's generated configuration local rather than managing partial-file commits or Git index flags. Portable preferences live in .vscode/settings.example.json. For a new checkout, copy the example once (without replacing existing local settings):

cp -n .vscode/settings.example.json .vscode/settings.json

The example includes clangd formatting, format-on-save for C/C++, and eight-space Kconfig indentation. Select the v6.1 installation with ESP-IDF: Select Current ESP-IDF Version; EIM discovers the local installations. Add these machine-specific clangd settings to the ignored file, using your own installation paths:

{
    "clangd.path": "/path/to/esp-clangd/bin/clangd",
    "clangd.arguments": [
        "--background-index",
        "--resource-dir=/path/to/esp-clang/lib/clang/21",
        "--query-driver=/path/to/xtensa-esp-elf/bin/xtensa-esp32-elf-*",
        "--compile-commands-dir=/absolute/path/to/espurr/build/firmware/normal"
    ]
}

The EIM-installed esp-clangd package lacks Clang's builtin headers. Pointing --resource-dir at the matching esp-clang installation (lib/clang/21) fixes missing standard headers such as float.h; --query-driver supplies the Xtensa GCC system includes. Update local paths when upgrading tools and run clangd: Restart language server after changing them. The .clangd file removes GCC-specific -f*/-m* flags only for editor analysis; it doesn't affect firmware builds.

The example limits Swift's SourceKit-LSP to Swift and Objective-C languages for the iOS app. Its default also claims C/C++ files, starting Apple's clangd alongside esp-clangd and producing spurious ESP32 header errors such as missing sys/features.h. Keep c and cpp out of swift.sourcekit-lsp.supported-languages in local workspace settings. Run Swift: Restart LSP Server after changing this setting if existing diagnostics remain.

.clang-format uses LLVM-based style: attached braces, 120 columns, four-space indentation, aligned declarations/assignments, and regrouped includes (generated config, project headers, standard library, then IDF/components). Settings apply to both C and C++; Standard: Latest is a formatter parsing setting, not a change to compiler language flags. SeparateDefinitionBlocks: Always separates definitions; a blank line after other }; terminators remains a style convention. VS Code's C/C++ default formatter is clangd, which reads this file for Format Document (Shift+Option+F on macOS) and format-on-save when using the example settings. Use IDF's bundled clang-format 21.1.3 as the formatting reference, matching VS Code's clangd. Homebrew 23.1.2 accepts the configuration but produces some different alignment. From the activated IDF terminal, format project-owned code:

clang-format --version
rg --files main components preview tests -g '*.c' -g '*.cpp' -g '*.h' \
  -g '!**/vendor/**' -g '!**/Generated/**' -0 | xargs -0 clang-format --style=file -i