Skip to content

Building from source

AWTRIX NG builds with two systems. PlatformIO builds the ESP32 firmware and runs the unit tests of the portable core. CMake builds awtrix-linux, the application the Ulanzi TC002 runs, together with every host contract test. The PlatformIO configuration is platformio.ini; the CMake entry points are CMakeLists.txt and CMakePresets.json.

You need PlatformIO, Python 3 and Node.js. The host tests also need CMake 3.20 or newer, Ninja and GCC. Node.js is used only by the 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
python scripts/test_native.py # host unit tests for the portable core

awtrix is the default environment (default_envs = awtrix), so a bare pio run builds the ESP32 firmware.

Build environments

platformio.ini has six environments. Three build device firmware (awtrix, awtrix_s3_octal, awtrix_s3_quad). Two are measurement builds of it (awtrix_probe, awtrix_s3_octal_probe). One runs the host unit tests (native).

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 +<*> -<platform/> +<platform/esp32/>, without core/synth/ and core/audio/PitchDetector.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. It is the same file you would upload through the web UI's update page.

awtrix_s3_octal - the ESP32-S3 firmware

The same sources built for an ESP32-S3 with octal PSRAM (external pseudo-static RAM), for generic DIY builds. It differs from awtrix by one build flag, AWTRIX_SOC_ESP32S3. That flag selects the S3 pin rules, the compiled matrix drivers and the pin defaults. Everything else 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. An S3 without PSRAM 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 board with quad PSRAM needs awtrix_s3_quad.

The V suffix some sellers add (N16R8V, N32R8V) is a different module: WROOM-2, 1.8 V, with octal flash. It needs memory_type = opi_opi and a matching bootloader, so it would need its own board file and image. Neither ships.

R8 says 8 MB, not which bus. On Espressif's own modules R8 is octal. Boards that carry their own PSRAM chip beside the SoC differ: an ESP-PSRAM64H is 8 MB over quad. Such a board needs awtrix_s3_quad despite its N16R8 label, and the released usb-awtrix-ng-s3-quad-16mb.bin matches its flash. For a local build, the 8 MB board file works as it is; its partition table leaves the upper half of the flash unused.

awtrix_s3_quad - the S3 firmware for quad PSRAM

Same sources and same flags as awtrix_s3_octal. Only the board file differs:

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. Those carry CONFIG_SPIRAM_MODE_OCT or CONFIG_SPIRAM_MODE_QUAD. The mode is compiled in, never detected, so one image cannot serve both kinds of board.

A wrong choice has different results in each direction:

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 comes from the quad libraries: they lack CONFIG_SPIRAM_IGNORE_NOTFOUND, so a failed PSRAM init calls abort(). Only a USB flash undoes it. That is why POST /update refuses an image built for the other PSRAM type with wrongChip instead of 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. 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. awtrix_s3_octal_probe is the same for the S3.

pio run -e awtrix_probe -t upload -t monitor

native - host unit tests

The core/ layer is portable C++17 without Arduino or FastLED, so it compiles and runs on your development machine. The fast runner uses CMake, Ninja and CTest with the Unity test framework. No ESP32 and no emulator are needed. PlatformIO still provides the declared dependencies.

Property Value
Platform native (host compiler)
Test framework unity
Sources test/CMakeLists.txt: core/ (via cmake/Core.cmake, with platform/linux/host/HostScriptHeap.cpp) plus transport/ScriptMqttBridge.cpp; PlatformIO only installs the dependencies (build_src_filter = -<*>)
Extra lib_deps berry (the vendored interpreter), base64 and Unity
Optimisation -O2
python scripts/test_native.py              # build and run every host suite
python scripts/test_native.py -R payload   # run matching suites after the build
python scripts/test_native.py -j 8         # limit parallel build and test jobs

The runner compiles the portable core once, links the suite executables in parallel and runs them in parallel with CTest. The suites live in test/. Generated files go to .pio/native-tests/. Both this runner and the Linux build use cmake/Core.cmake for the engine. The PlatformIO native environment supplies dependencies for the CMake tests.

On Windows, test/CMakeLists.txt links the GCC runtimes statically into the host binaries. See Host toolchain.

awtrix-linux with CMake

awtrix-linux is the application the Ulanzi TC002 runs. Without --board tc002 it is a headless Linux development target with a fixed display, real MQTT and persistent state. CMake builds it together with every host contract test. Run this on Linux, or in WSL (Windows Subsystem for Linux), from the repository root. Debian and Ubuntu packages are shown; mosquitto is only needed by the MQTT tests:

sudo apt-get install -y build-essential cmake libssl-dev liblzma-dev libjpeg-turbo8-dev openssl mosquitto
pio pkg install -e native
cmake --preset host
cmake --build --preset host --parallel
ctest --preset host --parallel 8
build/host/awtrix-linux --data "$HOME/.local/share/awtrix-ng-dev" --port 8080 \
  --webui "$PWD/webui/index.html"

Then open http://127.0.0.1:8080. It listens on loopback only. The flags and the run modes are described in AWTRIX on Linux. The TC002 release, built for ARM with the tc002 and tc002-loader presets, is described in the TC002 developer guide.

Preset Toolchain Builds
host the machine's compiler awtrix-linux and every regression test
tc002 cmake/toolchains/tc002-musl.cmake the static ARM programs of a TC002 release
tc002-loader cmake/toolchains/tc002-glibc.cmake libawtrix-loader.so

A compiler warning in AWTRIX code stops the build. With a newer compiler that warns about more, configure with -DAWTRIX_WERROR=OFF.

The embedded web UI

The tab sources live in webui/src/; manifest.json lists their assembly order. python scripts/webui_source.py writes the single webui/index.html served by Linux. PlatformIO, CMake and the jsdom harness run the same assembler. Use --check to detect an outdated generated file without rewriting it.

Before compiling, the pre:scripts/build_webui.py extra script assembles, minifies and gzips the HTML into src/transport/http/WebUiAsset.h. That header is generated and checked in. The compressed asset has a 92 KB budget; the build fails when it is exceeded. The header is rewritten only when webui/index.html has changed, so incremental builds stay incremental.

Minification runs html-minifier-terser, pinned to one exact version, through npx. 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.

Commit source edits and generated HTML together. Merge the sources and rebuild; never merge WebUiAsset.h by hand. Commit a changed header separately. The TC002 bundler checks that the HTML and embedded asset match their inputs.

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. SPIFFS takes the rest, 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 checks the rules 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 merges them into one file starting at offset 0, so it can be flashed with nothing but esptool. The usb-*.bin release assets are these images. Flashing is the user page for 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: run 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 like the release assets. The flash size selects the partition table; the app image inside is the same across all of them.

The merged file places the pieces at 0x1000 (bootloader; 0x0 on the S3), 0x8000 (partition table), 0xE000 (boot_app0.bin) and 0x10000 (the app), with 0xFF padding between. 0xFF is what erased flash reads as, so writing the image also clears NVS (the settings store). The file ends where the app ends, so it is about 1.5 MB rather than a full flash image. The SPIFFS region is left untouched.

An update over the network never rewrites the partition table. A changed layout only takes effect after a full USB flash of one of these images.

Vendored libraries

Three libraries are vendored: their source lives in the repository under lib/ instead of coming from the PlatformIO registry.

lib/ directory What it is
berry The Berry scripting language runtime
PubSubClient The PubSubClient MQTT client, 2.8 with checked bounds on inbound packets
TJpg_Decoder The TJpg_Decoder JPEG decoder

PlatformIO's Library Dependency Finder links PubSubClient and TJpg_Decoder into the device build without a lib_deps entry. berry is named in the native environment's lib_deps, which resolves to the local copy. The CMake host build compiles lib/PubSubClient and lib/berry directly. Every other device dependency (FastLED, base64, the Adafruit sensor libraries) is a registry dependency listed under lib_deps.

Host toolchain

The native test runner compiles with your host GCC. On Windows, scripts/test_native.py checks whether gcc and g++ are on PATH. If they are not, it falls back to a portable w64devkit at D:\tools\w64devkit (override with the W64DEVKIT environment variable). 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 any branch, 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 through pip, plus Node.js for the firmware builds and the web UI tests:

Job Command Covers
Core host unit tests python scripts/test_native.py The portable core/ layer
Linux application and shared host contracts CMake build, CTest, tests/linux/test_service.py The Linux executable, HTTP/MQTT/script/persistence contracts, HTTPS certificate checks, the systemd service, TC002 tool tests without hardware
Web UI tests (jsdom) npm test in webui/test, then node --test for the flow converter The web UI logic loaded from the shipped webui/index.html, tools/flowconv
TC002 image tools Reusable tc002-image-tools.yml The pinned WASM tools and browser/desktop installer tests; CI, Docs and installer packaging reuse the verified cache for identical inputs
Firmware build pio run -e <env>, then scripts/factory_image.py --all 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, check_flow_converter.py, gen_agent_skill.py --check, check_berry_api.py, check_prelude_solidified.py, check_font_sync.py, check_partitions.py Documented fields and error codes, the flow converter's maps, the downloadable agent skill, the editor's Berry table, the solidified prelude, the generated panel fonts, every partition table

On a v* tag a release job also publishes every ESP32 update image, the USB install images for each supported flash size, the TC002 update package, the TC002 USB installation package with its corresponding sources, the TC002 desktop installers and the license notices. The tag must match version, and docs/releases/<version>.md must exist: the release body only links that page in each clock's documentation. The job depends on the native, web UI, TC002 image tools, TC002 update package, TC002 installer, firmware and documentation checks.

Other workflows:

Workflow Runs on Does
docs.yml pushes and pull requests to main, and after a release Builds this site with tools/docs/build_site.py: one documentation per clock (fails on broken links and anchors, and on text that names another clock), including the browser flasher and the TC002 browser installer, and publishes it to GitHub Pages
tc002-update.yml pushes to main and v* tags (called by ci.yml), and on dispatch Builds awtrix-ng-tc002.awup from scratch: the musl toolchain (cached until the Buildroot version, configuration or patches change), the glibc toolchain, the kernel tree, build_bundle.sh and tc002_install.py package; then tc002-sources.tar.gz (source_package.py) and usb-awtrix-ng-tc002.zip (browser_package.py)
tc002-installer.yml v* tags (called by ci.yml), and pushes that change tools/tc002/desktop/, tools/tc002/browser-image/, docs/assets/tc002/ Builds and tests the TC002 desktop installer for Windows, macOS and Linux
system-build.yml pull requests that change firmware/buildroot/, tools/system/ Cross-builds the experimental ARM userspace
discord-release.yml a published release, or dispatch by the release job Announces a release

Fonts

The panel fonts are generated C++ headers. python scripts/gen_matrix_fonts.py regenerates src/media/MatrixFonts.h and src/media/MatrixFontsCompact.h; python tools/check_font_sync.py checks them and the legacy fonts against their sources. The ESP32 build sets AWTRIX_COMPACT_FONTS=1 and carries four fonts; Linux and the TC002 carry all twelve. The native tests exercise both catalogs. Sources, generators and licensing are in Fonts.