feat(repeater): politeness controls, dedicated radio profile, and diagnostics

Settings > Radio gains a repeater toggle, 16 community-suggested radio
presets plus manual Freq/SF/BW/CR tuning (digit-by-digit Freq editor),
4 persisted user preset slots, and the packet pool bumped 16->32 so
queued retransmits stop starving incoming-packet allocation while
relaying.

Four opt-in politeness knobs (skip-advert, max-hops, yield, min-SNR),
all flood-only and off by default, plus overhear suppression that
cancels a queued retransmit if a peer relays the same packet first.
Adaptive Power Control is suppressed while relaying (pins TX power to
the ceiling) and duty-cycle RX is forced off, since a repeater needs
to hear and relay at consistent power.

A dedicated Tools > Repeater screen consolidates the toggle, the
politeness knobs, and live forwarding stats, plus an optional "Custom"
radio profile — a dedicated frequency/SF/BW/CR for relaying, separate
from the companion's own network, band-matched to the companion
frequency by default and used everywhere a radio change can happen
(boot, on-device toggle, app-driven CMD_SET_RADIO_PARAMS, Settings
radio edits) so the device never silently falls back to the wrong
params mid-relay.

Diagnostics gains the actually-forwarded packet count, real heap-free
via mallinfo(), a reset-counters popup, and hardened loop-detect
bounds. Also: a frequency-floor fix and a float-equality preset-match
fix in the repeater profile logic, deduped BW-table/valCol/profile-
seeding helpers shared between Settings and the Repeater screen, and
a build.sh fix tolerating control characters in `pio project config`'s
JSON dump.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-06-20 09:07:00 +02:00
parent 327e659d51
commit 32c50d1cfe
27 changed files with 1296 additions and 89 deletions

View File

@@ -210,6 +210,10 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
#ifdef DISPLAY_ROTATION
_prefs.display_rotation = DISPLAY_ROTATION;
#endif
// 0 is a valid SNR threshold, so the "off" state needs its own sentinel set
// before reading — an older file lacking this field must read as disabled,
// not as "filter everything below 0 dB".
_prefs.repeat_min_snr = NodePrefs::REPEAT_SNR_DISABLED;
File file = openRead(_fs, filename);
if (!file) return;
@@ -331,6 +335,38 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
rd(&_prefs.bot_quiet_start, sizeof(_prefs.bot_quiet_start));
rd(&_prefs.bot_quiet_end, sizeof(_prefs.bot_quiet_end));
rd(_prefs.bot_trigger_ch, sizeof(_prefs.bot_trigger_ch));
rd(_prefs.user_radio_presets, sizeof(_prefs.user_radio_presets));
// → 0xC0DE000E: repeater politeness knobs. On a pre-E file the bytes here are
// that file's own sentinel tail, so clamp every out-of-range value back to its
// "off" default (same stray-byte handling as the fields below).
rd(&_prefs.repeat_skip_adverts, sizeof(_prefs.repeat_skip_adverts));
rd(&_prefs.repeat_max_hops, sizeof(_prefs.repeat_max_hops));
rd(&_prefs.repeat_delay_boost, sizeof(_prefs.repeat_delay_boost));
rd(&_prefs.repeat_min_snr, sizeof(_prefs.repeat_min_snr));
rd(&_prefs.repeat_suppress_dup, sizeof(_prefs.repeat_suppress_dup));
if (_prefs.repeat_skip_adverts > 1) _prefs.repeat_skip_adverts = 0;
if (_prefs.repeat_max_hops > 64) _prefs.repeat_max_hops = 0;
if (_prefs.repeat_delay_boost > 8) _prefs.repeat_delay_boost = 0;
if (_prefs.repeat_min_snr != NodePrefs::REPEAT_SNR_DISABLED &&
(_prefs.repeat_min_snr < -20 || _prefs.repeat_min_snr > 10))
_prefs.repeat_min_snr = NodePrefs::REPEAT_SNR_DISABLED; // match the UI's -20..10 range
if (_prefs.repeat_suppress_dup > 1) _prefs.repeat_suppress_dup = 0;
rd(&_prefs.repeater_use_profile, sizeof(_prefs.repeater_use_profile));
rd(&_prefs.repeater_freq, sizeof(_prefs.repeater_freq));
rd(&_prefs.repeater_bw, sizeof(_prefs.repeater_bw));
rd(&_prefs.repeater_sf, sizeof(_prefs.repeater_sf));
rd(&_prefs.repeater_cr, sizeof(_prefs.repeater_cr));
// Pre-0x10 files leave stray sentinel bytes here, same as a never-configured
// device. Either way there's no valid saved profile, so default to a profile
// in the same band as the companion's own network (_prefs.freq, already read
// above) rather than "Current" — a repeater silently following the companion
// onto whatever private network it later joins isn't the MeshCore community
// norm; that stays opt-in. Band-matched rather than a flat frequency so the
// default can't land outside what's legal where the companion is set up.
if (_prefs.repeater_use_profile > 1) _prefs.repeater_use_profile = 0;
if (!isValidRepeaterProfile(_prefs.repeater_freq, _prefs.repeater_bw, _prefs.repeater_sf, _prefs.repeater_cr)) {
seedDefaultRepeaterProfile(_prefs);
}
// → 0xC0DE000B: append bot_commands_enabled + quiet-hours. Older files leave
// stray bytes here; clamp so upgraders fall back to off / no quiet hours.
if (_prefs.bot_commands_enabled > 1) _prefs.bot_commands_enabled = 0;
@@ -385,6 +421,9 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
// so an existing channel bot keeps reacting to the same word after upgrade.
if (_prefs.bot_trigger_ch[0] == '\0')
strncpy(_prefs.bot_trigger_ch, _prefs.bot_trigger, sizeof(_prefs.bot_trigger_ch) - 1);
// → 0xC0DE000D: append user_radio_presets. No clamping needed — rd() already
// zero-inits it on a pre-0x0D file, and name[0]=='\0' is exactly the "empty
// slot" sentinel the UI already expects.
}
file.close();
@@ -484,6 +523,17 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
file.write((uint8_t *)&_prefs.bot_quiet_start, sizeof(_prefs.bot_quiet_start));
file.write((uint8_t *)&_prefs.bot_quiet_end, sizeof(_prefs.bot_quiet_end));
file.write((uint8_t *)_prefs.bot_trigger_ch, sizeof(_prefs.bot_trigger_ch));
file.write((uint8_t *)_prefs.user_radio_presets, sizeof(_prefs.user_radio_presets));
file.write((uint8_t *)&_prefs.repeat_skip_adverts, sizeof(_prefs.repeat_skip_adverts));
file.write((uint8_t *)&_prefs.repeat_max_hops, sizeof(_prefs.repeat_max_hops));
file.write((uint8_t *)&_prefs.repeat_delay_boost, sizeof(_prefs.repeat_delay_boost));
file.write((uint8_t *)&_prefs.repeat_min_snr, sizeof(_prefs.repeat_min_snr));
file.write((uint8_t *)&_prefs.repeat_suppress_dup, sizeof(_prefs.repeat_suppress_dup));
file.write((uint8_t *)&_prefs.repeater_use_profile, sizeof(_prefs.repeater_use_profile));
file.write((uint8_t *)&_prefs.repeater_freq, sizeof(_prefs.repeater_freq));
file.write((uint8_t *)&_prefs.repeater_bw, sizeof(_prefs.repeater_bw));
file.write((uint8_t *)&_prefs.repeater_sf, sizeof(_prefs.repeater_sf));
file.write((uint8_t *)&_prefs.repeater_cr, sizeof(_prefs.repeater_cr));
// Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL.
uint32_t sentinel = NodePrefs::SCHEMA_SENTINEL;

View File

@@ -282,7 +282,12 @@ int MyMesh::calcRxDelay(float score, uint32_t air_time) const {
uint32_t MyMesh::getRetransmitDelay(const mesh::Packet *packet) {
uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * 0.5f);
return getRNG()->nextInt(0, 5*t + 1);
uint32_t d = getRNG()->nextInt(0, 5*t + 1);
// Politeness yield (Settings > Radio): scale the flood retransmit delay so a
// mobile companion waits longer and lets better-sited fixed repeaters win the
// flood first. Only forwarded floods reach here — own sends pass their own
// delay to sendFlood() — so this never slows the companion's own traffic.
return d * (1 + _prefs.repeat_delay_boost);
}
uint32_t MyMesh::getDirectRetransmitDelay(const mesh::Packet *packet) {
uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * 0.2f);
@@ -491,7 +496,7 @@ bool MyMesh::filterRecvFloodPacket(mesh::Packet* packet) {
// positive feedback on the repeater link — sample its SNR. This is the only
// confirmation a channel send gets (no ACK), and is what lets APC recover power
// after it has trimmed too far for the repeaters to hear.
if (_prefs.tx_apc && _apc_flood_pending && packet->payload_len == _apc_flood_len) {
if (apcActive() && _apc_flood_pending && packet->payload_len == _apc_flood_len) {
uint8_t h[MAX_HASH_SIZE];
packet->calculatePacketHash(h);
if (memcmp(h, _apc_flood_hash, MAX_HASH_SIZE) == 0) {
@@ -521,8 +526,49 @@ bool MyMesh::filterRecvFloodPacket(mesh::Packet* packet) {
return false;
}
// Loop guard for flood packets, ported from simple_repeater's LOOP_DETECT_MODERATE
// thresholds (max times this node's hash may already appear in the path, by hash size).
// A companion moves around, so it re-enters its own flood's path more easily than a
// fixed repeater — hardcoded rather than configurable since there's no CLI here.
// Indexed by getPathHashSize() = (path_len>>6)+1, so 1..4. Index 0 is unused
// (hash size is never 0); index 4 covers path_mode 3, which tryParsePacket
// currently rejects — kept in-bounds so this can't OOB-read if that guard is
// ever relaxed.
static const uint8_t REPEAT_LOOP_MAX[] = { 0, /*1-byte*/ 2, /*2-byte*/ 1, /*3-byte*/ 1, /*4-byte*/ 1 };
// Caps how many hops an ADVERT flood gets repeated, matching simple_repeater's default
// flood_max_advert — adverts are the most frequent flood traffic, so this is the one
// depth limit worth keeping even without the rest of simple_repeater's flood_max knobs.
static const uint8_t REPEAT_MAX_ADVERT_HOPS = 8;
bool MyMesh::isRepeatLooped(const mesh::Packet* packet) const {
uint8_t hash_size = packet->getPathHashSize();
if (hash_size >= sizeof(REPEAT_LOOP_MAX)) return true; // unknown hash size: treat as looped, don't forward
uint8_t hash_count = packet->getPathHashCount();
uint8_t n = 0;
const uint8_t* path = packet->path;
while (hash_count > 0) {
if (self_id.isHashMatch(path, hash_size)) n++;
hash_count--;
path += hash_size;
}
return n >= REPEAT_LOOP_MAX[hash_size];
}
bool MyMesh::allowPacketForward(const mesh::Packet* packet) {
return _prefs.client_repeat != 0;
if (_prefs.client_repeat == 0) return false;
// "Politeness" filters (Settings > Radio) — all default off, so a plain repeater
// is unaffected. Flood-only by design: on a direct route this node is the named
// next hop, so dropping there would kill delivery with no alternate path, while
// dropping a flood copy just trims redundancy other nodes still carry.
if (packet->isRouteFlood()) {
if (_prefs.repeat_min_snr != NodePrefs::REPEAT_SNR_DISABLED
&& packet->getSNR() < (float)_prefs.repeat_min_snr) return false;
if (_prefs.repeat_skip_adverts && packet->getPayloadType() == PAYLOAD_TYPE_ADVERT) return false;
if (_prefs.repeat_max_hops > 0 && packet->getPathHashCount() >= _prefs.repeat_max_hops) return false;
if (packet->getPayloadType() == PAYLOAD_TYPE_ADVERT && packet->getPathHashCount() >= REPEAT_MAX_ADVERT_HOPS) return false;
if (isRepeatLooped(packet)) return false;
}
return true;
}
void MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis) {
@@ -550,7 +596,7 @@ void MyMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, ui
}
void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis) {
// TODO: have per-channel send_scope
if (_prefs.tx_apc) apcTrackFloodSend(pkt); // listen for a repeater echo to drive APC (channels have no ACK)
if (apcActive()) apcTrackFloodSend(pkt); // listen for a repeater echo to drive APC (channels have no ACK)
trackRelaySend(pkt); // and for the UI "relayed" marker
if (send_unscoped) {
sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1); // app has explicitly requested un-scoped
@@ -1195,7 +1241,7 @@ void MyMesh::trackRelaySend(const mesh::Packet* pkt) {
}
void MyMesh::onSendTimeout() {
if (_prefs.tx_apc) apcOnFailure();
if (apcActive()) apcOnFailure();
}
void MyMesh::onAckRecv(mesh::Packet* packet, uint32_t ack_crc) {
@@ -1204,7 +1250,7 @@ void MyMesh::onAckRecv(mesh::Packet* packet, uint32_t ack_crc) {
// Capture the match before the base handler's processAck() clears the entry.
bool mine = isAckPending(ack_crc);
BaseChatMesh::onAckRecv(packet, ack_crc);
if (mine && _prefs.tx_apc) apcSampleSnr(radio_driver.getLastSNR());
if (mine && apcActive()) apcSampleSnr(radio_driver.getLastSNR());
// Drive the DM delivery-status marker. Note isAckPending() only covers
// app/serial-initiated sends (expected_ack_table); a DM composed on the
// device UI registers in BaseChatMesh's own ack table instead, so gating on
@@ -1214,7 +1260,13 @@ void MyMesh::onAckRecv(mesh::Packet* packet, uint32_t ack_crc) {
}
MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store, AbstractUITask* ui)
: BaseChatMesh(radio, *new ArduinoMillis(), rng, rtc, *new StaticPoolPacketManager(16), tables),
// Sized to match simple_repeater's pool (32), not the old client-only 16: with the
// on-device Repeater toggle, queued retransmits (adverts/channel flood from
// neighbours) can now hold packet-pool slots for their retransmit delay window. A
// pool too small for that starves Dispatcher::checkRecv()'s allocNew(), which then
// silently drops every incoming packet — DMs and channels included — until a slot
// frees up.
: BaseChatMesh(radio, *new ArduinoMillis(), rng, rtc, *new StaticPoolPacketManager(32), tables),
_serial(NULL), telemetry(MAX_PACKET_PAYLOAD - 4), _store(&store), _ui(ui) {
_iter_started = false;
_cli_rescue = false;
@@ -1255,6 +1307,10 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
_prefs.bw = LORA_BW;
_prefs.cr = LORA_CR;
_prefs.tx_power_dbm = LORA_TX_POWER;
// Repeater profile default for a true first boot (no prefs file yet, so
// loadPrefs() below is a no-op) — same band-matched seed as the upgrade
// path in DataStore.cpp.
seedDefaultRepeaterProfile(_prefs);
_prefs.gps_enabled = 0; // GPS disabled by default
_prefs.gps_interval = 0; // No automatic GPS updates by default
_prefs.display_brightness = 2; // medium brightness by default
@@ -1366,14 +1422,21 @@ void MyMesh::begin(bool has_display) {
addChannel("Public", PUBLIC_GROUP_PSK); // pre-configure Andy's public channel
_store->loadChannels(this);
radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
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);
radio_driver.setPowerSaving(_prefs.rx_powersave); // hardware duty-cycle RX (battery saver)
radio_driver.setPowerSaving(_prefs.rx_powersave && !_prefs.client_repeat); // duty-cycle RX off while repeating (must hear all traffic)
MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: %s",
radio_driver.getRxBoostedGainMode() ? "Enabled" : "Disabled");
}
void MyMesh::applyRepeaterRadio() {
if (_prefs.client_repeat && _prefs.repeater_use_profile && repeaterProfileValid())
radio_driver.setParams(_prefs.repeater_freq, _prefs.repeater_bw, _prefs.repeater_sf, _prefs.repeater_cr);
else
radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
}
const char *MyMesh::getNodeName() {
return _prefs.node_name;
}
@@ -1784,9 +1847,15 @@ void MyMesh::handleCmdFrame(size_t len) {
repeat = cmd_frame[i++]; // FIRMWARE_VER_CODE 9+
}
if (repeat && !isValidClientRepeatFreq(freq)) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
} else if (freq >= 150000 && freq <= 2500000 && sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8 && bw >= 7000 &&
// Dedicated-band requirement for app-driven repeat disabled, to match the
// on-device Repeater toggle (Settings > Radio), which repeats on whatever
// frequency is already set with no band restriction. Uncomment to restore
// the old behaviour (repeat=1 only accepted on repeat_freq_ranges, i.e.
// 433.000/869.495/918.000 MHz exactly, by default).
// if (repeat && !isValidClientRepeatFreq(freq)) {
// writeErrFrame(ERR_CODE_ILLEGAL_ARG);
// } else
if (freq >= 150000 && freq <= 2500000 && sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8 && bw >= 7000 &&
bw <= 500000) {
_prefs.sf = sf;
_prefs.cr = cr;
@@ -1795,7 +1864,12 @@ void MyMesh::handleCmdFrame(size_t len) {
_prefs.client_repeat = repeat;
savePrefs();
radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
applyRepeaterRadio(); // companion params, or the repeater profile if relaying with one set
// Keep the "repeating ⇒ continuous RX, full TX power" invariants when repeat
// is toggled via the app, mirroring the on-device path (a repeater must hear
// all traffic and relay at consistent power).
radio_driver.setPowerSaving(_prefs.rx_powersave && !_prefs.client_repeat);
applyApc(); // pins power to the ceiling; apcActive() keeps it there while repeating
MESH_DEBUG_PRINTLN("OK: CMD_SET_RADIO_PARAMS: f=%d, bw=%d, sf=%d, cr=%d", freq, bw, (uint32_t)sf,
(uint32_t)cr);
@@ -2706,7 +2780,7 @@ void MyMesh::loop() {
// treated as a lost confirmation → ramp power up (lets channel sends recover).
if (_apc_flood_pending && millisHasNowPassed(_apc_flood_deadline)) {
_apc_flood_pending = false;
if (_prefs.tx_apc) apcOnFailure();
if (apcActive()) apcOnFailure();
}
// UI relay windows expired with no echo — just drop them (no echo is not a
// failure for channels; the marker simply stays "sent").

View File

@@ -41,18 +41,7 @@ class UITask;
/* ---------------------------------- CONFIGURATION ------------------------------------- */
#ifndef LORA_FREQ
#define LORA_FREQ 915.0
#endif
#ifndef LORA_BW
#define LORA_BW 250
#endif
#ifndef LORA_SF
#define LORA_SF 10
#endif
#ifndef LORA_CR
#define LORA_CR 5
#endif
// LORA_FREQ/BW/SF/CR fallbacks now live in NodePrefs.h (DataStore.cpp needs them too).
#ifndef LORA_TX_POWER
#define LORA_TX_POWER 20
#endif
@@ -152,6 +141,10 @@ protected:
uint8_t getExtraAckTransmitCount() const override;
bool filterRecvFloodPacket(mesh::Packet* packet) override;
bool allowPacketForward(const mesh::Packet* packet) override;
bool isRepeatLooped(const mesh::Packet* packet) const;
// Overhear suppression only makes sense while repeating; gated behind its own
// opt-in pref (Settings > Radio > Suppress dup).
bool wantsOverhearSuppress() const override { return _prefs.client_repeat && _prefs.repeat_suppress_dup; }
void sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis);
void sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis=0) override;
@@ -223,6 +216,21 @@ public:
void saveRTCTime() { _store->saveRTCTime(); }
DataStore* getDataStore() const { return _store; }
void applyApc(); // (re)initialise Adaptive Power Control from prefs
// Adaptive Power Control is suppressed while repeating: a repeater wants full,
// consistent TX power for relay reach, and its feedback sources (own ACKs /
// own flood echoes) don't fire on forwarded traffic anyway. applyApc() then
// pins power to the ceiling.
bool apcActive() const { return _prefs.tx_apc && !_prefs.client_repeat; }
// True when the optional repeater radio profile is a valid LoRa config.
bool repeaterProfileValid() const {
return isValidRepeaterProfile(_prefs.repeater_freq, _prefs.repeater_bw, _prefs.repeater_sf, _prefs.repeater_cr);
}
// Load the radio with the correct params for the current mode: the repeater
// profile when relaying with a valid dedicated profile, otherwise the
// companion's own params. Single source of truth, used at boot and whenever
// the repeater toggle / network / profile changes.
void applyRepeaterRadio();
bool isAckPending(uint32_t expected_ack) const {
for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; i++)

View File

@@ -2,6 +2,37 @@
#include <cstdint>
#include <stdio.h>
// Firmware's own boot-default radio params — used both as the companion's
// initial freq/sf/bw/cr (MyMesh.cpp) and, here, as the seed for a never-
// configured repeater profile (DataStore.cpp). Kept above any per-board
// target.h include so a board can still override via -D before this file
// is reached.
#ifndef LORA_FREQ
#define LORA_FREQ 915.0
#endif
#ifndef LORA_BW
#define LORA_BW 250
#endif
#ifndef LORA_SF
#define LORA_SF 10
#endif
#ifndef LORA_CR
#define LORA_CR 5
#endif
// Bucket a companion frequency into whichever of the three license-exempt
// bands MeshCore's app-driven repeat toggle historically restricted to (see
// repeat_freq_ranges in MyMesh.cpp: 433.000 / 869.495 / 918.000 MHz). Used to
// seed a never-configured repeater profile in the same legal band as the
// companion's own network (MyMesh.cpp begin(), DataStore.cpp) — a flat
// single frequency could land outside the bands allowed where the operator
// actually lives.
static inline float defaultRepeaterFreqForBand(float companion_freq) {
if (companion_freq < 500.0f) return 433.000f;
if (companion_freq < 890.0f) return 869.495f;
return 918.000f;
}
#define TELEM_MODE_DENY 0
#define TELEM_MODE_ALLOW_FLAGS 1 // use contact.flags
#define TELEM_MODE_ALLOW_ALL 2
@@ -140,11 +171,58 @@ struct NodePrefs { // persisted to file
// shows ✗. 0 = no auto-resend (single attempt). Default 2.
uint8_t dm_resend_count;
// User-saved radio presets (Settings > Radio > Preset > "Save current...").
// name[0] == '\0' marks an empty slot.
struct UserRadioPreset {
char name[16];
float freq;
float bw;
uint8_t sf;
uint8_t cr;
};
static const uint8_t USER_RADIO_PRESET_MAX = 4;
UserRadioPreset user_radio_presets[USER_RADIO_PRESET_MAX];
// Repeater "politeness" — only consulted when client_repeat is on, via
// MyMesh::allowPacketForward(). Both default to off (0) so behaviour is
// unchanged until the user opts in (Settings > Radio).
// repeat_skip_adverts: 1 = don't re-flood ADVERT packets (the highest-volume
// flood traffic); messages/acks still relay.
// repeat_max_hops: drop a flood packet once it has already travelled this
// many hops. 0 = no hop limit.
// repeat_delay_boost: extra retransmit-delay multiplier for FORWARDED floods
// only (own sends are unaffected) — a mobile companion yields to better-sited
// fixed repeaters. Effective delay = base * (1 + repeat_delay_boost). 0 = off.
// repeat_min_snr: drop a flood packet received below this SNR (dB), so marginal
// fringe traffic isn't re-flooded. REPEAT_SNR_DISABLED (-128) = off.
// repeat_suppress_dup: 1 = cancel a queued retransmit when the same flood is
// overheard from another node first (less redundant airtime in dense mesh).
uint8_t repeat_skip_adverts;
uint8_t repeat_max_hops;
uint8_t repeat_delay_boost;
int8_t repeat_min_snr;
static const int8_t REPEAT_SNR_DISABLED = -128;
uint8_t repeat_suppress_dup;
// Optional dedicated radio profile for repeater mode. When repeater_use_profile
// is 1, enabling the repeater switches the radio to repeater_freq/bw/sf/cr and
// disabling restores the companion's freq/bw/sf/cr (the fields above). 0 = the
// repeater stays on the current companion frequency. Default (a never-configured
// device) is 1, seeded via defaultRepeaterFreqForBand(freq) + LORA_SF/BW/CR —
// repeating on whatever private network the companion later joins isn't the
// community norm, and the seed stays in the same legal band as the companion's
// own network rather than a flat frequency.
uint8_t repeater_use_profile;
float repeater_freq;
float repeater_bw;
uint8_t repeater_sf;
uint8_t repeater_cr;
// Tail sentinel written at the end of /new_prefs. Bump the low byte when
// adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so
// older saves are detected on load and skipped (zero-init defaults kept).
// High 24 bits identify the file format; low byte is the schema revision.
static const uint32_t SCHEMA_SENTINEL = 0xC0DE000C;
static const uint32_t SCHEMA_SENTINEL = 0xC0DE0010;
// Bit-index for each home page. Used by page_order (entries store bit+1) and
// by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage
@@ -213,3 +291,23 @@ struct NodePrefs { // persisted to file
if (pos < size) buf[pos] = '\0';
}
};
// Bounds for a usable repeater radio profile — must match the radio chip's own
// validated range (see CustomSX1262Wrapper::getFreqBounds(), 150-960 MHz), so
// a profile that passes here is guaranteed to actually take effect on the radio.
static inline bool isValidRepeaterProfile(float freq, float bw, uint8_t sf, uint8_t cr) {
return freq >= 150.0f && freq <= 960.0f
&& sf >= 5 && sf <= 12
&& cr >= 5 && cr <= 8
&& bw >= 7.0f && bw <= 510.0f;
}
// Seed a never-configured repeater profile in the same band as the companion's
// own network (see defaultRepeaterFreqForBand() above for why).
static inline void seedDefaultRepeaterProfile(NodePrefs& prefs) {
prefs.repeater_use_profile = 1;
prefs.repeater_freq = defaultRepeaterFreqForBand(prefs.freq);
prefs.repeater_bw = LORA_BW;
prefs.repeater_sf = LORA_SF;
prefs.repeater_cr = LORA_CR;
}

View File

@@ -0,0 +1,50 @@
#pragma once
#include <math.h>
// Community-suggested per-region RF presets (freq/bw/sf/cr), sourced from the
// MeshCore app's "suggested_radio_settings" config plus a few community additions.
// Kept here (rather than guessed) since wrong values are a regulatory/connectivity
// concern, not just cosmetic.
struct RadioPreset {
const char* name;
float freq; // MHz
float bw; // kHz
uint8_t sf;
uint8_t cr;
};
// Deprecated entries and anything outside the 868-9xx MHz bands (433MHz) have been
// dropped — only current, in-band presets are kept.
static const RadioPreset RADIO_PRESETS[] = {
{ "Australia", 915.800f, 250.0f, 10, 5 },
{ "Australia (Narrow)", 916.575f, 62.5f, 7, 8 },
{ "Australia (Mid)", 915.075f, 125.0f, 9, 5 },
{ "Australia: SA, WA", 923.125f, 62.5f, 8, 8 },
{ "Australia: QLD", 923.125f, 62.5f, 8, 5 },
{ "Brazil", 923.125f, 62.5f, 8, 8 },
{ "EU/UK (Narrow)", 869.618f, 62.5f, 8, 8 },
{ "Czech Republic (Narrow)", 869.432f, 62.5f, 7, 5 },
{ "Netherlands", 869.618f, 62.5f, 7, 5 },
{ "New Zealand", 917.375f, 250.0f, 11, 5 },
{ "New Zealand (Narrow)", 917.375f, 62.5f, 7, 5 },
{ "Portugal 868", 869.618f, 62.5f, 7, 6 },
{ "Switzerland", 869.618f, 62.5f, 8, 8 },
{ "USA/Canada (Recommended)",910.525f, 62.5f, 7, 5 },
{ "USA/SOCAL", 927.875f, 62.5f, 7, 5 },
{ "Vietnam (Narrow)", 920.250f, 62.5f, 8, 5 },
};
static const int RADIO_PRESET_COUNT = sizeof(RADIO_PRESETS) / sizeof(RADIO_PRESETS[0]);
// Standard SX126x/SX127x LoRa bandwidths (kHz) — manual BW fields (companion and
// repeater profile alike) cycle through these rather than a free-form value, so
// they can't land on an unsupported setting.
static const float LORA_BW_OPTS[] = { 7.8f, 10.4f, 15.6f, 20.8f, 31.25f, 41.7f, 62.5f, 125.0f, 250.0f, 500.0f };
static const int LORA_BW_OPT_COUNT = sizeof(LORA_BW_OPTS) / sizeof(LORA_BW_OPTS[0]);
// True if (freq, bw, sf, cr) matches the radio's currently-applied (cur_*) params.
// Floats are compared with a small epsilon since they may have round-tripped
// through unit conversions (e.g. kHz integers from the companion app).
static inline bool radioParamsMatchPreset(float cur_freq, float cur_bw, uint8_t cur_sf, uint8_t cur_cr,
float freq, float bw, uint8_t sf, uint8_t cr) {
return fabsf(cur_freq - freq) < 0.001f && fabsf(cur_bw - bw) < 0.001f && cur_sf == sf && cur_cr == cr;
}

View File

@@ -4,6 +4,7 @@
#include <helpers/DeviceDiag.h>
#include <Arduino.h>
#include "icons.h"
#include "PopupMenu.h"
#include "../MyMesh.h"
extern MyMesh the_mesh;
@@ -18,9 +19,10 @@ extern MyMesh the_mesh;
class DiagnosticsScreen : public UIScreen {
UITask* _task;
int _scroll = 0;
PopupMenu _reset_menu; // Hold Enter → 1-item "Reset counters" action menu (Back dismisses)
struct Row { const char* label; char value[20]; };
static const int MAX_ROWS = 12;
static const int MAX_ROWS = 14;
Row _rows[MAX_ROWS];
int _row_count = 0;
@@ -76,6 +78,9 @@ class DiagnosticsScreen : public UIScreen {
addRxTxRow("Ack/Path", rte_rx, rte_tx);
addRxTxRow("Other", oth_rx, oth_tx);
snprintf(buf, sizeof(buf), "%lu", (unsigned long)the_mesh.getNumForwarded());
addRow("Forwarded", buf);
uint32_t heap_free, heap_total;
DeviceDiag::getHeapStats(heap_free, heap_total);
if (heap_total > 0) snprintf(buf, sizeof(buf), "%lu/%luKB", (unsigned long)(heap_free / 1024), (unsigned long)(heap_total / 1024));
@@ -95,6 +100,20 @@ class DiagnosticsScreen : public UIScreen {
addRow("Pool free", buf);
snprintf(buf, sizeof(buf), "%d", the_mesh.getOutboundQueueLen());
addRow("Queue", buf);
// Radio error flags since boot/reset, decoded to short tokens (F=queue full,
// C=CAD timeout, R=RX-start timeout). "OK" when none have fired.
uint16_t err = the_mesh.getErrFlags();
if (err == 0) strcpy(buf, "OK");
else {
buf[0] = '\0';
if (err & ERR_EVENT_FULL) strcat(buf, "F ");
if (err & ERR_EVENT_CAD_TIMEOUT) strcat(buf, "C ");
if (err & ERR_EVENT_STARTRX_TIMEOUT) strcat(buf, "R ");
int len = strlen(buf); // drop the trailing space so right-alignment sits flush
if (len > 0 && buf[len - 1] == ' ') buf[len - 1] = '\0';
}
addRow("Errors", buf);
}
public:
@@ -125,13 +144,27 @@ public:
}
drawScrollIndicator(display, start_y, visible * item_h, _row_count, visible, _scroll);
display.setColor(DisplayDriver::LIGHT);
return 1000; // stats refresh once a second — no need for a faster redraw
if (_reset_menu.active) _reset_menu.render(display);
return _reset_menu.active ? 50 : 1000; // popup wants snappier redraw; stats otherwise refresh once a second
}
bool handleInput(char c) override {
if (_reset_menu.active) {
auto res = _reset_menu.handleInput(c); // Back/Cancel dismisses; the only item is "Reset counters"
if (res == PopupMenu::SELECTED) {
the_mesh.resetStats(); // zeroes Dispatcher per-type counters + Mesh forward count + err flags
_task->showAlert("Counters reset", 800);
}
return true;
}
if (c == KEY_UP) { if (_scroll > 0) _scroll--; return true; }
if (c == KEY_DOWN) { _scroll++; return true; } // clamped to max_scroll in render()
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU || c == KEY_ENTER) { _task->gotoToolsScreen(); return true; }
if (c == KEY_CONTEXT_MENU) { // Hold Enter — same action-menu gesture as the rest of the UI
_reset_menu.begin("Diagnostics", 1);
_reset_menu.addItem("Reset counters");
return true;
}
if (c == KEY_CANCEL) { _task->gotoToolsScreen(); return true; }
return false;
}
};

View File

@@ -0,0 +1,86 @@
#pragma once
#include <helpers/ui/DisplayDriver.h>
#include <helpers/ui/UIScreen.h>
#include <Arduino.h>
// Digit-by-digit numeric editor: LEFT/RIGHT moves a cursor between decimal
// places (most-significant first), UP/DOWN adds/subtracts that place's value
// (100, 10, 1, 0.1, ...). Every place is directly addressable regardless of
// the starting value's own fractional offset — unlike a fixed step size
// nudging the raw value, which carries whatever odd fraction the starting
// value had forever, making different starting points land on different,
// unpredictable grids. Caller owns persisting the result; this widget only
// edits its own `value` in place.
struct DigitEditor {
float value = 0.0f;
float min_val = 0.0f, max_val = 0.0f;
uint8_t int_digits = 0; // digits before the decimal point
uint8_t dec_digits = 0; // digits after the decimal point
int8_t cursor = 0; // 0 = most-significant digit
bool active = false; // must default false: an embedding screen's input
// handler checks this before any begin() call
enum Result { NONE, DONE, CANCELLED };
void begin(float v, float vmin, float vmax, uint8_t intd, uint8_t decd) {
value = v; min_val = vmin; max_val = vmax;
int_digits = intd; dec_digits = decd;
cursor = 0; active = true;
}
int totalDigits() const { return (int)int_digits + (int)dec_digits; }
// Place value for the digit at `pos` (0 = most-significant integer digit),
// e.g. int_digits=3 → pos 0/1/2 = hundreds/tens/units, pos 3.. = tenths/...
float placeValue(int pos) const {
int exp = (int)int_digits - 1 - pos;
float v = 1.0f;
if (exp >= 0) { for (int i = 0; i < exp; i++) v *= 10.0f; }
else { for (int i = 0; i < -exp; i++) v /= 10.0f; }
return v;
}
Result handleInput(char c) {
if (!active) return CANCELLED;
if (keyIsPrev(c)) { if (cursor > 0) cursor--; return NONE; }
if (keyIsNext(c)) { if (cursor < totalDigits() - 1) cursor++; return NONE; }
if (c == KEY_UP || c == KEY_DOWN) {
float step = placeValue(cursor);
float next = value + (c == KEY_UP ? step : -step);
// Snap to the editor's own decimal precision so repeated +/- can't
// drift off-grid from float rounding.
float scale = 1.0f;
for (int i = 0; i < dec_digits; i++) scale *= 10.0f;
next = roundf(next * scale) / scale;
if (next >= min_val && next <= max_val) value = next;
return NONE;
}
if (c == KEY_ENTER) { active = false; return DONE; }
if (c == KEY_CANCEL) { active = false; return CANCELLED; }
return NONE;
}
// Draws `value` at (x, y) with the digit under the cursor shown inverted
// (small DARK box, LIGHT glyph). Assumes the caller is already inside a
// selected row (ink DARK on a LIGHT selection bar); restores LIGHT after.
void render(DisplayDriver& d, int x, int y) {
char buf[16];
snprintf(buf, sizeof(buf), "%.*f", (int)dec_digits, value);
const int cw = d.getCharWidth();
const int char_idx = cursor < int_digits ? cursor : cursor + 1; // skip '.'
for (int k = 0; buf[k]; k++) {
int cx = x + k * cw;
if (k == char_idx) {
d.setColor(DisplayDriver::DARK);
d.fillRect(cx, y - 1, cw, d.getLineHeight() + 1);
d.setColor(DisplayDriver::LIGHT);
} else {
d.setColor(DisplayDriver::DARK);
}
char one[2] = { buf[k], '\0' };
d.setCursor(cx, y);
d.print(one);
}
d.setColor(DisplayDriver::LIGHT);
}
};

View File

@@ -7,7 +7,7 @@
// Caller owns item string lifetimes — addItem() stores const char* pointers.
// Navigation wraps around: up from first item goes to last, and vice versa.
struct PopupMenu {
static const int PM_MAX_ITEMS = 16;
static const int PM_MAX_ITEMS = 24;
const char* _items[PM_MAX_ITEMS];
int _count;

View File

@@ -778,9 +778,14 @@ public:
}
void updateChannelUnread() {
if (_hist_sel < 0 || _sel_channel_idx < 0 || _sel_channel_idx >= MAX_GROUP_CHANNELS) return;
if (_hist_sel > _viewing_max_seen) _viewing_max_seen = _hist_sel;
if (_sel_channel_idx < 0 || _sel_channel_idx >= MAX_GROUP_CHANNELS) return;
// histEntryForChannel is newest-first: index 0 = newest (unread), higher = older.
// Count everything actually rendered on screen as seen — not just the
// highlighted row — so a taller screen that fits more boxes at once marks
// more read up front, instead of requiring a press per row.
int seen_to = _hist_scroll + _hist_visible - 1;
if (_hist_sel > seen_to) seen_to = _hist_sel;
if (seen_to > _viewing_max_seen) _viewing_max_seen = seen_to;
// Each step down from 0 sees one more message; seen count = max_seen + 1.
int remaining = _unread_at_entry - (_viewing_max_seen + 1);
_ch_unread[_sel_channel_idx] = (uint8_t)(remaining > 0 ? remaining : 0);
@@ -1182,6 +1187,7 @@ public:
}
}
_hist_visible = n_vis;
updateChannelUnread(); // mark everything in the just-computed visible window as seen
for (int i = 0; i < n_vis && (_hist_scroll + i) < ch_hist_count; i++) {
int item = _hist_scroll + i;
@@ -1555,7 +1561,10 @@ public:
_hist_sel = hc > 0 ? 0 : -1;
_viewing_max_seen = _hist_sel >= 0 ? _hist_sel : 0;
_phase = CHANNEL_HIST;
updateChannelUnread();
// Not updateChannelUnread() here: _hist_visible is still whatever this
// channel's first render() hasn't computed yet (stale, shared with
// DM_HIST) — calling it now could ratchet _viewing_max_seen past what's
// actually about to be shown. render() calls it once that's fresh.
if (_share_mode) beginShareCompose(true);
return true;
}

View File

@@ -0,0 +1,325 @@
#pragma once
// Tools Repeater — consolidates the repeater toggle, its flood "politeness"
// filters, an optional dedicated radio profile, and live forwarding stats 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,
// and keeps the relaying controls next to the numbers that show them working.
//
// 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 "PopupMenu.h"
#include "DigitEditor.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;
PopupMenu _preset_menu;
DigitEditor _freq_editor;
uint8_t _preset_user_slot[NodePrefs::USER_RADIO_PRESET_MAX];
int _preset_user_count = 0;
// Nearest entry in LORA_BW_OPTS to the repeater profile's bw.
int rptBwIndex(NodePrefs* p) const {
int best = 0;
float best_diff = 1e9f;
for (int i = 0; i < LORA_BW_OPT_COUNT; i++) {
float diff = fabsf(p->repeater_bw - LORA_BW_OPTS[i]);
if (diff < best_diff) { best_diff = diff; best = i; }
}
return best;
}
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 "";
}
// Name of the preset matching the repeater profile, or "Custom".
const char* rptPresetName(NodePrefs* p) const {
for (int i = 0; i < RADIO_PRESET_COUNT; i++) {
const RadioPreset& r = RADIO_PRESETS[i];
if (radioParamsMatchPreset(p->repeater_freq, p->repeater_bw, p->repeater_sf, p->repeater_cr, r.freq, r.bw, r.sf, r.cr))
return r.name;
}
for (int i = 0; i < NodePrefs::USER_RADIO_PRESET_MAX; i++) {
const NodePrefs::UserRadioPreset& u = p->user_radio_presets[i];
if (!u.name[0]) continue;
if (radioParamsMatchPreset(p->repeater_freq, p->repeater_bw, p->repeater_sf, p->repeater_cr, u.freq, u.bw, u.sf, u.cr))
return u.name;
}
return "Custom";
}
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, rptPresetName(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;
}
void openPresetMenu(NodePrefs* p) {
_preset_menu.begin("Repeater Preset", 6);
for (int i = 0; i < RADIO_PRESET_COUNT; i++) _preset_menu.addItem(RADIO_PRESETS[i].name);
_preset_user_count = 0;
for (int i = 0; i < NodePrefs::USER_RADIO_PRESET_MAX; i++) {
if (!p->user_radio_presets[i].name[0]) continue;
_preset_menu.addItem(p->user_radio_presets[i].name);
_preset_user_slot[_preset_user_count++] = (uint8_t)i;
}
}
public:
RepeaterScreen(UITask* task) : _task(task), _dirty(false), _sel(0), _scroll(0), _item_count(1) {}
void enter() { _dirty = false; _sel = 0; _scroll = 0; _preset_menu.active = false; _freq_editor.active = false; }
int render(DisplayDriver& display) override {
NodePrefs* p = _task->getNodePrefs();
buildItems(p);
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
display.drawCenteredHeader("REPEATER");
const int item_h = display.lineStep();
const int start_y = display.listStart();
int visible = display.listVisible(item_h);
if (visible < 1) visible = 1;
// Config only — live forwarding stats live on Tools Diagnostics.
int total = _item_count;
if (_sel < _scroll) _scroll = _sel;
if (_sel >= _scroll + visible) _scroll = _sel - visible + 1;
int max_scroll = total - visible;
if (max_scroll < 0) max_scroll = 0;
if (_scroll > max_scroll) _scroll = max_scroll;
if (_scroll < 0) _scroll = 0;
const int reserve = scrollIndicatorReserve(display, total, visible);
for (int i = 0; i < visible && (_scroll + i) < total; i++) {
int row = _scroll + i;
int y = start_y + i * item_h;
char val[16];
bool sel = (row == _sel);
int item = _items[row];
display.drawSelectionRow(0, y - 1, display.width() - reserve, item_h - 1, sel);
display.setCursor(2, y);
display.print(itemLabel(item));
if (item == IT_RFREQ && sel && _freq_editor.active) {
_freq_editor.render(display, display.valCol(), y);
} else {
itemValue(item, p, val, sizeof(val));
display.drawTextRightAlign(display.width() - reserve - 2, y, val);
}
display.setColor(DisplayDriver::LIGHT);
}
drawScrollIndicator(display, start_y, visible * item_h, total, visible, _scroll);
display.setColor(DisplayDriver::LIGHT);
if (_preset_menu.active) _preset_menu.render(display);
return (_preset_menu.active || _freq_editor.active) ? 50 : 500;
}
bool handleInput(char c) override {
NodePrefs* p = _task->getNodePrefs();
// Modal overlays first.
if (_preset_menu.active) {
auto res = _preset_menu.handleInput(c);
if (res == PopupMenu::SELECTED && p) {
int idx = _preset_menu.selectedIndex();
if (idx >= 0 && idx < RADIO_PRESET_COUNT) {
const RadioPreset& r = RADIO_PRESETS[idx];
p->repeater_freq = r.freq; p->repeater_bw = r.bw; p->repeater_sf = r.sf; p->repeater_cr = r.cr;
} else {
int u = idx - RADIO_PRESET_COUNT;
if (u >= 0 && u < _preset_user_count) {
const NodePrefs::UserRadioPreset& up = p->user_radio_presets[_preset_user_slot[u]];
p->repeater_freq = up.freq; p->repeater_bw = up.bw; p->repeater_sf = up.sf; p->repeater_cr = up.cr;
}
}
the_mesh.applyRepeaterRadio(); // live if currently relaying on the profile
_dirty = true;
}
return true;
}
if (_freq_editor.active) {
float before = _freq_editor.value;
_freq_editor.handleInput(c);
if (p && _freq_editor.value != before) {
p->repeater_freq = _freq_editor.value;
the_mesh.applyRepeaterRadio();
_dirty = true;
}
return true;
}
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) {
if (_dirty) the_mesh.savePrefs();
_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) { openPresetMenu(p); return true; }
if (item == IT_RFREQ && enter) {
float lo, hi; radio_driver.getFreqBounds(lo, hi);
_freq_editor.begin(p->repeater_freq, lo, hi, 3, 3);
return true;
}
if (item == IT_RSF) {
if (right && p->repeater_sf < 12) { p->repeater_sf++; the_mesh.applyRepeaterRadio(); _dirty = true; return true; }
if (left && p->repeater_sf > 5) { p->repeater_sf--; the_mesh.applyRepeaterRadio(); _dirty = true; return true; }
}
if (item == IT_RBW) {
int idx = rptBwIndex(p);
if (right && idx < LORA_BW_OPT_COUNT - 1) { p->repeater_bw = LORA_BW_OPTS[idx + 1]; the_mesh.applyRepeaterRadio(); _dirty = true; return true; }
if (left && idx > 0) { p->repeater_bw = LORA_BW_OPTS[idx - 1]; the_mesh.applyRepeaterRadio(); _dirty = true; return true; }
}
if (item == IT_RCR) {
if (right && p->repeater_cr < 8) { p->repeater_cr++; the_mesh.applyRepeaterRadio(); _dirty = true; return true; }
if (left && p->repeater_cr > 5) { p->repeater_cr--; 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;
}
};

View File

@@ -3,6 +3,8 @@
// Included by UITask.cpp after SensorPlaceholders.h is defined.
#include "../Features.h"
#include "../RadioPresets.h"
#include "DigitEditor.h"
class SettingsScreen : public UIScreen {
UITask* _task;
@@ -54,6 +56,8 @@ class SettingsScreen : public UIScreen {
// Radio section
SECTION_RADIO,
TX_POWER,
RADIO_PRESET,
CUSTOM_FREQ, CUSTOM_SF, CUSTOM_BW, CUSTOM_CR,
POWER_SAVE,
TX_APC,
// System section
@@ -111,6 +115,79 @@ class SettingsScreen : public UIScreen {
return 0;
}
static bool matchesPreset(NodePrefs* p, float freq, float bw, uint8_t sf, uint8_t cr) {
return radioParamsMatchPreset(p->freq, p->bw, p->sf, p->cr, freq, bw, sf, cr);
}
// Display name for the Preset row: a built-in preset, a saved user preset, or
// "Custom" if freq/sf/bw/cr don't exactly match any of them.
const char* currentPresetName() {
NodePrefs* p = _task->getNodePrefs();
if (!p) return "Custom";
for (int i = 0; i < RADIO_PRESET_COUNT; i++) {
const RadioPreset& r = RADIO_PRESETS[i];
if (matchesPreset(p, r.freq, r.bw, r.sf, r.cr)) return r.name;
}
for (int i = 0; i < NodePrefs::USER_RADIO_PRESET_MAX; i++) {
const auto& u = p->user_radio_presets[i];
if (u.name[0] && matchesPreset(p, u.freq, u.bw, u.sf, u.cr)) return u.name;
}
return "Custom";
}
// Position of the current settings within the popup list built by
// openPresetMenu() below ("Save current..." at 0, then built-ins, then
// non-empty user slots in slot order) — or -1 ("Custom") if nothing matches.
int currentPresetListIndex() {
NodePrefs* p = _task->getNodePrefs();
if (!p) return -1;
for (int i = 0; i < RADIO_PRESET_COUNT; i++) {
const RadioPreset& r = RADIO_PRESETS[i];
if (matchesPreset(p, r.freq, r.bw, r.sf, r.cr)) return i + 1;
}
int pos = RADIO_PRESET_COUNT + 1;
for (int i = 0; i < NodePrefs::USER_RADIO_PRESET_MAX; i++) {
const auto& u = p->user_radio_presets[i];
if (!u.name[0]) continue;
if (matchesPreset(p, u.freq, u.bw, u.sf, u.cr)) return pos;
pos++;
}
return -1;
}
// Save the current freq/sf/bw/cr as a named user preset: overwrite a slot with
// the same name if one exists, else the first empty slot, else slot 0 (oldest).
void saveCurrentAsPreset(const char* name) {
NodePrefs* p = _task->getNodePrefs();
if (!p || !name || !name[0]) return;
int slot = -1;
for (int i = 0; i < NodePrefs::USER_RADIO_PRESET_MAX; i++)
if (strcmp(p->user_radio_presets[i].name, name) == 0) { slot = i; break; }
if (slot < 0)
for (int i = 0; i < NodePrefs::USER_RADIO_PRESET_MAX; i++)
if (!p->user_radio_presets[i].name[0]) { slot = i; break; }
if (slot < 0) slot = 0;
NodePrefs::UserRadioPreset& u = p->user_radio_presets[slot];
strncpy(u.name, name, sizeof(u.name) - 1);
u.name[sizeof(u.name) - 1] = '\0';
u.freq = p->freq; u.bw = p->bw; u.sf = p->sf; u.cr = p->cr;
_dirty = true;
_task->showAlert("Preset saved", 800);
}
// Nearest entry in LORA_BW_OPTS to the current bw (also used to step left/right).
int customBwIndex() {
NodePrefs* p = _task->getNodePrefs();
float bw = p ? p->bw : 62.5f;
int best = 0;
float best_diff = 1e9f;
for (int i = 0; i < LORA_BW_OPT_COUNT; i++) {
float diff = fabsf(bw - LORA_BW_OPTS[i]);
if (diff < best_diff) { best_diff = diff; best = i; }
}
return best;
}
// Value column start, pulled left by the scrollbar gutter so right-side
// values never render under the indicator when the list scrolls.
int valCol(DisplayDriver& display) const { return display.valCol() - _reserve; }
@@ -489,14 +566,52 @@ class SettingsScreen : public UIScreen {
snprintf(buf, sizeof(buf),"%ddBm", p ? p->tx_power_dbm : 0);
display.setCursor(valCol(display), y);
display.print(buf);
} else if (item == RADIO_PRESET) {
display.print("Preset");
const char* name = currentPresetName();
int xc = valCol(display);
display.drawTextEllipsized(xc, y, display.width() - xc - _reserve, name);
} else if (item == CUSTOM_FREQ) {
display.print("Freq");
int xc = valCol(display);
if (sel && _freq_editor.active) {
_freq_editor.render(display, xc, y);
} else {
char buf[10];
snprintf(buf, sizeof(buf), "%.3f", p ? p->freq : 0.0f);
display.setCursor(xc, y);
display.print(buf);
}
} else if (item == CUSTOM_SF) {
display.print("SF");
char buf[6];
snprintf(buf, sizeof(buf), "%d", p ? (int)p->sf : 0);
display.setCursor(valCol(display), y);
display.print(buf);
} else if (item == CUSTOM_BW) {
display.print("BW");
char buf[10];
snprintf(buf, sizeof(buf), "%.1f", p ? p->bw : 0.0f);
display.setCursor(valCol(display), y);
display.print(buf);
} else if (item == CUSTOM_CR) {
display.print("CR");
char buf[6];
snprintf(buf, sizeof(buf), "%d", p ? (int)p->cr : 0);
display.setCursor(valCol(display), y);
display.print(buf);
} else if (item == POWER_SAVE) {
display.print("Pwr save");
display.setCursor(valCol(display), y);
display.print((p && p->rx_powersave) ? "ON" : "OFF");
// Forced off (and locked) while the repeater is on — it must hear all traffic.
if (p && p->client_repeat) display.print("--");
else display.print((p && p->rx_powersave) ? "ON" : "OFF");
} else if (item == TX_APC) {
display.print("Auto pwr");
display.setCursor(valCol(display), y);
display.print((p && p->tx_apc) ? "ON" : "OFF");
// 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");
#if AUTO_OFF_MILLIS > 0
} else if (item == AUTO_OFF) {
display.print("AutoOff");
@@ -595,6 +710,52 @@ class SettingsScreen : public UIScreen {
int _edit_slot; // -1 = not editing, 0..9 = slot being edited
KeyboardWidget* _kb;
// Radio preset picker — names are too long for the value column, so Enter on
// RADIO_PRESET opens this as a full-width scrollable list instead of cycling.
PopupMenu _preset_menu;
// Maps a user-preset row's position in the open popup back to its slot in
// NodePrefs::user_radio_presets (empty slots are skipped when building the list).
uint8_t _preset_user_slot[NodePrefs::USER_RADIO_PRESET_MAX];
int _preset_user_count = 0;
bool _preset_saving = false; // _kb is open to name a new preset, not a msg slot
bool _preset_deleting = false; // _preset_menu is showing the delete sub-list
// Digit-by-digit Freq editor (see DigitEditor.h) — only this field uses it;
// SF/BW/CR are small discrete sets where a plain left/right cycle is fine.
DigitEditor _freq_editor;
// Layout: [0]="+ Save current...", [1..RADIO_PRESET_COUNT]=built-ins,
// [..+_preset_user_count]=saved user presets, ["- Delete preset..." if any].
void openPresetMenu() {
NodePrefs* p = _task->getNodePrefs();
_preset_deleting = false;
_preset_menu.begin("Radio Preset", 6);
_preset_menu.addItem("+ Save current...");
for (int i = 0; i < RADIO_PRESET_COUNT; i++) _preset_menu.addItem(RADIO_PRESETS[i].name);
_preset_user_count = 0;
if (p) {
for (int i = 0; i < NodePrefs::USER_RADIO_PRESET_MAX; i++) {
if (!p->user_radio_presets[i].name[0]) continue;
_preset_menu.addItem(p->user_radio_presets[i].name);
_preset_user_slot[_preset_user_count++] = (uint8_t)i;
}
}
if (_preset_user_count > 0) _preset_menu.addItem("- Delete preset...");
int idx = currentPresetListIndex();
_preset_menu.setSelected(idx >= 0 ? idx : 0);
}
// Reuses _preset_menu for a second-level list of just the saved user presets
// (their slot mapping in _preset_user_slot is still the one openPresetMenu()
// just built, since this is only reached from within that same popup).
void openDeletePresetMenu() {
NodePrefs* p = _task->getNodePrefs();
_preset_menu.begin("Delete Preset", 6);
for (int i = 0; i < _preset_user_count; i++)
_preset_menu.addItem(p->user_radio_presets[_preset_user_slot[i]].name);
_preset_deleting = true;
}
public:
SettingsScreen(UITask* task, KeyboardWidget* kb)
: _task(task), _kb(kb), _selected(SECTION_DISPLAY), _scroll(0), _visible(4), _dirty(false), _edit_slot(-1) {
@@ -608,12 +769,13 @@ public:
_selected = SECTION_DISPLAY;
buildVis();
_scroll = 0;
_freq_editor.active = false;
}
int render(DisplayDriver& display) override {
display.setTextSize(1);
if (_edit_slot >= 0) {
if (_edit_slot >= 0 || _preset_saving) {
return _kb->render(display);
}
@@ -630,6 +792,8 @@ public:
drawScrollIndicator(display, start_y, _visible * item_h, _vis_count, _visible, _scroll);
if (_preset_menu.active) _preset_menu.render(display);
return 2000;
}
@@ -652,6 +816,70 @@ public:
return true;
}
// Digit-by-digit Freq editor
if (_freq_editor.active) {
float before = _freq_editor.value;
_freq_editor.handleInput(c);
if (p && _freq_editor.value != before) {
p->freq = _freq_editor.value;
_task->applyRadioParams();
_dirty = true;
}
return true;
}
// Keyboard editing mode for naming a new saved preset
if (_preset_saving) {
auto res = _kb->handleInput(c);
if (res == KeyboardWidget::DONE) {
saveCurrentAsPreset(_kb->buf);
_preset_saving = false;
} else if (res == KeyboardWidget::CANCELLED) {
_preset_saving = false;
}
return true;
}
// Radio preset popup (and its "delete a saved preset" sub-list)
if (_preset_menu.active) {
auto res = _preset_menu.handleInput(c);
if (res == PopupMenu::SELECTED && p) {
int idx = _preset_menu.selectedIndex();
if (_preset_deleting) {
if (idx >= 0 && idx < _preset_user_count) {
p->user_radio_presets[_preset_user_slot[idx]].name[0] = '\0';
_dirty = true;
_task->showAlert("Preset deleted", 800);
}
_preset_deleting = false;
} else {
int save_idx = 0;
int builtin_base = 1;
int user_base = builtin_base + RADIO_PRESET_COUNT;
int delete_idx = user_base + _preset_user_count;
if (idx == save_idx) {
_preset_saving = true;
_kb->begin("", (int)sizeof(p->user_radio_presets[0].name) - 1);
} else if (idx >= builtin_base && idx < user_base) {
const RadioPreset& r = RADIO_PRESETS[idx - builtin_base];
p->freq = r.freq; p->bw = r.bw; p->sf = r.sf; p->cr = r.cr;
_task->applyRadioParams();
_dirty = true;
} else if (idx >= user_base && idx < delete_idx) {
const NodePrefs::UserRadioPreset& u = p->user_radio_presets[_preset_user_slot[idx - user_base]];
p->freq = u.freq; p->bw = u.bw; p->sf = u.sf; p->cr = u.cr;
_task->applyRadioParams();
_dirty = true;
} else if (_preset_user_count > 0 && idx == delete_idx) {
openDeletePresetMenu();
}
}
} else if (res == PopupMenu::CANCELLED) {
_preset_deleting = false;
}
return true;
}
if (c == KEY_UP && _vis_count > 0) {
int vi = visIndexOf(_selected);
vi = (vi > 0) ? vi - 1 : _vis_count - 1;
@@ -743,13 +971,41 @@ public:
if (right && p->tx_power_dbm < 22) { p->tx_power_dbm++; _task->applyTxPower(); _dirty = true; return true; }
if (left && p->tx_power_dbm > 2) { p->tx_power_dbm--; _task->applyTxPower(); _dirty = true; return true; }
}
if (_selected == RADIO_PRESET && p && enter) {
openPresetMenu();
return true;
}
// Enter Freq's digit-by-digit editor (see DigitEditor.h). Bounds come from
// the radio driver itself (RadioLib's own validated range for this chip)
// so a digit can never be nudged to a value setFrequency() would reject.
if (_selected == CUSTOM_FREQ && p && enter) {
float min_mhz, max_mhz;
radio_driver.getFreqBounds(min_mhz, max_mhz);
_freq_editor.begin(p->freq, min_mhz, max_mhz, 3, 3); // SX1262 range fits 3 integer digits; 3 decimals = kHz
return true;
}
if (_selected == CUSTOM_SF && p) {
if (right && p->sf < 12) { p->sf++; _task->applyRadioParams(); _dirty = true; return true; }
if (left && p->sf > 5) { p->sf--; _task->applyRadioParams(); _dirty = true; return true; }
}
if (_selected == CUSTOM_BW && p) {
int idx = customBwIndex();
if (right && idx < LORA_BW_OPT_COUNT - 1) { p->bw = LORA_BW_OPTS[idx + 1]; _task->applyRadioParams(); _dirty = true; return true; }
if (left && idx > 0) { p->bw = LORA_BW_OPTS[idx - 1]; _task->applyRadioParams(); _dirty = true; return true; }
}
if (_selected == CUSTOM_CR && p) {
if (right && p->cr < 8) { p->cr++; _task->applyRadioParams(); _dirty = true; return true; }
if (left && p->cr > 5) { p->cr--; _task->applyRadioParams(); _dirty = true; return true; }
}
if (_selected == POWER_SAVE && p && (left || right || enter)) {
if (p->client_repeat) { _task->showAlert("Off while repeating", 900); return true; }
p->rx_powersave ^= 1;
_task->applyPowerSave();
_dirty = true;
return true;
}
if (_selected == TX_APC && p && (left || right || enter)) {
if (p->client_repeat) { _task->showAlert("Off while repeating", 900); return true; }
p->tx_apc ^= 1;
_task->applyApc();
_dirty = true;

View File

@@ -7,7 +7,7 @@ class ToolsScreen : public UIScreen {
int _sel;
int _scroll = 0;
static const int ITEM_COUNT = 7;
static const int ITEM_COUNT = 8;
static const char* ITEMS[ITEM_COUNT];
public:
@@ -38,8 +38,9 @@ public:
if (_sel == 4) { _task->gotoTrailScreen(); return true; }
if (_sel == 5) { _task->gotoCompassScreen(); return true; }
if (_sel == 6) { _task->gotoDiagnosticsScreen(); return true; }
if (_sel == 7) { _task->gotoRepeaterScreen(); return true; }
}
return false;
}
};
const char* ToolsScreen::ITEMS[7] = { "Ringtone Editor", "Auto-Reply Bot", "Nearby Nodes", "Auto-Advert", "Trail", "Compass", "Diagnostics" };
const char* ToolsScreen::ITEMS[8] = { "Ringtone Editor", "Auto-Reply Bot", "Nearby Nodes", "Auto-Advert", "Trail", "Compass", "Diagnostics", "Repeater" };

View File

@@ -126,6 +126,7 @@ static const int QUICK_MSGS_MAX = 10;
#include "TrailScreen.h"
#include "CompassScreen.h"
#include "DiagnosticsScreen.h"
#include "RepeaterScreen.h"
#include "ToolsScreen.h"
#ifndef BATT_MIN_MILLIVOLTS
@@ -477,6 +478,15 @@ class HomeScreen : public UIScreen {
if (blinkOn()) drawBoxedIcon(display, gX, ind, ind_h, ICON_TRAIL);
leftmostX = gX - 1;
}
// Repeater relaying. Same blink convention — a "leave it on and forget"
// mode, so an at-a-glance cue matters (especially on a Custom profile,
// where the device is off your own network while this is shown).
if (_node_prefs && _node_prefs->client_repeat) {
int rX = leftmostX - ind;
if (blinkOn()) drawBoxedIcon(display, rX, ind, ind_h, ICON_REPEATER);
leftmostX = rX - 1;
}
}
return leftmostX;
}
@@ -532,7 +542,11 @@ public:
display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name));
int rightEdge = renderBatteryIndicator(display, _task->getBattMilliVolts());
display.setColor(DisplayDriver::LIGHT);
if (_node_prefs && _node_prefs->tx_apc) {
// Only show the live-power readout when APC is actually controlling power —
// not merely when the pref is set. While repeating APC is suppressed and
// power is pinned to the ceiling, so apcActive() is false and the name bar
// drops the readout (matching the "--" lock in Settings).
if (the_mesh.apcActive()) {
char pwr_buf[8];
snprintf(pwr_buf, sizeof(pwr_buf), "%ddB", (int)radio_driver.getTxPower());
int pwr_w = display.getTextWidth(pwr_buf);
@@ -994,7 +1008,8 @@ public:
// Any blinking status-bar indicator needs a 1 s refresh to animate evenly —
// but the status bar (and its icons) is hidden on the CLOCK page, so don't
// pay the 1 s cadence there for icons that aren't drawn.
bool need_blink = (_page != HomePage::CLOCK) && (auto_adv || _task->trail().isActive());
bool repeating = _node_prefs && _node_prefs->client_repeat;
bool need_blink = (_page != HomePage::CLOCK) && (auto_adv || _task->trail().isActive() || repeating);
if (Features::IS_EINK) {
// slow display: poll every 30 s; inbound msgs force immediate refresh via notify()
return Features::HOME_REFRESH_MS;
@@ -1200,6 +1215,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
trail_screen = new TrailScreen(this, &_trail);
compass_screen = new CompassScreen(this);
diag_screen = new DiagnosticsScreen(this);
repeater_screen = new RepeaterScreen(this);
applyBrightness();
applyFont();
applyRotation();
@@ -1250,6 +1266,11 @@ void UITask::gotoDiagnosticsScreen() {
setCurrScreen(diag_screen);
}
void UITask::gotoRepeaterScreen() {
((RepeaterScreen*)repeater_screen)->enter();
setCurrScreen(repeater_screen);
}
void UITask::gotoAutoAdvertScreen() {
((AutoAdvertScreen*)auto_advert_screen)->enter();
setCurrScreen(auto_advert_screen);
@@ -2092,13 +2113,21 @@ void UITask::applyTxPower() {
void UITask::applyPowerSave() {
if (_node_prefs == NULL) return;
radio_driver.setPowerSaving(_node_prefs->rx_powersave);
// A repeater must hear every packet to relay it, so duty-cycle RX (which sleeps
// between preamble checks) is forced off while repeating — the user's pref is
// kept and restored when the repeater is switched off.
radio_driver.setPowerSaving(_node_prefs->rx_powersave && !_node_prefs->client_repeat);
}
void UITask::applyApc() {
the_mesh.applyApc(); // (re)initialise Adaptive Power Control from prefs
}
void UITask::applyRadioParams() {
if (_node_prefs == NULL) return;
the_mesh.applyRepeaterRadio(); // companion params, or the repeater profile if relaying with one set
}
void UITask::applyBrightness() {
if (_display != NULL && _node_prefs != NULL) {
_display->setBrightness(_node_prefs->display_brightness);

View File

@@ -80,6 +80,7 @@ class UITask : public AbstractUITask {
UIScreen* trail_screen;
UIScreen* compass_screen;
UIScreen* diag_screen;
UIScreen* repeater_screen;
UIScreen* curr;
CayenneLPP _dash_lpp;
TrailStore _trail;
@@ -154,6 +155,7 @@ public:
void gotoTrailScreen();
void gotoCompassScreen();
void gotoDiagnosticsScreen();
void gotoRepeaterScreen();
TrailStore& trail() { return _trail; }
WaypointStore& waypoints() { return _waypoints; }
// Shared on-screen keyboard — only one screen drives it at a time.
@@ -260,6 +262,7 @@ public:
void applyTxPower();
void applyPowerSave(); // hardware duty-cycle RX on/off from prefs
void applyApc(); // Adaptive Power Control on/off from prefs
void applyRadioParams(); // freq/bw/sf/cr from prefs (radio preset change)
void applyFont();
void applyRotation();
void applyFullRefreshInterval();

View File

@@ -74,28 +74,6 @@ inline void miniIconDrawTop(DisplayDriver& d, int x, int y, const MiniIcon& ic)
if (ic.rows[r] & (1 << c)) d.fillRect(x + c * s, y + r * s, s, s);
}
// Like miniIconDrawTop, but first lays down a 1px DARK halo that hugs the icon's
// shape (each pixel dilated by 1px), then the icon in LIGHT. Invisible on a dark
// row; on the LIGHT selection bar it outlines just the glyph — no boxed-in
// rectangle around it. Restores ink to LIGHT. clip_top bounds the halo so it
// can't bleed above a given y (e.g. onto a header separator just above the icon).
inline void miniIconDrawHalo(DisplayDriver& d, int x, int y, const MiniIcon& ic,
int clip_top = -100000) {
const int s = miniIconScale(d);
d.setColor(DisplayDriver::DARK);
for (int r = 0; r < ic.h; r++)
for (int c = 0; c < ic.w; c++)
if (ic.rows[r] & (1 << c)) {
int hy = y + r * s - 1, hh = s + 2;
if (hy < clip_top) { hh -= clip_top - hy; hy = clip_top; }
if (hh > 0) d.fillRect(x + c * s - 1, hy, s + 2, hh);
}
d.setColor(DisplayDriver::LIGHT);
for (int r = 0; r < ic.h; r++)
for (int c = 0; c < ic.w; c++)
if (ic.rows[r] & (1 << c)) d.fillRect(x + c * s, y + r * s, s, s);
}
// Draw a mini-icon centred on a point (scaled) — used for map markers, which
// are positioned by their centre rather than a text-line top.
inline void miniIconDrawCentered(DisplayDriver& d, int cx, int cy, const MiniIcon& ic) {
@@ -180,6 +158,13 @@ MINI_ICON(ICON_TRAIL, 6, // map pin / location marker (GPS trail logging)
packRow(".####."),
packRow("..##.."));
MINI_ICON(ICON_REPEATER, 6, // » double chevron — relaying/forwarding (repeater active)
packRow("#..#.."),
packRow(".#..#."),
packRow("..#..#"),
packRow(".#..#."),
packRow("#..#.."));
// Trail-map markers — centred on a point (see miniIconDrawCentered) rather
// than anchored to a text line.
MINI_ICON(ICON_MAP_DOT, 3, // ● filled trail point
@@ -318,20 +303,15 @@ inline void drawScrollIndicatorPx(DisplayDriver& d, int right_x, int top_y, int
if (thumb_h > band) thumb_h = band;
const int thumb_y = band_t + (int)((long)(band - thumb_h) * scroll_px / span);
// Each marker is drawn with a 1px DARK halo: invisible on a normal (dark) row,
// but on the LIGHT selection bar it carves out contrast so the LIGHT marker
// stays visible. Avoids having to know which row is currently selected.
// Thumb: a rectangle, so a plain box halo matches its shape.
d.setColor(DisplayDriver::DARK);
d.fillRect(bar_x - 1, thumb_y - 1, bar_w + 2, thumb_h + 2);
// No halo: every caller already keeps its selection bar clear of this column
// (reserve/_reserve), so the markers never need to fight a LIGHT background
// for contrast — and a halo on the bottom arrow had no symmetric clip like
// the top one, so it bled 1px into the container's bottom border.
d.setColor(DisplayDriver::LIGHT);
d.fillRect(bar_x, thumb_y, bar_w, thumb_h);
// Arrows: shape-hugging halo so they aren't boxed in on the selection bar.
// The top arrow clips its halo at top_y so it can't bite into the header
// separator sitting one pixel above the list area.
miniIconDrawHalo(d, x, top_y, ICON_SCROLL_UP, top_y);
miniIconDrawHalo(d, x, top_y + track_h - th, ICON_SCROLL_DOWN);
miniIconDrawTop(d, x, top_y, ICON_SCROLL_UP);
miniIconDrawTop(d, x, top_y + track_h - th, ICON_SCROLL_DOWN);
}
// Convenience overload anchored to the screen's right edge — the common case