LoRa Scanner: MeshCore searches for nodes and their answers, read

The first two MeshCore packets heard were these: a search for repeaters
and a repeater answering. Both are in clear; they are the test's bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
This commit is contained in:
2026-10-09 20:51:15 +02:00
co-authored by Claude Opus 5.5
parent 8ba41f72b6
commit 3339d9ee59
6 changed files with 97 additions and 4 deletions
+17 -2
View File
@@ -27,7 +27,10 @@ std::string meshcoreRow(const meshcore::Packet& m, size_t room) {
std::string out = meshcore::payloadShort(m.type);
std::string hops = m.hops ? " " + std::to_string(m.hops) + "h" : "";
meshcore::Advert a;
if (meshcore::parseAdvert(m, a)) {
meshcore::Discover d;
if (meshcore::parseDiscover(m, d)) {
out = d.response ? "here " + hex(d.id, 2) : "who's there?";
} else if (meshcore::parseAdvert(m, a)) {
size_t used = out.size() + 1 + hops.size();
out += " " + (a.name.empty() ? hex(a.key, 2) : a.name.substr(0, room > used ? room - used : 0));
} else if (m.addressed() && m.payloadLen >= 2)
@@ -65,7 +68,19 @@ std::vector<std::string> meshcoreLines(const uint8_t* data, size_t len) {
lines.push_back(m.flood() ? "Heard first hand: no repeater yet" : "No route in it: to a neighbour");
}
meshcore::Advert a;
if (meshcore::parseAdvert(m, a)) {
meshcore::Discover d;
if (meshcore::parseDiscover(m, d) && d.response) {
lines.push_back(std::string(meshcore::kindName(d.kind)) + " answering a search");
lines.push_back("Key " + hex(d.id, 8) + "...");
std::snprintf(s, sizeof s, "It heard the search at %.1f dB SNR", d.snr);
lines.push_back(s);
} else if (meshcore::parseDiscover(m, d)) {
std::string who;
for (int kind = 1; kind <= 4; ++kind)
if (d.kinds & (1 << kind)) who += std::string(who.empty() ? "" : ", ") + meshcore::kindName(static_cast<uint8_t>(kind));
lines.push_back("A search for nodes nearby");
lines.push_back("Wanted: " + (who.empty() ? std::string("any") : who));
} else if (meshcore::parseAdvert(m, a)) {
lines.push_back(std::string(meshcore::kindName(a.kind)) + (a.name.empty() ? "" : " " + a.name));
lines.push_back("Key " + hex(a.key, 8) + "...");
if (a.hasLocation) {
+1 -1
View File
@@ -22,7 +22,7 @@ struct PacketSummary {
// One list row, at most about 36 characters: "21:45:07 -97 6.2 5678>all 1/3". Meshtastic nodes by their
// default short name (the last 4 hex digits); headerLines() has the full numbers. MeshCore: what
// it is, then who ("adv Brussels-R1", "txt a3>7f", "chan #11") and the hops in its path ("2h").
// it is, then who ("adv Brussels-R1", "txt a3>7f", "chan #11", "who's there?", "here 18a6") and the hops in its path ("2h").
std::string row(const PacketSummary& p, const std::string& time);
// Eight bytes a line, with the printable ones: "0000 ff ff ff ff 78 56 34 12 ....xV4.".
+21
View File
@@ -67,6 +67,27 @@ bool parseAdvert(const Packet& p, Advert& out) {
return true;
}
bool parseDiscover(const Packet& p, Discover& out) {
if (p.type != Payload::Control || p.payloadLen < 6) return false;
uint8_t sub = p.payload[0] >> 4;
Discover d;
d.tag = static_cast<uint32_t>(le32(p.payload + 2));
if (sub == 0x8) {
d.kinds = p.payload[1];
} else if (sub == 0x9) {
d.response = true;
d.kind = p.payload[0] & 0x0F;
d.snr = static_cast<int8_t>(p.payload[1]) / 4.0f;
d.id = p.payload + 6;
d.idLen = p.payloadLen - 6;
if (d.idLen != 8 && d.idLen != 32) return false;
} else {
return false;
}
out = d;
return true;
}
const char* routeName(Route r) {
switch (r) {
case Route::TransportFlood: return "flood in a region";
+12
View File
@@ -58,6 +58,18 @@ struct Advert {
};
bool parseAdvert(const Packet& p, Advert& out);
// Control packets that look for nodes nearby, and the answers: sent in clear, to neighbours only.
struct Discover {
bool response = false;
uint8_t kinds = 0; // a request: one bit per kind of node wanted (bit 2: repeaters)
uint8_t kind = 0; // a response: what answers, as in an advert
float snr = 0; // a response: how well it heard the request, dB
uint32_t tag = 0; // random in the request, repeated in its responses
const uint8_t* id = nullptr; // a response: the node's key, or its first 8 bytes
size_t idLen = 0;
};
bool parseDiscover(const Packet& p, Discover& out);
const char* routeName(Route r); // "flood", "direct", ...
const char* payloadName(Payload t); // "advert", "text", ...
const char* payloadShort(Payload t); // for a list row: "adv", "txt", ...
+4 -1
View File
@@ -20,7 +20,9 @@ For a **MeshCore** packet it shows what the packet is, who it concerns and how m
| `adv Brussels-R1 2h` | A node saying who it is, by its name, heard through 2 repeaters |
| `txt a3>7f` | A private message from the node whose key starts with `a3` to the one starting with `7f` (`req`, `rsp` and `path` look the same) |
| `chan #11 1h` | A message on a channel; `#11` is the channel's byte, and `11` is the public channel's |
| `ack`, `anon`, `trace`, `ctl` | An acknowledgement, a login, a trace, a discovery |
| `who's there?` | Someone nearby searching for nodes (repeaters, usually) |
| `here 18a6` | A node answering that search; `18a6` starts its key |
| `ack`, `anon`, `trace`, `ctl` | An acknowledgement, a login, a trace, other control packets |
| Key | Does |
|---|---|
@@ -42,6 +44,7 @@ The **MeshCore** preset is the setting its networks use in Europe, "EU/UK (Narro
- what it is and how it travels: **flood** (every repeater passes it on, and adds itself to the path) or **direct** (it carries the route to follow);
- the path, one repeater a hop, each named by the first byte of its key;
- for an **advert**, which is signed and never encrypted: the node's name, its kind (chat node, repeater, room server, sensor), the start of its key, and its position if it gives one;
- for a **search** and its **answers**, also in clear: what kind of node is wanted; and in an answer the node's kind, the start of its key, and how well it heard the search;
- for a message: the first byte of the sender's and the receiver's keys, or the channel's byte, and how many bytes are encrypted.
A byte is all a packet says about who it is from: two nodes can share it. The name comes only with a node's advert, which a node sends now and then, so a quiet network can take a while to show its names.
+42
View File
@@ -146,6 +146,47 @@ void test_detail_lines() {
TEST_ASSERT_EQUAL_size_t(0, lora::headerLines(text, sizeof text, lora::Protocol::None).size());
}
// As received near Brussels on 2026-10-09: a search for repeaters, and one answering.
void test_discover_as_heard() {
const uint8_t ask[] = {0x2e, 0x00, 0x80, 0x04, 0xbb, 0xdd, 0x5f, 0x5a, 0x00, 0x00, 0x00, 0x00};
const uint8_t answer[] = {0x2e, 0x00, 0x92, 0x2d, 0xbb, 0xdd, 0x5f, 0x5a, 0x18, 0xa6, 0x3f, 0xae, 0xf4, 0x01, 0x9b, 0xee,
0xd0, 0x4f, 0xfd, 0xd3, 0xd1, 0x72, 0x65, 0xba, 0x4b, 0x9e, 0xeb, 0xd0, 0xdf, 0xb0, 0xb2, 0xc4,
0x94, 0xec, 0x50, 0x5b, 0x03, 0x20, 0x59, 0x9d};
Packet p;
Discover d;
TEST_ASSERT_TRUE(parsePacket(ask, sizeof ask, p));
TEST_ASSERT_TRUE(p.route == Route::Direct);
TEST_ASSERT_TRUE(p.type == Payload::Control);
TEST_ASSERT_TRUE(parseDiscover(p, d));
TEST_ASSERT_FALSE(d.response);
TEST_ASSERT_EQUAL_HEX8(0x04, d.kinds);
TEST_ASSERT_EQUAL_HEX32(0x5a5fddbb, d.tag);
TEST_ASSERT_TRUE(parsePacket(answer, sizeof answer, p));
TEST_ASSERT_TRUE(parseDiscover(p, d));
TEST_ASSERT_TRUE(d.response);
TEST_ASSERT_EQUAL(2, d.kind);
TEST_ASSERT_EQUAL_FLOAT(11.25f, d.snr);
TEST_ASSERT_EQUAL_HEX32(0x5a5fddbb, d.tag);
TEST_ASSERT_EQUAL_size_t(32, d.idLen);
lora::PacketSummary a{ask, sizeof ask, -107.0f, -7.0f, true, lora::Protocol::MeshCore};
TEST_ASSERT_EQUAL_STRING("20:44:01 -107 -7.0 who's there?", lora::row(a, "20:44:01").c_str());
lora::PacketSummary r{answer, sizeof answer, -96.0f, 3.0f, true, lora::Protocol::MeshCore};
TEST_ASSERT_EQUAL_STRING("20:44:02 -96 3.0 here 18a6", lora::row(r, "20:44:02").c_str());
auto lines = lora::headerLines(ask, sizeof ask, lora::Protocol::MeshCore);
TEST_ASSERT_EQUAL_size_t(4, lines.size());
TEST_ASSERT_EQUAL_STRING("MeshCore control, direct", lines[0].c_str());
TEST_ASSERT_EQUAL_STRING("No route in it: to a neighbour", lines[1].c_str());
TEST_ASSERT_EQUAL_STRING("A search for nodes nearby", lines[2].c_str());
TEST_ASSERT_EQUAL_STRING("Wanted: Repeater", lines[3].c_str());
lines = lora::headerLines(answer, sizeof answer, lora::Protocol::MeshCore);
TEST_ASSERT_EQUAL_size_t(5, lines.size());
TEST_ASSERT_EQUAL_STRING("Repeater answering a search", lines[2].c_str());
TEST_ASSERT_EQUAL_STRING("Key 18a63faef4019bee...", lines[3].c_str());
TEST_ASSERT_EQUAL_STRING("It heard the search at 11.2 dB SNR", lines[4].c_str());
}
void test_scan_presets() {
TEST_ASSERT_EQUAL_size_t(8, lora::scanPresetCount());
lora::ScanPreset p = lora::scanPreset(0); // the default stays first: it is stored by number
@@ -176,6 +217,7 @@ int main() {
RUN_TEST(test_advert);
RUN_TEST(test_rows);
RUN_TEST(test_detail_lines);
RUN_TEST(test_discover_as_heard);
RUN_TEST(test_scan_presets);
return UNITY_END();
}