feat: a channel's scope can follow the default -- Default is the new start value

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-06 14:09:22 +02:00
co-authored by Claude Opus 5.5
parent 5a92d36509
commit c4217d8632
8 changed files with 63 additions and 58 deletions
+5
View File
@@ -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
+9 -26
View File
@@ -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);
+10 -3
View File
@@ -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
+9 -2
View File
@@ -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;
@@ -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);
@@ -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();
}
@@ -63,10 +63,11 @@ class MessagesScreen : public UIScreen {
char _ctx_notif_item[22];
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).
char _ctx_scope_def[34]; // the picker's first row: "Default (<name>)"
// 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);
@@ -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);