diff --git a/lib/lora/src/packet_view.cpp b/lib/lora/src/packet_view.cpp index fce62f2..2bb8224 100644 --- a/lib/lora/src/packet_view.cpp +++ b/lib/lora/src/packet_view.cpp @@ -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 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(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) { diff --git a/lib/lora/src/packet_view.h b/lib/lora/src/packet_view.h index bc924ac..1f26279 100644 --- a/lib/lora/src/packet_view.h +++ b/lib/lora/src/packet_view.h @@ -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.". diff --git a/lib/meshcore/src/meshcore_packet.cpp b/lib/meshcore/src/meshcore_packet.cpp index f38422c..5a5e996 100644 --- a/lib/meshcore/src/meshcore_packet.cpp +++ b/lib/meshcore/src/meshcore_packet.cpp @@ -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(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(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"; diff --git a/lib/meshcore/src/meshcore_packet.h b/lib/meshcore/src/meshcore_packet.h index b3f1e63..026c98f 100644 --- a/lib/meshcore/src/meshcore_packet.h +++ b/lib/meshcore/src/meshcore_packet.h @@ -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", ... diff --git a/site/content/guide/lora-scanner.md b/site/content/guide/lora-scanner.md index 4ab2df4..4eaa35a 100644 --- a/site/content/guide/lora-scanner.md +++ b/site/content/guide/lora-scanner.md @@ -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. diff --git a/test/test_meshcore/test_meshcore.cpp b/test/test_meshcore/test_meshcore.cpp index 51e3567..1f28c8b 100644 --- a/test/test_meshcore/test_meshcore.cpp +++ b/test/test_meshcore/test_meshcore.cpp @@ -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(); }