Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,8 @@ Unit tests in `test/` directory. The canonical suite count is in `test/native-su
- `test_radio/` - Radio interface
- `test_rtc/` - RTC / time handling
- `test_serial/` - Serial communication
- `test_tak_config/` - TAK (ATAK) team/role value fidelity through set/save/load/get
- `test_module_config/` - every ModuleConfig submessage survives admin set -> save -> load -> get
- `test_traffic_management/` - Traffic management (dedup, rate-limit, hop-trim, role exceptions)
- `test_transmit_history/` - Retransmission tracking
- `test_type_conversions/` - NodeDB v25 type conversion (bitfield round-trips, NodeInfoLite)
Expand Down
1 change: 1 addition & 0 deletions src/mesh/NodeDB.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3008,6 +3008,7 @@ bool NodeDB::saveToDiskNoRetry(int saveWhat)
moduleConfig.has_audio = true;
moduleConfig.has_paxcounter = true;
moduleConfig.has_statusmessage = true;
moduleConfig.has_tak = true;
#if !MESHTASTIC_EXCLUDE_BEACON
moduleConfig.has_mesh_beacon = true;
#endif
Expand Down
10 changes: 10 additions & 0 deletions src/modules/AdminModule.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1299,6 +1299,11 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c)
moduleConfig.has_traffic_management = true;
moduleConfig.traffic_management = c.payload_variant.traffic_management;
break;
case meshtastic_ModuleConfig_tak_tag:
LOG_INFO("Set module config: TAK");
moduleConfig.has_tak = true;
moduleConfig.tak = c.payload_variant.tak;
break;
#if !MESHTASTIC_EXCLUDE_BEACON
case meshtastic_ModuleConfig_mesh_beacon_tag: {
LOG_INFO("Set module config: MeshBeacon");
Expand Down Expand Up @@ -1617,6 +1622,11 @@ void AdminModule::handleGetModuleConfig(const meshtastic_MeshPacket &req, const
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_traffic_management_tag;
res.get_module_config_response.payload_variant.traffic_management = moduleConfig.traffic_management;
break;
case meshtastic_AdminMessage_ModuleConfigType_TAK_CONFIG:
configName = "TAK";
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_tak_tag;
res.get_module_config_response.payload_variant.tak = moduleConfig.tak;
break;
#if !MESHTASTIC_EXCLUDE_BEACON
case meshtastic_AdminMessage_ModuleConfigType_MESHBEACON_CONFIG:
configName = "MeshBeacon";
Expand Down
2 changes: 2 additions & 0 deletions test/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,8 @@ A well-structured test suite follows this pattern:
| `test_position_precision` | Position precision helpers |
| `test_radio` | Radio interface |
| `test_serial` | Serial communication |
| `test_module_config` | AdminModule module config |
| `test_tak_config` | TAK (ATAK) team/role values |
| `test_traffic_management` | Traffic management |
| `test_transmit_history` | Retransmission tracking |
| `test_type_conversions` | NodeDB v25 type conversions |
Expand Down
2 changes: 1 addition & 1 deletion test/native-suite-count
Original file line number Diff line number Diff line change
@@ -1 +1 @@
38
40
195 changes: 195 additions & 0 deletions test/test_module_config/test_main.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// AdminModule module-config lifecycle coverage: every submessage must survive set -> save -> load -> get.
//
// Motivated by firmware#11182 / Meshtastic-Android#6430 (TAK team/role never persisted): a submessage
// with no case in handleSetModuleConfig() falls through the switch silently - the setter still ACKs
// and reboots - and one missing has_* flag in saveToDisk() makes nanopb drop the struct on the flash
// write. Both defects are invisible to per-module tests until someone hits them in the field, so the
// sweeps below enumerate the generated constants and put each type through the full lifecycle.
//
// The sweeps assert presence and routing, not field semantics - per-module value validation belongs
// to the per-module suites (test_mesh_beacon, test_admin_session_repro, ...).

#include "MeshTypes.h" // include BEFORE TestUtil.h
#include "TestUtil.h"
#include <unity.h>

#include "mesh/NodeDB.h"
#include "mesh/mesh-pb-constants.h"
#include "modules/AdminModule.h"
#include "support/AdminModuleTestShim.h"
#include "support/MockMeshService.h"
#include <cstdio>
#include <cstring>

static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A;
static constexpr NodeNum ADMIN_NODE = 0x0B0B0B0B;

static MockMeshService *mockService = nullptr;
static AdminModuleTestShim *admin = nullptr;

// Both generated sets are contiguous: ModuleConfigType runs 0.._MAX and the matching ModuleConfig
// submessage tags run 1.._MAX+1, so type + 1 == tag and no lookup table is needed.
static const uint32_t moduleConfigTypeCount = _meshtastic_AdminMessage_ModuleConfigType_MAX + 1;

// The submessages whose handlers are compiled out on some builds. Everything else must be present
// unconditionally.
static bool moduleConfigIsCompiledOut(pb_size_t tag)
{
(void)tag;
#if MESHTASTIC_EXCLUDE_MQTT
if (tag == meshtastic_ModuleConfig_mqtt_tag)
return true;
#endif
#if MESHTASTIC_EXCLUDE_BEACON
if (tag == meshtastic_ModuleConfig_mesh_beacon_tag)
return true;
#endif
return false;
}

// Unity assertion messages are plain strings, so name the failing iteration by number - nanopb keeps
// no field names to print. Cross-reference module_config.pb.h / admin.pb.h.
static const char *describeTag(const char *what, pb_size_t tag)
{
static char buf[96];
snprintf(buf, sizeof(buf), "%s: ModuleConfig submessage tag %u", what, (unsigned)tag);
return buf;
}

static meshtastic_MeshPacket makeGetRequest(NodeNum from)
{
meshtastic_MeshPacket req = meshtastic_MeshPacket_init_zero;
req.from = from;
req.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
req.decoded.want_response = true;
return req;
}

// Read back the variant tag of whatever get_module_config response the handler queued.
static bool decodeResponseTagFromReply(meshtastic_MeshPacket *reply, pb_size_t &out)
{
meshtastic_AdminMessage am = meshtastic_AdminMessage_init_zero;
if (!reply || reply->which_payload_variant != meshtastic_MeshPacket_decoded_tag)
return false;
if (!pb_decode_from_bytes(reply->decoded.payload.bytes, reply->decoded.payload.size, &meshtastic_AdminMessage_msg, &am))
return false;
if (am.which_payload_variant != meshtastic_AdminMessage_get_module_config_response_tag)
return false;
out = am.get_module_config_response.which_payload_variant;
return true;
}

// The proto guarantees the two enumerations stay in step; the sweeps below rely on it to pair a
// ModuleConfigType with its submessage tag without a lookup table.
void test_tagRangeMatchesTypeRange(void)
{
TEST_ASSERT_EQUAL_MESSAGE(moduleConfigTypeCount, meshtastic_ModuleConfig_mesh_beacon_tag,
"ModuleConfigType and ModuleConfig submessage tags are no longer 1:1");
}

// The full lifecycle per submessage: admin set -> encode (what saveToDisk writes) -> decode into the
// live global (a reboot's loadFromDisk) -> admin get answers with that submessage. A missing set case
// shows up as a 0-byte encode; a missing get case as an empty or mismatched response.
void test_everyModuleConfig_survivesSetSaveLoadGet(void)
{
for (pb_size_t tag = 1; tag <= moduleConfigTypeCount; tag++) {
if (moduleConfigIsCompiledOut(tag))
continue;
moduleConfig = meshtastic_LocalModuleConfig_init_zero;

// Set: the only content is this one submessage, so the encode below stands or falls with it.
meshtastic_ModuleConfig mc = meshtastic_ModuleConfig_init_zero;
mc.which_payload_variant = tag;
TEST_ASSERT_TRUE_MESSAGE(admin->handleSetModuleConfig(mc), describeTag("setter rejected", tag));

// Save: presence flag unset means nanopb drops the struct and the write encodes to nothing.
uint8_t saved[meshtastic_LocalModuleConfig_size];
size_t savedLen = pb_encode_to_bytes(saved, sizeof(saved), &meshtastic_LocalModuleConfig_msg, &moduleConfig);
TEST_ASSERT_GREATER_THAN_size_t_MESSAGE(0, savedLen, describeTag("dropped on save, presence flag not set", tag));

// Load: overwrite the live global, as a reboot would.
moduleConfig = meshtastic_LocalModuleConfig_init_zero;
TEST_ASSERT_TRUE_MESSAGE(pb_decode_from_bytes(saved, savedLen, &meshtastic_LocalModuleConfig_msg, &moduleConfig),
describeTag("failed to decode", tag));

// Re-encoding what was loaded must reproduce the saved bytes - presence survived the reload.
uint8_t reloaded[meshtastic_LocalModuleConfig_size];
size_t reloadedLen = pb_encode_to_bytes(reloaded, sizeof(reloaded), &meshtastic_LocalModuleConfig_msg, &moduleConfig);
TEST_ASSERT_EQUAL_size_t_MESSAGE(savedLen, reloadedLen, describeTag("changed size across reload", tag));
TEST_ASSERT_EQUAL_MEMORY_MESSAGE(saved, reloaded, savedLen, describeTag("changed content across reload", tag));

// Get: remote and local requests take the same branch and must answer with this submessage.
const NodeNum requesters[] = {ADMIN_NODE, 0};
for (NodeNum from : requesters) {
meshtastic_MeshPacket req = makeGetRequest(from);
admin->handleGetModuleConfig(req, tag - 1); // type pairs 1:1 with tag (guard test above)

pb_size_t responseTag = 0;
bool decoded = decodeResponseTagFromReply(admin->reply(), responseTag);
admin->drainReply();

TEST_ASSERT_TRUE_MESSAGE(decoded, describeTag("no get response", tag));
TEST_ASSERT_EQUAL_MESSAGE(tag, responseTag, describeTag("get answered with a different submessage", tag));
}
}
}

// Each set is a single-submessage edit: writing type N must not erase types 1..N-1. Every additional
// stored submessage grows the encoded config, so a clobbered earlier write shows up as a non-increase.
void test_sequentialSets_preserveEarlierWrites(void)
{
size_t prevLen = 0;
for (pb_size_t tag = 1; tag <= moduleConfigTypeCount; tag++) {
if (moduleConfigIsCompiledOut(tag))
continue;

meshtastic_ModuleConfig mc = meshtastic_ModuleConfig_init_zero;
mc.which_payload_variant = tag;
admin->handleSetModuleConfig(mc);

uint8_t buf[meshtastic_LocalModuleConfig_size];
size_t len = pb_encode_to_bytes(buf, sizeof(buf), &meshtastic_LocalModuleConfig_msg, &moduleConfig);
TEST_ASSERT_GREATER_THAN_size_t_MESSAGE(prevLen, len, describeTag("set erased an earlier submessage", tag));
prevLen = len;
}
}

void setUp(void)
{
mockService = new MockMeshService();
service = mockService;

admin = new AdminModuleTestShim();
admin->deferSaves(); // no disk write or reboot when a setter is accepted

moduleConfig = meshtastic_LocalModuleConfig_init_zero;
config = meshtastic_LocalConfig_init_zero;
myNodeInfo = meshtastic_MyNodeInfo_init_zero;
myNodeInfo.my_node_num = LOCAL_NODE;
}

void tearDown(void)
{
admin->drainReply();
delete admin;
admin = nullptr;

service = nullptr;
delete mockService;
mockService = nullptr;
}

void setup()
{
delay(10);
initializeTestEnvironment();
UNITY_BEGIN();

RUN_TEST(test_tagRangeMatchesTypeRange);
RUN_TEST(test_everyModuleConfig_survivesSetSaveLoadGet);
RUN_TEST(test_sequentialSets_preserveEarlierWrites);

exit(UNITY_END());
}

void loop() {}
Loading
Loading