Merge upstream keymindCascade (radio2 MQTT/USB log tagging, PR #27) into radio presets

Only test/fixtures/cli_reply_text.json conflicted; messages and profiles
identical, source token hashes regenerated for the merged sources.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mjgodatandClaude Opus 5.5 committed 2026-10-30 23:58:39 -07:00
commit d10cc5658e
32 files changed
+1442 -63

No files matched your search

+2
View File
@@ -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
+81
View File
@@ -877,6 +877,7 @@ While `mqtt.neighbors` is on, `get mqtt.status` appends `nbr: <next>/<last>` --
- 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: <next>/<last>` --
- `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
+121 -4
View File
@@ -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);
}
}
+19
View File
@@ -4,6 +4,7 @@
#include <new>
#include <Mesh.h>
#include <helpers/PacketFileLoggingConfig.h>
#include <helpers/Radio2DiscoverTrailer.h>
#include <helpers/PowerSavingDiagnostics.h>
#include <helpers/EmergencyOtaWork.h>
#include <helpers/FloodGroupPlainText.h>
@@ -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.
+85 -5
View File
@@ -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;
}
}
+11
View File
@@ -2,6 +2,7 @@
#include <Arduino.h> // needed for PlatformIO
#include <Mesh.h>
#include <helpers/Radio2DiscoverTrailer.h>
#include <helpers/PowerSavingDiagnostics.h>
#include <helpers/EmergencyOtaWork.h>
#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;
+1
View File
@@ -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
}
+10
View File
@@ -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
}
+42 -14
View File
@@ -6,6 +6,7 @@
#include "MQTTMessageBuilder.h"
#include "MQTTPayloadBuilder.h"
#include "TxtDataHelpers.h"
#include <ArduinoJson.h>
#include <cstring>
#include <math.h>
@@ -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) {
+27 -6
View File
@@ -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:
+43 -8
View File
@@ -26,6 +26,14 @@ static int serializeComplete(JsonDocument& doc, char* buffer, size_t buffer_size
return static_cast<int>(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<JsonObject>();
@@ -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<JsonArray>().add<JsonObject>();
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<JsonObject>();
@@ -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<JsonObject>();
@@ -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<JsonObject>();
@@ -236,6 +267,7 @@ static JsonArray buildNeighborsMessageBase(
JsonObject self = root["self"].to<JsonObject>();
self["scopes"] = self_scopes ? self_scopes : "";
self["default_scope"] = self_default_scope ? self_default_scope : "";
if (self_radio2) addRadio2Fields(self["radio2"].to<JsonObject>(), *self_radio2);
return root["neighbors"].to<JsonArray>();
}
@@ -256,6 +288,7 @@ static void addNeighborsMessageEntry(
}
nb["scopes"] = neighbor.scopes ? neighbor.scopes : "";
nb["status"] = neighbor.status;
if (neighbor.radio2) addRadio2Fields(nb["radio2"].to<JsonObject>(), *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++) {
+51 -5
View File
@@ -3,6 +3,7 @@
#include <ArduinoJson.h>
#include <stddef.h>
#include <stdint.h>
#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);
};
+78
View File
@@ -0,0 +1,78 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
// 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);
}
};
+29 -5
View File
@@ -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) {
+2 -1
View File
@@ -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.
+1 -1
View File
@@ -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",
+23 -1
View File
@@ -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<class Mesh> 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<class Mesh> 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<int>()==count);
if(count>0) assert(document["neighbors"][0]["pubkey"].template as<const char*>()!=nullptr);
// radio2 (MediaMesh): this node's own, and the one neighbour that reported one.
assert(!strcmp(document["self"]["radio2"]["mode"].template as<const char*>(),"rxtx"));
{
int with_radio2=0;
for (JsonObject nb : document["neighbors"].template as<JsonArray>()) {
if (!nb["radio2"].template is<JsonObject>()) continue;
++with_radio2;
assert(!strcmp(nb["radio2"]["mode"].template as<const char*>(),"rxtx"));
assert(!strcmp(nb["radio2"]["radio"].template as<const char*>(),"911.150000,250.0,7,5"));
assert(nb["pubkey"].template as<const char*>()[0]==
"0123456789abcdef"[((count-1)>>4)&15]);
}
const bool newest_published=count>0 && document["neighbors"].template as<JsonArray>().size()>0;
assert(with_radio2==(newest_published ? 1 : 0));
}
assert(document["neighbors"].template as<JsonArray>().size()<=size_t(count));
if(count>MQTTBridge::NEIGHBORS_MAX_PUBLISH_ENTRIES)
assert(document["truncated"].template as<bool>());
+3 -1
View File
@@ -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)
+46
View File
@@ -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 <cassert>\n#include <cstring>\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()
+2
View File
@@ -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;
@@ -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<const char*>());
EXPECT_TRUE(parsed["mediamesh"].is<bool>());
EXPECT_TRUE(parsed["mediamesh"].as<bool>());
EXPECT_FALSE(parsed["tx_radio_id"].is<JsonVariant>());
EXPECT_STREQ("10.3", parsed["SNR"].as<const char*>()); // 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<const char*>());
EXPECT_FALSE(parsed["rx_radio_id"].is<JsonVariant>());
EXPECT_TRUE(parsed["mediamesh"].as<bool>());
}
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<const char*>());
}
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<const char*>()); // 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<const char*>());
EXPECT_STREQ("910.525000,250.0,7,5", parsed["self"]["radio2"]["radio"].as<const char*>());
EXPECT_TRUE(parsed["self"]["radio2"]["temporary"].as<bool>());
JsonArray arr = parsed["neighbors"].as<JsonArray>();
ASSERT_EQ(3U, arr.size());
EXPECT_STREQ("rx", arr[0]["radio2"]["mode"].as<const char*>());
EXPECT_STREQ("911.150000,250.0,7,5", arr[0]["radio2"]["radio"].as<const char*>());
EXPECT_FALSE(arr[0]["radio2"]["temporary"].as<bool>());
EXPECT_STREQ("off", arr[1]["radio2"]["mode"].as<const char*>());
EXPECT_FALSE(arr[1]["radio2"]["radio"].is<JsonVariant>());
EXPECT_FALSE(arr[1]["radio2"]["temporary"].is<JsonVariant>());
EXPECT_FALSE(arr[2]["radio2"].is<JsonVariant>()); // 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<size_t>(len));
}
} // namespace
int main(int argc, char** argv) {
+2 -1
View File
@@ -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);
}
+4 -4
View File
@@ -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));
+2
View File
@@ -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<typename... Args>
@@ -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];
}
+16 -1
View File
@@ -34,6 +34,10 @@ class NeighborScopeStorageTest(unittest.TestCase):
#include <cstddef>
#define MAX_NEIGHBOURS 254
#define PUB_KEY_SIZE 32
#include <helpers/Radio2DiscoverTrailer.h>
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");
@@ -0,0 +1,87 @@
#include <gtest/gtest.h>
#include <cstring>
#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();
}
+111
View File
@@ -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 <cassert>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <helpers/Radio2DiscoverTrailer.h>
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()
+237
View File
@@ -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 <cstdarg>
#include <cstdint>
#include <cstdio>
#include <string>
#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 <size_t CAP = 256> 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 <cstdarg>
#include <cstdint>
#include <cstdio>
#include <string>
#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 <size_t CAP = 256> 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()
+96
View File
@@ -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 <cstdint>
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 <cstdint>
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 <cassert>
#include <cstring>
#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()
+5 -1
View File
@@ -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
}
};
@@ -44,7 +44,7 @@ public:
}
const char* getManufacturerName() const override {
return "Seeed Wio Tracker L1";
return "Seeed Wio Tracker L1 1W";
}
void powerOff() override {
+7 -1
View File
@@ -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
}
};