Beatled Controller

Source code lives in the main repo at controller/. (Historically a separate beatled-pico repository — that’s now a read-only mirror.)

Beatled Controller is embedded C firmware that drives WS2812 LED strips with beat-synchronized lighting patterns. It connects to the Beatled server over WiFi and receives tempo data and control commands via a binary UDP protocol.

The firmware runs on Raspberry Pi Pico W, ESP32, and as a native macOS/Linux application for development. A Hardware Abstraction Layer (HAL) with 10 modules allows the same application code to compile and run across all targets.

Supported Platforms

PortTargetCoresRTOSLED Driver
picoRaspberry Pi Pico W (RP2040)2Bare-metalPIO + DMA
pico_freertosRaspberry Pi Pico W (RP2040)2FreeRTOS SMPPIO + DMA
posixmacOS / Linux1pthreadsMetal simulation
posix_freertosmacOS / Linux1FreeRTOS (POSIX sim)Metal simulation
esp32ESP32-S3, ESP32-C3, etc.1-2FreeRTOS (ESP-IDF)RMT peripheral

Requirements

Pico W Hardware

  • Raspberry Pi Pico W
  • AAA Battery holder (Adafruit)
  • Wire (Adafruit)
  • WS2812 LED Strip (Sparkfun)
  • A hat or clothes to mount it on

ESP32 Hardware

  • ESP32-S3 or ESP32-C3 dev board (e.g., ESP32-S3-DevKitC)
  • WS2812 LED Strip
  • USB cable for flashing

Getting Started

Clone the main repo and initialise the controller’s vendored submodules (includes the Pico SDK and FreeRTOS kernel):

git clone https://github.com/oost/beatled.git
cd beatled
git submodule update --init --recursive

All firmware sources are at controller/. ./beatled.sh defaults PICO_DIR to that path; override it only if you’re working on a parallel firmware tree.


Building for Pico W (UF2)

This cross-compiles the firmware into a .uf2 binary that you flash directly onto the Pico W.

Dependencies

brew install cmake
brew install --cask gcc-arm-embedded   # ARM cross-compiler
brew install openocd                    # on-chip debugger (optional)
brew install minicom                    # serial monitor (optional)

(Full Pico SDK setup instructions)

Build

Copy the template and fill in your values:

cp .env.wifi.template .env.wifi
cp .env.pico.template .env.pico
# edit .env.wifi with your WIFI_SSID, WIFI_PASSWORD (shared with the Pi host)
# edit .env.pico with BEATLED_SERVER_NAME, NUM_PIXELS, WS2812_PIN

WiFi networks live in the shared .env.wifi (one source of truth for the firmware builds and the Pi host’s wifi-fallback service). To roam between locations, add up to three upstream fallback networks via WIFI_SSID_2/WIFI_PASSWORD_2_4, and optionally the Pi’s own fallback hotspot via HOTSPOT_SSID/HOTSPOT_PASSWORD (joined last — it’s what the Pi broadcasts when no upstream WiFi is reachable). At boot the controller tries each non-empty network in order; if a network is unreachable it falls through to the next, and once the list is exhausted it cycles back to the first and keeps retrying until one joins.

Then configure and build (the build wrapper sources .env.wifi before .env.pico; do the same by hand if you build directly):

export PICO_TOOLCHAIN_PATH="/Applications/ArmGNUToolchain/12.2.rel1/arm-none-eabi"
# `set -a` exports every var so CMake picks them up from the environment
# ($ENV{WIFI_SSID} etc.) — a plain `source` would set but not export them,
# baking in empty WiFi credentials.
set -a
source .env.wifi   # WIFI_SSID/WIFI_PASSWORD (+ _2..4 fallbacks, HOTSPOT_*)
source .env.pico   # BEATLED_SERVER_NAME, NUM_PIXELS, WS2812_PIN
set +a

cmake -B build-pico \
  -DPORT=pico \
  -DPICO_BOARD=pico_w \
  -DPICO_SDK_PATH=lib/pico-sdk \
  -DNUM_PIXELS=$NUM_PIXELS \
  -DWS2812_PIN=$WS2812_PIN

cmake --build build-pico

The output binary is at build-pico/src/pico_w_beatled.uf2.

For the FreeRTOS variant (uses FreeRTOS SMP instead of bare-metal multicore):

cmake -B build-pico-freertos \
  -DPORT=pico_freertos \
  -DPICO_BOARD=pico_w \
  -DPICO_SDK_PATH=lib/pico-sdk \
  -DNUM_PIXELS=$NUM_PIXELS \
  -DWS2812_PIN=$WS2812_PIN

cmake --build build-pico-freertos

Flashing

Hold the BOOTSEL button on the Pico W while plugging it in via USB. It mounts as a USB drive. Copy the UF2 file onto it:

cp build-pico/src/pico_w_beatled.uf2 /Volumes/RPI-RP2/

The Pico reboots automatically and starts running the firmware.

Serial Monitor

To view logs over USB serial:

minicom -b 115200 -D /dev/tty.usbmodem*

Building for ESP32

The ESP32 port uses a separate ESP-IDF project wrapper in the esp32/ directory. It supports both dual-core chips (ESP32, ESP32-S3) and single-core chips (ESP32-C3).

Dependencies

Install ESP-IDF v5.x or later:

mkdir -p ~/esp
cd ~/esp
git clone --recursive https://github.com/espressif/esp-idf.git
cd esp-idf
./install.sh esp32s3    # or esp32, esp32c3
. ./export.sh

Build

Copy the template and fill in your values:

cp .env.wifi.template .env.wifi
cp .env.esp32.template .env.esp32
# edit .env.wifi with WIFI_SSID, WIFI_PASSWORD (shared with the Pico build + Pi host)
# edit .env.esp32 with BEATLED_SERVER_NAME, ESP32_TARGET, ESP32_PORT, NUM_PIXELS, WS2812_PIN

Fallback networks work the same as on the Pico: set WIFI_SSID_2/ WIFI_PASSWORD_2_4 to have the controller try each in order and keep cycling until one connects.

Then build and flash using the project script from the beatled repo:

./beatled.sh controller esp32-freertos flash

Or manually:

cd esp32
# `set -a` exports every var so idf.py / CMake read them from the
# environment ($ENV{WIFI_SSID} etc.), including the _2..4 fallbacks and
# HOTSPOT_SSID/HOTSPOT_PASSWORD.
set -a
source ../.env.wifi
source ../.env.esp32
set +a

idf.py set-target $ESP32_TARGET
idf.py build flash monitor -p $ESP32_PORT

Flash & Monitor

# macOS
idf.py flash monitor -p /dev/cu.usbmodem101

# Linux
idf.py flash monitor -p /dev/ttyUSB0

Dual-Core vs Single-Core

On dual-core chips (ESP32, S3), the LED task is pinned to core 1 while networking runs on core 0, matching the Pico W architecture. On single-core chips (C3), both tasks share the single core via FreeRTOS preemptive scheduling.


Building the POSIX Port (macOS)

The POSIX port builds a native macOS executable that replaces hardware APIs with POSIX equivalents (pthreads, UDP sockets) and renders a 3D LED ring simulation using Metal shaders.

Image

Dependencies

brew install cmake

vcpkg must be installed and VCPKG_ROOT set in your environment.

Build

Copy the template and fill in your values:

cp .env.pico.template .env.pico
# edit .env.pico — BEATLED_SERVER_NAME defaults to localhost if unset
source .env.pico

Then build:

cmake -B build \
  -DPORT=posix \
  -DCMAKE_TOOLCHAIN_FILE="$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" \
  -DCMAKE_BUILD_TYPE=Debug \
  -DNUM_PIXELS=$NUM_PIXELS \
  -DWS2812_PIN=$WS2812_PIN

cmake --build build

For the FreeRTOS variant (runs a FreeRTOS POSIX simulator to validate RTOS-specific code paths):

cmake -B build_posix_freertos \
  -DPORT=posix_freertos \
  -DCMAKE_TOOLCHAIN_FILE="$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" \
  -DCMAKE_BUILD_TYPE=Debug \
  -DNUM_PIXELS=$NUM_PIXELS \
  -DWS2812_PIN=$WS2812_PIN

cmake --build build_posix_freertos

Run

./build/src/pico_w_beatled.app/Contents/MacOS/pico_w_beatled

Or using the project utility script from the beatled repo (reads .env.pico automatically):

./beatled.sh controller posix build       # posix port
./beatled.sh controller freertos-sim build # posix_freertos port

By default, the POSIX port connects to localhost. Set BEATLED_SERVER_NAME in .env.pico to override.

Running Tests

cmake --build build
./build/tests/posix/integration/test_integration
./build/tests/posix/command/test_command
./build/tests/posix/clock/test_clock
./build/tests/posix/queue/test_queue

Debugging in VS Code

The project ships with a ready-to-use VS Code debug configuration for the POSIX port.

  1. Open the controller/ folder in VS Code (or open the beatled repo root and point the CMake extension at controller/CMakeLists.txt)
  2. Make sure .vscode/settings.json has the POSIX port selected (this is the default):

    "cmake.configureSettings": {
      "PORT": "posix",
      "CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake"
    }
    
  3. Configure and build via the CMake extension (or the terminal commands above)
  4. Set pico_w_beatled as the CMake launch target (CMake: Set Launch Target)
  5. Press F5 or select the (lldb) Launch configuration

    This launches the POSIX executable under LLDB with full source-level debugging – breakpoints, variable inspection, and stepping all work as expected.


Device State Machine

Each controller follows this state machine to establish synchronization with the server:

stateDiagram-v2
    [*] --> STARTED
    STARTED --> INITIALIZED: Board init complete
    INITIALIZED --> REGISTERED: Hello response received
    REGISTERED --> TIME_SYNCED: Time sync complete
    TIME_SYNCED --> TEMPO_SYNCED: Tempo/NextBeat received
    TEMPO_SYNCED --> TEMPO_SYNCED: Re-sync (new tempo data)
StateDescription
STARTEDInitial state after power-on
INITIALIZEDWiFi connected, peripherals ready
REGISTEREDServer acknowledged device (assigned client_id)
TIME_SYNCEDNTP-style clock offset established
TEMPO_SYNCEDReceiving beat data, LEDs active

See Server ↔ Controller Communication for the full message exchange sequence.


Dual-Core Architecture

On dual-core targets (Pico W, ESP32-S3), the firmware splits work across two cores to keep LED timing deterministic. On single-core targets (ESP32-C3) and the POSIX port, the same tasks run concurrently via FreeRTOS scheduling or pthreads.

graph LR
    subgraph Core0["Core 0 - Network"]
        EL[Event Loop] --> CMD[Command Handler]
        CMD --> SM[State Manager]
        UDP[UDP Listener] --> EQ[Event Queue]
        EQ --> EL
    end

    subgraph IPC["Inter-core"]
        ICQ[Intercore Queue]
    end

    subgraph Core1["Core 1 - LEDs"]
        LP[LED Processor] --> WS[WS2812 Driver]
        REG[Registry<br/>Shared State] --> LP
    end

    CMD -->|tempo, program updates| ICQ
    ICQ --> LP
    CMD -->|mutex-protected| REG

LED Patterns

The firmware ships with 8 built-in patterns:

IDNameDescription
0SnakesAnimated snake trails along the strip
1RandomRandom pixel colors
2SparklesTwinkling sparkle effect
3GreysGreyscale gradient
4DropsRaindrop-style pulses
5SolidSolid brightness driven by beat fraction
6FadeGrey fade synced to beat
7Fade ColorColor fade synced to beat

Patterns are simple C functions that receive the beat position (0-255) and beat count. New patterns can be added in src/ws2812/programs/.


Hardware Abstraction Layer

The firmware uses a 10-module HAL so that application code (state machine, commands, clock, patterns) is completely platform-independent. Each module has a public header in src/hal/<module>/include/hal/ and port-specific implementations under src/hal/<module>/ports/<port_name>/.

Compile Definitions

Each port sets one or more of these defines for conditional compilation:

PortPICO_PORTPOSIX_PORTESP32_PORTFREERTOS_PORT
picox   
pico_freertosx  x
posix x  
posix_freertos x x
esp32  xx

HAL Modules

ModulePurposeKey API
blinkStatus LEDblink(speed, count)
boardBoard init + unique IDget_unique_board_id(id)
networkUDP communicationstart_udp(), send_udp_request(), get_ip_address()
processMulti-core / threadingstart_core1(fn), sleep_ms(ms)
queueThread-safe message queueshal_queue_init(), hal_queue_add_message(), hal_queue_pop_message()
registryShared state with mutexregistry_lock_mutex(), registry_unlock_mutex()
runtimeApplication startupstartup(main_fn)
timeMicrosecond clock + alarmstime_us_64(), hal_add_repeating_timer()
wifiWiFi managementhal_wifi_init(), wifi_check(ssid, password)
ws2812LED strip driverws2812_init(), output_strings_dma(pixels)

Implementation Per Port

Modulepicopico_freertosposixposix_freertosesp32
processPico multicoreFreeRTOS taskspthreadsFreeRTOS tasksFreeRTOS xTaskCreatePinnedToCore
queuePico queue_tFreeRTOS xQueuecircular buffer + mutex= pico_freertos= pico_freertos
registryPico mutexFreeRTOS mutexPOSIX mutex= pico_freertos= pico_freertos
networklwIP raw API= picoBSD sockets + pthreadsBSD sockets + FreeRTOS taskBSD sockets + FreeRTOS task
timeHW alarm pool= picoPOSIX timersFreeRTOS software timersesp_timer + FreeRTOS timers
wifiCYW43 driver= picostubstubesp_wifi event API
ws2812PIO + DMA= picoMetal renderer= posixRMT led_strip
runtimebare-metal initFreeRTOS schedulerMetal app delegateFreeRTOS schedulerNVS init + app_main

= pico / = posix / = pico_freertos means the port reuses the same source files.

Code Sharing

Three FreeRTOS ports (pico_freertos, posix_freertos, esp32) share the same queue and registry implementations because FreeRTOS queues and mutexes have no hardware dependencies. The POSIX DNS resolver (getaddrinfo) is shared by posix, posix_freertos, and esp32 since ESP-IDF’s lwIP supports it. FreeRTOS software timer alarms are shared between posix_freertos and esp32.

Wiring Diagram

Image

Circuit