Files
firmware/test/test_radio/test_main.cpp
T
Jonathan BennettandClaude Opus 5 e5c559a1c8 Make the frame-end anchor atomic
The radio worker writes lastFrameEndMs from onNotify() while a draw for a
packet being queued reads it on whatever thread called send(), so the access
was an unsynchronized read of a plain uint32_t. std::atomic with relaxed
ordering is free on every target here and matches MeshService's fromNum.

Relaxed is the right ordering: the anchor publishes nothing but itself, both
writers are the radio worker so the compare-then-store races with no other
write, and losing the race against a reader only anchors that one draw to the
frame before last.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkrqYBzhAxyybmoezb1TJV
2026-10-10 15:24:32 +00:00

917 lines
40 KiB
C++

#include "LR20x0Band.h"
#include "MeshRadio.h"
#include "MeshService.h"
#include "RadioInterface.h"
#include "RadioLibInterface.h"
#include "TestUtil.h"
#include "UptimeClock.h"
#include "memory/MemAudit.h"
#include <string.h>
#include <unity.h>
#include "meshtastic/config.pb.h"
#include "support/MockMeshService.h"
static MockMeshService *mockMeshService;
static void test_lr20x0BandClassification()
{
TEST_ASSERT_FALSE(isLr20x0HighBand(906.875f));
TEST_ASSERT_FALSE(isLr20x0HighBand(1500.0f));
TEST_ASSERT_TRUE(isLr20x0HighBand(2400.0f));
TEST_ASSERT_TRUE(isLr20x0HighBand(2420.71875f));
}
static void test_lr20x0CustomLfPaBandCoversOnly500To1000MHz()
{
// The board LF PA table is the datasheet's 915 MHz design; 433 MHz and anything above 1 GHz keep RadioLib's.
TEST_ASSERT_FALSE(isLr20x0CustomLfPaBand(433.175f));
TEST_ASSERT_FALSE(isLr20x0CustomLfPaBand(499.9f));
TEST_ASSERT_TRUE(isLr20x0CustomLfPaBand(500.0f));
TEST_ASSERT_TRUE(isLr20x0CustomLfPaBand(869.525f));
TEST_ASSERT_TRUE(isLr20x0CustomLfPaBand(906.875f));
TEST_ASSERT_TRUE(isLr20x0CustomLfPaBand(1000.0f));
TEST_ASSERT_FALSE(isLr20x0CustomLfPaBand(1000.1f));
TEST_ASSERT_FALSE(isLr20x0CustomLfPaBand(2420.71875f));
}
static void test_lr20x0BandHopDetection()
{
TEST_ASSERT_FALSE(isLr20x0BandHop(0.0f, 2420.71875f));
TEST_ASSERT_FALSE(isLr20x0BandHop(906.875f, 915.0f));
TEST_ASSERT_FALSE(isLr20x0BandHop(2400.0f, 2420.71875f));
TEST_ASSERT_TRUE(isLr20x0BandHop(906.875f, 2420.71875f));
TEST_ASSERT_TRUE(isLr20x0BandHop(2420.71875f, 906.875f));
// Invalid requested frequency must not look like a band hop.
TEST_ASSERT_FALSE(isLr20x0BandHop(2420.71875f, 0.0f));
TEST_ASSERT_FALSE(isLr20x0BandHop(906.875f, 0.0f));
TEST_ASSERT_FALSE(isLr20x0BandHop(2420.71875f, -1.0f));
TEST_ASSERT_FALSE(isLr20x0BandHop(906.875f, -915.0f));
}
static void test_lr20x0ReconfigurePathSelection()
{
// LF -> HF and HF -> LF take full begin(); same-band stays incremental.
TEST_ASSERT_EQUAL(static_cast<int>(Lr20x0ReconfigurePath::FullBegin),
static_cast<int>(lr20x0ReconfigurePath(906.875f, 2420.71875f)));
TEST_ASSERT_EQUAL(static_cast<int>(Lr20x0ReconfigurePath::FullBegin),
static_cast<int>(lr20x0ReconfigurePath(2420.71875f, 906.875f)));
TEST_ASSERT_EQUAL(static_cast<int>(Lr20x0ReconfigurePath::Incremental),
static_cast<int>(lr20x0ReconfigurePath(906.875f, 915.0f)));
TEST_ASSERT_EQUAL(static_cast<int>(Lr20x0ReconfigurePath::Incremental),
static_cast<int>(lr20x0ReconfigurePath(2400.0f, 2420.71875f)));
TEST_ASSERT_EQUAL(static_cast<int>(Lr20x0ReconfigurePath::Incremental),
static_cast<int>(lr20x0ReconfigurePath(0.0f, 2420.71875f)));
TEST_ASSERT_EQUAL(static_cast<int>(Lr20x0ReconfigurePath::Incremental),
static_cast<int>(lr20x0ReconfigurePath(2420.71875f, 0.0f)));
TEST_ASSERT_EQUAL(static_cast<int>(Lr20x0ReconfigurePath::Incremental),
static_cast<int>(lr20x0ReconfigurePath(906.875f, -1.0f)));
}
// Test shim to expose protected radio parameters set by applyModemConfig()
class TestableRadioInterface : public RadioInterface
{
public:
TestableRadioInterface() : RadioInterface() {}
uint8_t getCr() const { return cr; }
uint8_t getSf() const { return sf; }
float getBw() const { return bw; }
size_t beginSendingPublic(meshtastic_MeshPacket *p) { return beginSending(p); }
meshtastic_MeshPacket *getSendingPacket() const { return sendingPacket; }
void clearSendingPacketForTest() { sendingPacket = nullptr; }
uint32_t getLastFrameEndMs() const { return lastFrameEndMs.load(std::memory_order_relaxed); }
// Override reconfigure to call the base which invokes applyModemConfig()
bool reconfigure() override { return RadioInterface::reconfigure(); }
// Stubs for pure virtual methods required by RadioInterface
uint32_t getPacketTime(uint32_t, bool) override { return 0; }
ErrorCode send(meshtastic_MeshPacket *p) override { return ERRNO_OK; }
};
static void test_bwCodeToKHz_specialMappings()
{
TEST_ASSERT_FLOAT_WITHIN(0.0001f, 7.8f, bwCodeToKHz(8));
TEST_ASSERT_FLOAT_WITHIN(0.0001f, 10.4f, bwCodeToKHz(10));
TEST_ASSERT_FLOAT_WITHIN(0.0001f, 15.6f, bwCodeToKHz(16));
TEST_ASSERT_FLOAT_WITHIN(0.0001f, 20.8f, bwCodeToKHz(21));
TEST_ASSERT_FLOAT_WITHIN(0.0001f, 31.25f, bwCodeToKHz(31));
TEST_ASSERT_FLOAT_WITHIN(0.0001f, 41.7f, bwCodeToKHz(42));
TEST_ASSERT_FLOAT_WITHIN(0.0001f, 62.5f, bwCodeToKHz(62));
TEST_ASSERT_FLOAT_WITHIN(0.0001f, 203.125f, bwCodeToKHz(200));
TEST_ASSERT_FLOAT_WITHIN(0.0001f, 406.25f, bwCodeToKHz(400));
TEST_ASSERT_FLOAT_WITHIN(0.0001f, 812.5f, bwCodeToKHz(800));
TEST_ASSERT_FLOAT_WITHIN(0.0001f, 1625.0f, bwCodeToKHz(1600));
}
static void test_bwCodeToKHz_passthrough()
{
TEST_ASSERT_FLOAT_WITHIN(0.0001f, 125.0f, bwCodeToKHz(125));
TEST_ASSERT_FLOAT_WITHIN(0.0001f, 250.0f, bwCodeToKHz(250));
}
static void test_bwCodeToKHz_roundTrip()
{
// Round-trip: bwKHzToCode(bwCodeToKHz(code)) should return the original code
uint16_t codes[] = {8, 10, 16, 21, 31, 42, 62, 200, 400, 800, 1600};
for (size_t i = 0; i < sizeof(codes) / sizeof(codes[0]); i++) {
uint16_t code = codes[i];
float khz = bwCodeToKHz(code);
uint16_t result = bwKHzToCode(khz);
TEST_ASSERT_EQUAL_UINT16(code, result);
}
}
static void test_validateConfigLora_noopWhenUsePresetFalse()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.use_preset = false;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST;
cfg.bandwidth = 123;
cfg.spread_factor = 8;
RadioInterface::validateConfigLora(cfg);
TEST_ASSERT_EQUAL_UINT16(123, cfg.bandwidth);
TEST_ASSERT_EQUAL_UINT32(8, cfg.spread_factor);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, cfg.modem_preset);
}
static void test_validateConfigLora_validPreset_nonWideRegion()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.use_preset = true;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST;
TEST_ASSERT_TRUE(RadioInterface::validateConfigLora(cfg));
}
static void test_validateConfigLora_validPreset_wideRegion()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.use_preset = true;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST;
TEST_ASSERT_TRUE(RadioInterface::validateConfigLora(cfg));
}
static void test_validateConfigLora_rejectsInvalidPresetForRegion()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.use_preset = true;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO;
TEST_ASSERT_FALSE(RadioInterface::validateConfigLora(cfg));
}
static void test_clampConfigLora_invalidPresetClampedToDefault()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.use_preset = true;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO;
RadioInterface::clampConfigLora(cfg);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, cfg.modem_preset);
}
static void test_clampConfigLora_validPresetUnchanged()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.use_preset = true;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST;
RadioInterface::clampConfigLora(cfg);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, cfg.modem_preset);
}
// -----------------------------------------------------------------------
// applyModemConfig() coding rate tests (via reconfigure)
// -----------------------------------------------------------------------
static TestableRadioInterface *testRadio;
// After fresh flash: coding_rate=0, use_preset=true, modem_preset=LONG_FAST
// CR should come from the preset (5 for LONG_FAST), not from the zero default.
static void test_applyModemConfig_freshFlashCodingRateNotZero()
{
config.lora = meshtastic_Config_LoRaConfig_init_zero;
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
config.lora.use_preset = true;
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
// coding_rate is 0 (default after init_zero, same as fresh flash)
testRadio->reconfigure();
// LONG_FAST preset has cr=5; must never be 0
TEST_ASSERT_EQUAL_UINT8(5, testRadio->getCr());
TEST_ASSERT_EQUAL_UINT8(11, testRadio->getSf());
TEST_ASSERT_FLOAT_WITHIN(0.01f, 250.0f, testRadio->getBw());
}
// When coding_rate matches the preset exactly, should still use the preset value
static void test_applyModemConfig_codingRateMatchesPreset()
{
config.lora = meshtastic_Config_LoRaConfig_init_zero;
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
config.lora.use_preset = true;
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW;
config.lora.coding_rate = 8; // LONG_SLOW default is cr=8
testRadio->reconfigure();
TEST_ASSERT_EQUAL_UINT8(8, testRadio->getCr());
}
// Custom CR higher than preset should be used
static void test_applyModemConfig_customCodingRateHigherThanPreset()
{
config.lora = meshtastic_Config_LoRaConfig_init_zero;
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
config.lora.use_preset = true;
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
config.lora.coding_rate = 7; // LONG_FAST preset has cr=5, 7 > 5
testRadio->reconfigure();
TEST_ASSERT_EQUAL_UINT8(7, testRadio->getCr());
}
// Custom CR lower than preset: preset wins (higher is more robust)
static void test_applyModemConfig_customCodingRateLowerThanPreset()
{
config.lora = meshtastic_Config_LoRaConfig_init_zero;
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
config.lora.use_preset = true;
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW;
config.lora.coding_rate = 5; // LONG_SLOW preset has cr=8, 5 < 8
testRadio->reconfigure();
TEST_ASSERT_EQUAL_UINT8(8, testRadio->getCr());
}
// MEDIUM_TURBO performs like MEDIUM_FAST (sf=9, cr=5) but at 500 kHz. Verify the params resolve.
static void test_applyModemConfig_mediumTurbo()
{
config.lora = meshtastic_Config_LoRaConfig_init_zero;
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
config.lora.use_preset = true;
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO;
testRadio->reconfigure();
TEST_ASSERT_EQUAL_UINT8(5, testRadio->getCr());
TEST_ASSERT_EQUAL_UINT8(9, testRadio->getSf());
TEST_ASSERT_FLOAT_WITHIN(0.01f, 500.0f, testRadio->getBw());
}
// MEDIUM_TURBO is a 500 kHz preset, so it is invalid for EU_868 and must clamp to the region default.
static void test_clampConfigLora_mediumTurboInvalidForEU868()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.use_preset = true;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO;
RadioInterface::clampConfigLora(cfg);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, cfg.modem_preset);
}
// MEDIUM_TURBO is valid for US (PROFILE_STD) and must be left unchanged.
static void test_clampConfigLora_mediumTurboValidForUS()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.use_preset = true;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO;
RadioInterface::clampConfigLora(cfg);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO, cfg.modem_preset);
}
// -----------------------------------------------------------------------
// getRegionPresetMap() - region->valid-preset map sent to clients during want_config
// -----------------------------------------------------------------------
static size_t countKnownRegions()
{
size_t n = 0;
for (const RegionInfo *r = regions; r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET; r++)
n++;
return n;
}
// Every region in the firmware table (except the UNSET sentinel) must appear
// exactly once in the map, and all counts must stay within the mesh.options bounds
// (exceeding them would mean nanopb silently truncates the wire message).
static void test_regionPresetMap_coversAllRegionsWithinBounds()
{
meshtastic_LoRaRegionPresetMap map;
getRegionPresetMap(map);
#ifdef USERPREFS_LORACONFIG_MODEM_PRESET
const size_t known = countKnownRegions() + 1; // + the UNSET intent entry
#else
const size_t known = countKnownRegions();
#endif
TEST_ASSERT_EQUAL_UINT((unsigned)known, (unsigned)map.region_groups_count);
// Bounds derived from the generated nanopb arrays (mesh.options max_count), so
// this stays correct if those bounds change.
const size_t maxGroups = sizeof(map.groups) / sizeof(map.groups[0]);
const size_t maxRegions = sizeof(map.region_groups) / sizeof(map.region_groups[0]);
TEST_ASSERT_GREATER_THAN_UINT(0, map.groups_count);
TEST_ASSERT_LESS_OR_EQUAL_UINT((unsigned)maxGroups, map.groups_count);
TEST_ASSERT_LESS_OR_EQUAL_UINT((unsigned)maxRegions, map.region_groups_count);
// Each known region appears exactly once.
for (const RegionInfo *r = regions; r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET; r++) {
int hits = 0;
for (pb_size_t i = 0; i < map.region_groups_count; i++)
if (map.region_groups[i].region == r->code)
hits++;
TEST_ASSERT_EQUAL_INT(1, hits);
}
}
// The advertised presets must agree with the live region table: every preset is
// legal in its region, the default is among them, and the licensed flag matches.
static void test_regionPresetMap_matchesRegionTable()
{
meshtastic_LoRaRegionPresetMap map;
getRegionPresetMap(map);
for (pb_size_t i = 0; i < map.region_groups_count; i++) {
meshtastic_Config_LoRaConfig_RegionCode code = map.region_groups[i].region;
uint8_t gi = map.region_groups[i].group_index;
TEST_ASSERT_LESS_THAN_UINT(map.groups_count, gi);
const meshtastic_LoRaPresetGroup &grp = map.groups[gi];
const RegionInfo *r = getRegion(code);
#ifdef USERPREFS_LORACONFIG_MODEM_PRESET
// UNSET states the pinned preset, not PROFILE_UNDEF's list, so the table checks below don't apply.
if (code == meshtastic_Config_LoRaConfig_RegionCode_UNSET)
continue;
#endif
// Group's list is non-empty and within the generated array bound.
const size_t maxPresets = sizeof(grp.presets) / sizeof(grp.presets[0]);
TEST_ASSERT_GREATER_THAN_UINT(0, grp.presets_count);
TEST_ASSERT_LESS_OR_EQUAL_UINT((unsigned)maxPresets, grp.presets_count);
// Every advertised preset must be selectable from this region: either legal here,
// or legal in a sibling the firmware will auto-swap us to (the EU 86x trio, which
// advertises the union of the trio's presets rather than just its own).
for (pb_size_t p = 0; p < grp.presets_count; p++) {
bool selectable =
r->supportsPreset(grp.presets[p]) || RadioInterface::regionSwapForPreset(code, grp.presets[p]) != nullptr;
TEST_ASSERT_TRUE(selectable);
}
// The region's own enforced presets must all be advertised (advertised is a
// superset of the enforced list, never a subset).
const meshtastic_Config_LoRaConfig_ModemPreset *enforced = r->getAvailablePresets();
for (size_t e = 0; e < r->getNumPresets(); e++) {
bool advertised = false;
for (pb_size_t p = 0; p < grp.presets_count; p++)
if (grp.presets[p] == enforced[e])
advertised = true;
TEST_ASSERT_TRUE(advertised);
}
// Default preset matches the table, is legal, and is present in the list.
TEST_ASSERT_EQUAL(r->getDefaultPreset(), grp.default_preset);
TEST_ASSERT_TRUE(r->supportsPreset(grp.default_preset));
bool defaultInList = false;
for (pb_size_t p = 0; p < grp.presets_count; p++)
if (grp.presets[p] == grp.default_preset)
defaultInList = true;
TEST_ASSERT_TRUE(defaultInList);
// Licensed flag matches the region's profile.
TEST_ASSERT_EQUAL(r->profile->licensedOnly, grp.licensed_only);
}
}
// UNSET appears only when the build pins a preset, and then states exactly that preset.
// A stock build leaves it out entirely, which clients read as "unconstrained".
static void test_regionPresetMap_unsetCarriesUserprefsIntent()
{
meshtastic_LoRaRegionPresetMap map;
getRegionPresetMap(map);
const meshtastic_LoRaPresetGroup *grp = nullptr;
for (pb_size_t i = 0; i < map.region_groups_count; i++)
if (map.region_groups[i].region == meshtastic_Config_LoRaConfig_RegionCode_UNSET)
grp = &map.groups[map.region_groups[i].group_index];
#ifdef USERPREFS_LORACONFIG_MODEM_PRESET
const meshtastic_Config_LoRaConfig_ModemPreset pinned = USERPREFS_LORACONFIG_MODEM_PRESET;
TEST_ASSERT_NOT_NULL_MESSAGE(grp, "a build that pins a preset must state it for UNSET");
TEST_ASSERT_EQUAL_UINT_MESSAGE(1, (unsigned)grp->presets_count, "the pinned preset is the sole entry");
TEST_ASSERT_EQUAL(pinned, grp->presets[0]);
TEST_ASSERT_EQUAL(pinned, grp->default_preset);
TEST_ASSERT_FALSE_MESSAGE(grp->licensed_only, "UNSET is not a licensed-only region");
// Stating intent must not narrow what the device accepts: the firmware still takes any
// real preset while the region is unset (#11496), so the map cannot become enforcement.
const RegionInfo *unset = getRegion(meshtastic_Config_LoRaConfig_RegionCode_UNSET);
TEST_ASSERT_TRUE(unset->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST));
TEST_ASSERT_TRUE(unset->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO));
#else
TEST_ASSERT_NULL_MESSAGE(grp, "a stock build must leave UNSET out of the map entirely");
#endif
}
// In-flight packet bytes as packetPool reports them, 0 before the first alloc registers the tag.
static int32_t packetPoolLiveBytes()
{
memaudit::Tag rows[memaudit::kMaxTags];
size_t n = memaudit::snapshot(rows, memaudit::kMaxTags);
for (size_t i = 0; i < n; i++)
if (rows[i].tag && strcmp(rows[i].tag, "pktpool(live)") == 0)
return rows[i].bytes;
return 0;
}
// Oversize is refused at the radio queue in Router::send(). If one ever gets this far the memcpy is
// clamped instead of failing, and the packet stays the caller's to release.
static void test_beginSending_oversizedPayloadIsClamped()
{
const int32_t liveBefore = packetPoolLiveBytes();
meshtastic_MeshPacket *p = packetPool.allocZeroed();
TEST_ASSERT_NOT_NULL(p);
// Without this the check below would also pass against a dead probe.
TEST_ASSERT_GREATER_THAN_INT32(liveBefore, packetPoolLiveBytes());
p->from = 0x12345678;
p->to = 0x87654321;
p->id = 0x10203040;
p->which_payload_variant = meshtastic_MeshPacket_encrypted_tag;
p->encrypted.size = MAX_RADIO_PAYLOAD_LEN + 10;
TEST_ASSERT_EQUAL_UINT_MESSAGE(MAX_LORA_PAYLOAD_LEN, testRadio->beginSendingPublic(p),
"an oversized payload must be clamped to the PHY limit, not rejected");
TEST_ASSERT_EQUAL_PTR_MESSAGE(p, testRadio->getSendingPacket(), "beginSending must still take the packet");
// beginSending has no failure path that releases, so the packet is ours to free.
testRadio->clearSendingPacketForTest();
packetPool.release(p);
TEST_ASSERT_EQUAL_INT32(liveBefore, packetPoolLiveBytes());
}
// The clamp must not shorten ordinary traffic, and a maximum-size frame must still fit the PHY.
static void test_beginSending_fittingPayloadIsSentWhole()
{
meshtastic_MeshPacket *p = packetPool.allocZeroed();
TEST_ASSERT_NOT_NULL(p);
p->from = 0x12345678;
p->to = 0x87654321;
p->id = 0x10203041;
p->which_payload_variant = meshtastic_MeshPacket_encrypted_tag;
p->encrypted.size = MAX_RADIO_PAYLOAD_LEN;
TEST_ASSERT_EQUAL_UINT_MESSAGE(MAX_LORA_PAYLOAD_LEN, testRadio->beginSendingPublic(p),
"the largest allowed payload must produce a frame at the PHY limit");
testRadio->clearSendingPacketForTest();
packetPool.release(p);
}
// -----------------------------------------------------------------------
// computePacketTime(): RadioLib error codes must never be read as durations (#11935)
// -----------------------------------------------------------------------
// computePacketTime() is a template over the driver, so only the calls it makes have to exist.
class FakeLoraRadio
{
public:
RadioLibTime_t reportedTimeOnAir = 0; // what getTimeOnAir() answers
RadioLibTime_t calculatedTimeOnAir = 0; // what calculateTimeOnAir() answers
int16_t headerInfoResult = RADIOLIB_ERR_UNSUPPORTED;
uint8_t headerCodingRate = 0;
bool headerCrcEnabled = true;
uint32_t calculateCalls = 0;
uint8_t lastCodingRate = 0;
bool lastCrcEnabled = false;
RadioLibTime_t getTimeOnAir(size_t) { return reportedTimeOnAir; }
RadioLibTime_t calculateTimeOnAir(ModemType_t, DataRate_t dr, PacketConfig_t pc, size_t)
{
calculateCalls++;
lastCodingRate = dr.lora.codingRate;
lastCrcEnabled = pc.lora.crcEnabled;
return calculatedTimeOnAir;
}
int16_t getLoRaRxHeaderInfo(uint8_t *cr, bool *crc)
{
if (headerInfoResult == RADIOLIB_ERR_NONE) {
*cr = headerCodingRate;
*crc = headerCrcEnabled;
}
return headerInfoResult;
}
};
// A PhysicalLayer that answers only its IRQ flags, each generic RadioLib IRQ mapped to its own bit.
class FakeIrqRadio : public PhysicalLayer
{
public:
uint32_t irqFlags = 0;
FakeIrqRadio()
{
for (uint8_t i = 0; i < sizeof(irqMap) / sizeof(irqMap[0]); i++)
irqMap[i] = 1UL << i;
}
uint32_t getIrqFlags() override { return irqFlags; }
Module *getMod() override { return nullptr; }
};
// Test shim: no chip, just the modem parameters computePacketTime() reads.
class TestableRadioLibInterface : public RadioLibInterface
{
public:
explicit TestableRadioLibInterface(PhysicalLayer *phy = nullptr)
: RadioLibInterface(nullptr, RADIOLIB_NC, RADIOLIB_NC, RADIOLIB_NC, RADIOLIB_NC, phy)
{
}
// What getPacketTime() answers, so checkStaleRxFlags() has a max-packet window to time against
uint32_t packetTimeMs = 0;
void setModem(uint8_t spreadFactor, float bandwidth, uint8_t codingRate)
{
sf = spreadFactor;
bw = bandwidth;
cr = codingRate;
}
uint32_t computePacketTimePublic(FakeLoraRadio &radio, uint32_t pl, bool received)
{
return computePacketTime(radio, pl, received);
}
static bool isRadioLibTimeErrorPublic(RadioLibTime_t usec) { return isRadioLibTimeError(usec); }
// What a chip with a receive FIFO is asked to drop when a frame is given up on unread
uint32_t discardedRxFrames = 0;
void discardUnreadRxFrame() override { discardedRxFrames++; }
void clearReadIrqsPublic() { clearReadIrqs(); }
// Chip-specific hooks this test never reaches
uint32_t getPacketTime(uint32_t, bool) override { return packetTimeMs; }
int16_t getCurrentRSSI() override { return 0; }
bool isChannelActive() override { return false; }
bool isActivelyReceiving() override { return false; }
void addReceiveMetadata(meshtastic_MeshPacket *) override {}
void setRadioIsr(void (*)()) override {}
void clearRadioIsr() override {}
};
static TestableRadioLibInterface *makeTestableRadioLibInterface()
{
auto *radioIf = new TestableRadioLibInterface();
radioIf->setModem(11, 250.0f, 5); // LONG_FAST
return radioIf;
}
// A healthy chip answers getTimeOnAir(), and that answer is what we report.
static void test_computePacketTime_txUsesTheRadiosOwnAnswer()
{
auto *radioIf = makeTestableRadioLibInterface();
FakeLoraRadio radio;
radio.reportedTimeOnAir = 123456; // usec
radio.calculatedTimeOnAir = 999000;
TEST_ASSERT_EQUAL_UINT32(123, radioIf->computePacketTimePublic(radio, 32, false));
TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, radio.calculateCalls, "a working radio must not need the fallback");
delete radioIf;
}
// WRONG_MODEM read as a duration is 4294967ms of airtime for one packet, which stops the node
// transmitting until it is rebooted.
static void test_computePacketTime_txFallsBackWhenTheRadioReportsAnError()
{
auto *radioIf = makeTestableRadioLibInterface();
FakeLoraRadio radio;
radio.reportedTimeOnAir = (RadioLibTime_t)RADIOLIB_ERR_WRONG_MODEM;
radio.calculatedTimeOnAir = 500000; // usec, from the modem config we asked for
uint32_t msec = radioIf->computePacketTimePublic(radio, 32, false);
TEST_ASSERT_EQUAL_UINT32_MESSAGE(500, msec, "an error must fall back to the configured modem, not be divided by 1000");
TEST_ASSERT_EQUAL_UINT32(1, radio.calculateCalls);
delete radioIf;
}
// If the fallback fails too, report no airtime rather than let a code reach the airtime windows.
static void test_computePacketTime_reportsNoAirtimeWhenNothingCanBeComputed()
{
auto *radioIf = makeTestableRadioLibInterface();
FakeLoraRadio radio;
radio.reportedTimeOnAir = (RadioLibTime_t)RADIOLIB_ERR_WRONG_MODEM;
radio.calculatedTimeOnAir = (RadioLibTime_t)RADIOLIB_ERR_INVALID_CODING_RATE;
TEST_ASSERT_EQUAL_UINT32(0, radioIf->computePacketTimePublic(radio, 32, false));
delete radioIf;
}
// The RX path still takes coding rate and CRC from the header, and is guarded the same way.
static void test_computePacketTime_rxUsesHeaderInfoAndIsGuarded()
{
auto *radioIf = makeTestableRadioLibInterface();
FakeLoraRadio radio;
radio.headerInfoResult = RADIOLIB_ERR_NONE;
radio.headerCodingRate = 2; // raw header value for 4/6
radio.headerCrcEnabled = false;
radio.calculatedTimeOnAir = 78000;
TEST_ASSERT_EQUAL_UINT32(78, radioIf->computePacketTimePublic(radio, 32, true));
TEST_ASSERT_EQUAL_UINT8_MESSAGE(6, radio.lastCodingRate, "raw header coding rate must become a denominator");
TEST_ASSERT_FALSE(radio.lastCrcEnabled);
radio.calculatedTimeOnAir = (RadioLibTime_t)RADIOLIB_ERR_WRONG_MODEM;
TEST_ASSERT_EQUAL_UINT32(0, radioIf->computePacketTimePublic(radio, 32, true));
delete radioIf;
}
// Every RADIOLIB_ERR_* is rejected, every duration a LoRa packet can actually take is kept.
static void test_isRadioLibTimeError_separatesCodesFromDurations()
{
TEST_ASSERT_TRUE(TestableRadioLibInterface::isRadioLibTimeErrorPublic((RadioLibTime_t)RADIOLIB_ERR_WRONG_MODEM));
TEST_ASSERT_TRUE(TestableRadioLibInterface::isRadioLibTimeErrorPublic((RadioLibTime_t)RADIOLIB_ERR_UNKNOWN));
TEST_ASSERT_TRUE(TestableRadioLibInterface::isRadioLibTimeErrorPublic((RadioLibTime_t)RADIOLIB_ERR_SPI_CMD_FAILED));
TEST_ASSERT_TRUE_MESSAGE(TestableRadioLibInterface::isRadioLibTimeErrorPublic(0), "the PhysicalLayer stub answers 0");
TEST_ASSERT_FALSE(TestableRadioLibInterface::isRadioLibTimeErrorPublic(1));
TEST_ASSERT_FALSE(TestableRadioLibInterface::isRadioLibTimeErrorPublic(123456));
// ~229s: SF12 at 7.8kHz with a full 255-byte frame, the slowest packet that can be configured.
TEST_ASSERT_FALSE(TestableRadioLibInterface::isRadioLibTimeErrorPublic(229ul * 1000ul * 1000ul));
}
// clearReadIrqs() is how every early out gives up on a received frame unread: the readout task's bad length and full
// ring, and the radio thread's bad length, unset region and failed readData(). The LR2021 receives into a FIFO that only
// RadioLib's readData() empties, so r113 found each such drop left the frame's bytes at the front of the FIFO, and the
// next frame was read out on top of them and failed to decode - 37 of 37 drops on an X1.
//
// Pinned: dropping a frame's flags also asks the chip to drop the frame. Regression guarded: a drop path that clears
// only the IRQs, which corrupts the following frame on an LR2021 and is invisible on every other chip.
static void test_clearReadIrqs_alsoDiscardsTheUnreadFrame()
{
FakeIrqRadio phy;
TestableRadioLibInterface radioIf(&phy);
phy.irqFlags = (1UL << RADIOLIB_IRQ_RX_DONE);
radioIf.clearReadIrqsPublic();
TEST_ASSERT_EQUAL_UINT32(1, radioIf.discardedRxFrames);
}
// ---------------------------------------------------------------------------
// Anchored backoff slots (RadioInterface::anchoredSlotDelayMsec)
// ---------------------------------------------------------------------------
//
// What is pinned: a draw asking for a parity lands on a slot of that parity of the grid whose
// origin is the last frame's air end, and never before the first slot that has not started yet.
// That is the whole point of the mechanism - two nodes on opposite parities that redraw after the
// same frame cannot pick the same slot however differently they handled it, and so are always at
// least a slot apart - adjacent slots are still allowed. A change that lets a draw land mid-slot,
// or on the wrong parity, silently gives that back.
// The absolute slot index a delay lands on, counted from the anchor.
static uint32_t slotIndexOf(uint32_t sinceEndMs, uint32_t slotMsec, uint32_t delayMsec)
{
TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, (sinceEndMs + delayMsec) % slotMsec, "the draw did not land on a slot edge");
return (sinceEndMs + delayMsec) / slotMsec;
}
static void test_anchoredSlotDelay_keepsTheRequestedParity()
{
const uint32_t slotMsec = 43; // LongFast-ish, and deliberately not a round number
// Every position within a slot, either side of the boundary, on both parities of first slot.
for (uint32_t sinceEnd = 0; sinceEnd < 4 * slotMsec; sinceEnd++) {
for (uint32_t pairs = 0; pairs < 4; pairs++) {
const uint32_t evenDelay = RadioInterface::anchoredSlotDelayMsec(sinceEnd, slotMsec, pairs,
meshtastic_MeshPacket_SlotParity_SLOT_PARITY_EVEN);
const uint32_t oddDelay = RadioInterface::anchoredSlotDelayMsec(sinceEnd, slotMsec, pairs,
meshtastic_MeshPacket_SlotParity_SLOT_PARITY_ODD);
TEST_ASSERT_EQUAL_UINT32(0, slotIndexOf(sinceEnd, slotMsec, evenDelay) % 2);
TEST_ASSERT_EQUAL_UINT32(1, slotIndexOf(sinceEnd, slotMsec, oddDelay) % 2);
// Within the window it drew from: a slot index that wrapped would still divide cleanly,
// so the bound is what catches it.
TEST_ASSERT_TRUE(evenDelay <= (2 * pairs + 2) * slotMsec);
TEST_ASSERT_TRUE(oddDelay <= (2 * pairs + 2) * slotMsec);
}
}
}
static void test_anchoredSlotDelay_takesTheNextWholeSlot()
{
const uint32_t slotMsec = 40;
// Exactly on a boundary: slot 2 has not started, so an even draw of 0 pairs is due now.
TEST_ASSERT_EQUAL_UINT32(
0, RadioInterface::anchoredSlotDelayMsec(2 * slotMsec, slotMsec, 0, meshtastic_MeshPacket_SlotParity_SLOT_PARITY_EVEN));
// and the odd one waits out the rest of slot 2.
TEST_ASSERT_EQUAL_UINT32(slotMsec, RadioInterface::anchoredSlotDelayMsec(2 * slotMsec, slotMsec, 0,
meshtastic_MeshPacket_SlotParity_SLOT_PARITY_ODD));
// A millisecond into slot 2, the first slot not yet started is 3: the odd draw takes it.
TEST_ASSERT_EQUAL_UINT32(slotMsec - 1, RadioInterface::anchoredSlotDelayMsec(
2 * slotMsec + 1, slotMsec, 0, meshtastic_MeshPacket_SlotParity_SLOT_PARITY_ODD));
TEST_ASSERT_EQUAL_UINT32(
2 * slotMsec - 1,
RadioInterface::anchoredSlotDelayMsec(2 * slotMsec + 1, slotMsec, 0, meshtastic_MeshPacket_SlotParity_SLOT_PARITY_EVEN));
// Each drawn pair is two slots further out, so the parity survives the whole window.
TEST_ASSERT_EQUAL_UINT32(4 * slotMsec, RadioInterface::anchoredSlotDelayMsec(
2 * slotMsec, slotMsec, 2, meshtastic_MeshPacket_SlotParity_SLOT_PARITY_EVEN));
}
static void test_anchoredSlotDelay_survivesAnAnchorHoursOld()
{
// A node that has heard nothing for hours still has a live anchor. The slot index then runs to
// tens of millions, so multiplying it by the slot time would wrap - the delay must not.
const uint32_t slotMsec = 40;
const uint32_t sinceEnd = 6UL * 3600UL * 1000UL; // 6 hours, an exact multiple of the slot
const uint32_t delay =
RadioInterface::anchoredSlotDelayMsec(sinceEnd, slotMsec, 3, meshtastic_MeshPacket_SlotParity_SLOT_PARITY_ODD);
TEST_ASSERT_EQUAL_UINT32(7 * slotMsec, delay); // one slot to reach odd, then three pairs
TEST_ASSERT_EQUAL_UINT32(1, slotIndexOf(sinceEnd, slotMsec, delay) % 2);
}
static void test_noteFrameEnd_ignoresAFrameDeliveredLate()
{
const uint32_t now = Time::getMillis();
testRadio->noteFrameEnd(now - 10, "rx");
TEST_ASSERT_EQUAL_UINT32(Time::skipZero(now - 10), testRadio->getLastFrameEndMs());
// A frame the driver got round to after one that ended later must not drag the anchor back.
testRadio->noteFrameEnd(now - 50, "rx");
TEST_ASSERT_EQUAL_UINT32(Time::skipZero(now - 10), testRadio->getLastFrameEndMs());
testRadio->noteFrameEnd(now, "tx");
TEST_ASSERT_EQUAL_UINT32(Time::skipZero(now), testRadio->getLastFrameEndMs());
}
void setUp(void)
{
mockMeshService = new MockMeshService();
service = mockMeshService;
// RadioInterface computes slotTimeMsec during construction and expects myRegion to be valid.
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
initRegion();
testRadio = new TestableRadioInterface();
}
void tearDown(void)
{
delete testRadio;
testRadio = nullptr;
service = nullptr;
delete mockMeshService;
mockMeshService = nullptr;
}
// staleRxFlagAction() in src/mesh/RadioInterface.h: the decision behind RadioLibInterface::checkStaleRxFlags(),
// the plain-RX twin of checkCadHandoffTimeout(). Plain RX runs with no chip timeout, and the DIO mask carries
// RX_DONE only, so a PREAMBLE_DETECTED, HEADER_VALID or HEADER_ERR that never completes stays latched until
// something re-arms RX - on an idle node, never. RX_DONE clears every flag in readData(), so a flag still
// latched one max packet after it was first seen has no frame behind it.
//
// Pinned: nothing happens inside that window (a frame may still be arriving); after it, a bare preamble is
// only cleared, because a clear never aborts a reception; a header re-arms, because SX1280 DS rev 3.3 16.2
// leaves RX wedged after a header error with no RX_DONE or timeout to follow, and a clear does not unwedge it.
// Regressions guarded: re-arming on a preamble (startReceive() goes through standby, which aborts a frame
// that is in fact arriving), acting before the window closes, or never acting at all.
static void test_staleRxFlagAction_keepsFlagsInsideTheWindow()
{
TEST_ASSERT_EQUAL(static_cast<int>(StaleRxFlagAction::Keep), static_cast<int>(staleRxFlagAction(false, 0, 99)));
TEST_ASSERT_EQUAL(static_cast<int>(StaleRxFlagAction::Keep), static_cast<int>(staleRxFlagAction(true, 98, 99)));
}
static void test_staleRxFlagAction_windowEndsAtExactlyOneMaxPacket()
{
// Inclusive at the boundary, matching Throttle::hasElapsed().
TEST_ASSERT_EQUAL(static_cast<int>(StaleRxFlagAction::ClearPreamble), static_cast<int>(staleRxFlagAction(false, 99, 99)));
TEST_ASSERT_EQUAL(static_cast<int>(StaleRxFlagAction::Rearm), static_cast<int>(staleRxFlagAction(true, 99, 99)));
}
static void test_staleRxFlagAction_barePreambleIsOnlyCleared()
{
TEST_ASSERT_EQUAL(static_cast<int>(StaleRxFlagAction::ClearPreamble), static_cast<int>(staleRxFlagAction(false, 2200, 2115)));
}
static void test_staleRxFlagAction_staleHeaderIsRearmed()
{
TEST_ASSERT_EQUAL(static_cast<int>(StaleRxFlagAction::Rearm), static_cast<int>(staleRxFlagAction(true, 60000, 2115)));
}
// checkStaleRxFlags() itself, which the cases above cannot see: a header that shows after a bare preamble gets its own
// max-packet window, timed from when the header showed. Regression guarded: timing the header from the earlier
// preamble's stamp, which re-arms RX - a standby that aborts the frame whose header has only just arrived.
static void test_checkStaleRxFlags_headerAfterPreambleRestartsTheWindow()
{
FakeIrqRadio phy;
auto *radioIf = new TestableRadioLibInterface(&phy);
radioIf->packetTimeMs = 100;
Time::setTestMillis(1000);
phy.irqFlags = phy.getIrqMapped(1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED);
radioIf->checkStaleRxFlags();
TEST_ASSERT_EQUAL_UINT32(1000, radioIf->rxFlagsSeenMs);
TEST_ASSERT_FALSE(radioIf->rxFlagsSeenHeader);
// The header shows inside the preamble's window: its own window starts here
Time::setTestMillis(1090);
phy.irqFlags |= phy.getIrqMapped(1UL << RADIOLIB_IRQ_HEADER_VALID);
radioIf->checkStaleRxFlags();
TEST_ASSERT_EQUAL_UINT32(1090, radioIf->rxFlagsSeenMs);
TEST_ASSERT_TRUE(radioIf->rxFlagsSeenHeader);
// Past one max packet from the preamble, not from the header: nothing happens
Time::setTestMillis(1150);
radioIf->checkStaleRxFlags();
TEST_ASSERT_EQUAL_UINT32_MESSAGE(1090, radioIf->rxFlagsSeenMs, "the header must be timed from when it showed");
TEST_ASSERT_TRUE(radioIf->rxFlagsSeenHeader);
Time::useRealClock();
delete radioIf;
}
void setup()
{
delay(10);
delay(2000);
initializeTestEnvironment();
UNITY_BEGIN();
RUN_TEST(test_lr20x0BandClassification);
RUN_TEST(test_lr20x0CustomLfPaBandCoversOnly500To1000MHz);
RUN_TEST(test_lr20x0BandHopDetection);
RUN_TEST(test_lr20x0ReconfigurePathSelection);
RUN_TEST(test_bwCodeToKHz_specialMappings);
RUN_TEST(test_bwCodeToKHz_passthrough);
RUN_TEST(test_bwCodeToKHz_roundTrip);
RUN_TEST(test_validateConfigLora_noopWhenUsePresetFalse);
RUN_TEST(test_validateConfigLora_validPreset_nonWideRegion);
RUN_TEST(test_validateConfigLora_validPreset_wideRegion);
RUN_TEST(test_validateConfigLora_rejectsInvalidPresetForRegion);
RUN_TEST(test_clampConfigLora_invalidPresetClampedToDefault);
RUN_TEST(test_clampConfigLora_validPresetUnchanged);
RUN_TEST(test_applyModemConfig_freshFlashCodingRateNotZero);
RUN_TEST(test_applyModemConfig_codingRateMatchesPreset);
RUN_TEST(test_applyModemConfig_customCodingRateHigherThanPreset);
RUN_TEST(test_applyModemConfig_customCodingRateLowerThanPreset);
RUN_TEST(test_applyModemConfig_mediumTurbo);
RUN_TEST(test_clampConfigLora_mediumTurboInvalidForEU868);
RUN_TEST(test_clampConfigLora_mediumTurboValidForUS);
RUN_TEST(test_regionPresetMap_coversAllRegionsWithinBounds);
RUN_TEST(test_regionPresetMap_matchesRegionTable);
RUN_TEST(test_regionPresetMap_unsetCarriesUserprefsIntent);
RUN_TEST(test_beginSending_oversizedPayloadIsClamped);
RUN_TEST(test_beginSending_fittingPayloadIsSentWhole);
RUN_TEST(test_computePacketTime_txUsesTheRadiosOwnAnswer);
RUN_TEST(test_computePacketTime_txFallsBackWhenTheRadioReportsAnError);
RUN_TEST(test_computePacketTime_reportsNoAirtimeWhenNothingCanBeComputed);
RUN_TEST(test_computePacketTime_rxUsesHeaderInfoAndIsGuarded);
RUN_TEST(test_isRadioLibTimeError_separatesCodesFromDurations);
RUN_TEST(test_clearReadIrqs_alsoDiscardsTheUnreadFrame);
RUN_TEST(test_staleRxFlagAction_keepsFlagsInsideTheWindow);
RUN_TEST(test_staleRxFlagAction_windowEndsAtExactlyOneMaxPacket);
RUN_TEST(test_staleRxFlagAction_barePreambleIsOnlyCleared);
RUN_TEST(test_staleRxFlagAction_staleHeaderIsRearmed);
RUN_TEST(test_checkStaleRxFlags_headerAfterPreambleRestartsTheWindow);
RUN_TEST(test_anchoredSlotDelay_keepsTheRequestedParity);
RUN_TEST(test_anchoredSlotDelay_takesTheNextWholeSlot);
RUN_TEST(test_anchoredSlotDelay_survivesAnAnchorHoursOld);
RUN_TEST(test_noteFrameEnd_ignoresAFrameDeliveredLate);
exit(UNITY_END());
}
void loop() {}