rfm69_async/stack.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Smoltcp-style Stack/Runner split for concurrent rx/tx over a half-duplex
4//! radio.
5//!
6//! # Why
7//!
8//! The radio is half-duplex: only one of TX or RX can be active at a time.
9//! With a single `&mut self` driver, that constraint surfaces in user code
10//! as "you can't listen for incoming packets while another task wants to
11//! send" -- common patterns like *"always-listening node that occasionally
12//! responds"* end up needing ad-hoc `with_timeout` dances.
13//!
14//! This module hides the constraint behind a long-running [`Runner`] task
15//! that owns the radio exclusively. User tasks call [`Stack::send`] /
16//! [`Stack::recv`] from any number of independent tasks; the Runner
17//! arbitrates between TX requests and incoming packets via
18//! `embassy_futures::select`. From the user's perspective send and recv
19//! can run concurrently. Mirrors `embassy-net::Stack` / `embassy-net::Runner`.
20//!
21//! # TX serialization
22//!
23//! Half-duplex means at most one TX in flight at a time. [`Stack::send`]
24//! enforces this with a strict ordering inside the lock:
25//!
26//! 1. Acquire `tx_mutex` -- serializes callers FIFO via embassy's mutex queue.
27//! 2. **Reset `tx_response`** to clear any leftover from a cancelled previous
28//! call.
29//! 3. Send a [`TxRequest`] through the single-slot `tx_request` channel.
30//! 4. Await the result on `tx_response.wait()`.
31//! 5. Drop the guard.
32//!
33//! Step 2 *under the mutex* is the cancellation-safety contract: if a caller
34//! is dropped between `tx_request.send` and `tx_response.wait`, the Runner
35//! still writes the result into the [`Signal`], which the next `Stack::send`
36//! resets before posting its own request. The TX channel depth of 1 is
37//! intentional -- `tx_mutex` already guarantees no second request enters the
38//! channel before the first is consumed.
39//!
40//! # Cancellation safety of the Runner loop
41//!
42//! [`Runner::run`] loops on `select(tx_request_recv, trx.recv)`. When the TX
43//! branch wins, the dropped `trx.recv()` future has done no SPI work yet
44//! (the radio is just sitting in RX). When the RX branch wins, the dropped
45//! `tx_request_recv.receive()` future was a passive channel poll. Neither
46//! branch leaves hardware in a half-configured state.
47//!
48//! # Error type at the boundary
49//!
50//! The user-facing surface uses [`TxError`] (and the generic [`TrxError`]
51//! from [`crate::traits`]) -- a fixed, lossy error vocabulary. The
52//! parametric [`crate::Error`] is collapsed at the [`Transceiver`] boundary.
53//! See the [`TrxError`] rustdoc for the rationale.
54//!
55//! # Link state
56//!
57//! The Runner publishes a coarse health flag mirroring `embassy-net`'s
58//! `LinkState`:
59//!
60//! - default [`LinkState::Up`] on construction (the caller hands the Runner
61//! an already-configured radio);
62//! - flips to [`LinkState::Down`] after [`LINK_DOWN_STREAK`] consecutive
63//! `TrxError`s on any radio operation (rx, tx, ACK reply, ACK wait);
64//! - flips back to [`LinkState::Up`] on the first subsequent success.
65//!
66//! User code observes via [`Stack::link_state`] / [`Stack::is_link_up`]
67//! (sync) or [`Stack::wait_link_up`] / [`Stack::wait_link_down`] (async).
68//! Only one task may be waiting in each `wait_*` future at a time —
69//! multi-waiter fan-out is deferred; build a relay task with
70//! `embassy_sync::pubsub` if you need it.
71//!
72//! Active recovery is driven by [`Transceiver::recover`]. When the link
73//! transitions to [`LinkState::Down`], the Runner calls `trx.recover()` at
74//! the top of each loop iteration before issuing any more `send` / `recv`.
75//! A successful `recover` is treated as a normal radio op and flips the
76//! link back to `Up` via the usual streak machinery; a failing `recover`
77//! waits [`MacTiming::recover_backoff`] and retries on the next iteration.
78//! The default `Transceiver::recover` returns
79//! [`TrxError::RecoverUnsupported`](crate::TrxError::RecoverUnsupported), so a
80//! radio that doesn't override it makes `Down` sticky — the Runner keeps
81//! retrying `recover` every `recover_backoff` but never makes progress.
82//! Provide a real `recover` in your `Transceiver` impl to re-pulse `RESET`
83//! and re-apply a `config::*` helper.
84
85use core::cell::Cell;
86
87use embassy_futures::select::{Either, select};
88use embassy_sync::blocking_mutex::Mutex as BlockingMutex;
89use embassy_sync::blocking_mutex::raw::NoopRawMutex;
90use embassy_sync::channel::{Channel, DynamicReceiver, DynamicSender};
91use embassy_sync::mutex::Mutex;
92use embassy_sync::signal::Signal;
93use embassy_time::{Duration, Timer, with_timeout};
94use heapless::Vec;
95
96use crate::{Address, Flags, Packet, Transceiver, TrxError};
97
98/// Maximum payload bytes that fit on the wire (matches the `Packet` MTU).
99const PAYLOAD_CAP: usize = 61;
100
101/// Number of consecutive `TrxError`s on any radio operation that flips
102/// [`LinkState`] from `Up` to `Down`. The next successful operation flips
103/// it back to `Up`.
104pub const LINK_DOWN_STREAK: u8 = 3;
105
106/// Coarse health flag for the radio, observed via [`Stack`].
107///
108/// Mirrors `embassy-net::LinkState` semantics: not a per-error report, just
109/// "the radio is broadly working" (`Up`) or "we've seen a streak of failures"
110/// (`Down`). See the module-level docs for the exact transition rules.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
112#[cfg_attr(feature = "defmt", derive(defmt::Format))]
113pub enum LinkState {
114 #[default]
115 Up,
116 Down,
117}
118
119/// Errors returned by [`Stack::send`].
120#[derive(Debug)]
121#[cfg_attr(feature = "defmt", derive(defmt::Format))]
122pub enum TxError {
123 /// The peer did not ACK within the configured retries.
124 AckTimeout,
125 /// `data` was longer than the radio's 61-byte MTU.
126 DataTooLong,
127 /// The Runner reported an underlying radio error during the send.
128 Trx(TrxError),
129}
130
131/// Tunable MAC-layer timing knobs, owned by the [`Runner`].
132///
133/// Use [`MacTiming::defaults`] (also available as [`Default::default`]) to
134/// keep the historical values that today's first-cut Stack uses.
135#[derive(Debug, Clone, Copy)]
136pub struct MacTiming {
137 /// Pause inserted between handing the radio a packet that requested an
138 /// ACK and emitting the ACK reply, so the original sender has time to
139 /// transition back to RX. Default: 10 ms.
140 pub ack_tx_delay: Duration,
141 /// Per-attempt window to wait for the requested ACK before retrying.
142 /// Default: 50 ms.
143 pub ack_timeout: Duration,
144 /// Pause between consecutive TX attempts when the previous attempt's
145 /// ACK timed out. Default: 200 ms.
146 pub tx_retry_delay: Duration,
147 /// Pause between unsuccessful [`Transceiver::recover`](crate::Transceiver::recover)
148 /// attempts while the link is `Down`. The Runner re-invokes `recover` on
149 /// the next loop iteration with this gap so a stuck radio doesn't spin
150 /// the executor at full speed. Default: 500 ms.
151 pub recover_backoff: Duration,
152}
153
154impl MacTiming {
155 pub const fn defaults() -> Self {
156 Self {
157 ack_tx_delay: Duration::from_millis(10),
158 ack_timeout: Duration::from_millis(50),
159 tx_retry_delay: Duration::from_millis(200),
160 recover_backoff: Duration::from_millis(500),
161 }
162 }
163}
164
165impl Default for MacTiming {
166 fn default() -> Self {
167 Self::defaults()
168 }
169}
170
171/// Internal: a queued user send, picked up by the Runner.
172struct TxRequest {
173 dst: Address,
174 flags: Flags,
175 data: Vec<u8, PAYLOAD_CAP>,
176}
177
178/// Caller-allocated channel buffers for a [`Stack`].
179///
180/// Allocate this in a `static` (typically via `static_cell::make_static!`),
181/// then hand a `&mut` to [`Stack::new`]. The const generic `N_RX` controls
182/// how many incoming packets can buffer between Runner deliveries and
183/// `Stack::recv` consumption.
184pub struct StackResources<const N_RX: usize = 4> {
185 rx: Channel<NoopRawMutex, Packet, N_RX>,
186 tx_request: Channel<NoopRawMutex, TxRequest, 1>,
187 tx_response: Signal<NoopRawMutex, Result<(), TxError>>,
188 tx_mutex: Mutex<NoopRawMutex, ()>,
189 /// Current link state. Wrapped in a blocking mutex so [`Stack`] can read
190 /// it synchronously (the `NoopRawMutex` is a no-op on a single executor).
191 link_state: BlockingMutex<NoopRawMutex, Cell<LinkState>>,
192 /// Wakes async observers (`wait_link_up` / `wait_link_down`) on every
193 /// state transition. Only the most-recent transition is retained, so a
194 /// task that holds neither future across a flap may miss intermediate
195 /// values; the looping wait_* methods re-check the cell after each
196 /// signal to converge on the goal state.
197 link_state_signal: Signal<NoopRawMutex, LinkState>,
198}
199
200impl<const N_RX: usize> StackResources<N_RX> {
201 pub const fn new() -> Self {
202 Self {
203 rx: Channel::new(),
204 tx_request: Channel::new(),
205 tx_response: Signal::new(),
206 tx_mutex: Mutex::new(()),
207 link_state: BlockingMutex::new(Cell::new(LinkState::Up)),
208 link_state_signal: Signal::new(),
209 }
210 }
211}
212
213impl<const N_RX: usize> Default for StackResources<N_RX> {
214 fn default() -> Self {
215 Self::new()
216 }
217}
218
219/// Cheap (`Copy`) handle to a [`Stack`]. Pass to user tasks; clone freely.
220#[derive(Copy, Clone)]
221pub struct Stack<'a> {
222 address: Address,
223 rx: DynamicReceiver<'a, Packet>,
224 tx_request: DynamicSender<'a, TxRequest>,
225 tx_response: &'a Signal<NoopRawMutex, Result<(), TxError>>,
226 tx_mutex: &'a Mutex<NoopRawMutex, ()>,
227 link_state: &'a BlockingMutex<NoopRawMutex, Cell<LinkState>>,
228 link_state_signal: &'a Signal<NoopRawMutex, LinkState>,
229}
230
231/// Long-running task that owns the radio. Spawn it once; call its
232/// [`Runner::run`] method, which never returns.
233pub struct Runner<'a, TRX> {
234 trx: TRX,
235 address: Address,
236 rx: DynamicSender<'a, Packet>,
237 tx_request: DynamicReceiver<'a, TxRequest>,
238 tx_response: &'a Signal<NoopRawMutex, Result<(), TxError>>,
239 timing: MacTiming,
240 link_state: &'a BlockingMutex<NoopRawMutex, Cell<LinkState>>,
241 link_state_signal: &'a Signal<NoopRawMutex, LinkState>,
242 consecutive_errors: u8,
243}
244
245impl<'a> Stack<'a> {
246 /// Splits caller-allocated resources into a [`Stack`] handle and a
247 /// [`Runner`] that owns the radio. After this call the resources are
248 /// pinned in place via the returned references.
249 pub fn new<TRX, const N_RX: usize>(
250 trx: TRX,
251 address: Address,
252 resources: &'a mut StackResources<N_RX>,
253 timing: MacTiming,
254 ) -> (Stack<'a>, Runner<'a, TRX>) {
255 let StackResources {
256 rx,
257 tx_request,
258 tx_response,
259 tx_mutex,
260 link_state,
261 link_state_signal,
262 } = resources;
263 // Reset link state to a known-good baseline in case the resources
264 // were reused (e.g. a `static StackResources` reconstructed in a
265 // test harness). New construction sees this no-op.
266 link_state.lock(|c| c.set(LinkState::Up));
267 link_state_signal.reset();
268 let stack = Stack {
269 address,
270 rx: rx.dyn_receiver(),
271 tx_request: tx_request.dyn_sender(),
272 tx_response,
273 tx_mutex,
274 link_state,
275 link_state_signal,
276 };
277 let runner = Runner {
278 trx,
279 address,
280 rx: rx.dyn_sender(),
281 tx_request: tx_request.dyn_receiver(),
282 tx_response,
283 timing,
284 link_state,
285 link_state_signal,
286 consecutive_errors: 0,
287 };
288 (stack, runner)
289 }
290
291 /// The local address this Stack was constructed with.
292 pub fn address(&self) -> Address {
293 self.address
294 }
295
296 /// Current snapshot of the radio link state. See module-level docs for
297 /// the transition rules.
298 pub fn link_state(&self) -> LinkState {
299 self.link_state.lock(|c| c.get())
300 }
301
302 /// `true` iff [`Stack::link_state`] is [`LinkState::Up`].
303 pub fn is_link_up(&self) -> bool {
304 matches!(self.link_state(), LinkState::Up)
305 }
306
307 /// Resolves when the link state is (or becomes) [`LinkState::Up`].
308 /// Returns immediately if already up.
309 ///
310 /// Only one task may hold this future at a time; running it in two
311 /// tasks concurrently is undefined (one will be woken on the next
312 /// transition, the other can stay parked).
313 pub async fn wait_link_up(&self) {
314 loop {
315 if matches!(self.link_state(), LinkState::Up) {
316 return;
317 }
318 let _ = self.link_state_signal.wait().await;
319 }
320 }
321
322 /// Resolves when the link state is (or becomes) [`LinkState::Down`].
323 /// Returns immediately if already down. Same single-waiter caveat as
324 /// [`Stack::wait_link_up`].
325 pub async fn wait_link_down(&self) {
326 loop {
327 if matches!(self.link_state(), LinkState::Down) {
328 return;
329 }
330 let _ = self.link_state_signal.wait().await;
331 }
332 }
333
334 /// Send `data` to `dst` with the given `flags`. Serialized across all
335 /// concurrent callers via an internal mutex (the radio is half-duplex,
336 /// so only one TX flies at a time anyway).
337 ///
338 /// The order under the lock is **lock → reset response → enqueue
339 /// request → await response**, in that order. Resetting under the lock
340 /// is the cancellation-safety invariant: a previously-cancelled caller
341 /// may have left a stale value in the [`Signal`], and clearing it
342 /// before posting our own request makes sure we wait for the response
343 /// to *our* TX, not the dead one's. See the module-level docs.
344 pub async fn send(&self, dst: Address, flags: Flags, data: &[u8]) -> Result<(), TxError> {
345 let mut buf: Vec<u8, PAYLOAD_CAP> = Vec::new();
346 buf.extend_from_slice(data).map_err(|_| TxError::DataTooLong)?;
347
348 let _guard = self.tx_mutex.lock().await;
349 self.tx_response.reset();
350 self.tx_request.send(TxRequest { dst, flags, data: buf }).await;
351 self.tx_response.wait().await
352 }
353
354 /// Wait for the next packet addressed to this Stack (Unicast match) or
355 /// to `Address::Broadcast`. Other unicasts are filtered out by the
356 /// Runner before they reach the queue.
357 pub async fn recv(&self) -> Packet {
358 self.rx.receive().await
359 }
360}
361
362impl<'a, TRX: Transceiver> Runner<'a, TRX> {
363 /// Run forever. Intended to be spawned as an `#[embassy_executor::task]`.
364 ///
365 /// The body is `select(tx_request_recv, trx.recv())` -- whichever fires
366 /// first wins, and the loser's future is dropped. That's safe by
367 /// construction: `trx.recv()` hasn't started any SPI work while sitting
368 /// in RX, and `tx_request.receive()` is a passive channel poll. The
369 /// radio is never left in a half-configured state.
370 pub async fn run(&mut self) -> ! {
371 loop {
372 // Active recovery: while `Down`, drive `Transceiver::recover`
373 // before any further send/recv. Ok flips link via record_ok;
374 // Err keeps it Down and backs off so a stuck radio doesn't
375 // spin the executor. The default `recover` returns
376 // `TrxError::RecoverUnsupported`, so radios without a custom
377 // impl stay Down (recover retried every recover_backoff) until
378 // a real recover succeeds.
379 if matches!(self.link_state.lock(|c| c.get()), LinkState::Down) {
380 match self.trx.recover().await {
381 Ok(()) => {
382 info!("Stack: recover succeeded");
383 self.record_ok();
384 }
385 Err(e) => {
386 error!("Stack: recover failed: {:?}", e);
387 Timer::after(self.timing.recover_backoff).await;
388 continue;
389 }
390 }
391 }
392 match select(self.tx_request.receive(), self.trx.recv()).await {
393 Either::First(req) => self.handle_tx(req).await,
394 Either::Second(Ok(packet)) => {
395 self.record_ok();
396 self.handle_rx(packet).await
397 }
398 Either::Second(Err(e)) => {
399 error!("Stack: rx error: {:?}", e);
400 self.record_err();
401 }
402 }
403 }
404 }
405
406 async fn handle_tx(&mut self, req: TxRequest) {
407 let result = self.do_send(req).await;
408 self.tx_response.signal(result);
409 }
410
411 async fn do_send(&mut self, req: TxRequest) -> Result<(), TxError> {
412 let packet = Packet::new(self.address, req.dst, req.flags, &req.data)
413 .map_err(|_| TxError::Trx(TrxError::WrongPacketFormat))?;
414
415 match req.flags {
416 Flags::None | Flags::Ack(0) => {
417 info!("Stack: send (no ack)");
418 match self.trx.send(&packet).await {
419 Ok(()) => {
420 self.record_ok();
421 Ok(())
422 }
423 Err(e) => {
424 self.record_err();
425 Err(TxError::Trx(e))
426 }
427 }
428 }
429 Flags::Ack(retries) => {
430 for i in 1..=retries {
431 info!("Stack: send {} of {} (waiting ACK)", i, retries);
432 match self.trx.send(&packet).await {
433 Ok(()) => self.record_ok(),
434 Err(e) => {
435 self.record_err();
436 return Err(TxError::Trx(e));
437 }
438 }
439 match with_timeout(self.timing.ack_timeout, self.wait_for_ack(req.dst)).await {
440 Ok(Ok(())) => return Ok(()),
441 Ok(Err(e)) => return Err(TxError::Trx(e)),
442 Err(_) => Timer::after(self.timing.tx_retry_delay).await,
443 }
444 }
445 Err(TxError::AckTimeout)
446 }
447 }
448 }
449
450 /// Block on `trx.recv()` until our peer's ACK arrives. Non-ACK packets
451 /// that interleaved with the ACK race aren't dropped -- they get pushed
452 /// to the user rx queue via [`Self::try_deliver`] so the inbound side
453 /// doesn't lose user traffic during a TX.
454 async fn wait_for_ack(&mut self, from: Address) -> Result<(), TrxError> {
455 loop {
456 let packet = match self.trx.recv().await {
457 Ok(p) => {
458 self.record_ok();
459 p
460 }
461 Err(e) => {
462 self.record_err();
463 return Err(e);
464 }
465 };
466 if packet.src == from && packet.dst == self.address && packet.is_ack() {
467 info!("Stack: valid ACK");
468 return Ok(());
469 }
470 self.try_deliver(packet);
471 }
472 }
473
474 /// We ACK a packet only when `flags` is `Flags::Ack(n)` with `n > 0`
475 /// **and** `dst == self.address`. Broadcasts never ACK -- ACK storms
476 /// don't scale -- and `Ack(0)` is itself an ACK reply, so ACKing one
477 /// would be infinite recursion. The packet is delivered to the user
478 /// rx queue regardless of whether we ACKed.
479 async fn handle_rx(&mut self, packet: Packet) {
480 if let Flags::Ack(n) = packet.flags
481 && n > 0
482 && packet.dst == self.address
483 {
484 let Ok(ack) = Packet::new(self.address, packet.src, Flags::Ack(0), &[]) else {
485 return;
486 };
487 info!("Stack: replying ACK");
488 Timer::after(self.timing.ack_tx_delay).await;
489 match self.trx.send(&ack).await {
490 Ok(()) => self.record_ok(),
491 Err(e) => {
492 error!("Stack: ACK send failed: {:?}", e);
493 self.record_err();
494 return;
495 }
496 }
497 }
498 self.try_deliver(packet);
499 }
500
501 /// Reset the consecutive-error streak and, if the link was previously
502 /// reported `Down`, flip it back to `Up` and signal observers.
503 fn record_ok(&mut self) {
504 self.consecutive_errors = 0;
505 let was_down = self.link_state.lock(|c| {
506 let prev = c.get();
507 c.set(LinkState::Up);
508 matches!(prev, LinkState::Down)
509 });
510 if was_down {
511 info!("Stack: link up");
512 self.link_state_signal.signal(LinkState::Up);
513 }
514 }
515
516 /// Bump the consecutive-error streak; on crossing [`LINK_DOWN_STREAK`]
517 /// flip the link state to `Down` (idempotent across subsequent errors)
518 /// and signal observers exactly once per `Up`->`Down` transition.
519 fn record_err(&mut self) {
520 self.consecutive_errors = self.consecutive_errors.saturating_add(1);
521 if self.consecutive_errors < LINK_DOWN_STREAK {
522 return;
523 }
524 let was_up = self.link_state.lock(|c| {
525 let prev = c.get();
526 c.set(LinkState::Down);
527 matches!(prev, LinkState::Up)
528 });
529 if was_up {
530 warn!("Stack: link down after {} consecutive errors", LINK_DOWN_STREAK);
531 self.link_state_signal.signal(LinkState::Down);
532 }
533 }
534
535 /// Push to the user rx queue if the packet is addressed to us
536 /// (Unicast match or Broadcast). The push is non-blocking: an
537 /// overflowing rx queue logs a warning and drops the packet, never
538 /// backpressuring the radio. A user task that can't keep up should
539 /// raise `N_RX` rather than rely on the queue holding everything.
540 fn try_deliver(&self, packet: Packet) {
541 if packet.dst != Address::Broadcast && packet.dst != self.address {
542 return;
543 }
544 if self.rx.try_send(packet).is_err() {
545 warn!("Stack: rx queue full, packet dropped");
546 }
547 }
548}