fix(repeater): close radio-profile bypass paths, dedupe preset/BW logic

CMD_SET_RADIO_PARAMS and the on-device Settings radio editors
(Freq/SF/BW/CR/Preset) called radio_driver.setParams() directly instead
of applyRepeaterRadio(), so editing the companion's own radio params
while relaying on a dedicated profile silently retuned the live radio
off that profile. Both now route through applyRepeaterRadio(), the
single decision point already used at boot and by the on-device
repeater toggle.

Also: overhear-suppression matched queued packets by payload hash
alone, so it could cancel a queued direct-route retransmit that
happened to share a hash with an overheard flood packet — now scoped
to flood-queued entries only. repeaterProfileValid()'s frequency floor
(100 MHz) is raised to 150 MHz to match the radio's actual validated
range. RepeaterScreen's preset-name lookup now uses the same
epsilon-tolerant float comparison as Settings instead of exact ==,
avoiding inconsistent "Custom" labeling between the two screens.

Dedupe: the BW-options table, the value-column x-offset math, and the
default-profile-seeding block had each drifted into two or three
independent copies (RepeaterScreen vs Settings, MyMesh constructor vs
DataStore.cpp upgrade path); factored into shared helpers in
RadioPresets.h / NodePrefs.h.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-06-20 01:50:21 +02:00
parent afe7b8a0ca
commit 4999620fba
9 changed files with 63 additions and 50 deletions

View File

@@ -364,15 +364,8 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
// norm; that stays opt-in. Band-matched rather than a flat frequency so the
// default can't land outside what's legal where the companion is set up.
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 = 1;
_prefs.repeater_freq = defaultRepeaterFreqForBand(_prefs.freq);
_prefs.repeater_bw = LORA_BW;
_prefs.repeater_sf = LORA_SF;
_prefs.repeater_cr = LORA_CR;
if (!isValidRepeaterProfile(_prefs.repeater_freq, _prefs.repeater_bw, _prefs.repeater_sf, _prefs.repeater_cr)) {
seedDefaultRepeaterProfile(_prefs);
}
// → 0xC0DE000B: append bot_commands_enabled + quiet-hours. Older files leave
// stray bytes here; clamp so upgraders fall back to off / no quiet hours.

View File

@@ -1309,12 +1309,8 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
_prefs.tx_power_dbm = LORA_TX_POWER;
// Repeater profile default for a true first boot (no prefs file yet, so
// loadPrefs() below is a no-op) — same band-matched seed as the upgrade
// path in DataStore.cpp, kept in sync rather than left at memset's 0/Current.
_prefs.repeater_use_profile = 1;
_prefs.repeater_freq = defaultRepeaterFreqForBand(_prefs.freq);
_prefs.repeater_bw = LORA_BW;
_prefs.repeater_sf = LORA_SF;
_prefs.repeater_cr = LORA_CR;
// path in DataStore.cpp.
seedDefaultRepeaterProfile(_prefs);
_prefs.gps_enabled = 0; // GPS disabled by default
_prefs.gps_interval = 0; // No automatic GPS updates by default
_prefs.display_brightness = 2; // medium brightness by default
@@ -1868,7 +1864,7 @@ void MyMesh::handleCmdFrame(size_t len) {
_prefs.client_repeat = repeat;
savePrefs();
radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
applyRepeaterRadio(); // companion params, or the repeater profile if relaying with one set
// Keep the "repeating ⇒ continuous RX, full TX power" invariants when repeat
// is toggled via the app, mirroring the on-device path (a repeater must hear
// all traffic and relay at consistent power).

View File

@@ -224,10 +224,7 @@ public:
// 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;
return isValidRepeaterProfile(_prefs.repeater_freq, _prefs.repeater_bw, _prefs.repeater_sf, _prefs.repeater_cr);
}
// Load the radio with the correct params for the current mode: the repeater
// profile when relaying with a valid dedicated profile, otherwise the

View File

@@ -291,3 +291,23 @@ struct NodePrefs { // persisted to file
if (pos < size) buf[pos] = '\0';
}
};
// Bounds for a usable repeater radio profile — must match the radio chip's own
// validated range (see CustomSX1262Wrapper::getFreqBounds(), 150-960 MHz), so
// a profile that passes here is guaranteed to actually take effect on the radio.
static inline bool isValidRepeaterProfile(float freq, float bw, uint8_t sf, uint8_t cr) {
return freq >= 150.0f && freq <= 960.0f
&& sf >= 5 && sf <= 12
&& cr >= 5 && cr <= 8
&& bw >= 7.0f && bw <= 510.0f;
}
// Seed a never-configured repeater profile in the same band as the companion's
// own network (see defaultRepeaterFreqForBand() above for why).
static inline void seedDefaultRepeaterProfile(NodePrefs& prefs) {
prefs.repeater_use_profile = 1;
prefs.repeater_freq = defaultRepeaterFreqForBand(prefs.freq);
prefs.repeater_bw = LORA_BW;
prefs.repeater_sf = LORA_SF;
prefs.repeater_cr = LORA_CR;
}

View File

@@ -1,4 +1,5 @@
#pragma once
#include <math.h>
// Community-suggested per-region RF presets (freq/bw/sf/cr), sourced from the
// MeshCore app's "suggested_radio_settings" config plus a few community additions.
@@ -33,3 +34,17 @@ static const RadioPreset RADIO_PRESETS[] = {
{ "Vietnam (Narrow)", 920.250f, 62.5f, 8, 5 },
};
static const int RADIO_PRESET_COUNT = sizeof(RADIO_PRESETS) / sizeof(RADIO_PRESETS[0]);
// Standard SX126x/SX127x LoRa bandwidths (kHz) — manual BW fields (companion and
// repeater profile alike) cycle through these rather than a free-form value, so
// they can't land on an unsupported setting.
static const float LORA_BW_OPTS[] = { 7.8f, 10.4f, 15.6f, 20.8f, 31.25f, 41.7f, 62.5f, 125.0f, 250.0f, 500.0f };
static const int LORA_BW_OPT_COUNT = sizeof(LORA_BW_OPTS) / sizeof(LORA_BW_OPTS[0]);
// True if (freq, bw, sf, cr) matches the radio's currently-applied (cur_*) params.
// Floats are compared with a small epsilon since they may have round-tripped
// through unit conversions (e.g. kHz integers from the companion app).
static inline bool radioParamsMatchPreset(float cur_freq, float cur_bw, uint8_t cur_sf, uint8_t cur_cr,
float freq, float bw, uint8_t sf, uint8_t cr) {
return fabsf(cur_freq - freq) < 0.001f && fabsf(cur_bw - bw) < 0.001f && cur_sf == sf && cur_cr == cr;
}

View File

@@ -45,15 +45,15 @@ class RepeaterScreen : public UIScreen {
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;
}
// Nearest entry in LORA_BW_OPTS to the repeater profile's bw.
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
int best = 0;
float best_diff = 1e9f;
for (int i = 0; i < LORA_BW_OPT_COUNT; i++) {
float diff = fabsf(p->repeater_bw - LORA_BW_OPTS[i]);
if (diff < best_diff) { best_diff = diff; best = i; }
}
return best;
}
void buildItems(NodePrefs* p) {
@@ -100,13 +100,13 @@ class RepeaterScreen : public UIScreen {
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)
if (radioParamsMatchPreset(p->repeater_freq, p->repeater_bw, p->repeater_sf, p->repeater_cr, r.freq, r.bw, r.sf, r.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)
if (radioParamsMatchPreset(p->repeater_freq, p->repeater_bw, p->repeater_sf, p->repeater_cr, u.freq, u.bw, u.sf, u.cr))
return u.name;
}
return "Custom";
@@ -196,7 +196,7 @@ public:
display.setCursor(2, y);
display.print(itemLabel(item));
if (item == IT_RFREQ && sel && _freq_editor.active) {
_freq_editor.render(display, valCol(display), y);
_freq_editor.render(display, display.valCol(), y);
} else {
itemValue(item, p, val, sizeof(val));
display.drawTextRightAlign(display.width() - reserve - 2, y, val);
@@ -209,9 +209,6 @@ public:
return (_preset_menu.active || _freq_editor.active) ? 50 : 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();
@@ -289,9 +286,9 @@ public:
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; }
int idx = rptBwIndex(p);
if (right && idx < LORA_BW_OPT_COUNT - 1) { p->repeater_bw = LORA_BW_OPTS[idx + 1]; the_mesh.applyRepeaterRadio(); _dirty = true; return true; }
if (left && idx > 0) { p->repeater_bw = LORA_BW_OPTS[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; }

View File

@@ -102,10 +102,6 @@ class SettingsScreen : public UIScreen {
static const int SOUND_COUNT = 4;
static const char* AD_SCOPE_LABELS[2];
static const int AD_SCOPE_COUNT = 2;
// Standard SX126x/SX127x LoRa bandwidths (kHz) — manual BW field cycles through
// these rather than a free-form value, so it can't land on an unsupported setting.
static const float CUSTOM_BW_OPTS[10];
static const int CUSTOM_BW_COUNT = 10;
#if FEAT_FULL_REFRESH_SETTING
static const char* EINK_FULL_REFRESH_LABELS[5];
static const int EINK_FULL_REFRESH_COUNT = 5;
@@ -120,7 +116,7 @@ class SettingsScreen : public UIScreen {
}
static bool matchesPreset(NodePrefs* p, float freq, float bw, uint8_t sf, uint8_t cr) {
return fabsf(p->freq - freq) < 0.001f && fabsf(p->bw - bw) < 0.001f && p->sf == sf && p->cr == cr;
return radioParamsMatchPreset(p->freq, p->bw, p->sf, p->cr, freq, bw, sf, cr);
}
// Display name for the Preset row: a built-in preset, a saved user preset, or
@@ -179,14 +175,14 @@ class SettingsScreen : public UIScreen {
_task->showAlert("Preset saved", 800);
}
// Nearest entry in CUSTOM_BW_OPTS to the current bw (also used to step left/right).
// Nearest entry in LORA_BW_OPTS to the current bw (also used to step left/right).
int customBwIndex() {
NodePrefs* p = _task->getNodePrefs();
float bw = p ? p->bw : 62.5f;
int best = 0;
float best_diff = 1e9f;
for (int i = 0; i < CUSTOM_BW_COUNT; i++) {
float diff = fabsf(bw - CUSTOM_BW_OPTS[i]);
for (int i = 0; i < LORA_BW_OPT_COUNT; i++) {
float diff = fabsf(bw - LORA_BW_OPTS[i]);
if (diff < best_diff) { best_diff = diff; best = i; }
}
return best;
@@ -994,8 +990,8 @@ public:
}
if (_selected == CUSTOM_BW && p) {
int idx = customBwIndex();
if (right && idx < CUSTOM_BW_COUNT - 1) { p->bw = CUSTOM_BW_OPTS[idx + 1]; _task->applyRadioParams(); _dirty = true; return true; }
if (left && idx > 0) { p->bw = CUSTOM_BW_OPTS[idx - 1]; _task->applyRadioParams(); _dirty = true; return true; }
if (right && idx < LORA_BW_OPT_COUNT - 1) { p->bw = LORA_BW_OPTS[idx + 1]; _task->applyRadioParams(); _dirty = true; return true; }
if (left && idx > 0) { p->bw = LORA_BW_OPTS[idx - 1]; _task->applyRadioParams(); _dirty = true; return true; }
}
if (_selected == CUSTOM_CR && p) {
if (right && p->cr < 8) { p->cr++; _task->applyRadioParams(); _dirty = true; return true; }
@@ -1142,4 +1138,3 @@ const char* SettingsScreen::AD_SCOPE_LABELS[2] = { "All", "Zero-hop" };
#if FEAT_FULL_REFRESH_SETTING
const char* SettingsScreen::EINK_FULL_REFRESH_LABELS[5] = { "off", "5", "10", "20", "30" };
#endif
const float SettingsScreen::CUSTOM_BW_OPTS[10] = { 7.8f, 10.4f, 15.6f, 20.8f, 31.25f, 41.7f, 62.5f, 125.0f, 250.0f, 500.0f };

View File

@@ -2125,7 +2125,7 @@ void UITask::applyApc() {
void UITask::applyRadioParams() {
if (_node_prefs == NULL) return;
radio_driver.setParams(_node_prefs->freq, _node_prefs->bw, _node_prefs->sf, _node_prefs->cr);
the_mesh.applyRepeaterRadio(); // companion params, or the repeater profile if relaying with one set
}
void UITask::applyBrightness() {

View File

@@ -272,7 +272,7 @@ void Dispatcher::suppressQueuedDuplicate(Packet* pkt) {
int n = _mgr->getOutboundTotal();
for (int i = 0; i < n; i++) {
Packet* q = _mgr->getOutboundByIdx(i);
if (q == NULL) continue;
if (q == NULL || !q->isRouteFlood()) continue; // only ever suppress a queued flood retransmit
uint8_t qh[MAX_HASH_SIZE];
q->calculatePacketHash(qh);
if (memcmp(h, qh, MAX_HASH_SIZE) == 0) {