Skip to content

Building from Source

For developers who want to modify the firmware or contribute to the project.

PlatformIO provides reproducible builds with pinned dependencies.

Install

pip install platformio

Build

The firmware has two hardware variant environments:

  • esp32s3-v4p — V4P (Phil's box, most common)
  • esp32s3-v4b — V4B (Bob's box)
cd OnSpeed-Gen3

# Build V4P firmware (default when no -e is specified)
pio run

# Build a specific variant
pio run -e esp32s3-v4p
pio run -e esp32s3-v4b

# Build both variants
pio run -e esp32s3-v4p -e esp32s3-v4b

The variant environments differ only in the hardware define (-DHW_V4P vs -DHW_V4B), which controls pin assignments for pressure sensor chip selects, SD card SPI, and the external ADC.

Build and Upload

pio run -e esp32s3-v4p -t upload

Serial Monitor

pio device monitor

Run Unit Tests

Tests run on your development machine (no ESP32 needed):

pio test -e native
pio test -e native -v  # verbose output

Code Coverage

Coverage uses GCC's --coverage instrumentation + lcov, both of which work natively only with GNU toolchains. On macOS the system gcov is LLVM and incompatible with lcov, so coverage runs inside a Docker image that mirrors the CI environment.

Requirements: Docker Desktop (macOS/Windows) or Docker Engine (Linux).

./scripts/coverage.sh              # run coverage, produce report
./scripts/coverage.sh --rebuild    # rebuild image from scratch (no cache)
./scripts/coverage.sh --shell      # drop into a shell in the image for debug

Outputs:

  • coverage-report/index.html — browsable HTML report (open with open on macOS, xdg-open on Linux)
  • coverage.info — lcov tracefile (same file CI uploads to Codecov)

The Docker image (Dockerfile.coverage) pins ubuntu:24.04 to match GitHub Actions' ubuntu-latest, so coverage numbers match CI bit-for-bit. CI's test job calls the same scripts/coverage-inner.sh that runs inside the container, guaranteeing parity.

First run: ~3-4 minutes (Docker pulls base image, installs PlatformIO, warms the native platform cache). Subsequent runs: ~40 seconds on warm cache.

Scope

The report covers software/Libraries/onspeed_core/ only. Vendored dependencies (software/Libraries/tinyxml2/) are filtered out via lcov --remove in scripts/coverage-inner.sh — testing upstream libraries is not our job, and including tinyxml2 dragged the headline coverage number down by ~20 points with zero actionable signal.

Branch coverage caveat

gcov records a C++ exception-unwind edge on every heap-allocating call (std::string, std::vector, new, etc.). Those edges can only be covered by a test that induces std::bad_alloc — not a meaningful target for a 32-MB-flash embedded system. The --rc no_exception_branch=1 flag that would suppress them over-filters on our build (strips 98% of branches, not just exception edges), so we leave it off. Read the branch coverage number accordingly — the gap between line coverage (~97%) and branch coverage (~65%) is dominated by allocator-throw edges, not missing control-flow tests.

The codecov-style row

The summary output ends with a row:

  codecov-style.....: 77.4% (fully=1967 partial=505 missed=69)

This mirrors the metric Codecov uses on the PR status page: a line only counts as "fully" covered if every branch on it was taken. The formula is fully / (fully + partial + missed) — partials count as misses in the numerator. Our local branch-level classification is stricter than Codecov's block-level classification, so Codecov will typically report a few points higher (~83% when local shows ~77%). Drive local up and Codecov rises with it.

The partial count is the target to shrink. A line is partial when it executed but at least one of its branches didn't — typically error-path branches like if (!ParseFloat(...)) return false; where the error case isn't exercised by tests. Click into any file in coverage-report/index.html to see which specific lines are partial (yellow/orange highlight).

Arduino IDE

The Arduino IDE does not use the PlatformIO variant environments. Instead, the hardware variant defaults to V4P via the guard block at the top of HardwareMap.h. To build for V4B, edit the guard block and change the default HW_V4P to HW_V4B.

  1. Install Arduino IDE 2.x
  2. Add the ESP32 board URL to File → Preferences → Additional Boards Manager URLs:
    https://espressif.github.io/arduino-esp32/package_esp32_index.json
    
  3. Install the ESP32 board package version 3.3.5 via Tools → Board Manager
  4. Set board to ESP32S3 Dev Module Octal (WROOM2)
  5. Configure Tools menu:
    • PSRAM: OPI PSRAM
    • Flash Mode: OPI 80 MHz
    • Flash Size: 32MB (256 Mb)
    • Partition Scheme: 32M Flash (4.8MB APP/22MB LittleFS)
    • Upload Speed: 921600
    • USB Mode: Hardware CDC and JTAG
    • USB CDC On Boot: Disabled
  6. Open software/OnSpeed-Gen3-ESP32/OnSpeed-Gen3-ESP32.ino
  7. Compile and upload

Verifying an Arduino IDE build from the command line

Arduino IDE ships a bundled arduino-cli that uses the exact same compile pipeline as the GUI. This is the best way to sanity-check "does the firmware still build under Arduino IDE?" without clicking through the GUI — useful in CI or when reviewing a PR that touches includes or layout.

# The arduino-cli binary bundled inside Arduino IDE 2.x on macOS:
ARDUINO_CLI="/Applications/Arduino IDE.app/Contents/Resources/app/lib/backend/resources/arduino-cli"

# Set up an isolated test environment (one-time setup):
mkdir -p /tmp/arduino-test
"$ARDUINO_CLI" config init --dest-file /tmp/arduino-test/arduino-cli.yaml --overwrite
"$ARDUINO_CLI" --config-file /tmp/arduino-test/arduino-cli.yaml \
    config set board_manager.additional_urls \
    "https://espressif.github.io/arduino-esp32/package_esp32_index.json"
"$ARDUINO_CLI" --config-file /tmp/arduino-test/arduino-cli.yaml core update-index
"$ARDUINO_CLI" --config-file /tmp/arduino-test/arduino-cli.yaml core install esp32:esp32@3.3.5

# Point arduino-cli at this repo's bundled libraries (so we don't need to copy
# them into ~/Documents/Arduino/libraries/):
"$ARDUINO_CLI" --config-file /tmp/arduino-test/arduino-cli.yaml \
    config set directories.user $(pwd)/software

# Verify compile (no upload):
"$ARDUINO_CLI" --config-file /tmp/arduino-test/arduino-cli.yaml compile \
    --fqbn 'esp32:esp32:esp32s3-octal:PSRAM=opi,FlashSize=32M,FlashMode=opi,PartitionScheme=app5M_little24M_32MB,USBMode=hwcdc,CDCOnBoot=default' \
    software/OnSpeed-Gen3-ESP32

The FQBN options encode the Tools-menu settings from step 5 above — keep them in sync if you change any of the settings.

Expected output ends with something like:

Sketch uses 2176519 bytes (46%) of program storage space. Maximum is 4718592 bytes.
Global variables use 67784 bytes (20%) of dynamic memory, leaving 259896 bytes for local variables. Maximum is 327680 bytes.

To build for V4B, temporarily edit HardwareMap.h's guard block to default to HW_V4B, re-run the compile, then revert.

Build Notes

  • Target: ESP32-S3-WROOM-2 (32MB Flash, 8MB PSRAM)
  • Platform: pioarduino 55.03.35 (Arduino Core 3.3.5)
  • Zero-warning policy: The build enforces -Werror on project code. Any warnings will fail the build.
  • Build versioning: scripts/generate_buildinfo.py runs as a pre-build hook, extracting version from git tags into BuildInfo::version, BuildInfo::gitShortSha, etc.

Project Structure

OnSpeed-Gen3/
├── platformio.ini                   # Build configuration (V4P + V4B environments)
├── software/
│   ├── sketch_common/               # Shared sketch source (used by every board)
│   │   └── src/                     # Globals.h + drivers/, io/, audio_io/,
│   │                                # config/, tasks/, util/, web_server/
│   ├── OnSpeed-Gen3-ESP32/          # Gen3 sketch shell (.ino + HardwareMap.h)
│   │   ├── OnSpeed-Gen3-ESP32.ino
│   │   ├── HardwareMap.h            # Gen3-specific pins
│   │   ├── Audio/                   # PCM byte-array assets
│   │   ├── Web/                     # HTML/JS/CSS byte-array assets
│   │   └── src -> ../sketch_common/src   # symlink
│   └── Libraries/
│       ├── onspeed_core/            # Platform-independent algorithms
│       └── version/                 # Build version info (auto-generated + defaults)
├── test/                            # Native unit tests
└── scripts/                         # Build and analysis scripts

Each per-board sketch folder is a thin shell containing only the Arduino entry-point .ino, a board-specific HardwareMap.h, the asset folders (Audio/, Web/), and a src/ symlink that points at sketch_common/src/. All actual driver / IO / task / config / web-server code lives once in software/sketch_common/src/ and is shared across boards.

A future Gen2v4 sketch folder would be:

software/OnSpeed-Gen2v4-ESP32/
├── OnSpeed-Gen2v4-ESP32.ino
├── HardwareMap.h           # Gen2v4-specific pins
├── Audio/
├── Web/
└── src -> ../sketch_common/src   # same symlink target as Gen3

PlatformIO's src_dir = software/OnSpeed-Gen3-ESP32 and the -Isoftware/OnSpeed-Gen3-ESP32 include flag both resolve through the symlink, so sketch-root-relative includes like #include "src/Globals.h" and #include "src/drivers/SPI_IO.h" keep working unchanged. Arduino IDE 2.x follows the symlink during its src/** traversal and compiles the same way (verified via arduino-cli compile).

Windows users

Git symlinks need explicit opt-in on Windows. Before cloning:

git config --global core.symlinks true

You also need either Windows 10/11 Developer Mode enabled or to clone in an Administrator shell, otherwise Git stores src as a 20-byte text file containing the literal string ../sketch_common/src and the build fails immediately with "no source files found." If you've already cloned without core.symlinks, run git rm software/OnSpeed-Gen3-ESP32/src then git checkout software/OnSpeed-Gen3-ESP32/src after enabling the config — Git will then recreate it as a real symlink.

macOS, Linux, and the GitHub Actions Ubuntu runners handle the symlink natively without any setup.

Include Style

All project-internal #include directives use sketch-root-relative paths (Google/LLVM C++ Style Guide convention):

#include "Globals.h"              // top-level sketch header
#include "src/util/Helpers.h"     // file in a subdirectory
#include "Web/html_logo.h"        // generated asset
#include <ToneCalc.h>             // onspeed_core library (angle brackets)
#include <Arduino.h>              // framework library (angle brackets)

The -Isoftware/OnSpeed-Gen3-ESP32 flag in platformio.ini makes this work for PlatformIO. Arduino IDE 2.x resolves sketch-root includes by the same mechanism — verified with arduino-cli compile.

Globals.h is the umbrella header. It includes every project header under src/. A .cpp file that starts with #include "Globals.h" therefore does not need to also include its own paired header.

Every project #include uses the full path from the sketch root — no exceptions for same-directory siblings. Per the Google C++ Style Guide:

All of a project's header files should be listed as descendants of the project's source directory without use of UNIX directory aliases . or ...

Example — inside src/drivers/SPI_IO.cpp, including the paired header:

#include "src/drivers/SPI_IO.h"    // full path, even though it lives next door

Do not write #include "../../Globals.h", #include "../tasks/Flaps.h", or #include "SPI_IO.h" (bare same-directory). Filesystem-relative paths and bare same-directory includes break when files move, create ambiguity, and — under Arduino IDE's file-cache model — bypass #pragma once guards causing redefinition errors.

Quotes vs. angle brackets

Per Google style, angle brackets are reserved for libraries that require them. Everything else uses quotes — including most third-party libraries.

Category Bracket style Examples
C system headers <> <stdint.h>, <unistd.h>
C++ standard library <> <optional>, <string>, <cstdint>
Arduino / ESP32 framework <> <Arduino.h>, <HardwareSerial.h>, <WiFi.h>, <LittleFS.h>
onspeed_core library headers <> <ToneCalc.h>, <types/ImuSample.h>
Vendored third-party libraries under software/Libraries/ "" "SdFat.h", "FreeRTOS.h", "tinyxml2.h"
Project files (sketch-side) "" with sketch-root-relative path "Globals.h", "src/drivers/SPI_IO.h"

Google's rule: "Headers should only be included using an angle-bracketed path if the library requires you to do so" — treat the system vs. vendored boundary as the rule. If the library publishes its headers through the compiler's system include path (Arduino core, ESP-IDF built-ins), use <>. If it ships as a vendored dep under software/Libraries/, use "".

New code follows the table above. When adding a new third-party dependency, check whether it lives under software/Libraries/ (quotes) or comes from the Arduino/ESP32 framework's built-in library path (angle brackets).

Include every stdlib header you use

The macOS libc++ used for local native builds forwards transitive includes more aggressively than Linux libstdc++ used in CI. A .cpp file that calls strtof must #include <cstdlib> explicitly, even if the call happens to compile on macOS without it (because <cstring> or another header pulled stdlib.h in transitively). CI's Linux GCC build is authoritative — if Linux CI fails with "strtof was not declared," the file is missing the header, not CI.

Common symbols to watch:

Symbol Header
strtof, strtod, strtol, strtoul, atoi, atof <cstdlib>
strlen, strcmp, memcpy, memset <cstring>
printf, snprintf, fprintf <cstdio>
sqrt, sinf, cosf, fabsf, powf <cmath>
std::min, std::max, std::sort <algorithm>
std::optional <optional>
std::string_view <string_view>
int32_t, uint16_t, etc. <cstdint>
size_t, ptrdiff_t <cstddef>

Core invariants and regression tooling

Three tools guard the onspeed_core library boundary and catch behavior regressions. All three run in CI on every pull request; local invocation is identical.

scripts/check_core_purity.sh

Verifies that no file under software/Libraries/onspeed_core/ includes a platform header (Arduino.h, FreeRTOS.h, ESP-IDF headers) or calls a platform API (millis(), xTaskCreate, Serial., etc.). Run before every commit that touches onspeed_core/:

./scripts/check_core_purity.sh

Exits non-zero and prints the offending file + line if a forbidden pattern is found. The purity invariant is what makes onspeed_core compile with plain g++ on the host — and therefore with any future hardware.

scripts/check_board_flags.sh

Verifies that HW_V4P and HW_V4B appear only in HardwareMap.h. Every other sketch file must read if constexpr (kHasExternalMcp3202) (or another topology flag) instead of #ifdef HW_V4*.

./scripts/check_board_flags.sh

This is the mechanical proof that the multi-board design works. When a new board (e.g. Gen2v4) is added later, it should require writing one new HardwareMap.h and nothing else — this check fails if any file outside HardwareMap.h references a board flag, preventing future PRs from re-introducing the compile-time-fork style the refactor moved away from.

tools/regression/run_snapshot.py

Builds tools/regression/host_main.cpp against the current onspeed_core, feeds it a recorded flight-log excerpt, and diffs the output against a committed golden file. Catches behavioral regressions that per-module unit tests miss — for example, when individually-correct modules compose slightly differently after a refactor.

# Check for regression
./tools/regression/run_snapshot.py

# Accept an intentional behavior change (commit the new golden with the PR)
./tools/regression/run_snapshot.py --update-golden

The harness's pipeline (tools/regression/host_main.cpp) exercises the current onspeed_core modules end-to-end. When adding a new module to onspeed_core, extend host_main.cpp to exercise it and commit an updated golden alongside the code change.

Contributing

See the GitHub repository for contribution guidelines, issue tracking, and pull request workflow.