Skip to main content

ssh_stamp_esp32/
uart.rs

1// SPDX-FileCopyrightText: 2026 Roman Valls Guimera <brainstorm@nopcode.org>
2// SPDX-FileCopyrightText: 2026 Julio Beltran Ortega <jubeormk1@gmail.com>
3// SPDX-FileCopyrightText: 2026 Angus Gratton <gus@projectgus.com>
4// SPDX-FileCopyrightText: 2026 Sergio Gasquez <sergio.gasquez@gmail.com>
5// SPDX-FileCopyrightText: 2026 pancake <pancake@nopcode.org>
6// SPDX-FileCopyrightText: 2026 Gabriel Ku Wei Bin <gabriel.ku@fsfe.org>
7// SPDX-FileCopyrightText: 2026 Anthony Tambasco <anthony.tambasco@fastmail.com>
8// SPDX-FileCopyrightText: 2026 Marko Malenic <mmalenic1@gmail.com>
9//
10// SPDX-License-Identifier: GPL-3.0-or-later
11
12//! UART implementation for ESP32 family.
13//!
14//! Provides [`BufferedUart`] — a software-buffered, async, full-duplex UART
15//! satisfying [`ssh_stamp::serial::BufferedSerial`]. The bridge can poll the
16//! same UART from two futures (TX and RX) concurrently because both sides
17//! take `&self`.
18
19use core::future::Future;
20
21use embassy_executor::SendSpawner;
22use embassy_sync::pipe::TryWriteError;
23use embassy_sync::signal::Signal;
24use embassy_sync::{blocking_mutex::raw::CriticalSectionRawMutex, pipe::Pipe};
25use esp_hal::Async;
26use esp_hal::gpio::AnyPin;
27#[cfg(feature = "bench-loopback")]
28use esp_hal::gpio::Flex;
29use esp_hal::peripherals::UART1;
30use esp_hal::uart::{Config, DataBits, Parity, RxConfig, StopBits, Uart};
31use portable_atomic::{AtomicUsize, Ordering};
32use ssh_stamp::serial::BufferedSerial;
33use ssh_stamp_hal::{Parity as LineParity, UartParams};
34use static_cell::StaticCell;
35
36const INWARD_BUF_SZ: usize = 512;
37const OUTWARD_BUF_SZ: usize = 256;
38const UART_BUF_SZ: usize = 64;
39
40/// The ESP32 UART peripherals reject anything above 5 Mbaud.
41const MAX_BAUD: u32 = 5_000_000;
42
43/// Bidirectional pipe buffer for UART communications.
44pub struct BufferedUart {
45    outward: Pipe<CriticalSectionRawMutex, OUTWARD_BUF_SZ>,
46    inward: Pipe<CriticalSectionRawMutex, INWARD_BUF_SZ>,
47    dropped_rx_bytes: AtomicUsize,
48}
49
50impl BufferedUart {
51    #[must_use]
52    pub fn new() -> Self {
53        BufferedUart {
54            outward: Pipe::new(),
55            inward: Pipe::new(),
56            dropped_rx_bytes: AtomicUsize::from(0),
57        }
58    }
59
60    /// Transfer data between UART hardware and internal buffers.
61    ///
62    /// This should be awaited from an Embassy task run in an `InterruptExecutor`
63    /// for lower latency.
64    pub async fn run(&self, uart: Uart<'_, Async>) {
65        let (mut uart_rx, mut uart_tx) = uart.split();
66        let mut rx_buf = [0u8; UART_BUF_SZ];
67        let mut tx_buf = [0u8; UART_BUF_SZ];
68
69        loop {
70            use embassy_futures::select::select;
71
72            let rd_from = async {
73                loop {
74                    let Ok(n) = uart_rx.read_async(&mut rx_buf).await else {
75                        continue;
76                    };
77
78                    let mut rx_slice = &rx_buf[..n];
79
80                    while !rx_slice.is_empty() {
81                        rx_slice = match self.inward.try_write(rx_slice) {
82                            Ok(w) => &rx_slice[w..],
83                            Err(TryWriteError::Full) => {
84                                let mut drop_buf = [0u8; UART_BUF_SZ];
85                                let dropped = self
86                                    .inward
87                                    .try_read(&mut drop_buf[..rx_slice.len()])
88                                    .unwrap_or(0);
89                                let _ = self.dropped_rx_bytes.fetch_update(
90                                    Ordering::Relaxed,
91                                    Ordering::Relaxed,
92                                    |d| Some(d.saturating_add(dropped)),
93                                );
94                                rx_slice
95                            }
96                        };
97                    }
98                }
99            };
100
101            let rd_to = async {
102                loop {
103                    let n = self.outward.read(&mut tx_buf).await;
104
105                    // This must take into consideration the length returned by `write_async`,
106                    // as it may be less than the full buffer. Follow-up loop iterations
107                    // then write any remainder.
108                    let mut tx_slice = &tx_buf[..n];
109                    while !tx_slice.is_empty() {
110                        let Ok(written) = uart_tx.write_async(tx_slice).await else {
111                            break;
112                        };
113
114                        tx_slice = &tx_slice[written..];
115                    }
116                }
117            };
118
119            select(rd_from, rd_to).await;
120        }
121    }
122
123    pub async fn read(&self, buf: &mut [u8]) -> usize {
124        self.inward.read(buf).await
125    }
126
127    pub async fn write(&self, buf: &[u8]) {
128        self.outward.write_all(buf).await;
129    }
130
131    /// Number of bytes the RX side dropped since the last call. Resets the counter.
132    pub fn check_dropped_bytes(&self) -> usize {
133        self.dropped_rx_bytes.swap(0, Ordering::Relaxed)
134    }
135}
136
137impl Default for BufferedUart {
138    fn default() -> Self {
139        Self::new()
140    }
141}
142
143impl BufferedSerial for BufferedUart {
144    fn read(&self, buf: &mut [u8]) -> impl Future<Output = usize> {
145        BufferedUart::read(self, buf)
146    }
147
148    fn write(&self, buf: &[u8]) -> impl Future<Output = ()> {
149        BufferedUart::write(self, buf)
150    }
151
152    fn check_dropped_bytes(&self) -> usize {
153        BufferedUart::check_dropped_bytes(self)
154    }
155}
156
157/// UART pins configuration.
158///
159/// The pin numbers inside come from the selected board's TOML in the
160/// `ssh-stamp-esp32-boards` crate; its front page carries the generated pin
161/// catalog for every board of this platform.
162pub struct EspUartPins<'a> {
163    pub rx: AnyPin<'a>,
164    pub tx: AnyPin<'a>,
165}
166
167/// Static storage for the buffered UART singleton.
168pub static UART_BUF: StaticCell<BufferedUart> = StaticCell::new();
169
170/// Signal raised by [`ssh_stamp::platform::PlatformServices::activate_uart`]
171/// to release [`uart_task`] from its initial wait.
172pub static UART_SIGNAL: Signal<CriticalSectionRawMutex, u8> = Signal::new();
173
174/// Translates the persisted, target-agnostic [`UartParams`] into an esp-hal
175/// [`Config`].
176///
177/// Values the peripheral cannot honour fall back to the 8N1 default instead of
178/// refusing to bring the bridge up, so a stale or corrupt stored config still
179/// leaves a usable serial console.
180fn esp_uart_config(params: UartParams) -> Config {
181    let data_bits = match params.data_bits {
182        5 => DataBits::_5,
183        6 => DataBits::_6,
184        7 => DataBits::_7,
185        _ => DataBits::_8,
186    };
187    let parity = match params.parity {
188        LineParity::Even => Parity::Even,
189        LineParity::Odd => Parity::Odd,
190        LineParity::None => Parity::None,
191    };
192    let stop_bits = if params.stop_bits == 2 {
193        StopBits::_2
194    } else {
195        StopBits::_1
196    };
197
198    Config::default()
199        .with_baudrate(params.baud.clamp(1, MAX_BAUD))
200        .with_data_bits(data_bits)
201        .with_parity(parity)
202        .with_stop_bits(stop_bits)
203}
204
205/// Embassy task that owns the hardware UART and pumps it through
206/// [`BufferedUart::run`]. Spawn from a higher-priority `InterruptExecutor`
207/// for lower latency.
208///
209/// `params` are the line settings from the device config, applied here since
210/// the UART is configured once for the lifetime of the boot.
211#[embassy_executor::task]
212pub async fn uart_task(
213    uart_buf: &'static BufferedUart,
214    uart1: UART1<'static>,
215    pins: EspUartPins<'static>,
216    params: UartParams,
217) {
218    UART_SIGNAL.wait().await;
219
220    let uart_config = esp_uart_config(params).with_rx(
221        RxConfig::default()
222            .with_fifo_full_threshold(16)
223            .with_timeout(1),
224    );
225
226    let uart = Uart::new(uart1, uart_config).expect("UART config error");
227
228    // Route the TX back into the RX input to measure round trip.
229    #[cfg(feature = "bench-loopback")]
230    let uart = {
231        log::warn!("bench-loopback active, the TX is looped back to RX.");
232        let (rx_sig, tx_sig) = Flex::new(pins.tx).split();
233        uart.with_rx(rx_sig).with_tx(tx_sig).into_async()
234    };
235    #[cfg(not(feature = "bench-loopback"))]
236    let uart = uart.with_rx(pins.rx).with_tx(pins.tx).into_async();
237
238    uart_buf.run(uart).await;
239}
240
241/// Creates the [`BufferedUart`] singleton and spawns [`uart_task`] on the
242/// given spawner, returning the buffer the rest of the system talks to. The
243/// firmware feeds it the spawner from
244/// [`start_interrupt_executor`](crate::start_interrupt_executor), so the
245/// task runs at interrupt priority. The task waits on [`UART_SIGNAL`] before
246/// touching the hardware.
247///
248/// # Panics
249///
250/// Panics if called more than once per boot: the [`BufferedUart`] singleton
251/// and the task can each only be created once.
252pub fn spawn_uart(
253    spawner: SendSpawner,
254    uart1: UART1<'static>,
255    pins: EspUartPins<'static>,
256    params: UartParams,
257) -> &'static BufferedUart {
258    let uart_buf = UART_BUF.init_with(BufferedUart::new);
259    spawner.spawn(uart_task(uart_buf, uart1, pins, params).expect("uart_task spawn failed"));
260    uart_buf
261}