mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-09-14 15:16:40 +00:00
feat(companion): shared named-scope list, per-channel/repeater popup pickers
Replace the single device-wide Scope text field with a small named list (wildcard "*" fixed first, plus a movable default): each channel picks one scope of its own (matching the app's per-channel region picker, surfaced in the channel-history title), and the repeater's Extra scopes multi-selects from the same list instead of comma-typed names. Both pickers are popups (PopupMenu gains addCheckItem()/a fillable-square checkbox glyph) over the existing screen rather than a full-screen takeover. Also fixes a stray-bits bug where a pre-existing prefs file's own sentinel tail could read into the new repeat_extra_scope_mask/ch_scope_idx fields and silently reappear as a real pick later once the scope list grew into that range. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -618,6 +618,15 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
|
|||||||
rd(&_prefs.msg_wake_screen_off, sizeof(_prefs.msg_wake_screen_off));
|
rd(&_prefs.msg_wake_screen_off, sizeof(_prefs.msg_wake_screen_off));
|
||||||
if (_prefs.msg_wake_screen_off > 1) _prefs.msg_wake_screen_off = 0;
|
if (_prefs.msg_wake_screen_off > 1) _prefs.msg_wake_screen_off = 0;
|
||||||
|
|
||||||
|
// → 0xC0DE002B: append repeat_extra_scope_mask + ch_scope_idx. A pre-0x2B
|
||||||
|
// file has stray sentinel bytes from that file's own tail sitting here --
|
||||||
|
// read as-is for now, zeroed below once the sentinel mismatch confirms this
|
||||||
|
// really is a pre-0x2B file (can't range-clamp a mask/index here: every bit
|
||||||
|
// or byte value is technically "valid", so garbage can't be told apart from
|
||||||
|
// a real pick until we know which schema version wrote it).
|
||||||
|
rd(&_prefs.repeat_extra_scope_mask, sizeof(_prefs.repeat_extra_scope_mask));
|
||||||
|
rd(_prefs.ch_scope_idx, sizeof(_prefs.ch_scope_idx));
|
||||||
|
|
||||||
// Schema sentinel: bumped on layout changes. Mismatch means an older file
|
// Schema sentinel: bumped on layout changes. Mismatch means an older file
|
||||||
// (or a different schema); rd() already zero-inits any fields not present,
|
// (or a different schema); rd() already zero-inits any fields not present,
|
||||||
// so we just log it — next savePrefs writes the current sentinel.
|
// so we just log it — next savePrefs writes the current sentinel.
|
||||||
@@ -671,6 +680,19 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
|
|||||||
// → 0xC0DE000D: append user_radio_presets. No clamping needed — rd() already
|
// → 0xC0DE000D: append user_radio_presets. No clamping needed — rd() already
|
||||||
// zero-inits it on a pre-0x0D file, and name[0]=='\0' is exactly the "empty
|
// zero-inits it on a pre-0x0D file, and name[0]=='\0' is exactly the "empty
|
||||||
// slot" sentinel the UI already expects.
|
// slot" sentinel the UI already expects.
|
||||||
|
// 0xC0DE002A → 0xC0DE002B: repeat_extra_scope_mask + ch_scope_idx appended.
|
||||||
|
// Unlike the fields above, these can't be left with whatever stray bytes
|
||||||
|
// rd() picked up from a pre-0x2B file's own sentinel tail: every bit/byte
|
||||||
|
// value is "valid" (any mask or index could be a real pick), so garbage
|
||||||
|
// here isn't caught by a range clamp -- it just silently masquerades as a
|
||||||
|
// real one, and can even reactivate later once the scope list grows long
|
||||||
|
// enough to reach an index that used to be out of range. Zero both
|
||||||
|
// outright on this one transition; a fresh scope list is empty anyway, so
|
||||||
|
// there's nothing genuine to lose.
|
||||||
|
if (sentinel < 0xC0DE002B) {
|
||||||
|
_prefs.repeat_extra_scope_mask = 0;
|
||||||
|
memset(_prefs.ch_scope_idx, 0, sizeof(_prefs.ch_scope_idx));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
file.close();
|
file.close();
|
||||||
@@ -838,6 +860,8 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
|
|||||||
file.write((uint8_t *)_prefs.favourite_kinds, sizeof(_prefs.favourite_kinds));
|
file.write((uint8_t *)_prefs.favourite_kinds, sizeof(_prefs.favourite_kinds));
|
||||||
file.write((uint8_t *)&_prefs.fav_sort_off, sizeof(_prefs.fav_sort_off));
|
file.write((uint8_t *)&_prefs.fav_sort_off, sizeof(_prefs.fav_sort_off));
|
||||||
file.write((uint8_t *)&_prefs.msg_wake_screen_off, sizeof(_prefs.msg_wake_screen_off));
|
file.write((uint8_t *)&_prefs.msg_wake_screen_off, sizeof(_prefs.msg_wake_screen_off));
|
||||||
|
file.write((uint8_t *)&_prefs.repeat_extra_scope_mask, sizeof(_prefs.repeat_extra_scope_mask));
|
||||||
|
file.write((uint8_t *)_prefs.ch_scope_idx, sizeof(_prefs.ch_scope_idx));
|
||||||
|
|
||||||
// Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL. Its write is
|
// 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
|
// the one we check: once the flash fills, writes return 0, so a good
|
||||||
@@ -1083,6 +1107,66 @@ void DataStore::saveChannels(DataStoreHost* host) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool DataStore::loadScopeList(ScopeList& list, const NodePrefs& prefs) {
|
||||||
|
File file = openRead("/scopes1");
|
||||||
|
if (file) {
|
||||||
|
uint8_t hdr[2];
|
||||||
|
bool success = (file.read(hdr, 2) == 2);
|
||||||
|
uint8_t count = success ? hdr[1] : 0;
|
||||||
|
if (count > ScopeList::MAX_SCOPE_ENTRIES) count = 0; // corrupt header -- start empty rather than overrun entries[]
|
||||||
|
|
||||||
|
uint8_t loaded = 0;
|
||||||
|
for (uint8_t i = 0; i < count; i++) {
|
||||||
|
ScopeEntry e;
|
||||||
|
bool ok = (file.read((uint8_t *)e.name, sizeof(e.name)) == sizeof(e.name));
|
||||||
|
ok = ok && (file.read(e.key, sizeof(e.key)) == sizeof(e.key));
|
||||||
|
if (!ok) break; // truncated file -- keep whatever loaded fine so far
|
||||||
|
e.name[sizeof(e.name) - 1] = '\0';
|
||||||
|
list.entries[loaded++] = e;
|
||||||
|
}
|
||||||
|
file.close();
|
||||||
|
list.count = loaded;
|
||||||
|
list.default_idx = list.clamp(hdr[0]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No /scopes1 yet -- one-time migration of an existing single
|
||||||
|
// default_scope_name/key (Settings > Radio > Scope, pre-list) into list
|
||||||
|
// entry 1, so an already-configured device keeps sending under the same
|
||||||
|
// scope after upgrading. A never-configured device just stays at the
|
||||||
|
// default-constructed ScopeList (empty, default_idx 0 == "*").
|
||||||
|
list.count = 0;
|
||||||
|
list.default_idx = 0;
|
||||||
|
if (prefs.default_scope_name[0] != '\0') {
|
||||||
|
ScopeEntry& e = list.entries[0];
|
||||||
|
StrHelper::strncpy(e.name, prefs.default_scope_name, sizeof(e.name));
|
||||||
|
memcpy(e.key, prefs.default_scope_key, sizeof(e.key)); // already-derived key, no need to re-derive
|
||||||
|
list.count = 1;
|
||||||
|
list.default_idx = 1;
|
||||||
|
}
|
||||||
|
saveScopeList(list); // write /scopes1 so this migration runs only once
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void DataStore::saveScopeList(const ScopeList& list) {
|
||||||
|
File file = ::openWrite(_fs, "/scopes1.tmp");
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
uint8_t hdr[2] = { list.default_idx, list.count };
|
||||||
|
bool ok = (file.write(hdr, 2) == 2);
|
||||||
|
for (uint8_t i = 0; ok && i < list.count; i++) {
|
||||||
|
ok = (file.write((uint8_t *)list.entries[i].name, sizeof(list.entries[i].name)) == sizeof(list.entries[i].name));
|
||||||
|
ok = ok && (file.write(list.entries[i].key, sizeof(list.entries[i].key)) == sizeof(list.entries[i].key));
|
||||||
|
}
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
commitTempFile(_fs, "/scopes1.tmp", "/scopes1");
|
||||||
|
} else {
|
||||||
|
_fs->remove("/scopes1.tmp"); // keep the previous good /scopes1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||||
|
|
||||||
#define MAX_ADVERT_PKT_LEN (2 + 32 + PUB_KEY_SIZE + 4 + SIGNATURE_SIZE + MAX_ADVERT_DATA_SIZE)
|
#define MAX_ADVERT_PKT_LEN (2 + 32 + PUB_KEY_SIZE + 4 + SIGNATURE_SIZE + MAX_ADVERT_DATA_SIZE)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#include <helpers/ContactInfo.h>
|
#include <helpers/ContactInfo.h>
|
||||||
#include <helpers/ChannelDetails.h>
|
#include <helpers/ChannelDetails.h>
|
||||||
#include "NodePrefs.h"
|
#include "NodePrefs.h"
|
||||||
|
#include "ScopeList.h"
|
||||||
|
|
||||||
class DataStoreHost {
|
class DataStoreHost {
|
||||||
public:
|
public:
|
||||||
@@ -45,6 +46,15 @@ public:
|
|||||||
// deleted slot is simply absent from the file, not written as empty).
|
// deleted slot is simply absent from the file, not written as empty).
|
||||||
bool loadChannels(DataStoreHost* host);
|
bool loadChannels(DataStoreHost* host);
|
||||||
void saveChannels(DataStoreHost* host);
|
void saveChannels(DataStoreHost* host);
|
||||||
|
// /scopes1: the shared named-scope list (see ScopeList.h). `prefs` is only
|
||||||
|
// read, for a one-time migration of a pre-existing single
|
||||||
|
// default_scope_name/key into list entry 1 -- the file is authoritative
|
||||||
|
// once it exists. Returns true if the file existed or the migration ran
|
||||||
|
// (i.e. `list` reflects real prior configuration); false only means a
|
||||||
|
// genuinely fresh, unconfigured device (list left at its default: empty,
|
||||||
|
// default_idx 0 == "*").
|
||||||
|
bool loadScopeList(ScopeList& list, const NodePrefs& prefs);
|
||||||
|
void saveScopeList(const ScopeList& list);
|
||||||
void migrateToSecondaryFS();
|
void migrateToSecondaryFS();
|
||||||
uint8_t getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]);
|
uint8_t getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]);
|
||||||
bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], uint8_t len);
|
bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], uint8_t len);
|
||||||
|
|||||||
@@ -677,6 +677,9 @@ bool MyMesh::allowPacketForward(const mesh::Packet* packet) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void MyMesh::setPrimaryScope(const char* name) {
|
void MyMesh::setPrimaryScope(const char* name) {
|
||||||
|
// Keep the legacy fields in sync too -- inert for rebuildRepeatScopes()
|
||||||
|
// once /scopes1 exists, but still what a pre-list save file round-trips,
|
||||||
|
// and cheap to maintain.
|
||||||
strncpy(_prefs.default_scope_name, name, sizeof(_prefs.default_scope_name) - 1);
|
strncpy(_prefs.default_scope_name, name, sizeof(_prefs.default_scope_name) - 1);
|
||||||
_prefs.default_scope_name[sizeof(_prefs.default_scope_name) - 1] = '\0';
|
_prefs.default_scope_name[sizeof(_prefs.default_scope_name) - 1] = '\0';
|
||||||
if (_prefs.default_scope_name[0] == '\0') {
|
if (_prefs.default_scope_name[0] == '\0') {
|
||||||
@@ -689,37 +692,85 @@ void MyMesh::setPrimaryScope(const char* name) {
|
|||||||
temp.getAutoKeyFor(0, hashtag, key);
|
temp.getAutoKeyFor(0, hashtag, key);
|
||||||
memcpy(_prefs.default_scope_key, key.key, sizeof(key.key));
|
memcpy(_prefs.default_scope_key, key.key, sizeof(key.key));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_prefs.default_scope_name[0] == '\0') {
|
||||||
|
_scope_list.default_idx = 0; // "*"
|
||||||
|
} else {
|
||||||
|
// Find-or-create a matching named entry (e.g. the app driving this
|
||||||
|
// remotely via CMD_SET_DEFAULT_FLOOD_SCOPE), then mark it default.
|
||||||
|
uint8_t idx = 0;
|
||||||
|
for (uint8_t i = 0; i < _scope_list.count; i++) {
|
||||||
|
if (strcmp(_scope_list.entries[i].name, _prefs.default_scope_name) == 0) { idx = i + 1; break; }
|
||||||
|
}
|
||||||
|
if (idx == 0) idx = _scope_list.add(_prefs.default_scope_name);
|
||||||
|
_scope_list.default_idx = idx; // still 0 ("*") if the list was full
|
||||||
|
}
|
||||||
|
if (_store) _store->saveScopeList(_scope_list);
|
||||||
rebuildRepeatScopes();
|
rebuildRepeatScopes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uint8_t MyMesh::addScope(const char* name) {
|
||||||
|
uint8_t idx = _scope_list.add(name);
|
||||||
|
if (idx && _store) _store->saveScopeList(_scope_list);
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MyMesh::renameScope(uint8_t idx, const char* name) {
|
||||||
|
if (idx < 1 || idx > _scope_list.count || !name || !name[0]) return;
|
||||||
|
ScopeEntry& e = _scope_list.entries[idx - 1];
|
||||||
|
StrHelper::strncpy(e.name, name, sizeof(e.name));
|
||||||
|
ScopeList::deriveKey(e.name, e.key);
|
||||||
|
if (_store) _store->saveScopeList(_scope_list);
|
||||||
|
rebuildRepeatScopes(); // this entry's key may be repeat_scopes[]'s default or an extra slot
|
||||||
|
}
|
||||||
|
|
||||||
|
void MyMesh::removeScope(uint8_t idx) {
|
||||||
|
if (idx < 1 || idx > _scope_list.count) return;
|
||||||
|
_scope_list.remove(idx);
|
||||||
|
// repeat_extra_scope_mask bit (i) tracks list index (i+1) -- shift down the
|
||||||
|
// same way ScopeList::remove() shifted entries[], dropping the removed bit.
|
||||||
|
uint16_t old_mask = _prefs.repeat_extra_scope_mask, new_mask = 0;
|
||||||
|
for (uint8_t i = 0; i < ScopeList::MAX_SCOPE_ENTRIES; i++) { // one bit per possible list entry, not per MAX_REPEAT_SCOPES active slot
|
||||||
|
if (!(old_mask & (1u << i))) continue;
|
||||||
|
uint8_t list_idx = i + 1;
|
||||||
|
if (list_idx < idx) new_mask |= (1u << i);
|
||||||
|
else if (list_idx > idx) new_mask |= (1u << (i - 1));
|
||||||
|
// list_idx == idx: the removed one, dropped
|
||||||
|
}
|
||||||
|
_prefs.repeat_extra_scope_mask = new_mask;
|
||||||
|
// Any channel pointed at the removed entry (or shifted ones) needs the same
|
||||||
|
// fix-up ScopeList::remove() applied to default_idx.
|
||||||
|
for (uint8_t i = 0; i < NodePrefs::MAX_SCOPED_CHANNELS; i++) {
|
||||||
|
uint8_t ci = _prefs.ch_scope_idx[i];
|
||||||
|
if (ci == idx) _prefs.ch_scope_idx[i] = 0;
|
||||||
|
else if (ci > idx) _prefs.ch_scope_idx[i] = ci - 1;
|
||||||
|
}
|
||||||
|
if (_store) _store->saveScopeList(_scope_list);
|
||||||
|
rebuildRepeatScopes();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MyMesh::setDefaultScope(uint8_t idx) {
|
||||||
|
_scope_list.default_idx = _scope_list.clamp(idx);
|
||||||
|
if (_store) _store->saveScopeList(_scope_list);
|
||||||
|
rebuildRepeatScopes();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MyMesh::setChannelScope(uint8_t channel_idx, uint8_t idx) {
|
||||||
|
if (channel_idx >= NodePrefs::MAX_SCOPED_CHANNELS) return;
|
||||||
|
_prefs.ch_scope_idx[channel_idx] = _scope_list.clamp(idx);
|
||||||
|
}
|
||||||
|
|
||||||
void MyMesh::rebuildRepeatScopes() {
|
void MyMesh::rebuildRepeatScopes() {
|
||||||
repeat_scope_count = 0;
|
repeat_scope_count = 0;
|
||||||
|
|
||||||
TransportKey primary;
|
TransportKey primary = _scope_list.key(_scope_list.default_idx);
|
||||||
memcpy(primary.key, _prefs.default_scope_key, sizeof(primary.key));
|
|
||||||
if (!primary.isNull()) repeat_scopes[repeat_scope_count++] = primary;
|
if (!primary.isNull()) repeat_scopes[repeat_scope_count++] = primary;
|
||||||
|
|
||||||
TransportKeyStore temp;
|
for (uint8_t i = 0; i < _scope_list.count && repeat_scope_count < MAX_REPEAT_SCOPES; i++) {
|
||||||
char names[sizeof(_prefs.repeat_extra_scopes)];
|
if (!(_prefs.repeat_extra_scope_mask & (1u << i))) continue;
|
||||||
strncpy(names, _prefs.repeat_extra_scopes, sizeof(names));
|
uint8_t list_idx = i + 1;
|
||||||
names[sizeof(names) - 1] = '\0';
|
TransportKey k = _scope_list.key(list_idx);
|
||||||
|
if (!k.isNull()) repeat_scopes[repeat_scope_count++] = k;
|
||||||
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);
|
|
||||||
// Distinct id per scope: getAutoKeyFor() keys its cache on the id ALONE
|
|
||||||
// and ignores the name on a hit, so reusing one id here would hand every
|
|
||||||
// scope after the first the first one's key.
|
|
||||||
temp.getAutoKeyFor(repeat_scope_count, hashtag, repeat_scopes[repeat_scope_count]);
|
|
||||||
repeat_scope_count++;
|
|
||||||
}
|
|
||||||
tok = strtok(NULL, ",");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -739,25 +790,30 @@ void MyMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, ui
|
|||||||
if (send_unscoped) {
|
if (send_unscoped) {
|
||||||
sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1); // app has explicitly requested un-scoped
|
sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1); // app has explicitly requested un-scoped
|
||||||
} else {
|
} else {
|
||||||
TransportKey default_scope;
|
TransportKey default_scope = _scope_list.key(_scope_list.default_idx);
|
||||||
memcpy(&default_scope.key, _prefs.default_scope_key, sizeof(default_scope.key));
|
|
||||||
|
|
||||||
auto scope = send_scope.isNull() ? &default_scope : &send_scope;
|
auto scope = send_scope.isNull() ? &default_scope : &send_scope;
|
||||||
sendFloodScoped(*scope, pkt, delay_millis);
|
sendFloodScoped(*scope, pkt, delay_millis);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis) {
|
void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis) {
|
||||||
// TODO: have per-channel send_scope
|
|
||||||
if (apcActive()) apcTrackFloodSend(pkt); // listen for a repeater echo to drive APC (channels have no ACK)
|
if (apcActive()) apcTrackFloodSend(pkt); // listen for a repeater echo to drive APC (channels have no ACK)
|
||||||
trackRelaySend(pkt); // and for the UI "relayed" marker
|
trackRelaySend(pkt); // and for the UI "relayed" marker
|
||||||
if (send_unscoped) {
|
if (send_unscoped) {
|
||||||
sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1); // app has explicitly requested un-scoped
|
sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1); // app has explicitly requested un-scoped
|
||||||
|
} else if (!send_scope.isNull()) {
|
||||||
|
// App-driven per-send override (CMD_SET_FLOOD_SCOPE_KEY) still wins over
|
||||||
|
// this channel's own on-device pick, same precedence DMs already have.
|
||||||
|
sendFloodScoped(send_scope, pkt, delay_millis);
|
||||||
} else {
|
} else {
|
||||||
TransportKey default_scope;
|
// Resolve THIS channel's own scope-list pick (Messages > channel context
|
||||||
memcpy(&default_scope.key, _prefs.default_scope_key, sizeof(default_scope.key));
|
// menu > Scope:), falling back to the list's default if this channel has
|
||||||
|
// none of its own or can't be identified (e.g. a bot/room send path that
|
||||||
auto scope = send_scope.isNull() ? &default_scope : &send_scope;
|
// doesn't go through a slot in channels[]).
|
||||||
sendFloodScoped(*scope, pkt, delay_millis);
|
int channel_idx = findChannelIdx(channel);
|
||||||
|
uint8_t list_idx = (channel_idx >= 0 && channel_idx < NodePrefs::MAX_SCOPED_CHANNELS)
|
||||||
|
? _prefs.ch_scope_idx[channel_idx] : _scope_list.default_idx;
|
||||||
|
TransportKey scope = _scope_list.key(list_idx);
|
||||||
|
sendFloodScoped(scope, pkt, delay_millis);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1763,6 +1819,7 @@ void MyMesh::begin(bool has_display) {
|
|||||||
|
|
||||||
// load persisted prefs
|
// load persisted prefs
|
||||||
_store->loadPrefs(_prefs, sensors.node_lat, sensors.node_lon);
|
_store->loadPrefs(_prefs, sensors.node_lat, sensors.node_lon);
|
||||||
|
_store->loadScopeList(_scope_list, _prefs);
|
||||||
rebuildRepeatScopes();
|
rebuildRepeatScopes();
|
||||||
|
|
||||||
// sanitise bad pref values. NaN/inf must be reset BEFORE constrain(): constrain
|
// sanitise bad pref values. NaN/inf must be reset BEFORE constrain(): constrain
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class UITask;
|
|||||||
|
|
||||||
#include "DataStore.h"
|
#include "DataStore.h"
|
||||||
#include "NodePrefs.h"
|
#include "NodePrefs.h"
|
||||||
|
#include "ScopeList.h"
|
||||||
|
|
||||||
#include <RTClib.h>
|
#include <RTClib.h>
|
||||||
#include <helpers/ArduinoHelpers.h>
|
#include <helpers/ArduinoHelpers.h>
|
||||||
@@ -350,19 +351,45 @@ public:
|
|||||||
// the repeater toggle / network / profile changes.
|
// the repeater toggle / network / profile changes.
|
||||||
void applyRepeaterRadio();
|
void applyRepeaterRadio();
|
||||||
|
|
||||||
// Sets this device's own scope (Settings > Radio > Scope) from a single
|
// Sets this device's own default scope (Settings > Radio > Scope, and the
|
||||||
// typed region name, deriving default_scope_key the same "#name" -> SHA256
|
// app's CMD_SET_DEFAULT_FLOOD_SCOPE) from a single typed region name.
|
||||||
// way as DEFAULT_FLOOD_SCOPE_NAME (see begin()). Empty name clears the
|
// Keeps default_scope_name/default_scope_key in sync (legacy fields, inert
|
||||||
// scope. Governs what scope the companion's own messages send under, and
|
// once /scopes1 exists -- see ScopeList.h) *and* finds-or-creates a
|
||||||
// (as scope[0]) what repeat_scope_only accepts. Calls rebuildRepeatScopes().
|
// matching entry in the shared scope list, marking it default -- so a
|
||||||
|
// scope set remotely by the app shows up as a real, named entry on the
|
||||||
|
// device's own list too, not just in the two legacy fields. Empty name
|
||||||
|
// clears the default back to list index 0 ("*"). Calls
|
||||||
|
// rebuildRepeatScopes() and persists the list.
|
||||||
void setPrimaryScope(const char* name);
|
void setPrimaryScope(const char* name);
|
||||||
|
|
||||||
// Rebuilds repeat_scopes[]/repeat_scope_count from default_scope_key (slot 0,
|
// Rebuilds repeat_scopes[]/repeat_scope_count from the scope list's current
|
||||||
// if configured) plus the comma-separated repeat_extra_scopes (Tools >
|
// default entry (slot 0) plus repeat_extra_scope_mask (Tools > Repeater >
|
||||||
// Repeater > Extra scopes). Call after loading prefs at boot and whenever
|
// Extra scopes -- a toggle over the same list). Call after loading prefs +
|
||||||
// either scope setting is edited.
|
// the scope list at boot and whenever either scope setting is edited.
|
||||||
void rebuildRepeatScopes();
|
void rebuildRepeatScopes();
|
||||||
|
|
||||||
|
// The shared named-scope list (Settings > Radio > Scope). Read-only outside
|
||||||
|
// MyMesh -- edits go through setPrimaryScope()/addScope()/setChannelScope()/
|
||||||
|
// setDefaultScope() so repeat_scopes[]/persistence stay in sync.
|
||||||
|
const ScopeList& scopeList() const { return _scope_list; }
|
||||||
|
// Adds a new named entry (see ScopeList::add()), persists the list, and
|
||||||
|
// returns its list index (0 if the name's empty or the list's full).
|
||||||
|
uint8_t addScope(const char* name);
|
||||||
|
// Renames list index idx (1..count; a no-op for 0/"*" or an empty name),
|
||||||
|
// re-deriving its key (a scope's key is purely a hash of its name) and
|
||||||
|
// persisting the list.
|
||||||
|
void renameScope(uint8_t idx, const char* name);
|
||||||
|
// Removes list index idx (1..count; a no-op for 0/"*"), persists the list,
|
||||||
|
// fixes up repeat_extra_scope_mask, and clears any channel's ch_scope_idx
|
||||||
|
// that pointed at it (shift-and-clamp, matching ScopeList::remove()).
|
||||||
|
void removeScope(uint8_t idx);
|
||||||
|
// Marks list index idx as the default (used for DMs and any channel/
|
||||||
|
// repeater slot without its own pick). Persists the list and rebuilds
|
||||||
|
// repeat_scopes[].
|
||||||
|
void setDefaultScope(uint8_t idx);
|
||||||
|
// Sets channel_idx's own scope-list pick (0 = "*"). Persists prefs.
|
||||||
|
void setChannelScope(uint8_t channel_idx, uint8_t idx);
|
||||||
|
|
||||||
bool isAckPending(uint32_t expected_ack) const {
|
bool isAckPending(uint32_t expected_ack) const {
|
||||||
if (expected_ack == 0) return false; // 0 marks an empty/cleared slot, not a real ACK
|
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++)
|
for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; i++)
|
||||||
@@ -560,10 +587,17 @@ private:
|
|||||||
|
|
||||||
TransportKey send_scope;
|
TransportKey send_scope;
|
||||||
|
|
||||||
|
// The shared named-scope list backing Settings > Radio > Scope, the
|
||||||
|
// channel context menu's Scope: row, and Tools > Repeater > Extra scopes.
|
||||||
|
// Loaded once at boot (see begin()) via DataStore::loadScopeList(), kept in
|
||||||
|
// sync with /scopes1 by every mutator above.
|
||||||
|
ScopeList _scope_list;
|
||||||
|
|
||||||
// Runtime-only (not persisted) cache of scopes accepted by repeat_scope_only:
|
// 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
|
// slot 0 is the scope list's current default entry (if not "*"), the rest
|
||||||
// comma-separated repeat_extra_scopes. Rebuilt by rebuildRepeatScopes() (see
|
// are derived from repeat_extra_scope_mask's set bits. Rebuilt by
|
||||||
// the public section below) whenever either scope setting changes.
|
// rebuildRepeatScopes() (see the public section below) whenever either
|
||||||
|
// scope setting changes.
|
||||||
static const uint8_t MAX_REPEAT_SCOPES = 4;
|
static const uint8_t MAX_REPEAT_SCOPES = 4;
|
||||||
TransportKey repeat_scopes[MAX_REPEAT_SCOPES];
|
TransportKey repeat_scopes[MAX_REPEAT_SCOPES];
|
||||||
uint8_t repeat_scope_count;
|
uint8_t repeat_scope_count;
|
||||||
|
|||||||
@@ -149,7 +149,17 @@ struct NodePrefs { // persisted to file
|
|||||||
// Settings > Radio > Scope (comma-separated, e.g. "eu,de") — see
|
// Settings > Radio > Scope (comma-separated, e.g. "eu,de") — see
|
||||||
// MyMesh::rebuildRepeatScopes(). Relay-only: never affects what scope the
|
// MyMesh::rebuildRepeatScopes(). Relay-only: never affects what scope the
|
||||||
// companion's own messages send under, only what repeat_scope_only accepts.
|
// companion's own messages send under, only what repeat_scope_only accepts.
|
||||||
|
// Superseded by repeat_extra_scope_mask below (a scope-list toggle,
|
||||||
|
// replacing free-typed names); left allocated/unused rather than removed
|
||||||
|
// so the on-disk layout of every field after it stays put.
|
||||||
char repeat_extra_scopes[24];
|
char repeat_extra_scopes[24];
|
||||||
|
// On-disk position is at the struct's append-only tail (0xC0DE002B), even
|
||||||
|
// though grouped here with the rest of repeat_*. Bit i = scope-list index
|
||||||
|
// (i+1) is in this repeater's accept set (index 0, "*", isn't a real scope
|
||||||
|
// so isn't toggleable here) — see ScopeList/MyMesh::rebuildRepeatScopes().
|
||||||
|
// Same MAX_REPEAT_SCOPES=4 runtime cap as before, just resolved from the
|
||||||
|
// shared named-scope list instead of comma-tokenizing repeat_extra_scopes.
|
||||||
|
uint16_t repeat_extra_scope_mask;
|
||||||
// Optional dedicated radio profile for repeater mode. When repeater_use_profile
|
// 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
|
// 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
|
// disabling restores the companion's freq/bw/sf/cr (the fields above). 0 = the
|
||||||
@@ -329,6 +339,16 @@ struct NodePrefs { // persisted to file
|
|||||||
// Per-channel melody override (2 bitmasks, 1 bit per channel)
|
// Per-channel melody override (2 bitmasks, 1 bit per channel)
|
||||||
uint64_t ch_notif_melody_set; // bit i = channel i has explicit melody [del→onChannelRemoved]
|
uint64_t ch_notif_melody_set; // bit i = channel i has explicit melody [del→onChannelRemoved]
|
||||||
uint64_t ch_notif_melody_2; // bit i = use melody 2 (else melody 1, when set bit is set)
|
uint64_t ch_notif_melody_2; // bit i = use melody 2 (else melody 1, when set bit is set)
|
||||||
|
// On-disk position is at the struct's append-only tail (0xC0DE002B), even
|
||||||
|
// though grouped here with the other per-channel overrides. Scope-list
|
||||||
|
// index per channel (see ScopeList) -- 0 ("*"/unscoped) is the correct
|
||||||
|
// zero-init default, matching today's unconfigured behaviour exactly, so
|
||||||
|
// no migration is needed for this field itself. Fixed at 64 slots (not
|
||||||
|
// MAX_GROUP_CHANNELS, which varies by board/variant and would make
|
||||||
|
// sizeof(NodePrefs) variant-dependent) -- same implicit channel-count cap
|
||||||
|
// every ch_notif_*/ch_fav_bitmask uint64_t bitmask above already has.
|
||||||
|
static const uint8_t MAX_SCOPED_CHANNELS = 64;
|
||||||
|
uint8_t ch_scope_idx[MAX_SCOPED_CHANNELS]; // [del→onChannelRemoved]
|
||||||
struct DmNotifEntry { uint8_t prefix[4]; uint8_t state; }; // state: 0=default,1=muted,2=force-on
|
struct DmNotifEntry { uint8_t prefix[4]; uint8_t state; }; // state: 0=default,1=muted,2=force-on
|
||||||
static const int DM_NOTIF_TABLE_MAX = 16;
|
static const int DM_NOTIF_TABLE_MAX = 16;
|
||||||
DmNotifEntry dm_notif[DM_NOTIF_TABLE_MAX]; // 16*5 = 80 bytes [del→onContactRemoved]
|
DmNotifEntry dm_notif[DM_NOTIF_TABLE_MAX]; // 16*5 = 80 bytes [del→onContactRemoved]
|
||||||
@@ -568,7 +588,7 @@ struct NodePrefs { // persisted to file
|
|||||||
// repeat_* fields) instead of at the tail, which shifted every field after
|
// repeat_* fields) instead of at the tail, which shifted every field after
|
||||||
// them by 25 bytes when loading an older file. Never released, but a dev
|
// them by 25 bytes when loading an older file. Never released, but a dev
|
||||||
// build wrote it, so the number must not be reused for anything else.
|
// build wrote it, so the number must not be reused for anything else.
|
||||||
static const uint32_t SCHEMA_SENTINEL = 0xC0DE002A;
|
static const uint32_t SCHEMA_SENTINEL = 0xC0DE002B;
|
||||||
|
|
||||||
// Bit-index for each home page. Used by page_order (entries store bit+1) and
|
// 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
|
// by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage
|
||||||
@@ -708,7 +728,12 @@ struct NodePrefs { // persisted to file
|
|||||||
// msg_wake_screen_off (0xC0DE002A) landed in the 1 byte of padding the
|
// msg_wake_screen_off (0xC0DE002A) landed in the 1 byte of padding the
|
||||||
// 0xC0DE0029 bump left over -- confirmed via a real sim_companion_radio
|
// 0xC0DE0029 bump left over -- confirmed via a real sim_companion_radio
|
||||||
// (native) build, sizeof unchanged at 2760.
|
// (native) build, sizeof unchanged at 2760.
|
||||||
static_assert(sizeof(NodePrefs) == 2760,
|
// repeat_extra_scope_mask + ch_scope_idx[64] (0xC0DE002B) added 64 bytes,
|
||||||
|
// not 66 -- the struct had 2 bytes of spare tail padding left over from an
|
||||||
|
// earlier bump -- confirmed via a real sim_companion_radio (native) build
|
||||||
|
// and a real WioTrackerL1_companion_solo_dual (nRF52/ARM) build, sizeof
|
||||||
|
// 2824 on both.
|
||||||
|
static_assert(sizeof(NodePrefs) == 2824,
|
||||||
"NodePrefs layout changed — sync DataStore save/load + clamp, bump "
|
"NodePrefs layout changed — sync DataStore save/load + clamp, bump "
|
||||||
"SCHEMA_SENTINEL, then update this size (see steps above).");
|
"SCHEMA_SENTINEL, then update this size (see steps above).");
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <helpers/TransportKeyStore.h>
|
||||||
|
#include <helpers/TxtDataHelpers.h>
|
||||||
|
|
||||||
|
// Shared named-scope list: Settings > Radio > Scope used to be a single
|
||||||
|
// free-typed region name/key pair (NodePrefs.default_scope_name/
|
||||||
|
// default_scope_key). This replaces it with a small definable list, single-
|
||||||
|
// selected per channel (MessagesScreen's channel context menu) and multi-
|
||||||
|
// selected by the repeater's accept-filter (RepeaterScreen's Extra scopes,
|
||||||
|
// NodePrefs.repeat_extra_scope_mask) -- see MyMesh::rebuildRepeatScopes()
|
||||||
|
// and sendFloodScoped(const mesh::GroupChannel&, ...).
|
||||||
|
//
|
||||||
|
// List index 0 is the permanent, non-deletable, non-renamable "*" (wildcard/
|
||||||
|
// unscoped) -- it is never stored in entries[]/count below, only synthesised
|
||||||
|
// on read, so it can't be corrupted or migrated away. Real entries live at
|
||||||
|
// list index 1..count, i.e. entries[idx-1].
|
||||||
|
struct ScopeEntry {
|
||||||
|
char name[24];
|
||||||
|
uint8_t key[16];
|
||||||
|
};
|
||||||
|
|
||||||
|
class ScopeList {
|
||||||
|
public:
|
||||||
|
static const uint8_t MAX_SCOPE_ENTRIES = 8; // named entries; list indices 1..MAX_SCOPE_ENTRIES
|
||||||
|
|
||||||
|
uint8_t count = 0; // how many of entries[] are in use
|
||||||
|
uint8_t default_idx = 0; // list index (0 = "*") used for DMs and any
|
||||||
|
// channel/repeater slot without its own pick
|
||||||
|
ScopeEntry entries[MAX_SCOPE_ENTRIES];
|
||||||
|
|
||||||
|
// Number of selectable list entries, "*" included.
|
||||||
|
int totalCount() const { return count + 1; }
|
||||||
|
|
||||||
|
bool isWildcard(uint8_t idx) const { return idx == 0; }
|
||||||
|
|
||||||
|
// Clamp a possibly-stale index (e.g. read from an older/shorter list, or a
|
||||||
|
// channel's saved pick after entries were deleted) to a valid one.
|
||||||
|
uint8_t clamp(uint8_t idx) const { return (idx <= count) ? idx : 0; }
|
||||||
|
|
||||||
|
const char* name(uint8_t idx) const {
|
||||||
|
idx = clamp(idx);
|
||||||
|
return (idx == 0) ? "*" : entries[idx - 1].name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "*" (and any out-of-range index) resolves to a null TransportKey, which
|
||||||
|
// sendFloodScoped() already treats as "send unscoped" -- same behaviour a
|
||||||
|
// never-configured device has today.
|
||||||
|
TransportKey key(uint8_t idx) const {
|
||||||
|
TransportKey k;
|
||||||
|
idx = clamp(idx);
|
||||||
|
if (idx == 0) memset(k.key, 0, sizeof(k.key));
|
||||||
|
else memcpy(k.key, entries[idx - 1].key, sizeof(k.key));
|
||||||
|
return k;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derives a scope's key the same "#name" -> SHA256 way
|
||||||
|
// MyMesh::setPrimaryScope() already does -- reused via TransportKeyStore
|
||||||
|
// rather than re-implemented here.
|
||||||
|
static void deriveKey(const char* name, uint8_t key[16]) {
|
||||||
|
char hashtag[1 + 24];
|
||||||
|
snprintf(hashtag, sizeof(hashtag), "#%s", name);
|
||||||
|
TransportKeyStore temp;
|
||||||
|
TransportKey tk;
|
||||||
|
temp.getAutoKeyFor(0, hashtag, tk);
|
||||||
|
memcpy(key, tk.key, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adds a new named entry (deriving its key), returns its list index, or 0
|
||||||
|
// if the list is full or the name is empty.
|
||||||
|
uint8_t add(const char* name) {
|
||||||
|
if (!name || !name[0] || count >= MAX_SCOPE_ENTRIES) return 0;
|
||||||
|
ScopeEntry& e = entries[count];
|
||||||
|
StrHelper::strncpy(e.name, name, sizeof(e.name));
|
||||||
|
deriveKey(e.name, e.key);
|
||||||
|
count++;
|
||||||
|
return count; // list index of the new entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removes list index idx (1..count) -- index 0 ("*") can't be removed by
|
||||||
|
// callers since it's never a valid argument here. Shifts later entries
|
||||||
|
// down to close the gap and fixes up default_idx if it pointed at the
|
||||||
|
// removed entry or anything after it.
|
||||||
|
void remove(uint8_t idx) {
|
||||||
|
if (idx < 1 || idx > count) return;
|
||||||
|
for (uint8_t i = idx; i < count; i++) entries[i - 1] = entries[i];
|
||||||
|
count--;
|
||||||
|
if (default_idx == idx) default_idx = 0;
|
||||||
|
else if (default_idx > idx) default_idx--;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -68,6 +68,12 @@ class MessagesScreen : public UIScreen {
|
|||||||
uint8_t _ctx_ch_idx = 0;
|
uint8_t _ctx_ch_idx = 0;
|
||||||
char _ctx_notif_item[22];
|
char _ctx_notif_item[22];
|
||||||
char _ctx_melody_item[20];
|
char _ctx_melody_item[20];
|
||||||
|
char _ctx_scope_item[30]; // "Scope: <name>"
|
||||||
|
// Scope sub-picker: single-select from the shared named-scope list,
|
||||||
|
// reusing _ctx_menu itself as a popup -- same idiom as _pin_picker_active's
|
||||||
|
// "Pick slot" submenu below (row index == list index directly, no
|
||||||
|
// per-row value cycling needed since Enter just picks and closes).
|
||||||
|
bool _scope_pick_active = false;
|
||||||
char _ctx_pin_item[28]; // "Pin to dial" or "Unpin (slot N)"
|
char _ctx_pin_item[28]; // "Pin to dial" or "Unpin (slot N)"
|
||||||
char _ctx_fav_item[12]; // "Fav: ON" / "Fav: OFF" — shared by the channel,
|
char _ctx_fav_item[12]; // "Fav: ON" / "Fav: OFF" — shared by the channel,
|
||||||
// contact and room menus (never open at once)
|
// contact and room menus (never open at once)
|
||||||
@@ -1145,6 +1151,7 @@ public:
|
|||||||
_pick_bot_room = false;
|
_pick_bot_room = false;
|
||||||
_pin_picker_active = false;
|
_pin_picker_active = false;
|
||||||
_pin_slot_ch_idx = -1;
|
_pin_slot_ch_idx = -1;
|
||||||
|
_scope_pick_active = false;
|
||||||
_ch_delete_confirm_active = false;
|
_ch_delete_confirm_active = false;
|
||||||
_pick_fav_slot = -1;
|
_pick_fav_slot = -1;
|
||||||
_direct_entry = false;
|
_direct_entry = false;
|
||||||
@@ -1694,8 +1701,17 @@ public:
|
|||||||
|
|
||||||
ChannelDetails ch;
|
ChannelDetails ch;
|
||||||
the_mesh.getChannel(_sel_channel_idx, ch);
|
the_mesh.getChannel(_sel_channel_idx, ch);
|
||||||
char title[24];
|
char title[32];
|
||||||
snprintf(title, sizeof(title), "%.23s", ch.name);
|
NodePrefs* p_hdr = _task->getNodePrefs();
|
||||||
|
uint8_t hdr_sc_idx = (p_hdr && _sel_channel_idx < NodePrefs::MAX_SCOPED_CHANNELS) ? p_hdr->ch_scope_idx[_sel_channel_idx] : 0;
|
||||||
|
if (hdr_sc_idx != 0) {
|
||||||
|
// Non-wildcard scope set on this channel -- surface it in the title,
|
||||||
|
// same as the app's own per-channel scope tag, so it's obvious at a
|
||||||
|
// glance the channel isn't sending unscoped.
|
||||||
|
snprintf(title, sizeof(title), "%.16s [%.8s]", ch.name, the_mesh.scopeList().name(hdr_sc_idx));
|
||||||
|
} else {
|
||||||
|
snprintf(title, sizeof(title), "%.23s", ch.name);
|
||||||
|
}
|
||||||
display.drawCenteredHeader(title, true, _ctx_menu.active);
|
display.drawCenteredHeader(title, true, _ctx_menu.active);
|
||||||
|
|
||||||
int ch_hist_count = _history.histCountForChannel(_sel_channel_idx);
|
int ch_hist_count = _history.histCountForChannel(_sel_channel_idx);
|
||||||
@@ -2133,11 +2149,20 @@ public:
|
|||||||
if (_ctx_menu.active) {
|
if (_ctx_menu.active) {
|
||||||
// LEFT/RIGHT -- and Enter, via VALUE_NEXT below -- cycle Notif/Melody/Fav
|
// LEFT/RIGHT -- and Enter, via VALUE_NEXT below -- cycle Notif/Melody/Fav
|
||||||
// in place; the menu stays open and only Back closes it.
|
// in place; the menu stays open and only Back closes it.
|
||||||
if (!_pin_picker_active && !_ch_delete_confirm_active && (keyIsPrev(c) || keyIsNext(c))) {
|
if (!_pin_picker_active && !_ch_delete_confirm_active && !_scope_pick_active && (keyIsPrev(c) || keyIsNext(c))) {
|
||||||
cycleChannelCtxValue(_ctx_menu.selectedIndex(), keyIsNext(c) ? 1 : -1);
|
cycleChannelCtxValue(_ctx_menu.selectedIndex(), keyIsNext(c) ? 1 : -1);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
auto res = _ctx_menu.handleInput(c);
|
auto res = _ctx_menu.handleInput(c);
|
||||||
|
if (_scope_pick_active) {
|
||||||
|
// Scope sub-menu: row index == list index directly ("*" first).
|
||||||
|
if (res == PopupMenu::SELECTED) {
|
||||||
|
the_mesh.setChannelScope(_ctx_ch_idx, (uint8_t)_ctx_menu.selectedIndex());
|
||||||
|
the_mesh.savePrefs();
|
||||||
|
}
|
||||||
|
if (res != PopupMenu::NONE) _scope_pick_active = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (_pin_picker_active) {
|
if (_pin_picker_active) {
|
||||||
// Slot picker sub-menu: index 0..FAVOURITES_COUNT-1 maps directly to slot.
|
// Slot picker sub-menu: index 0..FAVOURITES_COUNT-1 maps directly to slot.
|
||||||
if (res == PopupMenu::SELECTED && _pin_slot_ch_idx >= 0) {
|
if (res == PopupMenu::SELECTED && _pin_slot_ch_idx >= 0) {
|
||||||
@@ -2177,7 +2202,16 @@ public:
|
|||||||
int cleared = (int)_history.chUnread(ch_idx);
|
int cleared = (int)_history.chUnread(ch_idx);
|
||||||
_history.setChUnread(ch_idx, 0);
|
_history.setChUnread(ch_idx, 0);
|
||||||
markReadAlert(cleared);
|
markReadAlert(cleared);
|
||||||
} else if (sel == 4) { // Pin / Unpin
|
} else if (sel == 4) { // Scope
|
||||||
|
NodePrefs* p2 = _task->getNodePrefs();
|
||||||
|
uint8_t cur = (p2 && ch_idx < NodePrefs::MAX_SCOPED_CHANNELS) ? p2->ch_scope_idx[ch_idx] : 0;
|
||||||
|
const ScopeList& sl = the_mesh.scopeList();
|
||||||
|
_ctx_menu.begin("Scope", 4);
|
||||||
|
for (uint8_t i = 0; i <= sl.count; i++) _ctx_menu.addItem(sl.name(i));
|
||||||
|
_ctx_menu.setSelected(cur);
|
||||||
|
_scope_pick_active = true;
|
||||||
|
return true; // list rebuild below would close the submenu
|
||||||
|
} else if (sel == 5) { // Pin / Unpin
|
||||||
int pinned_slot = _task->findFavouriteChannelSlot(ch_idx);
|
int pinned_slot = _task->findFavouriteChannelSlot(ch_idx);
|
||||||
if (pinned_slot >= 0) {
|
if (pinned_slot >= 0) {
|
||||||
_task->clearFavouriteSlot(pinned_slot);
|
_task->clearFavouriteSlot(pinned_slot);
|
||||||
@@ -2193,10 +2227,10 @@ public:
|
|||||||
_pin_picker_active = true;
|
_pin_picker_active = true;
|
||||||
return true; // list rebuild below would close the submenu
|
return true; // list rebuild below would close the submenu
|
||||||
}
|
}
|
||||||
} else if (sel == 5) { // Edit
|
} else if (sel == 6) { // Edit
|
||||||
ChannelDetails ch;
|
ChannelDetails ch;
|
||||||
if (the_mesh.getChannel(ch_idx, ch)) _ch_view.openEdit(ch_idx, ch.name);
|
if (the_mesh.getChannel(ch_idx, ch)) _ch_view.openEdit(ch_idx, ch.name);
|
||||||
} else if (sel == 6) { // Delete -- confirm first (destructive)
|
} else if (sel == 7) { // Delete -- confirm first (destructive)
|
||||||
_ctx_menu.beginConfirm("Delete channel?", "Delete");
|
_ctx_menu.beginConfirm("Delete channel?", "Delete");
|
||||||
_ch_delete_confirm_active = true;
|
_ch_delete_confirm_active = true;
|
||||||
return true; // list rebuild below would close the submenu
|
return true; // list rebuild below would close the submenu
|
||||||
@@ -2259,12 +2293,16 @@ public:
|
|||||||
{ int pinned_slot = _task->findFavouriteChannelSlot(ch_idx);
|
{ int pinned_slot = _task->findFavouriteChannelSlot(ch_idx);
|
||||||
if (pinned_slot >= 0) snprintf(_ctx_pin_item, sizeof(_ctx_pin_item), "Unpin (slot %d)", pinned_slot + 1);
|
if (pinned_slot >= 0) snprintf(_ctx_pin_item, sizeof(_ctx_pin_item), "Unpin (slot %d)", pinned_slot + 1);
|
||||||
else snprintf(_ctx_pin_item, sizeof(_ctx_pin_item), "Pin to dial"); }
|
else snprintf(_ctx_pin_item, sizeof(_ctx_pin_item), "Pin to dial"); }
|
||||||
|
{ NodePrefs* p2 = _task->getNodePrefs();
|
||||||
|
uint8_t sc_idx = (p2 && ch_idx < NodePrefs::MAX_SCOPED_CHANNELS) ? p2->ch_scope_idx[ch_idx] : 0;
|
||||||
|
snprintf(_ctx_scope_item, sizeof(_ctx_scope_item), "Scope: %s", the_mesh.scopeList().name(sc_idx)); }
|
||||||
_ctx_menu.begin("Channel options", 6);
|
_ctx_menu.begin("Channel options", 6);
|
||||||
_ctx_menu.addItem("Mark all read");
|
_ctx_menu.addItem("Mark all read");
|
||||||
_ctx_menu.addValueItem(_ctx_notif_item);
|
_ctx_menu.addValueItem(_ctx_notif_item);
|
||||||
_ctx_menu.addValueItem(_ctx_melody_item);
|
_ctx_menu.addValueItem(_ctx_melody_item);
|
||||||
_ctx_fav_idx = 3;
|
_ctx_fav_idx = 3;
|
||||||
_ctx_menu.addValueItem(_ctx_fav_item);
|
_ctx_menu.addValueItem(_ctx_fav_item);
|
||||||
|
_ctx_menu.addItem(_ctx_scope_item);
|
||||||
_ctx_menu.addItem(_ctx_pin_item);
|
_ctx_menu.addItem(_ctx_pin_item);
|
||||||
_ctx_menu.addItem("Edit");
|
_ctx_menu.addItem("Edit");
|
||||||
_ctx_menu.addItem("Delete");
|
_ctx_menu.addItem("Delete");
|
||||||
|
|||||||
@@ -20,13 +20,15 @@ struct PopupMenu {
|
|||||||
// than an action to run, so Enter advances the value and leaves the menu open
|
// than an action to run, so Enter advances the value and leaves the menu open
|
||||||
// (see handleInput). One bit per row; PM_MAX_ITEMS fits in a uint32_t.
|
// (see handleInput). One bit per row; PM_MAX_ITEMS fits in a uint32_t.
|
||||||
uint32_t _value_mask;
|
uint32_t _value_mask;
|
||||||
|
bool _has_checkboxes; // true once addCheckItem() has been used this begin()
|
||||||
|
uint32_t _checked_mask; // per-row checkbox state (addCheckItem/setChecked)
|
||||||
|
|
||||||
// VALUE_NEXT: Enter landed on a value row -- caller advances that row's value
|
// VALUE_NEXT: Enter landed on a value row -- caller advances that row's value
|
||||||
// (same as its RIGHT step) and the menu stays open.
|
// (same as its RIGHT step) and the menu stays open.
|
||||||
enum Result { NONE, SELECTED, CANCELLED, VALUE_NEXT };
|
enum Result { NONE, SELECTED, CANCELLED, VALUE_NEXT };
|
||||||
|
|
||||||
PopupMenu() : _count(0), _sel(0), _scroll(0), _cap(3), active(false), _title(nullptr),
|
PopupMenu() : _count(0), _sel(0), _scroll(0), _cap(3), active(false), _title(nullptr),
|
||||||
_value_mask(0) {}
|
_value_mask(0), _has_checkboxes(false), _checked_mask(0) {}
|
||||||
|
|
||||||
// `visible` is only a seed for the first frame: render() recomputes _cap from
|
// `visible` is only a seed for the first frame: render() recomputes _cap from
|
||||||
// the live display height, so it does not cap or pad the item list.
|
// the live display height, so it does not cap or pad the item list.
|
||||||
@@ -34,6 +36,7 @@ struct PopupMenu {
|
|||||||
_count = 0; _sel = 0; _scroll = 0;
|
_count = 0; _sel = 0; _scroll = 0;
|
||||||
_cap = visible; active = true; _title = title;
|
_cap = visible; active = true; _title = title;
|
||||||
_value_mask = 0;
|
_value_mask = 0;
|
||||||
|
_has_checkboxes = false; _checked_mask = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
void addItem(const char* item) {
|
void addItem(const char* item) {
|
||||||
@@ -49,6 +52,29 @@ struct PopupMenu {
|
|||||||
if (_count > i) _value_mask |= (1u << i);
|
if (_count > i) _value_mask |= (1u << i);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A checklist row: a value row (Enter toggles it and stays open, same
|
||||||
|
// VALUE_NEXT contract as addValueItem()) that also draws a fillable-square
|
||||||
|
// checkbox to the right of its label instead of the caller baking "[x]"/
|
||||||
|
// "[ ]" into the text itself -- see icons.h's drawCheckbox(), the same
|
||||||
|
// glyph SettingsScreen's volume/brightness bars use. Once any row uses
|
||||||
|
// this, every row in the menu reserves the checkbox gutter (plain/
|
||||||
|
// addValueItem rows just render without a box in it) -- mixing styles
|
||||||
|
// isn't a use case this menu has today.
|
||||||
|
void addCheckItem(const char* item, bool checked) {
|
||||||
|
int i = _count;
|
||||||
|
addValueItem(item);
|
||||||
|
if (_count > i) {
|
||||||
|
_has_checkboxes = true;
|
||||||
|
setChecked(i, checked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void setChecked(int i, bool on) {
|
||||||
|
if (i < 0 || i >= _count) return;
|
||||||
|
if (on) _checked_mask |= (1u << i);
|
||||||
|
else _checked_mask &= ~(1u << i);
|
||||||
|
}
|
||||||
|
bool isChecked(int i) const { return (_checked_mask >> i) & 1; }
|
||||||
|
|
||||||
// A two-row Action/Cancel confirm for a destructive or hard-to-reverse
|
// A two-row Action/Cancel confirm for a destructive or hard-to-reverse
|
||||||
// action, defaulting the highlight to Cancel (row 1) so accepting it takes
|
// action, defaulting the highlight to Cancel (row 1) so accepting it takes
|
||||||
// a deliberate move up. Same shape every such confirm in the UI uses --
|
// a deliberate move up. Same shape every such confirm in the UI uses --
|
||||||
@@ -107,7 +133,8 @@ struct PopupMenu {
|
|||||||
int w = display.getTextWidth(_items[i]);
|
int w = display.getTextWidth(_items[i]);
|
||||||
if (w > content_w) content_w = w;
|
if (w > content_w) content_w = w;
|
||||||
}
|
}
|
||||||
int bw = content_w + pad * 2 + arrow_w;
|
int box_w = _has_checkboxes ? (checkboxWidth(display) + pad) : 0;
|
||||||
|
int bw = content_w + pad * 2 + arrow_w + box_w;
|
||||||
int max_bw = display.width() - margin * 2;
|
int max_bw = display.width() - margin * 2;
|
||||||
if (bw > max_bw) bw = max_bw;
|
if (bw > max_bw) bw = max_bw;
|
||||||
int min_bw = cw * 6 + pad * 2;
|
int min_bw = cw * 6 + pad * 2;
|
||||||
@@ -130,7 +157,7 @@ struct PopupMenu {
|
|||||||
display.fillRect(bx, by + lh + 2, bw, sh); // separator just under the title; gap follows
|
display.fillRect(bx, by + lh + 2, bw, sh); // separator just under the title; gap follows
|
||||||
|
|
||||||
int list_y = by + title_h;
|
int list_y = by + title_h;
|
||||||
int text_w = bw - pad * 2 - arrow_w;
|
int text_w = bw - pad * 2 - arrow_w - box_w;
|
||||||
if (text_w < cw) text_w = cw;
|
if (text_w < cw) text_w = cw;
|
||||||
for (int i = 0; i < vis && (_scroll + i) < _count; i++) {
|
for (int i = 0; i < vis && (_scroll + i) < _count; i++) {
|
||||||
int idx = _scroll + i;
|
int idx = _scroll + i;
|
||||||
@@ -148,6 +175,7 @@ struct PopupMenu {
|
|||||||
// Return value not needed here: this popup already redraws every 50ms
|
// Return value not needed here: this popup already redraws every 50ms
|
||||||
// (below), faster than any marquee step, so the animation is already smooth.
|
// (below), faster than any marquee step, so the animation is already smooth.
|
||||||
display.drawTextEllipsized(bx + pad, py, text_w, _items[idx], idx == _sel);
|
display.drawTextEllipsized(bx + pad, py, text_w, _items[idx], idx == _sel);
|
||||||
|
if (_has_checkboxes) drawCheckbox(display, bx + bw - arrow_w - pad - checkboxWidth(display), py, isChecked(idx));
|
||||||
display.setColor(DisplayDriver::LIGHT);
|
display.setColor(DisplayDriver::LIGHT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
#include "RadioPresetPicker.h"
|
#include "RadioPresetPicker.h"
|
||||||
#include "../RadioPresets.h"
|
#include "../RadioPresets.h"
|
||||||
#include "../MyMesh.h"
|
#include "../MyMesh.h"
|
||||||
|
#include "PopupMenu.h"
|
||||||
|
|
||||||
extern MyMesh the_mesh;
|
extern MyMesh the_mesh;
|
||||||
|
|
||||||
@@ -38,7 +39,8 @@ class RepeaterScreen : public UIScreen {
|
|||||||
IT_SKIP, IT_HOPS, IT_YIELD, IT_SNR, IT_SUPPRESS, IT_SCOPE, IT_SCOPE_EXTRA
|
IT_SKIP, IT_HOPS, IT_YIELD, IT_SNR, IT_SUPPRESS, IT_SCOPE, IT_SCOPE_EXTRA
|
||||||
};
|
};
|
||||||
uint8_t _items[14];
|
uint8_t _items[14];
|
||||||
bool _editing_scope; // keyboard is entering/editing the extra scopes
|
bool _scope_picker_active = false; // multi-select popup over the extra scopes
|
||||||
|
PopupMenu _scope_menu;
|
||||||
int _item_count;
|
int _item_count;
|
||||||
|
|
||||||
RadioPresetPicker _picker;
|
RadioPresetPicker _picker;
|
||||||
@@ -127,9 +129,20 @@ class RepeaterScreen : public UIScreen {
|
|||||||
break;
|
break;
|
||||||
case IT_SUPPRESS: strncpy(buf, p->repeat_suppress_dup ? "ON" : "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: strncpy(buf, p->repeat_scope_only ? "ON" : "OFF", n); break;
|
||||||
case IT_SCOPE_EXTRA:
|
case IT_SCOPE_EXTRA: {
|
||||||
strncpy(buf, p->repeat_extra_scopes[0] ? p->repeat_extra_scopes : "(none)", n);
|
// Bound the scan to the list's real length, not the mask's full bit
|
||||||
|
// width -- a stray high bit (e.g. leftover sentinel bytes from a
|
||||||
|
// pre-scope-list save file) must never be counted as "picked", or a
|
||||||
|
// device that's never touched this picker can show a bogus non-zero
|
||||||
|
// count. rebuildRepeatScopes() already ignores such bits the same way.
|
||||||
|
uint8_t total = the_mesh.scopeList().count;
|
||||||
|
int picked = 0;
|
||||||
|
for (uint8_t i = 0; i < total; i++)
|
||||||
|
if (p->repeat_extra_scope_mask & (1u << i)) picked++;
|
||||||
|
if (total == 0) strncpy(buf, "(none)", n);
|
||||||
|
else snprintf(buf, n, "%d/%d", picked, total);
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
default: strncpy(buf, "", n); break;
|
default: strncpy(buf, "", n); break;
|
||||||
}
|
}
|
||||||
buf[n - 1] = '\0';
|
buf[n - 1] = '\0';
|
||||||
@@ -143,17 +156,17 @@ class RepeaterScreen : public UIScreen {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public:
|
public:
|
||||||
RepeaterScreen(UITask* task) : _task(task), _dirty(false), _sel(0), _scroll(0), _item_count(1), _editing_scope(false) {}
|
RepeaterScreen(UITask* task) : _task(task), _dirty(false), _sel(0), _scroll(0), _item_count(1) {}
|
||||||
|
|
||||||
void onShow() override {
|
void onShow() override {
|
||||||
_dirty = false; _sel = 0; _scroll = 0;
|
_dirty = false; _sel = 0; _scroll = 0;
|
||||||
_picker.menu.active = false; _editor.freq.active = false;
|
_picker.menu.active = false; _editor.freq.active = false;
|
||||||
_picker.saving = false; _picker.deleting = false; _picker.confirm_slot = -1;
|
_picker.saving = false; _picker.deleting = false; _picker.confirm_slot = -1;
|
||||||
_editing_scope = false;
|
_scope_picker_active = false; _scope_menu.active = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
int render(DisplayDriver& display) override {
|
int render(DisplayDriver& display) override {
|
||||||
if (_picker.saving || _editing_scope) return _task->keyboard().render(display);
|
if (_picker.saving) return _task->keyboard().render(display);
|
||||||
|
|
||||||
NodePrefs* p = _task->getNodePrefs();
|
NodePrefs* p = _task->getNodePrefs();
|
||||||
buildItems(p);
|
buildItems(p);
|
||||||
@@ -178,7 +191,8 @@ public:
|
|||||||
});
|
});
|
||||||
display.setColor(DisplayDriver::LIGHT);
|
display.setColor(DisplayDriver::LIGHT);
|
||||||
if (_picker.menu.active) _picker.menu.render(display);
|
if (_picker.menu.active) _picker.menu.render(display);
|
||||||
return (_picker.menu.active || _editor.active()) ? 50 : 500;
|
if (_scope_picker_active) _scope_menu.render(display);
|
||||||
|
return (_picker.menu.active || _editor.active() || _scope_picker_active) ? 50 : 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool handleInput(char c) override {
|
bool handleInput(char c) override {
|
||||||
@@ -199,19 +213,22 @@ public:
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keyboard editing mode for the extra (relay-only) scopes
|
// Multi-select popup for the extra (relay-only) scopes -- each row is a
|
||||||
if (_editing_scope) {
|
// checklist item (see PopupMenu::addCheckItem()); Enter toggles it in
|
||||||
auto res = _task->keyboard().handleInput(c);
|
// place via VALUE_NEXT and commits straight into prefs, no separate
|
||||||
if (res == KeyboardWidget::DONE) {
|
// "apply on close" step needed.
|
||||||
if (p) {
|
if (_scope_picker_active) {
|
||||||
strncpy(p->repeat_extra_scopes, _task->keyboard().buf, sizeof(p->repeat_extra_scopes) - 1);
|
auto res = _scope_menu.handleInput(c);
|
||||||
p->repeat_extra_scopes[sizeof(p->repeat_extra_scopes) - 1] = '\0';
|
if (res == PopupMenu::VALUE_NEXT && p) {
|
||||||
the_mesh.rebuildRepeatScopes();
|
int i = _scope_menu.selectedIndex();
|
||||||
_dirty = true;
|
bool now_on = !_scope_menu.isChecked(i);
|
||||||
}
|
_scope_menu.setChecked(i, now_on);
|
||||||
_editing_scope = false;
|
if (now_on) p->repeat_extra_scope_mask |= (1u << i);
|
||||||
} else if (res == KeyboardWidget::CANCELLED) {
|
else p->repeat_extra_scope_mask &= ~(uint16_t)(1u << i);
|
||||||
_editing_scope = false;
|
the_mesh.rebuildRepeatScopes();
|
||||||
|
_dirty = true;
|
||||||
|
} else if (res != PopupMenu::NONE && res != PopupMenu::VALUE_NEXT) {
|
||||||
|
_scope_picker_active = false; // Back closes it -- checklist has no plain-select row
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -319,8 +336,15 @@ public:
|
|||||||
p->repeat_scope_only ^= 1; _dirty = true; return true;
|
p->repeat_scope_only ^= 1; _dirty = true; return true;
|
||||||
}
|
}
|
||||||
if (item == IT_SCOPE_EXTRA && enter) {
|
if (item == IT_SCOPE_EXTRA && enter) {
|
||||||
_task->keyboard().begin(p->repeat_extra_scopes, (int)sizeof(p->repeat_extra_scopes) - 1);
|
const ScopeList& sl = the_mesh.scopeList();
|
||||||
_editing_scope = true;
|
if (sl.count == 0) {
|
||||||
|
_task->showAlert("No scopes defined", 1200);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
_scope_menu.begin("Extra scopes", sl.count < 4 ? sl.count : 4);
|
||||||
|
for (uint8_t i = 0; i < sl.count; i++)
|
||||||
|
_scope_menu.addCheckItem(sl.name((uint8_t)(i + 1)), (p->repeat_extra_scope_mask & (1u << i)) != 0);
|
||||||
|
_scope_picker_active = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
#include "RadioParamsEditor.h"
|
#include "RadioParamsEditor.h"
|
||||||
#include "RadioPresetPicker.h"
|
#include "RadioPresetPicker.h"
|
||||||
#include "AccordionList.h"
|
#include "AccordionList.h"
|
||||||
|
#include "PopupMenu.h" // scope list management's per-row action menu
|
||||||
|
|
||||||
class SettingsScreen : public UIScreen {
|
class SettingsScreen : public UIScreen {
|
||||||
UITask* _task;
|
UITask* _task;
|
||||||
@@ -568,8 +569,9 @@ class SettingsScreen : public UIScreen {
|
|||||||
} else if (item == SCOPE_NAME) {
|
} else if (item == SCOPE_NAME) {
|
||||||
display.print("Scope");
|
display.print("Scope");
|
||||||
int vx = valCol(display);
|
int vx = valCol(display);
|
||||||
|
const ScopeList& sl = the_mesh.scopeList();
|
||||||
int r = display.drawTextEllipsized(vx, y, display.width() - vx - _reserve,
|
int r = display.drawTextEllipsized(vx, y, display.width() - vx - _reserve,
|
||||||
(p && p->default_scope_name[0]) ? p->default_scope_name : "(none)", sel);
|
sl.name(sl.default_idx), sel);
|
||||||
if (sel && r > 0) mq_delay = r;
|
if (sel && r > 0) mq_delay = r;
|
||||||
#if AUTO_OFF_MILLIS > 0
|
#if AUTO_OFF_MILLIS > 0
|
||||||
} else if (item == AUTO_OFF) {
|
} else if (item == AUTO_OFF) {
|
||||||
@@ -701,9 +703,45 @@ class SettingsScreen : public UIScreen {
|
|||||||
// Keyboard state for editing message slots
|
// Keyboard state for editing message slots
|
||||||
int _edit_slot = -1; // -1 = not editing, 0..9 = slot being edited
|
int _edit_slot = -1; // -1 = not editing, 0..9 = slot being edited
|
||||||
bool _edit_name = false; // editing DEVICE_NAME via the keyboard
|
bool _edit_name = false; // editing DEVICE_NAME via the keyboard
|
||||||
bool _edit_scope = false; // editing SCOPE_NAME via the keyboard
|
|
||||||
KeyboardWidget* _kb;
|
KeyboardWidget* _kb;
|
||||||
|
|
||||||
|
// Scope list management (SCOPE_NAME row -> a full-screen add/rename/
|
||||||
|
// delete/set-default list over the shared named-scope list, ScopeList.h).
|
||||||
|
// Row 0 is always "*", rows 1..count are named entries, the trailing row
|
||||||
|
// is a synthetic "+ Add scope" -- same shape as MessagesScreen's channel
|
||||||
|
// picker. _scope_action_menu is the per-row Set default/Rename/Delete
|
||||||
|
// popup (Enter on a row); the keyboard (_kb) is reused for both Add and
|
||||||
|
// Rename's name entry, told apart by _scope_rename_idx (-1 = adding new).
|
||||||
|
bool _scope_mgmt_active = false;
|
||||||
|
int _scope_mgmt_sel = 0, _scope_mgmt_scroll = 0;
|
||||||
|
PopupMenu _scope_action_menu;
|
||||||
|
int _scope_action_idx = -1; // list index the open action menu targets
|
||||||
|
// -2 = _kb not open for a scope name; -1 = _kb is adding a new scope;
|
||||||
|
// >=0 = _kb is renaming that list index.
|
||||||
|
int _scope_rename_idx = -2;
|
||||||
|
bool _scope_delete_confirm_active = false;
|
||||||
|
|
||||||
|
int renderScopeMgmt(DisplayDriver& display) {
|
||||||
|
display.setColor(DisplayDriver::LIGHT);
|
||||||
|
display.drawCenteredHeader("SCOPE", true, _scope_action_menu.active);
|
||||||
|
const ScopeList& sl = the_mesh.scopeList();
|
||||||
|
int total = sl.totalCount() + 1; // +1 synthetic "+ Add scope" row
|
||||||
|
drawList(display, total, _scope_mgmt_sel, _scope_mgmt_scroll, [&](int idx, int y, bool sel, int reserve) {
|
||||||
|
drawRowSelection(display, y, sel, reserve);
|
||||||
|
display.setCursor(2, y);
|
||||||
|
if (idx == sl.totalCount()) {
|
||||||
|
display.print("+ Add scope");
|
||||||
|
} else {
|
||||||
|
display.print(sl.name((uint8_t)idx));
|
||||||
|
if ((uint8_t)idx == sl.default_idx)
|
||||||
|
display.drawTextRightAlign(display.width() - reserve - 2, y, "[default]");
|
||||||
|
}
|
||||||
|
display.setColor(DisplayDriver::LIGHT);
|
||||||
|
});
|
||||||
|
if (_scope_action_menu.active) _scope_action_menu.render(display);
|
||||||
|
return _scope_action_menu.active ? 50 : 500;
|
||||||
|
}
|
||||||
|
|
||||||
// Radio preset picker — names are too long for the value column, so Enter on
|
// Radio preset picker — names are too long for the value column, so Enter on
|
||||||
// RADIO_PRESET opens it as a full-width scrollable list instead of cycling.
|
// RADIO_PRESET opens it as a full-width scrollable list instead of cycling.
|
||||||
// Shared with Tools › Repeater (see RadioPresetPicker.h). _picker.saving means
|
// Shared with Tools › Repeater (see RadioPresetPicker.h). _picker.saving means
|
||||||
@@ -725,7 +763,10 @@ public:
|
|||||||
void onShow() override {
|
void onShow() override {
|
||||||
_dirty = false;
|
_dirty = false;
|
||||||
_edit_name = false;
|
_edit_name = false;
|
||||||
_edit_scope = false;
|
_scope_mgmt_active = false;
|
||||||
|
_scope_rename_idx = -2;
|
||||||
|
_scope_action_menu.active = false;
|
||||||
|
_scope_delete_confirm_active = false;
|
||||||
resetList();
|
resetList();
|
||||||
_editor.freq.active = false;
|
_editor.freq.active = false;
|
||||||
}
|
}
|
||||||
@@ -733,10 +774,12 @@ public:
|
|||||||
int render(DisplayDriver& display) override {
|
int render(DisplayDriver& display) override {
|
||||||
display.setTextSize(1);
|
display.setTextSize(1);
|
||||||
|
|
||||||
if (_edit_slot >= 0 || _edit_name || _edit_scope || _picker.saving) {
|
if (_edit_slot >= 0 || _edit_name || _scope_rename_idx != -2 || _picker.saving) {
|
||||||
return _kb->render(display);
|
return _kb->render(display);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_scope_mgmt_active) return renderScopeMgmt(display);
|
||||||
|
|
||||||
display.drawCenteredHeader("SETTINGS");
|
display.drawCenteredHeader("SETTINGS");
|
||||||
|
|
||||||
int mq_delay = 0;
|
int mq_delay = 0;
|
||||||
@@ -798,19 +841,68 @@ public:
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keyboard editing mode for the scope name
|
// Keyboard editing mode for adding/renaming a scope-list entry
|
||||||
if (_edit_scope) {
|
if (_scope_rename_idx != -2) {
|
||||||
auto res = _kb->handleInput(c);
|
auto res = _kb->handleInput(c);
|
||||||
if (res == KeyboardWidget::DONE) {
|
if (res == KeyboardWidget::DONE) {
|
||||||
the_mesh.setPrimaryScope(_kb->buf);
|
if (_scope_rename_idx == -1) the_mesh.addScope(_kb->buf);
|
||||||
_dirty = true;
|
else the_mesh.renameScope((uint8_t)_scope_rename_idx, _kb->buf);
|
||||||
_edit_scope = false;
|
_scope_rename_idx = -2;
|
||||||
} else if (res == KeyboardWidget::CANCELLED) {
|
} else if (res == KeyboardWidget::CANCELLED) {
|
||||||
_edit_scope = false;
|
_scope_rename_idx = -2;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_scope_mgmt_active) {
|
||||||
|
const ScopeList& sl = the_mesh.scopeList();
|
||||||
|
if (_scope_delete_confirm_active) {
|
||||||
|
auto res = _scope_action_menu.handleInput(c);
|
||||||
|
if (res == PopupMenu::SELECTED && _scope_action_menu.selectedIndex() == 0) { // "Delete"
|
||||||
|
the_mesh.removeScope((uint8_t)_scope_action_idx);
|
||||||
|
if (_scope_mgmt_sel > sl.totalCount()) _scope_mgmt_sel = sl.totalCount();
|
||||||
|
}
|
||||||
|
if (res != PopupMenu::NONE) _scope_delete_confirm_active = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (_scope_action_menu.active) {
|
||||||
|
auto res = _scope_action_menu.handleInput(c);
|
||||||
|
if (res == PopupMenu::SELECTED) {
|
||||||
|
int sel = _scope_action_menu.selectedIndex();
|
||||||
|
if (sel == 0) { // Set default
|
||||||
|
the_mesh.setDefaultScope((uint8_t)_scope_action_idx);
|
||||||
|
} else if (sel == 1 && _scope_action_idx >= 1) { // Rename
|
||||||
|
_scope_rename_idx = _scope_action_idx;
|
||||||
|
_kb->begin(sl.name((uint8_t)_scope_action_idx), 23);
|
||||||
|
_kb->clearPlaceholders();
|
||||||
|
} else if (sel == 2 && _scope_action_idx >= 1) { // Delete -- confirm first
|
||||||
|
_scope_action_menu.beginConfirm("Delete scope?", "Delete");
|
||||||
|
_scope_delete_confirm_active = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (c == KEY_CANCEL) { _scope_mgmt_active = false; return true; }
|
||||||
|
int total = sl.totalCount() + 1;
|
||||||
|
if (c == KEY_UP) { _scope_mgmt_sel = (_scope_mgmt_sel > 0) ? _scope_mgmt_sel - 1 : total - 1; return true; }
|
||||||
|
if (c == KEY_DOWN) { _scope_mgmt_sel = (_scope_mgmt_sel + 1 < total) ? _scope_mgmt_sel + 1 : 0; return true; }
|
||||||
|
if (c == KEY_ENTER) {
|
||||||
|
if (_scope_mgmt_sel == sl.totalCount()) { // "+ Add scope"
|
||||||
|
_scope_rename_idx = -1;
|
||||||
|
_kb->begin("", 23);
|
||||||
|
_kb->clearPlaceholders();
|
||||||
|
} else {
|
||||||
|
_scope_action_idx = _scope_mgmt_sel;
|
||||||
|
bool is_named = _scope_action_idx >= 1;
|
||||||
|
_scope_action_menu.begin("Scope", is_named ? 3 : 1);
|
||||||
|
_scope_action_menu.addItem("Set default");
|
||||||
|
if (is_named) { _scope_action_menu.addItem("Rename"); _scope_action_menu.addItem("Delete"); }
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return true; // list has focus -- swallow anything else rather than falling through
|
||||||
|
}
|
||||||
|
|
||||||
// Digit-by-digit Freq editor
|
// Digit-by-digit Freq editor
|
||||||
if (_editor.active()) {
|
if (_editor.active()) {
|
||||||
if (_editor.handleFreqInput(c) && p) { _task->applyRadioParams(); _dirty = true; }
|
if (_editor.handleFreqInput(c) && p) { _task->applyRadioParams(); _dirty = true; }
|
||||||
@@ -1012,9 +1104,9 @@ public:
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (_selected == SCOPE_NAME && p && enter) {
|
if (_selected == SCOPE_NAME && p && enter) {
|
||||||
_edit_scope = true;
|
_scope_mgmt_active = true;
|
||||||
_kb->begin(p->default_scope_name, (int)sizeof(p->default_scope_name) - 1);
|
_scope_mgmt_sel = 0;
|
||||||
_kb->clearPlaceholders(); // a scope name is literal, not a message
|
_scope_mgmt_scroll = 0;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (_selected == REBOOT && enter) {
|
if (_selected == REBOOT && enter) {
|
||||||
|
|||||||
@@ -3306,6 +3306,10 @@ void UITask::onChannelRemoved(uint8_t channel_idx) {
|
|||||||
_node_prefs->ch_fav_bitmask &= ~mask;
|
_node_prefs->ch_fav_bitmask &= ~mask;
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
if (channel_idx < NodePrefs::MAX_SCOPED_CHANNELS && _node_prefs->ch_scope_idx[channel_idx]) {
|
||||||
|
_node_prefs->ch_scope_idx[channel_idx] = 0; // back to "*", same as a never-configured channel
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
int fav_slot = findFavouriteChannelSlot(channel_idx);
|
int fav_slot = findFavouriteChannelSlot(channel_idx);
|
||||||
if (fav_slot >= 0) { clearFavouriteSlot(fav_slot); changed = true; }
|
if (fav_slot >= 0) { clearFavouriteSlot(fav_slot); changed = true; }
|
||||||
|
|
||||||
|
|||||||
@@ -776,3 +776,17 @@ inline int favStarWidth(DisplayDriver& d) {
|
|||||||
inline void drawFavStar(DisplayDriver& d, int x, int top_y) {
|
inline void drawFavStar(DisplayDriver& d, int x, int top_y) {
|
||||||
miniIconDraw(d, x, top_y, ICON_PG_STAR);
|
miniIconDraw(d, x, top_y, ICON_PG_STAR);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Multi-select toggle glyph -- an outlined box, filled solid when `on`, same
|
||||||
|
// visual language as SettingsScreen's volume/brightness renderBar() (a
|
||||||
|
// fillable square) rather than a "[x]"/"[ ]" text glyph that competes with
|
||||||
|
// the row's own label for width. Used by PopupMenu's checklist rows and any
|
||||||
|
// other multi-select list.
|
||||||
|
inline int checkboxWidth(DisplayDriver& d) {
|
||||||
|
return d.getLineHeight() - 2;
|
||||||
|
}
|
||||||
|
inline void drawCheckbox(DisplayDriver& d, int x, int y, bool on) {
|
||||||
|
int box = checkboxWidth(d);
|
||||||
|
d.drawRect(x, y, box, box);
|
||||||
|
if (on) d.fillRect(x + 2, y + 2, box - 4, box - 4);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
- **The Clock/Lock dashboard gets a separate "Altitude (GPS)" field**, alongside the existing barometric one (now labelled "Altitude (Baro)") — the original single Altitude field only ever read a barometric sensor's telemetry, showing `--` on any board without one even with a perfectly good GPS fix.
|
- **The Clock/Lock dashboard gets a separate "Altitude (GPS)" field**, alongside the existing barometric one (now labelled "Altitude (Baro)") — the original single Altitude field only ever read a barometric sensor's telemetry, showing `--` on any board without one even with a perfectly good GPS fix.
|
||||||
- **Received messages now show how many hops they actually took to reach you**, right in the message list — the same tiny digit-icon a sent message already uses for its repeater/echo count, now shown for incoming DMs and channel posts too, using the hop path the mesh already records for them.
|
- **Received messages now show how many hops they actually took to reach you**, right in the message list — the same tiny digit-icon a sent message already uses for its repeater/echo count, now shown for incoming DMs and channel posts too, using the hop path the mesh already records for them.
|
||||||
|
- **Scope is now a shared, freely-definable list, not one device-wide text field.** Settings › Radio › Scope manages a small named list (`*`/wildcard always first, plus a movable default) instead of a single free-typed name. Each channel picks exactly one scope of its own — a `Scope: <name>` row in the channel's context menu, matching the phone app's own per-channel region picker — and the channel's history title shows the tag when it's set to anything but `*`. Tools › Repeater's "Extra scopes" now multi-selects from the same list instead of comma-typing region names, capped at the same 4 active relay scopes as before. DMs and any not-yet-assigned channel keep using the list's current default, so an existing single-scope setup carries over unchanged on upgrade.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user