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:
77
FEATURES.md
77
FEATURES.md
@@ -441,6 +441,83 @@ Sequencing: hardware duty-cycle RX and APC are both done and in field test on
|
||||
`feat/power-saving`; see the status block at the top of this entry for what's
|
||||
left (PPK2 current measurement, multi-hop APC gating).
|
||||
|
||||
### ✅ Companion repeater + politeness knobs + diagnostics
|
||||
|
||||
On-device repeater for the Solo companion, scoped to the SX1262 boards (Wio
|
||||
Tracker L1 OLED/e-ink, GAT562 30S). All on `feature/companion-repeater-presets`.
|
||||
|
||||
- **Repeater toggle** (`client_repeat`, Tools › Repeater) — the companion relays
|
||||
flood/direct traffic, still working as a normal companion. By default it
|
||||
switches to a dedicated band on enable (see profile note below) rather than
|
||||
relaying on whatever network it's chatting on. `MyMesh::allowPacketForward`
|
||||
gates it; loop
|
||||
detection (`isRepeatLooped`, ported from `simple_repeater`) and an advert
|
||||
flood-depth cap are always applied. Packet pool bumped 16→32 to match the
|
||||
repeater workload (a too-small pool starved channel/DM reception once relaying
|
||||
queued retransmits).
|
||||
- **Consolidated on Tools › Repeater** — the toggle and the five filters share
|
||||
one full-width screen, rather than the original Settings › Radio sub-items
|
||||
(whose indented labels collided with the value column on a 128px OLED). Live
|
||||
forwarding stats (Forwarded / Pool free / Queue) stay on Tools › Diagnostics.
|
||||
- **Optional dedicated radio profile** (`Network: Current/Custom`,
|
||||
`repeater_use_profile` + `repeater_freq/bw/sf/cr`). Custom switches the radio to
|
||||
a preset/manual profile when the repeater is enabled and restores the
|
||||
companion's params when disabled (user-chosen revert-on-disable); a profile
|
||||
equal to the companion = "same network", a different one = drop onto a separate
|
||||
repeater network. `MyMesh::applyRepeaterRadio()` is the single decision point,
|
||||
called at boot and on every toggle/edit; `repeaterProfileValid()` gates it.
|
||||
Schema sentinel `0xC0DE0010`. **Custom is the default**: a never-configured
|
||||
device (or one upgrading from a pre-`0x10` file, which has no saved profile)
|
||||
turns the profile on and seeds `repeater_sf/bw/cr` from `LORA_SF/BW/CR` plus
|
||||
a band-matched `repeater_freq` — relaying on the same network the operator is
|
||||
chatting on isn't the MeshCore community norm, so "Current" stays opt-in.
|
||||
`defaultRepeaterFreqForBand()` (`NodePrefs.h`) buckets the companion's own
|
||||
`freq` into whichever of the three license-exempt bands MeshCore's app-driven
|
||||
repeat toggle historically restricted to (433.000 / 869.495 / 918.000 MHz —
|
||||
see `repeat_freq_ranges` in `MyMesh.cpp`), so the seeded default can't land
|
||||
outside what's legal for wherever the companion's own network already is.
|
||||
`LORA_FREQ/BW/SF/CR` fallback `#define`s moved from `MyMesh.h` to
|
||||
`NodePrefs.h` so `DataStore.cpp`'s migration code can see them too.
|
||||
- **Politeness knobs** (all opt-in, default off, flood-only — a direct route's
|
||||
named next hop is never dropped). Shown only while the repeater is on:
|
||||
- **Skip advert** — don't re-flood adverts (highest-volume flood).
|
||||
- **Max hops** — drop a flood past N hops.
|
||||
- **Yield** — scale the retransmit delay for *forwarded* floods only
|
||||
(`getRetransmitDelay`); own sends pass their own delay to `sendFlood`, so the
|
||||
companion's own traffic is never slowed. Widens the overhear window.
|
||||
- **Min SNR** — drop a flood copy below a dB threshold (`-128` sentinel = off,
|
||||
so an upgraded prefs file can't read as "filter at 0 dB").
|
||||
- **Suppress dup** — overhear cancel: a received flood whose hash matches a
|
||||
queued retransmit cancels our copy (`Dispatcher::suppressQueuedDuplicate`,
|
||||
`wantsOverhearSuppress` hook). MeshCore had no overhear-cancel before — the
|
||||
unused `removeOutboundByIdx`/`getOutboundByIdx` finally have a caller. Packet
|
||||
hash ignores the path for non-TRACE, so our copy and the peer's relayed copy
|
||||
hash equal. Pairs with Yield (longer delay → wider window to hear a peer).
|
||||
- **Diagnostics screen** (Tools › Diagnostics) — single read-only page: per-type
|
||||
RX/TX counters (generic `Dispatcher::n_recv_by_type`/`n_sent_by_type`), uptime,
|
||||
heap + stack (new `DeviceDiag` helper, nRF52 linker-symbol/`sbrk` heap +
|
||||
FreeRTOS stack high-water), noise floor, RSSI/SNR, pool free, outbound queue,
|
||||
**Forwarded** (`Mesh::n_forwarded` — actual retransmits; backed out on overhear
|
||||
cancel so it reflects what hits the air), and **Errors** (Dispatcher
|
||||
`ERR_EVENT_*` flags decoded to F/C/R). Hold Enter opens a one-item "Reset
|
||||
counters" menu (Back dismisses) — `resetStats` made virtual; `Mesh` override
|
||||
also clears `n_forwarded`.
|
||||
- **Radio settings locked while relaying** — a repeater must hear all traffic and
|
||||
relay at consistent power, so duty-cycle RX ("Pwr save") and APC ("Auto pwr")
|
||||
are forced off whenever `client_repeat` is on (effective `pref && !client_repeat`
|
||||
via `applyPowerSave()` / `apcActive()`), applied at boot, on the on-device
|
||||
toggle, and on the app's `CMD_SET_RADIO_PARAMS`; Settings shows `--` and blocks
|
||||
the toggle, preserving the user's pref for when the repeater goes off. A
|
||||
blinking `»` status-bar indicator (`ICON_REPEATER`) shows relaying at a glance.
|
||||
- Prefs persisted behind schema sentinels `0xC0DE000E` (four knobs),
|
||||
`0xC0DE000F` (suppress-dup), `0xC0DE0010` (radio profile), with stray-byte
|
||||
clamps for upgraders.
|
||||
- **Open question:** the app-side dedicated-band gate in `CMD_SET_RADIO_PARAMS`
|
||||
was commented out (not deleted) to match the on-device toggle's any-frequency
|
||||
behaviour — undecided whether that gate was UX-only or regulatory.
|
||||
- **Not done:** live two-device mesh verification (A→repeater→C); counters make
|
||||
it observable but don't replace the field test.
|
||||
|
||||
### SOS broadcast
|
||||
|
||||
Configurable in Settings › System › SOS:
|
||||
|
||||
@@ -49,7 +49,7 @@ All firmware files are published on the [releases page](https://github.com/Marek
|
||||
|
||||
- [Screen Lock](./docs/solo_features/screen_lock/screen_lock.md) — lock the device to prevent accidental keypresses, with a lock screen showing time and sensor data
|
||||
|
||||
- [Tools Screen](./docs/solo_features/tools_screen/tools_screen.md) — GPS trail & waypoints, compass, nearby nodes (with ping & navigate), ringtone editor, auto-reply bot, auto-advert
|
||||
- [Tools Screen](./docs/solo_features/tools_screen/tools_screen.md) — GPS trail & waypoints, compass, nearby nodes (with ping & navigate), ringtone editor, auto-reply bot, auto-advert, diagnostics, repeater
|
||||
|
||||
- **Battery saving (radio)** — two optional, independent toggles under Settings › Radio:
|
||||
- **Pwr save** — hardware duty-cycle receive (SX126x `SetRxDutyCycle`): the radio cycles RX↔sleep on its own and wakes on a preamble, cutting average RX current with only a little added receive latency
|
||||
@@ -97,7 +97,7 @@ Updating to a newer version usually does not require erasing flash unless the re
|
||||
| [Clock Screen](./docs/solo_features/clock_screen/clock_screen.md) | Clock page, date, configurable data fields |
|
||||
| [Settings Screen](./docs/solo_features/settings_screen/settings_screen.md) | All settings sections with values and interactions |
|
||||
| [Screen Lock](./docs/solo_features/screen_lock/screen_lock.md) | Lock/unlock sequence, lock screen, auto-lock |
|
||||
| [Tools Screen](./docs/solo_features/tools_screen/tools_screen.md) | GPS trail & waypoints, compass, navigation, nearby nodes, ringtone editor, auto-reply bot, auto-advert |
|
||||
| [Tools Screen](./docs/solo_features/tools_screen/tools_screen.md) | GPS trail & waypoints, compass, navigation, nearby nodes, ringtone editor, auto-reply bot, auto-advert, diagnostics, repeater |
|
||||
|
||||
### Upstream MeshCore
|
||||
|
||||
|
||||
2
build.sh
2
build.sh
@@ -96,7 +96,7 @@ get_platform_for_env() {
|
||||
local env_name=$1
|
||||
echo "$PIO_CONFIG_JSON" | python3 -c "
|
||||
import sys, json, re
|
||||
data = json.load(sys.stdin)
|
||||
data = json.load(sys.stdin, strict=False)
|
||||
for section, options in data:
|
||||
if section == 'env:$env_name':
|
||||
for key, value in options:
|
||||
|
||||
@@ -65,9 +65,16 @@ Lists all available home screen pages. For each entry:
|
||||
|
||||
| Setting | Options | Notes |
|
||||
| --------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| TX power | 2–22 dBm | LEFT/RIGHT. With **Auto pwr** on this is the *ceiling* — the radio may transmit lower. |
|
||||
| Pwr save | ON / OFF | **Battery saver.** Hardware duty-cycle receive: the SX126x cycles RX↔sleep on its own and wakes on a preamble, cutting average RX current. Trades a little receive latency; leave OFF for lowest-latency reception. Requires an SX126x radio (otherwise stays on continuous RX). |
|
||||
| Auto pwr | ON / OFF | **Adaptive Power Control.** Lowers actual TX power on strong links to save energy, ramping back up — to the **TX power** ceiling — on weak or lost links. Link quality comes from direct-message ACK SNR and, for channel messages (no ACK), from hearing a repeater rebroadcast your packet. The radio page / name bar shows the live power. Default OFF (fixed TX power). |
|
||||
| TX Pwr | 2–22 dBm | LEFT/RIGHT. With **Auto pwr** on this is the *ceiling* — the radio may transmit lower. |
|
||||
| Preset | named presets | LEFT/RIGHT cycles community RF presets (region frequency + bandwidth/SF/CR). **Enter** opens a popup to pick one, save the current settings as a named preset, or delete a saved one. Applies frequency, bandwidth, SF and CR together. |
|
||||
| Freq | chip range | **Enter** opens a digit-by-digit editor: LEFT/RIGHT moves between decimal places, UP/DOWN steps that digit. Bounds come from the radio chip's own validated range, so a value the radio would reject can't be entered. |
|
||||
| SF | 5–12 | LEFT/RIGHT. Spreading factor. |
|
||||
| BW | 7.8–500 kHz | LEFT/RIGHT cycles the standard LoRa bandwidths. |
|
||||
| CR | 5–8 | LEFT/RIGHT. Coding rate (4/5–4/8). |
|
||||
| Pwr save | ON / OFF | **Battery saver.** Hardware duty-cycle receive: the SX126x cycles RX↔sleep on its own and wakes on a preamble, cutting average RX current. Trades a little receive latency; leave OFF for lowest-latency reception. Requires an SX126x radio (otherwise stays on continuous RX). **Forced off (shown as `--`) while the repeater is on** — a repeater must listen continuously; your setting is restored when the repeater is switched off. |
|
||||
| Auto pwr | ON / OFF | **Adaptive Power Control.** Lowers actual TX power on strong links to save energy, ramping back up — to the **TX Pwr** ceiling — on weak or lost links. Link quality comes from direct-message ACK SNR and, for channel messages (no ACK), from hearing a repeater rebroadcast your packet. The radio page / name bar shows the live power. Default OFF (fixed TX power). **Suppressed (shown as `--`) while the repeater is on** — a repeater holds full TX power for consistent relay reach; your setting is restored when the repeater is switched off. |
|
||||
|
||||
The **repeater** mode and its flood filters live on their own screen — see **Tools › Repeater**.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
| :-----------------------: | :-----------------------: |
|
||||
|  |  |
|
||||
|
||||
The Tools screen is a hub for GPS trail recording, nearby node browsing, ringtone editing, auto-reply bot, and auto-advert. Navigate the tool list with **UP/DOWN** and press **Enter** to open a tool.
|
||||
The Tools screen is a hub for GPS trail recording, nearby node browsing, ringtone editing, auto-reply bot, auto-advert, compass, device diagnostics, and repeater mode. Navigate the tool list with **UP/DOWN** and press **Enter** to open a tool.
|
||||
|
||||
---
|
||||
|
||||
@@ -287,3 +287,61 @@ With **Commands** ON, a DM beginning with `!` is answered with live node data, i
|
||||
Several commands can be combined in one message — `!batt !time !hops` is answered with a single `4.10V | 14:30 | 3 hops` reply (one transmission). A message with no recognised command falls through to the trigger bot.
|
||||
|
||||
Commands also work on the **monitored channel** (the one selected for channel mode) — anyone there can query the node. Channel replies are broadcast to everyone, so unlike DM commands they respect quiet hours and use the shared per-channel cooldown. DM commands use the per-contact throttle.
|
||||
|
||||
---
|
||||
|
||||
## Diagnostics
|
||||
|
||||
A single read-only screen of live device and mesh stats, refreshed once a second. On a small OLED the rows scroll with **UP/DOWN**; on a larger e-ink display they all fit at once.
|
||||
|
||||
| Row | Shows |
|
||||
| ------------ | -------------------------------------------------------------------------------------------------- |
|
||||
| Uptime | Time since boot (`d hh:mm:ss`) |
|
||||
| Total rx/tx | All received / transmitted packets, summed across the categories below |
|
||||
| Msg | Text and group-text packets, `rx/tx` |
|
||||
| Advert | Advert packets, `rx/tx` |
|
||||
| Ack/Path | Ack, path-return and trace packets, `rx/tx` |
|
||||
| Other | Everything else (requests, responses, control, raw, …), `rx/tx` |
|
||||
| Forwarded | Packets this node actually re-transmitted as a repeater (reflects overhear suppression, if on) |
|
||||
| Heap free | Free / total heap |
|
||||
| Stack free | Current task's minimum-ever stack headroom |
|
||||
| Noise floor | Live radio noise floor (dBm) |
|
||||
| RSSI/SNR | Signal strength / signal-to-noise of the last received packet |
|
||||
| Pool free | Free entries in the packet pool |
|
||||
| Queue | Packets waiting in the outbound queue |
|
||||
| Errors | Radio error flags since boot/reset — `OK`, or tokens `F` (queue full), `C` (CAD timeout), `R` (RX-start timeout) |
|
||||
|
||||
The packet counters, **Forwarded** and **Errors** are cumulative since boot. **Hold Enter** opens a one-item *Reset counters* menu (Back dismisses it); the live readings (noise, RSSI/SNR, pool, queue, uptime) are not affected. **Cancel/Back** returns to the Tools list.
|
||||
|
||||
The counters make the repeater behaviour observable: **Forwarded** confirms the node is actually relaying (not just configured to), and **Pool free** / **Queue** show whether forwarding is exhausting the packet pool. See **Tools › Repeater** for the relaying options.
|
||||
|
||||
---
|
||||
|
||||
## Repeater
|
||||
|
||||
Turns the companion into a packet **repeater** while it keeps working as a normal companion — no separate firmware. By default, enabling it switches the radio to a dedicated repeater profile rather than relaying on whatever network you're chatting on (see **Network** below) — that matches the MeshCore community norm of repeaters sitting on a standard channel, not a private one. Loop-detection and an advert flood-depth cap are always applied. This screen keeps the toggle, the network/profile, and its flood-filter options together; live forwarding stats are on **Tools › Diagnostics**.
|
||||
|
||||
Navigate with **UP/DOWN**; change a value with **LEFT/RIGHT** (or **Enter** for toggles). **Cancel/Back** saves and returns to Tools.
|
||||
|
||||
| Setting | Options | Notes |
|
||||
| -------------- | --------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| Repeater | ON / OFF | Master switch. The options below appear only while it is ON. |
|
||||
| Network | Current / Custom | **Custom** _(default)_: enabling the repeater switches the radio to a dedicated profile (below) and disabling restores the companion's settings — so you can drop onto a separate repeater network and come back. A never-configured device seeds Custom with a frequency in the same band as your own network (433/868/915 MHz region), not a flat one-size-fits-all default — so it can't land outside what's legal for your region. Switching to Custom afterwards (if it was OFF and unconfigured) seeds it from your current settings instead. **Current**: relay on the companion's own frequency — opt-in; not the community norm. |
|
||||
| Rpt preset | named presets | _(Custom only)_ **Enter** picks a community/saved preset for the repeater profile. |
|
||||
| Rpt freq | chip range | _(Custom only)_ **Enter** opens the digit-by-digit editor (chip-validated bounds). |
|
||||
| Rpt SF / BW / CR | 5–12 / 7.8–500 kHz / 5–8 | _(Custom only)_ **LEFT/RIGHT** to adjust the profile's spreading factor, bandwidth, coding rate. |
|
||||
| Skip advert | ON / OFF | Don't re-flood **advert** packets (the highest-volume flood traffic); messages and acks still relay. |
|
||||
| Max hops | OFF / 1–8 | Drop a flood packet once it has already travelled this many hops. |
|
||||
| Yield | OFF / x2–x9 | Scales the retransmit delay for **forwarded** floods only (your own sends are unaffected), so a mobile companion defers to better-sited fixed repeaters. Widens the window for **Suppress dup**. |
|
||||
| Min SNR | OFF / −20…10 dB | Drop a flood copy received below this signal-to-noise threshold, so marginal fringe traffic isn't re-flooded. |
|
||||
| Suppress dup | ON / OFF | If the same flood packet is overheard from another node while still queued to retransmit, cancel our copy — a peer already relayed it. Cuts redundant airtime in dense meshes; pairs with **Yield**. |
|
||||
|
||||
The five flood filters are **opt-in** (default OFF, so a plain repeater is unaffected) and act on **flood** traffic only — on a direct route this node is the named next hop, so it never drops those.
|
||||
|
||||
**Same network vs. separate network.** With **Network = Current** (or a Custom profile set equal to your companion settings) the repeater stays on your own network — you keep messaging while relaying. With a *different* Custom profile the device moves entirely onto that network while relaying (a single radio can't be on two at once) and returns to your companion network when the repeater is switched off. The profile also re-applies after a reboot if the repeater was left on.
|
||||
|
||||
While the repeater is on, a **»** indicator appears in the status bar (same blink convention as the auto-advert and trail markers) so you can tell it's relaying at a glance. Two radio settings are also overridden while relaying and restored afterwards: **Settings › Radio › Pwr save** is forced off (a repeater must listen continuously) and **Auto pwr** is forced off (a repeater holds full TX power for consistent relay reach). Both show `--` in Settings while the repeater is on.
|
||||
|
||||
Live forwarding stats — **Forwarded**, **Pool free**, **Queue** — are shown on **Tools › Diagnostics**.
|
||||
|
||||
While the repeater is ON, the bottom of the screen shows live stats: **Forwarded** (packets actually re-transmitted — reflects Suppress dup), **Pool free**, and **Queue**. The same numbers appear, alongside the full device picture, in **Tools › Diagnostics**.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -240,6 +240,9 @@ void Dispatcher::checkRecv() {
|
||||
logRx(pkt, pkt->getRawLength(), score); // hook for custom logging
|
||||
|
||||
n_recv_by_type[pkt->getPayloadType()]++;
|
||||
// Overhear suppression: if we just heard a flood packet that we still have
|
||||
// queued to retransmit, another node beat us to it — drop our copy.
|
||||
if (pkt->isRouteFlood() && wantsOverhearSuppress()) suppressQueuedDuplicate(pkt);
|
||||
if (pkt->isRouteFlood()) {
|
||||
n_recv_flood++;
|
||||
|
||||
@@ -261,6 +264,27 @@ void Dispatcher::checkRecv() {
|
||||
}
|
||||
}
|
||||
|
||||
void Dispatcher::suppressQueuedDuplicate(Packet* pkt) {
|
||||
uint8_t h[MAX_HASH_SIZE];
|
||||
pkt->calculatePacketHash(h);
|
||||
// Scan the whole outbound queue (not just due entries). hasSeen() already
|
||||
// keeps at most one copy queued, so the first hash match is the only one.
|
||||
int n = _mgr->getOutboundTotal();
|
||||
for (int i = 0; i < n; i++) {
|
||||
Packet* q = _mgr->getOutboundByIdx(i);
|
||||
if (q == NULL || !q->isRouteFlood()) continue; // only ever suppress a queued flood retransmit
|
||||
uint8_t qh[MAX_HASH_SIZE];
|
||||
q->calculatePacketHash(qh);
|
||||
if (memcmp(h, qh, MAX_HASH_SIZE) == 0) {
|
||||
_mgr->removeOutboundByIdx(i);
|
||||
onRetransmitCancelled(q);
|
||||
_mgr->free(q);
|
||||
MESH_DEBUG_PRINTLN("%s Dispatcher: overhear suppressed a queued retransmit", getLogDateTime());
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Dispatcher::processRecvPacket(Packet* pkt) {
|
||||
DispatcherAction action = onRecvPacket(pkt);
|
||||
if (action == ACTION_RELEASE) {
|
||||
|
||||
@@ -170,6 +170,14 @@ protected:
|
||||
virtual int getAGCResetInterval() const { return 0; } // disabled by default
|
||||
virtual unsigned long getDutyCycleWindowMs() const { return 3600000; }
|
||||
|
||||
// When true, a received flood packet whose hash matches one still waiting in
|
||||
// the outbound queue cancels that queued retransmit (overhear suppression):
|
||||
// another node already relayed it, so this node stays quiet. Default off.
|
||||
virtual bool wantsOverhearSuppress() const { return false; }
|
||||
// Hook fired when such a queued retransmit is cancelled — lets a sub-class
|
||||
// keep its forward counter honest. Default no-op.
|
||||
virtual void onRetransmitCancelled(Packet* packet) { }
|
||||
|
||||
public:
|
||||
void begin();
|
||||
void loop();
|
||||
@@ -189,7 +197,8 @@ public:
|
||||
uint32_t getNumRecvByType(uint8_t payload_type) const { return n_recv_by_type[payload_type & 0x0F]; }
|
||||
int getPoolFreeCount() const { return _mgr->getFreeCount(); }
|
||||
int getOutboundQueueLen() const { return _mgr->getOutboundTotal(); }
|
||||
void resetStats() {
|
||||
uint16_t getErrFlags() const { return _err_flags; } // ERR_EVENT_* bitmask since last resetStats()
|
||||
virtual void resetStats() {
|
||||
n_sent_flood = n_sent_direct = n_recv_flood = n_recv_direct = 0;
|
||||
memset(n_sent_by_type, 0, sizeof(n_sent_by_type));
|
||||
memset(n_recv_by_type, 0, sizeof(n_recv_by_type));
|
||||
@@ -205,6 +214,7 @@ public:
|
||||
private:
|
||||
void checkRecv();
|
||||
void checkSend();
|
||||
void suppressQueuedDuplicate(Packet* pkt); // overhear cancel (see wantsOverhearSuppress)
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace mesh {
|
||||
|
||||
void Mesh::begin() {
|
||||
Dispatcher::begin();
|
||||
n_forwarded = 0;
|
||||
}
|
||||
|
||||
void Mesh::loop() {
|
||||
@@ -60,6 +61,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
|
||||
pkt->path[pkt->path_len++] = (int8_t) (pkt->getSNR()*4);
|
||||
|
||||
uint32_t d = getDirectRetransmitDelay(pkt);
|
||||
n_forwarded++;
|
||||
return ACTION_RETRANSMIT_DELAYED(5, d); // schedule with priority 5 (for now), maybe make configurable?
|
||||
}
|
||||
}
|
||||
@@ -100,7 +102,8 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
|
||||
removeSelfFromPath(pkt);
|
||||
|
||||
uint32_t d = getDirectRetransmitDelay(pkt);
|
||||
return ACTION_RETRANSMIT_DELAYED(0, d); // Routed traffic is HIGHEST priority
|
||||
n_forwarded++;
|
||||
return ACTION_RETRANSMIT_DELAYED(0, d); // Routed traffic is HIGHEST priority
|
||||
}
|
||||
}
|
||||
return ACTION_RELEASE; // this node is NOT the next hop (OR this packet has already been forwarded), so discard.
|
||||
@@ -336,6 +339,7 @@ DispatcherAction Mesh::routeRecvPacket(Packet* packet) {
|
||||
packet->setPathHashCount(n + 1);
|
||||
|
||||
uint32_t d = getRetransmitDelay(packet);
|
||||
n_forwarded++;
|
||||
// as this propagates outwards, give it lower and lower priority
|
||||
return ACTION_RETRANSMIT_DELAYED(packet->getPathHashCount(), d); // give priority to closer sources, than ones further away
|
||||
}
|
||||
|
||||
12
src/Mesh.h
12
src/Mesh.h
@@ -27,6 +27,7 @@ class Mesh : public Dispatcher {
|
||||
RTCClock* _rtc;
|
||||
RNG* _rng;
|
||||
MeshTables* _tables;
|
||||
uint32_t n_forwarded; // packets actually re-transmitted (repeater/transport role)
|
||||
|
||||
void removeSelfFromPath(Packet* packet);
|
||||
void routeDirectRecvAcks(Packet* packet, uint32_t delay_millis);
|
||||
@@ -55,6 +56,10 @@ protected:
|
||||
*/
|
||||
virtual bool allowPacketForward(const Packet* packet);
|
||||
|
||||
// Keep the forward counter honest when an overhear cancels a queued retransmit:
|
||||
// the packet was counted at the ACTION_RETRANSMIT decision, so back it out here.
|
||||
void onRetransmitCancelled(Packet* packet) override { if (n_forwarded) n_forwarded--; }
|
||||
|
||||
/**
|
||||
* \returns number of milliseconds delay to apply to retransmitting the given packet.
|
||||
*/
|
||||
@@ -178,6 +183,13 @@ public:
|
||||
|
||||
LocalIdentity self_id;
|
||||
|
||||
// packets actually re-transmitted (vs. just permitted by allowPacketForward()) —
|
||||
// confirms a repeater/transport role is really forwarding, not just configured to.
|
||||
uint32_t getNumForwarded() const { return n_forwarded; }
|
||||
|
||||
// also clear the forward counter alongside the Dispatcher packet counters.
|
||||
void resetStats() override { Dispatcher::resetStats(); n_forwarded = 0; }
|
||||
|
||||
RNG* getRNG() const { return _rng; }
|
||||
RTCClock* getRTCClock() const { return _rtc; }
|
||||
|
||||
|
||||
@@ -3,17 +3,20 @@
|
||||
#if defined(NRF52_PLATFORM)
|
||||
#include "FreeRTOS.h"
|
||||
#include "task.h"
|
||||
#include <malloc.h>
|
||||
|
||||
// Same linker symbols + _sbrk() the core's own new.cpp uses to implement
|
||||
// malloc's heap (see framework-arduinoadafruitnrf52/cores/nRF5/new.cpp) —
|
||||
// there's no separate "free heap" API on this core, so this mirrors it.
|
||||
extern "C" char* _sbrk(int incr);
|
||||
// Heap region size from the linker symbols the core's new.cpp uses for sbrk
|
||||
// (framework-arduinoadafruitnrf52/cores/nRF5/new.cpp). "Used" comes from
|
||||
// newlib's mallinfo().uordblks (bytes actually outstanding) rather than
|
||||
// sbrk(0) - __HeapBase: sbrk only ever grows, so a high-water mark there
|
||||
// counts freed-and-recycled blocks as permanently "used" and free heap looks
|
||||
// far lower than it really is.
|
||||
extern unsigned char __HeapBase[];
|
||||
extern unsigned char __HeapLimit[];
|
||||
|
||||
void DeviceDiag::getHeapStats(uint32_t& free_bytes, uint32_t& total_bytes) {
|
||||
total_bytes = (uint32_t)(__HeapLimit - __HeapBase);
|
||||
uint32_t used = (uint32_t)((unsigned char*)_sbrk(0) - __HeapBase);
|
||||
uint32_t used = (uint32_t)mallinfo().uordblks;
|
||||
free_bytes = (used < total_bytes) ? (total_bytes - used) : 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ public:
|
||||
updatePreamble(sf);
|
||||
}
|
||||
|
||||
// From RadioLib's SX1262::setFrequency(): RADIOLIB_CHECK_RANGE(freq, 150.0f, 960.0f, ...).
|
||||
void getFreqBounds(float& min_mhz, float& max_mhz) const override { min_mhz = 150.0f; max_mhz = 960.0f; }
|
||||
|
||||
bool isReceivingPacket() override {
|
||||
return ((CustomSX1262 *)_radio)->isReceiving();
|
||||
}
|
||||
|
||||
@@ -56,6 +56,13 @@ public:
|
||||
}
|
||||
|
||||
virtual void setParams(float freq, float bw, uint8_t sf, uint8_t cr) = 0;
|
||||
// RadioLib's own setFrequency() silently rejects values outside the chip's
|
||||
// validated range and leaves the radio retuned to its previous frequency —
|
||||
// setParams() above doesn't check that return code, so the UI clamps to this
|
||||
// instead of letting NodePrefs drift out of sync with the actual radio.
|
||||
// Default is the generic sanity bound the app's CMD_SET_RADIO_PARAMS already
|
||||
// uses; chips with a narrower RadioLib-validated range override it.
|
||||
virtual void getFreqBounds(float& min_mhz, float& max_mhz) const { min_mhz = 150.0f; max_mhz = 2500.0f; }
|
||||
uint32_t getRngSeed();
|
||||
void setTxPower(int8_t dbm);
|
||||
int8_t getTxPower() const { return _tx_dbm; } // actual current power (reflects APC)
|
||||
|
||||
Reference in New Issue
Block a user