umsh_ulcp_runtime/
radio_mux.rs

1//! Radio multiplexer: shares the one physical radio among multiple clients.
2//!
3//! Today the clients are the ULCP session and the device node. The
4//! physical radio's [`Channels`] bundle has a single `tx_done` signal, so
5//! completion attribution breaks the moment two clients transmit. The mux
6//! owns the real bundle and gives every client a private virtual
7//! [`Channels`] of the same type:
8//!
9//! - **TX**: requests are granted one at a time (round-robin under
10//!   contention) and forwarded to the real TX queue; the real `tx_done`
11//!   result is routed to the granting client's own `tx_done`, so each
12//!   client sees exactly the completions for its own requests. Once the
13//!   frame is on the air every *other* client receives a copy of it,
14//!   marked [`RxOrigin::LocalTx`]. A radio cannot hear itself, but the
15//!   clients share one antenna, and a client that never learns what the
16//!   antenna beside it emitted cannot talk to it at all.
17//! - **RX**: every received frame is fanned out to every client. Dual
18//!   delivery is what the ULCP spec requires — frames addressed
19//!   to the device identity are processed by the device itself *and*
20//!   independently offered to host receive filtering. A client whose RX
21//!   queue is full loses that frame (the same drop policy the radio
22//!   runner applies to the real queue) without stalling the others.
23//!
24//! RX fan-out continues while a transmit is in flight; only the grant of
25//! the *next* TX waits for the previous completion. The runner-side
26//! controls (`DeviceControl`: settings, RSSI sampling) are single-owner
27//! device-domain state and bypass the mux entirely.
28//!
29//! # Backhaul mode
30//!
31//! [`MuxMode::set_backhaul`] moves the session client off the shared
32//! medium and onto a point-to-point link with the rest of the clients:
33//!
34//! - Receptions off the air go to the medium clients only.
35//! - A session transmit never reaches the radio. It is handed to the
36//!   medium clients as [`RxOrigin::Backhaul`], which the MAC treats as a
37//!   frame heard from a neighbor and may therefore repeat.
38//! - Medium transmits are unchanged, so the session still sees everything
39//!   the device puts on the air.
40//!
41//! The device's own repeater then carries traffic in both directions
42//! between the attached host and the mesh, which is what a bridge needs:
43//! its forwarding rules, duplicate suppression, and hop accounting all
44//! apply to the tunneled traffic instead of being reimplemented above it.
45
46use core::future::poll_fn;
47use core::sync::atomic::{AtomicBool, Ordering};
48use core::task::Poll;
49
50use embassy_futures::select::{Either3, select3};
51use embassy_sync::blocking_mutex::raw::RawMutex;
52use umsh_core::{Fcf, UMSH_VERSION};
53use umsh_hal::{RxInfo, RxOrigin, Snr, TxError};
54use umsh_radio_loraphy::{Channels, MAX_PAYLOAD, RxFrame, TxRequest};
55use umsh_ulcp::stats::{Counter, StatsLedger};
56
57/// Index in `clients` of the ULCP session's virtual bundle.
58///
59/// Backhaul mode is defined in terms of "the session" and "the medium",
60/// so the mux has to know which client is which. Every board wires the
61/// session first.
62const SESSION: usize = 0;
63
64/// How the mux is routing, written by whoever handles
65/// [`Effect::ApplyBackhaul`](umsh_ulcp_device::Effect) and read by the
66/// mux as each frame is routed.
67pub struct MuxMode(AtomicBool);
68
69impl MuxMode {
70    pub const fn new() -> Self {
71        Self(AtomicBool::new(false))
72    }
73
74    /// Put the session client on a point-to-point link with the medium
75    /// clients, or return it to the shared medium.
76    pub fn set_backhaul(&self, enabled: bool) {
77        self.0.store(enabled, Ordering::Relaxed);
78    }
79
80    /// Whether backhaul mode is in effect.
81    pub fn backhaul(&self) -> bool {
82        self.0.load(Ordering::Relaxed)
83    }
84}
85
86impl Default for MuxMode {
87    fn default() -> Self {
88        Self::new()
89    }
90}
91
92/// The mode cell every board's mux and session driver share.
93pub static MUX_MODE: MuxMode = MuxMode::new();
94
95/// Run the multiplexer over the real radio `Channels` bundle.
96///
97/// `real` must be the bundle served by the radio runner, and the mux must
98/// be that bundle's only client. Each entry in `clients` is one virtual
99/// bundle, owned (RX-drained and TX-fed) by exactly one radio client.
100/// `clients[0]` must be the ULCP session; the rest are on the medium.
101///
102/// # Counting
103///
104/// This is the only place on a device where every frame that reaches the
105/// air, and every frame that comes off it, passes exactly once no matter
106/// which client owns it — the ULCP session transmits straight to the
107/// radio and never touches the device node's MAC — so `stats` is
108/// tallied here rather than anywhere further up. A board with no ledger
109/// passes `None`.
110pub async fn radio_mux<M, const RX: usize, const TX: usize>(
111    real: &'static Channels<M, RX, TX>,
112    clients: &'static [&'static Channels<M, RX, TX>],
113    mode: &'static MuxMode,
114    stats: Option<&'static StatsLedger>,
115) -> !
116where
117    M: RawMutex,
118{
119    // The transmit currently at the radio, if any. The frame bytes are
120    // kept because `tx_done` reports only a result, and the copy owed to
121    // the other clients can only be sent once the radio confirms the
122    // frame actually went out.
123    let mut in_flight: Option<InFlight> = None;
124    // Where the next contended TX scan starts, so one busy client cannot
125    // starve the others.
126    let mut arbitration_start: usize = 0;
127
128    loop {
129        // Only wait for a TX completion while one is outstanding, so a
130        // spurious tx_done can never be attributed to anyone.
131        let tx_done = async {
132            match in_flight {
133                Some(_) => real.tx_done.wait().await,
134                None => core::future::pending().await,
135            }
136        };
137        // Only grant a new transmit while the radio is free; queued
138        // requests keep waiting in their client's virtual TX queue.
139        let next_tx = async {
140            match in_flight {
141                None => receive_any_tx(clients, arbitration_start).await,
142                Some(_) => core::future::pending().await,
143            }
144        };
145
146        match select3(real.rx.receive(), tx_done, next_tx).await {
147            Either3::First(frame) => {
148                // Counted before delivery, because who hears it is a
149                // routing question and this one is about the antenna.
150                note_reception(stats, &frame.data);
151                // In backhaul mode the session is not on the medium, so
152                // it hears nothing off the air.
153                let skip = mode.backhaul().then_some(SESSION);
154                deliver(clients, skip, &frame.data, frame.info);
155            }
156            Either3::Second(result) => {
157                let Some(sent) = in_flight.take() else {
158                    continue;
159                };
160                let aired = result.is_ok();
161                if let Some(stats) = stats {
162                    // Every completion the radio reports arrives here, and
163                    // only frames that went to the radio have one — see
164                    // the backhaul branch below, which answers its own.
165                    match &result {
166                        Ok(()) => stats.bump(Counter::TxPackets),
167                        Err(TxError::CadTimeout) => stats.bump(Counter::TxChannelBusy),
168                        Err(_) => {}
169                    }
170                }
171                clients[sent.owner].tx_done.signal(result);
172                if aired {
173                    deliver(
174                        clients,
175                        Some(sent.owner),
176                        &sent.data,
177                        unmeasured(sent.data.len(), RxOrigin::LocalTx),
178                    );
179                }
180            }
181            Either3::Third((who, request)) => {
182                // Drop any stale latched completion (e.g. from an earlier
183                // transmit whose requester was cancelled before consuming
184                // it) so the client can only observe this request's
185                // result. `try_take` leaves a registered waiter intact,
186                // unlike `reset`, which would silently drop its waker.
187                let _ = clients[who].tx_done.try_take();
188                arbitration_start = (who + 1) % clients.len();
189
190                if who == SESSION && mode.backhaul() {
191                    // The session's link is point to point: the frame
192                    // goes to the medium clients and nowhere else.
193                    //
194                    // Nothing is counted on this path, and the early
195                    // return is what keeps it that way: the completion it
196                    // signals below is synthesized here, never seen by the
197                    // radio, so it cannot reach the arm above. A frame
198                    // that never reached the air is not a transmission,
199                    // and the busy verdict this branch invents to report a
200                    // backed-up tunnel is not a busy channel. If this
201                    // early return ever goes away, the counting above has
202                    // to grow a guard.
203                    let delivered = deliver(
204                        clients,
205                        Some(SESSION),
206                        &request.data,
207                        unmeasured(request.data.len(), RxOrigin::Backhaul),
208                    );
209                    // A full receive queue means the far side is behind,
210                    // and this frame is gone. Reporting it as a busy
211                    // channel is both true of the link and the one
212                    // outcome the sender already knows how to retry.
213                    clients[SESSION].tx_done.signal(if delivered {
214                        Ok(())
215                    } else {
216                        Err(TxError::CadTimeout)
217                    });
218                    continue;
219                }
220
221                let data = request.data.clone();
222                real.tx.send(request).await;
223                in_flight = Some(InFlight { owner: who, data });
224            }
225        }
226    }
227}
228
229/// A transmit handed to the radio, held until its completion is known.
230struct InFlight {
231    owner: usize,
232    data: heapless::Vec<u8, MAX_PAYLOAD>,
233}
234
235/// Tally one reception off the air, split by whether it is ours.
236///
237/// The test is the frame-control field alone — the protocol version and
238/// the reserved bit — not a header parse. The MAC walks the header
239/// anyway a moment later, and this runs in the path every client shares.
240///
241/// It is a test of provenance, not of health: a truncated or damaged
242/// UMSH frame still counts as UMSH, which is right. What went wrong with
243/// a frame of ours shows up in the CRC tally and in the gap between
244/// receptions and the ones the node acted on. What lands in
245/// `RxNonUmsh` is somebody else's traffic on the same sync word.
246fn note_reception(stats: Option<&'static StatsLedger>, data: &[u8]) {
247    let Some(stats) = stats else {
248        return;
249    };
250    let ours = match data.first() {
251        Some(&first) => {
252            let fcf = Fcf(first);
253            fcf.version() == UMSH_VERSION && fcf.reserved_valid()
254        }
255        // A zero-length reception is not a packet of anyone's, and the
256        // radio should never hand one up; count it with the foreign
257        // traffic rather than inventing a third bucket for it.
258        None => false,
259    };
260    stats.bump(if ours {
261        Counter::RxPackets
262    } else {
263        Counter::RxNonUmsh
264    });
265}
266
267/// Metadata for a frame that reached a client without being received:
268/// there is nothing to report but the length and where it came from.
269fn unmeasured(len: usize, origin: RxOrigin) -> RxInfo {
270    RxInfo {
271        len,
272        rssi: 0,
273        snr: Snr::from_centibels(0),
274        lqi: None,
275        origin,
276    }
277}
278
279/// Copy `data` into every client's receive queue except `skip`, returning
280/// whether every intended recipient accepted it. A client whose queue is
281/// full loses the frame rather than stalling the others.
282fn deliver<M, const RX: usize, const TX: usize>(
283    clients: &[&Channels<M, RX, TX>],
284    skip: Option<usize>,
285    data: &heapless::Vec<u8, MAX_PAYLOAD>,
286    info: RxInfo,
287) -> bool
288where
289    M: RawMutex,
290{
291    let mut delivered = true;
292    for (index, client) in clients.iter().enumerate() {
293        if Some(index) == skip {
294            continue;
295        }
296        let copy = RxFrame {
297            data: data.clone(),
298            info,
299        };
300        if client.rx.try_send(copy).is_ok() {
301            client.rx_waker.wake();
302        } else {
303            delivered = false;
304        }
305    }
306    delivered
307}
308
309/// Wait for a TX request from any client, scanning from `start` so
310/// arbitration round-robins instead of always favoring client 0.
311async fn receive_any_tx<M, const RX: usize, const TX: usize>(
312    clients: &[&Channels<M, RX, TX>],
313    start: usize,
314) -> (usize, TxRequest)
315where
316    M: RawMutex,
317{
318    poll_fn(move |cx| {
319        // Register with every queue before scanning: a send racing in
320        // behind an empty scan must still wake this future.
321        for client in clients {
322            let _ = client.tx.poll_ready_to_receive(cx);
323        }
324        for offset in 0..clients.len() {
325            let index = (start + offset) % clients.len();
326            if let Ok(request) = clients[index].tx.try_receive() {
327                return Poll::Ready((index, request));
328            }
329        }
330        Poll::Pending
331    })
332    .await
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use core::future::Future;
339    use core::sync::atomic::{AtomicUsize, Ordering};
340    use core::task::{Context, Waker};
341    use embassy_futures::select::{Either, select};
342    use embassy_sync::blocking_mutex::raw::NoopRawMutex;
343    use lora_phy::mod_params::RadioError;
344    use std::sync::Arc;
345    use umsh_hal::CadPolicy;
346
347    type TestCh = Channels<NoopRawMutex, 4, 2>;
348
349    fn block_on<F: Future>(future: F) -> F::Output {
350        let mut future = core::pin::pin!(future);
351        let waker = Waker::noop();
352        let mut context = Context::from_waker(&waker);
353        loop {
354            if let Poll::Ready(output) = future.as_mut().poll(&mut context) {
355                return output;
356            }
357        }
358    }
359
360    fn channels() -> &'static TestCh {
361        Box::leak(Box::new(Channels::new()))
362    }
363
364    /// Drive the mux and a test scenario concurrently until the scenario
365    /// completes. Each run gets its own mode cell, so tests sharing a
366    /// process cannot see each other's routing.
367    fn run<F: Future>(
368        real: &'static TestCh,
369        clients: &'static [&'static TestCh],
370        scenario: F,
371    ) -> F::Output {
372        run_with_mode(real, clients, mode(), scenario)
373    }
374
375    fn run_with_mode<F: Future>(
376        real: &'static TestCh,
377        clients: &'static [&'static TestCh],
378        mode: &'static MuxMode,
379        scenario: F,
380    ) -> F::Output {
381        drive(real, clients, mode, None, scenario)
382    }
383
384    /// Drive the mux with a ledger attached, so a scenario can read back
385    /// what it counted.
386    fn run_with_stats<F: Future>(
387        real: &'static TestCh,
388        clients: &'static [&'static TestCh],
389        mode: &'static MuxMode,
390        stats: &'static StatsLedger,
391        scenario: F,
392    ) -> F::Output {
393        drive(real, clients, mode, Some(stats), scenario)
394    }
395
396    fn drive<F: Future>(
397        real: &'static TestCh,
398        clients: &'static [&'static TestCh],
399        mode: &'static MuxMode,
400        stats: Option<&'static StatsLedger>,
401        scenario: F,
402    ) -> F::Output {
403        block_on(async {
404            match select(radio_mux(real, clients, mode, stats), scenario).await {
405                Either::First(_) => unreachable!("mux never returns"),
406                Either::Second(output) => output,
407            }
408        })
409    }
410
411    fn ledger() -> &'static StatsLedger {
412        Box::leak(Box::new(StatsLedger::new()))
413    }
414
415    fn mode() -> &'static MuxMode {
416        Box::leak(Box::new(MuxMode::new()))
417    }
418
419    fn backhaul_mode() -> &'static MuxMode {
420        let mode = mode();
421        mode.set_backhaul(true);
422        mode
423    }
424
425    fn rx_frame(tag: u8) -> RxFrame {
426        let mut data = heapless::Vec::new();
427        data.push(tag).unwrap();
428        RxFrame {
429            data,
430            info: RxInfo {
431                len: 1,
432                rssi: -40,
433                snr: Snr::from_decibels(5),
434                lqi: None,
435                origin: RxOrigin::Air,
436            },
437        }
438    }
439
440    fn tx_request(tag: u8) -> TxRequest {
441        let mut data = heapless::Vec::new();
442        data.push(tag).unwrap();
443        TxRequest {
444            data,
445            power_dbm: None,
446            cad: CadPolicy::Skip,
447        }
448    }
449
450    #[test]
451    fn tx_completions_route_to_the_requesting_client() {
452        let real = channels();
453        let a = channels();
454        let b = channels();
455        let clients: &'static [&'static TestCh] = Box::leak(Box::new([a, b]));
456
457        run(real, clients, async {
458            a.tx.send(tx_request(0xA1)).await;
459            b.tx.send(tx_request(0xB1)).await;
460
461            // Client A queued first from a fresh mux, so its request is
462            // granted first; B's stays held until A's completion.
463            let granted = real.tx.receive().await;
464            assert_eq!(granted.data.as_slice(), &[0xA1]);
465            assert!(
466                real.tx.try_receive().is_err(),
467                "B granted while A in flight"
468            );
469
470            real.tx_done
471                .signal(Err(TxError::Io(RadioError::TransmitTimeout)));
472            assert!(a.tx_done.wait().await.is_err());
473            assert!(b.tx_done.try_take().is_none(), "completion leaked to B");
474
475            let granted = real.tx.receive().await;
476            assert_eq!(granted.data.as_slice(), &[0xB1]);
477            real.tx_done.signal(Ok(()));
478            assert!(b.tx_done.wait().await.is_ok());
479            assert!(a.tx_done.try_take().is_none(), "completion leaked to A");
480        });
481    }
482
483    #[test]
484    fn tx_grants_round_robin_under_contention() {
485        let real = channels();
486        let a = channels();
487        let b = channels();
488        let clients: &'static [&'static TestCh] = Box::leak(Box::new([a, b]));
489
490        run(real, clients, async {
491            a.tx.send(tx_request(0xA1)).await;
492            a.tx.send(tx_request(0xA2)).await;
493            b.tx.send(tx_request(0xB1)).await;
494
495            // A1 goes first; B1 must beat A2 even though A queued earlier.
496            let mut order = std::vec::Vec::new();
497            for _ in 0..3 {
498                let granted = real.tx.receive().await;
499                order.push(granted.data[0]);
500                real.tx_done.signal(Ok(()));
501                // Consume the routed completion so the next wait is clean.
502                let owner = if order.last() == Some(&0xB1) { b } else { a };
503                assert!(owner.tx_done.wait().await.is_ok());
504            }
505            assert_eq!(order, [0xA1, 0xB1, 0xA2]);
506        });
507    }
508
509    #[test]
510    fn rx_fans_out_to_every_client() {
511        let real = channels();
512        let a = channels();
513        let b = channels();
514        let clients: &'static [&'static TestCh] = Box::leak(Box::new([a, b]));
515
516        struct CountingWake(AtomicUsize);
517        impl std::task::Wake for CountingWake {
518            fn wake(self: Arc<Self>) {
519                self.0.fetch_add(1, Ordering::SeqCst);
520            }
521        }
522
523        run(real, clients, async {
524            // Client B consumes frames the way LoraphyRadio does: via
525            // rx_waker, not the channel's own waitlist.
526            let wakes = Arc::new(CountingWake(AtomicUsize::new(0)));
527            b.rx_waker.register(&Waker::from(wakes.clone()));
528
529            real.rx.send(rx_frame(0x11)).await;
530
531            let got_a = a.rx.receive().await;
532            assert_eq!(got_a.data.as_slice(), &[0x11]);
533            assert_eq!(got_a.info.rssi, -40);
534            let got_b = b.rx.receive().await;
535            assert_eq!(got_b.data.as_slice(), &[0x11]);
536            assert!(wakes.0.load(Ordering::SeqCst) > 0, "rx_waker not woken");
537        });
538    }
539
540    #[test]
541    fn rx_overflow_drops_only_the_full_client() {
542        let real = channels();
543        let a = channels();
544        let b = channels();
545        let clients: &'static [&'static TestCh] = Box::leak(Box::new([a, b]));
546
547        run(real, clients, async {
548            // Nobody drains A, so it saturates at its queue depth of 4;
549            // B keeps receiving every frame with the mux never stalling.
550            for tag in 0..6u8 {
551                real.rx.send(rx_frame(tag)).await;
552                let got = b.rx.receive().await;
553                assert_eq!(got.data.as_slice(), &[tag]);
554            }
555            for expected in 0..4u8 {
556                let got = a.rx.try_receive().expect("frame dropped early");
557                assert_eq!(got.data.as_slice(), &[expected]);
558            }
559            assert!(a.rx.try_receive().is_err(), "overflow frame not dropped");
560        });
561    }
562
563    #[test]
564    fn rx_continues_while_tx_in_flight() {
565        let real = channels();
566        let a = channels();
567        let b = channels();
568        let clients: &'static [&'static TestCh] = Box::leak(Box::new([a, b]));
569
570        run(real, clients, async {
571            a.tx.send(tx_request(0xA1)).await;
572            let granted = real.tx.receive().await;
573            assert_eq!(granted.data.as_slice(), &[0xA1]);
574
575            // No completion yet — fan-out must not be blocked behind it.
576            real.rx.send(rx_frame(0x22)).await;
577            assert_eq!(b.rx.receive().await.data.as_slice(), &[0x22]);
578            assert_eq!(a.rx.receive().await.data.as_slice(), &[0x22]);
579
580            real.tx_done.signal(Ok(()));
581            assert!(a.tx_done.wait().await.is_ok());
582        });
583    }
584
585    #[test]
586    fn a_transmit_reaches_every_other_client() {
587        let real = channels();
588        let a = channels();
589        let b = channels();
590        let clients: &'static [&'static TestCh] = Box::leak(Box::new([a, b]));
591
592        run(real, clients, async {
593            a.tx.send(tx_request(0xA1)).await;
594            assert_eq!(real.tx.receive().await.data.as_slice(), &[0xA1]);
595            real.tx_done.signal(Ok(()));
596            assert!(a.tx_done.wait().await.is_ok());
597
598            let copy = b.rx.receive().await;
599            assert_eq!(copy.data.as_slice(), &[0xA1]);
600            assert_eq!(copy.info.origin, RxOrigin::LocalTx);
601            assert!(!copy.info.origin.is_measured());
602            assert!(
603                a.rx.try_receive().is_err(),
604                "a transmitter must not hear itself"
605            );
606        });
607    }
608
609    #[test]
610    fn an_abandoned_transmit_reaches_nobody() {
611        let real = channels();
612        let a = channels();
613        let b = channels();
614        let clients: &'static [&'static TestCh] = Box::leak(Box::new([a, b]));
615
616        run(real, clients, async {
617            a.tx.send(tx_request(0xA1)).await;
618            assert_eq!(real.tx.receive().await.data.as_slice(), &[0xA1]);
619            // CAD found the channel busy, so the frame never went out.
620            real.tx_done.signal(Err(TxError::CadTimeout));
621            assert!(a.tx_done.wait().await.is_err());
622
623            assert!(
624                b.rx.try_receive().is_err(),
625                "a frame that never aired was copied anyway"
626            );
627        });
628    }
629
630    #[test]
631    fn backhaul_keeps_the_session_off_the_air() {
632        let real = channels();
633        let session = channels();
634        let node = channels();
635        let clients: &'static [&'static TestCh] = Box::leak(Box::new([session, node]));
636
637        run_with_mode(real, clients, backhaul_mode(), async {
638            // The session's frame goes to the node, not to the radio.
639            session.tx.send(tx_request(0x51)).await;
640            let handed = node.rx.receive().await;
641            assert_eq!(handed.data.as_slice(), &[0x51]);
642            assert_eq!(handed.info.origin, RxOrigin::Backhaul);
643            assert!(
644                real.tx.try_receive().is_err(),
645                "backhaul frame reached the radio"
646            );
647            assert!(
648                session.tx_done.wait().await.is_ok(),
649                "a delivered frame must complete"
650            );
651
652            // Receptions belong to the medium, which the session left.
653            real.rx.send(rx_frame(0x22)).await;
654            assert_eq!(node.rx.receive().await.data.as_slice(), &[0x22]);
655            assert!(
656                session.rx.try_receive().is_err(),
657                "session heard the air in backhaul mode"
658            );
659
660            // What the node transmits still reaches the session, which is
661            // how the host sees anything at all from here.
662            node.tx.send(tx_request(0x77)).await;
663            assert_eq!(real.tx.receive().await.data.as_slice(), &[0x77]);
664            real.tx_done.signal(Ok(()));
665            assert!(node.tx_done.wait().await.is_ok());
666            let copy = session.rx.receive().await;
667            assert_eq!(copy.data.as_slice(), &[0x77]);
668            assert_eq!(copy.info.origin, RxOrigin::LocalTx);
669        });
670    }
671
672    #[test]
673    fn a_backlogged_backhaul_reports_a_busy_channel() {
674        let real = channels();
675        let session = channels();
676        let node = channels();
677        let clients: &'static [&'static TestCh] = Box::leak(Box::new([session, node]));
678
679        run_with_mode(real, clients, backhaul_mode(), async {
680            // Nobody drains the node, so its queue fills at depth 4.
681            for tag in 0..4u8 {
682                session.tx.send(tx_request(tag)).await;
683                assert!(session.tx_done.wait().await.is_ok());
684            }
685            session.tx.send(tx_request(0xFF)).await;
686            // The frame is gone rather than queued, and saying so lets
687            // the sender retry instead of counting it as delivered.
688            assert!(matches!(
689                session.tx_done.wait().await,
690                Err(TxError::CadTimeout)
691            ));
692        });
693    }
694
695    #[test]
696    fn stale_latched_completion_is_cleared_at_grant() {
697        let real = channels();
698        let a = channels();
699        let clients: &'static [&'static TestCh] = Box::leak(Box::new([a]));
700
701        run(real, clients, async {
702            // A previous requester abandoned its completion.
703            a.tx_done
704                .signal(Err(TxError::Io(RadioError::TransmitTimeout)));
705
706            a.tx.send(tx_request(0xA1)).await;
707            let granted = real.tx.receive().await;
708            assert_eq!(granted.data.as_slice(), &[0xA1]);
709            // The grant cleared the stale result; only the real outcome
710            // of this transmit can reach the client now.
711            assert!(a.tx_done.try_take().is_none(), "stale completion survived");
712
713            real.tx_done.signal(Ok(()));
714            assert!(a.tx_done.wait().await.is_ok());
715        });
716    }
717
718    /// A frame whose first octet carries the UMSH version and a clear
719    /// reserved bit.
720    fn umsh_rx_frame(tag: u8) -> RxFrame {
721        let mut frame = rx_frame(Fcf::new(umsh_core::PacketType::Broadcast, false, false).0);
722        frame.data.push(tag).unwrap();
723        frame.info.len = frame.data.len();
724        frame
725    }
726
727    #[test]
728    fn counts_transmits_at_the_radio_and_receptions_off_the_air() {
729        let real = channels();
730        let session = channels();
731        let node = channels();
732        let clients: &'static [&'static TestCh] = Box::leak(Box::new([session, node]));
733        let stats = ledger();
734
735        run_with_stats(real, clients, mode(), stats, async {
736            // A session transmit — the traffic the device node's MAC
737            // never sees, and the reason counting happens here.
738            session.tx.send(tx_request(0xA1)).await;
739            let _ = real.tx.receive().await;
740            real.tx_done.signal(Ok(()));
741            assert!(session.tx_done.wait().await.is_ok());
742            // Its own client is skipped, but the other one gets a copy;
743            // that copy is not a reception and must not be counted.
744            assert_eq!(node.rx.receive().await.info.origin, RxOrigin::LocalTx);
745
746            // A busy channel is an attempt, not a transmission.
747            node.tx.send(tx_request(0xB1)).await;
748            let _ = real.tx.receive().await;
749            real.tx_done.signal(Err(TxError::CadTimeout));
750            assert!(node.tx_done.wait().await.is_err());
751
752            // A radio fault is neither.
753            node.tx.send(tx_request(0xB2)).await;
754            let _ = real.tx.receive().await;
755            real.tx_done
756                .signal(Err(TxError::Io(RadioError::TransmitTimeout)));
757            assert!(node.tx_done.wait().await.is_err());
758
759            real.rx.send(umsh_rx_frame(0x01)).await;
760            let _ = session.rx.receive().await;
761            let _ = node.rx.receive().await;
762            // 0x00 is version 0, not ours.
763            real.rx.send(rx_frame(0x00)).await;
764            let _ = session.rx.receive().await;
765            let _ = node.rx.receive().await;
766
767            assert_eq!(stats.get(Counter::TxPackets), 1);
768            assert_eq!(stats.get(Counter::TxChannelBusy), 1);
769            assert_eq!(stats.get(Counter::RxPackets), 1);
770            assert_eq!(stats.get(Counter::RxNonUmsh), 1);
771        });
772    }
773
774    /// In backhaul mode a session frame is handed to the medium clients
775    /// and never reaches the radio, so it is not a transmission — and the
776    /// busy verdict the mux invents to report a backed-up tunnel is not a
777    /// busy channel either.
778    #[test]
779    fn counts_nothing_for_a_frame_that_never_reached_the_air() {
780        let real = channels();
781        let session = channels();
782        let node = channels();
783        let clients: &'static [&'static TestCh] = Box::leak(Box::new([session, node]));
784        let stats = ledger();
785
786        run_with_stats(real, clients, backhaul_mode(), stats, async {
787            session.tx.send(tx_request(0x51)).await;
788            let _ = node.rx.receive().await;
789            assert!(session.tx_done.wait().await.is_ok());
790
791            // Now fill the node's queue so the next one is refused.
792            for tag in 0..4u8 {
793                session.tx.send(tx_request(tag)).await;
794                assert!(session.tx_done.wait().await.is_ok());
795            }
796            session.tx.send(tx_request(0xFF)).await;
797            assert!(matches!(
798                session.tx_done.wait().await,
799                Err(TxError::CadTimeout)
800            ));
801
802            assert_eq!(stats.get(Counter::TxPackets), 0);
803            assert_eq!(stats.get(Counter::TxChannelBusy), 0);
804            assert_eq!(stats.get(Counter::RxPackets), 0);
805        });
806    }
807}