feat(ui-core,ui-lvgl): repeater control in the Core, L2 repeater mode

Min SNR now defaults to off on a fresh device (was 0 dB without a prefs file).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-25 23:33:11 +02:00
co-authored by Claude Opus 5.5
parent 6f5fc07cd0
commit 2624ab0548
10 changed files with 375 additions and 62 deletions
@@ -66,6 +66,15 @@ Confirmed: green dot inside the ring, waypoints much better, download works, set
- [ ] Room server login from the phone app / L1 still works while this exists (only admin logins go to the new session)
- [ ] L1 (ui-new): Tools > Admin works as before (login, saved password, typed values, confirm reboot); it now also skips the login for the node you just used
## New: Repeater mode (2026-09-25)
- [ ] Settings > CONNECTIVITY > Repeater (row shows Off / On and what it relays on): switch on → "Repeater on", the loop icon appears in the status bar; off → it goes away
- [ ] Relay on Custom: the radio moves to the profile's frequency while on and back to the chat frequency when off (Settings > Radio row / another node on each frequency); Current: stays on the chat frequency
- [ ] Custom profile: Preset dropdown, Frequency (typed, out-of-range refused), SF / BW / CR apply at once; while relaying on it the change is live
- [ ] While on: Settings > Radio > Auto power is greyed ("Off while repeating") and TX runs at the set power
- [ ] Another node two hops away receives messages through this device; Skip adverts stops relaying adverts only; Max hops / Min SNR / Yield / Skip duplicates / Scope only take effect (compare with L1 Tools › Repeater showing the same values)
- [ ] Extra scopes popup: a switch per scope, the row counts "N of M relayed"; with no scopes it points to Settings > Radio
- [ ] Fresh device (erased flash): Min SNR starts at Off, not 0 dB (fixed default, also on L1)
## New: My presets, scopes, battery display, clock seconds (2026-09-25)
- [ ] Settings > Radio > MY PRESETS > Save current settings: name popup, Enter → "Preset saved", the row shows freq / SF / BW / CR with a tick while in use, and it is in the Preset dropdown
- [ ] Tap a saved preset → Use (radio switches) / Delete (confirm, red) → "Preset deleted"; a 5th name replaces the oldest (hint says so when all 4 are used); presets survive a reboot
+1 -1
View File
@@ -110,7 +110,7 @@ call the same ones, so e.g. "marking a channel read" has one implementation.
### 5. Declarative settings schema
`ui-core/SettingsSchema.h` holds three pages -- navigation (trail, live share, arrival alert, units), display & power (brightness, screen off, wake on message, lock screen, battery display, battery shutdown, GPS power saving, time zone, 12-hour clock, clock seconds) and messages & contacts (DM resends, contact expiry, favourites first). An entry is a one-byte index by default, or a `values` table over a 1/2/4-byte field (seconds, millivolts, an hour offset, `{1, 0}` for a field stored inverted); side effects run through `UiCore` (`applyGpsInterval()`, `host()->applyDisplayPrefs()`). ui-lvgl renders each page; ui-new still uses its own screens. Radio settings are not schema rows (preset list, typed frequency, radio side effects): `ui-core/RadioControl.h` applies them (`applyParams`, `applyTxPower`, `applyApc`, presets = built-ins + user slots) for both ui-new's Settings > Radio and ui-lvgl's Radio screen. Channel management is `ui-core/ChannelControl.h` (`chanctl::`): add Public / Hashtag / Private (passphrase or 32-hex key), rename, delete, notifications / favourite / scope, and `onRemoved()` -- the cleanup of every channel-keyed pref, run by `UiCore::onChannelRemoved` whoever deleted the channel (this device or the app); contacts get the same through `UiCore::cleanupContactPrefs`. Favourites dial slots: `ui-core/Favourites.h` (`pinContact` / `pinChannel` move an entry that is already on another slot; the slot's name is `contactctl::favName`). The schema's display page also carries the lock screen (`auto_lock`) and the 12-hour clock, which ui-lvgl implements (a slide-to-unlock card after the screen turns off). Remote admin is `ui-core/AdminSession.h` (`UiCore::admin`): login with the saved password (forgotten on a wrong password or a silent timeout), CLI round trips with timeouts, the field table (`admin::`), typed values; results come out one at a time through `take()`. `UiCore` offers login answers and CLI replies to it first; only room logins it wasn't waiting for reach the frontend. ui-new's Tools > Admin and ui-lvgl's Admin screen are views over it. The bot's settings are a table too, `ui-core/BotConfig.h` (`botcfg::`: tabs, rows, the NodePrefs flag / text / hour behind each, reply placeholders); ui-new's Tools > Bot and ui-lvgl's Bot screen render it, the bot itself runs in MyMesh. Room servers: `ui-core/RoomSessions.h` (`UiCore::rooms`) keeps who is logged in this session, the one login in flight (with a timeout) and the saved passwords (saved on success, forgotten on failure); `open(contact)` says whether to go straight in, wait for a background login or ask for a password, and outcomes come out through `take()`. Per-contact helpers are `ui-core/ContactControl.h` (`contactctl::`: DM notification / melody overrides, favourite flag, the `@[name] ` reply prefix, splitting a room post into author and text, naming a message's path hops). ui-new's Messages and ui-lvgl's Chats / thread use both. Diagnostics rows are `ui-core/Diagnostics.h` (`diag::`: live counters, system card, font card, counter reset), shown by ui-new's Tools > Diagnostics and ui-lvgl's Settings > Diagnostics. Auto-advert is a schema row (`advert_auto_interval_sec`, sent by MyMesh); ui-new keeps its Tools > Auto-Advert screen over the same pref. The compass (ui-lvgl Home > Compass, ui-new Tools > Compass) reads `UiCore::course`. Saving / deleting the user's radio presets is `radioctl::saveUserPreset` / `deleteUserPreset` (ui-new's preset picker and ui-lvgl's Radio > My presets); the scope list is edited through MyMesh (`addScope` / `renameScope` / `removeScope` / `setDefaultScope`), which ui-lvgl's Radio > Scopes and ui-new's Settings > Radio > Scope call directly. The battery percentage (LiPo curve, empty at the shutdown voltage) and the display mode are `ui-core/Battery.h`, used by both status bars; the schema carries Battery display and Clock seconds.
`ui-core/SettingsSchema.h` holds three pages -- navigation (trail, live share, arrival alert, units), display & power (brightness, screen off, wake on message, lock screen, battery display, battery shutdown, GPS power saving, time zone, 12-hour clock, clock seconds) and messages & contacts (DM resends, contact expiry, favourites first). An entry is a one-byte index by default, or a `values` table over a 1/2/4-byte field (seconds, millivolts, an hour offset, `{1, 0}` for a field stored inverted); side effects run through `UiCore` (`applyGpsInterval()`, `host()->applyDisplayPrefs()`). ui-lvgl renders each page; ui-new still uses its own screens. Radio settings are not schema rows (preset list, typed frequency, radio side effects): `ui-core/RadioControl.h` applies them (`applyParams`, `applyTxPower`, `applyApc`, presets = built-ins + user slots) for both ui-new's Settings > Radio and ui-lvgl's Radio screen. Channel management is `ui-core/ChannelControl.h` (`chanctl::`): add Public / Hashtag / Private (passphrase or 32-hex key), rename, delete, notifications / favourite / scope, and `onRemoved()` -- the cleanup of every channel-keyed pref, run by `UiCore::onChannelRemoved` whoever deleted the channel (this device or the app); contacts get the same through `UiCore::cleanupContactPrefs`. Favourites dial slots: `ui-core/Favourites.h` (`pinContact` / `pinChannel` move an entry that is already on another slot; the slot's name is `contactctl::favName`). The schema's display page also carries the lock screen (`auto_lock`) and the 12-hour clock, which ui-lvgl implements (a slide-to-unlock card after the screen turns off). Remote admin is `ui-core/AdminSession.h` (`UiCore::admin`): login with the saved password (forgotten on a wrong password or a silent timeout), CLI round trips with timeouts, the field table (`admin::`), typed values; results come out one at a time through `take()`. `UiCore` offers login answers and CLI replies to it first; only room logins it wasn't waiting for reach the frontend. ui-new's Tools > Admin and ui-lvgl's Admin screen are views over it. The bot's settings are a table too, `ui-core/BotConfig.h` (`botcfg::`: tabs, rows, the NodePrefs flag / text / hour behind each, reply placeholders); ui-new's Tools > Bot and ui-lvgl's Bot screen render it, the bot itself runs in MyMesh. Room servers: `ui-core/RoomSessions.h` (`UiCore::rooms`) keeps who is logged in this session, the one login in flight (with a timeout) and the saved passwords (saved on success, forgotten on failure); `open(contact)` says whether to go straight in, wait for a background login or ask for a password, and outcomes come out through `take()`. Per-contact helpers are `ui-core/ContactControl.h` (`contactctl::`: DM notification / melody overrides, favourite flag, the `@[name] ` reply prefix, splitting a room post into author and text, naming a message's path hops). ui-new's Messages and ui-lvgl's Chats / thread use both. Diagnostics rows are `ui-core/Diagnostics.h` (`diag::`: live counters, system card, font card, counter reset), shown by ui-new's Tools > Diagnostics and ui-lvgl's Settings > Diagnostics. Auto-advert is a schema row (`advert_auto_interval_sec`, sent by MyMesh); ui-new keeps its Tools > Auto-Advert screen over the same pref. The compass (ui-lvgl Home > Compass, ui-new Tools > Compass) reads `UiCore::course`. Saving / deleting the user's radio presets is `radioctl::saveUserPreset` / `deleteUserPreset` (ui-new's preset picker and ui-lvgl's Radio > My presets); the scope list is edited through MyMesh (`addScope` / `renameScope` / `removeScope` / `setDefaultScope`), which ui-lvgl's Radio > Scopes and ui-new's Settings > Radio > Scope call directly. The battery percentage (LiPo curve, empty at the shutdown voltage) and the display mode are `ui-core/Battery.h`, used by both status bars; the schema carries Battery display and Clock seconds. The companion's own repeater mode is `ui-core/RepeaterControl.h` (`rptctl::`: on / off with its radio, RX power-save and APC side effects, Current / Custom network with the profile seeded from the chat params, the profile's preset, the filters' ranges and labels, the extra scopes mask); ui-new's Tools > Repeater and ui-lvgl's Settings > Repeater use it, MyMesh does the relaying.
Replaces the per-item code in `SettingsScreen`:
+3
View File
@@ -1872,6 +1872,9 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
_prefs.batt_display_mode = 0; // icon by default
//_prefs.rx_delay_base = 10.0f; enable once new algo fixed
_prefs.client_repeat = 0;
// 0 dB is a real threshold, so "off" is its own sentinel. DataStore sets it
// too, but only when a prefs file exists -- a fresh device must start off.
_prefs.repeat_min_snr = NodePrefs::REPEAT_SNR_DISABLED;
#if defined(USE_SX1262) || defined(USE_SX1268)
#ifdef SX126X_RX_BOOSTED_GAIN
_prefs.rx_boosted_gain = SX126X_RX_BOOSTED_GAIN;
@@ -0,0 +1,120 @@
#pragma once
// The companion's own repeater mode (NodePrefs::client_repeat and repeat_* /
// repeater_*), shared by ui-new's Tools > Repeater and ui-lvgl's Settings >
// Repeater: switching it on / off with its radio side effects, the network
// choice (the companion's own frequency, or a dedicated radio profile), the
// profile's preset, the flood filters' ranges and labels, and the extra
// scopes it relays. The relaying itself is MyMesh (allowPacketForward()).
//
// Include after RadioControl.h (target.h / MyMesh.h first, like it).
namespace rptctl {
// Filter ranges.
static const uint8_t MAX_HOPS = 8; // repeat_max_hops 0 (off) .. 8
static const uint8_t MAX_YIELD = 8; // repeat_delay_boost 0 (off) .. 8, delay x(1 + v)
static const int8_t SNR_MIN = -20, SNR_MAX = 10; // repeat_min_snr, or REPEAT_SNR_DISABLED
// Duty-cycle RX sleeps between preamble checks, so it's forced off while
// repeating (a repeater must hear every packet); the user's pref is kept.
static void applyPowerSave(const NodePrefs* p) {
if (!p) return;
#if FEAT_RX_POWERSAVE
radio_driver.setPowerSaving(p->rx_powersave && !p->client_repeat);
#else
radio_driver.setPowerSaving(false); // see Features.h FEAT_RX_POWERSAVE
#endif
}
// On: the radio moves to the profile (with one), RX power save and Adaptive
// Power Control pause. Off: the companion's own params and both come back.
// The caller saves the prefs.
static void setEnabled(NodePrefs* p, bool on) {
if (!p) return;
p->client_repeat = on ? 1 : 0;
the_mesh.applyRepeaterRadio();
applyPowerSave(p);
radioctl::applyApc();
}
// Custom network: relay on the dedicated profile. The first switch to it
// starts the profile from the companion's current params if it isn't valid.
static void setUseProfile(NodePrefs* p, bool on) {
if (!p) return;
p->repeater_use_profile = on ? 1 : 0;
if (on && !the_mesh.repeaterProfileValid()) {
p->repeater_freq = p->freq; p->repeater_bw = p->bw;
p->repeater_sf = p->sf; p->repeater_cr = p->cr;
}
the_mesh.applyRepeaterRadio();
}
// The profile changed (freq / bw / sf / cr): live if relaying on it now.
static void applyProfile() { the_mesh.applyRepeaterRadio(); }
// List index (radioctl::presetAt) of the preset the profile matches, else -1.
static int currentPreset(const NodePrefs* p) {
if (!p) return -1;
const char* name; float f, b; uint8_t s, c;
for (int i = 0; radioctl::presetAt(p, i, name, f, b, s, c); i++)
if (radioParamsMatchPreset(p->repeater_freq, p->repeater_bw, p->repeater_sf, p->repeater_cr, f, b, s, c)) return i;
return -1;
}
static const char* currentPresetName(const NodePrefs* p) {
const char* name; float f, b; uint8_t s, c;
return radioctl::presetAt(p, currentPreset(p), name, f, b, s, c) ? name : "Custom";
}
static bool choosePreset(NodePrefs* p, int idx) {
const char* name; float f, b; uint8_t s, c;
if (!p || !radioctl::presetAt(p, idx, name, f, b, s, c)) return false;
p->repeater_freq = f; p->repeater_bw = b; p->repeater_sf = s; p->repeater_cr = c;
applyProfile();
return true;
}
// ── Filters ─────────────────────────────────────────────────────────────────
// `off`: the frontend's word for a filter that's off ("Off", ui-new's "OFF").
static void fmtHops(char* b, int n, uint8_t v, const char* off = "Off") {
if (v) snprintf(b, n, "%u", (unsigned)v); else snprintf(b, n, "%s", off);
}
static void fmtYield(char* b, int n, uint8_t v, const char* off = "Off") {
if (v) snprintf(b, n, "x%u", (unsigned)v + 1); else snprintf(b, n, "%s", off);
}
static void fmtSnr(char* b, int n, int8_t v, const char* off = "Off") {
if (v == NodePrefs::REPEAT_SNR_DISABLED) snprintf(b, n, "%s", off); else snprintf(b, n, "%d dB", (int)v);
}
// Min SNR as a 0-based choice: 0 = off, then SNR_MIN..SNR_MAX.
static int snrChoiceCount() { return 1 + SNR_MAX - SNR_MIN + 1; }
static int snrToChoice(int8_t v) {
if (v == NodePrefs::REPEAT_SNR_DISABLED || v < SNR_MIN || v > SNR_MAX) return 0;
return 1 + v - SNR_MIN;
}
static int8_t snrFromChoice(int c) {
return c <= 0 ? NodePrefs::REPEAT_SNR_DISABLED : (int8_t)(SNR_MIN + c - 1);
}
// ── Extra scopes (relayed besides the default one) ──────────────────────────
// Bit i of repeat_extra_scope_mask = scope-list index i + 1. Counted only up
// to the list's length: a stray high bit (e.g. from an old save file) never
// shows as picked, the same way rebuildRepeatScopes() ignores it.
static bool extraScope(const NodePrefs* p, uint8_t i) { return p && (p->repeat_extra_scope_mask & (1u << i)); }
static int extraScopesPicked(const NodePrefs* p) {
int n = 0;
for (uint8_t i = 0; i < the_mesh.scopeList().count; i++) if (extraScope(p, i)) n++;
return n;
}
static void setExtraScope(NodePrefs* p, uint8_t i, bool on) {
if (!p || i >= ScopeList::MAX_SCOPE_ENTRIES) return;
if (on) p->repeat_extra_scope_mask |= (1u << i);
else p->repeat_extra_scope_mask &= ~(uint16_t)(1u << i);
the_mesh.rebuildRepeatScopes();
}
} // namespace rptctl
@@ -19,6 +19,7 @@ static const int TX_MAX = 22;
static lv_obj_t* s_overlay = nullptr; // frequency entry
static lv_obj_t* s_ta = nullptr;
static bool s_freq_rpt = false; // the entry is for the repeater profile (RepeaterScreen.h)
static char s_opts[1024];
// Label + optional hint on the left of a settings row; the control goes on the right.
@@ -57,7 +58,7 @@ static void onRadioSwitch(lv_event_t* e) {
s_ui->radioSet((int)(uintptr_t)lv_event_get_user_data(e),
lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED) ? 1 : 0);
}
static void onRadioFreq(lv_event_t* e) { (void)e; s_ui->radioFreqPopup(); }
static void onRadioFreq(lv_event_t* e) { (void)e; s_ui->radioFreqPopup(false); }
static void onRadioFreqKb(lv_event_t* e) { s_ui->radioFreqDone(lv_event_get_code(e) == LV_EVENT_READY); }
static void onOpenRadio(lv_event_t* e) { (void)e; s_ui->showRadio(); }
@@ -155,9 +156,10 @@ void UITask::rebuildRadio() {
if (_body) { lv_obj_update_layout(_body); lv_obj_scroll_to_y(_body, y, LV_ANIM_OFF); }
}
void UITask::radioFreqPopup() {
void UITask::radioFreqPopup(bool repeater) {
using namespace radioview;
if (s_overlay) return;
s_freq_rpt = repeater;
s_overlay = lv_obj_create(lv_screen_active());
lv_obj_remove_style_all(s_overlay);
lv_obj_set_size(s_overlay, LV_PCT(100), LV_PCT(100));
@@ -185,7 +187,7 @@ void UITask::radioFreqPopup() {
lv_textarea_set_accepted_chars(s_ta, "0123456789.");
lv_textarea_set_max_length(s_ta, 10);
char fs[16];
snprintf(fs, sizeof(fs), "%.3f", _prefs->freq);
snprintf(fs, sizeof(fs), "%.3f", repeater ? _prefs->repeater_freq : _prefs->freq);
lv_textarea_set_text(s_ta, fs);
lv_obj_set_size(s_ta, LV_PCT(100), 34);
lv_obj_set_style_border_width(s_ta, 1, 0);
@@ -211,12 +213,12 @@ void UITask::radioFreqDone(bool ok) {
radio_driver.getFreqBounds(lo, hi);
float f = strtof(lv_textarea_get_text(s_ta), nullptr);
if (f < lo || f > hi) { showToast("Out of the radio's range"); return; }
_prefs->freq = f;
radioctl::applyParams();
if (s_freq_rpt) { _prefs->repeater_freq = f; rptctl::applyProfile(); }
else { _prefs->freq = f; radioctl::applyParams(); }
the_mesh.savePrefs();
}
radioCloseFreq();
if (ok) buildRadio();
if (ok) { if (s_freq_rpt) rebuildRepeater(); else rebuildRadio(); }
}
void UITask::radioCloseFreq() {
@@ -0,0 +1,199 @@
#pragma once
// Settings > Repeater -- ui-new's Tools > Repeater: relay other people's
// messages from this device. The switch, the network it relays on (the
// companion's own frequency, or a dedicated profile with its preset / freq /
// SF / BW / CR), and the flood filters (adverts, hop limit, yield, min SNR,
// duplicate suppression, scope only, extra scopes). Every change applies at
// once and is saved; the logic is ui-core/RepeaterControl.h, the relaying MyMesh.
//
// Single-TU fragment: included at the end of ui-lvgl/UITask.cpp.
namespace rptview {
enum : uint8_t { RP_ON, RP_NETWORK, RP_PRESET, RP_SF, RP_BW, RP_CR, RP_HOPS, RP_YIELD, RP_SNR };
static char s_opts[768];
static lv_obj_t* s_scopes_sub = nullptr; // "Extra scopes" row's count, updated from the popup
} // namespace rptview
static void onOpenRepeater(lv_event_t* e) { (void)e; s_ui->showRepeater(); }
static void onRptDropdown(lv_event_t* e) {
s_ui->repeaterSet((int)(uintptr_t)lv_event_get_user_data(e), (int)lv_dropdown_get_selected((lv_obj_t*)lv_event_get_target(e)));
}
static void onRptSwitch(lv_event_t* e) {
s_ui->repeaterSet((int)(uintptr_t)lv_event_get_user_data(e),
lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED) ? 1 : 0);
}
static void onRptNetwork(lv_event_t* e) {
s_ui->repeaterSet(rptview::RP_NETWORK, (int)lv_buttonmatrix_get_selected_button((lv_obj_t*)lv_event_get_target(e)));
}
static void onRptFreq(lv_event_t* e) { (void)e; s_ui->radioFreqPopup(true); }
static void onRptScopes(lv_event_t* e) { (void)e; s_ui->repeaterScopesPopup(); }
static void onRptScopeSwitch(lv_event_t* e) {
s_ui->repeaterScopeSet((uint8_t)(uintptr_t)lv_event_get_user_data(e),
lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED));
}
static void extraScopesSummary(const NodePrefs* p, char* b, int n) {
const ScopeList& sl = the_mesh.scopeList();
if (sl.count == 0) snprintf(b, n, "No scopes set up (Settings > Radio)");
else snprintf(b, n, "%d of %u relayed besides the default", rptctl::extraScopesPicked(p), (unsigned)sl.count);
}
// Settings row: "Off", or what it relays on.
static void repeaterSummary(const NodePrefs* p, char* b, int n) {
if (!p->client_repeat) snprintf(b, n, "Off");
else if (p->repeater_use_profile) snprintf(b, n, "On - own profile, %.3f MHz", p->repeater_freq);
else snprintf(b, n, "On - your chat frequency");
}
void UITask::showRepeater() {
_screen = SCR_REPEATER;
buildRepeater();
}
void UITask::buildRepeater() {
using namespace rptview;
using radioview::settingRow;
using radioview::rowDropdown;
lv_obj_t* body = newScreen("Repeater", true);
NodePrefs* p = _prefs;
lv_obj_t* row = settingRow(body, "Repeater", p->client_repeat ? "Relaying for others" : "Relay others' messages");
lv_obj_t* sw = lv_switch_create(row);
lv_obj_set_size(sw, 46, 24);
lv_obj_align(sw, LV_ALIGN_RIGHT_MID, -theme::PAD, 0);
if (p->client_repeat) lv_obj_add_state(sw, LV_STATE_CHECKED);
lv_obj_add_event_cb(sw, onRptSwitch, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)RP_ON);
lv_obj_t* t = label(body, "While on, auto power and RX power saving pause. Relaying uses more battery.",
THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP);
lv_obj_set_width(t, LV_PCT(100));
sectionTitle(body, "NETWORK");
row = settingRow(body, "Relay on", p->repeater_use_profile ? "Its own frequency" : "Your chat frequency");
static const char* NET[] = { "Current", "Custom", "" };
lv_obj_t* seg = segmented(row, NET, p->repeater_use_profile ? 1 : 0, 150, 34);
lv_obj_align(seg, LV_ALIGN_RIGHT_MID, -4, 0);
lv_obj_add_event_cb(seg, onRptNetwork, LV_EVENT_VALUE_CHANGED, NULL);
if (p->repeater_use_profile) {
int o = snprintf(s_opts, sizeof(s_opts), "Custom");
const char* name; float f, b; uint8_t sf, cr;
for (int i = 0; radioctl::presetAt(p, i, name, f, b, sf, cr) && o < (int)sizeof(s_opts) - 24; i++)
o += snprintf(s_opts + o, sizeof(s_opts) - o, "\n%s", name);
lv_obj_t* dd = rowDropdown(settingRow(body, "Preset", nullptr), s_opts, rptctl::currentPreset(p) + 1, 200,
onRptDropdown, RP_PRESET);
lv_dropdown_set_dir(dd, LV_DIR_BOTTOM);
row = settingRow(body, "Frequency", "MHz");
lv_obj_t* fb = lv_button_create(row);
lv_obj_set_size(fb, 120, 34);
lv_obj_align(fb, LV_ALIGN_RIGHT_MID, -4, 0);
lv_obj_set_style_shadow_width(fb, 0, 0);
lv_obj_set_style_radius(fb, theme::RADIUS, 0);
lv_obj_set_style_bg_color(fb, lv_color_hex(theme::SURFACE_2), 0);
lv_obj_add_event_cb(fb, onRptFreq, LV_EVENT_CLICKED, NULL);
char fs[16];
snprintf(fs, sizeof(fs), "%.3f", p->repeater_freq);
lv_obj_center(label(fb, fs, THEME_FONT_BODY, theme::TEXT));
o = 0;
for (int v = 5; v <= 12; v++) o += snprintf(s_opts + o, sizeof(s_opts) - o, v > 5 ? "\n%d" : "%d", v);
rowDropdown(settingRow(body, "Spreading factor", nullptr), s_opts,
p->repeater_sf >= 5 && p->repeater_sf <= 12 ? p->repeater_sf - 5 : 0, 90, onRptDropdown, RP_SF);
o = 0;
for (int i = 0; i < LORA_BW_OPT_COUNT; i++)
o += snprintf(s_opts + o, sizeof(s_opts) - o, "%s%g kHz", i ? "\n" : "", (double)LORA_BW_OPTS[i]);
rowDropdown(settingRow(body, "Bandwidth", nullptr), s_opts, nearestBwIndex(p->repeater_bw), 130, onRptDropdown, RP_BW);
o = 0;
for (int v = 5; v <= 8; v++) o += snprintf(s_opts + o, sizeof(s_opts) - o, v > 5 ? "\n4/%d" : "4/%d", v);
rowDropdown(settingRow(body, "Coding rate", nullptr), s_opts,
p->repeater_cr >= 5 && p->repeater_cr <= 8 ? p->repeater_cr - 5 : 0, 90, onRptDropdown, RP_CR);
}
sectionTitle(body, "WHAT IT RELAYS");
switchRow(body, "Skip adverts", "Messages still relay, adverts don't", &p->repeat_skip_adverts);
char v[12];
int o = 0;
for (int i = 0; i <= rptctl::MAX_HOPS; i++) {
rptctl::fmtHops(v, sizeof(v), (uint8_t)i);
o += snprintf(s_opts + o, sizeof(s_opts) - o, "%s%s", i ? "\n" : "", v);
}
rowDropdown(settingRow(body, "Max hops", "Hop limit for floods"), s_opts, p->repeat_max_hops, 90,
onRptDropdown, RP_HOPS);
o = 0;
for (int i = 0; i <= rptctl::MAX_YIELD; i++) {
rptctl::fmtYield(v, sizeof(v), (uint8_t)i);
o += snprintf(s_opts + o, sizeof(s_opts) - o, "%s%s", i ? "\n" : "", v);
}
rowDropdown(settingRow(body, "Yield", "Let fixed repeaters go first"), s_opts, p->repeat_delay_boost, 90,
onRptDropdown, RP_YIELD);
o = 0;
for (int i = 0; i < rptctl::snrChoiceCount(); i++) {
rptctl::fmtSnr(v, sizeof(v), rptctl::snrFromChoice(i));
o += snprintf(s_opts + o, sizeof(s_opts) - o, "%s%s", i ? "\n" : "", v);
}
rowDropdown(settingRow(body, "Min SNR", "Ignore weaker packets"), s_opts, rptctl::snrToChoice(p->repeat_min_snr), 100,
onRptDropdown, RP_SNR);
switchRow(body, "Skip duplicates", "Cancel if another node relays it first", &p->repeat_suppress_dup);
switchRow(body, "Scope only", "Only floods in your scopes", &p->repeat_scope_only);
char sub[48];
extraScopesSummary(p, sub, sizeof(sub));
s_scopes_sub = lv_obj_get_child(listRow(body, LV_SYMBOL_LIST " Extra scopes", sub, onRptScopes, NULL), 1);
}
// Rebuilds the screen where it was scrolled to.
void UITask::rebuildRepeater() {
int32_t y = _body ? lv_obj_get_scroll_y(_body) : 0;
buildRepeater();
if (_body) { lv_obj_update_layout(_body); lv_obj_scroll_to_y(_body, y, LV_ANIM_OFF); }
}
void UITask::repeaterSet(int which, int v) {
using namespace rptview;
NodePrefs* p = _prefs;
if (!p) return;
switch (which) {
case RP_ON:
rptctl::setEnabled(p, v != 0);
showToast(v ? "Repeater on" : "Repeater off");
break;
case RP_NETWORK: rptctl::setUseProfile(p, v != 0); break;
case RP_PRESET: if (v == 0) return; rptctl::choosePreset(p, v - 1); break;
case RP_SF: p->repeater_sf = (uint8_t)(5 + v); rptctl::applyProfile(); break;
case RP_BW: if (v >= 0 && v < LORA_BW_OPT_COUNT) { p->repeater_bw = LORA_BW_OPTS[v]; rptctl::applyProfile(); } break;
case RP_CR: p->repeater_cr = (uint8_t)(5 + v); rptctl::applyProfile(); break;
case RP_HOPS: p->repeat_max_hops = (uint8_t)v; break;
case RP_YIELD: p->repeat_delay_boost = (uint8_t)v; break;
case RP_SNR: p->repeat_min_snr = rptctl::snrFromChoice(v); break;
}
the_mesh.savePrefs();
if (which <= RP_CR) rebuildRepeater(); // hints, the profile rows and the preset name follow
refreshStatusBar();
}
// A switch per named scope: relayed besides the default one.
void UITask::repeaterScopesPopup() {
const ScopeList& sl = the_mesh.scopeList();
if (sl.count == 0) { showToast("Set up scopes in Settings > Radio first"); return; }
lv_obj_t* panel = navPopupPanel("Extra scopes", sl.count > 2);
if (sl.count > 2) lv_obj_add_flag(panel, LV_OBJ_FLAG_SCROLLABLE); // up to 8 rows
lv_obj_t* t = label(panel, "Relayed besides the default scope. With Scope only off, everything is relayed anyway.",
THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP);
lv_obj_set_width(t, LV_PCT(100));
for (uint8_t i = 0; i < sl.count; i++) {
lv_obj_t* sw = switchRow(panel, sl.name((uint8_t)(i + 1)), (i + 1) == sl.default_idx ? "Default - always relayed" : nullptr,
nullptr);
if (rptctl::extraScope(_prefs, i)) lv_obj_add_state(sw, LV_STATE_CHECKED);
lv_obj_add_event_cb(sw, onRptScopeSwitch, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)i);
}
}
void UITask::repeaterScopeSet(uint8_t i, bool on) {
rptctl::setExtraScope(_prefs, i, on);
the_mesh.savePrefs();
char sub[48];
extraScopesSummary(_prefs, sub, sizeof(sub));
if (rptview::s_scopes_sub) lv_label_set_text(rptview::s_scopes_sub, sub);
}
+13 -1
View File
@@ -13,6 +13,7 @@
#include "../ui-core/GpsAverager.h"
#include "../ui-core/TrackBack.h"
#include "../ui-core/RadioControl.h"
#include "../ui-core/RepeaterControl.h"
#include "../ui-core/Diagnostics.h"
#include "../ui-core/Battery.h"
#include "../ui-core/ChannelControl.h"
@@ -390,7 +391,8 @@ void UITask::refreshStatusBar() {
case battery::VOLTAGE: snprintf(level, sizeof(level), " %u.%02u V", mv / 1000, (mv % 1000) / 10); break;
default: break;
}
lv_label_set_text_fmt(_status_icons, "%s%s%s", hasConnection() ? LV_SYMBOL_BLUETOOTH " " : "", batt, level);
lv_label_set_text_fmt(_status_icons, "%s%s%s%s", _prefs && _prefs->client_repeat ? LV_SYMBOL_LOOP " " : "",
hasConnection() ? LV_SYMBOL_BLUETOOTH " " : "", batt, level);
// GPS: green with a fix, muted while on and searching, absent when off.
int32_t lat, lon;
bool fix = _core->course.currentLocation(lat, lon);
@@ -517,6 +519,11 @@ void UITask::back() {
else if (_nav_overlay) navClosePopup();
else showSettings();
break;
case SCR_REPEATER:
if (radioPopupOpen()) radioCloseFreq();
else if (_nav_overlay) navClosePopup();
else showSettings();
break;
case SCR_SCOPES: // back to the Radio screen's bottom, where Scopes is
if (_nav_overlay) { navClosePopup(); break; }
showRadio();
@@ -2011,6 +2018,8 @@ void UITask::showSettings() {
static void onOpenWifi(lv_event_t* e) { (void)e; s_ui->showWifi(false); }
static void onOpenRadio(lv_event_t* e); // RadioScreen.h
static void onOpenRepeater(lv_event_t* e); // RepeaterScreen.h
static void repeaterSummary(const NodePrefs* p, char* b, int n);
void UITask::buildSettings() {
lv_obj_t* body = newScreen("Settings", true);
@@ -2027,6 +2036,8 @@ void UITask::buildSettings() {
if (pi >= 0) radioctl::presetAt(_prefs, pi, pn, f, b, sf, cr);
snprintf(sub, sizeof(sub), "%s - %.3f MHz, %d dBm", pn, _prefs->freq, _prefs->tx_power_dbm);
listRow(body, UI_SYMBOL_RADIO " Radio", sub, onOpenRadio, NULL);
repeaterSummary(_prefs, sub, sizeof(sub));
listRow(body, LV_SYMBOL_LOOP " Repeater", sub, onOpenRepeater, NULL);
}
if (_prefs) { // the same NodePrefs switches as ui-new's Home GPS toggle / Live share / Locator screens
sectionTitle(body, "NAVIGATION");
@@ -2083,3 +2094,4 @@ void UITask::setKeyboardAlphabets(int main_idx, int alt_sel) {
#include "DiagScreen.h"
#include "CompassScreen.h"
#include "RadioExtras.h"
#include "RepeaterScreen.h"
+9 -2
View File
@@ -107,7 +107,7 @@ public:
// Settings > Radio (RadioScreen.h)
void showRadio();
void radioSet(int which, int v);
void radioFreqPopup();
void radioFreqPopup(bool repeater); // the companion's frequency, or the repeater profile's
void radioFreqDone(bool ok);
// Settings > Radio: my presets, scopes (RadioExtras.h)
void presetMenu(int slot);
@@ -117,6 +117,11 @@ public:
void showScopes();
void scopeMenu(int idx);
void scopeAction(uint8_t act);
// Settings > Repeater (RepeaterScreen.h)
void showRepeater();
void repeaterSet(int which, int v);
void repeaterScopesPopup();
void repeaterScopeSet(uint8_t i, bool on);
// Channels (ChannelScreen.h)
void channelMenu(int idx);
void channelSet(uint8_t which, int v);
@@ -180,7 +185,7 @@ public:
void openAdminFor(const ContactInfo& ci, bool from_picker) { (void)ci; (void)from_picker; }
private:
enum Screen : uint8_t { SCR_HOME, SCR_CHATS, SCR_CONTACTS, SCR_THREAD, SCR_SETTINGS, SCR_NEARBY, SCR_NODE, SCR_MAP, SCR_WIFI, SCR_SETTINGS_NAV, SCR_CLOCK, SCR_RADIO, SCR_CHANNEL_EDIT, SCR_ADMIN, SCR_BOT, SCR_FAVS, SCR_DIAG, SCR_COMPASS, SCR_SCOPES };
enum Screen : uint8_t { SCR_HOME, SCR_CHATS, SCR_CONTACTS, SCR_THREAD, SCR_SETTINGS, SCR_NEARBY, SCR_NODE, SCR_MAP, SCR_WIFI, SCR_SETTINGS_NAV, SCR_CLOCK, SCR_RADIO, SCR_CHANNEL_EDIT, SCR_ADMIN, SCR_BOT, SCR_FAVS, SCR_DIAG, SCR_COMPASS, SCR_SCOPES, SCR_REPEATER };
void buildStatusBar();
void refreshStatusBar();
@@ -236,6 +241,8 @@ private:
void rebuildRadio();
void buildRadioExtras(lv_obj_t* body);
void buildScopes();
void buildRepeater();
void rebuildRepeater();
void radioCloseFreq();
void buildChannelEdit();
void buildAdmin();
@@ -24,6 +24,7 @@
#include "../RadioPresets.h"
#include "../MyMesh.h"
#include "PopupMenu.h"
#include "../ui-core/RepeaterControl.h" // toggles, profile, filter ranges / labels, extra scopes
extern MyMesh the_mesh;
@@ -115,32 +116,15 @@ class RepeaterScreen : public UIScreen {
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_HOPS: rptctl::fmtHops(buf, n, p->repeat_max_hops, "OFF"); break;
case IT_YIELD: rptctl::fmtYield(buf, n, p->repeat_delay_boost, "OFF"); break;
case IT_SNR: rptctl::fmtSnr(buf, n, p->repeat_min_snr, "OFF"); break;
case IT_SUPPRESS: strncpy(buf, p->repeat_suppress_dup ? "ON" : "OFF", n); break;
case IT_SCOPE: strncpy(buf, p->repeat_scope_only ? "ON" : "OFF", n); break;
case IT_SCOPE_EXTRA: {
// Bound the scan to the list's real length, not the mask's full bit
// width -- a stray high bit (e.g. leftover sentinel bytes from a
// pre-scope-list save file) must never be counted as "picked", or a
// device that's never touched this picker can show a bogus non-zero
// count. rebuildRepeatScopes() already ignores such bits the same way.
uint8_t total = the_mesh.scopeList().count;
int picked = 0;
for (uint8_t i = 0; i < total; i++)
if (p->repeat_extra_scope_mask & (1u << i)) picked++;
if (total == 0) strncpy(buf, "(none)", n);
else snprintf(buf, n, "%d/%d", picked, total);
else snprintf(buf, n, "%d/%d", rptctl::extraScopesPicked(p), (int)total);
break;
}
default: strncpy(buf, "", n); break;
@@ -148,13 +132,6 @@ class RepeaterScreen : public UIScreen {
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;
}
public:
RepeaterScreen(UITask* task) : _task(task), _dirty(false), _sel(0), _scroll(0), _item_count(1) {}
@@ -223,9 +200,7 @@ public:
int i = _scope_menu.selectedIndex();
bool now_on = !_scope_menu.isChecked(i);
_scope_menu.setChecked(i, now_on);
if (now_on) p->repeat_extra_scope_mask |= (1u << i);
else p->repeat_extra_scope_mask &= ~(uint16_t)(1u << i);
the_mesh.rebuildRepeatScopes();
rptctl::setExtraScope(p, (uint8_t)i, now_on);
_dirty = true;
} else if (res != PopupMenu::NONE && res != PopupMenu::VALUE_NEXT) {
_scope_picker_active = false; // Back closes it -- checklist has no plain-select row
@@ -279,19 +254,13 @@ public:
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
_task->applyPowerSave(); // duty-cycle RX is forced off while repeating
_task->applyApc(); // pin TX power to the ceiling while repeating (APC suppressed)
rptctl::setEnabled(p, !p->client_repeat); // radio profile, RX power save, APC follow
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();
rptctl::setUseProfile(p, !p->repeater_use_profile); // seeds a fresh profile from the companion's params
buildItems(p);
_dirty = true;
return true;
@@ -310,22 +279,22 @@ public:
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 (right && p->repeat_max_hops < rptctl::MAX_HOPS) { 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 (right && p->repeat_delay_boost < rptctl::MAX_YIELD) { 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);
v = (v == NodePrefs::REPEAT_SNR_DISABLED) ? rptctl::SNR_MIN : (v < rptctl::SNR_MAX ? v + 1 : rptctl::SNR_MAX);
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);
p->repeat_min_snr = (v <= rptctl::SNR_MIN) ? NodePrefs::REPEAT_SNR_DISABLED : (int8_t)(v - 1);
_dirty = true; return true;
}
}
+1 -9
View File
@@ -3202,15 +3202,7 @@ void UITask::applyTxPower() {
}
void UITask::applyPowerSave() {
if (_node_prefs == NULL) return;
#if FEAT_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);
#else
radio_driver.setPowerSaving(false); // see Features.h FEAT_RX_POWERSAVE -- ignore any stale persisted rx_powersave byte
#endif
rptctl::applyPowerSave(_node_prefs); // forced off while repeating (ui-core/RepeaterControl.h)
}
void UITask::applyApc() {