diff --git a/docs/cli_commands.md b/docs/cli_commands.md index d468e43d0..b60779b59 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -9,7 +9,11 @@ RX-only mode, cross-transmission policy, and optional radio preambles. For supported Full Companions, see [Meshtext channel chat](radio_profiles.md#meshtext-channel-chat) for `mt-` channels and the explicit `send mt.nodeinfo ` command. -Chat remains plain text; NodeInfo is sent only when that command is requested. +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 +`flood.retry.count`, with the metadata cap and `flood.retry.advert` gate for +NodeInfo. See [carrier-wave RF checks](carrier_wave.md) for `cw on [seconds]`, `cw2 on [seconds]`, and stopping a carrier with `off`. @@ -4389,6 +4393,18 @@ The `infra`, `rooftop`, and `mobile` defaults and `retry.preset` command in this section apply to infrastructure roles. Companion does not expose repeater bridge or prefix retry filters. +On builds with [Meshtext channel chat](radio_profiles.md#heard-repeat-retries), +`flood.retry.count` also limits retries of the node's own MT channel text. +Retries reuse the original encoded packet and stop after hearing a matching +forwarded copy with a lower remaining hop limit. This is not a recipient ACK +or a delivery guarantee. NodeInfo permits at most one additional transmission, +only with nonzero retry count and `flood.retry.advert on`, and waits an extra +minute beyond its airtime-based delay. These settings never enable relaying of +received MT traffic or apply MeshCore prefix/bridge rules to MT packets. +`set mt.hops 0` disables heard-repeat retries, without suppressing the original +chat or its first-use NodeInfo. `get mt.retry` reports retry/heard/exhausted +counters, pending work and the configured count without changing settings. + **Search terms:** flood tx retries, flood retry attempts, flood retransmissions, broadcast retries. **Usage:** diff --git a/docs/radio_profiles.md b/docs/radio_profiles.md index ef7df7c6a..997c8ff7b 100644 --- a/docs/radio_profiles.md +++ b/docs/radio_profiles.md @@ -465,6 +465,7 @@ get mt.status get mt.region get mt.slot get mt.hops +get mt.retry get mt.node get mt.shortname set mt.node !12345678 @@ -518,21 +519,28 @@ changed. `set mt.shortname default` clears the override and uses the last four hexadecimal digits of the effective MT node ID. This does not change the MeshCore node name, add a label to chat text or transmit anything. -`send mt.nodeinfo ` explicitly queues **one** standard +After the first chat packet actually finishes transmitting on a configured MT +channel, the Companion queues a separate, GPS-free `NODEINFO_APP` broadcast. +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. + +`send mt.nodeinfo ` still explicitly queues **one** standard `NODEINFO_APP` broadcast on the selected configured MT channel. The slot is the Companion channel index, not `mt.slot` (the frequency slot). The packet contains the actual sender ID, the global MeshCore node name as its long name (UTF-8-safe truncated to 24 bytes), the effective short name, `CLIENT_MUTE` -(no relaying), and the no-DM flag. It omits MAC, public keys and unsupported -metadata. The usual radio2 TX-mode, pending-TX, airtime and profile guards -apply. The reply says **queued**, not delivered or acknowledged. +(no relaying), and the no-DM flag. Both automatic and explicit NodeInfo omit +GPS/coordinates, MAC, public keys and unsupported metadata. The usual radio2 +TX-mode, pending-TX, airtime and profile guards apply. The reply says +**queued**, not delivered or acknowledged. -There are no automatic NodeInfo advertisements: not at boot, on a timer, when -names change, when chat is sent, or in response to received packets. Received -NodeInfo is still ignored; this backend does not maintain a node database. +Received NodeInfo is still ignored; this backend does not maintain a node +database. -Outgoing Meshtext chat and explicitly requested NodeInfo set **OK to MQTT** -on by default, allowing a compatible receiving gateway to upload those packets. +Outgoing Meshtext chat and automatic or explicitly requested NodeInfo set +**OK to MQTT** on by default, allowing a compatible receiving gateway to upload those packets. This does not start an MQTT client, bridge networks, request a reply, or mark the packet as having arrived through MQTT. The 228-byte text limit is unchanged. @@ -630,12 +638,12 @@ Outgoing MT chat is plain text exactly as entered, with the sender node ID in the standard radio header. It contains no custom name envelope or NodeInfo. Incoming text uses the exact sender-ID prefix `!12345678: ` before the received payload when passed to the MeshCore app. Changing the shortname only changes -future manually requested NodeInfo, not chat or the packet's sender ID; +future NodeInfo, not chat or the packet's sender ID; `set mt.node` changes the actual Meshtext sender ID. Neither setting change emits advertisements. A successful send response means **queued**, not acknowledged by a remote node. This backend does not relay MT packets or provide ACKs, administrative commands, direct -messages, automatic node-info adverts or a node database. MeshCore emergency forwarding +messages, periodic node-info adverts or a node database. MeshCore emergency forwarding does not apply to MT traffic. Interoperability is with stock peers' PSK channel text using AES-CTR, not AES-CCM. AES-CTR channel traffic is not authenticated; successful decoding is not proof of sender identity. @@ -657,6 +665,41 @@ The compatible protocol's fixed short preamble also does not provide the scan-aw preamble described below. Do not treat this as continuous coverage of both networks or guaranteed message delivery. +#### Heard-repeat retries + +MT channel chat uses the Companion's saved `flood.retry.count` budget: `0` +disables retries, and `1`–`15` allows that many extra transmissions after the +initial send. Locally originated MT traffic is hop zero for the +`flood.retry.path` gate, so a gate of `0` still permits it; the group-data gate +does not apply to channel text. `mt.hops 0` disables heard-repeat retries +because peers cannot forward a packet with no remaining hop budget; the +original chat and its first-use NodeInfo still transmit normally. +Retries keep the exact encoded payload and +packet ID, allowing peers to recognize duplicates. They use conservative, +airtime-based flood spacing and the normal CAD and airtime limits rather than +transmitting continuously. + +`get mt.retry` is read-only diagnostics: it reports retry transmissions, +matching heard repeats, exhausted retry sequences, pending work and the +configured retry count. Use `set flood.retry.count`, not this getter, to change +the budget. + +A matching over-air copy with a lower remaining hop limit cancels the retry +sequence. Merely receiving another message, an unchanged-hop copy or a packet +with the same ID but different payload does not. A heard repeat indicates a +compatible peer forwarded the packet; it does **not** confirm that a particular +person or app received it. This does not add DM delivery receipts or ACKs. + +NodeInfo follows the metadata retry cap: at most one extra send, only when +`flood.retry.count` is nonzero and `flood.retry.advert` is `on`. Its wait adds +one minute to the airtime-based delay, matching the conservative self-advert +policy. Turning retry count off cancels retained retries. Channel, identity, +settings or radio-profile changes invalidate retained work rather than sending +it on a different network. The bounded pending-frame storage may reject a new +send when full. Other people's MT packets are never forwarded by this backend; +MeshCore repeater prefix/bridge IDs and flood-filter rules are not MT routing +rules. + ## Choose whether traffic crosses between profiles ```text diff --git a/docs/terminal_chat_cli.md b/docs/terminal_chat_cli.md index f707ff5c4..a8062a79e 100644 --- a/docs/terminal_chat_cli.md +++ b/docs/terminal_chat_cli.md @@ -60,11 +60,35 @@ send mt.nodeinfo 0 channel 0 Hello ``` -Use a configured `mt-` channel slot. The explicit `send mt.nodeinfo` command -queues one standard name/ID advertisement; chat carries only the entered text. -There are no automatic NodeInfo sends or responses. The long name comes from -the existing node name; `get mt.shortname` shows the effective four-byte short -name. These commands are not added to smaller or feature-disabled builds. +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. +`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; +`get mt.shortname` shows the effective four-byte short name. These commands +are not added to smaller or feature-disabled builds. + +MT chat uses `flood.retry.count` (`0`–`15`) to retry when no matching forwarded +copy is heard. A heard repeat stops retries, but is not a recipient delivery +receipt. NodeInfo has at most one retry, only with a nonzero count and +`flood.retry.advert on`, with an extra one-minute wait. For example: + +```text +get flood.retry.count +set flood.retry.count 3 +set flood.retry.advert on +get mt.retry +``` + +`get mt.retry` shows retry/heard/exhausted counters, pending work and the +configured count; it does not change settings. With `set mt.hops 0`, no peer +can forward a copy, so heard-repeat retries are disabled. The original chat +and first-use NodeInfo are unaffected. + +This does not make the Companion relay other people's MT packets. See +[heard-repeat retries](radio_profiles.md#heard-repeat-retries) for the airtime +limits, exact-packet matching and cancellation rules. Local means a connection directly to the node: USB, BLE or WiFi/Ethernet Companion protocol, a TCP terminal, or the browser CLI on the LAN. These diff --git a/examples/companion_radio/MeshtextCompanion.cpp b/examples/companion_radio/MeshtextCompanion.cpp index f13d91294..4a969a09a 100644 --- a/examples/companion_radio/MeshtextCompanion.cpp +++ b/examples/companion_radio/MeshtextCompanion.cpp @@ -65,6 +65,16 @@ bool describe(const ChannelDetails& channel, mesh::mt::Channel& wire) { mesh::Utils::sha256(out, size, in, length); }); } +bool channelFingerprint(const ChannelDetails& channel, uint32_t node, uint8_t out[8]) { + mesh::mt::Channel wire; + if (!describe(channel, wire)) return false; + uint8_t input[48] = {}; + memcpy(input, wire.name, sizeof(wire.name)); + memcpy(input + 12, wire.key, sizeof(wire.key)); + for (unsigned i = 0; i < 4; ++i) input[44 + i] = node >> (8 * i); + mesh::Utils::sha256(out, 8, input, sizeof(input)); + return true; +} bool nextToken(const char*& cursor, char* out, size_t capacity) { while (*cursor == ' ' || *cursor == '\t') ++cursor; size_t size = 0; @@ -87,6 +97,9 @@ mesh::mt::Region MyMesh::meshtextRegion() const { } void MyMesh::refreshMeshtextIdentity() { + for (auto& frame : _mt_delivery) frame.cancelled = true; + memset(_mt_nodeinfo_announced, 0, sizeof(_mt_nodeinfo_announced)); + memset(_mt_nodeinfo_pending, 0, sizeof(_mt_nodeinfo_pending)); uint8_t digest[4]; mesh::Utils::sha256(digest, sizeof(digest), self_id.pub_key, PUB_KEY_SIZE); _mt_default_node = uint32_t(digest[0]) | (uint32_t(digest[1]) << 8) @@ -194,7 +207,13 @@ bool MyMesh::saveMeshtextSettings(mesh::mt::Region region, uint32_t slot, uint8_ refreshMeshtextProfile(); return false; } + const bool announcement_changed = region != _mt_region || slot != _mt_slot + || node_override != _mt_node_override || strcmp(short_name, _mt_short_name) != 0; _mt_region = region; _mt_slot = slot; _mt_hop_limit = hops; _mt_settings_valid = true; + for (auto& frame : _mt_delivery) frame.cancelled = true; + if (announcement_changed) + memset(_mt_nodeinfo_announced, 0, sizeof(_mt_nodeinfo_announced)); + memset(_mt_nodeinfo_pending, 0, sizeof(_mt_nodeinfo_pending)); memcpy(_mt_short_name, image + 12, sizeof(_mt_short_name)); _mt_node_override = node_override; _mt_node = node_override ? node_override : _mt_default_node; @@ -207,6 +226,8 @@ bool MyMesh::saveMeshtextSettings(mesh::mt::Region region, uint32_t slot, uint8_ } bool MyMesh::eraseMeshtextSettings() { + for (auto& frame : _mt_delivery) frame.cancelled = true; + memset(_mt_nodeinfo_pending, 0, sizeof(_mt_nodeinfo_pending)); // The filesystem erase also removes channels/identity on the next boot. // Do not keep sending with their still-resident RAM copies until then. _mt_active = false; @@ -387,6 +408,136 @@ void MyMesh::refreshMeshtextProfile() { _mt_status = _mt_active ? "active" : "radio2 manually off, leased, scheduled or busy"; } +int MyMesh::meshtextChannelIndex(const ChannelDetails& channel, uint8_t fingerprint[8]) { + if (!channelFingerprint(channel, _mt_node, fingerprint)) return -1; + for (int i = 0; i < MAX_GROUP_CHANNELS; ++i) { + ChannelDetails candidate; + uint8_t other[8]; + if (BaseChatMesh::getChannel(i, candidate) + && channelFingerprint(candidate, _mt_node, other) + && memcmp(other, fingerprint, sizeof(other)) == 0) return i; + } + return -1; +} + +bool MyMesh::meshtextDeliveryCurrent(const mesh::mt::PendingFrame& frame) { + const auto* profiles = _radio->profiles(); + if (frame.cancelled || !_mt_active || !profiles || !profiles->canTransmit(1) + || profiles->generation[1] != frame.generation + || profiles->secondary.params.protocol != mesh::RadioProfileProtocol::Meshtext) return false; + ChannelDetails channel; + uint8_t fingerprint[8]; + return BaseChatMesh::getChannel(frame.channel, channel) + && channelFingerprint(channel, _mt_node, fingerprint) + && memcmp(frame.fingerprint, fingerprint, sizeof(fingerprint)) == 0; +} + +uint8_t MyMesh::meshtextRetryLimit(const mesh::mt::PendingFrame& frame) const { + // These are local, zero-hop origins: any general path gate admits them. + // GRP_DATA-only gates do not apply to channel text. No forwarding echo can + // exist when mt.hops is zero, so don't force all configured duplicates. + if (!(frame.bytes[12] & 7)) return 0; + const uint8_t count = _prefs.flood_retry_attempts > 15 ? 15 : _prefs.flood_retry_attempts; + return frame.nodeinfo ? (_prefs.flood_retry_advert_enabled && count ? 1 : 0) : count; +} + +uint32_t MyMesh::meshtextRetryDelay(const mesh::mt::PendingFrame& frame) { + const uint64_t airtime = _radio->getProfileAirtime(1, frame.length); + // Same conservative flood wait/jitter and one-minute metadata extension, + // using the FOREIGN frame's real length and secondary PHY, not MC framing. + uint64_t delay = _radio->getProfileAirtime(1, mesh::mt::MaxFrameSize) + + 20 * airtime + airtime * getRNG()->nextInt(0, 201) / 100; + if (frame.nodeinfo) delay += 60000; + if (!delay) delay = 1; + return delay > 0x7fffffffULL ? 0x7fffffffU : static_cast(delay); +} + +bool MyMesh::queueMeshtextDelivery(const ChannelDetails& channel, const uint8_t* bytes, + size_t length, bool nodeinfo) { + uint8_t fingerprint[8]; + const int index = meshtextChannelIndex(channel, fingerprint); + if (index < 0 || !bytes || length <= mesh::mt::HeaderSize + || length > mesh::mt::MaxFrameSize || hasForeignRadioFrame()) return false; + // Metadata has a reserved slot so a burst of chats awaiting echoes cannot + // prevent the first-use announcement. Backpressure is explicit, never a + // successful admission which silently drops retry state. + const unsigned first = nodeinfo ? mesh::mt::ChatDeliverySlots : 0; + const unsigned end = nodeinfo ? mesh::mt::DeliverySlots : mesh::mt::ChatDeliverySlots; + for (unsigned i = first; i < end; ++i) { + auto& frame = _mt_delivery[i]; + if (frame.length) continue; + const auto* profiles = _radio->profiles(); + if (!profiles || !queueForeignRadioFrame(bytes, length, 1, profiles->generation[1])) return false; + frame = {}; + memcpy(frame.bytes, bytes, length); + memcpy(frame.fingerprint, fingerprint, sizeof(fingerprint)); + frame.length = length; frame.channel = index; frame.nodeinfo = nodeinfo; + frame.generation = profiles->generation[1]; + _mt_tx_slot = i; + if (memcmp(_mt_nodeinfo_fingerprint[index], fingerprint, sizeof(fingerprint))) { + memcpy(_mt_nodeinfo_fingerprint[index], fingerprint, sizeof(fingerprint)); + _mt_nodeinfo_announced[index] = _mt_nodeinfo_pending[index] = false; + } + return true; + } + return false; +} + +void MyMesh::loopMeshtextDelivery() { + const auto* profiles = _radio->profiles(); + const uint32_t now = millis(); + const bool usable = _mt_active && profiles && profiles->canTransmit(1) + && profiles->secondary.params.protocol == mesh::RadioProfileProtocol::Meshtext; + if (!usable || profiles->generation[1] != _mt_nodeinfo_generation) { + memset(_mt_nodeinfo_pending, 0, sizeof(_mt_nodeinfo_pending)); + } + for (unsigned i = 0; i < mesh::mt::DeliverySlots; ++i) { + auto& frame = _mt_delivery[i]; + if (!frame.length) continue; + if (!usable || profiles->generation[1] != frame.generation + || (_mt_delivery_config_dirty && !meshtextDeliveryCurrent(frame))) frame.cancelled = true; + if (frame.cancelled || (frame.sent && !meshtextRetryLimit(frame))) { + if (_mt_tx_slot == static_cast(i)) { + frame.cancelled = true; + discardQueuedForeignRadioFrame(mesh::RadioProfileProtocol::Meshtext); + } else frame.clear(); + continue; + } + } + _mt_delivery_config_dirty = false; + 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) { + if (!_mt_nodeinfo_pending[i]) continue; + ChannelDetails channel; + uint8_t fingerprint[8]; + if (!BaseChatMesh::getChannel(i, channel) + || !channelFingerprint(channel, _mt_node, fingerprint) + || memcmp(fingerprint, _mt_nodeinfo_fingerprint[i], sizeof(fingerprint))) { + _mt_nodeinfo_pending[i] = false; + continue; + } + if (_mt_delivery[mesh::mt::ChatDeliverySlots].length) break; + if (sendMeshtextNodeInfo(channel)) return; + // Invalid names/admission failure must not become an unbounded automatic + // transmit/encoding loop. Another successfully sent chat can try again. + _mt_nodeinfo_pending[i] = false; + } + for (unsigned i = 0; i < mesh::mt::DeliverySlots; ++i) { + auto& frame = _mt_delivery[i]; + if (!frame.length || !frame.sent || static_cast(now - frame.due) < 0) continue; + if (!meshtextDeliveryCurrent(frame)) { frame.clear(); continue; } + if (frame.retries >= meshtextRetryLimit(frame)) { + ++_mt_exhausted; frame.clear(); continue; + } + if (queueForeignRadioFrame(frame.bytes, frame.length, 1, frame.generation)) { + ++frame.retries; _mt_tx_slot = i; + } + return; // Queue contention doesn't consume an attempt or spin. + } +} + bool MyMesh::sendMeshtextText(const ChannelDetails& channel, const char* text, size_t length) { refreshMeshtextProfile(); if (!_mt_active || !text || !length || hasForeignRadioFrame()) return false; @@ -396,7 +547,7 @@ bool MyMesh::sendMeshtextText(const ChannelDetails& channel, const char* text, s mesh::mt::Channel wire; if (!describe(channel, wire)) return false; // Stock TEXT_MESSAGE_APP contains the user's text only. Sender identity is - // in the radio header; name metadata requires a separately requested User. + // in the radio header; first-use name metadata is a separate User packet. if (length > mesh::mt::MaxTextSize) return false; if (!_mt_packet_id) return false; // exhaustion: do not wrap/reuse CTR nonces. const uint32_t id = _mt_packet_id++; @@ -405,7 +556,7 @@ bool MyMesh::sendMeshtextText(const ChannelDetails& channel, const char* text, s // This does not enable forwarding or ACK responses in this Companion. const size_t size = mesh::mt::encodeText(_mt_node, id, _mt_hop_limit, wire.name, wire.key, wire.key_length, text, length, raw); - return size && queueForeignRadioFrame(raw, size, 1, profiles->generation[1]); + return size && queueMeshtextDelivery(channel, raw, size, false); } bool MyMesh::sendMeshtextNodeInfo(const ChannelDetails& channel) { @@ -428,8 +579,8 @@ bool MyMesh::sendMeshtextNodeInfo(const ChannelDetails& channel) { uint8_t raw[mesh::mt::MaxFrameSize]; const size_t size = mesh::mt::encodeNodeInfo(_mt_node, id, _mt_hop_limit, wire.name, wire.key, wire.key_length, _prefs.node_name, long_length, short_name, strlen(short_name), raw); - // Only the explicit CLI reaches here. No timer, RX reply or chat piggyback. - return size && queueForeignRadioFrame(raw, size, 1, profiles->generation[1]); + // Explicit CLI or first successfully transmitted chat, never a GPS packet. + return size && queueMeshtextDelivery(channel, raw, size, true); } bool MyMesh::onForeignRadioFrame(const uint8_t* bytes, size_t length, uint8_t profile, @@ -443,6 +594,17 @@ bool MyMesh::onForeignRadioFrame(const uint8_t* bytes, size_t length, uint8_t pr (void)rssi; const auto* profiles = _radio->profiles(); if (!_mt_active || !profiles || profile != 1 || generation != profiles->generation[1]) return false; + for (unsigned i = 0; i < mesh::mt::DeliverySlots; ++i) { + auto& frame = _mt_delivery[i]; + if (frame.generation != generation || !frame.matchesEcho(bytes, length) + || !meshtextDeliveryCurrent(frame)) continue; + if (!frame.heard) ++_mt_heard; + frame.heard = true; + if (_mt_tx_slot == static_cast(i)) + discardQueuedForeignRadioFrame(mesh::RadioProfileProtocol::Meshtext); + else frame.clear(); + return true; // Own echo is never displayed, forwarded or ACKed. + } for (int i = 0; i < MAX_GROUP_CHANNELS; ++i) { ChannelDetails channel; mesh::mt::Channel wire; @@ -477,8 +639,31 @@ void MyMesh::onForeignRadioSendComplete(bool success) { return; } #endif - if (success) ++_mt_sent; - else ++_mt_failed; + const int slot = _mt_tx_slot; + _mt_tx_slot = -1; + if (slot < 0 || slot >= static_cast(mesh::mt::DeliverySlots)) { + if (success) ++_mt_sent; else ++_mt_failed; + return; // e.g. an explicitly configured text beacon, not delivery work. + } + auto& frame = _mt_delivery[slot]; + if (frame.nodeinfo && !success) _mt_nodeinfo_pending[frame.channel] = false; + if (success) { + ++_mt_sent; + if (frame.retries) ++_mt_retried; + } + else if (!frame.heard && !frame.cancelled) ++_mt_failed; + if (!success || frame.cancelled || !meshtextDeliveryCurrent(frame)) { frame.clear(); return; } + frame.sent = true; + const int index = frame.channel; + if (frame.nodeinfo) { + _mt_nodeinfo_announced[index] = true; + _mt_nodeinfo_pending[index] = false; + } else if (!frame.retries && !_mt_nodeinfo_announced[index]) { + _mt_nodeinfo_pending[index] = true; + _mt_nodeinfo_generation = frame.generation; + } + if (frame.heard || !meshtextRetryLimit(frame)) frame.clear(); + else frame.due = millis() + meshtextRetryDelay(frame); } bool MyMesh::handleMeshtextCommand(const char* command, char* reply, size_t capacity) { @@ -495,7 +680,7 @@ bool MyMesh::handleMeshtextCommand(const char* command, char* reply, size_t capa } else if (!sendMeshtextNodeInfo(channel)) { snprintf(reply, capacity, "Error: NodeInfo not queued; check get mt.status, TX mode or pending TX"); } else { - snprintf(reply, capacity, "OK - NodeInfo queued on Meshtext channel %lu; no automatic adverts", + snprintf(reply, capacity, "OK - NodeInfo queued on Meshtext channel %lu; no GPS", (unsigned long)index); } return true; @@ -575,6 +760,14 @@ bool MyMesh::handleMeshtextCommand(const char* command, char* reply, size_t capa return true; } const bool status = !strcmp(command, "get mt") || !strcmp(command, "get mt.status"); + if (!strcmp(command, "get mt.retry")) { + unsigned pending = 0; + for (const auto& frame : _mt_delivery) if (frame.length) ++pending; + snprintf(reply, capacity, "> retry=%lu heard=%lu exhausted=%lu pending=%u count=%u advert=%s", + (unsigned long)_mt_retried, (unsigned long)_mt_heard, (unsigned long)_mt_exhausted, + pending, _prefs.flood_retry_attempts, _prefs.flood_retry_advert_enabled ? "on" : "off"); + return true; + } const bool get_region = !strcmp(command, "get mt.region"); const bool get_slot = !strcmp(command, "get mt.slot"); const bool get_hops = !strcmp(command, "get mt.hops"); @@ -643,7 +836,7 @@ bool MyMesh::handleMeshtextCommand(const char* command, char* reply, size_t capa _mt_node_override ? "saved override" : "identity default"); else if (get_short_name) { char short_name[5]; makeWireShortName(_mt_short_name, _mt_node, short_name); - snprintf(reply, capacity, "> %s; effective=%s (manual NodeInfo only)", + snprintf(reply, capacity, "> %s; effective=%s (first-chat/manual NodeInfo)", _mt_short_name[0] ? _mt_short_name : "default", short_name); } else { diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index a692c665d..e60c34b43 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1722,6 +1722,9 @@ bool MyMesh::setChannel(int channel_idx, const ChannelDetails& channel) { && parseCompanionChannelAlias(channel.name).kind != CompanionChannelKind::MeshCore) return false; #endif const bool saved = BaseChatMesh::setChannel(channel_idx, channel); +#if MESH_ENABLE_MESHTEXT_CHAT + if (saved) _mt_delivery_config_dirty = true; +#endif #if MESH_ENABLE_ETHERNET_TUNNEL if (saved) ++_ethernet_channel_generation; #endif @@ -11272,6 +11275,7 @@ void MyMesh::loop() { #endif #if MESH_ENABLE_MESHTEXT_CHAT refreshMeshtextProfile(); + if (!mesh::morseTimingActive(_radio)) loopMeshtextDelivery(); #endif #if MESH_ENABLE_ETHERNET_TUNNEL if (!mesh::morseTimingActive(_radio)) loopEthernetTunnel(); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index cfddf1d83..95d704220 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -36,6 +36,7 @@ namespace mesh { class BeaconManager; } #if MESH_ENABLE_MESHTEXT_CHAT #include +#include #endif #include #include "NodePrefs.h" @@ -430,6 +431,13 @@ protected: bool eraseMeshtextSettings(); bool sendMeshtextText(const ChannelDetails& channel, const char* text, size_t length); bool sendMeshtextNodeInfo(const ChannelDetails& channel); + bool queueMeshtextDelivery(const ChannelDetails& channel, const uint8_t* bytes, + size_t length, bool nodeinfo); + int meshtextChannelIndex(const ChannelDetails& channel, uint8_t fingerprint[8]); + bool meshtextDeliveryCurrent(const mesh::mt::PendingFrame& pending); + uint8_t meshtextRetryLimit(const mesh::mt::PendingFrame& pending) const; + uint32_t meshtextRetryDelay(const mesh::mt::PendingFrame& pending); + void loopMeshtextDelivery(); mesh::mt::Region meshtextRegion() const; #endif bool queueChannelText(uint8_t index, const uint8_t secret[32], float snr, @@ -754,6 +762,14 @@ private: uint8_t _mt_hop_limit = 7; uint32_t _mt_sent = 0, _mt_failed = 0, _mt_received = 0; mesh::mt::SeenPackets _mt_seen; + mesh::mt::PendingFrame _mt_delivery[mesh::mt::DeliverySlots]; + int8_t _mt_tx_slot = -1; + uint8_t _mt_nodeinfo_fingerprint[MAX_GROUP_CHANNELS][8] = {}; + bool _mt_nodeinfo_announced[MAX_GROUP_CHANNELS] = {}; + bool _mt_nodeinfo_pending[MAX_GROUP_CHANNELS] = {}; + uint32_t _mt_nodeinfo_generation = 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; uint32_t _mt_primary_generation = 0, _mt_secondary_generation = 0, _mt_intent_generation = 0; diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index c5d5b8177..8b26a6714 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -498,7 +498,8 @@ bool Dispatcher::checkForeignRadioSend() { foreign_radio_start = _ms->getMillis(); if (!_radio->startSendRaw(foreign_radio_bytes, foreign_radio_length)) { // RadioLib owns failure cleanup (including the board's FEM). Unlike - // MeshCore reliable routing, foreign chat never retries autonomously. + // MeshCore reliable routing, this transport never retries autonomously. + // Companion may later submit a separately validated retained chat copy. finishForeignRadioFrame(false, false); return false; } diff --git a/src/helpers/MeshtextChat.h b/src/helpers/MeshtextChat.h index 11859b343..be0574ad2 100644 --- a/src/helpers/MeshtextChat.h +++ b/src/helpers/MeshtextChat.h @@ -8,7 +8,7 @@ // Bounded adaptation of MIT-licensed esp-cpp/espp wire helpers. // See licenses/ESPP-Meshtext-MIT.txt for the pinned source and license. // Meshtext is a bounded text interoperability component, not a peer firmware fork. -// NodeInfo may only be explicitly encoded; received NodeInfo stays ignored. +// NodeInfo is a separate GPS-free User packet; received NodeInfo stays ignored. namespace mesh { namespace mt { @@ -47,7 +47,7 @@ struct DecodedText { // Expanded AES-128/256 PSK only. No unencrypted-key fallback. Names are exact, // canonical peer channel names (<12 bytes), without local display markers. -// Outgoing text and explicit NodeInfo permit peer MQTT upload (Data.bitfield +// Outgoing text and NodeInfo permit peer MQTT upload (Data.bitfield // bit 0); this neither enables a local MQTT client nor marks traffic via_mqtt. // Returns 0 for invalid arguments, malformed UTF-8, embedded NUL or overflow. size_t encodeText(uint32_t from, uint32_t id, uint8_t hop_limit, const char* channel_name, @@ -59,7 +59,8 @@ size_t encodeText(uint32_t from, uint32_t id, uint8_t hop_limit, const char* cha // names may be empty; short names require 1..4 UTF-8 bytes (stock wire bound). // This can hold four ASCII characters but fewer multi-byte characters. Longer // names are rejected, not byte-truncated. No MAC, PSK, public key or MC identity -// is copied. No ACK/response is requested and no automatic send is performed. +// is copied. No GPS/position or ACK/response is requested. The caller controls +// explicit and first-successful-chat announcements and bounded retries. size_t encodeNodeInfo(uint32_t from, uint32_t id, uint8_t hop_limit, const char* channel_name, const uint8_t* key, size_t key_len, const char* long_name, size_t long_name_len, const char* short_name, size_t short_name_len, uint8_t out[MaxFrameSize]); diff --git a/src/helpers/MeshtextDelivery.h b/src/helpers/MeshtextDelivery.h new file mode 100644 index 000000000..6e2d27a76 --- /dev/null +++ b/src/helpers/MeshtextDelivery.h @@ -0,0 +1,34 @@ +#pragma once + +#include "MeshtextChat.h" +#include + +namespace mesh { namespace mt { + +// Three retained chats and one metadata slot. No heap, packet-pool entries, +// keys, positions or forwarding state are allocated by the delivery policy. +struct PendingFrame { + uint8_t bytes[MaxFrameSize] = {}; + uint8_t fingerprint[8] = {}; + uint32_t generation = 0, due = 0; + uint16_t length = 0; + uint8_t channel = 0, retries = 0; + bool nodeinfo = false, sent = false, heard = false, cancelled = false; + + void clear() { length = 0; } + bool matchesEcho(const uint8_t* raw, size_t size) const { + // The sender/id/channel/ciphertext are immutable. Only remaining hops + // and next-hop/relay bytes may change. A copy at the original hop limit + // is not evidence that another node forwarded the transmission. + return length && sent && raw && size == length && size > HeaderSize + && memcmp(raw, bytes, 12) == 0 && raw[13] == bytes[13] + && (raw[12] & 0xf8) == (bytes[12] & 0xf8) + && (raw[12] & 7) < (bytes[12] & 7) + && memcmp(raw + HeaderSize, bytes + HeaderSize, size - HeaderSize) == 0; + } +}; + +static constexpr unsigned DeliverySlots = 4; +static constexpr unsigned ChatDeliverySlots = DeliverySlots - 1; + +} } // namespace mesh::mt diff --git a/test/fixtures/companion_meshtext.cpp b/test/fixtures/companion_meshtext.cpp index c37852744..4b1cf271a 100644 --- a/test/fixtures/companion_meshtext.cpp +++ b/test/fixtures/companion_meshtext.cpp @@ -3,6 +3,7 @@ @FIXTURE@ #include #include +#include #include #include #include @@ -60,6 +61,7 @@ struct BaseChatMesh { struct MtRadio : Radio { bool supported=true; bool supportsMeshtext() const override { return supported; } + uint32_t getProfileAirtime(uint8_t,int,uint8_t=0) override { return 10; } }; struct Serial { bool connected=false; @@ -80,7 +82,11 @@ class MyMesh : public BaseChatMesh { public: MemoryFS fs; Clock clock; MtRadio radio; mesh::Radio* _radio=&radio; mesh::RadioProfileCLI _radio_profiles; - struct {float freq=909.5; char node_name[32]="Test";} _prefs; + struct { + float freq=909.5; char node_name[32]="Test"; + uint8_t flood_retry_attempts=15,flood_retry_max_path=1; + uint8_t flood_retry_group_max_path=0xff,flood_retry_advert_enabled=1; + } _prefs; mesh::LocalIdentity self_id; mesh::mt::Region _mt_region=mesh::mt::Region::Unset; uint32_t _mt_slot=0,_mt_node=0,_mt_packet_id=0,_mt_sent=0,_mt_failed=0,_mt_received=0; @@ -88,10 +94,22 @@ public: uint8_t _mt_hop_limit=7; char _mt_short_name[17]={}; mesh::mt::SeenPackets _mt_seen; + mesh::mt::PendingFrame _mt_delivery[mesh::mt::DeliverySlots]; + int8_t _mt_tx_slot=-1; + 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; + struct Random { + uint32_t jitter=0; + uint32_t nextInt(uint32_t low,uint32_t high) { + assert(low==0 && high==201 && jitter> foreign,offline; @@ -116,6 +134,12 @@ public: void refreshMeshtextProfile(); bool sendMeshtextText(const ChannelDetails&,const char*,size_t); bool sendMeshtextNodeInfo(const ChannelDetails&); + bool queueMeshtextDelivery(const ChannelDetails&,const uint8_t*,size_t,bool); + int meshtextChannelIndex(const ChannelDetails&,uint8_t[8]); + bool meshtextDeliveryCurrent(const mesh::mt::PendingFrame&); + uint8_t meshtextRetryLimit(const mesh::mt::PendingFrame&) const; + uint32_t meshtextRetryDelay(const mesh::mt::PendingFrame&); + void loopMeshtextDelivery(); bool onForeignRadioFrame(const uint8_t*,size_t,uint8_t,uint32_t,float,float); void onForeignRadioSendComplete(bool); bool handleMeshtextCommand(const char*,char*,size_t); @@ -126,7 +150,25 @@ public: ++foreign_admissions; foreign.emplace_back(bytes,bytes+size); foreign_profile=profile; foreign_generation=generation; foreign_pending=true; return true; } + void completeForeign(bool success=true) { + assert(foreign_pending); + // The real Dispatcher releases queue ownership before the application + // completion callback. Successful RF TX is not a delivery acknowledgement. + foreign_pending=false; + foreign_sending=false; + onForeignRadioSendComplete(success); + } + bool discardQueuedForeignRadioFrame(mesh::RadioProfileProtocol protocol) { + assert(protocol==mesh::RadioProfileProtocol::Meshtext); + if(!foreign_pending || foreign_sending) return false; + completeForeign(false); return true; + } + void releaseForeign() { + if(foreign_pending) completeForeign(false); + loopMeshtextDelivery(); + } mesh::RTCClock* getRTCClock() { return &clock; } + Random* getRNG() { return &random; } bool addToOfflineQueue(const uint8_t* bytes,int size) { assert(size>=0 && size<=MAX_FRAME_SIZE); if(offline.size()>=offline_capacity) return false; offline.emplace_back(bytes,bytes+size); return true; @@ -146,7 +188,10 @@ public: } return reply; } - void advance(uint32_t ms) { g_mock_millis+=ms; clock.epoch+=ms/1000; _radio_profiles.loop(); refreshMeshtextProfile(); } + void advance(uint32_t ms) { + g_mock_millis+=ms; clock.epoch+=ms/1000; + _radio_profiles.loop(); refreshMeshtextProfile(); loopMeshtextDelivery(); + } }; @METHODS@ void MyMesh::command(uint8_t* cmd_frame,int len) { @@ -183,7 +228,7 @@ static void keys() { auto c=channel("#mt-longfast",size); const auto before=c; assert(describe(c,wire) && wire.key_length==size && !memcmp(wire.key,c.channel.secret,32)); assert(!memcmp(c.channel.secret,before.channel.secret,32)); - node.foreign_pending=false; assert(node.sendMeshtextText(c,"private",7)); + node.releaseForeign(); node.commit(0,c); assert(node.sendMeshtextText(c,"private",7)); mesh::mt::DecodedText decoded; assert(mesh::mt::decodeText(node.foreign.back().data(),node.foreign.back().size(),wire.name,wire.key,size,1,decoded)); assert(!strcmp(decoded.text,"private")); @@ -364,7 +409,7 @@ static void transmit() { assert(frame[12]==0xe7); // limit/start 7, no want-ACK or MQTT flag. assert(!mesh::mt::decodeText(frame.data(),frame.size(),"LongFast",mesh::mt::DefaultPsk,16,node._mt_node,decoded)); node.command(request,12); assert(!node.ok && node.error==ERR_CODE_BAD_STATE && node.foreign.size()==1); - node.foreign_pending=false; node.onForeignRadioSendComplete(true); node.onForeignRadioSendComplete(false); + node.completeForeign(); node.onForeignRadioSendComplete(false); assert(node._mt_sent==1 && node._mt_failed==1); char oversized[229]; memset(oversized,'x',sizeof(oversized)); assert(!node.sendMeshtextText(node.stored[0],oversized,sizeof(oversized))); @@ -538,13 +583,13 @@ static void hops() { assertUntouched(migrated); assertUntouched(migrated_reboot); puts("PASS hops"); } static mesh::mt::DecodedText decodeOwnText(MyMesh& node,const char* text,size_t length) { - node.foreign_pending=false; + node.releaseForeign(); const uint32_t expected_packet=node._mt_packet_id; assert(node.sendMeshtextText(node.stored[0],text,length)); const auto& raw=node.foreign.back(); mesh::mt::DecodedText decoded; assert(mesh::mt::decodeText(raw.data(),raw.size(),"LongFast",mesh::mt::DefaultPsk,16,1,decoded)); assert(decoded.from==node._mt_node && decoded.id==expected_packet && node._mt_packet_id==expected_packet+1); - node.foreign_pending=false; return decoded; + node.releaseForeign(); return decoded; } static void shortname() { Preferences::reset(); MyMesh node; active(node); char reply[256]; @@ -742,7 +787,7 @@ static void nodeinfo() { assertUntouched(node); assert(node.handleMeshtextCommand("get mt.shortname",reply,sizeof(reply))); char fallback[5]; snprintf(fallback,sizeof(fallback),"%04lx",(unsigned long)(node._mt_node&0xffff)); - assert(strstr(reply,fallback) && strstr(reply,"manual NodeInfo only")); + assert(strstr(reply,fallback) && strstr(reply,"first-chat/manual NodeInfo")); const uint8_t expected_default[]=@NODEINFO_DEFAULT@,expected_named[]=@NODEINFO_NAMED@, expected_utf8[]=@NODEINFO_UTF8@,expected_private[]=@NODEINFO_PRIVATE@; const auto original_settings=Preferences::blob; const auto original_writes=Preferences::writes; @@ -755,13 +800,13 @@ static void nodeinfo() { assert(node._mt_packet_id==pending_id && node.foreign.size()==pending_count); assert(!node.sendMeshtextText(node.stored[0],"busy",4)); assert(!node.saveMeshtextSettings(mesh::mt::Region::US,0)); - node.foreign_pending=false; + node.releaseForeign(); assert(node.handleMeshtextCommand("set mt.node !12345678",reply,sizeof(reply)) && !strncmp(reply,"OK",2)); assert(node.handleMeshtextCommand("set mt.shortname MIKE",reply,sizeof(reply)) && !strncmp(reply,"OK",2)); strcpy(node._prefs.node_name,"Mike Carper"); assert(node.handleMeshtextCommand("send mt.nodeinfo 0",reply,sizeof(reply)) && strstr(reply,"queued")); assert(node.foreign.back()==std::vector(expected_named,expected_named+sizeof(expected_named))); - node.foreign_pending=false; + node.releaseForeign(); auto text=decodeOwnText(node,"hello",5); assert(text.id==0x1234567a && !strcmp(text.text,"hello")); const char* emojis="\xf0\x9f\x98\x80\xf0\x9f\x98\x80\xf0\x9f\x98\x80\xf0\x9f\x98\x80"; @@ -771,10 +816,10 @@ static void nodeinfo() { assert(node.handleMeshtextCommand("send mt.nodeinfo 0",reply,sizeof(reply)) && strstr(reply,"queued")); assert(node.foreign.back()==std::vector(expected_utf8,expected_utf8+sizeof(expected_utf8))); assert(strlen(node._prefs.node_name)==29 && strlen(node._mt_short_name)==16); // no mutation - node.foreign_pending=false; node.commit(1,channel("mt-Team",32)); + node.releaseForeign(); node.commit(1,channel("mt-Team",32)); assert(node.handleMeshtextCommand("send mt.nodeinfo 1",reply,sizeof(reply)) && strstr(reply,"queued")); assert(node.foreign.back()==std::vector(expected_private,expected_private+sizeof(expected_private))); - node.foreign_pending=false; + node.releaseForeign(); const auto count=node.foreign.size(); const uint32_t packet=node._mt_packet_id; node.commit(2,channel("ordinary")); for(const char* bad:{"send mt.nodeinfo","send mt.nodeinfo ","send mt.nodeinfo -1", @@ -805,13 +850,287 @@ static void nodeinfo() { node.cli("set radio2 auto"); node.advance(2000); node._mt_packet_id=UINT32_MAX; assert(node.handleMeshtextCommand("send mt.nodeinfo 0",reply,sizeof(reply)) && strstr(reply,"queued")); - node.foreign_pending=false; + node.releaseForeign(); assert(node._mt_packet_id==0 && !node.sendMeshtextText(node.stored[0],"done",4)); assert(node.handleMeshtextCommand("send mt.nodeinfo 0",reply,sizeof(reply)) && !strncmp(reply,"Error",5)); MyMesh reboot; reboot.stored[0]=node.stored[0]; reboot.beginMeshtext(); assertUntouched(reboot); assert(!node.meshcore_sends); puts("PASS nodeinfo"); } +static mesh::mt::PendingFrame* pendingChat(MyMesh& node) { + for(unsigned i=0;i(due-millis()); + node.advance(delta>0?static_cast(delta):0); +} +static std::vector repeated(const std::vector& original) { + auto echo=original; + assert((echo[12]&7)>0); + echo[12]=static_cast((echo[12]&0xf8)|((echo[12]&7)-1)); + echo[14]=0x42; echo[15]=0x43; // Forwarder-controlled header fields. + return echo; +} +static void announce(MyMesh& node) { + node._prefs.flood_retry_attempts=0; + assert(node.sendMeshtextNodeInfo(node.stored[0])); + node.completeForeign(); node.advance(0); + assert(!node.foreign_pending); + node._prefs.flood_retry_attempts=15; +} +static void automaticNodeinfo() { + const uint8_t expected[]=@NODEINFO_AFTER_CHAT@; + Preferences::reset(); MyMesh node; active(node); + node._prefs.flood_retry_attempts=0; + node.advance(3600000); assertUntouched(node); + assert(node.sendMeshtextText(node.stored[0],"hello",5)); + 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); + assert(node.foreign.size()==2 && node.foreign.back()==std::vector(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. + node.completeForeign(); node.advance(3600000); assert(node.foreign.size()==2); + assert(node.sendMeshtextText(node.stored[0],"again",5)); + 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); + auto private_info=node.foreign.back(); + mesh::mt::DecodedText private_decoded; + assert(private_info!=text && !mesh::mt::decodeText(private_info.data(),private_info.size(), + "Team",node.stored[1].channel.secret,32,1,private_decoded)); + node.completeForeign(); node.advance(3600000); assert(node.foreign.size()==5); + assert(node.offline.empty() && !node.meshcore_sends && !node._notifications.calls); + + Preferences::reset(); MyMesh failure; active(failure); failure._prefs.flood_retry_attempts=0; + failure.admit_foreign=false; assert(!failure.sendMeshtextText(failure.stored[0],"rejected",8)); + failure.admit_foreign=true; failure.advance(1); assertUntouched(failure); + 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(); + + Preferences::reset(); MyMesh manual; active(manual); announce(manual); + manual._prefs.flood_retry_attempts=0; + assert(manual.sendMeshtextText(manual.stored[0],"already named",13)); + manual.completeForeign(); manual.advance(100000); assert(manual.foreign.size()==2); + puts("PASS auto-nodeinfo"); +} +static void heardRetries() { + Preferences::reset(); MyMesh node; active(node); announce(node); + node._prefs.flood_retry_attempts=2; + assert(node.sendMeshtextText(node.stored[0],"retry this",10)); + const auto original=node.foreign.back(); const auto packet=node._mt_packet_id; + const auto start=node.foreign.size(); + node.completeForeign(); auto* pending=pendingChat(node); assert(pending); + uint32_t due=pending->due; + node.advance(static_cast(due-millis()-1)); assert(node.foreign.size()==start); + node.advance(1); assert(node.foreign.size()==start+1 && node.foreign.back()==original); + assert(node._mt_packet_id==packet && node._mt_retried==0); + node.completeForeign(); assert(node._mt_retried==1); pending=pendingChat(node); assert(pending); + advanceTo(node,pending->due); + assert(node.foreign.size()==start+2 && node.foreign.back()==original && node._mt_retried==1); + node.completeForeign(); assert(node._mt_retried==2); pending=pendingChat(node); assert(pending); // Final echo window remains. + auto echo=repeated(original); + assert(receiveFrame(node,echo.data(),echo.size())); + assert(node._mt_heard==1 && !node._mt_exhausted && node.offline.empty()); + node.advance(3600000); assert(node.foreign.size()==start+2 && !pendingChat(node)); + + Preferences::reset(); MyMesh capped; active(capped); announce(capped); + capped._prefs.flood_retry_attempts=15; + assert(capped.sendMeshtextText(capped.stored[0],"bounded",7)); + const auto cap_original=capped.foreign.back(); capped.completeForeign(); + for(unsigned i=0;i<15;++i) { + auto* current=pendingChat(capped); assert(current); + advanceTo(capped,current->due); + assert(capped.foreign_pending && capped.foreign.back()==cap_original); + capped.completeForeign(); + } + assert(capped._mt_retried==15 && capped.foreign.size()==17); + auto* current=pendingChat(capped); assert(current); advanceTo(capped,current->due); + assert(capped._mt_exhausted==1 && !pendingChat(capped)); + capped.advance(3600000); assert(capped.foreign.size()==17); + + Preferences::reset(); MyMesh disabled; active(disabled); announce(disabled); + disabled._prefs.flood_retry_attempts=0; + assert(disabled.sendMeshtextText(disabled.stored[0],"once",4)); + disabled.completeForeign(); disabled.advance(3600000); + assert(disabled.foreign.size()==2 && !disabled._mt_retried && !pendingChat(disabled)); + disabled._prefs.flood_retry_attempts=15; disabled._mt_hop_limit=0; + assert(disabled.sendMeshtextText(disabled.stored[0],"no hops",7)); + disabled.completeForeign(); disabled.advance(3600000); + assert(disabled.foreign.size()==3 && !disabled._mt_retried && !pendingChat(disabled)); + puts("PASS heard-retries"); +} +static void strictEcho() { + Preferences::reset(); MyMesh node; active(node); announce(node); + assert(node.sendMeshtextText(node.stored[0],"strict echo",11)); + const auto original=node.foreign.back(); auto echo=repeated(original); + node.completeForeign(); const auto count=node.foreign.size(); + receiveFrame(node,original.data(),original.size()); assert(!node._mt_heard); + for(unsigned position:{0U,4U,8U,12U,13U,16U,unsigned(echo.size()-1)}) { + auto wrong=echo; wrong[position]^=position==12?0x20:1; + receiveFrame(node,wrong.data(),wrong.size()); assert(!node._mt_heard); + } + receiveFrame(node,echo.data(),echo.size()-1); assert(!node._mt_heard); + node.onForeignRadioFrame(echo.data(),echo.size(),0,node.radio.p.generation[1],1,-80); + node.onForeignRadioFrame(echo.data(),echo.size(),1,node.radio.p.generation[1]-1,1,-80); + assert(!node._mt_heard && pendingChat(node)); + assert(receiveFrame(node,echo.data(),echo.size()) && node._mt_heard==1); + receiveFrame(node,echo.data(),echo.size()); assert(node._mt_heard==1); + node.advance(100000); assert(node.foreign.size()==count && node.offline.empty()); + puts("PASS strict-echo"); +} +static void deliveryLifecycle() { + for(unsigned change=0;change<7;++change) { + Preferences::reset(); MyMesh node; active(node); announce(node); + assert(node.sendMeshtextText(node.stored[0],"stale",5)); + node.completeForeign(); assert(pendingChat(node)); + const auto count=node.foreign.size(); char reply[256]; + switch(change) { + case 0: ++node.radio.p.generation[1]; break; + case 1: node.cli("set radio2 off"); break; + case 2: node.cli("set radio2 906.875,250,11,5,rx,16"); break; + case 3: node.commit(0,channel("#mt-longfast",32)); break; + case 4: + assert(node.handleMeshtextCommand("set mt.node !12345678",reply,sizeof(reply)) + && !strncmp(reply,"OK",2)); break; + case 5: node.commit(0,{}); break; + case 6: node._radio_available=false; break; + } + node.advance(2000); node.advance(100000); + assert(!pendingChat(node) && !node.foreign_pending && node.foreign.size()==count); + assert(!node._mt_retried && !node._mt_exhausted); + } + // Configuration changes before RF completion cannot arm metadata intent. + Preferences::reset(); MyMesh inflight; active(inflight); + assert(inflight.sendMeshtextText(inflight.stored[0],"not sent",8)); + inflight.commit(0,channel("#mt-longfast",32)); inflight.advance(1); + assert(!inflight.foreign_pending && inflight.foreign.size()==1); + inflight.advance(100000); assert(inflight.foreign.size()==1); + // Config edits and RX by themselves remain completely silent. + inflight.self_id.pub_key[0]=43; inflight.refreshMeshtextIdentity(); inflight.advance(1); + assert(inflight.foreign.size()==1); + puts("PASS delivery-lifecycle"); +} +static void deliveryContention() { + Preferences::reset(); MyMesh node; active(node); announce(node); + assert(node.sendMeshtextText(node.stored[0],"older",5)); + const auto original=node.foreign.back(); node.completeForeign(); + auto* pending=pendingChat(node); assert(pending); const auto due=pending->due; + const auto count=node.foreign.size(); + node.admit_foreign=false; advanceTo(node,due); + assert(node.foreign.size()==count && !node._mt_retried && pending->retries==0); + node.admit_foreign=true; + assert(node.sendMeshtextText(node.stored[0],"newer",5)); // Don't block users for an echo window. + node.completeForeign(); node.advance(0); + assert(node.foreign_pending && node.foreign.back()==original && !node._mt_retried); + const auto failed=node._mt_failed; auto echo=repeated(original); + assert(receiveFrame(node,echo.data(),echo.size())); // Arrives while a retry is queued. + assert(!node.foreign_pending && node._mt_heard==1 && node._mt_failed==failed); + pending=pendingChat(node); assert(pending); advanceTo(node,pending->due); + assert(node.foreign_pending); // Newer chat's own timer still works. + const auto newer=node.foreign.back(); node.foreign_sending=true; + auto newer_echo=repeated(newer); + assert(receiveFrame(node,newer_echo.data(),newer_echo.size())); + assert(node.foreign_pending && node._mt_heard==2); // In-flight RF cannot be discarded. + node.completeForeign(); node.advance(100000); + assert(!node.foreign_pending && !pendingChat(node) && node._mt_failed==failed && node._mt_retried==1); + char reply[256]; assert(node.handleMeshtextCommand("get mt.retry",reply,sizeof(reply))); + assert(strstr(reply,"heard=2") && strstr(reply,"pending=0")); + + Preferences::reset(); MyMesh full; active(full); announce(full); + for(unsigned i=0;idue==109); + const auto count=node.foreign.size(); node.advance(209); + assert(node.foreign.size()==count); node.advance(1); + assert(node.foreign.size()==count+1 && node.foreign.back()==original); + node.completeForeign(); auto echo=repeated(original); + assert(receiveFrame(node,echo.data(),echo.size()) && node._mt_heard==1); + node.advance(100000); assert(!pendingChat(node)); + + Preferences::reset(); MyMesh metadata; active(metadata); metadata.random.jitter=200; + assert(metadata.sendMeshtextNodeInfo(metadata.stored[0])); + const auto info=metadata.foreign.back(); const auto packet=metadata._mt_packet_id; + metadata.completeForeign(); auto& advert=metadata._mt_delivery[mesh::mt::ChatDeliverySlots]; + assert(advert.length && advert.due-millis()==60230); + metadata.advance(60229); assert(metadata.foreign.size()==1); + metadata.advance(1); assert(metadata.foreign.size()==2 && metadata.foreign.back()==info); + assert(metadata._mt_packet_id==packet && metadata._mt_retried==0); + metadata.completeForeign(); assert(metadata._mt_retried==1); advanceTo(metadata,advert.due); + assert(!advert.length && metadata._mt_exhausted==1); + metadata.advance(3600000); assert(metadata.foreign.size()==2); // Metadata cap is one, not 15. + + Preferences::reset(); MyMesh no_advert; active(no_advert); + no_advert._prefs.flood_retry_advert_enabled=0; + assert(no_advert.sendMeshtextNodeInfo(no_advert.stored[0])); + no_advert.completeForeign(); no_advert.advance(3600000); + assert(no_advert.foreign.size()==1 && !no_advert._mt_retried); + puts("PASS delivery-timing"); +} +static void announcementLifecycle() { + Preferences::reset(); MyMesh node; active(node); announce(node); + node._prefs.flood_retry_attempts=0; + char reply[256]; + for(const char* command:{"set mt.hops 7","set mt.hops 3"}) { + assert(node.handleMeshtextCommand(command,reply,sizeof(reply)) && !strncmp(reply,"OK",2)); + node.advance(1); const auto count=node.foreign.size(); + assert(node.sendMeshtextText(node.stored[0],"no reannounce",13)); + node.completeForeign(); node.advance(1); assert(node.foreign.size()==count+1); + } + node.commit(1,channel("#mt-LoNgFaSt")); + const auto before_alias=node.foreign.size(); + assert(node.sendMeshtextText(node.stored[1],"same wire",9)); + node.completeForeign(); node.advance(1); assert(node.foreign.size()==before_alias+1); + 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(); + 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(); + + for(bool fail_admission:{false,true}) { + Preferences::reset(); MyMesh failure; active(failure); + 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); + if(!fail_admission) { + assert(failure.foreign_pending); failure.completeForeign(false); + } + const auto attempts=failure.foreign_admissions,packet=failure._mt_packet_id; + for(unsigned i=0;i<100;++i) failure.advance(1); + assert(!failure.foreign_pending && failure.foreign_admissions==attempts && failure._mt_packet_id==packet); + 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.completeForeign(); + } + puts("PASS announcement-lifecycle"); +} int main(int argc,char** argv) { assert(argc==2); if(!strcmp(argv[1],"keys"))keys(); @@ -827,5 +1146,12 @@ int main(int argc,char** argv) { else if(!strcmp(argv[1],"shortname"))shortname(); 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],"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 assert(!"unknown test case"); } diff --git a/test/test_companion_meshtext.py b/test/test_companion_meshtext.py index 633615767..a643aff91 100644 --- a/test/test_companion_meshtext.py +++ b/test/test_companion_meshtext.py @@ -102,6 +102,8 @@ class CompanionMeshtextTest(unittest.TestCase): vectors.update({ "@NODEINFO_DEFAULT@": nodeinfo_frame(default_node, 0x12345678, b"Test", ("%04x" % (default_node & 0xFFFF)).encode()), + "@NODEINFO_AFTER_CHAT@": nodeinfo_frame(default_node, 0x12345679, b"Test", + ("%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, @@ -200,16 +202,41 @@ class CompanionMeshtextTest(unittest.TestCase): self.assertEqual(imported.count("refreshMeshtextIdentity();"), 1, "the refresh belongs only to the successfully saved identity branch") - def test_manual_nodeinfo_standard_fields_shared_sequence_guards_and_no_automatic_adverts(self): + def test_manual_nodeinfo_standard_fields_shared_sequence_guards_and_no_periodic_adverts(self): self.run_case("nodeinfo") source = (ROOT / "examples/companion_radio/MeshtextCompanion.cpp").read_text(encoding="utf-8") - self.assertEqual(source.count("sendMeshtextNodeInfo("), 2, - "NodeInfo has exactly one caller: the explicit CLI") chat = body(source, "bool MyMesh::sendMeshtextText(") self.assertNotIn("_mt_short_name", chat) self.assertNotIn("node_name", chat) self.assertNotIn("encodeNodeInfo", chat) + def test_first_successful_chat_announces_once_per_channel_with_no_position(self): + self.run_case("auto-nodeinfo") + + def test_heard_repeat_retries_identical_packets_bounded_by_existing_settings(self): + self.run_case("heard-retries") + + def test_only_exact_forwarded_echo_on_the_current_profile_stops_retries(self): + self.run_case("strict-echo") + + def test_profile_channel_key_identity_and_radio_changes_cancel_stale_delivery(self): + self.run_case("delivery-lifecycle") + + def test_busy_queue_new_chat_backpressure_and_queued_vs_inflight_echo(self): + self.run_case("delivery-contention") + + def test_rollover_jitter_origin_path_gates_and_advert_retry_cap(self): + self.run_case("delivery-timing") + + def test_announcement_alias_settings_key_changes_and_failure_do_not_storm(self): + self.run_case("announcement-lifecycle") + + 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()") + self.assertIn("if (!mesh::morseTimingActive(_radio)) loopMeshtextDelivery();", loop) + self.assertLess(loop.index("loopMeshtextDelivery();"), loop.index("BaseChatMesh::loop();")) + if __name__ == "__main__": unittest.main()