Files
firmware/test/test_phone_api_config_dump/test_main.cpp
T

724 lines
32 KiB
C++

// PhoneAPI::getFromRadio() config-dump sequence, asserted on decoded FromRadio protobufs: the
// order client apps depend on, the heartbeat preempt, SPECIAL_NONCE_ONLY_* jumps, mid-dump
// restart, and the post-complete drain reaching idle.
#include "MeshTypes.h"
#include "TestUtil.h"
#include <unity.h>
#include "Channels.h"
#include "CryptoEngine.h"
#include "MeshService.h"
#include "NodeDB.h"
#include "PhoneAPI.h"
#include "Router.h"
#include "mesh-pb-constants.h"
#include "meshtastic/admin.pb.h"
#include <cstdio>
#include <cstring>
#include <vector>
// File-scope flag in PhoneAPI.cpp: set by a client heartbeat, cleared by the queueStatus reply.
extern bool heartbeatReceived;
namespace
{
constexpr uint32_t FULL_DUMP_NONCE = 0x51C0FFEE;
constexpr uint32_t SECOND_NONCE = 0x0DDBA11;
constexpr NodeNum SEEDED_NODE_A = 0x00000A01;
constexpr NodeNum SEEDED_NODE_B = 0x00000A02;
constexpr unsigned MAX_IDLE_DRAIN_READS = 8; // replay phases left to drain after config_complete_id
constexpr unsigned NUM_SINGLETON_PREFIX = 5; // my_info, deviceuiConfig, own node_info, metadata, region_presets
constexpr unsigned NUM_CONFIG_MESSAGES = _meshtastic_AdminMessage_ConfigType_MAX + 1;
constexpr unsigned NUM_MODULE_CONFIG_MESSAGES = _meshtastic_AdminMessage_ModuleConfigType_MAX + 1;
// STATE_SEND_CONFIG iterates config_state over the AdminMessage ConfigType enum but emits
// Config oneof tags: a proto bump that grows one without the other makes a config message
// carry inner variant 0. The static_asserts turn that drift into a compile error here.
const pb_size_t kExpectedConfigVariants[] = {
meshtastic_Config_device_tag, meshtastic_Config_position_tag, meshtastic_Config_power_tag,
meshtastic_Config_network_tag, meshtastic_Config_display_tag, meshtastic_Config_lora_tag,
meshtastic_Config_bluetooth_tag, meshtastic_Config_security_tag, meshtastic_Config_sessionkey_tag,
meshtastic_Config_device_ui_tag,
};
static_assert(sizeof(kExpectedConfigVariants) / sizeof(kExpectedConfigVariants[0]) == NUM_CONFIG_MESSAGES,
"AdminMessage ConfigType enum and Config oneof diverged - update PhoneAPI's STATE_SEND_CONFIG and this list");
const pb_size_t kExpectedModuleConfigVariants[] = {
meshtastic_ModuleConfig_mqtt_tag,
meshtastic_ModuleConfig_serial_tag,
meshtastic_ModuleConfig_external_notification_tag,
meshtastic_ModuleConfig_store_forward_tag,
meshtastic_ModuleConfig_range_test_tag,
meshtastic_ModuleConfig_telemetry_tag,
meshtastic_ModuleConfig_canned_message_tag,
meshtastic_ModuleConfig_audio_tag,
meshtastic_ModuleConfig_remote_hardware_tag,
meshtastic_ModuleConfig_neighbor_info_tag,
meshtastic_ModuleConfig_ambient_lighting_tag,
meshtastic_ModuleConfig_detection_sensor_tag,
meshtastic_ModuleConfig_paxcounter_tag,
meshtastic_ModuleConfig_statusmessage_tag,
meshtastic_ModuleConfig_traffic_management_tag,
meshtastic_ModuleConfig_tak_tag,
#if !MESHTASTIC_EXCLUDE_BEACON
meshtastic_ModuleConfig_mesh_beacon_tag,
#else
0, // beacon compiled out: the slot still ships, as an empty ModuleConfig
#endif
};
static_assert(sizeof(kExpectedModuleConfigVariants) / sizeof(kExpectedModuleConfigVariants[0]) == NUM_MODULE_CONFIG_MESSAGES,
"AdminMessage ModuleConfigType enum and ModuleConfig oneof diverged - update STATE_SEND_MODULECONFIG and this "
"list");
/// PhoneAPI over a permanently-connected fake transport.
class PhoneAPITestShim : public PhoneAPI
{
public:
unsigned dataNotifications = 0; // transport wake-ups, i.e. "come and read"
protected:
bool checkIsConnected() override { return true; }
void onNowHasData(uint32_t fromRadioNum) override { dataNotifications++; }
};
/// Concrete Router with no radio interface: getQueueStatus() reports an all-zero queue.
class TestRouter : public Router
{
public:
// Router's ctor allocated the global cryptLock; nothing else frees it.
~TestRouter()
{
delete cryptLock;
cryptLock = nullptr;
}
};
// Saved-global fixture, template test_event_channel_phone_api. Restored in tearDown() rather
// than by RAII because a failed TEST_ASSERT longjmps out of the test without running destructors.
struct GlobalState {
MeshService *service;
Router *router;
NodeDB *nodeDB;
concurrency::Lock *cryptLock;
meshtastic_MyNodeInfo myNodeInfo;
Channels channels;
meshtastic_ChannelFile channelFile;
meshtastic_LocalConfig config;
meshtastic_LocalModuleConfig moduleConfig;
meshtastic_DeviceState deviceState;
};
GlobalState *savedState = nullptr;
MeshService *mockService = nullptr;
TestRouter *testRouter = nullptr;
NodeDB *testNodeDB = nullptr;
PhoneAPITestShim *api = nullptr;
/// Give every channel slot a distinct index so the dump's 0..7 ordering is observable.
void configureTestChannels()
{
channelFile = meshtastic_ChannelFile_init_default;
channelFile.channels_count = MAX_NUM_CHANNELS;
for (pb_size_t i = 0; i < MAX_NUM_CHANNELS; i++) {
channelFile.channels[i].index = (int8_t)i;
channelFile.channels[i].has_settings = true;
channelFile.channels[i].role = i == 0 ? meshtastic_Channel_Role_PRIMARY : meshtastic_Channel_Role_SECONDARY;
}
channels.onConfigChanged();
}
/// Create a remote node in the scratch NodeDB the way received traffic would.
void seedRemoteNode(NodeNum num)
{
meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero;
p.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
p.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP;
p.from = num;
p.to = NODENUM_BROADCAST;
nodeDB->updateFrom(p);
}
bool sendToRadio(const meshtastic_ToRadio &message)
{
uint8_t encoded[meshtastic_ToRadio_size] = {};
const size_t encodedSize =
pb_encode_to_bytes(encoded, sizeof(encoded), &meshtastic_ToRadio_msg, const_cast<meshtastic_ToRadio *>(&message));
TEST_ASSERT_GREATER_THAN_UINT(0, encodedSize);
return api->handleToRadio(encoded, encodedSize);
}
void startHandshake(uint32_t nonce)
{
meshtastic_ToRadio request = meshtastic_ToRadio_init_zero;
request.which_payload_variant = meshtastic_ToRadio_want_config_id_tag;
request.want_config_id = nonce;
sendToRadio(request);
}
void sendPlainHeartbeat()
{
meshtastic_ToRadio hb = meshtastic_ToRadio_init_zero;
hb.which_payload_variant = meshtastic_ToRadio_heartbeat_tag;
hb.heartbeat = meshtastic_Heartbeat_init_zero; // nonce 0 = plain keepalive, expects a queueStatus reply
sendToRadio(hb);
}
/// One decoded FromRadio pulled off the wire; zero-length reads return false.
bool readOneFromRadio(meshtastic_FromRadio &out)
{
uint8_t buf[meshtastic_FromRadio_size];
const size_t len = api->getFromRadio(buf);
if (len == 0)
return false;
out = meshtastic_FromRadio_init_zero;
TEST_ASSERT_TRUE_MESSAGE(pb_decode_from_bytes(buf, len, &meshtastic_FromRadio_msg, &out),
"device emitted an undecodable FromRadio");
return true;
}
/// Everything the dump emitted, in order, as decoded facts rather than internals.
struct DumpTranscript {
std::vector<pb_size_t> variants; // outer which_payload_variant per message
std::vector<pb_size_t> configVariants; // inner variant of each FromRadio.config
std::vector<pb_size_t> moduleConfigVariants; // inner variant of each FromRadio.moduleConfig
std::vector<int> channelIndices;
std::vector<uint32_t> nodeNums;
unsigned fileInfoCount = 0;
unsigned queueStatusCount = 0;
uint32_t completeId = 0;
bool sawComplete = false;
};
/// Pull messages until config_complete_id; false if the stream stalls or overruns the cap.
bool drainUntilComplete(DumpTranscript &t, unsigned maxMessages = 600)
{
for (unsigned i = 0; i < maxMessages; i++) {
meshtastic_FromRadio msg;
if (!readOneFromRadio(msg))
return false;
t.variants.push_back(msg.which_payload_variant);
switch (msg.which_payload_variant) {
case meshtastic_FromRadio_config_tag:
t.configVariants.push_back(msg.config.which_payload_variant);
break;
case meshtastic_FromRadio_moduleConfig_tag:
t.moduleConfigVariants.push_back(msg.moduleConfig.which_payload_variant);
break;
case meshtastic_FromRadio_channel_tag:
t.channelIndices.push_back(msg.channel.index);
break;
case meshtastic_FromRadio_node_info_tag:
t.nodeNums.push_back(msg.node_info.num);
break;
case meshtastic_FromRadio_fileInfo_tag:
t.fileInfoCount++;
break;
case meshtastic_FromRadio_queueStatus_tag:
t.queueStatusCount++;
break;
case meshtastic_FromRadio_config_complete_id_tag:
t.completeId = msg.config_complete_id;
t.sawComplete = true;
return true;
default:
break;
}
}
return false;
}
/// What the first region set does to a live node: a minted key moves my_node_num with it.
void mintIdentity()
{
config.security.public_key.size = 32;
memset(config.security.public_key.bytes, 0x5A, sizeof(config.security.public_key.bytes));
TEST_ASSERT_TRUE_MESSAGE(nodeDB->createNewIdentity(), "identity did not move");
}
/// Read until available() reports idle; false if it never does within the cap.
bool drainToIdle()
{
uint8_t buf[meshtastic_FromRadio_size];
for (unsigned i = 0; i < MAX_IDLE_DRAIN_READS; i++) {
if (!api->available())
return true;
api->getFromRadio(buf);
}
return false;
}
unsigned countVariant(const DumpTranscript &t, pb_size_t tag)
{
unsigned n = 0;
for (pb_size_t v : t.variants)
if (v == tag)
n++;
return n;
}
/// Assert the two non-self node records are the seeded pair (DB iteration order not pinned).
void assertSeededPair(uint32_t first, uint32_t second)
{
const bool inOrder = first == SEEDED_NODE_A && second == SEEDED_NODE_B;
const bool swapped = first == SEEDED_NODE_B && second == SEEDED_NODE_A;
TEST_ASSERT_TRUE_MESSAGE(inOrder || swapped, "other node_infos are not the seeded pair");
}
// --- Tests ---
// The full documented sequence, section by section, ending in the nonce echo. Also pins the
// channel section: exactly MAX_NUM_CHANNELS messages, indices 0..7 in order, between
// region_presets and the first config.
void test_full_want_config_dump_emits_documented_sequence()
{
seedRemoteNode(SEEDED_NODE_A);
seedRemoteNode(SEEDED_NODE_B);
startHandshake(FULL_DUMP_NONCE);
DumpTranscript t;
TEST_ASSERT_TRUE_MESSAGE(drainUntilComplete(t), "dump stalled before config_complete_id");
const pb_size_t expectedPrefix[NUM_SINGLETON_PREFIX] = {
meshtastic_FromRadio_my_info_tag, meshtastic_FromRadio_deviceuiConfig_tag, meshtastic_FromRadio_node_info_tag,
meshtastic_FromRadio_metadata_tag, meshtastic_FromRadio_region_presets_tag};
TEST_ASSERT_GREATER_OR_EQUAL_UINT(NUM_SINGLETON_PREFIX, t.variants.size());
for (unsigned i = 0; i < NUM_SINGLETON_PREFIX; i++)
TEST_ASSERT_EQUAL_UINT_MESSAGE(expectedPrefix[i], t.variants[i], "header sequence changed");
// Bound the raw indexing below: header + channels + configs + moduleConfigs + 2 seeded
// node_infos + complete is the minimum a full dump can be.
TEST_ASSERT_GREATER_OR_EQUAL_UINT(
NUM_SINGLETON_PREFIX + MAX_NUM_CHANNELS + NUM_CONFIG_MESSAGES + NUM_MODULE_CONFIG_MESSAGES + 3, t.variants.size());
// Channel section: contiguous, complete, ordered.
const size_t channelStart = NUM_SINGLETON_PREFIX;
TEST_ASSERT_EQUAL_UINT((unsigned)MAX_NUM_CHANNELS, t.channelIndices.size());
for (unsigned i = 0; i < MAX_NUM_CHANNELS; i++) {
TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_channel_tag, t.variants[channelStart + i]);
TEST_ASSERT_EQUAL_INT_MESSAGE((int)i, t.channelIndices[i], "channels must arrive as indices 0..7 in order");
}
const size_t configStart = channelStart + MAX_NUM_CHANNELS;
for (unsigned i = 0; i < NUM_CONFIG_MESSAGES; i++)
TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_config_tag, t.variants[configStart + i]);
const size_t moduleStart = configStart + NUM_CONFIG_MESSAGES;
for (unsigned i = 0; i < NUM_MODULE_CONFIG_MESSAGES; i++)
TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_moduleConfig_tag, t.variants[moduleStart + i]);
// Other node_infos follow the module configs; the own record was already sent in the header.
const size_t nodesStart = moduleStart + NUM_MODULE_CONFIG_MESSAGES;
TEST_ASSERT_EQUAL_UINT(3, t.nodeNums.size());
TEST_ASSERT_EQUAL_UINT32(nodeDB->getNodeNum(), t.nodeNums[0]);
assertSeededPair(t.nodeNums[1], t.nodeNums[2]);
TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_node_info_tag, t.variants[nodesStart]);
TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_node_info_tag, t.variants[nodesStart + 1]);
// Everything between the node_infos and the completion id is file manifest (count is
// whatever the sandbox filesystem holds, so only the position is asserted).
for (size_t i = nodesStart + 2; i + 1 < t.variants.size(); i++)
TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_fileInfo_tag, t.variants[i]);
TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_config_complete_id_tag, t.variants.back());
TEST_ASSERT_EQUAL_UINT32_MESSAGE(FULL_DUMP_NONCE, t.completeId, "config_complete_id must echo the request nonce");
// Singletons exactly once, and no stray preempts.
TEST_ASSERT_EQUAL_UINT(1, countVariant(t, meshtastic_FromRadio_my_info_tag));
TEST_ASSERT_EQUAL_UINT(1, countVariant(t, meshtastic_FromRadio_deviceuiConfig_tag));
TEST_ASSERT_EQUAL_UINT(1, countVariant(t, meshtastic_FromRadio_metadata_tag));
TEST_ASSERT_EQUAL_UINT(1, countVariant(t, meshtastic_FromRadio_region_presets_tag));
TEST_ASSERT_EQUAL_UINT(1, countVariant(t, meshtastic_FromRadio_config_complete_id_tag));
TEST_ASSERT_EQUAL_UINT(0, t.queueStatusCount);
TEST_ASSERT_EQUAL_UINT(NUM_SINGLETON_PREFIX + MAX_NUM_CHANNELS + NUM_CONFIG_MESSAGES + NUM_MODULE_CONFIG_MESSAGES + 2 +
t.fileInfoCount + 1,
t.variants.size());
}
// Guards the ConfigType-enum-to-oneof-tag iteration: a desync emits a config message whose
// inner variant is 0, which every phone app decodes as an empty Config.
void test_config_section_inner_variants_match_config_type_enum()
{
startHandshake(FULL_DUMP_NONCE);
DumpTranscript t;
TEST_ASSERT_TRUE(drainUntilComplete(t));
TEST_ASSERT_EQUAL_UINT(NUM_CONFIG_MESSAGES, t.configVariants.size());
for (unsigned i = 0; i < NUM_CONFIG_MESSAGES; i++) {
TEST_ASSERT_NOT_EQUAL_MESSAGE(0, t.configVariants[i],
"config with inner variant 0: ConfigType enum drifted from the Config oneof");
TEST_ASSERT_EQUAL_UINT(kExpectedConfigVariants[i], t.configVariants[i]);
}
}
// Same closed-set guard for the module config section (the drift class already happened once,
// for statusmessage).
void test_module_config_section_inner_variants_match_module_config_type_enum()
{
startHandshake(FULL_DUMP_NONCE);
DumpTranscript t;
TEST_ASSERT_TRUE(drainUntilComplete(t));
TEST_ASSERT_EQUAL_UINT(NUM_MODULE_CONFIG_MESSAGES, t.moduleConfigVariants.size());
for (unsigned i = 0; i < NUM_MODULE_CONFIG_MESSAGES; i++) {
if (kExpectedModuleConfigVariants[i] != 0)
TEST_ASSERT_NOT_EQUAL_MESSAGE(
0, t.moduleConfigVariants[i],
"moduleConfig with inner variant 0: ModuleConfigType enum drifted from the ModuleConfig oneof");
TEST_ASSERT_EQUAL_UINT(kExpectedModuleConfigVariants[i], t.moduleConfigVariants[i]);
}
}
// SPECIAL_NONCE_ONLY_NODES jumps straight to the node stream: own record, others, completion -
// no headers, channels, configs, or manifest.
void test_only_nodes_nonce_sends_nodes_then_complete()
{
seedRemoteNode(SEEDED_NODE_A);
seedRemoteNode(SEEDED_NODE_B);
startHandshake(SPECIAL_NONCE_ONLY_NODES);
DumpTranscript t;
TEST_ASSERT_TRUE(drainUntilComplete(t));
TEST_ASSERT_EQUAL_UINT(4, t.variants.size()); // own + 2 seeded + complete
TEST_ASSERT_EQUAL_UINT(3, t.nodeNums.size());
TEST_ASSERT_EQUAL_UINT32(nodeDB->getNodeNum(), t.nodeNums[0]);
assertSeededPair(t.nodeNums[1], t.nodeNums[2]);
TEST_ASSERT_EQUAL_UINT32(SPECIAL_NONCE_ONLY_NODES, t.completeId);
TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_my_info_tag));
TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_deviceuiConfig_tag));
TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_metadata_tag));
TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_region_presets_tag));
TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_channel_tag));
TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_config_tag));
TEST_ASSERT_EQUAL_UINT(0, countVariant(t, meshtastic_FromRadio_moduleConfig_tag));
TEST_ASSERT_EQUAL_UINT(0, t.fileInfoCount);
// This nonce skips my_info entirely, so the trailing replay must not produce one either.
for (unsigned i = 0; i < MAX_IDLE_DRAIN_READS && api->available(); i++) {
meshtastic_FromRadio trailing;
if (readOneFromRadio(trailing))
TEST_ASSERT_NOT_EQUAL_MESSAGE(meshtastic_FromRadio_my_info_tag, trailing.which_payload_variant,
"nodes-only sync must not emit my_info");
}
}
// SPECIAL_NONCE_ONLY_CONFIG delivers the full config but skips the non-self node DB, and must
// not arm the post-complete satellite replay.
void test_only_config_nonce_skips_other_nodeinfos()
{
seedRemoteNode(SEEDED_NODE_A);
seedRemoteNode(SEEDED_NODE_B);
startHandshake(SPECIAL_NONCE_ONLY_CONFIG);
DumpTranscript t;
TEST_ASSERT_TRUE(drainUntilComplete(t));
TEST_ASSERT_EQUAL_UINT(1, countVariant(t, meshtastic_FromRadio_node_info_tag)); // own record only
TEST_ASSERT_EQUAL_UINT(1, t.nodeNums.size());
TEST_ASSERT_EQUAL_UINT32(nodeDB->getNodeNum(), t.nodeNums[0]);
TEST_ASSERT_EQUAL_UINT((unsigned)MAX_NUM_CHANNELS, countVariant(t, meshtastic_FromRadio_channel_tag));
TEST_ASSERT_EQUAL_UINT(NUM_CONFIG_MESSAGES, t.configVariants.size());
TEST_ASSERT_EQUAL_UINT(NUM_MODULE_CONFIG_MESSAGES, t.moduleConfigVariants.size());
TEST_ASSERT_EQUAL_UINT32(SPECIAL_NONCE_ONLY_CONFIG, t.completeId);
// ONLY_CONFIG skips node/satellite sync entirely: the stream must be idle immediately.
uint8_t buf[meshtastic_FromRadio_size];
TEST_ASSERT_EQUAL_UINT(0, api->getFromRadio(buf));
TEST_ASSERT_FALSE(api->available());
}
// A keepalive heartbeat mid-dump preempts exactly one read with a queueStatus, then the dump
// resumes where it left off; the flag self-clears so nothing repeats or restarts.
void test_heartbeat_mid_dump_preempts_once_then_resumes()
{
startHandshake(FULL_DUMP_NONCE);
// Pull the first three header messages, leaving the machine about to send metadata.
meshtastic_FromRadio msg;
TEST_ASSERT_TRUE(readOneFromRadio(msg));
TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_my_info_tag, msg.which_payload_variant);
TEST_ASSERT_TRUE(readOneFromRadio(msg));
TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_deviceuiConfig_tag, msg.which_payload_variant);
TEST_ASSERT_TRUE(readOneFromRadio(msg));
TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_node_info_tag, msg.which_payload_variant);
sendPlainHeartbeat();
TEST_ASSERT_TRUE(readOneFromRadio(msg));
TEST_ASSERT_EQUAL_UINT_MESSAGE(meshtastic_FromRadio_queueStatus_tag, msg.which_payload_variant,
"heartbeat must be answered with a queueStatus before the dump continues");
TEST_ASSERT_TRUE(readOneFromRadio(msg));
TEST_ASSERT_EQUAL_UINT_MESSAGE(meshtastic_FromRadio_metadata_tag, msg.which_payload_variant,
"dump must resume exactly where the heartbeat preempted it");
DumpTranscript rest;
TEST_ASSERT_TRUE(drainUntilComplete(rest));
TEST_ASSERT_EQUAL_UINT_MESSAGE(0, rest.queueStatusCount, "heartbeat flag must self-clear after one reply");
TEST_ASSERT_EQUAL_UINT_MESSAGE(0, countVariant(rest, meshtastic_FromRadio_my_info_tag),
"heartbeat must not restart the dump");
TEST_ASSERT_EQUAL_UINT32(FULL_DUMP_NONCE, rest.completeId);
}
// Disconnect mid-dump, then a fresh handshake: the machine restarts from my_info with the new
// nonce and every section is delivered exactly once.
void test_close_mid_dump_then_reconnect_restarts_clean()
{
seedRemoteNode(SEEDED_NODE_A);
startHandshake(FULL_DUMP_NONCE);
meshtastic_FromRadio msg;
for (unsigned i = 0; i < 5; i++)
TEST_ASSERT_TRUE(readOneFromRadio(msg));
api->close();
TEST_ASSERT_FALSE(api->isConnected());
uint8_t buf[meshtastic_FromRadio_size];
TEST_ASSERT_EQUAL_UINT_MESSAGE(0, api->getFromRadio(buf), "a closed connection must emit nothing");
startHandshake(SECOND_NONCE);
DumpTranscript t;
TEST_ASSERT_TRUE(drainUntilComplete(t));
TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_my_info_tag, t.variants[0]);
TEST_ASSERT_EQUAL_UINT((unsigned)MAX_NUM_CHANNELS, t.channelIndices.size());
TEST_ASSERT_EQUAL_UINT(NUM_CONFIG_MESSAGES, t.configVariants.size());
TEST_ASSERT_EQUAL_UINT(NUM_MODULE_CONFIG_MESSAGES, t.moduleConfigVariants.size());
TEST_ASSERT_EQUAL_UINT(1, countVariant(t, meshtastic_FromRadio_config_complete_id_tag));
TEST_ASSERT_EQUAL_UINT32(SECOND_NONCE, t.completeId);
}
// A new want_config while a dump is in flight (no disconnect) also restarts the machine, and
// stale mid-section progress must not leak into the new dump.
void test_rehandshake_mid_dump_restarts_from_my_info()
{
startHandshake(FULL_DUMP_NONCE);
// Read into the middle of the config section (5 headers + 8 channels + 7 configs).
meshtastic_FromRadio msg;
for (unsigned i = 0; i < NUM_SINGLETON_PREFIX + MAX_NUM_CHANNELS + 7; i++)
TEST_ASSERT_TRUE(readOneFromRadio(msg));
startHandshake(SECOND_NONCE);
DumpTranscript t;
TEST_ASSERT_TRUE(drainUntilComplete(t));
TEST_ASSERT_EQUAL_UINT_MESSAGE(meshtastic_FromRadio_my_info_tag, t.variants[0], "re-handshake must restart from my_info");
TEST_ASSERT_EQUAL_UINT((unsigned)MAX_NUM_CHANNELS, t.channelIndices.size());
for (unsigned i = 0; i < MAX_NUM_CHANNELS; i++)
TEST_ASSERT_EQUAL_INT((int)i, t.channelIndices[i]);
TEST_ASSERT_EQUAL_UINT_MESSAGE(NUM_CONFIG_MESSAGES, t.configVariants.size(),
"stale config_state leaked into the restarted dump");
TEST_ASSERT_EQUAL_UINT(NUM_MODULE_CONFIG_MESSAGES, t.moduleConfigVariants.size());
TEST_ASSERT_EQUAL_UINT32(SECOND_NONCE, t.completeId);
}
// After config_complete_id the trailing satellite replay must reach idle in bounded reads - a
// drain loop keyed on available() must terminate (the infinite-drain regression class).
void test_dump_reaches_idle_after_complete()
{
seedRemoteNode(SEEDED_NODE_A);
seedRemoteNode(SEEDED_NODE_B);
startHandshake(FULL_DUMP_NONCE);
DumpTranscript t;
TEST_ASSERT_TRUE(drainUntilComplete(t));
TEST_ASSERT_TRUE_MESSAGE(drainToIdle(), "post-complete drain never went idle: available() stuck true");
uint8_t buf[meshtastic_FromRadio_size];
TEST_ASSERT_EQUAL_UINT(0, api->getFromRadio(buf));
}
// A packet queued while the client is still consuming config is not announced by onNotify().
// Once config completes, re-arm the transport so the queued packet cannot sleep until later traffic.
void test_packet_queued_mid_sync_is_announced_after_complete()
{
startHandshake(FULL_DUMP_NONCE);
api->dataNotifications = 0;
meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero;
packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
packet.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP;
packet.from = SEEDED_NODE_A;
packet.to = NODENUM_BROADCAST;
packet.id = 0xA11CE001;
meshtastic_MeshPacket *queued = packetPool.allocCopy(packet);
TEST_ASSERT_NOT_NULL(queued);
service->sendToPhone(queued);
service->loop();
TEST_ASSERT_EQUAL_UINT_MESSAGE(0, api->dataNotifications, "mid-sync packet should not interrupt the config dump");
DumpTranscript t;
TEST_ASSERT_TRUE(drainUntilComplete(t));
const unsigned notificationsAtComplete = api->dataNotifications;
service->loop();
TEST_ASSERT_GREATER_THAN_UINT_MESSAGE(notificationsAtComplete, api->dataNotifications,
"config completion must re-announce packets queued during sync");
meshtastic_FromRadio msg;
TEST_ASSERT_TRUE(readOneFromRadio(msg));
TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_packet_tag, msg.which_payload_variant);
TEST_ASSERT_EQUAL_UINT32(packet.id, msg.packet.id);
}
// The first region set moves my_node_num live (NodeDB::createNewIdentity()) with no reboot to force a
// re-handshake, so the stream must re-announce my_info - exactly once - on its own.
void test_node_num_change_resends_my_info()
{
startHandshake(FULL_DUMP_NONCE);
DumpTranscript t;
TEST_ASSERT_TRUE(drainUntilComplete(t));
TEST_ASSERT_TRUE_MESSAGE(drainToIdle(), "post-complete drain never went idle");
const uint32_t handshakeNodeNum = nodeDB->getNodeNum();
const unsigned notificationsBefore = api->dataNotifications;
mintIdentity();
TEST_ASSERT_NOT_EQUAL_MESSAGE(handshakeNodeNum, nodeDB->getNodeNum(), "node num did not actually change");
service->loop(); // delivers the fromNum notify that arms the re-announce
TEST_ASSERT_GREATER_THAN_UINT_MESSAGE(notificationsBefore, api->dataNotifications,
"transport was never woken to come and read");
meshtastic_FromRadio msg;
TEST_ASSERT_TRUE(readOneFromRadio(msg));
TEST_ASSERT_EQUAL_UINT_MESSAGE(meshtastic_FromRadio_my_info_tag, msg.which_payload_variant,
"a renumber must be announced as my_info");
TEST_ASSERT_EQUAL_UINT32(nodeDB->getNodeNum(), msg.my_info.my_node_num);
// One-shot: the stream falls back to live traffic and nothing repeats.
TEST_ASSERT_FALSE_MESSAGE(api->available(), "my_info resend repeated after the client was told");
}
// The same move landing mid-sync: my_info is already out with the old number and there is no
// steady state to fall back from, so the dump restarts and carries the new one.
void test_node_num_change_mid_dump_restarts_sync()
{
startHandshake(FULL_DUMP_NONCE);
meshtastic_FromRadio msg;
for (unsigned i = 0; i < NUM_SINGLETON_PREFIX + MAX_NUM_CHANNELS; i++)
TEST_ASSERT_TRUE(readOneFromRadio(msg)); // through the channels, my_info long since sent
mintIdentity();
service->loop();
TEST_ASSERT_TRUE(readOneFromRadio(msg));
TEST_ASSERT_EQUAL_UINT_MESSAGE(meshtastic_FromRadio_my_info_tag, msg.which_payload_variant,
"a mid-sync renumber must restart the dump");
TEST_ASSERT_EQUAL_UINT32(nodeDB->getNodeNum(), msg.my_info.my_node_num);
// Everything after the my_info already read must arrive again, in order and complete.
DumpTranscript t;
TEST_ASSERT_TRUE_MESSAGE(drainUntilComplete(t), "restarted dump never completed");
const pb_size_t expectedPrefix[] = {meshtastic_FromRadio_deviceuiConfig_tag, meshtastic_FromRadio_node_info_tag,
meshtastic_FromRadio_metadata_tag, meshtastic_FromRadio_region_presets_tag};
TEST_ASSERT_EQUAL_UINT(NUM_SINGLETON_PREFIX - 1, sizeof(expectedPrefix) / sizeof(expectedPrefix[0]));
for (unsigned i = 0; i < NUM_SINGLETON_PREFIX - 1; i++)
TEST_ASSERT_EQUAL_UINT_MESSAGE(expectedPrefix[i], t.variants[i], "restarted dump changed the header sequence");
TEST_ASSERT_EQUAL_UINT((unsigned)MAX_NUM_CHANNELS, t.channelIndices.size());
TEST_ASSERT_EQUAL_UINT_MESSAGE(NUM_CONFIG_MESSAGES, t.configVariants.size(), "restarted dump lost part of the config");
TEST_ASSERT_EQUAL_UINT(NUM_MODULE_CONFIG_MESSAGES, t.moduleConfigVariants.size());
TEST_ASSERT_EQUAL_UINT(1, t.nodeNums.size()); // our own record; this test seeds no remotes
TEST_ASSERT_EQUAL_UINT32(nodeDB->getNodeNum(), t.nodeNums[0]);
TEST_ASSERT_EQUAL_UINT32(FULL_DUMP_NONCE, t.completeId);
}
// Same move during a nodes-only sync: the self record it already sent is gone from the DB, so that
// dump restarts too, even though this nonce never carries a my_info to be stale.
void test_node_num_change_mid_nodes_only_restarts_sync()
{
seedRemoteNode(SEEDED_NODE_A);
startHandshake(SPECIAL_NONCE_ONLY_NODES);
meshtastic_FromRadio msg;
TEST_ASSERT_TRUE(readOneFromRadio(msg));
TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_node_info_tag, msg.which_payload_variant);
const uint32_t staleSelf = msg.node_info.num;
mintIdentity();
service->loop();
DumpTranscript t;
TEST_ASSERT_TRUE_MESSAGE(drainUntilComplete(t), "restarted nodes-only dump never completed");
TEST_ASSERT_EQUAL_UINT_MESSAGE(nodeDB->getNumMeshNodes(), t.nodeNums.size(), "restart must resend every node");
TEST_ASSERT_NOT_EQUAL_MESSAGE(staleSelf, t.nodeNums[0], "restart must carry the new self record");
TEST_ASSERT_EQUAL_UINT32(nodeDB->getNodeNum(), t.nodeNums[0]);
TEST_ASSERT_EQUAL_UINT32(SPECIAL_NONCE_ONLY_NODES, t.completeId);
}
} // namespace
void setUp(void)
{
savedState =
new GlobalState{service, router, nodeDB, cryptLock, myNodeInfo, channels, channelFile, config, moduleConfig, devicestate};
service = mockService = new MeshService();
// A real boot starts with a zeroed nodeDatabase; in-process the global retains the previous
// test's vector (the decode callback appends, it does not clear), so reset it first.
nodeDatabase.version = 0;
nodeDatabase.nodes.clear();
nodeDB = testNodeDB = new NodeDB();
configureTestChannels();
cryptLock = nullptr; // Router's ctor asserts this is unset before allocating its own.
router = testRouter = new TestRouter();
api = new PhoneAPITestShim();
heartbeatReceived = false;
}
void tearDown(void)
{
delete api; // dtor runs close(), which still needs the mock service installed
api = nullptr;
delete testRouter; // ~TestRouter() deletes the cryptLock its ctor allocated
testRouter = nullptr;
delete testNodeDB;
testNodeDB = nullptr;
delete mockService;
mockService = nullptr;
heartbeatReceived = false;
service = savedState->service;
router = savedState->router;
nodeDB = savedState->nodeDB;
cryptLock = savedState->cryptLock; // ~TestRouter() nulled it; hand the saved router its own back
myNodeInfo = savedState->myNodeInfo;
channels = savedState->channels;
channelFile = savedState->channelFile;
config = savedState->config;
moduleConfig = savedState->moduleConfig;
devicestate = savedState->deviceState;
delete savedState;
savedState = nullptr;
}
void setup()
{
initializeTestEnvironment();
UNITY_BEGIN();
printf("\n=== want_config dump sequence ===\n");
RUN_TEST(test_full_want_config_dump_emits_documented_sequence);
RUN_TEST(test_config_section_inner_variants_match_config_type_enum);
RUN_TEST(test_module_config_section_inner_variants_match_module_config_type_enum);
printf("\n=== special nonces ===\n");
RUN_TEST(test_only_nodes_nonce_sends_nodes_then_complete);
RUN_TEST(test_only_config_nonce_skips_other_nodeinfos);
printf("\n=== preemption and restart ===\n");
RUN_TEST(test_heartbeat_mid_dump_preempts_once_then_resumes);
RUN_TEST(test_close_mid_dump_then_reconnect_restarts_clean);
RUN_TEST(test_rehandshake_mid_dump_restarts_from_my_info);
RUN_TEST(test_dump_reaches_idle_after_complete);
RUN_TEST(test_packet_queued_mid_sync_is_announced_after_complete);
RUN_TEST(test_node_num_change_resends_my_info);
RUN_TEST(test_node_num_change_mid_dump_restarts_sync);
RUN_TEST(test_node_num_change_mid_nodes_only_restarts_sync);
exit(UNITY_END());
}
void loop() {}