diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index c36a0d79..15f427ed 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -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)); 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 // (or a different schema); rd() already zero-inits any fields not present, // 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 // zero-inits it on a pre-0x0D file, and name[0]=='\0' is exactly the "empty // 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(); @@ -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.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.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 // 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) #define MAX_ADVERT_PKT_LEN (2 + 32 + PUB_KEY_SIZE + 4 + SIGNATURE_SIZE + MAX_ADVERT_DATA_SIZE) diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h index 1c286864..dc7ab616 100644 --- a/examples/companion_radio/DataStore.h +++ b/examples/companion_radio/DataStore.h @@ -4,6 +4,7 @@ #include #include #include "NodePrefs.h" +#include "ScopeList.h" class DataStoreHost { public: @@ -45,6 +46,15 @@ public: // deleted slot is simply absent from the file, not written as empty). bool loadChannels(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(); 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); diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index c2d55bb5..b6ed6265 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -677,6 +677,9 @@ bool MyMesh::allowPacketForward(const mesh::Packet* packet) { } 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); _prefs.default_scope_name[sizeof(_prefs.default_scope_name) - 1] = '\0'; if (_prefs.default_scope_name[0] == '\0') { @@ -689,37 +692,85 @@ void MyMesh::setPrimaryScope(const char* name) { temp.getAutoKeyFor(0, hashtag, 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(); } +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() { repeat_scope_count = 0; - TransportKey primary; - memcpy(primary.key, _prefs.default_scope_key, sizeof(primary.key)); + TransportKey primary = _scope_list.key(_scope_list.default_idx); 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); - // 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, ","); + for (uint8_t i = 0; i < _scope_list.count && repeat_scope_count < MAX_REPEAT_SCOPES; i++) { + if (!(_prefs.repeat_extra_scope_mask & (1u << i))) continue; + uint8_t list_idx = i + 1; + TransportKey k = _scope_list.key(list_idx); + if (!k.isNull()) repeat_scopes[repeat_scope_count++] = k; } } @@ -739,25 +790,30 @@ void MyMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, ui if (send_unscoped) { sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1); // app has explicitly requested un-scoped } else { - TransportKey default_scope; - memcpy(&default_scope.key, _prefs.default_scope_key, sizeof(default_scope.key)); - + TransportKey default_scope = _scope_list.key(_scope_list.default_idx); auto scope = send_scope.isNull() ? &default_scope : &send_scope; sendFloodScoped(*scope, pkt, 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) trackRelaySend(pkt); // and for the UI "relayed" marker if (send_unscoped) { 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 { - TransportKey default_scope; - memcpy(&default_scope.key, _prefs.default_scope_key, sizeof(default_scope.key)); - - auto scope = send_scope.isNull() ? &default_scope : &send_scope; - sendFloodScoped(*scope, pkt, delay_millis); + // Resolve THIS channel's own scope-list pick (Messages > channel context + // 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 + // doesn't go through a slot in channels[]). + 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 _store->loadPrefs(_prefs, sensors.node_lat, sensors.node_lon); + _store->loadScopeList(_scope_list, _prefs); rebuildRepeatScopes(); // sanitise bad pref values. NaN/inf must be reset BEFORE constrain(): constrain diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 04f3fbf5..47edcb0e 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -42,6 +42,7 @@ class UITask; #include "DataStore.h" #include "NodePrefs.h" +#include "ScopeList.h" #include #include @@ -350,19 +351,45 @@ 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(). + // Sets this device's own default scope (Settings > Radio > Scope, and the + // app's CMD_SET_DEFAULT_FLOOD_SCOPE) from a single typed region name. + // Keeps default_scope_name/default_scope_key in sync (legacy fields, inert + // once /scopes1 exists -- see ScopeList.h) *and* finds-or-creates a + // 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); - // 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. + // Rebuilds repeat_scopes[]/repeat_scope_count from the scope list's current + // default entry (slot 0) plus repeat_extra_scope_mask (Tools > Repeater > + // Extra scopes -- a toggle over the same list). Call after loading prefs + + // the scope list at boot and whenever either scope setting is edited. 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 { 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++) @@ -560,10 +587,17 @@ private: 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: - // 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. + // slot 0 is the scope list's current default entry (if not "*"), the rest + // are derived from repeat_extra_scope_mask's set bits. 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; diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 5c8832e3..2046a9ca 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -149,7 +149,17 @@ struct NodePrefs { // persisted to file // 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. + // 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]; + // 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 // 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 @@ -329,6 +339,16 @@ struct NodePrefs { // persisted to file // 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_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 static const int DM_NOTIF_TABLE_MAX = 16; 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 // 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. - 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 // 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 // 0xC0DE0029 bump left over -- confirmed via a real sim_companion_radio // (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 " "SCHEMA_SENTINEL, then update this size (see steps above)."); diff --git a/examples/companion_radio/ScopeList.h b/examples/companion_radio/ScopeList.h new file mode 100644 index 00000000..04d529c3 --- /dev/null +++ b/examples/companion_radio/ScopeList.h @@ -0,0 +1,91 @@ +#pragma once +#include +#include +#include + +// 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--; + } +}; diff --git a/examples/companion_radio/ui-new/MessagesScreen.h b/examples/companion_radio/ui-new/MessagesScreen.h index e08d09c4..c7ed284c 100644 --- a/examples/companion_radio/ui-new/MessagesScreen.h +++ b/examples/companion_radio/ui-new/MessagesScreen.h @@ -68,6 +68,12 @@ class MessagesScreen : public UIScreen { uint8_t _ctx_ch_idx = 0; char _ctx_notif_item[22]; char _ctx_melody_item[20]; + char _ctx_scope_item[30]; // "Scope: " + // 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_fav_item[12]; // "Fav: ON" / "Fav: OFF" — shared by the channel, // contact and room menus (never open at once) @@ -1145,6 +1151,7 @@ public: _pick_bot_room = false; _pin_picker_active = false; _pin_slot_ch_idx = -1; + _scope_pick_active = false; _ch_delete_confirm_active = false; _pick_fav_slot = -1; _direct_entry = false; @@ -1694,8 +1701,17 @@ public: ChannelDetails ch; the_mesh.getChannel(_sel_channel_idx, ch); - char title[24]; - snprintf(title, sizeof(title), "%.23s", ch.name); + char title[32]; + 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); int ch_hist_count = _history.histCountForChannel(_sel_channel_idx); @@ -2133,11 +2149,20 @@ public: if (_ctx_menu.active) { // LEFT/RIGHT -- and Enter, via VALUE_NEXT below -- cycle Notif/Melody/Fav // 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); return true; } 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) { // Slot picker sub-menu: index 0..FAVOURITES_COUNT-1 maps directly to slot. if (res == PopupMenu::SELECTED && _pin_slot_ch_idx >= 0) { @@ -2177,7 +2202,16 @@ public: int cleared = (int)_history.chUnread(ch_idx); _history.setChUnread(ch_idx, 0); 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); if (pinned_slot >= 0) { _task->clearFavouriteSlot(pinned_slot); @@ -2193,10 +2227,10 @@ public: _pin_picker_active = true; return true; // list rebuild below would close the submenu } - } else if (sel == 5) { // Edit + } else if (sel == 6) { // Edit ChannelDetails ch; 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"); _ch_delete_confirm_active = true; return true; // list rebuild below would close the submenu @@ -2259,12 +2293,16 @@ public: { 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); 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.addItem("Mark all read"); _ctx_menu.addValueItem(_ctx_notif_item); _ctx_menu.addValueItem(_ctx_melody_item); _ctx_fav_idx = 3; _ctx_menu.addValueItem(_ctx_fav_item); + _ctx_menu.addItem(_ctx_scope_item); _ctx_menu.addItem(_ctx_pin_item); _ctx_menu.addItem("Edit"); _ctx_menu.addItem("Delete"); diff --git a/examples/companion_radio/ui-new/PopupMenu.h b/examples/companion_radio/ui-new/PopupMenu.h index 788762ba..840fe2e3 100644 --- a/examples/companion_radio/ui-new/PopupMenu.h +++ b/examples/companion_radio/ui-new/PopupMenu.h @@ -20,13 +20,15 @@ struct PopupMenu { // 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. 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 // (same as its RIGHT step) and the menu stays open. enum Result { NONE, SELECTED, CANCELLED, VALUE_NEXT }; 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 // 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; _cap = visible; active = true; _title = title; _value_mask = 0; + _has_checkboxes = false; _checked_mask = 0; } void addItem(const char* item) { @@ -49,6 +52,29 @@ struct PopupMenu { 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 // 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 -- @@ -107,7 +133,8 @@ struct PopupMenu { int w = display.getTextWidth(_items[i]); 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; if (bw > max_bw) bw = max_bw; 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 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; for (int i = 0; i < vis && (_scroll + i) < _count; i++) { int idx = _scroll + i; @@ -148,6 +175,7 @@ struct PopupMenu { // Return value not needed here: this popup already redraws every 50ms // (below), faster than any marquee step, so the animation is already smooth. 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); } diff --git a/examples/companion_radio/ui-new/RepeaterScreen.h b/examples/companion_radio/ui-new/RepeaterScreen.h index 407a5e71..1dd35b1d 100644 --- a/examples/companion_radio/ui-new/RepeaterScreen.h +++ b/examples/companion_radio/ui-new/RepeaterScreen.h @@ -23,6 +23,7 @@ #include "RadioPresetPicker.h" #include "../RadioPresets.h" #include "../MyMesh.h" +#include "PopupMenu.h" 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 }; 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; RadioPresetPicker _picker; @@ -127,9 +129,20 @@ class RepeaterScreen : public UIScreen { 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); + case IT_SCOPE_EXTRA: { + // Bound the scan to the list's real length, not the mask's full bit + // width -- a stray high bit (e.g. leftover sentinel bytes from a + // pre-scope-list save file) must never be counted as "picked", or a + // device that's never touched this picker can show a bogus non-zero + // count. rebuildRepeatScopes() already ignores such bits the same way. + uint8_t total = the_mesh.scopeList().count; + int picked = 0; + for (uint8_t i = 0; i < total; i++) + if (p->repeat_extra_scope_mask & (1u << i)) picked++; + if (total == 0) strncpy(buf, "(none)", n); + else snprintf(buf, n, "%d/%d", picked, total); break; + } default: strncpy(buf, "", n); break; } buf[n - 1] = '\0'; @@ -143,17 +156,17 @@ class RepeaterScreen : public UIScreen { } 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 { _dirty = false; _sel = 0; _scroll = 0; _picker.menu.active = false; _editor.freq.active = false; _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 { - if (_picker.saving || _editing_scope) return _task->keyboard().render(display); + if (_picker.saving) return _task->keyboard().render(display); NodePrefs* p = _task->getNodePrefs(); buildItems(p); @@ -178,7 +191,8 @@ public: }); display.setColor(DisplayDriver::LIGHT); 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 { @@ -199,19 +213,22 @@ 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; + // Multi-select popup for the extra (relay-only) scopes -- each row is a + // checklist item (see PopupMenu::addCheckItem()); Enter toggles it in + // place via VALUE_NEXT and commits straight into prefs, no separate + // "apply on close" step needed. + if (_scope_picker_active) { + auto res = _scope_menu.handleInput(c); + if (res == PopupMenu::VALUE_NEXT && p) { + int i = _scope_menu.selectedIndex(); + bool now_on = !_scope_menu.isChecked(i); + _scope_menu.setChecked(i, now_on); + if (now_on) p->repeat_extra_scope_mask |= (1u << i); + else p->repeat_extra_scope_mask &= ~(uint16_t)(1u << i); + the_mesh.rebuildRepeatScopes(); + _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; } @@ -319,8 +336,15 @@ public: 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; + const ScopeList& sl = the_mesh.scopeList(); + 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 false; diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index 768bd463..7f90061b 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -7,6 +7,7 @@ #include "RadioParamsEditor.h" #include "RadioPresetPicker.h" #include "AccordionList.h" +#include "PopupMenu.h" // scope list management's per-row action menu class SettingsScreen : public UIScreen { UITask* _task; @@ -568,8 +569,9 @@ class SettingsScreen : public UIScreen { } else if (item == SCOPE_NAME) { display.print("Scope"); int vx = valCol(display); + const ScopeList& sl = the_mesh.scopeList(); 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 AUTO_OFF_MILLIS > 0 } else if (item == AUTO_OFF) { @@ -701,9 +703,45 @@ 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; + // 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 opens it as a full-width scrollable list instead of cycling. // Shared with Tools › Repeater (see RadioPresetPicker.h). _picker.saving means @@ -725,7 +763,10 @@ public: void onShow() override { _dirty = 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(); _editor.freq.active = false; } @@ -733,10 +774,12 @@ public: int render(DisplayDriver& display) override { 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); } + if (_scope_mgmt_active) return renderScopeMgmt(display); + display.drawCenteredHeader("SETTINGS"); int mq_delay = 0; @@ -798,19 +841,68 @@ public: return true; } - // Keyboard editing mode for the scope name - if (_edit_scope) { + // Keyboard editing mode for adding/renaming a scope-list entry + if (_scope_rename_idx != -2) { auto res = _kb->handleInput(c); if (res == KeyboardWidget::DONE) { - the_mesh.setPrimaryScope(_kb->buf); - _dirty = true; - _edit_scope = false; + if (_scope_rename_idx == -1) the_mesh.addScope(_kb->buf); + else the_mesh.renameScope((uint8_t)_scope_rename_idx, _kb->buf); + _scope_rename_idx = -2; } else if (res == KeyboardWidget::CANCELLED) { - _edit_scope = false; + _scope_rename_idx = -2; } 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 if (_editor.active()) { if (_editor.handleFreqInput(c) && p) { _task->applyRadioParams(); _dirty = true; } @@ -1012,9 +1104,9 @@ public: 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 + _scope_mgmt_active = true; + _scope_mgmt_sel = 0; + _scope_mgmt_scroll = 0; return true; } if (_selected == REBOOT && enter) { diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 5c64b1a4..a83acf78 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -3306,6 +3306,10 @@ void UITask::onChannelRemoved(uint8_t channel_idx) { _node_prefs->ch_fav_bitmask &= ~mask; 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); if (fav_slot >= 0) { clearFavouriteSlot(fav_slot); changed = true; } diff --git a/examples/companion_radio/ui-new/icons.h b/examples/companion_radio/ui-new/icons.h index 49e21c59..8a05e853 100644 --- a/examples/companion_radio/ui-new/icons.h +++ b/examples/companion_radio/ui-new/icons.h @@ -776,3 +776,17 @@ inline int favStarWidth(DisplayDriver& d) { inline void drawFavStar(DisplayDriver& d, int x, int top_y) { 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); +} diff --git a/release-notes.md b/release-notes.md index 7332148f..c57d594b 100644 --- a/release-notes.md +++ b/release-notes.md @@ -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. - **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: ` 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