feat(repeater): suppress Adaptive Power Control while relaying

A repeater wants full, consistent TX power for relay reach, and APC's feedback
(own ACKs / own flood echoes) never fires on forwarded traffic — so leaving APC
on would relay at whatever reduced power it last trimmed to, or sit inert on a
dedicated repeater network. Force it off whenever client_repeat is on, mirroring
the power-save lock:

- apcActive() = tx_apc && !client_repeat gates every APC control site
  (sample/failure/track).
- applyApc() pins power to the tx_power_dbm ceiling; called at boot, on the
  on-device repeater toggle, and on the app's CMD_SET_RADIO_PARAMS.
- Settings "Auto pwr" shows "--" and blocks the toggle while repeating; the
  user's pref is preserved and restored when the repeater is switched off.

Also refreshes the FEATURES repeater entry (Hold-Enter reset, Errors row, schema
0x10, the radio-setting locks and status indicator).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-06-19 14:45:34 +02:00
parent 84a76adcf1
commit 885662beae
7 changed files with 36 additions and 16 deletions

View File

@@ -485,12 +485,21 @@ Tracker L1 OLED/e-ink, GAT562 30S). All on `feature/companion-repeater-presets`.
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,
and **Forwarded** (`Mesh::n_forwarded` — actual retransmits; backed out on
overhear cancel so it reflects what hits the air). Enter opens a confirm popup
to reset the cumulative counters (`resetStats` made virtual; `Mesh` override
also clears `n_forwarded`).
- Prefs persisted behind schema sentinels `0xC0DE000E` (four knobs) then
`0xC0DE000F` (suppress-dup), with stray-byte clamps for upgraders.
**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.

View File

@@ -72,7 +72,7 @@ Lists all available home screen pages. For each entry:
| BW | 7.8500 kHz | LEFT/RIGHT cycles the standard LoRa bandwidths. |
| CR | 58 | LEFT/RIGHT. Coding rate (4/54/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). |
| 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**.

View File

@@ -340,7 +340,7 @@ The five flood filters are **opt-in** (default OFF, so a plain repeater is unaff
**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, and **Settings Radio Pwr save** is forced off (a repeater must listen continuously) — your power-save setting returns when you switch the repeater off.
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**.

View File

@@ -496,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) {
@@ -596,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
@@ -1241,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) {
@@ -1250,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
@@ -1861,9 +1861,11 @@ void MyMesh::handleCmdFrame(size_t len) {
savePrefs();
radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
// Keep the "repeating ⇒ continuous RX" invariant when repeat is toggled via
// the app, mirroring the on-device path (a repeater must hear all traffic).
// 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);
@@ -2774,7 +2776,7 @@ void MyMesh::loop() {
// treated as a lost confirmation → ramp power up (lets channel sends recover).
if (_apc_flood_pending && millisHasNowPassed(_apc_flood_deadline)) {
_apc_flood_pending = false;
if (_prefs.tx_apc) apcOnFailure();
if (apcActive()) apcOnFailure();
}
// UI relay windows expired with no echo — just drop them (no echo is not a
// failure for channels; the marker simply stays "sent").

View File

@@ -156,6 +156,11 @@ protected:
// 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; }
// 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; }
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;

View File

@@ -260,6 +260,7 @@ public:
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;

View File

@@ -613,7 +613,9 @@ class SettingsScreen : public UIScreen {
} 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");
@@ -1007,6 +1009,7 @@ public:
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;