Building from source¶
AWTRIX NG is a PlatformIO project. Five environments in
platformio.ini cover the device firmware, the host unit tests and the host
simulator.
You need PlatformIO, Python 3 and Node.js. Node is used only by the pre-build step that embeds the web UI, but a firmware build stops without it.
pio run -e awtrix # ESP32 firmware (the default environment)
pio run -e awtrix_s3_octal # ESP32-S3 firmware, octal PSRAM
pio run -e awtrix_s3_quad # ESP32-S3 firmware, quad PSRAM
pio test -e native # host unit tests for the portable core
pio run -e native_sim # host simulator: firmware + web UI without an ESP32
awtrix is the default environment (default_envs = awtrix), so a bare pio run
builds the ESP32 firmware.
Build environments¶
There are six. Three build device firmware (awtrix, awtrix_s3_octal,
awtrix_s3_quad), one is a measurement build of the device firmware (awtrix_probe),
and two run on the host (native, native_sim).
awtrix - the ESP32 firmware¶
The default build, and the image for every classic ESP32 board: commercial 32×8 clocks, AWTRIX 2 mainboard conversions and DIY builds all flash the same file. The GPIO map is runtime configuration, not a compile-time choice - see GPIO & boards.
| Property | Value |
|---|---|
| Platform | espressif32@6.12.0 |
| Board | esp32dev |
| Framework | arduino |
| CPU | 240 MHz (board_build.f_cpu = 240000000L) |
| Partitions | generated by scripts/gen_partitions.py |
| Upload speed | 921600 |
| Monitor speed | 115200 (with esp32_exception_decoder) |
| Source filter | +<*> -<sim/> -<core/script/ScriptHeapNative.cpp> |
pio run -e awtrix # build
pio run -e awtrix -t upload # build + flash over USB
pio run -e awtrix -t upload -t monitor # flash + open the serial monitor
The build product is .pio/build/awtrix/firmware.bin - the same file you would
upload through the web UI's OTA page.
awtrix_s3_octal - the ESP32-S3 firmware¶
The same sources built for an ESP32-S3 with octal PSRAM, for generic DIY builds. It
differs from awtrix by one build flag, AWTRIX_SOC_ESP32S3, which selects the S3
pin rules, the compiled matrix drivers and the pin defaults. Everything else about
the firmware is identical.
| Property | Value |
|---|---|
| Board | esp32-s3-devkitc-1-n16r8 (in boards/) |
| Flash | 16 MB, quad (flash_mode = qio) |
| PSRAM | octal, memory_type = qio_opi |
| Serial | UART0, the port the DevKitC-1's USB bridge is wired to |
A board file describes one module. N16R8 is the ESP32-S3-WROOM-1 with 16 MB quad flash and 8 MB
octal PSRAM. A PSRAM-less S3 runs this image as it is - the octal framework libraries carry
CONFIG_SPIRAM_IGNORE_NOTFOUND, so a failed PSRAM init is logged and the boot continues. A
quad-PSRAM board needs awtrix_s3_quad below.
The V suffix some sellers add (N16R8V, N32R8V) is a different module: WROOM-2, 1.8 V, with
octal flash. That needs memory_type = opi_opi and a bootloader to match, so it is a board
file and an image of its own - neither ships.
R8 says 8 MB, not which bus. That holds for Espressif's own modules, where R8 is octal, but
not for the boards that carry their own PSRAM chip beside the SoC - an ESP-PSRAM64H is 8 MB over
quad. Such a board wants awtrix_s3_quad despite its N16R8 label, and the released
usb-awtrix-ng-s3-quad-16mb.bin matches its flash. Building locally for one, the 8 MB board file
works as it is: its partition table simply leaves the upper half of the flash unused.
awtrix_s3_quad - the S3 firmware for quad PSRAM¶
Identical sources and identical flags to awtrix_s3_octal. The whole difference is the board file, and
in it one field:
| Property | Value |
|---|---|
| Board | esp32-s3-devkitc-1-n8r2 (in boards/) |
| Flash | 8 MB - the common quad module. The released USB images carry a table per flash size regardless |
| PSRAM | quad, memory_type = qio_qspi |
memory_type picks which set of precompiled framework libraries the app links against, and those
carry CONFIG_SPIRAM_MODE_OCT or CONFIG_SPIRAM_MODE_QUAD respectively. The mode is compiled in,
never detected, which is why one image cannot serve both kinds of board.
Getting it wrong is not symmetric:
| Image | On an octal board | On a quad board | With no PSRAM |
|---|---|---|---|
awtrix_s3_octal |
correct | boots, PSRAM invisible: no radio, Berry heap on internal RAM | boots, same as a quad board |
awtrix_s3_quad |
boot loop | correct | boot loop |
The boot loop is the quad libraries' missing CONFIG_SPIRAM_IGNORE_NOTFOUND: a PSRAM init that
fails calls abort(). It takes a USB cable to undo, which is why POST /update refuses an image
built for the other PSRAM type with wrongChip rather than installing it.
awtrix_probe - the heap measurement build¶
awtrix plus -D AWTRIX_HEAP_PROBE and link-time wrapping of malloc, free,
calloc and realloc, so every allocation the loop task makes is counted and
reported through the ordinary log. Optimisation stays at the release -Os. It is
never released and CI does not build it.
native - host unit tests¶
The core/ layer is Arduino- and FastLED-free portable C++17, so it can be compiled
and tested on your development machine. The native environment builds and runs
those tests with the Unity framework - no ESP32, no emulator.
| Property | Value |
|---|---|
| Platform | native (host compiler) |
| Test framework | unity |
| Source filter | +<core/> plus transport/ScriptMqttBridge.cpp |
Extra lib_deps |
berry (the vendored interpreter) and base64 |
| Optimisation | -O2 |
pio test -e native # run the core host unit tests
pio test -e native -v # verbose (exactly what CI runs)
On Windows, scripts/native_toolchain.py links the GCC runtimes statically into the
host binaries - see Host toolchain.
native_sim - the host simulator¶
The simulator runs the full firmware behaviour and the real web UI on your machine with no hardware attached. It shares the portable core with the firmware and adds stand-in board hardware, a small HTTP server, and the web UI served from disk. See Simulator for how to drive it.
| Property | Value |
|---|---|
| Platform | native (host compiler) |
| Defines | AWTRIX_NATIVE, AWTRIX_SIM |
| Source filter | core/, sim/, plus media/GifPlayer.cpp, media/MicroGif.cpp, media/ScriptIcon.cpp, persistence/DeviceConfigJson.cpp, persistence/SystemConfigApply.cpp, persistence/FsRestoreSink.cpp, system/Log.cpp, transport/ScriptMqttBridge.cpp, transport/DeviceStateJson.cpp, transport/mqtt/MqttService.cpp, transport/mqtt/MqttLink.cpp, transport/mqtt/HaAnnouncer.cpp |
Extra lib_deps |
base64, PubSubClient |
pio run -e native_sim
.pio/build/native_sim/program # Linux/macOS
.pio\build\native_sim\program.exe # Windows
CI does not build native_sim, so it can break without turning CI red. Build it
locally after changes that touch src/sim/, the shared core/, or the web UI.
The embedded web UI¶
Before compiling, the pre:scripts/build_webui.py extra script minifies and gzips
webui/index.html into src/transport/http/WebUiAsset.h, a generated header that
is checked in. The compressed asset has an 80 KB budget, and the header is rewritten
only when webui/index.html has actually changed, so incremental builds stay
incremental.
Minification runs html-minifier-terser, pinned to one exact version, through
npx, so Node.js has to be on PATH whenever the web UI source has changed
since the header was generated. Without npx the build stops rather than embedding
an unminified asset.
Partition tables are generated¶
There is no checked-in partition table. pre:scripts/gen_partitions.py derives one
from the board's SoC and flash size and points the build at it. The app slot is
sized from the measured firmware plus at least a 20 % growth margin - not from the
available flash - and SPIFFS takes the remainder, minus a 64 KB coredump region. One
slot size covers every SoC and every flash variant.
| Flash | App slot (two of them) | SPIFFS |
|---|---|---|
| 4 MB | 1.69 MB | 512 KB |
| 8 MB | 1.69 MB | 4.5 MB |
| 16 MB | 1.69 MB | 12.5 MB |
python tools/check_partitions.py renders every combination and asserts the
invariants that make a table bootable; it runs in CI.
To inspect or produce one by hand:
USB install images¶
pio run -t upload writes the bootloader, the partition table, boot_app0 and the
app as separate transfers. A USB install image is those merged into one file starting
at offset 0, so it can be flashed with nothing but esptool. The usb-*.bin release
assets are these images, and Flashing is the page
that uses them.
pio run -e awtrix
python scripts/factory_image.py --env awtrix --flash-size 4MB -o dist/usb-awtrix-ng-4mb.bin
Merging runs through esptool, which is not a PlatformIO dependency: pip install
esptool first.
--all instead of --flash-size builds every variant that SoC ships in (4/8/16 MB
for the ESP32, 8/16 MB for the S3) into a directory, named the way the release
assets are. The flash size selects the partition table; the app image inside is
the same across all of them.
The merged file lays the pieces at 0x1000 (bootloader; 0x0 on the S3), 0x8000
(partition table), 0xE000 (boot_app0.bin) and 0x10000 (the app), with 0xFF
padding between - which is what erased flash reads as, so writing it also clears
NVS. It ends where the app ends, so it is about 1.5 MB rather than a full flash
image, and the SPIFFS region is left untouched.
Because an OTA update never rewrites the partition table, AWTRIX only picks up a changed layout from a full USB flash of one of these.
Vendored libraries¶
Two libraries are vendored - their source lives in the repository under lib/
rather than being pulled from the PlatformIO registry:
lib/ directory |
What it is |
|---|---|
TJpg_Decoder |
The TJpg_Decoder JPEG decoder |
berry |
The Berry scripting language runtime |
TJpg_Decoder has no corresponding entry in lib_deps - PlatformIO's Library
Dependency Finder links it automatically. berry is named in the native
environment's lib_deps, which resolves that name to the local copy rather than
fetching from the registry. Every other dependency (FastLED, PubSubClient, base64,
the Adafruit sensor libraries, and so on) is a registry dependency listed under
lib_deps for the device build.
Host toolchain¶
The native and native_sim environments compile with your host GCC. On Windows,
the pre:scripts/native_toolchain.py script checks whether g++ is on PATH; if
it is not, it falls back to a portable
w64devkit at D:\tools\w64devkit (override
the location with the W64DEVKIT environment variable) by prepending its bin
directory to the build's PATH. Nothing is installed system-wide - extract a
w64devkit release and you are done. On Linux and macOS the system compiler is used
directly.
Continuous integration¶
.github/workflows/ci.yml runs on every push (to main, to any branch, and on v*
tags), on pull requests targeting main, and on manual dispatch. Its jobs run on
ubuntu-latest with Python 3.12 and PlatformIO installed via pip, plus Node.js 22
for the firmware builds and the web UI tests:
| Job | Command | Covers |
|---|---|---|
| Core host unit tests | pio test -e native -v |
The portable core/ layer |
| Web UI tests (jsdom) | npm test (in webui/test) |
The web UI JS logic, loaded from the shipped webui/index.html via jsdom |
| Firmware build | pio run -e <env>, then scripts/factory_image.py --all |
Every device image - the matrix is awtrix, awtrix_s3_octal, awtrix_s3_quad - and a USB install image per flash size |
| API docs match the firmware | tools/check_docs_sync.py, tools/check_berry_api.py, tools/check_prelude_solidified.py, tools/check_font_sync.py, tools/check_partitions.py |
Documented fields and error codes, the editor's Berry table, the solidified prelude, the generated panel font, and every partition table |
On a v* tag a release job additionally publishes every OTA image and the USB install
images for each supported flash size.
A second workflow, .github/workflows/docs.yml, builds this documentation with
mkdocs build --strict and fails on broken links and anchors.