9.4 KiB · text History dee327e

lambent-flex-c6

CI status firmware status license install lambent-flex-c6 (raw)

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 = -1    # -1 = default (0 for local builds, 360 for CI ones)

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

Every 6 hours for a CI build; a locally flashed one never polls on its own, so cargo run --release is not quietly undone a minute later by the forge’s build.

Driving it by hand

GET / says what a box is running — note it answers 218, not 200, which the packet sender matches on. GET /ota reports what the updater last found and does nothing; POST /ota is what makes it go and look now rather than at the next interval:

$ curl -s http://172.16.15.139/ | jq '{version, ota_slot, ota_state, ota_available}'
{
  "version": "0.1.0",
  "ota_slot": "ota_0",
  "ota_state": "unknown",
  "ota_available": null
}

$ curl -s -i -X POST http://172.16.15.139/ota | head -1
HTTP/1.1 202 Accepted

$ # a GET is safe, and says so
$ curl -s http://172.16.15.139/ota | jq -r .hint
POST to this URL to check for an update now

$ curl -s -X POST http://172.16.15.139/ota | jq
{
  "queued": true,
  "running": "0.1.0",
  "available": null,
  "last_error": null,
  "checked": false
}

202 means queued, not done. The check and the download run on the OTA thread, so the response says nothing about the outcome — and a successful update ends in a reboot, so the device stops answering for a few seconds. Ask again once it is back:

$ curl -s http://172.16.15.139/ | jq '{version, ota_slot, ota_state}'
{
  "version": "0.1.0+ci20260916.g2131e3c",
  "ota_slot": "ota_1",
  "ota_state": "valid"
}

It moved slots, and valid means this image reached the point of serving HTTP and cancelled its own rollback. A failed check leaves the device exactly where it was and says why:

$ curl -s http://172.16.15.139/ | jq '{ota_available, ota_error}'
{
  "ota_available": null,
  "ota_error": "fetching https://gg.neiam.co/pkg/raw/neiam/lambent-flex-c6/manifest.json: HTTP 503"
}

ota_state reads unknown on a board flashed over USB, which is not a fault: --erase-parts otadata leaves no state entry to read. It becomes valid after the first over-the-air install.

A fleet

# what is everyone running?
for ip in 172.16.15.{139,140,141}; do
  printf '%-16s %s\n' "$ip" "$(curl -s --max-time 5 "http://$ip/" | jq -r '.version // "unreachable"')"
done

# everyone, go and look now
for ip in 172.16.15.{139,140,141}; do
  curl -s --max-time 5 -X POST "http://$ip/ota" > /dev/null &
done; wait

Nothing needs coordinating: each device downloads for itself, verifies the SHA-256 the manifest gave it, and reboots when it is ready. One that is offline or mid-frame simply picks the update up at its next check.

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.