Files
MeshCore-Solo/examples/companion_radio/ui-new/RepeaterScreen.h
T
JakubandClaude Sonnet 5 89c02eea27 feat(companion): shared named-scope list, per-channel/repeater popup pickers
Replace the single device-wide Scope text field with a small named list
(wildcard "*" fixed first, plus a movable default): each channel picks one
scope of its own (matching the app's per-channel region picker, surfaced in
the channel-history title), and the repeater's Extra scopes multi-selects
from the same list instead of comma-typed names. Both pickers are popups
(PopupMenu gains addCheckItem()/a fillable-square checkbox glyph) over the
existing screen rather than a full-screen takeover.

Also fixes a stray-bits bug where a pre-existing prefs file's own sentinel
tail could read into the new repeat_extra_scope_mask/ch_scope_idx fields and
silently reappear as a real pick later once the scope list grew into that
range.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 00:05:40 +02:00

353 lines
15 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#pragma once
// Tools Repeater — consolidates the repeater toggle, its flood forwarding
// filters, and an optional dedicated radio profile on one screen. A dedicated
// screen (vs. the old Settings Radio sub-items) gives full-width rows, so the
// longer labels no longer collide with the value column. Live forwarding stats
// live separately on Tools Diagnostics.
//
// Network modes:
// - Current — repeat on the companion's current frequency. Opt-in only:
// relaying on whatever network the operator is chatting on
// isn't the MeshCore community norm.
// - Custom — enabling the repeater switches the radio to a dedicated profile
// (preset or manual), and disabling restores the companion params.
// Default for a never-configured device, seeded with a frequency
// in the same band as the companion's own network (so the default
// can't land outside what's legal for the operator's region).
// Included by UITask.cpp.
#include <helpers/ui/DisplayDriver.h>
#include <helpers/ui/UIScreen.h>
#include "icons.h"
#include "RadioParamsEditor.h"
#include "RadioPresetPicker.h"
#include "../RadioPresets.h"
#include "../MyMesh.h"
#include "PopupMenu.h"
extern MyMesh the_mesh;
class RepeaterScreen : public UIScreen {
UITask* _task;
bool _dirty;
int _sel; // index into the interactive items currently shown
int _scroll; // first visible row (render keeps _sel in view)
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_SCOPE, IT_SCOPE_EXTRA
};
uint8_t _items[14];
bool _scope_picker_active = false; // multi-select popup over the extra scopes
PopupMenu _scope_menu;
int _item_count;
RadioPresetPicker _picker;
RadioParamsEditor _editor;
// The dedicated repeater profile's fields, as the shared preset picker's target
// (Settings Radio points the same picker at the companion's own params).
RadioPresetPicker::Target rptTarget(NodePrefs* p) const {
return { &p->repeater_freq, &p->repeater_bw, &p->repeater_sf, &p->repeater_cr };
}
// Shown regardless of the Repeater ON/OFF row itself -- these are plain
// NodePrefs settings, no different from Bot's per-target rows or Live
// Share's move/gap/heartbeat, so they stay configurable (and visible) while
// off rather than forcing an enable/configure/maybe-disable-again dance.
void buildItems(NodePrefs* p) {
_item_count = 0;
_items[_item_count++] = IT_REPEATER;
if (p) {
_items[_item_count++] = IT_NETWORK;
if (p->repeater_use_profile) {
_items[_item_count++] = IT_RPRESET;
_items[_item_count++] = IT_RFREQ;
_items[_item_count++] = IT_RSF;
_items[_item_count++] = IT_RBW;
_items[_item_count++] = IT_RCR;
}
_items[_item_count++] = IT_SKIP;
_items[_item_count++] = IT_HOPS;
_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;
}
// Freq/SF/BW/CR match Settings > Radio's own terminology exactly -- no "Rpt "
// prefix needed since this whole screen is already the repeater's own
// profile (Settings' radio screen is a separate screen for the companion's
// own params), so the prefix was just noise, not disambiguation.
static const char* itemLabel(int item) {
switch (item) {
case IT_REPEATER: return "Repeater";
case IT_NETWORK: return "Network";
case IT_RPRESET: return "Preset";
case IT_RFREQ: return "Freq";
case IT_RSF: return "SF";
case IT_RBW: return "BW";
case IT_RCR: return "CR";
case IT_SKIP: return "Skip advert";
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_SCOPE: return "Scope only";
case IT_SCOPE_EXTRA: return "Extra scopes";
}
return "";
}
void itemValue(int item, NodePrefs* p, char* buf, size_t n) const {
if (!p) { strncpy(buf, "OFF", n); buf[n-1]=0; return; }
switch (item) {
case IT_REPEATER: strncpy(buf, p->client_repeat ? "ON" : "OFF", n); break;
case IT_NETWORK: strncpy(buf, p->repeater_use_profile ? "Custom" : "Current", n); break;
case IT_RPRESET: strncpy(buf, _picker.currentName(p, rptTarget(p)), n); break;
case IT_RFREQ: snprintf(buf, n, "%.3f", p->repeater_freq); break;
case IT_RSF: snprintf(buf, n, "%d", (int)p->repeater_sf); break;
case IT_RBW: snprintf(buf, n, "%.1f", p->repeater_bw); break;
case IT_RCR: snprintf(buf, n, "%d", (int)p->repeater_cr); break;
case IT_SKIP: strncpy(buf, p->repeat_skip_adverts ? "ON" : "OFF", n); break;
case IT_HOPS:
if (p->repeat_max_hops > 0) snprintf(buf, n, "%d", (int)p->repeat_max_hops);
else strncpy(buf, "OFF", n);
break;
case IT_YIELD:
if (p->repeat_delay_boost > 0) snprintf(buf, n, "x%d", (int)p->repeat_delay_boost + 1);
else strncpy(buf, "OFF", n);
break;
case IT_SNR:
if (p->repeat_min_snr != NodePrefs::REPEAT_SNR_DISABLED) snprintf(buf, n, "%ddB", (int)p->repeat_min_snr);
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: {
// 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';
}
// Seed the profile from the companion's current params (used when first
// switching to Custom, so the starting point is a valid, familiar config).
void seedProfileFromCompanion(NodePrefs* p) {
p->repeater_freq = p->freq; p->repeater_bw = p->bw;
p->repeater_sf = p->sf; p->repeater_cr = p->cr;
}
public:
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;
_scope_picker_active = false; _scope_menu.active = false;
}
int render(DisplayDriver& display) override {
if (_picker.saving) return _task->keyboard().render(display);
NodePrefs* p = _task->getNodePrefs();
buildItems(p);
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
display.drawCenteredHeader("REPEATER");
// Config only — live forwarding stats live on Tools Diagnostics.
drawList(display, _item_count, _sel, _scroll, [&](int row, int y, bool sel, int reserve) {
int item = _items[row];
drawRowSelection(display, y, sel, reserve);
display.setCursor(2, y);
display.print(itemLabel(item));
if (item == IT_RFREQ && sel && _editor.active()) {
_editor.render(display, display.valCol(), y);
} else {
char val[16];
itemValue(item, p, val, sizeof(val));
display.drawTextRightAlign(display.width() - reserve - 2, y, val);
}
display.setColor(DisplayDriver::LIGHT);
});
display.setColor(DisplayDriver::LIGHT);
if (_picker.menu.active) _picker.menu.render(display);
if (_scope_picker_active) _scope_menu.render(display);
return (_picker.menu.active || _editor.active() || _scope_picker_active) ? 50 : 500;
}
bool handleInput(char c) override {
NodePrefs* p = _task->getNodePrefs();
// Keyboard editing mode for naming a new saved preset
if (_picker.saving) {
auto res = _task->keyboard().handleInput(c);
if (res == KeyboardWidget::DONE) {
if (_picker.save(p, _task->keyboard().buf, rptTarget(p))) {
_dirty = true;
_task->showAlert("Preset saved", 800);
}
_picker.saving = false;
} else if (res == KeyboardWidget::CANCELLED) {
_picker.saving = false;
}
return true;
}
// 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;
}
// Modal overlays first.
if (_picker.menu.active) {
auto res = _picker.menu.handleInput(c);
if (res == PopupMenu::SELECTED && p) {
switch (_picker.onSelected(_picker.menu.selectedIndex(), p, rptTarget(p))) {
case RadioPresetPicker::START_SAVE:
_task->keyboard().begin("", (int)sizeof(p->user_radio_presets[0].name) - 1);
_task->keyboard().clearPlaceholders(); // {loc}/{time} are for messages, not preset names
break;
case RadioPresetPicker::APPLIED:
the_mesh.applyRepeaterRadio(); // live if currently relaying on the profile
_dirty = true;
break;
case RadioPresetPicker::DELETED:
_dirty = true;
_task->showAlert("Preset deleted", 800);
break;
case RadioPresetPicker::NONE:
break;
}
} else if (res == PopupMenu::CANCELLED) {
_picker.deleting = false;
_picker.confirm_slot = -1;
}
return true;
}
if (_editor.active()) {
if (_editor.handleFreqInput(c) && p) { the_mesh.applyRepeaterRadio(); _dirty = true; }
return true;
}
if (c == KEY_CANCEL) {
_task->savePrefsIfDirty(_dirty);
_task->gotoToolsScreen();
return true;
}
if (c == KEY_UP) { _sel = (_sel > 0) ? _sel - 1 : _item_count - 1; return true; }
if (c == KEY_DOWN) { _sel = (_sel < _item_count - 1) ? _sel + 1 : 0; return true; }
if (!p) return false;
bool right = keyIsNext(c);
bool left = keyIsPrev(c);
bool enter = (c == KEY_ENTER);
int item = _items[_sel];
if (item == IT_REPEATER && (left || right || enter)) {
p->client_repeat ^= 1;
the_mesh.applyRepeaterRadio(); // switch to profile on enable / restore companion on disable
_task->applyPowerSave(); // duty-cycle RX is forced off while repeating
_task->applyApc(); // pin TX power to the ceiling while repeating (APC suppressed)
buildItems(p);
_dirty = true;
return true;
}
if (item == IT_NETWORK && (left || right || enter)) {
p->repeater_use_profile ^= 1;
if (p->repeater_use_profile && !the_mesh.repeaterProfileValid())
seedProfileFromCompanion(p); // start Custom from a valid, familiar config
the_mesh.applyRepeaterRadio();
buildItems(p);
_dirty = true;
return true;
}
if (item == IT_RPRESET && enter) { _picker.open(p, rptTarget(p), "Repeater Preset"); return true; }
if (item == IT_RFREQ && enter) {
float lo, hi; radio_driver.getFreqBounds(lo, hi);
_editor.beginFreq(p->repeater_freq, lo, hi);
return true;
}
int dir = right ? 1 : (left ? -1 : 0);
if (item == IT_RSF && dir && RadioParamsEditor::stepSF(p->repeater_sf, dir)) { the_mesh.applyRepeaterRadio(); _dirty = true; return true; }
if (item == IT_RBW && dir && RadioParamsEditor::stepBW(p->repeater_bw, dir)) { the_mesh.applyRepeaterRadio(); _dirty = true; return true; }
if (item == IT_RCR && dir && RadioParamsEditor::stepCR(p->repeater_cr, dir)) { the_mesh.applyRepeaterRadio(); _dirty = true; return true; }
if (item == IT_SKIP && (left || right || enter)) {
p->repeat_skip_adverts ^= 1; _dirty = true; return true;
}
if (item == IT_HOPS) {
if (right && p->repeat_max_hops < 8) { p->repeat_max_hops++; _dirty = true; return true; }
if (left && p->repeat_max_hops > 0) { p->repeat_max_hops--; _dirty = true; return true; }
}
if (item == IT_YIELD) {
if (right && p->repeat_delay_boost < 8) { p->repeat_delay_boost++; _dirty = true; return true; }
if (left && p->repeat_delay_boost > 0) { p->repeat_delay_boost--; _dirty = true; return true; }
}
if (item == IT_SNR) {
int8_t v = p->repeat_min_snr;
if (right) {
v = (v == NodePrefs::REPEAT_SNR_DISABLED) ? -20 : (v < 10 ? v + 1 : 10);
p->repeat_min_snr = v; _dirty = true; return true;
}
if (left) {
if (v != NodePrefs::REPEAT_SNR_DISABLED)
p->repeat_min_snr = (v <= -20) ? NodePrefs::REPEAT_SNR_DISABLED : (int8_t)(v - 1);
_dirty = true; return true;
}
}
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) {
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;
}
};