From 0da487729d04b89fb6bf22d3d16e070ade6938ba Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 19 Jun 2026 09:20:16 +0200 Subject: [PATCH] feat(repeater): dedicated Tools screen + optional radio profile Move the repeater controls out of Settings > Radio (where the indented sub-item labels collided with the value column on a 128px OLED) onto their own full-width Tools > Repeater screen, holding the toggle, the five flood-politeness filters, and live forwarding stats (Forwarded / Pool free / Queue) together. Toggle text standardised to ON/OFF throughout. Adds an optional dedicated radio profile (Network: Current/Custom): enabling the repeater can switch the radio to a preset/manual freq/bw/sf/cr and disabling restores the companion's params (revert-on-disable). A profile equal to the companion = same network; a different one = drop onto a separate repeater network. MyMesh::applyRepeaterRadio() is the single decision point (boot + every toggle/edit), gated by repeaterProfileValid(); the profile re-applies after a reboot left with the repeater on. Persisted behind schema 0xC0DE0010, cleared to off when an upgraded file yields an invalid config. Docs updated (settings_screen, tools_screen, README, FEATURES). Co-Authored-By: Claude Sonnet 4.6 --- FEATURES.md | 18 +- README.md | 4 +- .../settings_screen/settings_screen.md | 16 +- .../tools_screen/tools_screen.md | 31 +- examples/companion_radio/DataStore.cpp | 21 ++ examples/companion_radio/MyMesh.cpp | 9 +- examples/companion_radio/MyMesh.h | 13 + examples/companion_radio/NodePrefs.h | 12 +- .../companion_radio/ui-new/RepeaterScreen.h | 336 ++++++++++++++++++ .../companion_radio/ui-new/SettingsScreen.h | 87 ----- examples/companion_radio/ui-new/ToolsScreen.h | 5 +- examples/companion_radio/ui-new/UITask.cpp | 7 + examples/companion_radio/ui-new/UITask.h | 2 + 13 files changed, 449 insertions(+), 112 deletions(-) create mode 100644 examples/companion_radio/ui-new/RepeaterScreen.h diff --git a/FEATURES.md b/FEATURES.md index d43d28cf..80ec251d 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -446,16 +446,28 @@ left (PPK2 current measurement, multi-hop APC gating). On-device repeater for the Solo companion, scoped to the SX1262 boards (Wio Tracker L1 OLED/e-ink, GAT562 30S). All on `feature/companion-repeater-presets`. -- **Repeater toggle** (`client_repeat`, Settings › Radio) — the companion relays +- **Repeater toggle** (`client_repeat`, Tools › Repeater) — the companion relays flood/direct traffic on its **current** frequency (no dedicated band), still working as a normal companion. `MyMesh::allowPacketForward` gates it; loop detection (`isRepeatLooped`, ported from `simple_repeater`) and an advert flood-depth cap are always applied. Packet pool bumped 16→32 to match the repeater workload (a too-small pool starved channel/DM reception once relaying queued retransmits). +- **Consolidated on Tools › Repeater** — the toggle, the five filters, and live + forwarding stats (Forwarded / Pool free / Queue) share one full-width screen, + rather than the original Settings › Radio sub-items (whose indented labels + collided with the value column on a 128px OLED). +- **Optional dedicated radio profile** (`Network: Current/Custom`, + `repeater_use_profile` + `repeater_freq/bw/sf/cr`). Custom switches the radio to + a preset/manual profile when the repeater is enabled and restores the + companion's params when disabled (user-chosen revert-on-disable); a profile + equal to the companion = "same network", a different one = drop onto a separate + repeater network. `MyMesh::applyRepeaterRadio()` is the single decision point, + called at boot and on every toggle/edit; `repeaterProfileValid()` gates it. + Schema sentinel `0xC0DE0010`, profile cleared to off if load finds an invalid + config (stray bytes from older files). - **Politeness knobs** (all opt-in, default off, flood-only — a direct route's - named next hop is never dropped). Shown under Radio only while the repeater is - on: + named next hop is never dropped). Shown only while the repeater is on: - **Skip advert** — don't re-flood adverts (highest-volume flood). - **Max hops** — drop a flood past N hops. - **Yield** — scale the retransmit delay for *forwarded* floods only diff --git a/README.md b/README.md index a20f5408..30c9ad19 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ All firmware files are published on the [releases page](https://github.com/Marek - [Screen Lock](./docs/solo_features/screen_lock/screen_lock.md) — lock the device to prevent accidental keypresses, with a lock screen showing time and sensor data -- [Tools Screen](./docs/solo_features/tools_screen/tools_screen.md) — GPS trail & waypoints, compass, nearby nodes (with ping & navigate), ringtone editor, auto-reply bot, auto-advert, diagnostics +- [Tools Screen](./docs/solo_features/tools_screen/tools_screen.md) — GPS trail & waypoints, compass, nearby nodes (with ping & navigate), ringtone editor, auto-reply bot, auto-advert, diagnostics, repeater - **Battery saving (radio)** — two optional, independent toggles under Settings › Radio: - **Pwr save** — hardware duty-cycle receive (SX126x `SetRxDutyCycle`): the radio cycles RX↔sleep on its own and wakes on a preamble, cutting average RX current with only a little added receive latency @@ -97,7 +97,7 @@ Updating to a newer version usually does not require erasing flash unless the re | [Clock Screen](./docs/solo_features/clock_screen/clock_screen.md) | Clock page, date, configurable data fields | | [Settings Screen](./docs/solo_features/settings_screen/settings_screen.md) | All settings sections with values and interactions | | [Screen Lock](./docs/solo_features/screen_lock/screen_lock.md) | Lock/unlock sequence, lock screen, auto-lock | -| [Tools Screen](./docs/solo_features/tools_screen/tools_screen.md) | GPS trail & waypoints, compass, navigation, nearby nodes, ringtone editor, auto-reply bot, auto-advert, diagnostics | +| [Tools Screen](./docs/solo_features/tools_screen/tools_screen.md) | GPS trail & waypoints, compass, navigation, nearby nodes, ringtone editor, auto-reply bot, auto-advert, diagnostics, repeater | ### Upstream MeshCore diff --git a/docs/solo_features/settings_screen/settings_screen.md b/docs/solo_features/settings_screen/settings_screen.md index 516d621a..bf4dbd4c 100644 --- a/docs/solo_features/settings_screen/settings_screen.md +++ b/docs/solo_features/settings_screen/settings_screen.md @@ -71,23 +71,11 @@ Lists all available home screen pages. For each entry: | SF | 5–12 | LEFT/RIGHT. Spreading factor. | | BW | 7.8–500 kHz | LEFT/RIGHT cycles the standard LoRa bandwidths. | | CR | 5–8 | LEFT/RIGHT. Coding rate (4/5–4/8). | -| Repeater | ON / OFF | When ON the companion also relays mesh traffic on its **current** frequency (no dedicated band needed), while still working as a normal companion. Loop-detection and flood-depth limits are always applied. Forwarding activity is visible in **Tools › Diagnostics**. | - -When **Repeater** is ON, five optional "politeness" sub-items appear below it (all default OFF, so a plain repeater is unaffected). They only filter **flood** traffic — on a direct route this node is the named next hop, so it never drops those. - -| Setting | Options | Notes | -| -------------- | ------------- | -------------------------------------------------------------------------------------------------------------- | -| Skip advert | ON / OFF | Don't re-flood **advert** packets (the highest-volume flood traffic); messages and acks still relay. | -| Max hops | Off / 1–8 | Drop a flood packet once it has already travelled this many hops. | -| Yield | Off / x2–x9 | Scales the retransmit delay for **forwarded** floods only (own sends are unaffected), so a mobile companion defers to better-sited fixed repeaters. Widens the window for **Suppress dup**. | -| Min SNR | Off / −20…10 dB | Drop a flood copy received below this signal-to-noise threshold, so marginal fringe traffic isn't re-flooded. | -| Suppress dup | ON / OFF | If the same flood packet is overheard from another node while still queued to retransmit, cancel our copy — a peer already relayed it. Cuts redundant airtime in dense meshes; pairs with **Yield**. | - -| Setting | Options | Notes | -| --------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Pwr save | ON / OFF | **Battery saver.** Hardware duty-cycle receive: the SX126x cycles RX↔sleep on its own and wakes on a preamble, cutting average RX current. Trades a little receive latency; leave OFF for lowest-latency reception. Requires an SX126x radio (otherwise stays on continuous RX). | | Auto pwr | ON / OFF | **Adaptive Power Control.** Lowers actual TX power on strong links to save energy, ramping back up — to the **TX Pwr** ceiling — on weak or lost links. Link quality comes from direct-message ACK SNR and, for channel messages (no ACK), from hearing a repeater rebroadcast your packet. The radio page / name bar shows the live power. Default OFF (fixed TX power). | +The **repeater** mode and its flood filters live on their own screen — see **Tools › Repeater**. + --- ### System diff --git a/docs/solo_features/tools_screen/tools_screen.md b/docs/solo_features/tools_screen/tools_screen.md index ef28899b..95f91af3 100644 --- a/docs/solo_features/tools_screen/tools_screen.md +++ b/docs/solo_features/tools_screen/tools_screen.md @@ -8,7 +8,7 @@ | :-----------------------: | :-----------------------: | | ![](./tls_scr_1_oled.png) | ![](./tls_scr_1_eink.png) | -The Tools screen is a hub for GPS trail recording, nearby node browsing, ringtone editing, auto-reply bot, auto-advert, compass, and device diagnostics. Navigate the tool list with **UP/DOWN** and press **Enter** to open a tool. +The Tools screen is a hub for GPS trail recording, nearby node browsing, ringtone editing, auto-reply bot, auto-advert, compass, device diagnostics, and repeater mode. Navigate the tool list with **UP/DOWN** and press **Enter** to open a tool. --- @@ -312,4 +312,31 @@ A single read-only screen of live device and mesh stats, refreshed once a second The packet counters and **Forwarded** are cumulative since boot. Press **Enter** to open a confirm popup that resets them (defaults to *Cancel*); the live readings (noise, RSSI/SNR, pool, queue, uptime) are not affected. **Cancel/Back** returns to the Tools list. -The counters make the repeater behaviour observable: **Forwarded** confirms the node is actually relaying (not just configured to), and **Pool free** / **Queue** show whether forwarding is exhausting the packet pool. See **Settings › Radio › Repeater** for the relaying options. +The counters make the repeater behaviour observable: **Forwarded** confirms the node is actually relaying (not just configured to), and **Pool free** / **Queue** show whether forwarding is exhausting the packet pool. See **Tools › Repeater** for the relaying options (which also shows these same forwarding stats in context). + +--- + +## Repeater + +Turns the companion into a packet **repeater** while it keeps working as a normal companion — no separate firmware. By default it relays on its **current** frequency, or you can give it a dedicated radio profile to switch to while relaying (see **Network** below). Loop-detection and an advert flood-depth cap are always applied. This screen keeps the toggle, the network/profile, its flood-filter options, and live forwarding stats together. + +Navigate with **UP/DOWN**; change a value with **LEFT/RIGHT** (or **Enter** for toggles). **Cancel/Back** saves and returns to Tools. + +| Setting | Options | Notes | +| -------------- | --------------- | -------------------------------------------------------------------------------------------------------------- | +| Repeater | ON / OFF | Master switch. The options below appear only while it is ON. | +| Network | Current / Custom | **Current**: relay on the companion's own frequency (default). **Custom**: enabling the repeater switches the radio to a dedicated profile (below) and disabling restores the companion's settings — so you can drop onto a separate repeater network and come back. Switching to Custom seeds the profile from your current settings. | +| Rpt preset | named presets | _(Custom only)_ **Enter** picks a community/saved preset for the repeater profile. | +| Rpt freq | chip range | _(Custom only)_ **Enter** opens the digit-by-digit editor (chip-validated bounds). | +| Rpt SF / BW / CR | 5–12 / 7.8–500 kHz / 5–8 | _(Custom only)_ **LEFT/RIGHT** to adjust the profile's spreading factor, bandwidth, coding rate. | +| Skip advert | ON / OFF | Don't re-flood **advert** packets (the highest-volume flood traffic); messages and acks still relay. | +| Max hops | OFF / 1–8 | Drop a flood packet once it has already travelled this many hops. | +| Yield | OFF / x2–x9 | Scales the retransmit delay for **forwarded** floods only (your own sends are unaffected), so a mobile companion defers to better-sited fixed repeaters. Widens the window for **Suppress dup**. | +| Min SNR | OFF / −20…10 dB | Drop a flood copy received below this signal-to-noise threshold, so marginal fringe traffic isn't re-flooded. | +| Suppress dup | ON / OFF | If the same flood packet is overheard from another node while still queued to retransmit, cancel our copy — a peer already relayed it. Cuts redundant airtime in dense meshes; pairs with **Yield**. | + +The five flood filters are **opt-in** (default OFF, so a plain repeater is unaffected) and act on **flood** traffic only — on a direct route this node is the named next hop, so it never drops those. + +**Same network vs. separate network.** With **Network = Current** (or a Custom profile set equal to your companion settings) the repeater stays on your own network — you keep messaging while relaying. With a *different* Custom profile the device moves entirely onto that network while relaying (a single radio can't be on two at once) and returns to your companion network when the repeater is switched off. The profile also re-applies after a reboot if the repeater was left on. + +While the repeater is ON, the bottom of the screen shows live stats: **Forwarded** (packets actually re-transmitted — reflects Suppress dup), **Pool free**, and **Queue**. The same numbers appear, alongside the full device picture, in **Tools › Diagnostics**. diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index baec7e90..27064899 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -351,6 +351,22 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no (_prefs.repeat_min_snr < -30 || _prefs.repeat_min_snr > 20)) _prefs.repeat_min_snr = NodePrefs::REPEAT_SNR_DISABLED; if (_prefs.repeat_suppress_dup > 1) _prefs.repeat_suppress_dup = 0; + rd(&_prefs.repeater_use_profile, sizeof(_prefs.repeater_use_profile)); + rd(&_prefs.repeater_freq, sizeof(_prefs.repeater_freq)); + rd(&_prefs.repeater_bw, sizeof(_prefs.repeater_bw)); + rd(&_prefs.repeater_sf, sizeof(_prefs.repeater_sf)); + rd(&_prefs.repeater_cr, sizeof(_prefs.repeater_cr)); + // Pre-0x10 files leave stray sentinel bytes here. If the profile fields aren't a + // valid radio config, force the profile off so the repeater can't switch to junk. + if (_prefs.repeater_use_profile > 1) _prefs.repeater_use_profile = 0; + if (!(_prefs.repeater_freq >= 100.0f && _prefs.repeater_freq <= 960.0f + && _prefs.repeater_sf >= 5 && _prefs.repeater_sf <= 12 + && _prefs.repeater_cr >= 5 && _prefs.repeater_cr <= 8 + && _prefs.repeater_bw >= 7.0f && _prefs.repeater_bw <= 510.0f)) { + _prefs.repeater_use_profile = 0; + _prefs.repeater_freq = _prefs.repeater_bw = 0.0f; + _prefs.repeater_sf = _prefs.repeater_cr = 0; + } // → 0xC0DE000B: append bot_commands_enabled + quiet-hours. Older files leave // stray bytes here; clamp so upgraders fall back to off / no quiet hours. if (_prefs.bot_commands_enabled > 1) _prefs.bot_commands_enabled = 0; @@ -513,6 +529,11 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)&_prefs.repeat_delay_boost, sizeof(_prefs.repeat_delay_boost)); file.write((uint8_t *)&_prefs.repeat_min_snr, sizeof(_prefs.repeat_min_snr)); file.write((uint8_t *)&_prefs.repeat_suppress_dup, sizeof(_prefs.repeat_suppress_dup)); + file.write((uint8_t *)&_prefs.repeater_use_profile, sizeof(_prefs.repeater_use_profile)); + file.write((uint8_t *)&_prefs.repeater_freq, sizeof(_prefs.repeater_freq)); + file.write((uint8_t *)&_prefs.repeater_bw, sizeof(_prefs.repeater_bw)); + file.write((uint8_t *)&_prefs.repeater_sf, sizeof(_prefs.repeater_sf)); + file.write((uint8_t *)&_prefs.repeater_cr, sizeof(_prefs.repeater_cr)); // Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL. uint32_t sentinel = NodePrefs::SCHEMA_SENTINEL; diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 7a1f7f37..13fb9e3c 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1418,7 +1418,7 @@ void MyMesh::begin(bool has_display) { addChannel("Public", PUBLIC_GROUP_PSK); // pre-configure Andy's public channel _store->loadChannels(this); - radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + applyRepeaterRadio(); // companion params, or the repeater profile if relaying with one set applyApc(); // sets TX power to the ceiling and arms APC if enabled radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); radio_driver.setPowerSaving(_prefs.rx_powersave); // hardware duty-cycle RX (battery saver) @@ -1426,6 +1426,13 @@ void MyMesh::begin(bool has_display) { radio_driver.getRxBoostedGainMode() ? "Enabled" : "Disabled"); } +void MyMesh::applyRepeaterRadio() { + if (_prefs.client_repeat && _prefs.repeater_use_profile && repeaterProfileValid()) + radio_driver.setParams(_prefs.repeater_freq, _prefs.repeater_bw, _prefs.repeater_sf, _prefs.repeater_cr); + else + radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); +} + const char *MyMesh::getNodeName() { return _prefs.node_name; } diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index a033274f..816390fa 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -228,6 +228,19 @@ public: DataStore* getDataStore() const { return _store; } void applyApc(); // (re)initialise Adaptive Power Control from prefs + // True when the optional repeater radio profile is a valid LoRa config. + bool repeaterProfileValid() const { + return _prefs.repeater_freq >= 100.0f && _prefs.repeater_freq <= 960.0f + && _prefs.repeater_sf >= 5 && _prefs.repeater_sf <= 12 + && _prefs.repeater_cr >= 5 && _prefs.repeater_cr <= 8 + && _prefs.repeater_bw >= 7.0f && _prefs.repeater_bw <= 510.0f; + } + // Load the radio with the correct params for the current mode: the repeater + // profile when relaying with a valid dedicated profile, otherwise the + // companion's own params. Single source of truth, used at boot and whenever + // the repeater toggle / network / profile changes. + void applyRepeaterRadio(); + bool isAckPending(uint32_t expected_ack) const { for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; i++) if (expected_ack_table[i].ack == expected_ack) return true; diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 46a8bf15..a69b8ba3 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -173,11 +173,21 @@ struct NodePrefs { // persisted to file static const int8_t REPEAT_SNR_DISABLED = -128; uint8_t repeat_suppress_dup; + // Optional dedicated radio profile for repeater mode. When repeater_use_profile + // is 1, enabling the repeater switches the radio to repeater_freq/bw/sf/cr and + // disabling restores the companion's freq/bw/sf/cr (the fields above). 0 = the + // repeater stays on the current companion frequency (default — no switch). + uint8_t repeater_use_profile; + float repeater_freq; + float repeater_bw; + uint8_t repeater_sf; + uint8_t repeater_cr; + // Tail sentinel written at the end of /new_prefs. Bump the low byte when // adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so // older saves are detected on load and skipped (zero-init defaults kept). // High 24 bits identify the file format; low byte is the schema revision. - static const uint32_t SCHEMA_SENTINEL = 0xC0DE000F; + static const uint32_t SCHEMA_SENTINEL = 0xC0DE0010; // Bit-index for each home page. Used by page_order (entries store bit+1) and // by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage diff --git a/examples/companion_radio/ui-new/RepeaterScreen.h b/examples/companion_radio/ui-new/RepeaterScreen.h new file mode 100644 index 00000000..f090c5a4 --- /dev/null +++ b/examples/companion_radio/ui-new/RepeaterScreen.h @@ -0,0 +1,336 @@ +#pragma once +// Tools › Repeater — consolidates the repeater toggle, its flood "politeness" +// filters, an optional dedicated radio profile, and live forwarding stats on one +// screen. A dedicated screen (vs. the old Settings › Radio sub-items) gives +// full-width rows, so the longer labels no longer collide with the value column, +// and keeps the relaying controls next to the numbers that show them working. +// +// Network modes: +// - Current — repeat on the companion's current frequency (default). +// - Custom — enabling the repeater switches the radio to a dedicated profile +// (preset or manual), and disabling restores the companion params. +// Set the profile equal to the companion's for "same network". +// Included by UITask.cpp. + +#include +#include +#include "icons.h" +#include "PopupMenu.h" +#include "DigitEditor.h" +#include "../RadioPresets.h" +#include "../MyMesh.h" + +extern MyMesh the_mesh; + +class RepeaterScreen : public UIScreen { + UITask* _task; + bool _dirty; + int _sel; // index into the interactive items currently shown + int _scroll; // first visible row (render keeps _sel in view) + + enum Item { + IT_REPEATER, IT_NETWORK, + IT_RPRESET, IT_RFREQ, IT_RSF, IT_RBW, IT_RCR, // dedicated profile (Custom network) + IT_SKIP, IT_HOPS, IT_YIELD, IT_SNR, IT_SUPPRESS + }; + uint8_t _items[12]; + int _item_count; + + PopupMenu _preset_menu; + DigitEditor _freq_editor; + uint8_t _preset_user_slot[NodePrefs::USER_RADIO_PRESET_MAX]; + int _preset_user_count = 0; + + static const float* bwOpts(int& count) { + static const float OPTS[] = { 7.8f, 10.4f, 15.6f, 20.8f, 31.25f, 41.7f, 62.5f, 125.0f, 250.0f, 500.0f }; + count = 10; + return OPTS; + } + int rptBwIndex(NodePrefs* p) const { + int n; const float* o = bwOpts(n); + for (int i = 0; i < n; i++) if (o[i] == p->repeater_bw) return i; + return 6; // default to 62.5 + } + + void buildItems(NodePrefs* p) { + _item_count = 0; + _items[_item_count++] = IT_REPEATER; + if (p && p->client_repeat) { + _items[_item_count++] = IT_NETWORK; + if (p->repeater_use_profile) { + _items[_item_count++] = IT_RPRESET; + _items[_item_count++] = IT_RFREQ; + _items[_item_count++] = IT_RSF; + _items[_item_count++] = IT_RBW; + _items[_item_count++] = IT_RCR; + } + _items[_item_count++] = IT_SKIP; + _items[_item_count++] = IT_HOPS; + _items[_item_count++] = IT_YIELD; + _items[_item_count++] = IT_SNR; + _items[_item_count++] = IT_SUPPRESS; + } + if (_sel >= _item_count) _sel = _item_count - 1; + if (_sel < 0) _sel = 0; + } + + static const char* itemLabel(int item) { + switch (item) { + case IT_REPEATER: return "Repeater"; + case IT_NETWORK: return "Network"; + case IT_RPRESET: return "Rpt preset"; + case IT_RFREQ: return "Rpt freq"; + case IT_RSF: return "Rpt SF"; + case IT_RBW: return "Rpt BW"; + case IT_RCR: return "Rpt CR"; + case IT_SKIP: return "Skip advert"; + case IT_HOPS: return "Max hops"; + case IT_YIELD: return "Yield"; + case IT_SNR: return "Min SNR"; + case IT_SUPPRESS: return "Suppress dup"; + } + return ""; + } + + // Name of the preset matching the repeater profile, or "Custom". + const char* rptPresetName(NodePrefs* p) const { + for (int i = 0; i < RADIO_PRESET_COUNT; i++) { + const RadioPreset& r = RADIO_PRESETS[i]; + if (r.freq == p->repeater_freq && r.bw == p->repeater_bw && r.sf == p->repeater_sf && r.cr == p->repeater_cr) + return r.name; + } + for (int i = 0; i < NodePrefs::USER_RADIO_PRESET_MAX; i++) { + const NodePrefs::UserRadioPreset& u = p->user_radio_presets[i]; + if (!u.name[0]) continue; + if (u.freq == p->repeater_freq && u.bw == p->repeater_bw && u.sf == p->repeater_sf && u.cr == p->repeater_cr) + return u.name; + } + return "Custom"; + } + + void itemValue(int item, NodePrefs* p, char* buf, size_t n) const { + if (!p) { strncpy(buf, "OFF", n); buf[n-1]=0; return; } + switch (item) { + case IT_REPEATER: strncpy(buf, p->client_repeat ? "ON" : "OFF", n); break; + case IT_NETWORK: strncpy(buf, p->repeater_use_profile ? "Custom" : "Current", n); break; + case IT_RPRESET: strncpy(buf, rptPresetName(p), n); break; + case IT_RFREQ: snprintf(buf, n, "%.3f", p->repeater_freq); break; + case IT_RSF: snprintf(buf, n, "%d", (int)p->repeater_sf); break; + case IT_RBW: snprintf(buf, n, "%.1f", p->repeater_bw); break; + case IT_RCR: snprintf(buf, n, "%d", (int)p->repeater_cr); break; + case IT_SKIP: strncpy(buf, p->repeat_skip_adverts ? "ON" : "OFF", n); break; + case IT_HOPS: + if (p->repeat_max_hops > 0) snprintf(buf, n, "%d", (int)p->repeat_max_hops); + else strncpy(buf, "OFF", n); + break; + case IT_YIELD: + if (p->repeat_delay_boost > 0) snprintf(buf, n, "x%d", (int)p->repeat_delay_boost + 1); + else strncpy(buf, "OFF", n); + break; + case IT_SNR: + if (p->repeat_min_snr != NodePrefs::REPEAT_SNR_DISABLED) snprintf(buf, n, "%ddB", (int)p->repeat_min_snr); + else strncpy(buf, "OFF", n); + break; + case IT_SUPPRESS: strncpy(buf, p->repeat_suppress_dup ? "ON" : "OFF", n); break; + default: strncpy(buf, "", n); break; + } + buf[n - 1] = '\0'; + } + + // Seed the profile from the companion's current params (used when first + // switching to Custom, so the starting point is a valid, familiar config). + void seedProfileFromCompanion(NodePrefs* p) { + p->repeater_freq = p->freq; p->repeater_bw = p->bw; + p->repeater_sf = p->sf; p->repeater_cr = p->cr; + } + + void openPresetMenu(NodePrefs* p) { + _preset_menu.begin("Repeater Preset", 6); + for (int i = 0; i < RADIO_PRESET_COUNT; i++) _preset_menu.addItem(RADIO_PRESETS[i].name); + _preset_user_count = 0; + for (int i = 0; i < NodePrefs::USER_RADIO_PRESET_MAX; i++) { + if (!p->user_radio_presets[i].name[0]) continue; + _preset_menu.addItem(p->user_radio_presets[i].name); + _preset_user_slot[_preset_user_count++] = (uint8_t)i; + } + } + +public: + RepeaterScreen(UITask* task) : _task(task), _dirty(false), _sel(0), _scroll(0), _item_count(1) {} + + void enter() { _dirty = false; _sel = 0; _scroll = 0; _preset_menu.active = false; _freq_editor.active = false; } + + int render(DisplayDriver& display) override { + NodePrefs* p = _task->getNodePrefs(); + buildItems(p); + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + display.drawCenteredHeader("REPEATER"); + + const int item_h = display.lineStep(); + const int start_y = display.listStart(); + int visible = display.listVisible(item_h); + if (visible < 1) visible = 1; + + bool show_stats = p && p->client_repeat; + int total = _item_count + (show_stats ? 3 : 0); + + if (_sel < _scroll) _scroll = _sel; + if (_sel >= _scroll + visible) _scroll = _sel - visible + 1; + if (_sel == _item_count - 1) _scroll = total - visible; // reveal stats tail on last item + int max_scroll = total - visible; + if (max_scroll < 0) max_scroll = 0; + if (_scroll > max_scroll) _scroll = max_scroll; + if (_scroll < 0) _scroll = 0; + + const int reserve = scrollIndicatorReserve(display, total, visible); + for (int i = 0; i < visible && (_scroll + i) < total; i++) { + int row = _scroll + i; + int y = start_y + i * item_h; + char val[16]; + if (row < _item_count) { + bool sel = (row == _sel); + int item = _items[row]; + display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h - 1, sel); + display.setCursor(2, y); + display.print(itemLabel(item)); + if (item == IT_RFREQ && sel && _freq_editor.active) { + _freq_editor.render(display, valCol(display), y); + } else { + itemValue(item, p, val, sizeof(val)); + display.drawTextRightAlign(display.width() - reserve - 2, y, val); + } + display.setColor(DisplayDriver::LIGHT); + } else { + int s = row - _item_count; + const char* label = (s == 0) ? "Forwarded" : (s == 1) ? "Pool free" : "Queue"; + if (s == 0) snprintf(val, sizeof(val), "%lu", (unsigned long)the_mesh.getNumForwarded()); + else if (s == 1) snprintf(val, sizeof(val), "%d", the_mesh.getPoolFreeCount()); + else snprintf(val, sizeof(val), "%d", the_mesh.getOutboundQueueLen()); + display.setColor(DisplayDriver::LIGHT); + display.setCursor(2, y); + display.print(label); + display.drawTextRightAlign(display.width() - reserve - 2, y, val); + } + } + drawScrollIndicator(display, start_y, visible * item_h, total, visible, _scroll); + display.setColor(DisplayDriver::LIGHT); + if (_preset_menu.active) _preset_menu.render(display); + return (_preset_menu.active || _freq_editor.active) ? 50 : (show_stats ? 1000 : 500); + } + + // x where the right-side value column starts (matches Settings' valCol math). + static int valCol(DisplayDriver& d) { return d.width() - d.getCharWidth() * 8; } + + bool handleInput(char c) override { + NodePrefs* p = _task->getNodePrefs(); + + // Modal overlays first. + if (_preset_menu.active) { + auto res = _preset_menu.handleInput(c); + if (res == PopupMenu::SELECTED && p) { + int idx = _preset_menu.selectedIndex(); + if (idx >= 0 && idx < RADIO_PRESET_COUNT) { + const RadioPreset& r = RADIO_PRESETS[idx]; + p->repeater_freq = r.freq; p->repeater_bw = r.bw; p->repeater_sf = r.sf; p->repeater_cr = r.cr; + } else { + int u = idx - RADIO_PRESET_COUNT; + if (u >= 0 && u < _preset_user_count) { + const NodePrefs::UserRadioPreset& up = p->user_radio_presets[_preset_user_slot[u]]; + p->repeater_freq = up.freq; p->repeater_bw = up.bw; p->repeater_sf = up.sf; p->repeater_cr = up.cr; + } + } + the_mesh.applyRepeaterRadio(); // live if currently relaying on the profile + _dirty = true; + } + return true; + } + if (_freq_editor.active) { + float before = _freq_editor.value; + _freq_editor.handleInput(c); + if (p && _freq_editor.value != before) { + p->repeater_freq = _freq_editor.value; + the_mesh.applyRepeaterRadio(); + _dirty = true; + } + return true; + } + + if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { + if (_dirty) the_mesh.savePrefs(); + _task->gotoToolsScreen(); + return true; + } + if (c == KEY_UP) { _sel = (_sel > 0) ? _sel - 1 : _item_count - 1; return true; } + if (c == KEY_DOWN) { _sel = (_sel < _item_count - 1) ? _sel + 1 : 0; return true; } + if (!p) return false; + + bool right = keyIsNext(c); + bool left = keyIsPrev(c); + bool enter = (c == KEY_ENTER); + int item = _items[_sel]; + + if (item == IT_REPEATER && (left || right || enter)) { + p->client_repeat ^= 1; + the_mesh.applyRepeaterRadio(); // switch to profile on enable / restore companion on disable + buildItems(p); + _dirty = true; + return true; + } + if (item == IT_NETWORK && (left || right || enter)) { + p->repeater_use_profile ^= 1; + if (p->repeater_use_profile && !the_mesh.repeaterProfileValid()) + seedProfileFromCompanion(p); // start Custom from a valid, familiar config + the_mesh.applyRepeaterRadio(); + buildItems(p); + _dirty = true; + return true; + } + if (item == IT_RPRESET && enter) { openPresetMenu(p); return true; } + if (item == IT_RFREQ && enter) { + float lo, hi; radio_driver.getFreqBounds(lo, hi); + _freq_editor.begin(p->repeater_freq, lo, hi, 3, 3); + return true; + } + if (item == IT_RSF) { + if (right && p->repeater_sf < 12) { p->repeater_sf++; the_mesh.applyRepeaterRadio(); _dirty = true; return true; } + if (left && p->repeater_sf > 5) { p->repeater_sf--; the_mesh.applyRepeaterRadio(); _dirty = true; return true; } + } + if (item == IT_RBW) { + int n; const float* o = bwOpts(n); int idx = rptBwIndex(p); + if (right && idx < n - 1) { p->repeater_bw = o[idx + 1]; the_mesh.applyRepeaterRadio(); _dirty = true; return true; } + if (left && idx > 0) { p->repeater_bw = o[idx - 1]; the_mesh.applyRepeaterRadio(); _dirty = true; return true; } + } + if (item == IT_RCR) { + if (right && p->repeater_cr < 8) { p->repeater_cr++; the_mesh.applyRepeaterRadio(); _dirty = true; return true; } + if (left && p->repeater_cr > 5) { p->repeater_cr--; the_mesh.applyRepeaterRadio(); _dirty = true; return true; } + } + if (item == IT_SKIP && (left || right || enter)) { + p->repeat_skip_adverts ^= 1; _dirty = true; return true; + } + if (item == IT_HOPS) { + if (right && p->repeat_max_hops < 8) { p->repeat_max_hops++; _dirty = true; return true; } + if (left && p->repeat_max_hops > 0) { p->repeat_max_hops--; _dirty = true; return true; } + } + if (item == IT_YIELD) { + if (right && p->repeat_delay_boost < 8) { p->repeat_delay_boost++; _dirty = true; return true; } + if (left && p->repeat_delay_boost > 0) { p->repeat_delay_boost--; _dirty = true; return true; } + } + if (item == IT_SNR) { + int8_t v = p->repeat_min_snr; + if (right) { + v = (v == NodePrefs::REPEAT_SNR_DISABLED) ? -20 : (v < 10 ? v + 1 : 10); + p->repeat_min_snr = v; _dirty = true; return true; + } + if (left) { + if (v != NodePrefs::REPEAT_SNR_DISABLED) + p->repeat_min_snr = (v <= -20) ? NodePrefs::REPEAT_SNR_DISABLED : (int8_t)(v - 1); + _dirty = true; return true; + } + } + if (item == IT_SUPPRESS && (left || right || enter)) { + p->repeat_suppress_dup ^= 1; _dirty = true; return true; + } + return false; + } +}; diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index 08d57c36..f281e72a 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -58,12 +58,6 @@ class SettingsScreen : public UIScreen { TX_POWER, RADIO_PRESET, CUSTOM_FREQ, CUSTOM_SF, CUSTOM_BW, CUSTOM_CR, - REPEATER, - RPT_SKIP_ADV, // repeater politeness sub-items, only shown when REPEATER is on - RPT_MAX_HOPS, - RPT_DELAY, - RPT_MIN_SNR, - RPT_SUPPRESS, POWER_SAVE, TX_APC, // System section @@ -279,22 +273,11 @@ class SettingsScreen : public UIScreen { cur_collapsed = (_collapsed >> cur_sec) & 1; if (_vis_count < MAX_VIS) _vis[_vis_count++] = (uint8_t)i; } else if (!cur_collapsed && _vis_count < MAX_VIS) { - // Repeater politeness knobs are meaningless when the repeater is off, so - // hide them until it's enabled — keeps the Radio section short by default. - if (isRepeaterSubItem(i)) { - NodePrefs* p = _task->getNodePrefs(); - if (!p || !p->client_repeat) continue; - } _vis[_vis_count++] = (uint8_t)i; } } } - bool isRepeaterSubItem(int item) const { - return item == RPT_SKIP_ADV || item == RPT_MAX_HOPS || item == RPT_DELAY - || item == RPT_MIN_SNR || item == RPT_SUPPRESS; - } - bool isHomePage(int item) const { return item == HOME_CLOCK || item == HOME_RECENT || item == HOME_RADIO || item == HOME_BT || item == HOME_ADVERT || item == HOME_TOOLS || @@ -621,39 +604,6 @@ class SettingsScreen : public UIScreen { snprintf(buf, sizeof(buf), "%d", p ? (int)p->cr : 0); display.setCursor(valCol(display), y); display.print(buf); - } else if (item == REPEATER) { - display.print("Repeater"); - display.setCursor(valCol(display), y); - display.print((p && p->client_repeat) ? "ON" : "OFF"); - } else if (item == RPT_SKIP_ADV) { - display.print(" Skip advert"); - display.setCursor(valCol(display), y); - display.print((p && p->repeat_skip_adverts) ? "ON" : "OFF"); - } else if (item == RPT_MAX_HOPS) { - display.print(" Max hops"); - char buf[6]; - if (p && p->repeat_max_hops > 0) snprintf(buf, sizeof(buf), "%d", (int)p->repeat_max_hops); - else strcpy(buf, "Off"); - display.setCursor(valCol(display), y); - display.print(buf); - } else if (item == RPT_DELAY) { - display.print(" Yield"); - char buf[6]; - if (p && p->repeat_delay_boost > 0) snprintf(buf, sizeof(buf), "x%d", (int)p->repeat_delay_boost + 1); - else strcpy(buf, "Off"); - display.setCursor(valCol(display), y); - display.print(buf); - } else if (item == RPT_MIN_SNR) { - display.print(" Min SNR"); - char buf[8]; - if (p && p->repeat_min_snr != NodePrefs::REPEAT_SNR_DISABLED) snprintf(buf, sizeof(buf), "%ddB", (int)p->repeat_min_snr); - else strcpy(buf, "Off"); - display.setCursor(valCol(display), y); - display.print(buf); - } else if (item == RPT_SUPPRESS) { - display.print(" Suppress dup"); - display.setCursor(valCol(display), y); - display.print((p && p->repeat_suppress_dup) ? "ON" : "OFF"); } else if (item == POWER_SAVE) { display.print("Pwr save"); display.setCursor(valCol(display), y); @@ -1047,43 +997,6 @@ public: if (right && p->cr < 8) { p->cr++; _task->applyRadioParams(); _dirty = true; return true; } if (left && p->cr > 5) { p->cr--; _task->applyRadioParams(); _dirty = true; return true; } } - if (_selected == REPEATER && p && (left || right || enter)) { - p->client_repeat = p->client_repeat ? 0 : 1; - buildVis(); // show/hide the politeness sub-items immediately - _dirty = true; - return true; - } - if (_selected == RPT_SKIP_ADV && p && (left || right || enter)) { - p->repeat_skip_adverts ^= 1; - _dirty = true; - return true; - } - if (_selected == RPT_MAX_HOPS && p) { - if (right && p->repeat_max_hops < 8) { p->repeat_max_hops++; _dirty = true; return true; } - if (left && p->repeat_max_hops > 0) { p->repeat_max_hops--; _dirty = true; return true; } - } - if (_selected == RPT_DELAY && p) { - if (right && p->repeat_delay_boost < 8) { p->repeat_delay_boost++; _dirty = true; return true; } - if (left && p->repeat_delay_boost > 0) { p->repeat_delay_boost--; _dirty = true; return true; } - } - if (_selected == RPT_MIN_SNR && p) { - // Steps -20..10 dB; stepping left past the floor turns the filter off. - int8_t v = p->repeat_min_snr; - if (right) { - v = (v == NodePrefs::REPEAT_SNR_DISABLED) ? -20 : (v < 10 ? v + 1 : 10); - p->repeat_min_snr = v; _dirty = true; return true; - } - if (left) { - if (v != NodePrefs::REPEAT_SNR_DISABLED) - p->repeat_min_snr = (v <= -20) ? NodePrefs::REPEAT_SNR_DISABLED : (int8_t)(v - 1); - _dirty = true; return true; - } - } - if (_selected == RPT_SUPPRESS && p && (left || right || enter)) { - p->repeat_suppress_dup ^= 1; - _dirty = true; - return true; - } if (_selected == POWER_SAVE && p && (left || right || enter)) { p->rx_powersave ^= 1; _task->applyPowerSave(); diff --git a/examples/companion_radio/ui-new/ToolsScreen.h b/examples/companion_radio/ui-new/ToolsScreen.h index 3b058fbf..f53f7251 100644 --- a/examples/companion_radio/ui-new/ToolsScreen.h +++ b/examples/companion_radio/ui-new/ToolsScreen.h @@ -7,7 +7,7 @@ class ToolsScreen : public UIScreen { int _sel; int _scroll = 0; - static const int ITEM_COUNT = 7; + static const int ITEM_COUNT = 8; static const char* ITEMS[ITEM_COUNT]; public: @@ -38,8 +38,9 @@ public: if (_sel == 4) { _task->gotoTrailScreen(); return true; } if (_sel == 5) { _task->gotoCompassScreen(); return true; } if (_sel == 6) { _task->gotoDiagnosticsScreen(); return true; } + if (_sel == 7) { _task->gotoRepeaterScreen(); return true; } } return false; } }; -const char* ToolsScreen::ITEMS[7] = { "Ringtone Editor", "Auto-Reply Bot", "Nearby Nodes", "Auto-Advert", "Trail", "Compass", "Diagnostics" }; +const char* ToolsScreen::ITEMS[8] = { "Ringtone Editor", "Auto-Reply Bot", "Nearby Nodes", "Auto-Advert", "Trail", "Compass", "Diagnostics", "Repeater" }; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 658213e5..df88eea2 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -126,6 +126,7 @@ static const int QUICK_MSGS_MAX = 10; #include "TrailScreen.h" #include "CompassScreen.h" #include "DiagnosticsScreen.h" +#include "RepeaterScreen.h" #include "ToolsScreen.h" #ifndef BATT_MIN_MILLIVOLTS @@ -1200,6 +1201,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no trail_screen = new TrailScreen(this, &_trail); compass_screen = new CompassScreen(this); diag_screen = new DiagnosticsScreen(this); + repeater_screen = new RepeaterScreen(this); applyBrightness(); applyFont(); applyRotation(); @@ -1250,6 +1252,11 @@ void UITask::gotoDiagnosticsScreen() { setCurrScreen(diag_screen); } +void UITask::gotoRepeaterScreen() { + ((RepeaterScreen*)repeater_screen)->enter(); + setCurrScreen(repeater_screen); +} + void UITask::gotoAutoAdvertScreen() { ((AutoAdvertScreen*)auto_advert_screen)->enter(); setCurrScreen(auto_advert_screen); diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 8d2757f2..c1d147bb 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -80,6 +80,7 @@ class UITask : public AbstractUITask { UIScreen* trail_screen; UIScreen* compass_screen; UIScreen* diag_screen; + UIScreen* repeater_screen; UIScreen* curr; CayenneLPP _dash_lpp; TrailStore _trail; @@ -154,6 +155,7 @@ public: void gotoTrailScreen(); void gotoCompassScreen(); void gotoDiagnosticsScreen(); + void gotoRepeaterScreen(); TrailStore& trail() { return _trail; } WaypointStore& waypoints() { return _waypoints; } // Shared on-screen keyboard — only one screen drives it at a time.