umsh_ulcp_device/
duty.rs

1//! Transmit duty-cycle accounting for `PROP_PHY_DUTY_NOW`.
2//!
3//! Per the spec: fifteen 16-bit bins, one per 4-minute interval,
4//! covering the past hour. Each bin counts 5 ms units of transmit
5//! time. Usage is `sum(bins) * 65535 / 720000`, i.e. 0-65535 maps to
6//! 0-100% of the hour.
7//!
8//! [`DutyTracker`] is the accounting engine; [`DutyLedger`] wraps one
9//! tracker in a shared, interior-mutable form so every radio client on
10//! a device — the ULCP session and the device node — draws from
11//! the same combined budget (`PROP_PHY_DUTY_LIMIT` bounds their *total*
12//! airtime, and `PROP_PHY_DUTY_NOW` reports the combined figure).
13
14use 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
21/// Length of one accounting interval in milliseconds (4 minutes).
22const INTERVAL_MS: u64 = 240_000;
23/// Number of bins covering the past hour.
24const BINS: usize = 15;
25/// Milliseconds of airtime per bin increment.
26const UNIT_MS: u32 = 5;
27/// Usage denominator per spec (`sum * 65535 / 720000`): one hour
28/// expressed in 5 ms units.
29const HOUR_UNITS: u64 = 720_000;
30
31#[derive(Debug)]
32pub struct DutyTracker {
33    bins: [u16; BINS],
34    current: usize,
35    /// Interval number (`now_ms / INTERVAL_MS`) that `current` maps to.
36    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    /// Rotate bins forward to the interval containing `now_ms`.
49    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    /// Record `airtime_ms` of transmission into the current bin,
64    /// rounding up to whole 5 ms units.
65    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    /// Current usage on the `PROP_PHY_DUTY_NOW` scale (0-65535 for
73    /// 0-100%).
74    pub fn usage(&mut self, now_ms: u64) -> u16 {
75        self.advance(now_ms);
76        Self::scale(self.total())
77    }
78
79    /// Whether transmitting `airtime_ms` now would push usage past
80    /// `limit`.
81    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    /// Zero all accounting (used by protocol reset).
88    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/// A transmit was refused because it would push combined usage past
108/// `PROP_PHY_DUTY_LIMIT`.
109#[derive(Clone, Copy, Debug, PartialEq, Eq)]
110pub struct DutyExceeded;
111
112struct LedgerState {
113    tracker: DutyTracker,
114    /// Live `PROP_PHY_DUTY_LIMIT`. The session owns its lifecycle
115    /// (defaults, property sets, snapshot restore); it lives here so
116    /// every radio client enforces the same bound.
117    limit: u16,
118    /// Modulation parameters of the last applied radio configuration,
119    /// for computing the airtime of frames whose sender has no view of
120    /// the session's settings (the device node).
121    sf: u8,
122    bw_hz: u32,
123    cr_denom: u8,
124}
125
126/// The shared duty ledger: one [`DutyTracker`] plus the active limit
127/// and modulation parameters, behind a blocking mutex so it can sit in
128/// a `static` and be consulted from every radio client's TX path.
129///
130/// All time comes in as caller-supplied `now_ms` (the same monotonic
131/// clock the session uses), keeping the ledger free of any platform
132/// timer dependency.
133pub 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                // Placeholder modulation until the first radio apply;
152                // nothing can transmit before one happens.
153                sf: 7,
154                bw_hz: 125_000,
155                cr_denom: 5,
156            })),
157        }
158    }
159
160    /// Current combined usage on the `PROP_PHY_DUTY_NOW` scale.
161    pub fn usage(&self, now_ms: u64) -> u16 {
162        self.state
163            .lock(|state| state.borrow_mut().tracker.usage(now_ms))
164    }
165
166    /// Whether transmitting `airtime_ms` now would push combined usage
167    /// past the active limit.
168    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    /// Record `airtime_ms` of completed transmission.
177    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    /// Zero the accounting bins (protocol reset). The limit and
183    /// modulation parameters are configuration and stay.
184    pub fn reset_accounting(&self) {
185        self.state.lock(|state| state.borrow_mut().tracker.reset());
186    }
187
188    /// The active `PROP_PHY_DUTY_LIMIT`.
189    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    /// Update the modulation parameters used for [`Self::airtime_ms`].
198    /// The session calls this wherever it (re)applies radio settings.
199    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    /// Airtime of a `frame_len`-byte frame at the active modulation.
209    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    /// Admission check for a client that knows only its frame length
217    /// (the device node's radio path): compute the airtime at the
218    /// active modulation and test it against the combined budget.
219    /// Returns the airtime to [`Self::record`] once the transmit
220    /// completes. Does not itself record — refused frames and failed
221    /// transmits must not consume budget.
222    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        // 20 ms costs 4 units; 22 ms costs 5 (spec examples).
249        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        // 1% of an hour = 36 s of airtime.
260        for _ in 0..36 {
261            duty.record(0, 1_000);
262        }
263        // Spec's table: 1% ≈ 655.
264        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        // 14 intervals later the bin is still in the window...
273        assert!(duty.usage(14 * INTERVAL_MS) > 0);
274        // ...15 intervals later it has rotated out.
275        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); // ≈1%: 36 s of airtime per hour.
290
291        // Two "clients" record into the same ledger; the combined
292        // figure gates both.
293        for _ in 0..18 {
294            ledger.record(0, 1_000); // session
295            ledger.record(0, 1_000); // node
296        }
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        // Refusals consume nothing: after the window ages out, a frame
303        // is admitted with the modulation-derived airtime.
304        let airtime = ledger.admit(20 * INTERVAL_MS, 32).unwrap();
305        assert_eq!(airtime, lora_airtime_ms(9, 250_000, 5, 32));
306        // Admission alone records nothing either.
307        assert_eq!(ledger.usage(20 * INTERVAL_MS), 0);
308
309        // The disabled sentinel never blocks.
310        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        // Reset zeroes accounting but keeps configuration.
317        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        // 655 ≈ 1%: 36 s of airtime per hour.
328        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        // NODUTY-style unlimited value never blocks.
335        assert!(!duty.would_exceed(0, 1_000, u16::MAX));
336    }
337}