diff --git a/.cargo/config.toml b/.cargo/config.toml index 6e53fe0..761f385 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -3,45 +3,28 @@ # SPDX-License-Identifier: GPL-3.0-or-later [alias] -# All ESP32 targets build the binary in the ssh-stamp-esp32 crate. -# Board features select a specific PCB and imply the IC feature. -# `--no-default-features` prevents the default board from clashing. +# Every build goes through the xtask runner: `cargo xtask `. +# Board/chip/toolchain knowledge lives in xtask/targets.toml and the +# `[build]` section of each ssh-stamp-*-boards/boards/*.toml, instead of one +# alias per board*feature combination (see issue #116). # -# Boards with a BSP entry use a board- feature: -build-esp32c5 = "build --release --target riscv32imac-unknown-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-esp32c5-devkitc" -build-esp32c61 = "build --release --target riscv32imac-unknown-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-esp32c61-devkitc" -build-esp32c6 = "build --release --target riscv32imac-unknown-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-esp32c6-devkitc" -build-esp32c6-generic = "build --release --target riscv32imac-unknown-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-esp32c6-generic" -build-esp32s2 = "build --profile esp32s2 --target xtensa-esp32s2-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-esp32-s2-saola -Z build-std=core,alloc" -build-esp32s3-touch-lcd-43 = "build --release --target xtensa-esp32s3-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-waveshare-esp32-s3-touch-lcd-43,can -Z build-std=core,alloc" -# Bench variant: CAN in no-acknowledge mode (exactly one frame per write with no ACKing node attached) -build-esp32s3-touch-lcd-43-noack = "build --release --target xtensa-esp32s3-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-waveshare-esp32-s3-touch-lcd-43,can-no-ack -Z build-std=core,alloc" - -run-esp32c5 = "run --release --target riscv32imac-unknown-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-esp32c5-devkitc" -run-esp32c61 = "run --release --target riscv32imac-unknown-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-esp32c61-devkitc" -run-esp32c6 = "run --release --target riscv32imac-unknown-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-esp32c6-devkitc" -run-esp32c6-generic = "run --release --target riscv32imac-unknown-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-esp32c6-generic" -run-esp32s2 = "run --profile esp32s2 --target xtensa-esp32s2-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-esp32-s2-saola" -run-esp32s3-touch-lcd-43 = "run --release --target xtensa-esp32s3-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-waveshare-esp32-s3-touch-lcd-43,can -Z build-std=core,alloc" -run-esp32s3-touch-lcd-43-noack = "run --release --target xtensa-esp32s3-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-waveshare-esp32-s3-touch-lcd-43,can-no-ack -Z build-std=core,alloc" - -# IC-only targets (no BSP entry yet) build the library only — the binary hits -# the `compile_error!("No board feature selected.")` guard until a board module -# is added to ssh-stamp-esp32-boards. Use `--lib` so the alias succeeds. -build-esp32 = "build --release --target xtensa-esp32-none-elf -p ssh-stamp-esp32 --lib --no-default-features --features esp32 -Z build-std=core,alloc" -build-esp32c2 = "build --release --target riscv32imc-unknown-none-elf -p ssh-stamp-esp32 --lib --no-default-features --features esp32c2" -build-esp32c3 = "build --release --target riscv32imc-unknown-none-elf -p ssh-stamp-esp32 --lib --no-default-features --features esp32c3" -build-esp32s3 = "build --release --target xtensa-esp32s3-none-elf -p ssh-stamp-esp32 --lib --no-default-features --features esp32s3 -Z build-std=core,alloc" - -# Test alias -test-ota = "test --package ota --target x86_64-unknown-linux-gnu" - -# ota packer aliases -build-packer = "build --package ota --bin packer --target x86_64-unknown-linux-gnu" -packer = "run --package ota --bin packer --target x86_64-unknown-linux-gnu" - -# doc aliases (cannot use "doc" — shadows cargo's built-in command) -build-doc = "doc --target riscv32imac-unknown-none-elf --no-deps --lib -p ssh-stamp -p ssh-stamp-hal -p ssh-stamp-esp32 -p ssh-stamp-esp32-boards -p ota --no-default-features --features ssh-stamp-esp32/board-esp32c6-devkitc" +# cargo xtask list +# cargo xtask build esp32c6-devkitc +# cargo xtask run waveshare-esp32-s3-touch-lcd-43 --features can-no-ack +# cargo xtask ci +xtask = "run --package xtask --" + +# The OTA image packer is a host tool, not a build target. +packer = "run --package ota --bin packer" + +# NOTE: there is deliberately no `[build] target` or `[unstable] build-std` +# here. Both used to be global, which forced *every* cargo invocation — +# including host tools like xtask and the packer — to cross-compile to +# riscv32imac. xtask now passes `--target` and `-Zbuild-std` per chip, so +# host tooling builds for the host on any OS without a hardcoded triple. +# Editors wanting cross-compilation diagnostics should set the target in +# their own rust-analyzer config, e.g.: +# "rust-analyzer.cargo.target": "riscv32imac-unknown-none-elf" [target.xtensa-esp32-none-elf] # ESP32 runner = "espflash flash --baud=921600 --monitor --chip esp32" @@ -49,7 +32,7 @@ rustflags = ["-C", "link-arg=-nostartfiles", '--cfg=feature="esp32"'] [target.riscv32imc-unknown-none-elf] # ESP32-C2 / ESP32-C3 runner = "espflash flash --baud=921600 --monitor" rustflags = ["-C", "force-frame-pointers"] -[target.riscv32imac-unknown-none-elf] # ESP32C6 +[target.riscv32imac-unknown-none-elf] # ESP32-C5 / C6 / C61 runner = "espflash flash --baud=921600 --partition-table ssh-stamp-esp32/partitions.csv --monitor" rustflags = ["-C", "force-frame-pointers"] @@ -70,10 +53,3 @@ ESP_LOG = "info" #ESP_HAL_CONFIG_STACK_GUARD_OFFSET=4096 #ESP_HAL_CONFIG_STACK_GUARD_VALUE=3740121773 #ESP_HAL_CONFIG_IMPL_CRITICAL_SECTION=true - - -[build] -target = "riscv32imac-unknown-none-elf" - -[unstable] -build-std = ["core", "alloc"] diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 80ef62d..39d24be 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,25 +16,28 @@ on: - main jobs: - espressif-targets: - name: Espressif target ${{ matrix.device.soc }} + targets: + name: Build ${{ matrix.target.name }} runs-on: ubuntu-latest strategy: fail-fast: false + # Just the target name plus which toolchain has to be installed before + # xtask can run. Everything else (triple, profile, features, build-std) + # comes from xtask/targets.toml — see `cargo xtask list`. matrix: - device: [ - # RISC-V devices: - { soc: "esp32c2", board: "esp32c2", target: "riscv32imc-unknown-none-elf", toolchain: "stable", buildstd: "", binlib: "--lib" }, - { soc: "esp32c3", board: "esp32c3", target: "riscv32imc-unknown-none-elf", toolchain: "stable", buildstd: "", binlib: "--lib" }, - { soc: "esp32c5", board: "board-esp32c5-devkitc", target: "riscv32imac-unknown-none-elf", toolchain: "stable", buildstd: "", binlib: "--bin ssh-stamp-esp32" }, - { soc: "esp32c6", board: "board-esp32c6-devkitc", target: "riscv32imac-unknown-none-elf", toolchain: "stable", buildstd: "", binlib: "--bin ssh-stamp-esp32" }, - { soc: "esp32c61", board: "board-esp32c61-devkitc", target: "riscv32imac-unknown-none-elf", toolchain: "stable", buildstd: "", binlib: "--bin ssh-stamp-esp32" }, - { soc: "esp32c6", board: "board-esp32c6-generic", target: "riscv32imac-unknown-none-elf", toolchain: "stable", buildstd: "", binlib: "--bin ssh-stamp-esp32" }, - # Xtensa devices: - { soc: "esp32", board: "esp32", target: "xtensa-esp32-none-elf", toolchain: "esp", buildstd: "-Z build-std=core,alloc", binlib: "--lib" }, - { soc: "esp32s2", board: "board-esp32-s2-saola", target: "xtensa-esp32s2-none-elf", toolchain: "esp", buildstd: "-Z build-std=core,alloc", binlib: "--bin ssh-stamp-esp32" }, - { soc: "esp32s3", board: "esp32s3", target: "xtensa-esp32s3-none-elf", toolchain: "esp", buildstd: "-Z build-std=core,alloc", binlib: "--lib" }, - { soc: "esp32s3", board: "board-waveshare-esp32-s3-touch-lcd-43,can", target: "xtensa-esp32s3-none-elf", toolchain: "esp", buildstd: "-Z build-std=core,alloc", binlib: "--bin ssh-stamp-esp32" }, + target: [ + # Chips without a BSP entry: library-only builds. + { name: "esp32c2", xtensa: false }, + { name: "esp32c3", xtensa: false }, + { name: "esp32", xtensa: true }, + { name: "esp32s3", xtensa: true }, + # Boards: full firmware binaries. + { name: "esp32c5-devkitc", xtensa: false }, + { name: "esp32c6-devkitc", xtensa: false }, + { name: "esp32c61-devkitc", xtensa: false }, + { name: "esp32c6-generic", xtensa: false }, + { name: "esp32-s2-saola", xtensa: true }, + { name: "waveshare-esp32-s3-touch-lcd-43", xtensa: true }, ] steps: - name: Cache @@ -42,27 +45,39 @@ jobs: - name: Checkout code uses: actions/checkout@v6 - name: Setup Rust toolchain for RISC-V - if: ${{ !contains(fromJson('["esp32", "esp32s2", "esp32s3"]'), matrix.device.soc) }} + if: ${{ !matrix.target.xtensa }} uses: dtolnay/rust-toolchain@v1 with: target: riscv32imc-unknown-none-elf,riscv32imac-unknown-none-elf toolchain: stable components: rust-src, clippy, rustfmt - name: Setup Rust toolchain for Xtensa - if: ${{ contains(fromJson('["esp32", "esp32s2", "esp32s3"]'), matrix.device.soc) }} + if: ${{ matrix.target.xtensa }} uses: esp-rs/xtensa-toolchain@v1.7.0 with: ldproxy: false version: 1.96.0 - - name: Build project - run: cargo +${{ matrix.device.toolchain }} build --release --target ${{ matrix.device.target }} -p ssh-stamp-esp32 ${{ matrix.device.binlib }} --no-default-features --features ${{ matrix.device.board }} ${{ matrix.device.buildstd }} + - name: Build + run: cargo xtask build ${{ matrix.target.name }} + + lint: + name: Lints and format + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + - name: Setup Stable Rust Toolchain + uses: dtolnay/rust-toolchain@v1 + with: + target: riscv32imac-unknown-none-elf + toolchain: stable + components: rust-src, clippy, rustfmt + - name: Clippy + run: cargo xtask clippy + - name: Format + run: cargo xtask fmt --check - - name: Check lints and format - if: ${{ matrix.device.board == 'board-esp32c6-devkitc' }} - run: | - cargo +${{ matrix.device.toolchain }} clippy --release --features board-esp32c6-devkitc --target riscv32imac-unknown-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features -- -D warnings -A clippy::default_trait_access - cargo +${{ matrix.device.toolchain }} fmt -- --check packer: name: OTA Packer runs-on: ubuntu-latest @@ -74,10 +89,10 @@ jobs: - name: Setup Stable Rust Toolchain uses: dtolnay/rust-toolchain@v1 with: - target: riscv32imac-unknown-none-elf toolchain: stable - name: Build utility - run: cargo build-packer + run: cargo build --package ota --bin packer + docs: name: Documentation runs-on: ubuntu-latest @@ -90,6 +105,6 @@ jobs: target: riscv32imac-unknown-none-elf toolchain: stable - name: Build all docs - run: cargo build-doc + run: cargo xtask doc - name: Check doc warnings - run: cargo build-doc 2>&1 | grep -iE "broken|warning.*link" || true + run: cargo xtask doc 2>&1 | grep -iE "broken|warning.*link" || true diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index aaf1da0..f614b66 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,7 +28,6 @@ jobs: - name: Setup Stable Rust Toolchain uses: dtolnay/rust-toolchain@v1 with: - target: riscv32imac-unknown-none-elf toolchain: stable - name: Package test - run: cargo test-ota + run: cargo xtask test diff --git a/Cargo.lock b/Cargo.lock index 06edea1..715849e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3000,6 +3000,14 @@ dependencies = [ "zeroize", ] +[[package]] +name = "xtask" +version = "0.1.0" +dependencies = [ + "serde", + "toml", +] + [[package]] name = "xtensa-lx" version = "0.13.0" diff --git a/Cargo.toml b/Cargo.toml index 4a4a856..fbc22eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,13 @@ license = "GPL-3.0-or-later" [lib] [workspace] -members = ["ssh-stamp-hal", "ssh-stamp-esp32", "ssh-stamp-esp32-boards", "ota"] +members = [ + "ssh-stamp-hal", + "ssh-stamp-esp32", + "ssh-stamp-esp32-boards", + "ota", + "xtask", +] [workspace.lints.clippy] mem_forget = "warn" diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 9c56694..c20eded 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -1,5 +1,13 @@ # Building +Everything is driven by `cargo xtask`, which knows every board, chip, +toolchain and target triple the project supports. Start with: + +``` +cargo xtask list # what can I build? +cargo xtask # usage +``` + Tooling is controlled by `rust-toolchain.toml`. On a fresh host you'll typically need the Rust source component and a flasher (we use `espflash` below as an example): ``` @@ -7,39 +15,75 @@ rustup toolchain install stable --component rust-src cargo install espflash --locked ``` -Build/flash for your board using the short command pattern (replace `` with the concrete chip you have): +Xtensa targets (ESP32/ESP32-S2/S3) do require `espup` in addition to `rustup`: -| Machine target | Rust toolchain target | -| --- | --- | -| `esp32` | `xtensa-esp32-none-elf` | -| `esp32c2` | `riscv32imc-unknown-none-elf` | -| `esp32c3` | `riscv32imc-unknown-none-elf` | -| `esp32c5` | `riscv32imac-unknown-none-elf` | -| `esp32c6` | `riscv32imac-unknown-none-elf` | -| `esp32c61` | `riscv32imac-unknown-none-elf` | -| `esp32s2` | `xtensa-esp32s2-none-elf` | -| `esp32s3` | `xtensa-esp32s3-none-elf` | +``` +cargo install espup +espup install +source $HOME/export-esp.sh +``` + +## Building + +Pass either a **board** (builds the firmware binary) or a bare **chip** +(builds the library only, for chips with no board definition yet): ``` -rustup target add -cargo build- # e.g. cargo build-esp32c6, cargo build-esp32c3, cargo build-esp32 -cargo run- # convenience helper (if supported) that builds + flashes +cargo xtask build esp32c6-devkitc # a board +cargo xtask build esp32c3 # a chip, library only +cargo xtask build waveshare-esp32-s3-touch-lcd-43 # Xtensa, picks the esp toolchain itself ``` -Xtensa targets (ESP32/ESP32-S2/S3) do require `espup` in addition to the `rustup` command above: +Optional features and profiles are flags rather than separate commands, and +anything after `--` goes straight to cargo: ``` -cargo install espup -espup install -source $HOME/export-esp.sh +cargo xtask build esp32c6-devkitc --features sftp-ota +cargo xtask build waveshare-esp32-s3-touch-lcd-43 --features can-no-ack +cargo xtask build esp32c6-devkitc --profile dev -- --timings ``` -# Flashing +You do not need to remember which targets need `+esp`, `-Zbuild-std` or a +special profile: that lives in [`xtask/targets.toml`](../xtask/targets.toml) +and in the `[build]` section of each board definition under +`ssh-stamp-esp32-boards/boards/`. + +## Flashing + +`run` builds, flashes and opens the serial monitor (via the `runner` +configured per target triple in `.cargo/config.toml`): + +``` +cargo xtask run esp32c6-devkitc +``` -Flash the firmware and open the serial console (example): +## Everything CI checks ``` -# build & flash (example for esp32c6) -cargo build-esp32c6 --release -cargo run-esp32c6 -``` \ No newline at end of file +cargo xtask clippy # lints one representative board +cargo xtask fmt # --check to verify instead of rewrite +cargo xtask doc +cargo xtask test # host-side crates +cargo xtask ci # all of the above, for every board and chip +``` + +## Adding a board + +1. Drop a `boards/.toml` into the relevant BSP crate, with the pin map + and a `[build]` section naming its chip: + ```toml + [build] + chip = "esp32c6" + # features = ["can"] # optional: features this board always needs + ``` +2. Add the matching `board-` feature in that platform's `Cargo.toml`. + +That is it — `cargo xtask list` picks it up by scanning the boards +directory, so no alias, matrix entry or xtask code has to change. + +## Adding a chip or a new vendor + +Add a `[chips.]` entry to `xtask/targets.toml` with its target triple +(plus `toolchain`, `build-std` or `profile` if it needs them). A whole new +manufacturer is a `[platforms.]` entry pointing at that vendor's +crate and boards directory; nothing in the xtask code is Espressif-specific. diff --git a/docs/USING.md b/docs/USING.md index 16c9b97..c7ab500 100644 --- a/docs/USING.md +++ b/docs/USING.md @@ -72,8 +72,10 @@ repository hard-codes UART pin numbers. To see the available boards and their pin assignments, run: ``` -cargo build-doc +cargo xtask doc ``` +(`cargo xtask list` gives the same board list as a quick terminal summary.) + Then open `target/riscv32imac-unknown-none-elf/doc/ssh_stamp_esp32_boards/index.html`, which contains the auto-generated per-board pin assignment table. \ No newline at end of file diff --git a/ota/README.md b/ota/README.md index 21070ee..adb5524 100644 --- a/ota/README.md +++ b/ota/README.md @@ -26,7 +26,7 @@ Following you can find the steps to perform an OTA. Some steps are optionals. #### 1. Build and extract app bin from elf file ``` -cargo build-esp32c6 +cargo xtask build esp32c6-devkitc espflash save-image --chip=esp32c6 target/riscv32imac-unknown-none-elf/release/ssh-stamp ssh-stamp.bin ``` @@ -46,7 +46,7 @@ Optionally erase the flash for a fresh test # optional espflash erase-flash -cargo run-esp32c6 --features sftp-ota +cargo xtask run esp32c6-devkitc --features sftp-ota ``` At the end of the bootloader log look for the app offset (At this point Factory) diff --git a/ssh-stamp-esp32-boards/boards/esp32-s2-saola.toml b/ssh-stamp-esp32-boards/boards/esp32-s2-saola.toml index 2cb418d..fbef35e 100644 --- a/ssh-stamp-esp32-boards/boards/esp32-s2-saola.toml +++ b/ssh-stamp-esp32-boards/boards/esp32-s2-saola.toml @@ -7,4 +7,8 @@ url = "https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32s2/esp32- [pins] uart_rx = 10 -uart_tx = 11 \ No newline at end of file +uart_tx = 11 + +# Consumed by `cargo xtask`; the BSP build script ignores this section. +[build] +chip = "esp32s2" \ No newline at end of file diff --git a/ssh-stamp-esp32-boards/boards/esp32c5-devkitc.toml b/ssh-stamp-esp32-boards/boards/esp32c5-devkitc.toml index 0e3ecdb..38d5e67 100644 --- a/ssh-stamp-esp32-boards/boards/esp32c5-devkitc.toml +++ b/ssh-stamp-esp32-boards/boards/esp32c5-devkitc.toml @@ -7,4 +7,8 @@ url = "https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32c5/esp32- [pins] uart_rx = 8 -uart_tx = 9 \ No newline at end of file +uart_tx = 9 + +# Consumed by `cargo xtask`; the BSP build script ignores this section. +[build] +chip = "esp32c5" \ No newline at end of file diff --git a/ssh-stamp-esp32-boards/boards/esp32c6-devkitc.toml b/ssh-stamp-esp32-boards/boards/esp32c6-devkitc.toml index 04a7a0c..dbbd135 100644 --- a/ssh-stamp-esp32-boards/boards/esp32c6-devkitc.toml +++ b/ssh-stamp-esp32-boards/boards/esp32c6-devkitc.toml @@ -7,4 +7,8 @@ url = "https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32c6/esp32- [pins] uart_rx = 10 -uart_tx = 11 \ No newline at end of file +uart_tx = 11 + +# Consumed by `cargo xtask`; the BSP build script ignores this section. +[build] +chip = "esp32c6" \ No newline at end of file diff --git a/ssh-stamp-esp32-boards/boards/esp32c6-generic.toml b/ssh-stamp-esp32-boards/boards/esp32c6-generic.toml index 4154ab2..1bfaf9c 100644 --- a/ssh-stamp-esp32-boards/boards/esp32c6-generic.toml +++ b/ssh-stamp-esp32-boards/boards/esp32c6-generic.toml @@ -7,4 +7,8 @@ [pins] uart_rx = 10 -uart_tx = 11 \ No newline at end of file +uart_tx = 11 + +# Consumed by `cargo xtask`; the BSP build script ignores this section. +[build] +chip = "esp32c6" \ No newline at end of file diff --git a/ssh-stamp-esp32-boards/boards/esp32c61-devkitc.toml b/ssh-stamp-esp32-boards/boards/esp32c61-devkitc.toml index b358a5e..882ec22 100644 --- a/ssh-stamp-esp32-boards/boards/esp32c61-devkitc.toml +++ b/ssh-stamp-esp32-boards/boards/esp32c61-devkitc.toml @@ -7,4 +7,8 @@ url = "https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32c61/esp32 [pins] uart_rx = 2 -uart_tx = 3 \ No newline at end of file +uart_tx = 3 + +# Consumed by `cargo xtask`; the BSP build script ignores this section. +[build] +chip = "esp32c61" \ No newline at end of file diff --git a/ssh-stamp-esp32-boards/boards/waveshare-esp32-s3-touch-lcd-43.toml b/ssh-stamp-esp32-boards/boards/waveshare-esp32-s3-touch-lcd-43.toml index 6eaf5a4..50b90b2 100644 --- a/ssh-stamp-esp32-boards/boards/waveshare-esp32-s3-touch-lcd-43.toml +++ b/ssh-stamp-esp32-boards/boards/waveshare-esp32-s3-touch-lcd-43.toml @@ -21,3 +21,11 @@ can_rx = 19 i2c_sda = 8 i2c_scl = 9 writes = [[0x24, 0x01], [0x38, 0x20]] + +# Consumed by `cargo xtask`; the BSP build script ignores this section. +# `can` is on by default because the onboard TJA1051 transceiver is the +# reason to pick this board; add `--features can-no-ack` for bench work +# without an acknowledging CAN node attached. +[build] +chip = "esp32s3" +features = ["can"] diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 0000000..599156d --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: 2026 Roman Valls Guimera +# +# SPDX-License-Identifier: GPL-3.0-or-later + +[package] +name = "xtask" +version = "0.1.0" +edition = "2024" +license = "GPL-3.0-or-later" +publish = false + +# Deliberately dependency-light: this is the build entry point for every +# target, so it must compile fast on a bare `cargo xtask` with no extra +# toolchain setup. Argument parsing is hand-rolled (see `Args::parse`) +# rather than pulling in clap. See issue #116. +[dependencies] +serde = { version = "1", features = ["derive"] } +toml = "0.8" + +[lints] +workspace = true diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 0000000..05e16de --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1,411 @@ +// SPDX-FileCopyrightText: 2026 Roman Valls Guimera +// +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Build automation for ssh-stamp: `cargo xtask `. +//! +//! Replaces the per-board `cargo` aliases that were multiplying with every +//! new board and MCU (see issue #116). All target knowledge lives in +//! `xtask/targets.toml` plus the `[build]` section of each board TOML, so +//! adding a board or a whole new vendor is data, not more aliases. +//! +//! Run `cargo xtask` with no arguments for usage. + +mod targets; + +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode}; + +use targets::{Chip, Registry}; + +const USAGE: &str = "\ +cargo xtask — ssh-stamp build automation + +USAGE: + cargo xtask [args] + +COMMANDS: + list List known boards, chips and platforms + build [opts] Build a board (binary) or a bare chip (library) + run [opts] Build, flash and monitor a board + clippy [] Lint (defaults to the registry's default board) + fmt [--check] Format the workspace + doc Build the rustdoc for all library crates + test Run the host-side test suites + ci Everything CI checks: every target, lints, format + +OPTIONS: + --features Extra cargo features, comma or space separated + --profile Cargo profile override (default: release) + --check For `fmt`: check instead of rewriting + -- Everything after `--` is passed through to cargo + +EXAMPLES: + cargo xtask build esp32c6-devkitc + cargo xtask build esp32c3 # chip only, library build + cargo xtask run waveshare-esp32-s3-touch-lcd-43 --features can-no-ack + cargo xtask build esp32c6-devkitc -- --timings +"; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(msg) => { + eprintln!("\nxtask: {msg}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), String> { + let root = workspace_root()?; + let registry = Registry::load(&root)?; + + let mut raw = std::env::args().skip(1); + let Some(command) = raw.next() else { + print!("{USAGE}"); + return Ok(()); + }; + let args = Args::parse(raw)?; + + match command.as_str() { + "list" => { + list(®istry); + Ok(()) + } + "build" => build(&root, ®istry, &args, false), + "run" => build(&root, ®istry, &args, true), + "clippy" => clippy(&root, ®istry, &args), + "fmt" => fmt(&root, ®istry, &args), + "doc" => doc(&root, ®istry), + "test" => test(&root, ®istry), + "ci" => ci(&root, ®istry), + "-h" | "--help" | "help" => { + print!("{USAGE}"); + Ok(()) + } + other => Err(format!("unknown command `{other}`\n\n{USAGE}")), + } +} + +/// The workspace root: this crate lives in `/xtask`. +fn workspace_root() -> Result { + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + manifest + .parent() + .map(Path::to_path_buf) + .ok_or_else(|| format!("cannot find workspace root above {}", manifest.display())) +} + +#[derive(Debug, Default)] +struct Args { + /// Positional arguments (typically the target name). + positional: Vec, + features: Vec, + profile: Option, + check: bool, + /// Everything after a literal `--`, forwarded to cargo verbatim. + passthrough: Vec, +} + +impl Args { + fn parse(raw: impl Iterator) -> Result { + let mut args = Args::default(); + let mut raw = raw.peekable(); + + while let Some(arg) = raw.next() { + match arg.as_str() { + "--" => { + args.passthrough.extend(raw); + break; + } + "--check" => args.check = true, + "--features" => { + let value = raw + .next() + .ok_or_else(|| "--features needs a value".to_string())?; + args.features.extend( + value + .split([',', ' ']) + .filter(|f| !f.is_empty()) + .map(str::to_string), + ); + } + "--profile" => { + args.profile = Some( + raw.next() + .ok_or_else(|| "--profile needs a value".to_string())?, + ); + } + other if other.starts_with('-') => { + return Err(format!("unknown option `{other}`\n\n{USAGE}")); + } + other => args.positional.push(other.to_string()), + } + } + Ok(args) + } + + fn target(&self) -> Result<&str, String> { + match self.positional.len() { + 0 => Err("expected a board or chip name (`cargo xtask list`)".to_string()), + 1 => Ok(&self.positional[0]), + _ => Err(format!( + "expected one target, got {:?}", + self.positional.as_slice() + )), + } + } +} + +/// What a target name resolved to: a board builds the firmware binary, +/// a bare chip builds the library only. +struct Unit<'a> { + chip: &'a Chip, + /// Cargo features selecting the target, before user additions. + features: Vec, + /// `--bin ` for a board, `--lib` for a bare chip. + artifact: Vec, +} + +fn resolve<'a>(registry: &'a Registry, name: &str) -> Result, String> { + if let Some(board) = registry.boards.get(name) { + let chip = registry + .chips + .get(&board.chip) + .ok_or_else(|| format!("board `{name}` refers to unknown chip `{}`", board.chip))?; + let platform = registry.platform_of(chip)?; + + let mut features = vec![format!("{}{}", platform.board_feature_prefix, board.name)]; + features.extend(board.features.iter().cloned()); + + return Ok(Unit { + chip, + features, + artifact: vec!["--bin".to_string(), platform.bin.clone()], + }); + } + + if let Some(chip) = registry.chips.get(name) { + // No BSP for this chip yet: the binary would hit the "No board + // feature selected" guard, so build the library instead. + return Ok(Unit { + chip, + features: vec![name.to_string()], + artifact: vec!["--lib".to_string()], + }); + } + + Err(format!( + "unknown target `{name}`; known boards: {}; known chips: {}", + join(registry.boards.keys()), + join(registry.chips.keys()) + )) +} + +/// A cargo invocation with the parent's toolchain pinning stripped out. +/// +/// `cargo xtask` runs us through cargo, which exports `RUSTUP_TOOLCHAIN`, +/// `RUSTC`, `CARGO` and friends. Left in place they would override a +/// `+toolchain` argument (so Xtensa builds would silently use the host +/// toolchain) and shadow `.cargo/config.toml` rustflags. +fn cargo(toolchain: Option<&str>) -> Command { + let mut cmd = Command::new("cargo"); + for var in [ + "RUSTUP_TOOLCHAIN", + "RUSTC", + "RUSTDOC", + "CARGO", + "RUSTFLAGS", + "CARGO_ENCODED_RUSTFLAGS", + ] { + cmd.env_remove(var); + } + if let Some(toolchain) = toolchain { + cmd.arg(format!("+{toolchain}")); + } + cmd +} + +fn exec(root: &Path, mut cmd: Command) -> Result<(), String> { + cmd.current_dir(root); + + let rendered = format!( + "cargo {}", + cmd.get_args() + .map(|a| a.to_string_lossy().into_owned()) + .collect::>() + .join(" ") + ); + println!("\x1b[1;36m>\x1b[0m {rendered}"); + + let status = cmd + .status() + .map_err(|e| format!("failed to spawn `{rendered}`: {e}"))?; + if status.success() { + Ok(()) + } else { + Err(format!("`{rendered}` failed with {status}")) + } +} + +/// Explicit `--profile` wins, then the chip's own, else `release`. +fn profile_for(chip: &Chip, args: &Args) -> String { + args.profile + .clone() + .or_else(|| chip.profile.clone()) + .unwrap_or_else(|| "release".to_string()) +} + +fn build(root: &Path, registry: &Registry, args: &Args, flash: bool) -> Result<(), String> { + let name = args.target()?; + let unit = resolve(registry, name)?; + let platform = registry.platform_of(unit.chip)?; + + let mut features = unit.features; + features.extend(args.features.iter().cloned()); + + let profile = profile_for(unit.chip, args); + + let mut cmd = cargo(registry.toolchain_for(unit.chip)); + cmd.arg(if flash { "run" } else { "build" }) + .args(["--profile", &profile]) + .args(["--target", &unit.chip.target]) + .args(["-p", &platform.package]) + .args(&unit.artifact) + .arg("--no-default-features") + .args(["--features", &features.join(",")]); + + if unit.chip.build_std { + cmd.arg("-Zbuild-std=core,alloc"); + } + cmd.args(&args.passthrough); + + exec(root, cmd) +} + +fn clippy(root: &Path, registry: &Registry, args: &Args) -> Result<(), String> { + let name = args + .positional + .first() + .cloned() + .unwrap_or_else(|| registry.defaults.board.clone()); + let unit = resolve(registry, &name)?; + let platform = registry.platform_of(unit.chip)?; + + let mut features = unit.features; + features.extend(args.features.iter().cloned()); + + let profile = profile_for(unit.chip, args); + + let mut cmd = cargo(registry.toolchain_for(unit.chip)); + cmd.arg("clippy") + .args(["--profile", &profile]) + .args(["--target", &unit.chip.target]) + .args(["-p", &platform.package]) + .args(&unit.artifact) + .arg("--no-default-features") + .args(["--features", &features.join(",")]); + + if unit.chip.build_std { + cmd.arg("-Zbuild-std=core,alloc"); + } + cmd.args(&args.passthrough).args(["--", "-D", "warnings"]); + + exec(root, cmd) +} + +fn fmt(root: &Path, registry: &Registry, args: &Args) -> Result<(), String> { + let mut cmd = cargo(registry.host_toolchain()); + cmd.args(["fmt", "--all"]); + if args.check { + cmd.args(["--", "--check"]); + } + exec(root, cmd) +} + +fn doc(root: &Path, registry: &Registry) -> Result<(), String> { + let board = registry.defaults.board.clone(); + let unit = resolve(registry, &board)?; + let platform = registry.platform_of(unit.chip)?; + + let mut cmd = cargo(registry.toolchain_for(unit.chip)); + cmd.arg("doc") + .args(["--target", &unit.chip.target]) + .args(["--no-deps", "--lib"]); + for package in ®istry.defaults.doc_packages { + cmd.args(["-p", package]); + } + // Features must be qualified one by one: `pkg/a,b` would make `b` a + // feature of the workspace root package rather than of `pkg`. + let features: Vec = unit + .features + .iter() + .map(|f| format!("{}/{f}", platform.package)) + .collect(); + cmd.arg("--no-default-features") + .args(["--features", &features.join(",")]); + if unit.chip.build_std { + cmd.arg("-Zbuild-std=core,alloc"); + } + exec(root, cmd) +} + +fn test(root: &Path, registry: &Registry) -> Result<(), String> { + // Host-side crates only; the firmware crates are no_std and cannot run + // tests on the host. + let mut cmd = cargo(registry.host_toolchain()); + cmd.args(["test", "-p", "ota"]); + exec(root, cmd) +} + +fn ci(root: &Path, registry: &Registry) -> Result<(), String> { + for name in registry.boards.keys().chain(registry.chips.keys()) { + let args = Args { + positional: vec![name.clone()], + ..Args::default() + }; + build(root, registry, &args, false)?; + } + clippy(root, registry, &Args::default())?; + fmt( + root, + registry, + &Args { + check: true, + ..Args::default() + }, + )?; + doc(root, registry)?; + test(root, registry) +} + +fn list(registry: &Registry) { + println!("Boards (firmware binary):"); + for (name, board) in ®istry.boards { + let extra = if board.features.is_empty() { + String::new() + } else { + format!(" [+{}]", board.features.join(",")) + }; + println!(" {name:<38} {}{extra}", board.chip); + } + + println!("\nChips (library-only build):"); + for (name, chip) in ®istry.chips { + let toolchain = chip + .toolchain + .as_ref() + .map_or(String::new(), |t| format!(" (+{t})")); + println!(" {name:<38} {}{toolchain}", chip.target); + } + + println!("\nPlatforms:"); + for (name, platform) in ®istry.platforms { + println!(" {name:<38} {}", platform.package); + } +} + +fn join<'a>(items: impl Iterator) -> String { + items.cloned().collect::>().join(", ") +} diff --git a/xtask/src/targets.rs b/xtask/src/targets.rs new file mode 100644 index 0000000..c68d291 --- /dev/null +++ b/xtask/src/targets.rs @@ -0,0 +1,223 @@ +// SPDX-FileCopyrightText: 2026 Roman Valls Guimera +// +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Build registry: platforms and chips from `xtask/targets.toml`, boards +//! discovered by scanning each platform's `boards/*.toml` directory. +//! +//! Keeping board discovery filesystem-driven means adding a board is still +//! "drop in a TOML + add the cargo feature" — the build tool needs no edit. + +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; + +use serde::Deserialize; + +/// A cargo package producing firmware for one MCU family / vendor. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct Platform { + /// Cargo package to build (`-p`). + pub package: String, + /// Binary target inside that package (`--bin`). + pub bin: String, + /// Directory of board definitions, relative to the workspace root. + pub boards: String, + /// Prefix turning a board name into its cargo feature. + pub board_feature_prefix: String, +} + +/// One MCU: the Rust target triple plus how it has to be built. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct Chip { + /// Key into [`Registry::platforms`]. + pub platform: String, + /// Rust target triple. + pub target: String, + /// Rustup toolchain override (e.g. `esp` for Xtensa), if any. + #[serde(default)] + pub toolchain: Option, + /// Whether the target needs `-Z build-std` (no prebuilt core). + #[serde(default)] + pub build_std: bool, + /// Cargo profile override; defaults to `release`. + #[serde(default)] + pub profile: Option, +} + +/// Settings for jobs that need one representative target. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct Defaults { + pub board: String, + pub doc_packages: Vec, + /// Toolchain used when a chip does not name its own. + #[serde(default)] + pub toolchain: Option, +} + +/// A concrete PCB, discovered from a platform's boards directory. +#[derive(Debug)] +pub struct Board { + pub name: String, + pub chip: String, + /// Extra cargo features this board always needs (e.g. `can` on a board + /// whose whole point is the CAN transceiver). + pub features: Vec, +} + +#[derive(Debug, Deserialize)] +struct RegistryFile { + platforms: BTreeMap, + chips: BTreeMap, + defaults: Defaults, +} + +/// The `[build]` section of a `boards/*.toml`. Every other section belongs +/// to the BSP build script and is ignored here. +#[derive(Debug, Deserialize)] +struct BoardFile { + build: Option, +} + +#[derive(Debug, Deserialize)] +struct BoardBuild { + chip: String, + #[serde(default)] + features: Vec, +} + +#[derive(Debug)] +pub struct Registry { + pub platforms: BTreeMap, + pub chips: BTreeMap, + pub defaults: Defaults, + pub boards: BTreeMap, +} + +impl Registry { + /// Parse `xtask/targets.toml` and scan every platform for boards. + /// + /// # Errors + /// + /// Returns a message if the registry or any board file is missing, + /// unreadable, malformed, or refers to an unknown platform/chip. + pub fn load(root: &Path) -> Result { + let path = root.join("xtask/targets.toml"); + let text = fs::read_to_string(&path) + .map_err(|e| format!("cannot read {}: {e}", path.display()))?; + let file: RegistryFile = + toml::from_str(&text).map_err(|e| format!("cannot parse {}: {e}", path.display()))?; + + let mut boards = BTreeMap::new(); + for (platform_name, platform) in &file.platforms { + let dir = root.join(&platform.boards); + for (name, board) in scan_boards(&dir)? { + let chip = file.chips.get(&board.chip).ok_or_else(|| { + format!( + "board `{name}` wants chip `{}`, which is not in targets.toml", + board.chip + ) + })?; + if &chip.platform != platform_name { + return Err(format!( + "board `{name}` lives in platform `{platform_name}` but its chip \ + `{}` belongs to platform `{}`", + board.chip, chip.platform + )); + } + boards.insert(name, board); + } + } + + for (name, chip) in &file.chips { + if !file.platforms.contains_key(&chip.platform) { + return Err(format!( + "chip `{name}` refers to unknown platform `{}`", + chip.platform + )); + } + } + + Ok(Registry { + platforms: file.platforms, + chips: file.chips, + defaults: file.defaults, + boards, + }) + } + + /// Look up the platform a chip belongs to. + /// + /// # Errors + /// + /// Returns a message if the chip's platform is missing from the registry. + pub fn platform_of(&self, chip: &Chip) -> Result<&Platform, String> { + self.platforms + .get(&chip.platform) + .ok_or_else(|| format!("unknown platform `{}`", chip.platform)) + } + + /// The toolchain to build `chip` with: its own, else the default. + pub fn toolchain_for<'a>(&'a self, chip: &'a Chip) -> Option<&'a str> { + chip.toolchain + .as_deref() + .or(self.defaults.toolchain.as_deref()) + } + + /// The toolchain for host-side jobs (fmt, host tests). + pub fn host_toolchain(&self) -> Option<&str> { + self.defaults.toolchain.as_deref() + } +} + +fn scan_boards(dir: &Path) -> Result, String> { + if !dir.is_dir() { + return Err(format!("boards directory {} not found", dir.display())); + } + + let mut entries: Vec<_> = fs::read_dir(dir) + .map_err(|e| format!("cannot read {}: {e}", dir.display()))? + .filter_map(Result::ok) + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|ext| ext == "toml")) + .collect(); + entries.sort(); + + let mut boards = Vec::new(); + for path in entries { + let name = path + .file_stem() + .ok_or_else(|| format!("board file {} has no name", path.display()))? + .to_string_lossy() + .into_owned(); + + let text = fs::read_to_string(&path) + .map_err(|e| format!("cannot read {}: {e}", path.display()))?; + let file: BoardFile = + toml::from_str(&text).map_err(|e| format!("cannot parse {}: {e}", path.display()))?; + + // A board without a [build] section is a BSP-only entry: it has pins + // but nothing that says which chip to compile for, so it cannot be a + // build target. Flag it rather than silently skipping it. + let build = file.build.ok_or_else(|| { + format!( + "{} has no [build] section; add `chip = \"\"` so xtask \ + knows how to build it", + path.display() + ) + })?; + + boards.push(( + name.clone(), + Board { + name, + chip: build.chip, + features: build.features, + }, + )); + } + Ok(boards) +} diff --git a/xtask/targets.toml b/xtask/targets.toml new file mode 100644 index 0000000..2fcb8cf --- /dev/null +++ b/xtask/targets.toml @@ -0,0 +1,82 @@ +# SPDX-FileCopyrightText: 2026 Roman Valls Guimera +# +# SPDX-License-Identifier: GPL-3.0-or-later + +# Build registry for `cargo xtask`. +# +# Nothing here is vendor-specific by construction: a platform is "some cargo +# package that produces a firmware binary", and a chip is "some MCU with a +# Rust target triple". Supporting a non-Espressif MCU means adding a +# `[platforms.]` entry pointing at that vendor's crate plus one +# `[chips.]` entry per MCU — no changes to the xtask code itself. +# +# Boards are NOT listed here. They are discovered by scanning each platform's +# `boards` directory, so `boards/*.toml` stays the single source of truth for +# what boards exist (each declares its chip in a `[build]` section). + +[platforms.espressif] +package = "ssh-stamp-esp32" +bin = "ssh-stamp-esp32" +boards = "ssh-stamp-esp32-boards/boards" +board-feature-prefix = "board-" + +# Chips without a board entry are still buildable as a library +# (`cargo xtask build esp32c3`), which is what CI does to keep them honest +# until someone contributes a BSP for a concrete PCB. + +[chips.esp32] +platform = "espressif" +target = "xtensa-esp32-none-elf" +toolchain = "esp" +build-std = true + +[chips.esp32c2] +platform = "espressif" +target = "riscv32imc-unknown-none-elf" + +[chips.esp32c3] +platform = "espressif" +target = "riscv32imc-unknown-none-elf" + +[chips.esp32c5] +platform = "espressif" +target = "riscv32imac-unknown-none-elf" + +[chips.esp32c6] +platform = "espressif" +target = "riscv32imac-unknown-none-elf" + +[chips.esp32c61] +platform = "espressif" +target = "riscv32imac-unknown-none-elf" + +[chips.esp32s2] +platform = "espressif" +target = "xtensa-esp32s2-none-elf" +toolchain = "esp" +build-std = true +# The S2 runs out of RAM at opt-level 3; see the profile in the root manifest. +profile = "esp32s2" + +[chips.esp32s3] +platform = "espressif" +target = "xtensa-esp32s3-none-elf" +toolchain = "esp" +build-std = true + +# Single-target jobs (clippy, rustdoc) need one representative board. +[defaults] +board = "esp32c6-devkitc" +# Toolchain for chips that do not name one. Pinned rather than inherited so +# builds match CI even when a contributor has a rustup directory override +# (e.g. `rustup override set esp`, needed for Xtensa) in this repo: the +# Xtensa fork is a linked toolchain with no prebuilt RISC-V core, so +# inheriting it would silently force `-Zbuild-std` on RISC-V targets. +toolchain = "stable" +doc-packages = [ + "ssh-stamp", + "ssh-stamp-hal", + "ssh-stamp-esp32", + "ssh-stamp-esp32-boards", + "ota", +]