Public Access
LoRa Scanner: a MeshCore preset, and its packets read
- MeshCore joins the presets (EU/UK Narrow: 869.618 MHz, 62.5 kHz, SF8, CR 4/8, sync 0x12), after the Meshtastic ones so stored choices keep their number; `lora preset MeshCore` on the console - lib/meshcore reads what MeshCore sends in clear: header, path, and a node's advert (name, kind, key, position) - the list row, the details, a Capture's viewer and the console echo read a packet by the sync word it was received on - the bandwidth is printed as 62.5, not 62 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
This commit is contained in:
@@ -188,7 +188,7 @@ inline constexpr KeyHelp kGeminiAnswer[] = {
|
||||
inline constexpr KeyHelp kLora[] = {
|
||||
{"; .", "up, down"},
|
||||
{"Enter", "the packet's details"},
|
||||
{"p", "pick a Meshtastic preset"},
|
||||
{"p", "pick a preset"},
|
||||
{"c", "start a Capture, or stop it"},
|
||||
{"Tab", "the Sweep"},
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
|
||||
#include "meshcore_packet.h"
|
||||
#include "meshtastic_header.h"
|
||||
#include "meshtastic_presets.h"
|
||||
|
||||
@@ -10,13 +11,97 @@ namespace roro::lora {
|
||||
|
||||
using namespace meshtastic;
|
||||
|
||||
namespace {
|
||||
std::string hex(const uint8_t* p, size_t n) {
|
||||
std::string out;
|
||||
char s[4];
|
||||
for (size_t i = 0; i < n; ++i) {
|
||||
std::snprintf(s, sizeof s, "%02x", p[i]);
|
||||
out += s;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// `room` characters at most: a node's name is cut to fit.
|
||||
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)) {
|
||||
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)
|
||||
out += " " + hex(m.payload + 1, 1) + ">" + hex(m.payload, 1);
|
||||
else if (m.group() && m.payloadLen >= 1)
|
||||
out += " #" + hex(m.payload, 1);
|
||||
else if (m.type == meshcore::Payload::AnonRequest && m.payloadLen >= 1)
|
||||
out += " >" + hex(m.payload, 1);
|
||||
return out + hops;
|
||||
}
|
||||
|
||||
std::vector<std::string> meshcoreLines(const uint8_t* data, size_t len) {
|
||||
meshcore::Packet m;
|
||||
if (!meshcore::parsePacket(data, len, m)) return {};
|
||||
char s[64];
|
||||
std::vector<std::string> lines;
|
||||
lines.push_back("MeshCore " + std::string(meshcore::payloadName(m.type)) + ", " + meshcore::routeName(m.route));
|
||||
if (m.hasTransport) {
|
||||
std::snprintf(s, sizeof s, "Region code %04x", m.transport[0]);
|
||||
lines.push_back(s);
|
||||
}
|
||||
// A flood gathers who repeated it; a direct packet carries who is still to repeat it.
|
||||
if (m.hops) {
|
||||
std::string path = std::string(m.flood() ? "Through" : "Route");
|
||||
for (uint8_t i = 0; i < m.hops; ++i) {
|
||||
std::string hop = " " + hex(m.path + i * m.hashSize, m.hashSize);
|
||||
if (path.size() + hop.size() > 36) { // onto another line
|
||||
lines.push_back(path);
|
||||
path = " ";
|
||||
}
|
||||
path += hop;
|
||||
}
|
||||
lines.push_back(path);
|
||||
} else {
|
||||
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)) {
|
||||
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) {
|
||||
std::snprintf(s, sizeof s, "At %.5f, %.5f", a.latE6 / 1e6, a.lonE6 / 1e6);
|
||||
lines.push_back(s);
|
||||
}
|
||||
} 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));
|
||||
lines.push_back(s);
|
||||
} 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?)" : "",
|
||||
static_cast<unsigned>(m.payloadLen - 3));
|
||||
lines.push_back(s);
|
||||
} else if (m.type == meshcore::Payload::AnonRequest && m.payloadLen >= 35) {
|
||||
std::snprintf(s, sizeof s, "To ..%02x from key %s...", m.payload[0], hex(m.payload + 1, 4).c_str());
|
||||
lines.push_back(s);
|
||||
} else if (m.type == meshcore::Payload::Ack && m.payloadLen >= 4) {
|
||||
lines.push_back("Acknowledges " + hex(m.payload, 4));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
Protocol protocolOf(uint8_t syncWord) {
|
||||
return syncWord == meshtastic::kSyncWord ? Protocol::Meshtastic : syncWord == meshcore::kSyncWord ? Protocol::MeshCore : Protocol::None;
|
||||
}
|
||||
|
||||
std::string row(const PacketSummary& p, const std::string& time) {
|
||||
char s[64];
|
||||
int n = std::snprintf(s, sizeof s, "%s %ld %.1f ", time.c_str(), std::lround(p.rssi), p.snr);
|
||||
std::string out(s, n);
|
||||
PacketHeader h;
|
||||
if (!p.crcOk) return out + "bad CRC, " + std::to_string(p.len) + " B";
|
||||
if (!p.meshtastic || !parseHeader(p.data, p.len, h)) return out + std::to_string(p.len) + " B";
|
||||
meshcore::Packet m;
|
||||
if (p.protocol == Protocol::MeshCore && meshcore::parsePacket(p.data, p.len, m)) return out + meshcoreRow(m, out.size() < 38 ? 38 - out.size() : 0);
|
||||
if (p.protocol != Protocol::Meshtastic || !parseHeader(p.data, p.len, h)) return out + std::to_string(p.len) + " B";
|
||||
auto shortId = [](uint32_t node) {
|
||||
if (node == kBroadcast) return std::string("all");
|
||||
char id[8];
|
||||
@@ -48,7 +133,9 @@ std::vector<std::string> hexDump(const uint8_t* data, size_t len) {
|
||||
return lines;
|
||||
}
|
||||
|
||||
std::vector<std::string> headerLines(const uint8_t* data, size_t len) {
|
||||
std::vector<std::string> headerLines(const uint8_t* data, size_t len, Protocol protocol) {
|
||||
if (protocol == Protocol::MeshCore) return meshcoreLines(data, len);
|
||||
if (protocol != Protocol::Meshtastic) return {};
|
||||
PacketHeader h;
|
||||
if (!parseHeader(data, len, h)) return {};
|
||||
char s[64];
|
||||
|
||||
@@ -7,23 +7,29 @@
|
||||
|
||||
namespace roro::lora {
|
||||
|
||||
// Whose packets the radio's settings were for, by their sync word: whose clear header to read.
|
||||
enum class Protocol : uint8_t { None, Meshtastic, MeshCore };
|
||||
Protocol protocolOf(uint8_t syncWord);
|
||||
|
||||
// What the LoRa Scanner shows of one packet (M3, Q96).
|
||||
struct PacketSummary {
|
||||
const uint8_t* data;
|
||||
size_t len;
|
||||
float rssi, snr;
|
||||
bool crcOk;
|
||||
bool meshtastic; // received on Meshtastic settings (sync word 0x2B): read its clear header
|
||||
Protocol protocol; // what the settings it was received on were for
|
||||
};
|
||||
|
||||
// One list row, at most about 36 characters: "21:45:07 -97 6.2 5678>all 1/3". Nodes by their default
|
||||
// short name (the last 4 hex digits); headerLines() has the full numbers.
|
||||
// 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").
|
||||
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.".
|
||||
std::vector<std::string> hexDump(const uint8_t* data, size_t len);
|
||||
|
||||
// The Meshtastic header, one field a line; empty when the packet is too short to have one.
|
||||
std::vector<std::string> headerLines(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
|
||||
// advert) who the node says it is. 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);
|
||||
|
||||
} // namespace roro::lora
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
#include "scan_presets.h"
|
||||
|
||||
#include <cctype>
|
||||
|
||||
#include "meshcore_packet.h"
|
||||
#include "meshtastic_presets.h"
|
||||
|
||||
namespace roro::lora {
|
||||
|
||||
size_t scanPresetCount() { return meshtastic::kEu868PresetCount + 1; }
|
||||
|
||||
ScanPreset scanPreset(size_t i) {
|
||||
if (i < meshtastic::kEu868PresetCount) {
|
||||
const meshtastic::Preset& p = meshtastic::kEu868Presets[i];
|
||||
return {p.name, meshtastic::eu868FrequencyHz(p), p.bwKHz, p.sf, p.cr, meshtastic::kSyncWord, meshtastic::kPreambleLength, Protocol::Meshtastic};
|
||||
}
|
||||
return {meshcore::kPresetName, meshcore::kEuNarrowHz, meshcore::kEuNarrowBwKHz, meshcore::kEuNarrowSf, meshcore::kEuNarrowCr,
|
||||
meshcore::kSyncWord, meshcore::kPreambleLength, Protocol::MeshCore};
|
||||
}
|
||||
|
||||
bool findScanPreset(const char* name, ScanPreset& out) {
|
||||
for (size_t i = 0; i < scanPresetCount(); ++i) {
|
||||
ScanPreset p = scanPreset(i);
|
||||
const char *a = p.name, *b = name;
|
||||
while (*a && *b && std::tolower(static_cast<unsigned char>(*a)) == std::tolower(static_cast<unsigned char>(*b))) ++a, ++b;
|
||||
if (!*a && !*b) {
|
||||
out = p;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace roro::lora
|
||||
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
#include "packet_view.h"
|
||||
|
||||
namespace roro::lora {
|
||||
|
||||
// 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
|
||||
// chosen one is stored by its number.
|
||||
struct ScanPreset {
|
||||
const char* name;
|
||||
uint32_t frequencyHz;
|
||||
float bwKHz;
|
||||
uint8_t sf, cr, syncWord;
|
||||
uint16_t preamble;
|
||||
Protocol protocol;
|
||||
};
|
||||
|
||||
size_t scanPresetCount();
|
||||
ScanPreset scanPreset(size_t i); // i < scanPresetCount()
|
||||
// By name, whatever its case. False when there is none.
|
||||
bool findScanPreset(const char* name, ScanPreset& out);
|
||||
|
||||
} // namespace roro::lora
|
||||
@@ -0,0 +1,128 @@
|
||||
#include "meshcore_packet.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace roro::meshcore {
|
||||
|
||||
namespace {
|
||||
int32_t le32(const uint8_t* p) { return static_cast<int32_t>(p[0] | p[1] << 8 | p[2] << 16 | static_cast<uint32_t>(p[3]) << 24); }
|
||||
constexpr size_t kMaxPath = 64, kMaxPayload = 184;
|
||||
} // namespace
|
||||
|
||||
bool parsePacket(const uint8_t* data, size_t len, Packet& out) {
|
||||
if (!data || len < 2) return false;
|
||||
uint8_t header = data[0];
|
||||
if (header >> 6) return false; // versions 1 to 3 are reserved
|
||||
uint8_t type = (header >> 2) & 0x0F;
|
||||
if (type >= 0xC && type <= 0xE) return false; // reserved
|
||||
Packet p;
|
||||
p.route = static_cast<Route>(header & 0x03);
|
||||
p.type = static_cast<Payload>(type);
|
||||
size_t at = 1;
|
||||
p.hasTransport = p.route == Route::TransportFlood || p.route == Route::TransportDirect;
|
||||
if (p.hasTransport) {
|
||||
if (len < at + 5) return false;
|
||||
p.transport[0] = static_cast<uint16_t>(data[at] | data[at + 1] << 8);
|
||||
p.transport[1] = static_cast<uint16_t>(data[at + 2] | data[at + 3] << 8);
|
||||
at += 4;
|
||||
}
|
||||
uint8_t pathLen = data[at++];
|
||||
p.hops = pathLen & 0x3F;
|
||||
p.hashSize = static_cast<uint8_t>((pathLen >> 6) + 1);
|
||||
size_t pathBytes = static_cast<size_t>(p.hops) * p.hashSize;
|
||||
if (p.hashSize > 3 || pathBytes > kMaxPath || at + pathBytes > len) return false;
|
||||
p.path = data + at;
|
||||
at += pathBytes;
|
||||
p.payload = data + at;
|
||||
p.payloadLen = len - at;
|
||||
if (p.payloadLen > kMaxPayload) return false;
|
||||
out = p;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool parseAdvert(const Packet& p, Advert& out) {
|
||||
constexpr size_t kFixed = 32 + 4 + 64; // key, timestamp, signature
|
||||
if (p.type != Payload::Advert || p.payloadLen < kFixed + 1) return false;
|
||||
Advert a;
|
||||
std::memcpy(a.key, p.payload, 32);
|
||||
a.timestamp = static_cast<uint32_t>(le32(p.payload + 32));
|
||||
const uint8_t* app = p.payload + kFixed;
|
||||
size_t left = p.payloadLen - kFixed;
|
||||
uint8_t flags = app[0];
|
||||
size_t at = 1;
|
||||
a.kind = flags & 0x0F;
|
||||
if (flags & 0x10) {
|
||||
if (left < at + 8) return false;
|
||||
a.hasLocation = true;
|
||||
a.latE6 = le32(app + at);
|
||||
a.lonE6 = le32(app + at + 4);
|
||||
at += 8;
|
||||
}
|
||||
if (flags & 0x20) at += 2; // two reserved fields
|
||||
if (flags & 0x40) at += 2;
|
||||
if (at > left) return false;
|
||||
if (flags & 0x80)
|
||||
for (; at < left && app[at]; ++at) a.name += app[at] >= 0x20 && app[at] < 0x7F ? static_cast<char>(app[at]) : '?';
|
||||
out = a;
|
||||
return true;
|
||||
}
|
||||
|
||||
const char* routeName(Route r) {
|
||||
switch (r) {
|
||||
case Route::TransportFlood: return "flood in a region";
|
||||
case Route::Flood: return "flood";
|
||||
case Route::Direct: return "direct";
|
||||
case Route::TransportDirect: return "direct in a region";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
const char* payloadName(Payload t) {
|
||||
switch (t) {
|
||||
case Payload::Request: return "request";
|
||||
case Payload::Response: return "response";
|
||||
case Payload::Text: return "text message";
|
||||
case Payload::Ack: return "ack";
|
||||
case Payload::Advert: return "advert";
|
||||
case Payload::GroupText: return "channel message";
|
||||
case Payload::GroupData: return "channel data";
|
||||
case Payload::AnonRequest: return "anonymous request";
|
||||
case Payload::Path: return "returned path";
|
||||
case Payload::Trace: return "trace";
|
||||
case Payload::Multipart: return "multipart";
|
||||
case Payload::Control: return "control";
|
||||
case Payload::RawCustom: return "custom";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
const char* payloadShort(Payload t) {
|
||||
switch (t) {
|
||||
case Payload::Request: return "req";
|
||||
case Payload::Response: return "rsp";
|
||||
case Payload::Text: return "txt";
|
||||
case Payload::Ack: return "ack";
|
||||
case Payload::Advert: return "adv";
|
||||
case Payload::GroupText: return "chan";
|
||||
case Payload::GroupData: return "cdat";
|
||||
case Payload::AnonRequest: return "anon";
|
||||
case Payload::Path: return "path";
|
||||
case Payload::Trace: return "trace";
|
||||
case Payload::Multipart: return "multi";
|
||||
case Payload::Control: return "ctl";
|
||||
case Payload::RawCustom: return "raw";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
const char* kindName(uint8_t kind) {
|
||||
switch (kind) {
|
||||
case 1: return "Chat node";
|
||||
case 2: return "Repeater";
|
||||
case 3: return "Room server";
|
||||
case 4: return "Sensor";
|
||||
}
|
||||
return "Node";
|
||||
}
|
||||
|
||||
} // namespace roro::meshcore
|
||||
@@ -0,0 +1,66 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
// MeshCore's packets as they are on air (its docs/packet_format.md and docs/payloads.md). Only
|
||||
// what is sent in clear is read: the header, the path, and a node's advertisement.
|
||||
namespace roro::meshcore {
|
||||
|
||||
// Radio settings: RadioLib's "private" sync word, and the preset its EU/UK networks use.
|
||||
constexpr uint8_t kSyncWord = 0x12;
|
||||
constexpr uint16_t kPreambleLength = 16;
|
||||
constexpr const char* kPresetName = "MeshCore"; // "EU/UK (Narrow)" in its apps
|
||||
constexpr uint32_t kEuNarrowHz = 869618000;
|
||||
constexpr float kEuNarrowBwKHz = 62.5f;
|
||||
constexpr uint8_t kEuNarrowSf = 8, kEuNarrowCr = 8;
|
||||
|
||||
// The first byte of the SHA-256 of the public channel's key (izOH6cXN6mrJ5e26oRXNcg==): what a
|
||||
// group message on the channel every node has starts with.
|
||||
constexpr uint8_t kPublicChannelHash = 0x11;
|
||||
|
||||
enum class Route : uint8_t { TransportFlood = 0, Flood = 1, Direct = 2, TransportDirect = 3 };
|
||||
enum class Payload : uint8_t {
|
||||
Request = 0x0, Response = 0x1, Text = 0x2, Ack = 0x3, Advert = 0x4, GroupText = 0x5, GroupData = 0x6,
|
||||
AnonRequest = 0x7, Path = 0x8, Trace = 0x9, Multipart = 0xA, Control = 0xB, RawCustom = 0xF,
|
||||
};
|
||||
|
||||
struct Packet {
|
||||
Route route = Route::Flood;
|
||||
Payload type = Payload::Request;
|
||||
bool hasTransport = false;
|
||||
uint16_t transport[2] = {0, 0}; // the first is the region's; the second is reserved
|
||||
uint8_t hops = 0; // entries in the path
|
||||
uint8_t hashSize = 1; // bytes each
|
||||
const uint8_t* path = nullptr; // hops * hashSize bytes
|
||||
const uint8_t* payload = nullptr;
|
||||
size_t payloadLen = 0;
|
||||
|
||||
bool flood() const { return route == Route::Flood || route == Route::TransportFlood; }
|
||||
// Sent to one node from one node, each named by the first byte of its key.
|
||||
bool addressed() const { return type == Payload::Request || type == Payload::Response || type == Payload::Text || type == Payload::Path; }
|
||||
bool group() const { return type == Payload::GroupText || type == Payload::GroupData; }
|
||||
};
|
||||
|
||||
// False when the bytes can't be a MeshCore packet: a version other than the first, a reserved
|
||||
// type, or a path longer than what follows.
|
||||
bool parsePacket(const uint8_t* data, size_t len, Packet& out);
|
||||
|
||||
// A node saying who it is, signed: never encrypted.
|
||||
struct Advert {
|
||||
uint8_t key[32];
|
||||
uint32_t timestamp = 0; // the node's clock, seconds since 1970
|
||||
uint8_t kind = 0; // 1 chat, 2 repeater, 3 room server, 4 sensor
|
||||
bool hasLocation = false;
|
||||
int32_t latE6 = 0, lonE6 = 0;
|
||||
std::string name; // printable ASCII kept, the rest as '?'
|
||||
};
|
||||
bool parseAdvert(const Packet& p, Advert& 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", ...
|
||||
const char* kindName(uint8_t kind); // "Chat node", "Repeater", ...
|
||||
|
||||
} // namespace roro::meshcore
|
||||
Reference in New Issue
Block a user