Files
firmware/test/test_mesh_beacon/test_main.cpp
T
Ben Meadors 7afd270f39 Gut beacon send-as-node and consolidate TX onto broadcast_targets (#11646)
* Gut beacon send-as-node and consolidate TX onto broadcast_targets

Two MeshBeaconConfig changes, both against fields that never reached a tagged
release, so there is no migration for existing nodes.

broadcast_send_as_node let a client name a node ID to send beacons AS, rewriting
the packet's `from`. Firmware never applied it - the assignment was commented
out, so `from` was always the local node and the field was a settable, persisted
no-op. It was also unsound as designed: rewriting `from` forges no signature, it
only makes isFromUs() false, so perhapsEncode() skips XEdDSA signing and
receivers get an unsigned packet attributed to another node.

broadcast_on_channel / broadcast_on_region / broadcast_on_preset were a second
way to name a beacon destination alongside broadcast_targets, chosen silently on
whether broadcast_targets was empty. The comments claimed the two were
equivalent; they were not. An inline ChannelSettings carries name and PSK, so
broadcast_on_channel could transmit on a channel absent from the node's channel
table, which channel_index cannot express. That is dropped deliberately - the
channel must exist on the node.

Empty broadcast_targets now synthesises one target on the running preset and
region over the primary channel, matching what the scalar path produced when
left unset, so an otherwise unconfigured node still beacons.

The USERPREFS_MESH_BEACON_ON_* keys go with the fields. A preconfigured build
that still defines one now fails at compile time with a pointer to the
USERPREFS_MESH_BEACON_TARGET_0_* equivalents, rather than silently losing its
beacon channel. The replacement names a channel-table slot, so such a build must
also provision that channel.

MeshBeaconConfig shrinks 324 -> 240 bytes and ModuleConfig 328 -> 244, against
the 512-byte MAX_TO_FROM_RADIO_SIZE ceiling that FromRadio sits 2 bytes under.

The protobufs submodule points at a branch carrying both proto changes; it needs
re-pointing to master once meshtastic/protobufs#1047 and #1048 merge.

* Point protobufs submodule at master now that the beacon protos are merged

meshtastic/protobufs#1047 and #1048 are in master, so drop the temporary
beacon-proto-integration pin. MeshBeaconConfig stays 240 bytes and ModuleConfig
244, unchanged from the integration branch.

The bump also picks up master's unrelated additions: the MESHNOLOGY_W12 and
MESHPAGER_X2 hardware models, and a ground-speed unit correction in Position.
2026-08-28 19:51:43 +00:00

1774 lines
80 KiB
C++

/**
* Unit tests for MeshBeaconModule:
* - AdminModule::handleSetModuleConfig validation (invalid/valid inputs)
* - MeshBeaconBroadcastModule payload cache lifecycle
* - MeshBeaconBroadcastModule::sendBeacon sends a correctly formed packet
* - MeshBeaconListenerModule offer caching and empty-message guard
*/
#include "TestUtil.h"
#include <unity.h>
#if defined(ARCH_PORTDUINO)
#define BEACON_TEST_ENTRY extern "C"
#else
#define BEACON_TEST_ENTRY
#endif
#if !MESHTASTIC_EXCLUDE_BEACON
#include "MeshRadio.h"
#include "MeshService.h"
#include "NodeDB.h"
#include "RadioInterface.h"
#include "airtime.h"
#include "modules/AdminModule.h"
#include "modules/MeshBeaconModule.h"
#include "support/AdminModuleTestShim.h"
#include "support/MockMeshService.h"
#include <cstdio>
#include <cstring>
#include <memory>
#include <pb_decode.h>
#include <vector>
// ---------------------------------------------------------------------------
// Formatted diagnostic helper. TEST_MESSAGE emits a line into Unity's per-test
// output (shown inline alongside the :PASS/:FAIL result); use this when you need
// a printf-style formatted note tied to a specific assertion. Plain printf() also
// works for free-standing log lines (e.g. the group headers in setup() below).
// ---------------------------------------------------------------------------
#define MSG_BUF_LEN 256
#define TEST_MSG_FMT(fmt, ...) \
do { \
char _buf[MSG_BUF_LEN]; \
snprintf(_buf, sizeof(_buf), fmt, ##__VA_ARGS__); \
TEST_MESSAGE(_buf); \
} while (0)
namespace
{
constexpr NodeNum kLocalNode = 0xAAAA0001;
constexpr NodeNum kRemoteNode = 0xBBBB0002;
// MockMeshService (test/support) stubs the side-effecting virtuals; handleToRadio is non-virtual so
// it runs the real implementation - the router->sendLocal path is guarded by MockRouter below.
// ---------------------------------------------------------------------------
// MockRouter: captures every packet handed to send() instead of transmitting.
// ---------------------------------------------------------------------------
class MockRouter : public Router
{
public:
~MockRouter()
{
delete cryptLock;
cryptLock = nullptr;
}
ErrorCode send(meshtastic_MeshPacket *p) override
{
// Capture the primary channel as seen AT send() time. sendBeaconPacket() temporarily swaps
// the beacon channel into the primary slot around this call so perhapsEncode keys off it,
// so this snapshot reflects which channel the packet would actually be encrypted on.
if (channelFile.channels_count > 0)
primaryAtSend.push_back(channels.getByIndex(channels.getPrimaryIndex()).settings);
sentPackets.push_back(*p);
packetPool.release(p);
return ERRNO_OK;
}
// Locally-addressed packets land here instead of send(). Release immediately
// rather than queuing into fromRadioQueue (which is never drained in tests).
void enqueueReceivedMessage(meshtastic_MeshPacket *p) override { packetPool.release(p); }
std::vector<meshtastic_MeshPacket> sentPackets;
std::vector<meshtastic_ChannelSettings> primaryAtSend;
};
// AdminModuleTestShim (test/support) exposes protected handleSetModuleConfig.
// ---------------------------------------------------------------------------
// MeshBeaconBroadcastModuleTestShim - exposes private internals for testing.
// ---------------------------------------------------------------------------
class MeshBeaconBroadcastModuleTestShim : public MeshBeaconBroadcastModule
{
public:
using MeshBeaconBroadcastModule::payloadCache;
using MeshBeaconBroadcastModule::payloadCacheDirty;
using MeshBeaconBroadcastModule::payloadCacheSize;
using MeshBeaconBroadcastModule::rebuildCache;
using MeshBeaconBroadcastModule::runOnce;
using MeshBeaconBroadcastModule::sendBeacon;
};
// ---------------------------------------------------------------------------
// MeshBeaconListenerModuleTestShim - exposes handleReceivedProtobuf.
// ---------------------------------------------------------------------------
class MeshBeaconListenerModuleTestShim : public MeshBeaconListenerModule
{
public:
using MeshBeaconListenerModule::handleReceivedProtobuf;
using MeshBeaconListenerModule::wantPacket;
};
// ---------------------------------------------------------------------------
// Globals managed by setUp / tearDown.
// ---------------------------------------------------------------------------
static MockMeshService *mockSvc = nullptr;
static MockRouter *mockRouter = nullptr;
static AdminModuleTestShim *testAdmin = nullptr;
static AirTime *testAirTime = nullptr;
// ---------------------------------------------------------------------------
// Helper: build a ModuleConfig wrapper for the beacon case (mirrors the wire
// format used by set_module_config admin messages).
// ---------------------------------------------------------------------------
static meshtastic_ModuleConfig makeBeaconModuleConfig(meshtastic_ModuleConfig_MeshBeaconConfig bcfg)
{
meshtastic_ModuleConfig mc = meshtastic_ModuleConfig_init_zero;
mc.which_payload_variant = meshtastic_ModuleConfig_mesh_beacon_tag;
mc.payload_variant.mesh_beacon = bcfg;
return mc;
}
// ---------------------------------------------------------------------------
// Helper: reset module/device config to a known baseline.
// ---------------------------------------------------------------------------
static void resetConfig()
{
moduleConfig = meshtastic_LocalModuleConfig_init_zero;
config = meshtastic_LocalConfig_init_zero;
// Device is an EU_868 node with LONG_FAST - the starting point.
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
config.lora.use_preset = true;
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
// Allow TX unconditionally so airtime checks don't block sendBeacon().
config.lora.override_duty_cycle = true;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
myNodeInfo.my_node_num = kLocalNode;
initRegion();
}
// Install a single PRIMARY channel with an explicit name + PSK so the beacon channel-swap path
// (sendBeaconPacket) has a real primary slot to save/restore and to read the active PSK from.
static void installTestPrimaryChannel(const char *name, const uint8_t *psk, size_t pskLen)
{
channelFile.channels_count = 1;
meshtastic_Channel &ch = channelFile.channels[0];
ch = meshtastic_Channel_init_zero;
ch.index = 0;
ch.has_settings = true;
ch.role = meshtastic_Channel_Role_PRIMARY;
strncpy(ch.settings.name, name, sizeof(ch.settings.name) - 1);
ch.settings.psk.size = (pb_size_t)pskLen;
memcpy(ch.settings.psk.bytes, psk, pskLen);
channels.onConfigChanged(); // set primaryIndex + recompute hashes
}
// Install a secondary channel at the given table index. Pass psk=nullptr/pskLen=0 for a blank slot
// (no name, no PSK) to exercise the "referenced slot is unconfigured" fallback.
static void installTestSecondaryChannel(uint8_t index, const char *name, const uint8_t *psk, size_t pskLen)
{
if (channelFile.channels_count < (pb_size_t)(index + 1))
channelFile.channels_count = index + 1;
meshtastic_Channel &ch = channelFile.channels[index];
ch = meshtastic_Channel_init_zero;
ch.index = index;
ch.has_settings = true;
ch.role = meshtastic_Channel_Role_SECONDARY;
if (name)
strncpy(ch.settings.name, name, sizeof(ch.settings.name) - 1);
if (psk && pskLen) {
ch.settings.psk.size = (pb_size_t)pskLen;
memcpy(ch.settings.psk.bytes, psk, pskLen);
}
channels.onConfigChanged();
}
// ===========================================================================
// Group 1: AdminModule config validation - bad inputs must be sanitised
// ===========================================================================
/**
* Verify SHORT_TURBO is rejected when the region is EU_868 (turbo presets are not in that
* region's allowed preset set). Important to catch regressions where admin stores unlawful
* radio settings that would violate regional radio regulations.
*/
static void test_adminValidation_turboPresetOnEU868_isCleared(void)
{
resetConfig();
meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero;
bcfg.flags |= MESH_BEACON_FLAG_BROADCAST_ENABLED;
bcfg.broadcast_targets_count = 1;
bcfg.broadcast_targets[0].has_preset = true;
bcfg.broadcast_targets[0].preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO;
testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg));
TEST_ASSERT_TRUE(moduleConfig.has_mesh_beacon);
TEST_ASSERT_FALSE_MESSAGE(moduleConfig.mesh_beacon.broadcast_targets[0].has_preset, "SHORT_TURBO must be cleared for EU_868");
}
/**
* Verify LONG_TURBO is also cleared for EU_868, not just SHORT_TURBO.
* Important to confirm rejection covers the entire turbo preset family rather than one variant.
*/
static void test_adminValidation_longTurboPresetOnEU868_isCleared(void)
{
resetConfig();
meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero;
bcfg.broadcast_targets_count = 1;
bcfg.broadcast_targets[0].has_preset = true;
bcfg.broadcast_targets[0].preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO;
testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg));
TEST_ASSERT_FALSE(moduleConfig.mesh_beacon.broadcast_targets[0].has_preset);
}
/**
* Verify a turbo preset passes validation for US (PROFILE_STD allows all presets).
* Important because the same preset that is illegal in EU_868 must be preserved in permissive regions.
*/
static void test_adminValidation_turboPresetOnUS_isAccepted(void)
{
resetConfig();
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
initRegion();
meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero;
bcfg.broadcast_targets_count = 1;
bcfg.broadcast_targets[0].has_preset = true;
bcfg.broadcast_targets[0].preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO;
testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg));
TEST_ASSERT_TRUE(moduleConfig.mesh_beacon.broadcast_targets[0].has_preset);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, moduleConfig.mesh_beacon.broadcast_targets[0].preset);
}
/**
* Verify MEDIUM_TURBO is also cleared for EU_868. Like SHORT_TURBO/LONG_TURBO it is a 500 kHz preset
* that does not fit EU_868's 250 kHz band, so it must not survive admin validation there.
*/
static void test_adminValidation_mediumTurboPresetOnEU868_isCleared(void)
{
resetConfig();
meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero;
bcfg.broadcast_targets_count = 1;
bcfg.broadcast_targets[0].has_preset = true;
bcfg.broadcast_targets[0].preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO;
testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg));
TEST_ASSERT_TRUE(moduleConfig.has_mesh_beacon);
TEST_ASSERT_FALSE(moduleConfig.mesh_beacon.broadcast_targets[0].has_preset);
}
/**
* Verify MEDIUM_TURBO passes validation for US (PROFILE_STD allows the full turbo family).
* The same 500 kHz preset that is illegal in EU_868 must be preserved in permissive regions.
*/
static void test_adminValidation_mediumTurboPresetOnUS_isAccepted(void)
{
resetConfig();
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
initRegion();
meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero;
bcfg.broadcast_targets_count = 1;
bcfg.broadcast_targets[0].has_preset = true;
bcfg.broadcast_targets[0].preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO;
testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg));
TEST_ASSERT_TRUE(moduleConfig.mesh_beacon.broadcast_targets[0].has_preset);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO,
moduleConfig.mesh_beacon.broadcast_targets[0].preset);
}
/**
* Verify an out-of-range region code (255) is sanitised to UNSET rather than stored verbatim.
* Important to prevent invalid proto enum values from reaching the broadcaster and being broadcast
* over the air.
*/
static void test_adminValidation_unknownOfferRegion_isCleared(void)
{
resetConfig();
meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero;
bcfg.broadcast_offer_region = (meshtastic_Config_LoRaConfig_RegionCode)255;
testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg));
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, moduleConfig.mesh_beacon.broadcast_offer_region);
}
/**
* Verify a known-good offer region (US) is written through unchanged after admin validation.
* Important as a positive-path control alongside the rejection tests.
*/
static void test_adminValidation_validOfferRegion_isPreserved(void)
{
resetConfig();
meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero;
bcfg.broadcast_offer_region = meshtastic_Config_LoRaConfig_RegionCode_US;
testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg));
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, moduleConfig.mesh_beacon.broadcast_offer_region);
}
/**
* Verify an out-of-range region in a multi-target entry is sanitised to UNSET on write.
* Important because an invalid enum must never reach the radio-switch path.
*/
static void test_adminValidation_targetUnknownRegion_isCleared(void)
{
resetConfig();
meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero;
bcfg.broadcast_targets_count = 1;
bcfg.broadcast_targets[0].region = (meshtastic_Config_LoRaConfig_RegionCode)255;
testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg));
TEST_ASSERT_EQUAL(1, moduleConfig.mesh_beacon.broadcast_targets_count);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, moduleConfig.mesh_beacon.broadcast_targets[0].region);
}
/**
* Verify a preset that is illegal for a broadcast target's region clears that entry's preset
* and its channel.
*/
static void test_adminValidation_targetInvalidPresetForRegion_isCleared(void)
{
resetConfig();
meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero;
bcfg.broadcast_targets_count = 1;
bcfg.broadcast_targets[0].region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
bcfg.broadcast_targets[0].has_preset = true;
bcfg.broadcast_targets[0].preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO;
bcfg.broadcast_targets[0].has_channel_index = true;
bcfg.broadcast_targets[0].channel_index = 1;
testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg));
TEST_ASSERT_FALSE_MESSAGE(moduleConfig.mesh_beacon.broadcast_targets[0].has_preset,
"SHORT_TURBO must be cleared for EU_868 target");
TEST_ASSERT_FALSE(moduleConfig.mesh_beacon.broadcast_targets[0].has_channel_index);
}
/**
* Verify a channel_index beyond the channel-table capacity is cleared on write.
* Important so the broadcaster never indexes out of bounds when resolving a target channel.
*/
static void test_adminValidation_targetChannelIndexOutOfRange_isCleared(void)
{
resetConfig();
meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero;
bcfg.broadcast_targets_count = 1;
bcfg.broadcast_targets[0].has_channel_index = true;
bcfg.broadcast_targets[0].channel_index = MAX_NUM_CHANNELS; // one past the last valid slot
testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg));
TEST_ASSERT_FALSE(moduleConfig.mesh_beacon.broadcast_targets[0].has_channel_index);
}
/**
* Verify an in-range channel_index survives admin validation unchanged.
*/
static void test_adminValidation_targetChannelIndexInRange_isPreserved(void)
{
resetConfig();
meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero;
bcfg.broadcast_targets_count = 1;
bcfg.broadcast_targets[0].has_channel_index = true;
bcfg.broadcast_targets[0].channel_index = 0;
testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg));
TEST_ASSERT_TRUE(moduleConfig.mesh_beacon.broadcast_targets[0].has_channel_index);
TEST_ASSERT_EQUAL_UINT32(0, moduleConfig.mesh_beacon.broadcast_targets[0].channel_index);
}
/**
* Verify a valid preset/region multi-target entry survives admin validation unchanged.
* Positive-path control for the per-target validation.
*/
static void test_adminValidation_targetValidPresetForRegion_isPreserved(void)
{
resetConfig();
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
initRegion();
meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero;
bcfg.broadcast_targets_count = 1;
bcfg.broadcast_targets[0].region = meshtastic_Config_LoRaConfig_RegionCode_US;
bcfg.broadcast_targets[0].has_preset = true;
bcfg.broadcast_targets[0].preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO;
testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg));
TEST_ASSERT_TRUE(moduleConfig.mesh_beacon.broadcast_targets[0].has_preset);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, moduleConfig.mesh_beacon.broadcast_targets[0].preset);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, moduleConfig.mesh_beacon.broadcast_targets[0].region);
}
/**
* Verify broadcast_message is hard-capped at 100 characters (NUL forced at index 100).
* Important to prevent oversized beacon payloads from abusing airtime across the mesh.
*/
static void test_adminValidation_messageTooLong_isTruncatedAt100(void)
{
resetConfig();
meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero;
// Fill with 'A' up to the full array size; admin must enforce ≤100 chars.
memset(bcfg.broadcast_message, 'A', sizeof(bcfg.broadcast_message));
bcfg.broadcast_message[sizeof(bcfg.broadcast_message) - 1] = '\0'; // pb_decode guarantee
testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg));
// Byte at index 100 must be NUL (length capped at 100).
TEST_ASSERT_EQUAL('\0', moduleConfig.mesh_beacon.broadcast_message[100]);
// Bytes before it should still be 'A'.
TEST_ASSERT_EQUAL('A', moduleConfig.mesh_beacon.broadcast_message[0]);
}
/**
* Verify any non-zero interval below 3600 s is clamped up to the 1-hour minimum.
* Important to prevent high-rate beacon floods from a misconfigured or malicious client.
*/
static void test_adminValidation_intervalTooLow_isClamped(void)
{
resetConfig();
meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero;
bcfg.broadcast_interval_secs = 60; // way below minimum
testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg));
TEST_ASSERT_EQUAL_UINT32(3600, moduleConfig.mesh_beacon.broadcast_interval_secs);
}
/**
* Verify an interval above the minimum is stored as-is without modification.
* Important to confirm the clamp is one-sided (lower bound only, no upper bound enforced).
*/
static void test_adminValidation_intervalTooHigh_isPreserved(void)
{
resetConfig();
meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero;
bcfg.broadcast_interval_secs = 999999;
testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg));
TEST_ASSERT_EQUAL_UINT32(999999, moduleConfig.mesh_beacon.broadcast_interval_secs);
}
/**
* Verify LONG_FAST (enum value 0) survives admin validation without being treated as 'absent'.
* Important to guard the has_broadcast_offer_preset presence-flag fix against zero-value erasure.
*/
static void test_adminValidation_longFastOfferPreset_isPreserved(void)
{
resetConfig();
meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero;
bcfg.has_broadcast_offer_preset = true;
bcfg.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg));
TEST_ASSERT_TRUE(moduleConfig.mesh_beacon.has_broadcast_offer_preset);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, moduleConfig.mesh_beacon.broadcast_offer_preset);
}
/**
* Verify that interval 0 (the documented 'use default' sentinel) is not raised to 3600 by the clamp.
* Important because 0 and 3600 have different runtime semantics in runOnce via
* Default::getConfiguredOrDefault.
*/
static void test_adminValidation_intervalZero_isNotClamped(void)
{
resetConfig();
meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero;
bcfg.broadcast_interval_secs = 0;
testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg));
TEST_ASSERT_EQUAL_UINT32(0, moduleConfig.mesh_beacon.broadcast_interval_secs);
}
/**
* Verify that saving a new beacon config marks the broadcaster's payload cache dirty.
* Important so the next TX re-encodes from the latest config rather than a pre-save stale snapshot.
*/
static void test_adminValidation_validSave_invalidatesCache(void)
{
resetConfig();
// Prime the broadcaster with a clean state so the dirty flag is known.
std::unique_ptr<MeshBeaconBroadcastModuleTestShim> bcast(new MeshBeaconBroadcastModuleTestShim());
meshBeaconBroadcastModule = bcast.get();
bcast->payloadCacheDirty = false; // pretend it was freshly built
meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero;
bcfg.flags |= MESH_BEACON_FLAG_BROADCAST_ENABLED;
strncpy(bcfg.broadcast_message, "hello", sizeof(bcfg.broadcast_message) - 1);
testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg));
TEST_ASSERT_TRUE_MESSAGE(bcast->payloadCacheDirty, "Config save must mark payload cache dirty");
meshBeaconBroadcastModule = nullptr;
}
// ===========================================================================
// Group 2: Broadcaster payload cache
// ===========================================================================
/**
* Verify rebuildCache produces at least one encoded byte when broadcast_message is set.
* Important as the most basic liveness check for the protobuf encoding path.
*/
static void test_broadcaster_rebuildCache_producesNonEmptyPayload(void)
{
resetConfig();
moduleConfig.has_mesh_beacon = true;
strncpy(moduleConfig.mesh_beacon.broadcast_message, "Test beacon", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1);
MeshBeaconBroadcastModuleTestShim bcast;
TEST_ASSERT_TRUE(bcast.payloadCacheDirty);
bcast.rebuildCache();
TEST_ASSERT_FALSE_MESSAGE(bcast.payloadCacheDirty, "rebuildCache must clear dirty flag");
TEST_ASSERT_GREATER_THAN_MESSAGE(0, (int)bcast.payloadCacheSize, "rebuildCache must produce a non-empty payload");
}
/**
* Verify the cached bytes round-trip through pb_decode back to the original message string.
* Important to catch any protobuf field-tag or wire-type regression in the encoding path.
*/
static void test_broadcaster_rebuildCache_payloadDecodesCorrectly(void)
{
resetConfig();
moduleConfig.has_mesh_beacon = true;
const char *msg = "Hello, Meshtastic!";
strncpy(moduleConfig.mesh_beacon.broadcast_message, msg, sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1);
MeshBeaconBroadcastModuleTestShim bcast;
bcast.rebuildCache();
// Decode the cached bytes back into a MeshBeacon struct.
meshtastic_MeshBeacon decoded = meshtastic_MeshBeacon_init_zero;
pb_istream_t stream = pb_istream_from_buffer(bcast.payloadCache, bcast.payloadCacheSize);
bool ok = pb_decode(&stream, &meshtastic_MeshBeacon_msg, &decoded);
TEST_ASSERT_TRUE_MESSAGE(ok, "Cached payload must decode without error");
TEST_ASSERT_EQUAL_STRING(msg, decoded.message);
}
/**
* Verify offer_region and offer_preset are present in the encoded cache payload.
* Important to confirm the offer-carrying path correctly uses the has_broadcast_offer_preset flag.
*/
static void test_broadcaster_rebuildCache_offerFieldsEncoded(void)
{
resetConfig();
moduleConfig.has_mesh_beacon = true;
moduleConfig.mesh_beacon.broadcast_offer_region = meshtastic_Config_LoRaConfig_RegionCode_US;
moduleConfig.mesh_beacon.has_broadcast_offer_preset = true;
moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST;
strncpy(moduleConfig.mesh_beacon.broadcast_message, "offer-test", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1);
MeshBeaconBroadcastModuleTestShim bcast;
bcast.rebuildCache();
meshtastic_MeshBeacon decoded = meshtastic_MeshBeacon_init_zero;
pb_istream_t stream = pb_istream_from_buffer(bcast.payloadCache, bcast.payloadCacheSize);
pb_decode(&stream, &meshtastic_MeshBeacon_msg, &decoded);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, decoded.offer_region);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, decoded.offer_preset);
}
/**
* Verify invalidateCache flips payloadCacheDirty back to true after a successful rebuild.
* Important to confirm the cache-invalidation contract relied on by admin saves and config observers.
*/
static void test_broadcaster_invalidateCache_setsDirtyFlag(void)
{
resetConfig();
moduleConfig.has_mesh_beacon = true;
MeshBeaconBroadcastModuleTestShim bcast;
bcast.rebuildCache();
TEST_ASSERT_FALSE(bcast.payloadCacheDirty);
bcast.invalidateCache();
TEST_ASSERT_TRUE(bcast.payloadCacheDirty);
}
/**
* Verify calling rebuildCache a second time without an intervening invalidation is a no-op.
* Important to prevent spurious re-encodes when config-observer callbacks fire multiple times.
*/
static void test_broadcaster_rebuildCache_idempotent(void)
{
resetConfig();
moduleConfig.has_mesh_beacon = true;
strncpy(moduleConfig.mesh_beacon.broadcast_message, "idem", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1);
MeshBeaconBroadcastModuleTestShim bcast;
bcast.rebuildCache();
pb_size_t firstSize = bcast.payloadCacheSize;
bcast.rebuildCache(); // second call - should be identical
pb_size_t secondSize = bcast.payloadCacheSize;
TEST_ASSERT_FALSE(bcast.payloadCacheDirty);
TEST_ASSERT_EQUAL(firstSize, secondSize);
}
// ===========================================================================
// Group 3: Broadcaster sendBeacon - packet structure
// ===========================================================================
/**
* Verify the 'from' field is the local node number.
* Important for correct source attribution in peer node tables that receive the beacon.
*/
static void test_broadcaster_sendBeacon_fromIsLocalNodeWhenUnset(void)
{
resetConfig();
moduleConfig.has_mesh_beacon = true;
moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_BROADCAST_ENABLED;
strncpy(moduleConfig.mesh_beacon.broadcast_message, "from-local", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1);
MeshBeaconBroadcastModuleTestShim bcast;
bcast.sendBeacon();
TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size());
TEST_ASSERT_EQUAL_UINT32(kLocalNode, mockRouter->sentPackets[0].from);
}
/**
* Verify the 'to' field is always NODENUM_BROADCAST regardless of other settings.
* Important because beacons are mesh-wide announcements and must never be addressed to a single peer.
*/
static void test_broadcaster_sendBeacon_addressedToBroadcast(void)
{
resetConfig();
moduleConfig.has_mesh_beacon = true;
strncpy(moduleConfig.mesh_beacon.broadcast_message, "bcast-addr", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1);
MeshBeaconBroadcastModuleTestShim bcast;
bcast.sendBeacon();
TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size());
TEST_ASSERT_EQUAL_UINT32(NODENUM_BROADCAST, mockRouter->sentPackets[0].to);
}
/**
* Verify MESH_BEACON_APP portnum is used when the packet carries a radio offer payload.
* Important so receivers use the structured protobuf decoder rather than treating it as raw text.
*/
static void test_broadcaster_sendBeacon_usesBeaconPortnum(void)
{
resetConfig();
moduleConfig.has_mesh_beacon = true;
strncpy(moduleConfig.mesh_beacon.broadcast_message, "portnum-check", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1);
moduleConfig.mesh_beacon.has_broadcast_offer_preset = true;
moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW;
MeshBeaconBroadcastModuleTestShim bcast;
bcast.sendBeacon();
TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size());
TEST_ASSERT_EQUAL(meshtastic_PortNum_MESH_BEACON_APP, mockRouter->sentPackets[0].decoded.portnum);
}
/**
* Verify TEXT_MESSAGE_APP portnum is used when no offer content is present, even if a
* broadcast target preset is set (that governs which radio config to use for TX, not portnum).
* Important so standard clients display plain-text beacons without needing a MESH_BEACON_APP decoder.
*/
static void test_broadcaster_sendBeacon_fallsBackToTextMessagePortnum(void)
{
resetConfig();
moduleConfig.has_mesh_beacon = true;
const char *msg = "plain-text-beacon";
strncpy(moduleConfig.mesh_beacon.broadcast_message, msg, sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1);
// Target preset set, but no offer - should still be TEXT_MESSAGE_APP
moduleConfig.mesh_beacon.broadcast_targets_count = 1;
moduleConfig.mesh_beacon.broadcast_targets[0].has_preset = true;
moduleConfig.mesh_beacon.broadcast_targets[0].preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW;
MeshBeaconBroadcastModuleTestShim bcast;
bcast.sendBeacon();
TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size());
const meshtastic_MeshPacket &p = mockRouter->sentPackets[0];
TEST_ASSERT_EQUAL(meshtastic_PortNum_TEXT_MESSAGE_APP, p.decoded.portnum);
TEST_ASSERT_EQUAL_UINT32(strlen(msg), p.decoded.payload.size);
TEST_ASSERT_EQUAL_STRING_LEN(msg, (const char *)p.decoded.payload.bytes, p.decoded.payload.size);
}
/**
* Verify the MESH_BEACON_APP payload decodes back to the original message string.
* Important to catch encode/decode regressions in the full sendBeacon → wire → pb_decode round-trip.
*/
static void test_broadcaster_sendBeacon_payloadDecodesCorrectly(void)
{
resetConfig();
moduleConfig.has_mesh_beacon = true;
const char *msg = "Greetings from the beacon";
strncpy(moduleConfig.mesh_beacon.broadcast_message, msg, sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1);
moduleConfig.mesh_beacon.has_broadcast_offer_preset = true;
moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW;
MeshBeaconBroadcastModuleTestShim bcast;
bcast.sendBeacon();
TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size());
const meshtastic_MeshPacket &p = mockRouter->sentPackets[0];
meshtastic_MeshBeacon decoded = meshtastic_MeshBeacon_init_zero;
pb_istream_t stream = pb_istream_from_buffer(p.decoded.payload.bytes, p.decoded.payload.size);
bool ok = pb_decode(&stream, &meshtastic_MeshBeacon_msg, &decoded);
TEST_ASSERT_TRUE_MESSAGE(ok, "Sent payload must decode without error");
TEST_ASSERT_EQUAL_STRING(msg, decoded.message);
}
/**
* Verify a beacon with offer fields but no message text is still emitted on MESH_BEACON_APP.
* Important because offer-only beacons are a valid use case that the early-return guard must not
* suppress.
*/
static void test_broadcaster_sendBeacon_offerOnly_isSent(void)
{
resetConfig();
moduleConfig.has_mesh_beacon = true;
moduleConfig.mesh_beacon.has_broadcast_offer_preset = true;
moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
MeshBeaconBroadcastModuleTestShim bcast;
bcast.sendBeacon();
TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size());
TEST_ASSERT_EQUAL(meshtastic_PortNum_MESH_BEACON_APP, mockRouter->sentPackets[0].decoded.portnum);
}
/**
* Verify runOnce sends exactly one packet when broadcast_enabled is true.
* Important to confirm the OSThread timer callback drives the full send path end-to-end.
*/
static void test_broadcaster_runOnce_sendsWhenEnabled(void)
{
resetConfig();
moduleConfig.has_mesh_beacon = true;
moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_BROADCAST_ENABLED;
moduleConfig.mesh_beacon.broadcast_interval_secs = 3600;
strncpy(moduleConfig.mesh_beacon.broadcast_message, "runOnce-enabled",
sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1);
MeshBeaconBroadcastModuleTestShim bcast;
bcast.runOnce();
TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size());
}
/**
* Verify runOnce transmits nothing when broadcast_enabled is false.
* Important to confirm the feature can be cleanly disabled via remote admin without rebooting.
*/
static void test_broadcaster_runOnce_silentWhenDisabled(void)
{
resetConfig();
moduleConfig.has_mesh_beacon = true;
moduleConfig.mesh_beacon.flags &= ~MESH_BEACON_FLAG_BROADCAST_ENABLED;
strncpy(moduleConfig.mesh_beacon.broadcast_message, "runOnce-disabled",
sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1);
MeshBeaconBroadcastModuleTestShim bcast;
bcast.runOnce();
TEST_ASSERT_EQUAL_UINT32(0, mockRouter->sentPackets.size());
}
// ===========================================================================
// Group 4: Listener - offer caching and guards
// ===========================================================================
// Helper: build a decoded MESH_BEACON_APP packet carrying the given MeshBeacon.
static meshtastic_MeshPacket makeBeaconPacket(const meshtastic_MeshBeacon &b, NodeNum from = kRemoteNode)
{
meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero;
p.from = from;
p.to = NODENUM_BROADCAST;
p.id = 0xDEAD0001;
p.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
p.decoded.portnum = meshtastic_PortNum_MESH_BEACON_APP;
p.decoded.payload.size =
(pb_size_t)pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), &meshtastic_MeshBeacon_msg, &b);
return p;
}
/**
* Verify a beacon carrying preset and region offer fields is stored in lastReceivedOffer.
* Important to confirm the client app's offer cache is populated correctly for join-offer UI flows.
*/
static void test_listener_receiveWithOffer_cachesOffer(void)
{
resetConfig();
moduleConfig.has_mesh_beacon = true;
moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LISTEN_ENABLED;
MeshBeaconListenerModuleTestShim listener;
MeshBeaconListenerModule::lastReceivedOffer = {};
meshtastic_MeshBeacon b = meshtastic_MeshBeacon_init_zero;
strncpy(b.message, "Join us on US/MEDIUM_FAST", sizeof(b.message) - 1);
b.has_offer_preset = true;
b.offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST;
b.offer_region = meshtastic_Config_LoRaConfig_RegionCode_US;
meshtastic_MeshPacket mp = makeBeaconPacket(b);
listener.handleReceivedProtobuf(mp, &b);
TEST_ASSERT_TRUE_MESSAGE(MeshBeaconListenerModule::lastReceivedOffer.valid, "Offer with preset must be cached");
TEST_ASSERT_EQUAL(kRemoteNode, MeshBeaconListenerModule::lastReceivedOffer.sender);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, MeshBeaconListenerModule::lastReceivedOffer.preset);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, MeshBeaconListenerModule::lastReceivedOffer.region);
}
/**
* Verify a beacon with a full ChannelSettings offer sets has_channel and copies the channel struct.
* Important because the client app checks has_channel before rendering a channel join offer.
*/
static void test_listener_receiveWithChannelOffer_setsHasChannel(void)
{
resetConfig();
moduleConfig.has_mesh_beacon = true;
moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LISTEN_ENABLED;
MeshBeaconListenerModuleTestShim listener;
MeshBeaconListenerModule::lastReceivedOffer = {};
meshtastic_MeshBeacon b = meshtastic_MeshBeacon_init_zero;
strncpy(b.message, "Channel offer test", sizeof(b.message) - 1);
b.has_offer_channel = true;
b.offer_channel.channel_num = 5;
strncpy(b.offer_channel.name, "TestNet", sizeof(b.offer_channel.name) - 1);
meshtastic_MeshPacket mp = makeBeaconPacket(b);
listener.handleReceivedProtobuf(mp, &b);
TEST_ASSERT_TRUE(MeshBeaconListenerModule::lastReceivedOffer.valid);
TEST_ASSERT_TRUE_MESSAGE(MeshBeaconListenerModule::lastReceivedOffer.has_channel,
"has_channel must be set when offer_channel is present");
TEST_ASSERT_EQUAL_UINT32(5, MeshBeaconListenerModule::lastReceivedOffer.channel.channel_num);
}
/**
* Verify a beacon with neither message text nor offer fields is silently discarded.
* Important to avoid spurious cache updates and wasted inbox copies from empty-payload packets.
*/
static void test_listener_emptyMessageWithoutOffer_isDropped(void)
{
resetConfig();
moduleConfig.has_mesh_beacon = true;
moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LISTEN_ENABLED;
MeshBeaconListenerModuleTestShim listener;
MeshBeaconListenerModule::lastReceivedOffer = {};
meshtastic_MeshBeacon b = meshtastic_MeshBeacon_init_zero;
// message field intentionally left blank
meshtastic_MeshPacket mp = makeBeaconPacket(b);
listener.handleReceivedProtobuf(mp, &b);
TEST_ASSERT_FALSE_MESSAGE(MeshBeaconListenerModule::lastReceivedOffer.valid, "Empty message must not update offer cache");
}
/**
* Verify a LONG_FAST offer (preset enum value 0) with no message still populates the offer cache.
* Important to guard the has_offer_preset fix - LONG_FAST must not be treated as 'no offer present'.
*/
static void test_listener_offerOnly_isCached(void)
{
resetConfig();
moduleConfig.has_mesh_beacon = true;
moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LISTEN_ENABLED;
MeshBeaconListenerModuleTestShim listener;
MeshBeaconListenerModule::lastReceivedOffer = {};
meshtastic_MeshBeacon b = meshtastic_MeshBeacon_init_zero;
b.has_offer_preset = true;
b.offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
meshtastic_MeshPacket mp = makeBeaconPacket(b);
listener.handleReceivedProtobuf(mp, &b);
TEST_ASSERT_TRUE(MeshBeaconListenerModule::lastReceivedOffer.valid);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, MeshBeaconListenerModule::lastReceivedOffer.preset);
}
/**
* Verify a null MeshBeacon pointer is handled gracefully and returns false without a crash.
* Important to guard against the ProtobufModule base class passing nullptr on a decode failure.
*/
static void test_listener_nullBeacon_isDropped(void)
{
resetConfig();
moduleConfig.has_mesh_beacon = true;
moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LISTEN_ENABLED;
MeshBeaconListenerModuleTestShim listener;
MeshBeaconListenerModule::lastReceivedOffer = {};
meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero;
bool result = listener.handleReceivedProtobuf(mp, nullptr);
TEST_ASSERT_FALSE_MESSAGE(result, "Null beacon must return false");
TEST_ASSERT_FALSE(MeshBeaconListenerModule::lastReceivedOffer.valid);
}
/**
* Verify a text-only beacon (no offer fields set) does not mark the offer cache valid.
* Important to prevent the client from showing a join dialog in response to plain-text beacons.
*/
static void test_listener_receiveWithNoOffer_cacheStaysInvalid(void)
{
resetConfig();
moduleConfig.has_mesh_beacon = true;
moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LISTEN_ENABLED;
MeshBeaconListenerModuleTestShim listener;
MeshBeaconListenerModule::lastReceivedOffer = {};
meshtastic_MeshBeacon b = meshtastic_MeshBeacon_init_zero;
strncpy(b.message, "No offer here", sizeof(b.message) - 1);
// has_offer_preset == false, has_offer_channel == false
meshtastic_MeshPacket mp = makeBeaconPacket(b);
listener.handleReceivedProtobuf(mp, &b);
TEST_ASSERT_FALSE_MESSAGE(MeshBeaconListenerModule::lastReceivedOffer.valid, "No offer fields → cache must stay invalid");
}
/**
* Verify the listener does NOT unwrap a combined beacon's text into a synthesized TEXT_MESSAGE_APP.
* The original MESH_BEACON_APP packet already reaches the client (the handler returns CONTINUE), so
* a beacon-aware client reads `message` directly from it - injecting a copy would only duplicate it,
* and re-injecting onto the mesh would amplify/re-attribute. So: nothing onto the mesh, nothing
* synthesized to the phone, and the handler must not consume the packet.
*/
static void test_listener_textMessage_notUnwrapped(void)
{
resetConfig();
moduleConfig.has_mesh_beacon = true;
moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LISTEN_ENABLED;
MeshBeaconListenerModuleTestShim listener;
MeshBeaconListenerModule::lastReceivedOffer = {};
meshtastic_MeshBeacon b = meshtastic_MeshBeacon_init_zero;
strncpy(b.message, "hello mesh", sizeof(b.message) - 1);
meshtastic_MeshPacket mp = makeBeaconPacket(b);
bool consumed = listener.handleReceivedProtobuf(mp, &b);
// CONTINUE (not STOP): the original MESH_BEACON_APP keeps flowing to the client, which reads
// `message` from it - the simple path for a beacon-aware client.
TEST_ASSERT_FALSE_MESSAGE(consumed, "Listener must not consume the beacon; the original must reach the client");
// Nothing re-injected onto the mesh.
TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, mockRouter->sentPackets.size(),
"Received beacon text must not be re-injected into the mesh");
// No synthesized TEXT_MESSAGE_APP delivered to the phone (no duplicate of the beacon's text).
meshtastic_MeshPacket *toPhone = service->getForPhone();
TEST_ASSERT_NULL_MESSAGE(toPhone, "Listener must not inject a duplicate text packet to the phone");
}
/**
* Verify wantPacket returns false for MESH_BEACON_APP when listen_enabled is false.
* Important to confirm the module opts out of processing when its config flag is cleared.
*/
static void test_listener_wantPacket_falseWhenDisabled(void)
{
resetConfig();
moduleConfig.has_mesh_beacon = true;
moduleConfig.mesh_beacon.flags &= ~MESH_BEACON_FLAG_LISTEN_ENABLED;
MeshBeaconListenerModuleTestShim listener;
meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero;
mp.decoded.portnum = meshtastic_PortNum_MESH_BEACON_APP;
TEST_ASSERT_FALSE(listener.wantPacket(&mp));
}
/**
* Verify wantPacket returns true for MESH_BEACON_APP packets when listen_enabled is true.
* Important as a basic routing sanity check confirming the module is registered for its portnum.
*/
static void test_listener_wantPacket_trueWhenEnabled(void)
{
resetConfig();
moduleConfig.has_mesh_beacon = true;
moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LISTEN_ENABLED;
MeshBeaconListenerModuleTestShim listener;
meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero;
mp.decoded.portnum = meshtastic_PortNum_MESH_BEACON_APP;
TEST_ASSERT_TRUE(listener.wantPacket(&mp));
}
// ===========================================================================
// Group 6: Legacy split messages
// ===========================================================================
/**
* Verify broadcast_legacy_split causes sendBeacon to emit exactly two packets when both
* text and offer content are present.
* Important to confirm the split path is wired end-to-end rather than short-circuiting.
*/
static void test_broadcaster_legacySplit_sendsTwoPackets(void)
{
resetConfig();
moduleConfig.has_mesh_beacon = true;
moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LEGACY_SPLIT;
strncpy(moduleConfig.mesh_beacon.broadcast_message, "split-text", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1);
moduleConfig.mesh_beacon.has_broadcast_offer_preset = true;
moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW;
MeshBeaconBroadcastModuleTestShim bcast;
bcast.sendBeacon();
TEST_ASSERT_EQUAL_UINT32_MESSAGE(2, mockRouter->sentPackets.size(), "Legacy split must emit exactly 2 packets");
}
/**
* Verify the first packet in a legacy-split send is MESH_BEACON_APP (the offer packet).
* Important so receivers without a MESH_BEACON_APP decoder still get the text from packet B.
*/
static void test_broadcaster_legacySplit_firstPacketIsBeaconApp(void)
{
resetConfig();
moduleConfig.has_mesh_beacon = true;
moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LEGACY_SPLIT;
strncpy(moduleConfig.mesh_beacon.broadcast_message, "split-offer-only",
sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1);
moduleConfig.mesh_beacon.has_broadcast_offer_preset = true;
moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW;
MeshBeaconBroadcastModuleTestShim bcast;
bcast.sendBeacon();
TEST_ASSERT_EQUAL_UINT32(2, mockRouter->sentPackets.size());
TEST_ASSERT_EQUAL(meshtastic_PortNum_MESH_BEACON_APP, mockRouter->sentPackets[0].decoded.portnum);
}
/**
* Verify the MESH_BEACON_APP packet in a legacy-split send carries no message text.
* Important so peers' MESH_BEACON_APP handlers do not duplicate the text already in packet B.
*/
static void test_broadcaster_legacySplit_firstPacketHasNoMessageText(void)
{
resetConfig();
moduleConfig.has_mesh_beacon = true;
moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LEGACY_SPLIT;
strncpy(moduleConfig.mesh_beacon.broadcast_message, "hidden-in-split",
sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1);
moduleConfig.mesh_beacon.has_broadcast_offer_preset = true;
moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW;
MeshBeaconBroadcastModuleTestShim bcast;
bcast.sendBeacon();
TEST_ASSERT_EQUAL_UINT32(2, mockRouter->sentPackets.size());
const meshtastic_MeshPacket &pA = mockRouter->sentPackets[0];
meshtastic_MeshBeacon decodedA = meshtastic_MeshBeacon_init_zero;
pb_istream_t stream = pb_istream_from_buffer(pA.decoded.payload.bytes, pA.decoded.payload.size);
pb_decode(&stream, &meshtastic_MeshBeacon_msg, &decodedA);
TEST_ASSERT_EQUAL_STRING_MESSAGE("", decodedA.message, "Offer-only MESH_BEACON_APP must have empty message");
}
/**
* Verify the second packet in a legacy-split send is TEXT_MESSAGE_APP containing the message text.
* Important so legacy clients that only handle TEXT_MESSAGE_APP receive the human-readable text.
*/
static void test_broadcaster_legacySplit_secondPacketIsTextMessage(void)
{
resetConfig();
moduleConfig.has_mesh_beacon = true;
moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LEGACY_SPLIT;
const char *msg = "split-B-text";
strncpy(moduleConfig.mesh_beacon.broadcast_message, msg, sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1);
moduleConfig.mesh_beacon.has_broadcast_offer_preset = true;
moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW;
MeshBeaconBroadcastModuleTestShim bcast;
bcast.sendBeacon();
TEST_ASSERT_EQUAL_UINT32(2, mockRouter->sentPackets.size());
const meshtastic_MeshPacket &pB = mockRouter->sentPackets[1];
TEST_ASSERT_EQUAL(meshtastic_PortNum_TEXT_MESSAGE_APP, pB.decoded.portnum);
TEST_ASSERT_EQUAL_STRING_LEN(msg, (const char *)pB.decoded.payload.bytes, pB.decoded.payload.size);
}
// ===========================================================================
// Group 7: Beacon-channel PSK swap (target channel-table slot)
// ===========================================================================
/**
* With no target channel_index, the beacon must transmit on the primary channel unchanged
* (no swap). Guards against the swap firing - and churning the channel table - when it isn't needed.
*/
static void test_broadcaster_noChannelOverride_doesNotSwapPrimary(void)
{
resetConfig();
static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f};
installTestPrimaryChannel("Home", homePsk, sizeof(homePsk));
moduleConfig.has_mesh_beacon = true;
moduleConfig.mesh_beacon.has_broadcast_offer_preset = true;
moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW;
// No target channel_index.
MeshBeaconBroadcastModuleTestShim bcast;
bcast.sendBeacon();
TEST_ASSERT_TRUE_MESSAGE(mockRouter->primaryAtSend.size() >= 1, "expected at least one send");
TEST_ASSERT_EQUAL_STRING_MESSAGE("Home", mockRouter->primaryAtSend[0].name,
"primary must stay unchanged when no channel override is set");
}
/**
* A broadcast_target whose channel_index points at a configured table slot must transmit encrypted
* on THAT slot's channel (name + PSK), not the primary. Verifies the slot-index → channel-table
* resolution introduced when BroadcastTarget dropped its embedded ChannelSettings.
*/
static void test_broadcaster_targetChannelIndex_usesTableSlot(void)
{
resetConfig();
static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f};
static const uint8_t beaconPsk[16] = {0xBB, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17,
0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f};
installTestPrimaryChannel("Home", homePsk, sizeof(homePsk));
installTestSecondaryChannel(1, "BeaconNet", beaconPsk, sizeof(beaconPsk));
moduleConfig.has_mesh_beacon = true;
moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; // content to send
moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW;
moduleConfig.mesh_beacon.broadcast_targets_count = 1;
moduleConfig.mesh_beacon.broadcast_targets[0].has_channel_index = true;
moduleConfig.mesh_beacon.broadcast_targets[0].channel_index = 1;
MeshBeaconBroadcastModuleTestShim bcast;
bcast.sendBeacon();
TEST_ASSERT_TRUE_MESSAGE(mockRouter->primaryAtSend.size() >= 1, "expected at least one send");
const meshtastic_ChannelSettings &atSend = mockRouter->primaryAtSend[0];
TEST_ASSERT_EQUAL_STRING_MESSAGE("BeaconNet", atSend.name, "beacon must be encrypted on the referenced slot's channel");
TEST_ASSERT_EQUAL_UINT(sizeof(beaconPsk), atSend.psk.size);
TEST_ASSERT_EQUAL_UINT8_MESSAGE(0xBB, atSend.psk.bytes[0], "encryption must use the slot's PSK");
// Primary slot restored to home after send (no leak into normal traffic).
const meshtastic_ChannelSettings &after = channels.getByIndex(channels.getPrimaryIndex()).settings;
TEST_ASSERT_EQUAL_STRING_MESSAGE("Home", after.name, "primary channel must be restored after send");
TEST_ASSERT_EQUAL_UINT(sizeof(homePsk), after.psk.size);
TEST_ASSERT_EQUAL_UINT8(0xAA, after.psk.bytes[0]);
}
/**
* A broadcast_target whose channel_index points at a BLANK table slot (no name, no PSK) must fall
* back to the default channel for the preset rather than borrowing the primary's name/PSK with a
* clobbered channel_num. Guards the blank-slot handling for multi-target configs.
*/
static void test_broadcaster_targetChannelIndex_blankSlotFallsBackToPreset(void)
{
resetConfig();
static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f};
installTestPrimaryChannel("Home", homePsk, sizeof(homePsk));
installTestSecondaryChannel(1, nullptr, nullptr, 0); // blank slot: no name, no PSK
moduleConfig.has_mesh_beacon = true;
moduleConfig.mesh_beacon.has_broadcast_offer_preset = true;
moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW;
moduleConfig.mesh_beacon.broadcast_targets_count = 1;
moduleConfig.mesh_beacon.broadcast_targets[0].has_channel_index = true;
moduleConfig.mesh_beacon.broadcast_targets[0].channel_index = 1;
MeshBeaconBroadcastModuleTestShim bcast;
bcast.sendBeacon();
// Exactly one beacon goes out, and a blank slot does NOT trigger a crypto swap - the primary
// stays "Home" (no garbage / no clobber), matching the no-channel-override default-for-preset path.
TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size());
TEST_ASSERT_TRUE_MESSAGE(mockRouter->primaryAtSend.size() >= 1, "expected at least one send");
TEST_ASSERT_EQUAL_STRING_MESSAGE("Home", mockRouter->primaryAtSend[0].name,
"blank slot must not swap the beacon onto a borrowed channel");
}
/**
* Two broadcast_targets that resolve to the same effective radio config (same preset/region/channel)
* must produce only ONE beacon - the payload is identical, so re-broadcasting wastes airtime.
*/
static void test_broadcaster_duplicateTargets_dedupedToOnePacket(void)
{
resetConfig();
static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f};
installTestPrimaryChannel("Home", homePsk, sizeof(homePsk));
moduleConfig.has_mesh_beacon = true;
moduleConfig.mesh_beacon.has_broadcast_offer_preset = true;
moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW;
moduleConfig.mesh_beacon.broadcast_targets_count = 2;
for (int i = 0; i < 2; i++) {
moduleConfig.mesh_beacon.broadcast_targets[i].has_preset = true;
moduleConfig.mesh_beacon.broadcast_targets[i].preset = meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW;
moduleConfig.mesh_beacon.broadcast_targets[i].region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
}
MeshBeaconBroadcastModuleTestShim bcast;
bcast.sendBeacon();
TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, mockRouter->sentPackets.size(), "duplicate targets must collapse to one beacon");
}
/**
* Two distinct broadcast_targets (different presets) must BOTH be sent - dedup must not over-collapse.
*/
static void test_broadcaster_distinctTargets_bothSent(void)
{
resetConfig();
static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f};
installTestPrimaryChannel("Home", homePsk, sizeof(homePsk));
moduleConfig.has_mesh_beacon = true;
moduleConfig.mesh_beacon.has_broadcast_offer_preset = true;
moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW;
moduleConfig.mesh_beacon.broadcast_targets_count = 2;
moduleConfig.mesh_beacon.broadcast_targets[0].has_preset = true;
moduleConfig.mesh_beacon.broadcast_targets[0].preset = meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW;
moduleConfig.mesh_beacon.broadcast_targets[1].has_preset = true;
moduleConfig.mesh_beacon.broadcast_targets[1].preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
MeshBeaconBroadcastModuleTestShim bcast;
bcast.sendBeacon();
TEST_ASSERT_EQUAL_UINT32_MESSAGE(2, mockRouter->sentPackets.size(), "distinct targets must each be sent");
}
// ---------------------------------------------------------------------------
// Radio switch/restore re-entrancy
// ---------------------------------------------------------------------------
/**
* Stands in for a real driver on the restore path. RadioLibInterface::reconfigure() standbys the
* chip, setStandby() calls completeSending(), and completeSending() calls back into
* reconfigureForBeaconTX(iface, nullptr) - so reconfigure() re-entering is the normal case, not an
* exotic one. Bounded, so a regression fails an assertion instead of overflowing the stack.
*/
class ReentrantRadioInterface : public RadioInterface
{
public:
static constexpr int kReentryLimit = 16;
int reconfigureCalls = 0;
bool reenterOnReconfigure = false;
ErrorCode send(meshtastic_MeshPacket *p) override
{
packetPool.release(p);
return ERRNO_OK;
}
uint32_t getPacketTime(uint32_t totalPacketLen, bool received = false) override
{
(void)totalPacketLen;
(void)received;
return 0;
}
bool reconfigure() override
{
reconfigureCalls++;
if (reenterOnReconfigure && reconfigureCalls < kReentryLimit)
MeshBeaconModule::reconfigureForBeaconTX(this, nullptr);
return true;
}
};
/**
* The restore must clear its guard before reconfiguring, or completeSending() re-enters the restore
* branch and it reconfigures the radio once per level until the stack runs out. Seen in the field as
* a run of "Beacon: restore radio config after TX" with a full applyModemConfig() between each.
*/
static void test_beaconRestore_isNotReenteredByCompleteSending(void)
{
resetConfig();
static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f};
installTestPrimaryChannel("Home", homePsk, sizeof(homePsk));
ReentrantRadioInterface radio;
meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_zero;
pkt.id = 0x5EED0001;
MeshBeaconModule::setTargetRadioSettings(&pkt, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, 0, false,
meshtastic_Config_LoRaConfig_RegionCode_UNSET, false, nullptr);
// Switch to the beacon config. Not the case under test, so leave re-entry off.
TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, &pkt), "beacon switch should have applied");
TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, config.lora.modem_preset,
"switch must leave the radio on the beacon preset");
// Now restore, with reconfigure() re-entering exactly as completeSending() does.
MeshBeaconModule::clearTargetRadioSettings(&pkt);
radio.reconfigureCalls = 0;
radio.reenterOnReconfigure = true;
TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, nullptr), "restore should have applied");
TEST_ASSERT_EQUAL_INT_MESSAGE(1, radio.reconfigureCalls, "restore must reconfigure the radio exactly once");
TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset,
"restore must put the home preset back");
TEST_ASSERT_EQUAL_STRING_MESSAGE("Home", channels.getByIndex(channels.getPrimaryIndex()).settings.name,
"restore must put the home channel back");
}
/**
* A second switch before the restore has run must survive the same re-entry. completeSending() calls
* in with a null packet, which reads as "restore" - so without the guard it would undo the switch that
* is still being applied, leaving the beacon to transmit on the home channel instead of its target.
*/
static void test_beaconSwitch_isNotUndoneByCompleteSending(void)
{
resetConfig();
static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f};
installTestPrimaryChannel("Home", homePsk, sizeof(homePsk));
ReentrantRadioInterface radio;
meshtastic_MeshPacket first = meshtastic_MeshPacket_init_zero;
first.id = 0x5EED0002;
MeshBeaconModule::setTargetRadioSettings(&first, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, 0, false,
meshtastic_Config_LoRaConfig_RegionCode_UNSET, false, nullptr);
TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, &first), "first switch should have applied");
// Second switch with the restore still outstanding, and reconfigure() re-entering.
meshtastic_MeshPacket second = meshtastic_MeshPacket_init_zero;
second.id = 0x5EED0003;
MeshBeaconModule::setTargetRadioSettings(&second, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST, 0, false,
meshtastic_Config_LoRaConfig_RegionCode_UNSET, false, nullptr);
radio.reconfigureCalls = 0;
radio.reenterOnReconfigure = true;
TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, &second), "second switch should have applied");
TEST_ASSERT_EQUAL_INT_MESSAGE(1, radio.reconfigureCalls, "second switch must reconfigure the radio exactly once");
TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST, config.lora.modem_preset,
"second switch must not be undone mid-flight");
// The home config must still be recoverable afterwards - the snapshot survives a second switch.
radio.reenterOnReconfigure = false;
MeshBeaconModule::clearTargetRadioSettings(&first);
MeshBeaconModule::clearTargetRadioSettings(&second);
TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, nullptr), "restore should have applied");
TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset,
"restore must return to the home preset, not the first beacon target");
TEST_ASSERT_EQUAL_STRING_MESSAGE("Home", channels.getByIndex(channels.getPrimaryIndex()).settings.name,
"restore must return to the home channel");
}
/** A restore with nothing switched must do nothing at all - the guard is what makes re-entry safe. */
static void test_beaconRestore_withoutSwitch_isNoOp(void)
{
resetConfig();
ReentrantRadioInterface radio;
// reconfigureForBeaconTX() keeps its switched/not-switched state in a function-local static, so an
// earlier test that aborted mid-way can leave a switch outstanding. Drain it before asserting.
MeshBeaconModule::reconfigureForBeaconTX(&radio, nullptr);
radio.reconfigureCalls = 0;
radio.reenterOnReconfigure = true;
TEST_ASSERT_FALSE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, nullptr), "restore without a switch is a no-op");
TEST_ASSERT_EQUAL_INT_MESSAGE(0, radio.reconfigureCalls, "no-op restore must not touch the radio");
}
/**
* The restore is driven by "our beacon finished", not by "the radio changed state". completeSending()
* clears a packet's target settings before restoring, so a caller that arrives without that - the
* pre-TX channel scan standbys the radio, and setStandby() calls completeSending() - must be refused.
* Otherwise the home config goes back under a beacon that has not keyed up yet, and it transmits on
* the wrong preset with the beacon channel hash already stamped on it.
*/
static void test_beaconRestore_deferredUntilPacketCompletes(void)
{
resetConfig();
static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f};
installTestPrimaryChannel("Home", homePsk, sizeof(homePsk));
ReentrantRadioInterface radio;
meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_zero;
pkt.id = 0x5EED0004;
MeshBeaconModule::setTargetRadioSettings(&pkt, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, 0, false,
meshtastic_Config_LoRaConfig_RegionCode_UNSET, false, nullptr);
TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, &pkt), "beacon switch should have applied");
// The packet has not been sent yet, so its target settings are still live.
radio.reconfigureCalls = 0;
TEST_ASSERT_FALSE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, nullptr),
"restore must be refused while the beacon is still outstanding");
TEST_ASSERT_EQUAL_INT_MESSAGE(0, radio.reconfigureCalls, "a refused restore must not touch the radio");
TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, config.lora.modem_preset,
"the beacon preset must still be in place when the packet keys up");
// completeSending() clears the target settings first; only then is the restore ours to make.
MeshBeaconModule::clearTargetRadioSettings(&pkt);
TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, nullptr), "restore should have applied");
TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset,
"restore must put the home preset back");
TEST_ASSERT_EQUAL_STRING_MESSAGE("Home", channels.getByIndex(channels.getPrimaryIndex()).settings.name,
"restore must put the home channel back");
}
// ---------------------------------------------------------------------------
// MeshBeaconTxHook (the radio driver's view of the beacon)
// ---------------------------------------------------------------------------
/**
* Normal traffic must pass straight through the hook: no radio switch, no drop, and nothing claimed
* that would stop the driver listening on a busy channel.
*/
static void test_txHook_normalPacket_isSend(void)
{
resetConfig();
static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f};
installTestPrimaryChannel("Home", homePsk, sizeof(homePsk));
MeshBeaconTxHook hook;
ReentrantRadioInterface radio;
meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_zero;
pkt.id = 0x7A000001;
TEST_ASSERT_EQUAL_INT_MESSAGE(RadioTxHook::PRETX_SEND, RadioTxHooks::beforeTransmit(&radio, &pkt),
"a packet with no beacon target must transmit as usual");
TEST_ASSERT_FALSE_MESSAGE(RadioTxHooks::holdsRadio(&pkt), "normal traffic must not claim the radio");
TEST_ASSERT_EQUAL_INT_MESSAGE(0, radio.reconfigureCalls, "normal traffic must not reconfigure the radio");
}
/**
* A beacon asks the driver for a fresh transmit delay, because the switch leaves the radio on a
* channel the last channel scan never covered.
*/
static void test_txHook_beaconPacket_isDefer(void)
{
resetConfig();
static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f};
installTestPrimaryChannel("Home", homePsk, sizeof(homePsk));
MeshBeaconTxHook hook;
ReentrantRadioInterface radio;
meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_zero;
pkt.id = 0x7A000002;
MeshBeaconModule::setTargetRadioSettings(&pkt, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, 0, false,
meshtastic_Config_LoRaConfig_RegionCode_UNSET, false, nullptr);
TEST_ASSERT_EQUAL_INT_MESSAGE(RadioTxHook::PRETX_DEFER, RadioTxHooks::beforeTransmit(&radio, &pkt),
"a beacon switch must defer the transmit");
TEST_ASSERT_TRUE_MESSAGE(RadioTxHooks::holdsRadio(&pkt), "a queued beacon must claim the radio");
TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, config.lora.modem_preset,
"the hook must leave the radio on the beacon preset");
// packetReleased() is what the driver calls once the packet is sent, cancelled or dropped.
RadioTxHooks::packetReleased(&radio, &pkt);
TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset,
"releasing the packet must put the home preset back");
TEST_ASSERT_EQUAL_STRING_MESSAGE("Home", channels.getByIndex(channels.getPrimaryIndex()).settings.name,
"releasing the packet must put the home channel back");
}
/**
* SHORT_TURBO is not legal on EU_868, so the beacon has nowhere valid to transmit. The driver must be
* told to drop it rather than let it fall through onto the home config.
*/
static void test_txHook_invalidTarget_isDrop(void)
{
resetConfig();
static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f};
installTestPrimaryChannel("Home", homePsk, sizeof(homePsk));
MeshBeaconTxHook hook;
ReentrantRadioInterface radio;
meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_zero;
pkt.id = 0x7A000003;
MeshBeaconModule::setTargetRadioSettings(&pkt, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, 0, false,
meshtastic_Config_LoRaConfig_RegionCode_UNSET, false, nullptr);
TEST_ASSERT_EQUAL_INT_MESSAGE(RadioTxHook::PRETX_DROP, RadioTxHooks::beforeTransmit(&radio, &pkt),
"an invalid target config must be dropped, not transmitted");
TEST_ASSERT_EQUAL_INT_MESSAGE(0, radio.reconfigureCalls, "a dropped beacon must not reconfigure the radio");
TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset,
"a dropped beacon must leave the home preset alone");
RadioTxHooks::packetReleased(&radio, &pkt); // the driver's drop path, so the sidecar entry is freed
TEST_ASSERT_FALSE_MESSAGE(MeshBeaconModule::hasTargetRadioSettings(&pkt), "the dropped packet's target must be released");
}
/**
* The hook list is what keeps the driver free of module includes: with nothing registered every call
* is a no-op, so a build without the beacon module behaves exactly as one with beacons idle.
*/
static void test_txHook_unregistered_isNoOp(void)
{
resetConfig();
ReentrantRadioInterface radio;
meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_zero;
pkt.id = 0x7A000004;
MeshBeaconModule::setTargetRadioSettings(&pkt, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, 0, false,
meshtastic_Config_LoRaConfig_RegionCode_UNSET, false, nullptr);
TEST_ASSERT_EQUAL_INT_MESSAGE(RadioTxHook::PRETX_SEND, RadioTxHooks::beforeTransmit(&radio, &pkt),
"with no hook registered even a beacon is ordinary traffic to the driver");
TEST_ASSERT_FALSE_MESSAGE(RadioTxHooks::holdsRadio(&pkt), "with no hook registered nothing claims the radio");
TEST_ASSERT_EQUAL_INT_MESSAGE(0, radio.reconfigureCalls, "with no hook registered the radio is never reconfigured");
MeshBeaconModule::clearTargetRadioSettings(&pkt);
}
/**
* A higher-priority packet can enqueue ahead of a still-queued beacon, so the driver asks the hook about
* an untagged packet while the beacon that armed the switch is live. The restore gate is right to hold
* off a release then, and wrong to hold off this: the untagged packet is about to key up, and without
* the restore it transmits on the beacon's preset, slot and region.
*/
static void test_txHook_untaggedPacketAheadOfQueuedBeacon_restoresHome(void)
{
resetConfig();
static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f};
installTestPrimaryChannel("Home", homePsk, sizeof(homePsk));
MeshBeaconTxHook hook;
ReentrantRadioInterface radio;
// The beacon reaches the head of the queue and the hook switches the radio for it.
meshtastic_MeshPacket beacon = meshtastic_MeshPacket_init_zero;
beacon.id = 0x7A000005;
MeshBeaconModule::setTargetRadioSettings(&beacon, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, 0, false,
meshtastic_Config_LoRaConfig_RegionCode_UNSET, false, nullptr);
TEST_ASSERT_EQUAL_INT_MESSAGE(RadioTxHook::PRETX_DEFER, RadioTxHooks::beforeTransmit(&radio, &beacon),
"the beacon switch should have applied");
TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, config.lora.modem_preset,
"the radio must be on the beacon preset before the queue jump");
// An ordinary packet now jumps the queue. The beacon is still queued, so its target is still live.
TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::hasTargetRadioSettings(&beacon),
"the queued beacon must still hold its target, or this is not the case under test");
meshtastic_MeshPacket ordinary = meshtastic_MeshPacket_init_zero;
ordinary.id = 0x7A000006;
TEST_ASSERT_EQUAL_INT_MESSAGE(RadioTxHook::PRETX_DEFER, RadioTxHooks::beforeTransmit(&radio, &ordinary),
"restoring the radio owes the driver a fresh delay and scan");
TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset,
"an untagged packet must never transmit on the beacon preset");
TEST_ASSERT_EQUAL_STRING_MESSAGE("Home", channels.getByIndex(channels.getPrimaryIndex()).settings.name,
"an untagged packet must never transmit on the beacon channel");
// The beacon is not lost by the restore: it switches the radio back when it next reaches the head.
TEST_ASSERT_EQUAL_INT_MESSAGE(RadioTxHook::PRETX_DEFER, RadioTxHooks::beforeTransmit(&radio, &beacon),
"the beacon must switch back when it reaches the head again");
TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, config.lora.modem_preset,
"the beacon must be back on its target preset");
MeshBeaconModule::clearTargetRadioSettings(&beacon);
RadioTxHooks::packetReleased(&radio, &beacon);
}
} // namespace
// ===========================================================================
// Unity lifecycle
// ===========================================================================
void setUp(void)
{
testAirTime = new AirTime();
airTime = testAirTime;
mockSvc = new MockMeshService();
service = mockSvc;
mockRouter = new MockRouter();
router = mockRouter;
testAdmin = new AdminModuleTestShim();
}
void tearDown(void)
{
meshBeaconBroadcastModule = nullptr;
delete testAdmin;
testAdmin = nullptr;
// Drain any packets the listener delivered via sendToPhone() (toPhoneQueue takes ownership and
// nothing else dequeues them in tests) so they are returned to packetPool - otherwise they leak
// and LeakSanitizer aborts the process at exit.
if (mockSvc) {
meshtastic_MeshPacket *p;
while ((p = mockSvc->getForPhone()) != nullptr)
mockSvc->releaseToPool(p);
}
service = nullptr;
delete mockSvc;
mockSvc = nullptr;
router = nullptr;
delete mockRouter;
mockRouter = nullptr;
airTime = nullptr;
delete testAirTime;
testAirTime = nullptr;
}
BEACON_TEST_ENTRY void setup()
{
delay(10);
initializeTestEnvironment();
UNITY_BEGIN();
printf("\n=== AdminModule config validation ===\n");
RUN_TEST(test_adminValidation_turboPresetOnEU868_isCleared);
RUN_TEST(test_adminValidation_longTurboPresetOnEU868_isCleared);
RUN_TEST(test_adminValidation_turboPresetOnUS_isAccepted);
RUN_TEST(test_adminValidation_mediumTurboPresetOnEU868_isCleared);
RUN_TEST(test_adminValidation_mediumTurboPresetOnUS_isAccepted);
RUN_TEST(test_adminValidation_unknownOfferRegion_isCleared);
RUN_TEST(test_adminValidation_validOfferRegion_isPreserved);
RUN_TEST(test_adminValidation_targetUnknownRegion_isCleared);
RUN_TEST(test_adminValidation_targetInvalidPresetForRegion_isCleared);
RUN_TEST(test_adminValidation_targetValidPresetForRegion_isPreserved);
RUN_TEST(test_adminValidation_targetChannelIndexOutOfRange_isCleared);
RUN_TEST(test_adminValidation_targetChannelIndexInRange_isPreserved);
RUN_TEST(test_adminValidation_messageTooLong_isTruncatedAt100);
RUN_TEST(test_adminValidation_intervalTooLow_isClamped);
RUN_TEST(test_adminValidation_intervalTooHigh_isPreserved);
RUN_TEST(test_adminValidation_intervalZero_isNotClamped);
RUN_TEST(test_adminValidation_longFastOfferPreset_isPreserved);
RUN_TEST(test_adminValidation_validSave_invalidatesCache);
printf("\n=== Broadcaster payload cache ===\n");
RUN_TEST(test_broadcaster_rebuildCache_producesNonEmptyPayload);
RUN_TEST(test_broadcaster_rebuildCache_payloadDecodesCorrectly);
RUN_TEST(test_broadcaster_rebuildCache_offerFieldsEncoded);
RUN_TEST(test_broadcaster_invalidateCache_setsDirtyFlag);
RUN_TEST(test_broadcaster_rebuildCache_idempotent);
printf("\n=== Broadcaster sendBeacon ===\n");
RUN_TEST(test_broadcaster_sendBeacon_fromIsLocalNodeWhenUnset);
RUN_TEST(test_broadcaster_sendBeacon_addressedToBroadcast);
RUN_TEST(test_broadcaster_sendBeacon_usesBeaconPortnum);
RUN_TEST(test_broadcaster_sendBeacon_fallsBackToTextMessagePortnum);
RUN_TEST(test_broadcaster_sendBeacon_payloadDecodesCorrectly);
RUN_TEST(test_broadcaster_sendBeacon_offerOnly_isSent);
RUN_TEST(test_broadcaster_runOnce_sendsWhenEnabled);
RUN_TEST(test_broadcaster_runOnce_silentWhenDisabled);
printf("\n=== Listener offer caching ===\n");
RUN_TEST(test_listener_receiveWithOffer_cachesOffer);
RUN_TEST(test_listener_receiveWithChannelOffer_setsHasChannel);
RUN_TEST(test_listener_emptyMessageWithoutOffer_isDropped);
RUN_TEST(test_listener_offerOnly_isCached);
RUN_TEST(test_listener_nullBeacon_isDropped);
RUN_TEST(test_listener_receiveWithNoOffer_cacheStaysInvalid);
RUN_TEST(test_listener_textMessage_notUnwrapped);
RUN_TEST(test_listener_wantPacket_falseWhenDisabled);
RUN_TEST(test_listener_wantPacket_trueWhenEnabled);
printf("\n=== Legacy split messages ===\n");
RUN_TEST(test_broadcaster_legacySplit_sendsTwoPackets);
RUN_TEST(test_broadcaster_legacySplit_firstPacketIsBeaconApp);
RUN_TEST(test_broadcaster_legacySplit_firstPacketHasNoMessageText);
RUN_TEST(test_broadcaster_legacySplit_secondPacketIsTextMessage);
printf("\n=== Beacon-channel PSK swap ===\n");
RUN_TEST(test_broadcaster_noChannelOverride_doesNotSwapPrimary);
RUN_TEST(test_broadcaster_targetChannelIndex_usesTableSlot);
RUN_TEST(test_broadcaster_targetChannelIndex_blankSlotFallsBackToPreset);
RUN_TEST(test_broadcaster_duplicateTargets_dedupedToOnePacket);
RUN_TEST(test_broadcaster_distinctTargets_bothSent);
printf("\n=== Radio switch/restore re-entrancy ===\n");
RUN_TEST(test_beaconRestore_isNotReenteredByCompleteSending);
RUN_TEST(test_beaconSwitch_isNotUndoneByCompleteSending);
RUN_TEST(test_beaconRestore_withoutSwitch_isNoOp);
RUN_TEST(test_beaconRestore_deferredUntilPacketCompletes);
printf("\n=== MeshBeaconTxHook ===\n");
RUN_TEST(test_txHook_normalPacket_isSend);
RUN_TEST(test_txHook_beaconPacket_isDefer);
RUN_TEST(test_txHook_invalidTarget_isDrop);
RUN_TEST(test_txHook_unregistered_isNoOp);
RUN_TEST(test_txHook_untaggedPacketAheadOfQueuedBeacon_restoresHome);
exit(UNITY_END());
}
BEACON_TEST_ENTRY void loop() {}
#else // MESHTASTIC_EXCLUDE_BEACON
void setUp(void) {}
void tearDown(void) {}
BEACON_TEST_ENTRY void setup()
{
initializeTestEnvironment();
UNITY_BEGIN();
exit(UNITY_END());
}
BEACON_TEST_ENTRY void loop() {}
#endif