lambent-flex-c6
ESP32-C6 port of lambent-flex: WS2812 pixels driven over RMT, fed by UDP packets, with a small JSON status endpoint over HTTP.
What changed from the C3 version
| lambent-flex (C3) | lambent-flex-c6 | |
|---|---|---|
| Rust target | riscv32imc-esp-espidf |
riscv32imac-esp-espidf |
MCU env |
esp32c3 |
esp32c6 |
The C6 core is RV32IMAC — it has the atomic extension the C3 lacks, hence the different target triple.
riscv32imac-esp-espidf is shared by the H2, C5, C6 and P4. esp-idf-sys
picks the first match (H2) when nothing disambiguates, so MCU=esp32c6 in
.cargo/config.toml is required, not cosmetic.
ESP32-C6 support landed in ESP-IDF v5.1, but the tree pins ESP_IDF_VERSION = "v5.2.3": esp-idf-hal 0.46 references pcnt intr_priority and rmt
queue_non_blocking, both added in 5.2. The 4MB flash size in
sdkconfig.defaults is also load-bearing — it selects 64KB MMU pages, and
32KB-page images will not boot under espflash’s bundled bootloader.
Everything else (RMT WS2812 bit-banging, the UDP handler threads, the HTTP server) is chip-agnostic and carried over unchanged.
Pins
Pins come from config, not the source. Defaults shown; the C3 build hardcodes GPIO8 + GPIO9.
| Config | Default | Use | Driver | UDP port |
|---|---|---|---|---|
pin_0 |
GPIO5 | strand 0 | RMT channel0 |
7777 |
pin_1 |
GPIO6 | strand 1 | RMT channel1 |
7778 |
indicator_pin |
-1 (off) |
indicator LED | bit-banged | 7776 |
pin_0 serves the first port, pin_1 the second. The C3 build reverses
this — it hands strands out with ledzz.pop(), so there the vec’s last pin
serves port 7777. Worth knowing if you are moving a config between the two.
Setting -1 for a strand pin falls back to the default, so configs written
before these fields existed keep the old pinout. pin_0 == pin_1, pins outside
0–30, and GPIO12/GPIO13 are refused at boot.
Changing pins on an already-provisioned board needs an NVS erase first — see Config below.
Why the strands moved
On the ESP32-C6-DevKitC-1, GPIO8 is the on-board addressable LED and GPIO9 is the BOOT button — and both are strapping pins, sampled at reset. Driving strands from them meant attached hardware could hold them at the wrong level during boot. Moving to GPIO5/GPIO6 clears that, frees GPIO8 to be the status indicator it already is on the devkit, and leaves GPIO9 available as a physical hold-BOOT input.
GPIO0–GPIO7 and GPIO10+ are fair game, except GPIO12/GPIO13 (USB D-/D+) —
using those kills the USB-Serial-JTAG console you flash and monitor over, so
the firmware refuses them. GPIO15 is also a strapping pin. Two strands is the
ceiling regardless: port_count above 2 logs a warning and ignores the
surplus.
The indicator is not a third strand
RMT on the C6 has exactly 2 TX channels, verified against ESP-IDF’s own
soc/esp32c6/include/soc/soc_caps.h:
#define SOC_RMT_TX_CANDIDATES_PER_GROUP 2
#define SOC_RMT_CHANNELS_PER_GROUP 4 /* 4 total, only 2 can transmit */
Both are taken by the strands, so the indicator has no channel left and is
driven by hand in src/led/indicator.rs — GPIO register writes timed off the
RISC-V cycle counter, with interrupts masked for the ~30us frame. That caps the
firmware at two strands; a third addressable output would need SPI or a
different driver entirely.
The indicator is optional and off by default (indicator_pin = -1). When
enabled it listens on its own port, one below udp_start, and no longer
mirrors strand traffic. Discovery advertises it as indicator_port /
has_indicator.
Config
toml-cfg reads a cfg.toml at the crate root (gitignored). Create one:
[lambent-flex-c6]
wifi_ssid = "..."
wifi_psk = "..."
port_count = 2
udp_start = 7777
pin_0 = 4 # strand 0 -> udp_start (-1 = default GPIO5)
pin_1 = 5 # strand 1 -> udp_start + 1 (-1 = default GPIO6)
indicator_pin = 8 # -1 (default) disables the status LED
ota_url = "" # "" = the forge manifest (see OTA below)
ota_interval_mins = 360 # 0 = never poll; POST /ota still works
The section name must match the crate name, so it differs from the C3 project’s
[lambent-flex].
cfg.toml only seeds an unprovisioned device: load_or_seed returns the
stored NVS blob untouched once one exists. Editing cfg.toml on a board that
has already joined a network therefore looks like it does nothing. Erase NVS to
re-seed:
~/.espressif/python_env/idf5.2_py3.13_env/bin/python -m esptool \
--port /dev/ttyACM0 --chip esp32c6 erase_region 0x9000 0x6000
cargo run --release
That discards the stored WiFi credentials, so cfg.toml must have working ones
or the device comes up in its provisioning portal. The boot line
pins:4,5 indicator:8 confirms the new values took.
Build & flash
cargo build --release
cargo run --release # -> flash.sh: espflash with the OTA layout
Requires the pinned nightly with rust-src (rust-toolchain.toml — rustup
installs it for you), plus ldproxy and espflash. The pin is deliberate:
current nightlies do not build std for this target.
cargo run goes through flash.sh, which adds three things OTA needs: the
two-slot partitions.csv, our own bootloader (rollback is a bootloader
feature, so espflash’s bundled one will not do), and an otadata erase so a
USB flash always comes up on ota_0.
OTA
The device updates itself from the forge. CI builds every push to master,
publishes the image to gg.neiam.co’s raw registry, and each device polls one
manifest and installs anything that differs from what it is running:
https://gg.neiam.co/pkg/raw/neiam/lambent-flex-c6/manifest.json
Default is every 6 hours; POST /ota forces a check immediately. GET /
reports version, ota_slot, ota_state, and whatever the last check found.
$ curl -X POST http://<device-ip>/ota
{"queued":true,"running":"0.1.0+ci20260916.g1a2b3c4","available":null,…}
A device flashed before OTA existed needs one USB flash to migrate onto the
two-slot partition table — the app slot moved and there is no otadata. NVS
keeps its offset and size, so credentials and pins survive; do not erase it.
CI publishes a merged flash-all.bin for exactly this:
curl -fsSLO https://gg.neiam.co/pkg/raw/neiam/lambent-flex-c6/latest/flash-all.bin
espflash write-bin 0x0 flash-all.bin
An image that fails before it can serve HTTP never marks itself valid, and the
bootloader puts the previous one back on the next reset. See src/ota.rs.