mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-08-02 10:16:11 +00:00
The "_dirty bool, then if (_dirty) the_mesh.savePrefs() on exit" pattern was duplicated across 9 screens with subtle divergence: some cleared the flag after saving, some left it set and relied on onShow() to reset. Replace all 12 exit sites with a single _task->savePrefsIfDirty(flag) helper that saves once only if dirty and always clears the flag, so the "did we touch flash?" answer lives in one place and the reset is consistent. Edit sites still mark the flag manually (inherent to change tracking); only the persist-on-exit boilerplate is centralised. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
284 lines
11 KiB
C++
284 lines
11 KiB
C++
#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"
|
||
|
||
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
|
||
};
|
||
uint8_t _items[12];
|
||
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 };
|
||
}
|
||
|
||
void buildItems(NodePrefs* p) {
|
||
_item_count = 0;
|
||
_items[_item_count++] = IT_REPEATER;
|
||
if (p && p->client_repeat) {
|
||
_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;
|
||
}
|
||
if (_sel >= _item_count) _sel = _item_count - 1;
|
||
if (_sel < 0) _sel = 0;
|
||
}
|
||
|
||
static const char* itemLabel(int item) {
|
||
switch (item) {
|
||
case IT_REPEATER: return "Repeater";
|
||
case IT_NETWORK: return "Network";
|
||
case IT_RPRESET: return "Rpt preset";
|
||
case IT_RFREQ: return "Rpt freq";
|
||
case IT_RSF: return "Rpt SF";
|
||
case IT_RBW: return "Rpt BW";
|
||
case IT_RCR: return "Rpt 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";
|
||
}
|
||
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;
|
||
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;
|
||
}
|
||
|
||
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];
|
||
display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel);
|
||
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);
|
||
return (_picker.menu.active || _editor.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;
|
||
}
|
||
|
||
// 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);
|
||
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;
|
||
}
|
||
return true;
|
||
}
|
||
if (_editor.active()) {
|
||
if (_editor.handleFreqInput(c) && p) { the_mesh.applyRepeaterRadio(); _dirty = true; }
|
||
return true;
|
||
}
|
||
|
||
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) {
|
||
_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;
|
||
}
|
||
return false;
|
||
}
|
||
};
|