Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 30 additions & 14 deletions examples/companion_radio/ui-new/UITask.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,8 @@ class HomeScreen : public UIScreen {
#endif
}

// Channel-health bars use DisplayDriver::drawHealthBar (shared row helper).

CayenneLPP sensors_lpp;
int sensors_nb = 0;
bool sensors_scroll = false;
Expand Down Expand Up @@ -308,24 +310,38 @@ class HomeScreen : public UIScreen {
display.print(tmp);
}
} else if (_page == HomePage::RADIO) {
display.setColor(UIColor::primary_txt);
// 5 rows at 9px pitch (text is 8px high) so all three channel-health
// metrics render as uniform label + bar rows within a 128x64 display
display.setTextSize(1);
// freq / sf
display.setCursor(0, 20);
sprintf(tmp, "FQ: %06.3f SF: %d", _node_prefs->freq, _node_prefs->sf);
display.print(tmp);

display.setCursor(0, 31);
sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr);
// freq / sf / tx power
display.setColor(UIColor::primary_txt);
display.setCursor(0, 19);
sprintf(tmp, "FQ:%06.3f SF%d TX%d", _node_prefs->freq, _node_prefs->sf, _node_prefs->tx_power_dbm);
display.print(tmp);

// tx power, noise floor
display.setCursor(0, 42);
sprintf(tmp, "TX: %ddBm", _node_prefs->tx_power_dbm);
display.print(tmp);
display.setCursor(0, 53);
sprintf(tmp, "Noise floor: %d", radio_driver.getNoiseFloor());
// bw / cr, plus noise floor
display.setCursor(0, 28);
sprintf(tmp, "BW:%03.2f CR%d", _node_prefs->bw, _node_prefs->cr);
display.print(tmp);
sprintf(tmp, "NF:%d", radio_driver.getNoiseFloor());
display.drawTextRightAlign(display.width(), 28, tmp);

// channel-health bars (windowed, positive framing: full bar = good);
// radios that measure nothing (e.g. ESP-NOW) render as no-data instead
// of a false "all healthy" bar
bool has_health = radio_driver.hasChannelHealth();
display.drawHealthBar(37, "CH free", has_health ? 100 - radio_driver.getChannelUtilizationPct() : 0, 50, !has_health);
display.drawHealthBar(46, "RX ready", has_health ? 100 - radio_driver.getRxDeafnessPct() : 0, 80, !has_health);

// RX quality: windowed good vs total packet decodes (~10 min window) as
// a uniform bar row like the two above; the underlying counts stay
// available via stats-radio. The fetch is sequenced separately from the
// draw call: passing rxq_pct by value AND by reference (getRxQualityPct)
// in one argument list is unsequenced read+write (undefined behavior) -
// the bar could receive the pre-call 0 instead of the measured value.
uint8_t rxq_pct = 0;
bool has_rxq = radio_driver.getRxQualityPct(rxq_pct);
display.drawHealthBar(55, "RX quality", rxq_pct, 80, !has_rxq);
} else if (_page == HomePage::BLUETOOTH) {
display.setColor(UIColor::corp_blue);
display.drawXbm((display.width() - 32) / 2, 18,
Expand Down
19 changes: 19 additions & 0 deletions examples/companion_radio/ui-tiny/UITask.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ class HomeScreen : public UIScreen {
}
}

// Channel-health bars use DisplayDriver::drawHealthBar (shared row helper).

int render(DisplayDriver& display) override {
char tmp[80];

Expand Down Expand Up @@ -237,6 +239,23 @@ class HomeScreen : public UIScreen {
sprintf(tmp, "TX%d", _node_prefs->tx_power_dbm);
display.drawTextRightAlign(display.width(), 26, tmp);

// channel-health bars (windowed, positive framing: full bar = good);
// radios that measure nothing (e.g. ESP-NOW) render as no-data instead
// of a false "all healthy" bar
bool has_health = radio_driver.hasChannelHealth();
display.drawHealthBar(35, "CH free", has_health ? 100 - radio_driver.getChannelUtilizationPct() : 0, 50, !has_health);
display.drawHealthBar(44, "RX ready", has_health ? 100 - radio_driver.getRxDeafnessPct() : 0, 80, !has_health);

// RX quality: windowed good vs total packet decodes (~10 min window) as
// a uniform bar row like the two above; the underlying counts stay
// available via stats-radio. The fetch is sequenced separately from the
// draw call: passing rxq_pct by value AND by reference (getRxQualityPct)
// in one argument list is unsequenced read+write (undefined behavior) -
// the bar could receive the pre-call 0 instead of the measured value.
uint8_t rxq_pct = 0;
bool has_rxq = radio_driver.getRxQualityPct(rxq_pct);
display.drawHealthBar(53, "RX quality", rxq_pct, 80, !has_rxq);

} else if (_page == HomePage::BLUETOOTH) {
display.setColor(UIColor::corp_blue);
display.drawXbm((display.width() - 32) / 2, 8,
Expand Down
19 changes: 19 additions & 0 deletions examples/simple_repeater/UITask.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ static const uint8_t meshcore_logo [] PROGMEM = {
0xe3, 0xe3, 0x8f, 0xff, 0x1f, 0xfc, 0x3c, 0x0e, 0x1f, 0xf8, 0xff, 0xf8, 0x70, 0x3c, 0x7f, 0xf8,
};

// Channel-health bars use DisplayDriver::drawHealthBar (shared row helper).

void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version) {
_prevBtnState = HIGH;
_auto_off = millis() + AUTO_OFF_MILLIS;
Expand Down Expand Up @@ -106,6 +108,23 @@ void UITask::renderCurrScreen() {
_display->setCursor(0, 30);
sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr);
_display->print(tmp);

// channel-health bars (windowed, positive framing: full bar = good);
// radios that measure nothing render as no-data instead of a false
// "all healthy" bar
bool has_health = radio_driver.hasChannelHealth();
_display->drawHealthBar(38, "CH free", has_health ? 100 - radio_driver.getChannelUtilizationPct() : 0, 50, !has_health);
_display->drawHealthBar(47, "RX ready", has_health ? 100 - radio_driver.getRxDeafnessPct() : 0, 80, !has_health);

// RX quality: windowed good vs total packet decodes (~10 min window) as a
// uniform bar row like the two above; the underlying counts stay
// available via stats-radio. The fetch is sequenced separately from the
// draw call: passing rxq_pct by value AND by reference (getRxQualityPct)
// in one argument list is unsequenced read+write (undefined behavior) -
// the bar could receive the pre-call 0 instead of the measured value.
uint8_t rxq_pct = 0;
bool has_rxq = radio_driver.getRxQualityPct(rxq_pct);
_display->drawHealthBar(56, "RX quality", rxq_pct, 80, !has_rxq);
}
}

Expand Down
18 changes: 18 additions & 0 deletions src/Dispatcher.h
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,24 @@ class Radio {

virtual int getNoiseFloor() const { return 0; }

/**
* \brief windowed channel-health metrics: utilization/deafness over the
* last ~5 observed seconds, RX-quality counts over the last ~10
* minutes. All use "0 = good" semantics. hasChannelHealth() tells
* callers whether this radio measures them at all: radios that do
* not (e.g. ESPNOW) must be rendered as "no data" instead of a
* false "all healthy" 0%.
*/
virtual bool hasChannelHealth() { return false; }
virtual uint8_t getChannelUtilizationPct() { return 0; } // % of time the channel was busy
virtual uint8_t getRxDeafnessPct() { return 0; } // % of time the radio was NOT in RX
// Good vs total packet decodes in the RX-quality window (~10 min): 'total'
// counts decodes plus SNR-relevant CRC failures (weak distant stations are
// excluded), 'good' the ones that decoded (passed CRC). The pct variant
// returns false while the window holds no events yet ("no data").
virtual void getRxQualityCounts(uint16_t& good, uint16_t& total) { good = 0; total = 0; }
virtual bool getRxQualityPct(uint8_t& pct) { pct = 0; return false; }

virtual void triggerNoiseFloorCalibrate(int threshold) { }

virtual void setCADEnabled(bool enable) { }
Expand Down
20 changes: 17 additions & 3 deletions src/helpers/StatsFormatHelper.h
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,27 @@ class StatsFormatHelper {
RadioDriverType& driver,
uint32_t total_air_time_ms,
uint32_t total_rx_air_time_ms) {
sprintf(reply,
"{\"noise_floor\":%d,\"last_rssi\":%d,\"last_snr\":%.2f,\"tx_air_secs\":%u,\"rx_air_secs\":%u}",
// good/tot: decodes vs (decodes + SNR-relevant CRC failures) over the
// ~10 min RX-quality window. Weak distant-station failures are excluded.
// The new keys are deliberately SHORT: callers format this into a
// 160-byte CLI reply buffer and the 5 legacy fields already take ~110
// bytes at large counter values - worst case here must stay below 160
// incl. NUL (it peaks at ~151). The error % is derivable as
// 100 - good*100/tot and is not printed separately.
uint16_t rx_good = 0, rx_total = 0;
radio->getRxQualityCounts(rx_good, rx_total);
sprintf(reply,
"{\"noise_floor\":%d,\"last_rssi\":%d,\"last_snr\":%.2f,\"tx_air_secs\":%u,\"rx_air_secs\":%u,"
"\"util\":%u,\"deaf\":%u,\"good\":%u,\"tot\":%u}",
(int16_t)radio->getNoiseFloor(),
(int16_t)driver.getLastRSSI(),
driver.getLastSNR(),
total_air_time_ms / 1000,
total_rx_air_time_ms / 1000
total_rx_air_time_ms / 1000,
radio->getChannelUtilizationPct(),
radio->getRxDeafnessPct(),
rx_good,
rx_total
);
}

Expand Down
116 changes: 116 additions & 0 deletions src/helpers/WindowedPercent.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
#pragma once

#include <stdint.h>

/**
* \brief Windowed percentage of "active" time over the last ~5 observed
* seconds, kept as a ring of five 1-second buckets of active
* milliseconds. Integer-only and millis-delta driven, so it is
* independent of the loop() call rate. ~32 bytes RAM.
*/
class WindowedPercent {
uint32_t buckets[5]; // completed 1s buckets: active ms in each (each observed exactly 1000 ms)
uint32_t cur_active; // current (partial) second: active ms
uint16_t cur_total; // current second: observed ms (0..1000)
uint8_t oldest; // index of oldest bucket (next to overwrite)
uint8_t filled; // number of completed buckets (grows to 5)
uint32_t last_ms; // stamp of previous add()
public:
WindowedPercent() : cur_active(0), cur_total(0), oldest(0), filled(0), last_ms(0) {
for (int i = 0; i < 5; i++) buckets[i] = 0;
}

// Attribute 'active_ms' of the time elapsed since the previous call.
void add(uint32_t now, uint32_t active_ms) {
uint32_t dt = now - last_ms; last_ms = now;
if (dt > 1000) dt = 1000; // long stall: count at most 1s of the last state
if (active_ms > dt) active_ms = dt;
while (dt > 0) { // split across the 1s bucket boundary
uint32_t space = 1000 - cur_total;
uint32_t take = (dt < space) ? dt : space;
cur_total += take; dt -= take;
uint32_t a = (active_ms < take) ? active_ms : take;
cur_active += a; active_ms -= a;
if (cur_total >= 1000) { // roll into the ring
buckets[oldest] = cur_active;
oldest = (oldest + 1) % 5;
if (filled < 5) filled++;
cur_active = 0; cur_total = 0;
}
}
}

// Percent 0..100 across the observed window. The denominator is the
// *observed* time: completed buckets observed exactly 1000 ms each.
uint8_t pct() const {
uint32_t num = cur_active, den = cur_total + 1000UL * filled;
for (int i = 0; i < 5; i++) num += buckets[i];
return (den == 0) ? 0 : (uint8_t)((num * 100) / den);
}

// Forget everything (stats reset). last_ms is left alone: loop()-rate
// callers pass dt of only a few ms, so observation restarts at ~0.
void clear() {
for (int i = 0; i < 5; i++) buckets[i] = 0;
cur_active = 0; cur_total = 0; oldest = 0; filled = 0;
}
};

/**
* \brief Windowed ratio of "bad" discrete events over the last ~10 minutes
* (e.g. RX CRC failures). Same bucket-ring idea as WindowedPercent, but
* count-based and much longer: packet counts on a quiet mesh need
* minutes, not seconds, to become statistically meaningful. The
* time-based utilization/deafness metrics stay at ~5 s.
*/
template <uint8_t N_BUCKETS = 60, uint32_t BUCKET_MS = 10000> // 60 x 10 s = ~10 min
class WindowedCountedRatio {
uint16_t ev[N_BUCKETS], bad[N_BUCKETS]; // completed buckets: event / bad-event counts
uint16_t cur_ev, cur_bad; // current (partial) bucket
uint32_t cur_ms; // ms accumulated toward the next bucket roll
uint8_t oldest;
uint32_t last_ms;
void advance(uint32_t now) { // roll completed buckets
uint32_t dt = now - last_ms; last_ms = now;
uint32_t window_ms = (uint32_t)N_BUCKETS * BUCKET_MS;
if (dt > window_ms) dt = window_ms; // long stall/sleep: the window has slid past anyway
cur_ms += dt; // accumulate: callers tick far faster than one bucket,
// A stall spanning the whole window makes everything pre-stall older than
// the window: drop it instead of baking it into the oldest (surviving) bucket.
if (cur_ms / BUCKET_MS >= N_BUCKETS) { cur_ev = 0; cur_bad = 0; }
while (cur_ms >= BUCKET_MS) { // so no single dt ever fills a bucket by itself
ev[oldest] = cur_ev; bad[oldest] = cur_bad;
oldest = (oldest + 1) % N_BUCKETS;
cur_ev = 0; cur_bad = 0; // long stall: window just slides past
cur_ms -= BUCKET_MS;
}
}
public:
WindowedCountedRatio() : cur_ev(0), cur_bad(0), cur_ms(0), oldest(0), last_ms(0) {
for (int i = 0; i < N_BUCKETS; i++) { ev[i] = 0; bad[i] = 0; }
}

// 'n_ev' counts ALL events (attempts), of which 'n_bad' failed.
void add(uint32_t now, uint16_t n_ev, uint16_t n_bad) {
advance(now);
cur_ev += n_ev; cur_bad += n_bad;
}

// Forget everything (stats reset). last_ms is left alone: loop()-rate
// callers pass dt of only a few ms, so post-clear observation restarts at ~0.
void clear() {
for (int i = 0; i < N_BUCKETS; i++) { ev[i] = 0; bad[i] = 0; }
cur_ev = 0; cur_bad = 0; cur_ms = 0; oldest = 0;
}

// Window totals: all events (attempts) and the failing subset, saturated at
// 0xFFFF. Only the good/bad RATIO is meaningful for display; the absolute
// counts reflect what has actually been observed since construction or the
// last clear() (they grow to full-window scale as the window fills).
void counts(uint16_t& n_ev, uint16_t& n_bad) const {
uint32_t e = cur_ev, b = cur_bad;
for (int i = 0; i < N_BUCKETS; i++) { e += ev[i]; b += bad[i]; }
n_ev = (e > 0xFFFF) ? 0xFFFF : (uint16_t)e;
n_bad = (b > 0xFFFF) ? 0xFFFF : (uint16_t)b;
}
};
Loading