neiam
/lambent-flex-c6
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Initial commit: ESP32-C6 port of lambent-flex, with OTA
9d79b3d · Gabriel Morell · 2026-09-16 18:17
Message
{commit_body(@commit)}
Files changed
added
.cargo/config.toml
+30
−0
| 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
| 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
| 1 | +/.vscode | |
| 2 | +/.embuild | |
| 3 | +/target | |
| 4 | +/Cargo.lock | |
| 5 | +cfg.toml |
added
.idea/.gitignore
+8
−0
| 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
| 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: