Files
firmware/test/test_mesh_beacon/test_main.cpp
Ben Meadors c5355641d3 Add MEDIUM_TURBO modem preset (#10988)
* Protobufs

* Wire up MEDIUM_TURBO modem preset

MEDIUM_TURBO (500 kHz, SF9, CR 4/5) already existed in the protobuf enum but
was never wired into firmware, so selecting it silently fell through to the
LONG_FAST default and rendered an "Invalid" display name.

Add its bw/sf/cr mapping (modemPresetToParams), display name (MediumTurbo/MedT),
PRESETS_STD membership (standard regions only — 500 kHz does not fit EU868's
250 kHz band, so it stays out of PRESETS_EU_868 and is rejected/clamped there),
and the MEDIUM SNR-grading bucket. Includes positive coverage in test_radio,
EU868-reject + US-accept coverage in test_admin_radio and test_mesh_beacon,
the STD preset count 9->10, an extended fuzz range, and the client-spec doc.

* Address review feedback on MEDIUM_TURBO tests

- test_mesh_beacon: assert has_mesh_beacon before checking the invalid preset was
  cleared, so the EU868-cleared test can't pass on a dropped message (matches the
  existing SHORT_TURBO test).
- test_fuzz_packets: draw modem presets from _ModemPreset_ARRAYSIZE instead of a
  hard-coded 17 so the fuzz range tracks future enum additions automatically.
2026-07-11 08:24:35 -05:00

1485 lines
64 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.has_broadcast_on_preset = true;
bcfg.broadcast_on_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.has_broadcast_on_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.has_broadcast_on_preset = true;
bcfg.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO;
testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg));
TEST_ASSERT_FALSE(moduleConfig.mesh_beacon.has_broadcast_on_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.has_broadcast_on_preset = true;
bcfg.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO;
testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg));
TEST_ASSERT_TRUE(moduleConfig.mesh_beacon.has_broadcast_on_preset);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, moduleConfig.mesh_beacon.broadcast_on_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.has_broadcast_on_preset = true;
bcfg.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO;
testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg));
TEST_ASSERT_TRUE(moduleConfig.has_mesh_beacon);
TEST_ASSERT_FALSE(moduleConfig.mesh_beacon.has_broadcast_on_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.has_broadcast_on_preset = true;
bcfg.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO;
testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg));
TEST_ASSERT_TRUE(moduleConfig.mesh_beacon.has_broadcast_on_preset);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO, moduleConfig.mesh_beacon.broadcast_on_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 broadcast_targets entries are validated independently of the single-target
* broadcast_on_* fields, and 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 multi-target entry's region clears that entry's preset
* (and its channel), matching the single-target broadcast_on_preset rule.
*/
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 defaults to the local node number when broadcast_send_as_node is 0.
* 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;
moduleConfig.mesh_beacon.broadcast_send_as_node = 0;
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 broadcast_send_as_node is currently disabled: 'from' is always the local node
* even when broadcast_send_as_node is set to a remote node number.
* (broadcast_send_as_node is commented out as "not suitable right now".)
*/
static void test_broadcaster_sendBeacon_fromIsCustomNodeWhenSet(void)
{
resetConfig();
moduleConfig.has_mesh_beacon = true;
moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_BROADCAST_ENABLED;
moduleConfig.mesh_beacon.broadcast_send_as_node = kRemoteNode;
strncpy(moduleConfig.mesh_beacon.broadcast_message, "from-remote", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1);
MeshBeaconBroadcastModuleTestShim bcast;
bcast.sendBeacon();
TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size());
// broadcast_send_as_node is disabled; from is always the local node
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
* broadcast_on_preset is set (that field 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);
// broadcast_on_preset set, but no offer - should still be TEXT_MESSAGE_APP
moduleConfig.mesh_beacon.has_broadcast_on_preset = true;
moduleConfig.mesh_beacon.broadcast_on_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 (broadcast_on_channel override)
// ===========================================================================
/**
* When broadcast_on_channel overrides the primary channel's name/PSK, the packet must be encrypted
* on the BEACON channel, not the primary. perhapsEncode keys off the primary slot, so sendBeaconPacket
* temporarily installs the beacon channel there for the send and restores it after. Verify both: the
* primary slot IS the beacon channel during send(), and it is restored afterwards (no leak).
*/
static void test_broadcaster_channelPskOverride_swapsBeaconChannelAndRestores(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; // gives the beacon radio content to send
moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW;
moduleConfig.mesh_beacon.has_broadcast_on_channel = true;
strncpy(moduleConfig.mesh_beacon.broadcast_on_channel.name, "BeaconCh",
sizeof(moduleConfig.mesh_beacon.broadcast_on_channel.name) - 1);
static const uint8_t beaconPsk[16] = {0xBB, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17,
0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f};
moduleConfig.mesh_beacon.broadcast_on_channel.psk.size = sizeof(beaconPsk);
memcpy(moduleConfig.mesh_beacon.broadcast_on_channel.psk.bytes, beaconPsk, sizeof(beaconPsk));
MeshBeaconBroadcastModuleTestShim bcast;
bcast.sendBeacon();
// During send(), the primary slot must hold the BEACON channel (so encryption uses its PSK).
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("BeaconCh", atSend.name, "primary must be the beacon channel during send");
TEST_ASSERT_EQUAL_UINT(sizeof(beaconPsk), atSend.psk.size);
TEST_ASSERT_EQUAL_UINT8_MESSAGE(0xBB, atSend.psk.bytes[0], "encryption must use the beacon channel PSK");
// After send(), the primary channel must be restored to the original (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]);
}
/**
* Without a broadcast_on_channel override, 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 broadcast_on_channel override.
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");
TEST_ASSERT_EQUAL_STRING_MESSAGE("BeaconNet", mockRouter->primaryAtSend[0].name,
"beacon must be encrypted on the referenced slot's channel");
// Primary slot restored to home after send (no leak).
TEST_ASSERT_EQUAL_STRING("Home", channels.getByIndex(channels.getPrimaryIndex()).settings.name);
}
/**
* 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");
}
} // 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_fromIsCustomNodeWhenSet);
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_channelPskOverride_swapsBeaconChannelAndRestores);
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);
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