From c4217d8632348cf4d6703e6eac47ca98e2e3dd0c Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:09:22 +0200 Subject: [PATCH] feat: a channel's scope can follow the default -- Default is the new start value Co-Authored-By: Claude Opus 5.5 --- examples/companion_radio/DataStore.cpp | 5 +++ examples/companion_radio/MyMesh.cpp | 35 +++++-------------- examples/companion_radio/MyMesh.h | 13 +++++-- examples/companion_radio/NodePrefs.h | 11 ++++-- .../companion_radio/ui-core/ChannelControl.h | 14 +++++--- .../companion_radio/ui-lvgl/ChannelScreen.h | 8 ++--- .../companion_radio/ui-new/MessagesScreen.h | 27 +++++++------- .../companion_radio/ui-new/SettingsScreen.h | 8 ++--- 8 files changed, 63 insertions(+), 58 deletions(-) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 3e8925d7..ba9006cb 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -333,6 +333,11 @@ static void sanitize(NodePrefs& p) { if (!battery::validCurve(p.batt_curve_mv)) memset(p.batt_curve_mv, 0, sizeof(p.batt_curve_mv)); flag(p.lock_compact, 0); flag(p.ble_off, 0); + if (!p.ch_scope_follow) { + for (uint8_t i = 0; i < NodePrefs::MAX_SCOPED_CHANNELS; i++) + if (p.ch_scope_idx[i] == 0) p.ch_scope_idx[i] = NodePrefs::CH_SCOPE_DEFAULT; + p.ch_scope_follow = 1; + } } // The settings a firmware before 2.0 kept in /new_prefs that still matter diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index f9469d27..7f5f7734 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -804,6 +804,7 @@ void MyMesh::removeScope(uint8_t idx) { // 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 == NodePrefs::CH_SCOPE_DEFAULT) continue; // follows the default, fixed up above if (ci == idx) _prefs.ch_scope_idx[i] = 0; else if (ci > idx) _prefs.ch_scope_idx[i] = ci - 1; } @@ -835,7 +836,7 @@ void MyMesh::setDefaultScope(uint8_t idx) { 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); + _prefs.ch_scope_idx[channel_idx] = idx == NodePrefs::CH_SCOPE_DEFAULT ? idx : _scope_list.clamp(idx); } void MyMesh::rebuildRepeatScopes() { @@ -887,17 +888,12 @@ void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pk // this channel's own on-device pick, same precedence DMs already have. sendFloodScoped(send_scope, pkt, delay_millis); } else { - // Resolve THIS channel's own scope-list pick (Messages > channel context - // menu > Scope:). Index 0 is "*", which means unscoped -- NOT "inherit the - // default": the default is what DMs and the relay filter's primary slot - // use, and what a channel is seeded with on upgrade, but once a channel - // has a pick that pick is the whole story. The list default is only the - // fallback for a channel we can't identify at all (findChannelIdx() == -1, - // e.g. a send whose secret isn't in channels[]), where there's no pick to - // read in the first place. + // THIS channel's own scope (Messages > channel context menu > Scope:): + // "Default" follows the list's default, "*" sends unscoped. A channel we + // can't identify (findChannelIdx() == -1, e.g. a send whose secret isn't + // in channels[]) takes the default too. 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; + uint8_t list_idx = channel_idx >= 0 ? channelScope((uint8_t)channel_idx) : _scope_list.default_idx; TransportKey scope = _scope_list.key(list_idx); sendFloodScoped(scope, pkt, delay_millis); } @@ -1841,6 +1837,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe // defaults memset(&_prefs, 0, sizeof(_prefs)); + memset(_prefs.ch_scope_idx, NodePrefs::CH_SCOPE_DEFAULT, sizeof(_prefs.ch_scope_idx)); _prefs.airtime_factor = 1.0; strcpy(_prefs.node_name, "NONAME"); _prefs.freq = LORA_FREQ; @@ -1938,7 +1935,7 @@ void MyMesh::begin() { _store->loadPrefs(_prefs, sensors.node_lat, sensors.node_lon); // True only on the first boot after upgrading a device that had the old // single Scope field set -- acted on once the channels are loaded, below. - bool scope_migrated_legacy = _store->loadScopeList(_scope_list, _prefs); + _store->loadScopeList(_scope_list, _prefs); rebuildRepeatScopes(); // sanitise bad pref values. NaN/inf must be reset BEFORE constrain(): constrain @@ -1975,20 +1972,6 @@ void MyMesh::begin() { addChannel("Public", PUBLIC_GROUP_PSK); // pre-configure Andy's public channel } - // First boot after upgrading from the single device-wide Scope field: every - // channel now carries its own pick, and an unset pick means "*" == unscoped, - // not "inherit the default". Left alone, an upgrader's channel traffic would - // quietly go out unscoped while their DMs kept the old scope. Seed only the - // slots that actually hold a channel today -- a blanket fill would also hand - // the scope to whatever channel gets created in an empty slot later on. - if (scope_migrated_legacy && _scope_list.default_idx >= 1) { - for (uint8_t i = 0; i < NodePrefs::MAX_SCOPED_CHANNELS; i++) { - ChannelDetails ch; - if (getChannel(i, ch) && ch.name[0]) _prefs.ch_scope_idx[i] = _scope_list.default_idx; - } - savePrefs(); - } - applyRepeaterRadio(); // companion params, or the repeater profile if relaying with one set applyApc(); // sets TX power to the ceiling and arms APC if enabled radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 11ea8fb2..53032ebb 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -495,12 +495,19 @@ public: // 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 + // Marks list index idx as the default (used for DMs, the repeater's primary + // slot and every channel left on "Default"). Persists the list and rebuilds // repeat_scopes[]. void setDefaultScope(uint8_t idx); - // Sets channel_idx's own scope-list pick (0 = "*"). Persists prefs. + // Sets channel_idx's own scope-list pick (0 = "*", NodePrefs::CH_SCOPE_DEFAULT + // = follow the default). The caller saves the prefs. void setChannelScope(uint8_t channel_idx, uint8_t idx); + // The list index channel_idx sends in, its "follow the default" resolved. + uint8_t channelScope(uint8_t channel_idx) const { + uint8_t ci = channel_idx < NodePrefs::MAX_SCOPED_CHANNELS ? _prefs.ch_scope_idx[channel_idx] + : NodePrefs::CH_SCOPE_DEFAULT; + return ci == NodePrefs::CH_SCOPE_DEFAULT ? _scope_list.default_idx : _scope_list.clamp(ci); + } bool isAckPending(uint32_t expected_ack) const { if (expected_ack == 0) return false; // 0 marks an empty/cleared slot, not a real ACK diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index b9e3d72d..334774d6 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -314,12 +314,14 @@ 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) - // Scope-list index per channel (see ScopeList) -- 0 ("*"/unscoped) by - // default. Fixed at 64 slots (not + // Scope-list index per channel (see ScopeList): 0 = "*" (unscoped), 1.. a + // list entry, CH_SCOPE_DEFAULT (the default for every channel) = whatever + // the list's default is (MyMesh::channelScope()). 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; + static const uint8_t CH_SCOPE_DEFAULT = 0xFF; 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; @@ -525,6 +527,11 @@ struct NodePrefs { // persisted to file // hibernate (MyMesh::startInterface). USB keeps working either way. uint8_t ble_off; + // 1 once ch_scope_idx knows CH_SCOPE_DEFAULT. A 2.0-rc1 file has 0 here and + // its channels' 0 ("*", then the only start value) becomes "follow the + // default" on load (DataStore sanitize). + uint8_t ch_scope_follow; + // Single source of truth for the live-share option tables (shared by the Map // UI labels and the auto-send engine in UITask). static const uint8_t LOC_SHARE_MOVE_COUNT = 4; diff --git a/examples/companion_radio/ui-core/ChannelControl.h b/examples/companion_radio/ui-core/ChannelControl.h index a4ad685d..44742689 100644 --- a/examples/companion_radio/ui-core/ChannelControl.h +++ b/examples/companion_radio/ui-core/ChannelControl.h @@ -193,9 +193,15 @@ static void setFavourite(NodePrefs* p, uint8_t idx, bool on) { if (on) p->ch_fav_bitmask |= 1ULL << idx; else p->ch_fav_bitmask &= ~(1ULL << idx); } -// Scope: index into the_mesh.scopeList() (0 = "*", unscoped). +// Scope as a picker row: 0 = Default (follow the list's default), then the +// list itself from 1 ("*" = unscoped, then the named entries). static uint8_t scope(const NodePrefs* p, uint8_t idx) { - return (p && idx < NodePrefs::MAX_SCOPED_CHANNELS) ? p->ch_scope_idx[idx] : 0; + const uint8_t ci = (p && idx < NodePrefs::MAX_SCOPED_CHANNELS) ? p->ch_scope_idx[idx] : NodePrefs::CH_SCOPE_DEFAULT; + return ci == NodePrefs::CH_SCOPE_DEFAULT ? 0 : ci + 1; +} +// A picker row back to the ch_scope_idx value (MyMesh::setChannelScope()). +static uint8_t scopeFromRow(int row) { + return row <= 0 ? NodePrefs::CH_SCOPE_DEFAULT : (uint8_t)(row - 1); } // CONTRACT: every NodePrefs field that keys on a channel index is cleared @@ -214,8 +220,8 @@ static bool onRemoved(NodePrefs* p, uint8_t idx) { if (p->ch_notif_melody_set & m) { p->ch_notif_melody_set &= ~m; p->ch_notif_melody_2 &= ~m; changed = true; } if (p->ch_notif_override & m) { p->ch_notif_override &= ~m; p->ch_notif_muted &= ~m; changed = true; } if (p->ch_fav_bitmask & m) { p->ch_fav_bitmask &= ~m; changed = true; } - if (idx < NodePrefs::MAX_SCOPED_CHANNELS && p->ch_scope_idx[idx]) { - p->ch_scope_idx[idx] = 0; // back to "*", same as a never-configured channel + if (idx < NodePrefs::MAX_SCOPED_CHANNELS && p->ch_scope_idx[idx] != NodePrefs::CH_SCOPE_DEFAULT) { + p->ch_scope_idx[idx] = NodePrefs::CH_SCOPE_DEFAULT; // back to Default, as a new channel changed = true; } int slot = favslots::findChannel(p, idx); diff --git a/examples/companion_radio/ui-lvgl/ChannelScreen.h b/examples/companion_radio/ui-lvgl/ChannelScreen.h index 0b0f2b90..8170d72f 100644 --- a/examples/companion_radio/ui-lvgl/ChannelScreen.h +++ b/examples/companion_radio/ui-lvgl/ChannelScreen.h @@ -82,9 +82,9 @@ void UITask::channelMenu(int idx) { (void*)(uintptr_t)C_MELODY); const ScopeList& sl = the_mesh.scopeList(); if (sl.count > 0) { // only once regions are set up (Settings in the app) - int o = 0; - for (uint8_t i = 0; i <= sl.count && o < (int)sizeof(s_scope_opts) - 24; i++) - o += snprintf(s_scope_opts + o, sizeof(s_scope_opts) - o, "%s%s", i ? "\n" : "", sl.name(i)); + int o = snprintf(s_scope_opts, sizeof(s_scope_opts), "Default (%s)", sl.name(sl.default_idx)); + for (uint8_t i = 0; i <= sl.count && o < (int)sizeof(s_scope_opts) - 26; i++) + o += snprintf(s_scope_opts + o, sizeof(s_scope_opts) - o, "\n%s", sl.name(i)); choiceRow(g, "Scope", "Region it's sent in", s_scope_opts, chanctl::scope(_prefs, idx), onChanDropdown, (void*)(uintptr_t)C_SCOPE); } @@ -110,7 +110,7 @@ void UITask::channelSet(uint8_t which, int v) { using namespace chanview; if (!chanctl::exists(s_idx) || !_prefs) return; if (which == C_NOTIF) chanctl::setNotif(_prefs, s_idx, (uint8_t)v); - else if (which == C_SCOPE) the_mesh.setChannelScope(s_idx, (uint8_t)v); + else if (which == C_SCOPE) the_mesh.setChannelScope(s_idx, chanctl::scopeFromRow(v)); else if (which == C_MELODY) { chanctl::setMelody(_prefs, s_idx, (uint8_t)v); hearMelody(v); } prefsSave(); } diff --git a/examples/companion_radio/ui-new/MessagesScreen.h b/examples/companion_radio/ui-new/MessagesScreen.h index 5d717f2c..198d7614 100644 --- a/examples/companion_radio/ui-new/MessagesScreen.h +++ b/examples/companion_radio/ui-new/MessagesScreen.h @@ -63,10 +63,11 @@ class MessagesScreen : public UIScreen { 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). + char _ctx_scope_def[34]; // the picker's first row: "Default ()" + // Scope sub-picker: single-select from Default + the shared named-scope + // list, reusing _ctx_menu itself as a popup -- same idiom as + // _pin_picker_active's "Pick slot" submenu below (rows as chanctl::scope(), + // 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, @@ -1670,7 +1671,7 @@ public: the_mesh.getChannel(_sel_channel_idx, ch); 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; + uint8_t hdr_sc_idx = p_hdr ? the_mesh.channelScope(_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 @@ -2076,9 +2077,9 @@ public: } auto res = _ctx_menu.handleInput(c); if (_scope_pick_active) { - // Scope sub-menu: row index == list index directly ("*" first). + // Scope sub-menu: Default first, then the list ("*" first). if (res == PopupMenu::SELECTED) { - the_mesh.setChannelScope(_ctx_ch_idx, (uint8_t)_ctx_menu.selectedIndex()); + the_mesh.setChannelScope(_ctx_ch_idx, chanctl::scopeFromRow(_ctx_menu.selectedIndex())); the_mesh.savePrefs(); } if (res != PopupMenu::NONE) _scope_pick_active = false; @@ -2121,12 +2122,12 @@ public: _history.setChUnread(ch_idx, 0); markReadAlert(cleared); } 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(); + snprintf(_ctx_scope_def, sizeof(_ctx_scope_def), "Default (%s)", sl.name(sl.default_idx)); _ctx_menu.begin("Scope", 4); + _ctx_menu.addItem(_ctx_scope_def); for (uint8_t i = 0; i <= sl.count; i++) _ctx_menu.addItem(sl.name(i)); - _ctx_menu.setSelected(cur); + _ctx_menu.setSelected(chanctl::scope(_task->getNodePrefs(), ch_idx)); _scope_pick_active = true; return true; // list rebuild below would close the submenu } else if (sel == 5) { // Pin / Unpin @@ -2211,9 +2212,9 @@ 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)); } + { const uint8_t row = chanctl::scope(_task->getNodePrefs(), ch_idx); + snprintf(_ctx_scope_item, sizeof(_ctx_scope_item), "Scope: %s", + row ? the_mesh.scopeList().name(row - 1) : "Default"); } _ctx_menu.begin("Channel options", 6); _ctx_menu.addItem("Mark all read"); _ctx_menu.addValueItem(_ctx_notif_item); diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index 5cdc2dda..77fa1cec 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -761,13 +761,9 @@ public: if (res == PopupMenu::SELECTED) { int sel = _scope_action_menu.selectedIndex(); if (sel == 0) { // Set as default + // DMs, the relay's primary slot and every channel left on + // "Default" follow it; the list marks it [default]. the_mesh.setDefaultScope((uint8_t)_scope_action_idx); - // "Default" is the protocol's own word (CMD_SET_DEFAULT_FLOOD_SCOPE, - // and what the app shows), but on its own it doesn't say default - // for *what* -- channels carry their own pick and don't inherit it. - // Spell out the two things it actually governs, at the moment the - // user sets it, rather than leaving the list marker to imply more. - _task->showAlert("Default: DMs + relay", 1400); } 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);