Files
MeshCore/src/RadioProfiles.h
T
mikecarper 95aca52b4e Add per-profile TX routing and bridge filtering
Persist Companion TX preferences per contact and channel. Default repeater,
room, and sensor replies to both active TX profiles, with a reply-only force
option for an RX-only secondary profile. Track both reply copies before a
temporary-radio handoff and account for OTA copies under queue backpressure.

Add bridge/crossover filter modes and the third built-in wardriving filter,
plus compact filter CLI syntax, documentation, and CI coverage.

Fix quoted target names being interpreted as slot/key selectors, partial
recovery of invalid saved reply settings, filter suspension during tempradio2,
and unnecessary packet allocation while OTA traffic is throttled.

Validation: 1,477 native tests, 52 Python integration tests, 29 filter UI tests,
and five firmware builds covering ESP32, nRF52, and STM32 passed.
2026-09-15 01:58:43 -07:00

231 lines
11 KiB
C++

#pragma once
#include <stdint.h>
#include <stddef.h>
#include <math.h>
#include "RadioTxPolicy.h"
namespace mesh {
enum class RadioProfileMode : uint8_t { Off = 0, Rx = 1, RxTx = 2 };
enum class RadioCrossMode : uint8_t { Auto = 0, On = 1, Off = 2 };
// These are modulation profiles of ONE transceiver, never additional radios.
struct RadioProfileParams {
float freq = 0;
float bw = 0;
uint16_t preamble = 0; // 0 = automatic, otherwise symbols
uint8_t sf = 0;
uint8_t cr = 0;
bool operator==(const RadioProfileParams& other) const {
return freq == other.freq && bw == other.bw && sf == other.sf
&& cr == other.cr && preamble == other.preamble;
}
bool operator!=(const RadioProfileParams& other) const { return !(*this == other); }
};
struct RadioProfileConfig {
RadioProfileParams params;
RadioProfileMode mode = RadioProfileMode::Off;
};
// Portable timing/routing model, also used by the native tests. Hardware access
// remains in RadioLibWrapper; timers and persisted settings remain in the CLI.
class RadioProfiles {
public:
// Normal RX uses a microsecond window, so fractional symbols are supported.
static constexpr double MinListenSymbols = 4.6;
static constexpr double SlowListenSymbols = MinListenSymbols;
static constexpr double MinFastListenSymbols = MinListenSymbols;
static constexpr uint8_t AcquisitionSymbols = 8;
static constexpr uint16_t MaxPreamble = 65528;
// Recent V4 single-pass hops measured up to 836 us, including post-packet
// cache refresh; Indicator measured up to 8428 us (expander GPIO).
// Use the requested 0.6 ms nominal allowance for fast switching. This is
// not a worst-case bound; measured overruns raise diagnostic recommendations.
// Packet receptions can extend a visit; these budgets describe an idle scan.
static constexpr uint32_t SwitchBudgetUs = 600;
static constexpr uint32_t LoopBudgetUs = 300;
struct ChirpTiming {
bool valid = false;
uint8_t slow = 0;
double symbol_us[2] = {};
uint32_t listen_us[2] = {};
double preamble[2] = {}; // Rounded recommendations, not silently capped.
double cycle_us = 0;
uint32_t switch_us = 0, loop_us = 0;
};
RadioProfileParams primary;
RadioProfileConfig secondary;
bool primary_temporary = false;
bool secondary_temporary = false;
RadioCrossMode cross = RadioCrossMode::Auto;
// Infrastructure opts into BOTH at startup; Companions keep their own
// contact/channel policy. Force applies only to locally generated replies.
uint8_t reply_tx = RADIO_TX_AUTO;
bool reply_force = false;
uint32_t generation[2] = {1, 1};
uint32_t switches = 0;
uint32_t rx_packets[2] = {};
uint32_t tx_packets[2] = {};
uint32_t switch_failures = 0;
uint32_t longest_switch_us = 0;
bool enabled() const { return secondary.mode != RadioProfileMode::Off; }
bool canTransmit(uint8_t profile, bool reply_rx_override = false) const {
return profile == 0 || (profile == 1 && (secondary.mode == RadioProfileMode::RxTx
|| (reply_rx_override && secondary.mode == RadioProfileMode::Rx)));
}
bool canCross() const {
return cross == RadioCrossMode::On || (cross == RadioCrossMode::Auto
&& primary_temporary == secondary_temporary);
}
uint8_t transmitMask(uint8_t origin) const {
if (origin > 1) origin = 0;
uint8_t result = canTransmit(origin) ? (1U << origin) : 0;
if (enabled() && canCross() && canTransmit(origin ^ 1)) result |= 1U << (origin ^ 1);
return result;
}
const RadioProfileParams& params(uint8_t profile) const {
return profile == 1 ? secondary.params : primary;
}
static bool valid(const RadioProfileParams& p) {
if (!isfinite(p.freq) || !isfinite(p.bw) || p.freq < 150 || p.freq > 2500
|| p.sf < 5 || p.sf > 12 || p.cr < 5 || p.cr > 8
|| (p.preamble != 0 && (p.preamble < 8 || p.preamble > MaxPreamble))) return false;
const float bandwidths[] = {7.8f, 10.4f, 15.6f, 20.8f, 31.25f, 41.7f,
62.5f, 125, 250, 500, 1000, 812.5f, 1625};
for (float bw : bandwidths) if (fabsf(p.bw - bw) < 0.01f) return true;
return false;
}
static double symbolUs(const RadioProfileParams& p) {
return p.bw > 0 && p.sf <= 12 ? (double)(1U << p.sf) * 1000.0 / p.bw : 0;
}
static bool safePreamble(const RadioProfileParams& p, uint16_t symbols) {
const double symbol = symbolUs(p);
if (symbol <= 0) return false;
const int de = symbol >= 16000 ? 1 : 0;
const double payload = 8 + ceil((8.0 * 255 - 4 * p.sf
+ (p.sf <= 6 ? 20 : 28) + 16) / (4 * (p.sf - 2 * de))) * 8;
// RadioLib's microsecond airtime calculation multiplies by four before
// dividing. Reject settings that overflow that intermediate for a full
// packet, including a retry using coding rate 4/8.
return (symbols + (p.sf <= 6 ? 6.25 : 4.25) + payload) * symbol * 4 <= UINT32_MAX;
}
uint8_t slowerProfile() const {
return symbolUs(primary) >= symbolUs(secondary.params) ? 0 : 1;
}
static double roundPreamble(double symbols) {
return ceil((symbols < 32 ? 32 : symbols) / 8.0) * 8.0;
}
static uint32_t minimumListenUs(const RadioProfileParams& p, bool slow = false) {
const double symbol = symbolUs(p);
const double symbols = slow ? SlowListenSymbols : MinFastListenSymbols;
return isfinite(symbol) && symbol > 0 && symbol <= UINT32_MAX / symbols
? (uint32_t)ceil(symbols * symbol) : 0;
}
static double preambleForVisits(const RadioProfileParams& p, const RadioProfileParams& other,
bool slow, double own_us, double other_us, double overhead_us) {
const double symbol = symbolUs(p);
if (symbol <= 0) return 32;
double result = slow ? roundPreamble(2.0 * (own_us + other_us + overhead_us) / symbol)
: roundPreamble((other_us + overhead_us) / symbol + 2 * AcquisitionSymbols);
// Production V4/XIAO empirical floor; retain prior measured margin.
if (!slow && p.sf == 8 && p.bw == 500 && other.sf == 7 && other.bw == 62.5f && result < 88)
result = 88;
return result;
}
// A two-profile idle-scan timing policy, NOT a fitted zero-loss model.
// Use a 4.6-symbol minimum on both profiles. Reserve
// two return opportunities on the slow profile, and blind time + 16
// acquisition symbols on the fast one. The 4.6/300-us pair bench received
// 197/200: a sweep fitting inside 32 is not a guarantee of acquisition.
// Explicit visit times let diagnostics account for the actual scheduler,
// including a longer fast visit selected by an explicit slow preamble.
static ChirpTiming calculateChirpTiming(const RadioProfileParams& a, const RadioProfileParams& b,
uint32_t visit_a_us = 0, uint32_t visit_b_us = 0,
uint32_t switch_us = SwitchBudgetUs, uint32_t loop_us = LoopBudgetUs) {
ChirpTiming t;
if (!valid(a) || !valid(b)) return t;
t.symbol_us[0] = symbolUs(a); t.symbol_us[1] = symbolUs(b);
t.slow = t.symbol_us[0] >= t.symbol_us[1] ? 0 : 1;
const uint32_t minimum_a = minimumListenUs(a, t.slow == 0);
const uint32_t minimum_b = minimumListenUs(b, t.slow == 1);
t.listen_us[0] = visit_a_us > minimum_a ? visit_a_us : minimum_a;
t.listen_us[1] = visit_b_us > minimum_b ? visit_b_us : minimum_b;
t.switch_us = switch_us; t.loop_us = loop_us;
const double overhead = 2.0 * switch_us + loop_us;
t.cycle_us = double(t.listen_us[0]) + t.listen_us[1] + overhead;
t.preamble[0] = preambleForVisits(a, b, t.slow == 0, t.listen_us[0], t.listen_us[1], overhead);
t.preamble[1] = preambleForVisits(b, a, t.slow == 1, t.listen_us[1], t.listen_us[0], overhead);
t.valid = true;
return t;
}
double automaticPreamble(uint8_t profile) const {
if (profile > 1) return 32;
// Keep the hot retune path to one profile's calculation; the full
// validated diagnostic structure is only needed by CLI/reporting.
const uint8_t slow = slowerProfile();
return preambleForVisits(params(profile), params(profile ^ 1), profile == slow,
minimumListenUs(params(profile), profile == slow), minimumListenUs(params(profile ^ 1), (profile ^ 1) == slow),
2.0 * SwitchBudgetUs + LoopBudgetUs);
}
uint32_t listenUs(uint8_t profile, uint16_t slow_preamble = 0) const {
const uint8_t slow = slowerProfile();
const double minimum = minimumListenUs(params(profile), enabled() && profile == slow);
if (!enabled() || profile == slow) return (uint32_t)ceil(minimum);
if (!slow_preamble) slow_preamble = preamble(slow, 32);
const double available = double(slow_preamble) / 2.0 * symbolUs(params(slow))
- minimumListenUs(params(slow), true) - 2.0 * SwitchBudgetUs - LoopBudgetUs;
// Rejected previews must not cause an out-of-range floating-to-int cast.
return available > UINT32_MAX ? UINT32_MAX
: available > minimum ? (uint32_t)floor(available) : (uint32_t)minimum;
}
ChirpTiming chirpTiming() const {
if (!enabled()) return {};
// Measured overruns raise advisory requirements only. Do not silently
// change wire preambles or persisted settings based on one slow hop.
const uint32_t budget = longest_switch_us > SwitchBudgetUs ? longest_switch_us : SwitchBudgetUs;
return calculateChirpTiming(primary, secondary.params, listenUs(0), listenUs(1), budget);
}
uint16_t preamble(uint8_t profile, uint16_t single_profile_default) const {
const auto& p = params(profile);
if (p.preamble) return p.preamble;
if (!enabled()) return single_profile_default;
double symbols = automaticPreamble(profile);
if (symbols < single_profile_default) symbols = single_profile_default;
if (symbols > MaxPreamble) symbols = MaxPreamble;
return (uint16_t)symbols;
}
bool automaticPreambleFits() const {
for (uint8_t i = 0; i < (enabled() ? 2 : 1); ++i) {
if (!valid(params(i)) || !safePreamble(params(i), preamble(i, 32))) return false;
if (!enabled()) continue;
if (!params(i).preamble && automaticPreamble(i) > MaxPreamble) return false;
}
if (enabled()) {
const uint8_t slow = slowerProfile();
const double available = double(preamble(slow, 32)) / 2.0 * symbolUs(params(slow))
- minimumListenUs(params(slow), true) - 2.0 * SwitchBudgetUs - LoopBudgetUs;
if (available < minimumListenUs(params(slow ^ 1))) return false;
}
return true;
}
void setPrimary(const RadioProfileParams& p, bool temporary) {
if (primary != p || primary_temporary != temporary) ++generation[0];
primary = p;
primary_temporary = temporary;
}
void setSecondary(const RadioProfileConfig& p, bool temporary) {
if (secondary.params != p.params || secondary.mode != p.mode
|| secondary_temporary != temporary) ++generation[1];
secondary = p;
secondary_temporary = temporary;
}
};
} // namespace mesh