Initial commit: ESP32-C6 port of lambent-flex, with OTA

9d79b3d · Gabriel Morell · 2026-09-16 18:17

29 files +3469 -0
Message
{commit_body(@commit)}

Files changed

added .cargo/config.toml
+30 −0
@@ -0,0 +1,30 @@
1 +[build]
2 +target = "riscv32imac-esp-espidf"
3 +
4 +[target.riscv32imac-esp-espidf]
5 +linker = "ldproxy"
6 +# `flash.sh` rather than espflash directly: OTA needs a partition table, our
7 +# own bootloader (rollback is a bootloader feature) and an otadata erase, and
8 +# the bootloader's path contains a content hash embuild picks — so it has to be
9 +# resolved at flash time rather than written down here.
10 +runner = "./flash.sh"
11 +rustflags = ["--cfg", "espidf_time64", "-C", "default-linker-libraries"]
12 +
13 +[unstable]
14 +build-std = ["std", "panic_abort"]
15 +
16 +[env]
17 +# embuild derives the "workspace dir" by popping 6 path components off OUT_DIR
18 +# (embuild-0.33.3/src/cargo.rs:438), which assumes cargo's old
19 +# `target/<triple>/<profile>/build/<pkg>-<hash>/out` layout. Modern cargo nests
20 +# it as `build/<pkg>/<hash>/out` — one level deeper — so it lands on `target/`,
21 +# looks for `target/sdkconfig.defaults`, finds nothing, and SILENTLY ignores our
22 +# sdkconfig.defaults. That left the main task stack at the 3584 default and the
23 +# app died with a stack protection fault on boot. embuild checks this var first.
24 +CARGO_WORKSPACE_DIR = { value = ".", relative = true }
25 +MCU="esp32c6"
26 +# Note: this variable is not used by the pio builder (`cargo build --features pio`)
27 +# ESP32-C6 support requires ESP-IDF v5.1 or newer; esp-idf-hal 0.46 requires v5.2+
28 +# (it references pcnt intr_priority and rmt queue_non_blocking, both added in 5.2).
29 +ESP_IDF_VERSION = "v5.2.3"
30 +ESP_IDF_TOOLS_INSTALL_DIR = { value = "global" } # add this line
added .forgejo/workflows/firmware.yml
+167 −0
@@ -0,0 +1,167 @@
1 +name: firmware
2 +
3 +# Every build on master publishes, because the device updates to whatever the
4 +# forge calls `latest` — so "published" and "what the fleet will run" are the
5 +# same statement. A tag publishes under its own name instead of a `+ci…`
6 +# version, which is the only difference between the two.
7 +on:
8 + push:
9 + branches: [master]
10 + tags: ['v*']
11 +
12 +jobs:
13 + firmware:
14 + runs-on: self-hosted
15 + # Espressif's own Rust image, pinned to a version rather than
16 + # `esp32c6_latest`. The pin is load-bearing twice over: it fixes the
17 + # ESP-IDF-capable toolchain, and it fixes the *nightly* the image installed
18 + # when it was built. `rust-toolchain.toml` asks for "nightly", rustup uses
19 + # whatever nightly is already installed rather than fetching a newer one,
20 + # and a current nightly does not build `std` for this target at all (see
21 + # CLAUDE.md, failure 1 — `AT_FDCWD`).
22 + #
23 + # The image carries rustup + espup's nightly, ldproxy and espflash. It does
24 + # NOT carry ESP-IDF: esp-idf-sys clones v5.2.3 and installs its tools on the
25 + # first run, which is what the cache below exists for.
26 + container: espressif/idf-rust:esp32c6_1.95.0.0
27 +
28 + steps:
29 + # Into a subdirectory, because Cargo.toml depends on `../neiam-portal`
30 + # by path and the two have to be siblings.
31 + - uses: actions/checkout@v4
32 + with:
33 + path: lambent-flex-c6
34 +
35 + - name: Resolve the forge URL
36 + env:
37 + CI_FORGE_URL: ${{ vars.CI_FORGE_URL }}
38 + run: |
39 + set -euo pipefail
40 + forge_url=${CI_FORGE_URL:-${GITHUB_SERVER_URL:-}}
41 +
42 + if [ -z "$forge_url" ]; then
43 + echo "No forge URL available." >&2
44 + echo "Set a CI_FORGE_URL variable on this repository or its org." >&2
45 + exit 1
46 + fi
47 +
48 + echo "FORGE_URL=$forge_url" >> "$GITHUB_ENV"
49 +
50 + # The path dependency. A plain clone rather than a second
51 + # actions/checkout: `github.token` is scoped to this repository alone, so
52 + # it would authenticate as the wrong thing — and neiam-portal is public,
53 + # which is exactly what anonymous clone is for.
54 + - name: Fetch neiam-portal
55 + run: |
56 + set -euo pipefail
57 + git clone --depth 1 "$FORGE_URL/neiam/neiam-portal.git" neiam-portal
58 + git -C neiam-portal log -1 --format='neiam-portal at %h %s'
59 +
60 + # ~/.espressif is the expensive one: ESP-IDF v5.2.3 plus its toolchains,
61 + # ~2GB and ~15 minutes to reinstall. Keyed on the version pinned in
62 + # .cargo/config.toml, so bumping ESP-IDF invalidates it and nothing else
63 + # does.
64 + - name: Cache ESP-IDF and cargo
65 + uses: actions/cache@v4
66 + with:
67 + path: |
68 + ~/.espressif
69 + ~/.cargo/registry
70 + ~/.cargo/git
71 + lambent-flex-c6/target
72 + key: esp-${{ hashFiles('lambent-flex-c6/.cargo/config.toml', 'lambent-flex-c6/Cargo.toml', 'lambent-flex-c6/sdkconfig.defaults') }}
73 + restore-keys: esp-
74 +
75 + - name: Version
76 + run: |
77 + set -euo pipefail
78 + base=$(grep -m1 '^version' lambent-flex-c6/Cargo.toml | cut -d'"' -f2)
79 +
80 + # A tag is a release and says so; everything else is stamped with the
81 + # date and commit it came from, which is what a device reports back
82 + # over /status and what a person needs to find the build again.
83 + if [ "${GITHUB_REF_TYPE:-branch}" = "tag" ]; then
84 + version=${GITHUB_REF_NAME#v}
85 + else
86 + version="${base}+ci$(date -u +%Y%m%d).g${GITHUB_SHA:0:7}"
87 + fi
88 +
89 + echo "version: $version"
90 + echo "VERSION=$version" >> "$GITHUB_ENV"
91 +
92 + # No cfg.toml exists here and none is created: a published image must
93 + # carry no credentials, and a provisioned device reads its own from NVS
94 + # regardless. A factory-fresh device flashed with this comes up in the
95 + # captive portal, which is the correct behaviour for a build that knows
96 + # nothing about anyone's network.
97 + - name: Build
98 + working-directory: lambent-flex-c6
99 + env:
100 + LAMBENT_FLEX_VERSION: ${{ env.VERSION }}
101 + run: |
102 + set -euo pipefail
103 + [ -f "$HOME/export-esp.sh" ] && . "$HOME/export-esp.sh"
104 + cargo build --release
105 +
106 + - name: Package
107 + working-directory: lambent-flex-c6
108 + run: |
109 + set -euo pipefail
110 + mkdir -p dist
111 +
112 + # The OTA payload: the app image alone, which is what gets written
113 + # into the inactive slot.
114 + espflash save-image --chip esp32c6 \
115 + --partition-table partitions.csv \
116 + target/riscv32imac-esp-espidf/release/lambent-flex-c6 \
117 + dist/lambent-flex-c6.bin
118 +
119 + # …and everything a device needs over USB, for the one-time
120 + # migration onto the two-slot layout (and for any board that has to
121 + # be recovered with a cable). Merged, so it flashes at 0x0 in one go.
122 + build_dir=$(./tools/idf-build-dir.sh release)
123 + cp "$build_dir/bootloader/bootloader.bin" dist/bootloader.bin
124 + espflash save-image --chip esp32c6 --merge --skip-padding \
125 + --partition-table partitions.csv \
126 + --bootloader dist/bootloader.bin \
127 + target/riscv32imac-esp-espidf/release/lambent-flex-c6 \
128 + dist/flash-all.bin
129 + cp partitions.csv dist/partitions.csv
130 +
131 + # An image that outgrew its slot installs as a truncated write and
132 + # bricks whatever took it. Cheaper to find out here.
133 + slot=$(awk -F, '/^ota_0/ {gsub(/ /, "", $5); print $5}' partitions.csv)
134 + slot=$((slot))
135 + size=$(stat -c %s dist/lambent-flex-c6.bin)
136 + echo "app image ${size} bytes, ota slot ${slot} bytes ($((100 * size / slot))%)"
137 + if [ "$size" -gt "$slot" ]; then
138 + echo "The app image does not fit in an OTA slot." >&2
139 + exit 1
140 + fi
141 +
142 + # `${{ github.token }}` is minted for this job and may write this
143 + # repository's packages and nothing else, so there is no secret to
144 + # create first.
145 + #
146 + # The app image goes first on purpose. Publishing creates the version,
147 + # and the version becomes the manifest's `latest` the moment it exists —
148 + # so a device checking in mid-publish should find the one file it
149 + # actually needs already there.
150 + - name: Publish to the raw registry
151 + working-directory: lambent-flex-c6
152 + env:
153 + REPOSITORY: ${{ github.repository }}
154 + TOKEN: ${{ github.token }}
155 + run: |
156 + set -euo pipefail
157 + for file in lambent-flex-c6.bin flash-all.bin bootloader.bin partitions.csv; do
158 + echo "publishing $file"
159 + curl --fail-with-body -sS \
160 + -u "gitgud-actions:$TOKEN" \
161 + --upload-file "dist/$file" \
162 + "$FORGE_URL/pkg/raw/$REPOSITORY/$VERSION/$file"
163 + echo
164 + done
165 +
166 + echo "Devices will pick this up from:"
167 + echo " $FORGE_URL/pkg/raw/$REPOSITORY/manifest.json"
added .gitignore
+5 −0
@@ -0,0 +1,5 @@
1 +/.vscode
2 +/.embuild
3 +/target
4 +/Cargo.lock
5 +cfg.toml
added .idea/.gitignore
+8 −0
@@ -0,0 +1,8 @@
1 +# Default ignored files
2 +/shelf/
3 +/workspace.xml
4 +# Editor-based HTTP Client requests
5 +/httpRequests/
6 +# Datasource local storage ignored files
7 +/dataSources/
8 +/dataSources.local.xml
added .idea/lambent-flex-c6.iml
+11 −0
@@ -0,0 +1,11 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<module type="EMPTY_MODULE" version="4">
3 + <component name="NewModuleRootManager">
4 + <content url="file://$MODULE_DIR$">
5 + <sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
6 + <excludeFolder url="file://$MODULE_DIR$/target" />
7 + </content>
8 + <orderEntry type="inheritedJdk" />
9 + <orderEntry type="sourceFolder" forTests="false" />
10 + </component>
11 +</module>
added .idea/modules.xml
+8 −0

Click to load diff…

added .idea/vcs.xml
+6 −0

Click to load diff…

added CLAUDE.md
+583 −0

Click to load diff…

added Cargo.toml
+55 −0

Click to load diff…

added LICENSE
+674 −0

Click to load diff…

added README.md
+166 −0

Click to load diff…

added build.rs
+9 −0

Click to load diff…

added flash.sh
+36 −0

Click to load diff…

added partitions.csv
+27 −0

Click to load diff…

added rust-toolchain.toml
+3 −0

Click to load diff…

added sdkconfig.defaults
+30 −0

Click to load diff…

added src/led/indicator.rs
+170 −0

Click to load diff…

added src/led/mod.rs
+2 −0

Click to load diff…

added src/led/ws2812.rs
+110 −0

Click to load diff…

added src/main.rs
+827 −0

Click to load diff…

added src/ota.rs
+516 −0

Click to load diff…

added tools/idf-build-dir.sh
+26 −0

Click to load diff…

Parents: