umsh_ulcp_runtime/
meshcore.rs

1//! The MeshCore forwarder: a third radio client beside the session and
2//! the device node, forwarding MeshCore v1 traffic the way a MeshCore
3//! repeater does.
4//!
5//! The forwarding decisions are [`umsh_meshcore_forwarder`]'s. This
6//! module is what makes it a device: its virtual mux bundle
7//! ([`MESHCORE_CH`]), the shared duty ledger and the CAD gate on its
8//! transmits, the busy backoff it shares with the UMSH MAC, and the
9//! device-domain settings that say whether it runs at all.
10//!
11//! It runs only while all of these hold:
12//!
13//! - the radio is enabled (`PROP_PHY_ENABLED`);
14//! - MeshCore forwarding is enabled (`PROP_MESHCORE_FORWARDING_ENABLED`),
15//!   on its own: the device node's repeater has its own switch;
16//! - the identity it was brought up under has a public key MeshCore lets
17//!   a repeater run under.
18//!
19//! When any of them stops holding, everything the forwarder had seen
20//! and queued is forgotten. Its receive queue is drained regardless:
21//! the mux hands every client a copy of each frame, and in backhaul mode
22//! a full queue here would refuse the host's own transmissions.
23//!
24//! The only identity input is the public key, which supplies this
25//! node's path entries. The forwarder signs nothing and originates
26//! nothing.
27
28use core::cell::RefCell;
29
30use core::future::poll_fn;
31use core::task::Poll;
32
33use embassy_futures::join::join3;
34use embassy_futures::select::{Either, select};
35use embassy_sync::blocking_mutex::raw::NoopRawMutex;
36use embassy_sync::signal::Signal;
37use embassy_sync::watch::Watch;
38use embassy_time::{Instant, Timer};
39use rand_core::RngCore;
40use umsh_hal::{CadPolicy, EmbassyClock, Radio, RxOrigin, TxError, TxOptions};
41use umsh_mac::MAX_CAD_ATTEMPTS;
42use umsh_meshcore_forwarder::packet::{Frame, RouteType};
43use umsh_meshcore_forwarder::{Environment, Forwarder, Heard, RegionPolicy, Settings, Verdict};
44use umsh_radio_loraphy::{Channels, LoraphyRadio};
45use umsh_ulcp::items::MESHCORE_REGION_NAME_MAX_LEN;
46use umsh_ulcp::stats::{Counter, StatsLedger};
47use umsh_ulcp_device::{DutyLedger, MAX_MESHCORE_REGIONS};
48
49use crate::device_node::NodeMutex;
50use crate::driver::DevDomainSnapshot;
51use crate::duty_gate::DutyGatedRadio;
52use crate::log::debug_log;
53use crate::reseed::{ReseedSlot, ReseedingRng};
54
55/// Forwards that may wait out their retransmit delay at once. Each holds
56/// a full radio frame.
57const QUEUE: usize = 8;
58
59/// The forwarder's bulk state: its duplicate memory, its delay queue,
60/// the frame being transmitted, and its generator. A few kilobytes of plain data, which
61/// a board places where its RAM allows—external RAM where it has
62/// some—and hands to [`forwarder_loop`].
63pub struct MeshCoreStorage {
64    forwarder: Forwarder<QUEUE>,
65    sending: Frame,
66    rng: ReseedingRng,
67}
68
69impl MeshCoreStorage {
70    /// Storage for the identity whose public key is `public_key`, with a
71    /// generator seeded from [`derive_seed`].
72    pub fn new(public_key: [u8; 32], seed: [u8; 32]) -> Self {
73        Self {
74            forwarder: Forwarder::new(public_key, Settings::default()),
75            sending: Frame::new(),
76            rng: ReseedingRng::new(seed, &MESHCORE_RESEED),
77        }
78    }
79}
80
81/// The forwarder's virtual radio bundle (a mux client after the device
82/// node). Static whether or not the forwarder is running, like
83/// [`crate::device_node::NODE_CH`].
84pub static MESHCORE_CH: Channels<NodeMutex, 4, 2> = Channels::new();
85
86/// Fresh seeds for the forwarder's generator, from a board that harvests
87/// entropy after boot.
88pub static MESHCORE_RESEED: ReseedSlot = ReseedSlot::new();
89
90/// Every region the session accepts gets a key in the forwarder.
91const _: () = assert!(MAX_MESHCORE_REGIONS <= umsh_meshcore_forwarder::MAX_REGIONS);
92
93/// The device-domain settings the forwarder follows.
94#[derive(Clone, PartialEq, Eq)]
95struct DomainSettings {
96    /// `PROP_MESHCORE_FORWARDING_ENABLED`.
97    enabled: bool,
98    region_policy: RegionPolicy,
99    /// `PROP_MESHCORE_REGIONS`, each name without its `#`.
100    region_names:
101        heapless::Vec<heapless::Vec<u8, MESHCORE_REGION_NAME_MAX_LEN>, MAX_MESHCORE_REGIONS>,
102    min_rssi: Option<i16>,
103    min_snr: Option<i8>,
104}
105
106/// The latest settings. A `Watch` rather than a second consumer of the
107/// device node's single-consumer `DEV_SYNC`: the forwarder starts after
108/// the first publication and must still see it.
109static SETTINGS: Watch<NodeMutex, DomainSettings, 1> = Watch::new();
110
111/// Raised when the radio is switched on or off, which is not a
112/// device-domain change and so arrives by its own path.
113static RADIO_CHANGED: Signal<NodeMutex, ()> = Signal::new();
114
115/// Mirror a device-domain snapshot to the forwarder. Called wherever the
116/// snapshot is published, so a host write, a boot restore, and a
117/// `CMD_RST` all reach it by one path.
118pub(crate) fn publish(snapshot: &DevDomainSnapshot) {
119    SETTINGS.sender().send(DomainSettings {
120        enabled: snapshot.meshcore_forwarding_enabled,
121        // Validated by the session; an unassigned value cannot be stored.
122        region_policy: RegionPolicy::from_u8(snapshot.meshcore_region_policy).unwrap_or_default(),
123        region_names: snapshot.meshcore_region_names.clone(),
124        min_rssi: snapshot.repeater_min_rssi,
125        min_snr: snapshot.repeater_min_snr,
126    });
127}
128
129/// Note that the radio was switched on or off.
130pub(crate) fn radio_changed() {
131    RADIO_CHANGED.signal(());
132}
133
134/// The forwarder's generator seed, derived from the device node's.
135///
136/// Both are expanded from the one boot draw, under different labels, so
137/// a board need not reserve a second hardware read for a generator whose
138/// only job is retransmit timing.
139pub fn derive_seed(node_seed: &[u8; 32]) -> [u8; 32] {
140    let mut seed = [0u8; 32];
141    umsh_crypto::hkdf_sha256(
142        &umsh_crypto::software::SoftwareSha256,
143        node_seed,
144        &[],
145        b"umsh-meshcore-forwarder-rng-v1",
146        &mut seed,
147    );
148    seed
149}
150
151/// A uniform delay in `T_frame/40..=T_frame/4`: the UMSH MAC's backoff
152/// after a busy channel, so both protocols on this radio wait the same
153/// way and neither crowds the other out.
154fn busy_backoff_ms(t_frame_ms: u32, draw: u32) -> u32 {
155    let low = t_frame_ms / 40;
156    let high = (t_frame_ms / 4).max(low);
157    low + ((u64::from(draw) * u64::from(high - low + 1)) >> 32) as u32
158}
159
160/// Everything the three halves of the loop share.
161struct State {
162    forwarder: &'static mut Forwarder<QUEUE>,
163    rng: &'static mut ReseedingRng,
164    /// Whether the forwarder may run, given the settings and the radio.
165    active: bool,
166}
167
168/// The device's side of [`Environment`].
169struct Env<'a> {
170    duty: &'static DutyLedger,
171    rng: &'a mut ReseedingRng,
172}
173
174impl Environment for Env<'_> {
175    fn airtime_ms(&self, len: usize) -> u32 {
176        self.duty.airtime_ms(len)
177    }
178
179    fn random_u32(&mut self) -> u32 {
180        self.rng.next_u32()
181    }
182}
183
184/// Tally a verdict in the device's counters: every reception is seen,
185/// and is then a forward of one kind, declined, or a duplicate.
186fn count(stats: Option<&StatsLedger>, verdict: Verdict, header: u8) {
187    let Some(stats) = stats else {
188        return;
189    };
190    let outcome = match verdict {
191        // Another protocol, or a copy of this device's own transmission.
192        Verdict::Foreign | Verdict::Remembered => return,
193        Verdict::Duplicate => None,
194        Verdict::Scheduled { .. } if RouteType::from_header(header).is_flood() => {
195            Some(Counter::MeshCoreForwardedFlood)
196        }
197        Verdict::Scheduled { .. } => Some(Counter::MeshCoreForwardedRouted),
198        Verdict::Malformed | Verdict::LinkGated | Verdict::Declined(_) | Verdict::QueueFull => {
199            Some(Counter::MeshCoreDeclined)
200        }
201    };
202    stats.bump(Counter::MeshCoreSeen);
203    if let Some(counter) = outcome {
204        stats.bump(counter);
205    }
206}
207
208fn heard(origin: RxOrigin, rssi: i16, snr: umsh_hal::Snr) -> Heard {
209    match origin {
210        RxOrigin::Air => Heard::Air { rssi, snr },
211        RxOrigin::LocalTx => Heard::LocalTx,
212        RxOrigin::Backhaul | RxOrigin::Bearer(_) => Heard::Backhaul,
213    }
214}
215
216/// The forwarder's gate: the radio on, forwarding enabled, and an identity
217/// MeshCore lets a repeater run under. No settings yet means closed.
218fn gate(identity_allowed: bool, radio_on: bool, enabled: Option<bool>) -> bool {
219    identity_allowed && radio_on && enabled == Some(true)
220}
221
222/// How a queued forward left.
223#[derive(Debug, PartialEq, Eq)]
224enum Sent {
225    Transmitted,
226    /// The channel stayed busy, or the duty ledger kept refusing, for
227    /// every attempt.
228    Abandoned,
229    /// The gate closed while it waited.
230    Withdrawn,
231    Failed,
232}
233
234/// The UMSH MAC's channel-access rule: CAD before every attempt, and on
235/// a busy channel—or a refusal from the duty ledger, which arrives the
236/// same way—a random backoff and another try, up to the MAC's attempt
237/// limit.
238async fn transmit_with_backoff<R: Radio>(
239    radio: &mut R,
240    frame: &[u8],
241    mut active: impl FnMut() -> bool,
242    mut draw: impl FnMut() -> u32,
243) -> Sent
244where
245    R::Error: core::fmt::Debug,
246{
247    for attempt in 1..=MAX_CAD_ATTEMPTS {
248        if !active() {
249            return Sent::Withdrawn;
250        }
251        let options = TxOptions {
252            cad: CadPolicy::Gate,
253        };
254        match radio.transmit(frame, options).await {
255            Ok(()) => return Sent::Transmitted,
256            Err(TxError::CadTimeout) if attempt < MAX_CAD_ATTEMPTS => {
257                Timer::after_millis(u64::from(busy_backoff_ms(radio.t_frame_ms(), draw()))).await;
258            }
259            Err(TxError::CadTimeout) => {}
260            Err(TxError::Io(error)) => {
261                debug_log(format_args!("meshcore: transmit FAILED {error:?}"));
262                return Sent::Failed;
263            }
264        }
265    }
266    debug_log(format_args!(
267        "meshcore: abandoned after {MAX_CAD_ATTEMPTS} busy attempts"
268    ));
269    Sent::Abandoned
270}
271
272fn settings_for(domain: &DomainSettings) -> Settings {
273    Settings {
274        region_policy: domain.region_policy,
275        regions: Default::default(),
276        min_rssi: domain.min_rssi,
277        min_snr: domain.min_snr,
278    }
279    .with_region_names(domain.region_names.iter().map(|name| name.as_slice()))
280}
281
282/// Run the forwarder in `storage`, built for the identity whose public
283/// key is `public_key`.
284///
285/// `t_frame_ms` is the worst-case frame airtime the device node's MAC
286/// was given, which sizes the busy backoff the same way for both.
287/// `note_external_load` is the board's battery-estimator coupling,
288/// called after each completed transmit. `stats` is the device's
289/// counter ledger, where a board keeps one.
290pub async fn forwarder_loop(
291    storage: &'static mut MeshCoreStorage,
292    public_key: [u8; 32],
293    t_frame_ms: u32,
294    duty: &'static DutyLedger,
295    stats: Option<&'static StatsLedger>,
296    note_external_load: fn(),
297) -> ! {
298    let identity_allowed = umsh_ulcp::ids::meshcore_identity_allowed(public_key[0]);
299    if !identity_allowed {
300        debug_log(format_args!(
301            "meshcore: identity prefix {:02x} is reserved; not forwarding",
302            public_key[0]
303        ));
304    }
305    let MeshCoreStorage {
306        forwarder,
307        sending,
308        rng,
309    } = storage;
310    let state = RefCell::new(State {
311        forwarder,
312        rng,
313        active: false,
314    });
315    // A forward has been queued; the transmit half re-reads its deadline.
316    let queued: Signal<NoopRawMutex, ()> = Signal::new();
317
318    let settings = async {
319        let Some(mut receiver) = SETTINGS.receiver() else {
320            debug_assert!(false, "meshcore: forwarder_loop is single-caller");
321            return;
322        };
323        // Only the enable outlives an update; the rest goes straight into
324        // the engine, and a copy here would cost the task its size.
325        let mut enabled = None;
326        loop {
327            let update = select(receiver.changed(), RADIO_CHANGED.wait()).await;
328            let mut state = state.borrow_mut();
329            if let Either::First(domain) = &update {
330                enabled = Some(domain.enabled);
331                state.forwarder.set_settings(settings_for(domain));
332            }
333            drop(update);
334            let radio_on = crate::device_node::radio_enabled() == Some(true);
335            let active = gate(identity_allowed, radio_on, enabled);
336            if active != state.active {
337                debug_log(format_args!(
338                    "meshcore: forwarding {}",
339                    if active { "ON" } else { "off" }
340                ));
341                // Restarting with an empty duplicate memory can at most
342                // repeat a frame heard just before the gate closed, which
343                // every other node's duplicate suppression absorbs.
344                state.forwarder.reset();
345                state.active = active;
346            }
347        }
348    };
349
350    // Each frame is handled where the queue hands it over, so the task
351    // never holds a copy of one across a wait.
352    let receive = poll_fn(|cx| {
353        while let Poll::Ready(frame) = MESHCORE_CH.rx.poll_receive(cx) {
354            let mut guard = state.borrow_mut();
355            let State {
356                forwarder,
357                rng,
358                active,
359            } = &mut *guard;
360            if !*active {
361                continue;
362            }
363            let info = frame.info;
364            let verdict = forwarder.receive(
365                &frame.data,
366                heard(info.origin, info.rssi, info.snr),
367                Instant::now().as_millis(),
368                &mut Env {
369                    duty,
370                    rng: &mut **rng,
371                },
372            );
373            let header = frame.data.first().copied().unwrap_or(0);
374            count(stats, verdict, header);
375            if verdict != Verdict::Foreign {
376                debug_log(format_args!(
377                    "meshcore: rx len={} header={header:02x} rssi={} snr={:?}: {verdict:?}",
378                    frame.data.len(),
379                    info.rssi,
380                    info.snr
381                ));
382            }
383            if let Verdict::Scheduled { .. } = verdict {
384                queued.signal(());
385            }
386        }
387        Poll::<()>::Pending
388    });
389
390    let transmit = async {
391        let mut radio = DutyGatedRadio::with_load_hook(
392            LoraphyRadio::new(&MESHCORE_CH, t_frame_ms),
393            duty,
394            EmbassyClock,
395            note_external_load,
396        );
397        loop {
398            loop {
399                let now = Instant::now().as_millis();
400                let next = {
401                    let mut state = state.borrow_mut();
402                    match state.forwarder.pop_due(now) {
403                        Some(frame) => {
404                            *sending = frame;
405                            None
406                        }
407                        None => Some(state.forwarder.next_due_ms()),
408                    }
409                };
410                let Some(next) = next else {
411                    break;
412                };
413                match next {
414                    Some(due) => {
415                        let _ = select(Timer::at(Instant::from_millis(due)), queued.wait()).await;
416                    }
417                    None => queued.wait().await,
418                }
419            }
420            // A gate that closed while this frame waited takes it with
421            // everything else.
422            transmit_with_backoff(
423                &mut radio,
424                sending,
425                || state.borrow().active,
426                || state.borrow_mut().rng.next_u32(),
427            )
428            .await;
429        }
430    };
431
432    join3(settings, receive, transmit).await;
433    unreachable!("the forwarder's loops never end");
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439
440    #[test]
441    fn the_backoff_spans_a_fortieth_to_a_quarter_of_the_frame() {
442        assert_eq!(busy_backoff_ms(1_000, 0), 25);
443        assert_eq!(busy_backoff_ms(1_000, u32::MAX), 250);
444        let mid = busy_backoff_ms(1_000, u32::MAX / 2);
445        assert!((25..=250).contains(&mid));
446        // A frame too short to divide still backs off by something sane.
447        assert_eq!(busy_backoff_ms(3, u32::MAX), 0);
448    }
449
450    fn block_on<F: core::future::Future>(future: F) -> F::Output {
451        let mut future = core::pin::pin!(future);
452        let waker = core::task::Waker::noop();
453        let mut context = core::task::Context::from_waker(waker);
454        loop {
455            if let core::task::Poll::Ready(output) = future.as_mut().poll(&mut context) {
456                return output;
457            }
458        }
459    }
460
461    /// Answers busy a set number of times, then transmits.
462    struct BusyRadio {
463        busy: u8,
464        attempts: u8,
465        fail: bool,
466    }
467
468    impl Radio for BusyRadio {
469        type Error = ();
470
471        async fn transmit(&mut self, _: &[u8], options: TxOptions) -> Result<(), TxError<()>> {
472            assert_eq!(options.cad, CadPolicy::Gate);
473            self.attempts += 1;
474            if self.fail {
475                return Err(TxError::Io(()));
476            }
477            if self.attempts <= self.busy {
478                return Err(TxError::CadTimeout);
479            }
480            Ok(())
481        }
482
483        fn poll_receive(
484            &mut self,
485            _: &mut core::task::Context<'_>,
486            _: &mut [u8],
487        ) -> core::task::Poll<Result<umsh_hal::RxInfo, ()>> {
488            core::task::Poll::Pending
489        }
490
491        fn max_frame_size(&self) -> usize {
492            255
493        }
494
495        fn t_frame_ms(&self) -> u32 {
496            // Short enough that sixteen backoffs pass quickly.
497            8
498        }
499    }
500
501    fn send(radio: &mut BusyRadio, mut active: impl FnMut() -> bool) -> Sent {
502        block_on(transmit_with_backoff(radio, &[0; 16], &mut active, || {
503            u32::MAX
504        }))
505    }
506
507    #[test]
508    fn a_busy_channel_is_retried_and_then_abandoned() {
509        let mut radio = BusyRadio {
510            busy: 3,
511            attempts: 0,
512            fail: false,
513        };
514        assert_eq!(send(&mut radio, || true), Sent::Transmitted);
515        assert_eq!(radio.attempts, 4);
516
517        let mut radio = BusyRadio {
518            busy: u8::MAX,
519            attempts: 0,
520            fail: false,
521        };
522        assert_eq!(send(&mut radio, || true), Sent::Abandoned);
523        assert_eq!(radio.attempts, MAX_CAD_ATTEMPTS);
524    }
525
526    #[test]
527    fn a_closed_gate_withdraws_a_waiting_forward() {
528        let mut radio = BusyRadio {
529            busy: u8::MAX,
530            attempts: 0,
531            fail: false,
532        };
533        let mut checks = 0;
534        let sent = send(&mut radio, || {
535            checks += 1;
536            checks <= 2
537        });
538        assert_eq!(sent, Sent::Withdrawn);
539        assert_eq!(radio.attempts, 2);
540    }
541
542    #[test]
543    fn a_radio_failure_is_not_retried() {
544        let mut radio = BusyRadio {
545            busy: 0,
546            attempts: 0,
547            fail: true,
548        };
549        assert_eq!(send(&mut radio, || true), Sent::Failed);
550        assert_eq!(radio.attempts, 1);
551    }
552
553    fn domain(enabled: bool, names: &[&[u8]]) -> DomainSettings {
554        DomainSettings {
555            enabled,
556            region_policy: RegionPolicy::ScopedOnly,
557            region_names: names
558                .iter()
559                .map(|name| heapless::Vec::from_slice(name).unwrap())
560                .collect(),
561            min_rssi: Some(-100),
562            min_snr: Some(-5),
563        }
564    }
565
566    #[test]
567    fn every_term_of_the_gate_is_required() {
568        assert!(gate(true, true, Some(true)));
569        assert!(!gate(false, true, Some(true)));
570        assert!(!gate(true, false, Some(true)));
571        assert!(!gate(true, true, Some(false)));
572        assert!(!gate(true, true, None));
573    }
574
575    #[test]
576    fn the_engine_takes_the_meshcore_regions_and_shared_thresholds() {
577        let settings = settings_for(&domain(true, &[b"sf-bay", b"pdx"]));
578        assert_eq!(settings.region_policy, RegionPolicy::ScopedOnly);
579        assert_eq!(settings.min_rssi, Some(-100));
580        assert_eq!(settings.min_snr, Some(-5));
581        // Each stored name derives under the `#` it is stored without.
582        assert_eq!(
583            settings.regions.as_slice(),
584            &[
585                umsh_meshcore_forwarder::TransportKey::for_region_name(b"#sf-bay").unwrap(),
586                umsh_meshcore_forwarder::TransportKey::for_region_name(b"#pdx").unwrap(),
587            ]
588        );
589    }
590
591    #[test]
592    fn every_reception_is_seen_and_then_one_thing() {
593        use umsh_meshcore_forwarder::Declined;
594        let stats = StatsLedger::new();
595        let flood = 0x40 | 0x01;
596        let direct = 0x40 | 0x02;
597        count(Some(&stats), Verdict::Scheduled { due_ms: 0 }, flood);
598        count(Some(&stats), Verdict::Scheduled { due_ms: 0 }, direct);
599        count(Some(&stats), Verdict::Duplicate, flood);
600        count(Some(&stats), Verdict::Declined(Declined::ZeroHop), direct);
601        count(Some(&stats), Verdict::LinkGated, flood);
602        count(Some(&stats), Verdict::QueueFull, flood);
603        count(Some(&stats), Verdict::Malformed, flood);
604        // Not receptions: another protocol, and this device's own frame.
605        count(Some(&stats), Verdict::Foreign, 0x00);
606        count(Some(&stats), Verdict::Remembered, flood);
607        count(None, Verdict::Scheduled { due_ms: 0 }, flood);
608        assert_eq!(stats.get(Counter::MeshCoreSeen), 7);
609        assert_eq!(stats.get(Counter::MeshCoreForwardedFlood), 1);
610        assert_eq!(stats.get(Counter::MeshCoreForwardedRouted), 1);
611        assert_eq!(stats.get(Counter::MeshCoreDeclined), 4);
612    }
613
614    /// The whole loop, driven through its real mux bundle with a waker
615    /// that reports whether a delivery woke it: every frame the mux hands
616    /// over is counted, not only the first.
617    #[test]
618    fn every_delivered_frame_wakes_the_loop_and_is_counted() {
619        use core::sync::atomic::{AtomicBool, Ordering};
620        use core::task::{Context, RawWaker, RawWakerVTable, Waker};
621        use umsh_hal::{RxInfo, Snr};
622        use umsh_radio_loraphy::RxFrame;
623
624        static WOKEN: AtomicBool = AtomicBool::new(false);
625        fn clone(_: *const ()) -> RawWaker {
626            RawWaker::new(core::ptr::null(), &VTABLE)
627        }
628        fn wake(_: *const ()) {
629            WOKEN.store(true, Ordering::SeqCst);
630        }
631        fn drop(_: *const ()) {}
632        static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, wake, wake, drop);
633        let waker = unsafe { Waker::from_raw(RawWaker::new(core::ptr::null(), &VTABLE)) };
634        let mut cx = Context::from_waker(&waker);
635
636        let key = [0x5A; 32];
637        let storage: &'static mut MeshCoreStorage =
638            Box::leak(Box::new(MeshCoreStorage::new(key, [1; 32])));
639        let duty: &'static DutyLedger = Box::leak(Box::new(DutyLedger::new()));
640        let stats: &'static StatsLedger = Box::leak(Box::new(StatsLedger::new()));
641        let mut run = Box::pin(forwarder_loop(
642            storage,
643            key,
644            1_000,
645            duty,
646            Some(stats),
647            || {},
648        ));
649
650        crate::device_node::set_radio_enabled(true);
651        SETTINGS.sender().send(domain(true, &[]));
652        assert!(run.as_mut().poll(&mut cx).is_pending());
653
654        for i in 0..6u8 {
655            // An unscoped GRP_TXT flood with an empty path and a unique body.
656            let mut data = heapless::Vec::new();
657            data.extend_from_slice(&[0x15, 0x00, i, 1, 2, 3, 4])
658                .unwrap();
659            let frame = RxFrame {
660                data,
661                info: RxInfo {
662                    len: 7,
663                    rssi: -50,
664                    snr: Snr::from_centibels(100),
665                    lqi: None,
666                    origin: RxOrigin::Air,
667                    buffered: None,
668                },
669            };
670            WOKEN.store(false, Ordering::SeqCst);
671            MESHCORE_CH.rx.try_send(frame).ok().expect("queue has room");
672            assert!(
673                WOKEN.load(Ordering::SeqCst),
674                "frame {i} did not wake the loop"
675            );
676            assert!(run.as_mut().poll(&mut cx).is_pending());
677            assert_eq!(stats.get(Counter::MeshCoreSeen), u32::from(i) + 1);
678        }
679    }
680
681    #[test]
682    fn the_seed_is_not_the_node_seed() {
683        let node = [7u8; 32];
684        assert_ne!(derive_seed(&node), node);
685        assert_eq!(derive_seed(&node), derive_seed(&node));
686    }
687}