umsh_ulcp_runtime/
duty_gate.rs1use umsh_hal::{Clock, Radio, RxInfo, TxError, TxOptions};
22use umsh_ulcp_device::DutyLedger;
23
24pub struct DutyGatedRadio<R, C> {
26 inner: R,
27 ledger: &'static DutyLedger,
28 clock: C,
29 load_hook: fn(),
33}
34
35impl<R, C> DutyGatedRadio<R, C> {
36 pub fn new(inner: R, ledger: &'static DutyLedger, clock: C) -> Self {
37 Self::with_load_hook(inner, ledger, clock, || {})
38 }
39
40 pub fn with_load_hook(
41 inner: R,
42 ledger: &'static DutyLedger,
43 clock: C,
44 load_hook: fn(),
45 ) -> Self {
46 Self {
47 inner,
48 ledger,
49 clock,
50 load_hook,
51 }
52 }
53}
54
55impl<R: Radio, C: Clock> Radio for DutyGatedRadio<R, C> {
56 type Error = R::Error;
57
58 async fn transmit(
59 &mut self,
60 data: &[u8],
61 options: TxOptions,
62 ) -> Result<(), TxError<Self::Error>> {
63 let airtime_ms = self
64 .ledger
65 .admit(self.clock.now_ms(), data.len())
66 .map_err(|_| TxError::CadTimeout)?;
67 self.inner.transmit(data, options).await?;
68 self.ledger.record(self.clock.now_ms(), airtime_ms);
72 (self.load_hook)();
73 Ok(())
74 }
75
76 fn poll_receive(
77 &mut self,
78 cx: &mut core::task::Context<'_>,
79 buf: &mut [u8],
80 ) -> core::task::Poll<Result<RxInfo, Self::Error>> {
81 self.inner.poll_receive(cx, buf)
82 }
83
84 fn max_frame_size(&self) -> usize {
85 self.inner.max_frame_size()
86 }
87
88 fn t_frame_ms(&self) -> u32 {
89 self.inner.t_frame_ms()
90 }
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96 use core::future::Future;
97 use core::task::{Context, Poll, Waker};
98 use std::cell::Cell;
99 use std::rc::Rc;
100
101 fn block_on<F: Future>(future: F) -> F::Output {
102 let mut future = core::pin::pin!(future);
103 let waker = Waker::noop();
104 let mut context = Context::from_waker(&waker);
105 loop {
106 if let Poll::Ready(output) = future.as_mut().poll(&mut context) {
107 return output;
108 }
109 }
110 }
111
112 fn ledger() -> &'static DutyLedger {
113 Box::leak(Box::new(DutyLedger::new()))
114 }
115
116 struct MockRadio {
118 sent: std::vec::Vec<usize>,
119 fail_next: bool,
120 }
121
122 impl Radio for MockRadio {
123 type Error = u8;
124
125 async fn transmit(
126 &mut self,
127 data: &[u8],
128 _options: TxOptions,
129 ) -> Result<(), TxError<Self::Error>> {
130 if self.fail_next {
131 self.fail_next = false;
132 return Err(TxError::Io(0xEE));
133 }
134 self.sent.push(data.len());
135 Ok(())
136 }
137
138 fn poll_receive(
139 &mut self,
140 _cx: &mut Context<'_>,
141 _buf: &mut [u8],
142 ) -> Poll<Result<RxInfo, Self::Error>> {
143 Poll::Pending
144 }
145
146 fn max_frame_size(&self) -> usize {
147 255
148 }
149
150 fn t_frame_ms(&self) -> u32 {
151 1_000
152 }
153 }
154
155 #[derive(Clone)]
156 struct TestClock(Rc<Cell<u64>>);
157
158 impl Clock for TestClock {
159 fn now_ms(&self) -> u64 {
160 self.0.get()
161 }
162 }
163
164 #[test]
170 fn interleaved_session_and_node_tx_share_one_budget() {
171 let ledger = ledger();
172 ledger.set_phy(9, 250_000, 5);
174 ledger.set_limit(655); let now = Rc::new(Cell::new(0u64));
176 let mut node = DutyGatedRadio::new(
177 MockRadio {
178 sent: vec![],
179 fail_next: false,
180 },
181 ledger,
182 TestClock(now.clone()),
183 );
184
185 block_on(node.transmit(&[0u8; 32], TxOptions::default())).unwrap();
187 assert_eq!(node.inner.sent, [32]);
188 assert!(ledger.usage(now.get()) > 0);
189
190 for _ in 0..36 {
194 ledger.record(now.get(), 1_000);
195 }
196 assert!(ledger.would_exceed(now.get(), 100));
198 let refused = block_on(node.transmit(&[0u8; 32], TxOptions::default()));
201 assert!(matches!(refused, Err(TxError::CadTimeout)));
202 assert_eq!(node.inner.sent, [32]);
203 let usage_after_refusal = ledger.usage(now.get());
204
205 now.set(now.get() + 60 * 60 * 1_000);
208 assert_eq!(ledger.usage(now.get()), 0);
209 for _ in 0..36 {
210 ledger.record(now.get(), 1_000);
211 }
212 assert!(ledger.would_exceed(now.get(), 100));
213 assert!(matches!(
214 block_on(node.transmit(&[0u8; 32], TxOptions::default())),
215 Err(TxError::CadTimeout)
216 ));
217 let _ = usage_after_refusal;
218 }
219
220 #[test]
223 fn failed_transmit_records_nothing() {
224 let ledger = ledger();
225 ledger.set_phy(9, 250_000, 5);
226 ledger.set_limit(655);
227 let now = Rc::new(Cell::new(0u64));
228 let mut node = DutyGatedRadio::new(
229 MockRadio {
230 sent: vec![],
231 fail_next: true,
232 },
233 ledger,
234 TestClock(now.clone()),
235 );
236 let failed = block_on(node.transmit(&[0u8; 48], TxOptions::default()));
237 assert!(matches!(failed, Err(TxError::Io(0xEE))));
238 assert_eq!(ledger.usage(0), 0);
239
240 block_on(node.transmit(&[0u8; 48], TxOptions::default())).unwrap();
241 let expected = umsh_ulcp::airtime::lora_airtime_ms(9, 250_000, 5, 48);
242 assert_eq!(
245 u64::from(ledger.usage(0)),
246 u64::from(expected.div_ceil(5)) * 65_535 / 720_000
247 );
248 }
249}