diff --git a/.cargo/config.toml b/.cargo/config.toml index 6e53fe0..f686161 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -13,17 +13,17 @@ build-esp32c61 = "build --release --target riscv32imac-unknown-none-elf -p ssh-s build-esp32c6 = "build --release --target riscv32imac-unknown-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-esp32c6-devkitc" build-esp32c6-generic = "build --release --target riscv32imac-unknown-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-esp32c6-generic" build-esp32s2 = "build --profile esp32s2 --target xtensa-esp32s2-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-esp32-s2-saola -Z build-std=core,alloc" -build-esp32s3-touch-lcd-43 = "build --release --target xtensa-esp32s3-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-waveshare-esp32-s3-touch-lcd-43,can -Z build-std=core,alloc" +build-esp32s3-touch-lcd-43 = "build --release --target xtensa-esp32s3-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-waveshare-esp32-s3-touch-lcd-43,can,i2c -Z build-std=core,alloc" # Bench variant: CAN in no-acknowledge mode (exactly one frame per write with no ACKing node attached) -build-esp32s3-touch-lcd-43-noack = "build --release --target xtensa-esp32s3-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-waveshare-esp32-s3-touch-lcd-43,can-no-ack -Z build-std=core,alloc" +build-esp32s3-touch-lcd-43-noack = "build --release --target xtensa-esp32s3-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-waveshare-esp32-s3-touch-lcd-43,can-no-ack,i2c -Z build-std=core,alloc" run-esp32c5 = "run --release --target riscv32imac-unknown-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-esp32c5-devkitc" run-esp32c61 = "run --release --target riscv32imac-unknown-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-esp32c61-devkitc" run-esp32c6 = "run --release --target riscv32imac-unknown-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-esp32c6-devkitc" run-esp32c6-generic = "run --release --target riscv32imac-unknown-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-esp32c6-generic" run-esp32s2 = "run --profile esp32s2 --target xtensa-esp32s2-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-esp32-s2-saola" -run-esp32s3-touch-lcd-43 = "run --release --target xtensa-esp32s3-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-waveshare-esp32-s3-touch-lcd-43,can -Z build-std=core,alloc" -run-esp32s3-touch-lcd-43-noack = "run --release --target xtensa-esp32s3-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-waveshare-esp32-s3-touch-lcd-43,can-no-ack -Z build-std=core,alloc" +run-esp32s3-touch-lcd-43 = "run --release --target xtensa-esp32s3-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-waveshare-esp32-s3-touch-lcd-43,can,i2c -Z build-std=core,alloc" +run-esp32s3-touch-lcd-43-noack = "run --release --target xtensa-esp32s3-none-elf -p ssh-stamp-esp32 --bin ssh-stamp-esp32 --no-default-features --features board-waveshare-esp32-s3-touch-lcd-43,can-no-ack,i2c -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 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 80ef62d..171bdab 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,7 +34,7 @@ jobs: { soc: "esp32", board: "esp32", target: "xtensa-esp32-none-elf", toolchain: "esp", buildstd: "-Z build-std=core,alloc", binlib: "--lib" }, { soc: "esp32s2", board: "board-esp32-s2-saola", target: "xtensa-esp32s2-none-elf", toolchain: "esp", buildstd: "-Z build-std=core,alloc", binlib: "--bin ssh-stamp-esp32" }, { soc: "esp32s3", board: "esp32s3", target: "xtensa-esp32s3-none-elf", toolchain: "esp", buildstd: "-Z build-std=core,alloc", binlib: "--lib" }, - { soc: "esp32s3", board: "board-waveshare-esp32-s3-touch-lcd-43,can", target: "xtensa-esp32s3-none-elf", toolchain: "esp", buildstd: "-Z build-std=core,alloc", binlib: "--bin ssh-stamp-esp32" }, + { soc: "esp32s3", board: "board-waveshare-esp32-s3-touch-lcd-43,can,i2c", target: "xtensa-esp32s3-none-elf", toolchain: "esp", buildstd: "-Z build-std=core,alloc", binlib: "--bin ssh-stamp-esp32" }, ] steps: - name: Cache diff --git a/Cargo.toml b/Cargo.toml index 4a4a856..a9c7667 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -168,3 +168,6 @@ sftp-ota = [] # Enables CAN bus tunnelling over SSH via the "can" subsystem can = ["dep:embedded-can"] + +# Enables I2C master tunnelling over SSH via the "i2c" subsystem +i2c = [] diff --git a/src/app.rs b/src/app.rs index 12a4660..47e43e1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -19,13 +19,11 @@ use heapless::String; use log::{debug, error, info, warn}; use ssh_key::HashAlg; use ssh_stamp_hal::{BandMode, WifiApConfigStatic}; -#[cfg(feature = "can")] -use sunset::ChanHandle; use sunset::SignKey; use sunset_async::SunsetMutex; use crate::config::SSHStampConfig; -use crate::handle::{self, SessionType}; +use crate::handle::{self, SessionQueues, SessionType}; use crate::platform::PlatformServices; use crate::serial::BufferedSerial; use crate::serve; @@ -135,17 +133,9 @@ where let ssh_server = serve::ssh_wait_for_initialisation(&mut inbuf, &mut outbuf); let chan_pipe = Channel::::new(); - #[cfg(feature = "can")] - let can_queue = Channel::::new(); - #[cfg(feature = "can")] - let connection = - serve::connection_loop(&ssh_server, &chan_pipe, config, platform, &can_queue); - #[cfg(not(feature = "can"))] - let connection = serve::connection_loop(&ssh_server, &chan_pipe, config, platform); - #[cfg(feature = "can")] - let bridge = handle::ssh_client(uart, &ssh_server, &chan_pipe, platform, &can_queue); - #[cfg(not(feature = "can"))] - let bridge = handle::ssh_client(uart, &ssh_server, &chan_pipe, platform); + let queues = SessionQueues::new(); + let connection = serve::connection_loop(&ssh_server, &chan_pipe, config, platform, &queues); + let bridge = handle::ssh_client(uart, &ssh_server, &chan_pipe, platform, &queues); let (mut rsock, mut wsock) = tcp_socket.split(); let server = ssh_server.run(&mut rsock, &mut wsock); diff --git a/src/handle.rs b/src/handle.rs index 6236e2c..dbcfbb0 100644 --- a/src/handle.rs +++ b/src/handle.rs @@ -22,9 +22,16 @@ use crate::serial::{BufferedSerial, serial_bridge}; #[cfg(feature = "can")] use crate::can::can_bridge; +#[cfg(feature = "i2c")] +use crate::i2c::i2c_bridge; -#[cfg(feature = "can")] +#[cfg(all( + any(feature = "can", feature = "i2c"), + not(all(feature = "can", feature = "i2c")) +))] use embassy_futures::select::{Either, select}; +#[cfg(all(feature = "can", feature = "i2c"))] +use embassy_futures::select::{Either3, select3}; use embassy_sync::blocking_mutex::raw::NoopRawMutex; use embassy_sync::channel::Channel; @@ -155,22 +162,50 @@ pub enum SessionType { Sftp(ChanHandle), } +/// Hand-off channels from the connection loop to the per-subsystem bridge +/// futures in [`ssh_client`]. One slot per optional bridge subsystem; +/// empty when no bridge feature is enabled. +pub struct SessionQueues { + #[cfg(feature = "can")] + pub can: Channel, + #[cfg(feature = "i2c")] + pub i2c: Channel, +} + +impl SessionQueues { + #[must_use] + pub fn new() -> Self { + SessionQueues { + #[cfg(feature = "can")] + can: Channel::new(), + #[cfg(feature = "i2c")] + i2c: Channel::new(), + } + } +} + +impl Default for SessionQueues { + fn default() -> Self { + Self::new() + } +} + pub struct EventContext<'a> { pub session: &'a mut Option, pub auth_checked: &'a mut bool, pub config_changed: &'a mut bool, pub needs_reset: &'a mut bool, - /// Hands accepted `can` subsystem channels to the CAN bridge, which - /// runs concurrently with the shell (UART) session. - #[cfg(feature = "can")] - pub can_queue: &'a Channel, - /// Set once a CAN session is dispatched on this connection. SFTP (OTA) - /// needs the connection's full bandwidth, so it is refused afterwards. - #[cfg(all(feature = "sftp-ota", feature = "can"))] - pub can_dispatched: &'a mut bool, + /// Hands accepted bridge subsystem channels (CAN, I2C) to their + /// bridges, which run concurrently with the shell (UART) session. + pub queues: &'a SessionQueues, + /// Set once a bridge session is dispatched on this connection. SFTP + /// (OTA) needs the connection's full bandwidth, so it is refused + /// afterwards. + #[cfg(all(feature = "sftp-ota", any(feature = "can", feature = "i2c")))] + pub bridge_dispatched: &'a mut bool, } -/// Handles SSH session subsystem requests (e.g., SFTP, CAN). +/// Handles SSH session subsystem requests (e.g., SFTP, CAN, I2C). /// /// # Errors /// @@ -190,13 +225,13 @@ pub fn session_subsystem( #[cfg(feature = "sftp-ota")] { // SFTP (OTA) is exclusive: it needs the connection's full - // bandwidth, so refuse it once a CAN session is active. - #[cfg(feature = "can")] - let can_active = *ctx.can_dispatched; - #[cfg(not(feature = "can"))] - let can_active = false; - if can_active { - warn!("SFTP subsystem refused: a CAN session is active on this connection"); + // bandwidth, so refuse it once a bridge session is active. + #[cfg(any(feature = "can", feature = "i2c"))] + let bridge_active = *ctx.bridge_dispatched; + #[cfg(not(any(feature = "can", feature = "i2c")))] + let bridge_active = false; + if bridge_active { + warn!("SFTP subsystem refused: a bridge session is active on this connection"); a.fail()?; } else if let Some(ch) = ctx.session.take() { debug_assert_eq!(ch.num(), a.channel()); @@ -224,12 +259,12 @@ pub fn session_subsystem( // auth_checked is deliberately left untouched so the same // (already authenticated) connection can still request a // shell session and bridge UART concurrently with CAN. - if let Err(e) = ctx.can_queue.try_send(ch) { + if let Err(e) = ctx.queues.can.try_send(ch) { log::error!("Could not send the CAN channel: {e:?}"); } #[cfg(feature = "sftp-ota")] { - *ctx.can_dispatched = true; + *ctx.bridge_dispatched = true; } } else { a.fail()?; @@ -239,6 +274,29 @@ pub fn session_subsystem( warn!("CAN subsystem requested but not supported in this build"); a.fail()?; } + } else if a.command()?.to_lowercase().as_str() == "i2c" { + #[cfg(feature = "i2c")] + if let Some(ch) = ctx.session.take() { + debug_assert_eq!(ch.num(), a.channel()); + a.succeed()?; + debug!("We got I2C subsystem"); + // As with CAN, auth_checked is left untouched so shell and + // other bridge sessions can still be requested afterwards. + if let Err(e) = ctx.queues.i2c.try_send(ch) { + log::error!("Could not send the I2C channel: {e:?}"); + } + #[cfg(feature = "sftp-ota")] + { + *ctx.bridge_dispatched = true; + } + } else { + a.fail()?; + } + #[cfg(not(feature = "i2c"))] + { + warn!("I2C subsystem requested but not supported in this build"); + a.fail()?; + } } else { a.fail()?; } @@ -749,10 +807,10 @@ pub fn defunct() -> Result<(), sunset::Error> { /// Handles an SSH client connection, bridging UART and SSH. /// #[cfg_attr( - feature = "can", - doc = "A `can` subsystem channel is bridged concurrently with the shell", - doc = "(UART) session on the same connection. The whole connection is", - doc = "torn down when either bridge finishes.", + any(feature = "can", feature = "i2c"), + doc = "Bridge subsystem channels (CAN, I2C) are bridged concurrently", + doc = "with the shell (UART) session on the same connection. The whole", + doc = "connection is torn down when any bridge finishes.", doc = "" )] /// # Errors @@ -762,11 +820,12 @@ pub async fn ssh_client<'a, 'b, U, P>( ssh_server: &'b SSHServer<'a>, chan_pipe: &'b Channel, #[cfg_attr( - not(any(feature = "sftp-ota", feature = "can")), + not(any(feature = "sftp-ota", feature = "can", feature = "i2c")), allow(unused_variables) )] platform: &'b P, - #[cfg(feature = "can")] can_queue: &'b Channel, + #[cfg_attr(not(any(feature = "can", feature = "i2c")), allow(unused_variables))] + queues: &'b SessionQueues, ) -> Result<(), sunset::Error> where U: BufferedSerial, @@ -796,20 +855,38 @@ where }; #[cfg(feature = "can")] - let result = { - let can_session = async { - let ch = can_queue.receive().await; - info!("Handling CAN session"); - let chan_io: ChanInOut<'_> = ssh_server.stdio(ch).await?; - let (stdin, stdout) = chan_io.split(); - info!("Starting CAN bridge"); - can_bridge(stdin, stdout, platform.can()).await - }; - match select(session, can_session).await { - Either::First(r) | Either::Second(r) => r, - } + let can_session = async { + let ch = queues.can.receive().await; + info!("Handling CAN session"); + let chan_io: ChanInOut<'_> = ssh_server.stdio(ch).await?; + let (stdin, stdout) = chan_io.split(); + info!("Starting CAN bridge"); + can_bridge(stdin, stdout, platform.can()).await + }; + + #[cfg(feature = "i2c")] + let i2c_session = async { + let ch = queues.i2c.receive().await; + info!("Handling I2C session"); + let chan_io: ChanInOut<'_> = ssh_server.stdio(ch).await?; + let (stdin, stdout) = chan_io.split(); + info!("Starting I2C bridge"); + i2c_bridge(stdin, stdout, platform.i2c()).await + }; + + #[cfg(all(feature = "can", feature = "i2c"))] + let result = match select3(session, can_session, i2c_session).await { + Either3::First(r) | Either3::Second(r) | Either3::Third(r) => r, + }; + #[cfg(all(feature = "can", not(feature = "i2c")))] + let result = match select(session, can_session).await { + Either::First(r) | Either::Second(r) => r, + }; + #[cfg(all(feature = "i2c", not(feature = "can")))] + let result = match select(session, i2c_session).await { + Either::First(r) | Either::Second(r) => r, }; - #[cfg(not(feature = "can"))] + #[cfg(not(any(feature = "can", feature = "i2c")))] let result = session.await; result } diff --git a/src/i2c.rs b/src/i2c.rs new file mode 100644 index 0000000..fd770ca --- /dev/null +++ b/src/i2c.rs @@ -0,0 +1,290 @@ +// SPDX-FileCopyrightText: 2026 Roman Valls Guimera +// +// SPDX-License-Identifier: GPL-3.0-or-later + +//! I2C master tunnelling over SSH. +//! +//! Provides the framing layer for the SSH `i2c` subsystem, mirroring the +//! CAN design in [`can`](crate::can): the protocol (parser + response +//! encoding) lives here, platform crates only execute the bus operations. +//! Unlike CAN there is no unsolicited traffic — the device is the bus +//! master, so everything is request/response. +//! +//! # Wire protocol (ASCII, one command per `\r`/`\n`-terminated line) +//! +//! All numbers are hexadecimal; addresses are 7-bit. At most +//! [`I2C_DATA_MAX`] data bytes per transfer. +//! +//! | Command | Meaning | Reply | +//! |--------------|---------------------------------------------|-------| +//! | `s` | scan the bus (`0x08..=0x77`, 1-byte reads) | found addresses, space-separated (empty line if none) | +//! | `wAADD...` | write bytes `DD...` to address `AA` | `OK` | +//! | `rAALL` | read `LL` bytes from address `AA` | the bytes, hex | +//! | `xAALLDD...` | write `DD...`, repeated-start read `LL` | the bytes, hex | +//! +//! Failed transfers reply `NACK`, `TIMEOUT` or `ERR`; unparseable lines +//! reply `BADCMD`. Example: `w503A` writes `0x3A` to the device at `0x50`, +//! `x50021A` reads 2 bytes from register `0x1A` of the same device. + +use core::fmt::Write as _; +use core::future::Future; + +use embassy_futures::select::select; +use embedded_io_async::{Read, Write}; +use log::debug; + +/// Maximum data bytes in a single read or write transfer. +pub const I2C_DATA_MAX: usize = 32; + +/// First and last 7-bit addresses probed by a bus scan (the reserved +/// address ranges are skipped, as `i2cdetect` does). +pub const SCAN_FIRST: u8 = 0x08; +pub const SCAN_LAST: u8 = 0x77; + +/// Longest command line: `x` + 2 addr chars + 2 len chars + 64 data chars. +const LINE_SZ: usize = 5 + I2C_DATA_MAX * 2; + +/// Upper bound for one encoded reply: a full scan finds +/// `SCAN_LAST - SCAN_FIRST + 1` = 112 addresses at 3 chars each, plus the +/// line terminator. +pub const RESPONSE_MAX: usize = 112 * 3 + 2; + +/// Platform-agnostic buffered I2C master bridge. +/// +/// Pumps protocol bytes between the SSH channel and the target I2C +/// peripheral. Every platform provides a concrete type implementing this +/// trait (ESP32: `ssh_stamp_esp32::BufferedI2c`). +pub trait BufferedI2c: Sync { + fn read(&self, buf: &mut [u8]) -> impl Future; + fn write(&self, buf: &[u8]) -> impl Future; + + /// Start-of-session hook: drop half-parsed command state left by a + /// previous session and discard any stale buffered replies. + fn reset_protocol(&self); +} + +/// A decoded host command, ready for the platform to execute on the bus. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum I2cRequest { + /// Probe `SCAN_FIRST..=SCAN_LAST` and report responding addresses. + Scan, + /// Write `data` to `addr`. + Write { + addr: u8, + data: heapless::Vec, + }, + /// Read `len` bytes from `addr`. + Read { addr: u8, len: u8 }, + /// Write `data` to `addr`, then read `len` bytes with a repeated start. + WriteRead { + addr: u8, + data: heapless::Vec, + len: u8, + }, + /// The line did not parse; reply [`I2cResponse::BadCommand`]. + Malformed, +} + +/// Outcome of executing an [`I2cRequest`], encoded by [`encode_response`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum I2cResponse { + /// Write completed. + Ok, + /// Bytes returned by a read or write-read. + Data(heapless::Vec), + /// Addresses that acknowledged during a scan. + Scan(heapless::Vec), + /// The device did not acknowledge. + Nack, + /// The transfer timed out. + Timeout, + /// Any other bus error. + Error, + /// The command line did not parse. + BadCommand, +} + +/// Encode `resp` as one protocol line. Returns the number of bytes +/// written; `buf` must hold at least [`RESPONSE_MAX`] bytes. +pub fn encode_response(resp: &I2cResponse, buf: &mut [u8]) -> usize { + let mut s = heapless::String::::new(); + match resp { + I2cResponse::Ok => { + let _ = s.push_str("OK"); + } + I2cResponse::Data(data) => { + for &b in data { + let _ = write!(s, "{b:02X}"); + } + } + I2cResponse::Scan(addrs) => { + for (i, &a) in addrs.iter().enumerate() { + if i > 0 { + let _ = s.push(' '); + } + let _ = write!(s, "{a:02X}"); + } + } + I2cResponse::Nack => { + let _ = s.push_str("NACK"); + } + I2cResponse::Timeout => { + let _ = s.push_str("TIMEOUT"); + } + I2cResponse::Error => { + let _ = s.push_str("ERR"); + } + I2cResponse::BadCommand => { + let _ = s.push_str("BADCMD"); + } + } + let _ = s.push_str("\r\n"); + let len = s.len().min(buf.len()); + buf[..len].copy_from_slice(&s.as_bytes()[..len]); + len +} + +/// Byte-stream front end for the SSH `i2c` subsystem: accumulates +/// `\r`/`\n`-terminated lines (SSH reads arrive fragmented) and decodes +/// them into [`I2cRequest`]s. +pub struct I2cParser { + line: heapless::Vec, + /// The current line overflowed [`LINE_SZ`]; report it as malformed at + /// the next terminator instead of decoding a truncated command. + overflow: bool, +} + +impl I2cParser { + #[must_use] + pub fn new() -> Self { + I2cParser { + line: heapless::Vec::new(), + overflow: false, + } + } + + /// Drop any half-accumulated command line. + pub fn reset(&mut self) { + self.line.clear(); + self.overflow = false; + } + + /// Consume one host byte, returning a request once a line is complete. + pub fn feed(&mut self, byte: u8) -> Option { + if byte != b'\r' && byte != b'\n' { + if self.line.push(byte).is_err() { + self.overflow = true; + } + return None; + } + let request = if self.overflow { + Some(I2cRequest::Malformed) + } else if self.line.is_empty() { + None + } else { + Some(decode_line(&self.line)) + }; + self.reset(); + request + } +} + +impl Default for I2cParser { + fn default() -> Self { + Self::new() + } +} + +fn decode_line(line: &[u8]) -> I2cRequest { + match line[0] { + b's' if line.len() == 1 => I2cRequest::Scan, + b'w' => match (hex_byte(line, 1), hex_data(&line[3..])) { + (Some(addr), Some(data)) if addr <= 0x7F && !data.is_empty() => { + I2cRequest::Write { addr, data } + } + _ => I2cRequest::Malformed, + }, + b'r' if line.len() == 5 => match (hex_byte(line, 1), hex_byte(line, 3)) { + (Some(addr), Some(len)) + if addr <= 0x7F && len >= 1 && usize::from(len) <= I2C_DATA_MAX => + { + I2cRequest::Read { addr, len } + } + _ => I2cRequest::Malformed, + }, + b'x' => match (hex_byte(line, 1), hex_byte(line, 3), hex_data(&line[5..])) { + (Some(addr), Some(len), Some(data)) + if addr <= 0x7F + && len >= 1 + && usize::from(len) <= I2C_DATA_MAX + && !data.is_empty() => + { + I2cRequest::WriteRead { addr, data, len } + } + _ => I2cRequest::Malformed, + }, + _ => I2cRequest::Malformed, + } +} + +/// Parse the two hex chars at `line[at..at + 2]`. +fn hex_byte(line: &[u8], at: usize) -> Option { + let s = line.get(at..at + 2)?; + let s = core::str::from_utf8(s).ok()?; + u8::from_str_radix(s, 16).ok() +} + +/// Parse an even run of hex chars into bytes. +fn hex_data(chars: &[u8]) -> Option> { + if !chars.len().is_multiple_of(2) { + return None; + } + let mut data = heapless::Vec::new(); + for pair in chars.chunks_exact(2) { + let s = core::str::from_utf8(pair).ok()?; + data.push(u8::from_str_radix(s, 16).ok()?).ok()?; + } + Some(data) +} + +/// Forwards an incoming SSH I2C channel to/from the local I2C bus, until +/// the connection drops. +/// +/// # Errors +/// Returns an error if the SSH connection fails. +pub async fn i2c_bridge( + chan_read: impl Read, + chan_write: impl Write, + i2c: &I, +) -> Result<(), sunset::Error> { + debug!("Starting I2C <--> SSH bridge"); + i2c.reset_protocol(); + select(i2c_to_ssh(i2c, chan_write), ssh_to_i2c(chan_read, i2c)).await; + debug!("Stopping I2C <--> SSH bridge"); + Ok(()) +} + +async fn i2c_to_ssh( + i2c_buf: &I, + mut chan_write: impl Write, +) -> Result<(), sunset::Error> { + let mut ssh_tx_buf = [0u8; 128]; + loop { + let n = i2c_buf.read(&mut ssh_tx_buf).await; + chan_write.write_all(&ssh_tx_buf[..n]).await?; + } +} + +async fn ssh_to_i2c( + mut chan_read: impl Read, + i2c_buf: &I, +) -> Result<(), sunset::Error> { + let mut i2c_rx_buf = [0u8; 64]; + loop { + let n = chan_read.read(&mut i2c_rx_buf).await?; + if n == 0 { + return Err(sunset::Error::ChannelEOF); + } + i2c_buf.write(&i2c_rx_buf[..n]).await; + } +} diff --git a/src/lib.rs b/src/lib.rs index c5ac88a..cf7c27d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -101,6 +101,8 @@ pub mod can; pub mod config; pub mod errors; pub mod handle; +#[cfg(feature = "i2c")] +pub mod i2c; pub mod platform; pub mod serial; pub mod serve; diff --git a/src/platform.rs b/src/platform.rs index 3af3aa7..950d7bf 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -50,6 +50,16 @@ pub trait PlatformServices { #[cfg(feature = "can")] fn can(&self) -> &'static Self::Can; + /// Buffered I2C type this platform provides. The I2C pump task owns + /// it for the lifetime of the device, so `'static` is required. + #[cfg(feature = "i2c")] + type I2c: crate::i2c::BufferedI2c + 'static; + + /// Access the platform's buffered I2C master for the SSH `i2c` + /// subsystem bridge. + #[cfg(feature = "i2c")] + fn i2c(&self) -> &'static Self::I2c; + /// Persist the full config to non-volatile storage. /// /// # Errors diff --git a/src/serve.rs b/src/serve.rs index 05b38ff..1a0519a 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -16,8 +16,9 @@ use log::trace; use crate::config::SSHStampConfig; use crate::handle::{ - EventContext, SessionType, defunct, first_auth, hostkeys, open_session, password_auth, - pubkey_auth, session_env, session_exec, session_pty, session_shell, session_subsystem, + EventContext, SessionQueues, SessionType, defunct, first_auth, hostkeys, open_session, + password_auth, pubkey_auth, session_env, session_exec, session_pty, session_shell, + session_subsystem, }; use crate::platform::PlatformServices; use crate::settings::UART_BUFFER_SIZE; @@ -42,14 +43,14 @@ pub async fn connection_loop( chan_pipe: &Channel, config: &SunsetMutex, platform: &P, - #[cfg(feature = "can")] can_queue: &Channel, + queues: &SessionQueues, ) -> Result<(), sunset::Error> { let mut session: Option = None; let mut config_changed = false; let mut needs_reset = false; let mut auth_checked = false; - #[cfg(all(feature = "sftp-ota", feature = "can"))] - let mut can_dispatched = false; + #[cfg(all(feature = "sftp-ota", any(feature = "can", feature = "i2c")))] + let mut bridge_dispatched = false; loop { let mut ph = ProgressHolder::new(); @@ -62,10 +63,9 @@ pub async fn connection_loop( auth_checked: &mut auth_checked, config_changed: &mut config_changed, needs_reset: &mut needs_reset, - #[cfg(feature = "can")] - can_queue, - #[cfg(all(feature = "sftp-ota", feature = "can"))] - can_dispatched: &mut can_dispatched, + queues, + #[cfg(all(feature = "sftp-ota", any(feature = "can", feature = "i2c")))] + bridge_dispatched: &mut bridge_dispatched, }; match ev { diff --git a/ssh-stamp-esp32-boards/boards/waveshare-esp32-s3-touch-lcd-43.toml b/ssh-stamp-esp32-boards/boards/waveshare-esp32-s3-touch-lcd-43.toml index 6eaf5a4..c5233a8 100644 --- a/ssh-stamp-esp32-boards/boards/waveshare-esp32-s3-touch-lcd-43.toml +++ b/ssh-stamp-esp32-boards/boards/waveshare-esp32-s3-touch-lcd-43.toml @@ -10,6 +10,11 @@ uart_rx = 44 uart_tx = 43 can_tx = 20 can_rx = 19 +# The external I2C connector shares the bus with the onboard CH422G IO +# expander and GT911 touch controller (both visible in a bus scan). Note +# that writes to the CH422G (0x24/0x38) can un-route the CAN transceiver. +i2c_sda = 8 +i2c_scl = 9 # The CAN pins (GPIO19/20) are shared with USB through an FSUSB42UMX analog # switch, steered by the USB_SEL line (EXIO5) of a CH422G I2C IO expander diff --git a/ssh-stamp-esp32-boards/build.rs b/ssh-stamp-esp32-boards/build.rs index a08d59d..b8c0a1d 100644 --- a/ssh-stamp-esp32-boards/build.rs +++ b/ssh-stamp-esp32-boards/build.rs @@ -89,6 +89,8 @@ struct Pins { uart_tx: u8, can_tx: Option, can_rx: Option, + i2c_sda: Option, + i2c_scl: Option, } /// Boards that share their CAN pins with other functions behind an @@ -112,6 +114,8 @@ struct Board { uart_tx: u8, can_tx: Option, can_rx: Option, + i2c_sda: Option, + i2c_scl: Option, can_mux: Option, } @@ -209,6 +213,8 @@ fn load_boards(boards_dir: &Path) -> Result> { uart_tx: def.pins.uart_tx, can_tx: def.pins.can_tx, can_rx: def.pins.can_rx, + i2c_sda: def.pins.i2c_sda, + i2c_scl: def.pins.i2c_scl, can_mux: def.can_mux, }); } @@ -252,6 +258,7 @@ fn generate_code(boards: &[Board]) -> Result { gen_catalog(&mut out, boards)?; gen_take_uart_pins(&mut out, boards); gen_take_can_pins(&mut out, boards); + gen_take_i2c_pins(&mut out, boards); gen_setup_can_transceiver(&mut out, boards); gen_select_board(&mut out, boards); @@ -283,7 +290,7 @@ fn gen_catalog(out: &mut String, boards: &[Board]) -> Result<()> { // replaces the old hand-maintained README pin table. writeln!( out, - "/// # Available boards\n///\n/// | Board feature | UART RX | UART TX | CAN TX | CAN RX | URL |\n/// |---|---|---|---|---|---|" + "/// # Available boards\n///\n/// | Board feature | UART RX | UART TX | CAN TX | CAN RX | I2C SDA | I2C SCL | URL |\n/// |---|---|---|---|---|---|---|---|" )?; for b in boards { let url = match &b.url { @@ -292,10 +299,12 @@ fn gen_catalog(out: &mut String, boards: &[Board]) -> Result<()> { }; let can_tx = b.can_tx.map_or("—".to_string(), |v| v.to_string()); let can_rx = b.can_rx.map_or("—".to_string(), |v| v.to_string()); + let i2c_sda = b.i2c_sda.map_or("—".to_string(), |v| v.to_string()); + let i2c_scl = b.i2c_scl.map_or("—".to_string(), |v| v.to_string()); writeln!( out, - "/// | `{}` | {} | {} | {} | {} | {} |", - b.feature, b.uart_rx, b.uart_tx, can_tx, can_rx, url, + "/// | `{}` | {} | {} | {} | {} | {} | {} | {} |", + b.feature, b.uart_rx, b.uart_tx, can_tx, can_rx, i2c_sda, i2c_scl, url, )?; } writeln!(out, "pub mod board_catalog {{}}\n")?; @@ -414,6 +423,73 @@ fn gen_take_can_pins(out: &mut String, boards: &[Board]) { out.push('\n'); } +/// Doc-comment + signature for `take_i2c_pins!`. Per-board branches are +/// inserted at `{branches}` and the fallback `not(any(...))` feature list +/// at `{features}`. +const TAKE_I2C_PINS_TMPL: &str = r#"/// Extract I2C GPIO pins from `peripherals`. +/// +/// Returns `(sda_pin, scl_pin)`. The pin numbers come from `boards/*.toml`. +/// Only call this macro when the `i2c` feature is enabled. +/// +/// # Panics +/// +/// Compile-time error if no board feature is selected, or if the selected +/// board does not have I2C pins defined. +#[macro_export] +macro_rules! take_i2c_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."); + }} + }}; +} +"#; + +/// Per-board `#[cfg]` branch inside `take_i2c_pins!`. +const I2C_PIN_BRANCH_SOME: &str = r#" #[cfg(feature = "{feature}")] + {{ + ( + $peripherals.GPIO{sda}.into(), + $peripherals.GPIO{scl}.into(), + ) + }} +"#; + +const I2C_PIN_BRANCH_NONE: &str = r#" #[cfg(feature = "{feature}")] + {{ + compile_error!("Board `{feature}` does not have I2C pins defined. Enable the `i2c` feature only for boards with I2C support."); + }} +"#; + +fn gen_take_i2c_pins(out: &mut String, boards: &[Board]) { + let mut branches = String::new(); + for b in boards { + if let (Some(sda), Some(scl)) = (b.i2c_sda, b.i2c_scl) { + branches.push_str( + &I2C_PIN_BRANCH_SOME + .replace("{feature}", &b.feature) + .replace("{sda}", &sda.to_string()) + .replace("{scl}", &scl.to_string()), + ); + } else { + branches.push_str(&I2C_PIN_BRANCH_NONE.replace("{feature}", &b.feature)); + } + } + + let features: Vec = boards + .iter() + .map(|b| format!("feature = \"{}\"", b.feature)) + .collect(); + + let rendered = TAKE_I2C_PINS_TMPL + .replace("{branches}", &branches) + .replace("{features}", &features.join(", ")); + + out.push_str(&rendered); + out.push('\n'); +} + /// Doc-comment + signature for `setup_can_transceiver!`. Per-board branches /// are inserted at `{branches}` and the fallback `not(any(...))` feature /// list at `{features}`. @@ -444,15 +520,20 @@ macro_rules! setup_can_transceiver { /// `#[cfg]` branch inside `setup_can_transceiver!` for a board with a /// `[can_mux]` TOML section. The `{writes}` placeholder receives one /// `CAN_MUX_WRITE` line per `[address, value]` pair. +/// +/// The I2C peripheral and pins are `reborrow()`ed, not consumed: the mux +/// may sit on the same bus the board exposes for the `i2c` subsystem +/// (e.g. Waveshare's ESP32-S3-Touch-LCD-4.3), so the singletons must stay +/// available for a later `take_i2c_pins!`. const CAN_MUX_BRANCH: &str = r#" #[cfg(feature = "{feature}")] { let mut i2c = ::esp_hal::i2c::master::I2c::new( - $peripherals.I2C0, + $peripherals.I2C0.reborrow(), ::esp_hal::i2c::master::Config::default(), ) .expect("I2C init error") - .with_sda($peripherals.GPIO{sda}) - .with_scl($peripherals.GPIO{scl}); + .with_sda($peripherals.GPIO{sda}.reborrow()) + .with_scl($peripherals.GPIO{scl}.reborrow()); {writes} } "#; diff --git a/ssh-stamp-esp32/Cargo.toml b/ssh-stamp-esp32/Cargo.toml index 2da1984..c90c904 100644 --- a/ssh-stamp-esp32/Cargo.toml +++ b/ssh-stamp-esp32/Cargo.toml @@ -62,6 +62,7 @@ can = ["ssh-stamp/can"] # real bus — Normal mode retransmits until a node acknowledges, as CAN # reliability expects. can-no-ack = ["can"] +i2c = ["ssh-stamp/i2c"] ipv6 = ["ssh-stamp/ipv6"] # Board features — select a specific PCB. Each enables the corresponding diff --git a/ssh-stamp-esp32/src/bin/ssh-stamp-esp32.rs b/ssh-stamp-esp32/src/bin/ssh-stamp-esp32.rs index 9cdaa65..1d2803c 100644 --- a/ssh-stamp-esp32/src/bin/ssh-stamp-esp32.rs +++ b/ssh-stamp-esp32/src/bin/ssh-stamp-esp32.rs @@ -39,6 +39,8 @@ use ssh_stamp::store; use ssh_stamp::{app, settings::DEFAULT_IP}; #[cfg(feature = "can")] use ssh_stamp_esp32::{BufferedCan, CAN_BUF, EspCanPins, can_task}; +#[cfg(feature = "i2c")] +use ssh_stamp_esp32::{BufferedI2c, EspI2cPins, I2C_BUF, i2c_task}; use ssh_stamp_esp32::{ BufferedUart, EspPlatform, EspUartPins, EspWifi, UART_BUF, flash, mac_address, register_custom_rng, uart_task, @@ -72,7 +74,10 @@ async fn main(spawner: Spawner) -> ! { logger::init_logger_from_env(); debug!("HSM: initialising peripherals"); - let peripherals = esp_hal::init(esp_hal::Config::default()); + // `mut` is only exercised by setup_can_transceiver!'s reborrows on + // boards whose CAN mux shares the I2C bus. + #[allow(unused_mut)] + let mut peripherals = esp_hal::init(esp_hal::Config::default()); // Enable true random number generation using ADC entropy source before config creation. // The ESP32 hardware RNG only produces true random numbers when RF subsystem is enabled @@ -177,6 +182,8 @@ async fn main(spawner: Spawner) -> ! { let can_buf: &'static BufferedCan = { // Boards that mux their CAN pins with other functions declare the // routing in their TOML; a no-op for boards without a [can_mux]. + // Must run before the I2C block below: the mux may sit on the same + // bus, so it only reborrows the I2C peripheral and pins. ssh_stamp_esp32_boards::setup_can_transceiver!(peripherals); let can_pins = ssh_stamp_esp32_boards::take_can_pins!(peripherals); @@ -190,9 +197,26 @@ async fn main(spawner: Spawner) -> ! { can_buf }; - #[cfg(feature = "can")] + #[cfg(feature = "i2c")] + let i2c_buf: &'static BufferedI2c = { + let i2c_pins = ssh_stamp_esp32_boards::take_i2c_pins!(peripherals); + let i2c_pins = EspI2cPins { + sda: i2c_pins.0, + scl: i2c_pins.1, + }; + let i2c_buf = I2C_BUF.init_with(BufferedI2c::new); + interrupt_spawner + .spawn(i2c_task(i2c_buf, peripherals.I2C0, i2c_pins).expect("i2c_task spawn failed")); + i2c_buf + }; + + #[cfg(all(feature = "can", feature = "i2c"))] + let platform = EspPlatform::new(can_buf, i2c_buf); + #[cfg(all(feature = "can", not(feature = "i2c")))] let platform = EspPlatform::new(can_buf); - #[cfg(not(feature = "can"))] + #[cfg(all(feature = "i2c", not(feature = "can")))] + let platform = EspPlatform::new(i2c_buf); + #[cfg(not(any(feature = "can", feature = "i2c")))] let platform = EspPlatform::new(); debug!("Initialising radio"); diff --git a/ssh-stamp-esp32/src/i2c.rs b/ssh-stamp-esp32/src/i2c.rs new file mode 100644 index 0000000..8ccd4f6 --- /dev/null +++ b/ssh-stamp-esp32/src/i2c.rs @@ -0,0 +1,188 @@ +// SPDX-FileCopyrightText: 2026 Roman Valls Guimera +// +// SPDX-License-Identifier: GPL-3.0-or-later + +//! I2C master implementation for ESP32 family. +//! +//! Provides [`BufferedI2c`] — a software-buffered, async I2C master +//! satisfying [`ssh_stamp::i2c::BufferedI2c`]. All protocol details +//! (command parsing, response encoding) live in the platform-agnostic +//! [`ssh_stamp::i2c`] layer; this file only executes bus operations. +//! The device is the bus master, so unlike CAN there is no unsolicited +//! traffic and no `select` in the pump: one request, one reply. + +use core::future::Future; + +use embassy_sync::{blocking_mutex::raw::CriticalSectionRawMutex, pipe::Pipe}; +use esp_hal::gpio::AnyPin; +use esp_hal::i2c::master::{Config, Error, I2c}; +use esp_hal::peripherals::I2C0; +use portable_atomic::{AtomicBool, Ordering}; +use ssh_stamp::i2c::{ + I2C_DATA_MAX, I2cParser, I2cRequest, I2cResponse, RESPONSE_MAX, SCAN_FIRST, SCAN_LAST, + encode_response, +}; +use static_cell::StaticCell; + +const INWARD_BUF_SZ: usize = 256; +const OUTWARD_BUF_SZ: usize = 256; + +/// Bidirectional pipe buffer between the I2C peripheral and the SSH +/// `i2c` subsystem bridge. `outward` carries command lines from the host, +/// `inward` carries the replies. +pub struct BufferedI2c { + outward: Pipe, + inward: Pipe, + /// Set by [`BufferedI2c::reset_protocol`]; makes the pump task drop + /// parser state left over from a previous session. + proto_reset: AtomicBool, +} + +impl BufferedI2c { + #[must_use] + pub fn new() -> Self { + BufferedI2c { + outward: Pipe::new(), + inward: Pipe::new(), + proto_reset: AtomicBool::new(false), + } + } + + /// Parse host commands, execute them on the bus and queue the replies. + /// + /// This should be awaited from an Embassy task run in an + /// `InterruptExecutor` for lower latency. + pub async fn run(&self, mut i2c: I2c<'static, esp_hal::Async>) { + let mut parser = I2cParser::new(); + let mut chunk = [0u8; 64]; + let mut reply_buf = [0u8; RESPONSE_MAX]; + loop { + let n = self.outward.read(&mut chunk).await; + if self.proto_reset.swap(false, Ordering::Relaxed) { + parser.reset(); + } + for &byte in &chunk[..n] { + let Some(request) = parser.feed(byte) else { + continue; + }; + let response = execute(&mut i2c, &request).await; + let len = encode_response(&response, &mut reply_buf); + self.inward.write_all(&reply_buf[..len]).await; + } + } + } + + pub async fn read(&self, buf: &mut [u8]) -> usize { + self.inward.read(buf).await + } + + pub async fn write(&self, buf: &[u8]) { + self.outward.write_all(buf).await; + } + + /// Start-of-session reset: drop half-parsed command state and discard + /// replies buffered for a previous session. + pub fn reset_protocol(&self) { + self.proto_reset.store(true, Ordering::Relaxed); + let mut sink = [0u8; 32]; + while self.inward.try_read(&mut sink).is_ok() {} + } +} + +/// Execute one decoded request on the bus and map the outcome. +async fn execute(i2c: &mut I2c<'static, esp_hal::Async>, request: &I2cRequest) -> I2cResponse { + match request { + I2cRequest::Scan => { + // Probe with 1-byte reads: esp-hal rejects zero-length + // transfers (`Error::ZeroLengthInvalid`), and a read is the + // least intrusive probe for most devices. + let mut found = heapless::Vec::new(); + let mut scratch = [0u8; 1]; + for addr in SCAN_FIRST..=SCAN_LAST { + if i2c.read_async(addr, &mut scratch).await.is_ok() { + let _ = found.push(addr); + } + } + I2cResponse::Scan(found) + } + I2cRequest::Write { addr, data } => match i2c.write_async(*addr, data).await { + Ok(()) => I2cResponse::Ok, + Err(e) => error_response(e), + }, + I2cRequest::Read { addr, len } => { + let mut data = heapless::Vec::::new(); + data.resize_default(usize::from(*len)).unwrap_or_default(); + match i2c.read_async(*addr, &mut data).await { + Ok(()) => I2cResponse::Data(data), + Err(e) => error_response(e), + } + } + I2cRequest::WriteRead { addr, data, len } => { + let mut read = heapless::Vec::::new(); + read.resize_default(usize::from(*len)).unwrap_or_default(); + match i2c.write_read_async(*addr, data, &mut read).await { + Ok(()) => I2cResponse::Data(read), + Err(e) => error_response(e), + } + } + I2cRequest::Malformed => I2cResponse::BadCommand, + } +} + +fn error_response(e: Error) -> I2cResponse { + match e { + Error::AcknowledgeCheckFailed(_) => I2cResponse::Nack, + Error::Timeout => I2cResponse::Timeout, + _ => I2cResponse::Error, + } +} + +impl Default for BufferedI2c { + fn default() -> Self { + Self::new() + } +} + +impl ssh_stamp::i2c::BufferedI2c for BufferedI2c { + fn read(&self, buf: &mut [u8]) -> impl Future { + BufferedI2c::read(self, buf) + } + + fn write(&self, buf: &[u8]) -> impl Future { + BufferedI2c::write(self, buf) + } + + fn reset_protocol(&self) { + BufferedI2c::reset_protocol(self); + } +} + +/// I2C pins configuration. +/// +/// The pin numbers inside are target-specific and come from the board's +/// TOML in the `ssh-stamp-esp32-boards` crate. +pub struct EspI2cPins<'a> { + pub sda: AnyPin<'a>, + pub scl: AnyPin<'a>, +} + +/// Static storage for the buffered I2C singleton. +pub static I2C_BUF: StaticCell = StaticCell::new(); + +/// Embassy task that owns the hardware I2C master and pumps it through +/// [`BufferedI2c::run`]. The bus runs at the esp-hal default speed +/// (100 kHz standard mode). +#[embassy_executor::task] +pub async fn i2c_task( + i2c_buf: &'static BufferedI2c, + i2c0: I2C0<'static>, + pins: EspI2cPins<'static>, +) { + let i2c = I2c::new(i2c0, Config::default()) + .expect("I2C config error") + .with_sda(pins.sda) + .with_scl(pins.scl) + .into_async(); + + i2c_buf.run(i2c).await; +} diff --git a/ssh-stamp-esp32/src/lib.rs b/ssh-stamp-esp32/src/lib.rs index 2b4fc79..db01659 100644 --- a/ssh-stamp-esp32/src/lib.rs +++ b/ssh-stamp-esp32/src/lib.rs @@ -20,6 +20,8 @@ extern crate alloc; mod can; pub mod flash; mod hash; +#[cfg(feature = "i2c")] +mod i2c; mod network; mod platform; mod rng; @@ -30,6 +32,8 @@ mod uart; pub use can::{BufferedCan, CAN_BUF, EspCanPins, can_task}; pub use flash::{EspOtaWriter, FlashBuffer, get_flash_n_buffer, init as flash_init}; pub use hash::EspHmac; +#[cfg(feature = "i2c")] +pub use i2c::{BufferedI2c, EspI2cPins, I2C_BUF, i2c_task}; pub use network::{EspWifi, accept_requests, dhcp_server, net_up, wifi_up}; pub use platform::EspPlatform; pub use rng::{EspRng, register_custom_rng}; diff --git a/ssh-stamp-esp32/src/platform.rs b/ssh-stamp-esp32/src/platform.rs index 44c611d..e013643 100644 --- a/ssh-stamp-esp32/src/platform.rs +++ b/ssh-stamp-esp32/src/platform.rs @@ -24,19 +24,26 @@ use crate::uart::UART_SIGNAL; pub struct EspPlatform { #[cfg(feature = "can")] can: &'static crate::can::BufferedCan, + #[cfg(feature = "i2c")] + i2c: &'static crate::i2c::BufferedI2c, } impl EspPlatform { #[must_use] - pub fn new(#[cfg(feature = "can")] can: &'static crate::can::BufferedCan) -> Self { + pub fn new( + #[cfg(feature = "can")] can: &'static crate::can::BufferedCan, + #[cfg(feature = "i2c")] i2c: &'static crate::i2c::BufferedI2c, + ) -> Self { Self { #[cfg(feature = "can")] can, + #[cfg(feature = "i2c")] + i2c, } } } -#[cfg(not(feature = "can"))] +#[cfg(not(any(feature = "can", feature = "i2c")))] impl Default for EspPlatform { fn default() -> Self { Self::new() @@ -54,6 +61,14 @@ impl PlatformServices for EspPlatform { self.can } + #[cfg(feature = "i2c")] + type I2c = crate::i2c::BufferedI2c; + + #[cfg(feature = "i2c")] + fn i2c(&self) -> &'static Self::I2c { + self.i2c + } + async fn save_config(&self, config: &SSHStampConfig) -> Result<(), HalError> { let Some(flash_guard) = flash::get_flash_n_buffer() else { return Err(HalError::Flash(FlashError::InternalError));