ssh_stamp_esp32/can.rs
1// SPDX-FileCopyrightText: 2026 Roman Valls Guimera <brainstorm@nopcode.org>
2//
3// SPDX-License-Identifier: GPL-3.0-or-later
4
5//! CAN (TWAI) implementation for ESP32 family.
6//!
7//! Provides [`BufferedCan`] — a software-buffered, async CAN interface
8//! satisfying [`ssh_stamp::can::BufferedCan`]. The bridge can pump the
9//! same TWAI peripheral from two futures (TX and RX) concurrently because
10//! both sides take `&self`. All framing (slcan / GVRET auto-detection)
11//! lives in the platform-agnostic [`ssh_stamp::can`] layer; this file only
12//! moves bytes and frames.
13
14use core::future::Future;
15
16use embassy_sync::{blocking_mutex::raw::CriticalSectionRawMutex, pipe::Pipe};
17use embassy_time::{Duration, with_timeout};
18use esp_hal::gpio::AnyPin;
19use esp_hal::peripherals::TWAI0;
20use esp_hal::twai::{self, EspTwaiFrame, ExtendedId, StandardId, TwaiMode};
21use log::warn;
22use portable_atomic::{AtomicBool, AtomicUsize, Ordering};
23use ssh_stamp::can::{CanAction, CanId, CanParser, ENCODED_FRAME_MAX, encode_frame};
24use static_cell::StaticCell;
25
26const INWARD_BUF_SZ: usize = 512;
27const OUTWARD_BUF_SZ: usize = 256;
28
29/// Bus bitrate in bit/s. Keep in sync with the `BaudRate` passed to the
30/// TWAI driver in [`can_task`]; also reported to GVRET clients.
31const CAN_BITRATE: u32 = 500_000;
32
33/// TWAI operating mode.
34///
35/// `Normal` is what a real bus needs: the controller takes part in
36/// acknowledgement and retransmits a frame until some node acknowledges it.
37/// On a bench with no acknowledging peer (only a scope/analyzer attached)
38/// that same rule makes a single write repeat on the wire, so the
39/// `can-no-ack` feature switches to `SelfTest` mode, which drops the
40/// acknowledgement requirement: one slcan line, exactly one frame.
41#[cfg(feature = "can-no-ack")]
42const TWAI_MODE: TwaiMode = TwaiMode::SelfTest;
43#[cfg(not(feature = "can-no-ack"))]
44const TWAI_MODE: TwaiMode = TwaiMode::Normal;
45
46/// Safety net for transmissions stuck retrying (shorted/unwired bus, or a
47/// frame nothing acknowledges in `Normal` mode). Dropping the transmit
48/// future on timeout issues a TWAI TX-abort, cancelling the pending
49/// retransmissions instead of retrying forever. In `Normal` mode the
50/// timeout is generous so arbitration on a busy but healthy bus never
51/// drops frames; with `can-no-ack` any wait at all means a bus fault.
52#[cfg(feature = "can-no-ack")]
53const TX_TIMEOUT: Duration = Duration::from_millis(1);
54#[cfg(not(feature = "can-no-ack"))]
55const TX_TIMEOUT: Duration = Duration::from_millis(10);
56
57/// Bidirectional pipe buffer between the TWAI peripheral and the SSH
58/// `can` subsystem bridge. Traffic in both pipes is framed by the codec
59/// layer (slcan lines or GVRET binary messages).
60pub struct BufferedCan {
61 outward: Pipe<CriticalSectionRawMutex, OUTWARD_BUF_SZ>,
62 inward: Pipe<CriticalSectionRawMutex, INWARD_BUF_SZ>,
63 dropped_rx_frames: AtomicUsize,
64 /// Bus→host framing: GVRET binary after the host sent `0xE7`,
65 /// slcan ASCII otherwise. Reset at the start of every session.
66 binary_mode: AtomicBool,
67 /// Set by [`BufferedCan::reset_protocol`]; makes the pump task drop
68 /// parser state left over from a previous session.
69 proto_reset: AtomicBool,
70}
71
72impl BufferedCan {
73 #[must_use]
74 pub fn new() -> Self {
75 BufferedCan {
76 outward: Pipe::new(),
77 inward: Pipe::new(),
78 dropped_rx_frames: AtomicUsize::from(0),
79 binary_mode: AtomicBool::new(false),
80 proto_reset: AtomicBool::new(false),
81 }
82 }
83
84 /// Transfer frames between the TWAI hardware and internal buffers.
85 ///
86 /// This should be awaited from an Embassy task run in an `InterruptExecutor`
87 /// for lower latency.
88 ///
89 /// Both directions write into `inward` (encoded bus frames and GVRET
90 /// replies). That is safe from interleaving because they run in this
91 /// single task and only issue a `write_all` after checking the whole
92 /// message fits, so the write never yields midway.
93 pub async fn run(&self, twai: twai::Twai<'static, esp_hal::Async>) {
94 let (mut twai_rx, mut twai_tx) = twai.split();
95
96 loop {
97 use embassy_futures::select::select;
98
99 let rd_from = async {
100 let mut frame_buf = [0u8; ENCODED_FRAME_MAX];
101 loop {
102 let frame = match twai_rx.receive_async().await {
103 Ok(frame) => frame,
104 Err(e) => {
105 warn!("TWAI RX error: {e:?}");
106 continue;
107 }
108 };
109 let binary = self.binary_mode.load(Ordering::Relaxed);
110 let n = encode_frame(&frame, binary, &mut frame_buf);
111 self.send_to_ssh(&frame_buf[..n]).await;
112 }
113 };
114
115 let rd_to = async {
116 let mut parser = CanParser::new(CAN_BITRATE);
117 let mut chunk = [0u8; 64];
118 loop {
119 let n = self.outward.read(&mut chunk).await;
120 if self.proto_reset.swap(false, Ordering::Relaxed) {
121 parser.reset();
122 }
123 for &byte in &chunk[..n] {
124 match parser.feed(byte) {
125 None => {}
126 Some(CanAction::EnableBinary) => {
127 self.binary_mode.store(true, Ordering::Relaxed);
128 }
129 Some(CanAction::Reply(bytes)) => {
130 self.send_to_ssh(&bytes).await;
131 }
132 Some(CanAction::Transmit(frame)) => {
133 let id: Option<twai::Id> = match frame.id {
134 CanId::Standard(id) => StandardId::new(id).map(twai::Id::from),
135 CanId::Extended(id) => ExtendedId::new(id).map(twai::Id::from),
136 };
137 let Some(esp_frame) =
138 id.and_then(|id| EspTwaiFrame::new(id, &frame.data))
139 else {
140 continue;
141 };
142 match with_timeout(TX_TIMEOUT, twai_tx.transmit_async(&esp_frame))
143 .await
144 {
145 Ok(Ok(())) => {}
146 Ok(Err(e)) => warn!("TWAI TX error: {e:?}"),
147 Err(_) => warn!(
148 "TWAI TX stuck (bus fault or missing ACK), aborting retransmission"
149 ),
150 }
151 }
152 }
153 }
154 }
155 };
156
157 select(rd_from, rd_to).await;
158 }
159 }
160
161 /// Queue one whole encoded message for the SSH side, or drop it (and
162 /// count the drop) when the session isn't keeping up: a partial slcan
163 /// line or GVRET message would corrupt the stream.
164 async fn send_to_ssh(&self, msg: &[u8]) {
165 if self.inward.free_capacity() < msg.len() {
166 let _ =
167 self.dropped_rx_frames
168 .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |d| {
169 Some(d.saturating_add(1))
170 });
171 } else {
172 self.inward.write_all(msg).await;
173 }
174 }
175
176 pub async fn read(&self, buf: &mut [u8]) -> usize {
177 self.inward.read(buf).await
178 }
179
180 pub async fn write(&self, buf: &[u8]) {
181 self.outward.write_all(buf).await;
182 }
183
184 /// Number of frames the RX side dropped since the last call. Resets the counter.
185 pub fn check_dropped_frames(&self) -> usize {
186 self.dropped_rx_frames.swap(0, Ordering::Relaxed)
187 }
188
189 /// Start-of-session reset: back to slcan framing, drop half-parsed
190 /// protocol state and discard bus traffic buffered while no session
191 /// was attached.
192 pub fn reset_protocol(&self) {
193 self.binary_mode.store(false, Ordering::Relaxed);
194 self.proto_reset.store(true, Ordering::Relaxed);
195 let mut sink = [0u8; 32];
196 while self.inward.try_read(&mut sink).is_ok() {}
197 }
198}
199
200impl Default for BufferedCan {
201 fn default() -> Self {
202 Self::new()
203 }
204}
205
206impl ssh_stamp::can::BufferedCan for BufferedCan {
207 fn read(&self, buf: &mut [u8]) -> impl Future<Output = usize> {
208 BufferedCan::read(self, buf)
209 }
210
211 fn write(&self, buf: &[u8]) -> impl Future<Output = ()> {
212 BufferedCan::write(self, buf)
213 }
214
215 fn check_dropped_frames(&self) -> usize {
216 BufferedCan::check_dropped_frames(self)
217 }
218
219 fn reset_protocol(&self) {
220 BufferedCan::reset_protocol(self);
221 }
222}
223
224/// CAN pins configuration.
225///
226/// The pin numbers inside are target-specific and come from the board's
227/// TOML in the `ssh-stamp-esp32-boards` crate.
228pub struct EspCanPins<'a> {
229 pub tx: AnyPin<'a>,
230 pub rx: AnyPin<'a>,
231}
232
233/// Static storage for the buffered CAN singleton.
234pub static CAN_BUF: StaticCell<BufferedCan> = StaticCell::new();
235
236/// Embassy task that owns the hardware TWAI peripheral and pumps it
237/// through [`BufferedCan::run`]. Spawn from a higher-priority
238/// `InterruptExecutor` for lower latency.
239#[embassy_executor::task]
240pub async fn can_task(
241 can_buf: &'static BufferedCan,
242 twai0: TWAI0<'static>,
243 pins: EspCanPins<'static>,
244) {
245 let twai_config =
246 twai::TwaiConfiguration::new(twai0, pins.rx, pins.tx, twai::BaudRate::B500K, TWAI_MODE);
247
248 let twai = twai_config.into_async().start();
249 can_buf.run(twai).await;
250}