Files
MeshCore/src/helpers/RadioActivityWindow.h
T

218 lines
7.7 KiB
C++

#pragma once
#include <stdint.h>
#include <string.h>
// Fixed-memory rolling window of RF receive activity, bucketed by minute.
//
// Pure logic: no Arduino, radio, display or role headers. Callers supply a
// millisecond counter and the per-packet values.
//
// The caller's 32-bit millis() is extended to a monotonic 64-bit clock on entry
// (see tick()), so nothing downstream has a rollover case. Unsigned-subtraction
// tricks are not enough here: they only survive a single wrap crossing, while an
// always-on observer accumulates uptime past 2^32 ms (~49.7 days), at which
// point a 32-bit tracker age would collapse back to a small value and the
// window would re-enter warm-up and divide 20 minutes of traffic by seconds.
#define RADIO_ACTIVITY_BUCKETS 20
#define RADIO_ACTIVITY_BUCKET_MS 60000UL
// Beyond this, "time since last packet" stops being reported rather than shown
// as a stale or (after a 49-day rollover) nonsensical age.
#define RADIO_ACTIVITY_MAX_AGE_MS (100UL * RADIO_ACTIVITY_BUCKET_MS)
// How far a timestamp may run behind the previous one and still count as an
// out-of-order reading rather than a very long forward gap.
#define RADIO_ACTIVITY_BACKSTEP_TOLERANCE_MS 5000UL
struct RadioActivitySnapshot {
uint32_t packets;
uint32_t wire_bytes;
uint32_t airtime_ms;
int32_t snr_q4_sum;
int32_t rssi_sum;
// Span the totals actually cover: 19 whole minutes plus the elapsed part of
// the current one, so it tops out just under 20 minutes and never claims
// coverage the ring does not have.
uint32_t window_ms;
uint32_t tracking_ms; // how long the tracker has been running
uint32_t last_packet_age_ms;
bool has_last_packet; // false until the first packet, and once stale
uint16_t peak_per_min;
uint16_t buckets[RADIO_ACTIVITY_BUCKETS]; // [0] oldest .. [N-1] current minute
bool isEmpty() const { return packets == 0; }
bool isWarmingUp() const {
return tracking_ms < (uint32_t)RADIO_ACTIVITY_BUCKETS * RADIO_ACTIVITY_BUCKET_MS;
}
uint32_t warmupMinutes() const { return tracking_ms / RADIO_ACTIVITY_BUCKET_MS; }
// Derived values, in integer fixed point so host tests are exact and the
// formatting path stays off the FPU. All are zero when the window is empty.
uint32_t packetsPerMinuteX10() const {
if (window_ms == 0) return 0;
return (uint32_t)(((uint64_t)packets * RADIO_ACTIVITY_BUCKET_MS * 10) / window_ms);
}
uint32_t bytesPerSecondX10() const {
if (window_ms == 0) return 0;
return (uint32_t)(((uint64_t)wire_bytes * 1000 * 10) / window_ms);
}
uint32_t avgBytesPerPacket() const {
if (packets == 0) return 0;
return (wire_bytes + packets / 2) / packets;
}
// Receive airtime as tenths of a percent of the window.
uint32_t airtimePercentX10() const {
if (window_ms == 0) return 0;
return (uint32_t)(((uint64_t)airtime_ms * 1000) / window_ms);
}
// Average SNR in tenths of a dB (sums are quarter-dB units).
int32_t avgSnrX10() const {
if (packets == 0) return 0;
return (snr_q4_sum * 10) / ((int32_t)packets * 4);
}
int32_t avgRssi() const {
if (packets == 0) return 0;
return rssi_sum / (int32_t)packets;
}
};
class RadioActivityWindow {
public:
RadioActivityWindow() { reset(0); }
void reset(uint32_t now_ms) {
memset(_buckets, 0, sizeof(_buckets));
_head = 0;
_now_ms = 0;
_last_input_ms = now_ms;
_bucket_start_ms = 0;
_tracking_since_ms = 0;
_last_packet_ms = 0;
_ever_received = false;
}
void recordPacket(uint32_t now_ms, uint16_t wire_bytes, uint32_t airtime_ms, int8_t snr_q4,
int16_t rssi_dbm) {
advance(now_ms);
Bucket& b = _buckets[_head];
// Saturated: drop the event whole so the bucket's averages stay consistent
// with its packet count. Unreachable at any real LoRa packet rate.
if (b.packets == 0xFFFF) return;
b.packets++;
b.wire_bytes += wire_bytes;
b.airtime_ms += airtime_ms;
b.snr_q4_sum += snr_q4;
b.rssi_sum += rssi_dbm;
_last_packet_ms = _now_ms;
_ever_received = true;
}
void snapshot(uint32_t now_ms, RadioActivitySnapshot* out) {
advance(now_ms);
memset(out, 0, sizeof(*out));
for (int i = 0; i < RADIO_ACTIVITY_BUCKETS; i++) {
const Bucket& b = _buckets[(_head + 1 + i) % RADIO_ACTIVITY_BUCKETS];
out->buckets[i] = b.packets;
out->packets += b.packets;
out->wire_bytes += b.wire_bytes;
out->airtime_ms += b.airtime_ms;
out->snr_q4_sum += b.snr_q4_sum;
out->rssi_sum += b.rssi_sum;
if (b.packets > out->peak_per_min) out->peak_per_min = b.packets;
}
uint64_t elapsed_in_current = _now_ms - _bucket_start_ms; // < BUCKET_MS after advance()
uint64_t max_span =
(uint64_t)(RADIO_ACTIVITY_BUCKETS - 1) * RADIO_ACTIVITY_BUCKET_MS + elapsed_in_current;
uint64_t tracking = _now_ms - _tracking_since_ms;
const uint64_t full_span = (uint64_t)RADIO_ACTIVITY_BUCKETS * RADIO_ACTIVITY_BUCKET_MS;
// Clamped, so the 32-bit snapshot fields stay in range on a long-lived node.
// Past full_span the exact tracker age is not needed: the window is warm.
out->tracking_ms = (uint32_t)(tracking < full_span ? tracking : full_span);
out->window_ms = (uint32_t)(tracking < max_span ? tracking : max_span);
if (_ever_received) {
uint64_t age = _now_ms - _last_packet_ms;
if (age <= RADIO_ACTIVITY_MAX_AGE_MS) {
out->last_packet_age_ms = (uint32_t)age;
out->has_last_packet = true;
}
}
}
private:
struct Bucket {
uint32_t wire_bytes;
uint32_t airtime_ms;
int32_t snr_q4_sum;
int32_t rssi_sum;
uint16_t packets;
uint16_t _reserved;
};
Bucket _buckets[RADIO_ACTIVITY_BUCKETS];
uint64_t _now_ms; // monotonic clock, extended from the caller's
uint64_t _bucket_start_ms; // start of the current (newest) minute
uint64_t _tracking_since_ms;
uint64_t _last_packet_ms;
uint32_t _last_input_ms; // last 32-bit value the caller handed in
uint8_t _head; // ring index of the current minute
bool _ever_received;
// Accumulates the unsigned delta since the previous call, which is correct
// across one millis() wrap.
//
// A delta past the halfway mark is ambiguous: it is either a slightly stale
// reading or a genuine gap of more than ~24.8 days. recordPacket() and
// snapshot() sample millis() microseconds apart, so a real stale reading is
// tiny - anything larger is treated as the long gap it is, which matters
// because rejecting it outright would freeze the ring and leave a
// month-old packet looking recently received.
void tick(uint32_t now_ms) {
uint32_t delta = now_ms - _last_input_ms;
if (delta > 0x80000000u &&
(uint32_t)(_last_input_ms - now_ms) <= RADIO_ACTIVITY_BACKSTEP_TOLERANCE_MS) {
return; // out-of-order reading: no time has passed
}
_now_ms += delta;
_last_input_ms = now_ms;
}
// Retires expired buckets lazily, advancing the boundary by whole BUCKET_MS
// steps so the minute phase is preserved across gaps.
void advance(uint32_t now_ms) {
tick(now_ms);
uint64_t elapsed = _now_ms - _bucket_start_ms;
if (elapsed < RADIO_ACTIVITY_BUCKET_MS) return;
uint64_t steps = elapsed / RADIO_ACTIVITY_BUCKET_MS;
_bucket_start_ms += steps * RADIO_ACTIVITY_BUCKET_MS;
if (steps >= RADIO_ACTIVITY_BUCKETS) {
memset(_buckets, 0, sizeof(_buckets));
_head = 0;
_tracking_since_ms = _bucket_start_ms;
return;
}
for (uint64_t i = 0; i < steps; i++) {
_head = (uint8_t)((_head + 1) % RADIO_ACTIVITY_BUCKETS);
memset(&_buckets[_head], 0, sizeof(Bucket));
}
}
};
static_assert(sizeof(RadioActivityWindow) <= 1024, "RadioActivityWindow must stay under 1 KiB");