Merge PR #311: pick a region from a known-regions list

oumike. Closes #271.

Region presets stop being freq/BW/SF typed in by hand: the list carries the
legal frequency and duty-cycle for each region, so picking one sets a
coherent set rather than three fields that have to agree.
This commit is contained in:
Kaj Schittecat committed 2026-08-22 21:48:52 +02:00
commit 8cd8a5366c
18 files changed
+563 -1

No files matched your search

+217 -1
View File
@@ -4831,6 +4831,7 @@ static void chanScopeClose(); // fwd
static bool blockedModalIsOpen();// fwd: the bar's Back chevron also closes the blocked-users sheet
static void blockedModalClose(); // fwd
static void openBlockedUsersModal(); // ignore-list manager (unblock)
static void openRegionsModal(); // known-regions manager (#271)
static bool overlayBlocksTabSwipe(); // theme/channel-scope pickers swallow tab swipes
static bool drawerPopupOpen(); // popups floating over the app drawer (signal/mentions/power/files)
static void refreshContactsList();
@@ -9136,6 +9137,11 @@ static void saveRadioParamsCb(lv_event_t* e) {
if (has_region_ta) {
the_mesh.setDefaultFloodScope(region);
touchPrefsSetRegionScope(region);
// #271: keep the registry in step with the region we just adopted, so its
// messages are named immediately. Any previously-registered region keeps its
// slot, so changing our own region does not un-name the history we
// received while we were in the old one.
if (region[0]) the_mesh.regionRegistry().ensureRegion(region);
has_region = (region[0] != '\0');
}
if (ok) {
@@ -10617,6 +10623,25 @@ static void buildRadioSettings() {
lv_obj_update_layout(note);
y += lv_obj_get_height(note) + SC(8);
}
// #271: the regions above are the ones we SEND under. This opens the list of
// regions we can RECOGNISE. Naming them in message details needs only the
// name, since the key is derived from it.
{
lv_obj_t* rb = lv_btn_create(body);
lv_obj_set_size(rb, s_settings_content_w - SC(4), SC(34));
lv_obj_set_pos(rb, 2, y);
styleButton(rb);
lv_obj_add_event_cb(rb, [](lv_event_t* e) {
if (lv_event_get_code(e) != LV_EVENT_CLICKED) return;
closeSettingsModal();
openRegionsModal();
}, LV_EVENT_CLICKED, nullptr);
lv_obj_t* rl = lv_label_create(rb);
lv_label_set_text(rl, TR(LV_SYMBOL_LIST " Known regions"));
lv_obj_set_style_text_font(rl, &g_font_14, LV_PART_MAIN);
lv_obj_center(rl);
y += SC(40);
}
// Opt-in: also scope DIRECT messages / logins / remote-management floods to the region
// (not just channels). For networks where your only repeater re-floods a single region.
@@ -32449,8 +32474,22 @@ static void openMessageInfoPopup(int msg_idx) {
// changing (#259). Report what was actually determined: whether it verified
// against our own region key. The hex stays, in parentheses, for anyone
// cross-checking a capture.
// #271: when the code verified against one of the registered regions we can
// name it outright. Fall back to the older my-region / another-region
// answer when nothing matched, which is still all we can honestly say.
char region[40];
if (m.meta_flags & UITask::MSG_META_SCOPE_HOME) {
const uint8_t rslot = UITask::metaScopeSlot(m.meta_flags);
const char* rname = (rslot && rslot != REGION_SLOT_AMBIGUOUS)
? the_mesh.regionRegistry().nameForSlot(rslot) : nullptr;
if (rslot == REGION_SLOT_AMBIGUOUS) {
// Several registered regions produced this same 16-bit code. Naming any
// one of them would be a guess, and a confident wrong region reads worse
// than an honest "cannot tell". The tag is only 16 bits, so with N keys
// live a collision runs at about N/65534 per packet.
snprintf(region, sizeof region, "%s", TR("ambiguous (several regions matched)"));
} else if (rname) {
snprintf(region, sizeof region, "%s", rname);
} else if (m.meta_flags & UITask::MSG_META_SCOPE_HOME) {
char home[24] = {0};
touchPrefsGetRegionScope(home, sizeof home);
snprintf(region, sizeof region, "%s", home[0] ? home : TR("my region"));
@@ -44273,6 +44312,11 @@ static void chanScopeSaveCb(lv_event_t* e) {
const char* t = lv_textarea_get_text(s_chanscope_ta);
#if defined(ESP32)
touchPrefsSetChannelScope(s_chanscope_slot, t ? t : "");
// #271: register it now rather than at next boot, so messages arriving in
// this region are named from the next packet on. Idempotent; the old region
// keeps its slot, since a slot outlives the config that introduced it (old
// messages must keep resolving to the region they actually arrived under).
if (t && t[0]) the_mesh.regionRegistry().ensureRegion(t);
#endif
}
chanScopeClose();
@@ -44333,6 +44377,153 @@ static void blockedUnblockNameCb(lv_event_t* e) {
}
openBlockedUsersModal(); // rebuild the list
}
// ---- Known regions manager (#271) -------------------------------------------
// The list that makes scope naming possible. A region's key is derived from its
// NAME (SHA256("#tag")), so holding the name is enough to recompute an incoming
// packet's transport code and recognise it. That is why adding a region here
// names its traffic even though we do not participate in it.
//
// Adding is the whole point of the feature; removing is deliberately "retire":
// the slot and name are kept so messages already received under that region keep
// resolving, it just stops being matched from now on. See RegionRegistry.
static lv_obj_t* s_regions_modal = nullptr;
static lv_obj_t* s_regions_ta = nullptr;
static void openRegionsModal(); // fwd: the add/remove callbacks rebuild the page
static void regionsModalClose() {
if (s_regions_modal) { hideKb(); popupClose(&s_regions_modal); }
s_regions_ta = nullptr;
if (s_apppage_close == regionsModalClose) {
s_apppage_title = nullptr; s_apppage_close = nullptr;
statusBarSetTall(false); updateGlobalStatusBar();
}
}
static void regionsAddCb(lv_event_t* e) {
if (lv_event_get_code(e) != LV_EVENT_CLICKED) return;
if (!s_regions_ta) return;
const char* t = lv_textarea_get_text(s_regions_ta);
if (!t || !t[0]) { if (g_lv.task) g_lv.task->showAlert(TR("Enter a region name"), 1400); return; }
RegionRegistry& reg = the_mesh.regionRegistry();
if (reg.ensureRegion(t) == REGION_SLOT_NONE) {
// Two different failures, and the user can only act on one of them: the list
// being full, or the name canonicalising away to nothing (blank, or a bare
// "#"). Say which rather than silently not adding a row.
if (g_lv.task) g_lv.task->showAlert(reg.isFull() ? TR("Region list is full")
: TR("Enter a region name"), 2000);
return;
}
openRegionsModal(); // rebuild (closes first)
}
static void regionsRemoveCb(lv_event_t* e) {
if (lv_event_get_code(e) != LV_EVENT_CLICKED) return;
const uint8_t slot = (uint8_t)(intptr_t)lv_event_get_user_data(e);
the_mesh.regionRegistry().retire(slot);
openRegionsModal(); // rebuild
}
static void openRegionsModal() {
regionsModalClose();
const lv_coord_t sw = lv_disp_get_hor_res(nullptr);
const lv_coord_t sh = lv_disp_get_ver_res(nullptr);
// Go tall BEFORE measuring, and measure with statusBarCurH() rather than
// chatBarH(). They disagree on the Pager: chatBarH() is one row there (that
// board keeps back/cog/title on a single line), but statusBarSetTall() still
// doubles the bar to STATUSBAR_H * 2. Laying out against chatBarH() therefore
// put the page origin a whole row UNDER the bar and the top control was clipped
// by it. statusBarCurH() is the height the bar actually has once tall, so the
// content clears it on every board and this stays identical elsewhere.
s_apppage_title = "Known regions";
s_apppage_close = regionsModalClose;
statusBarSetTall(true);
const lv_coord_t top = statusBarCurH();
s_regions_modal = lv_obj_create(lv_layer_top());
lv_obj_remove_style_all(s_regions_modal);
lv_obj_set_size(s_regions_modal, sw, sh - top);
lv_obj_set_pos(s_regions_modal, 0, top);
lv_obj_set_style_bg_color(s_regions_modal, lv_color_hex(COLOR_BG), LV_PART_MAIN);
lv_obj_set_style_bg_opa(s_regions_modal, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_clear_flag(s_regions_modal, LV_OBJ_FLAG_SCROLLABLE);
updateGlobalStatusBar();
lv_obj_move_foreground(s_regions_modal);
if (g_statusbar.root) lv_obj_move_foreground(g_statusbar.root);
const lv_coord_t pad = SC(6);
lv_coord_t y = SC(6);
// Add row: field + button on one line, so the common action is reachable
// without scrolling on the short panels too.
const lv_coord_t btn_w = SC(64);
s_regions_ta = lv_textarea_create(s_regions_modal);
lv_textarea_set_one_line(s_regions_ta, true);
lv_textarea_set_max_length(s_regions_ta, TOUCH_REGION_SCOPE_MAXLEN - 1);
lv_obj_set_size(s_regions_ta, sw - pad * 3 - btn_w, SC(32));
lv_obj_set_pos(s_regions_ta, pad, y);
taSetPlaceholder(s_regions_ta, TR("#region"));
attachSettingsTaEvents(s_regions_ta);
lv_obj_t* add = lv_btn_create(s_regions_modal);
lv_obj_set_size(add, btn_w, SC(32));
lv_obj_set_pos(add, sw - pad - btn_w, y);
styleButton(add);
lv_obj_add_event_cb(add, regionsAddCb, LV_EVENT_CLICKED, nullptr);
{ lv_obj_t* l = lv_label_create(add); lv_label_set_text(l, TR("Add"));
lv_obj_set_style_text_font(l, &g_font_12, LV_PART_MAIN); lv_obj_center(l); }
y += SC(38);
RegionRegistry& reg = the_mesh.regionRegistry();
if (reg.count() <= 0) {
lv_obj_t* empty = lv_label_create(s_regions_modal);
lv_label_set_text(empty, TR("No regions yet.\n\nAdd a #region to have messages\nscoped to it named in message details."));
lv_label_set_long_mode(empty, LV_LABEL_LONG_WRAP);
lv_obj_set_width(empty, sw - pad * 2);
lv_obj_set_style_text_color(empty, lv_color_hex(COLOR_SUB), LV_PART_MAIN);
lv_obj_set_style_text_font(empty, &g_font_12, LV_PART_MAIN);
lv_obj_set_pos(empty, pad, y);
return;
}
lv_obj_t* list = lv_obj_create(s_regions_modal);
lv_obj_remove_style_all(list);
lv_obj_set_size(list, sw - pad * 2, sh - top - y - SC(6));
lv_obj_set_pos(list, pad, y);
lv_obj_set_flex_flow(list, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(list, SC(5), LV_PART_MAIN);
lv_obj_set_scroll_dir(list, LV_DIR_VER);
for (uint8_t slot = 1; slot <= REGION_SLOT_MAX; ++slot) {
if (!reg.isActive(slot)) continue;
const char* nm = reg.nameForSlot(slot);
if (!nm || !nm[0]) continue;
lv_obj_t* row = lv_obj_create(list);
lv_obj_remove_style_all(row);
lv_obj_set_size(row, sw - pad * 2 - SC(4), SC(36));
lv_obj_set_style_bg_color(row, lv_color_hex(COLOR_PANEL), LV_PART_MAIN);
lv_obj_set_style_bg_opa(row, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_radius(row, 6, LV_PART_MAIN);
lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* lbl = lv_label_create(row);
lv_label_set_text(lbl, nm);
lv_label_set_long_mode(lbl, LV_LABEL_LONG_DOT);
lv_obj_set_width(lbl, sw - pad * 2 - SC(4) - SC(84));
lv_obj_set_style_text_color(lbl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN);
lv_obj_set_style_text_font(lbl, &g_font_14, LV_PART_MAIN);
lv_obj_align(lbl, LV_ALIGN_LEFT_MID, SC(6), 0);
lv_obj_t* rm = lv_btn_create(row);
lv_obj_set_size(rm, SC(72), SC(28));
lv_obj_align(rm, LV_ALIGN_RIGHT_MID, -SC(5), 0);
styleButton(rm);
lv_obj_add_event_cb(rm, regionsRemoveCb, LV_EVENT_CLICKED, (void*)(intptr_t)slot);
{ lv_obj_t* l = lv_label_create(rm); lv_label_set_text(l, TR("Remove"));
lv_obj_set_style_text_font(l, &g_font_12, LV_PART_MAIN); lv_obj_center(l); }
}
}
static void openBlockedUsersModal() {
blockedModalClose();
const lv_coord_t sw = lv_disp_get_hor_res(nullptr);
@@ -49570,6 +49761,27 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
// factor rewritten + persisted; see healPresetAirtimeFactor for the guard.
healPresetAirtimeFactor();
// #271: seed the region registry with the default and per-channel scopes held
// in touch prefs. MyMesh::begin() seeds the companion-configured default from
// NodePrefs; the touch UI stores its display name separately, so it must be
// included here as well. ensureRegion() canonicalises and de-duplicates, so
// channels sharing a region collapse to one slot and re-running each boot is a
// no-op. Registering a region is what lets an incoming message be NAMED as
// being from it. A channel scoped to "#denmark" means we hold that key, so
// every "#denmark" message becomes identifiable, not just this channel's.
{
char rgn[TOUCH_REGION_SCOPE_MAXLEN] = {0};
if (touchPrefsGetRegionScope(rgn, sizeof rgn) > 0 && rgn[0])
the_mesh.regionRegistry().ensureRegion(rgn);
}
#ifdef MAX_GROUP_CHANNELS
for (int i = 0; i < MAX_GROUP_CHANNELS; ++i) {
char rgn[TOUCH_REGION_SCOPE_MAXLEN] = {0};
if (touchPrefsGetChannelScope(i, rgn, sizeof rgn) > 0 && rgn[0])
the_mesh.regionRegistry().ensureRegion(rgn);
}
#endif
// #207 auto-retry: apply the persisted Radio & Mesh toggle (default ON).
the_mesh.setCompanionRetryEnabled(touchPrefsGetRetryEcho());
@@ -51725,6 +51937,10 @@ void UITask::newMsgImpl(uint8_t path_len, const char* from_name, const char* tex
meta_flags |= MSG_META_HAS_SCOPE;
// Verified at RX (the check needs the packet); the popup only reads the bit.
if (the_mesh.lastRxScopeIsHome()) meta_flags |= MSG_META_SCOPE_HOME;
// Which registered region it verified against (#271), also decided at RX
// for the same reason: the packet is gone by the time the popup opens, so
// the answer has to be captured here and persisted with the message.
meta_flags = metaWithScopeSlot(meta_flags, the_mesh.lastRxScopeSlot());
}
}
const int msg_slot = _ui_msg_head; // appendMessage writes _ui_msgs[head], then advances head