From 29cca32db441d0529b32503335e41bbc24d32b0a Mon Sep 17 00:00:00 2001 From: brainstorm Date: Sat, 20 Jun 2026 18:24:35 +0200 Subject: [PATCH 1/5] Board Support Package support --- .cargo/config.toml | 29 +++-- .github/workflows/build.yml | 20 ++-- Cargo.lock | 5 + Cargo.toml | 2 +- README.md | 18 ++- ssh-stamp-esp32-boards/Cargo.toml | 20 ++++ ssh-stamp-esp32-boards/src/esp32_s2_saola.rs | 18 +++ ssh-stamp-esp32-boards/src/esp32c6_devkitc.rs | 18 +++ ssh-stamp-esp32-boards/src/esp32c6_generic.rs | 22 ++++ ssh-stamp-esp32-boards/src/lib.rs | 103 ++++++++++++++++++ ssh-stamp-esp32/Cargo.toml | 14 ++- ssh-stamp-esp32/src/bin/ssh-stamp-esp32.rs | 80 +++++++------- 12 files changed, 281 insertions(+), 68 deletions(-) create mode 100644 ssh-stamp-esp32-boards/Cargo.toml create mode 100644 ssh-stamp-esp32-boards/src/esp32_s2_saola.rs create mode 100644 ssh-stamp-esp32-boards/src/esp32c6_devkitc.rs create mode 100644 ssh-stamp-esp32-boards/src/esp32c6_generic.rs create mode 100644 ssh-stamp-esp32-boards/src/lib.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index d9f3cfb..32c7e7d 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -4,22 +4,27 @@ [alias] # All ESP32 targets build the binary in the ssh-stamp-esp32 crate. -# `--no-default-features` keeps cargo from selecting the default (esp32c6) feature -# alongside another MCU's, which would fail at link time. +# Board features select a specific PCB and imply the IC feature. +# `--no-default-features` prevents the default board from clashing. +# +# Boards with a BSP entry use a board- feature: +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" + +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" + +# IC-only targets (no BSP entry yet) use the IC feature directly. These build +# the library but the binary will hit the `compile_error!("No board feature +# selected.")` guard — add a board- feature to ssh-stamp-esp32-boards to +# make them bootable. build-esp32 = "build --release --target xtensa-esp32-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features esp32 -Z build-std=core,alloc" build-esp32c2 = "build --release --target riscv32imc-unknown-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features esp32c2" build-esp32c3 = "build --release --target riscv32imc-unknown-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features esp32c3" -build-esp32c6 = "build --release --target riscv32imac-unknown-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features esp32c6" -build-esp32s2 = "build --profile esp32s2 --target xtensa-esp32s2-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features esp32s2 -Z build-std=core,alloc" build-esp32s3 = "build --release --target xtensa-esp32s3-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features esp32s3 -Z build-std=core,alloc" -run-esp32 = "run --release --target xtensa-esp32-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features esp32" -run-esp32c2 = "run --release --target riscv32imc-unknown-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features esp32c2" -run-esp32c3 = "run --release --target riscv32imc-unknown-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features esp32c3" -run-esp32c6 = "run --release --target riscv32imac-unknown-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features esp32c6" -run-esp32s2 = "run --profile esp32s2 --target xtensa-esp32s2-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features esp32s2" -run-esp32s3 = "run --release --target xtensa-esp32s3-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features esp32s3" - # Test alias test-ota = "test --package ota --target x86_64-unknown-linux-gnu" @@ -28,7 +33,7 @@ build-packer = "build --package ota --bin packer --target x86_64-unknown-linux-g 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 ota --no-default-features --features ssh-stamp-esp32/esp32c6" +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" [target.xtensa-esp32-none-elf] # ESP32 runner = "espflash flash --baud=921600 --monitor --chip esp32" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b933d33..603283b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,14 +24,14 @@ jobs: matrix: device: [ # RISC-V devices: - { soc: "esp32c2", toolchain: "stable" }, - { soc: "esp32c3", toolchain: "stable" }, -# { soc: "esp32c5", toolchain: "stable" }, - { soc: "esp32c6", toolchain: "stable" }, + { soc: "esp32c2", board: "esp32c2", target: "riscv32imc-unknown-none-elf", toolchain: "stable", buildstd: "" }, + { soc: "esp32c3", board: "esp32c3", target: "riscv32imc-unknown-none-elf", toolchain: "stable", buildstd: "" }, + { soc: "esp32c6", board: "board-esp32c6-devkitc", target: "riscv32imac-unknown-none-elf", toolchain: "stable", buildstd: "" }, + { soc: "esp32c6", board: "board-esp32c6-generic", target: "riscv32imac-unknown-none-elf", toolchain: "stable", buildstd: "" }, # Xtensa devices: - { soc: "esp32", toolchain: "esp" }, - { soc: "esp32s2", toolchain: "esp" }, - { soc: "esp32s3", toolchain: "esp" }, + { soc: "esp32", board: "esp32", target: "xtensa-esp32-none-elf", toolchain: "esp", buildstd: "-Z build-std=core,alloc" }, + { soc: "esp32s2", board: "board-esp32-s2-saola", target: "xtensa-esp32s2-none-elf", toolchain: "esp", buildstd: "-Z build-std=core,alloc" }, + { soc: "esp32s3", board: "esp32s3", target: "xtensa-esp32s3-none-elf", toolchain: "esp", buildstd: "-Z build-std=core,alloc" }, ] steps: - name: Cache @@ -53,12 +53,12 @@ jobs: version: 1.96.0 - name: Build project - run: cargo +${{ matrix.device.toolchain }} build-${{ matrix.device.soc }} + run: cargo +${{ matrix.device.toolchain }} build --release --target ${{ matrix.device.target }} -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features ${{ matrix.device.board }} ${{ matrix.device.buildstd }} - name: Check lints and format - if: ${{ contains(fromJson('["esp32c6"]'), matrix.device.soc) }} + if: ${{ matrix.device.board == 'board-esp32c6-devkitc' }} run: | - cargo +${{ matrix.device.toolchain }} clippy --release --features ${{ matrix.device.soc }} --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 }} 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 diff --git a/Cargo.lock b/Cargo.lock index 97c0bd1..af1886c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2397,11 +2397,16 @@ dependencies = [ "portable-atomic", "sha2", "ssh-stamp", + "ssh-stamp-esp32-boards", "ssh-stamp-hal", "static_cell", "sunset-async", ] +[[package]] +name = "ssh-stamp-esp32-boards" +version = "0.1.0" + [[package]] name = "ssh-stamp-hal" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 2d6f77e..5b90a23 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ license = "GPL-3.0-or-later" [lib] [workspace] -members = ["ssh-stamp-hal", "ssh-stamp-esp32", "ota"] +members = ["ssh-stamp-hal", "ssh-stamp-esp32", "ssh-stamp-esp32-boards", "ota"] [workspace.lints.clippy] mem_forget = "warn" diff --git a/README.md b/README.md index acdc0db..4e08f65 100644 --- a/README.md +++ b/README.md @@ -121,13 +121,27 @@ If your SSH client doesn't forward environment variables by default, use the `-o # UART pins -Default UART RX/TX pins vary by target and are defined in the port binary (`ssh-stamp-esp32/src/bin/ssh-stamp-esp32.rs`). To look them up, run: +UART RX/TX pins are defined per-board in the `ssh-stamp-esp32-boards` crate. +Each board feature (e.g. `board-esp32c6-devkitc`) selects a specific PCB and +its pin assignments. The `Board` trait is the single source of truth — no +other file in the repository hard-codes UART pin numbers. + +Supported boards: + +| Board feature | IC | UART RX | UART TX | Board | +|--------------------------|-----------|---------|---------|------------------------------------------------------------------------------------| +| `board-esp32c6-devkitc` | ESP32-C6 | 10 | 11 | [ESP32-C6-DevKitC-1](https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32c6/esp32-c6-devkitc-1/index.html) | +| `board-esp32c6-generic` | ESP32-C6 | 10 | 11 | Generic ESP32-C6 board | +| `board-esp32-s2-saola` | ESP32-S2 | 10 | 11 | [ESP32-S2-Saola-1](https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32s2/esp32-s2-saola-1/index.html) | + +To see the full list with links, run: ``` cargo build-doc ``` -Then open `target/riscv32imac-unknown-none-elf/doc/ssh_stamp/index.html` and navigate to the `ssh_stamp_esp32` crate documentation, which contains a per-target pin assignment table. +Then open `target/riscv32imac-unknown-none-elf/doc/ssh_stamp_esp32_boards/index.html`, +which contains the per-board pin assignment table. # Example usecases diff --git a/ssh-stamp-esp32-boards/Cargo.toml b/ssh-stamp-esp32-boards/Cargo.toml new file mode 100644 index 0000000..1788b1e --- /dev/null +++ b/ssh-stamp-esp32-boards/Cargo.toml @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: 2026 Roman Valls Guimera +# +# SPDX-License-Identifier: GPL-3.0-or-later + +[package] +name = "ssh-stamp-esp32-boards" +version = "0.1.0" +edition = "2024" +authors = ["Roman Valls Guimera "] +description = "Board Support Package — per-PCB pin mappings for ssh-stamp-esp32" +license = "GPL-3.0-or-later" +repository = "https://github.com/brainstorm/ssh-stamp" + +[features] +board-esp32c6-devkitc = [] +board-esp32c6-generic = [] +board-esp32-s2-saola = [] + +[lints] +workspace = true \ No newline at end of file diff --git a/ssh-stamp-esp32-boards/src/esp32_s2_saola.rs b/ssh-stamp-esp32-boards/src/esp32_s2_saola.rs new file mode 100644 index 0000000..5c0c19b --- /dev/null +++ b/ssh-stamp-esp32-boards/src/esp32_s2_saola.rs @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2026 Roman Valls Guimera +// +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Espressif ESP32-S2-Saola-1 board support. +//! +//! Official board page: +//! + +use crate::Board; + +pub struct Esp32s2Saola; + +impl Board for Esp32s2Saola { + const NAME: &'static str = "esp32-s2-saola"; + const UART_RX: u8 = 10; + const UART_TX: u8 = 11; +} diff --git a/ssh-stamp-esp32-boards/src/esp32c6_devkitc.rs b/ssh-stamp-esp32-boards/src/esp32c6_devkitc.rs new file mode 100644 index 0000000..1455d74 --- /dev/null +++ b/ssh-stamp-esp32-boards/src/esp32c6_devkitc.rs @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2026 Roman Valls Guimera +// +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Espressif ESP32-C6-DevKitC-1 board support. +//! +//! Official board page: +//! + +use crate::Board; + +pub struct Esp32c6Devkitc; + +impl Board for Esp32c6Devkitc { + const NAME: &'static str = "esp32c6-devkitc"; + const UART_RX: u8 = 10; + const UART_TX: u8 = 11; +} diff --git a/ssh-stamp-esp32-boards/src/esp32c6_generic.rs b/ssh-stamp-esp32-boards/src/esp32c6_generic.rs new file mode 100644 index 0000000..3fa4fd5 --- /dev/null +++ b/ssh-stamp-esp32-boards/src/esp32c6_generic.rs @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2026 Roman Valls Guimera +// +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Generic ESP32-C6 board support. +//! +//! Intended for custom ESP32-C6 boards that follow the standard C6 pinout +//! but are not the Espressif DevKitC-1. Uses GPIO 10/11 for UART, matching +//! the safe default on the ESP32-C6 family. +//! +//! See the ESP32-C6 datasheet for pin multiplexing constraints: +//! + +use crate::Board; + +pub struct Esp32c6Generic; + +impl Board for Esp32c6Generic { + const NAME: &'static str = "esp32c6-generic"; + const UART_RX: u8 = 10; + const UART_TX: u8 = 11; +} diff --git a/ssh-stamp-esp32-boards/src/lib.rs b/ssh-stamp-esp32-boards/src/lib.rs new file mode 100644 index 0000000..06d6b56 --- /dev/null +++ b/ssh-stamp-esp32-boards/src/lib.rs @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: 2026 Roman Valls Guimera +// +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Board Support Package for ssh-stamp-esp32. +//! +//! Each board feature selects a specific PCB and provides both the logical +//! pin numbers (via [`Board::UART_RX`] / [`Board::UART_TX`]) and the +//! hardware GPIO extraction (via [`take_uart_pins!`]). +//! +//! The [`Board`] trait is the single source of truth for pin numbers. The +//! [`take_uart_pins!`] macro verifies at compile time that its GPIO field +//! access matches the trait's consts, so the two can never drift: changing +//! a pin in the trait without updating the macro (or vice versa) is a +//! compile error. +//! +//! # Available boards +//! +//! | Board feature | IC | UART RX | UART TX | Board | +//! |--------------------------|-----------|---------|---------|------------------------------------------------------------------------------------| +//! | `board-esp32c6-devkitc` | ESP32-C6 | 10 | 11 | [ESP32-C6-DevKitC-1](https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32c6/esp32-c6-devkitc-1/index.html) | +//! | `board-esp32c6-generic` | ESP32-C6 | 10 | 11 | Generic ESP32-C6 board | +//! | `board-esp32-s2-saola` | ESP32-S2 | 10 | 11 | [ESP32-S2-Saola-1](https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32s2/esp32-s2-saola-1/index.html) | + +#![no_std] + +mod esp32_s2_saola; +mod esp32c6_devkitc; +mod esp32c6_generic; + +pub use esp32_s2_saola::Esp32s2Saola; +pub use esp32c6_devkitc::Esp32c6Devkitc; +pub use esp32c6_generic::Esp32c6Generic; + +/// Board-level pin assignment. +/// +/// Each supported board implements this trait to provide GPIO pin numbers +/// for the UART bridge. These consts are the single source of truth for +/// pin numbers — the [`take_uart_pins!`] macro cross-checks them at +/// compile time against its GPIO field access. +/// +/// To add a new board: +/// 1. Create a module in `src/` with a unit struct implementing `Board`. +/// 2. Add a `board-` feature in `Cargo.toml` (implies the IC feature on `esp-hal`). +/// 3. Add the GPIO mapping branch to the `take_uart_pins!` macro in this file, +/// with a `const { assert!(...) }` matching the trait consts. +/// 4. Add the feature gate + `use` in the binary's `cfg_if!` block. +pub trait Board { + /// Human-readable board name (e.g. `"esp32c6-devkitc"`). + const NAME: &'static str; + /// GPIO number for UART RX. + const UART_RX: u8; + /// GPIO number for UART TX. + const UART_TX: u8; +} + +/// Extract UART GPIO pins from `peripherals` for the active board. +/// +/// Pass the active board type so the macro can verify at compile time that +/// its GPIO field access matches [`Board::UART_RX`] / [`Board::UART_TX`]. +/// Returns `(AnyPin, AnyPin)` — the caller wraps these into the appropriate +/// UART pins struct. +/// +/// This macro is the **only** place in the codebase where UART GPIO singletons +/// are accessed. The pin numbers in the macro are checked against the `Board` +/// trait consts via `const { assert!(...) }`, so the `Board` trait remains the +/// canonical declaration and the two can never silently drift. +/// +/// # Panics +/// +/// Compile-time error if no board feature is selected, or if the `Board` +/// trait consts disagree with the macro's GPIO field access. +#[macro_export] +macro_rules! take_uart_pins { + ($peripherals:expr, $board:ty) => {{ + #[cfg(feature = "board-esp32c6-devkitc")] + { + const { assert!(<$board>::UART_RX == 10) }; + const { assert!(<$board>::UART_TX == 11) }; + ($peripherals.GPIO10.into(), $peripherals.GPIO11.into()) + } + #[cfg(feature = "board-esp32c6-generic")] + { + const { assert!(<$board>::UART_RX == 10) }; + const { assert!(<$board>::UART_TX == 11) }; + ($peripherals.GPIO10.into(), $peripherals.GPIO11.into()) + } + #[cfg(feature = "board-esp32-s2-saola")] + { + const { assert!(<$board>::UART_RX == 10) }; + const { assert!(<$board>::UART_TX == 11) }; + ($peripherals.GPIO10.into(), $peripherals.GPIO11.into()) + } + #[cfg(not(any( + feature = "board-esp32c6-devkitc", + feature = "board-esp32c6-generic", + feature = "board-esp32-s2-saola", + )))] + { + compile_error!("No board feature selected."); + } + }}; +} diff --git a/ssh-stamp-esp32/Cargo.toml b/ssh-stamp-esp32/Cargo.toml index d506f30..ee23362 100644 --- a/ssh-stamp-esp32/Cargo.toml +++ b/ssh-stamp-esp32/Cargo.toml @@ -40,6 +40,8 @@ esp-println = { version = "0.17", features = ["log-04"] } embedded-storage = { workspace = true } embedded-storage-async = { workspace = true } +ssh-stamp-esp32-boards = { path = "../ssh-stamp-esp32-boards", optional = true } + once_cell = { workspace = true } sunset-async = { workspace = true } sha2 = { workspace = true } @@ -51,9 +53,19 @@ portable-atomic = { version = "1" } cfg-if = "1" [features] -default = ["esp32c6"] +default = ["board-esp32c6-devkitc"] sftp-ota = ["ssh-stamp/sftp-ota"] ipv6 = ["ssh-stamp/ipv6"] + +# Board features — select a specific PCB. Each enables the corresponding +# board definition in ssh-stamp-esp32-boards AND the IC feature it targets. +board-esp32c6-devkitc = ["dep:ssh-stamp-esp32-boards", "ssh-stamp-esp32-boards/board-esp32c6-devkitc", "esp32c6"] +board-esp32c6-generic = ["dep:ssh-stamp-esp32-boards", "ssh-stamp-esp32-boards/board-esp32c6-generic", "esp32c6"] +board-esp32-s2-saola = ["dep:ssh-stamp-esp32-boards", "ssh-stamp-esp32-boards/board-esp32-s2-saola", "esp32s2"] + +# IC features — select the chip only; activated automatically by board features. +# Kept for all Espressif MCUs even when no board definition exists yet, so the +# chip remains targetable for custom boards or future BSP entries. esp32 = ["esp-hal/esp32", "esp-radio/esp32", "esp-storage/esp32", "esp-bootloader-esp-idf/esp32", "esp-alloc/esp32", "esp-backtrace/esp32", "esp-rtos/esp32", "esp-println/esp32"] esp32c2 = ["esp-hal/esp32c2", "esp-radio/esp32c2", "esp-storage/esp32c2", "esp-bootloader-esp-idf/esp32c2", "esp-alloc/esp32c2", "esp-backtrace/esp32c2", "esp-rtos/esp32c2", "esp-println/esp32c2"] esp32c3 = ["esp-hal/esp32c3", "esp-radio/esp32c3", "esp-storage/esp32c3", "esp-bootloader-esp-idf/esp32c3", "esp-alloc/esp32c3", "esp-backtrace/esp32c3", "esp-rtos/esp32c3", "esp-println/esp32c3"] diff --git a/ssh-stamp-esp32/src/bin/ssh-stamp-esp32.rs b/ssh-stamp-esp32/src/bin/ssh-stamp-esp32.rs index ee6e753..988a436 100644 --- a/ssh-stamp-esp32/src/bin/ssh-stamp-esp32.rs +++ b/ssh-stamp-esp32/src/bin/ssh-stamp-esp32.rs @@ -15,24 +15,22 @@ //! //! # UART Pin Assignments //! -//! GPIO pin numbers for the UART bridge vary by target: -//! -//! | Target | RX | TX | Notes | -//! |----------|-----|-----|--------------------------------------------| -//! | ESP32 | 13 | 14 | | -//! | ESP32-C2 | 18 | 19 | GPIO9 is a strapping pin; 18/19 avoid it | -//! | ESP32-C3 | 20 | 21 | | -//! | ESP32-C6 | 10 | 11 | Default (also used for S2/S3) | -//! | ESP32-S2 | 10 | 11 | | -//! | ESP32-S3 | 10 | 11 | | -//! -//! These are the only source of truth for pin numbers. Port binaries for -//! other MCUs define their own assignments; no other file in this repository -//! hard-codes UART pin values. +//! UART pin numbers are defined per-board in [`ssh_stamp_esp32_boards::Board`]. +//! Select a board via a `board-` feature (e.g. `board-esp32c6-devkitc`). +//! See the `ssh-stamp-esp32-boards` crate documentation for the full list. #![no_std] #![no_main] +#[cfg(not(any( + feature = "board-esp32c6-devkitc", + feature = "board-esp32c6-generic", + feature = "board-esp32-s2-saola", +)))] +compile_error!( + "No board feature selected. Pass --features board- (e.g. board-esp32c6-devkitc). See ssh-stamp-esp32-boards crate for available boards." +); + extern crate alloc; use embassy_executor::Spawner; @@ -50,6 +48,7 @@ use ssh_stamp_esp32::{ BufferedUart, EspPlatform, EspUartPins, EspWifi, UART_BUF, flash, mac_address, register_custom_rng, uart_task, }; +use ssh_stamp_esp32_boards::Board; use ssh_stamp_hal::{HalError, WifiError}; use ssh_stamp_hal::{NetworkProviderHal, WifiHal}; use static_cell::StaticCell; @@ -103,39 +102,36 @@ async fn main(spawner: Spawner) -> ! { .expect("Failed to validate the current ota partition"); } - // UART pin assignment — single source of truth for all ESP32 targets. - // The `cfg_if!` block selects per-target GPIO numbers that are used both - // for the hardware UART pins (EspUartPins) and for the config record - // (UartPins). No other file in the repository defines UART pin numbers. + // Board selection — determines UART pin numbers via the Board trait. + // Each board feature also activates the corresponding IC feature. cfg_if::cfg_if!( - if #[cfg(feature = "esp32")] { - let uart_pins = UartPins { rx: 13, tx: 14 }; - let pins = EspUartPins { - rx: peripherals.GPIO13.into(), - tx: peripherals.GPIO14.into(), - }; - } else if #[cfg(feature = "esp32c2")] { - // GPIO9 is a strapping pin - use GPIO18/19 instead to avoid boot interference - let uart_pins = UartPins { rx: 18, tx: 19 }; - let pins = EspUartPins { - rx: peripherals.GPIO18.into(), - tx: peripherals.GPIO19.into(), - }; - } else if #[cfg(feature = "esp32c3")] { - let uart_pins = UartPins { rx: 20, tx: 21 }; - let pins = EspUartPins { - rx: peripherals.GPIO20.into(), - tx: peripherals.GPIO21.into(), - }; + if #[cfg(feature = "board-esp32c6-devkitc")] { + use ssh_stamp_esp32_boards::Esp32c6Devkitc as B; + } else if #[cfg(feature = "board-esp32c6-generic")] { + use ssh_stamp_esp32_boards::Esp32c6Generic as B; + } else if #[cfg(feature = "board-esp32-s2-saola")] { + use ssh_stamp_esp32_boards::Esp32s2Saola as B; } else { - let uart_pins = UartPins { rx: 10, tx: 11 }; - let pins = EspUartPins { - rx: peripherals.GPIO10.into(), - tx: peripherals.GPIO11.into(), - }; + compile_error!("No board feature selected."); } ); + let uart_pins = UartPins { + rx: B::UART_RX, + tx: B::UART_TX, + }; + + // Extract UART GPIO pins for the active board. The macro selects + // the correct GPIO fields from peripherals based on the board feature + // and verifies at compile time that they match the Board trait consts, + // so the binary never references peripherals.GPIONN directly and the + // Board trait remains the single source of truth for pin numbers. + let (rx_pin, tx_pin) = ssh_stamp_esp32_boards::take_uart_pins!(peripherals, B); + let pins = EspUartPins { + rx: rx_pin, + tx: tx_pin, + }; + debug!("Loading config"); let flash_config = { let Some(flash_storage_guard) = flash::get_flash_n_buffer() else { From e18593e905d71d59cb95219304f085cdb4636a97 Mon Sep 17 00:00:00 2001 From: brainstorm Date: Tue, 23 Jun 2026 21:08:20 +0200 Subject: [PATCH 2/5] IC-only targets (as opposed to board targets) are now built via --lib, since they'll not be deployed on boards anyway... --bin OTOH is used for actual board 'instantiations' --- .cargo/config.toml | 15 +++++++-------- .github/workflows/build.yml | 16 ++++++++-------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index 32c7e7d..bf62d2f 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -16,14 +16,13 @@ run-esp32c6 = "run --release --target riscv32imac-unknown-none-elf -p ssh-stamp- 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" -# IC-only targets (no BSP entry yet) use the IC feature directly. These build -# the library but the binary will hit the `compile_error!("No board feature -# selected.")` guard — add a board- feature to ssh-stamp-esp32-boards to -# make them bootable. -build-esp32 = "build --release --target xtensa-esp32-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features esp32 -Z build-std=core,alloc" -build-esp32c2 = "build --release --target riscv32imc-unknown-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features esp32c2" -build-esp32c3 = "build --release --target riscv32imc-unknown-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features esp32c3" -build-esp32s3 = "build --release --target xtensa-esp32s3-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features esp32s3 -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" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 603283b..3f17dbe 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,14 +24,14 @@ jobs: matrix: device: [ # RISC-V devices: - { soc: "esp32c2", board: "esp32c2", target: "riscv32imc-unknown-none-elf", toolchain: "stable", buildstd: "" }, - { soc: "esp32c3", board: "esp32c3", target: "riscv32imc-unknown-none-elf", toolchain: "stable", buildstd: "" }, - { soc: "esp32c6", board: "board-esp32c6-devkitc", target: "riscv32imac-unknown-none-elf", toolchain: "stable", buildstd: "" }, - { soc: "esp32c6", board: "board-esp32c6-generic", target: "riscv32imac-unknown-none-elf", toolchain: "stable", buildstd: "" }, + { 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: "esp32c6", board: "board-esp32c6-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" }, - { soc: "esp32s2", board: "board-esp32-s2-saola", target: "xtensa-esp32s2-none-elf", toolchain: "esp", buildstd: "-Z build-std=core,alloc" }, - { soc: "esp32s3", board: "esp32s3", target: "xtensa-esp32s3-none-elf", toolchain: "esp", buildstd: "-Z build-std=core,alloc" }, + { 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" }, ] steps: - name: Cache @@ -53,7 +53,7 @@ jobs: version: 1.96.0 - name: Build project - run: cargo +${{ matrix.device.toolchain }} build --release --target ${{ matrix.device.target }} -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features ${{ matrix.device.board }} ${{ matrix.device.buildstd }} + 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: Check lints and format if: ${{ matrix.device.board == 'board-esp32c6-devkitc' }} From d830b5cea1c20c01ddaaf5d50170047cc4280eba Mon Sep 17 00:00:00 2001 From: brainstorm Date: Thu, 25 Jun 2026 21:25:44 +0200 Subject: [PATCH 3/5] Codegen experiments for pin management: now the boards/pins are defined in TOML files and code is generated instead of relying on unsafe Pin::steal() mechanisms or complex macros --- Cargo.lock | 71 ++++- ssh-stamp-esp32-boards/Cargo.toml | 4 + .../boards/esp32-s2-saola.toml | 10 + .../boards/esp32c6-devkitc.toml | 10 + .../boards/esp32c6-generic.toml | 10 + ssh-stamp-esp32-boards/build.rs | 278 ++++++++++++++++++ ssh-stamp-esp32-boards/src/esp32_s2_saola.rs | 18 -- ssh-stamp-esp32-boards/src/esp32c6_devkitc.rs | 18 -- ssh-stamp-esp32-boards/src/esp32c6_generic.rs | 22 -- ssh-stamp-esp32-boards/src/lib.rs | 106 ++----- ssh-stamp-esp32/src/bin/ssh-stamp-esp32.rs | 50 +--- 11 files changed, 415 insertions(+), 182 deletions(-) create mode 100644 ssh-stamp-esp32-boards/boards/esp32-s2-saola.toml create mode 100644 ssh-stamp-esp32-boards/boards/esp32c6-devkitc.toml create mode 100644 ssh-stamp-esp32-boards/boards/esp32c6-generic.toml create mode 100644 ssh-stamp-esp32-boards/build.rs delete mode 100644 ssh-stamp-esp32-boards/src/esp32_s2_saola.rs delete mode 100644 ssh-stamp-esp32-boards/src/esp32c6_devkitc.rs delete mode 100644 ssh-stamp-esp32-boards/src/esp32c6_generic.rs diff --git a/Cargo.lock b/Cargo.lock index af1886c..2388c96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1936,7 +1936,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit", + "toml_edit 0.25.12+spec-1.1.0", ] [[package]] @@ -2148,6 +2148,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -2406,6 +2415,10 @@ dependencies = [ [[package]] name = "ssh-stamp-esp32-boards" version = "0.1.0" +dependencies = [ + "serde", + "toml", +] [[package]] name = "ssh-stamp-hal" @@ -2584,6 +2597,27 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -2593,6 +2627,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + [[package]] name = "toml_edit" version = "0.25.12+spec-1.1.0" @@ -2600,9 +2648,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.3", ] [[package]] @@ -2611,9 +2659,15 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.3", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tracing" version = "0.1.44" @@ -2800,6 +2854,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.3" diff --git a/ssh-stamp-esp32-boards/Cargo.toml b/ssh-stamp-esp32-boards/Cargo.toml index 1788b1e..639e4ee 100644 --- a/ssh-stamp-esp32-boards/Cargo.toml +++ b/ssh-stamp-esp32-boards/Cargo.toml @@ -16,5 +16,9 @@ board-esp32c6-devkitc = [] board-esp32c6-generic = [] board-esp32-s2-saola = [] +[build-dependencies] +toml = "0.8" +serde = { version = "1", features = ["derive"] } + [lints] workspace = true \ No newline at end of file diff --git a/ssh-stamp-esp32-boards/boards/esp32-s2-saola.toml b/ssh-stamp-esp32-boards/boards/esp32-s2-saola.toml new file mode 100644 index 0000000..2cb418d --- /dev/null +++ b/ssh-stamp-esp32-boards/boards/esp32-s2-saola.toml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2026 Roman Valls Guimera +# +# SPDX-License-Identifier: GPL-3.0-or-later + +# Espressif ESP32-S2-Saola-1 +url = "https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32s2/esp32-s2-saola-1/index.html" + +[pins] +uart_rx = 10 +uart_tx = 11 \ 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 new file mode 100644 index 0000000..04a7a0c --- /dev/null +++ b/ssh-stamp-esp32-boards/boards/esp32c6-devkitc.toml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2026 Roman Valls Guimera +# +# SPDX-License-Identifier: GPL-3.0-or-later + +# Espressif ESP32-C6-DevKitC-1 +url = "https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32c6/esp32-c6-devkitc-1/index.html" + +[pins] +uart_rx = 10 +uart_tx = 11 \ 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 new file mode 100644 index 0000000..4154ab2 --- /dev/null +++ b/ssh-stamp-esp32-boards/boards/esp32c6-generic.toml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2026 Roman Valls Guimera +# +# SPDX-License-Identifier: GPL-3.0-or-later + +# Generic ESP32-C6 board. +# Uses GPIO 10/11 for UART, matching the safe default on the ESP32-C6 family. + +[pins] +uart_rx = 10 +uart_tx = 11 \ No newline at end of file diff --git a/ssh-stamp-esp32-boards/build.rs b/ssh-stamp-esp32-boards/build.rs new file mode 100644 index 0000000..d9cc7e0 --- /dev/null +++ b/ssh-stamp-esp32-boards/build.rs @@ -0,0 +1,278 @@ +// SPDX-FileCopyrightText: 2026 Roman Valls Guimera +// +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Build script for ssh-stamp-esp32-boards. +//! +//! Reads `boards/*.toml` (one file per board, containing pin mappings and an +//! optional documentation URL) and generates `OUT_DIR/boards_gen.rs` with: +//! +//! - A `pub struct` + `impl Board` per board. +//! - The `take_uart_pins!` macro whose `#[cfg]` branches emit the correct +//! `peripherals.GPIO{N}.into()` tokens — the pin numbers come from the TOML +//! files, which are the single source of truth. +//! - The `select_board!` macro that expands to the `cfg_if!` + `use` block, +//! so the binary has zero per-board lines. +//! - A rustdoc board-catalog table. +//! +//! Adding a board = add a `boards/{name}.toml` file + a `board-{name}` feature +//! in `Cargo.toml`. No `.rs` file, no macro editing. + +use std::fmt::Write as _; +use std::fs; +use std::path::PathBuf; + +use serde::Deserialize; + +// --- Static code templates (the boilerplate around the per-board data) --- + +/// Header prepended to every generated file. +const HEADER: &str = "// Auto-generated by build.rs from boards/*.toml — DO NOT EDIT.\n\n"; + +/// Doc-comment + signature for `take_uart_pins!`. Per-board branches are +/// inserted at `{branches}` and the fallback `not(any(...))` feature list +/// at `{features}`. +const TAKE_UART_PINS_TMPL: &str = r#"/// Extract UART GPIO pins and logical pin numbers from `peripherals`. +/// +/// Returns `(rx_pin, tx_pin, rx_num, tx_num)`. The pin numbers come from +/// `boards/*.toml` — this macro is generated by `build.rs`, not hand-written. +/// +/// # Panics +/// +/// Compile-time error if no board feature is selected. +#[macro_export] +macro_rules! take_uart_pins { + ($peripherals:expr) => {{ +{branches} #[cfg(not(any({features})))] + {{ + compile_error!("No board feature selected. Pass --features board-. See ssh-stamp-esp32-boards crate for available boards."); + }} + }}; +} +"#; + +/// Doc-comment + signature for `select_board!`. Per-board `cfg_if!` arms are +/// inserted at `{arms}`. +const SELECT_BOARD_TMPL: &str = r#"/// Select the active board type `B` at compile time. +/// +/// Expands to a `cfg_if!` block that imports the active board's struct as `B`. +/// The caller can then use `B::NAME` for logging. Zero per-board lines in the binary. +/// +/// # Panics +/// +/// Compile-time error if no board feature is selected. +#[macro_export] +macro_rules! select_board { + () => { + cfg_if::cfg_if!( +{arms} else { + compile_error!("No board feature selected."); + } + ); + }; +} +"#; + +// --- TOML deserialization structs --- + +#[derive(Debug, Deserialize)] +struct BoardDef { + url: Option, + pins: Pins, +} + +#[derive(Debug, Deserialize)] +struct Pins { + uart_rx: u8, + uart_tx: u8, +} + +/// A parsed board ready for codegen. +struct Board { + /// Filename without `.toml` (e.g. `esp32c6-devkitc`). + name: String, + /// `PascalCase` struct name (e.g. `Esp32c6Devkitc`). + struct_name: String, + /// Cargo feature name (e.g. `board-esp32c6-devkitc`). + feature: String, + url: Option, + uart_rx: u8, + uart_tx: u8, +} + +fn main() { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let boards_dir = manifest_dir.join("boards"); + + println!("cargo:rerun-if-changed=boards/"); + + let mut boards: Vec = Vec::new(); + + if boards_dir.is_dir() { + let mut entries: Vec<_> = fs::read_dir(&boards_dir) + .unwrap_or_else(|e| panic!("Failed to read boards/ directory: {e}")) + .filter_map(std::result::Result::ok) + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|ext| ext == "toml")) + .collect(); + entries.sort(); + + for path in entries { + let stem = path.file_stem().unwrap().to_string_lossy().to_string(); + + let content = fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("Failed to read {}: {e}", path.display())); + + let def: BoardDef = toml::from_str(&content) + .unwrap_or_else(|e| panic!("Failed to parse {}: {e}", path.display())); + + boards.push(Board { + name: stem.clone(), + struct_name: to_pascal_case(&stem), + feature: format!("board-{stem}"), + url: def.url, + uart_rx: def.pins.uart_rx, + uart_tx: def.pins.uart_tx, + }); + } + } + + // Validation: if a board feature is selected but no matching TOML was + // found, fail loudly. + for (key, _val) in std::env::vars() { + if let Some(rest) = key.strip_prefix("CARGO_FEATURE_BOARD_") { + let requested = format!("board-{}", rest.to_lowercase().replace('_', "-")); + if boards.iter().any(|b| b.feature == requested) { + continue; + } + panic!( + "Feature `{requested}` is enabled in Cargo.toml but no corresponding \ + board definition file `boards/{requested}.toml` was found. \ + Create it with a [pins] section containing uart_rx and uart_tx.", + ); + } + } + + let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR not set")); + let out_path = out_dir.join("boards_gen.rs"); + fs::write(&out_path, generate_code(&boards)) + .unwrap_or_else(|e| panic!("Failed to write {}: {e}", out_path.display())); +} + +/// Convert `esp32c6-devkitc` -> `Esp32c6Devkitc`. +fn to_pascal_case(s: &str) -> String { + s.split('-') + .map(|segment| { + let mut chars = segment.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } + }) + .collect() +} + +fn generate_code(boards: &[Board]) -> String { + let mut out = String::new(); + + out.push_str(HEADER); + gen_structs(&mut out, boards); + gen_catalog(&mut out, boards); + gen_take_uart_pins(&mut out, boards); + gen_select_board(&mut out, boards); + + out +} + +fn gen_structs(out: &mut String, boards: &[Board]) { + for b in boards { + let doc = match &b.url { + Some(url) => format!("/// Board: {}.\n///\n/// <{url}>", b.name), + None => format!("/// Board: {}.", b.name), + }; + writeln!( + out, + "{doc}\npub struct {s};\nimpl Board for {s} {{\n const NAME: &str = \"{n}\";\n}}\n", + s = b.struct_name, + n = b.name, + ) + .unwrap(); + } +} + +fn gen_catalog(out: &mut String, boards: &[Board]) { + let mut table = String::from( + "/// # Available boards\n\ + ///\n\ + /// | Board feature | UART RX | UART TX | URL |\n\ + /// |---|---|---|---|\n", + ); + for b in boards { + let url = match &b.url { + Some(u) => format!("<{u}>"), + None => "—".to_string(), + }; + writeln!( + table, + "/// | `{}` | {} | {} | {} |", + b.feature, b.uart_rx, b.uart_tx, url + ) + .unwrap(); + } + table.push_str("pub mod board_catalog {}\n\n"); + out.push_str(&table); +} + +fn gen_take_uart_pins(out: &mut String, boards: &[Board]) { + let mut branches = String::new(); + for b in boards { + writeln!( + branches, + " #[cfg(feature = \"{f}\")]\n\ + \x20 {{\n\ + \x20 (\n\ + \x20 $peripherals.GPIO{rx}.into(),\n\ + \x20 $peripherals.GPIO{tx}.into(),\n\ + \x20 {rx}u8,\n\ + \x20 {tx}u8,\n\ + \x20 )\n\ + \x20 }}\n", + f = b.feature, + rx = b.uart_rx, + tx = b.uart_tx, + ) + .unwrap(); + } + + let features: Vec = boards + .iter() + .map(|b| format!("feature = \"{}\"", b.feature)) + .collect(); + + let rendered = TAKE_UART_PINS_TMPL + .replace("{branches}", &branches) + .replace("{features}", &features.join(", ")); + + out.push_str(&rendered); + out.push('\n'); +} + +fn gen_select_board(out: &mut String, boards: &[Board]) { + let mut arms = String::new(); + for (i, b) in boards.iter().enumerate() { + let kw = if i == 0 { "if" } else { "else if" }; + writeln!( + arms, + " {kw} #[cfg(feature = \"{f}\")] {{\n\ + \x20 use $crate::{s} as B;\n\ + \x20 }}\n", + kw = kw, + f = b.feature, + s = b.struct_name, + ) + .unwrap(); + } + + let rendered = SELECT_BOARD_TMPL.replace("{arms}", &arms); + out.push_str(&rendered); +} diff --git a/ssh-stamp-esp32-boards/src/esp32_s2_saola.rs b/ssh-stamp-esp32-boards/src/esp32_s2_saola.rs deleted file mode 100644 index 5c0c19b..0000000 --- a/ssh-stamp-esp32-boards/src/esp32_s2_saola.rs +++ /dev/null @@ -1,18 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Roman Valls Guimera -// -// SPDX-License-Identifier: GPL-3.0-or-later - -//! Espressif ESP32-S2-Saola-1 board support. -//! -//! Official board page: -//! - -use crate::Board; - -pub struct Esp32s2Saola; - -impl Board for Esp32s2Saola { - const NAME: &'static str = "esp32-s2-saola"; - const UART_RX: u8 = 10; - const UART_TX: u8 = 11; -} diff --git a/ssh-stamp-esp32-boards/src/esp32c6_devkitc.rs b/ssh-stamp-esp32-boards/src/esp32c6_devkitc.rs deleted file mode 100644 index 1455d74..0000000 --- a/ssh-stamp-esp32-boards/src/esp32c6_devkitc.rs +++ /dev/null @@ -1,18 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Roman Valls Guimera -// -// SPDX-License-Identifier: GPL-3.0-or-later - -//! Espressif ESP32-C6-DevKitC-1 board support. -//! -//! Official board page: -//! - -use crate::Board; - -pub struct Esp32c6Devkitc; - -impl Board for Esp32c6Devkitc { - const NAME: &'static str = "esp32c6-devkitc"; - const UART_RX: u8 = 10; - const UART_TX: u8 = 11; -} diff --git a/ssh-stamp-esp32-boards/src/esp32c6_generic.rs b/ssh-stamp-esp32-boards/src/esp32c6_generic.rs deleted file mode 100644 index 3fa4fd5..0000000 --- a/ssh-stamp-esp32-boards/src/esp32c6_generic.rs +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Roman Valls Guimera -// -// SPDX-License-Identifier: GPL-3.0-or-later - -//! Generic ESP32-C6 board support. -//! -//! Intended for custom ESP32-C6 boards that follow the standard C6 pinout -//! but are not the Espressif DevKitC-1. Uses GPIO 10/11 for UART, matching -//! the safe default on the ESP32-C6 family. -//! -//! See the ESP32-C6 datasheet for pin multiplexing constraints: -//! - -use crate::Board; - -pub struct Esp32c6Generic; - -impl Board for Esp32c6Generic { - const NAME: &'static str = "esp32c6-generic"; - const UART_RX: u8 = 10; - const UART_TX: u8 = 11; -} diff --git a/ssh-stamp-esp32-boards/src/lib.rs b/ssh-stamp-esp32-boards/src/lib.rs index 06d6b56..3d9456b 100644 --- a/ssh-stamp-esp32-boards/src/lib.rs +++ b/ssh-stamp-esp32-boards/src/lib.rs @@ -4,100 +4,38 @@ //! Board Support Package for ssh-stamp-esp32. //! -//! Each board feature selects a specific PCB and provides both the logical -//! pin numbers (via [`Board::UART_RX`] / [`Board::UART_TX`]) and the -//! hardware GPIO extraction (via [`take_uart_pins!`]). +//! Board definitions live in `boards/*.toml` (one file per board, containing +//! pin mappings and an optional documentation URL). The `build.rs` reads +//! those TOML files and generates all Rust code — board structs, the +//! [`take_uart_pins!`] macro, and the [`select_board!`] macro — into +//! `OUT_DIR/boards_gen.rs`, which is included here. //! -//! The [`Board`] trait is the single source of truth for pin numbers. The -//! [`take_uart_pins!`] macro verifies at compile time that its GPIO field -//! access matches the trait's consts, so the two can never drift: changing -//! a pin in the trait without updating the macro (or vice versa) is a -//! compile error. +//! The TOML files are the single source of truth for pin numbers. No human +//! writes or edits the generated Rust code. +//! +//! # Adding a board +//! +//! 1. Create `boards/{board-name}.toml` with a `[pins]` section (`uart_rx`, +//! `uart_tx`) and an optional `url`. +//! 2. Add `board-{name} = []` to `[features]` in `Cargo.toml`. +//! +//! No `.rs` file, no macro editing, no binary changes. The `build.rs` +//! validates that selected features have matching TOML files. //! //! # Available boards //! -//! | Board feature | IC | UART RX | UART TX | Board | -//! |--------------------------|-----------|---------|---------|------------------------------------------------------------------------------------| -//! | `board-esp32c6-devkitc` | ESP32-C6 | 10 | 11 | [ESP32-C6-DevKitC-1](https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32c6/esp32-c6-devkitc-1/index.html) | -//! | `board-esp32c6-generic` | ESP32-C6 | 10 | 11 | Generic ESP32-C6 board | -//! | `board-esp32-s2-saola` | ESP32-S2 | 10 | 11 | [ESP32-S2-Saola-1](https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32s2/esp32-s2-saola-1/index.html) | +//! See the [`board_catalog`] module for the generated table. #![no_std] -mod esp32_s2_saola; -mod esp32c6_devkitc; -mod esp32c6_generic; - -pub use esp32_s2_saola::Esp32s2Saola; -pub use esp32c6_devkitc::Esp32c6Devkitc; -pub use esp32c6_generic::Esp32c6Generic; - -/// Board-level pin assignment. -/// -/// Each supported board implements this trait to provide GPIO pin numbers -/// for the UART bridge. These consts are the single source of truth for -/// pin numbers — the [`take_uart_pins!`] macro cross-checks them at -/// compile time against its GPIO field access. +/// Board identification trait. /// -/// To add a new board: -/// 1. Create a module in `src/` with a unit struct implementing `Board`. -/// 2. Add a `board-` feature in `Cargo.toml` (implies the IC feature on `esp-hal`). -/// 3. Add the GPIO mapping branch to the `take_uart_pins!` macro in this file, -/// with a `const { assert!(...) }` matching the trait consts. -/// 4. Add the feature gate + `use` in the binary's `cfg_if!` block. +/// Each board struct generated from `boards/*.toml` implements this trait. +/// The `NAME` const is the board's filename (without `.toml`), used for +/// boot-time logging. pub trait Board { /// Human-readable board name (e.g. `"esp32c6-devkitc"`). const NAME: &'static str; - /// GPIO number for UART RX. - const UART_RX: u8; - /// GPIO number for UART TX. - const UART_TX: u8; } -/// Extract UART GPIO pins from `peripherals` for the active board. -/// -/// Pass the active board type so the macro can verify at compile time that -/// its GPIO field access matches [`Board::UART_RX`] / [`Board::UART_TX`]. -/// Returns `(AnyPin, AnyPin)` — the caller wraps these into the appropriate -/// UART pins struct. -/// -/// This macro is the **only** place in the codebase where UART GPIO singletons -/// are accessed. The pin numbers in the macro are checked against the `Board` -/// trait consts via `const { assert!(...) }`, so the `Board` trait remains the -/// canonical declaration and the two can never silently drift. -/// -/// # Panics -/// -/// Compile-time error if no board feature is selected, or if the `Board` -/// trait consts disagree with the macro's GPIO field access. -#[macro_export] -macro_rules! take_uart_pins { - ($peripherals:expr, $board:ty) => {{ - #[cfg(feature = "board-esp32c6-devkitc")] - { - const { assert!(<$board>::UART_RX == 10) }; - const { assert!(<$board>::UART_TX == 11) }; - ($peripherals.GPIO10.into(), $peripherals.GPIO11.into()) - } - #[cfg(feature = "board-esp32c6-generic")] - { - const { assert!(<$board>::UART_RX == 10) }; - const { assert!(<$board>::UART_TX == 11) }; - ($peripherals.GPIO10.into(), $peripherals.GPIO11.into()) - } - #[cfg(feature = "board-esp32-s2-saola")] - { - const { assert!(<$board>::UART_RX == 10) }; - const { assert!(<$board>::UART_TX == 11) }; - ($peripherals.GPIO10.into(), $peripherals.GPIO11.into()) - } - #[cfg(not(any( - feature = "board-esp32c6-devkitc", - feature = "board-esp32c6-generic", - feature = "board-esp32-s2-saola", - )))] - { - compile_error!("No board feature selected."); - } - }}; -} +include!(concat!(env!("OUT_DIR"), "/boards_gen.rs")); diff --git a/ssh-stamp-esp32/src/bin/ssh-stamp-esp32.rs b/ssh-stamp-esp32/src/bin/ssh-stamp-esp32.rs index 988a436..05beb53 100644 --- a/ssh-stamp-esp32/src/bin/ssh-stamp-esp32.rs +++ b/ssh-stamp-esp32/src/bin/ssh-stamp-esp32.rs @@ -15,22 +15,14 @@ //! //! # UART Pin Assignments //! -//! UART pin numbers are defined per-board in [`ssh_stamp_esp32_boards::Board`]. -//! Select a board via a `board-` feature (e.g. `board-esp32c6-devkitc`). -//! See the `ssh-stamp-esp32-boards` crate documentation for the full list. +//! UART pin numbers are defined per-board in `boards/*.toml` files in the +//! `ssh-stamp-esp32-boards` crate. Select a board via a `board-` feature +//! (e.g. `board-esp32c6-devkitc`). See the `ssh-stamp-esp32-boards` crate +//! documentation for the full list. #![no_std] #![no_main] -#[cfg(not(any( - feature = "board-esp32c6-devkitc", - feature = "board-esp32c6-generic", - feature = "board-esp32-s2-saola", -)))] -compile_error!( - "No board feature selected. Pass --features board- (e.g. board-esp32c6-devkitc). See ssh-stamp-esp32-boards crate for available boards." -); - extern crate alloc; use embassy_executor::Spawner; @@ -102,35 +94,21 @@ async fn main(spawner: Spawner) -> ! { .expect("Failed to validate the current ota partition"); } - // Board selection — determines UART pin numbers via the Board trait. - // Each board feature also activates the corresponding IC feature. - cfg_if::cfg_if!( - if #[cfg(feature = "board-esp32c6-devkitc")] { - use ssh_stamp_esp32_boards::Esp32c6Devkitc as B; - } else if #[cfg(feature = "board-esp32c6-generic")] { - use ssh_stamp_esp32_boards::Esp32c6Generic as B; - } else if #[cfg(feature = "board-esp32-s2-saola")] { - use ssh_stamp_esp32_boards::Esp32s2Saola as B; - } else { - compile_error!("No board feature selected."); - } - ); - - let uart_pins = UartPins { - rx: B::UART_RX, - tx: B::UART_TX, - }; + // Board selection — the generated select_board! macro expands to a + // cfg_if! that imports the active board's struct as B. The pin numbers + // come from boards/*.toml via build.rs codegen — no per-board lines here. + ssh_stamp_esp32_boards::select_board!(); + debug!("Active board: {}", B::NAME); - // Extract UART GPIO pins for the active board. The macro selects - // the correct GPIO fields from peripherals based on the board feature - // and verifies at compile time that they match the Board trait consts, - // so the binary never references peripherals.GPIONN directly and the - // Board trait remains the single source of truth for pin numbers. - let (rx_pin, tx_pin) = ssh_stamp_esp32_boards::take_uart_pins!(peripherals, B); + let (rx_pin, tx_pin, rx_num, tx_num) = ssh_stamp_esp32_boards::take_uart_pins!(peripherals); let pins = EspUartPins { rx: rx_pin, tx: tx_pin, }; + let uart_pins = UartPins { + rx: rx_num, + tx: tx_num, + }; debug!("Loading config"); let flash_config = { From de7186c46e6c09ad62e00ac148e9edc8a6c8c47a Mon Sep 17 00:00:00 2001 From: brainstorm Date: Thu, 25 Jun 2026 21:32:15 +0200 Subject: [PATCH 4/5] Remove explicit PIN/boards mention from README, the toml files should now be the SSOT --- README.md | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 4e08f65..af6303f 100644 --- a/README.md +++ b/README.md @@ -121,27 +121,20 @@ If your SSH client doesn't forward environment variables by default, use the `-o # UART pins -UART RX/TX pins are defined per-board in the `ssh-stamp-esp32-boards` crate. -Each board feature (e.g. `board-esp32c6-devkitc`) selects a specific PCB and -its pin assignments. The `Board` trait is the single source of truth — no -other file in the repository hard-codes UART pin numbers. +UART RX/TX pins are defined per-board in `boards/*.toml` files inside the +`ssh-stamp-esp32-boards` crate. Each board feature (e.g. +`board-esp32c6-devkitc`) selects a specific PCB and its pin assignments. +The TOML files are the single source of truth — no other file in the +repository hard-codes UART pin numbers. -Supported boards: - -| Board feature | IC | UART RX | UART TX | Board | -|--------------------------|-----------|---------|---------|------------------------------------------------------------------------------------| -| `board-esp32c6-devkitc` | ESP32-C6 | 10 | 11 | [ESP32-C6-DevKitC-1](https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32c6/esp32-c6-devkitc-1/index.html) | -| `board-esp32c6-generic` | ESP32-C6 | 10 | 11 | Generic ESP32-C6 board | -| `board-esp32-s2-saola` | ESP32-S2 | 10 | 11 | [ESP32-S2-Saola-1](https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32s2/esp32-s2-saola-1/index.html) | - -To see the full list with links, run: +To see the available boards and their pin assignments, run: ``` cargo build-doc ``` Then open `target/riscv32imac-unknown-none-elf/doc/ssh_stamp_esp32_boards/index.html`, -which contains the per-board pin assignment table. +which contains the auto-generated per-board pin assignment table. # Example usecases From 2f04a3483ec7e9c8c24927095645a15c20d9b214 Mon Sep 17 00:00:00 2001 From: brainstorm Date: Thu, 25 Jun 2026 21:51:44 +0200 Subject: [PATCH 5/5] Codegen cleanup --- ssh-stamp-esp32-boards/build.rs | 287 ++++++++++++++++++++------------ 1 file changed, 182 insertions(+), 105 deletions(-) diff --git a/ssh-stamp-esp32-boards/build.rs b/ssh-stamp-esp32-boards/build.rs index d9cc7e0..f42f9d6 100644 --- a/ssh-stamp-esp32-boards/build.rs +++ b/ssh-stamp-esp32-boards/build.rs @@ -18,9 +18,10 @@ //! Adding a board = add a `boards/{name}.toml` file + a `board-{name}` feature //! in `Cargo.toml`. No `.rs` file, no macro editing. +use std::fmt; use std::fmt::Write as _; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use serde::Deserialize; @@ -51,12 +52,15 @@ macro_rules! take_uart_pins { } "#; -/// Doc-comment + signature for `select_board!`. Per-board `cfg_if!` arms are -/// inserted at `{arms}`. +/// Doc-comment + signature for `select_board!`. Per-board `#[cfg]` aliases +/// are inserted at `{arms}` and the fallback feature list at `{features}`. +/// Each arm creates a `type B = ...` alias guarded by a `#[cfg(feature = ...)]` +/// attribute, so exactly one `B` is in scope at the call site — no `cfg_if!` +/// block scoping issues. const SELECT_BOARD_TMPL: &str = r#"/// Select the active board type `B` at compile time. /// -/// Expands to a `cfg_if!` block that imports the active board's struct as `B`. -/// The caller can then use `B::NAME` for logging. Zero per-board lines in the binary. +/// Emits a `type B = ...` alias for the active board's struct. The caller can +/// then use `B::NAME` for logging. Zero per-board lines in the binary. /// /// # Panics /// @@ -64,11 +68,8 @@ const SELECT_BOARD_TMPL: &str = r#"/// Select the active board type `B` at compi #[macro_export] macro_rules! select_board { () => { - cfg_if::cfg_if!( -{arms} else { - compile_error!("No board feature selected."); - } - ); +{arms} #[cfg(not(any({features})))] + compile_error!("No board feature selected."); }; } "#; @@ -89,74 +90,125 @@ struct Pins { /// A parsed board ready for codegen. struct Board { - /// Filename without `.toml` (e.g. `esp32c6-devkitc`). name: String, - /// `PascalCase` struct name (e.g. `Esp32c6Devkitc`). struct_name: String, - /// Cargo feature name (e.g. `board-esp32c6-devkitc`). feature: String, url: Option, uart_rx: u8, uart_tx: u8, } -fn main() { +/// Errors surfaced by the build script as `Result`. +#[derive(Debug)] +enum BuildError { + /// A board feature was selected in Cargo.toml but no matching TOML file + /// was found in `boards/`. + MissingBoardDef { feature: String }, + /// A `boards/*.toml` file could not be read or parsed. + BoardFile { + path: PathBuf, + source: Box, + }, +} + +impl fmt::Display for BuildError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingBoardDef { feature } => write!( + f, + "Feature `{feature}` is enabled in Cargo.toml but no corresponding \ + board definition file `boards/{feature}.toml` was found. \ + Create it with a [pins] section containing uart_rx and uart_tx.", + ), + Self::BoardFile { path, source } => { + write!( + f, + "Failed to process board file {}: {source}", + path.display() + ) + } + } + } +} + +impl std::error::Error for BuildError {} + +type Result = std::result::Result>; + +fn main() -> Result<()> { let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); let boards_dir = manifest_dir.join("boards"); println!("cargo:rerun-if-changed=boards/"); - let mut boards: Vec = Vec::new(); - - if boards_dir.is_dir() { - let mut entries: Vec<_> = fs::read_dir(&boards_dir) - .unwrap_or_else(|e| panic!("Failed to read boards/ directory: {e}")) - .filter_map(std::result::Result::ok) - .map(|e| e.path()) - .filter(|p| p.extension().is_some_and(|ext| ext == "toml")) - .collect(); - entries.sort(); - - for path in entries { - let stem = path.file_stem().unwrap().to_string_lossy().to_string(); - - let content = fs::read_to_string(&path) - .unwrap_or_else(|e| panic!("Failed to read {}: {e}", path.display())); - - let def: BoardDef = toml::from_str(&content) - .unwrap_or_else(|e| panic!("Failed to parse {}: {e}", path.display())); - - boards.push(Board { - name: stem.clone(), - struct_name: to_pascal_case(&stem), - feature: format!("board-{stem}"), - url: def.url, - uart_rx: def.pins.uart_rx, - uart_tx: def.pins.uart_tx, - }); - } + let boards = load_boards(&boards_dir)?; + + validate_features(&boards)?; + + let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").ok_or("OUT_DIR not set by cargo")?); + let out_path = out_dir.join("boards_gen.rs"); + fs::write(&out_path, generate_code(&boards)?)?; + + Ok(()) +} + +/// Read all `boards/*.toml` files and parse them into `Board` structs. +fn load_boards(boards_dir: &Path) -> Result> { + if !boards_dir.is_dir() { + return Ok(Vec::new()); + } + + let mut entries: Vec<_> = fs::read_dir(boards_dir)? + .filter_map(std::result::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 stem = path + .file_stem() + .ok_or("board file has no stem")? + .to_string_lossy() + .to_string(); + + let content = fs::read_to_string(&path).map_err(|e| BuildError::BoardFile { + path: path.clone(), + source: Box::new(e), + })?; + + let def: BoardDef = toml::from_str(&content).map_err(|e| BuildError::BoardFile { + path: path.clone(), + source: Box::new(e), + })?; + + boards.push(Board { + name: stem.clone(), + struct_name: to_pascal_case(&stem), + feature: format!("board-{stem}"), + url: def.url, + uart_rx: def.pins.uart_rx, + uart_tx: def.pins.uart_tx, + }); } - // Validation: if a board feature is selected but no matching TOML was - // found, fail loudly. + Ok(boards) +} + +/// Check that every selected `CARGO_FEATURE_BOARD_*` env var has a matching +/// board TOML file. +fn validate_features(boards: &[Board]) -> Result<()> { for (key, _val) in std::env::vars() { if let Some(rest) = key.strip_prefix("CARGO_FEATURE_BOARD_") { let requested = format!("board-{}", rest.to_lowercase().replace('_', "-")); if boards.iter().any(|b| b.feature == requested) { continue; } - panic!( - "Feature `{requested}` is enabled in Cargo.toml but no corresponding \ - board definition file `boards/{requested}.toml` was found. \ - Create it with a [pins] section containing uart_rx and uart_tx.", - ); + return Err(BuildError::MissingBoardDef { feature: requested }.into()); } } - - let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR not set")); - let out_path = out_dir.join("boards_gen.rs"); - fs::write(&out_path, generate_code(&boards)) - .unwrap_or_else(|e| panic!("Failed to write {}: {e}", out_path.display())); + Ok(()) } /// Convert `esp32c6-devkitc` -> `Esp32c6Devkitc`. @@ -172,19 +224,21 @@ fn to_pascal_case(s: &str) -> String { .collect() } -fn generate_code(boards: &[Board]) -> String { +fn generate_code(boards: &[Board]) -> Result { let mut out = String::new(); out.push_str(HEADER); - gen_structs(&mut out, boards); - gen_catalog(&mut out, boards); + gen_structs(&mut out, boards)?; + gen_catalog(&mut out, boards)?; gen_take_uart_pins(&mut out, boards); gen_select_board(&mut out, boards); - out + Ok(out) } -fn gen_structs(out: &mut String, boards: &[Board]) { +fn gen_structs(out: &mut String, boards: &[Board]) -> Result<()> { + // One `pub struct` + `impl Board` per board, with a rustdoc link to the + // board's official documentation page (if the TOML provided a `url`). for b in boards { let doc = match &b.url { Some(url) => format!("/// Board: {}.\n///\n/// <{url}>", b.name), @@ -192,58 +246,70 @@ fn gen_structs(out: &mut String, boards: &[Board]) { }; writeln!( out, - "{doc}\npub struct {s};\nimpl Board for {s} {{\n const NAME: &str = \"{n}\";\n}}\n", + "{doc}\npub struct {s};\nimpl Board for {s} {{\n const NAME: &str = \"{n}\";\n}}", s = b.struct_name, n = b.name, - ) - .unwrap(); + )?; + out.push('\n'); } + Ok(()) } -fn gen_catalog(out: &mut String, boards: &[Board]) { - let mut table = String::from( - "/// # Available boards\n\ - ///\n\ - /// | Board feature | UART RX | UART TX | URL |\n\ - /// |---|---|---|---|\n", - ); +fn gen_catalog(out: &mut String, boards: &[Board]) -> Result<()> { + // A rustdoc table summarising all boards — rendered in `cargo doc` output + // as the `board_catalog` module page. This is the "one place to look" that + // replaces the old hand-maintained README pin table. + writeln!( + out, + "/// # Available boards\n///\n/// | Board feature | UART RX | UART TX | URL |\n/// |---|---|---|---|" + )?; for b in boards { let url = match &b.url { Some(u) => format!("<{u}>"), None => "—".to_string(), }; writeln!( - table, + out, "/// | `{}` | {} | {} | {} |", - b.feature, b.uart_rx, b.uart_tx, url - ) - .unwrap(); + b.feature, b.uart_rx, b.uart_tx, url, + )?; } - table.push_str("pub mod board_catalog {}\n\n"); - out.push_str(&table); + writeln!(out, "pub mod board_catalog {{}}\n")?; + Ok(()) } +/// Per-board `#[cfg]` branch inside `take_uart_pins!`. +/// +/// Each branch expands to a tuple of `(AnyPin, AnyPin, u8, u8)` — the two GPIO +/// singletons consumed by the UART driver, plus the raw pin numbers persisted +/// in `SSHStampConfig`. The `GPIO{N}` field-access tokens are built here from +/// the TOML's pin numbers; they cannot be constructed at source level because +/// esp-hal's peripheral singletons are named fields (`peripherals.GPIO10`). +const UART_PIN_BRANCH: &str = r#" #[cfg(feature = "{feature}")] + {{ + ( + $peripherals.GPIO{rx}.into(), + $peripherals.GPIO{tx}.into(), + {rx}u8, + {tx}u8, + ) + }} +"#; + fn gen_take_uart_pins(out: &mut String, boards: &[Board]) { + // Build the per-board `#[cfg]` branches from the TOML pin data. let mut branches = String::new(); for b in boards { - writeln!( - branches, - " #[cfg(feature = \"{f}\")]\n\ - \x20 {{\n\ - \x20 (\n\ - \x20 $peripherals.GPIO{rx}.into(),\n\ - \x20 $peripherals.GPIO{tx}.into(),\n\ - \x20 {rx}u8,\n\ - \x20 {tx}u8,\n\ - \x20 )\n\ - \x20 }}\n", - f = b.feature, - rx = b.uart_rx, - tx = b.uart_tx, - ) - .unwrap(); + branches.push_str( + &UART_PIN_BRANCH + .replace("{feature}", &b.feature) + .replace("{rx}", &b.uart_rx.to_string()) + .replace("{tx}", &b.uart_tx.to_string()), + ); } + // The fallback `not(any(...))` guard lists every board feature so that + // selecting none produces a clear compile_error. let features: Vec = boards .iter() .map(|b| format!("feature = \"{}\"", b.feature)) @@ -257,22 +323,33 @@ fn gen_take_uart_pins(out: &mut String, boards: &[Board]) { out.push('\n'); } +/// One `#[cfg]`-guarded `type B` alias inside `select_board!`. +const SELECT_BOARD_ARM: &str = r#" #[cfg(feature = "{feature}")] + type B = $crate::{struct_name}; +"#; + fn gen_select_board(out: &mut String, boards: &[Board]) { + // Build the per-board `type B = ...` aliases. Each is guarded by its + // `#[cfg(feature = ...)]` so only the active board's alias is compiled. let mut arms = String::new(); - for (i, b) in boards.iter().enumerate() { - let kw = if i == 0 { "if" } else { "else if" }; - writeln!( - arms, - " {kw} #[cfg(feature = \"{f}\")] {{\n\ - \x20 use $crate::{s} as B;\n\ - \x20 }}\n", - kw = kw, - f = b.feature, - s = b.struct_name, - ) - .unwrap(); + for b in boards { + arms.push_str( + &SELECT_BOARD_ARM + .replace("{feature}", &b.feature) + .replace("{struct_name}", &b.struct_name), + ); } - let rendered = SELECT_BOARD_TMPL.replace("{arms}", &arms); + // The fallback `not(any(...))` guard produces a compile_error if no board + // feature is selected. + let features: Vec = boards + .iter() + .map(|b| format!("feature = \"{}\"", b.feature)) + .collect(); + + let rendered = SELECT_BOARD_TMPL + .replace("{arms}", &arms) + .replace("{features}", &features.join(", ")); + out.push_str(&rendered); }