From 4aedbc805902074ee2a163890f81b9954b466d0d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 02:09:41 -0700 Subject: [PATCH 1/7] MQTT: tag radio2 (MediaMesh) packets, status and neighbors Observers already publish radio2 packets, but nothing says they came over radio2, so analyzers can't tell MediaMesh traffic or MediaMesh repeaters apart. Add optional keys, all absent on the main radio, so existing consumers see byte-identical messages: - packets and raw: "rx_radio_id"/"tx_radio_id": "radio2" and "mediamesh": true, from the packet's own radio_profile (captured on receive, kept by outbound copies), not the radio's profile at publish time. rx_radio_id is CoreScope's multi-radio key (#1869). - status: "radios": [{id: "radio2", radio, mode, mediamesh, temporary}] while radio2 is on with the MeshCore protocol; "radio" stays the main radio. - neighbors (repeater and room server): the refresh's DISCOVER_REQ sets the radio2 flag (0x02, docs/radio_profiles.md); each neighbour's trailer is kept and published as "radio2" {mode, radio, temporary}, with "self.radio2" for this node. No trailer leaves the key out (unknown, never off). Radio2DiscoverTrailer parses the trailer (host-tested). publishRaw now takes the direction. MQTT_IMPLEMENTATION.md documents the keys. Tests: new payload-builder cases (golden prefix, rx/tx/raw tags, status radios, neighbors radio2, measurements), trailer parsing, and the neighbor lifecycle fixture now checks radio2 end to end for both roles. Harnesses updated for the new field and the publishRaw signature. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011i5tJJtj95MB4oUgncCUe5 --- MQTT_IMPLEMENTATION.md | 44 ++++ examples/simple_repeater/MyMesh.cpp | 72 ++++++- examples/simple_repeater/MyMesh.h | 11 + examples/simple_room_server/MyMesh.cpp | 63 +++++- examples/simple_room_server/MyMesh.h | 11 + src/helpers/MQTTMessageBuilder.cpp | 56 +++-- src/helpers/MQTTMessageBuilder.h | 33 ++- src/helpers/MQTTPayloadBuilder.cpp | 51 ++++- src/helpers/MQTTPayloadBuilder.h | 56 ++++- src/helpers/Radio2DiscoverTrailer.h | 71 +++++++ src/helpers/bridges/MQTTBridge.cpp | 34 ++- src/helpers/bridges/MQTTBridge.h | 3 +- test/fixtures/neighbor_scope_lifecycle.cpp | 24 ++- test/test_mqtt_handshake_scheduler.py | 2 + .../test_mqtt_payload_builder.cpp | 200 +++++++++++++++++- test/test_mqtt_payload_oom.py | 3 +- test/test_mqtt_publish_admission.py | 8 +- test/test_mqtt_slot_status.py | 2 + test/test_neighbor_scope_storage.py | 11 +- .../test_radio2_discover_trailer.cpp | 82 +++++++ 20 files changed, 779 insertions(+), 58 deletions(-) create mode 100644 src/helpers/Radio2DiscoverTrailer.h create mode 100644 test/test_radio2_discover_trailer/test_radio2_discover_trailer.cpp diff --git a/MQTT_IMPLEMENTATION.md b/MQTT_IMPLEMENTATION.md index 4eafe8fd7..c0ff3d7c8 100644 --- a/MQTT_IMPLEMENTATION.md +++ b/MQTT_IMPLEMENTATION.md @@ -877,6 +877,7 @@ While `mqtt.neighbors` is on, `get mqtt.status` appends `nbr: /` -- - Timestamps are always emitted in UTC with an explicit `+00:00` offset. - The `stats` object is only included when at least one stat value is available; individual fields are omitted when their value is unavailable. - `packets_sent` / `packets_received` are cumulative totals since boot (flood + direct), sourced from the dispatcher counters. +- `radios` appears only while radio2 is on (`rx` or `rxtx`) with the MeshCore protocol; see [radio2 (MediaMesh)](#radio2-mediamesh) below. ### Packet Message ```json @@ -906,6 +907,7 @@ While `mqtt.neighbors` is on, `get mqtt.status` appends `nbr: /` -- - `time` and `date` are always UTC (`HH:MM:SS` and `DD/MM/YYYY`); `timestamp` is UTC with an explicit `+00:00` offset. - `SNR`, `RSSI`, and `score` are only present for RX packets (received from radio). TX packets omit these fields since the packet originates from this node. - `score` is the firmware's rebroadcast score for the received packet (the same value used to compute flood-rebroadcast delay), scaled x1000 to match the integer printed in the serial RX log - e.g. a score of `0.234` is emitted as `"234"` (range `0`-`1000`). It is recomputed at publish time from the packet's SNR and length via the radio's `packetScore()`, so it matches what the firmware used on receive. Omitted when unavailable (e.g. the non-PSRAM reconstruction-less fallback path). +- `rx_radio_id` / `tx_radio_id` and `mediamesh` are only present for packets received or sent on radio2; see [radio2 (MediaMesh)](#radio2-mediamesh) below. - `path` is only present for direct-route packets that carry path data. It is a JSON array of lowercase hex hop tokens, one element per hop - e.g. `["aa","bb","cc"]` for single-byte hashes, or `["aaaa","bbbb"]` for multi-byte hashes. This matches the `path` representation emitted by [meshcore-packet-capture](https://github.com/agessaman/meshcore-packet-capture). ### Raw Message @@ -972,6 +974,48 @@ itself on the next publish cycle. default`); it is `*` when no default region is set, matching the unscoped flood the radio actually performs in that case. +`self.radio2` and each neighbor's `radio2` are described under +[radio2 (MediaMesh)](#radio2-mediamesh) below. + +### radio2 (MediaMesh) + +An observer with radio2 tags what used it, so analyzers can tell MediaMesh +traffic and MediaMesh repeaters apart (CoreScope draws MediaMesh paths +differently). Every key below is optional and absent otherwise: main-radio +messages are byte-for-byte unchanged, no topic changes, and radio2 packets were +already published before, untagged. Consumers that do not know these keys +ignore them. + +**Status:** while radio2 is on with the MeshCore protocol, a `radios` list, the +multi-radio convention CoreScope reads (`id` + `radio`, its #1869). `radio` +stays the main radio. +```json +"radios": [{ "id": "radio2", "radio": "911.150000,250.0,7,5", "mode": "rxtx", "mediamesh": true, "temporary": false }] +``` +`mode` is `rx` or `rxtx`; `temporary` is `true` while a `tempradio2` runs. + +**Packets and raw:** a packet received on radio2 adds `"rx_radio_id": "radio2"`, +one sent on radio2 adds `"tx_radio_id": "radio2"`, and both add +`"mediamesh": true` (a JSON boolean). The packet's own profile decides +(`Packet::radio_profile`, captured on receive), not the radio's profile at +publish time. A packet sent on both profiles is published once per +transmission, and only the radio2 copy is tagged. + +**Neighbors:** the neighbor refresh asks for each neighbor's radio2 profile (bit +`0x02` of `DISCOVER_REQ`, see `docs/radio_profiles.md`) and reports it: +```json +"self": { "scopes": "DEN", "default_scope": "*", + "radio2": { "radio": "910.525000,250.0,7,5", "mode": "rxtx", "temporary": false } }, +"neighbors": [ + { "pubkey": "0011…", "…": "…", "radio2": { "radio": "911.150000,250.0,7,5", "mode": "rx", "temporary": false } }, + { "pubkey": "8899…", "…": "…", "radio2": { "mode": "off" } } +] +``` +`mode` is `rx`, `rxtx` or `off`; `radio` and `temporary` only when it is not +`off`. **A missing `radio2` means unknown, never off**: that neighbor sent no +trailer (older firmware, or no answer this cycle). `self.radio2` is left out +while this node's radio2 is off. + ## Key Features ### Connection Handling diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 4177b1483..0f8b47089 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -579,6 +579,10 @@ void MyMesh::putNeighbour(const mesh::Identity &id, uint32_t timestamp, float sn } // update neighbour info +#if defined(WITH_MQTT_NEIGHBORS) + // A slot taken over by another identity forgets the old one's radio2. + if (!id.matches(neighbour->id)) neighbour->radio2 = Radio2DiscoverTrailer::unknown(); +#endif neighbour->id = id; neighbour->advert_timestamp = timestamp; neighbour->heard_timestamp = getRTCClock()->getCurrentTime(); @@ -3684,12 +3688,23 @@ void MyMesh::onControlDataRecv(mesh::Packet* packet) { } putNeighbour(id, rtc_clock.getCurrentTime(), packet->getSNR(), packet->getRSSI()); +#if defined(WITH_MQTT_NEIGHBORS) + // Its radio2 (MediaMesh) profile, when the reply carries one after the + // full key; no trailer (older firmware) is unknown, not off. + noteNeighbourRadio2(id, Radio2DiscoverTrailer::parse( + &packet->payload[6 + PUB_KEY_SIZE], packet->payload_len - (6 + PUB_KEY_SIZE))); +#endif } } void MyMesh::sendNodeDiscoverReq() { uint8_t data[10]; data[0] = CTL_TYPE_NODE_DISCOVER_REQ; // prefix_only=0 +#if defined(WITH_MQTT_NEIGHBORS) + // Bit 1: "add your radio2 profile" (docs/radio_profiles.md). Stock firmware + // ignores it; the reply limit is unchanged. Published per neighbour as `radio2`. + data[0] |= 0x02; +#endif data[1] = (1 << ADV_TYPE_REPEATER); getRNG()->random(&data[2], 4); // tag memcpy(&pending_discover_tag, &data[2], 4); @@ -14261,9 +14276,12 @@ void MyMesh::resetNeighborDiscoverJsonBudget() { neighbor_discover_publish_count = 0; neighbor_discover_queried_count = 0; neighbor_discover_truncated = false; + MQTTRadio2Info self_radio2; + char self_radio2_text[48]; neighbor_discover_json_size = MQTTMessageBuilder::measureNeighborsMessageBase( neighbor_discover_origin, self_pubkey_hex, timestamp, self_scopes_buf, - self_default_scope_buf, neighbor_discover_count); + self_default_scope_buf, neighbor_discover_count, + selfRadio2(self_radio2, self_radio2_text, sizeof(self_radio2_text))); } // Account for one terminal result. The base measurement reserves maximum-width @@ -14289,6 +14307,11 @@ bool MyMesh::completeNeighborDiscoverEntry() { : (entry.status == ND_SEND_FAILED ? "send_failed" : "timeout") }; measured.rssi = entry.rssi; + MQTTRadio2Info measured_radio2; + char measured_radio2_text[48]; + measured.radio2 = radio2InfoFor(neighbor_discover_radio2[neighbor_discover_next], + measured_radio2, measured_radio2_text, + sizeof(measured_radio2_text)); size_t added = MQTTMessageBuilder::measureNeighborsMessageEntry(measured); if (neighbor_discover_publish_count > 0) added++; // array comma @@ -14337,6 +14360,34 @@ bool MyMesh::handleNeighborDiscoverResponse(int overlay_idx, return true; } +void MyMesh::noteNeighbourRadio2(const mesh::Identity& id, + const Radio2DiscoverTrailer& radio2) { +#if MAX_NEIGHBOURS + for (int i = 0; i < MAX_NEIGHBOURS; i++) { + if (id.matches(neighbours[i].id)) { + neighbours[i].radio2 = radio2; + return; + } + } +#else + (void)id; (void)radio2; +#endif +} + +const MQTTRadio2Info* MyMesh::selfRadio2(MQTTRadio2Info& out, char* radio, size_t radio_size) { + const mesh::RadioProfiles* profiles = _radio->profiles(); + if (!profiles || !profiles->enabled() + || profiles->secondary.params.protocol != mesh::RadioProfileProtocol::MeshCore) { + return nullptr; + } + const mesh::RadioProfileParams& p2 = profiles->secondary.params; + MQTTMessageBuilder::formatRadioProfile(p2.freq, p2.bw, p2.sf, p2.cr, radio, radio_size); + out.radio = radio; + out.mode = profiles->secondary.mode == mesh::RadioProfileMode::RxTx ? "rxtx" : "rx"; + out.temporary = profiles->secondary_temporary; + return &out; +} + void MyMesh::touchNeighbourHeard(const mesh::Identity& id, uint32_t heard_timestamp, float snr, int16_t rssi) { @@ -14454,12 +14505,18 @@ void MyMesh::finishNeighborDiscover() { const size_t hex_size = PUB_KEY_SIZE * 2 + 1; const size_t entries_bytes = sizeof(MQTTMessageBuilder::NeighborsMessageEntry) * publish_count; - void* scratch = neighborsAlloc(entries_bytes + hex_size * publish_count); + // Each entry's radio2 (MediaMesh) report and its "MHz,kHz,SF,CR" text. + const size_t radio2_text_size = 48; + const size_t radio2_bytes = sizeof(MQTTRadio2Info) * publish_count; + void* scratch = neighborsAlloc(entries_bytes + radio2_bytes + + (hex_size + radio2_text_size) * publish_count); char* json_buf = (char*)neighborsAlloc(MQTTBridge::NEIGHBORS_JSON_BUFFER_SIZE); if (json_buf && (scratch || publish_count == 0)) { auto* entries = (MQTTMessageBuilder::NeighborsMessageEntry*)scratch; - char* pubkey_hex = (char*)scratch + entries_bytes; + auto* radio2_infos = (MQTTRadio2Info*)((char*)scratch + entries_bytes); + char* pubkey_hex = (char*)scratch + entries_bytes + radio2_bytes; + char* radio2_text = pubkey_hex + hex_size * publish_count; uint32_t now_secs = getRTCClock()->getCurrentTime(); for (int i = 0; i < publish_count; i++) { @@ -14473,6 +14530,8 @@ void MyMesh::finishNeighborDiscover() { entries[i].heard_unknown = !heard_known; entries[i].heard_secs_ago = heard_known ? (now_secs - entry.heard_timestamp) : 0; entries[i].scopes = neighbor_discover_scopes[i]; + entries[i].radio2 = radio2InfoFor(neighbor_discover_radio2[i], radio2_infos[i], + &radio2_text[i * radio2_text_size], radio2_text_size); switch (entry.status) { case ND_RESPONDED: entries[i].status = "responded"; break; case ND_SEND_FAILED: entries[i].status = "send_failed"; break; @@ -14497,12 +14556,15 @@ void MyMesh::finishNeighborDiscover() { #else JsonDocument doc; #endif + MQTTRadio2Info self_radio2; + char self_radio2_text[48]; int json_len = MQTTMessageBuilder::buildNeighborsMessage( doc, neighbor_discover_origin, self_pubkey_hex, timestamp, self_scopes_buf, self_default_scope_buf, entries, publish_count, json_buf, MQTTBridge::NEIGHBORS_JSON_BUFFER_SIZE, neighbor_discover_count, neighbor_discover_queried_count, - neighbor_discover_truncated); + neighbor_discover_truncated, + selfRadio2(self_radio2, self_radio2_text, sizeof(self_radio2_text))); if (json_len > 0 && mqtt_bridge) { mqtt_bridge->requestPublishNeighbors(json_buf, (size_t)json_len); @@ -14641,6 +14703,7 @@ bool MyMesh::startNeighborDiscover(char* reply) { } memset(neighbor_discover_metadata, 0, sizeof(neighbor_discover_metadata)); + memset(neighbor_discover_radio2, 0, sizeof(neighbor_discover_radio2)); // Unknown for (int i = 0; i < neighbor_discover_count; i++) { const auto& source = neighbours[order[i]]; neighbor_discover[i].id = source.id; @@ -14650,6 +14713,7 @@ bool MyMesh::startNeighborDiscover(char* reply) { metadata.snr = source.snr; metadata.rssi = source.rssi; metadata.status = ND_UNSENT; + neighbor_discover_radio2[i] = source.radio2; } } diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 3709f71aa..1dba30e13 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -163,6 +164,9 @@ struct NeighbourInfo { int8_t snr; // multiplied by 4, user should divide to get float value #if defined(WITH_MQTT_NEIGHBORS) int16_t rssi; // dBm from the last packet heard from this neighbour + // Its radio2 (MediaMesh) profile from the last DISCOVER_RESP trailer; + // Unknown until it answers one. Published as `radio2` in the neighbors message. + Radio2DiscoverTrailer radio2; #endif }; @@ -886,6 +890,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks MAX_NEIGHBOURS < MQTTBridge::NEIGHBORS_SCOPE_RESULT_CAPACITY ? MAX_NEIGHBOURS : MQTTBridge::NEIGHBORS_SCOPE_RESULT_CAPACITY; NeighborDiscoverMetadata neighbor_discover_metadata[NEIGHBOR_SCOPE_RESULTS]; + // Each queried neighbour's radio2 (MediaMesh) profile, snapshotted with the + // metadata above (which keeps its compact 12-byte layout). + Radio2DiscoverTrailer neighbor_discover_radio2[NEIGHBOR_SCOPE_RESULTS]; char neighbor_discover_scopes[NEIGHBOR_SCOPE_RESULTS][96]; uint8_t neighbor_discover_count; uint8_t neighbor_discover_next; // newest-first entry currently being queried @@ -916,6 +923,10 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks size_t len, float snr, int16_t rssi); void touchNeighbourHeard(const mesh::Identity& id, uint32_t heard_timestamp, float snr, int16_t rssi); + // A neighbour's radio2 profile from its DISCOVER_RESP trailer (MediaMesh). + void noteNeighbourRadio2(const mesh::Identity& id, const Radio2DiscoverTrailer& radio2); + // This node's radio2, for the neighbors message's `self.radio2`; nullptr when off. + const MQTTRadio2Info* selfRadio2(MQTTRadio2Info& out, char* radio, size_t radio_size); void getLocalScopes(char* buf, size_t len); // Overlay peer indices are offset by this base so onPeerDataRecv can tell a // discovery response apart from a normal ACL-client index. diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index cc75ce521..59b3ec2f8 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -2552,6 +2552,8 @@ void MyMesh::putNeighbour(const mesh::Identity &id, uint32_t timestamp, float sn } // update neighbour info + // A slot taken over by another identity forgets the old one's radio2. + if (!id.matches(neighbour->id)) neighbour->radio2 = Radio2DiscoverTrailer::unknown(); neighbour->id = id; neighbour->advert_timestamp = timestamp; neighbour->heard_timestamp = getRTCClock()->getCurrentTime(); @@ -2590,12 +2592,18 @@ void MyMesh::onControlDataRecv(mesh::Packet* packet) { } putNeighbour(id, getRTCClock()->getCurrentTime(), packet->getSNR(), packet->getRSSI()); + // Its radio2 (MediaMesh) profile, when the reply carries one after the + // full key; no trailer (older firmware) is unknown, not off. + noteNeighbourRadio2(id, Radio2DiscoverTrailer::parse( + &packet->payload[6 + PUB_KEY_SIZE], packet->payload_len - (6 + PUB_KEY_SIZE))); } } void MyMesh::sendNodeDiscoverReq() { uint8_t data[10]; - data[0] = CTL_TYPE_NODE_DISCOVER_REQ; // prefix_only=0 + // Bit 1: "add your radio2 profile" (docs/radio_profiles.md). Stock firmware + // ignores it; the reply limit is unchanged. Published per neighbour as `radio2`. + data[0] = CTL_TYPE_NODE_DISCOVER_REQ | 0x02; // prefix_only=0 data[1] = (1 << ADV_TYPE_REPEATER); getRNG()->random(&data[2], 4); // tag memcpy(&pending_discover_tag, &data[2], 4); @@ -4719,9 +4727,12 @@ void MyMesh::resetNeighborDiscoverJsonBudget() { neighbor_discover_publish_count = 0; neighbor_discover_queried_count = 0; neighbor_discover_truncated = false; + MQTTRadio2Info self_radio2; + char self_radio2_text[48]; neighbor_discover_json_size = MQTTMessageBuilder::measureNeighborsMessageBase( neighbor_discover_origin, self_pubkey_hex, timestamp, self_scopes_buf, - self_default_scope_buf, neighbor_discover_count); + self_default_scope_buf, neighbor_discover_count, + selfRadio2(self_radio2, self_radio2_text, sizeof(self_radio2_text))); } // Account for one terminal result. The base measurement reserves maximum-width @@ -4747,6 +4758,11 @@ bool MyMesh::completeNeighborDiscoverEntry() { : (entry.status == ND_SEND_FAILED ? "send_failed" : "timeout") }; measured.rssi = entry.rssi; + MQTTRadio2Info measured_radio2; + char measured_radio2_text[48]; + measured.radio2 = radio2InfoFor(neighbor_discover_radio2[neighbor_discover_next], + measured_radio2, measured_radio2_text, + sizeof(measured_radio2_text)); size_t added = MQTTMessageBuilder::measureNeighborsMessageEntry(measured); if (neighbor_discover_publish_count > 0) added++; // array comma @@ -4795,6 +4811,30 @@ bool MyMesh::handleNeighborDiscoverResponse(int overlay_idx, return true; } +void MyMesh::noteNeighbourRadio2(const mesh::Identity& id, + const Radio2DiscoverTrailer& radio2) { + for (int i = 0; i < MAX_NEIGHBOURS; i++) { + if (id.matches(neighbours[i].id)) { + neighbours[i].radio2 = radio2; + return; + } + } +} + +const MQTTRadio2Info* MyMesh::selfRadio2(MQTTRadio2Info& out, char* radio, size_t radio_size) { + const mesh::RadioProfiles* profiles = _radio->profiles(); + if (!profiles || !profiles->enabled() + || profiles->secondary.params.protocol != mesh::RadioProfileProtocol::MeshCore) { + return nullptr; + } + const mesh::RadioProfileParams& p2 = profiles->secondary.params; + MQTTMessageBuilder::formatRadioProfile(p2.freq, p2.bw, p2.sf, p2.cr, radio, radio_size); + out.radio = radio; + out.mode = profiles->secondary.mode == mesh::RadioProfileMode::RxTx ? "rxtx" : "rx"; + out.temporary = profiles->secondary_temporary; + return &out; +} + void MyMesh::touchNeighbourHeard(const mesh::Identity& id, uint32_t heard_timestamp, float snr, int16_t rssi) { @@ -4910,12 +4950,18 @@ void MyMesh::finishNeighborDiscover() { const size_t hex_size = PUB_KEY_SIZE * 2 + 1; const size_t entries_bytes = sizeof(MQTTMessageBuilder::NeighborsMessageEntry) * publish_count; - void* scratch = neighborsAlloc(entries_bytes + hex_size * publish_count); + // Each entry's radio2 (MediaMesh) report and its "MHz,kHz,SF,CR" text. + const size_t radio2_text_size = 48; + const size_t radio2_bytes = sizeof(MQTTRadio2Info) * publish_count; + void* scratch = neighborsAlloc(entries_bytes + radio2_bytes + + (hex_size + radio2_text_size) * publish_count); char* json_buf = (char*)neighborsAlloc(MQTTBridge::NEIGHBORS_JSON_BUFFER_SIZE); if (json_buf && (scratch || publish_count == 0)) { auto* entries = (MQTTMessageBuilder::NeighborsMessageEntry*)scratch; - char* pubkey_hex = (char*)scratch + entries_bytes; + auto* radio2_infos = (MQTTRadio2Info*)((char*)scratch + entries_bytes); + char* pubkey_hex = (char*)scratch + entries_bytes + radio2_bytes; + char* radio2_text = pubkey_hex + hex_size * publish_count; uint32_t now_secs = getRTCClock()->getCurrentTime(); for (int i = 0; i < publish_count; i++) { @@ -4929,6 +4975,8 @@ void MyMesh::finishNeighborDiscover() { entries[i].heard_unknown = !heard_known; entries[i].heard_secs_ago = heard_known ? (now_secs - entry.heard_timestamp) : 0; entries[i].scopes = neighbor_discover_scopes[i]; + entries[i].radio2 = radio2InfoFor(neighbor_discover_radio2[i], radio2_infos[i], + &radio2_text[i * radio2_text_size], radio2_text_size); switch (entry.status) { case ND_RESPONDED: entries[i].status = "responded"; break; case ND_SEND_FAILED: entries[i].status = "send_failed"; break; @@ -4953,12 +5001,15 @@ void MyMesh::finishNeighborDiscover() { #else JsonDocument doc; #endif + MQTTRadio2Info self_radio2; + char self_radio2_text[48]; int json_len = MQTTMessageBuilder::buildNeighborsMessage( doc, neighbor_discover_origin, self_pubkey_hex, timestamp, self_scopes_buf, self_default_scope_buf, entries, publish_count, json_buf, MQTTBridge::NEIGHBORS_JSON_BUFFER_SIZE, neighbor_discover_count, neighbor_discover_queried_count, - neighbor_discover_truncated); + neighbor_discover_truncated, + selfRadio2(self_radio2, self_radio2_text, sizeof(self_radio2_text))); if (json_len > 0 && bridge) { bridge->requestPublishNeighbors(json_buf, (size_t)json_len); @@ -5097,6 +5148,7 @@ bool MyMesh::startNeighborDiscover(char* reply) { } memset(neighbor_discover_metadata, 0, sizeof(neighbor_discover_metadata)); + memset(neighbor_discover_radio2, 0, sizeof(neighbor_discover_radio2)); // Unknown for (int i = 0; i < neighbor_discover_count; i++) { const auto& source = neighbours[order[i]]; neighbor_discover[i].id = source.id; @@ -5106,6 +5158,7 @@ bool MyMesh::startNeighborDiscover(char* reply) { metadata.snr = source.snr; metadata.rssi = source.rssi; metadata.status = ND_UNSENT; + neighbor_discover_radio2[i] = source.radio2; } } diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 2093e6f79..dae198b49 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -2,6 +2,7 @@ #include // needed for PlatformIO #include +#include #include #include #if MESH_ENABLE_EMERGENCY_POWER && defined(ENABLE_OTA) @@ -175,6 +176,9 @@ struct NeighbourInfo { uint32_t heard_timestamp; int8_t snr; // multiplied by 4, user should divide to get float value int16_t rssi; // dBm from the last packet heard from this neighbour + // Its radio2 (MediaMesh) profile from the last DISCOVER_RESP trailer; + // Unknown until it answers one. Published as `radio2` in the neighbors message. + Radio2DiscoverTrailer radio2; }; class MyMesh : public mesh::Mesh, public CommonCLICallbacks, @@ -320,6 +324,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks, MAX_NEIGHBOURS < MQTTBridge::NEIGHBORS_SCOPE_RESULT_CAPACITY ? MAX_NEIGHBOURS : MQTTBridge::NEIGHBORS_SCOPE_RESULT_CAPACITY; NeighborDiscoverMetadata neighbor_discover_metadata[NEIGHBOR_SCOPE_RESULTS]; + // Each queried neighbour's radio2 (MediaMesh) profile, snapshotted with the + // metadata above (which keeps its compact 12-byte layout). + Radio2DiscoverTrailer neighbor_discover_radio2[NEIGHBOR_SCOPE_RESULTS]; char neighbor_discover_scopes[NEIGHBOR_SCOPE_RESULTS][96]; uint8_t neighbor_discover_count; uint8_t neighbor_discover_next; @@ -353,6 +360,10 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks, size_t len, float snr, int16_t rssi); void touchNeighbourHeard(const mesh::Identity& id, uint32_t heard_timestamp, float snr, int16_t rssi); + // A neighbour's radio2 profile from its DISCOVER_RESP trailer (MediaMesh). + void noteNeighbourRadio2(const mesh::Identity& id, const Radio2DiscoverTrailer& radio2); + // This node's radio2, for the neighbors message's `self.radio2`; nullptr when off. + const MQTTRadio2Info* selfRadio2(MQTTRadio2Info& out, char* radio, size_t radio_size); void getLocalScopes(char* buf, size_t len); static const int NEIGHBOR_DISCOVER_PEER_BASE = 1000; static const unsigned long NEIGHBOR_DISCOVER_QUEUE_TIMEOUT_MS = 29000; diff --git a/src/helpers/MQTTMessageBuilder.cpp b/src/helpers/MQTTMessageBuilder.cpp index 5b836f159..74318e795 100644 --- a/src/helpers/MQTTMessageBuilder.cpp +++ b/src/helpers/MQTTMessageBuilder.cpp @@ -6,6 +6,7 @@ #include "MQTTMessageBuilder.h" #include "MQTTPayloadBuilder.h" +#include "TxtDataHelpers.h" #include #include #include @@ -62,13 +63,31 @@ int MQTTMessageBuilder::buildStatusMessage( int packets_sent, int packets_received, const char* repeat, - const MQTTConnHealth& conn_health + const MQTTConnHealth& conn_health, + const MQTTRadio2Info* radio2 ) { return MQTTPayloadBuilder::buildStatusMessage( doc, origin, origin_id, model, firmware_version, radio, client_version, status, timestamp, buffer, buffer_size, battery_mv, uptime_secs, errors, queue_len, noise_floor, tx_air_secs, rx_air_secs, recv_errors, internal_heap, - packets_sent, packets_received, repeat, conn_health); + packets_sent, packets_received, repeat, conn_health, radio2); +} + +void MQTTMessageBuilder::formatRadioProfile(float freq, float bw, int sf, int cr, + char* dest, size_t dest_size) { + if (!dest || dest_size == 0) return; + char freq_text[20]; + char bw_text[16]; + StrHelper::ftoaFixed(freq_text, sizeof(freq_text), freq, 6); + StrHelper::ftoaFixed(bw_text, sizeof(bw_text), bw, 1); + snprintf(dest, dest_size, "%s,%s,%d,%d", freq_text, bw_text, sf, cr); +} + +const char* MQTTMessageBuilder::radioIdFor(const mesh::Packet* packet) { + // The packet's own profile, captured on receive (Dispatcher::checkRecv) and + // kept by an outbound copy: not the radio's profile at publish time, which a + // tempradio2 or a scheduled switch can change in between. + return (packet && packet->radio_profile == 1) ? kMQTTRadio2Id : nullptr; } int MQTTMessageBuilder::buildPacketMessage( @@ -92,12 +111,13 @@ int MQTTMessageBuilder::buildPacketMessage( int path_hop_count, int path_hash_size, char* buffer, - size_t buffer_size + size_t buffer_size, + const char* radio_id ) { return MQTTPayloadBuilder::buildPacketMessage( doc, origin, origin_id, timestamp, direction, time, date, len, packet_type, route, payload_len, raw, snr, rssi, score, hash, path_bytes, path_hop_count, - path_hash_size, MAX_PATH_SIZE, buffer, buffer_size); + path_hash_size, MAX_PATH_SIZE, buffer, buffer_size, radio_id); } int MQTTMessageBuilder::buildRawMessage( @@ -107,10 +127,12 @@ int MQTTMessageBuilder::buildRawMessage( const char* timestamp, const char* raw, char* buffer, - size_t buffer_size + size_t buffer_size, + const char* radio_id, + bool is_tx ) { return MQTTPayloadBuilder::buildRawMessage( - doc, origin, origin_id, timestamp, raw, buffer, buffer_size); + doc, origin, origin_id, timestamp, raw, buffer, buffer_size, radio_id, is_tx); } int MQTTMessageBuilder::buildNeighborsMessage( @@ -126,12 +148,13 @@ int MQTTMessageBuilder::buildNeighborsMessage( size_t buffer_size, int total_neighbors, int queried_neighbors, - bool truncated + bool truncated, + const MQTTRadio2Info* self_radio2 ) { return MQTTPayloadBuilder::buildNeighborsMessage( doc, origin, origin_id, timestamp, self_scopes, self_default_scope, neighbors, neighbor_count, buffer, buffer_size, total_neighbors, - queried_neighbors, truncated); + queried_neighbors, truncated, self_radio2); } size_t MQTTMessageBuilder::measureNeighborsMessageBase( @@ -140,11 +163,12 @@ size_t MQTTMessageBuilder::measureNeighborsMessageBase( const char* timestamp, const char* self_scopes, const char* self_default_scope, - int total_neighbors + int total_neighbors, + const MQTTRadio2Info* self_radio2 ) { return MQTTPayloadBuilder::measureNeighborsMessageBase( origin, origin_id, timestamp, self_scopes, self_default_scope, - total_neighbors); + total_neighbors, self_radio2); } size_t MQTTMessageBuilder::measureNeighborsMessageEntry( @@ -224,7 +248,8 @@ int MQTTMessageBuilder::buildPacketJSON( has_path ? packet->path : nullptr, has_path ? packet->getPathHashCount() : 0, has_path ? packet->getPathHashSize() : 0, - buffer, buffer_size + buffer, buffer_size, + radioIdFor(packet) ); } @@ -303,7 +328,8 @@ int MQTTMessageBuilder::buildPacketJSONFromRaw( has_path ? packet->path : nullptr, has_path ? packet->getPathHashCount() : 0, has_path ? packet->getPathHashSize() : 0, - buffer, buffer_size + buffer, buffer_size, + radioIdFor(packet) ); } @@ -314,7 +340,8 @@ int MQTTMessageBuilder::buildRawJSON( const char* origin_id, Timezone* timezone, char* buffer, - size_t buffer_size + size_t buffer_size, + bool is_tx ) { if (!packet) return 0; @@ -328,7 +355,8 @@ int MQTTMessageBuilder::buildRawJSON( char raw_hex[WIRE_HEX_SCRATCH_SIZE]; packetToHex(packet, raw_hex, sizeof(raw_hex)); - return buildRawMessage(doc, origin, origin_id, timestamp, raw_hex, buffer, buffer_size); + return buildRawMessage(doc, origin, origin_id, timestamp, raw_hex, buffer, buffer_size, + radioIdFor(packet), is_tx); } const char* MQTTMessageBuilder::getRouteTypeString(int route_type) { diff --git a/src/helpers/MQTTMessageBuilder.h b/src/helpers/MQTTMessageBuilder.h index 10a43d087..f6935f3fb 100644 --- a/src/helpers/MQTTMessageBuilder.h +++ b/src/helpers/MQTTMessageBuilder.h @@ -71,6 +71,7 @@ public: * @param internal_heap Internal heap free bytes (optional, -1 to omit) * @param repeat Repeat/forwarding status ("on" or "off"); nullptr omits the field * @param conn_health Slot connection health; defaults omit every field + * @param radio2 radio2 (MediaMesh) when on: adds the `radios` list; nullptr omits it * @return Length of JSON string, or 0 on error */ static int buildStatusMessage( @@ -97,7 +98,8 @@ public: int packets_sent = -1, int packets_received = -1, const char* repeat = nullptr, - const MQTTConnHealth& conn_health = MQTTConnHealth() + const MQTTConnHealth& conn_health = MQTTConnHealth(), + const MQTTRadio2Info* radio2 = nullptr ); /** @@ -145,7 +147,8 @@ public: int path_hop_count, int path_hash_size, char* buffer, - size_t buffer_size + size_t buffer_size, + const char* radio_id = nullptr // see radioIdFor(); nullptr = main radio ); /** @@ -166,7 +169,9 @@ public: const char* timestamp, const char* raw, char* buffer, - size_t buffer_size + size_t buffer_size, + const char* radio_id = nullptr, + bool is_tx = false ); // Neighbors table entry + JSON builder. The layout logic lives in the pure, @@ -186,7 +191,8 @@ public: size_t buffer_size, int total_neighbors = -1, int queried_neighbors = -1, - bool truncated = false + bool truncated = false, + const MQTTRadio2Info* self_radio2 = nullptr ); static size_t measureNeighborsMessageBase( const char* origin, @@ -194,8 +200,22 @@ public: const char* timestamp, const char* self_scopes, const char* self_default_scope, - int total_neighbors + int total_neighbors, + const MQTTRadio2Info* self_radio2 = nullptr ); + + /** + * "MHz,kHz,SF,CR" for a radio profile, the status `radio` format (frequency + * to 6 decimals, bandwidth to 1). + */ + static void formatRadioProfile(float freq, float bw, int sf, int cr, char* dest, size_t dest_size); + + /** + * The `rx_radio_id`/`tx_radio_id` a packet's message carries: "radio2" for a + * packet received or sent on radio2 (Packet::radio_profile == 1), else + * nullptr (the main radio: no radio keys, as before). + */ + static const char* radioIdFor(const mesh::Packet* packet); static size_t measureNeighborsMessageEntry(const NeighborsMessageEntry& neighbor); /** @@ -253,7 +273,8 @@ public: const char* origin_id, Timezone* timezone, char* buffer, - size_t buffer_size + size_t buffer_size, + bool is_tx = false ); private: diff --git a/src/helpers/MQTTPayloadBuilder.cpp b/src/helpers/MQTTPayloadBuilder.cpp index b48b875ab..7ee8f6fe5 100644 --- a/src/helpers/MQTTPayloadBuilder.cpp +++ b/src/helpers/MQTTPayloadBuilder.cpp @@ -26,6 +26,14 @@ static int serializeComplete(JsonDocument& doc, char* buffer, size_t buffer_size return static_cast(written); } +static void addRadio2Fields(JsonObject obj, const MQTTRadio2Info& r) { + obj["mode"] = r.mode ? r.mode : "off"; + if (r.radio && r.mode && strcmp(r.mode, "off") != 0) { + obj["radio"] = r.radio; + obj["temporary"] = r.temporary; + } +} + } // namespace int MQTTPayloadBuilder::buildStatusMessage( @@ -52,7 +60,8 @@ int MQTTPayloadBuilder::buildStatusMessage( int packets_sent, int packets_received, const char* repeat, - const MQTTConnHealth& conn_health + const MQTTConnHealth& conn_health, + const MQTTRadio2Info* radio2 ) { doc.clear(); JsonObject root = doc.to(); @@ -68,6 +77,14 @@ int MQTTPayloadBuilder::buildStatusMessage( if (repeat != nullptr) { root["repeat"] = repeat; } + if (radio2 && radio2->radio && radio2->mode && strcmp(radio2->mode, "off") != 0) { + JsonObject r2 = root["radios"].to().add(); + r2["id"] = kMQTTRadio2Id; + r2["radio"] = radio2->radio; + r2["mode"] = radio2->mode; + r2["mediamesh"] = true; + r2["temporary"] = radio2->temporary; + } const bool has_conn_health = conn_health.slots_up >= 0 || conn_health.slots_total >= 0 || conn_health.worst_outage_secs >= 0 || conn_health.heap_largest >= 0 || @@ -127,7 +144,8 @@ int MQTTPayloadBuilder::buildPacketMessage( int path_hash_size, size_t max_path_bytes, char* buffer, - size_t buffer_size + size_t buffer_size, + const char* radio_id ) { doc.clear(); JsonObject root = doc.to(); @@ -187,6 +205,12 @@ int MQTTPayloadBuilder::buildPacketMessage( } } + if (radio_id) { + const bool tx = direction && strcmp(direction, "tx") == 0; + root[tx ? "tx_radio_id" : "rx_radio_id"] = radio_id; + root["mediamesh"] = true; + } + return serializeComplete(doc, buffer, buffer_size); } @@ -197,7 +221,9 @@ int MQTTPayloadBuilder::buildRawMessage( const char* timestamp, const char* raw, char* buffer, - size_t buffer_size + size_t buffer_size, + const char* radio_id, + bool is_tx ) { doc.clear(); JsonObject root = doc.to(); @@ -207,6 +233,10 @@ int MQTTPayloadBuilder::buildRawMessage( root["timestamp"] = timestamp; root["type"] = "RAW"; root["data"] = raw; + if (radio_id) { + root[is_tx ? "tx_radio_id" : "rx_radio_id"] = radio_id; + root["mediamesh"] = true; + } return serializeComplete(doc, buffer, buffer_size); } @@ -220,7 +250,8 @@ static JsonArray buildNeighborsMessageBase( const char* self_default_scope, int total_neighbors, int queried_neighbors, - bool truncated + bool truncated, + const MQTTRadio2Info* self_radio2 ) { doc.clear(); JsonObject root = doc.to(); @@ -236,6 +267,7 @@ static JsonArray buildNeighborsMessageBase( JsonObject self = root["self"].to(); self["scopes"] = self_scopes ? self_scopes : ""; self["default_scope"] = self_default_scope ? self_default_scope : ""; + if (self_radio2) addRadio2Fields(self["radio2"].to(), *self_radio2); return root["neighbors"].to(); } @@ -256,6 +288,7 @@ static void addNeighborsMessageEntry( } nb["scopes"] = neighbor.scopes ? neighbor.scopes : ""; nb["status"] = neighbor.status; + if (neighbor.radio2) addRadio2Fields(nb["radio2"].to(), *neighbor.radio2); } size_t MQTTPayloadBuilder::measureNeighborsMessageBase( @@ -264,12 +297,13 @@ size_t MQTTPayloadBuilder::measureNeighborsMessageBase( const char* timestamp, const char* self_scopes, const char* self_default_scope, - int total_neighbors + int total_neighbors, + const MQTTRadio2Info* self_radio2 ) { JsonDocument doc; buildNeighborsMessageBase( doc, origin, origin_id, timestamp, self_scopes, self_default_scope, - total_neighbors, total_neighbors, false); + total_neighbors, total_neighbors, false, self_radio2); return measureJson(doc); } @@ -296,14 +330,15 @@ int MQTTPayloadBuilder::buildNeighborsMessage( size_t buffer_size, int total_neighbors, int queried_neighbors, - bool truncated + bool truncated, + const MQTTRadio2Info* self_radio2 ) { if (!buffer || buffer_size == 0) return 0; buffer[0] = '\0'; JsonArray arr = buildNeighborsMessageBase( doc, origin, origin_id, timestamp, self_scopes, self_default_scope, - total_neighbors, queried_neighbors, truncated); + total_neighbors, queried_neighbors, truncated, self_radio2); if (doc.overflowed() || arr.isNull() || measureJson(doc) >= buffer_size) return 0; for (int i = 0; i < neighbor_count; i++) { diff --git a/src/helpers/MQTTPayloadBuilder.h b/src/helpers/MQTTPayloadBuilder.h index b76e58363..f89291a32 100644 --- a/src/helpers/MQTTPayloadBuilder.h +++ b/src/helpers/MQTTPayloadBuilder.h @@ -3,6 +3,7 @@ #include #include #include +#include "Radio2DiscoverTrailer.h" // A healthy slot reports sibling outages because a disconnected slot cannot // report itself. Defaults omit the fields for existing callers. @@ -15,6 +16,36 @@ struct MQTTConnHealth { int slots_breaker = -1; }; +// radio2 (MediaMesh) as an observer reports it: the node's own second profile +// in the status `radios` list and the neighbors message's `self.radio2`, or a +// neighbour's profile from its DISCOVER_RESP trailer. `radio` is +// "MHz,kHz,SF,CR" like the status `radio` field (nullptr when mode is "off"); +// `mode` is "rx", "rxtx" or "off". No default initializers: aggregate. +struct MQTTRadio2Info { + const char* radio; + const char* mode; + bool temporary; +}; + +// A neighbour's DISCOVER_RESP trailer as the neighbors message's `radio2`: +// nullptr when it sent none (the key is then left out), `radio` only when on. +inline const MQTTRadio2Info* radio2InfoFor(const Radio2DiscoverTrailer& t, MQTTRadio2Info& out, + char* radio, size_t radio_size) { + if (t.state == Radio2DiscoverTrailer::Unknown) return nullptr; + out.mode = t.modeName(); + out.temporary = t.temporary; + out.radio = nullptr; + if (t.state == Radio2DiscoverTrailer::On && radio && radio_size) { + t.formatRadio(radio, radio_size); + out.radio = radio; + } + return &out; +} + +// The id a radio2 packet carries (`rx_radio_id`/`tx_radio_id`), and the status +// `radios` entry's id: the multi-radio convention CoreScope reads (#1869). +static const char* const kMQTTRadio2Id = "radio2"; + // Mesh-independent JSON serialization core for MQTT publication payloads. // MQTTMessageBuilder keeps the firmware-facing API and delegates these three // deterministic contracts here so they can be exercised by native tests. @@ -44,7 +75,10 @@ public: int packets_sent = -1, int packets_received = -1, const char* repeat = nullptr, - const MQTTConnHealth& conn_health = MQTTConnHealth() + const MQTTConnHealth& conn_health = MQTTConnHealth(), + // radio2 on (rx or rxtx, MeshCore): adds `radios: [{id: "radio2", radio, + // mode, mediamesh: true, temporary}]`. nullptr (radio2 off) adds nothing. + const MQTTRadio2Info* radio2 = nullptr ); static int buildPacketMessage( @@ -69,7 +103,11 @@ public: int path_hash_size, size_t max_path_bytes, char* buffer, - size_t buffer_size + size_t buffer_size, + // Set for a packet received or sent on radio2: adds `rx_radio_id` (rx) or + // `tx_radio_id` (tx) with this id, and `mediamesh: true`. nullptr (the main + // radio) leaves the message byte-for-byte as before. + const char* radio_id = nullptr ); static int buildRawMessage( @@ -79,7 +117,9 @@ public: const char* timestamp, const char* raw, char* buffer, - size_t buffer_size + size_t buffer_size, + const char* radio_id = nullptr, // as buildPacketMessage + bool is_tx = false ); struct NeighborsMessageEntry { @@ -94,6 +134,10 @@ public: // aggregate for the device toolchain, and a zeroed tail means "age known", // so size-measurement callers keep reserving the widest numeric value. bool heard_unknown; + // The neighbour's radio2 from its DISCOVER_RESP trailer. nullptr = it sent + // none (older firmware, or no answer): the `radio2` key is left out, which + // consumers read as unknown, never as off. + const MQTTRadio2Info* radio2; }; // Build neighbors-table JSON for the meshcore/{iata}/{device}/neighbors topic. @@ -112,7 +156,8 @@ public: size_t buffer_size, int total_neighbors = -1, int queried_neighbors = -1, - bool truncated = false + bool truncated = false, + const MQTTRadio2Info* self_radio2 = nullptr // `self.radio2`; nullptr leaves it out ); // Exact serialized-size components used by paced neighbor discovery. The @@ -124,7 +169,8 @@ public: const char* timestamp, const char* self_scopes, const char* self_default_scope, - int total_neighbors + int total_neighbors, + const MQTTRadio2Info* self_radio2 = nullptr ); static size_t measureNeighborsMessageEntry(const NeighborsMessageEntry& neighbor); }; diff --git a/src/helpers/Radio2DiscoverTrailer.h b/src/helpers/Radio2DiscoverTrailer.h new file mode 100644 index 000000000..f90fd9a4d --- /dev/null +++ b/src/helpers/Radio2DiscoverTrailer.h @@ -0,0 +1,71 @@ +#pragma once + +#include +#include +#include + +// The radio2 (MediaMesh) profile a repeater appends to its DISCOVER_RESP when +// the request set bit 1 (0x02) of its flags byte (docs/radio_profiles.md, +// "Discover the local radio2 profile"): +// [version=1][flags][freq u32 LE, MHz x1000][bw u32 LE, kHz x1000][sf][cr] +// flags: bit 0 configured, bits 1-2 mode (0 off, 1 rx, 2 rxtx), bit 3 temporary +// radio2 off: just [1][0] +// A responder that sent no trailer is unknown (older firmware), not off. +// Mesh-independent so native tests can exercise it. +struct Radio2DiscoverTrailer { + enum State : uint8_t { Unknown = 0, Off = 1, On = 2 }; + uint32_t freq_khz; // MHz x1000 + uint32_t bw_hz; // kHz x1000 + uint8_t state; + uint8_t mode; // 1 rx, 2 rxtx (On only) + uint8_t sf; + uint8_t cr; + bool temporary; + + static Radio2DiscoverTrailer unknown() { + Radio2DiscoverTrailer t = {0, 0, Unknown, 0, 0, 0, false}; + return t; + } + + // The trailer in `b` (what follows the public key), or Unknown when there is + // none or it is incomplete or of an unknown version. A later version may + // append; the first twelve bytes keep their meaning. + static Radio2DiscoverTrailer parse(const uint8_t* b, size_t n) { + Radio2DiscoverTrailer t = unknown(); + if (!b || n < 2 || b[0] < 1) return t; + const uint8_t flags = b[1]; + if (!(flags & 0x01)) { + t.state = Off; + return t; + } + if (n < 12) return t; + const uint8_t mode = (flags >> 1) & 0x03; + if (mode != 1 && mode != 2) { + t.state = Off; + return t; + } + t.state = On; + t.mode = mode; + t.temporary = (flags & 0x08) != 0; + t.freq_khz = (uint32_t)b[2] | ((uint32_t)b[3] << 8) | ((uint32_t)b[4] << 16) | ((uint32_t)b[5] << 24); + t.bw_hz = (uint32_t)b[6] | ((uint32_t)b[7] << 8) | ((uint32_t)b[8] << 16) | ((uint32_t)b[9] << 24); + t.sf = b[10]; + t.cr = b[11]; + return t; + } + + const char* modeName() const { + if (state != On) return "off"; + return mode == 2 ? "rxtx" : "rx"; + } + + // "MHz,kHz,SF,CR" in the MQTT status `radio` format (frequency to 6 + // decimals, bandwidth to 1), from the trailer's exact integers. + void formatRadio(char* dest, size_t dest_size) const { + if (!dest || dest_size == 0) return; + snprintf(dest, dest_size, "%lu.%03lu000,%lu.%lu,%u,%u", + (unsigned long)(freq_khz / 1000), (unsigned long)(freq_khz % 1000), + (unsigned long)(bw_hz / 1000), (unsigned long)((bw_hz % 1000) / 100), + (unsigned)sf, (unsigned)cr); + } +}; diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index f854f20c3..63aa25958 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -3978,7 +3978,7 @@ void MQTTBridge::processPacketQueue() { bool raw_eligible = false; bool raw_published = false; if (_obs->mqtt_raw_enabled) { - raw_published = publishRaw(&queued.packet_copy, raw_eligible); + raw_published = publishRaw(&queued.packet_copy, queued.is_tx, raw_eligible); } // Decide intentional completion once across the entire queue item. An @@ -4112,7 +4112,7 @@ void MQTTBridge::processPacketQueue() { bool raw_eligible = false; bool raw_published = false; if (_obs->mqtt_raw_enabled) { - raw_published = publishRaw(&queued.packet_copy, raw_eligible); + raw_published = publishRaw(&queued.packet_copy, queued.is_tx, raw_eligible); } // Decide intentional completion once across the entire queue item. An @@ -4311,6 +4311,11 @@ bool MQTTBridge::publishStatus(uint8_t slot_mask) { // Internal heap free (for diagnosing repeater hangs from internal heap exhaustion) int internal_heap_free = (int)heap_caps_get_free_size(MALLOC_CAP_INTERNAL); + // radio2 (MediaMesh): the status `radios` entry when it is on. + char radio2_info[64]; + MQTTRadio2Info radio2 = {nullptr, nullptr, false}; + const MQTTRadio2Info* radio2_report = radio2StatusInfo(radio2, radio2_info, sizeof(radio2_info)); + int len = MQTTMessageBuilder::buildStatusMessage( _json_scratch_doc, _origin, origin_id, _board_model, _firmware_version, radio_info, @@ -4318,7 +4323,7 @@ bool MQTTBridge::publishStatus(uint8_t slot_mask) { battery_mv, uptime_secs, errors, _queue_count, noise_floor, tx_air_secs, rx_air_secs, recv_errors, internal_heap_free, packets_sent, packets_received, - repeatStatus(), collectConnHealth() + repeatStatus(), collectConnHealth(), radio2_report ); if (len <= 0) return false; @@ -4453,7 +4458,26 @@ bool MQTTBridge::publishPacket(mesh::Packet* packet, bool is_tx, return false; } -bool MQTTBridge::publishRaw(mesh::Packet* packet, bool& has_eligible_target) { +// radio2 (MediaMesh) on with the MeshCore protocol: the status `radios` entry, +// so analyzers can tell this observer's radio2 packets (rx_radio_id) apart. +// Off, or a foreign protocol: nullptr, and the status is as before. +const MQTTRadio2Info* MQTTBridge::radio2StatusInfo(MQTTRadio2Info& out, char* radio, + size_t radio_size) const { + if (!_radio) return nullptr; + const mesh::RadioProfiles* profiles = _radio->profiles(); + if (!profiles || !profiles->enabled() + || profiles->secondary.params.protocol != mesh::RadioProfileProtocol::MeshCore) { + return nullptr; + } + const mesh::RadioProfileParams& p2 = profiles->secondary.params; + formatRadioInfo(p2.freq, p2.bw, p2.sf, p2.cr, radio, radio_size); + out.radio = radio; + out.mode = profiles->secondary.mode == mesh::RadioProfileMode::RxTx ? "rxtx" : "rx"; + out.temporary = profiles->secondary_temporary; + return &out; +} + +bool MQTTBridge::publishRaw(mesh::Packet* packet, bool is_tx, bool& has_eligible_target) { has_eligible_target = false; if (!packet) return false; @@ -4490,7 +4514,7 @@ bool MQTTBridge::publishRaw(mesh::Packet* packet, bool& has_eligible_target) { int len = MQTTMessageBuilder::buildRawJSON( _json_scratch_doc, - packet, _origin, origin_id, _timezone, active_buffer, active_buffer_size + packet, _origin, origin_id, _timezone, active_buffer, active_buffer_size, is_tx ); if (len > 0) { diff --git a/src/helpers/bridges/MQTTBridge.h b/src/helpers/bridges/MQTTBridge.h index 436cdd264..6a2d1a9d2 100644 --- a/src/helpers/bridges/MQTTBridge.h +++ b/src/helpers/bridges/MQTTBridge.h @@ -555,7 +555,8 @@ private: bool publishPacket(mesh::Packet* packet, bool is_tx, bool& has_eligible_target, const uint8_t* raw_data = nullptr, int raw_len = 0, float snr = 0.0f, float rssi = 0.0f); - bool publishRaw(mesh::Packet* packet, bool& has_eligible_target); + bool publishRaw(mesh::Packet* packet, bool is_tx, bool& has_eligible_target); + const MQTTRadio2Info* radio2StatusInfo(MQTTRadio2Info& out, char* radio, size_t radio_size) const; #if defined(WITH_MQTT_NEIGHBORS) // Publishes the pending _neighbors_json_buffer to every connected slot's // neighbors topic. Runs on the MQTT task (Core 0) only. diff --git a/test/fixtures/neighbor_scope_lifecycle.cpp b/test/fixtures/neighbor_scope_lifecycle.cpp index d9de220f0..511022c3a 100644 --- a/test/fixtures/neighbor_scope_lifecycle.cpp +++ b/test/fixtures/neighbor_scope_lifecycle.cpp @@ -69,7 +69,8 @@ struct ACL { ClientInfo* getClientByIdx(int i) { return &clients.at(size_t(i)); } }; struct GuestLogin { bool value=false; bool enabled() const { return value; } }; -struct NeighbourInfo { mesh::Identity id; uint32_t heard_timestamp; int8_t snr; int16_t rssi; }; +struct NeighbourInfo { mesh::Identity id; uint32_t heard_timestamp; int8_t snr; int16_t rssi; + Radio2DiscoverTrailer radio2; }; static bool fail_scratch=false, fail_json=false; static unsigned allocation=0; static void* neighborsAlloc(size_t size) { @@ -107,6 +108,12 @@ template std::string runPass(Mesh& m,int count,int result,size_t sco row.snr=int8_t(i%32); row.rssi=int16_t(-80-i%15); } + // The newest neighbour (published first) answered the radio2 flag: MediaMesh + // 911.150 MHz / 250 kHz / SF7 / CR5, rxtx. The rest sent no trailer. + if (count>0) { + static const uint8_t trailer[]={1,0x05,0x2E,0xE7,0x0D,0x00,0x90,0xD0,0x03,0x00,7,5}; + m.neighbours[count-1].radio2=Radio2DiscoverTrailer::parse(trailer,sizeof(trailer)); + } memset(m.neighbor_discover_scopes,'z',sizeof(m.neighbor_discover_scopes)); allocation=0; char reply[160]={}; @@ -147,6 +154,21 @@ template std::string runPass(Mesh& m,int count,int result,size_t sco assert(!deserializeJson(document,m.mock_bridge.last_json)); assert(document["total_neighbors"].template as()==count); if(count>0) assert(document["neighbors"][0]["pubkey"].template as()!=nullptr); + // radio2 (MediaMesh): this node's own, and the one neighbour that reported one. + assert(!strcmp(document["self"]["radio2"]["mode"].template as(),"rxtx")); + { + int with_radio2=0; + for (JsonObject nb : document["neighbors"].template as()) { + if (!nb["radio2"].template is()) continue; + ++with_radio2; + assert(!strcmp(nb["radio2"]["mode"].template as(),"rxtx")); + assert(!strcmp(nb["radio2"]["radio"].template as(),"911.150000,250.0,7,5")); + assert(nb["pubkey"].template as()[0]== + "0123456789abcdef"[((count-1)>>4)&15]); + } + const bool newest_published=count>0 && document["neighbors"].template as().size()>0; + assert(with_radio2==(newest_published ? 1 : 0)); + } assert(document["neighbors"].template as().size()<=size_t(count)); if(count>MQTTBridge::NEIGHBORS_MAX_PUBLISH_ENTRIES) assert(document["truncated"].template as()); diff --git a/test/test_mqtt_handshake_scheduler.py b/test/test_mqtt_handshake_scheduler.py index 42228c99b..5a37825c1 100644 --- a/test/test_mqtt_handshake_scheduler.py +++ b/test/test_mqtt_handshake_scheduler.py @@ -88,6 +88,7 @@ struct Preset { bool mqttPresetEnforcesTokenExp(const Preset* preset) { return preset && preset->enforce_exp; } +struct MQTTRadio2Info { const char* radio; const char* mode; bool temporary; }; namespace MQTTMessageBuilder { void formatIsoTimestampForMqtt(long, long, const char*, char* buffer, size_t size) { snprintf(buffer, size, "2026-10-14T00:00:00+00:00"); @@ -254,6 +255,7 @@ struct MQTTBridge { void getClientVersion(char* output, size_t size) { snprintf(output, size, "test"); } int repeatStatus() const { return 0; } int collectConnHealth() const { return 0; } + const MQTTRadio2Info* radio2StatusInfo(MQTTRadio2Info&, char*, size_t) const { return nullptr; } bool buildTopicForSlot(int, int, char* output, size_t size) { snprintf(output, size, "status"); return true; diff --git a/test/test_mqtt_payload_builder/test_mqtt_payload_builder.cpp b/test/test_mqtt_payload_builder/test_mqtt_payload_builder.cpp index dc380adbd..01cc4dad9 100644 --- a/test/test_mqtt_payload_builder/test_mqtt_payload_builder.cpp +++ b/test/test_mqtt_payload_builder/test_mqtt_payload_builder.cpp @@ -503,7 +503,7 @@ TEST(MQTTPayloadBuilder, NeighborsMessageMeasurementsMatchCompletePayload) { } TEST(MQTTPayloadBuilder, NeighborsMessageMeasuredPrefixStopsBeforeOverflow) { - MQTTPayloadBuilder::NeighborsMessageEntry neighbors[20]; + MQTTPayloadBuilder::NeighborsMessageEntry neighbors[20] = {}; static char keys[20][17]; static char long_scopes[96]; memset(long_scopes, 'S', sizeof(long_scopes) - 1); @@ -615,7 +615,7 @@ TEST(MQTTPayloadBuilder, NeighborsMessageHandlesEmptyTableAndNullScopes) { TEST(MQTTPayloadBuilder, NeighborsMessageDropsTailWhenBufferFills) { // Twenty entries far exceed a tight buffer; the builder must emit a prefix // that still parses as complete JSON rather than truncating mid-document. - MQTTPayloadBuilder::NeighborsMessageEntry neighbors[20]; + MQTTPayloadBuilder::NeighborsMessageEntry neighbors[20] = {}; static char keys[20][17]; for (int i = 0; i < 20; i++) { snprintf(keys[i], sizeof(keys[i]), "%016X", i); @@ -650,7 +650,7 @@ TEST(MQTTPayloadBuilder, NeighborsMessageDropsTailWhenBufferFills) { TEST(MQTTPayloadBuilder, NeighborsMessageRendersUnknownHeardAgeAsNull) { // A neighbour stamped before the clock was set has no usable age. The key // stays present and null so a consumer cannot read it as "heard just now". - MQTTPayloadBuilder::NeighborsMessageEntry neighbors[2]; + MQTTPayloadBuilder::NeighborsMessageEntry neighbors[2] = {}; neighbors[0] = {"0011223344556677", 9.75f, 42, "DEN", "responded"}; neighbors[1] = {"8899AABBCCDDEEFF", -3.5f, 0, "DEN", "responded"}; neighbors[0].rssi = -87; @@ -691,6 +691,200 @@ TEST(MQTTPayloadBuilder, NeighborsMessageUnknownHeardAgeFitsMeasuredWidth) { MQTTPayloadBuilder::measureNeighborsMessageEntry(measured)); } +// ---- radio2 (MediaMesh) ------------------------------------------------------- +// Main-radio messages stay byte-for-byte as before: the exact-contract tests +// above (status, raw) and the golden packet below pass no radio id. + +TEST(MQTTPayloadBuilder, MainRadioPacketHasNoRadioKeys) { + JsonDocument doc; + char with_default[1024]; + char explicit_null[1024]; + int a = buildRepresentativePacket(doc, "rx", 0.125f, nullptr, 0, 0, "AABB", + with_default, sizeof(with_default)); + int b = MQTTPayloadBuilder::buildPacketMessage( + doc, "DEN Repeater", "0123456789ABCDEF", kTimestamp, "rx", + "12:34:56", "18/07/2026", 42, 4, "D", 20, "AABB", + 10.26f, -87, 0.125f, "89ABCDEF01234567", nullptr, 0, 0, 64, + explicit_null, sizeof(explicit_null), nullptr); + ASSERT_GT(a, 0); + EXPECT_EQ(a, b); + EXPECT_STREQ(with_default, explicit_null); + EXPECT_EQ(nullptr, strstr(with_default, "radio_id")); + EXPECT_EQ(nullptr, strstr(with_default, "mediamesh")); +} + +TEST(MQTTPayloadBuilder, Radio2RxPacketCarriesRxRadioIdAndMediamesh) { + JsonDocument doc; + char main_buf[1024]; + char r2_buf[1024]; + int main_len = buildRepresentativePacket(doc, "rx", 0.125f, nullptr, 0, 0, "AABB", + main_buf, sizeof(main_buf)); + int len = MQTTPayloadBuilder::buildPacketMessage( + doc, "DEN Repeater", "0123456789ABCDEF", kTimestamp, "rx", + "12:34:56", "18/07/2026", 42, 4, "D", 20, "AABB", + 10.26f, -87, 0.125f, "89ABCDEF01234567", nullptr, 0, 0, 64, + r2_buf, sizeof(r2_buf), kMQTTRadio2Id); + ASSERT_GT(len, 0); + // Only appended keys: the main-radio message is an exact prefix. + std::string expected(main_buf, main_len - 1); + expected += ",\"rx_radio_id\":\"radio2\",\"mediamesh\":true}"; + EXPECT_EQ(expected, std::string(r2_buf)); + + JsonDocument parsed; + ASSERT_FALSE(deserializeJson(parsed, r2_buf)); + EXPECT_STREQ("radio2", parsed["rx_radio_id"].as()); + EXPECT_TRUE(parsed["mediamesh"].is()); + EXPECT_TRUE(parsed["mediamesh"].as()); + EXPECT_FALSE(parsed["tx_radio_id"].is()); + EXPECT_STREQ("10.3", parsed["SNR"].as()); // existing keys unchanged +} + +TEST(MQTTPayloadBuilder, Radio2TxPacketCarriesTxRadioId) { + JsonDocument doc; + char buffer[1024]; + int len = MQTTPayloadBuilder::buildPacketMessage( + doc, "DEN Repeater", "0123456789ABCDEF", kTimestamp, "tx", + "12:34:56", "18/07/2026", 42, 4, "F", 20, "AABB", + -999.0f, -999, NAN, "89ABCDEF01234567", nullptr, 0, 0, 64, + buffer, sizeof(buffer), kMQTTRadio2Id); + ASSERT_GT(len, 0); + JsonDocument parsed; + ASSERT_FALSE(deserializeJson(parsed, buffer)); + EXPECT_STREQ("radio2", parsed["tx_radio_id"].as()); + EXPECT_FALSE(parsed["rx_radio_id"].is()); + EXPECT_TRUE(parsed["mediamesh"].as()); +} + +TEST(MQTTPayloadBuilder, Radio2RawMessageCarriesRadioId) { + JsonDocument doc; + char buffer[512]; + int len = MQTTPayloadBuilder::buildRawMessage( + doc, "node", "id", kTimestamp, "AABB", buffer, sizeof(buffer), kMQTTRadio2Id, false); + ASSERT_GT(len, 0); + EXPECT_STREQ( + "{\"origin\":\"node\",\"origin_id\":\"id\"," + "\"timestamp\":\"2026-07-18T12:34:56.123456+00:00\"," + "\"type\":\"RAW\",\"data\":\"AABB\",\"rx_radio_id\":\"radio2\",\"mediamesh\":true}", + buffer); + len = MQTTPayloadBuilder::buildRawMessage( + doc, "node", "id", kTimestamp, "AABB", buffer, sizeof(buffer), kMQTTRadio2Id, true); + ASSERT_GT(len, 0); + JsonDocument parsed; + ASSERT_FALSE(deserializeJson(parsed, buffer)); + EXPECT_STREQ("radio2", parsed["tx_radio_id"].as()); +} + +TEST(MQTTPayloadBuilder, StatusListsRadio2WhenOn) { + const MQTTRadio2Info radio2 = {"911.150000,250.0,7,5", "rxtx", false}; + JsonDocument doc; + char buffer[1024]; + char minimal[1024]; + int minimal_len = buildMinimalStatus(doc, minimal, sizeof(minimal)); + int len = MQTTPayloadBuilder::buildStatusMessage( + doc, "DEN Repeater", "0123456789ABCDEF", "Heltec V3", "v1.16.0", + "915.000000,62.5,7,5", "MeshCore", "online", kTimestamp, + buffer, sizeof(buffer), -1, -1, -1, -1, -999, -1, -1, -1, -1, -1, -1, + nullptr, MQTTConnHealth(), &radio2); + ASSERT_GT(len, 0); + std::string expected(minimal, minimal_len - 1); + expected += ",\"radios\":[{\"id\":\"radio2\",\"radio\":\"911.150000,250.0,7,5\"," + "\"mode\":\"rxtx\",\"mediamesh\":true,\"temporary\":false}]}"; + EXPECT_EQ(expected, std::string(buffer)); + + JsonDocument parsed; + ASSERT_FALSE(deserializeJson(parsed, buffer)); + EXPECT_STREQ("915.000000,62.5,7,5", parsed["radio"].as()); // still the main radio +} + +TEST(MQTTPayloadBuilder, StatusOmitsRadiosWhenRadio2OffOrAbsent) { + const MQTTRadio2Info off = {nullptr, "off", false}; + JsonDocument doc; + char buffer[1024]; + char minimal[1024]; + buildMinimalStatus(doc, minimal, sizeof(minimal)); + int len = MQTTPayloadBuilder::buildStatusMessage( + doc, "DEN Repeater", "0123456789ABCDEF", "Heltec V3", "v1.16.0", + "915.000000,62.5,7,5", "MeshCore", "online", kTimestamp, + buffer, sizeof(buffer), -1, -1, -1, -1, -999, -1, -1, -1, -1, -1, -1, + nullptr, MQTTConnHealth(), &off); + ASSERT_GT(len, 0); + EXPECT_STREQ(minimal, buffer); +} + +TEST(MQTTPayloadBuilder, NeighborsMessageCarriesRadio2) { + const MQTTRadio2Info self = {"910.525000,250.0,7,5", "rxtx", true}; + const MQTTRadio2Info on = {"911.150000,250.0,7,5", "rx", false}; + const MQTTRadio2Info off = {nullptr, "off", false}; + MQTTPayloadBuilder::NeighborsMessageEntry neighbors[] = { + {"0011223344556677", 9.75f, 42, "DEN", "responded"}, + {"8899AABBCCDDEEFF", 12.5f, 300, "DEN", "responded"}, + {"1122334455667788", 1.0f, 900, "", "timeout"}, + }; + neighbors[0].radio2 = &on; + neighbors[1].radio2 = &off; + // neighbors[2]: no trailer, radio2 stays nullptr (unknown) + + JsonDocument scratch; + char buffer[2048]; + int len = MQTTPayloadBuilder::buildNeighborsMessage( + scratch, "DEN Repeater", "0123456789ABCDEF", kTimestamp, "DEN", "*", + neighbors, 3, buffer, sizeof(buffer), 3, 3, false, &self); + ASSERT_GT(len, 0); + JsonDocument parsed; + ASSERT_FALSE(deserializeJson(parsed, buffer)); + EXPECT_STREQ("rxtx", parsed["self"]["radio2"]["mode"].as()); + EXPECT_STREQ("910.525000,250.0,7,5", parsed["self"]["radio2"]["radio"].as()); + EXPECT_TRUE(parsed["self"]["radio2"]["temporary"].as()); + + JsonArray arr = parsed["neighbors"].as(); + ASSERT_EQ(3U, arr.size()); + EXPECT_STREQ("rx", arr[0]["radio2"]["mode"].as()); + EXPECT_STREQ("911.150000,250.0,7,5", arr[0]["radio2"]["radio"].as()); + EXPECT_FALSE(arr[0]["radio2"]["temporary"].as()); + EXPECT_STREQ("off", arr[1]["radio2"]["mode"].as()); + EXPECT_FALSE(arr[1]["radio2"]["radio"].is()); + EXPECT_FALSE(arr[1]["radio2"]["temporary"].is()); + EXPECT_FALSE(arr[2]["radio2"].is()); // unknown: key left out +} + +TEST(MQTTPayloadBuilder, NeighborsMessageWithoutRadio2IsUnchanged) { + MQTTPayloadBuilder::NeighborsMessageEntry neighbors[] = { + {"0011223344556677", 9.75f, 42, "DEN,APRS", "responded"}, + }; + JsonDocument scratch; + char before[1024]; + char after[1024]; + int a = MQTTPayloadBuilder::buildNeighborsMessage( + scratch, "DEN Repeater", "0123456789ABCDEF", kTimestamp, "DEN,APRS", "*", + neighbors, 1, before, sizeof(before), 1, 1, false); + int b = MQTTPayloadBuilder::buildNeighborsMessage( + scratch, "DEN Repeater", "0123456789ABCDEF", kTimestamp, "DEN,APRS", "*", + neighbors, 1, after, sizeof(after), 1, 1, false, nullptr); + ASSERT_GT(a, 0); + EXPECT_EQ(a, b); + EXPECT_STREQ(before, after); + EXPECT_EQ(nullptr, strstr(before, "radio2")); +} + +TEST(MQTTPayloadBuilder, NeighborsMeasurementsIncludeRadio2) { + const MQTTRadio2Info self = {"910.525000,250.0,7,5", "rxtx", false}; + const MQTTRadio2Info on = {"911.150000,250.0,7,5", "rxtx", true}; + MQTTPayloadBuilder::NeighborsMessageEntry entry = { + "0011223344556677", 9.75f, 42, "DEN", "responded"}; + entry.radio2 = &on; + JsonDocument scratch; + char buffer[2048]; + int len = MQTTPayloadBuilder::buildNeighborsMessage( + scratch, "DEN Repeater", "0123456789ABCDEF", kTimestamp, "DEN", "*", + &entry, 1, buffer, sizeof(buffer), 1, 1, false, &self); + ASSERT_GT(len, 0); + size_t measured = + MQTTPayloadBuilder::measureNeighborsMessageBase( + "DEN Repeater", "0123456789ABCDEF", kTimestamp, "DEN", "*", 1, &self) + + MQTTPayloadBuilder::measureNeighborsMessageEntry(entry); + EXPECT_GE(measured, static_cast(len)); +} + } // namespace int main(int argc, char** argv) { diff --git a/test/test_mqtt_payload_oom.py b/test/test_mqtt_payload_oom.py index 4eacf5924..68d366ddd 100644 --- a/test/test_mqtt_payload_oom.py +++ b/test/test_mqtt_payload_oom.py @@ -49,8 +49,9 @@ static int build(unsigned kind, JsonDocument& doc, char* buffer, size_t capacity } if (kind == 2) return MQTTPayloadBuilder::buildRawMessage(doc, "node", "identity", "timestamp", "AABB", buffer, capacity); + static const MQTTRadio2Info neighbor_radio2 = {"911.150000,250.0,7,5", "rxtx", false}; const MQTTPayloadBuilder::NeighborsMessageEntry neighbor = { - "ABCDEF", 2.5f, 10, "region", "active", -85, false}; + "ABCDEF", 2.5f, 10, "region", "active", -85, false, &neighbor_radio2}; return MQTTPayloadBuilder::buildNeighborsMessage(doc, "node", "identity", "timestamp", "region", "*", &neighbor, 1, buffer, capacity, 1, 1, false); } diff --git a/test/test_mqtt_publish_admission.py b/test/test_mqtt_publish_admission.py index 961c5bacc..e6d9ab646 100644 --- a/test/test_mqtt_publish_admission.py +++ b/test/test_mqtt_publish_admission.py @@ -67,7 +67,7 @@ struct MQTTBridge { bool hasPublishHeadroom(size_t=0,size_t=0,uint8_t=0); bool publishToSlot(int,const char*,const char*,size_t,bool=false,uint8_t=0); bool publishPacket(mesh::Packet*,bool,bool&,const uint8_t*=nullptr,int=0,float=0,float=0); - bool publishRaw(mesh::Packet*,bool&); + bool publishRaw(mesh::Packet*,bool,bool&); }; '''.replace('const uint8_t*=', 'const uint8_t* =') @@ -100,7 +100,7 @@ int main(){ MQTTBridge bridge;Client client;bridge._slots[0].client=&client; mesh::Packet packet;bool eligible=false; assert(!bridge.publishPacket(&packet,false,eligible)&&eligible); - assert(!bridge.publishRaw(&packet,eligible)&&eligible); + assert(!bridge.publishRaw(&packet,false,eligible)&&eligible); assert(!bridge.publishToSlot(0,"status","{}",2,true,1)); assert(builds==0&&client.calls==0&&bridge._skipped_publishes==3); assert(heap_queries==2); // Both denied paths cache the heap walk, even at tick0. @@ -108,10 +108,10 @@ int main(){ MQTTTransportHealth::RecoveryReason::None); dma_free=19833;clock_ms=5000; assert(bridge.publishPacket(&packet,false,eligible)); - assert(bridge.publishRaw(&packet,eligible)); + assert(bridge.publishRaw(&packet,false,eligible)); assert(bridge.publishToSlot(0,"status","{}",2,true,1)); assert(builds==2&&client.calls==3&&heap_queries==4); - bridge.eligible=0;assert(!bridge.publishRaw(&packet,eligible)&&!eligible); + bridge.eligible=0;assert(!bridge.publishRaw(&packet,false,eligible)&&!eligible); assert(builds==2&&client.calls==3); client.result=-1; assert(!bridge.publishToSlot(0,"raw","{}",2,false,0)); diff --git a/test/test_mqtt_slot_status.py b/test/test_mqtt_slot_status.py index 4e9954950..72000b358 100644 --- a/test/test_mqtt_slot_status.py +++ b/test/test_mqtt_slot_status.py @@ -35,6 +35,7 @@ static bool build_succeeds = true; uint32_t millis() { return now_ms; } size_t heap_caps_get_free_size(int) { return 100000; } struct { uint64_t value = 0xAABBCCDDEEFF; uint64_t getEfuseMac() const { return value; } } ESP; +struct MQTTRadio2Info { const char* radio; const char* mode; bool temporary; }; namespace MQTTMessageBuilder { void formatIsoTimestampForMqtt(long, long, const char*, char* out, size_t size) { snprintf(out, size, "UTC"); } template @@ -89,6 +90,7 @@ struct MQTTBridge { void refreshOriginFromPrefs() {} void getClientVersion(char* out, size_t size) { snprintf(out, size, "test"); } int repeatStatus() const { return 0; } int collectConnHealth() const { return 0; } + const MQTTRadio2Info* radio2StatusInfo(MQTTRadio2Info&, char*, size_t) const { return nullptr; } bool buildTopicForSlot(int i, int type, char* out, size_t size) { assert(type == MSG_STATUS); snprintf(out, size, "status%d", i); return topic_enabled[i]; } diff --git a/test/test_neighbor_scope_storage.py b/test/test_neighbor_scope_storage.py index eb9886331..24e098996 100644 --- a/test/test_neighbor_scope_storage.py +++ b/test/test_neighbor_scope_storage.py @@ -34,6 +34,10 @@ class NeighborScopeStorageTest(unittest.TestCase): #include #define MAX_NEIGHBOURS 254 #define PUB_KEY_SIZE 32 +#include +struct MQTTRadio2Info { const char* radio; const char* mode; bool temporary; }; +inline const MQTTRadio2Info* radio2InfoFor(const Radio2DiscoverTrailer&, MQTTRadio2Info&, + char*, size_t) { return nullptr; } namespace mesh { struct Identity { uint8_t pub_key[32]; }; struct Utils { static void toHex(char* out, const uint8_t*, size_t) { out[0] = 0; } }; @@ -44,7 +48,7 @@ struct MQTTBridge { struct MQTTMessageBuilder { struct NeighborsMessageEntry { const char* key; float snr; uint32_t age; const char* scopes; - const char* status; int16_t rssi; + const char* status; int16_t rssi; const MQTTRadio2Info* radio2; }; static size_t measureNeighborsMessageEntry(const NeighborsMessageEntry&) { return 100; } }; @@ -91,6 +95,7 @@ int main() { path = Path(directory) (path / "test.cpp").write_text(code) subprocess.run(["g++", "-std=c++17", "-fsanitize=address,undefined", + "-I" + str(ROOT / "src"), str(path / "test.cpp"), "-o", str(path / "test")], check=True) subprocess.run([str(path / "test")], check=True) @@ -191,6 +196,10 @@ int main() { ++requested; tag=uint32_t(requested); return admit ? &packet : nullptr; } void touchNeighbourHeard(const mesh::Identity&,uint32_t,float,int16_t) { ++touched; } + const MQTTRadio2Info* selfRadio2(MQTTRadio2Info& out,char* radio,size_t size) { + snprintf(radio,size,"910.525000,250.0,7,5"); + out.radio=radio; out.mode="rxtx"; out.temporary=false; return &out; + } void resetNeighborDiscoverJsonBudget() { strcpy(self_scopes_buf,"sea;pdx"); strcpy(self_default_scope_buf,"sea"); strcpy(neighbor_discover_origin,"Seattle"); diff --git a/test/test_radio2_discover_trailer/test_radio2_discover_trailer.cpp b/test/test_radio2_discover_trailer/test_radio2_discover_trailer.cpp new file mode 100644 index 000000000..88db0e2eb --- /dev/null +++ b/test/test_radio2_discover_trailer/test_radio2_discover_trailer.cpp @@ -0,0 +1,82 @@ +#include + +#include + +#include "helpers/Radio2DiscoverTrailer.h" + +// The DISCOVER_RESP radio2 trailer (docs/radio_profiles.md). Golden bytes: the +// MediaMesh preset, 911.150 MHz, 250 kHz, SF7, CR 4/5, rxtx (flags 0x05). +static const uint8_t kMediaMesh[] = {1, 0x05, 0x2E, 0xE7, 0x0D, 0x00, 0x90, 0xD0, 0x03, 0x00, 7, 5}; + +TEST(Radio2DiscoverTrailer, ParsesTheMediaMeshPreset) { + Radio2DiscoverTrailer t = Radio2DiscoverTrailer::parse(kMediaMesh, sizeof(kMediaMesh)); + EXPECT_EQ(Radio2DiscoverTrailer::On, t.state); + EXPECT_EQ(2, t.mode); + EXPECT_STREQ("rxtx", t.modeName()); + EXPECT_FALSE(t.temporary); + EXPECT_EQ(911150U, t.freq_khz); + EXPECT_EQ(250000U, t.bw_hz); + EXPECT_EQ(7, t.sf); + EXPECT_EQ(5, t.cr); + char radio[48]; + t.formatRadio(radio, sizeof(radio)); + EXPECT_STREQ("911.150000,250.0,7,5", radio); +} + +TEST(Radio2DiscoverTrailer, ListenOnlyTemporaryAndFractionalBandwidth) { + uint8_t b[12]; + memcpy(b, kMediaMesh, sizeof(b)); + b[1] = 0x01 | (1 << 1) | 0x08; // configured, rx, temporary + b[6] = 0x24; b[7] = 0xF4; b[8] = 0x00; b[9] = 0x00; // 62500 = 62.5 kHz + Radio2DiscoverTrailer t = Radio2DiscoverTrailer::parse(b, sizeof(b)); + EXPECT_EQ(Radio2DiscoverTrailer::On, t.state); + EXPECT_STREQ("rx", t.modeName()); + EXPECT_TRUE(t.temporary); + char radio[48]; + t.formatRadio(radio, sizeof(radio)); + EXPECT_STREQ("911.150000,62.5,7,5", radio); +} + +TEST(Radio2DiscoverTrailer, OffIsOff) { + const uint8_t off[] = {1, 0}; + Radio2DiscoverTrailer t = Radio2DiscoverTrailer::parse(off, sizeof(off)); + EXPECT_EQ(Radio2DiscoverTrailer::Off, t.state); + EXPECT_STREQ("off", t.modeName()); +} + +TEST(Radio2DiscoverTrailer, MissingOrBrokenIsUnknownNotOff) { + EXPECT_EQ(Radio2DiscoverTrailer::Unknown, Radio2DiscoverTrailer::parse(nullptr, 0).state); + EXPECT_EQ(Radio2DiscoverTrailer::Unknown, Radio2DiscoverTrailer::parse(kMediaMesh, 0).state); + EXPECT_EQ(Radio2DiscoverTrailer::Unknown, Radio2DiscoverTrailer::parse(kMediaMesh, 1).state); + EXPECT_EQ(Radio2DiscoverTrailer::Unknown, Radio2DiscoverTrailer::parse(kMediaMesh, 11).state); + const uint8_t v0[] = {0, 0x05, 0, 0, 0, 0, 0, 0, 0, 0, 7, 5}; + EXPECT_EQ(Radio2DiscoverTrailer::Unknown, Radio2DiscoverTrailer::parse(v0, sizeof(v0)).state); +} + +TEST(Radio2DiscoverTrailer, ConfiguredWithModeOffIsOff) { + uint8_t b[12]; + memcpy(b, kMediaMesh, sizeof(b)); + b[1] = 0x01; // configured, mode 0 + EXPECT_EQ(Radio2DiscoverTrailer::Off, Radio2DiscoverTrailer::parse(b, sizeof(b)).state); +} + +TEST(Radio2DiscoverTrailer, LaterVersionsMayAppend) { + uint8_t b[16] = {}; + memcpy(b, kMediaMesh, sizeof(kMediaMesh)); + b[0] = 2; + Radio2DiscoverTrailer t = Radio2DiscoverTrailer::parse(b, sizeof(b)); + EXPECT_EQ(Radio2DiscoverTrailer::On, t.state); + EXPECT_EQ(911150U, t.freq_khz); +} + +TEST(Radio2DiscoverTrailer, UnknownIsZero) { + Radio2DiscoverTrailer zero; + memset(&zero, 0, sizeof(zero)); + EXPECT_EQ(Radio2DiscoverTrailer::Unknown, zero.state); // memset neighbour tables start unknown + EXPECT_EQ(Radio2DiscoverTrailer::Unknown, Radio2DiscoverTrailer::unknown().state); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From fef5ce0f17241453af6b650bb56ac552b9ea3260 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 02:59:01 -0700 Subject: [PATCH 2/7] MQTT radio2: keep repeater neighbours' radio2 in a small cache Full ESP32 repeater builds keep up to 254 neighbours, and a 16-byte radio2 field on each pushed them past the DRAM check (checkprogsize). Instead: - Radio2DiscoverTrailer is 8 bytes (bandwidth in 100 Hz steps, which is all the status format prints; sf/cr/state/mode/temporary in bitfields). - The repeater keeps a 24-entry cache keyed by key prefix (288 bytes): a reply without a trailer changes nothing, a node's newer report replaces its older one, and a full cache keeps On (MediaMesh) reports over Off ones. - The discovery snapshot reads from it; JSON output is unchanged. The room server keeps its per-neighbour field (now 8 bytes). Tests: test_radio2_neighbour_cache.py compiles the real cache functions; the trailer test pins the 8-byte size; the lifecycle harness looks radio2 up the same way the repeater does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011i5tJJtj95MB4oUgncCUe5 --- examples/simple_repeater/MyMesh.cpp | 54 +++++++-- examples/simple_repeater/MyMesh.h | 16 ++- src/helpers/Radio2DiscoverTrailer.h | 33 ++++-- test/test_neighbor_scope_storage.py | 6 + .../test_radio2_discover_trailer.cpp | 7 +- test/test_radio2_neighbour_cache.py | 111 ++++++++++++++++++ 6 files changed, 196 insertions(+), 31 deletions(-) create mode 100644 test/test_radio2_neighbour_cache.py diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 0f8b47089..b23441532 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -579,10 +579,6 @@ void MyMesh::putNeighbour(const mesh::Identity &id, uint32_t timestamp, float sn } // update neighbour info -#if defined(WITH_MQTT_NEIGHBORS) - // A slot taken over by another identity forgets the old one's radio2. - if (!id.matches(neighbour->id)) neighbour->radio2 = Radio2DiscoverTrailer::unknown(); -#endif neighbour->id = id; neighbour->advert_timestamp = timestamp; neighbour->heard_timestamp = getRTCClock()->getCurrentTime(); @@ -3860,6 +3856,10 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc #if MAX_NEIGHBOURS memset(neighbours, 0, sizeof(neighbours)); #endif +#if defined(WITH_MQTT_NEIGHBORS) + memset(radio2_cache, 0, sizeof(radio2_cache)); // all Unknown + radio2_cache_next = 0; +#endif // defaults _prefs.airtime_factor = 1.0; @@ -14362,16 +14362,44 @@ bool MyMesh::handleNeighborDiscoverResponse(int overlay_idx, void MyMesh::noteNeighbourRadio2(const mesh::Identity& id, const Radio2DiscoverTrailer& radio2) { -#if MAX_NEIGHBOURS - for (int i = 0; i < MAX_NEIGHBOURS; i++) { - if (id.matches(neighbours[i].id)) { - neighbours[i].radio2 = radio2; - return; + if (radio2.state == Radio2DiscoverTrailer::Unknown) return; // no trailer: keep what we knew + int slot = -1, empty = -1, off = -1; + for (int i = 0; i < RADIO2_CACHE_SIZE; i++) { + const Radio2CacheEntry& e = radio2_cache[i]; + if (e.radio2.state == Radio2DiscoverTrailer::Unknown) { + if (empty < 0) empty = i; + } else if (memcmp(e.prefix, id.pub_key, sizeof(e.prefix)) == 0) { + slot = i; + break; + } else if (e.radio2.state == Radio2DiscoverTrailer::Off && off < 0) { + off = i; } } -#else - (void)id; (void)radio2; -#endif + if (slot < 0) slot = empty; + if (slot < 0 && radio2.state == Radio2DiscoverTrailer::On) { + // A full cache keeps MediaMesh nodes: an On report replaces an Off one, + // else the oldest slot in turn. An Off report is dropped. + if (off >= 0) { + slot = off; + } else { + slot = radio2_cache_next; + radio2_cache_next = (uint8_t)((radio2_cache_next + 1) % RADIO2_CACHE_SIZE); + } + } + if (slot < 0) return; + memcpy(radio2_cache[slot].prefix, id.pub_key, sizeof(radio2_cache[slot].prefix)); + radio2_cache[slot].radio2 = radio2; +} + +Radio2DiscoverTrailer MyMesh::neighbourRadio2(const mesh::Identity& id) const { + for (int i = 0; i < RADIO2_CACHE_SIZE; i++) { + const Radio2CacheEntry& e = radio2_cache[i]; + if (e.radio2.state != Radio2DiscoverTrailer::Unknown + && memcmp(e.prefix, id.pub_key, sizeof(e.prefix)) == 0) { + return e.radio2; + } + } + return Radio2DiscoverTrailer::unknown(); } const MQTTRadio2Info* MyMesh::selfRadio2(MQTTRadio2Info& out, char* radio, size_t radio_size) { @@ -14713,7 +14741,7 @@ bool MyMesh::startNeighborDiscover(char* reply) { metadata.snr = source.snr; metadata.rssi = source.rssi; metadata.status = ND_UNSENT; - neighbor_discover_radio2[i] = source.radio2; + neighbor_discover_radio2[i] = neighbourRadio2(source.id); } } diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 1dba30e13..feca4f334 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -164,9 +164,6 @@ struct NeighbourInfo { int8_t snr; // multiplied by 4, user should divide to get float value #if defined(WITH_MQTT_NEIGHBORS) int16_t rssi; // dBm from the last packet heard from this neighbour - // Its radio2 (MediaMesh) profile from the last DISCOVER_RESP trailer; - // Unknown until it answers one. Published as `radio2` in the neighbors message. - Radio2DiscoverTrailer radio2; #endif }; @@ -923,8 +920,19 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks size_t len, float snr, int16_t rssi); void touchNeighbourHeard(const mesh::Identity& id, uint32_t heard_timestamp, float snr, int16_t rssi); - // A neighbour's radio2 profile from its DISCOVER_RESP trailer (MediaMesh). + // Neighbours' radio2 (MediaMesh) profiles from their DISCOVER_RESP trailers, + // published as `radio2` in the neighbors message. A small cache rather than + // a field per neighbour: full builds keep up to 254 neighbours and have no + // DRAM for that. On reports win over Off ones; a node not here is unknown. + struct Radio2CacheEntry { + uint8_t prefix[4]; + Radio2DiscoverTrailer radio2; + }; + static const int RADIO2_CACHE_SIZE = 24; + Radio2CacheEntry radio2_cache[RADIO2_CACHE_SIZE]; + uint8_t radio2_cache_next; void noteNeighbourRadio2(const mesh::Identity& id, const Radio2DiscoverTrailer& radio2); + Radio2DiscoverTrailer neighbourRadio2(const mesh::Identity& id) const; // This node's radio2, for the neighbors message's `self.radio2`; nullptr when off. const MQTTRadio2Info* selfRadio2(MQTTRadio2Info& out, char* radio, size_t radio_size); void getLocalScopes(char* buf, size_t len); diff --git a/src/helpers/Radio2DiscoverTrailer.h b/src/helpers/Radio2DiscoverTrailer.h index f90fd9a4d..d57a298db 100644 --- a/src/helpers/Radio2DiscoverTrailer.h +++ b/src/helpers/Radio2DiscoverTrailer.h @@ -3,6 +3,7 @@ #include #include #include +#include // The radio2 (MediaMesh) profile a repeater appends to its DISCOVER_RESP when // the request set bit 1 (0x02) of its flags byte (docs/radio_profiles.md, @@ -12,21 +13,26 @@ // radio2 off: just [1][0] // A responder that sent no trailer is unknown (older firmware), not off. // Mesh-independent so native tests can exercise it. +// Eight bytes: repeaters keep one per cached neighbour and per discovery +// result, and the large-neighbour ESP32 builds have little DRAM to spare. struct Radio2DiscoverTrailer { enum State : uint8_t { Unknown = 0, Off = 1, On = 2 }; - uint32_t freq_khz; // MHz x1000 - uint32_t bw_hz; // kHz x1000 - uint8_t state; - uint8_t mode; // 1 rx, 2 rxtx (On only) - uint8_t sf; - uint8_t cr; - bool temporary; + uint32_t freq_khz; // MHz x1000 + uint16_t bw_dhz; // bandwidth in 100 Hz steps (kHz x10), all the status format shows + uint8_t sf : 4; + uint8_t cr : 4; + uint8_t state : 2; + uint8_t mode : 2; // 1 rx, 2 rxtx (On only) + uint8_t temporary : 1; static Radio2DiscoverTrailer unknown() { - Radio2DiscoverTrailer t = {0, 0, Unknown, 0, 0, 0, false}; + Radio2DiscoverTrailer t; + memset(&t, 0, sizeof(t)); return t; } + uint32_t bwHz() const { return (uint32_t)bw_dhz * 100; } + // The trailer in `b` (what follows the public key), or Unknown when there is // none or it is incomplete or of an unknown version. A later version may // append; the first twelve bytes keep their meaning. @@ -46,11 +52,12 @@ struct Radio2DiscoverTrailer { } t.state = On; t.mode = mode; - t.temporary = (flags & 0x08) != 0; + t.temporary = (flags & 0x08) != 0 ? 1 : 0; t.freq_khz = (uint32_t)b[2] | ((uint32_t)b[3] << 8) | ((uint32_t)b[4] << 16) | ((uint32_t)b[5] << 24); - t.bw_hz = (uint32_t)b[6] | ((uint32_t)b[7] << 8) | ((uint32_t)b[8] << 16) | ((uint32_t)b[9] << 24); - t.sf = b[10]; - t.cr = b[11]; + const uint32_t bw = (uint32_t)b[6] | ((uint32_t)b[7] << 8) | ((uint32_t)b[8] << 16) | ((uint32_t)b[9] << 24); + t.bw_dhz = (uint16_t)((bw / 100) > 0xFFFF ? 0xFFFF : bw / 100); + t.sf = b[10] & 0x0F; + t.cr = b[11] & 0x0F; return t; } @@ -65,7 +72,7 @@ struct Radio2DiscoverTrailer { if (!dest || dest_size == 0) return; snprintf(dest, dest_size, "%lu.%03lu000,%lu.%lu,%u,%u", (unsigned long)(freq_khz / 1000), (unsigned long)(freq_khz % 1000), - (unsigned long)(bw_hz / 1000), (unsigned long)((bw_hz % 1000) / 100), + (unsigned long)(bw_dhz / 10), (unsigned long)(bw_dhz % 10), (unsigned)sf, (unsigned)cr); } }; diff --git a/test/test_neighbor_scope_storage.py b/test/test_neighbor_scope_storage.py index 24e098996..89e4f862b 100644 --- a/test/test_neighbor_scope_storage.py +++ b/test/test_neighbor_scope_storage.py @@ -196,6 +196,12 @@ int main() { ++requested; tag=uint32_t(requested); return admit ? &packet : nullptr; } void touchNeighbourHeard(const mesh::Identity&,uint32_t,float,int16_t) { ++touched; } + // The fixture keeps each neighbour's radio2 beside it; the repeater's + // real lookup (a small cache) is tested in test_radio2_neighbour_cache.py. + Radio2DiscoverTrailer neighbourRadio2(const mesh::Identity& id) const { + for (const auto& n : neighbours) if (!memcmp(n.id.pub_key,id.pub_key,32)) return n.radio2; + return Radio2DiscoverTrailer::unknown(); + } const MQTTRadio2Info* selfRadio2(MQTTRadio2Info& out,char* radio,size_t size) { snprintf(radio,size,"910.525000,250.0,7,5"); out.radio=radio; out.mode="rxtx"; out.temporary=false; return &out; diff --git a/test/test_radio2_discover_trailer/test_radio2_discover_trailer.cpp b/test/test_radio2_discover_trailer/test_radio2_discover_trailer.cpp index 88db0e2eb..26e80341e 100644 --- a/test/test_radio2_discover_trailer/test_radio2_discover_trailer.cpp +++ b/test/test_radio2_discover_trailer/test_radio2_discover_trailer.cpp @@ -15,7 +15,7 @@ TEST(Radio2DiscoverTrailer, ParsesTheMediaMeshPreset) { EXPECT_STREQ("rxtx", t.modeName()); EXPECT_FALSE(t.temporary); EXPECT_EQ(911150U, t.freq_khz); - EXPECT_EQ(250000U, t.bw_hz); + EXPECT_EQ(250000U, t.bwHz()); EXPECT_EQ(7, t.sf); EXPECT_EQ(5, t.cr); char radio[48]; @@ -69,6 +69,11 @@ TEST(Radio2DiscoverTrailer, LaterVersionsMayAppend) { EXPECT_EQ(911150U, t.freq_khz); } +TEST(Radio2DiscoverTrailer, FitsInEightBytes) { + // One per cached neighbour and per discovery result on DRAM-tight builds. + EXPECT_EQ(8u, sizeof(Radio2DiscoverTrailer)); +} + TEST(Radio2DiscoverTrailer, UnknownIsZero) { Radio2DiscoverTrailer zero; memset(&zero, 0, sizeof(zero)); diff --git a/test/test_radio2_neighbour_cache.py b/test/test_radio2_neighbour_cache.py new file mode 100644 index 000000000..cb5912942 --- /dev/null +++ b/test/test_radio2_neighbour_cache.py @@ -0,0 +1,111 @@ +"""The repeater's radio2 (MediaMesh) neighbour cache: noteNeighbourRadio2 and +neighbourRadio2 from examples/simple_repeater/MyMesh.cpp, compiled as they are. + +Full ESP32 builds keep up to 254 neighbours, so radio2 is not a field per +neighbour but a small cache keyed by public-key prefix. These pin its rules: +unknown (no trailer) changes nothing, a node's newer report replaces its +older one, and a full cache keeps On (MediaMesh) reports over Off ones. +""" +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] + + +class Radio2NeighbourCacheTest(unittest.TestCase): + def build_and_run(self): + header = (ROOT / "examples/simple_repeater/MyMesh.h").read_text() + source = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text() + decl = header[header.index(" struct Radio2CacheEntry {"): + header.index(" uint8_t radio2_cache_next;")] + size = int(re.search(r"RADIO2_CACHE_SIZE = (\d+);", decl).group(1)) + methods = "\n".join(extract_braced(source, signature) for signature in ( + "void MyMesh::noteNeighbourRadio2(", + "Radio2DiscoverTrailer MyMesh::neighbourRadio2(")) + code = r''' +#include +#include +#include +#include +#include +namespace mesh { struct Identity { uint8_t pub_key[32]; }; } +struct MyMesh { +''' + decl + r''' + uint8_t radio2_cache_next = 0; + MyMesh() { memset(radio2_cache, 0, sizeof(radio2_cache)); } + void noteNeighbourRadio2(const mesh::Identity& id, const Radio2DiscoverTrailer& radio2); + Radio2DiscoverTrailer neighbourRadio2(const mesh::Identity& id) const; +}; +''' + methods + r''' +static mesh::Identity key(int n) { mesh::Identity id; memset(id.pub_key, 0, 32); + id.pub_key[0] = uint8_t(n); id.pub_key[1] = uint8_t(n >> 8); id.pub_key[3] = 0xA5; return id; } +static const uint8_t ON[] = {1, 0x05, 0x2E, 0xE7, 0x0D, 0x00, 0x90, 0xD0, 0x03, 0x00, 7, 5}; +static const uint8_t OFF[] = {1, 0}; +int main() { + const Radio2DiscoverTrailer on = Radio2DiscoverTrailer::parse(ON, sizeof(ON)); + const Radio2DiscoverTrailer off = Radio2DiscoverTrailer::parse(OFF, sizeof(OFF)); + const Radio2DiscoverTrailer unknown = Radio2DiscoverTrailer::parse(nullptr, 0); + MyMesh m; + // Nothing known yet. + assert(m.neighbourRadio2(key(1)).state == Radio2DiscoverTrailer::Unknown); + // A report sticks; a later reply without a trailer does not erase it. + m.noteNeighbourRadio2(key(1), on); + assert(m.neighbourRadio2(key(1)).state == Radio2DiscoverTrailer::On); + assert(m.neighbourRadio2(key(1)).freq_khz == 911150); + m.noteNeighbourRadio2(key(1), unknown); + assert(m.neighbourRadio2(key(1)).state == Radio2DiscoverTrailer::On); + // A newer report from the same node replaces it, in the same slot. + m.noteNeighbourRadio2(key(1), off); + assert(m.neighbourRadio2(key(1)).state == Radio2DiscoverTrailer::Off); + int used = 0; + for (const auto& e : m.radio2_cache) used += e.radio2.state != Radio2DiscoverTrailer::Unknown; + assert(used == 1); + // Fill the cache with Off reports; one more Off is dropped. + for (int n = 2; n <= @SIZE@; n++) m.noteNeighbourRadio2(key(n), off); + m.noteNeighbourRadio2(key(500), off); + assert(m.neighbourRadio2(key(500)).state == Radio2DiscoverTrailer::Unknown); + // An On report takes an Off slot: MediaMesh nodes win a full cache. + m.noteNeighbourRadio2(key(501), on); + assert(m.neighbourRadio2(key(501)).state == Radio2DiscoverTrailer::On); + // All On: further On reports rotate through, newest kept. + for (int n = 600; n < 600 + 3 * @SIZE@; n++) m.noteNeighbourRadio2(key(n), on); + assert(m.neighbourRadio2(key(600 + 3 * @SIZE@ - 1)).state == Radio2DiscoverTrailer::On); + int on_count = 0; + for (const auto& e : m.radio2_cache) on_count += e.radio2.state == Radio2DiscoverTrailer::On; + assert(on_count == @SIZE@); + // Small: one cache entry is a 4-byte prefix and an 8-byte report. + static_assert(sizeof(MyMesh::Radio2CacheEntry) == 12, "compact cache entry"); + printf("radio2 neighbour cache ok\n"); + return 0; +} +'''.replace("@SIZE@", str(size)) + compiler = shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler) + with tempfile.TemporaryDirectory(prefix="meshcore-radio2-cache-") as temporary: + work = Path(temporary) + generated = work / "cache.cpp" + generated.write_text(code) + binary = work / "cache" + sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all"] + if sys.platform.startswith("linux") else []) + compiled = subprocess.run([compiler, "-std=c++11", "-Wall", "-Wextra", "-Werror", *sanitizers, + "-I" + str(ROOT / "src"), str(generated), "-o", str(binary)], + capture_output=True, text=True, timeout=60) + self.assertEqual(compiled.returncode, 0, compiled.stderr) + return subprocess.run([str(binary)], capture_output=True, text=True, timeout=30) + + def test_cache_rules(self): + checked = self.build_and_run() + self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) + self.assertIn("radio2 neighbour cache ok", checked.stdout) + + +if __name__ == "__main__": + unittest.main() From 1ac6301096fef573c1b8f605d8a42814d39a1d81 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 08:43:49 -0700 Subject: [PATCH 3/7] USB packet log: mark radio2 (MediaMesh) packets for serial observers nRF52 observers publish through meshcoretomqtt, which reads the USB packet log, not the MQTT bridge. A packet on radio2 now gets a line of its own just before its RX/TX line: 12:34:56 - 29/10/2026 U RADIO: radio2 The RX/TX line is unchanged, so meshcoretomqtt (whose pattern is anchored to the end of the line) keeps parsing it, and readers that don't know RADIO: skip the extra line. A reader that does tags the next RX/TX line with the same rx_radio_id/tx_radio_id and mediamesh keys the MQTT bridge uses. Main-radio output and compact logging are unchanged. Test: test_usb_log_radio2.py compiles the real logPacketLine and checks the lines against meshcoretomqtt's own regex. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011i5tJJtj95MB4oUgncCUe5 --- MQTT_IMPLEMENTATION.md | 12 ++++ src/Dispatcher.cpp | 10 +++ test/test_usb_log_radio2.py | 138 ++++++++++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+) create mode 100644 test/test_usb_log_radio2.py diff --git a/MQTT_IMPLEMENTATION.md b/MQTT_IMPLEMENTATION.md index c0ff3d7c8..39267fd77 100644 --- a/MQTT_IMPLEMENTATION.md +++ b/MQTT_IMPLEMENTATION.md @@ -1016,6 +1016,18 @@ transmission, and only the radio2 copy is tagged. trailer (older firmware, or no answer this cycle). `self.radio2` is left out while this node's radio2 is off. +**USB packet log (serial observers, e.g. nRF52 with meshcoretomqtt):** a packet +on radio2 gets a line of its own right before its `RX`/`TX` line: +``` +12:34:56 - 29/10/2026 U RADIO: radio2 +12:34:56 - 29/10/2026 U: RX, len=64 (type=4, route=F, payload_len=48) SNR=9 RSSI=-71 score=1000 time=88 hash=… +``` +The `RX`/`TX` line itself is unchanged, so readers that match it to the end +(meshcoretomqtt does) keep working, and a reader that does not know `RADIO:` +skips the line. One that does applies it to the next `RX`/`TX` line only and +publishes the same `rx_radio_id`/`tx_radio_id` and `mediamesh` keys as above. +Compact logging (`R`/`T` hex lines) is unchanged. + ## Key Features ### Connection Handling diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index 875044f35..7af45830e 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -547,6 +547,16 @@ uint32_t Dispatcher::getCADFailMaxDuration() const { void Dispatcher::logPacketLine(const char* direction, const Packet* packet, int len, bool include_rx_metrics, float score, uint32_t air_time) { + // radio2 (MediaMesh): a line of its own just before the packet's, so + // meshcoretomqtt and other serial-log readers that match the RX/TX line to + // its end keep parsing it unchanged, and old ones ignore the extra line. + // A reader that knows it tags the next RX/TX line rx_radio_id/tx_radio_id + // "radio2" (MQTT_IMPLEMENTATION.md, "radio2 (MediaMesh)"). + if (packet->radio_profile == 1) { + SerialLogLine<64> radio_line; + radio_line.printf("%s RADIO: radio2", getLogDateTime()); + radio_line.flush(usbLoggingPort()); + } SerialLogLine<256> line; line.printf("%s: %s, len=%d (type=%d, route=%s, payload_len=%d)", getLogDateTime(), direction, len, packet->getPayloadType(), diff --git a/test/test_usb_log_radio2.py b/test/test_usb_log_radio2.py new file mode 100644 index 000000000..7da907cd2 --- /dev/null +++ b/test/test_usb_log_radio2.py @@ -0,0 +1,138 @@ +"""USB packet log: a radio2 (MediaMesh) packet gets a `RADIO: radio2` line of +its own before its RX/TX line, and nothing else changes. + +Compiles the real Dispatcher::logPacketLine with small stand-ins, then checks +the lines against the regex meshcoretomqtt (Cisien/meshcoretomqtt, +bridge/message_parser.py) uses: the RX/TX line still parses, the extra line is +ignored by it, and main-radio output is byte-for-byte what it was. +""" +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] + +# meshcoretomqtt's patterns, verbatim (bridge/message_parser.py @ 9b4e022). +MCTOMQTT_PACKET = re.compile( + r"(\d{2}:\d{2}:\d{2}) - (\d{1,2}/\d{1,2}/\d{4}) U: (RX|TX), len=(\d+) \(type=(\d+), route=([A-Z]), payload_len=(\d+)\)" + r"(?: SNR=(-?\d+) RSSI=(-?\d+) score=(\d+)(?: time=(\d+))?)?" + r"(?: hash=([0-9A-F]+))?" + r"(?: \[(.*)\])?$" +) + +HARNESS = r''' +#include +#include +#include +#include +#include +#define MESH_PACKET_LOGGING 1 +#define MAX_HASH_SIZE 8 +#define PAYLOAD_TYPE_REQ 0 +#define PAYLOAD_TYPE_RESPONSE 1 +#define PAYLOAD_TYPE_TXT_MSG 2 +#define PAYLOAD_TYPE_ADVERT 4 +#define PAYLOAD_TYPE_PATH 8 +static std::string out; +struct Stream {}; +static Stream port; +namespace mesh { +inline Stream& usbLoggingPort() { return port; } +template struct SerialLogLine { + std::string s; + void printf(const char* f, ...) { char b[CAP]; va_list a; va_start(a, f); vsnprintf(b, sizeof(b), f, a); va_end(a); s += b; } + void hex(const uint8_t* d, size_t n) { char b[3]; for (size_t i = 0; i < n; i++) { snprintf(b, 3, "%02X", d[i]); s += b; } } + bool flush(Stream&) { out += s + "\r\n"; return true; } +}; +struct Packet { + uint8_t radio_profile = 0, type = PAYLOAD_TYPE_ADVERT; bool direct = false; + uint8_t payload[4] = {0xAA, 0xBB, 0, 0}; uint16_t payload_len = 48; + uint8_t getPayloadType() const { return type; } + bool isRouteDirect() const { return direct; } + float getSNR() const { return 9.0f; } + int getRSSI() const { return -71; } + void calculatePacketHash(uint8_t* h) const { for (int i = 0; i < MAX_HASH_SIZE; i++) h[i] = uint8_t(0xA0 + i); } +}; +struct Dispatcher { + const char* getLogDateTime() { return "12:34:56 - 29/10/2026 U"; } + void logPacketLine(const char* direction, const Packet* packet, int len, + bool include_rx_metrics, float score, uint32_t air_time); +}; +@METHOD@ +} +int main() { + mesh::Dispatcher d; + mesh::Packet main_rx, r2_rx, r2_tx_direct; + r2_rx.radio_profile = 1; + r2_tx_direct.radio_profile = 1; r2_tx_direct.direct = true; r2_tx_direct.type = PAYLOAD_TYPE_TXT_MSG; + d.logPacketLine("RX", &main_rx, 64, true, 1.0f, 88); + out += "--\n"; + d.logPacketLine("RX", &r2_rx, 64, true, 1.0f, 88); + out += "--\n"; + d.logPacketLine("TX", &r2_tx_direct, 64, false, 0.0f, 0); + fputs(out.c_str(), stdout); +} +''' + + +class UsbLogRadio2Test(unittest.TestCase): + def run_harness(self): + source = (ROOT / "src/Dispatcher.cpp").read_text() + method = extract_braced(source, "void Dispatcher::logPacketLine(") + compiler = shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler) + with tempfile.TemporaryDirectory(prefix="meshcore-usb-radio2-") as tmp: + src = Path(tmp) / "log.cpp" + src.write_text(HARNESS.replace("@METHOD@", method)) + exe = Path(tmp) / "log" + built = subprocess.run([compiler, "-std=c++17", "-Wall", "-Werror", str(src), "-o", str(exe)], + capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stderr) + ran = subprocess.run([str(exe)], capture_output=True, text=True, timeout=30) + self.assertEqual(ran.returncode, 0, ran.stderr) + blocks, cur = [], [] + for line in ran.stdout.splitlines(): + if line == "--": + blocks.append(cur); cur = [] + elif line: + cur.append(line) + blocks.append(cur) + return blocks + + def test_radio2_line_precedes_and_main_radio_is_unchanged(self): + main, r2_rx, r2_tx = self.run_harness() + main = [l for l in main if l] + r2_rx = [l for l in r2_rx if l] + r2_tx = [l for l in r2_tx if l] + # Main radio: exactly one line, as before. + self.assertEqual(len(main), 1) + self.assertNotIn("RADIO", main[0]) + # radio2: the RADIO line, then the same RX line as the main radio. + self.assertEqual(r2_rx[0], "12:34:56 - 29/10/2026 U RADIO: radio2") + self.assertEqual(r2_rx[1], main[0]) + self.assertEqual(r2_tx[0], "12:34:56 - 29/10/2026 U RADIO: radio2") + self.assertTrue(r2_tx[1].startswith("12:34:56 - 29/10/2026 U: TX, len=64")) + + def test_meshcoretomqtt_still_parses_and_skips_the_radio_line(self): + main, r2_rx, r2_tx = self.run_harness() + r2_rx = [l for l in r2_rx if l] + r2_tx = [l for l in r2_tx if l] + self.assertIsNone(MCTOMQTT_PACKET.match(r2_rx[0])) + self.assertNotIn("U RAW:", r2_rx[0]) + self.assertFalse(r2_rx[0].startswith("DEBUG")) + m = MCTOMQTT_PACKET.match(r2_rx[1]) + self.assertIsNotNone(m, r2_rx[1]) + self.assertEqual(m.group(3), "RX") + self.assertEqual(m.group(12), "A0A1A2A3A4A5A6A7") + t = MCTOMQTT_PACKET.match(r2_tx[1]) + self.assertIsNotNone(t, r2_tx[1]) + self.assertEqual(t.group(13), "BB -> AA") + + +if __name__ == "__main__": + unittest.main() From b2d6184bda7ee33b81896d1d851b79ae277b71f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 09:47:32 -0700 Subject: [PATCH 4/7] USB packet log: print the radio2 marker from the repeater/room hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The marker in Dispatcher::logPacketLine cost the RAK4631 Full Companion 72 bytes it doesn't have (712776 of 712704). Companions aren't serial observers, so print it where only repeaters and room servers pay for it: their logRxRaw, right after the RAW line and before the RX line, using the radio's current receive profile. 12:34:56 - 29/10/2026 U RAW: 15A3… 12:34:56 - 29/10/2026 U RADIO: radio2 12:34:56 - 29/10/2026 U: RX, len=64 … The shared dispatcher is back to upstream, so Companion images are unchanged. Only receptions are marked in the USB log now; the MQTT bridge still tags both directions. test_usb_log_radio2.py compiles both roles' real logRxRaw and checks the marker against meshcoretomqtt's patterns, and that the dispatcher and Companion sources don't carry it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011i5tJJtj95MB4oUgncCUe5 --- MQTT_IMPLEMENTATION.md | 17 ++-- examples/simple_repeater/MyMesh.cpp | 8 ++ examples/simple_room_server/MyMesh.cpp | 8 ++ src/Dispatcher.cpp | 10 -- test/test_usb_log_radio2.py | 128 ++++++++++--------------- 5 files changed, 80 insertions(+), 91 deletions(-) diff --git a/MQTT_IMPLEMENTATION.md b/MQTT_IMPLEMENTATION.md index 39267fd77..b4ca9e38b 100644 --- a/MQTT_IMPLEMENTATION.md +++ b/MQTT_IMPLEMENTATION.md @@ -1016,17 +1016,22 @@ transmission, and only the radio2 copy is tagged. trailer (older firmware, or no answer this cycle). `self.radio2` is left out while this node's radio2 is off. -**USB packet log (serial observers, e.g. nRF52 with meshcoretomqtt):** a packet -on radio2 gets a line of its own right before its `RX`/`TX` line: +**USB packet log (serial observers, e.g. nRF52 with meshcoretomqtt):** a +repeater or room server that receives a packet on radio2 prints a line of its +own between the packet's `RAW` and `RX` lines: ``` +12:34:56 - 29/10/2026 U RAW: 15A3… 12:34:56 - 29/10/2026 U RADIO: radio2 12:34:56 - 29/10/2026 U: RX, len=64 (type=4, route=F, payload_len=48) SNR=9 RSSI=-71 score=1000 time=88 hash=… ``` -The `RX`/`TX` line itself is unchanged, so readers that match it to the end +The `RAW` and `RX` lines are unchanged, so readers that match them to the end (meshcoretomqtt does) keep working, and a reader that does not know `RADIO:` -skips the line. One that does applies it to the next `RX`/`TX` line only and -publishes the same `rx_radio_id`/`tx_radio_id` and `mediamesh` keys as above. -Compact logging (`R`/`T` hex lines) is unchanged. +skips the line. One that does applies it to the next `RX` line only (a new +`RAW` line drops it) and publishes the same `rx_radio_id` and `mediamesh` keys +as above. Only receptions are marked here, and only by repeaters and room +servers: the line is printed by their raw-receive hook, so the shared +dispatcher, Companion builds and compact logging (`R`/`T` hex lines) are +unchanged. ## Key Features diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index b23441532..730575450 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1270,6 +1270,14 @@ void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { line.printf("%s RAW: ", getLogDateTime()); line.hex(raw, len); line.flush(mesh::usbLoggingPort()); + // radio2 (MediaMesh): a line of its own after RAW and before the RX + // line, which stays unchanged for meshcoretomqtt (its pattern is anchored + // to the end of the line). See MQTT_IMPLEMENTATION.md, "radio2". + if (_radio->receiveProfile() == 1) { + mesh::SerialLogLine<64> radio_line; + radio_line.printf("%s RADIO: radio2", getLogDateTime()); + radio_line.flush(mesh::usbLoggingPort()); + } #endif } #endif diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 59b3ec2f8..2719ccdfb 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1463,6 +1463,14 @@ void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { line.printf("%s RAW: ", getLogDateTime()); line.hex(raw, len); line.flush(mesh::usbLoggingPort()); + // radio2 (MediaMesh): a line of its own after RAW and before the RX + // line, which stays unchanged for meshcoretomqtt (its pattern is anchored + // to the end of the line). See MQTT_IMPLEMENTATION.md, "radio2". + if (_radio->receiveProfile() == 1) { + mesh::SerialLogLine<64> radio_line; + radio_line.printf("%s RADIO: radio2", getLogDateTime()); + radio_line.flush(mesh::usbLoggingPort()); + } } #endif diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index 7af45830e..875044f35 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -547,16 +547,6 @@ uint32_t Dispatcher::getCADFailMaxDuration() const { void Dispatcher::logPacketLine(const char* direction, const Packet* packet, int len, bool include_rx_metrics, float score, uint32_t air_time) { - // radio2 (MediaMesh): a line of its own just before the packet's, so - // meshcoretomqtt and other serial-log readers that match the RX/TX line to - // its end keep parsing it unchanged, and old ones ignore the extra line. - // A reader that knows it tags the next RX/TX line rx_radio_id/tx_radio_id - // "radio2" (MQTT_IMPLEMENTATION.md, "radio2 (MediaMesh)"). - if (packet->radio_profile == 1) { - SerialLogLine<64> radio_line; - radio_line.printf("%s RADIO: radio2", getLogDateTime()); - radio_line.flush(usbLoggingPort()); - } SerialLogLine<256> line; line.printf("%s: %s, len=%d (type=%d, route=%s, payload_len=%d)", getLogDateTime(), direction, len, packet->getPayloadType(), diff --git a/test/test_usb_log_radio2.py b/test/test_usb_log_radio2.py index 7da907cd2..d2bbea4af 100644 --- a/test/test_usb_log_radio2.py +++ b/test/test_usb_log_radio2.py @@ -1,10 +1,12 @@ -"""USB packet log: a radio2 (MediaMesh) packet gets a `RADIO: radio2` line of -its own before its RX/TX line, and nothing else changes. +"""USB packet log: a repeater or room server that receives a packet on radio2 +(MediaMesh) prints `RADIO: radio2` between the packet's RAW and RX lines, and +nothing else changes. -Compiles the real Dispatcher::logPacketLine with small stand-ins, then checks -the lines against the regex meshcoretomqtt (Cisien/meshcoretomqtt, -bridge/message_parser.py) uses: the RX/TX line still parses, the extra line is -ignored by it, and main-radio output is byte-for-byte what it was. +Compiles the real MyMesh::logRxRaw of both roles with small stand-ins, then +checks the lines against the patterns meshcoretomqtt (Cisien/meshcoretomqtt, +bridge/message_parser.py) uses: its RAW handling and RX pattern are untouched, +and the extra line matches neither. The shared Dispatcher (and so every +Companion build) must not carry the marker. """ from pathlib import Path import re @@ -29,109 +31,85 @@ HARNESS = r''' #include #include #include -#include #include #define MESH_PACKET_LOGGING 1 -#define MAX_HASH_SIZE 8 -#define PAYLOAD_TYPE_REQ 0 -#define PAYLOAD_TYPE_RESPONSE 1 -#define PAYLOAD_TYPE_TXT_MSG 2 -#define PAYLOAD_TYPE_ADVERT 4 -#define PAYLOAD_TYPE_PATH 8 +#define MESH_PACKET_LOGGING_COMPACT 0 static std::string out; struct Stream {}; static Stream port; namespace mesh { inline Stream& usbLoggingPort() { return port; } +inline bool isUsbPacketLoggingAllowed() { return true; } +inline bool serialLogCompactPacket(Stream&, char, const uint8_t*, size_t) { return true; } template struct SerialLogLine { std::string s; void printf(const char* f, ...) { char b[CAP]; va_list a; va_start(a, f); vsnprintf(b, sizeof(b), f, a); va_end(a); s += b; } void hex(const uint8_t* d, size_t n) { char b[3]; for (size_t i = 0; i < n; i++) { snprintf(b, 3, "%02X", d[i]); s += b; } } - bool flush(Stream&) { out += s + "\r\n"; return true; } + bool flush(Stream&) { out += s + "\n"; return true; } }; -struct Packet { - uint8_t radio_profile = 0, type = PAYLOAD_TYPE_ADVERT; bool direct = false; - uint8_t payload[4] = {0xAA, 0xBB, 0, 0}; uint16_t payload_len = 48; - uint8_t getPayloadType() const { return type; } - bool isRouteDirect() const { return direct; } - float getSNR() const { return 9.0f; } - int getRSSI() const { return -71; } - void calculatePacketHash(uint8_t* h) const { for (int i = 0; i < MAX_HASH_SIZE; i++) h[i] = uint8_t(0xA0 + i); } -}; -struct Dispatcher { +} +struct Radio { uint8_t profile = 0; uint8_t receiveProfile() const { return profile; } }; +struct MyMesh { + Radio radio; Radio* _radio = &radio; const char* getLogDateTime() { return "12:34:56 - 29/10/2026 U"; } - void logPacketLine(const char* direction, const Packet* packet, int len, - bool include_rx_metrics, float score, uint32_t air_time); + void logRxRaw(float snr, float rssi, const uint8_t raw[], int len); }; @METHOD@ -} int main() { - mesh::Dispatcher d; - mesh::Packet main_rx, r2_rx, r2_tx_direct; - r2_rx.radio_profile = 1; - r2_tx_direct.radio_profile = 1; r2_tx_direct.direct = true; r2_tx_direct.type = PAYLOAD_TYPE_TXT_MSG; - d.logPacketLine("RX", &main_rx, 64, true, 1.0f, 88); + MyMesh m; + const uint8_t raw[] = {0x15, 0xA3, 0x01}; + m.logRxRaw(9.0f, -71.0f, raw, sizeof(raw)); out += "--\n"; - d.logPacketLine("RX", &r2_rx, 64, true, 1.0f, 88); - out += "--\n"; - d.logPacketLine("TX", &r2_tx_direct, 64, false, 0.0f, 0); + m.radio.profile = 1; + m.logRxRaw(9.0f, -71.0f, raw, sizeof(raw)); fputs(out.c_str(), stdout); } ''' class UsbLogRadio2Test(unittest.TestCase): - def run_harness(self): - source = (ROOT / "src/Dispatcher.cpp").read_text() - method = extract_braced(source, "void Dispatcher::logPacketLine(") + def run_role(self, role): + source = (ROOT / "examples" / role / "MyMesh.cpp").read_text() + method = extract_braced(source, "void MyMesh::logRxRaw(") compiler = shutil.which("g++") or shutil.which("clang++") self.assertIsNotNone(compiler) with tempfile.TemporaryDirectory(prefix="meshcore-usb-radio2-") as tmp: src = Path(tmp) / "log.cpp" src.write_text(HARNESS.replace("@METHOD@", method)) exe = Path(tmp) / "log" - built = subprocess.run([compiler, "-std=c++17", "-Wall", "-Werror", str(src), "-o", str(exe)], - capture_output=True, text=True, timeout=60) + built = subprocess.run([compiler, "-std=c++17", "-Wall", "-Werror", "-Wno-unused-parameter", + str(src), "-o", str(exe)], capture_output=True, text=True, timeout=60) self.assertEqual(built.returncode, 0, built.stderr) ran = subprocess.run([str(exe)], capture_output=True, text=True, timeout=30) self.assertEqual(ran.returncode, 0, ran.stderr) - blocks, cur = [], [] - for line in ran.stdout.splitlines(): - if line == "--": - blocks.append(cur); cur = [] - elif line: - cur.append(line) - blocks.append(cur) - return blocks + main, radio2 = ran.stdout.split("--\n") + return main.splitlines(), radio2.splitlines() - def test_radio2_line_precedes_and_main_radio_is_unchanged(self): - main, r2_rx, r2_tx = self.run_harness() - main = [l for l in main if l] - r2_rx = [l for l in r2_rx if l] - r2_tx = [l for l in r2_tx if l] - # Main radio: exactly one line, as before. - self.assertEqual(len(main), 1) - self.assertNotIn("RADIO", main[0]) - # radio2: the RADIO line, then the same RX line as the main radio. - self.assertEqual(r2_rx[0], "12:34:56 - 29/10/2026 U RADIO: radio2") - self.assertEqual(r2_rx[1], main[0]) - self.assertEqual(r2_tx[0], "12:34:56 - 29/10/2026 U RADIO: radio2") - self.assertTrue(r2_tx[1].startswith("12:34:56 - 29/10/2026 U: TX, len=64")) + def check_role(self, role): + main, radio2 = self.run_role(role) + # Main radio: just the RAW line, as before. + self.assertEqual(main, ["12:34:56 - 29/10/2026 U RAW: 15A301"]) + # radio2: the same RAW line, then the marker. + self.assertEqual(radio2, ["12:34:56 - 29/10/2026 U RAW: 15A301", + "12:34:56 - 29/10/2026 U RADIO: radio2"]) + marker = radio2[1] + # meshcoretomqtt: not a packet, not RAW, not DEBUG, so it is skipped. + self.assertIsNone(MCTOMQTT_PACKET.match(marker)) + self.assertNotIn("U RAW:", marker) + self.assertFalse(marker.startswith("DEBUG")) - def test_meshcoretomqtt_still_parses_and_skips_the_radio_line(self): - main, r2_rx, r2_tx = self.run_harness() - r2_rx = [l for l in r2_rx if l] - r2_tx = [l for l in r2_tx if l] - self.assertIsNone(MCTOMQTT_PACKET.match(r2_rx[0])) - self.assertNotIn("U RAW:", r2_rx[0]) - self.assertFalse(r2_rx[0].startswith("DEBUG")) - m = MCTOMQTT_PACKET.match(r2_rx[1]) - self.assertIsNotNone(m, r2_rx[1]) - self.assertEqual(m.group(3), "RX") - self.assertEqual(m.group(12), "A0A1A2A3A4A5A6A7") - t = MCTOMQTT_PACKET.match(r2_tx[1]) - self.assertIsNotNone(t, r2_tx[1]) - self.assertEqual(t.group(13), "BB -> AA") + def test_repeater(self): + self.check_role("simple_repeater") + + def test_room_server(self): + self.check_role("simple_room_server") + + def test_shared_dispatcher_and_companions_are_unchanged(self): + # Full Companion images are packed to the byte; the marker lives only + # in the repeater and room server hooks. + self.assertNotIn("RADIO: radio2", (ROOT / "src/Dispatcher.cpp").read_text()) + for path in (ROOT / "examples/companion_radio").rglob("*.cpp"): + self.assertNotIn("RADIO: radio2", path.read_text(errors="ignore"), str(path)) if __name__ == "__main__": From 7418d754194509cc9aac579c7c900bacb479a60f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 10:05:11 -0700 Subject: [PATCH 5/7] test: re-pin the repeater source hash for the radio2 changes test_cli_reply_text pins the tokens of simple_repeater/MyMesh.cpp so reviewed reply text is the only thing compact profiles change; the radio2 neighbour cache and USB marker change that file, so the pin moves with it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011i5tJJtj95MB4oUgncCUe5 --- test/fixtures/cli_reply_text.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/fixtures/cli_reply_text.json b/test/fixtures/cli_reply_text.json index c6f44147c..5f767f96c 100644 --- a/test/fixtures/cli_reply_text.json +++ b/test/fixtures/cli_reply_text.json @@ -1407,7 +1407,7 @@ ], "noncompact_tokens_sha256": { "src/helpers/CommonCLI.cpp": "42669159fbdc7361dac4c4d0b2654bfbd84118b6223c1f44ecf83b452b8224a7", - "examples/simple_repeater/MyMesh.cpp": "8567eeef2f327100e397c40b0aaf5b675fcdfea254987437d1ca8d005ad3a6f0", + "examples/simple_repeater/MyMesh.cpp": "e551f8c0e0be5e22be1548b5c8ec140d5564cc951c5aea97f882a1b03a022b4b", "src/helpers/RadioProfileCLI.cpp": "d641d16e872eba3e73b9b5a50f9718c10b0b7da72782f793706ac36b3724a624", "src/helpers/WirelessControl.h": "f19bb602999118cc423ec74c7de7100ed91cb2c553295038077e52e321bb4e73", "src/helpers/PowerSavingDiagnostics.h": "18b78f5780046ea8c772c4ce6c337741ae0c027039d9399c94c5f4205d114000", From 619c98d5a61c091462c384bf56981b67506e30f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 11:45:17 -0700 Subject: [PATCH 6/7] USB packet log: print the sent frame and radio2 marker ahead of TX lines A TX line had no RAW of its own, so meshcoretomqtt published each transmission with the last frame it had received, and a serial observer's own radio2 transmissions never showed. The repeater and room server now print the frame as sent (RAW), and RADIO: radio2 when it goes out on radio2, just before the dispatcher's TX line, as they already do for RX. The dispatcher calls mesh::usbLogTxFrame, a weak function only those two roles define; Companion builds keep it undefined and pay a null test. RX/TX lines and compact logging are unchanged. Test: test_usb_log_radio2.py compiles both roles' real hook and runs the lines through meshcoretomqtt's parsing: the TX now carries the sent frame, tagged radio2 only when sent on radio2. cli_reply_text.json re-pinned. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011i5tJJtj95MB4oUgncCUe5 --- MQTT_IMPLEMENTATION.md | 27 ++++-- examples/simple_repeater/MyMesh.cpp | 17 ++++ examples/simple_room_server/MyMesh.cpp | 17 ++++ src/Dispatcher.cpp | 1 + src/Dispatcher.h | 10 +++ test/fixtures/cli_reply_text.json | 2 +- test/test_usb_log_radio2.py | 119 ++++++++++++++++++++++++- 7 files changed, 182 insertions(+), 11 deletions(-) diff --git a/MQTT_IMPLEMENTATION.md b/MQTT_IMPLEMENTATION.md index b4ca9e38b..ff33ceb57 100644 --- a/MQTT_IMPLEMENTATION.md +++ b/MQTT_IMPLEMENTATION.md @@ -1024,14 +1024,27 @@ own between the packet's `RAW` and `RX` lines: 12:34:56 - 29/10/2026 U RADIO: radio2 12:34:56 - 29/10/2026 U: RX, len=64 (type=4, route=F, payload_len=48) SNR=9 RSSI=-71 score=1000 time=88 hash=… ``` -The `RAW` and `RX` lines are unchanged, so readers that match them to the end +For a packet it sends, it prints the frame as sent ahead of the `TX` line, in +the same order, with the marker when the packet goes out on radio2: +``` +12:34:57 - 29/10/2026 U RAW: 15A4… +12:34:57 - 29/10/2026 U RADIO: radio2 +12:34:57 - 29/10/2026 U: TX, len=65 (type=4, route=F, payload_len=48) +``` +Before this, a `TX` line had no `RAW` of its own, so meshcoretomqtt published +each transmission with the last frame it had received. Now it publishes the +frame actually sent, and an observer's own radio2 transmissions show it runs +MediaMesh. + +The `RX` and `TX` lines are unchanged, so readers that match them to the end (meshcoretomqtt does) keep working, and a reader that does not know `RADIO:` -skips the line. One that does applies it to the next `RX` line only (a new -`RAW` line drops it) and publishes the same `rx_radio_id` and `mediamesh` keys -as above. Only receptions are marked here, and only by repeaters and room -servers: the line is printed by their raw-receive hook, so the shared -dispatcher, Companion builds and compact logging (`R`/`T` hex lines) are -unchanged. +skips the line. One that does applies it to the next `RX` or `TX` line only (a +new `RAW` line drops it) and publishes the same `rx_radio_id`/`tx_radio_id` and +`mediamesh` keys as above. Only repeaters and room servers print these lines: +reception from their raw-receive hook, transmission from `mesh::usbLogTxFrame`, +which the dispatcher calls just before its `TX` line. Other roles leave that +weak function undefined, so Companion builds pay only a null test, and compact +logging (`R`/`T` hex lines) is unchanged. ## Key Features diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 730575450..40a255dc1 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1260,6 +1260,23 @@ bool MyMesh::allowTransportPacket(const mesh::Packet* packet, uint8_t context) { } #endif +#if MESH_PACKET_LOGGING && !MESH_PACKET_LOGGING_COMPACT +// The frame as sent, ahead of the Dispatcher's TX line, like logRxRaw does for +// RX: RAW, then the radio2 marker when the packet goes out on radio2. Without +// the RAW line meshcoretomqtt publishes a TX with the last received frame. +void mesh::usbLogTxFrame(const char* log_time, const mesh::Packet* packet, const uint8_t raw[], int len) { + mesh::SerialLogLine<> line; + line.printf("%s RAW: ", log_time); + line.hex(raw, len); + line.flush(mesh::usbLoggingPort()); + if (packet->radio_profile == 1) { + mesh::SerialLogLine<64> radio_line; + radio_line.printf("%s RADIO: radio2", log_time); + radio_line.flush(mesh::usbLoggingPort()); + } +} +#endif + void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { #if MESH_PACKET_LOGGING if (mesh::isUsbPacketLoggingAllowed()) { diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 2719ccdfb..375b8e2ce 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1456,6 +1456,23 @@ bool MyMesh::allowTransportPacket(const mesh::Packet* packet, uint8_t context) { } #endif +#if MESH_PACKET_LOGGING && !MESH_PACKET_LOGGING_COMPACT +// The frame as sent, ahead of the Dispatcher's TX line, like logRxRaw does for +// RX: RAW, then the radio2 marker when the packet goes out on radio2. Without +// the RAW line meshcoretomqtt publishes a TX with the last received frame. +void mesh::usbLogTxFrame(const char* log_time, const mesh::Packet* packet, const uint8_t raw[], int len) { + mesh::SerialLogLine<> line; + line.printf("%s RAW: ", log_time); + line.hex(raw, len); + line.flush(mesh::usbLoggingPort()); + if (packet->radio_profile == 1) { + mesh::SerialLogLine<64> radio_line; + radio_line.printf("%s RADIO: radio2", log_time); + radio_line.flush(mesh::usbLoggingPort()); + } +} +#endif + void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { #if MESH_PACKET_LOGGING if (mesh::isUsbPacketLoggingAllowed()) { diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index 875044f35..cdc701c88 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -240,6 +240,7 @@ bool Dispatcher::startOutboundTransmit() { #if MESH_PACKET_LOGGING_COMPACT serialLogCompactPacket(usbLoggingPort(), 'T', raw, len); #else + if (usbLogTxFrame) usbLogTxFrame(getLogDateTime(), outbound, raw, len); logPacketLine("TX", outbound, len, false, 0.0f, 0); #endif } diff --git a/src/Dispatcher.h b/src/Dispatcher.h index 811b44b69..352bd70e3 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -785,4 +785,14 @@ private: void checkSend(); }; +#if MESH_PACKET_LOGGING && !MESH_PACKET_LOGGING_COMPACT +// Optional: printed ahead of a packet's TX line in the USB packet log, with +// the frame as sent. The repeater and room server define it, so serial +// observers (meshcoretomqtt) publish the transmitted packet and its radio2 +// marker; see MQTT_IMPLEMENTATION.md, "radio2". Other roles leave it +// undefined, and the weak reference costs them only a null test. +void usbLogTxFrame(const char* log_time, const Packet* packet, const uint8_t raw[], int len) + __attribute__((weak)); +#endif + } diff --git a/test/fixtures/cli_reply_text.json b/test/fixtures/cli_reply_text.json index 4ac8aaec1..c42ccf7b9 100644 --- a/test/fixtures/cli_reply_text.json +++ b/test/fixtures/cli_reply_text.json @@ -1407,7 +1407,7 @@ ], "noncompact_tokens_sha256": { "src/helpers/CommonCLI.cpp": "fd13053f606a8d008c10a690cbc7729c2701f44f84e433bb3cd2c776b9e61b0c", - "examples/simple_repeater/MyMesh.cpp": "e551f8c0e0be5e22be1548b5c8ec140d5564cc951c5aea97f882a1b03a022b4b", + "examples/simple_repeater/MyMesh.cpp": "4ff12f53d6fd5e01d58937dc10e8fb661832a0f5853f702cf52cf21ab275ec3a", "src/helpers/RadioProfileCLI.cpp": "d641d16e872eba3e73b9b5a50f9718c10b0b7da72782f793706ac36b3724a624", "src/helpers/WirelessControl.h": "f19bb602999118cc423ec74c7de7100ed91cb2c553295038077e52e321bb4e73", "src/helpers/PowerSavingDiagnostics.h": "18b78f5780046ea8c772c4ce6c337741ae0c027039d9399c94c5f4205d114000", diff --git a/test/test_usb_log_radio2.py b/test/test_usb_log_radio2.py index d2bbea4af..c8b512629 100644 --- a/test/test_usb_log_radio2.py +++ b/test/test_usb_log_radio2.py @@ -1,6 +1,7 @@ """USB packet log: a repeater or room server that receives a packet on radio2 -(MediaMesh) prints `RADIO: radio2` between the packet's RAW and RX lines, and -nothing else changes. +(MediaMesh) prints `RADIO: radio2` between the packet's RAW and RX lines. For +a packet it sends, it prints the frame as sent (RAW) ahead of the TX line, and +the same marker when the packet goes out on radio2. Compiles the real MyMesh::logRxRaw of both roles with small stand-ins, then checks the lines against the patterns meshcoretomqtt (Cisien/meshcoretomqtt, @@ -20,6 +21,7 @@ from test_replay_reset_integration import extract_braced ROOT = Path(__file__).resolve().parents[1] # meshcoretomqtt's patterns, verbatim (bridge/message_parser.py @ 9b4e022). +MCTOMQTT_RAW = re.compile(r"(\d{2}:\d{2}:\d{2}) - (\d{1,2}/\d{1,2}/\d{4}) U RAW: (.*)") MCTOMQTT_PACKET = re.compile( r"(\d{2}:\d{2}:\d{2}) - (\d{1,2}/\d{1,2}/\d{4}) U: (RX|TX), len=(\d+) \(type=(\d+), route=([A-Z]), payload_len=(\d+)\)" r"(?: SNR=(-?\d+) RSSI=(-?\d+) score=(\d+)(?: time=(\d+))?)?" @@ -67,6 +69,68 @@ int main() { ''' +TX_HARNESS = r''' +#include +#include +#include +#include +#define MESH_PACKET_LOGGING 1 +#define MESH_PACKET_LOGGING_COMPACT 0 +static std::string out; +struct Stream {}; +static Stream port; +namespace mesh { +inline Stream& usbLoggingPort() { return port; } +template struct SerialLogLine { + std::string s; + void printf(const char* f, ...) { char b[CAP]; va_list a; va_start(a, f); vsnprintf(b, sizeof(b), f, a); va_end(a); s += b; } + void hex(const uint8_t* d, size_t n) { char b[3]; for (size_t i = 0; i < n; i++) { snprintf(b, 3, "%02X", d[i]); s += b; } } + bool flush(Stream&) { out += s + "\n"; return true; } +}; +struct Packet { uint8_t radio_profile = 0; }; +void usbLogTxFrame(const char* log_time, const Packet* packet, const uint8_t raw[], int len); +} +@HOOK@ +int main() { + mesh::Packet pkt; + const uint8_t raw[] = {0x15, 0xA3, 0x02, 0x7C}; + mesh::usbLogTxFrame("12:34:57 - 29/10/2026 U", &pkt, raw, sizeof(raw)); + out += "--\n"; + pkt.radio_profile = 1; + mesh::usbLogTxFrame("12:34:57 - 29/10/2026 U", &pkt, raw, sizeof(raw)); + fputs(out.c_str(), stdout); +} +''' + +TX_LINE = "12:34:57 - 29/10/2026 U: TX, len=4 (type=5, route=F, payload_len=1)" + + +def mctomqtt_publish(lines): + """meshcoretomqtt's state machine for these lines (message_parser.py with + the radio2 tagging): RAW sets the frame and clears a pending radio id, + RADIO sets it, and each RX/TX line is published with both.""" + radio_re = re.compile(r"\d{2}:\d{2}:\d{2} - \d{1,2}/\d{1,2}/\d{4} U RADIO: ([A-Za-z0-9_-]{1,16})$") + last_raw, pending, published = None, None, [] + for line in lines: + m = radio_re.match(line) + if m: + pending = m.group(1) + continue + if "U RAW:" in line: + last_raw, pending = line.split("U RAW:")[1].strip(), None + m = MCTOMQTT_PACKET.match(line) + if m: + direction = m.group(3).lower() + msg = {"direction": direction, "raw": last_raw} + if pending: + msg[direction + "_radio_id"] = pending + if pending == "radio2": + msg["mediamesh"] = True + pending = None + published.append(msg) + return published + + class UsbLogRadio2Test(unittest.TestCase): def run_role(self, role): source = (ROOT / "examples" / role / "MyMesh.cpp").read_text() @@ -104,12 +168,61 @@ class UsbLogRadio2Test(unittest.TestCase): def test_room_server(self): self.check_role("simple_room_server") + def run_tx_hook(self, role): + source = (ROOT / "examples" / role / "MyMesh.cpp").read_text() + hook = extract_braced(source, "void mesh::usbLogTxFrame(") + compiler = shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler) + with tempfile.TemporaryDirectory(prefix="meshcore-usb-tx-radio2-") as tmp: + src = Path(tmp) / "tx.cpp" + src.write_text(TX_HARNESS.replace("@HOOK@", hook)) + exe = Path(tmp) / "tx" + built = subprocess.run([compiler, "-std=c++17", "-Wall", "-Werror", "-Wno-unused-parameter", + str(src), "-o", str(exe)], capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stderr) + ran = subprocess.run([str(exe)], capture_output=True, text=True, timeout=30) + self.assertEqual(ran.returncode, 0, ran.stderr) + main, radio2 = ran.stdout.split("--\n") + return main.splitlines(), radio2.splitlines() + + def check_tx_role(self, role): + main, radio2 = self.run_tx_hook(role) + self.assertEqual(main, ["12:34:57 - 29/10/2026 U RAW: 15A3027C"]) + self.assertEqual(radio2, ["12:34:57 - 29/10/2026 U RAW: 15A3027C", + "12:34:57 - 29/10/2026 U RADIO: radio2"]) + self.assertIsNotNone(MCTOMQTT_RAW.match(main[0])) + # As meshcoretomqtt publishes it: after a received frame, the TX now + # carries the frame it sent, not the last one it heard, and the radio2 + # tag goes on the TX alone. + rx = ["12:34:56 - 29/10/2026 U RAW: 15A301", + "12:34:56 - 29/10/2026 U: RX, len=3 (type=5, route=F, payload_len=1) SNR=9 RSSI=-71 score=1000"] + self.assertEqual(mctomqtt_publish(rx + main + [TX_LINE]), + [{"direction": "rx", "raw": "15A301"}, {"direction": "tx", "raw": "15A3027C"}]) + self.assertEqual(mctomqtt_publish(rx + radio2 + [TX_LINE]), + [{"direction": "rx", "raw": "15A301"}, + {"direction": "tx", "raw": "15A3027C", "tx_radio_id": "radio2", "mediamesh": True}]) + + def test_repeater_tx(self): + self.check_tx_role("simple_repeater") + + def test_room_server_tx(self): + self.check_tx_role("simple_room_server") + + def test_dispatcher_calls_the_tx_hook_just_before_the_tx_line(self): + dispatcher = (ROOT / "src/Dispatcher.cpp").read_text() + self.assertIn('if (usbLogTxFrame) usbLogTxFrame(getLogDateTime(), outbound, raw, len);\n' + ' logPacketLine("TX", outbound, len, false, 0.0f, 0);', dispatcher) + header = (ROOT / "src/Dispatcher.h").read_text() + self.assertRegex(header, r"void usbLogTxFrame\([^;]*\)\s*__attribute__\(\(weak\)\);") + def test_shared_dispatcher_and_companions_are_unchanged(self): # Full Companion images are packed to the byte; the marker lives only # in the repeater and room server hooks. self.assertNotIn("RADIO: radio2", (ROOT / "src/Dispatcher.cpp").read_text()) for path in (ROOT / "examples/companion_radio").rglob("*.cpp"): - self.assertNotIn("RADIO: radio2", path.read_text(errors="ignore"), str(path)) + text = path.read_text(errors="ignore") + self.assertNotIn("RADIO: radio2", text, str(path)) + self.assertNotIn("usbLogTxFrame", text, str(path)) if __name__ == "__main__": From 7a0fada0da79aa684d3e174aa5d87cb92c6b4756 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 30 Oct 2026 16:46:41 -0700 Subject: [PATCH 7/7] Report distinct Wio and Indicator hardware identities --- test/test_esp32_usb_serial_hygiene.py | 4 +- test/test_indicator_identity.py | 46 +++++++++ test/test_wio_tracker_identity.py | 96 +++++++++++++++++++ variants/sensecap_indicator-espnow/target.h | 6 +- .../wio-tracker-l1-1w/WioTrackerL1Board.h | 2 +- variants/wio-tracker-l1/WioTrackerL1Board.h | 8 +- 6 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 test/test_indicator_identity.py create mode 100644 test/test_wio_tracker_identity.py diff --git a/test/test_esp32_usb_serial_hygiene.py b/test/test_esp32_usb_serial_hygiene.py index 59ec1eb79..962691b56 100644 --- a/test/test_esp32_usb_serial_hygiene.py +++ b/test/test_esp32_usb_serial_hygiene.py @@ -612,7 +612,9 @@ int main() { self.assertIn( "class SenseCapIndicatorBoard : public ESP32Board", header ) - self.assertIn('return "Seeed SenseCAP Indicator";', header) + self.assertIn('#ifdef SENSECAP_INDICATOR_LORA', header) + self.assertIn('return "Seeed SenseCAP Indicator LoRa";', header) + self.assertIn('return "Seeed SenseCAP Indicator ESP-NOW";', header) self.assertIn("extern SenseCapIndicatorBoard board;", header) self.assertIn("SenseCapIndicatorBoard board;", implementation) diff --git a/test/test_indicator_identity.py b/test/test_indicator_identity.py new file mode 100644 index 000000000..cf8f8855d --- /dev/null +++ b/test/test_indicator_identity.py @@ -0,0 +1,46 @@ +"""Keep Indicator's LoRa and ESP-NOW application reports distinguishable.""" + +from pathlib import Path +import os +import re +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +class IndicatorIdentityTests(unittest.TestCase): + def test_actual_board_method_reports_its_compiled_transport(self): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if not compiler: + self.skipTest("C++ compiler unavailable") + header = (ROOT / "variants/sensecap_indicator-espnow/target.h").read_text() + method = re.search(r"const char\* getManufacturerName\(\) const override\s*\{([^}]+)\}", + header).group(1) + configuration = (ROOT / "variants/sensecap_indicator-espnow/platformio.ini").read_text() + self.assertIn("-D SENSECAP_INDICATOR_LORA", configuration) + for lora in (False, True): + with self.subTest(lora=lora), tempfile.TemporaryDirectory(prefix="indicator-identity-") as folder: + folder = Path(folder) + cpp = folder / "identity.cpp" + executable = folder / ("identity.exe" if os.name == "nt" else "identity") + expected = "Seeed SenseCAP Indicator " + ("LoRa" if lora else "ESP-NOW") + cpp.write_text('#include \n#include \n' + 'const char* name() {' + method + '}\n' + f'int main() {{ assert(std::strcmp(name(), "{expected}") == 0); }}\n', + encoding="ascii") + command = [compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror"] + if lora: + command.append("-DSENSECAP_INDICATOR_LORA") + result = subprocess.run(command + [str(cpp), "-o", str(executable)], + capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(executable)], capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_wio_tracker_identity.py b/test/test_wio_tracker_identity.py new file mode 100644 index 000000000..a02a4d23e --- /dev/null +++ b/test/test_wio_tracker_identity.py @@ -0,0 +1,96 @@ +"""Verify USB-visible Wio Tracker identities without accessing a radio.""" + +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +STUB_ARDUINO = r""" +#pragma once +#include +constexpr int AR_INTERNAL = 0, PIN_VBAT_READ = 1; +constexpr float ADC_MULTIPLIER = 2.0f, AREF_VOLTAGE = 3.3f; +inline void analogReadResolution(int) {} +inline void analogReference(int) {} +inline void delay(int) {} +inline int analogRead(int) { return 0; } +""" +STUB_BOARD = r""" +#pragma once +#include +class KeyValueStore {}; +class NRF52Board { +public: + explicit NRF52Board(const char* = "") {} + virtual ~NRF52Board() = default; + virtual bool supportsBatteryCalibration() const { return false; } + virtual uint16_t getBattMilliVolts() { return 0; } + virtual const char* getManufacturerName() const = 0; + virtual bool setLoRaFemPaGainEnabled(bool) { return false; } + virtual bool isLoRaFemPaGainEnabled() const { return false; } + virtual bool canControlLoRaFemPaGain() const { return false; } + virtual void powerOff() {} + uint16_t calibrateBatteryMillivolts(float value) { return value; } +}; +class NRF52BoardDCDC : virtual public NRF52Board {}; +""" + + +class WioTrackerIdentityTests(unittest.TestCase): + def check_header(self, variant, expected, *, eink=False): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if not compiler: + self.skipTest("C++ compiler unavailable") + with tempfile.TemporaryDirectory(prefix="wio-tracker-identity-") as folder: + folder = Path(folder) + (folder / "helpers").mkdir() + (folder / "MeshCore.h").write_text("#pragma once\n", encoding="ascii") + (folder / "Arduino.h").write_text(STUB_ARDUINO, encoding="ascii") + (folder / "helpers/NRF52Board.h").write_text(STUB_BOARD, encoding="ascii") + cpp = folder / "identity.cpp" + executable = folder / ("identity.exe" if os.name == "nt" else "identity") + source = f''' +#include +#include +#include "variants/{variant}/WioTrackerL1Board.h" +void WioTrackerL1Board::begin() {{}} +''' + if variant.endswith("-1w"): + source += "bool WioTrackerL1Board::setLoRaFemPaGainEnabled(bool) { return true; }\n" + source += f''' +int main() {{ + const WioTrackerL1Board board; + assert(std::strcmp(board.getManufacturerName(), "{expected}") == 0); + assert(std::strcmp(board.getManufacturerName(), "Seeed Wio Tracker L1") != 0); +}} +''' + cpp.write_text(source, encoding="ascii") + command = [compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-I", str(folder), "-I", str(ROOT)] + if eink: + command.append("-DWIO_TRACKER_L1_EINK") + result = subprocess.run(command + [str(cpp), "-o", str(executable)], + capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(executable)], capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_pro_reports_the_standard_power_profile(self): + self.check_header("wio-tracker-l1", "Seeed Wio Tracker L1 Pro") + + def test_eink_reports_the_display_variant_from_its_existing_build_flag(self): + configuration = (ROOT / "variants/wio-tracker-l1-eink/platformio.ini").read_text() + self.assertIn("-I variants/wio-tracker-l1", configuration) + self.assertIn("-D WIO_TRACKER_L1_EINK", configuration) + self.check_header("wio-tracker-l1", "Seeed Wio Tracker L1 E-Ink", eink=True) + + def test_one_watt_reports_the_distinct_fem_power_profile(self): + self.check_header("wio-tracker-l1-1w", "Seeed Wio Tracker L1 1W") + + +if __name__ == "__main__": + unittest.main() diff --git a/variants/sensecap_indicator-espnow/target.h b/variants/sensecap_indicator-espnow/target.h index 06f7235d8..4ffc73fa0 100644 --- a/variants/sensecap_indicator-espnow/target.h +++ b/variants/sensecap_indicator-espnow/target.h @@ -23,7 +23,11 @@ class SenseCapIndicatorBoard : public ESP32Board { public: const char* getManufacturerName() const override { - return "Seeed SenseCAP Indicator"; +#ifdef SENSECAP_INDICATOR_LORA + return "Seeed SenseCAP Indicator LoRa"; +#else + return "Seeed SenseCAP Indicator ESP-NOW"; +#endif } }; diff --git a/variants/wio-tracker-l1-1w/WioTrackerL1Board.h b/variants/wio-tracker-l1-1w/WioTrackerL1Board.h index ab45498bb..835f78dae 100644 --- a/variants/wio-tracker-l1-1w/WioTrackerL1Board.h +++ b/variants/wio-tracker-l1-1w/WioTrackerL1Board.h @@ -44,7 +44,7 @@ public: } const char* getManufacturerName() const override { - return "Seeed Wio Tracker L1"; + return "Seeed Wio Tracker L1 1W"; } void powerOff() override { diff --git a/variants/wio-tracker-l1/WioTrackerL1Board.h b/variants/wio-tracker-l1/WioTrackerL1Board.h index a5372bf18..569e7b64a 100644 --- a/variants/wio-tracker-l1/WioTrackerL1Board.h +++ b/variants/wio-tracker-l1/WioTrackerL1Board.h @@ -38,6 +38,12 @@ public: } const char* getManufacturerName() const override { - return "Seeed Wio Tracker L1"; + // Keep the physical display/power variants distinguishable to read-only + // USB clients. Older releases used the same name for Pro, E-Ink and 1W. +#if defined(WIO_TRACKER_L1_EINK) + return "Seeed Wio Tracker L1 E-Ink"; +#else + return "Seeed Wio Tracker L1 Pro"; +#endif } };