Fix Meshtext metadata lifecycle and radio2 readiness refresh

Delay automatic GPS-free NodeInfo until a quiet TX gap, invalidate stale metadata after durable node renames, and refresh the profile when same-tuple publication releases its busy guard. Cover the real Companion methods and document the behavior.
This commit is contained in:
mikecarper committed 2026-10-15 21:05:14 -07:00
1 parent 8687b027d6
commit bc1b414af0
8 files changed
+220 -14

No files matched your search

+3 -1
View File
@@ -11,7 +11,9 @@ For supported Full Companions, see [Meshtext channel chat](radio_profiles.md#mes
for `mt-` channels and the explicit `send mt.nodeinfo <channel slot>` command.
Chat remains plain text. A separate GPS-free NodeInfo is sent after the first
successful chat transmission per used channel/identity per boot; the explicit
command can also request one. MT heard-repeat retries use the Companion's
command can also request one. Automatic metadata waits for a 250 ms gap after
the latest successful MT transmission; explicit requests bypass that gap.
MT heard-repeat retries use the Companion's
`flood.retry.count`, with the metadata cap and `flood.retry.advert` gate for
NodeInfo.
+5
View File
@@ -521,10 +521,15 @@ MeshCore node name, add a label to chat text or transmit anything.
After the first chat packet actually finishes transmitting on a configured MT
channel, the Companion queues a separate, GPS-free `NODEINFO_APP` broadcast.
Automatic metadata waits for a 250 ms gap after the most recent successful MT
transmission, including retries, so a stock peer can re-arm its receiver.
Explicit `send mt.nodeinfo` does not wait for this automatic-metadata gap.
This happens once per used channel and sender identity per boot, not for every
chat message. A rejected or failed chat send does not trigger it. There is no
NodeInfo transmission just from booting, receiving traffic, changing settings
or joining a channel, and there is no periodic NodeInfo timer.
Changing the node name cancels old-name metadata retries; the next new
successful chat announces the updated name without sending from the rename.
`send mt.nodeinfo <channel slot>` still explicitly queues **one** standard
`NODEINFO_APP` broadcast on the selected configured MT channel. The slot is
+1
View File
@@ -63,6 +63,7 @@ channel 0 Hello
Use a configured `mt-` channel slot. Chat carries only the entered text. After
the first successful over-air chat transmission per used channel/identity per
boot, the firmware queues a separate standard name/ID NodeInfo advertisement.
Automatic metadata waits for a 250 ms quiet TX gap so the peer can resume RX.
`send mt.nodeinfo` can also request one explicitly. Neither contains GPS or
coordinates. No NodeInfo is sent just from booting, configuration, receiving
traffic or a timer. The long name comes from the existing node name;
+14 -3
View File
@@ -13,6 +13,7 @@
namespace {
constexpr size_t LegacySettingsSize = 16;
constexpr size_t SettingsSize = 40;
constexpr uint32_t AutoNodeInfoQuietMillis = 250;
bool validShortName(const char* name) {
if (!name) return false;
const size_t length = strnlen(name, 17);
@@ -321,12 +322,16 @@ void MyMesh::refreshMeshtextProfile() {
const uint32_t primary_generation = current_profiles ? current_profiles->generation[0] : 0;
const uint32_t secondary_generation = current_profiles ? current_profiles->generation[1] : 0;
const uint32_t intent_generation = _radio_profiles.secondaryIntentGeneration();
const bool secondary_ready = _radio_profiles.secondaryRuntimeAvailable();
if (!_mt_profile_dirty && primary_generation == _mt_primary_generation
&& secondary_generation == _mt_secondary_generation && intent_generation == _mt_intent_generation
&& _mt_last_radio_ready == _radio_available && _mt_primary_frequency == _prefs.freq) return;
&& _mt_last_radio_ready == _radio_available && _mt_primary_frequency == _prefs.freq
&& _mt_last_secondary_ready == secondary_ready) return;
// Hashing names and scanning the channel table every radio loop would add
// blind time to the dual-profile scanner. Recompute only on configuration
// or lease/generation edges; RX/TX still derive a fresh bounded wire copy.
// or lease/generation/readiness edges. Publishing an unchanged saved tuple
// can release a busy guard without changing either RF generation.
// RX/TX still derive a fresh bounded wire copy.
struct CacheUpdate {
MyMesh& owner;
~CacheUpdate() {
@@ -335,6 +340,7 @@ void MyMesh::refreshMeshtextProfile() {
owner._mt_secondary_generation = p ? p->generation[1] : 0;
owner._mt_intent_generation = owner._radio_profiles.secondaryIntentGeneration();
owner._mt_last_radio_ready = owner._radio_available;
owner._mt_last_secondary_ready = owner._radio_profiles.secondaryRuntimeAvailable();
owner._mt_primary_frequency = owner._prefs.freq;
owner._mt_profile_dirty = false;
}
@@ -508,7 +514,11 @@ void MyMesh::loopMeshtextDelivery() {
if (!usable || hasForeignRadioFrame()) return;
// Announce only channels which actually completed a user text TX. Never
// turn boot, received traffic, a timer, or a configuration edit into TX.
for (int i = 0; i < MAX_GROUP_CHANNELS; ++i) {
// Stock peers need time to re-arm RX after chat/rebroadcast processing.
// Every successful MT TX restarts this gap; ordinary chat retries remain
// eligible below while the automatic announcement is still only intent.
const bool metadata_quiet = uint32_t(now - _mt_last_tx_completed_at) >= AutoNodeInfoQuietMillis;
for (int i = 0; metadata_quiet && i < MAX_GROUP_CHANNELS; ++i) {
if (!_mt_nodeinfo_pending[i]) continue;
ChannelDetails channel;
uint8_t fingerprint[8];
@@ -639,6 +649,7 @@ void MyMesh::onForeignRadioSendComplete(bool success) {
return;
}
#endif
if (success) _mt_last_tx_completed_at = millis();
const int slot = _mt_tx_slot;
_mt_tx_slot = -1;
if (slot < 0 || slot >= static_cast<int>(mesh::mt::DeliverySlots)) {
+15 -2
View File
@@ -8079,7 +8079,20 @@ bool MyMesh::saveAdvertName(const char* name) {
char previous[sizeof(_prefs.node_name)];
memcpy(previous, _prefs.node_name, sizeof(previous));
StrHelper::strncpy(_prefs.node_name, name, sizeof(_prefs.node_name));
if (savePrefs()) return true;
if (savePrefs()) {
#if MESH_ENABLE_MESHTEXT_CHAT
if (strncmp(previous, _prefs.node_name, sizeof(previous)) != 0) {
// NodeInfo contains the global advert name; retained ciphertext must
// not advertise the previous name after a successful rename. Preserve
// independent text retries, and wait for a new successful chat before
// announcing again rather than transmitting from a settings edit.
for (auto& frame : _mt_delivery) if (frame.nodeinfo) frame.cancelled = true;
memset(_mt_nodeinfo_announced, 0, sizeof(_mt_nodeinfo_announced));
memset(_mt_nodeinfo_pending, 0, sizeof(_mt_nodeinfo_pending));
}
#endif
return true;
}
memcpy(_prefs.node_name, previous, sizeof(previous));
return false;
}
@@ -10154,7 +10167,7 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt,
" set mt.hops <0..7> (saved; default 7)\r\n"
" set mt.node <!8hex|default> (saved; use a unique ID)\r\n"
" set mt.shortname <1..4 UTF-8 characters|default> (saved; NodeInfo uses 4 bytes)\r\n"
" send mt.nodeinfo <channel slot> (one manual advert; no automatic adverts)\r\n"
" send mt.nodeinfo <channel slot> (GPS-free; also automatic after first chat)\r\n"
" set mt.preset <longfast|shortturbo|...> (saved manual radio2)\r\n"
" set mt.channel <slot> <mt-name> <32/64 hex digits|AQ==>\r\n"
" get mt.channel <slot> / delete mt.channel <slot>\r\n"
+2
View File
@@ -768,10 +768,12 @@ private:
bool _mt_nodeinfo_announced[MAX_GROUP_CHANNELS] = {};
bool _mt_nodeinfo_pending[MAX_GROUP_CHANNELS] = {};
uint32_t _mt_nodeinfo_generation = 0;
uint32_t _mt_last_tx_completed_at = 0;
bool _mt_delivery_config_dirty = false;
uint32_t _mt_retried = 0, _mt_heard = 0, _mt_exhausted = 0;
bool _mt_settings_valid = true, _mt_active = false;
bool _mt_profile_dirty = true, _mt_last_radio_ready = false;
bool _mt_last_secondary_ready = false;
uint32_t _mt_primary_generation = 0, _mt_secondary_generation = 0, _mt_intent_generation = 0;
float _mt_primary_frequency = 0;
const char* _mt_status = "no Meshtext channels";
+167 -7
View File
@@ -99,6 +99,7 @@ public:
uint8_t _mt_nodeinfo_fingerprint[MAX_GROUP_CHANNELS][8]={};
bool _mt_nodeinfo_announced[MAX_GROUP_CHANNELS]={},_mt_nodeinfo_pending[MAX_GROUP_CHANNELS]={};
uint32_t _mt_nodeinfo_generation=0,_mt_retried=0,_mt_heard=0,_mt_exhausted=0;
uint32_t _mt_last_tx_completed_at=0;
struct Random {
uint32_t jitter=0;
uint32_t nextInt(uint32_t low,uint32_t high) {
@@ -106,6 +107,7 @@ public:
}
} random;
bool _mt_settings_valid=true,_mt_active=false,_mt_profile_dirty=true,_mt_last_radio_ready=false;
bool _mt_last_secondary_ready=false;
bool _mt_delivery_config_dirty=true;
uint32_t _mt_primary_generation=0,_mt_secondary_generation=0,_mt_intent_generation=0;
float _mt_primary_frequency=0; const char* _mt_status="no Meshtext channels";
@@ -174,6 +176,8 @@ public:
offline.emplace_back(bytes,bytes+size); return true;
}
bool saveChannels() { return save_result; }
bool savePrefs() { return save_result; }
bool saveAdvertName(const char*);
void writeErrFrame(uint8_t value) { error=value; }
void writeOKFrame() { ok=true; }
bool sendGroupMessage(uint32_t,mesh::GroupChannel&,const char*,const char*,int) { ++meshcore_sends; return true; }
@@ -889,7 +893,7 @@ static void automaticNodeinfo() {
const auto text=node.foreign.back();
node.advance(60000); assert(node.foreign.size()==1); // Admission is not RF success.
node.completeForeign(); assert(node.foreign.size()==1);
node.advance(1);
node.advance(250);
assert(node.foreign.size()==2 && node.foreign.back()==std::vector<uint8_t>(expected,expected+sizeof(expected)));
// Exact independent protobuf/AES bytes prove only User/NodeInfo was sent:
// no position packet, coordinates, MAC, public key or custom name envelope.
@@ -898,7 +902,7 @@ static void automaticNodeinfo() {
node.completeForeign(); node.advance(100000); assert(node.foreign.size()==3);
node.commit(1,channel("mt-Team",32));
assert(node.sendMeshtextText(node.stored[1],"private",7));
node.completeForeign(); node.advance(1); assert(node.foreign.size()==5);
node.completeForeign(); node.advance(250); assert(node.foreign.size()==5);
auto private_info=node.foreign.back();
mesh::mt::DecodedText private_decoded;
assert(private_info!=text && !mesh::mt::decodeText(private_info.data(),private_info.size(),
@@ -912,7 +916,7 @@ static void automaticNodeinfo() {
assert(failure.sendMeshtextText(failure.stored[0],"failed",6));
failure.completeForeign(false); failure.advance(100000); assert(failure.foreign.size()==1);
assert(failure.sendMeshtextText(failure.stored[0],"success",7));
failure.completeForeign(); failure.advance(1); assert(failure.foreign.size()==3);
failure.completeForeign(); failure.advance(250); assert(failure.foreign.size()==3);
failure.completeForeign();
Preferences::reset(); MyMesh manual; active(manual); announce(manual);
@@ -921,6 +925,59 @@ static void automaticNodeinfo() {
manual.completeForeign(); manual.advance(100000); assert(manual.foreign.size()==2);
puts("PASS auto-nodeinfo");
}
static void automaticNodeinfoQuiet() {
Preferences::reset(); MyMesh node; active(node);
node._prefs.flood_retry_attempts=0;
assert(node.sendMeshtextText(node.stored[0],"first",5)); node.completeForeign();
assert(node._mt_nodeinfo_pending[0] && !node._mt_last_tx_completed_at);
node.advance(0); assert(node.foreign.size()==1 && !node.foreign_pending);
node.advance(249); assert(node.foreign.size()==1 && node._mt_nodeinfo_pending[0]);
node.advance(1); assert(node.foreign.size()==2 && node.foreign_pending);
node.completeForeign();
// Retrying chats are not blocked by the metadata quiet gap. Every actual
// completion restarts that gap, so the announcement follows the burst.
Preferences::reset(); MyMesh retries; active(retries);
retries._prefs.flood_retry_attempts=2;
assert(retries.sendMeshtextText(retries.stored[0],"retry",5));
const auto original=retries.foreign.back(); retries.completeForeign();
auto* pending=pendingChat(retries); assert(pending && pending->due==210);
for(unsigned attempt=0;attempt<2;++attempt) {
advanceTo(retries,pending->due);
assert(retries.foreign_pending && retries.foreign.back()==original
&& retries._mt_nodeinfo_pending[0]);
const auto prior=retries._mt_last_tx_completed_at;
retries.completeForeign();
assert(retries._mt_last_tx_completed_at==millis()
&& retries._mt_last_tx_completed_at!=prior);
pending=pendingChat(retries); assert(pending);
}
const auto count=retries.foreign.size();
retries.advance(249); assert(retries.foreign.size()==count && !retries.foreign_pending);
retries.advance(1); assert(retries.foreign.size()==count+1
&& retries.foreign.back()!=original && retries.foreign_pending);
retries.completeForeign();
Preferences::reset(); MyMesh wrap; active(wrap); wrap._prefs.flood_retry_attempts=0;
g_mock_millis=UINT32_MAX-100;
assert(wrap.sendMeshtextText(wrap.stored[0],"wrap",4)); wrap.completeForeign();
wrap.advance(249); assert(wrap.foreign.size()==1 && !wrap.foreign_pending);
wrap.advance(1); assert(wrap.foreign.size()==2 && wrap.foreign_pending);
wrap.completeForeign();
// A failed TX does not arm metadata or move the successful-completion
// timestamp. Explicit manual NodeInfo still admits immediately, by choice.
Preferences::reset(); MyMesh failure; active(failure); failure._prefs.flood_retry_attempts=0;
failure.advance(1000);
assert(failure.sendMeshtextText(failure.stored[0],"failed",6)); failure.completeForeign(false);
assert(!failure._mt_last_tx_completed_at && !failure._mt_nodeinfo_pending[0]);
failure.advance(1000); assert(failure.foreign.size()==1);
assert(failure.sendMeshtextText(failure.stored[0],"success",7)); failure.completeForeign();
assert(failure._mt_last_tx_completed_at==millis());
assert(failure.sendMeshtextNodeInfo(failure.stored[0]) && failure.foreign.size()==3);
failure.completeForeign(); failure.advance(250); assert(failure.foreign.size()==3);
puts("PASS auto-nodeinfo-quiet");
}
static void heardRetries() {
Preferences::reset(); MyMesh node; active(node); announce(node);
node._prefs.flood_retry_attempts=2;
@@ -1103,12 +1160,12 @@ static void announcementLifecycle() {
assert(node.handleMeshtextCommand("set mt.shortname NEW",reply,sizeof(reply)) && !strncmp(reply,"OK",2));
node.advance(100000); assert(node.foreign.size()==before_alias+1); // Name changes do not send alone.
assert(node.sendMeshtextText(node.stored[0],"new name",8));
node.completeForeign(); node.advance(1); assert(node.foreign.size()==before_alias+3);
node.completeForeign(); node.advance(250); assert(node.foreign.size()==before_alias+3);
node.completeForeign();
auto changed=channel("#mt-longfast",32); changed.channel.secret[0]^=0x40;
node.commit(0,changed); node.advance(1); const auto before_key=node.foreign.size();
assert(node.sendMeshtextText(node.stored[0],"new key",7));
node.completeForeign(); node.advance(1); assert(node.foreign.size()==before_key+2);
node.completeForeign(); node.advance(250); assert(node.foreign.size()==before_key+2);
node.completeForeign();
for(bool fail_admission:{false,true}) {
@@ -1116,7 +1173,7 @@ static void announcementLifecycle() {
failure._prefs.flood_retry_attempts=0;
assert(failure.sendMeshtextText(failure.stored[0],"first",5)); failure.completeForeign();
const auto first_count=failure.foreign.size();
failure.admit_foreign=!fail_admission; failure.advance(1);
failure.admit_foreign=!fail_admission; failure.advance(250);
if(!fail_admission) {
assert(failure.foreign_pending); failure.completeForeign(false);
}
@@ -1126,11 +1183,111 @@ static void announcementLifecycle() {
assert(failure.foreign.size()==first_count+(fail_admission?0:1));
failure.admit_foreign=true;
assert(failure.sendMeshtextText(failure.stored[0],"another",7)); failure.completeForeign();
failure.advance(1); assert(failure.foreign_pending);
failure.advance(250); assert(failure.foreign_pending);
failure.completeForeign();
}
puts("PASS announcement-lifecycle");
}
static void advertNameDelivery() {
// A failed save or unchanged effective name must preserve the announced
// state and the exact retained packet awaiting its one metadata retry.
Preferences::reset(); MyMesh node; active(node);
assert(node.sendMeshtextNodeInfo(node.stored[0]));
const auto original=node.foreign.back(); node.completeForeign();
auto& metadata=node._mt_delivery[mesh::mt::ChatDeliverySlots];
assert(metadata.length && node._mt_nodeinfo_announced[0]);
const auto old_due=metadata.due;
node.save_result=false;
assert(!node.saveAdvertName("Rejected") && !strcmp(node._prefs.node_name,"Test"));
assert(metadata.length && !metadata.cancelled && node._mt_nodeinfo_announced[0]);
node.save_result=true;
assert(node.saveAdvertName("Test"));
assert(metadata.length && !metadata.cancelled && metadata.due==old_due
&& node._mt_nodeinfo_announced[0]);
assert(node.saveAdvertName("Renamed") && !strcmp(node._prefs.node_name,"Renamed"));
assert(metadata.cancelled && !node._mt_nodeinfo_announced[0] && !node._mt_nodeinfo_pending[0]);
advanceTo(node,old_due); node.advance(100000);
assert(!metadata.length && node.foreign.size()==1 && !node.foreign_pending
&& !node._mt_retried && !node._mt_failed);
// No transmission just from renaming. The next new chat advertises the
// updated name with independently generated standard User/AES bytes.
node._prefs.flood_retry_attempts=0;
assert(node.sendMeshtextText(node.stored[0],"new name",8));
node.completeForeign(); node.advance(250);
const uint8_t expected[]=@NODEINFO_RENAMED@;
assert(node.foreign.size()==3 && node.foreign.back()!=original
&& node.foreign.back()==std::vector<uint8_t>(expected,expected+sizeof(expected)));
node.completeForeign(); node.advance(100000); assert(node.foreign.size()==3);
// A queued old-name frame is cancelled. One which is already physically
// transmitting may finish, but cannot re-arm metadata or its old retry.
for(bool sending:{false,true}) {
Preferences::reset(); MyMesh busy; active(busy);
assert(busy.sendMeshtextNodeInfo(busy.stored[0]));
busy.foreign_sending=sending;
assert(busy.saveAdvertName("Renamed")); busy.advance(1);
assert(busy.foreign_pending==sending);
if(sending) busy.completeForeign();
busy.advance(100000);
assert(busy.foreign.size()==1 && !busy._mt_nodeinfo_announced[0]
&& !busy._mt_nodeinfo_pending[0] && !busy._mt_failed && !busy._mt_retried);
}
// Clearing a deferred first-use intent does not turn the edit into TX, and
// old text ciphertext remains independently eligible for its own retry.
Preferences::reset(); MyMesh chat; active(chat);
assert(chat.sendMeshtextText(chat.stored[0],"text",4));
const auto text=chat.foreign.back(); chat.completeForeign();
auto* pending=pendingChat(chat); assert(pending && chat._mt_nodeinfo_pending[0]);
assert(chat.saveAdvertName("Renamed") && !pending->cancelled
&& !chat._mt_nodeinfo_pending[0]);
chat.advance(1); assert(chat.foreign.size()==1);
advanceTo(chat,pending->due);
assert(chat.foreign.size()==2 && chat.foreign.back()==text);
chat.completeForeign(); chat.advance(1);
assert(!chat._mt_nodeinfo_pending[0] && chat.foreign.size()==2);
auto echo=repeated(text); assert(receiveFrame(chat,echo.data(),echo.size()));
chat.advance(100000); assert(chat.foreign.size()==2);
puts("PASS advert-name-delivery");
}
static void readinessCache() {
// A delayed same-tuple publication releases its busy state without changing
// either RF generation. An MT channel installed during that delay must not
// remain permanently stuck behind a cached busy result.
for(const char* command:{"set radio2 909.5,500,5,5,rxtx,32","set tempradio2 off"}) {
Preferences::reset(); MyMesh node; node.beginMeshtext();
node.cli("set radio2 909.5,500,5,5,rxtx,32"); node.advance(2001);
const auto generation=node.radio.p.generation[1];
const auto initial=node.radio.p.secondary;
assert(initial.params.protocol==mesh::RadioProfileProtocol::MeshCore);
node.cli(command); node.commit(0,channel("mt-labtest",16));
assert(!node._mt_active && !node._radio_profiles.secondaryRuntimeAvailable());
node.advance(1999);
assert(!node._mt_active && node.radio.p.generation[1]==generation);
node.advance(1);
assert(node._radio_profiles.secondaryRuntimeAvailable() && node._mt_active
&& node.radio.p.secondary.params.protocol==mesh::RadioProfileProtocol::Meshtext
&& node.radio.p.secondary.params.preamble==16);
assertUntouched(node); // Readiness/configuration alone still never transmits.
const auto active_generation=node.radio.p.generation[1];
node.advance(1000);
assert(node._mt_active && node.radio.p.generation[1]==active_generation);
// Readiness caching must remain read-only and preserve external ownership:
// do not force the overlay back when another runtime owner changed radio2.
auto external=initial; external.params.freq=910.5f;
node.radio.p.setSecondary(external,false);
const auto external_generation=node.radio.p.generation[1];
node.advance(1);
assert(!node._mt_active && !node._radio_profiles.secondaryRuntimeAvailable());
node.commit(0,channel("mt-other",16)); node.advance(100000);
assert(!node._mt_active && node.radio.p.generation[1]==external_generation
&& node.radio.p.secondary.params==external.params
&& node.radio.p.secondary.mode==external.mode);
assertUntouched(node);
}
puts("PASS readiness-cache");
}
int main(int argc,char** argv) {
assert(argc==2);
if(!strcmp(argv[1],"keys"))keys();
@@ -1147,11 +1304,14 @@ int main(int argc,char** argv) {
else if(!strcmp(argv[1],"node"))nodeOverride();
else if(!strcmp(argv[1],"nodeinfo"))nodeinfo();
else if(!strcmp(argv[1],"auto-nodeinfo"))automaticNodeinfo();
else if(!strcmp(argv[1],"auto-nodeinfo-quiet"))automaticNodeinfoQuiet();
else if(!strcmp(argv[1],"heard-retries"))heardRetries();
else if(!strcmp(argv[1],"strict-echo"))strictEcho();
else if(!strcmp(argv[1],"delivery-lifecycle"))deliveryLifecycle();
else if(!strcmp(argv[1],"delivery-contention"))deliveryContention();
else if(!strcmp(argv[1],"delivery-timing"))deliveryTiming();
else if(!strcmp(argv[1],"announcement-lifecycle"))announcementLifecycle();
else if(!strcmp(argv[1],"advert-name-delivery"))advertNameDelivery();
else if(!strcmp(argv[1],"readiness-cache"))readinessCache();
else assert(!"unknown test case");
}
+13 -1
View File
@@ -56,7 +56,8 @@ class CompanionMeshtextTest(unittest.TestCase):
mt = (ROOT / "examples/companion_radio/MeshtextCompanion.cpp").read_text(encoding="utf-8")
mt = mt[mt.index("namespace {"):].rsplit("#endif", 1)[0]
signatures = ("bool MyMesh::isAliasRadio2Defined(", "bool MyMesh::resolveChannelAlias(",
"bool MyMesh::getChannel(", "bool MyMesh::setChannel(", "bool MyMesh::queueChannelText(")
"bool MyMesh::getChannel(", "bool MyMesh::setChannel(", "bool MyMesh::queueChannelText(",
"bool MyMesh::saveAdvertName(")
methods = "\n".join(body(source, signature) for signature in signatures)
utils_source = (ROOT / "src/Utils.cpp").read_text(encoding="utf-8")
# CLI keys use the production strict hexadecimal parser as well.
@@ -104,6 +105,8 @@ class CompanionMeshtextTest(unittest.TestCase):
("%04x" % (default_node & 0xFFFF)).encode()),
"@NODEINFO_AFTER_CHAT@": nodeinfo_frame(default_node, 0x12345679, b"Test",
("%04x" % (default_node & 0xFFFF)).encode()),
"@NODEINFO_RENAMED@": nodeinfo_frame(default_node, 0x1234567A, b"Renamed",
("%04x" % (default_node & 0xFFFF)).encode()),
"@NODEINFO_NAMED@": nodeinfo_frame(0x12345678, 0x12345679, b"Mike Carper", b"MIKE"),
"@NODEINFO_UTF8@": nodeinfo_frame(0x12345678, 0x1234567B, b"A" * 22, "\U0001f600".encode()),
"@NODEINFO_PRIVATE@": nodeinfo_frame(0x12345678, 0x1234567C, b"A" * 22,
@@ -213,6 +216,9 @@ class CompanionMeshtextTest(unittest.TestCase):
def test_first_successful_chat_announces_once_per_channel_with_no_position(self):
self.run_case("auto-nodeinfo")
def test_automatic_nodeinfo_waits_for_quiet_tx_gap_with_retry_refresh_and_wrap(self):
self.run_case("auto-nodeinfo-quiet")
def test_heard_repeat_retries_identical_packets_bounded_by_existing_settings(self):
self.run_case("heard-retries")
@@ -231,6 +237,12 @@ class CompanionMeshtextTest(unittest.TestCase):
def test_announcement_alias_settings_key_changes_and_failure_do_not_storm(self):
self.run_case("announcement-lifecycle")
def test_advert_name_change_cancels_only_stale_metadata_and_waits_for_new_chat(self):
self.run_case("advert-name-delivery")
def test_same_tuple_publish_busy_release_refreshes_without_stealing_external_owner(self):
self.run_case("readiness-cache")
def test_delivery_service_is_wired_before_radio_processing_and_paused_for_morse(self):
source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text(encoding="utf-8")
loop = body(source, "void MyMesh::loop()")