feat(companion): on-device scope + repeater scope filtering; fix CAD, UTF-8 truncation, Public channel, Nodes list, keyboard cursor

- Settings > Radio > Scope: type a community/region name on-device (derives
  the shared key the same "#name" -> SHA256 way as DEFAULT_FLOOD_SCOPE_NAME),
  previously only settable from a connected app.
- Tools > Repeater > Scope only + Extra scopes: only relay flood traffic
  matching the device's own scope or a comma-separated list of additional
  scopes, without changing what scope the device's own messages send under.
  No-op while unconfigured.
- getCADEnabled()/getInterferenceThreshold() were hardcoded off on
  companion_radio; CAD now auto-enables whenever RX power-save (duty-cycle)
  is active, since the noise floor isn't kept fresh during duty-cycle sleep.
- Message truncation to fit the send frame could split a multi-byte UTF-8
  character in half; now stops at the last complete character.
- The default "Public" channel was unconditionally re-added at every boot
  before the saved channel list was loaded, so deleting it never stuck.
  Only seeded now on a genuinely fresh device (no channel file yet).
- Tools > Nodes read contacts from the wrong starting offset, landing on
  internally-reserved bookkeeping slots instead of real contacts -- showed
  as blank "Unknown" rows and silently dropped that many real contacts off
  the end of the list.
- resetContacts() only cleared the first few reserved slots, not the whole
  contact table, contrary to its own comment; only reachable today via
  private-key import, fixed to match stated intent regardless.
- Keyboard's multi-line text preview could render the cursor on an empty
  line below short typed text instead of right after it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-08-21 10:40:50 +02:00
co-authored by Claude Sonnet 5
parent 7e10e0359c
commit f589b9b2d1
13 changed files with 283 additions and 27 deletions
@@ -577,10 +577,18 @@ struct KeyboardWidget {
// ...and the byte offset that line starts at.
int ps = 0;
for (int n = first_line * cpl; n > 0 && ps < len; n--) ps += kbUtf8CharBytesAt(buf, ps, len);
bool cursor_drawn = false; // draw it on exactly one line, even once the text itself runs out
for (int pl = 0; pl < prev_lines; pl++) {
int pe = ps; // byte offset cpl codepoints further along (or end of text)
for (int k = 0; k < cpl && pe < len; k++) pe += kbUtf8CharBytesAt(buf, pe, len);
bool cursor_here = (ps <= cursor_pos && (cursor_pos < pe || pl == prev_lines - 1));
// cursor_pos == len == pe is the common "typing at the end" case: that's
// this line's cursor only if THIS is where the text actually ends (pe ==
// len), not just whichever line happens to be the bottom of the preview
// area -- short text (fitting in fewer than prev_lines rows) would
// otherwise always show the cursor stranded on the last blank row
// instead of right after what was just typed.
bool cursor_here = !cursor_drawn && ps <= cursor_pos && (cursor_pos < pe || pe == len);
if (cursor_here) cursor_drawn = true;
int line_end = (len < pe) ? len : pe;
char linebuf[KB_PREVIEW_BYTES + 2]; // cpl codepoints + cursor '_' + NUL
if (cursor_here) {
+11 -2
View File
@@ -173,7 +173,16 @@ class NearbyScreen : public UIScreen {
int nc = the_mesh.getNumContacts();
for (int i = 0; i < nc && _count < MAX_NEARBY; i++) {
ContactInfo ci;
if (!the_mesh.getContactByIdx(i, ci)) continue;
// getContactByIdx() indexes the RAW contact table, whose first
// MAX_ANON_CONTACTS slots are reserved for anon-request bookkeeping
// (see BaseChatMesh::resetContacts()/ContactsIterator) -- getNumContacts()
// already excludes them from the count, so real contact 0 lives at raw
// index MAX_ANON_CONTACTS, not 0. Reading from 0 pulled those reserved
// (blank, type=ADV_TYPE_NONE) slots into the list as bogus "Unknown"
// rows, and silently dropped the same number of real contacts off the
// end -- while never touching the anon slots themselves, so it always
// reproduced the same way regardless of the auto-add overwrite setting.
if (!the_mesh.getContactByIdx(i + MAX_ANON_CONTACTS, ci)) continue;
if (!typeMatchesFilter(ci.type, ci.flags, true)) continue;
Entry& e = _entries[_count++];
@@ -188,7 +197,7 @@ class NearbyScreen : public UIScreen {
? geo::haversineKm(_own_lat, _own_lon, ci.gps_lat, ci.gps_lon)
: -1.0f;
e.type = ci.type;
e.contact_idx = i;
e.contact_idx = i + MAX_ANON_CONTACTS; // raw index -- other lookups re-key off this directly
e.lastmod = ci.lastmod;
e.is_known = true;
e.is_live = false;
@@ -35,9 +35,10 @@ class RepeaterScreen : public UIScreen {
enum Item {
IT_REPEATER, IT_NETWORK,
IT_RPRESET, IT_RFREQ, IT_RSF, IT_RBW, IT_RCR, // dedicated profile (Custom network)
IT_SKIP, IT_HOPS, IT_YIELD, IT_SNR, IT_SUPPRESS
IT_SKIP, IT_HOPS, IT_YIELD, IT_SNR, IT_SUPPRESS, IT_SCOPE, IT_SCOPE_EXTRA
};
uint8_t _items[12];
uint8_t _items[14];
bool _editing_scope; // keyboard is entering/editing the extra scopes
int _item_count;
RadioPresetPicker _picker;
@@ -66,6 +67,8 @@ class RepeaterScreen : public UIScreen {
_items[_item_count++] = IT_YIELD;
_items[_item_count++] = IT_SNR;
_items[_item_count++] = IT_SUPPRESS;
_items[_item_count++] = IT_SCOPE;
_items[_item_count++] = IT_SCOPE_EXTRA;
}
if (_sel >= _item_count) _sel = _item_count - 1;
if (_sel < 0) _sel = 0;
@@ -84,7 +87,9 @@ class RepeaterScreen : public UIScreen {
case IT_HOPS: return "Max hops";
case IT_YIELD: return "Yield";
case IT_SNR: return "Min SNR";
case IT_SUPPRESS: return "Suppress dup";
case IT_SUPPRESS: return "Suppress dup";
case IT_SCOPE: return "Scope only";
case IT_SCOPE_EXTRA: return "Extra scopes";
}
return "";
}
@@ -113,6 +118,10 @@ class RepeaterScreen : public UIScreen {
else strncpy(buf, "OFF", n);
break;
case IT_SUPPRESS: strncpy(buf, p->repeat_suppress_dup ? "ON" : "OFF", n); break;
case IT_SCOPE: strncpy(buf, p->repeat_scope_only ? "ON" : "OFF", n); break;
case IT_SCOPE_EXTRA:
strncpy(buf, p->repeat_extra_scopes[0] ? p->repeat_extra_scopes : "(none)", n);
break;
default: strncpy(buf, "", n); break;
}
buf[n - 1] = '\0';
@@ -126,16 +135,17 @@ class RepeaterScreen : public UIScreen {
}
public:
RepeaterScreen(UITask* task) : _task(task), _dirty(false), _sel(0), _scroll(0), _item_count(1) {}
RepeaterScreen(UITask* task) : _task(task), _dirty(false), _sel(0), _scroll(0), _item_count(1), _editing_scope(false) {}
void onShow() override {
_dirty = false; _sel = 0; _scroll = 0;
_picker.menu.active = false; _editor.freq.active = false;
_picker.saving = false; _picker.deleting = false;
_editing_scope = false;
}
int render(DisplayDriver& display) override {
if (_picker.saving) return _task->keyboard().render(display);
if (_picker.saving || _editing_scope) return _task->keyboard().render(display);
NodePrefs* p = _task->getNodePrefs();
buildItems(p);
@@ -181,6 +191,23 @@ 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;
}
return true;
}
// Modal overlays first.
if (_picker.menu.active) {
auto res = _picker.menu.handleInput(c);
@@ -279,6 +306,14 @@ public:
if (item == IT_SUPPRESS && (left || right || enter)) {
p->repeat_suppress_dup ^= 1; _dirty = true; return true;
}
if (item == IT_SCOPE && (left || right || enter)) {
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;
return true;
}
return false;
}
};
@@ -61,6 +61,7 @@ class SettingsScreen : public UIScreen {
CUSTOM_FREQ, CUSTOM_SF, CUSTOM_BW, CUSTOM_CR,
POWER_SAVE,
TX_APC,
SCOPE_NAME,
// System section
SECTION_SYSTEM,
DEVICE_NAME,
@@ -555,6 +556,11 @@ class SettingsScreen : public UIScreen {
// Suppressed (and locked) while repeating — a repeater holds full TX power.
if (p && p->client_repeat) display.print("--");
else display.print((p && p->tx_apc) ? "ON" : "OFF");
} else if (item == SCOPE_NAME) {
display.print("Scope");
int vx = valCol(display);
display.drawTextEllipsized(vx, y, display.width() - vx - _reserve,
(p && p->default_scope_name[0]) ? p->default_scope_name : "(none)");
#if AUTO_OFF_MILLIS > 0
} else if (item == AUTO_OFF) {
display.print("AutoOff");
@@ -678,6 +684,7 @@ 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;
// Radio preset picker — names are too long for the value column, so Enter on
@@ -701,6 +708,7 @@ public:
void onShow() override {
_dirty = false;
_edit_name = false;
_edit_scope = false;
resetList();
_editor.freq.active = false;
}
@@ -708,7 +716,7 @@ public:
int render(DisplayDriver& display) override {
display.setTextSize(1);
if (_edit_slot >= 0 || _edit_name || _picker.saving) {
if (_edit_slot >= 0 || _edit_name || _edit_scope || _picker.saving) {
return _kb->render(display);
}
@@ -771,6 +779,19 @@ public:
return true;
}
// Keyboard editing mode for the scope name
if (_edit_scope) {
auto res = _kb->handleInput(c);
if (res == KeyboardWidget::DONE) {
the_mesh.setPrimaryScope(_kb->buf);
_dirty = true;
_edit_scope = false;
} else if (res == KeyboardWidget::CANCELLED) {
_edit_scope = false;
}
return true;
}
// Digit-by-digit Freq editor
if (_editor.active()) {
if (_editor.handleFreqInput(c) && p) { _task->applyRadioParams(); _dirty = true; }
@@ -966,6 +987,12 @@ public:
_kb->clearPlaceholders(); // a device name is literal, not a message
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
return true;
}
if (_selected == REBOOT && enter) {
_task->savePrefsIfDirty(_dirty); // don't lose pending edits across the restart
_task->showAlert("Rebooting...", 800);