mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-07-26 14:58:12 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -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++)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
50
examples/companion_radio/RadioPresets.h
Normal file
50
examples/companion_radio/RadioPresets.h
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
86
examples/companion_radio/ui-new/DigitEditor.h
Normal file
86
examples/companion_radio/ui-new/DigitEditor.h
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
325
examples/companion_radio/ui-new/RepeaterScreen.h
Normal file
325
examples/companion_radio/ui-new/RepeaterScreen.h
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user