Skip to main content

Getting Started

Toolchain

Safi builds with ESP-IDF v6.0.2, installed through EIM (the ESP-IDF Installation Manager):

eim install -i v6.0.2 --idf-features=mcp --do-not-track true

Then in every shell that builds:

source ~/.espressif/tools/activate_idf_v6.0.2.sh

or run each command through EIM instead of activating a shell: eim run idf.py ..., eim run ./build.sh. IDF pins its own Python environment, compiler, and CMake (3.22.1 or newer, Python 3.10 or newer), so nothing else needs installing. Why a pinned version rather than "latest": IDF minor releases change Kconfig defaults, driver behavior, and link-time layout; the firmware is validated against exactly one release, and CI builds the same one.

Clone and build

git clone https://github.com/ahmeddwalid/Safi.git
cd Safi
./build.sh

build.sh is a thin, readable wrapper over idf.py that exists for one reason: configuration layering (next section) has to be assembled consistently, and typing it by hand invites drift. Builds go through the repo's CMake presets (dev, st7789, production, test), and build.sh picks the preset from its mode:

InvocationEffect
./build.shDevelopment build
./build.sh cleanfullclean then build
./build.sh --productionProduction profile: requires sdkconfig.production, signing and encryption keys in the environment, and runs the preflight checks. See security

A bare clone needs touch sdkconfig.local before the first idf.py --preset dev build: the dev preset's defaults chain is sdkconfig.defaults;sdkconfig.local, and the gitignored local file is absent from a fresh clone.

Flash and watch the console (the board's USB-C is a native USB serial device, no adapter involved):

./flash.sh # defaults to /dev/ttyACM0
./monitor.sh # idf.py monitor, Ctrl+] to exit

Configuration layering

ESP-IDF configuration is a single generated sdkconfig file, built from layered defaults. Safi splits the layers by intent:

FileTracked in gitPurpose
sdkconfig.defaultsyesThe project's baseline: partition table, PSRAM, LVGL options, TLS placement, WiFi buffer sizing
sdkconfig.localno (gitignored)Your machine's secrets and conveniences, chiefly CONFIG_SAFI_WIFI_SSID and CONFIG_SAFI_WIFI_PASSWORD
sdkconfig.productionyesThe hardened profile: secure boot, flash encryption, production gates
sdkconfigno (generated)What the build actually used

build.sh appends sdkconfig.local for development builds and sdkconfig.production for production builds. The design goal is simple: credentials can never reach git, because the only file that holds them is never tracked, and the production build refuses to include it.

Why not environment variables for WiFi credentials? Kconfig values flow into the binary through one audited mechanism, appear in menuconfig, and are diffable against the generated sdkconfig; ad hoc environment lookups in CMake are none of those.

Project-specific options live under Safi Configuration in idf.py menuconfig; every one of them is cataloged in the Kconfig reference.

Your first modification

A quick end-to-end loop to prove the setup:

  1. Edit main/main.c and add a log line in app_main:

    ESP_LOGI("HELLO", "my first Safi build");
  2. ./build.sh && ./flash.sh && ./monitor.sh

  3. Find your line in the boot log, right after the heap report.

The build is incremental; after the first full compile (a few minutes), edits rebuild in seconds.

The serial console

The console at 115200 baud is the primary development instrument. The firmware logs generously: boot progress with internal heap milestones, every WiFi attempt with its failure reason and a scan of visible networks, playback state changes, and the API token on first boot. Most debugging sessions in this documentation's war stories started with nothing but this log.

Two practical notes:

  • Opening the port resets the board (the USB serial device wires DTR/RTS to reset, which is also how flashing works). Scripted capture therefore always sees a fresh boot.
  • idf.py monitor decodes crash backtraces into function names automatically; a raw terminal shows only addresses. For decoding addresses by hand, see observability.

Common build failures

Four failure modes account for almost every broken build in this project's history:

SymptomCauseFix
fatal error: cJSON.h: No such file or directoryA component uses a library without declaring itAdd the missing name (json here) to REQUIRES in that component's CMakeLists.txt
undefined reference to esp_http_client_initHeader found via an indirect include, but the library never linkedSame fix: declare the dependency in REQUIRES
Error: LittleFS image is too largeThe littlefs_data/ payload outgrew the storage partitionTrim the payload or grow storage in partitions.csv (currently 2 MB)
multiple definition of ...A variable defined in a header included twiceMake it static, or declare extern in the header and define it in one .c

The pattern behind the first two: ESP-IDF components only see what they declare. An include that happens to resolve through another component's public headers will still fail at link time.

Running the tests

The on-target unit tests are a separate app in test/:

idf.py -C test -B build-test set-target esp32s3 build
idf.py -C test -B build-test -p /dev/ttyACM0 flash monitor

CMake preset discovery does not reach the test/ subproject, so the test app is built directly with -C test -B build-test (the same form CI uses). The root-project presets (dev, st7789, production) only apply to the main app.

They run on the real chip and print a Unity summary. What is tested and why host-side harnesses complement it: testing.

Building the dashboard and docs

  • The web dashboard is plain HTML/JS in littlefs_data/www/; it is packed into a LittleFS image at build time and flashed as the storage partition. Editing it requires no toolchain beyond the firmware build itself.
  • This documentation site lives in safi-docs/ (npm install && npm run start).