mirror of
https://github.com/meshtastic/firmware.git
synced 2026-08-01 10:58:30 -04:00
* 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.
1485 lines
64 KiB
C++
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
|