1use 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
55const QUEUE: usize = 8;
58
59pub struct MeshCoreStorage {
64 forwarder: Forwarder<QUEUE>,
65 sending: Frame,
66 rng: ReseedingRng,
67}
68
69impl MeshCoreStorage {
70 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
81pub static MESHCORE_CH: Channels<NodeMutex, 4, 2> = Channels::new();
85
86pub static MESHCORE_RESEED: ReseedSlot = ReseedSlot::new();
89
90const _: () = assert!(MAX_MESHCORE_REGIONS <= umsh_meshcore_forwarder::MAX_REGIONS);
92
93#[derive(Clone, PartialEq, Eq)]
95struct DomainSettings {
96 enabled: bool,
98 region_policy: RegionPolicy,
99 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
106static SETTINGS: Watch<NodeMutex, DomainSettings, 1> = Watch::new();
110
111static RADIO_CHANGED: Signal<NodeMutex, ()> = Signal::new();
114
115pub(crate) fn publish(snapshot: &DevDomainSnapshot) {
119 SETTINGS.sender().send(DomainSettings {
120 enabled: snapshot.meshcore_forwarding_enabled,
121 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
129pub(crate) fn radio_changed() {
131 RADIO_CHANGED.signal(());
132}
133
134pub 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
151fn 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
160struct State {
162 forwarder: &'static mut Forwarder<QUEUE>,
163 rng: &'static mut ReseedingRng,
164 active: bool,
166}
167
168struct 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
184fn count(stats: Option<&StatsLedger>, verdict: Verdict, header: u8) {
187 let Some(stats) = stats else {
188 return;
189 };
190 let outcome = match verdict {
191 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
216fn gate(identity_allowed: bool, radio_on: bool, enabled: Option<bool>) -> bool {
219 identity_allowed && radio_on && enabled == Some(true)
220}
221
222#[derive(Debug, PartialEq, Eq)]
224enum Sent {
225 Transmitted,
226 Abandoned,
229 Withdrawn,
231 Failed,
232}
233
234async 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
282pub 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 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 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 state.forwarder.reset();
345 state.active = active;
346 }
347 }
348 };
349
350 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 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 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 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 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 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 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 #[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 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}