umsh_ulcp/stats.rs
1//! Traffic counters for the `PROP_STAT_*` properties.
2//!
3//! [`StatsLedger`] is the one place a device tallies what its radio did.
4//! It sits in a `static` and is written by whichever parts of the stack
5//! are in a position to see each event exactly once — on a firmware that
6//! is the radio multiplexer for frames on the air, the PHY runner for
7//! receptions the demodulator rejected, and the device node's pump for
8//! what the MAC decided — and it is read by the ULCP session, by the
9//! device's own display, and by anything else that wants the same
10//! numbers rather than its own.
11//!
12//! # Every producer adds
13//!
14//! Each counter is a pair of `u32`s: a `raw` tally that only ever grows
15//! (wrapping, never resetting) and a `base` that a host's write moves.
16//! What a host reads is `raw - base`, and clearing a counter is
17//! `base = raw`.
18//!
19//! Zeroing `raw` instead would be wrong twice over. Two of the producers
20//! mirror tallies the ledger does not own — the MAC's counters and the
21//! PHY's — so a zeroed cell would be overwritten by the next mirror pass
22//! and the clear would visibly bounce back; and a cell another task is
23//! concurrently `fetch_add`-ing cannot be zeroed without losing whatever
24//! landed in between. Mirroring producers therefore feed the *difference*
25//! since their last pass, which makes every write to the ledger an
26//! addition and leaves the base as the only thing a reset touches.
27//!
28//! Counters wrap rather than saturate. Wrapping arithmetic is what makes
29//! `raw - base` right across the boundary; a saturating counter would
30//! pin at the maximum and never move again, which is worse than starting
31//! over.
32
33use core::sync::atomic::{AtomicU32, Ordering};
34
35use crate::ids::prop;
36
37/// The counters a device keeps.
38///
39/// The discriminants are the ledger's slot indices and are not on the
40/// wire; [`Counter::property`] gives the identifier that is.
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub enum Counter {
43 /// Frames that reached the air, from any client of the radio.
44 TxPackets,
45 /// Transmit attempts the channel-activity check held back.
46 TxChannelBusy,
47 /// Receptions off the air that are UMSH packets.
48 RxPackets,
49 /// Receptions the radio rejected on CRC.
50 RxBadCrc,
51 /// Receptions that passed CRC and are not UMSH packets.
52 RxNonUmsh,
53 /// Receptions the device's own node acted on.
54 RxAccepted,
55 /// Receptions this node chose to repeat.
56 Forwarded,
57 /// Receptions declined under the operator's forwarding policy.
58 ForwardDropped,
59 /// Queued repeats dropped after overhearing the destination's ack.
60 ForwardCancelled,
61}
62
63/// How many counters a ledger holds.
64pub const COUNTERS: usize = 9;
65
66impl Counter {
67 /// Every counter, in identifier order.
68 pub const ALL: [Counter; COUNTERS] = [
69 Counter::TxPackets,
70 Counter::TxChannelBusy,
71 Counter::RxPackets,
72 Counter::RxBadCrc,
73 Counter::RxNonUmsh,
74 Counter::RxAccepted,
75 Counter::Forwarded,
76 Counter::ForwardDropped,
77 Counter::ForwardCancelled,
78 ];
79
80 /// The property identifier this counter is read and cleared through.
81 pub const fn property(self) -> u32 {
82 match self {
83 Counter::TxPackets => prop::STAT_TX_PACKETS,
84 Counter::TxChannelBusy => prop::STAT_TX_CHANNEL_BUSY,
85 Counter::RxPackets => prop::STAT_RX_PACKETS,
86 Counter::RxBadCrc => prop::STAT_RX_BAD_CRC,
87 Counter::RxNonUmsh => prop::STAT_RX_NON_UMSH,
88 Counter::RxAccepted => prop::STAT_RX_ACCEPTED,
89 Counter::Forwarded => prop::STAT_FORWARDED,
90 Counter::ForwardDropped => prop::STAT_FORWARD_DROPPED,
91 Counter::ForwardCancelled => prop::STAT_FORWARD_CANCELLED,
92 }
93 }
94
95 /// The counter a property identifier names, if it names one.
96 pub const fn from_property(key: u32) -> Option<Counter> {
97 Some(match key {
98 prop::STAT_TX_PACKETS => Counter::TxPackets,
99 prop::STAT_TX_CHANNEL_BUSY => Counter::TxChannelBusy,
100 prop::STAT_RX_PACKETS => Counter::RxPackets,
101 prop::STAT_RX_BAD_CRC => Counter::RxBadCrc,
102 prop::STAT_RX_NON_UMSH => Counter::RxNonUmsh,
103 prop::STAT_RX_ACCEPTED => Counter::RxAccepted,
104 prop::STAT_FORWARDED => Counter::Forwarded,
105 prop::STAT_FORWARD_DROPPED => Counter::ForwardDropped,
106 prop::STAT_FORWARD_CANCELLED => Counter::ForwardCancelled,
107 _ => return None,
108 })
109 }
110
111 /// Whether this counter can only be answered by a device running a
112 /// node of its own.
113 ///
114 /// The four that come from the MAC. A session with nothing behind it
115 /// does not have these properties at all, rather than reporting a
116 /// zero that reads as "this repeater has never repeated anything".
117 pub const fn needs_node(self) -> bool {
118 matches!(
119 self,
120 Counter::RxAccepted
121 | Counter::Forwarded
122 | Counter::ForwardDropped
123 | Counter::ForwardCancelled
124 )
125 }
126}
127
128/// One counter: what has happened, and where the host last started
129/// counting from.
130struct Cell {
131 raw: AtomicU32,
132 base: AtomicU32,
133}
134
135impl Cell {
136 const fn new() -> Self {
137 Self {
138 raw: AtomicU32::new(0),
139 base: AtomicU32::new(0),
140 }
141 }
142}
143
144/// The shared traffic ledger.
145///
146/// Lock-free and `const`-constructible, so a board declares one `static`
147/// and hands out `&'static` references to every producer and reader.
148/// Ordering is `Relaxed` throughout: each counter is independent, no
149/// reader is deciding anything from the ordering between two of them,
150/// and the alternative buys a fence on the radio's hot path to make
151/// diagnostics agree about an instant nobody is looking at.
152pub struct StatsLedger {
153 cells: [Cell; COUNTERS],
154}
155
156impl StatsLedger {
157 pub const fn new() -> Self {
158 Self {
159 cells: [const { Cell::new() }; COUNTERS],
160 }
161 }
162
163 /// Record one event.
164 pub fn bump(&self, counter: Counter) {
165 self.add(counter, 1);
166 }
167
168 /// Record `n` events. Adding zero is free and is the ordinary case
169 /// for a mirroring producer whose source has not moved.
170 pub fn add(&self, counter: Counter, n: u32) {
171 if n != 0 {
172 self.cells[counter as usize]
173 .raw
174 .fetch_add(n, Ordering::Relaxed);
175 }
176 }
177
178 /// What a host reads: events since boot, or since it last cleared
179 /// this counter.
180 pub fn get(&self, counter: Counter) -> u32 {
181 let cell = &self.cells[counter as usize];
182 cell.raw
183 .load(Ordering::Relaxed)
184 .wrapping_sub(cell.base.load(Ordering::Relaxed))
185 }
186
187 /// The underlying tally, ignoring any clear. Nothing on the wire
188 /// reports this; it is here so a device that wants a since-boot view
189 /// of its own has one.
190 pub fn raw(&self, counter: Counter) -> u32 {
191 self.cells[counter as usize].raw.load(Ordering::Relaxed)
192 }
193
194 /// Start this counter over from here.
195 ///
196 /// Clearing several counters is several of these, so a frame landing
197 /// mid-sweep is counted before one and after another. Nothing reads
198 /// them closely enough for that to matter, and the alternative is a
199 /// lock on the radio's hot path.
200 pub fn reset(&self, counter: Counter) {
201 let cell = &self.cells[counter as usize];
202 cell.base
203 .store(cell.raw.load(Ordering::Relaxed), Ordering::Relaxed);
204 }
205}
206
207impl Default for StatsLedger {
208 fn default() -> Self {
209 Self::new()
210 }
211}
212
213impl core::fmt::Debug for StatsLedger {
214 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
215 f.debug_struct("StatsLedger")
216 .field("tx_packets", &self.get(Counter::TxPackets))
217 .field("rx_packets", &self.get(Counter::RxPackets))
218 .finish_non_exhaustive()
219 }
220}
221
222/// A mirror of a monotone tally kept somewhere else.
223///
224/// Producers that copy someone else's counters — the MAC's, the PHY's —
225/// hold one of these per source field and feed the ledger the difference
226/// since the last pass, so the ledger only ever sees additions. A source
227/// that goes backwards (a counter reconstructed, a peripheral restarted)
228/// contributes nothing rather than a nonsensical jump.
229#[derive(Clone, Copy, Debug, Default)]
230pub struct Mirror {
231 last: u32,
232}
233
234impl Mirror {
235 pub const fn new() -> Self {
236 Self { last: 0 }
237 }
238
239 /// Fold the source's current value in, returning what was added.
240 pub fn advance(&mut self, ledger: &StatsLedger, counter: Counter, now: u32) -> u32 {
241 let delta = now.saturating_sub(self.last);
242 self.last = now;
243 ledger.add(counter, delta);
244 delta
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 #[test]
253 fn properties_round_trip() {
254 for counter in Counter::ALL {
255 assert_eq!(Counter::from_property(counter.property()), Some(counter));
256 }
257 assert_eq!(Counter::from_property(prop::PHY_DUTY_NOW), None);
258 }
259
260 #[test]
261 fn counting_and_clearing() {
262 let ledger = StatsLedger::new();
263 assert_eq!(ledger.get(Counter::TxPackets), 0);
264
265 ledger.bump(Counter::TxPackets);
266 ledger.add(Counter::TxPackets, 4);
267 assert_eq!(ledger.get(Counter::TxPackets), 5);
268 // Counters are independent.
269 assert_eq!(ledger.get(Counter::RxPackets), 0);
270
271 ledger.reset(Counter::TxPackets);
272 assert_eq!(ledger.get(Counter::TxPackets), 0);
273 // The underlying tally is untouched, and counting resumes from
274 // the clear rather than from boot.
275 assert_eq!(ledger.raw(Counter::TxPackets), 5);
276 ledger.bump(Counter::TxPackets);
277 assert_eq!(ledger.get(Counter::TxPackets), 1);
278 }
279
280 #[test]
281 fn reported_value_survives_the_wrap() {
282 let ledger = StatsLedger::new();
283 ledger.add(Counter::RxPackets, u32::MAX - 1);
284 ledger.reset(Counter::RxPackets);
285 // Three more events take the raw tally across the boundary; the
286 // reported value is still three.
287 ledger.add(Counter::RxPackets, 3);
288 assert_eq!(ledger.raw(Counter::RxPackets), 1);
289 assert_eq!(ledger.get(Counter::RxPackets), 3);
290 }
291
292 #[test]
293 fn mirrors_feed_the_difference() {
294 let ledger = StatsLedger::new();
295 let mut mirror = Mirror::new();
296
297 // A source that is already nonzero when the mirror starts
298 // contributes its whole value once, and nothing again until it
299 // moves.
300 assert_eq!(mirror.advance(&ledger, Counter::Forwarded, 7), 7);
301 assert_eq!(mirror.advance(&ledger, Counter::Forwarded, 7), 0);
302 assert_eq!(mirror.advance(&ledger, Counter::Forwarded, 9), 2);
303 assert_eq!(ledger.get(Counter::Forwarded), 9);
304
305 // A clear holds even though the source keeps climbing.
306 ledger.reset(Counter::Forwarded);
307 mirror.advance(&ledger, Counter::Forwarded, 9);
308 assert_eq!(ledger.get(Counter::Forwarded), 0);
309 mirror.advance(&ledger, Counter::Forwarded, 11);
310 assert_eq!(ledger.get(Counter::Forwarded), 2);
311
312 // A source that goes backwards contributes nothing rather than
313 // dragging the ledger with it.
314 mirror.advance(&ledger, Counter::Forwarded, 0);
315 assert_eq!(ledger.get(Counter::Forwarded), 2);
316 mirror.advance(&ledger, Counter::Forwarded, 1);
317 assert_eq!(ledger.get(Counter::Forwarded), 3);
318 }
319
320 #[test]
321 fn only_the_mac_sourced_counters_need_a_node() {
322 let needs: Vec<Counter> = Counter::ALL
323 .into_iter()
324 .filter(|c| c.needs_node())
325 .collect();
326 assert_eq!(
327 needs,
328 [
329 Counter::RxAccepted,
330 Counter::Forwarded,
331 Counter::ForwardDropped,
332 Counter::ForwardCancelled
333 ]
334 );
335 }
336}