From 2624ab05487838f775c6c9a107675cc643d6ab09 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:33:11 +0200 Subject: [PATCH] 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 --- docs/development/l2-hardware-checklist.md | 9 + docs/development/ui-core.md | 2 +- examples/companion_radio/MyMesh.cpp | 3 + .../companion_radio/ui-core/RepeaterControl.h | 120 +++++++++++ .../companion_radio/ui-lvgl/RadioScreen.h | 14 +- .../companion_radio/ui-lvgl/RepeaterScreen.h | 199 ++++++++++++++++++ examples/companion_radio/ui-lvgl/UITask.cpp | 14 +- examples/companion_radio/ui-lvgl/UITask.h | 11 +- .../companion_radio/ui-new/RepeaterScreen.h | 55 ++--- examples/companion_radio/ui-new/UITask.cpp | 10 +- 10 files changed, 375 insertions(+), 62 deletions(-) create mode 100644 examples/companion_radio/ui-core/RepeaterControl.h create mode 100644 examples/companion_radio/ui-lvgl/RepeaterScreen.h diff --git a/docs/development/l2-hardware-checklist.md b/docs/development/l2-hardware-checklist.md index 80501e17..8abfe4b5 100644 --- a/docs/development/l2-hardware-checklist.md +++ b/docs/development/l2-hardware-checklist.md @@ -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 diff --git a/docs/development/ui-core.md b/docs/development/ui-core.md index 57bc9cb0..6bb8cfef 100644 --- a/docs/development/ui-core.md +++ b/docs/development/ui-core.md @@ -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`: diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index e97b566e..4380d677 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -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; diff --git a/examples/companion_radio/ui-core/RepeaterControl.h b/examples/companion_radio/ui-core/RepeaterControl.h new file mode 100644 index 00000000..0f40d03a --- /dev/null +++ b/examples/companion_radio/ui-core/RepeaterControl.h @@ -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 diff --git a/examples/companion_radio/ui-lvgl/RadioScreen.h b/examples/companion_radio/ui-lvgl/RadioScreen.h index c166f5bb..f2ff528f 100644 --- a/examples/companion_radio/ui-lvgl/RadioScreen.h +++ b/examples/companion_radio/ui-lvgl/RadioScreen.h @@ -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() { diff --git a/examples/companion_radio/ui-lvgl/RepeaterScreen.h b/examples/companion_radio/ui-lvgl/RepeaterScreen.h new file mode 100644 index 00000000..861cd1c8 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/RepeaterScreen.h @@ -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); +} diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index 8198d867..b022cd24 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -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" diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index 2e08997d..c8263620 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -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(); diff --git a/examples/companion_radio/ui-new/RepeaterScreen.h b/examples/companion_radio/ui-new/RepeaterScreen.h index 1dd35b1d..1375ef87 100644 --- a/examples/companion_radio/ui-new/RepeaterScreen.h +++ b/examples/companion_radio/ui-new/RepeaterScreen.h @@ -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; } } diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 562933cb..0f139345 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -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() {