1
0
mirror of https://github.com/f4exb/sdrangel.git synced 2026-06-27 14:03:24 -04:00

meshcore: add MeshCore protocol RX/TX channel plugins

This commit is contained in:
Tom Hensel
2026-06-09 23:08:56 +02:00
parent 964bc0994d
commit 92552f6b4f
69 changed files with 26529 additions and 0 deletions
+546
View File
@@ -0,0 +1,546 @@
///////////////////////////////////////////////////////////////////////////////////
// Copyright (C) 2026 Tom Hensel <code@jitter.eu> //
// //
// This program is free software; you can redistribute it and/or modify //
// it under the terms of the GNU General Public License as published by //
// the Free Software Foundation as version 3 of the License, or //
// (at your option) any later version. //
// //
// MESHCORE: command parser implementation. //
///////////////////////////////////////////////////////////////////////////////////
#include "meshcore_command.h"
#include "meshcore_builders.h"
#include "meshcore_crypto.h"
#include <QDateTime>
#include <QStringList>
namespace modemmeshcore
{
namespace command
{
namespace
{
QString lower(const QString& s) { return s.toLower(); }
// Parse a hex string into bytes; empty on parse error.
QByteArray hex(const QString& v)
{
QByteArray b = QByteArray::fromHex(v.toLatin1());
return b;
}
// Resolve a key (case-insensitive). Empty if missing.
QString get(const QMap<QString, QString>& kv, const char* key)
{
auto it = kv.find(QString::fromLatin1(key));
return (it != kv.end()) ? it.value() : QString();
}
bool toUInt(const QString& s, uint32_t& out)
{
bool ok = false;
if (s.startsWith("0x", Qt::CaseInsensitive)) {
out = s.mid(2).toUInt(&ok, 16);
} else {
out = s.toUInt(&ok, 10);
}
return ok;
}
bool toRouteType(const QString& s, uint8_t& out)
{
const QString l = lower(s);
if (l == "t_flood") { out = RouteTFlood; return true; }
if (l == "flood") { out = RouteFlood; return true; }
if (l == "direct") { out = RouteDirect; return true; }
if (l == "t_direct") { out = RouteTDirect; return true; }
return false;
}
uint32_t resolveTimestamp(const QMap<QString, QString>& kv)
{
const QString ts = get(kv, "ts");
if (ts.isEmpty()) {
return static_cast<uint32_t>(QDateTime::currentSecsSinceEpoch());
}
uint32_t v = 0;
return toUInt(ts, v) ? v : 0;
}
} // namespace
bool tokenize(const QString& body, QMap<QString, QString>& kv, QString& error)
{
kv.clear();
const QStringList tokens = body.split(';', Qt::SkipEmptyParts);
for (const QString& raw : tokens)
{
const QString tok = raw.trimmed();
if (tok.isEmpty()) {
continue;
}
const int eq = tok.indexOf('=');
if (eq <= 0) {
error = QString("malformed token (expected key=value): '%1'").arg(tok);
return false;
}
const QString k = tok.left(eq).trimmed().toLower();
const QString v = tok.mid(eq + 1).trimmed();
if (k.isEmpty()) {
error = QString("empty key in token: '%1'").arg(tok);
return false;
}
kv.insert(k, v);
}
return true;
}
namespace
{
// MeshCore regional preset table.
//
// Entries below are the well-known regional defaults the MeshCore
// community uses. They map directly to the firmware CLI's
// `set radio freq_MHz,bw_kHz,sf,cr` form (cr is encoded as 5..8 for
// 4/5..4/8 — `cr` here is therefore parityBits + 4). Default profile
// is `EU_NARROW` (MeshCore EU/UK Narrow / Recommended channel).
struct MeshcorePresetEntry
{
const char* key; // canonical preset key (uppercase, snake_case)
const char* displayName; // human-readable label (for logs/summary)
qint64 freqHz;
int bandwidthHz;
int spreadFactor;
int parityBits; // CR = parityBits + 4
};
constexpr MeshcorePresetEntry kMeshcorePresets[] = {
{"EU_NARROW", "EU/UK Narrow (Recommended)", 869618000, 62500, 8, 4},
{"EU_LONG_RANGE", "EU/UK Long Range", 869525000, 250000, 11, 1},
{"EU_MEDIUM_RANGE", "EU/UK Medium Range", 869525000, 250000, 10, 1},
{"AU", "Australia", 915800000, 250000, 10, 1},
{"AU_VICTORIA", "Australia: Victoria", 916575000, 62500, 7, 4},
{"CZ_NARROW", "Czech Republic Narrow", 869525000, 62500, 7, 1},
{"EU_433_LONG_RANGE", "EU 433 MHz Long Range", 433650000, 250000, 11, 1},
{"NZ", "New Zealand", 917375000, 250000, 11, 1},
{"NZ_NARROW", "New Zealand Narrow", 917375000, 62500, 7, 1},
{"PT_433", "Portugal 433", 433375000, 62500, 9, 2},
{"PT_868", "Portugal 868", 869618000, 62500, 7, 2},
{"CH", "Switzerland", 869618000, 62500, 8, 4},
{"USA", "USA / Canada", 910525000, 62500, 7, 1},
{"VN", "Vietnam", 920250000, 250000, 11, 1},
};
bool applyMeshcorePreset(const QString& preset, TxRadioSettings& settings, QString& error)
{
const QByteArray key = preset.toUpper().toLatin1();
if (key == "USER") {
return true; // USER preset = no overrides; caller's individual keys win
}
for (const auto& entry : kMeshcorePresets) {
if (key == entry.key) {
settings.centerFrequencyHz = entry.freqHz;
settings.hasCenterFrequency = true;
settings.bandwidthHz = entry.bandwidthHz;
settings.spreadFactor = entry.spreadFactor;
settings.parityBits = entry.parityBits;
settings.hasLoRaParams = true;
settings.summary = QString("MeshCore preset %1").arg(entry.displayName);
return true;
}
}
error = QString("invalid MeshCore preset: '%1'").arg(preset);
return false;
}
} // namespace
bool applyRadioParams(const QMap<QString, QString>& kv, TxRadioSettings& settings, QString& error)
{
bool changed = false;
// Apply MeshCore regional preset first (if present), then let individual
// sf/bw/cr/freq/sync/preamble keys override specific fields.
const QString presetS = get(kv, "preset");
if (!presetS.isEmpty()) {
if (!applyMeshcorePreset(presetS, settings, error)) {
return false;
}
// SF-dependent preamble preamble: SF < 9 → 32, SF > 8 → 16.
// Matches upstream meshcore-py commit 2026-06.
if (settings.spreadFactor < 9) { settings.preambleChirps = 32; }
else { settings.preambleChirps = 16; }
changed = true;
}
const QString sfS = get(kv, "sf");
if (!sfS.isEmpty()) {
uint32_t sf = 0;
if (!toUInt(sfS, sf) || sf < 7 || sf > 12) {
error = QString("invalid sf: '%1' (expected 7..12)").arg(sfS);
return false;
}
settings.spreadFactor = static_cast<int>(sf);
settings.hasLoRaParams = true;
changed = true;
}
const QString bwS = get(kv, "bw");
if (!bwS.isEmpty()) {
uint32_t bw = 0;
if (!toUInt(bwS, bw) || bw == 0) {
error = QString("invalid bw: '%1' (expected Hz)").arg(bwS);
return false;
}
settings.bandwidthHz = static_cast<int>(bw);
settings.hasLoRaParams = true;
changed = true;
}
const QString crS = get(kv, "cr");
if (!crS.isEmpty()) {
uint32_t cr = 0;
if (!toUInt(crS, cr) || cr < 5 || cr > 8) {
error = QString("invalid cr: '%1' (expected 5..8 for 4/5..4/8)").arg(crS);
return false;
}
settings.parityBits = static_cast<int>(cr) - 4; // 5 -> 1, 8 -> 4
settings.hasLoRaParams = true;
changed = true;
}
const QString syncS = get(kv, "sync");
if (!syncS.isEmpty()) {
uint32_t sync = 0;
if (!toUInt(syncS, sync) || sync > 0xFF) {
error = QString("invalid sync: '%1'").arg(syncS);
return false;
}
settings.syncWord = static_cast<uint8_t>(sync);
settings.hasLoRaParams = true;
changed = true;
}
const QString freqS = get(kv, "freq");
if (!freqS.isEmpty()) {
// Accept "869618000" (Hz) or "869.618M" / "869.618MHz"
QString s = freqS;
s.replace("Hz", "", Qt::CaseInsensitive);
const bool isMhz = s.contains('M', Qt::CaseInsensitive);
s.replace("M", "", Qt::CaseInsensitive);
bool ok = false;
const double v = s.toDouble(&ok);
if (!ok || v <= 0) {
error = QString("invalid freq: '%1'").arg(freqS);
return false;
}
settings.centerFrequencyHz = static_cast<qint64>(isMhz ? v * 1.0e6 : v);
settings.hasCenterFrequency = true;
changed = true;
}
const QString prS = get(kv, "preamble");
if (!prS.isEmpty()) {
uint32_t pr = 0;
if (!toUInt(prS, pr) || pr == 0 || pr > 1024) {
error = QString("invalid preamble: '%1'").arg(prS);
return false;
}
settings.preambleChirps = static_cast<int>(pr);
settings.hasLoRaParams = true;
changed = true;
}
if (changed) {
settings.hasCommand = true;
}
return true;
}
namespace
{
bool buildAdvertFromKv(const QMap<QString, QString>& kv,
QByteArray& frame, QString& summary, QString& error)
{
const QByteArray seed = hex(get(kv, "seed"));
if (seed.size() != kPubKeySize) {
error = "advert: seed (hex 32 bytes) is required";
return false;
}
const QByteArray pub = detail::derivePubKey(seed);
if (pub.size() != kPubKeySize) {
error = "advert: failed to derive public key";
return false;
}
builders::AdvertOptions opts;
opts.timestamp = resolveTimestamp(kv);
opts.name = get(kv, "name");
const QString latS = get(kv, "lat");
const QString lonS = get(kv, "lon");
if (!latS.isEmpty() && !lonS.isEmpty()) {
bool okLat = false, okLon = false;
const double lat = latS.toDouble(&okLat);
const double lon = lonS.toDouble(&okLon);
if (!okLat || !okLon) {
error = "advert: invalid lat/lon";
return false;
}
opts.latLon = std::make_pair(lat, lon);
}
const QString routeS = get(kv, "route");
if (!routeS.isEmpty()) {
if (!toRouteType(routeS, opts.routeType)) {
error = QString("advert: invalid route '%1'").arg(routeS);
return false;
}
}
frame = builders::buildAdvert(seed, pub, opts);
if (frame.isEmpty()) {
error = "advert: build failed";
return false;
}
summary = QString("MESHCORE TX|advert pub=%1 ts=%2 name=\"%3\"")
.arg(QString::fromLatin1(pub.toHex().left(16)))
.arg(opts.timestamp)
.arg(opts.name);
return true;
}
bool buildTxtMsgFromKv(const QMap<QString, QString>& kv,
QByteArray& frame, QString& summary, QString& error)
{
const QByteArray seed = hex(get(kv, "seed"));
const QByteArray dest = hex(get(kv, "dest"));
const QString text = get(kv, "text");
if (seed.size() != kPubKeySize) {
error = "txt_msg: seed (hex 32 bytes) required";
return false;
}
if (dest.size() != kPubKeySize) {
error = "txt_msg: dest (hex 32 bytes) required";
return false;
}
if (text.isEmpty()) {
error = "txt_msg: text required";
return false;
}
builders::TxtMsgOptions opts;
opts.timestamp = resolveTimestamp(kv);
uint32_t txtType = 0, attempt = 0;
const QString tt = get(kv, "txt_type");
if (!tt.isEmpty() && (!toUInt(tt, txtType) || txtType > 0x3F)) {
error = "txt_msg: invalid txt_type";
return false;
}
const QString at = get(kv, "attempt");
if (!at.isEmpty() && (!toUInt(at, attempt) || attempt > 3)) {
error = "txt_msg: invalid attempt (0..3)";
return false;
}
opts.txtType = static_cast<uint8_t>(txtType);
opts.attempt = static_cast<uint8_t>(attempt);
const QString routeS = get(kv, "route");
if (!routeS.isEmpty() && !toRouteType(routeS, opts.routeType)) {
error = QString("txt_msg: invalid route '%1'").arg(routeS);
return false;
}
frame = builders::buildTxtMsg(seed, dest, text.toUtf8(), opts);
if (frame.isEmpty()) {
error = "txt_msg: build failed";
return false;
}
summary = QString("MESHCORE TX|txt_msg dest=%1 ts=%2 text=\"%3\"")
.arg(QString::fromLatin1(dest.toHex().left(16)))
.arg(opts.timestamp)
.arg(text);
return true;
}
bool buildAnonReqFromKv(const QMap<QString, QString>& kv,
QByteArray& frame, QString& summary, QString& error)
{
const QByteArray seed = hex(get(kv, "seed"));
const QByteArray dest = hex(get(kv, "dest"));
if (seed.size() != kPubKeySize) {
error = "anon_req: seed required";
return false;
}
if (dest.size() != kPubKeySize) {
error = "anon_req: dest required";
return false;
}
QByteArray data;
const QString dataHex = get(kv, "data");
const QString dataTxt = get(kv, "text");
if (!dataHex.isEmpty()) {
data = hex(dataHex);
if (data.isEmpty()) {
error = "anon_req: invalid data hex";
return false;
}
} else if (!dataTxt.isEmpty()) {
data = dataTxt.toUtf8();
} else {
error = "anon_req: data or text required";
return false;
}
builders::AnonReqOptions opts;
opts.timestamp = resolveTimestamp(kv);
const QString routeS = get(kv, "route");
if (!routeS.isEmpty() && !toRouteType(routeS, opts.routeType)) {
error = QString("anon_req: invalid route '%1'").arg(routeS);
return false;
}
frame = builders::buildAnonReq(seed, dest, data, opts);
if (frame.isEmpty()) {
error = "anon_req: build failed";
return false;
}
summary = QString("MESHCORE TX|anon_req dest=%1 ts=%2 len=%3")
.arg(QString::fromLatin1(dest.toHex().left(16)))
.arg(opts.timestamp)
.arg(data.size());
return true;
}
bool buildGrpTxtFromKv(const QMap<QString, QString>& kv,
QByteArray& frame, QString& summary, QString& error)
{
const QString text = get(kv, "text");
if (text.isEmpty()) {
error = "grp_txt: text required";
return false;
}
QByteArray psk;
QString chanName = get(kv, "channel");
const QString pskS = get(kv, "channel_psk");
if (!pskS.isEmpty()) {
psk = hex(pskS);
if (psk.size() != 16 && psk.size() != 32) {
error = "grp_txt: channel_psk must be 16 or 32 hex bytes";
return false;
}
if (chanName.isEmpty()) {
chanName = "psk";
}
} else if (chanName.compare("public", Qt::CaseInsensitive) == 0) {
psk = builders::publicChannelPsk();
} else {
error = "grp_txt: channel_psk or channel=public required";
return false;
}
const builders::GroupChannel ch = builders::GroupChannel::fromPsk(chanName, psk);
if (!ch.isValid()) {
error = "grp_txt: invalid channel";
return false;
}
builders::GrpTxtOptions opts;
opts.timestamp = resolveTimestamp(kv);
uint32_t txtType = 0, attempt = 0;
const QString tt = get(kv, "txt_type");
if (!tt.isEmpty() && (!toUInt(tt, txtType) || txtType > 0x3F)) {
error = "grp_txt: invalid txt_type";
return false;
}
const QString at = get(kv, "attempt");
if (!at.isEmpty() && (!toUInt(at, attempt) || attempt > 3)) {
error = "grp_txt: invalid attempt";
return false;
}
opts.txtType = static_cast<uint8_t>(txtType);
opts.attempt = static_cast<uint8_t>(attempt);
const QString routeS = get(kv, "route");
if (!routeS.isEmpty() && !toRouteType(routeS, opts.routeType)) {
error = QString("grp_txt: invalid route '%1'").arg(routeS);
return false;
}
frame = builders::buildGrpTxt(ch, text.toUtf8(), opts);
if (frame.isEmpty()) {
error = "grp_txt: build failed";
return false;
}
summary = QString("MESHCORE TX|grp_txt channel=\"%1\" hash=%2 ts=%3 text=\"%4\"")
.arg(chanName)
.arg(QString::fromLatin1(ch.hash.toHex()))
.arg(opts.timestamp)
.arg(text);
return true;
}
bool buildAckFromKv(const QMap<QString, QString>& kv,
QByteArray& frame, QString& summary, QString& error)
{
const QByteArray dest = hex(get(kv, "dest"));
const QByteArray msgHash = hex(get(kv, "msg_hash"));
if (dest.size() != kPubKeySize) {
error = "ack: dest required";
return false;
}
if (msgHash.size() != 4) {
error = "ack: msg_hash must be 4 hex bytes";
return false;
}
builders::AckOptions opts;
const QString routeS = get(kv, "route");
if (!routeS.isEmpty() && !toRouteType(routeS, opts.routeType)) {
error = QString("ack: invalid route '%1'").arg(routeS);
return false;
}
frame = builders::buildAck(dest, msgHash, opts);
if (frame.isEmpty()) {
error = "ack: build failed";
return false;
}
summary = QString("MESHCORE TX|ack dest=%1 hash=%2")
.arg(QString::fromLatin1(dest.toHex().left(16)))
.arg(QString::fromLatin1(msgHash.toHex()));
return true;
}
} // namespace
bool buildFrameByType(const QString& typeName,
const QMap<QString, QString>& kv,
QByteArray& frame,
QString& summary,
QString& error)
{
const QString t = typeName.toLower();
if (t == "advert") { return buildAdvertFromKv(kv, frame, summary, error); }
if (t == "txt_msg") { return buildTxtMsgFromKv(kv, frame, summary, error); }
if (t == "anon_req") { return buildAnonReqFromKv(kv, frame, summary, error); }
if (t == "grp_txt") { return buildGrpTxtFromKv(kv, frame, summary, error); }
if (t == "ack") { return buildAckFromKv(kv, frame, summary, error); }
error = QString("unknown type: '%1' (expected advert|txt_msg|anon_req|grp_txt|ack)")
.arg(typeName);
return false;
}
} // namespace command
} // namespace modemmeshcore