Skip to main content

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}