6.4 KiB · text History 9d79b3d

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 nightly toolchain with rust-src (see rust-toolchain.toml) for build-std, plus ldproxy and espflash.

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.