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:
| Invocation | Effect |
|---|---|
./build.sh | Development build |
./build.sh clean | fullclean then build |
./build.sh --production | Production 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:
| File | Tracked in git | Purpose |
|---|---|---|
sdkconfig.defaults | yes | The project's baseline: partition table, PSRAM, LVGL options, TLS placement, WiFi buffer sizing |
sdkconfig.local | no (gitignored) | Your machine's secrets and conveniences, chiefly CONFIG_SAFI_WIFI_SSID and CONFIG_SAFI_WIFI_PASSWORD |
sdkconfig.production | yes | The hardened profile: secure boot, flash encryption, production gates |
sdkconfig | no (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:
-
Edit
main/main.cand add a log line inapp_main:ESP_LOGI("HELLO", "my first Safi build"); -
./build.sh && ./flash.sh && ./monitor.sh -
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 monitordecodes 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:
| Symptom | Cause | Fix |
|---|---|---|
fatal error: cJSON.h: No such file or directory | A component uses a library without declaring it | Add the missing name (json here) to REQUIRES in that component's CMakeLists.txt |
undefined reference to esp_http_client_init | Header found via an indirect include, but the library never linked | Same fix: declare the dependency in REQUIRES |
Error: LittleFS image is too large | The littlefs_data/ payload outgrew the storage partition | Trim the payload or grow storage in partitions.csv (currently 2 MB) |
multiple definition of ... | A variable defined in a header included twice | Make 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 thestoragepartition. Editing it requires no toolchain beyond the firmware build itself. - This documentation site lives in
safi-docs/(npm install && npm run start).