LoRa Scanner: MeshCore by default, and its public channel read
CI / build (pull_request) Successful in 2m19s
Site / build (pull_request) Successful in 9s

The MeshCore preset is the default for a new device, in the Settings and
in the radio before the Scanner is opened. The setting now accepts it:
it only took the 7 Meshtastic presets, so MeshCore picked in the App was
not saved.

Messages on MeshCore's public channel are decrypted with the channel's
published key (AES-128, checked with 2 bytes of HMAC-SHA256): the row
shows who says they sent it and the start of the text, the details the
sender, the time and the whole text. Other channels and private messages
stay encrypted.

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 22:19:14 +02:00
co-authored by Claude Opus 5.5
parent 3339d9ee59
commit 305818466f
18 changed files with 344 additions and 21 deletions
+1 -1
View File
@@ -138,7 +138,7 @@ On a page, `b` bookmarks it, `s` saves it to the SD card to read offline (a non-
## LoRa Scanner ## LoRa Scanner
The LoRa Scanner (docs/milestones/M3.md) listens with the Cap's radio and **never transmits**. The Sniffer lists what it hears, newest first: time, RSSI, SNR, and for Meshtastic packets the sender and receiver (their last 4 hex digits) and hops; for MeshCore packets what they are (an advert with the node's name, a message with the first byte of each node's key, a channel message with the channel's byte) and the hops in their path. Enter shows a packet's details: what is never encrypted (the Meshtastic header; MeshCore's header, path and, in an advert, the node's name, kind, key and position) and a hex dump. `p` picks one of the 7 Meshtastic presets allowed in EU868 (LongFast by default) or **MeshCore** (its EU/UK Narrow setting: 869.618 MHz, 62.5 kHz, SF8, CR 4/8), `c` starts or stops a Capture: a pcap file with LoRaTap headers in `/captures/lora/`, for Wireshark. A Capture keeps recording with the App closed; otherwise the radio sleeps when the App isn't open. Tab switches to **Sweep**: the signal strength across 863–870 MHz in 100 kHz steps, as bars with peak hold and a waterfall, with the Sniffer's frequency marked; the Sniffer is paused meanwhile and picks up where it was. The GNSS receiver on the same Cap raises the radio's noise floor by 8 dB while it runs: Settings > "Pause GNSS for LoRa" (off by default) puts it in standby while the radio listens, except during a Track. The Status Bar shows `L` while the radio listens (bright for a moment on each packet), `SW` while sweeping, and `CAP` while capturing. The LoRa Scanner (docs/milestones/M3.md) listens with the Cap's radio and **never transmits**. The Sniffer lists what it hears, newest first: time, RSSI, SNR, and for Meshtastic packets the sender and receiver (their last 4 hex digits) and hops; for MeshCore packets what they are (an advert with the node's name, a message with the first byte of each node's key, a message on the public channel read, with the name its sender gives; any other channel message with the channel's byte) and the hops in their path. Enter shows a packet's details: what is never encrypted (the Meshtastic header; MeshCore's header, path and, in an advert, the node's name, kind, key and position; and a public channel message's sender, time and text, decrypted with the channel's published key) and a hex dump. `p` picks **MeshCore** (the default; its EU/UK Narrow setting: 869.618 MHz, 62.5 kHz, SF8, CR 4/8) or one of the 7 Meshtastic presets allowed in EU868, `c` starts or stops a Capture: a pcap file with LoRaTap headers in `/captures/lora/`, for Wireshark. A Capture keeps recording with the App closed; otherwise the radio sleeps when the App isn't open. Tab switches to **Sweep**: the signal strength across 863–870 MHz in 100 kHz steps, as bars with peak hold and a waterfall, with the Sniffer's frequency marked; the Sniffer is paused meanwhile and picks up where it was. The GNSS receiver on the same Cap raises the radio's noise floor by 8 dB while it runs: Settings > "Pause GNSS for LoRa" (off by default) puts it in standby while the radio listens, except during a Track. The Status Bar shows `L` while the radio listens (bright for a moment on each packet), `SW` while sweeping, and `CAP` while capturing.
## Storage ## Storage
+1 -1
View File
@@ -36,7 +36,7 @@
| Q92 | A **Radio Service** owns the SX1262: driver, bus lock, IRQ task. The LoRa Scanner uses it in M3; the Mesh Service sits on top of it in M4. | | Q92 | A **Radio Service** owns the SX1262: driver, bus lock, IRQ task. The LoRa Scanner uses it in M3; the Mesh Service sits on top of it in M4. |
| Q93 | Every radio transfer takes the shared bus lock (`SPI.beginTransaction`, as the card does). DIO1's interrupt only wakes the task; no SPI in the ISR. **Done when** a Gemini page streams to the card while the Sniffer receives, with no lost packets and no card errors (`lora status` counters). | | Q93 | Every radio transfer takes the shared bus lock (`SPI.beginTransaction`, as the card does). DIO1's interrupt only wakes the task; no SPI in the ISR. **Done when** a Gemini page streams to the card while the Sniffer receives, with no lost packets and no card errors (`lora status` counters). |
| Q94 | **Receive only:** the Radio Service has no transmit function in M3. It doesn't exist, rather than being unused. | | Q94 | **Receive only:** the Radio Service has no transmit function in M3. It doesn't exist, rather than being unused. |
| Q95 | Sniffer defaults: **EU868 LongFast**, 869.525 MHz, BW 250 kHz, SF 11, CR 4/5, sync word 0x2B, preamble 16 (Q19). The other Meshtastic presets are offered, plus custom settings. | | Q95 | Sniffer defaults: **EU868 LongFast**, 869.525 MHz, BW 250 kHz, SF 11, CR 4/5, sync word 0x2B, preamble 16 (Q19). The other Meshtastic presets are offered, plus custom settings. *Later, the default became is the MeshCore preset (869.618 MHz, BW 62.5 kHz, SF 8, CR 4/8, sync word 0x12): it is what is heard here.* |
| Q96 | The Sniffer lists packets (time, RSSI, SNR, frequency error, length; hex dump on Enter) **and decodes the Meshtastic header**: the first 16 bytes are never encrypted (destination, sender, packet ID, hop limit and hop start, channel hash, next hop, relay node). Host-tested. Payload decryption is M4. | | Q96 | The Sniffer lists packets (time, RSSI, SNR, frequency error, length; hex dump on Enter) **and decodes the Meshtastic header**: the first 16 bytes are never encrypted (destination, sender, packet ID, hop limit and hop start, channel hash, next hop, relay node). Host-tested. Payload decryption is M4. |
| Q97 | A Sniffer **Capture** is pcap with **LoRaTap** headers (link type 270), for Wireshark. Started by hand, Status Bar mark, its own Clean-up category, the 90% rule. | | Q97 | A Sniffer **Capture** is pcap with **LoRaTap** headers (link type 270), for Wireshark. Started by hand, Status Bar mark, its own Clean-up category, the 90% rule. |
| Q98 | **Sweep** steps across the Region's band (863–870 MHz) in 100 kHz steps by default, reading instant RSSI: bars with peak hold, and a waterfall, Wi-Fi Tools style. Optionally CAD on the preset's frequency to tell LoRa traffic from noise. | | Q98 | **Sweep** steps across the Region's band (863–870 MHz) in 100 kHz steps by default, reading instant RSSI: bars with peak hold, and a waterfall, Wi-Fi Tools style. Optionally CAD on the preset's frequency to tell LoRa traffic from noise. |
+28 -1
View File
@@ -2,7 +2,9 @@
#include <cmath> #include <cmath>
#include <cstdio> #include <cstdio>
#include <ctime>
#include "meshcore_channel.h"
#include "meshcore_packet.h" #include "meshcore_packet.h"
#include "meshtastic_header.h" #include "meshtastic_header.h"
#include "meshtastic_presets.h" #include "meshtastic_presets.h"
@@ -22,14 +24,31 @@ std::string hex(const uint8_t* p, size_t n) {
return out; return out;
} }
// `room` characters at most: a node's name is cut to fit. // Onto lines of `cols` characters at most, broken at spaces where there are some.
void wrapInto(std::vector<std::string>& lines, std::string text, size_t cols) {
while (text.size() > cols) {
size_t cut = text.rfind(' ', cols);
if (cut == std::string::npos || cut == 0) cut = cols;
lines.push_back(text.substr(0, cut));
text.erase(0, text[cut] == ' ' ? cut + 1 : cut);
}
if (!text.empty()) lines.push_back(text);
}
// `room` characters at most: a node's name or a message is cut to fit.
std::string meshcoreRow(const meshcore::Packet& m, size_t room) { std::string meshcoreRow(const meshcore::Packet& m, size_t room) {
std::string out = meshcore::payloadShort(m.type); std::string out = meshcore::payloadShort(m.type);
std::string hops = m.hops ? " " + std::to_string(m.hops) + "h" : ""; std::string hops = m.hops ? " " + std::to_string(m.hops) + "h" : "";
meshcore::Advert a; meshcore::Advert a;
meshcore::Discover d; meshcore::Discover d;
meshcore::ChannelText t;
if (meshcore::parseDiscover(m, d)) { if (meshcore::parseDiscover(m, d)) {
out = d.response ? "here " + hex(d.id, 2) : "who's there?"; out = d.response ? "here " + hex(d.id, 2) : "who's there?";
} else if (meshcore::readChannelText(m, t)) {
out = (t.sender.empty() ? "" : t.sender + ": ") + t.text;
out = out.substr(0, room > hops.size() ? room - hops.size() : 0);
while (!out.empty() && out.back() == ' ') out.pop_back();
return out + hops;
} else if (meshcore::parseAdvert(m, a)) { } else if (meshcore::parseAdvert(m, a)) {
size_t used = out.size() + 1 + hops.size(); 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)); out += " " + (a.name.empty() ? hex(a.key, 2) : a.name.substr(0, room > used ? room - used : 0));
@@ -90,6 +109,14 @@ std::vector<std::string> meshcoreLines(const uint8_t* data, size_t len) {
} else if (m.addressed() && m.payloadLen >= 4) { } else if (m.addressed() && m.payloadLen >= 4) {
std::snprintf(s, sizeof s, "From ..%02x to ..%02x, %u B encrypted", m.payload[1], m.payload[0], static_cast<unsigned>(m.payloadLen - 4)); std::snprintf(s, sizeof s, "From ..%02x to ..%02x, %u B encrypted", m.payload[1], m.payload[0], static_cast<unsigned>(m.payloadLen - 4));
lines.push_back(s); lines.push_back(s);
} else if (meshcore::ChannelText t; meshcore::readChannelText(m, t)) {
lines.push_back(std::string("On the ") + t.channel + " channel" + (t.sender.empty() ? "" : ", from " + t.sender));
std::time_t sent = t.timestamp;
std::tm tm{};
gmtime_r(&sent, &tm);
std::strftime(s, sizeof s, "Sent %Y-%m-%d %H:%M:%S UTC", &tm);
lines.push_back(s);
wrapInto(lines, t.text, 38);
} else if (m.group() && m.payloadLen >= 3) { } else if (m.group() && m.payloadLen >= 3) {
std::snprintf(s, sizeof s, "Channel 0x%02x%s, %u B encrypted", m.payload[0], m.payload[0] == meshcore::kPublicChannelHash ? " (public?)" : "", std::snprintf(s, sizeof s, "Channel 0x%02x%s, %u B encrypted", m.payload[0], m.payload[0] == meshcore::kPublicChannelHash ? " (public?)" : "",
static_cast<unsigned>(m.payloadLen - 3)); static_cast<unsigned>(m.payloadLen - 3));
+3 -2
View File
@@ -22,14 +22,15 @@ struct PacketSummary {
// One list row, at most about 36 characters: "21:45:07 -97 6.2 5678>all 1/3". Meshtastic nodes by their // 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 // 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", "who's there?", "here 18a6") 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");
// a message on the public channel is read: "Alice: hello fro 2h".
std::string row(const PacketSummary& p, const std::string& time); 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.". // Eight bytes a line, with the printable ones: "0000 ff ff ff ff 78 56 34 12 ....xV4.".
std::vector<std::string> hexDump(const uint8_t* data, size_t len); std::vector<std::string> hexDump(const uint8_t* data, size_t len);
// What is in clear, one field a line: the Meshtastic header, or MeshCore's header, path and (for an // What is in clear, one field a line: the Meshtastic header, or MeshCore's header, path and (for an
// advert) who the node says it is. Empty when the packet can't be read as that. // advert) who the node says it is, and a message on its public channel. Empty when the packet can't be read as that.
std::vector<std::string> headerLines(const uint8_t* data, size_t len, Protocol protocol = Protocol::Meshtastic); std::vector<std::string> headerLines(const uint8_t* data, size_t len, Protocol protocol = Protocol::Meshtastic);
} // namespace roro::lora } // namespace roro::lora
+2 -1
View File
@@ -8,7 +8,7 @@
namespace roro::lora { namespace roro::lora {
// What the LoRa Scanner can be told to listen to by name: the Meshtastic presets allowed in // What the LoRa Scanner can be told to listen to by name: the Meshtastic presets allowed in
// EU_868, LongFast (the default) first, then MeshCore's EU/UK (Narrow). The order is kept: the // EU_868, LongFast first, then MeshCore's EU/UK (Narrow), the default. The order is kept: the
// chosen one is stored by its number. // chosen one is stored by its number.
struct ScanPreset { struct ScanPreset {
const char* name; const char* name;
@@ -19,6 +19,7 @@ struct ScanPreset {
Protocol protocol; Protocol protocol;
}; };
constexpr size_t kDefaultScanPreset = 7; // MeshCore: the Settings' default says the same
size_t scanPresetCount(); size_t scanPresetCount();
ScanPreset scanPreset(size_t i); // i < scanPresetCount() ScanPreset scanPreset(size_t i); // i < scanPresetCount()
// By name, whatever its case. False when there is none. // By name, whatever its case. False when there is none.
+87
View File
@@ -0,0 +1,87 @@
#include "aes128.h"
#include <cstring>
namespace roro::meshcore {
namespace {
// The S-box and its inverse, worked out once rather than typed in.
struct Tables {
uint8_t s[256], inv[256];
Tables() {
auto rotl = [](uint8_t x, int n) { return static_cast<uint8_t>(x << n | x >> (8 - n)); };
uint8_t p = 1, q = 1;
do {
p = static_cast<uint8_t>(p ^ (p << 1) ^ (p & 0x80 ? 0x1B : 0)); // p times 3
q ^= static_cast<uint8_t>(q << 1); // q divided by 3
q ^= static_cast<uint8_t>(q << 2);
q ^= static_cast<uint8_t>(q << 4);
if (q & 0x80) q ^= 0x09;
uint8_t x = static_cast<uint8_t>(q ^ rotl(q, 1) ^ rotl(q, 2) ^ rotl(q, 3) ^ rotl(q, 4) ^ 0x63);
s[p] = x;
inv[x] = p;
} while (p != 1);
s[0] = 0x63;
inv[0x63] = 0;
}
};
const Tables& tables() {
static const Tables t;
return t;
}
uint8_t mul(uint8_t a, uint8_t b) { // in GF(2^8)
uint8_t r = 0;
for (; b; b >>= 1) {
if (b & 1) r ^= a;
a = static_cast<uint8_t>(a << 1 ^ (a & 0x80 ? 0x1B : 0));
}
return r;
}
} // namespace
Aes128::Aes128(const uint8_t key[16]) {
const Tables& t = tables();
std::memcpy(roundKeys_, key, 16);
uint8_t rcon = 1;
for (int i = 16; i < 176; i += 4) {
uint8_t w[4];
std::memcpy(w, roundKeys_ + i - 4, 4);
if (i % 16 == 0) {
uint8_t first = w[0];
w[0] = static_cast<uint8_t>(t.s[w[1]] ^ rcon);
w[1] = t.s[w[2]];
w[2] = t.s[w[3]];
w[3] = t.s[first];
rcon = static_cast<uint8_t>(rcon << 1 ^ (rcon & 0x80 ? 0x1B : 0));
}
for (int j = 0; j < 4; ++j) roundKeys_[i + j] = roundKeys_[i - 16 + j] ^ w[j];
}
}
void Aes128::decryptBlock(const uint8_t in[16], uint8_t out[16]) const {
const Tables& t = tables();
uint8_t s[16];
for (int i = 0; i < 16; ++i) s[i] = in[i] ^ roundKeys_[160 + i];
for (int round = 9; round >= 0; --round) {
// Rows shifted back and bytes substituted back, in one go: column c, row r came from column c - r.
uint8_t u[16];
for (int c = 0; c < 4; ++c)
for (int r = 0; r < 4; ++r) u[4 * c + r] = t.inv[s[4 * ((c - r + 4) % 4) + r]];
for (int i = 0; i < 16; ++i) u[i] ^= roundKeys_[16 * round + i];
if (round == 0) {
std::memcpy(s, u, 16);
break;
}
for (int c = 0; c < 4; ++c) {
const uint8_t* a = u + 4 * c;
s[4 * c + 0] = mul(a[0], 14) ^ mul(a[1], 11) ^ mul(a[2], 13) ^ mul(a[3], 9);
s[4 * c + 1] = mul(a[0], 9) ^ mul(a[1], 14) ^ mul(a[2], 11) ^ mul(a[3], 13);
s[4 * c + 2] = mul(a[0], 13) ^ mul(a[1], 9) ^ mul(a[2], 14) ^ mul(a[3], 11);
s[4 * c + 3] = mul(a[0], 11) ^ mul(a[1], 13) ^ mul(a[2], 9) ^ mul(a[3], 14);
}
}
std::memcpy(out, s, 16);
}
} // namespace roro::meshcore
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <cstdint>
namespace roro::meshcore {
// AES-128 (FIPS 197), one block at a time, decryption only: what reading a MeshCore message
// takes. Small and dependency-free, so the same code runs in the PC tests and on the device.
class Aes128 {
public:
explicit Aes128(const uint8_t key[16]);
void decryptBlock(const uint8_t in[16], uint8_t out[16]) const;
private:
uint8_t roundKeys_[176];
};
} // namespace roro::meshcore
+87
View File
@@ -0,0 +1,87 @@
#include "meshcore_channel.h"
#include <cstring>
#include "aes128.h"
#include "sha256.h"
namespace roro::meshcore {
namespace {
// izOH6cXN6mrJ5e26oRXNcg== in base64, as the apps show it.
const Channel kChannels[] = {
{"Public", {0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a, 0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72}},
};
void hmacSha256(const uint8_t key[16], const uint8_t* data, size_t len, uint8_t out[32]) {
uint8_t pad[64], inner[32];
std::memset(pad, 0x36, sizeof pad);
for (int i = 0; i < 16; ++i) pad[i] ^= key[i];
Sha256 in;
in.update(pad, sizeof pad);
in.update(data, len);
in.finish(inner);
std::memset(pad, 0x5C, sizeof pad);
for (int i = 0; i < 16; ++i) pad[i] ^= key[i];
Sha256 o;
o.update(pad, sizeof pad);
o.update(inner, sizeof inner);
o.finish(out);
}
// Bytes that aren't printable ASCII become '?': one for a whole UTF-8 character.
std::string printable(const uint8_t* p, size_t n) {
std::string out;
for (size_t i = 0; i < n; ++i) {
if (p[i] >= 0x20 && p[i] < 0x7F) out += static_cast<char>(p[i]);
else if ((p[i] & 0xC0) != 0x80) out += '?';
}
return out;
}
} // namespace
size_t channelCount() { return sizeof kChannels / sizeof kChannels[0]; }
const Channel& channel(size_t i) { return kChannels[i]; }
uint8_t channelHash(const Channel& c) {
uint8_t h[32];
Sha256::hash(c.key, sizeof c.key, h);
return h[0];
}
bool readChannelText(const Packet& p, ChannelText& out) {
// The channel's hash, 2 bytes of check, then whole AES blocks.
if (p.type != Payload::GroupText || p.payloadLen < 3 + 16 || (p.payloadLen - 3) % 16) return false;
const uint8_t* cipher = p.payload + 3;
size_t len = p.payloadLen - 3;
for (size_t i = 0; i < channelCount(); ++i) {
const Channel& c = channel(i);
if (channelHash(c) != p.payload[0]) continue;
uint8_t mac[32];
hmacSha256(c.key, cipher, len, mac);
if (mac[0] != p.payload[1] || mac[1] != p.payload[2]) continue;
uint8_t plain[192];
Aes128 aes(c.key);
for (size_t at = 0; at < len; at += 16) aes.decryptBlock(cipher + at, plain + at);
// Timestamp, then a byte whose top 6 bits say what follows: 0 is plain text, "name: words".
if (plain[4] >> 2) return false;
ChannelText t;
t.channel = c.name;
t.timestamp = static_cast<uint32_t>(plain[0] | plain[1] << 8 | plain[2] << 16 | static_cast<uint32_t>(plain[3]) << 24);
size_t end = 5;
while (end < len && plain[end]) ++end; // padded with zeros
std::string all = printable(plain + 5, end - 5);
size_t colon = all.find(": ");
if (colon == std::string::npos) {
t.text = all;
} else {
t.sender = all.substr(0, colon);
t.text = all.substr(colon + 2);
}
out = t;
return true;
}
return false;
}
} // namespace roro::meshcore
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include "meshcore_packet.h"
// MeshCore's channel messages: encrypted with a key everyone on the channel has. The public
// channel's key is published, so what is said there can be read by anyone listening.
namespace roro::meshcore {
struct Channel {
const char* name;
uint8_t key[16];
};
// The channels whose key is known here: the public one, for now.
size_t channelCount();
const Channel& channel(size_t i);
// What a message on the channel starts with: the first byte of the SHA-256 of its key.
uint8_t channelHash(const Channel& c);
struct ChannelText {
const char* channel = "";
uint32_t timestamp = 0; // the sender's clock, seconds since 1970
std::string sender; // what the sender calls itself: not signed, anyone can claim a name
std::string text; // printable ASCII kept, the rest as '?'
};
// True when the packet is a text on a known channel and its check (2 bytes of HMAC-SHA256) holds.
bool readChannelText(const Packet& p, ChannelText& out);
} // namespace roro::meshcore
+3 -2
View File
@@ -4,8 +4,9 @@
#include <cstdint> #include <cstdint>
#include <string> #include <string>
// MeshCore's packets as they are on air (its docs/packet_format.md and docs/payloads.md). Only // MeshCore's packets as they are on air (its docs/packet_format.md and docs/payloads.md): what is
// what is sent in clear is read: the header, the path, and a node's advertisement. // sent in clear, which is the header, the path, and a node's advertisement. meshcore_channel.h
// reads the public channel's messages.
namespace roro::meshcore { namespace roro::meshcore {
// Radio settings: RadioLib's "private" sync word, and the preset its EU/UK networks use. // Radio settings: RadioLib's "private" sync word, and the preset its EU/UK networks use.
+1 -1
View File
@@ -35,7 +35,7 @@ const Definition kDefinitions[] = {
{"wifi_on", Kind::Bool, 1, nullptr, 0, 1}, {"wifi_on", Kind::Bool, 1, nullptr, 0, 1},
{"gnss_on", Kind::Bool, 1, nullptr, 0, 1}, {"gnss_on", Kind::Bool, 1, nullptr, 0, 1},
{"coord_dms", Kind::Bool, 0, nullptr, 0, 1}, {"coord_dms", Kind::Bool, 0, nullptr, 0, 1},
{"lora_preset", Kind::Int, 0, nullptr, 0, 6}, // LongFast first {"lora_preset", Kind::Int, 7, nullptr, 0, 7}, // MeshCore, after the 7 Meshtastic ones
{"dns1", Kind::String, 0, "9.9.9.9", 7, 15}, // Quad9 {"dns1", Kind::String, 0, "9.9.9.9", 7, 15}, // Quad9
{"dns2", Kind::String, 0, "1.1.1.1", 0, 15}, // Cloudflare {"dns2", Kind::String, 0, "1.1.1.1", 0, 15}, // Cloudflare
{"dns_always", Kind::Bool, 0, nullptr, 0, 1}, {"dns_always", Kind::Bool, 0, nullptr, 0, 1},
+1 -1
View File
@@ -23,7 +23,7 @@ enum class Setting : uint8_t {
WifiEnabled, // bool: the Wi-Fi Service stays Connected when a Saved Network is in range WifiEnabled, // bool: the Wi-Fi Service stays Connected when a Saved Network is in range
GnssEnabled, // bool: the GNSS Service reads the receiver (M2, Q58) GnssEnabled, // bool: the GNSS Service reads the receiver (M2, Q58)
CoordinatesDms, // bool: show degrees, minutes and seconds instead of decimal degrees (Q64) CoordinatesDms, // bool: show degrees, minutes and seconds instead of decimal degrees (Q64)
LoraPreset, // int: the LoRa Scanner's Meshtastic preset, an index into the EU868 list (M3, Q95) LoraPreset, // int: the LoRa Scanner's preset, an index into lora::scanPreset (M3, Q95): MeshCore by default
Dns1, // string: the first DNS server, an IPv4 address (S1, Q108, Q109) Dns1, // string: the first DNS server, an IPv4 address (S1, Q108, Q109)
Dns2, // string: the second, or empty Dns2, // string: the second, or empty
DnsAlways, // bool: use them on Automatic (DHCP) networks too, instead of DHCP's DnsAlways, // bool: use them on Automatic (DHCP) networks too, instead of DHCP's
+1 -1
View File
@@ -44,7 +44,7 @@ tag = "M3"
| Q92 | A **Radio Service** owns the SX1262: driver, bus lock, IRQ task. The LoRa Scanner uses it in M3; the Mesh Service sits on top of it in M4. | | Q92 | A **Radio Service** owns the SX1262: driver, bus lock, IRQ task. The LoRa Scanner uses it in M3; the Mesh Service sits on top of it in M4. |
| Q93 | Every radio transfer takes the shared bus lock (`SPI.beginTransaction`, as the card does). DIO1's interrupt only wakes the task; no SPI in the ISR. **Done when** a Gemini page streams to the card while the Sniffer receives, with no lost packets and no card errors (`lora status` counters). | | Q93 | Every radio transfer takes the shared bus lock (`SPI.beginTransaction`, as the card does). DIO1's interrupt only wakes the task; no SPI in the ISR. **Done when** a Gemini page streams to the card while the Sniffer receives, with no lost packets and no card errors (`lora status` counters). |
| Q94 | **Receive only:** the Radio Service has no transmit function in M3. It doesn't exist, rather than being unused. | | Q94 | **Receive only:** the Radio Service has no transmit function in M3. It doesn't exist, rather than being unused. |
| Q95 | Sniffer defaults: **EU868 LongFast**, 869.525 MHz, BW 250 kHz, SF 11, CR 4/5, sync word 0x2B, preamble 16 (Q19). The other Meshtastic presets are offered, plus custom settings. | | Q95 | Sniffer defaults: **EU868 LongFast**, 869.525 MHz, BW 250 kHz, SF 11, CR 4/5, sync word 0x2B, preamble 16 (Q19). The other Meshtastic presets are offered, plus custom settings. *Later, the default became is the MeshCore preset (869.618 MHz, BW 62.5 kHz, SF 8, CR 4/8, sync word 0x12): it is what is heard here.* |
| Q96 | The Sniffer lists packets (time, RSSI, SNR, frequency error, length; hex dump on Enter) **and decodes the Meshtastic header**: the first 16 bytes are never encrypted (destination, sender, packet ID, hop limit and hop start, channel hash, next hop, relay node). Host-tested. Payload decryption is M4. | | Q96 | The Sniffer lists packets (time, RSSI, SNR, frequency error, length; hex dump on Enter) **and decodes the Meshtastic header**: the first 16 bytes are never encrypted (destination, sender, packet ID, hop limit and hop start, channel hash, next hop, relay node). Host-tested. Payload decryption is M4. |
| Q97 | A Sniffer **Capture** is pcap with **LoRaTap** headers (link type 270), for Wireshark. Started by hand, Status Bar mark, its own Clean-up category, the 90% rule. | | Q97 | A Sniffer **Capture** is pcap with **LoRaTap** headers (link type 270), for Wireshark. Started by hand, Status Bar mark, its own Clean-up category, the 90% rule. |
| Q98 | **Sweep** steps across the Region's band (863–870 MHz) in 100 kHz steps by default, reading instant RSSI: bars with peak hold, and a waterfall, Wi-Fi Tools style. Optionally CAD on the preset's frequency to tell LoRa traffic from noise. | | Q98 | **Sweep** steps across the Region's band (863–870 MHz) in 100 kHz steps by default, reading instant RSSI: bars with peak hold, and a waterfall, Wi-Fi Tools style. Optionally CAD on the preset's frequency to tell LoRa traffic from noise. |
+8 -4
View File
@@ -19,7 +19,8 @@ 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 | | `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) | | `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 | | `Alice: hello 1h` | A message on the **public channel**, read: who says they sent it, and the start of what it says |
| `chan #5c 1h` | A message on another channel, still encrypted; `#5c` is the channel's byte |
| `who's there?` | Someone nearby searching for nodes (repeaters, usually) | | `who's there?` | Someone nearby searching for nodes (repeaters, usually) |
| `here 18a6` | A node answering that search; `18a6` starts its key | | `here 18a6` | A node answering that search; `18a6` starts its key |
| `ack`, `anon`, `trace`, `ctl` | An acknowledgement, a login, a trace, other control packets | | `ack`, `anon`, `trace`, `ctl` | An acknowledgement, a login, a trace, other control packets |
@@ -27,11 +28,11 @@ For a **MeshCore** packet it shows what the packet is, who it concerns and how m
| Key | Does | | Key | Does |
|---|---| |---|---|
| <kbd>Enter</kbd> | The details of a packet: what is never encrypted (below), and a hex dump | | <kbd>Enter</kbd> | The details of a packet: what is never encrypted (below), and a hex dump |
| <kbd>p</kbd> | Picks what to listen to: one of the 7 Meshtastic presets allowed in EU868 (LongFast by default), or **MeshCore** | | <kbd>p</kbd> | Picks what to listen to: **MeshCore** (the default), or one of the 7 Meshtastic presets allowed in EU868 |
| <kbd>c</kbd> | Starts or stops a **capture** | | <kbd>c</kbd> | Starts or stops a **capture** |
| <kbd>Tab</kbd> | Switches to the Sweep | | <kbd>Tab</kbd> | Switches to the Sweep |
The Scanner shows the header and the bytes; it does not decrypt the message itself, which Meshtastic and MeshCore both encrypt. The Scanner shows the header and the bytes. Meshtastic and MeshCore both encrypt the message itself, and the Scanner reads only one kind: what is said on MeshCore's public channel, whose key is published.
**One network at a time.** The radio listens on one frequency with one setting, and the two networks share neither: on a Meshtastic preset no MeshCore packet is heard, and the other way round. **One network at a time.** The radio listens on one frequency with one setting, and the two networks share neither: on a Meshtastic preset no MeshCore packet is heard, and the other way round.
@@ -45,7 +46,10 @@ The **MeshCore** preset is the setting its networks use in Europe, "EU/UK (Narro
- the path, one repeater a hop, each named by the first byte of its key; - 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 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 **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. - for a message on the **public channel**: the name the sender gives, when the sender's clock says it was sent (UTC), and the text;
- for any other message: the first byte of the sender's and the receiver's keys, or the channel's byte, and how many bytes are encrypted.
The public channel is the one every MeshCore node starts with, and its key is the same everywhere, so anyone listening can read it. Private messages and other channels stay encrypted. The sender's name is part of the message and is not signed: anyone can write any name. Letters outside plain ASCII (accents, emoji) show as `?`.
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. 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.
+2 -2
View File
@@ -9,12 +9,12 @@ tag = "LoRa Scanner"
The radio only **listens**: nothing is transmitted. You need the **Cap LoRa-1262** and an **SD card**. The radio only **listens**: nothing is transmitted. You need the **Cap LoRa-1262** and an **SD card**.
1. **Open the LoRa Scanner.** The Status Bar shows `L` while the radio listens. 1. **Open the LoRa Scanner.** The Status Bar shows `L` while the radio listens.
2. **Pick a preset with <kbd>p</kbd>.** The Scanner offers the 7 Meshtastic presets allowed in EU868 (LongFast is the default) and MeshCore. 2. **Pick a preset with <kbd>p</kbd>.** The Scanner offers MeshCore (the default) and the 7 Meshtastic presets allowed in EU868.
3. **Wait for packets.** Each line is a packet: the time, RSSI and SNR, and for a Meshtastic packet the sender, the receiver and the hops. <kbd>Enter</kbd> shows a packet's header and its bytes. 3. **Wait for packets.** Each line is a packet: the time, RSSI and SNR, and for a Meshtastic packet the sender, the receiver and the hops. <kbd>Enter</kbd> shows a packet's header and its bytes.
4. **Press <kbd>c</kbd> to start a capture.** The Status Bar shows `CAP`. It keeps recording with the App closed. 4. **Press <kbd>c</kbd> to start a capture.** The Status Bar shows `CAP`. It keeps recording with the App closed.
5. **Press <kbd>c</kbd> again to stop.** 5. **Press <kbd>c</kbd> again to stop.**
6. **Get the file.** It is in `/captures/lora/`, a `.pcap` with LoRaTap headers. In the [Storage App](/guide/storage/) you can already look at its packets; on a computer, open it in Wireshark. 6. **Get the file.** It is in `/captures/lora/`, a `.pcap` with LoRaTap headers. In the [Storage App](/guide/storage/) you can already look at its packets; on a computer, open it in Wireshark.
**What you will and will not see.** Meshtastic's header is never encrypted, so who sent a packet and how far it hopped is visible. The message itself is encrypted with the channel's key, and the Scanner does not decrypt it. **What you will and will not see.** Meshtastic's header is never encrypted, so who sent a packet and how far it hopped is visible. The message itself is encrypted with the channel's key, and the Scanner does not decrypt it. MeshCore's public channel is the exception: its key is published, and the Scanner [reads what is said there](/guide/lora-scanner/#meshcore).
**Hearing nothing is normal** if no node is in range, or if the preset does not match what the nodes nearby use. The [Sweep](/guide/lora-scanner/) (<kbd>Tab</kbd>) shows whether anything is on the air at all across 863 to 870 MHz. **Hearing nothing is normal** if no node is in range, or if the preset does not match what the nodes nearby use. The [Sweep](/guide/lora-scanner/) (<kbd>Tab</kbd>) shows whether anything is on the air at all across 863 to 870 MHz.
+1 -1
View File
@@ -39,7 +39,7 @@ constexpr float kDeafBelow = -108.0f;
RadioService* RadioService::instance_ = nullptr; RadioService* RadioService::instance_ = nullptr;
RadioService::RadioService() : lock_(xSemaphoreCreateMutex()) { RadioService::RadioService() : lock_(xSemaphoreCreateMutex()) {
lora::ScanPreset p = lora::scanPreset(0); // LongFast (Q95) lora::ScanPreset p = lora::scanPreset(lora::kDefaultScanPreset);
config_ = {p.frequencyHz, p.bwKHz, p.sf, p.cr, p.syncWord, p.preamble, p.name}; config_ = {p.frequencyHz, p.bwKHz, p.sf, p.cr, p.syncWord, p.preamble, p.name};
} }
+64 -1
View File
@@ -4,6 +4,8 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include "aes128.h"
#include "meshcore_channel.h"
#include "meshcore_packet.h" #include "meshcore_packet.h"
#include "packet_view.h" #include "packet_view.h"
#include "scan_presets.h" #include "scan_presets.h"
@@ -187,9 +189,67 @@ void test_discover_as_heard() {
TEST_ASSERT_EQUAL_STRING("It heard the search at 11.2 dB SNR", lines[4].c_str()); TEST_ASSERT_EQUAL_STRING("It heard the search at 11.2 dB SNR", lines[4].c_str());
} }
void test_aes128_decrypts_the_standard_block() { // FIPS 197, appendix C.1
uint8_t key[16], in[16] = {0x69, 0xc4, 0xe0, 0xd8, 0x6a, 0x7b, 0x04, 0x30, 0xd8, 0xcd, 0xb7, 0x80, 0x70, 0xb4, 0xc5, 0x5a}, out[16];
for (int i = 0; i < 16; ++i) key[i] = static_cast<uint8_t>(i);
Aes128(key).decryptBlock(in, out);
for (int i = 0; i < 16; ++i) TEST_ASSERT_EQUAL_HEX8(i * 0x11, out[i]);
}
// Messages on the public channel, relayed twice, encrypted and signed with openssl:
// "Alice: hello from Brussels" and "Zo\xc3\xa9: \xc3\xa7a va ?", both sent at 1791575000.
static const uint8_t kHello[] = {0x15, 0x02, 0xa3, 0x7f, 0x11, 0x20, 0x14, 0x90, 0xb7, 0xe1, 0x1d, 0x46, 0x5f,
0x3f, 0xac, 0x4f, 0x98, 0xa8, 0xc6, 0x07, 0x79, 0x4c, 0x96, 0x19, 0x5e, 0x54,
0x60, 0x67, 0x2b, 0x2e, 0xec, 0x0e, 0xe4, 0x6a, 0xbc, 0x8b, 0x51, 0x38, 0xfd};
static const uint8_t kAccents[] = {0x15, 0x02, 0xa3, 0x7f, 0x11, 0x19, 0xdd, 0x85, 0x58, 0xc5, 0x73, 0x2d, 0x55,
0x4d, 0xf4, 0xfd, 0x02, 0x47, 0xdc, 0xfa, 0xa2, 0xce, 0x3b, 0xcb, 0xb1, 0x8e,
0x8f, 0x66, 0xc7, 0xd6, 0x61, 0x30, 0x3c, 0xbe, 0x29, 0xd7, 0xb8, 0x08, 0x26};
void test_public_channel_message() {
TEST_ASSERT_EQUAL_HEX8(kPublicChannelHash, channelHash(channel(0)));
Packet p;
ChannelText t;
TEST_ASSERT_TRUE(parsePacket(kHello, sizeof kHello, p));
TEST_ASSERT_TRUE(readChannelText(p, t));
TEST_ASSERT_EQUAL_STRING("Public", t.channel);
TEST_ASSERT_EQUAL_UINT32(1791575000, t.timestamp);
TEST_ASSERT_EQUAL_STRING("Alice", t.sender.c_str());
TEST_ASSERT_EQUAL_STRING("hello from Brussels", t.text.c_str());
TEST_ASSERT_TRUE(parsePacket(kAccents, sizeof kAccents, p));
TEST_ASSERT_TRUE(readChannelText(p, t));
TEST_ASSERT_EQUAL_STRING("Zo?", t.sender.c_str()); // one '?' for a character outside ASCII
TEST_ASSERT_EQUAL_STRING("?a va ?", t.text.c_str());
// One bit wrong and the check fails: nothing is made up from it.
std::vector<uint8_t> bad(kHello, kHello + sizeof kHello);
bad[20] ^= 1;
TEST_ASSERT_TRUE(parsePacket(bad.data(), bad.size(), p));
TEST_ASSERT_FALSE(readChannelText(p, t));
// Another channel with the same first byte, or not whole blocks: left encrypted.
bad.assign(kHello, kHello + sizeof kHello - 1);
TEST_ASSERT_TRUE(parsePacket(bad.data(), bad.size(), p));
TEST_ASSERT_FALSE(readChannelText(p, t));
}
void test_public_channel_message_shown() {
lora::PacketSummary s{kHello, sizeof kHello, -97, 6.2f, true, lora::Protocol::MeshCore};
std::string row = lora::row(s, "21:45:07");
TEST_ASSERT_EQUAL_STRING("21:45:07 -97 6.2 Alice: hello from 2h", row.c_str());
TEST_ASSERT_LESS_OR_EQUAL(38, row.size());
auto lines = lora::headerLines(kHello, sizeof kHello, lora::Protocol::MeshCore);
TEST_ASSERT_EQUAL_size_t(5, lines.size());
TEST_ASSERT_EQUAL_STRING("MeshCore channel message, flood", lines[0].c_str());
TEST_ASSERT_EQUAL_STRING("Through a3 7f", lines[1].c_str());
TEST_ASSERT_EQUAL_STRING("On the Public channel, from Alice", lines[2].c_str());
TEST_ASSERT_EQUAL_STRING("Sent 2026-10-09 19:43:20 UTC", lines[3].c_str());
TEST_ASSERT_EQUAL_STRING("hello from Brussels", lines[4].c_str());
}
void test_scan_presets() { void test_scan_presets() {
TEST_ASSERT_EQUAL_size_t(8, lora::scanPresetCount()); TEST_ASSERT_EQUAL_size_t(8, lora::scanPresetCount());
lora::ScanPreset p = lora::scanPreset(0); // the default stays first: it is stored by number TEST_ASSERT_EQUAL_STRING("MeshCore", lora::scanPreset(lora::kDefaultScanPreset).name);
lora::ScanPreset p = lora::scanPreset(0); // the order stays: a preset is stored by number
TEST_ASSERT_EQUAL_STRING("LongFast", p.name); TEST_ASSERT_EQUAL_STRING("LongFast", p.name);
TEST_ASSERT_EQUAL_UINT32(869525000u, p.frequencyHz); TEST_ASSERT_EQUAL_UINT32(869525000u, p.frequencyHz);
TEST_ASSERT_EQUAL_HEX8(0x2B, p.syncWord); TEST_ASSERT_EQUAL_HEX8(0x2B, p.syncWord);
@@ -218,6 +278,9 @@ int main() {
RUN_TEST(test_rows); RUN_TEST(test_rows);
RUN_TEST(test_detail_lines); RUN_TEST(test_detail_lines);
RUN_TEST(test_discover_as_heard); RUN_TEST(test_discover_as_heard);
RUN_TEST(test_aes128_decrypts_the_standard_block);
RUN_TEST(test_public_channel_message);
RUN_TEST(test_public_channel_message_shown);
RUN_TEST(test_scan_presets); RUN_TEST(test_scan_presets);
return UNITY_END(); return UNITY_END();
} }
+2 -2
View File
@@ -28,8 +28,8 @@ void test_defaults_when_store_is_empty() {
TEST_ASSERT_TRUE(s.getBool(Setting::WifiEnabled)); TEST_ASSERT_TRUE(s.getBool(Setting::WifiEnabled));
TEST_ASSERT_TRUE(s.getBool(Setting::GnssEnabled)); // Q58: on by default TEST_ASSERT_TRUE(s.getBool(Setting::GnssEnabled)); // Q58: on by default
TEST_ASSERT_FALSE(s.getBool(Setting::CoordinatesDms)); // Q64: decimal degrees TEST_ASSERT_FALSE(s.getBool(Setting::CoordinatesDms)); // Q64: decimal degrees
TEST_ASSERT_EQUAL_INT32(0, s.getInt(Setting::LoraPreset)); // Q95: LongFast TEST_ASSERT_EQUAL_INT32(7, s.getInt(Setting::LoraPreset)); // MeshCore, after the seven EU868 presets
TEST_ASSERT_FALSE(s.setInt(Setting::LoraPreset, 7)); // seven EU868 presets TEST_ASSERT_FALSE(s.setInt(Setting::LoraPreset, 8));
TEST_ASSERT_EQUAL_STRING("EU868", s.getString(Setting::Region).c_str()); TEST_ASSERT_EQUAL_STRING("EU868", s.getString(Setting::Region).c_str());
TEST_ASSERT_EQUAL_STRING("CET-1CEST,M3.5.0,M10.5.0/3", s.getString(Setting::Timezone).c_str()); TEST_ASSERT_EQUAL_STRING("CET-1CEST,M3.5.0,M10.5.0/3", s.getString(Setting::Timezone).c_str());
} }