diff --git a/.cargo/config.toml b/.cargo/config.toml index d9f3cfb..907f2af 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -4,21 +4,29 @@ [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-esp32c5 = "build --release --target riscv32imac-unknown-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-esp32c5-devkitc" +build-esp32c61 = "build --release --target riscv32imac-unknown-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-esp32c61-devkitc" +build-esp32c6 = "build --release --target riscv32imac-unknown-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-esp32c6-devkitc" +build-esp32c6-generic = "build --release --target riscv32imac-unknown-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-esp32c6-generic" +build-esp32s2 = "build --profile esp32s2 --target xtensa-esp32s2-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-esp32-s2-saola -Z build-std=core,alloc" + +run-esp32c5 = "run --release --target riscv32imac-unknown-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-esp32c5-devkitc" +run-esp32c61 = "run --release --target riscv32imac-unknown-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-esp32c61-devkitc" +run-esp32c6 = "run --release --target riscv32imac-unknown-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-esp32c6-devkitc" +run-esp32c6-generic = "run --release --target riscv32imac-unknown-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-esp32c6-generic" +run-esp32s2 = "run --profile esp32s2 --target xtensa-esp32s2-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-esp32-s2-saola" -run-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 +36,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..42acdd4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,14 +24,16 @@ 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: "esp32c5", board: "board-esp32c5-devkitc", target: "riscv32imac-unknown-none-elf", toolchain: "stable", buildstd: "", binlib: "--bin ssh-stamp-esp32" }, + { soc: "esp32c6", board: "board-esp32c6-devkitc", target: "riscv32imac-unknown-none-elf", toolchain: "stable", buildstd: "", binlib: "--bin ssh-stamp-esp32" }, + { soc: "esp32c61", board: "board-esp32c61-devkitc", target: "riscv32imac-unknown-none-elf", toolchain: "stable", buildstd: "", binlib: "--bin ssh-stamp-esp32" }, + { soc: "esp32c6", board: "board-esp32c6-generic", target: "riscv32imac-unknown-none-elf", toolchain: "stable", buildstd: "", binlib: "--bin ssh-stamp-esp32" }, # Xtensa devices: - { soc: "esp32", 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 +55,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..cf78740 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1017,7 +1017,9 @@ dependencies = [ "esp32", "esp32c2", "esp32c3", + "esp32c5", "esp32c6", + "esp32c61", "esp32h2", "esp32s2", "esp32s3", @@ -1076,13 +1078,17 @@ dependencies = [ "esp-wifi-sys-esp32", "esp-wifi-sys-esp32c2", "esp-wifi-sys-esp32c3", + "esp-wifi-sys-esp32c5", "esp-wifi-sys-esp32c6", + "esp-wifi-sys-esp32c61", "esp-wifi-sys-esp32s2", "esp-wifi-sys-esp32s3", "esp32", "esp32c2", "esp32c3", + "esp32c5", "esp32c6", + "esp32c61", "esp32s2", "esp32s3", "log", @@ -1113,6 +1119,10 @@ dependencies = [ "document-features", "embassy-net-driver", "embassy-sync 0.8.0", + "embedded-io 0.6.1", + "embedded-io 0.7.1", + "embedded-io-async 0.6.1", + "embedded-io-async 0.7.0", "enumset", "esp-alloc", "esp-config", @@ -1126,14 +1136,18 @@ dependencies = [ "esp-wifi-sys-esp32", "esp-wifi-sys-esp32c2", "esp-wifi-sys-esp32c3", + "esp-wifi-sys-esp32c5", "esp-wifi-sys-esp32c6", + "esp-wifi-sys-esp32c61", "esp-wifi-sys-esp32h2", "esp-wifi-sys-esp32s2", "esp-wifi-sys-esp32s3", "esp32", "esp32c2", "esp32c3", + "esp32c5", "esp32c6", + "esp32c61", "esp32s2", "esp32s3", "heapless 0.9.3", @@ -1180,7 +1194,9 @@ dependencies = [ "esp32", "esp32c2", "esp32c3", + "esp32c5", "esp32c6", + "esp32c61", "esp32s2", "esp32s3", ] @@ -1282,6 +1298,12 @@ dependencies = [ "log", ] +[[package]] +name = "esp-wifi-sys-esp32c5" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "039337bdc50a40335ed01598e83053164426fed82817492719513d922dcc3592" + [[package]] name = "esp-wifi-sys-esp32c6" version = "0.2.0" @@ -1291,6 +1313,12 @@ dependencies = [ "log", ] +[[package]] +name = "esp-wifi-sys-esp32c61" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac5e79401e84dc2715569271782fb4c34059e175e4835c2f3f92ff8df5e519c8" + [[package]] name = "esp-wifi-sys-esp32h2" version = "0.2.0" @@ -1345,6 +1373,15 @@ dependencies = [ "vcell", ] +[[package]] +name = "esp32c5" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6f8ba56195df10224263c60ceb93b4578450019ee838b7338039a281117cb02" +dependencies = [ + "vcell", +] + [[package]] name = "esp32c6" version = "0.23.2" @@ -1354,6 +1391,15 @@ dependencies = [ "vcell", ] +[[package]] +name = "esp32c61" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a926616351b1edfdb50b7ea6451a8dff0c7515c6ceb687063af7d32b117ec0d" +dependencies = [ + "vcell", +] + [[package]] name = "esp32h2" version = "0.19.2" @@ -1936,7 +1982,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 +2194,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 +2452,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 +2643,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 +2673,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 +2694,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 +2705,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 +2900,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..d21857c 100644 --- a/README.md +++ b/README.md @@ -37,13 +37,12 @@ Build/flash for your board using the short command pattern (replace `` w | `esp32` | `xtensa-esp32-none-elf` | | `esp32c2` | `riscv32imc-unknown-none-elf` | | `esp32c3` | `riscv32imc-unknown-none-elf` | +| `esp32c5` | `riscv32imac-unknown-none-elf` | | `esp32c6` | `riscv32imac-unknown-none-elf` | +| `esp32c61` | `riscv32imac-unknown-none-elf` | | `esp32s2` | `xtensa-esp32s2-none-elf` | | `esp32s3` | `xtensa-esp32s3-none-elf` | - - - ``` rustup target add cargo build- # e.g. cargo build-esp32c6, cargo build-esp32c3, cargo build-esp32 @@ -112,22 +111,37 @@ export SSH_STAMP_WIFI_STA_SSID="" ssh -o SendEnv=SSH_STAMP_WIFI_STA_SSID root@192.168.4.1 ``` +- To select the WiFi band (ESP32-C5 only; ignored on other chips): +``` +export SSH_STAMP_WIFI_BAND="5g" +ssh -o SendEnv=SSH_STAMP_WIFI_BAND root@192.168.4.1 +``` +Accepts `2.4g` (default), `5g`, or `auto`. The device resets after applying the change. + Notes: - `SSH_STAMP_PUBKEY` is accepted on first-boot to add the initial admin key. - `SSH_STAMP_WIFI_AP_SSID` and `SSH_STAMP_WIFI_AP_PSK` may be applied while authenticated via pubkey (or on first-boot). After a successful change the device persists the settings and performs a software reset so the new WiFi settings take effect. +- `SSH_STAMP_WIFI_BAND` selects the AP radio band. Only the ESP32-C5 supports 5GHz; other chips ignore the setting and stay on 2.4GHz. - If you prefer a single-step provisioning, export all three env vars locally and forward them with `SendEnv` in the same SSH invocation. If your SSH client doesn't forward environment variables by default, use the `-o SendEnv=VAR` option as shown above or configure `SendEnv` in your SSH client config. # 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/ota/src/handler.rs b/ota/src/handler.rs index 5c77b33..f173a3c 100644 --- a/ota/src/handler.rs +++ b/ota/src/handler.rs @@ -134,7 +134,7 @@ impl UpdateProcessor { debug!( "Decoding TLV from tlv_holder: {:?}, current_len: {}", - &self.tlv_holder, &self.current_len + self.tlv_holder, self.current_len ); let mut singular_source = tlv::TlvsSource::new(&self.tlv_holder[..self.current_len]); diff --git a/ota/src/sftpserver.rs b/ota/src/sftpserver.rs index f93da62..64186b6 100644 --- a/ota/src/sftpserver.rs +++ b/ota/src/sftpserver.rs @@ -143,7 +143,7 @@ impl SftpServer for SftpOt self.file_handle = Some(handle.clone()); info!( "SftpServer Open operation: path = {:?}, write_permission = {:?}, handle = {:?}", - path, self.write_permission, &handle + path, self.write_permission, handle ); Ok(handle) } else { diff --git a/src/app.rs b/src/app.rs index 9ce4833..6d712d6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -18,7 +18,7 @@ use embassy_sync::{blocking_mutex::raw::NoopRawMutex, channel::Channel}; use heapless::String; use log::{debug, error, info, warn}; use ssh_key::HashAlg; -use ssh_stamp_hal::WifiApConfigStatic; +use ssh_stamp_hal::{BandMode, WifiApConfigStatic}; use sunset::SignKey; use sunset_async::SunsetMutex; @@ -72,12 +72,22 @@ pub async fn prepare_ap_config( print_hostkey_fingerprint(&guard.hostkey); + // Resolve band mode from the stored u8 (0=2.4G, 1=5G, 2=Auto). + // 5GHz is only available on the ESP32-C5; other chips silently fall + // back to 2.4GHz at the radio level. + let band = BandMode::from(guard.wifi_ap_band); + // Channel 1 for 2.4GHz, channel 36 for 5GHz/Auto (esp-radio default). + let channel = if guard.wifi_ap_band == 0 { 1 } else { 36 }; + + info!("WIFI AP band: {band:?} (channel {channel})"); + Ok(WifiApConfigStatic { ap_ssid: guard.wifi_ap_ssid.clone(), ap_password: guard.wifi_ap_pw.clone(), sta_ssid: guard.wifi_sta_ssid.clone(), sta_password: guard.wifi_sta_pw.clone(), - channel: 1, + channel, + band, mac, }) } diff --git a/src/config.rs b/src/config.rs index 8a3c239..ac5c423 100644 --- a/src/config.rs +++ b/src/config.rs @@ -48,6 +48,8 @@ pub struct SSHStampConfig { /// Access Point Mode pub wifi_ap_ssid: String<32>, pub wifi_ap_pw: String<63>, + /// AP band mode (2.4GHz / 5GHz / Auto). Ignored on chips without 5GHz. + pub wifi_ap_band: u8, /// Station Mode pub wifi_sta_ssid: String<32>, pub wifi_sta_pw: String<63>, @@ -82,7 +84,7 @@ const MAC_RANDOM_SENTINEL: [u8; 6] = [0xFF; 6]; impl SSHStampConfig { /// Bump this when the format changes - pub const CURRENT_VERSION: u8 = 10; + pub const CURRENT_VERSION: u8 = 11; /// Check if configured for random MAC on each boot #[must_use] @@ -118,6 +120,7 @@ impl SSHStampConfig { // Wifi Access Point Mode let wifi_ap_ssid = Self::generate_wifi_ssid()?; let wifi_ap_pw = Self::generate_wifi_password()?; + let wifi_ap_band = 0; // BandMode::Band2_4G (default) // Wifi Station Mode let wifi_sta_ssid = String::<32>::new(); let wifi_sta_pw = String::<63>::new(); @@ -133,6 +136,7 @@ impl SSHStampConfig { pubkeys: Default::default(), wifi_ap_ssid, wifi_ap_pw, + wifi_ap_band, wifi_sta_ssid, wifi_sta_pw, mac, @@ -249,7 +253,7 @@ fn enc_ipv4_config(v: Option<&StaticConfigV4>, s: &mut dyn SSHSink) -> WireResul v.is_some().enc(s)?; if let Some(v) = v { v.address.address().to_bits().enc(s)?; - debug!("enc_ipv4_config: prefix = {}", &v.address.prefix_len()); + debug!("enc_ipv4_config: prefix = {}", v.address.prefix_len()); v.address.prefix_len().enc(s)?; // to u32 let gw = v.gateway.as_ref().map(|g| g.to_bits()); @@ -332,6 +336,7 @@ impl SSHEncode for SSHStampConfig { // Wifi Access Point Mode self.wifi_ap_ssid.as_str().enc(s)?; self.wifi_ap_pw.as_str().enc(s)?; + self.wifi_ap_band.enc(s)?; // Wifi Station Mode self.wifi_sta_ssid.as_str().enc(s)?; self.wifi_sta_pw.as_str().enc(s)?; @@ -369,6 +374,7 @@ impl<'de> SSHDecode<'de> for SSHStampConfig { let wifi_ap_ssid = String::try_from(wifi_ap_ssid_str).map_err(|_| WireError::BadString)?; let wifi_ap_pw_str: &str = SSHDecode::dec(s)?; let wifi_ap_pw = String::try_from(wifi_ap_pw_str).map_err(|_| WireError::BadString)?; + let wifi_ap_band: u8 = SSHDecode::dec(s)?; // Wifi Station Mode let wifi_sta_ssid_str: &str = SSHDecode::dec(s)?; let wifi_sta_ssid = @@ -395,6 +401,7 @@ impl<'de> SSHDecode<'de> for SSHStampConfig { pubkeys, wifi_ap_ssid, wifi_ap_pw, + wifi_ap_band, wifi_sta_ssid, wifi_sta_pw, mac, diff --git a/src/handle.rs b/src/handle.rs index 6f8c130..35ecfc2 100644 --- a/src/handle.rs +++ b/src/handle.rs @@ -31,6 +31,7 @@ use sunset_async::{ChanInOut, SSHServer, SunsetMutex}; pub mod env_parser { use super::String; + use core::str::FromStr; /// Limit the maximum length accepted for an SSH key, Ed25519 lines /// should be less than this. @@ -129,6 +130,17 @@ pub mod env_parser { } Some([parts[0], parts[1], parts[2], parts[3], parts[4], parts[5]]) } + + /// Parses a `WiFi` band mode from an environment variable value. + /// + /// Accepts: `"2.4g"`, `"5g"`, `"auto"` (case-insensitive). + /// Returns the band as a `u8`: 0 = 2.4GHz, 1 = 5GHz, 2 = Auto. + #[must_use] + pub fn parse_wifi_band(value: &str) -> Option { + ssh_stamp_hal::BandMode::from_str(value) + .ok() + .map(|band| band as u8) + } } #[derive(Debug)] @@ -163,7 +175,7 @@ pub fn session_subsystem( a.fail()?; } else if a.command()?.to_lowercase().as_str() == "sftp" { if let Some(ch) = ctx.session.take() { - debug_assert!(ch.num() == a.channel()); + debug_assert_eq!(ch.num(), a.channel()); #[cfg(feature = "sftp-ota")] { a.succeed()?; @@ -220,7 +232,7 @@ pub async fn session_shell( platform.reset(); } } - debug_assert!(ch.num() == a.channel()); + debug_assert_eq!(ch.num(), a.channel()); a.succeed()?; debug!("We got shell"); platform.activate_uart(); @@ -382,6 +394,9 @@ pub async fn session_env( "SSH_STAMP_WIFI_AP_PSK" => { wifi_ap_psk_env(a, config, ctx).await?; } + "SSH_STAMP_WIFI_BAND" => { + wifi_band_env(a, config, ctx).await?; + } "SSH_STAMP_WIFI_STA_SSID" => { wifi_sta_ssid_env(a, config, ctx).await?; } @@ -500,6 +515,38 @@ pub async fn wifi_ap_psk_env( Ok(()) } +/// Handles `SSH_STAMP_WIFI_BAND` environment variable requests. +/// +/// Accepts `2.4g`, `5g`, or `auto` (case-insensitive). Only the ESP32-C5 +/// supports 5GHz; other chips will ignore the setting at runtime. +/// Triggers a config save + reset on success. +/// +/// # Errors +/// Returns an error if SSH protocol operations fail. +pub async fn wifi_band_env( + a: sunset::event::ServEnvironmentRequest<'_, '_>, + config: &SunsetMutex, + ctx: &mut EventContext<'_>, +) -> Result<(), sunset::Error> { + let mut config_guard = config.lock().await; + if *ctx.auth_checked || config_guard.first_login { + if let Some(band) = env_parser::parse_wifi_band(a.value()?) { + config_guard.wifi_ap_band = band; + debug!("Set WIFI AP band from ENV: {band}"); + a.succeed()?; + *ctx.config_changed = true; + *ctx.needs_reset = true; + } else { + warn!("SSH_STAMP_WIFI_BAND must be 2.4g, 5g, or auto"); + a.fail()?; + } + } else { + warn!("SSH_STAMP_WIFI_BAND env received but not authenticated; rejecting"); + a.fail()?; + } + Ok(()) +} + /// Handles `SSH_STAMP_WIFI_STA_SSID` environment variable requests. /// /// # Errors diff --git a/src/serve.rs b/src/serve.rs index 0617639..e35282a 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -52,7 +52,7 @@ pub async fn connection_loop( let mut ph = ProgressHolder::new(); let ev = serv.progress(&mut ph).await?; - trace!("{:?}", &ev); + trace!("{ev:?}"); let mut ctx = EventContext { session: &mut session, diff --git a/src/store.rs b/src/store.rs index b6b44a3..4382b70 100644 --- a/src/store.rs +++ b/src/store.rs @@ -97,7 +97,7 @@ where { let c = SSHStampConfig::new(default_mac, default_uart_pins)?; save(flash, buf, &c)?; - debug!("Created new config: {:?}", &c); + debug!("Created new config: {c:?}"); Ok(c) } @@ -157,9 +157,9 @@ where hash: config_hash(config)?, }; - debug!("Before write_ssh, with hash: {}", &sc.hash.hex_dump()); + debug!("Before write_ssh, with hash: {}", sc.hash.hex_dump()); let l = sshwire::write_ssh(buf, &sc)?; - debug!("Saved flash (after write_ssh): {}", &buf[..l].hex_dump()); + debug!("Saved flash (after write_ssh): {}", buf[..l].hex_dump()); debug!( "CONFIG_OFFSET + FlashConfig::BUF_SIZE = {}", diff --git a/ssh-stamp-esp32-boards/Cargo.toml b/ssh-stamp-esp32-boards/Cargo.toml new file mode 100644 index 0000000..4d5c1f6 --- /dev/null +++ b/ssh-stamp-esp32-boards/Cargo.toml @@ -0,0 +1,26 @@ +# 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-esp32c5-devkitc = [] +board-esp32c61-devkitc = [] +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/esp32c5-devkitc.toml b/ssh-stamp-esp32-boards/boards/esp32c5-devkitc.toml new file mode 100644 index 0000000..0e3ecdb --- /dev/null +++ b/ssh-stamp-esp32-boards/boards/esp32c5-devkitc.toml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2026 Roman Valls Guimera +# +# SPDX-License-Identifier: GPL-3.0-or-later + +# Espressif ESP32-C5-DevKitC-1 +url = "https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32c5/esp32-c5-devkitc-1/user_guide.html" + +[pins] +uart_rx = 8 +uart_tx = 9 \ 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/boards/esp32c61-devkitc.toml b/ssh-stamp-esp32-boards/boards/esp32c61-devkitc.toml new file mode 100644 index 0000000..b358a5e --- /dev/null +++ b/ssh-stamp-esp32-boards/boards/esp32c61-devkitc.toml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2026 Roman Valls Guimera +# +# SPDX-License-Identifier: GPL-3.0-or-later + +# Espressif ESP32-C61-DevKitC-1 +url = "https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32c61/esp32-c61-devkitc-1/user_guide.html" + +[pins] +uart_rx = 2 +uart_tx = 3 \ 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..67c0319 100644 --- a/ssh-stamp-esp32/Cargo.toml +++ b/ssh-stamp-esp32/Cargo.toml @@ -29,7 +29,7 @@ edge-nal-embassy = { workspace = true } heapless = { workspace = true } esp-hal = { workspace = true } -esp-radio = { version = "1.0.0-beta.0", features = ["wifi", "log-04"] } +esp-radio = { version = "1.0.0-beta.0", features = ["wifi", "log-04", "unstable"] } esp-storage = { version = "0.9" } esp-bootloader-esp-idf = { version = "0.5" } esp-alloc = { version = "0.10" } @@ -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,13 +53,27 @@ 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-esp32c5-devkitc = ["dep:ssh-stamp-esp32-boards", "ssh-stamp-esp32-boards/board-esp32c5-devkitc", "esp32c5"] +board-esp32c61-devkitc = ["dep:ssh-stamp-esp32-boards", "ssh-stamp-esp32-boards/board-esp32c61-devkitc", "esp32c61"] +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"] +esp32c5 = ["esp-hal/esp32c5", "esp-radio/esp32c5", "esp-storage/esp32c5", "esp-bootloader-esp-idf/esp32c5", "esp-alloc/esp32c5", "esp-backtrace/esp32c5", "esp-rtos/esp32c5", "esp-println/esp32c5"] esp32c6 = ["esp-hal/esp32c6", "esp-radio/esp32c6", "esp-storage/esp32c6", "esp-bootloader-esp-idf/esp32c6", "esp-alloc/esp32c6", "esp-backtrace/esp32c6", "esp-rtos/esp32c6", "esp-println/esp32c6"] +esp32c61 = ["esp-hal/esp32c61", "esp-radio/esp32c61", "esp-storage/esp32c61", "esp-bootloader-esp-idf/esp32c61", "esp-alloc/esp32c61", "esp-backtrace/esp32c61", "esp-rtos/esp32c61", "esp-println/esp32c61"] esp32s2 = ["esp-hal/esp32s2", "esp-radio/esp32s2", "esp-storage/esp32s2", "esp-bootloader-esp-idf/esp32s2", "esp-alloc/esp32s2", "esp-backtrace/esp32s2", "esp-rtos/esp32s2", "esp-println/esp32s2"] esp32s3 = ["esp-hal/esp32s3", "esp-radio/esp32s3", "esp-storage/esp32s3", "esp-bootloader-esp-idf/esp32s3", "esp-alloc/esp32s3", "esp-backtrace/esp32s3", "esp-rtos/esp32s3", "esp-println/esp32s3"] diff --git a/ssh-stamp-esp32/build.rs b/ssh-stamp-esp32/build.rs index b433827..be4c9cd 100644 --- a/ssh-stamp-esp32/build.rs +++ b/ssh-stamp-esp32/build.rs @@ -5,4 +5,6 @@ fn main() { println!("cargo:rustc-link-arg-bins=-Tlinkall.x"); + // esp-radio sets this cfg on 5GHz-capable chips (ESP32-C5). + println!("cargo:rustc-check-cfg=cfg(wifi_has_5g)"); } diff --git a/ssh-stamp-esp32/src/bin/ssh-stamp-esp32.rs b/ssh-stamp-esp32/src/bin/ssh-stamp-esp32.rs index ee6e753..ed71946 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] @@ -37,6 +27,7 @@ extern crate alloc; use embassy_executor::Spawner; use esp_hal::interrupt::{Priority, software::SoftwareInterruptControl}; +#[cfg(not(any(feature = "esp32c5", feature = "esp32c61")))] use esp_hal::rng::{Trng, TrngSource}; use esp_println::logger; use esp_rtos::embassy::InterruptExecutor; @@ -50,6 +41,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; @@ -86,11 +78,23 @@ async fn main(spawner: Spawner) -> ! { // cryptographically secure random values. // See: https://github.com/brainstorm/ssh-stamp/issues/10 // See: https://github.com/esp-rs/esp-hal/pull/3829 - let trng_source = TrngSource::new(peripherals.RNG, peripherals.ADC1); - let trng = Trng::try_new().unwrap(); - let rng = trng.downgrade(); - register_custom_rng(rng); - drop(trng_source); + // + // TODO: The ESP32-C5/C61 TRNG is not yet available in esp-hal 1.1.1. Once + // https://github.com/esp-rs/esp-hal/pull/4978 lands in a release, remove + // this cfg_if! and use Trng/TrngSource unconditionally for all targets. + cfg_if::cfg_if! { + if #[cfg(any(feature = "esp32c5", feature = "esp32c61"))] { + // ESP32-C5/C61 have no TRNG peripheral — use the basic Rng directly. + let rng = esp_hal::rng::Rng::new(); + register_custom_rng(rng); + } else { + let trng_source = TrngSource::new(peripherals.RNG, peripherals.ADC1); + let trng = Trng::try_new().unwrap(); + let rng = trng.downgrade(); + register_custom_rng(rng); + drop(trng_source); + } + } debug!("Initialising flash"); flash::init(peripherals.FLASH); @@ -103,38 +107,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 = { @@ -175,8 +162,10 @@ async fn main(spawner: Spawner) -> ! { cfg_if::cfg_if! { if #[cfg(any(feature = "esp32", feature = "esp32s2", feature = "esp32s3"))] { let interrupt_spawner = interrupt_executor.start(Priority::Priority3); - } else { + } else if #[cfg(feature = "esp32c6")] { let interrupt_spawner = interrupt_executor.start(Priority::Priority10); + } else { + let interrupt_spawner = interrupt_executor.start(Priority::Priority1); } } interrupt_spawner diff --git a/ssh-stamp-esp32/src/network/wifi.rs b/ssh-stamp-esp32/src/network/wifi.rs index a6948e7..7cad3f9 100644 --- a/ssh-stamp-esp32/src/network/wifi.rs +++ b/ssh-stamp-esp32/src/network/wifi.rs @@ -30,13 +30,15 @@ use embassy_time::{Duration, Timer}; use esp_hal::peripherals::WIFI; use esp_hal::rng::Rng; use esp_radio::wifi::{ - AuthenticationMethod, Config as RadioConfig, ControllerConfig, Interface, WifiController, - ap::AccessPointConfig, ap::EventInfo, sta::StationConfig, + AuthenticationMethod, BandMode as RadioBandMode, Config as RadioConfig, ControllerConfig, + Interface, WifiController, ap::AccessPointConfig, ap::EventInfo, sta::StationConfig, }; use log::info; use log::{debug, error, warn}; use ssh_stamp::settings::STATION_MODE_MAX_RETRY_SECONDS; -use ssh_stamp_hal::{HalError, NetworkProviderHal, WifiApConfigStatic, WifiError, WifiHal}; +use ssh_stamp_hal::{ + BandMode, HalError, NetworkProviderHal, WifiApConfigStatic, WifiError, WifiHal, +}; use static_cell::StaticCell; extern crate alloc; @@ -98,42 +100,19 @@ impl NetworkProviderHal for EspWifi { .map_err(|_| HalError::Wifi(WifiError::Initialization))?; let sta_ssid_static: &'static str = STA_SSID_CELL.init(ap_config.sta_ssid.clone()).as_str(); - let ap_radio_config; - let net_config; - let wifi_interface; - - if sta_ssid_static.is_empty() { - info!("Wifi configuring Access Point Mode"); - let password = AllocString::from(ap_config.ap_password.as_str()); - ap_radio_config = RadioConfig::AccessPoint( - AccessPointConfig::default() - .with_ssid(AllocString::from(ap_config.ap_ssid.as_str())) - .with_auth_method(AuthenticationMethod::Wpa2Wpa3Personal) - .with_password(password.clone()), - ); - net_config = embassy_net::Config::ipv4_static(StaticConfigV4 { - address: Ipv4Cidr::new(self.gateway, 24), - gateway: Some(self.gateway), - // The embassy-net heapless version is different so `Default::default()` must be used here. - dns_servers: Default::default(), - }); - wifi_interface = Interface::access_point(); - } else { - info!("Wifi configuring Station Mode"); - let password = AllocString::from(ap_config.sta_password.as_str()); - ap_radio_config = RadioConfig::Station( - StationConfig::default() - .with_ssid(AllocString::from(ap_config.sta_ssid.as_str())) - .with_password(password.clone()), - ); - net_config = embassy_net::Config::dhcpv4(DhcpConfig::default()); - wifi_interface = Interface::station(); - } + let (ap_radio_config, net_config, wifi_interface) = + build_radio_config(&ap_config, sta_ssid_static, self.gateway); let controller_config = ControllerConfig::default().with_initial_config(ap_radio_config); - let wifi_controller = WifiController::new(wifi_peri, controller_config) + let mut wifi_controller = WifiController::new(wifi_peri, controller_config) .map_err(|_| HalError::Wifi(WifiError::Initialization))?; + // Set the WiFi band mode (AP mode only). Only the ESP32-C5 supports + // 5GHz; other chips ignore the setting. + if sta_ssid_static.is_empty() { + set_band_mode(&mut wifi_controller, ap_config.band); + } + let seed = u64::from(self.rng.random()) << 32 | u64::from(self.rng.random()); let (ap_stack, runner) = embassy_net::new( @@ -194,6 +173,62 @@ impl NetworkProviderHal for EspWifi { } } +/// Build the esp-radio config, embassy-net config, and interface for AP or +/// Station mode based on whether a Station SSID is configured. +fn build_radio_config( + ap_config: &WifiApConfigStatic, + sta_ssid: &str, + gateway: Ipv4Addr, +) -> (RadioConfig, embassy_net::Config, Interface) { + if sta_ssid.is_empty() { + info!("Wifi configuring Access Point Mode"); + let password = AllocString::from(ap_config.ap_password.as_str()); + let radio = RadioConfig::AccessPoint( + AccessPointConfig::default() + .with_ssid(AllocString::from(ap_config.ap_ssid.as_str())) + .with_auth_method(AuthenticationMethod::Wpa2Wpa3Personal) + .with_password(password) + .with_channel(ap_config.channel), + ); + let net = embassy_net::Config::ipv4_static(StaticConfigV4 { + address: Ipv4Cidr::new(gateway, 24), + gateway: Some(gateway), + dns_servers: Default::default(), + }); + (radio, net, Interface::access_point()) + } else { + info!("Wifi configuring Station Mode"); + let password = AllocString::from(ap_config.sta_password.as_str()); + let radio = RadioConfig::Station( + StationConfig::default() + .with_ssid(AllocString::from(ap_config.sta_ssid.as_str())) + .with_password(password), + ); + let net = embassy_net::Config::dhcpv4(DhcpConfig::default()); + (radio, net, Interface::station()) + } +} + +/// Set the `WiFi` band mode on the controller. Only the ESP32-C5 supports 5GHz; +/// on other chips `set_band_mode` returns an error that is logged and ignored. +fn set_band_mode(wifi_controller: &mut WifiController<'static>, band: BandMode) { + let radio_band = match band { + BandMode::Band2_4G => RadioBandMode::_2_4G, + // _5G and Auto only exist when wifi_has_5g cfg is set (ESP32-C5). + // On other chips, fall back to 2.4GHz. + #[cfg(wifi_has_5g)] + BandMode::Band5G => RadioBandMode::_5G, + #[cfg(wifi_has_5g)] + BandMode::Auto => RadioBandMode::Auto, + #[cfg(not(wifi_has_5g))] + _ => RadioBandMode::_2_4G, + }; + match wifi_controller.set_band_mode(radio_band.clone()) { + Ok(()) => debug!("Set WiFi band mode: {radio_band:?}"), + Err(e) => warn!("Failed to set band mode {radio_band:?}: {e:?} (non-5G chip?)"), + } +} + /// Accept an incoming TCP connection on port 22. /// Returns a connected `TcpSocket` ready for SSH processing. /// diff --git a/ssh-stamp-hal/src/config.rs b/ssh-stamp-hal/src/config.rs index a7fff5f..b094d24 100644 --- a/ssh-stamp-hal/src/config.rs +++ b/ssh-stamp-hal/src/config.rs @@ -6,6 +6,7 @@ //! Hardware configuration types. +use core::str::FromStr; use heapless::String; /// UART peripheral configuration. @@ -35,6 +36,57 @@ impl Default for UartConfig { } } +/// `WiFi` band mode for the access point. +/// +/// Selects whether the AP operates on 2.4GHz, 5GHz, or both. +/// Only the ESP32-C5 supports 5GHz; other chips ignore the setting +/// and always operate on 2.4GHz. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum BandMode { + /// 2.4 GHz only (default, supported by all ESP32 variants). + #[default] + Band2_4G, + /// 5 GHz only (ESP32-C5 only). + Band5G, + /// Dual-band 2.4 GHz + 5 GHz (ESP32-C5 only). + Auto, +} + +impl FromStr for BandMode { + type Err = (); + + /// Parses a `WiFi` band mode from a string value. + /// + /// Accepts `"2.4g"`, `"2g"`, `"24g"`, `"5g"`, or `"auto"` (case-insensitive). + fn from_str(value: &str) -> Result { + if value.eq_ignore_ascii_case("2.4g") + || value.eq_ignore_ascii_case("2g") + || value.eq_ignore_ascii_case("24g") + { + Ok(Self::Band2_4G) + } else if value.eq_ignore_ascii_case("5g") { + Ok(Self::Band5G) + } else if value.eq_ignore_ascii_case("auto") { + Ok(Self::Auto) + } else { + Err(()) + } + } +} + +impl From for BandMode { + /// Resolves a `BandMode` from its on-wire `u8` representation. + /// + /// Unknown values fall back to `Band2_4G` (the default). + fn from(value: u8) -> Self { + match value { + 1 => Self::Band5G, + 2 => Self::Auto, + _ => Self::Band2_4G, + } + } +} + /// `WiFi` access point configuration. /// /// Contains settings for running the device as a `WiFi` access point. @@ -49,8 +101,10 @@ pub struct WifiApConfigStatic { /// which is not something we want to support. pub ap_password: String<63>, pub sta_password: String<63>, - /// `WiFi` channel (1-14 for 2.4GHz). + /// `WiFi` channel (1-14 for 2.4GHz, 36+ for 5GHz). pub channel: u8, + /// `WiFi` band mode (2.4GHz / 5GHz / Auto). Ignored on chips without 5GHz. + pub band: BandMode, /// MAC address for the access point interface. pub mac: [u8; 6], } @@ -63,6 +117,7 @@ impl Default for WifiApConfigStatic { sta_ssid: String::new(), sta_password: String::new(), channel: 1, + band: BandMode::default(), mac: [0; 6], } } diff --git a/ssh-stamp-hal/src/lib.rs b/ssh-stamp-hal/src/lib.rs index 7165308..2bf6261 100644 --- a/ssh-stamp-hal/src/lib.rs +++ b/ssh-stamp-hal/src/lib.rs @@ -66,6 +66,6 @@ pub mod config; pub mod error; pub mod traits; -pub use config::{UartConfig, WifiApConfigStatic}; +pub use config::{BandMode, UartConfig, WifiApConfigStatic}; pub use error::{FlashError, HalError, HashError, UartError, WifiError}; pub use traits::*;