Files
Thomas Göttgens 2a01676227 fix(nodedb): track whether each node was heard on the current LoRa config (#11811)
* fix(nodedb): track whether each node was heard on the current LoRa config

Set NODEINFO_BITFIELD_HEARD_ON_CURRENT_LORA on a genuine RF hear and clear it for every node when the LoRa slot config moves, so clients can tell which nodes went unreachable after a preset, region, slot or primary-channel-name change. Hooked into MeshService::reloadConfig(), the single funnel for the device menu, admin/CLI and scanned-URL paths, plus NodeDB::restorePreferences(), which reboots without passing through it.

Fixes #11745

* fix(nodedb): store the slot each node was heard on instead of sweeping a bit

A client scanning for traffic rolls through presets with live set_config writes, so every hop reached reloadConfig and the sweep cleared the marks on the way out and again on the way home. Each node now carries a 12-bit fingerprint of the slot it was heard on in spare bitfield bits, and heard_on_current_lora is derived by comparing that against the slot the radio is committed to. Config changes no longer touch the node database at all.

* fix(nodedb): keep comments inside the two-line limit, rename a test

Trunk read test_fingerprint_channelNumIsASlotChange as a Lob API key, since it is test_ followed by exactly 35 alphanumerics, so the tail is now shorter. The comments added under src/ are back within the one-or-two-line limit in AGENTS.md.

* fix(nodedb): drop legacy bitfield bits above 10 during v24 migration

v24 assigned bits 0..10, so a legacy record carrying anything higher would arrive claiming an RF hear with a stray slot fingerprint, and a never-heard node would read as reachable whenever that stray value matched ours. The migration now masks those bits off, and a new case in test_nodedb_legacy_migration pins it.
2026-09-11 18:51:10 +00:00

339 lines
12 KiB
C++

// The "heard on the current LoRa config" mark - src/mesh/NodeDB.cpp and src/mesh/TypeConversions.cpp.
// Each node stores the slot it was last heard on; NodeInfo.heard_on_current_lora is that matching the
// slot the radio is committed to. The regression guarded is a client rolling through presets to scan
// for traffic: nothing may be swept on the way out, and returning to a slot must mark its nodes again.
#include "MeshTypes.h" // BEFORE TestUtil.h - provides WARM_NODE_COUNT / MAX_NUM_NODES via mesh-pb-constants.h
#include "TestUtil.h"
#include <unity.h>
#if defined(ARCH_PORTDUINO)
#define NDB_TEST_ENTRY extern "C"
#else
#define NDB_TEST_ENTRY
#endif
#include "mesh/NodeDB.h"
#include "mesh/TypeConversions.h"
#include <cstring>
// Name and global scope both fixed by the `friend class NodeDBTestShim` declaration in NodeDB.h.
class NodeDBTestShim : public NodeDB
{
public:
void clearHot()
{
meshNodes->clear();
numMeshNodes = 0;
}
// A node admitted without ever being heard over RF - an all-zero bitfield, as a pre-feature
// record loaded from disk has.
void push(NodeNum num)
{
meshtastic_NodeInfoLite n = meshtastic_NodeInfoLite_init_zero;
n.num = num;
n.last_heard = 1000;
meshNodes->push_back(n);
numMeshNodes = meshNodes->size();
}
};
namespace
{
NodeDBTestShim *db = nullptr;
meshtastic_Config_LoRaConfig savedLora;
// Every field the snapshot reads is non-default, so changing one is a real change, not a zero swap.
meshtastic_Config_LoRaConfig baselineLora()
{
meshtastic_Config_LoRaConfig lora = meshtastic_Config_LoRaConfig_init_zero;
lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
lora.use_preset = true;
lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
lora.bandwidth = 250;
lora.spread_factor = 11;
lora.coding_rate = 5;
lora.override_frequency = 869.525f;
lora.channel_num = 7;
return lora;
}
uint16_t fp(const meshtastic_Config_LoRaConfig &lora, const char *name)
{
return loraSlotSnapshotFrom(lora, name).fingerprint();
}
// What a client actually sees: derived at conversion time from the slot the radio is committed to.
bool heard(NodeNum num)
{
return TypeConversions::ConvertToNodeInfo(db->getMeshNode(num), nullptr, nullptr).heard_on_current_lora;
}
// A decoded packet as updateFrom() sees it coming off the RX pipeline.
meshtastic_MeshPacket rxPacket(NodeNum from)
{
meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero;
mp.from = from;
mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
mp.has_rx_time = true;
mp.rx_time = 1000;
mp.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA;
return mp;
}
// Move the radio the way a client's set_config(lora) does, committing to the new slot.
void commitPreset(meshtastic_Config_LoRaConfig_ModemPreset preset)
{
config.lora.modem_preset = preset;
db->refreshCommittedLoraSlot();
}
void commitHome()
{
config.lora = savedLora;
db->refreshCommittedLoraSlot();
}
} // namespace
void setUp(void)
{
db->clearHot();
config.lora = savedLora;
db->setLoraSlotTransient(false);
db->refreshCommittedLoraSlot();
}
void tearDown(void) {}
// ---------- the fingerprint: what counts as a different slot ---------------------------------
static void test_fingerprint_identicalConfigMatches(void)
{
const meshtastic_Config_LoRaConfig lora = baselineLora();
TEST_ASSERT_EQUAL_UINT16(fp(lora, "LongFast"), fp(lora, "LongFast"));
}
static void test_fingerprint_regionIsASlotChange(void)
{
meshtastic_Config_LoRaConfig other = baselineLora();
other.region = meshtastic_Config_LoRaConfig_RegionCode_US;
TEST_ASSERT_NOT_EQUAL_UINT16(fp(baselineLora(), "LongFast"), fp(other, "LongFast"));
}
static void test_fingerprint_presetIsASlotChange(void)
{
meshtastic_Config_LoRaConfig other = baselineLora();
other.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST;
TEST_ASSERT_NOT_EQUAL_UINT16(fp(baselineLora(), "LongFast"), fp(other, "LongFast"));
}
static void test_fingerprint_channelNumChangesSlot(void)
{
meshtastic_Config_LoRaConfig other = baselineLora();
other.channel_num = 8;
TEST_ASSERT_NOT_EQUAL_UINT16(fp(baselineLora(), "LongFast"), fp(other, "LongFast"));
}
static void test_fingerprint_overrideFrequencyIsASlotChange(void)
{
meshtastic_Config_LoRaConfig other = baselineLora();
other.override_frequency = 869.4f;
TEST_ASSERT_NOT_EQUAL_UINT16(fp(baselineLora(), "LongFast"), fp(other, "LongFast"));
}
// Slot is the hash of the primary channel name, so a rename or a scanned QR moves the radio.
static void test_fingerprint_primaryChannelRenameIsASlotChange(void)
{
const meshtastic_Config_LoRaConfig lora = baselineLora();
TEST_ASSERT_NOT_EQUAL_UINT16(fp(lora, "LongFast"), fp(lora, "MyMesh"));
}
static void test_fingerprint_usePresetToggleIsASlotChange(void)
{
meshtastic_Config_LoRaConfig other = baselineLora();
other.use_preset = false;
TEST_ASSERT_NOT_EQUAL_UINT16(fp(baselineLora(), "LongFast"), fp(other, "LongFast"));
}
// The dormant half of the preset/custom pair moves nothing on air; editing it must not read as a move.
static void test_fingerprint_dormantModemFieldsIgnoredWhenUsingPreset(void)
{
meshtastic_Config_LoRaConfig other = baselineLora(); // use_preset = true
other.bandwidth = 125;
other.spread_factor = 7;
other.coding_rate = 8;
TEST_ASSERT_EQUAL_UINT16(fp(baselineLora(), "LongFast"), fp(other, "LongFast"));
}
static void test_fingerprint_dormantPresetIgnoredWhenNotUsingPreset(void)
{
meshtastic_Config_LoRaConfig base = baselineLora();
base.use_preset = false;
meshtastic_Config_LoRaConfig other = base;
other.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST;
TEST_ASSERT_EQUAL_UINT16(fp(base, "LongFast"), fp(other, "LongFast"));
}
static void test_fingerprint_customModemFieldsCountWhenNotUsingPreset(void)
{
meshtastic_Config_LoRaConfig base = baselineLora();
base.use_preset = false;
meshtastic_Config_LoRaConfig bw = base, sf = base, cr = base;
bw.bandwidth = 125;
sf.spread_factor = 7;
cr.coding_rate = 8;
TEST_ASSERT_NOT_EQUAL_UINT16(fp(base, "LongFast"), fp(bw, "LongFast"));
TEST_ASSERT_NOT_EQUAL_UINT16(fp(base, "LongFast"), fp(sf, "LongFast"));
TEST_ASSERT_NOT_EQUAL_UINT16(fp(base, "LongFast"), fp(cr, "LongFast"));
}
// ---------- storing the slot on a hear -------------------------------------------------------
static void test_hear_rfHearMarksNodeOnCurrentSlot(void)
{
db->updateFrom(rxPacket(0x4444));
TEST_ASSERT_TRUE(heard(0x4444));
}
// A gateway rebroadcast is TRANSPORT_LORA + via_mqtt: we heard the gateway, not the node.
static void test_hear_mqttRelayDoesNotMark(void)
{
meshtastic_MeshPacket mp = rxPacket(0x5555);
mp.via_mqtt = true;
db->updateFrom(mp);
TEST_ASSERT_NOT_NULL(db->getMeshNode(0x5555)); // admitted...
TEST_ASSERT_FALSE(heard(0x5555)); // ...but not as an RF hear on this slot
}
static void test_hear_mqttTransportDoesNotMark(void)
{
meshtastic_MeshPacket mp = rxPacket(0x6666);
mp.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT;
db->updateFrom(mp);
TEST_ASSERT_NOT_NULL(db->getMeshNode(0x6666));
TEST_ASSERT_FALSE(heard(0x6666));
}
// The mark is about the radio, not the clock: an RF hear counts before the clock is trusted.
static void test_hear_countsWithUntrustedClock(void)
{
meshtastic_MeshPacket mp = rxPacket(0x7777);
mp.has_rx_time = false;
db->updateFrom(mp);
TEST_ASSERT_TRUE(heard(0x7777));
}
// A pre-feature record has an all-zero bitfield. Without the has-RF-hear bit gating it, stored slot 0
// would collide with whatever the radio happens to be on and mark every legacy node heard.
static void test_hear_legacyRecordReadsUnheard(void)
{
db->push(0xAAAA);
TEST_ASSERT_FALSE(heard(0xAAAA));
}
// ---------- the scan: rolling through presets and back ---------------------------------------
// The regression this design exists for. A client scanning A->B->C->A must leave A's marks intact:
// the hops sweep nothing, and coming home makes the stored slots match again on their own.
static void test_scan_roundTripRestoresTheMark(void)
{
db->updateFrom(rxPacket(0x1111)); // heard on A
TEST_ASSERT_TRUE(heard(0x1111));
commitPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST); // hop to B
TEST_ASSERT_FALSE(heard(0x1111)); // unreachable while parked on B
commitPreset(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST); // hop to C
TEST_ASSERT_FALSE(heard(0x1111));
commitHome();
TEST_ASSERT_TRUE(heard(0x1111));
}
// The other direction: a node heard only while parked on B must not read as reachable back on A.
static void test_scan_nodeHeardOnOtherSlotStaysUnheardAtHome(void)
{
commitPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST);
db->updateFrom(rxPacket(0x2222)); // a foreign node, heard on B
TEST_ASSERT_TRUE(heard(0x2222));
commitHome();
TEST_ASSERT_FALSE(heard(0x2222));
}
// Re-reading the committed slot is not a sweep: it must never touch a node's stored bitfield, which
// is what keeps a scan off the flash and makes the round trip above possible at all.
static void test_scan_refreshWritesNoNode(void)
{
db->updateFrom(rxPacket(0x3333));
const uint32_t before = db->getMeshNode(0x3333)->bitfield;
commitPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST);
TEST_ASSERT_EQUAL_UINT32(before, db->getMeshNode(0x3333)->bitfield);
}
// ---------- transient switch (a beacon keyed up on another preset) ---------------------------
// MeshBeaconModule rewrites config.lora for a beacon TX and restores it. The committed slot is pinned
// across that window, so the whole node list does not blink to unheard while we key up elsewhere.
static void test_transient_committedSlotIsPinned(void)
{
db->updateFrom(rxPacket(0x1111));
const uint16_t home = db->committedLoraSlot();
db->setLoraSlotTransient(true);
commitPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST);
TEST_ASSERT_EQUAL_UINT16(home, db->committedLoraSlot());
TEST_ASSERT_TRUE(heard(0x1111));
}
// A hear while parked on the beacon's preset belongs to that preset, so it stops matching at home.
static void test_transient_hearIsStampedWithTheLiveSlot(void)
{
db->setLoraSlotTransient(true);
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST;
db->updateFrom(rxPacket(0x8888));
db->setLoraSlotTransient(false);
commitHome();
TEST_ASSERT_NOT_NULL(db->getMeshNode(0x8888)); // still admitted
TEST_ASSERT_FALSE(heard(0x8888));
}
NDB_TEST_ENTRY void setup()
{
initializeTestEnvironment();
db = new NodeDBTestShim();
nodeDB = db;
savedLora = config.lora;
UNITY_BEGIN();
RUN_TEST(test_fingerprint_identicalConfigMatches);
RUN_TEST(test_fingerprint_regionIsASlotChange);
RUN_TEST(test_fingerprint_presetIsASlotChange);
RUN_TEST(test_fingerprint_channelNumChangesSlot);
RUN_TEST(test_fingerprint_overrideFrequencyIsASlotChange);
RUN_TEST(test_fingerprint_primaryChannelRenameIsASlotChange);
RUN_TEST(test_fingerprint_usePresetToggleIsASlotChange);
RUN_TEST(test_fingerprint_dormantModemFieldsIgnoredWhenUsingPreset);
RUN_TEST(test_fingerprint_dormantPresetIgnoredWhenNotUsingPreset);
RUN_TEST(test_fingerprint_customModemFieldsCountWhenNotUsingPreset);
RUN_TEST(test_hear_rfHearMarksNodeOnCurrentSlot);
RUN_TEST(test_hear_mqttRelayDoesNotMark);
RUN_TEST(test_hear_mqttTransportDoesNotMark);
RUN_TEST(test_hear_countsWithUntrustedClock);
RUN_TEST(test_hear_legacyRecordReadsUnheard);
RUN_TEST(test_scan_roundTripRestoresTheMark);
RUN_TEST(test_scan_nodeHeardOnOtherSlotStaysUnheardAtHome);
RUN_TEST(test_scan_refreshWritesNoNode);
RUN_TEST(test_transient_committedSlotIsPinned);
RUN_TEST(test_transient_hearIsStampedWithTheLiveSlot);
exit(UNITY_END());
}
NDB_TEST_ENTRY void loop() {}