diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index a36c44740..099b9ccc4 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -574,6 +574,8 @@ jobs: python3 -B test/test_webconfig_json_allocator.py -v python3 -B test/test_mqtt_handshake_scheduler.py -v python3 -B test/test_mqtt_slot_status.py -v + python3 -B test/test_radio2_neighbour_cache.py -v + python3 -B test/test_usb_log_radio2.py -v python3 -B test/test_mqtt_transport_health.py -v python3 -B test/test_mqtt_diagnostics.py -v python3 -B test/test_mqtt_advert_verify.py -v diff --git a/MQTT_IMPLEMENTATION.md b/MQTT_IMPLEMENTATION.md index 4eafe8fd7..a5f38946c 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,85 @@ 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.** These are cached, +last-known reports, not confirmation that a neighbor answered this cycle. +Repeaters keep a 24-entry RAM cache with no age expiry; a response without a +trailer preserves the cached report. New reports update an existing entry. +When full, an on report replaces an off entry first, then entries in rotation +if all are on; an off report for an uncached node is dropped. Evicted reports +become unknown. Room servers keep the report with each neighbor instead; +a response without a trailer clears it to unknown. Neither role refreshes +a cached report when the neighbor does not answer. `self.radio2` is left out +while this node's radio2 is off. + +**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=… +``` +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` 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 ### Connection Handling diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index a6f658d5f..c96a1a867 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1267,6 +1267,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()) { @@ -1277,6 +1294,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 @@ -3697,12 +3722,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); @@ -3858,6 +3894,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; @@ -14283,9 +14323,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 @@ -14311,6 +14354,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 @@ -14359,6 +14407,62 @@ bool MyMesh::handleNeighborDiscoverResponse(int overlay_idx, return true; } +void MyMesh::noteNeighbourRadio2(const mesh::Identity& id, + const Radio2DiscoverTrailer& radio2) { + 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; + } + } + 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) { + 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) { @@ -14476,12 +14580,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++) { @@ -14495,6 +14605,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; @@ -14519,12 +14631,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); @@ -14663,6 +14778,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; @@ -14672,6 +14788,7 @@ bool MyMesh::startNeighborDiscover(char* reply) { metadata.snr = source.snr; metadata.rssi = source.rssi; metadata.status = ND_UNSENT; + neighbor_discover_radio2[i] = neighbourRadio2(source.id); } } diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index fecab3006..ae36f1001 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -886,6 +887,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 +920,21 @@ 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); + // 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); // 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 c21817033..91b531d57 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()) { @@ -1463,6 +1480,16 @@ 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()); +#if !MESH_PACKET_LOGGING_COMPACT + // 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 @@ -2556,6 +2583,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(); @@ -2594,12 +2623,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); @@ -4723,9 +4758,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 @@ -4751,6 +4789,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 @@ -4799,6 +4842,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) { @@ -4914,12 +4981,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++) { @@ -4933,6 +5006,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; @@ -4957,12 +5032,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); @@ -5101,6 +5179,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; @@ -5110,6 +5189,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/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/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..d57a298db --- /dev/null +++ b/src/helpers/Radio2DiscoverTrailer.h @@ -0,0 +1,78 @@ +#pragma once + +#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, +// "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. +// 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 + 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; + 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. + 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 ? 1 : 0; + t.freq_khz = (uint32_t)b[2] | ((uint32_t)b[3] << 8) | ((uint32_t)b[4] << 16) | ((uint32_t)b[5] << 24); + 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; + } + + 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_dhz / 10), (unsigned long)(bw_dhz % 10), + (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/cli_reply_text.json b/test/fixtures/cli_reply_text.json index f0379685e..5c1d456e7 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": "62dfec7f147de2b6e0367e2b2313b673a01c25a7b7cbe0d691aa5d7e68ab44a8", - "examples/simple_repeater/MyMesh.cpp": "b7cb1472b9e535420292469b594300859e8c778c42a92bd278969a7170d591cb", + "examples/simple_repeater/MyMesh.cpp": "e2aa3aeb1fbabcf5baf57451b1c7a3c97ccbc812f8008345191f394423734e3e", "src/helpers/RadioProfileCLI.cpp": "d641d16e872eba3e73b9b5a50f9718c10b0b7da72782f793706ac36b3724a624", "src/helpers/WirelessControl.h": "f19bb602999118cc423ec74c7de7100ed91cb2c553295038077e52e321bb4e73", "src/helpers/PowerSavingDiagnostics.h": "18b78f5780046ea8c772c4ce6c337741ae0c027039d9399c94c5f4205d114000", 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_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_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..89e4f862b 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,16 @@ 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; + } 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..26e80341e --- /dev/null +++ b/test/test_radio2_discover_trailer/test_radio2_discover_trailer.cpp @@ -0,0 +1,87 @@ +#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.bwHz()); + 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, 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)); + 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(); +} 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() diff --git a/test/test_usb_log_radio2.py b/test/test_usb_log_radio2.py new file mode 100644 index 000000000..353227389 --- /dev/null +++ b/test/test_usb_log_radio2.py @@ -0,0 +1,237 @@ +"""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. 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, +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 +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_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+))?)?" + r"(?: hash=([0-9A-F]+))?" + r"(?: \[(.*)\])?$" +) + +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; } +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 + "\n"; return true; } +}; +} +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 logRxRaw(float snr, float rssi, const uint8_t raw[], int len); +}; +@METHOD@ +int main() { + MyMesh m; + const uint8_t raw[] = {0x15, 0xA3, 0x01}; + m.logRxRaw(9.0f, -71.0f, raw, sizeof(raw)); + out += "--\n"; + m.radio.profile = 1; + m.logRxRaw(9.0f, -71.0f, raw, sizeof(raw)); + fputs(out.c_str(), stdout); +} +''' + + +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, compact=False): + 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" + harness = HARNESS.replace("#define MESH_PACKET_LOGGING_COMPACT 0", + "#define MESH_PACKET_LOGGING_COMPACT " + str(int(compact))) + src.write_text(harness.replace("@METHOD@", method)) + exe = Path(tmp) / "log" + 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_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_repeater(self): + self.check_role("simple_repeater") + + def test_room_server(self): + self.check_role("simple_room_server") + + def test_compact_room_keeps_existing_raw_without_radio_marker(self): + main, radio2 = self.run_role("simple_room_server", compact=True) + expected = ["12:34:56 - 29/10/2026 U RAW: 15A301"] + self.assertEqual(main, expected) + self.assertEqual(radio2, expected) + + 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"): + text = path.read_text(errors="ignore") + self.assertNotIn("RADIO: radio2", text, str(path)) + self.assertNotIn("usbLogTxFrame", text, str(path)) + + +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 } };