diff --git a/.cargo/config.toml b/.cargo/config.toml index d9f3cfb..bf62d2f 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -4,21 +4,25 @@ [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. -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" +# 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" -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" +# 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" @@ -28,7 +32,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..3f17dbe 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: "", 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", 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", 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,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 ${{ matrix.device.binlib }} --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..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" @@ -2397,11 +2406,20 @@ 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" +dependencies = [ + "serde", + "toml", +] + [[package]] name = "ssh-stamp-hal" version = "0.2.0" @@ -2579,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" @@ -2588,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" @@ -2595,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]] @@ -2606,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" @@ -2795,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/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..af6303f 100644 --- a/README.md +++ b/README.md @@ -121,13 +121,20 @@ 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 `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. + +To see the available boards and their pin assignments, 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 auto-generated 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..639e4ee --- /dev/null +++ b/ssh-stamp-esp32-boards/Cargo.toml @@ -0,0 +1,24 @@ +# 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 = [] + +[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..f42f9d6 --- /dev/null +++ b/ssh-stamp-esp32-boards/build.rs @@ -0,0 +1,355 @@ +// 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; +use std::fmt::Write as _; +use std::fs; +use std::path::{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]` 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. +/// +/// 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 +/// +/// Compile-time error if no board feature is selected. +#[macro_export] +macro_rules! select_board { + () => { +{arms} #[cfg(not(any({features})))] + 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 { + name: String, + struct_name: String, + feature: String, + url: Option, + uart_rx: u8, + uart_tx: u8, +} + +/// 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 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, + }); + } + + 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; + } + return Err(BuildError::MissingBoardDef { feature: requested }.into()); + } + } + Ok(()) +} + +/// 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]) -> Result { + 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); + + Ok(out) +} + +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), + None => format!("/// Board: {}.", b.name), + }; + writeln!( + out, + "{doc}\npub struct {s};\nimpl Board for {s} {{\n const NAME: &str = \"{n}\";\n}}", + s = b.struct_name, + n = b.name, + )?; + out.push('\n'); + } + Ok(()) +} + +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!( + out, + "/// | `{}` | {} | {} | {} |", + b.feature, b.uart_rx, b.uart_tx, url, + )?; + } + 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 { + 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)) + .collect(); + + let rendered = TAKE_UART_PINS_TMPL + .replace("{branches}", &branches) + .replace("{features}", &features.join(", ")); + + out.push_str(&rendered); + 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 b in boards { + arms.push_str( + &SELECT_BOARD_ARM + .replace("{feature}", &b.feature) + .replace("{struct_name}", &b.struct_name), + ); + } + + // 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); +} diff --git a/ssh-stamp-esp32-boards/src/lib.rs b/ssh-stamp-esp32-boards/src/lib.rs new file mode 100644 index 0000000..3d9456b --- /dev/null +++ b/ssh-stamp-esp32-boards/src/lib.rs @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2026 Roman Valls Guimera +// +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Board Support Package for ssh-stamp-esp32. +//! +//! 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 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 +//! +//! See the [`board_catalog`] module for the generated table. + +#![no_std] + +/// Board identification trait. +/// +/// 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; +} + +include!(concat!(env!("OUT_DIR"), "/boards_gen.rs")); 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..05beb53 100644 --- a/ssh-stamp-esp32/src/bin/ssh-stamp-esp32.rs +++ b/ssh-stamp-esp32/src/bin/ssh-stamp-esp32.rs @@ -15,20 +15,10 @@ //! //! # 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 `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] @@ -50,6 +40,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,38 +94,21 @@ 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. - 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(), - }; - } else { - let uart_pins = UartPins { rx: 10, tx: 11 }; - let pins = EspUartPins { - rx: peripherals.GPIO10.into(), - tx: peripherals.GPIO11.into(), - }; - } - ); + // 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); + + 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 = {