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

@@ -453,10 +453,10 @@ Tracker L1 OLED/e-ink, GAT562 30S). All on `feature/companion-repeater-presets`.
flood-depth cap are always applied. Packet pool bumped 16→32 to match the
repeater workload (a too-small pool starved channel/DM reception once relaying
queued retransmits).
- **Consolidated on Tools Repeater** — the toggle, the five filters, and live
forwarding stats (Forwarded / Pool free / Queue) share one full-width screen,
rather than the original Settings Radio sub-items (whose indented labels
collided with the value column on a 128px OLED).
- **Consolidated on Tools Repeater** — the toggle and the five filters share
one full-width screen, rather than the original Settings Radio sub-items
(whose indented labels collided with the value column on a 128px OLED). Live
forwarding stats (Forwarded / Pool free / Queue) stay on Tools Diagnostics.
- **Optional dedicated radio profile** (`Network: Current/Custom`,
`repeater_use_profile` + `repeater_freq/bw/sf/cr`). Custom switches the radio to
a preset/manual profile when the repeater is enabled and restores the

View File

@@ -71,7 +71,7 @@ Lists all available home screen pages. For each entry:
| SF | 512 | LEFT/RIGHT. Spreading factor. |
| BW | 7.8500 kHz | LEFT/RIGHT cycles the standard LoRa bandwidths. |
| CR | 58 | LEFT/RIGHT. Coding rate (4/54/8). |
| Pwr save | ON / OFF | **Battery saver.** Hardware duty-cycle receive: the SX126x cycles RX↔sleep on its own and wakes on a preamble, cutting average RX current. Trades a little receive latency; leave OFF for lowest-latency reception. Requires an SX126x radio (otherwise stays on continuous RX). |
| Pwr save | ON / OFF | **Battery saver.** Hardware duty-cycle receive: the SX126x cycles RX↔sleep on its own and wakes on a preamble, cutting average RX current. Trades a little receive latency; leave OFF for lowest-latency reception. Requires an SX126x radio (otherwise stays on continuous RX). **Forced off (shown as `--`) while the repeater is on** — a repeater must listen continuously; your setting is restored when the repeater is switched off. |
| Auto pwr | ON / OFF | **Adaptive Power Control.** Lowers actual TX power on strong links to save energy, ramping back up — to the **TX Pwr** ceiling — on weak or lost links. Link quality comes from direct-message ACK SNR and, for channel messages (no ACK), from hearing a repeater rebroadcast your packet. The radio page / name bar shows the live power. Default OFF (fixed TX power). |
The **repeater** mode and its flood filters live on their own screen — see **Tools Repeater**.

View File

@@ -309,16 +309,17 @@ A single read-only screen of live device and mesh stats, refreshed once a second
| RSSI/SNR | Signal strength / signal-to-noise of the last received packet |
| Pool free | Free entries in the packet pool |
| Queue | Packets waiting in the outbound queue |
| Errors | Radio error flags since boot/reset — `OK`, or tokens `F` (queue full), `C` (CAD timeout), `R` (RX-start timeout) |
The packet counters and **Forwarded** are cumulative since boot. Press **Enter** to open a confirm popup that resets them (defaults to *Cancel*); the live readings (noise, RSSI/SNR, pool, queue, uptime) are not affected. **Cancel/Back** returns to the Tools list.
The packet counters, **Forwarded** and **Errors** are cumulative since boot. **Hold Enter** opens a one-item *Reset counters* menu (Back dismisses it); the live readings (noise, RSSI/SNR, pool, queue, uptime) are not affected. **Cancel/Back** returns to the Tools list.
The counters make the repeater behaviour observable: **Forwarded** confirms the node is actually relaying (not just configured to), and **Pool free** / **Queue** show whether forwarding is exhausting the packet pool. See **Tools Repeater** for the relaying options (which also shows these same forwarding stats in context).
The counters make the repeater behaviour observable: **Forwarded** confirms the node is actually relaying (not just configured to), and **Pool free** / **Queue** show whether forwarding is exhausting the packet pool. See **Tools Repeater** for the relaying options.
---
## Repeater
Turns the companion into a packet **repeater** while it keeps working as a normal companion — no separate firmware. By default it relays on its **current** frequency, or you can give it a dedicated radio profile to switch to while relaying (see **Network** below). Loop-detection and an advert flood-depth cap are always applied. This screen keeps the toggle, the network/profile, its flood-filter options, and live forwarding stats together.
Turns the companion into a packet **repeater** while it keeps working as a normal companion — no separate firmware. By default it relays on its **current** frequency, or you can give it a dedicated radio profile to switch to while relaying (see **Network** below). Loop-detection and an advert flood-depth cap are always applied. This screen keeps the toggle, the network/profile, and its flood-filter options together; live forwarding stats are on **Tools Diagnostics**.
Navigate with **UP/DOWN**; change a value with **LEFT/RIGHT** (or **Enter** for toggles). **Cancel/Back** saves and returns to Tools.
@@ -339,4 +340,8 @@ The five flood filters are **opt-in** (default OFF, so a plain repeater is unaff
**Same network vs. separate network.** With **Network = Current** (or a Custom profile set equal to your companion settings) the repeater stays on your own network — you keep messaging while relaying. With a *different* Custom profile the device moves entirely onto that network while relaying (a single radio can't be on two at once) and returns to your companion network when the repeater is switched off. The profile also re-applies after a reboot if the repeater was left on.
While the repeater is on, a **»** indicator appears in the status bar (same blink convention as the auto-advert and trail markers) so you can tell it's relaying at a glance, and **Settings Radio Pwr save** is forced off (a repeater must listen continuously) — your power-save setting returns when you switch the repeater off.
Live forwarding stats — **Forwarded**, **Pool free**, **Queue** — are shown on **Tools Diagnostics**.
While the repeater is ON, the bottom of the screen shows live stats: **Forwarded** (packets actually re-transmitted — reflects Suppress dup), **Pool free**, and **Queue**. The same numbers appear, alongside the full device picture, in **Tools Diagnostics**.

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

View File

@@ -197,6 +197,7 @@ public:
uint32_t getNumRecvByType(uint8_t payload_type) const { return n_recv_by_type[payload_type & 0x0F]; }
int getPoolFreeCount() const { return _mgr->getFreeCount(); }
int getOutboundQueueLen() const { return _mgr->getOutboundTotal(); }
uint16_t getErrFlags() const { return _err_flags; } // ERR_EVENT_* bitmask since last resetStats()
virtual void resetStats() {
n_sent_flood = n_sent_direct = n_recv_flood = n_recv_direct = 0;
memset(n_sent_by_type, 0, sizeof(n_sent_by_type));