1use core::cell::RefCell;
15
16use embassy_sync::blocking_mutex::Mutex;
17use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
18use umsh_ulcp::airtime::lora_airtime_ms;
19use umsh_ulcp::ids::DUTY_LIMIT_DISABLED;
20
21const INTERVAL_MS: u64 = 240_000;
23const BINS: usize = 15;
25const UNIT_MS: u32 = 5;
27const HOUR_UNITS: u64 = 720_000;
30
31#[derive(Debug)]
32pub struct DutyTracker {
33 bins: [u16; BINS],
34 current: usize,
35 interval: u64,
37}
38
39impl DutyTracker {
40 pub const fn new() -> Self {
41 Self {
42 bins: [0; BINS],
43 current: 0,
44 interval: 0,
45 }
46 }
47
48 pub fn advance(&mut self, now_ms: u64) {
50 let interval = now_ms / INTERVAL_MS;
51 let elapsed = interval.saturating_sub(self.interval);
52 if elapsed >= BINS as u64 {
53 self.bins = [0; BINS];
54 } else {
55 for _ in 0..elapsed {
56 self.current = (self.current + 1) % BINS;
57 self.bins[self.current] = 0;
58 }
59 }
60 self.interval = interval;
61 }
62
63 pub fn record(&mut self, now_ms: u64, airtime_ms: u32) {
66 self.advance(now_ms);
67 let units = airtime_ms.div_ceil(UNIT_MS);
68 let bin = &mut self.bins[self.current];
69 *bin = bin.saturating_add(units.min(u32::from(u16::MAX)) as u16);
70 }
71
72 pub fn usage(&mut self, now_ms: u64) -> u16 {
75 self.advance(now_ms);
76 Self::scale(self.total())
77 }
78
79 pub fn would_exceed(&mut self, now_ms: u64, airtime_ms: u32, limit: u16) -> bool {
82 self.advance(now_ms);
83 let projected = self.total() + u64::from(airtime_ms.div_ceil(UNIT_MS));
84 Self::scale(projected) > limit
85 }
86
87 pub fn reset(&mut self) {
89 self.bins = [0; BINS];
90 }
91
92 fn total(&self) -> u64 {
93 self.bins.iter().map(|&bin| u64::from(bin)).sum()
94 }
95
96 fn scale(units: u64) -> u16 {
97 (units * 65_535 / HOUR_UNITS).min(65_535) as u16
98 }
99}
100
101impl Default for DutyTracker {
102 fn default() -> Self {
103 Self::new()
104 }
105}
106
107#[derive(Clone, Copy, Debug, PartialEq, Eq)]
110pub struct DutyExceeded;
111
112struct LedgerState {
113 tracker: DutyTracker,
114 limit: u16,
118 sf: u8,
122 bw_hz: u32,
123 cr_denom: u8,
124}
125
126pub struct DutyLedger {
134 state: Mutex<CriticalSectionRawMutex, RefCell<LedgerState>>,
135}
136
137impl core::fmt::Debug for DutyLedger {
138 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
139 f.debug_struct("DutyLedger")
140 .field("limit", &self.limit())
141 .finish_non_exhaustive()
142 }
143}
144
145impl DutyLedger {
146 pub const fn new() -> Self {
147 Self {
148 state: Mutex::new(RefCell::new(LedgerState {
149 tracker: DutyTracker::new(),
150 limit: DUTY_LIMIT_DISABLED,
151 sf: 7,
154 bw_hz: 125_000,
155 cr_denom: 5,
156 })),
157 }
158 }
159
160 pub fn usage(&self, now_ms: u64) -> u16 {
162 self.state
163 .lock(|state| state.borrow_mut().tracker.usage(now_ms))
164 }
165
166 pub fn would_exceed(&self, now_ms: u64, airtime_ms: u32) -> bool {
169 self.state.lock(|state| {
170 let mut state = state.borrow_mut();
171 let limit = state.limit;
172 state.tracker.would_exceed(now_ms, airtime_ms, limit)
173 })
174 }
175
176 pub fn record(&self, now_ms: u64, airtime_ms: u32) {
178 self.state
179 .lock(|state| state.borrow_mut().tracker.record(now_ms, airtime_ms));
180 }
181
182 pub fn reset_accounting(&self) {
185 self.state.lock(|state| state.borrow_mut().tracker.reset());
186 }
187
188 pub fn limit(&self) -> u16 {
190 self.state.lock(|state| state.borrow().limit)
191 }
192
193 pub fn set_limit(&self, limit: u16) {
194 self.state.lock(|state| state.borrow_mut().limit = limit);
195 }
196
197 pub fn set_phy(&self, sf: u8, bw_hz: u32, cr_denom: u8) {
200 self.state.lock(|state| {
201 let mut state = state.borrow_mut();
202 state.sf = sf;
203 state.bw_hz = bw_hz;
204 state.cr_denom = cr_denom;
205 });
206 }
207
208 pub fn airtime_ms(&self, frame_len: usize) -> u32 {
210 self.state.lock(|state| {
211 let state = state.borrow();
212 lora_airtime_ms(state.sf, state.bw_hz, state.cr_denom, frame_len)
213 })
214 }
215
216 pub fn admit(&self, now_ms: u64, frame_len: usize) -> Result<u32, DutyExceeded> {
223 self.state.lock(|state| {
224 let mut state = state.borrow_mut();
225 let airtime_ms = lora_airtime_ms(state.sf, state.bw_hz, state.cr_denom, frame_len);
226 let limit = state.limit;
227 if state.tracker.would_exceed(now_ms, airtime_ms, limit) {
228 Err(DutyExceeded)
229 } else {
230 Ok(airtime_ms)
231 }
232 })
233 }
234}
235
236impl Default for DutyLedger {
237 fn default() -> Self {
238 Self::new()
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn spec_scaling() {
248 let mut duty = DutyTracker::new();
250 duty.record(0, 20);
251 assert_eq!(duty.bins[duty.current], 4);
252 duty.record(0, 22);
253 assert_eq!(duty.bins[duty.current], 9);
254 }
255
256 #[test]
257 fn one_percent_duty() {
258 let mut duty = DutyTracker::new();
259 for _ in 0..36 {
261 duty.record(0, 1_000);
262 }
263 assert_eq!(duty.usage(0), 655);
265 }
266
267 #[test]
268 fn bins_age_out_after_an_hour() {
269 let mut duty = DutyTracker::new();
270 duty.record(0, 10_000);
271 assert!(duty.usage(0) > 0);
272 assert!(duty.usage(14 * INTERVAL_MS) > 0);
274 assert_eq!(duty.usage(15 * INTERVAL_MS), 0);
276 }
277
278 #[test]
279 fn long_gap_clears_everything() {
280 let mut duty = DutyTracker::new();
281 duty.record(0, 60_000);
282 assert_eq!(duty.usage(100 * INTERVAL_MS), 0);
283 }
284
285 #[test]
286 fn ledger_combines_clients_and_admits_by_frame_length() {
287 let ledger = DutyLedger::new();
288 ledger.set_phy(9, 250_000, 5);
289 ledger.set_limit(655); for _ in 0..18 {
294 ledger.record(0, 1_000); ledger.record(0, 1_000); }
297 assert!(ledger.usage(0) >= 655);
298 assert!(ledger.would_exceed(0, 1_000));
299 let refused = ledger.admit(0, 32);
300 assert_eq!(refused, Err(DutyExceeded));
301
302 let airtime = ledger.admit(20 * INTERVAL_MS, 32).unwrap();
305 assert_eq!(airtime, lora_airtime_ms(9, 250_000, 5, 32));
306 assert_eq!(ledger.usage(20 * INTERVAL_MS), 0);
308
309 ledger.set_limit(DUTY_LIMIT_DISABLED);
311 for _ in 0..1000 {
312 ledger.record(0, 60_000);
313 }
314 assert!(ledger.admit(0, 255).is_ok());
315
316 ledger.set_limit(655);
318 ledger.record(21 * INTERVAL_MS, 60_000);
319 ledger.reset_accounting();
320 assert_eq!(ledger.usage(21 * INTERVAL_MS), 0);
321 assert_eq!(ledger.limit(), 655);
322 }
323
324 #[test]
325 fn limit_projection() {
326 let mut duty = DutyTracker::new();
327 let limit = 655;
329 assert!(!duty.would_exceed(0, 1_000, limit));
330 for _ in 0..36 {
331 duty.record(0, 1_000);
332 }
333 assert!(duty.would_exceed(0, 1_000, limit));
334 assert!(!duty.would_exceed(0, 1_000, u16::MAX));
336 }
337}