feat(repeater): status-bar indicator, power-save lock, diag polish

- Status bar: blinking » indicator while relaying (new ICON_REPEATER), added to
  the 1 s blink cadence alongside the advert/trail markers.
- Power-save lock: a repeater must hear all traffic, so duty-cycle RX is forced
  off whenever client_repeat is on. Effective state = rx_powersave && !repeat,
  applied at boot, in applyPowerSave(), on the on-device toggle, and on the app's
  CMD_SET_RADIO_PARAMS. Settings shows "--" and blocks the toggle while repeating;
  the user's pref is preserved and restored when the repeater is switched off.
- Diagnostics: reset now uses the standard Hold-Enter one-item action menu (Back
  dismisses) instead of an Enter/Cancel popup; new "Errors" row decodes the
  Dispatcher ERR_EVENT_* flags (F/C/R, or OK).
- RepeaterScreen: drop the live stats block (they live on Diagnostics).
- Min-SNR load clamp tightened to the UI's -20..10 range.

Docs updated (settings_screen, tools_screen, FEATURES).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-06-19 14:05:00 +02:00
parent 0da487729d
commit 84a76adcf1
11 changed files with 80 additions and 53 deletions

View File

@@ -348,8 +348,8 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
if (_prefs.repeat_max_hops > 64) _prefs.repeat_max_hops = 0;
if (_prefs.repeat_delay_boost > 8) _prefs.repeat_delay_boost = 0;
if (_prefs.repeat_min_snr != NodePrefs::REPEAT_SNR_DISABLED &&
(_prefs.repeat_min_snr < -30 || _prefs.repeat_min_snr > 20))
_prefs.repeat_min_snr = NodePrefs::REPEAT_SNR_DISABLED;
(_prefs.repeat_min_snr < -20 || _prefs.repeat_min_snr > 10))
_prefs.repeat_min_snr = NodePrefs::REPEAT_SNR_DISABLED; // match the UI's -20..10 range
if (_prefs.repeat_suppress_dup > 1) _prefs.repeat_suppress_dup = 0;
rd(&_prefs.repeater_use_profile, sizeof(_prefs.repeater_use_profile));
rd(&_prefs.repeater_freq, sizeof(_prefs.repeater_freq));

View File

@@ -1421,7 +1421,7 @@ void MyMesh::begin(bool has_display) {
applyRepeaterRadio(); // companion params, or the repeater profile if relaying with one set
applyApc(); // sets TX power to the ceiling and arms APC if enabled
radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain);
radio_driver.setPowerSaving(_prefs.rx_powersave); // hardware duty-cycle RX (battery saver)
radio_driver.setPowerSaving(_prefs.rx_powersave && !_prefs.client_repeat); // duty-cycle RX off while repeating (must hear all traffic)
MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: %s",
radio_driver.getRxBoostedGainMode() ? "Enabled" : "Disabled");
}
@@ -1861,6 +1861,9 @@ void MyMesh::handleCmdFrame(size_t len) {
savePrefs();
radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
// Keep the "repeating ⇒ continuous RX" invariant when repeat is toggled via
// the app, mirroring the on-device path (a repeater must hear all traffic).
radio_driver.setPowerSaving(_prefs.rx_powersave && !_prefs.client_repeat);
MESH_DEBUG_PRINTLN("OK: CMD_SET_RADIO_PARAMS: f=%d, bw=%d, sf=%d, cr=%d", freq, bw, (uint32_t)sf,
(uint32_t)cr);

View File

@@ -19,10 +19,10 @@ extern MyMesh the_mesh;
class DiagnosticsScreen : public UIScreen {
UITask* _task;
int _scroll = 0;
PopupMenu _reset_menu; // Enter → confirm before zeroing the cumulative counters
PopupMenu _reset_menu; // Hold Enter → 1-item "Reset counters" action menu (Back dismisses)
struct Row { const char* label; char value[20]; };
static const int MAX_ROWS = 13;
static const int MAX_ROWS = 14;
Row _rows[MAX_ROWS];
int _row_count = 0;
@@ -100,6 +100,18 @@ class DiagnosticsScreen : public UIScreen {
addRow("Pool free", buf);
snprintf(buf, sizeof(buf), "%d", the_mesh.getOutboundQueueLen());
addRow("Queue", buf);
// Radio error flags since boot/reset, decoded to short tokens (F=queue full,
// C=CAD timeout, R=RX-start timeout). "OK" when none have fired.
uint16_t err = the_mesh.getErrFlags();
if (err == 0) strcpy(buf, "OK");
else {
buf[0] = '\0';
if (err & ERR_EVENT_FULL) strcat(buf, "F ");
if (err & ERR_EVENT_CAD_TIMEOUT) strcat(buf, "C ");
if (err & ERR_EVENT_STARTRX_TIMEOUT) strcat(buf, "R ");
}
addRow("Errors", buf);
}
public:
@@ -136,25 +148,21 @@ public:
bool handleInput(char c) override {
if (_reset_menu.active) {
auto res = _reset_menu.handleInput(c);
// Index 0 = "Reset", 1 = "Cancel" (destructive option first, but never the
// default landing row — see setSelected below).
if (res == PopupMenu::SELECTED && _reset_menu.selectedIndex() == 0) {
the_mesh.resetStats(); // zeroes Dispatcher per-type counters + Mesh forward count
auto res = _reset_menu.handleInput(c); // Back/Cancel dismisses; the only item is "Reset counters"
if (res == PopupMenu::SELECTED) {
the_mesh.resetStats(); // zeroes Dispatcher per-type counters + Mesh forward count + err flags
_task->showAlert("Counters reset", 800);
}
return true;
}
if (c == KEY_UP) { if (_scroll > 0) _scroll--; return true; }
if (c == KEY_DOWN) { _scroll++; return true; } // clamped to max_scroll in render()
if (c == KEY_ENTER) {
_reset_menu.begin("Reset counters?", 2);
_reset_menu.addItem("Reset");
_reset_menu.addItem("Cancel");
_reset_menu.setSelected(1); // default to Cancel so a stray Enter doesn't wipe stats
if (c == KEY_CONTEXT_MENU) { // Hold Enter — same action-menu gesture as the rest of the UI
_reset_menu.begin("Diagnostics", 1);
_reset_menu.addItem("Reset counters");
return true;
}
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { _task->gotoToolsScreen(); return true; }
if (c == KEY_CANCEL) { _task->gotoToolsScreen(); return true; }
return false;
}
};

View File

@@ -172,12 +172,10 @@ public:
int visible = display.listVisible(item_h);
if (visible < 1) visible = 1;
bool show_stats = p && p->client_repeat;
int total = _item_count + (show_stats ? 3 : 0);
// Config only — live forwarding stats live on Tools Diagnostics.
int total = _item_count;
if (_sel < _scroll) _scroll = _sel;
if (_sel >= _scroll + visible) _scroll = _sel - visible + 1;
if (_sel == _item_count - 1) _scroll = total - visible; // reveal stats tail on last item
int max_scroll = total - visible;
if (max_scroll < 0) max_scroll = 0;
if (_scroll > max_scroll) _scroll = max_scroll;
@@ -188,35 +186,23 @@ public:
int row = _scroll + i;
int y = start_y + i * item_h;
char val[16];
if (row < _item_count) {
bool sel = (row == _sel);
int item = _items[row];
display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h - 1, sel);
display.setCursor(2, y);
display.print(itemLabel(item));
if (item == IT_RFREQ && sel && _freq_editor.active) {
_freq_editor.render(display, valCol(display), y);
} else {
itemValue(item, p, val, sizeof(val));
display.drawTextRightAlign(display.width() - reserve - 2, y, val);
}
display.setColor(DisplayDriver::LIGHT);
bool sel = (row == _sel);
int item = _items[row];
display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h - 1, sel);
display.setCursor(2, y);
display.print(itemLabel(item));
if (item == IT_RFREQ && sel && _freq_editor.active) {
_freq_editor.render(display, valCol(display), y);
} else {
int s = row - _item_count;
const char* label = (s == 0) ? "Forwarded" : (s == 1) ? "Pool free" : "Queue";
if (s == 0) snprintf(val, sizeof(val), "%lu", (unsigned long)the_mesh.getNumForwarded());
else if (s == 1) snprintf(val, sizeof(val), "%d", the_mesh.getPoolFreeCount());
else snprintf(val, sizeof(val), "%d", the_mesh.getOutboundQueueLen());
display.setColor(DisplayDriver::LIGHT);
display.setCursor(2, y);
display.print(label);
itemValue(item, p, val, sizeof(val));
display.drawTextRightAlign(display.width() - reserve - 2, y, val);
}
display.setColor(DisplayDriver::LIGHT);
}
drawScrollIndicator(display, start_y, visible * item_h, total, visible, _scroll);
display.setColor(DisplayDriver::LIGHT);
if (_preset_menu.active) _preset_menu.render(display);
return (_preset_menu.active || _freq_editor.active) ? 50 : (show_stats ? 1000 : 500);
return (_preset_menu.active || _freq_editor.active) ? 50 : 500;
}
// x where the right-side value column starts (matches Settings' valCol math).
@@ -273,6 +259,7 @@ public:
if (item == IT_REPEATER && (left || right || enter)) {
p->client_repeat ^= 1;
the_mesh.applyRepeaterRadio(); // switch to profile on enable / restore companion on disable
_task->applyPowerSave(); // duty-cycle RX is forced off while repeating
buildItems(p);
_dirty = true;
return true;

View File

@@ -607,7 +607,9 @@ class SettingsScreen : public UIScreen {
} else if (item == POWER_SAVE) {
display.print("Pwr save");
display.setCursor(valCol(display), y);
display.print((p && p->rx_powersave) ? "ON" : "OFF");
// Forced off (and locked) while the repeater is on — it must hear all traffic.
if (p && p->client_repeat) display.print("--");
else display.print((p && p->rx_powersave) ? "ON" : "OFF");
} else if (item == TX_APC) {
display.print("Auto pwr");
display.setCursor(valCol(display), y);
@@ -998,6 +1000,7 @@ public:
if (left && p->cr > 5) { p->cr--; _task->applyRadioParams(); _dirty = true; return true; }
}
if (_selected == POWER_SAVE && p && (left || right || enter)) {
if (p->client_repeat) { _task->showAlert("Off while repeating", 900); return true; }
p->rx_powersave ^= 1;
_task->applyPowerSave();
_dirty = true;

View File

@@ -478,6 +478,15 @@ class HomeScreen : public UIScreen {
if (blinkOn()) drawBoxedIcon(display, gX, ind, ind_h, ICON_TRAIL);
leftmostX = gX - 1;
}
// Repeater relaying. Same blink convention — a "leave it on and forget"
// mode, so an at-a-glance cue matters (especially on a Custom profile,
// where the device is off your own network while this is shown).
if (_node_prefs && _node_prefs->client_repeat) {
int rX = leftmostX - ind;
if (blinkOn()) drawBoxedIcon(display, rX, ind, ind_h, ICON_REPEATER);
leftmostX = rX - 1;
}
}
return leftmostX;
}
@@ -995,7 +1004,8 @@ public:
// Any blinking status-bar indicator needs a 1 s refresh to animate evenly —
// but the status bar (and its icons) is hidden on the CLOCK page, so don't
// pay the 1 s cadence there for icons that aren't drawn.
bool need_blink = (_page != HomePage::CLOCK) && (auto_adv || _task->trail().isActive());
bool repeating = _node_prefs && _node_prefs->client_repeat;
bool need_blink = (_page != HomePage::CLOCK) && (auto_adv || _task->trail().isActive() || repeating);
if (Features::IS_EINK) {
// slow display: poll every 30 s; inbound msgs force immediate refresh via notify()
return Features::HOME_REFRESH_MS;
@@ -2099,7 +2109,10 @@ void UITask::applyTxPower() {
void UITask::applyPowerSave() {
if (_node_prefs == NULL) return;
radio_driver.setPowerSaving(_node_prefs->rx_powersave);
// A repeater must hear every packet to relay it, so duty-cycle RX (which sleeps
// between preamble checks) is forced off while repeating — the user's pref is
// kept and restored when the repeater is switched off.
radio_driver.setPowerSaving(_node_prefs->rx_powersave && !_node_prefs->client_repeat);
}
void UITask::applyApc() {

View File

@@ -158,6 +158,13 @@ MINI_ICON(ICON_TRAIL, 6, // map pin / location marker (GPS trail logging)
packRow(".####."),
packRow("..##.."));
MINI_ICON(ICON_REPEATER, 6, // » double chevron — relaying/forwarding (repeater active)
packRow("#..#.."),
packRow(".#..#."),
packRow("..#..#"),
packRow(".#..#."),
packRow("#..#.."));
// Trail-map markers — centred on a point (see miniIconDrawCentered) rather
// than anchored to a text line.
MINI_ICON(ICON_MAP_DOT, 3, // ● filled trail point