feat(companion): on-device scope + repeater scope filtering; fix CAD, UTF-8 truncation, Public channel, Nodes list, keyboard cursor

- Settings > Radio > Scope: type a community/region name on-device (derives
  the shared key the same "#name" -> SHA256 way as DEFAULT_FLOOD_SCOPE_NAME),
  previously only settable from a connected app.
- Tools > Repeater > Scope only + Extra scopes: only relay flood traffic
  matching the device's own scope or a comma-separated list of additional
  scopes, without changing what scope the device's own messages send under.
  No-op while unconfigured.
- getCADEnabled()/getInterferenceThreshold() were hardcoded off on
  companion_radio; CAD now auto-enables whenever RX power-save (duty-cycle)
  is active, since the noise floor isn't kept fresh during duty-cycle sleep.
- Message truncation to fit the send frame could split a multi-byte UTF-8
  character in half; now stops at the last complete character.
- The default "Public" channel was unconditionally re-added at every boot
  before the saved channel list was loaded, so deleting it never stuck.
  Only seeded now on a genuinely fresh device (no channel file yet).
- Tools > Nodes read contacts from the wrong starting offset, landing on
  internally-reserved bookkeeping slots instead of real contacts -- showed
  as blank "Unknown" rows and silently dropped that many real contacts off
  the end of the list.
- resetContacts() only cleared the first few reserved slots, not the whole
  contact table, contrary to its own comment; only reachable today via
  private-key import, fixed to match stated intent regardless.
- Keyboard's multi-line text preview could render the cursor on an empty
  line below short typed text instead of right after it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-08-21 10:40:50 +02:00
co-authored by Claude Sonnet 5
parent 7e10e0359c
commit f589b9b2d1
13 changed files with 283 additions and 27 deletions
+28 -2
View File
@@ -383,6 +383,18 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
(_prefs.repeat_min_snr < -20 || _prefs.repeat_min_snr > 10))
_prefs.repeat_min_snr = NodePrefs::REPEAT_SNR_DISABLED; // match the UI's -20..10 range
if (_prefs.repeat_suppress_dup > 1) _prefs.repeat_suppress_dup = 0;
// → 0xC0DE0025: append repeat_scope_only right after the other repeater
// forwarding filters. A pre-0x25 file has no byte here; clamp to 0 (off,
// unchanged forwarding behaviour for upgraders).
rd(&_prefs.repeat_scope_only, sizeof(_prefs.repeat_scope_only));
if (_prefs.repeat_scope_only > 1) _prefs.repeat_scope_only = 0;
// → 0xC0DE0026: append repeat_extra_scopes right after it. A pre-0x26 file
// has no bytes here; rd() zero-inits, which is already an empty string.
rd(_prefs.repeat_extra_scopes, sizeof(_prefs.repeat_extra_scopes));
_prefs.repeat_extra_scopes[sizeof(_prefs.repeat_extra_scopes) - 1] = '\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));
@@ -569,6 +581,13 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
rd(&_prefs.keyboard_cardkb_compact, sizeof(_prefs.keyboard_cardkb_compact));
if (_prefs.keyboard_cardkb_compact > 1) _prefs.keyboard_cardkb_compact = 0;
// → 0xC0DE0024: append interference_threshold + cad_enabled at the tail.
// A pre-0x24 file has no bytes here; clamp to 0 (both off, matching the
// getters' previous hardcoded behaviour for upgraders).
rd(&_prefs.interference_threshold, sizeof(_prefs.interference_threshold));
rd(&_prefs.cad_enabled, sizeof(_prefs.cad_enabled));
if (_prefs.cad_enabled > 1) _prefs.cad_enabled = 0;
// Schema sentinel: bumped on layout changes. Mismatch means an older file
// (or a different schema); rd() already zero-inits any fields not present,
// so we just log it — next savePrefs writes the current sentinel.
@@ -729,6 +748,8 @@ 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.repeat_scope_only, sizeof(_prefs.repeat_scope_only));
file.write((uint8_t *)_prefs.repeat_extra_scopes, sizeof(_prefs.repeat_extra_scopes));
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));
@@ -781,6 +802,8 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
file.write((uint8_t *)&_prefs.gpio3_mode, sizeof(_prefs.gpio3_mode));
file.write((uint8_t *)&_prefs.gpio4_mode, sizeof(_prefs.gpio4_mode));
file.write((uint8_t *)&_prefs.keyboard_cardkb_compact, sizeof(_prefs.keyboard_cardkb_compact));
file.write((uint8_t *)&_prefs.interference_threshold, sizeof(_prefs.interference_threshold));
file.write((uint8_t *)&_prefs.cad_enabled, sizeof(_prefs.cad_enabled));
// Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL. Its write is
// the one we check: once the flash fills, writes return 0, so a good
@@ -894,7 +917,7 @@ void DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactIn
}
}
void DataStore::loadChannels(DataStoreHost* host) {
bool DataStore::loadChannels(DataStoreHost* host) {
FILESYSTEM* fs = _getContactsChannelsFS();
File file = openRead(fs, "/channels3");
if (file) {
@@ -942,7 +965,7 @@ void DataStore::loadChannels(DataStoreHost* host) {
MESH_DEBUG_PRINTLN("loadChannels: skipped %u corrupted/empty channel entr%s",
(unsigned)skipped, skipped == 1 ? "y" : "ies");
}
return;
return true;
}
// One-time migration from the old /channels2 format (sequential index,
@@ -972,7 +995,10 @@ void DataStore::loadChannels(DataStoreHost* host) {
}
file.close();
saveChannels(host); // write /channels3 so the migration runs only once
return true;
}
return false; // neither file exists -- genuinely fresh device
}
void DataStore::saveChannels(DataStoreHost* host) {
+7 -1
View File
@@ -37,7 +37,13 @@ public:
void savePrefs(const NodePrefs& prefs, double node_lat, double node_lon);
void loadContacts(DataStoreHost* host);
void saveContacts(DataStoreHost* host, bool (*filter)(const ContactInfo& c) = NULL);
void loadChannels(DataStoreHost* host);
// Returns true if a channels file (current or legacy) existed and was
// loaded -- false only on a genuinely fresh device with neither file, so
// the caller knows whether it's safe to seed default channels (see
// MyMesh::begin()'s addChannel("Public", ...) -- seeding unconditionally
// would resurrect a channel the user had deliberately deleted, since a
// deleted slot is simply absent from the file, not written as empty).
bool loadChannels(DataStoreHost* host);
void saveChannels(DataStoreHost* host);
void migrateToSecondaryFS();
uint8_t getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]);
+74 -8
View File
@@ -9,6 +9,7 @@
#ifdef DISPLAY_CLASS
#include "helpers/ui/DisplayDriver.h"
#include "UITask.h"
#include <helpers/UTF8Helpers.h>
#endif
#define CMD_APP_START 1
@@ -277,10 +278,16 @@ float MyMesh::getAirtimeBudgetFactor() const {
}
int MyMesh::getInterferenceThreshold() const {
return 0; // disabled for now, until currentRSSI() problem is resolved
return _prefs.interference_threshold;
}
bool MyMesh::getCADEnabled() const {
return false; // hardware CAD before TX (disabled by default, until configurable)
// RSSI-threshold interference detection relies on _noise_floor, which is only
// kept fresh by continuous RX — stale during RX duty-cycle sleep. Auto-enable
// hardware CAD (a fresh explicit scan) whenever power-save is actually active.
// _prefs.cad_enabled itself has no UI/CLI exposure yet on companion_radio
// (unlike simple_repeater's CommonCLI `cad` command) — it's wired and
// persisted for a future manual override, but always 0 today.
return _prefs.cad_enabled || (_prefs.rx_powersave && !_prefs.client_repeat);
}
int MyMesh::calcRxDelay(float score, uint32_t air_time) const {
@@ -512,9 +519,9 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe
memcpy(&out_frame[i], extra, extra_len);
i += extra_len;
}
int tlen = strlen(text); // TODO: UTF-8 ??
int tlen = strlen(text);
if (i + tlen > MAX_FRAME_SIZE) {
tlen = MAX_FRAME_SIZE - i;
tlen = mesh::validUtf8PrefixLength(text, MAX_FRAME_SIZE - i); // don't split a multi-byte char
}
memcpy(&out_frame[i], text, tlen);
i += tlen;
@@ -634,11 +641,62 @@ bool MyMesh::allowPacketForward(const mesh::Packet* packet) {
if (_prefs.repeat_skip_adverts && packet->getPayloadType() == PAYLOAD_TYPE_ADVERT) return false;
if (_prefs.repeat_max_hops > 0 && packet->getPathHashCount() >= _prefs.repeat_max_hops) return false;
if (packet->getPayloadType() == PAYLOAD_TYPE_ADVERT && packet->getPathHashCount() >= REPEAT_MAX_ADVERT_HOPS) return false;
if (_prefs.repeat_scope_only && repeat_scope_count > 0) { // no-op while unconfigured
if (!packet->hasTransportCodes()) return false; // unscoped flood, we only want our own scope(s)
bool matched = false;
for (uint8_t i = 0; i < repeat_scope_count && !matched; i++) {
matched = repeat_scopes[i].calcTransportCode(packet) == packet->transport_codes[0];
}
if (!matched) return false;
}
if (isRepeatLooped(packet)) return false;
}
return true;
}
void MyMesh::setPrimaryScope(const char* name) {
strncpy(_prefs.default_scope_name, name, sizeof(_prefs.default_scope_name) - 1);
_prefs.default_scope_name[sizeof(_prefs.default_scope_name) - 1] = '\0';
if (_prefs.default_scope_name[0] == '\0') {
memset(_prefs.default_scope_key, 0, sizeof(_prefs.default_scope_key));
} else {
char hashtag[1 + sizeof(_prefs.default_scope_name)];
snprintf(hashtag, sizeof(hashtag), "#%s", _prefs.default_scope_name);
TransportKeyStore temp;
TransportKey key;
temp.getAutoKeyFor(0, hashtag, key);
memcpy(_prefs.default_scope_key, key.key, sizeof(key.key));
}
rebuildRepeatScopes();
}
void MyMesh::rebuildRepeatScopes() {
repeat_scope_count = 0;
TransportKey primary;
memcpy(primary.key, _prefs.default_scope_key, sizeof(primary.key));
if (!primary.isNull()) repeat_scopes[repeat_scope_count++] = primary;
TransportKeyStore temp;
char names[sizeof(_prefs.repeat_extra_scopes)];
strncpy(names, _prefs.repeat_extra_scopes, sizeof(names));
names[sizeof(names) - 1] = '\0';
char* tok = strtok(names, ",");
while (tok != NULL && repeat_scope_count < MAX_REPEAT_SCOPES) {
while (*tok == ' ') tok++; // trim leading spaces
char* end = tok + strlen(tok);
while (end > tok && end[-1] == ' ') *(--end) = '\0'; // trim trailing spaces
if (*tok != '\0') {
char hashtag[1 + sizeof(_prefs.repeat_extra_scopes)];
snprintf(hashtag, sizeof(hashtag), "#%s", tok);
temp.getAutoKeyFor(0, hashtag, repeat_scopes[repeat_scope_count++]);
}
tok = strtok(NULL, ",");
}
}
void MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis) {
if (scope.isNull()) {
sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1);
@@ -769,9 +827,9 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe
out_frame[i++] = TXT_TYPE_PLAIN;
memcpy(&out_frame[i], &timestamp, 4);
i += 4;
int tlen = strlen(text); // TODO: UTF-8 ??
int tlen = strlen(text);
if (i + tlen > MAX_FRAME_SIZE) {
tlen = MAX_FRAME_SIZE - i;
tlen = mesh::validUtf8PrefixLength(text, MAX_FRAME_SIZE - i); // don't split a multi-byte char
}
memcpy(&out_frame[i], text, tlen);
i += tlen;
@@ -1592,6 +1650,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
memset(_ping_results, 0, sizeof(_ping_results));
send_unscoped = false;
repeat_scope_count = 0;
// defaults
memset(&_prefs, 0, sizeof(_prefs));
@@ -1683,6 +1742,7 @@ void MyMesh::begin(bool has_display) {
// load persisted prefs
_store->loadPrefs(_prefs, sensors.node_lat, sensors.node_lon);
rebuildRepeatScopes();
// sanitise bad pref values. NaN/inf must be reset BEFORE constrain(): constrain
// is a min/max macro and NaN compares false against both bounds, so it would
@@ -1724,8 +1784,14 @@ void MyMesh::begin(bool has_display) {
resetContacts();
_store->loadContacts(this);
bootstrapRTCfromContacts();
addChannel("Public", PUBLIC_GROUP_PSK); // pre-configure Andy's public channel
_store->loadChannels(this);
// Only seed the default Public channel on a genuinely fresh device (no
// channels file at all yet) -- a deleted channel is simply absent from
// /channels3, not written back as an empty record (see saveChannels()), so
// seeding unconditionally here would silently resurrect Public every boot
// even after the user explicitly deleted it.
if (!_store->loadChannels(this)) {
addChannel("Public", PUBLIC_GROUP_PSK); // pre-configure Andy's public channel
}
applyRepeaterRadio(); // companion params, or the repeater profile if relaying with one set
applyApc(); // sets TX power to the ceiling and arms APC if enabled
+23 -2
View File
@@ -12,7 +12,7 @@ class UITask;
#define FIRMWARE_VER_CODE 13
#ifndef FIRMWARE_BUILD_DATE
#define FIRMWARE_BUILD_DATE "14 Aug 2026"
#define FIRMWARE_BUILD_DATE "19 Aug 2026"
#endif
// Fallback only -- every real build (local or CI) goes through build.sh, which
@@ -20,7 +20,7 @@ class UITask;
// "dev-<commit>" otherwise; see build-solo-firmwares.yml). This default only
// shows up for a `pio run` invoked directly, bypassing build.sh entirely.
#ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "v1.25-dev"
#define FIRMWARE_VERSION "v1.26-dev"
#endif
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
@@ -301,6 +301,19 @@ public:
// the repeater toggle / network / profile changes.
void applyRepeaterRadio();
// Sets this device's own scope (Settings > Radio > Scope) from a single
// typed region name, deriving default_scope_key the same "#name" -> SHA256
// way as DEFAULT_FLOOD_SCOPE_NAME (see begin()). Empty name clears the
// scope. Governs what scope the companion's own messages send under, and
// (as scope[0]) what repeat_scope_only accepts. Calls rebuildRepeatScopes().
void setPrimaryScope(const char* name);
// Rebuilds repeat_scopes[]/repeat_scope_count from default_scope_key (slot 0,
// if configured) plus the comma-separated repeat_extra_scopes (Tools >
// Repeater > Extra scopes). Call after loading prefs at boot and whenever
// either scope setting is edited.
void rebuildRepeatScopes();
bool isAckPending(uint32_t expected_ack) const {
if (expected_ack == 0) return false; // 0 marks an empty/cleared slot, not a real ACK
for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; i++)
@@ -498,6 +511,14 @@ private:
TransportKey send_scope;
// Runtime-only (not persisted) cache of scopes accepted by repeat_scope_only:
// slot 0 is default_scope_key (if configured), the rest are derived from the
// comma-separated repeat_extra_scopes. Rebuilt by rebuildRepeatScopes() (see
// the public section below) whenever either scope setting changes.
static const uint8_t MAX_REPEAT_SCOPES = 4;
TransportKey repeat_scopes[MAX_REPEAT_SCOPES];
uint8_t repeat_scope_count;
uint8_t cmd_frame[MAX_FRAME_SIZE + 1];
uint8_t out_frame[MAX_FRAME_SIZE + 1];
CayenneLPP telemetry;
+31 -2
View File
@@ -208,12 +208,24 @@ struct NodePrefs { // persisted to file
// fringe traffic isn't re-flooded. REPEAT_SNR_DISABLED (-128) = off.
// repeat_suppress_dup: 1 = cancel a queued retransmit when the same flood is
// overheard from another node first (less redundant airtime in dense mesh).
// repeat_scope_only: 1 = only forward flood packets matching this device's
// own scope (Settings > Radio > Scope, default_scope_key) or one of the
// repeat_extra_scopes below — drops unscoped floods and floods tagged for
// a different community. A no-op (forwards everything, unchanged) while
// no scope is configured at all, so enabling this on an unconfigured
// device can't silently blackhole all flood traffic.
uint8_t repeat_skip_adverts;
uint8_t repeat_max_hops;
uint8_t repeat_delay_boost;
int8_t repeat_min_snr;
static const int8_t REPEAT_SNR_DISABLED = -128;
uint8_t repeat_suppress_dup;
uint8_t repeat_scope_only;
// Extra region names this repeater also relays for, beyond its own
// Settings > Radio > Scope (comma-separated, e.g. "eu,de") — see
// MyMesh::rebuildRepeatScopes(). Relay-only: never affects what scope the
// companion's own messages send under, only what repeat_scope_only accepts.
char repeat_extra_scopes[24];
// 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
@@ -441,6 +453,16 @@ struct NodePrefs { // persisted to file
// unchanged behaviour) on upgrade.
uint8_t keyboard_cardkb_compact;
// RSSI-based interference detection (relative to the radio's own noise
// floor) and hardware Channel Activity Detection before TX. See
// MyMesh::getInterferenceThreshold()/getCADEnabled() — CAD is also
// auto-enabled whenever rx_powersave is active, regardless of this flag,
// since duty-cycle sleep leaves the noise floor stale. Both default 0/off,
// and neither has a Settings/CLI toggle yet on companion_radio — persisted
// and wired for a future manual override.
uint8_t interference_threshold;
uint8_t cad_enabled;
// Single source of truth for the live-share option tables (shared by the Map
// UI labels and the auto-send engine in UITask).
static const uint8_t LOC_SHARE_MOVE_COUNT = 4;
@@ -503,7 +525,7 @@ struct NodePrefs { // persisted to file
// 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 = 0xC0DE0023;
static const uint32_t SCHEMA_SENTINEL = 0xC0DE0026;
// 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
@@ -608,7 +630,14 @@ struct NodePrefs { // persisted to file
// bumps, 7 more uint8_t total) added 8 bytes, not 7 -- one byte of tail
// padding got consumed along the way. 2720 confirmed via a real
// WioTrackerL1Eink_companion_solo_dual build.
static_assert(sizeof(NodePrefs) == 2720,
// interference_threshold + cad_enabled (0xC0DE0024) are the new struct tail --
// added 8 bytes, not 2 -- the 2 real bytes rounded the struct up to its next
// alignment boundary. Confirmed via a real Heltec_v3_companion_radio_ble build.
// repeat_scope_only (0xC0DE0025) landed in the padding left over from the
// 0xC0DE0024 bump -- confirmed via a real build, sizeof unchanged at 2728.
// repeat_extra_scopes[24] (0xC0DE0026) added exactly 24 bytes, no leftover
// padding this time -- confirmed via a real build, sizeof now 2752.
static_assert(sizeof(NodePrefs) == 2752,
"NodePrefs layout changed — sync DataStore save/load + clamp, bump "
"SCHEMA_SENTINEL, then update this size (see steps above).");
@@ -577,10 +577,18 @@ struct KeyboardWidget {
// ...and the byte offset that line starts at.
int ps = 0;
for (int n = first_line * cpl; n > 0 && ps < len; n--) ps += kbUtf8CharBytesAt(buf, ps, len);
bool cursor_drawn = false; // draw it on exactly one line, even once the text itself runs out
for (int pl = 0; pl < prev_lines; pl++) {
int pe = ps; // byte offset cpl codepoints further along (or end of text)
for (int k = 0; k < cpl && pe < len; k++) pe += kbUtf8CharBytesAt(buf, pe, len);
bool cursor_here = (ps <= cursor_pos && (cursor_pos < pe || pl == prev_lines - 1));
// cursor_pos == len == pe is the common "typing at the end" case: that's
// this line's cursor only if THIS is where the text actually ends (pe ==
// len), not just whichever line happens to be the bottom of the preview
// area -- short text (fitting in fewer than prev_lines rows) would
// otherwise always show the cursor stranded on the last blank row
// instead of right after what was just typed.
bool cursor_here = !cursor_drawn && ps <= cursor_pos && (cursor_pos < pe || pe == len);
if (cursor_here) cursor_drawn = true;
int line_end = (len < pe) ? len : pe;
char linebuf[KB_PREVIEW_BYTES + 2]; // cpl codepoints + cursor '_' + NUL
if (cursor_here) {
+11 -2
View File
@@ -173,7 +173,16 @@ class NearbyScreen : public UIScreen {
int nc = the_mesh.getNumContacts();
for (int i = 0; i < nc && _count < MAX_NEARBY; i++) {
ContactInfo ci;
if (!the_mesh.getContactByIdx(i, ci)) continue;
// getContactByIdx() indexes the RAW contact table, whose first
// MAX_ANON_CONTACTS slots are reserved for anon-request bookkeeping
// (see BaseChatMesh::resetContacts()/ContactsIterator) -- getNumContacts()
// already excludes them from the count, so real contact 0 lives at raw
// index MAX_ANON_CONTACTS, not 0. Reading from 0 pulled those reserved
// (blank, type=ADV_TYPE_NONE) slots into the list as bogus "Unknown"
// rows, and silently dropped the same number of real contacts off the
// end -- while never touching the anon slots themselves, so it always
// reproduced the same way regardless of the auto-add overwrite setting.
if (!the_mesh.getContactByIdx(i + MAX_ANON_CONTACTS, ci)) continue;
if (!typeMatchesFilter(ci.type, ci.flags, true)) continue;
Entry& e = _entries[_count++];
@@ -188,7 +197,7 @@ class NearbyScreen : public UIScreen {
? geo::haversineKm(_own_lat, _own_lon, ci.gps_lat, ci.gps_lon)
: -1.0f;
e.type = ci.type;
e.contact_idx = i;
e.contact_idx = i + MAX_ANON_CONTACTS; // raw index -- other lookups re-key off this directly
e.lastmod = ci.lastmod;
e.is_known = true;
e.is_live = false;
@@ -35,9 +35,10 @@ class RepeaterScreen : public UIScreen {
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
IT_SKIP, IT_HOPS, IT_YIELD, IT_SNR, IT_SUPPRESS, IT_SCOPE, IT_SCOPE_EXTRA
};
uint8_t _items[12];
uint8_t _items[14];
bool _editing_scope; // keyboard is entering/editing the extra scopes
int _item_count;
RadioPresetPicker _picker;
@@ -66,6 +67,8 @@ class RepeaterScreen : public UIScreen {
_items[_item_count++] = IT_YIELD;
_items[_item_count++] = IT_SNR;
_items[_item_count++] = IT_SUPPRESS;
_items[_item_count++] = IT_SCOPE;
_items[_item_count++] = IT_SCOPE_EXTRA;
}
if (_sel >= _item_count) _sel = _item_count - 1;
if (_sel < 0) _sel = 0;
@@ -84,7 +87,9 @@ class RepeaterScreen : public UIScreen {
case IT_HOPS: return "Max hops";
case IT_YIELD: return "Yield";
case IT_SNR: return "Min SNR";
case IT_SUPPRESS: return "Suppress dup";
case IT_SUPPRESS: return "Suppress dup";
case IT_SCOPE: return "Scope only";
case IT_SCOPE_EXTRA: return "Extra scopes";
}
return "";
}
@@ -113,6 +118,10 @@ class RepeaterScreen : public UIScreen {
else strncpy(buf, "OFF", n);
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:
strncpy(buf, p->repeat_extra_scopes[0] ? p->repeat_extra_scopes : "(none)", n);
break;
default: strncpy(buf, "", n); break;
}
buf[n - 1] = '\0';
@@ -126,16 +135,17 @@ class RepeaterScreen : public UIScreen {
}
public:
RepeaterScreen(UITask* task) : _task(task), _dirty(false), _sel(0), _scroll(0), _item_count(1) {}
RepeaterScreen(UITask* task) : _task(task), _dirty(false), _sel(0), _scroll(0), _item_count(1), _editing_scope(false) {}
void onShow() override {
_dirty = false; _sel = 0; _scroll = 0;
_picker.menu.active = false; _editor.freq.active = false;
_picker.saving = false; _picker.deleting = false;
_editing_scope = false;
}
int render(DisplayDriver& display) override {
if (_picker.saving) return _task->keyboard().render(display);
if (_picker.saving || _editing_scope) return _task->keyboard().render(display);
NodePrefs* p = _task->getNodePrefs();
buildItems(p);
@@ -181,6 +191,23 @@ public:
return true;
}
// Keyboard editing mode for the extra (relay-only) scopes
if (_editing_scope) {
auto res = _task->keyboard().handleInput(c);
if (res == KeyboardWidget::DONE) {
if (p) {
strncpy(p->repeat_extra_scopes, _task->keyboard().buf, sizeof(p->repeat_extra_scopes) - 1);
p->repeat_extra_scopes[sizeof(p->repeat_extra_scopes) - 1] = '\0';
the_mesh.rebuildRepeatScopes();
_dirty = true;
}
_editing_scope = false;
} else if (res == KeyboardWidget::CANCELLED) {
_editing_scope = false;
}
return true;
}
// Modal overlays first.
if (_picker.menu.active) {
auto res = _picker.menu.handleInput(c);
@@ -279,6 +306,14 @@ public:
if (item == IT_SUPPRESS && (left || right || enter)) {
p->repeat_suppress_dup ^= 1; _dirty = true; return true;
}
if (item == IT_SCOPE && (left || right || enter)) {
p->repeat_scope_only ^= 1; _dirty = true; return true;
}
if (item == IT_SCOPE_EXTRA && enter) {
_task->keyboard().begin(p->repeat_extra_scopes, (int)sizeof(p->repeat_extra_scopes) - 1);
_editing_scope = true;
return true;
}
return false;
}
};
@@ -61,6 +61,7 @@ class SettingsScreen : public UIScreen {
CUSTOM_FREQ, CUSTOM_SF, CUSTOM_BW, CUSTOM_CR,
POWER_SAVE,
TX_APC,
SCOPE_NAME,
// System section
SECTION_SYSTEM,
DEVICE_NAME,
@@ -555,6 +556,11 @@ class SettingsScreen : public UIScreen {
// Suppressed (and locked) while repeating — a repeater holds full TX power.
if (p && p->client_repeat) display.print("--");
else display.print((p && p->tx_apc) ? "ON" : "OFF");
} else if (item == SCOPE_NAME) {
display.print("Scope");
int vx = valCol(display);
display.drawTextEllipsized(vx, y, display.width() - vx - _reserve,
(p && p->default_scope_name[0]) ? p->default_scope_name : "(none)");
#if AUTO_OFF_MILLIS > 0
} else if (item == AUTO_OFF) {
display.print("AutoOff");
@@ -678,6 +684,7 @@ class SettingsScreen : public UIScreen {
// Keyboard state for editing message slots
int _edit_slot = -1; // -1 = not editing, 0..9 = slot being edited
bool _edit_name = false; // editing DEVICE_NAME via the keyboard
bool _edit_scope = false; // editing SCOPE_NAME via the keyboard
KeyboardWidget* _kb;
// Radio preset picker — names are too long for the value column, so Enter on
@@ -701,6 +708,7 @@ public:
void onShow() override {
_dirty = false;
_edit_name = false;
_edit_scope = false;
resetList();
_editor.freq.active = false;
}
@@ -708,7 +716,7 @@ public:
int render(DisplayDriver& display) override {
display.setTextSize(1);
if (_edit_slot >= 0 || _edit_name || _picker.saving) {
if (_edit_slot >= 0 || _edit_name || _edit_scope || _picker.saving) {
return _kb->render(display);
}
@@ -771,6 +779,19 @@ public:
return true;
}
// Keyboard editing mode for the scope name
if (_edit_scope) {
auto res = _kb->handleInput(c);
if (res == KeyboardWidget::DONE) {
the_mesh.setPrimaryScope(_kb->buf);
_dirty = true;
_edit_scope = false;
} else if (res == KeyboardWidget::CANCELLED) {
_edit_scope = false;
}
return true;
}
// Digit-by-digit Freq editor
if (_editor.active()) {
if (_editor.handleFreqInput(c) && p) { _task->applyRadioParams(); _dirty = true; }
@@ -966,6 +987,12 @@ public:
_kb->clearPlaceholders(); // a device name is literal, not a message
return true;
}
if (_selected == SCOPE_NAME && p && enter) {
_edit_scope = true;
_kb->begin(p->default_scope_name, (int)sizeof(p->default_scope_name) - 1);
_kb->clearPlaceholders(); // a scope name is literal, not a message
return true;
}
if (_selected == REBOOT && enter) {
_task->savePrefsIfDirty(_dirty); // don't lose pending edits across the restart
_task->showAlert("Rebooting...", 800);