lambent-flex-c6
ESP32-C6 firmware in Rust (std / ESP-IDF, not bare-metal). Listens on a set of
UDP ports and drives one WS2812 strand per port via RMT; also serves a small
JSON status blob over HTTP.
This is a port of ../lambent-flex, which targets the ESP32-C3. The two
trees are otherwise near-identical, so fixes usually want to land in both.
Provenance — read this first
Created 2026-08-14 by copying ../lambent-flex (branch master, commit
6ec42e1, clean) and retargeting it. .git, target/, .embuild/ and
Cargo.lock were excluded from the copy; this is a fresh git repo with all
files staged and no commit yet. Making that first commit is unfinished
business — it was deliberately left to the user.
Complete list of what differs from the C3 tree:
| File | Change |
|---|---|
.cargo/config.toml |
target riscv32imc-esp-espidf → riscv32imac-esp-espidf; MCU esp32c3 → esp32c6; ESP_IDF_VERSION now v5.2.3; added CARGO_WORKSPACE_DIR |
Cargo.toml |
package name → lambent-flex-c6; esp-rs stack bumped (see below) |
src/led/ws2812.rs |
comment now says C6-DevKitC-1; migrated to esp-idf-hal 0.46 API |
src/main.rs |
migrated to esp-idf-svc 0.52 / esp-idf-hal 0.46 API |
.idea/ |
ws2812esp32rs.iml → lambent-flex-c6.iml, modules.xml updated |
README.md, CLAUDE.md |
new |
The dependency bump and API migration are NOT part of the original port. They were forced later, on 2026-08-14, to get the tree to compile at all — see “Getting it to build” below. The C3 tree has not received them yet and almost certainly needs the same treatment.
Chip facts that were verified against vendored crate source
Don’t re-derive these from memory; they were checked by reading the actual
crates in ~/.cargo/registry:
MCU=esp32c6in.cargo/config.tomlis load-bearing.esp-idf-sys’sbuild/native/cargo_driver/chip.rsmapsriscv32imac-esp-espidf→[ESP32H2, ESP32C5, ESP32C6, ESP32P4]and takes the first match by default. Drop the env var and you silently build for H2.- C6 is RV32IMAC (has the atomic extension the C3 lacks) — hence the different target triple, which is the whole substance of the port.
toml-cfgkeyscfg.tomlsections offCARGO_PKG_NAMEverbatim, no dash→underscore mangling (toml-cfg-0.1.3/src/lib.rs:214). Because the package was renamed,cfg.tomlhere needs[lambent-flex-c6]. A stale[lambent-flex]header does not error — it silently falls through to the#[default("")]SSID and the device just never associates. Suspect this first on “won’t join wifi”.ESP_IDF_VERSIONmust now be ≥ v5.2, not v5.1.esp-idf-hal0.46 referencespcnt_unit_config_t::intr_priorityand rmtqueue_non_blocking, both added in ESP-IDF 5.2. It is pinned tov5.2.3, which is alsoesp-idf-sys0.37’s ownDEFAULT_ESP_IDF_VERSION. (C6 support itself landed in v5.1, so 5.1 remains the chip’s floor — it’s the HAL that needs 5.2.)
RMT on C6 has exactly 2 TX channels — verified, against
esp-idf/v5.2.3/components/soc/esp32c6/include/soc/soc_caps.h:
#define SOC_RMT_GROUPS 1U
#define SOC_RMT_TX_CANDIDATES_PER_GROUP 2
#define SOC_RMT_RX_CANDIDATES_PER_GROUP 2
#define SOC_RMT_CHANNELS_PER_GROUP 4 /* 4 total, only 2 can transmit */
The two WS2812 strands consume both. There is no third channel for another
addressable device; anything extra must be a plain GPIO LED, or chained onto
the end of an existing strand. Note the legacy HAL shim declares
CHANNEL0..CHANNEL3 ungated, so the dead RMT struct in main.rs
type-checks regardless — that is not evidence of 4 usable TX channels.
Build state
The firmware builds and flashes. First verified 2026-08-14.
cargo build --release
cargo run --release # runner = ./flash.sh (espflash + the OTA layout)
Needs nightly + rust-src (see rust-toolchain.toml) for build-std, plus
ldproxy and espflash on PATH. ~/.espressif holds ESP-IDF v5.1.1 and
v5.2.3 side by side; v5.2.3 is the one in use.
Known-good running state
Verified end-to-end on hardware 2026-08-14: joins wifi, both UDP handlers bind, HTTP server answers on the LAN.
Chip type: esp32c6 (revision v0.2)
Flash size: 4MB
App/part. size: 1,159,808/4,128,768 bytes, 28.09%
...
I (3727) wifi:connected with <ssid>, aid = 13, channel 6, BW20
pins:-1,-1,-1,-1
2
Handler 0 listening on port 7777
Handler 1 listening on port 7778
I (3907) esp_idf_svc::http::server: Registered Httpd server handler Get for URI "/"
I (4867) esp_netif_handlers: sta ip: 172.16.15.139
$ curl -i http://<device-ip>/
HTTP/1.1 218 This Is Fine
{"id":0,"ip":"172.16.15.139","json":"baby","mac_addr":"8C:FD:49:10:64:CC",
"udp_sox":[7777,7778],"indicator_pin":null,"has_indicator":false,
"hostname":"lambent-flex-1064cc","version":"0.1.0+ci20260916.g1a2b3c4",
"ota_state":"valid","ota_slot":"ota_0","ota_available":null,"ota_error":null}
indicator_pin is the pin of a working indicator, or null; a configured
pin that was rejected or failed calibration reports null rather than claiming
hardware that is not being driven. has_indicator is the same fact as a bool,
for senders that would rather not special-case null.
3-byte RGB UDP datagrams to 7777/7778 are accepted without crashing.
Flash size is load-bearing — it sets the MMU page size
sdkconfig.defaults pins CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y. Don’t drop it.
The chip really is 4MB, but the reason it’s pinned is a side effect: flash size
drives CONFIG_MMU_PAGE_SIZE. At the ESP-IDF default of 2MB you get 32KB
pages; at 4MB you get 64KB.
espflash flashes its own bundled bootloader (currently ESP-IDF v5.5.1), which validates segment alignment against 64KB pages. With 32KB-page images every flash produced an unbootable app:
E (76) esp_image: Segment 0 load address 0x420e8020, doesn't match data 0x00010020
E (83) boot: Factory app partition is not bootable
The rule is vaddr % page == flash_offset % page; 0x8020 vs 0x0020 fails at
64KB and matches at 32KB. With 4MB/64KB configured the app boots on espflash’s
bundled bootloader and plain cargo run --release works with no extra flags.
If you ever do need to pair the app with our own matching bootloader (e.g. after changing flash size back), that is:
D=$(ls -d target/riscv32imac-esp-espidf/release/build/esp-idf-sys/*/out/build | head -1)
espflash flash --port /dev/ttyACM0 \
--bootloader $D/bootloader/bootloader.bin \
target/riscv32imac-esp-espidf/release/lambent-flex-c6
Note our own generated partition table gives factory only 1M while the
release app is ~1.10M, so don’t pass --partition-table with it; espflash’s
default table (3.9M factory) is what works.
Download mode after flashing — power cycle, don’t reset
If the ROM reports:
rst:0x15 (USB_UART_HPSYS),boot:0x0 (USB_BOOT)
wait usb download
the chip is stuck in USB download mode. The C6’s USB-Serial-JTAG latches a
download-boot request that survives soft resets, espflash reset,
--after hard-reset, and watchdog-reset. Unplug and replug USB. After a
true power cycle it comes up boot:0x8 (SPI_FAST_FLASH_BOOT) normally.
This was originally misdiagnosed here as the GPIO8/GPIO9 strapping hazard. It is not — the straps are fine on this board.
Getting it to build — five toolchain-skew failures
The inherited 2023-era esp-rs stack does not compile on a 2026 toolchain. All five of these were hit in sequence; the first is still a live local patch.
-
error[E0425]: cannot find value AT_FDCWD in crate libc, buildingstdviabuild-std. Upstream Rust regression:set_perm_nofollowinlibrary/std/src/sys/fs/unix.rsdispatches withcfg_select!, and espidf falls through to a_arm callinglibc::fchmodat(libc::AT_FDCWD, ...). The espidf libc module defines neither symbol (sibling newlib targetrtemsdefines its ownAT_FDCWD; espidf never got one). The arm’s own comment says ESP-IDF should skipO_NOFOLLOW, so the intended behaviour is plainchmod.Worked around by patching the rustup toolchain in place, adding an
any(target_os = "espidf", target_os = "horizon")arm that callsset_perm. Backup atunix.rs.origbeside it.rustup updatesilently reverts this and the build breaks again — ifAT_FDCWDreappears, that’s why. Not fixed by the dependency bump; it is purely astdproblem. -
~40 bindgen types emitted as opaque
{ _address: u8 }, includingspi_transaction_t,struct stat,netif,esp_netif_config,mbedtls_ssl_config,http_parser. Cause: bindgen was pinned at 0.63.0 (2022) but driven by the system libclang 22. Not intentional — neitheresp-idf-sysnorembuildsets anyopaque_typelist. Fixed by the dependency bump, which brings bindgen 0.71. -
c_charmismatch:esp-idf-svc0.47 hardcodes*const i8, but the bindings emit::core::ffi::c_char, which current Rust defines asu8for this target. That is correct —riscv32-esp-elf-gccdefines__CHAR_UNSIGNED__. Fixed by the bump. -
libxml2.so.2: cannot open shared object file— see below. -
sdkconfig.defaultssilently ignored, which is nastier than it sounds because it fails at runtime, not build time. The app booted and then died:Guru Meditation Error: Core 0 panic'ed (Stack protection fault). Detected in task "main" Stack bounds: 0x4082200c - 0x40823000 <- ~4KB, i.e. the 3584 defaultembuild::cargo::workspace_dir()(embuild-0.33.3/src/cargo.rs:438) derives the workspace root by popping 6 path components offOUT_DIR, assuming cargo’s oldtarget/<triple>/<profile>/build/<pkg>-<hash>/outlayout. Modern cargo nests it asbuild/<pkg>/<hash>/out— one component deeper — so it lands on<proj>/target, looks fortarget/sdkconfig.defaults, finds nothing, and drops it without a warning. Confirmed two ways: the build config dump printsesp_idf_sdkconfig_defaults: None, and thecmakeinvocation getsSDKCONFIG_DEFAULTS=".../out/gen-sdkconfig.defaults"only.Fixed by setting
CARGO_WORKSPACE_DIRin.cargo/config.toml[env], whichworkspace_dir()checks before doing the pop-count math.This silently defeats every option in
sdkconfig.defaults, not just the stack size. If you add settings there and they seem to do nothing, verify against the generatedout/sdkconfigbefore believing them.
libxml2: a machine-level, recurring gotcha
Espressif’s clang binaries link against libxml2.so.2. openSUSE ships libxml2
with soname 16 (libxml2.so.16), so every esp-clang on this box fails to
load, which makes esp-idf-sys’s tool health-check fail with the unhelpful
Could not install all requested Tools.
Worked around with a symlink in each esp-clang’s own lib dir:
for d in ~/.espressif/tools/esp-clang/*/esp-clang; do
ln -sfn /usr/lib64/libxml2.so.16 "$d/lib/libxml2.so.2"
done
RPATH picks it up, so no LD_LIBRARY_PATH is needed. This is ABI-safe here:
libclang.so references zero libxml2 symbols, and libLLVM-*.so’s 17 all
resolve against soname 16 and are reachable only from
llvm::windows_manifest::WindowsManifestMerger, dead code on Linux.
Redo this whenever ESP-IDF installs a new esp-clang (a new ESP_IDF_VERSION
pulls a new one — v5.1.1 wanted 15.0.0, v5.2.3 wants 16.0.1).
Other build-cache traps
cargo clean -p esp-idf-sysdoes not clear embuild’s CMake tree — it lives attarget/<triple>/release/build/esp-idf-sys/<hash>/(nested dir, not cargo’sesp-idf-sys-<hash>). A staleCMakeCache.txtthere pinned gcc 12.2.0 after the ESP-IDF version bump and producedTool doesn't match supported version from list ['esp-13.2.0_20230928']. Fix:rm -rf target/<triple>/release/build/esp-idf-sys.- ESP-IDF tool installation is not atomic; a mid-way failure leaves a partial
install that reports the same generic
Could not install all requested Tools. Re-running works, or drive it directly:IDF_TOOLS_PATH=~/.espressif ~/.espressif/python_env/<env>/bin/python \ ~/.espressif/esp-idf/<ver>/tools/idf_tools.py --non-interactive install
Dependency bump and API migration (2026-08-14)
| Crate | Was | Now |
|---|---|---|
esp-idf-svc |
0.47.3 | 0.52.1 |
esp-idf-hal |
(transitive 0.42.5) | direct 0.46.2, features = ["rmt-legacy"] |
esp-idf-sys |
0.33.7 | 0.37.2 |
embuild |
0.31.3 | 0.33 |
embedded-svc |
0.26.4 | 0.29 |
| ESP-IDF | v5.1.1 | v5.2.3 |
esp-idf-hal is now a direct dependency solely to enable rmt-legacy:
0.46 replaced rmt with the new ESP-IDF 5 driver and moved the old
TxRmtDriver/FixedLengthSignal API behind that feature. esp-idf-svc has no
passthrough for it. ws2812.rs still uses the legacy path and compiles with
deprecation warnings; rewriting it against the new encoder-based RMT API is
open work.
Source changes the bump forced:
hal::preludeis gone; it re-exportedperipherals::*andunits::*.- The
Peripheraltrait/indirection is gone — drivers take peripherals by value, soWS2812RMT::newtakesimpl OutputPin/impl RmtChannel. - Legacy
CHANNEL0..3gained a lifetime param, henceRMT<'a>. heapless0.9 droppedFrom<&str>for fixed-capacityString, so the wifi SSID (32 bytes) and PSK (64) go throughtry_into()and are now fallible.EspHttpServer::fn_handleris generic over the handler error type (needs an explicitOk::<(), EspIOError>(())) and requires'static, so the/handler closure is nowmove.
embassy was removed from default features. Nothing in src/ uses it,
and it fails to link with these versions (undefined reference to __embassy_time_queue_item_from_waker). Still opt-in via --features embassy.
Layout
src/main.rs— wifi client bring-up,UdpHandlerper port, one thread each,EspHttpServerwith a single/JSON handler, then an idle loop printing IP info every 10s.src/led/ws2812.rs—WS2812RMT, emits the 24-bit GRB frame as RMT pulses.set_pixelsets a single pixel; there is no multi-pixel path.src/led/indicator.rs—Ws2812BitBang, the same frame driven by hand for the one LED that has no RMT channel. See “Pinout and the indicator LED”.src/ota.rs— the update thread: fetches one JSON manifest over TLS, streams a differing image into the inactive slot, verifies, reboots. See “OTA”.partitions.csv— two app slots.flash.shis thecargo runrunner that passes it, our bootloader and--erase-parts otadatato espflash..forgejo/workflows/firmware.yml— the build that produces and publishes the image devices install.sdkconfig.defaults— bumps main task stack to 8000 (Rust needs more than the 3K default), pins flash size to 4MB (which is what selects 64KB MMU pages — see “Flash size is load-bearing”), enables bootloader rollback and pins the mbedtls/certificate-bundle options OTA’s TLS needs. Only honored becauseCARGO_WORKSPACE_DIRis set; see failure 5 below.cfg.toml(gitignored) — wifi creds + port config viatoml-cfg. Exists as of 2026-08-14 and works; needs the[lambent-flex-c6]header. Without it the firmware boots with an empty SSID and spins in the connect loop, never reaching the UDP handlers or HTTP server.
Inherited quirks — pre-existing, NOT port bugs
All of these came from the C3 tree unchanged. Don’t “fix” them as part of port work without asking; do fix them in both trees if asked.
Port→pin mapping is reversed.Fixed here (2026-08-14).ledzz.pop()is gone; handlers and strands are zipped in order, sopin_0serves the first port (7777) andpin_1the second. The C3 tree still reverses — the two firmwares now disagree about port ordering until it gets the same fix.Fixed here as a side effect of the above:port_count > 2panics.zipdrops the surplus with a warning instead ofpop().unwrap()panicking. Two strands is still the ceiling (two RMT TX channels).Fixed here.pin_0..pin_3are dead.pin_0/pin_1now select the strand GPIOs viaAnyOutputPin::steal, with-1falling back toDEFAULT_STRAND_PINS.pin_2/pin_3were removed — the C6 has two RMT TX channels, so they could never have meant anything here.- Short UDP packets panic.
callback()slices&data[0..3]with no length check. - The wifi wait loop had no delay —
while !wifi_driver.is_connected()busy-spun printing the wholeClientConfigurationas fast as the UART would take it. It never yielded, so the IDLE task starved and the task watchdog killed the device (task_wdt: - IDLE (CPU 0),mainstuck inuart_tx_char) any time association took more than a few seconds. It only ever appeared to work because association usually completed in ~3.7s, inside the timeout. Fixed here (2026-08-14) with a 500mssleepin the loop; this one is a real crash, not a cosmetic quirk, and wants porting to the C3 tree. Still an unbounded loop — it retries forever if the AP never answers. - Dead code:
RMTstruct,setup_routes,templated,index_html,let q =. Neither CI config can work.Both deleted (2026-09-16), replaced by.forgejo/workflows/firmware.yml, which builds in Espressif’s own container on the forge. The inherited.github/workflows/rust_ci.ymlrancargo buildon ubuntu-latest with no ESP-IDF setup (and had a literal typo at line 34 —dt olnay/rust-toolchain@v1);.gitlab-ci.ymlrancargo test --workspacein arust:1.75-bookwormcontainer. Neither could build for this target. The C3 tree still carries both.
OTA
Devices update themselves from the forge. Three pieces, and each is documented
where it lives: src/ota.rs (the device), .forgejo/workflows/firmware.yml
(the build), and GitGud.Packages.Formats.Raw in ../gitgud (the hosting).
push to master -> forgejo CI -> PUT /pkg/raw/neiam/lambent-flex-c6/<version>/…
|
device polls manifest.json every 6h ─────┘ -> streams the .bin into the
inactive slot -> reboots
The manifest is one document with a latest pointer and, per file, a URL and a
SHA-256. https://gg.neiam.co/pkg/raw/neiam/lambent-flex-c6/manifest.json is
compiled in as the default; ota_url / ota_interval_mins override it, and
POST /ota forces a check now. GET / reports version, ota_slot,
ota_state and what the last check found.
The device updates on different, not newer
latest is whatever CI published last, and the device installs anything that
is not the string it was built with. There is no version comparison, because
0.1.0+ci20260916.g1a2b3c4 has no ordering worth trusting — and because a
device that refuses to go “backwards” cannot be rolled back from the forge,
which is the only way to roll back a box that is already installed somewhere.
To roll a fleet back, republish the older bytes under a new version.
LAMBENT_FLEX_VERSION is what stamps the running version; CI sets it, and a
local build falls back to the Cargo.toml version. That fallback is deliberate:
a laptop-flashed device reads as different from everything on the forge and so
takes the next published build rather than sitting out the rollout.
Partition table — the migration is a one-time USB flash
partitions.csv replaces espflash’s default table (one 3.9M factory) with
ota_0 / ota_1 at 1984K each, plus otadata. A device flashed before this
existed must be flashed over USB once: the app slot moved and there is no
otadata partition to boot from.
nvs deliberately keeps both its offset and its size (0x9000, 0x6000). Change
either and the stored blob is invalidated — so the migration costs no
credentials and no pin config, and the erase_region recipe below is
specifically not what you want here. CI publishes a merged flash-all.bin
for the migration; espflash write-bin 0x0 flash-all.bin.
The 60KB between otadata (ends 0x11000) and ota_0 (starts 0x20000) is the
price of leaving nvs alone: app partitions must be 64KB-aligned. That
alignment is also what keeps the MMU-page rule satisfied — see “Flash size is
load-bearing”; with 4MB flash the pages are 64KB and both slots are 64KB
multiples, so vaddr % page == offset % page holds either way.
Rollback lives in the bootloader, so espflash’s bundled one will not do
CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y makes a freshly installed image boot
as unverified; the bootloader reverts to the previous image on the next reset
unless the app calls esp_ota_mark_app_valid_cancel_rollback. main.rs makes
that call after wifi is up, both UDP handlers are bound and the HTTP server
is serving — an image that panics on the way there un-installs itself, which is
the only defence a deployed box has.
That is a bootloader config, so cargo run now goes through flash.sh,
which passes --bootloader (ours, resolved via tools/idf-build-dir.sh),
--partition-table partitions.csv and --erase-parts otadata. The build
directory carries a content hash and cargo keeps stale ones around, so the
script picks the newest — taking the first match of the glob is how you
flash a bootloader built from a different sdkconfig and get a boot loop instead
of an error.
Size
TLS is expensive: the image went from 1.10MB to 1.51MB, 78% of a slot. Most of
that is mbedtls and the certificate bundle. espflash save-image prints the
ratio on every build, and CI fails the build if the image stops fitting rather
than letting a truncated write brick whatever takes it.
EspOta is a singleton
EspOta::new() fails while another instance is alive, so the running slot’s
label and state are read once at boot and cached for the status endpoint.
Querying per request would report “unknown” precisely while an update was in
progress — the opposite of what is happening.
CI builds in Espressif’s container, pinned
espressif/idf-rust:esp32c6_1.95.0.0, not esp32c6_latest. The pin fixes the
nightly the image installed at build time (2026-04-27), and rust-toolchain.toml
asks for “nightly” — which rustup resolves to whatever is already installed
rather than fetching a newer one. That is what keeps CI away from the current
nightly and the AT_FDCWD regression in failure 1 above. If CI ever starts
failing on AT_FDCWD, something moved that pin.
The image carries rustup, espup’s nightly, ldproxy and espflash, but not
ESP-IDF — esp-idf-sys clones v5.2.3 and installs its tools on the first run,
which is what ~/.espressif is cached for. Debian bookworm, so libxml2.so.2
is the system soname and the symlink workaround above is not needed there.
Cargo.toml depends on ../neiam-portal by path, so the workflow clones it
next to the checkout. That repository must exist on the forge; it is not
there as of this writing, and CI cannot build until it is pushed.
Pinout and the indicator LED
All three are configurable; the table shows the defaults
(DEFAULT_STRAND_PINS, DEFAULT_INDICATOR_PIN). The board on hand runs
pin_0 = 4, pin_1 = 5, indicator_pin = 8.
| Config | Default | Use | UDP port |
|---|---|---|---|
pin_0 |
GPIO5 | strand, RMT channel0 |
udp_start (7777) |
pin_1 |
GPIO6 | strand, RMT channel1 |
udp_start + 1 (7778) |
indicator_pin |
-1 (off) |
on-board WS2812, bit-banged | udp_start - 1 (7776) |
| — | — | GPIO9 free — BOOT on the devkit | — |
Validation refuses pin_0 == pin_1, pins outside 0–30, and GPIO12/GPIO13
(USB D-/D+ — using those kills the console you flash over). The indicator
additionally refuses any pin already in use by a strand, checked against the
configured pins rather than a hardcoded pair.
The strands moved off GPIO8/GPIO9 (the inherited C3 pinout) so GPIO8 could be the indicator. Two consequences worth knowing:
- GPIO9 is free again, so the physical hold-BOOT-to-reprovision escape
hatch is available. Nothing reads it yet;
MAX_BOOT_FAILSis still the only implemented route back into provisioning. - The indicator cannot use RMT. Both TX channels are taken (see the
verified
soc_caps.hnumbers above), soled::indicator::Ws2812BitBangdrives GPIO8 by hand: GPIOW1TS/W1TCregister writes timed off the RISC-V cycle counter, insideinterrupt::free. Each 24-bit frame blocks interrupts for ~30us, which is why it runs on its own thread and is poked via anAtomicU32rather than being called from the UDP hot path.
The cycle counter is CSR_PCCR_MACHINE (0x7e2), not mcycle — the C6 is PLIC,
not CLIC, so rv_utils_get_cycle_count() takes that branch (riscv/rv_utils.h:86).
The driver enables PCER/PCMR itself and calibrates against esp_timer at
startup, logging indicator: cycle counter calibrated at N cycles/us. If that
line is missing or new() errors, the counter is not running and the timing
would be garbage — the driver refuses rather than emitting nonsense.
indicator_pin defaults to -1 (disabled), so boards without one are
unaffected. Pins 12/13 (USB D-/D+) and the strand pins are rejected with a
logged warning rather than a boot failure — a bad pin number should not brick a
light controller. A bad strand pin is fatal, because there is no sensible way
to carry on without the thing the firmware exists to drive.
Changing any of these on a provisioned device needs an NVS erase
pin_0, pin_1 and indicator_pin are not on the shipped portal page (same
as udp_start / port_count), so in practice they come from cfg.toml. But
ConfigStore::load_or_seed returns the stored blob untouched whenever it is
provisioned (neiam-portal/src/store.rs:99) — the cfg.toml seed is only
consulted for an unprovisioned device. An already-provisioned board therefore
has a blob with no pin_0 key at all, serde fills the default, and editing
cfg.toml appears to do nothing.
To apply a pin change:
# nvs lives at 0x9000, length 0x6000 (see the boot log's partition table).
# espflash erase-parts wants a partition table it cannot find, hence esptool.
~/.espressif/python_env/idf5.2_py3.13_env/bin/python -m esptool \
--port /dev/ttyACM0 --chip esp32c6 erase_region 0x9000 0x6000
cargo run --release
Next boot is unprovisioned, so cfg.toml reseeds and persists. Confirm with
the boot line pins:4,5 indicator:8. This discards the stored WiFi
credentials, so cfg.toml must carry working ones or the device comes up in
the captive portal instead.
This is not part of the OTA partition migration — that keeps nvs exactly
where it was so a device keeps its credentials. Don’t run this while doing one.
Network hostname
The device reports lambent-flex-<device id> (e.g. lambent-flex-1064cc) as
its DHCP hostname — that is what a router’s client list and its DNS show. The
id is the last 3 MAC bytes in lowercase hex, the same one flex-setup-<id>
uses while provisioning; the prefix is HOSTNAME_PREFIX in main.rs and is
deliberately not lambent-flex-c6, since nothing on the network cares which
chip it is.
Without this a device reports ESP-IDF’s CONFIG_LWIP_LOCAL_HOSTNAME default,
espressif, and a fleet is indistinguishable on the LAN.
Set via neiam_portal::wifi::connect_sta_blocking_named, which calls
esp_netif_set_hostname on the STA netif after EspWifi::new and before
start(). Both halves of that window matter: earlier there is no struct netif yet (ESP_ERR_ESP_NETIF_IF_NOT_READY), and later the DHCP DISCOVER has
already gone out with espressif, so the router keeps the old name until the
lease renews. connect_sta_blocking / connect_sta still exist unchanged and
delegate with None, so nfc-522 and apollos-palm-t4-s3 are unaffected.
This is DHCP only — there is no mDNS responder, so lambent-flex-<id>.local
does not resolve unless the router synthesizes DNS from DHCP leases (most do).
GET / returns 218, not 200
The packet sender matches on the exact status code. neiam_portal’s
StatusServer::json hardcodes 200 (status.rs:58), so / is registered by
hand through server_mut() instead — keeping the odd code local rather than
changing a helper nfc-522 also uses. /status is unaffected and still 200.
Historical note: despite appearances this was not a restoration. Both this
tree and ../lambent-flex returned 200 from the start; 218 appears nowhere in
either history.
Hardware note
main.rs drives GPIO8 + GPIO9. On the C6, both are strapping pins sampled
at reset, so attached hardware must not hold them at the wrong level during
boot. On a C6-DevKitC-1, GPIO9 is also the BOOT button. GPIO0–7 or GPIO10+ are
safer. (Kept as-is to stay a faithful copy of the C3 build, where the devkit
pinout is the same.)
Still only a latent hazard — the board on hand boots fine
(boot:0x8 SPI_FAST_FLASH_BOOT) with the strands attached. A boot:0x0 (USB_BOOT) sighting is far more likely the USB download-mode latch than the
straps; power cycle before suspecting the pins.