mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-11-04 13:17:15 +00:00
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:
commit
d10cc5658e
32 files changed
+1442
-63
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Vendored
+1
-1
@@ -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
@@ -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>());
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
Reference in new issue
Block a user