Skip to content

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.

pio run -e awtrix_s3_quad

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.

pio run -e awtrix_probe -t upload -t monitor

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:

python scripts/gen_partitions.py --soc esp32s3 --flash-size 16MB

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.