From ca67494f8c73cd51f9cf4e5611556b1add68923e Mon Sep 17 00:00:00 2001 From: mikecarper Date: Mon, 19 Oct 2026 18:49:15 -0700 Subject: [PATCH] Correct chip-specific radio profile validation and airtime Retain supported LR2021 bandwidths and reject frequency gaps. Enable LR1121 high-band bandwidth selection and account for its forced LDRO in profile and beacon airtime. Cover all eight chip families with production-method regression tests. --- .github/workflows/run-unit-tests.yml | 1 + src/RadioProfiles.h | 6 +- src/helpers/radiolib/CustomLR1121Wrapper.h | 2 +- src/helpers/radiolib/RadioLibWrappers.cpp | 50 ++++++++-- .../test_radio_profile_validation_backport.py | 92 +++++++++++++++++++ 5 files changed, 140 insertions(+), 11 deletions(-) create mode 100644 test/test_radio_profile_validation_backport.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index c380448aa..7969b124d 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -618,6 +618,7 @@ jobs: test_sx1262_modem_cache test_esp32_retune_priority test_tdeck_spi_clock_timing test_radio_chirp_math test_lr1110_profile_switch test_lr1110_packet_setter_cache + test_radio_profile_validation_backport - name: Verify radio lab collectors and compiled experiment contracts run: | diff --git a/src/RadioProfiles.h b/src/RadioProfiles.h index 986a7836c..68f045aa9 100644 --- a/src/RadioProfiles.h +++ b/src/RadioProfiles.h @@ -147,13 +147,13 @@ class RadioProfiles { 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 + if (!isfinite(p.freq) || !isfinite(p.bw) || p.freq < 137 || p.freq > 2500 || p.sf < 5 || p.sf > 12 || p.cr < 5 || p.cr > 8 || (p.protocol != RadioProfileProtocol::MeshCore && p.protocol != RadioProfileProtocol::Meshtext && p.protocol != RadioProfileProtocol::Ethernet) || (p.preamble != 0 && (p.preamble < 8 || p.preamble > MaxPreamble))) return false; - static constexpr float bandwidths[] = {7.8f, 10.4f, 15.6f, 20.8f, 31.25f, 41.7f, - 62.5f, 125, 250, 500, 1000, 812.5f, 1625}; + static constexpr float bandwidths[] = {7.8f, 10.4f, 15.6f, 20.8f, 31.25f, 41.67f, 41.7f, + 62.5f, 83.34f, 101.56f, 125, 203.125f, 250, 406.25f, 500, 812.5f, 1000, 1625}; for (float bw : bandwidths) if (fabsf(p.bw - bw) < 0.01f) return true; return false; } diff --git a/src/helpers/radiolib/CustomLR1121Wrapper.h b/src/helpers/radiolib/CustomLR1121Wrapper.h index bc79b25ff..bf519ae17 100644 --- a/src/helpers/radiolib/CustomLR1121Wrapper.h +++ b/src/helpers/radiolib/CustomLR1121Wrapper.h @@ -90,7 +90,7 @@ protected: bool applyParams(float freq, float bw, uint8_t sf, uint8_t cr) override { if (((CustomLR1121 *)_radio)->setFrequency(freq) != RADIOLIB_ERR_NONE || ((CustomLR1121 *)_radio)->setSpreadingFactor(sf) != RADIOLIB_ERR_NONE - || ((CustomLR1121 *)_radio)->setBandwidth(bw) != RADIOLIB_ERR_NONE + || ((CustomLR1121 *)_radio)->setBandwidth(bw, bw == 203.125f || bw == 406.25f || bw == 812.5f) != RADIOLIB_ERR_NONE || ((CustomLR1121 *)_radio)->setCodingRate(cr) != RADIOLIB_ERR_NONE || !updatePreamble(sf, bw)) return false; PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, preambleLengthForParams(sf, bw)); diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 678b1177c..175e88fcb 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -465,22 +465,40 @@ bool RadioLibWrapper::validateProfile(const mesh::RadioProfileParams& p) const { #else if (p.protocol == mesh::RadioProfileProtocol::Ethernet) return false; #endif +#if MESH_RADIO_PROFILE_CHIP != 1110 && MESH_RADIO_PROFILE_CHIP != 1121 && MESH_RADIO_PROFILE_CHIP != 2021 && MESH_RADIO_PROFILE_CHIP != 68 && MESH_RADIO_PROFILE_CHIP != 1272 + static constexpr float bandwidths[] = {7.8f, 10.4f, 15.6f, 20.8f, 31.25f, 41.7f, 62.5f, 125, 250, 500}; + bool supported = false; + for (float bw : bandwidths) if (fabsf(p.bw - bw) < 0.01f) supported = true; + if (!supported) return false; +#endif #if MESH_RADIO_PROFILE_CHIP == 1276 return p.freq >= 137 && p.freq <= 1020 && p.sf >= 7 && p.bw <= 500; #elif MESH_RADIO_PROFILE_CHIP == 1272 - return p.freq >= 860 && p.freq <= 1020 && p.sf >= 7 && p.bw >= 125 && p.bw <= 500; + return p.freq >= 860 && p.freq <= 1020 && p.sf >= 7 + && (p.bw == 125 || p.bw == 250 || p.bw == 500); #elif MESH_RADIO_PROFILE_CHIP == 68 return p.freq >= 150 && p.freq <= 960 && ((p.bw == 125 && p.sf <= 9) || (p.bw == 250 && p.sf <= 10) || (p.bw == 500 && p.sf <= 11)); #elif MESH_RADIO_PROFILE_CHIP == 2021 - return (p.freq <= 1090 || p.freq >= 1900) && p.bw >= 31.25 && p.bw <= 1000; + static constexpr float bandwidths[] = {31.25f, 41.67f, 41.7f, 62.5f, 83.34f, 101.56f, + 125, 203.125f, 250, 406.25f, 500, 812.5f, 1000}; + bool supported = false; + for (float bw : bandwidths) if (fabsf(p.bw - bw) < 0.01f) supported = true; + return ((p.freq >= 150 && p.freq <= 1090) || (p.freq >= 1900 && p.freq <= 2200) + || (p.freq >= 2400 && p.freq <= 2500)) && supported; #elif MESH_RADIO_PROFILE_CHIP == 1110 - return p.freq <= 960 && p.bw >= 62.5 && p.bw <= 500; + return p.freq >= 150 && p.freq <= 960 + && (p.bw == 62.5 || p.bw == 125 || p.bw == 250 || p.bw == 500); +#elif MESH_RADIO_PROFILE_CHIP == 1121 + return ((p.freq >= 150 && p.freq <= 960) || (p.freq >= 1900 && p.freq <= 2200) + || (p.freq >= 2400 && p.freq <= 2500)) + && (p.bw == 62.5 || p.bw == 125 || p.bw == 250 || p.bw == 500 + || (p.freq >= 1900 && (p.bw == 203.125f || p.bw == 406.25f || p.bw == 812.5f))); #elif MESH_RADIO_PROFILE_CHIP == 1268 return p.freq >= 410 && p.freq <= 810 && p.bw <= 500; #else - return p.freq <= 960 && p.bw <= 500; + return p.freq >= 150 && p.freq <= 960 && p.bw <= 500; #endif } @@ -613,7 +631,11 @@ uint32_t RadioLibWrapper::getBeaconAirtime(const mesh::RadioProfileParams& p, in ? 16 : (p.preamble ? p.preamble : 32); if (!mesh::RadioProfiles::safePreamble(p, preamble)) return 0; const double symbol = mesh::RadioProfiles::symbolUs(p); - const int de = symbol >= 16000 ? 1 : 0; + const int de = symbol >= 16000 +#if MESH_RADIO_PROFILE_CHIP == 1121 + || (p.freq > 1000 && p.sf > 10) +#endif + ? 1 : 0; const double coded = ceil((8.0 * length - 4 * p.sf + (p.sf <= 6 ? 20 : 28) + 16) / (4 * (p.sf - 2 * de))); const double payload = 8 + (coded > 0 ? coded * p.cr : 0); @@ -935,17 +957,31 @@ bool RadioLibWrapper::getProfileIdleDelay(uint32_t& delay_ms) const { } uint32_t RadioLibWrapper::getProfileAirtime(uint8_t profile, int len, uint8_t cr) { + if (profile > 1 || len < 0 || len > MAX_TRANS_UNIT) return 0; if (!_profiles.enabled() && profile == 0 && cr == 0) return getEstAirtimeFor(len); const auto& p = _profiles.params(profile); + if (!validateProfile(p)) return 0; const double symbol = mesh::RadioProfiles::symbolUs(p); if (symbol <= 0 || len < 0) return 0; if (cr < 5 || cr > 8) cr = p.cr; - const int de = symbol >= 16000 ? 1 : 0; + const int de = symbol >= 16000 +#if MESH_RADIO_PROFILE_CHIP == 1121 + // RadioLib 7.8.1 forces SX128x-compatible LDRO for high-band SF11/12. + || (p.freq > 1000 && p.sf > 10) +#endif + ? 1 : 0; const double coded = ceil((8.0 * len - 4 * p.sf + (p.sf <= 6 ? 20 : 28) + 16) / (4 * (p.sf - 2 * de))); const double payload_symbols = 8 + (coded > 0 ? coded * cr : 0); const double suffix = p.sf <= 6 ? 6.25 : 4.25; - return (uint32_t)ceil((profilePreamble(profile) + suffix + payload_symbols) * symbol / 1000.0); + const double airtime = ceil((profilePreamble(profile) + suffix + payload_symbols) * symbol / 1000.0); + const uint32_t rounded = airtime > UINT32_MAX ? UINT32_MAX : uint32_t(airtime); +#if MESH_RADIO_PROFILE_CHIP == 1110 || MESH_RADIO_PROFILE_CHIP == 1121 + // Preserve these wrappers' operational floor even for an inactive tuple. + return rounded < 200 ? 200 : rounded; +#else + return rounded; +#endif } bool RadioLibWrapper::setParams(float freq, float bw, uint8_t sf, uint8_t cr, diff --git a/test/test_radio_profile_validation_backport.py b/test/test_radio_profile_validation_backport.py new file mode 100644 index 000000000..e3f5d1a8f --- /dev/null +++ b/test/test_radio_profile_validation_backport.py @@ -0,0 +1,92 @@ +"""Exercise production profile validation and per-tuple airtime on each chip.""" +from pathlib import Path +import os +import subprocess +import tempfile +import unittest +from cpp_source import body + +ROOT = Path(__file__).resolve().parents[1] + + +class ProfileValidationBackportTests(unittest.TestCase): + def test_supported_tuples_protocol_guards_and_operational_airtime(self): + source = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text() + methods = "\n".join(body(source, signature).replace("RadioLibWrapper::", "").replace("uint8_t cr)", "uint8_t cr = 0)") + for signature in ("bool RadioLibWrapper::validateProfile(", + "uint32_t RadioLibWrapper::getProfileAirtime(", + "uint32_t RadioLibWrapper::getBeaconAirtime(")) + harness = r''' +#include +#include +#include +#define MAX_TRANS_UNIT 255 +#define MESH_ENABLE_ETHERNET_WIRE 1 +struct RadioLibWrapper { + mesh::RadioProfiles _profiles; + bool foreign=false; + bool supportsMeshtext()const{return foreign;} + bool supportsEthernetTunnel()const{return foreign;} + uint16_t profilePreamble(uint8_t)const{return 32;} + uint32_t getEstAirtimeFor(int){return 777;} + @METHODS@ +}; +int main(){ + RadioLibWrapper w; + mesh::RadioProfileParams p;p.freq=MESH_RADIO_PROFILE_CHIP==1268?433:915;p.bw=125;p.sf=8;p.cr=5; + assert(w.validateProfile(p)); + p.protocol=mesh::RadioProfileProtocol::Meshtext;assert(!w.validateProfile(p)); + w.foreign=true;assert(w.validateProfile(p)); + p.protocol=mesh::RadioProfileProtocol::Ethernet;assert(w.validateProfile(p)); + w.foreign=false;assert(!w.validateProfile(p));p.protocol=mesh::RadioProfileProtocol::MeshCore; + p.freq=137; + assert(w.validateProfile(p)==(MESH_RADIO_PROFILE_CHIP==1276)); + p.freq=149;assert(w.validateProfile(p)==(MESH_RADIO_PROFILE_CHIP==1276)); + p.freq=915;p.bw=406.25f; + assert(w.validateProfile(p)==(MESH_RADIO_PROFILE_CHIP==2021)); + p.freq=2400; + assert(w.validateProfile(p)==(MESH_RADIO_PROFILE_CHIP==1121||MESH_RADIO_PROFILE_CHIP==2021)); + p.freq=2300;assert(!w.validateProfile(p)); + p.freq=MESH_RADIO_PROFILE_CHIP==1268?433:915;p.bw=125;p.sf=8; + w._profiles.primary=p; + assert(w.getProfileAirtime(0,10)==777); // Preserve the existing live-tuple path. + w._profiles.secondary.params=p;w._profiles.secondary.mode=mesh::RadioProfileMode::Rx; + const auto short_air=w.getProfileAirtime(0,1); +#if MESH_RADIO_PROFILE_CHIP==1110||MESH_RADIO_PROFILE_CHIP==1121 + assert(short_air==200); +#else + assert(short_air<200); +#endif +#if MESH_RADIO_PROFILE_CHIP==1121 + p.freq=2400;p.bw=812.5;p.sf=12;w._profiles.primary=p; + // Pinned LR11xx forces high-band LDRO: 51 coded blocks * CR5, plus overhead. + const auto expected=uint32_t(ceil((32+4.25+8+51*5)*4096.0/812.5)); + assert(w.getProfileAirtime(0,255)==expected); + assert(w.getBeaconAirtime(p,255)==expected); +#endif + assert(w.getProfileAirtime(2,10)==0&&w.getProfileAirtime(0,-1)==0); + assert(w.getProfileAirtime(0,256)==0); + for(float bandwidth:{31.25f,41.67f,41.7f,62.5f,83.34f,101.56f,125.0f,203.125f,250.0f,406.25f,500.0f,812.5f,1000.0f}) { + p.freq=915;p.bw=bandwidth; +#if MESH_RADIO_PROFILE_CHIP==2021 + assert(w.validateProfile(p)); +#endif + } +} +'''.replace("#include ", "#include \n#include ").replace("@METHODS@", methods) + with tempfile.TemporaryDirectory(prefix="profile-validation-") as folder: + cpp = Path(folder) / "test.cpp" + binary = Path(folder) / "test.exe" + cpp.write_text(harness) + for chip in (1262, 1268, 1276, 1272, 68, 1110, 1121, 2021): + with self.subTest(chip=chip): + flags = [] if os.name == "nt" else ["-fsanitize=address,undefined", "-fno-pie", "-no-pie"] + built = subprocess.run([os.environ.get("CXX", "g++"), "-std=c++11", *flags, + "-DMESH_RADIO_PROFILE_CHIP="+str(chip), "-I", str(ROOT / "src"), + str(cpp), "-o", str(binary)], capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stderr) + ran = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(ran.returncode, 0, ran.stderr) + + +if __name__ == "__main__": unittest.main()