feat(repeater): dedicated Tools screen + optional radio profile

Move the repeater controls out of Settings > Radio (where the indented sub-item
labels collided with the value column on a 128px OLED) onto their own full-width
Tools > Repeater screen, holding the toggle, the five flood-politeness filters,
and live forwarding stats (Forwarded / Pool free / Queue) together. Toggle text
standardised to ON/OFF throughout.

Adds an optional dedicated radio profile (Network: Current/Custom): enabling the
repeater can switch the radio to a preset/manual freq/bw/sf/cr and disabling
restores the companion's params (revert-on-disable). A profile equal to the
companion = same network; a different one = drop onto a separate repeater
network. MyMesh::applyRepeaterRadio() is the single decision point (boot + every
toggle/edit), gated by repeaterProfileValid(); the profile re-applies after a
reboot left with the repeater on. Persisted behind schema 0xC0DE0010, cleared to
off when an upgraded file yields an invalid config.

Docs updated (settings_screen, tools_screen, README, FEATURES).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-06-19 09:20:16 +02:00
parent 22ad46257f
commit 0da487729d
13 changed files with 449 additions and 112 deletions

View File

@@ -351,6 +351,22 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
(_prefs.repeat_min_snr < -30 || _prefs.repeat_min_snr > 20))
_prefs.repeat_min_snr = NodePrefs::REPEAT_SNR_DISABLED;
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));
rd(&_prefs.repeater_bw, sizeof(_prefs.repeater_bw));
rd(&_prefs.repeater_sf, sizeof(_prefs.repeater_sf));
rd(&_prefs.repeater_cr, sizeof(_prefs.repeater_cr));
// Pre-0x10 files leave stray sentinel bytes here. If the profile fields aren't a
// valid radio config, force the profile off so the repeater can't switch to junk.
if (_prefs.repeater_use_profile > 1) _prefs.repeater_use_profile = 0;
if (!(_prefs.repeater_freq >= 100.0f && _prefs.repeater_freq <= 960.0f
&& _prefs.repeater_sf >= 5 && _prefs.repeater_sf <= 12
&& _prefs.repeater_cr >= 5 && _prefs.repeater_cr <= 8
&& _prefs.repeater_bw >= 7.0f && _prefs.repeater_bw <= 510.0f)) {
_prefs.repeater_use_profile = 0;
_prefs.repeater_freq = _prefs.repeater_bw = 0.0f;
_prefs.repeater_sf = _prefs.repeater_cr = 0;
}
// → 0xC0DE000B: append bot_commands_enabled + quiet-hours. Older files leave
// stray bytes here; clamp so upgraders fall back to off / no quiet hours.
if (_prefs.bot_commands_enabled > 1) _prefs.bot_commands_enabled = 0;
@@ -513,6 +529,11 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
file.write((uint8_t *)&_prefs.repeat_delay_boost, sizeof(_prefs.repeat_delay_boost));
file.write((uint8_t *)&_prefs.repeat_min_snr, sizeof(_prefs.repeat_min_snr));
file.write((uint8_t *)&_prefs.repeat_suppress_dup, sizeof(_prefs.repeat_suppress_dup));
file.write((uint8_t *)&_prefs.repeater_use_profile, sizeof(_prefs.repeater_use_profile));
file.write((uint8_t *)&_prefs.repeater_freq, sizeof(_prefs.repeater_freq));
file.write((uint8_t *)&_prefs.repeater_bw, sizeof(_prefs.repeater_bw));
file.write((uint8_t *)&_prefs.repeater_sf, sizeof(_prefs.repeater_sf));
file.write((uint8_t *)&_prefs.repeater_cr, sizeof(_prefs.repeater_cr));
// Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL.
uint32_t sentinel = NodePrefs::SCHEMA_SENTINEL;

View File

@@ -1418,7 +1418,7 @@ void MyMesh::begin(bool has_display) {
addChannel("Public", PUBLIC_GROUP_PSK); // pre-configure Andy's public channel
_store->loadChannels(this);
radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
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)
@@ -1426,6 +1426,13 @@ void MyMesh::begin(bool has_display) {
radio_driver.getRxBoostedGainMode() ? "Enabled" : "Disabled");
}
void MyMesh::applyRepeaterRadio() {
if (_prefs.client_repeat && _prefs.repeater_use_profile && repeaterProfileValid())
radio_driver.setParams(_prefs.repeater_freq, _prefs.repeater_bw, _prefs.repeater_sf, _prefs.repeater_cr);
else
radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
}
const char *MyMesh::getNodeName() {
return _prefs.node_name;
}

View File

@@ -228,6 +228,19 @@ public:
DataStore* getDataStore() const { return _store; }
void applyApc(); // (re)initialise Adaptive Power Control from prefs
// True when the optional repeater radio profile is a valid LoRa config.
bool repeaterProfileValid() const {
return _prefs.repeater_freq >= 100.0f && _prefs.repeater_freq <= 960.0f
&& _prefs.repeater_sf >= 5 && _prefs.repeater_sf <= 12
&& _prefs.repeater_cr >= 5 && _prefs.repeater_cr <= 8
&& _prefs.repeater_bw >= 7.0f && _prefs.repeater_bw <= 510.0f;
}
// Load the radio with the correct params for the current mode: the repeater
// profile when relaying with a valid dedicated profile, otherwise the
// companion's own params. Single source of truth, used at boot and whenever
// the repeater toggle / network / profile changes.
void applyRepeaterRadio();
bool isAckPending(uint32_t expected_ack) const {
for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; i++)
if (expected_ack_table[i].ack == expected_ack) return true;

View File

@@ -173,11 +173,21 @@ struct NodePrefs { // persisted to file
static const int8_t REPEAT_SNR_DISABLED = -128;
uint8_t repeat_suppress_dup;
// Optional dedicated radio profile for repeater mode. When repeater_use_profile
// is 1, enabling the repeater switches the radio to repeater_freq/bw/sf/cr and
// disabling restores the companion's freq/bw/sf/cr (the fields above). 0 = the
// repeater stays on the current companion frequency (default — no switch).
uint8_t repeater_use_profile;
float repeater_freq;
float repeater_bw;
uint8_t repeater_sf;
uint8_t repeater_cr;
// Tail sentinel written at the end of /new_prefs. Bump the low byte when
// adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so
// older saves are detected on load and skipped (zero-init defaults kept).
// High 24 bits identify the file format; low byte is the schema revision.
static const uint32_t SCHEMA_SENTINEL = 0xC0DE000F;
static const uint32_t SCHEMA_SENTINEL = 0xC0DE0010;
// Bit-index for each home page. Used by page_order (entries store bit+1) and
// by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage

View File

@@ -0,0 +1,336 @@
#pragma once
// Tools Repeater — consolidates the repeater toggle, its flood "politeness"
// filters, an optional dedicated radio profile, and live forwarding stats on one
// screen. A dedicated screen (vs. the old Settings Radio sub-items) gives
// full-width rows, so the longer labels no longer collide with the value column,
// and keeps the relaying controls next to the numbers that show them working.
//
// Network modes:
// - Current — repeat on the companion's current frequency (default).
// - Custom — enabling the repeater switches the radio to a dedicated profile
// (preset or manual), and disabling restores the companion params.
// Set the profile equal to the companion's for "same network".
// Included by UITask.cpp.
#include <helpers/ui/DisplayDriver.h>
#include <helpers/ui/UIScreen.h>
#include "icons.h"
#include "PopupMenu.h"
#include "DigitEditor.h"
#include "../RadioPresets.h"
#include "../MyMesh.h"
extern MyMesh the_mesh;
class RepeaterScreen : public UIScreen {
UITask* _task;
bool _dirty;
int _sel; // index into the interactive items currently shown
int _scroll; // first visible row (render keeps _sel in view)
enum Item {
IT_REPEATER, IT_NETWORK,
IT_RPRESET, IT_RFREQ, IT_RSF, IT_RBW, IT_RCR, // dedicated profile (Custom network)
IT_SKIP, IT_HOPS, IT_YIELD, IT_SNR, IT_SUPPRESS
};
uint8_t _items[12];
int _item_count;
PopupMenu _preset_menu;
DigitEditor _freq_editor;
uint8_t _preset_user_slot[NodePrefs::USER_RADIO_PRESET_MAX];
int _preset_user_count = 0;
static const float* bwOpts(int& count) {
static const float OPTS[] = { 7.8f, 10.4f, 15.6f, 20.8f, 31.25f, 41.7f, 62.5f, 125.0f, 250.0f, 500.0f };
count = 10;
return OPTS;
}
int rptBwIndex(NodePrefs* p) const {
int n; const float* o = bwOpts(n);
for (int i = 0; i < n; i++) if (o[i] == p->repeater_bw) return i;
return 6; // default to 62.5
}
void buildItems(NodePrefs* p) {
_item_count = 0;
_items[_item_count++] = IT_REPEATER;
if (p && p->client_repeat) {
_items[_item_count++] = IT_NETWORK;
if (p->repeater_use_profile) {
_items[_item_count++] = IT_RPRESET;
_items[_item_count++] = IT_RFREQ;
_items[_item_count++] = IT_RSF;
_items[_item_count++] = IT_RBW;
_items[_item_count++] = IT_RCR;
}
_items[_item_count++] = IT_SKIP;
_items[_item_count++] = IT_HOPS;
_items[_item_count++] = IT_YIELD;
_items[_item_count++] = IT_SNR;
_items[_item_count++] = IT_SUPPRESS;
}
if (_sel >= _item_count) _sel = _item_count - 1;
if (_sel < 0) _sel = 0;
}
static const char* itemLabel(int item) {
switch (item) {
case IT_REPEATER: return "Repeater";
case IT_NETWORK: return "Network";
case IT_RPRESET: return "Rpt preset";
case IT_RFREQ: return "Rpt freq";
case IT_RSF: return "Rpt SF";
case IT_RBW: return "Rpt BW";
case IT_RCR: return "Rpt CR";
case IT_SKIP: return "Skip advert";
case IT_HOPS: return "Max hops";
case IT_YIELD: return "Yield";
case IT_SNR: return "Min SNR";
case IT_SUPPRESS: return "Suppress dup";
}
return "";
}
// Name of the preset matching the repeater profile, or "Custom".
const char* rptPresetName(NodePrefs* p) const {
for (int i = 0; i < RADIO_PRESET_COUNT; i++) {
const RadioPreset& r = RADIO_PRESETS[i];
if (r.freq == p->repeater_freq && r.bw == p->repeater_bw && r.sf == p->repeater_sf && r.cr == p->repeater_cr)
return r.name;
}
for (int i = 0; i < NodePrefs::USER_RADIO_PRESET_MAX; i++) {
const NodePrefs::UserRadioPreset& u = p->user_radio_presets[i];
if (!u.name[0]) continue;
if (u.freq == p->repeater_freq && u.bw == p->repeater_bw && u.sf == p->repeater_sf && u.cr == p->repeater_cr)
return u.name;
}
return "Custom";
}
void itemValue(int item, NodePrefs* p, char* buf, size_t n) const {
if (!p) { strncpy(buf, "OFF", n); buf[n-1]=0; return; }
switch (item) {
case IT_REPEATER: strncpy(buf, p->client_repeat ? "ON" : "OFF", n); break;
case IT_NETWORK: strncpy(buf, p->repeater_use_profile ? "Custom" : "Current", n); break;
case IT_RPRESET: strncpy(buf, rptPresetName(p), n); break;
case IT_RFREQ: snprintf(buf, n, "%.3f", p->repeater_freq); break;
case IT_RSF: snprintf(buf, n, "%d", (int)p->repeater_sf); break;
case IT_RBW: snprintf(buf, n, "%.1f", p->repeater_bw); break;
case IT_RCR: snprintf(buf, n, "%d", (int)p->repeater_cr); break;
case IT_SKIP: strncpy(buf, p->repeat_skip_adverts ? "ON" : "OFF", n); break;
case IT_HOPS:
if (p->repeat_max_hops > 0) snprintf(buf, n, "%d", (int)p->repeat_max_hops);
else strncpy(buf, "OFF", n);
break;
case IT_YIELD:
if (p->repeat_delay_boost > 0) snprintf(buf, n, "x%d", (int)p->repeat_delay_boost + 1);
else strncpy(buf, "OFF", n);
break;
case IT_SNR:
if (p->repeat_min_snr != NodePrefs::REPEAT_SNR_DISABLED) snprintf(buf, n, "%ddB", (int)p->repeat_min_snr);
else strncpy(buf, "OFF", n);
break;
case IT_SUPPRESS: strncpy(buf, p->repeat_suppress_dup ? "ON" : "OFF", n); break;
default: strncpy(buf, "", n); break;
}
buf[n - 1] = '\0';
}
// Seed the profile from the companion's current params (used when first
// switching to Custom, so the starting point is a valid, familiar config).
void seedProfileFromCompanion(NodePrefs* p) {
p->repeater_freq = p->freq; p->repeater_bw = p->bw;
p->repeater_sf = p->sf; p->repeater_cr = p->cr;
}
void openPresetMenu(NodePrefs* p) {
_preset_menu.begin("Repeater Preset", 6);
for (int i = 0; i < RADIO_PRESET_COUNT; i++) _preset_menu.addItem(RADIO_PRESETS[i].name);
_preset_user_count = 0;
for (int i = 0; i < NodePrefs::USER_RADIO_PRESET_MAX; i++) {
if (!p->user_radio_presets[i].name[0]) continue;
_preset_menu.addItem(p->user_radio_presets[i].name);
_preset_user_slot[_preset_user_count++] = (uint8_t)i;
}
}
public:
RepeaterScreen(UITask* task) : _task(task), _dirty(false), _sel(0), _scroll(0), _item_count(1) {}
void enter() { _dirty = false; _sel = 0; _scroll = 0; _preset_menu.active = false; _freq_editor.active = false; }
int render(DisplayDriver& display) override {
NodePrefs* p = _task->getNodePrefs();
buildItems(p);
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
display.drawCenteredHeader("REPEATER");
const int item_h = display.lineStep();
const int start_y = display.listStart();
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);
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;
if (_scroll < 0) _scroll = 0;
const int reserve = scrollIndicatorReserve(display, total, visible);
for (int i = 0; i < visible && (_scroll + i) < total; i++) {
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);
} 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);
display.drawTextRightAlign(display.width() - reserve - 2, y, val);
}
}
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);
}
// x where the right-side value column starts (matches Settings' valCol math).
static int valCol(DisplayDriver& d) { return d.width() - d.getCharWidth() * 8; }
bool handleInput(char c) override {
NodePrefs* p = _task->getNodePrefs();
// Modal overlays first.
if (_preset_menu.active) {
auto res = _preset_menu.handleInput(c);
if (res == PopupMenu::SELECTED && p) {
int idx = _preset_menu.selectedIndex();
if (idx >= 0 && idx < RADIO_PRESET_COUNT) {
const RadioPreset& r = RADIO_PRESETS[idx];
p->repeater_freq = r.freq; p->repeater_bw = r.bw; p->repeater_sf = r.sf; p->repeater_cr = r.cr;
} else {
int u = idx - RADIO_PRESET_COUNT;
if (u >= 0 && u < _preset_user_count) {
const NodePrefs::UserRadioPreset& up = p->user_radio_presets[_preset_user_slot[u]];
p->repeater_freq = up.freq; p->repeater_bw = up.bw; p->repeater_sf = up.sf; p->repeater_cr = up.cr;
}
}
the_mesh.applyRepeaterRadio(); // live if currently relaying on the profile
_dirty = true;
}
return true;
}
if (_freq_editor.active) {
float before = _freq_editor.value;
_freq_editor.handleInput(c);
if (p && _freq_editor.value != before) {
p->repeater_freq = _freq_editor.value;
the_mesh.applyRepeaterRadio();
_dirty = true;
}
return true;
}
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) {
if (_dirty) the_mesh.savePrefs();
_task->gotoToolsScreen();
return true;
}
if (c == KEY_UP) { _sel = (_sel > 0) ? _sel - 1 : _item_count - 1; return true; }
if (c == KEY_DOWN) { _sel = (_sel < _item_count - 1) ? _sel + 1 : 0; return true; }
if (!p) return false;
bool right = keyIsNext(c);
bool left = keyIsPrev(c);
bool enter = (c == KEY_ENTER);
int item = _items[_sel];
if (item == IT_REPEATER && (left || right || enter)) {
p->client_repeat ^= 1;
the_mesh.applyRepeaterRadio(); // switch to profile on enable / restore companion on disable
buildItems(p);
_dirty = true;
return true;
}
if (item == IT_NETWORK && (left || right || enter)) {
p->repeater_use_profile ^= 1;
if (p->repeater_use_profile && !the_mesh.repeaterProfileValid())
seedProfileFromCompanion(p); // start Custom from a valid, familiar config
the_mesh.applyRepeaterRadio();
buildItems(p);
_dirty = true;
return true;
}
if (item == IT_RPRESET && enter) { openPresetMenu(p); return true; }
if (item == IT_RFREQ && enter) {
float lo, hi; radio_driver.getFreqBounds(lo, hi);
_freq_editor.begin(p->repeater_freq, lo, hi, 3, 3);
return true;
}
if (item == IT_RSF) {
if (right && p->repeater_sf < 12) { p->repeater_sf++; the_mesh.applyRepeaterRadio(); _dirty = true; return true; }
if (left && p->repeater_sf > 5) { p->repeater_sf--; the_mesh.applyRepeaterRadio(); _dirty = true; return true; }
}
if (item == IT_RBW) {
int n; const float* o = bwOpts(n); int idx = rptBwIndex(p);
if (right && idx < n - 1) { p->repeater_bw = o[idx + 1]; the_mesh.applyRepeaterRadio(); _dirty = true; return true; }
if (left && idx > 0) { p->repeater_bw = o[idx - 1]; the_mesh.applyRepeaterRadio(); _dirty = true; return true; }
}
if (item == IT_RCR) {
if (right && p->repeater_cr < 8) { p->repeater_cr++; the_mesh.applyRepeaterRadio(); _dirty = true; return true; }
if (left && p->repeater_cr > 5) { p->repeater_cr--; the_mesh.applyRepeaterRadio(); _dirty = true; return true; }
}
if (item == IT_SKIP && (left || right || enter)) {
p->repeat_skip_adverts ^= 1; _dirty = true; return true;
}
if (item == IT_HOPS) {
if (right && p->repeat_max_hops < 8) { p->repeat_max_hops++; _dirty = true; return true; }
if (left && p->repeat_max_hops > 0) { p->repeat_max_hops--; _dirty = true; return true; }
}
if (item == IT_YIELD) {
if (right && p->repeat_delay_boost < 8) { p->repeat_delay_boost++; _dirty = true; return true; }
if (left && p->repeat_delay_boost > 0) { p->repeat_delay_boost--; _dirty = true; return true; }
}
if (item == IT_SNR) {
int8_t v = p->repeat_min_snr;
if (right) {
v = (v == NodePrefs::REPEAT_SNR_DISABLED) ? -20 : (v < 10 ? v + 1 : 10);
p->repeat_min_snr = v; _dirty = true; return true;
}
if (left) {
if (v != NodePrefs::REPEAT_SNR_DISABLED)
p->repeat_min_snr = (v <= -20) ? NodePrefs::REPEAT_SNR_DISABLED : (int8_t)(v - 1);
_dirty = true; return true;
}
}
if (item == IT_SUPPRESS && (left || right || enter)) {
p->repeat_suppress_dup ^= 1; _dirty = true; return true;
}
return false;
}
};

View File

@@ -58,12 +58,6 @@ class SettingsScreen : public UIScreen {
TX_POWER,
RADIO_PRESET,
CUSTOM_FREQ, CUSTOM_SF, CUSTOM_BW, CUSTOM_CR,
REPEATER,
RPT_SKIP_ADV, // repeater politeness sub-items, only shown when REPEATER is on
RPT_MAX_HOPS,
RPT_DELAY,
RPT_MIN_SNR,
RPT_SUPPRESS,
POWER_SAVE,
TX_APC,
// System section
@@ -279,22 +273,11 @@ class SettingsScreen : public UIScreen {
cur_collapsed = (_collapsed >> cur_sec) & 1;
if (_vis_count < MAX_VIS) _vis[_vis_count++] = (uint8_t)i;
} else if (!cur_collapsed && _vis_count < MAX_VIS) {
// Repeater politeness knobs are meaningless when the repeater is off, so
// hide them until it's enabled — keeps the Radio section short by default.
if (isRepeaterSubItem(i)) {
NodePrefs* p = _task->getNodePrefs();
if (!p || !p->client_repeat) continue;
}
_vis[_vis_count++] = (uint8_t)i;
}
}
}
bool isRepeaterSubItem(int item) const {
return item == RPT_SKIP_ADV || item == RPT_MAX_HOPS || item == RPT_DELAY
|| item == RPT_MIN_SNR || item == RPT_SUPPRESS;
}
bool isHomePage(int item) const {
return item == HOME_CLOCK || item == HOME_RECENT || item == HOME_RADIO ||
item == HOME_BT || item == HOME_ADVERT || item == HOME_TOOLS ||
@@ -621,39 +604,6 @@ class SettingsScreen : public UIScreen {
snprintf(buf, sizeof(buf), "%d", p ? (int)p->cr : 0);
display.setCursor(valCol(display), y);
display.print(buf);
} else if (item == REPEATER) {
display.print("Repeater");
display.setCursor(valCol(display), y);
display.print((p && p->client_repeat) ? "ON" : "OFF");
} else if (item == RPT_SKIP_ADV) {
display.print(" Skip advert");
display.setCursor(valCol(display), y);
display.print((p && p->repeat_skip_adverts) ? "ON" : "OFF");
} else if (item == RPT_MAX_HOPS) {
display.print(" Max hops");
char buf[6];
if (p && p->repeat_max_hops > 0) snprintf(buf, sizeof(buf), "%d", (int)p->repeat_max_hops);
else strcpy(buf, "Off");
display.setCursor(valCol(display), y);
display.print(buf);
} else if (item == RPT_DELAY) {
display.print(" Yield");
char buf[6];
if (p && p->repeat_delay_boost > 0) snprintf(buf, sizeof(buf), "x%d", (int)p->repeat_delay_boost + 1);
else strcpy(buf, "Off");
display.setCursor(valCol(display), y);
display.print(buf);
} else if (item == RPT_MIN_SNR) {
display.print(" Min SNR");
char buf[8];
if (p && p->repeat_min_snr != NodePrefs::REPEAT_SNR_DISABLED) snprintf(buf, sizeof(buf), "%ddB", (int)p->repeat_min_snr);
else strcpy(buf, "Off");
display.setCursor(valCol(display), y);
display.print(buf);
} else if (item == RPT_SUPPRESS) {
display.print(" Suppress dup");
display.setCursor(valCol(display), y);
display.print((p && p->repeat_suppress_dup) ? "ON" : "OFF");
} else if (item == POWER_SAVE) {
display.print("Pwr save");
display.setCursor(valCol(display), y);
@@ -1047,43 +997,6 @@ public:
if (right && p->cr < 8) { p->cr++; _task->applyRadioParams(); _dirty = true; return true; }
if (left && p->cr > 5) { p->cr--; _task->applyRadioParams(); _dirty = true; return true; }
}
if (_selected == REPEATER && p && (left || right || enter)) {
p->client_repeat = p->client_repeat ? 0 : 1;
buildVis(); // show/hide the politeness sub-items immediately
_dirty = true;
return true;
}
if (_selected == RPT_SKIP_ADV && p && (left || right || enter)) {
p->repeat_skip_adverts ^= 1;
_dirty = true;
return true;
}
if (_selected == RPT_MAX_HOPS && p) {
if (right && p->repeat_max_hops < 8) { p->repeat_max_hops++; _dirty = true; return true; }
if (left && p->repeat_max_hops > 0) { p->repeat_max_hops--; _dirty = true; return true; }
}
if (_selected == RPT_DELAY && p) {
if (right && p->repeat_delay_boost < 8) { p->repeat_delay_boost++; _dirty = true; return true; }
if (left && p->repeat_delay_boost > 0) { p->repeat_delay_boost--; _dirty = true; return true; }
}
if (_selected == RPT_MIN_SNR && p) {
// Steps -20..10 dB; stepping left past the floor turns the filter off.
int8_t v = p->repeat_min_snr;
if (right) {
v = (v == NodePrefs::REPEAT_SNR_DISABLED) ? -20 : (v < 10 ? v + 1 : 10);
p->repeat_min_snr = v; _dirty = true; return true;
}
if (left) {
if (v != NodePrefs::REPEAT_SNR_DISABLED)
p->repeat_min_snr = (v <= -20) ? NodePrefs::REPEAT_SNR_DISABLED : (int8_t)(v - 1);
_dirty = true; return true;
}
}
if (_selected == RPT_SUPPRESS && p && (left || right || enter)) {
p->repeat_suppress_dup ^= 1;
_dirty = true;
return true;
}
if (_selected == POWER_SAVE && p && (left || right || enter)) {
p->rx_powersave ^= 1;
_task->applyPowerSave();

View File

@@ -7,7 +7,7 @@ class ToolsScreen : public UIScreen {
int _sel;
int _scroll = 0;
static const int ITEM_COUNT = 7;
static const int ITEM_COUNT = 8;
static const char* ITEMS[ITEM_COUNT];
public:
@@ -38,8 +38,9 @@ public:
if (_sel == 4) { _task->gotoTrailScreen(); return true; }
if (_sel == 5) { _task->gotoCompassScreen(); return true; }
if (_sel == 6) { _task->gotoDiagnosticsScreen(); return true; }
if (_sel == 7) { _task->gotoRepeaterScreen(); return true; }
}
return false;
}
};
const char* ToolsScreen::ITEMS[7] = { "Ringtone Editor", "Auto-Reply Bot", "Nearby Nodes", "Auto-Advert", "Trail", "Compass", "Diagnostics" };
const char* ToolsScreen::ITEMS[8] = { "Ringtone Editor", "Auto-Reply Bot", "Nearby Nodes", "Auto-Advert", "Trail", "Compass", "Diagnostics", "Repeater" };

View File

@@ -126,6 +126,7 @@ static const int QUICK_MSGS_MAX = 10;
#include "TrailScreen.h"
#include "CompassScreen.h"
#include "DiagnosticsScreen.h"
#include "RepeaterScreen.h"
#include "ToolsScreen.h"
#ifndef BATT_MIN_MILLIVOLTS
@@ -1200,6 +1201,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
trail_screen = new TrailScreen(this, &_trail);
compass_screen = new CompassScreen(this);
diag_screen = new DiagnosticsScreen(this);
repeater_screen = new RepeaterScreen(this);
applyBrightness();
applyFont();
applyRotation();
@@ -1250,6 +1252,11 @@ void UITask::gotoDiagnosticsScreen() {
setCurrScreen(diag_screen);
}
void UITask::gotoRepeaterScreen() {
((RepeaterScreen*)repeater_screen)->enter();
setCurrScreen(repeater_screen);
}
void UITask::gotoAutoAdvertScreen() {
((AutoAdvertScreen*)auto_advert_screen)->enter();
setCurrScreen(auto_advert_screen);

View File

@@ -80,6 +80,7 @@ class UITask : public AbstractUITask {
UIScreen* trail_screen;
UIScreen* compass_screen;
UIScreen* diag_screen;
UIScreen* repeater_screen;
UIScreen* curr;
CayenneLPP _dash_lpp;
TrailStore _trail;
@@ -154,6 +155,7 @@ public:
void gotoTrailScreen();
void gotoCompassScreen();
void gotoDiagnosticsScreen();
void gotoRepeaterScreen();
TrailStore& trail() { return _trail; }
WaypointStore& waypoints() { return _waypoints; }
// Shared on-screen keyboard — only one screen drives it at a time.