mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-09-14 15:16:40 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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[]);
|
||||
|
||||
@@ -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], ×tamp, 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user