1
0
mirror of https://github.com/f4exb/sdrangel.git synced 2026-03-31 04:05:44 -04:00
sdrangel/modemmeshtastic/meshtasticpacket.cpp

3442 lines
102 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

///////////////////////////////////////////////////////////////////////////////////
// Copyright (C) 2026 Alejandro Aleman //
// Copyright (C) 2026 Edouard Griffiths, F4EXB <f4exb06@gmail.com> //
// //
// 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. //
// //
// This program is distributed in the hope that it will be useful, //
// but WITHOUT ANY WARRANTY; without even the implied warranty of //
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the //
// GNU General Public License V3 for more details. //
// //
// You should have received a copy of the GNU General Public License //
// along with this program. If not, see <http://www.gnu.org/licenses/>. //
///////////////////////////////////////////////////////////////////////////////////
#include "meshtasticpacket.h"
#include <QByteArrayList>
#include <QDateTime>
#include <QDebug>
#include <QMap>
#include <QProcessEnvironment>
#include <QRandomGenerator>
#include <QStringList>
#include <algorithm>
#include <array>
#include <cmath>
#include <cstring>
#include <set>
#include <vector>
namespace modemmeshtastic
{
namespace
{
constexpr uint8_t kFlagHopLimitMask = 0x07;
constexpr uint8_t kFlagWantAckMask = 0x08;
constexpr uint8_t kFlagViaMqttMask = 0x10;
constexpr uint8_t kFlagHopStartMask = 0xE0;
constexpr int kHeaderLength = 16;
constexpr uint32_t kBroadcastNode = 0xFFFFFFFFu;
struct Header
{
uint32_t to = kBroadcastNode;
uint32_t from = 0;
uint32_t id = 0;
uint8_t flags = 0;
uint8_t channel = 0;
uint8_t nextHop = 0;
uint8_t relayNode = 0;
};
struct DataFields
{
bool hasPortnum = false;
uint32_t portnum = 0;
QByteArray payload;
bool wantResponse = false;
bool hasDest = false;
uint32_t dest = 0;
bool hasSource = false;
uint32_t source = 0;
bool hasRequestId = false;
uint32_t requestId = 0;
bool hasReplyId = false;
uint32_t replyId = 0;
bool hasEmoji = false;
uint32_t emoji = 0;
bool hasBitfield = false;
uint32_t bitfield = 0;
};
struct KeyEntry
{
QString label;
QString channelName;
QByteArray key;
bool hasExpectedHash = false;
uint8_t expectedHash = 0;
};
struct CommandConfig
{
Header header;
DataFields data;
bool encrypt = true;
QByteArray key;
QString keyLabel;
QString channelName;
QString presetName;
bool hasRegion = false;
QString regionName;
bool hasChannelNum = false;
uint32_t channelNum = 0; // 1-based
bool hasOverrideFrequencyMHz = false;
double overrideFrequencyMHz = 0.0;
double frequencyOffsetMHz = 0.0;
bool hasFrequencyOffsetMHz = false;
};
struct RegionBand
{
const char* name;
double freqStartMHz;
double freqEndMHz;
double spacingMHz;
bool wideLora;
};
static const uint8_t kDefaultChannelKey[16] = {
0xD4, 0xF1, 0xBB, 0x3A, 0x20, 0x29, 0x07, 0x59,
0xF0, 0xBC, 0xFF, 0xAB, 0xCF, 0x4E, 0x69, 0x01
};
// Port numbers from meshtastic/portnums.proto (high value subset is accepted as numeric fallback).
static QMap<QString, uint32_t> makePortMap()
{
QMap<QString, uint32_t> m;
m.insert("UNKNOWN_APP", 0);
m.insert("TEXT", 1);
m.insert("TEXT_MESSAGE_APP", 1);
m.insert("REMOTE_HARDWARE_APP", 2);
m.insert("POSITION_APP", 3);
m.insert("NODEINFO_APP", 4);
m.insert("ROUTING_APP", 5);
m.insert("ADMIN_APP", 6);
m.insert("TEXT_MESSAGE_COMPRESSED_APP", 7);
m.insert("WAYPOINT_APP", 8);
m.insert("AUDIO_APP", 9);
m.insert("DETECTION_SENSOR_APP", 10);
m.insert("ALERT_APP", 11);
m.insert("KEY_VERIFICATION_APP", 12);
m.insert("REPLY_APP", 32);
m.insert("IP_TUNNEL_APP", 33);
m.insert("PAXCOUNTER_APP", 34);
m.insert("STORE_FORWARD_PLUSPLUS_APP", 35);
m.insert("NODE_STATUS_APP", 36);
m.insert("SERIAL_APP", 64);
m.insert("STORE_FORWARD_APP", 65);
m.insert("RANGE_TEST_APP", 66);
m.insert("TELEMETRY_APP", 67);
m.insert("ZPS_APP", 68);
m.insert("SIMULATOR_APP", 69);
m.insert("TRACEROUTE_APP", 70);
m.insert("NEIGHBORINFO_APP", 71);
m.insert("ATAK_PLUGIN", 72);
return m;
}
static const QMap<QString, uint32_t> kPortMap = makePortMap();
static const RegionBand kRegionBands[] = {
{"US", 902.0, 928.0, 0.0, false},
{"EU_433", 433.0, 434.0, 0.0, false},
{"EU_868", 869.4, 869.65, 0.0, false},
{"CN", 470.0, 510.0, 0.0, false},
{"JP", 920.5, 923.5, 0.0, false},
{"ANZ", 915.0, 928.0, 0.0, false},
{"ANZ_433", 433.05, 434.79, 0.0, false},
{"RU", 868.7, 869.2, 0.0, false},
{"KR", 920.0, 923.0, 0.0, false},
{"TW", 920.0, 925.0, 0.0, false},
{"IN", 865.0, 867.0, 0.0, false},
{"NZ_865", 864.0, 868.0, 0.0, false},
{"TH", 920.0, 925.0, 0.0, false},
{"UA_433", 433.0, 434.7, 0.0, false},
{"UA_868", 868.0, 868.6, 0.0, false},
{"MY_433", 433.0, 435.0, 0.0, false},
{"MY_919", 919.0, 924.0, 0.0, false},
{"SG_923", 917.0, 925.0, 0.0, false},
{"PH_433", 433.0, 434.7, 0.0, false},
{"PH_868", 868.0, 869.4, 0.0, false},
{"PH_915", 915.0, 918.0, 0.0, false},
{"KZ_433", 433.075, 434.775, 0.0, false},
{"KZ_863", 863.0, 868.0, 0.0, false},
{"NP_865", 865.0, 868.0, 0.0, false},
{"BR_902", 902.0, 907.5, 0.0, false},
{"LORA_24", 2400.0, 2483.5, 0.0, true}
};
static const int kRegionBandsCount = static_cast<int>(sizeof(kRegionBands) / sizeof(kRegionBands[0]));
static QString trimQuotes(const QString& s)
{
QString out = s.trimmed();
if ((out.startsWith('"') && out.endsWith('"')) || (out.startsWith('\'') && out.endsWith('\''))) {
out = out.mid(1, out.size() - 2);
}
return out;
}
static bool parseBool(const QString& s, bool& out)
{
const QString v = s.trimmed().toLower();
if (v == "1" || v == "true" || v == "yes" || v == "on") {
out = true;
return true;
}
if (v == "0" || v == "false" || v == "no" || v == "off") {
out = false;
return true;
}
return false;
}
static bool parseUInt(const QString& s, uint64_t& out)
{
QString v = s.trimmed();
v.remove('_');
bool ok = false;
int base = 10;
if (v.startsWith("0x") || v.startsWith("0X")) {
base = 16;
v = v.mid(2);
}
out = v.toULongLong(&ok, base);
return ok;
}
static bool parseDouble(const QString& s, double& out)
{
QString v = s.trimmed();
v.remove('_');
bool ok = false;
out = v.toDouble(&ok);
return ok;
}
static QString normalizeToken(const QString& value)
{
QString v = value.trimmed().toUpper();
v.replace('-', '_');
v.replace(' ', '_');
return v;
}
static QByteArray normalizeHex(const QString& s)
{
QByteArray out;
const QByteArray in = s.toLatin1();
for (char c : in)
{
const bool isHex = ((c >= '0') && (c <= '9')) || ((c >= 'a') && (c <= 'f')) || ((c >= 'A') && (c <= 'F'));
if (isHex) {
out.append(c);
}
}
return out;
}
static bool parseHexBytes(const QString& s, QByteArray& out)
{
QByteArray hex = normalizeHex(s);
if (hex.isEmpty() || (hex.size() % 2) != 0) {
return false;
}
out = QByteArray::fromHex(hex);
return !out.isEmpty();
}
static QByteArray expandSimpleKey(unsigned int simple)
{
if (simple == 0) {
return QByteArray();
}
if (simple > 10) {
return QByteArray();
}
QByteArray key(reinterpret_cast<const char*>(kDefaultChannelKey), sizeof(kDefaultChannelKey));
if (simple > 1) {
const int offset = static_cast<int>(simple - 1);
key[15] = static_cast<char>(static_cast<uint8_t>(kDefaultChannelKey[15] + offset));
}
return key;
}
static bool parseKeySpec(const QString& rawSpec, QByteArray& key, QString& label)
{
QString spec = rawSpec.trimmed();
const QString lower = spec.toLower();
if (lower.isEmpty() || lower == "default" || lower == "simple1") {
key = expandSimpleKey(1);
label = "default";
return true;
}
if (lower == "none" || lower == "unencrypted" || lower == "simple0" || lower == "0") {
key = QByteArray();
label = "none";
return true;
}
if (lower.startsWith("simple"))
{
uint64_t n = 0;
if (!parseUInt(lower.mid(6), n)) {
return false;
}
if (n > 10) {
return false;
}
key = expandSimpleKey(static_cast<unsigned int>(n));
label = QString("simple%1").arg(n);
return true;
}
if (lower.startsWith("hex:"))
{
if (!parseHexBytes(spec.mid(4), key)) {
return false;
}
label = "hex";
return key.size() == 16 || key.size() == 32;
}
if (lower.startsWith("base64:") || lower.startsWith("b64:"))
{
const int p = spec.indexOf(':');
key = QByteArray::fromBase64(spec.mid(p + 1).trimmed().toLatin1());
if (key.isEmpty()) {
return false;
}
label = "base64";
return key.size() == 16 || key.size() == 32;
}
if (lower == "1" || lower == "2" || lower == "3" || lower == "4" || lower == "5" || lower == "6" || lower == "7" || lower == "8" || lower == "9" || lower == "10")
{
uint64_t n = 0;
parseUInt(lower, n);
key = expandSimpleKey(static_cast<unsigned int>(n));
label = QString("simple%1").arg(n);
return true;
}
QByteArray parsed;
if (parseHexBytes(spec, parsed) && (parsed.size() == 16 || parsed.size() == 32)) {
key = parsed;
label = "hex";
return true;
}
parsed = QByteArray::fromBase64(spec.toLatin1());
if (parsed.size() == 16 || parsed.size() == 32) {
key = parsed;
label = "base64";
return true;
}
return false;
}
static uint32_t readU32LE(const char* p)
{
return static_cast<uint32_t>(static_cast<uint8_t>(p[0]))
| (static_cast<uint32_t>(static_cast<uint8_t>(p[1])) << 8)
| (static_cast<uint32_t>(static_cast<uint8_t>(p[2])) << 16)
| (static_cast<uint32_t>(static_cast<uint8_t>(p[3])) << 24);
}
static void writeU32LE(char* p, uint32_t v)
{
p[0] = static_cast<char>(v & 0xFF);
p[1] = static_cast<char>((v >> 8) & 0xFF);
p[2] = static_cast<char>((v >> 16) & 0xFF);
p[3] = static_cast<char>((v >> 24) & 0xFF);
}
static bool parseHeader(const QByteArray& frame, Header& h)
{
if (frame.size() < kHeaderLength) {
return false;
}
const char* p = frame.constData();
h.to = readU32LE(p + 0);
h.from = readU32LE(p + 4);
h.id = readU32LE(p + 8);
h.flags = static_cast<uint8_t>(p[12]);
h.channel = static_cast<uint8_t>(p[13]);
h.nextHop = static_cast<uint8_t>(p[14]);
h.relayNode = static_cast<uint8_t>(p[15]);
return true;
}
static QByteArray encodeHeader(const Header& h)
{
QByteArray out(kHeaderLength, 0);
char* p = out.data();
writeU32LE(p + 0, h.to);
writeU32LE(p + 4, h.from);
writeU32LE(p + 8, h.id);
p[12] = static_cast<char>(h.flags);
p[13] = static_cast<char>(h.channel);
p[14] = static_cast<char>(h.nextHop);
p[15] = static_cast<char>(h.relayNode);
return out;
}
static bool readVarint(const QByteArray& bytes, int& pos, uint64_t& value)
{
value = 0;
int shift = 0;
while (pos < bytes.size() && shift <= 63)
{
const uint8_t b = static_cast<uint8_t>(bytes[pos++]);
value |= static_cast<uint64_t>(b & 0x7F) << shift;
if ((b & 0x80) == 0) {
return true;
}
shift += 7;
}
return false;
}
static bool readFixed32(const QByteArray& bytes, int& pos, uint32_t& value)
{
if ((pos + 4) > bytes.size()) {
return false;
}
value = readU32LE(bytes.constData() + pos);
pos += 4;
return true;
}
static bool readFixed64(const QByteArray& bytes, int& pos, uint64_t& value)
{
if ((pos + 8) > bytes.size()) {
return false;
}
const char* p = bytes.constData() + pos;
value = static_cast<uint64_t>(static_cast<uint8_t>(p[0]))
| (static_cast<uint64_t>(static_cast<uint8_t>(p[1])) << 8)
| (static_cast<uint64_t>(static_cast<uint8_t>(p[2])) << 16)
| (static_cast<uint64_t>(static_cast<uint8_t>(p[3])) << 24)
| (static_cast<uint64_t>(static_cast<uint8_t>(p[4])) << 32)
| (static_cast<uint64_t>(static_cast<uint8_t>(p[5])) << 40)
| (static_cast<uint64_t>(static_cast<uint8_t>(p[6])) << 48)
| (static_cast<uint64_t>(static_cast<uint8_t>(p[7])) << 56);
pos += 8;
return true;
}
static bool skipField(const QByteArray& bytes, int& pos, uint32_t wireType)
{
switch (wireType)
{
case 0: {
uint64_t v = 0;
return readVarint(bytes, pos, v);
}
case 1:
if ((pos + 8) > bytes.size()) {
return false;
}
pos += 8;
return true;
case 2: {
uint64_t len = 0;
if (!readVarint(bytes, pos, len)) {
return false;
}
if (len > static_cast<uint64_t>(bytes.size() - pos)) {
return false;
}
pos += static_cast<int>(len);
return true;
}
case 5:
if ((pos + 4) > bytes.size()) {
return false;
}
pos += 4;
return true;
default:
return false;
}
}
static bool parseData(const QByteArray& bytes, DataFields& d)
{
int pos = 0;
while (pos < bytes.size())
{
uint64_t rawTag = 0;
if (!readVarint(bytes, pos, rawTag)) {
return false;
}
const uint32_t field = static_cast<uint32_t>(rawTag >> 3);
const uint32_t wire = static_cast<uint32_t>(rawTag & 0x7);
switch (field)
{
case 1: {
if (wire != 0) {
return false;
}
uint64_t v = 0;
if (!readVarint(bytes, pos, v)) {
return false;
}
d.hasPortnum = true;
d.portnum = static_cast<uint32_t>(v);
break;
}
case 2: {
if (wire != 2) {
return false;
}
uint64_t len = 0;
if (!readVarint(bytes, pos, len)) {
return false;
}
if (len > static_cast<uint64_t>(bytes.size() - pos)) {
return false;
}
d.payload = bytes.mid(pos, static_cast<int>(len));
pos += static_cast<int>(len);
break;
}
case 3: {
if (wire != 0) {
return false;
}
uint64_t v = 0;
if (!readVarint(bytes, pos, v)) {
return false;
}
d.wantResponse = (v != 0);
break;
}
case 4: {
if (wire != 5) {
return false;
}
uint32_t v = 0;
if (!readFixed32(bytes, pos, v)) {
return false;
}
d.hasDest = true;
d.dest = v;
break;
}
case 5: {
if (wire != 5) {
return false;
}
uint32_t v = 0;
if (!readFixed32(bytes, pos, v)) {
return false;
}
d.hasSource = true;
d.source = v;
break;
}
case 6: {
if (wire != 5) {
return false;
}
uint32_t v = 0;
if (!readFixed32(bytes, pos, v)) {
return false;
}
d.hasRequestId = true;
d.requestId = v;
break;
}
case 7: {
if (wire != 5) {
return false;
}
uint32_t v = 0;
if (!readFixed32(bytes, pos, v)) {
return false;
}
d.hasReplyId = true;
d.replyId = v;
break;
}
case 8: {
if (wire != 5) {
return false;
}
uint32_t v = 0;
if (!readFixed32(bytes, pos, v)) {
return false;
}
d.hasEmoji = true;
d.emoji = v;
break;
}
case 9: {
if (wire != 0) {
return false;
}
uint64_t v = 0;
if (!readVarint(bytes, pos, v)) {
return false;
}
d.hasBitfield = true;
d.bitfield = static_cast<uint32_t>(v);
break;
}
default:
if (!skipField(bytes, pos, wire)) {
return false;
}
break;
}
}
return d.hasPortnum;
}
static void writeVarint(QByteArray& out, uint64_t value)
{
while (true)
{
uint8_t b = static_cast<uint8_t>(value & 0x7F);
value >>= 7;
if (value != 0) {
b |= 0x80;
out.append(static_cast<char>(b));
} else {
out.append(static_cast<char>(b));
break;
}
}
}
static void writeTag(QByteArray& out, uint32_t field, uint32_t wire)
{
const uint64_t tag = (static_cast<uint64_t>(field) << 3) | static_cast<uint64_t>(wire);
writeVarint(out, tag);
}
static void writeFixed32(QByteArray& out, uint32_t v)
{
char b[4];
writeU32LE(b, v);
out.append(b, 4);
}
static QByteArray encodeData(const DataFields& d)
{
QByteArray out;
writeTag(out, 1, 0);
writeVarint(out, d.portnum);
if (!d.payload.isEmpty()) {
writeTag(out, 2, 2);
writeVarint(out, static_cast<uint64_t>(d.payload.size()));
out.append(d.payload);
}
if (d.wantResponse) {
writeTag(out, 3, 0);
writeVarint(out, 1);
}
if (d.hasDest) {
writeTag(out, 4, 5);
writeFixed32(out, d.dest);
}
if (d.hasSource) {
writeTag(out, 5, 5);
writeFixed32(out, d.source);
}
if (d.hasRequestId) {
writeTag(out, 6, 5);
writeFixed32(out, d.requestId);
}
if (d.hasReplyId) {
writeTag(out, 7, 5);
writeFixed32(out, d.replyId);
}
if (d.hasEmoji) {
writeTag(out, 8, 5);
writeFixed32(out, d.emoji);
}
if (d.hasBitfield) {
writeTag(out, 9, 0);
writeVarint(out, d.bitfield);
}
return out;
}
static uint8_t xorHash(const QByteArray& bytes)
{
uint8_t h = 0;
for (char c : bytes) {
h ^= static_cast<uint8_t>(c);
}
return h;
}
static uint8_t generateChannelHash(const QString& channelName, const QByteArray& key)
{
QByteArray name = channelName.toUtf8();
if (name.isEmpty()) {
name = "X";
}
return xorHash(name) ^ xorHash(key);
}
// Tiny AES implementation adapted from tiny-AES-c (public domain / unlicense).
class AesCtx
{
public:
bool init(const QByteArray& key)
{
if (key.size() != 16 && key.size() != 32) {
return false;
}
m_nk = key.size() / 4;
m_nr = m_nk + 6;
const int words = 4 * (m_nr + 1);
m_roundKey.resize(words * 4);
keyExpansion(reinterpret_cast<const uint8_t*>(key.constData()));
return true;
}
void encryptBlock(const uint8_t in[16], uint8_t out[16]) const
{
uint8_t state[16];
memcpy(state, in, 16);
addRoundKey(state, 0);
for (int round = 1; round < m_nr; ++round)
{
subBytes(state);
shiftRows(state);
mixColumns(state);
addRoundKey(state, round);
}
subBytes(state);
shiftRows(state);
addRoundKey(state, m_nr);
memcpy(out, state, 16);
}
private:
int m_nk = 0;
int m_nr = 0;
std::vector<uint8_t> m_roundKey;
static uint8_t xtime(uint8_t x)
{
return static_cast<uint8_t>((x << 1) ^ (((x >> 7) & 1) * 0x1B));
}
static uint8_t mul(uint8_t a, uint8_t b)
{
uint8_t res = 0;
uint8_t x = a;
uint8_t y = b;
while (y)
{
if (y & 1) {
res ^= x;
}
x = xtime(x);
y >>= 1;
}
return res;
}
static uint8_t sub(uint8_t x)
{
static const uint8_t sbox[256] = {
0x63,0x7c,0x77,0x7b,0xf2,0x6b,0x6f,0xc5,0x30,0x01,0x67,0x2b,0xfe,0xd7,0xab,0x76,
0xca,0x82,0xc9,0x7d,0xfa,0x59,0x47,0xf0,0xad,0xd4,0xa2,0xaf,0x9c,0xa4,0x72,0xc0,
0xb7,0xfd,0x93,0x26,0x36,0x3f,0xf7,0xcc,0x34,0xa5,0xe5,0xf1,0x71,0xd8,0x31,0x15,
0x04,0xc7,0x23,0xc3,0x18,0x96,0x05,0x9a,0x07,0x12,0x80,0xe2,0xeb,0x27,0xb2,0x75,
0x09,0x83,0x2c,0x1a,0x1b,0x6e,0x5a,0xa0,0x52,0x3b,0xd6,0xb3,0x29,0xe3,0x2f,0x84,
0x53,0xd1,0x00,0xed,0x20,0xfc,0xb1,0x5b,0x6a,0xcb,0xbe,0x39,0x4a,0x4c,0x58,0xcf,
0xd0,0xef,0xaa,0xfb,0x43,0x4d,0x33,0x85,0x45,0xf9,0x02,0x7f,0x50,0x3c,0x9f,0xa8,
0x51,0xa3,0x40,0x8f,0x92,0x9d,0x38,0xf5,0xbc,0xb6,0xda,0x21,0x10,0xff,0xf3,0xd2,
0xcd,0x0c,0x13,0xec,0x5f,0x97,0x44,0x17,0xc4,0xa7,0x7e,0x3d,0x64,0x5d,0x19,0x73,
0x60,0x81,0x4f,0xdc,0x22,0x2a,0x90,0x88,0x46,0xee,0xb8,0x14,0xde,0x5e,0x0b,0xdb,
0xe0,0x32,0x3a,0x0a,0x49,0x06,0x24,0x5c,0xc2,0xd3,0xac,0x62,0x91,0x95,0xe4,0x79,
0xe7,0xc8,0x37,0x6d,0x8d,0xd5,0x4e,0xa9,0x6c,0x56,0xf4,0xea,0x65,0x7a,0xae,0x08,
0xba,0x78,0x25,0x2e,0x1c,0xa6,0xb4,0xc6,0xe8,0xdd,0x74,0x1f,0x4b,0xbd,0x8b,0x8a,
0x70,0x3e,0xb5,0x66,0x48,0x03,0xf6,0x0e,0x61,0x35,0x57,0xb9,0x86,0xc1,0x1d,0x9e,
0xe1,0xf8,0x98,0x11,0x69,0xd9,0x8e,0x94,0x9b,0x1e,0x87,0xe9,0xce,0x55,0x28,0xdf,
0x8c,0xa1,0x89,0x0d,0xbf,0xe6,0x42,0x68,0x41,0x99,0x2d,0x0f,0xb0,0x54,0xbb,0x16
};
return sbox[x];
}
static void subBytes(uint8_t state[16])
{
for (int i = 0; i < 16; ++i) {
state[i] = sub(state[i]);
}
}
static void shiftRows(uint8_t state[16])
{
uint8_t t;
t = state[1];
state[1] = state[5];
state[5] = state[9];
state[9] = state[13];
state[13] = t;
t = state[2];
state[2] = state[10];
state[10] = t;
t = state[6];
state[6] = state[14];
state[14] = t;
t = state[3];
state[3] = state[15];
state[15] = state[11];
state[11] = state[7];
state[7] = t;
}
static void mixColumns(uint8_t state[16])
{
for (int c = 0; c < 4; ++c)
{
uint8_t* col = &state[c * 4];
const uint8_t a0 = col[0];
const uint8_t a1 = col[1];
const uint8_t a2 = col[2];
const uint8_t a3 = col[3];
col[0] = static_cast<uint8_t>(mul(a0, 2) ^ mul(a1, 3) ^ a2 ^ a3);
col[1] = static_cast<uint8_t>(a0 ^ mul(a1, 2) ^ mul(a2, 3) ^ a3);
col[2] = static_cast<uint8_t>(a0 ^ a1 ^ mul(a2, 2) ^ mul(a3, 3));
col[3] = static_cast<uint8_t>(mul(a0, 3) ^ a1 ^ a2 ^ mul(a3, 2));
}
}
void addRoundKey(uint8_t state[16], int round) const
{
const uint8_t* rk = &m_roundKey[round * 16];
for (int i = 0; i < 16; ++i) {
state[i] ^= rk[i];
}
}
static uint32_t subWord(uint32_t w)
{
return (static_cast<uint32_t>(sub(static_cast<uint8_t>((w >> 24) & 0xFF))) << 24)
| (static_cast<uint32_t>(sub(static_cast<uint8_t>((w >> 16) & 0xFF))) << 16)
| (static_cast<uint32_t>(sub(static_cast<uint8_t>((w >> 8) & 0xFF))) << 8)
| static_cast<uint32_t>(sub(static_cast<uint8_t>(w & 0xFF)));
}
static uint32_t rotWord(uint32_t w)
{
return (w << 8) | (w >> 24);
}
void keyExpansion(const uint8_t* key)
{
static const uint8_t rcon[11] = {0x00,0x01,0x02,0x04,0x08,0x10,0x20,0x40,0x80,0x1B,0x36};
const int words = 4 * (m_nr + 1);
std::vector<uint32_t> w(words, 0);
for (int i = 0; i < m_nk; ++i)
{
w[i] = (static_cast<uint32_t>(key[4 * i]) << 24)
| (static_cast<uint32_t>(key[4 * i + 1]) << 16)
| (static_cast<uint32_t>(key[4 * i + 2]) << 8)
| static_cast<uint32_t>(key[4 * i + 3]);
}
for (int i = m_nk; i < words; ++i)
{
uint32_t temp = w[i - 1];
if ((i % m_nk) == 0) {
temp = subWord(rotWord(temp)) ^ (static_cast<uint32_t>(rcon[i / m_nk]) << 24);
} else if (m_nk > 6 && (i % m_nk) == 4) {
temp = subWord(temp);
}
w[i] = w[i - m_nk] ^ temp;
}
for (int i = 0; i < words; ++i)
{
m_roundKey[4 * i] = static_cast<uint8_t>((w[i] >> 24) & 0xFF);
m_roundKey[4 * i + 1] = static_cast<uint8_t>((w[i] >> 16) & 0xFF);
m_roundKey[4 * i + 2] = static_cast<uint8_t>((w[i] >> 8) & 0xFF);
m_roundKey[4 * i + 3] = static_cast<uint8_t>(w[i] & 0xFF);
}
}
};
enum class CounterMode
{
BigEndian,
LittleEndian
};
static void incrementCounter4(uint8_t counter[16], CounterMode mode)
{
if (mode == CounterMode::BigEndian)
{
for (int i = 15; i >= 12; --i)
{
counter[i] = static_cast<uint8_t>(counter[i] + 1);
if (counter[i] != 0) {
break;
}
}
}
else
{
for (int i = 12; i <= 15; ++i)
{
counter[i] = static_cast<uint8_t>(counter[i] + 1);
if (counter[i] != 0) {
break;
}
}
}
}
static void initNonce(uint8_t nonce[16], uint32_t fromNode, uint32_t packetId)
{
memset(nonce, 0, 16);
const uint64_t packetId64 = packetId;
memcpy(nonce, &packetId64, sizeof(packetId64));
memcpy(nonce + sizeof(packetId64), &fromNode, sizeof(fromNode));
}
static QByteArray aesCtrCrypt(const QByteArray& in, const QByteArray& key, uint32_t fromNode, uint32_t packetId, CounterMode mode)
{
QByteArray out(in);
if (key.isEmpty()) {
return out;
}
AesCtx aes;
if (!aes.init(key)) {
return QByteArray();
}
uint8_t counter[16];
initNonce(counter, fromNode, packetId);
int pos = 0;
while (pos < out.size())
{
uint8_t keystream[16];
aes.encryptBlock(counter, keystream);
const int remain = std::min<int>(16, static_cast<int>(out.size() - pos));
for (int i = 0; i < remain; ++i) {
out[pos + i] = static_cast<char>(static_cast<uint8_t>(out[pos + i]) ^ keystream[i]);
}
pos += remain;
incrementCounter4(counter, mode);
}
return out;
}
static QString formatNode(uint32_t node)
{
return QString("0x%1").arg(node, 8, 16, QChar('0'));
}
static QString payloadToText(const QByteArray& payload)
{
QString s = QString::fromUtf8(payload);
if (s.isEmpty() && !payload.isEmpty()) {
return QString();
}
return s;
}
static QString portToName(uint32_t p)
{
for (auto it = kPortMap.constBegin(); it != kPortMap.constEnd(); ++it)
{
if (it.value() == p && it.key().endsWith("_APP")) {
return it.key();
}
}
return QString("PORT_%1").arg(p);
}
static bool addKeyEntry(
std::vector<KeyEntry>& keys,
const QString& channelName,
const QString& keySpec,
QString* error = nullptr)
{
QByteArray key;
QString label;
if (!parseKeySpec(keySpec, key, label))
{
if (error) {
*error = QString("invalid key spec '%1'").arg(keySpec);
}
return false;
}
KeyEntry e;
e.key = key;
e.channelName = channelName;
e.label = channelName.isEmpty() ? label : QString("%1:%2").arg(channelName, label);
if (!channelName.isEmpty())
{
e.hasExpectedHash = true;
e.expectedHash = generateChannelHash(channelName, key);
}
keys.push_back(e);
return true;
}
static bool parseKeyListEntry(
const QString& rawEntry,
QString& channelName,
QString& keySpec,
QString* error = nullptr)
{
const QString entry = rawEntry.trimmed();
if (entry.isEmpty() || entry.startsWith('#')) {
return false;
}
QByteArray parsedKey;
QString parsedLabel;
if (parseKeySpec(entry, parsedKey, parsedLabel))
{
channelName.clear();
keySpec = entry;
return true;
}
const int eq = entry.indexOf('=');
if (eq <= 0)
{
if (error) {
*error = QString("invalid key entry '%1' (use 'channel=key' or key only)").arg(entry);
}
return false;
}
channelName = trimQuotes(entry.left(eq));
keySpec = trimQuotes(entry.mid(eq + 1));
if (channelName.isEmpty())
{
if (error) {
*error = QString("missing channel name in '%1'").arg(entry);
}
return false;
}
if (keySpec.isEmpty())
{
if (error) {
*error = QString("missing key spec in '%1'").arg(entry);
}
return false;
}
return true;
}
static bool parseKeySpecList(
const QString& rawList,
std::vector<KeyEntry>& keys,
QString* error = nullptr,
int* keyCount = nullptr,
bool strict = true)
{
QString input = rawList;
input.replace('\r', '\n');
input.replace(';', '\n');
input.replace(',', '\n');
const QStringList entries = input.split('\n', Qt::SkipEmptyParts);
int parsedCount = 0;
for (const QString& rawEntry : entries)
{
QString channelName;
QString keySpec;
QString entryError;
const bool hasEntry = parseKeyListEntry(rawEntry, channelName, keySpec, &entryError);
if (!hasEntry)
{
if (!entryError.isEmpty() && strict)
{
if (error) {
*error = entryError;
}
return false;
}
continue;
}
if (!addKeyEntry(keys, channelName, keySpec, &entryError))
{
if (strict)
{
if (error) {
*error = QString("%1 in '%2'").arg(entryError, rawEntry.trimmed());
}
return false;
}
continue;
}
parsedCount++;
}
if (keyCount) {
*keyCount = parsedCount;
}
return true;
}
static std::vector<KeyEntry> defaultKeysFromEnv()
{
std::vector<KeyEntry> keys;
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
const QString envKeys = env.value("SDRANGEL_MESHTASTIC_KEYS").trimmed();
if (!envKeys.isEmpty())
{
parseKeySpecList(envKeys, keys, nullptr, nullptr, false);
}
else
{
const QString channel = env.value("SDRANGEL_MESHTASTIC_CHANNEL_NAME", "LongFast").trimmed();
const QString keySpec = env.value("SDRANGEL_MESHTASTIC_KEY", "default").trimmed();
addKeyEntry(keys, channel, keySpec);
addKeyEntry(keys, QString(), "none");
}
if (keys.empty()) {
addKeyEntry(keys, "LongFast", "default");
addKeyEntry(keys, QString(), "none");
}
return keys;
}
static bool parsePortValue(const QString& raw, uint32_t& port)
{
uint64_t numeric = 0;
if (parseUInt(raw, numeric)) {
port = static_cast<uint32_t>(numeric);
return true;
}
const QString upper = raw.trimmed().toUpper();
if (kPortMap.contains(upper)) {
port = kPortMap.value(upper);
return true;
}
if (kPortMap.contains(upper + "_APP")) {
port = kPortMap.value(upper + "_APP");
return true;
}
return false;
}
static bool parsePresetName(const QString& presetValue, QString& presetName)
{
QString p = normalizeToken(presetValue);
p.remove('_');
if (p == "LONGFAST") { presetName = "LONG_FAST"; return true; }
if (p == "LONGSLOW") { presetName = "LONG_SLOW"; return true; }
if (p == "LONGTURBO") { presetName = "LONG_TURBO"; return true; }
if (p == "LONGMODERATE") { presetName = "LONG_MODERATE"; return true; }
if (p == "MEDIUMFAST") { presetName = "MEDIUM_FAST"; return true; }
if (p == "MEDIUMSLOW") { presetName = "MEDIUM_SLOW"; return true; }
if (p == "SHORTFAST") { presetName = "SHORT_FAST"; return true; }
if (p == "SHORTSLOW") { presetName = "SHORT_SLOW"; return true; }
if (p == "SHORTTURBO") { presetName = "SHORT_TURBO"; return true; }
return false;
}
static bool presetToChannelName(const QString& presetName, QString& channelName)
{
if (presetName == "LONG_FAST") { channelName = "LongFast"; return true; }
if (presetName == "LONG_SLOW") { channelName = "LongSlow"; return true; }
if (presetName == "LONG_TURBO") { channelName = "LongTurbo"; return true; }
if (presetName == "LONG_MODERATE") { channelName = "LongModerate"; return true; }
if (presetName == "MEDIUM_FAST") { channelName = "MediumFast"; return true; }
if (presetName == "MEDIUM_SLOW") { channelName = "MediumSlow"; return true; }
if (presetName == "SHORT_FAST") { channelName = "ShortFast"; return true; }
if (presetName == "SHORT_SLOW") { channelName = "ShortSlow"; return true; }
if (presetName == "SHORT_TURBO") { channelName = "ShortTurbo"; return true; }
return false;
}
static bool presetToParams(const QString& presetName, bool wideLora, int& bandwidthHz, int& spreadFactor, int& parityBits)
{
int bwKHz = 0;
int sf = 0;
int cr = 0;
if (presetName == "SHORT_TURBO") { bwKHz = wideLora ? 1625 : 500; cr = 5; sf = 7; }
else if (presetName == "SHORT_FAST") { bwKHz = wideLora ? 812 : 250; cr = 5; sf = 7; }
else if (presetName == "SHORT_SLOW") { bwKHz = wideLora ? 812 : 250; cr = 5; sf = 8; }
else if (presetName == "MEDIUM_FAST") { bwKHz = wideLora ? 812 : 250; cr = 5; sf = 9; }
else if (presetName == "MEDIUM_SLOW") { bwKHz = wideLora ? 812 : 250; cr = 5; sf = 10; }
else if (presetName == "LONG_TURBO") { bwKHz = wideLora ? 1625 : 500; cr = 8; sf = 11; }
else if (presetName == "LONG_MODERATE") { bwKHz = wideLora ? 406 : 125; cr = 8; sf = 11; }
else if (presetName == "LONG_SLOW") { bwKHz = wideLora ? 406 : 125; cr = 8; sf = 12; }
else if (presetName == "LONG_FAST") { bwKHz = wideLora ? 812 : 250; cr = 5; sf = 11; }
else {
return false;
}
bandwidthHz = bwKHz * 1000;
spreadFactor = sf;
parityBits = std::max(1, std::min(4, cr - 4));
return true;
}
static QString presetToDisplayName(const QString& presetName)
{
QString channelName;
if (presetToChannelName(presetName, channelName)) {
return channelName;
}
return QString("LongFast");
}
static uint32_t meshHashDjb2(const QString& s)
{
const QByteArray bytes = s.toUtf8();
uint32_t h = 5381u;
for (char c : bytes) {
h = ((h << 5) + h) + static_cast<uint8_t>(c);
}
return h;
}
static const RegionBand* findRegionBand(const QString& regionValue)
{
QString r = normalizeToken(regionValue);
r.remove('_');
if (r == "EU868") { r = "EU_868"; }
else if (r == "EU433") { r = "EU_433"; }
else if (r == "NZ865") { r = "NZ_865"; }
else if (r == "UA868") { r = "UA_868"; }
else if (r == "UA433") { r = "UA_433"; }
else if (r == "MY433") { r = "MY_433"; }
else if (r == "MY919") { r = "MY_919"; }
else if (r == "SG923") { r = "SG_923"; }
else if (r == "PH433") { r = "PH_433"; }
else if (r == "PH868") { r = "PH_868"; }
else if (r == "PH915") { r = "PH_915"; }
else if (r == "KZ433") { r = "KZ_433"; }
else if (r == "KZ863") { r = "KZ_863"; }
else if (r == "NP865") { r = "NP_865"; }
else if (r == "BR902") { r = "BR_902"; }
else if (r == "ANZ433") { r = "ANZ_433"; }
else if (r == "LORA24") { r = "LORA_24"; }
else { r = normalizeToken(regionValue); }
for (int i = 0; i < kRegionBandsCount; ++i) {
if (r == kRegionBands[i].name) {
return &kRegionBands[i];
}
}
return nullptr;
}
static bool parseCommand(const QString& command, CommandConfig& cfg, QString& error)
{
if (!Packet::isCommand(command)) {
error = "command must start with MESH:";
return false;
}
cfg.header.to = kBroadcastNode;
cfg.header.from = 0;
cfg.header.id = QRandomGenerator::global()->generate();
cfg.header.channel = 0;
cfg.header.nextHop = 0;
cfg.header.relayNode = 0;
cfg.data.hasPortnum = true;
cfg.data.portnum = 1; // TEXT_MESSAGE_APP
cfg.data.payload.clear();
cfg.encrypt = true;
cfg.key = expandSimpleKey(1);
cfg.keyLabel = "default";
cfg.channelName = "LongFast";
cfg.presetName = "LONG_FAST";
bool encryptAuto = true;
uint8_t hopLimit = 3;
uint8_t hopStart = 3;
bool wantAck = false;
bool viaMqtt = false;
QString body = command.mid(5).trimmed();
QStringList parts = body.split(';', Qt::SkipEmptyParts);
QStringList freeText;
for (QString part : parts)
{
part = part.trimmed();
if (part.isEmpty()) {
continue;
}
const int sep = part.indexOf('=');
if (sep <= 0)
{
freeText.push_back(trimQuotes(part));
continue;
}
const QString key = part.left(sep).trimmed().toLower();
const QString value = trimQuotes(part.mid(sep + 1));
uint64_t u = 0;
if (key == "to")
{
if (!parseUInt(value, u)) {
error = "invalid to";
return false;
}
cfg.header.to = static_cast<uint32_t>(u);
}
else if (key == "from")
{
if (!parseUInt(value, u)) {
error = "invalid from";
return false;
}
cfg.header.from = static_cast<uint32_t>(u);
}
else if (key == "id")
{
if (!parseUInt(value, u)) {
error = "invalid id";
return false;
}
cfg.header.id = static_cast<uint32_t>(u);
}
else if (key == "port" || key == "portnum")
{
uint32_t p = 0;
if (!parsePortValue(value, p)) {
error = "invalid port/portnum";
return false;
}
cfg.data.portnum = p;
cfg.data.hasPortnum = true;
}
else if (key == "text")
{
cfg.data.payload = value.toUtf8();
}
else if (key == "payload_hex")
{
QByteArray p;
if (!parseHexBytes(value, p)) {
error = "invalid payload_hex";
return false;
}
cfg.data.payload = p;
}
else if (key == "payload_b64" || key == "payload_base64")
{
QByteArray p = QByteArray::fromBase64(value.toLatin1());
if (p.isEmpty() && !value.isEmpty()) {
error = "invalid payload_base64";
return false;
}
cfg.data.payload = p;
}
else if (key == "want_response")
{
bool b = false;
if (!parseBool(value, b)) {
error = "invalid want_response";
return false;
}
cfg.data.wantResponse = b;
}
else if (key == "dest")
{
if (!parseUInt(value, u)) {
error = "invalid dest";
return false;
}
cfg.data.hasDest = true;
cfg.data.dest = static_cast<uint32_t>(u);
}
else if (key == "source")
{
if (!parseUInt(value, u)) {
error = "invalid source";
return false;
}
cfg.data.hasSource = true;
cfg.data.source = static_cast<uint32_t>(u);
}
else if (key == "request_id")
{
if (!parseUInt(value, u)) {
error = "invalid request_id";
return false;
}
cfg.data.hasRequestId = true;
cfg.data.requestId = static_cast<uint32_t>(u);
}
else if (key == "reply_id")
{
if (!parseUInt(value, u)) {
error = "invalid reply_id";
return false;
}
cfg.data.hasReplyId = true;
cfg.data.replyId = static_cast<uint32_t>(u);
}
else if (key == "emoji")
{
if (!parseUInt(value, u)) {
error = "invalid emoji";
return false;
}
cfg.data.hasEmoji = true;
cfg.data.emoji = static_cast<uint32_t>(u);
}
else if (key == "bitfield")
{
if (!parseUInt(value, u)) {
error = "invalid bitfield";
return false;
}
cfg.data.hasBitfield = true;
cfg.data.bitfield = static_cast<uint32_t>(u);
}
else if (key == "hop_limit")
{
if (!parseUInt(value, u) || u > 7) {
error = "invalid hop_limit";
return false;
}
hopLimit = static_cast<uint8_t>(u);
}
else if (key == "hop_start")
{
if (!parseUInt(value, u) || u > 7) {
error = "invalid hop_start";
return false;
}
hopStart = static_cast<uint8_t>(u);
}
else if (key == "want_ack")
{
if (!parseBool(value, wantAck)) {
error = "invalid want_ack";
return false;
}
}
else if (key == "via_mqtt")
{
if (!parseBool(value, viaMqtt)) {
error = "invalid via_mqtt";
return false;
}
}
else if (key == "next_hop")
{
if (!parseUInt(value, u) || u > 255) {
error = "invalid next_hop";
return false;
}
cfg.header.nextHop = static_cast<uint8_t>(u);
}
else if (key == "relay_node")
{
if (!parseUInt(value, u) || u > 255) {
error = "invalid relay_node";
return false;
}
cfg.header.relayNode = static_cast<uint8_t>(u);
}
else if (key == "channel_hash" || key == "channel")
{
if (!parseUInt(value, u) || u > 255) {
error = "invalid channel_hash";
return false;
}
cfg.header.channel = static_cast<uint8_t>(u);
cfg.channelName.clear();
}
else if (key == "channel_name")
{
cfg.channelName = value;
}
else if (key == "preset" || key == "modem_preset")
{
QString presetName;
if (!parsePresetName(value, presetName)) {
error = "invalid preset/modem_preset";
return false;
}
cfg.presetName = presetName;
presetToChannelName(cfg.presetName, cfg.channelName);
}
else if (key == "region" || key == "region_code")
{
const RegionBand* band = findRegionBand(value);
if (!band) {
error = "invalid region";
return false;
}
cfg.hasRegion = true;
cfg.regionName = band->name;
}
else if (key == "channel_num" || key == "slot")
{
if (!parseUInt(value, u) || u < 1 || u > 10000) {
error = "invalid channel_num";
return false;
}
cfg.hasChannelNum = true;
cfg.channelNum = static_cast<uint32_t>(u);
}
else if (key == "frequency" || key == "freq" || key == "override_frequency" || key == "frequency_mhz" || key == "freq_mhz")
{
double f = 0.0;
if (!parseDouble(value, f) || f <= 0.0) {
error = "invalid frequency";
return false;
}
if (f > 1000000.0) {
cfg.overrideFrequencyMHz = f / 1000000.0;
} else {
cfg.overrideFrequencyMHz = f;
}
cfg.hasOverrideFrequencyMHz = true;
}
else if (key == "freq_hz" || key == "frequency_hz")
{
if (!parseUInt(value, u) || u < 1000000ull) {
error = "invalid frequency_hz";
return false;
}
cfg.overrideFrequencyMHz = static_cast<double>(u) / 1000000.0;
cfg.hasOverrideFrequencyMHz = true;
}
else if (key == "frequency_offset" || key == "freq_offset")
{
double off = 0.0;
if (!parseDouble(value, off)) {
error = "invalid frequency_offset";
return false;
}
// Meshtastic uses MHz for frequency offset. We also accept Hz-like values.
if (std::fabs(off) > 100000.0) {
off /= 1000000.0;
}
cfg.frequencyOffsetMHz = off;
cfg.hasFrequencyOffsetMHz = true;
}
else if (key == "frequency_offset_hz" || key == "freq_offset_hz")
{
double offHz = 0.0;
if (!parseDouble(value, offHz)) {
error = "invalid frequency_offset_hz";
return false;
}
cfg.frequencyOffsetMHz = offHz / 1000000.0;
cfg.hasFrequencyOffsetMHz = true;
}
else if (key == "key" || key == "psk")
{
QByteArray parsedKey;
QString keyLabel;
if (!parseKeySpec(value, parsedKey, keyLabel)) {
error = "invalid key/psk";
return false;
}
cfg.key = parsedKey;
cfg.keyLabel = keyLabel;
if (encryptAuto) {
cfg.encrypt = !cfg.key.isEmpty();
}
}
else if (key == "encrypt")
{
const QString lower = value.toLower();
if (lower == "auto") {
encryptAuto = true;
cfg.encrypt = !cfg.key.isEmpty();
}
else
{
bool b = false;
if (!parseBool(value, b)) {
error = "invalid encrypt";
return false;
}
encryptAuto = false;
cfg.encrypt = b;
}
}
else
{
error = QString("unknown key '%1'").arg(key);
return false;
}
}
if (cfg.data.payload.isEmpty() && !freeText.isEmpty()) {
cfg.data.payload = freeText.join(';').toUtf8();
}
if (cfg.header.channel == 0 && !cfg.channelName.isEmpty()) {
cfg.header.channel = generateChannelHash(cfg.channelName, cfg.key);
}
if (encryptAuto) {
cfg.encrypt = !cfg.key.isEmpty();
}
if (cfg.encrypt && cfg.key.isEmpty()) {
error = "encrypt=true but key resolves to none";
return false;
}
cfg.header.flags = static_cast<uint8_t>(hopLimit & 0x07);
if (wantAck) {
cfg.header.flags |= kFlagWantAckMask;
}
if (viaMqtt) {
cfg.header.flags |= kFlagViaMqttMask;
}
cfg.header.flags |= static_cast<uint8_t>((hopStart & 0x07) << 5);
return true;
}
// Forward declaration: summarizePortPayload is defined after the per-port parsers below.
static QString summarizePortPayload(const DataFields& d);
static QString summarizeHeader(const Header& h)
{
const int hopLimit = h.flags & kFlagHopLimitMask;
const int hopStart = (h.flags & kFlagHopStartMask) >> 5;
const bool wantAck = (h.flags & kFlagWantAckMask) != 0;
const bool viaMqtt = (h.flags & kFlagViaMqttMask) != 0;
return QString("to=%1 from=%2 id=0x%3 ch=0x%4 hop=%5/%6 ack=%7 mqtt=%8 next=%9 relay=%10")
.arg(formatNode(h.to))
.arg(formatNode(h.from))
.arg(h.id, 8, 16, QChar('0'))
.arg(h.channel, 2, 16, QChar('0'))
.arg(hopLimit)
.arg(hopStart)
.arg(wantAck ? 1 : 0)
.arg(viaMqtt ? 1 : 0)
.arg(h.nextHop)
.arg(h.relayNode);
}
static QString summarizeData(const DataFields& d)
{
const QString portName = portToName(d.portnum);
QString s = QString("port=%1(%2)").arg(portName).arg(d.portnum);
if (d.wantResponse) {
s += " want_response=1";
}
if (d.hasDest) {
s += QString(" dest=%1").arg(formatNode(d.dest));
}
if (d.hasSource) {
s += QString(" source=%1").arg(formatNode(d.source));
}
if (d.hasRequestId) {
s += QString(" request_id=0x%1").arg(d.requestId, 8, 16, QChar('0'));
}
if (d.hasReplyId) {
s += QString(" reply_id=0x%1").arg(d.replyId, 8, 16, QChar('0'));
}
if (d.hasEmoji) {
s += QString(" emoji=%1").arg(d.emoji);
}
if (d.hasBitfield) {
s += QString(" bitfield=0x%1").arg(d.bitfield, 0, 16);
}
s += summarizePortPayload(d);
return s;
}
static void addDecodeField(DecodeResult& result, const QString& path, const QString& value)
{
DecodeResult::Field f;
f.path = path;
f.value = value;
result.fields.append(f);
}
static void addDecodeField(DecodeResult& result, const QString& path, uint32_t value)
{
addDecodeField(result, path, QString::number(value));
}
static void addDecodeField(DecodeResult& result, const QString& path, bool value)
{
addDecodeField(result, path, QString(value ? "true" : "false"));
}
static void appendHeaderDecodeFields(const Header& h, DecodeResult& result)
{
const int hopLimit = h.flags & kFlagHopLimitMask;
const int hopStart = (h.flags & kFlagHopStartMask) >> 5;
const bool wantAck = (h.flags & kFlagWantAckMask) != 0;
const bool viaMqtt = (h.flags & kFlagViaMqttMask) != 0;
addDecodeField(result, "header.to", formatNode(h.to));
addDecodeField(result, "header.from", formatNode(h.from));
addDecodeField(result, "header.id", QString("0x%1").arg(h.id, 8, 16, QChar('0')));
addDecodeField(result, "header.channel_hash", QString("0x%1").arg(h.channel, 2, 16, QChar('0')));
addDecodeField(result, "header.hop_limit", QString::number(hopLimit));
addDecodeField(result, "header.hop_start", QString::number(hopStart));
addDecodeField(result, "header.want_ack", wantAck);
addDecodeField(result, "header.via_mqtt", viaMqtt);
addDecodeField(result, "header.next_hop", QString::number(h.nextHop));
addDecodeField(result, "header.relay_node", QString::number(h.relayNode));
}
// =============================================================================
// Per-port protobuf payload decoders
// Strategy: extend the existing hand-parser with one sub-parser per port type.
// Each port section has three parts:
// 1. A plain data struct that holds the decoded fields.
// 2. A parse*Payload() function that populates the struct from raw bytes.
// 3. An append*DecodeFields() function that pushes the fields into DecodeResult.
// =============================================================================
// --- Shared decode helpers ---
/** Zigzag-decode a protobuf sint32 value. */
static int32_t zigzagDecode32(uint64_t n)
{
return static_cast<int32_t>((n >> 1) ^ static_cast<uint64_t>(-(static_cast<int64_t>(n & 1))));
}
/** Reinterpret the 4 bytes of a protobuf fixed32 field as an IEEE 754 float. */
static float fixed32ToFloat(uint32_t v)
{
float f;
memcpy(&f, &v, 4);
return f;
}
static double fixed64ToDouble(uint64_t v)
{
double d;
memcpy(&d, &v, 8);
return d;
}
static bool decodeCoordinateFromFixed32(uint32_t raw, double& coordinate)
{
const int32_t fixedValue = static_cast<int32_t>(raw);
const double scaled = static_cast<double>(fixedValue) / 1e7;
if (std::isfinite(scaled) && std::fabs(scaled) <= 180.0)
{
coordinate = scaled;
return true;
}
const double floatValue = static_cast<double>(fixed32ToFloat(raw));
if (std::isfinite(floatValue) && std::fabs(floatValue) <= 180.0)
{
coordinate = floatValue;
return true;
}
return false;
}
// =============================================================================
// POSITION_APP (portnum = 3)
// Proto: meshtastic/mesh.proto message Position
// =============================================================================
struct PositionFields
{
bool hasLatitude = false;
double latitude = 0.0;
bool hasLongitude = false;
double longitude = 0.0;
bool hasLatitudeI = false;
int32_t latitudeI = 0; // degrees * 1e7 (sint32 field 1)
bool hasLongitudeI = false;
int32_t longitudeI = 0; // degrees * 1e7 (sint32 field 2)
bool hasAltitude = false;
int32_t altitude = 0; // metres above MSL (int32 field 3)
bool hasTime = false;
uint32_t time = 0; // Unix timestamp, deprecated (uint32 field 4)
bool hasPdop = false;
uint32_t pdop = 0; // position DOP * 100 (uint32 field 8)
bool hasGroundSpeed = false;
uint32_t groundSpeed = 0; // km/h (uint32 field 12)
bool hasGroundTrack = false;
uint32_t groundTrack = 0; // centi-degrees (uint32 field 13)
bool hasSatsInView = false;
uint32_t satsInView = 0; // (int32 field 16)
bool hasTimestamp = false;
uint32_t timestamp = 0; // Unix timestamp, preferred (uint32 field 32)
};
static bool parsePositionPayload(const QByteArray& bytes, PositionFields& p)
{
int pos = 0;
while (pos < bytes.size())
{
uint64_t rawTag = 0;
if (!readVarint(bytes, pos, rawTag)) {
return false;
}
const uint32_t field = static_cast<uint32_t>(rawTag >> 3);
const uint32_t wire = static_cast<uint32_t>(rawTag & 0x7);
switch (field)
{
case 1: { // latitude_i: sint32
if (wire == 0)
{
uint64_t v = 0;
if (!readVarint(bytes, pos, v)) { return false; }
p.latitudeI = zigzagDecode32(v);
p.hasLatitudeI = true;
p.latitude = p.latitudeI / 1e7;
p.hasLatitude = true;
}
else if (wire == 5)
{
uint32_t v = 0;
if (!readFixed32(bytes, pos, v)) { return false; }
double latitude = 0.0;
if (decodeCoordinateFromFixed32(v, latitude)) {
p.latitude = latitude;
p.hasLatitude = true;
p.latitudeI = static_cast<int32_t>(std::lround(latitude * 1e7));
p.hasLatitudeI = true;
}
}
else if (wire == 1)
{
uint64_t v = 0;
if (!readFixed64(bytes, pos, v)) { return false; }
p.latitude = fixed64ToDouble(v);
p.hasLatitude = true;
}
else
{
if (!skipField(bytes, pos, wire)) { return false; }
}
break;
}
case 2: { // longitude_i: sint32
if (wire == 0)
{
uint64_t v = 0;
if (!readVarint(bytes, pos, v)) { return false; }
p.longitudeI = zigzagDecode32(v);
p.hasLongitudeI = true;
p.longitude = p.longitudeI / 1e7;
p.hasLongitude = true;
}
else if (wire == 5)
{
uint32_t v = 0;
if (!readFixed32(bytes, pos, v)) { return false; }
double longitude = 0.0;
if (decodeCoordinateFromFixed32(v, longitude)) {
p.longitude = longitude;
p.hasLongitude = true;
p.longitudeI = static_cast<int32_t>(std::lround(longitude * 1e7));
p.hasLongitudeI = true;
}
}
else if (wire == 1)
{
uint64_t v = 0;
if (!readFixed64(bytes, pos, v)) { return false; }
p.longitude = fixed64ToDouble(v);
p.hasLongitude = true;
}
else
{
if (!skipField(bytes, pos, wire)) { return false; }
}
break;
}
case 3: { // altitude: int32
if (wire == 0)
{
uint64_t v = 0;
if (!readVarint(bytes, pos, v)) { return false; }
p.altitude = static_cast<int32_t>(v);
p.hasAltitude = true;
}
else if (wire == 5)
{
uint32_t v = 0;
if (!readFixed32(bytes, pos, v)) { return false; }
p.altitude = static_cast<int32_t>(std::lround(static_cast<double>(fixed32ToFloat(v))));
p.hasAltitude = true;
}
else
{
if (!skipField(bytes, pos, wire)) { return false; }
}
break;
}
case 4: { // time: uint32 (deprecated, still widely used)
if (wire == 0)
{
uint64_t v = 0;
if (!readVarint(bytes, pos, v)) { return false; }
p.time = static_cast<uint32_t>(v);
p.hasTime = true;
}
else if (wire == 5)
{
uint32_t v = 0;
if (!readFixed32(bytes, pos, v)) { return false; }
p.time = v;
p.hasTime = true;
}
else
{
if (!skipField(bytes, pos, wire)) { return false; }
}
break;
}
case 8: { // PDOP: uint32
if (wire == 0)
{
uint64_t v = 0;
if (!readVarint(bytes, pos, v)) { return false; }
p.pdop = static_cast<uint32_t>(v);
p.hasPdop = true;
}
else
{
if (!skipField(bytes, pos, wire)) { return false; }
}
break;
}
case 12: { // ground_speed: uint32 (km/h)
if (wire == 0)
{
uint64_t v = 0;
if (!readVarint(bytes, pos, v)) { return false; }
p.groundSpeed = static_cast<uint32_t>(v);
p.hasGroundSpeed = true;
}
else
{
if (!skipField(bytes, pos, wire)) { return false; }
}
break;
}
case 13: { // ground_track: uint32 (centi-degrees)
if (wire == 0)
{
uint64_t v = 0;
if (!readVarint(bytes, pos, v)) { return false; }
p.groundTrack = static_cast<uint32_t>(v);
p.hasGroundTrack = true;
}
else
{
if (!skipField(bytes, pos, wire)) { return false; }
}
break;
}
case 16: { // sats_in_view: int32
if (wire == 0)
{
uint64_t v = 0;
if (!readVarint(bytes, pos, v)) { return false; }
p.satsInView = static_cast<uint32_t>(v);
p.hasSatsInView = true;
}
else
{
if (!skipField(bytes, pos, wire)) { return false; }
}
break;
}
case 32: { // timestamp: uint32 (preferred over field 4)
if (wire == 0)
{
uint64_t v = 0;
if (!readVarint(bytes, pos, v)) { return false; }
p.timestamp = static_cast<uint32_t>(v);
p.hasTimestamp = true;
}
else if (wire == 5)
{
uint32_t v = 0;
if (!readFixed32(bytes, pos, v)) { return false; }
p.timestamp = v;
p.hasTimestamp = true;
}
else
{
if (!skipField(bytes, pos, wire)) { return false; }
}
break;
}
default:
if (!skipField(bytes, pos, wire)) { return false; }
break;
}
}
if (!p.hasLatitude && p.hasLatitudeI) {
p.latitude = p.latitudeI / 1e7;
p.hasLatitude = true;
}
if (!p.hasLongitude && p.hasLongitudeI) {
p.longitude = p.longitudeI / 1e7;
p.hasLongitude = true;
}
return p.hasLatitude || p.hasLongitude || p.hasAltitude || p.hasTime || p.hasTimestamp;
}
static void appendPositionDecodeFields(const PositionFields& p, DecodeResult& result)
{
if (p.hasLatitude) {
addDecodeField(result, "position.latitude",
QString::number(p.latitude, 'f', 7));
}
if (p.hasLongitude) {
addDecodeField(result, "position.longitude",
QString::number(p.longitude, 'f', 7));
}
if (p.hasAltitude) {
addDecodeField(result, "position.altitude_m", QString::number(p.altitude));
}
// Prefer field 32 (timestamp) over deprecated field 4 (time).
const uint32_t ts = p.hasTimestamp ? p.timestamp : (p.hasTime ? p.time : 0u);
if (ts != 0) {
addDecodeField(result, "position.timestamp", QString::number(ts));
const QDateTime dt = QDateTime::fromSecsSinceEpoch(static_cast<qint64>(ts), Qt::UTC);
addDecodeField(result, "position.datetime_utc", dt.toString(Qt::ISODate));
}
if (p.hasGroundSpeed) {
addDecodeField(result, "position.ground_speed_kmh", QString::number(p.groundSpeed));
}
if (p.hasGroundTrack) {
addDecodeField(result, "position.ground_track_deg",
QString::number(p.groundTrack / 100.0, 'f', 2));
}
if (p.hasSatsInView) {
addDecodeField(result, "position.sats_in_view", QString::number(p.satsInView));
}
if (p.hasPdop) {
addDecodeField(result, "position.pdop", QString::number(p.pdop));
}
}
// =============================================================================
// NODEINFO_APP (portnum = 4)
// Proto: meshtastic/mesh.proto message User
// =============================================================================
struct UserFields
{
bool hasId = false;
QString id; // node ID string e.g. "!aabbccdd" (string field 1)
bool hasLongName = false;
QString longName; // human-readable name (string field 2)
bool hasShortName = false;
QString shortName; // 2-4 char callsign (string field 3)
bool hasMacaddr = false;
QByteArray macaddr; // 6-byte MAC address (bytes field 4)
bool hasHwModel = false;
uint32_t hwModel = 0; // HardwareModel enum (uint32 field 5)
bool hasIsLicensed = false;
bool isLicensed = false; // licensed amateur radio operator (bool field 7)
};
static bool parseUserPayload(const QByteArray& bytes, UserFields& u)
{
int pos = 0;
while (pos < bytes.size())
{
uint64_t rawTag = 0;
if (!readVarint(bytes, pos, rawTag)) { return false; }
const uint32_t field = static_cast<uint32_t>(rawTag >> 3);
const uint32_t wire = static_cast<uint32_t>(rawTag & 0x7);
switch (field)
{
case 1: { // id: string
if (wire != 2) { return false; }
uint64_t len = 0;
if (!readVarint(bytes, pos, len)) { return false; }
if (len > static_cast<uint64_t>(bytes.size() - pos)) { return false; }
u.id = QString::fromUtf8(bytes.constData() + pos, static_cast<int>(len));
u.hasId = true;
pos += static_cast<int>(len);
break;
}
case 2: { // long_name: string
if (wire != 2) { return false; }
uint64_t len = 0;
if (!readVarint(bytes, pos, len)) { return false; }
if (len > static_cast<uint64_t>(bytes.size() - pos)) { return false; }
u.longName = QString::fromUtf8(bytes.constData() + pos, static_cast<int>(len));
u.hasLongName = true;
pos += static_cast<int>(len);
break;
}
case 3: { // short_name: string
if (wire != 2) { return false; }
uint64_t len = 0;
if (!readVarint(bytes, pos, len)) { return false; }
if (len > static_cast<uint64_t>(bytes.size() - pos)) { return false; }
u.shortName = QString::fromUtf8(bytes.constData() + pos, static_cast<int>(len));
u.hasShortName = true;
pos += static_cast<int>(len);
break;
}
case 4: { // macaddr: bytes
if (wire != 2) { return false; }
uint64_t len = 0;
if (!readVarint(bytes, pos, len)) { return false; }
if (len > static_cast<uint64_t>(bytes.size() - pos)) { return false; }
u.macaddr = bytes.mid(pos, static_cast<int>(len));
u.hasMacaddr = true;
pos += static_cast<int>(len);
break;
}
case 5: { // hw_model: HardwareModel (enum → uint32)
if (wire != 0) { return false; }
uint64_t v = 0;
if (!readVarint(bytes, pos, v)) { return false; }
u.hwModel = static_cast<uint32_t>(v);
u.hasHwModel = true;
break;
}
case 7: { // is_licensed: bool
if (wire != 0) { return false; }
uint64_t v = 0;
if (!readVarint(bytes, pos, v)) { return false; }
u.isLicensed = (v != 0);
u.hasIsLicensed = true;
break;
}
default:
if (!skipField(bytes, pos, wire)) { return false; }
break;
}
}
return u.hasId || u.hasLongName || u.hasShortName;
}
static void appendUserDecodeFields(const UserFields& u, DecodeResult& result)
{
if (u.hasId) { addDecodeField(result, "nodeinfo.id", u.id); }
if (u.hasLongName) { addDecodeField(result, "nodeinfo.long_name", u.longName); }
if (u.hasShortName) { addDecodeField(result, "nodeinfo.short_name", u.shortName); }
if (u.hasMacaddr) { addDecodeField(result, "nodeinfo.macaddr", QString(u.macaddr.toHex(':'))); }
if (u.hasHwModel) { addDecodeField(result, "nodeinfo.hw_model", u.hwModel); }
if (u.hasIsLicensed){ addDecodeField(result, "nodeinfo.is_licensed", u.isLicensed); }
}
// =============================================================================
// TELEMETRY_APP (portnum = 67)
// Proto: meshtastic/telemetry.proto message Telemetry
// which embeds DeviceMetrics (field 2) and EnvironmentMetrics (field 3)
// =============================================================================
struct DeviceMetrics
{
bool hasBatteryLevel = false;
float batteryLevel = 0.0f; // 0100 %
bool hasVoltage = false;
float voltage = 0.0f; // V
bool hasChannelUtilization = false;
float channelUtilization = 0.0f; // %
bool hasAirUtilTx = false;
float airUtilTx = 0.0f; // %
bool hasUptimeSeconds = false;
uint32_t uptimeSeconds = 0;
};
struct EnvironmentMetrics
{
bool hasTemperature = false;
float temperature = 0.0f; // °C
bool hasRelativeHumidity = false;
float relativeHumidity = 0.0f; // %
bool hasBarometricPressure = false;
float barometricPressure = 0.0f; // hPa
bool hasGasResistance = false;
float gasResistance = 0.0f; // MOhm
bool hasIaq = false;
float iaq = 0.0f; // indoor air quality index
};
struct TelemetryFields
{
bool hasTime = false;
uint32_t time = 0;
bool hasDeviceMetrics = false;
DeviceMetrics deviceMetrics;
bool hasEnvironmentMetrics = false;
EnvironmentMetrics environmentMetrics;
};
static bool parseDeviceMetrics(const QByteArray& bytes, DeviceMetrics& dm)
{
int pos = 0;
while (pos < bytes.size())
{
uint64_t rawTag = 0;
if (!readVarint(bytes, pos, rawTag)) { return false; }
const uint32_t field = static_cast<uint32_t>(rawTag >> 3);
const uint32_t wire = static_cast<uint32_t>(rawTag & 0x7);
switch (field)
{
case 1: { // battery_level: float
if (wire == 5)
{
uint32_t v = 0;
if (!readFixed32(bytes, pos, v)) { return false; }
dm.batteryLevel = fixed32ToFloat(v);
dm.hasBatteryLevel = true;
}
else if (wire == 0)
{
uint64_t v = 0;
if (!readVarint(bytes, pos, v)) { return false; }
dm.batteryLevel = static_cast<float>(v);
dm.hasBatteryLevel = true;
}
else
{
if (!skipField(bytes, pos, wire)) { return false; }
}
break;
}
case 2: { // voltage: float
if (wire == 5)
{
uint32_t v = 0;
if (!readFixed32(bytes, pos, v)) { return false; }
dm.voltage = fixed32ToFloat(v);
dm.hasVoltage = true;
}
else if (wire == 0)
{
uint64_t v = 0;
if (!readVarint(bytes, pos, v)) { return false; }
const double raw = static_cast<double>(v);
dm.voltage = static_cast<float>((raw > 1000.0) ? (raw / 1000.0) : raw);
dm.hasVoltage = true;
}
else
{
if (!skipField(bytes, pos, wire)) { return false; }
}
break;
}
case 3: { // channel_utilization: float
if (wire == 5)
{
uint32_t v = 0;
if (!readFixed32(bytes, pos, v)) { return false; }
dm.channelUtilization = fixed32ToFloat(v);
dm.hasChannelUtilization = true;
}
else if (wire == 0)
{
uint64_t v = 0;
if (!readVarint(bytes, pos, v)) { return false; }
dm.channelUtilization = static_cast<float>(v);
dm.hasChannelUtilization = true;
}
else
{
if (!skipField(bytes, pos, wire)) { return false; }
}
break;
}
case 4: { // air_util_tx: float
if (wire == 5)
{
uint32_t v = 0;
if (!readFixed32(bytes, pos, v)) { return false; }
dm.airUtilTx = fixed32ToFloat(v);
dm.hasAirUtilTx = true;
}
else if (wire == 0)
{
uint64_t v = 0;
if (!readVarint(bytes, pos, v)) { return false; }
dm.airUtilTx = static_cast<float>(v);
dm.hasAirUtilTx = true;
}
else
{
if (!skipField(bytes, pos, wire)) { return false; }
}
break;
}
case 5: { // uptime_seconds: uint32
if (wire == 0)
{
uint64_t v = 0;
if (!readVarint(bytes, pos, v)) { return false; }
dm.uptimeSeconds = static_cast<uint32_t>(v);
dm.hasUptimeSeconds = true;
}
else if (wire == 5)
{
uint32_t v = 0;
if (!readFixed32(bytes, pos, v)) { return false; }
dm.uptimeSeconds = v;
dm.hasUptimeSeconds = true;
}
else
{
if (!skipField(bytes, pos, wire)) { return false; }
}
break;
}
default:
if (!skipField(bytes, pos, wire)) { return false; }
break;
}
}
return true;
}
static bool parseEnvironmentMetrics(const QByteArray& bytes, EnvironmentMetrics& em)
{
int pos = 0;
while (pos < bytes.size())
{
uint64_t rawTag = 0;
if (!readVarint(bytes, pos, rawTag)) { return false; }
const uint32_t field = static_cast<uint32_t>(rawTag >> 3);
const uint32_t wire = static_cast<uint32_t>(rawTag & 0x7);
switch (field)
{
case 1: { // temperature: float (°C)
if (wire == 5)
{
uint32_t v = 0;
if (!readFixed32(bytes, pos, v)) { return false; }
em.temperature = fixed32ToFloat(v);
em.hasTemperature = true;
}
else if (wire == 0)
{
uint64_t v = 0;
if (!readVarint(bytes, pos, v)) { return false; }
em.temperature = static_cast<float>(static_cast<int32_t>(v));
em.hasTemperature = true;
}
else
{
if (!skipField(bytes, pos, wire)) { return false; }
}
break;
}
case 2: { // relative_humidity: float (%)
if (wire == 5)
{
uint32_t v = 0;
if (!readFixed32(bytes, pos, v)) { return false; }
em.relativeHumidity = fixed32ToFloat(v);
em.hasRelativeHumidity = true;
}
else if (wire == 0)
{
uint64_t v = 0;
if (!readVarint(bytes, pos, v)) { return false; }
em.relativeHumidity = static_cast<float>(v);
em.hasRelativeHumidity = true;
}
else
{
if (!skipField(bytes, pos, wire)) { return false; }
}
break;
}
case 3: { // barometric_pressure: float (hPa)
if (wire == 5)
{
uint32_t v = 0;
if (!readFixed32(bytes, pos, v)) { return false; }
em.barometricPressure = fixed32ToFloat(v);
em.hasBarometricPressure = true;
}
else if (wire == 0)
{
uint64_t v = 0;
if (!readVarint(bytes, pos, v)) { return false; }
em.barometricPressure = static_cast<float>(v);
em.hasBarometricPressure = true;
}
else
{
if (!skipField(bytes, pos, wire)) { return false; }
}
break;
}
case 4: { // gas_resistance: float (MOhm)
if (wire != 5) { return false; }
uint32_t v = 0;
if (!readFixed32(bytes, pos, v)) { return false; }
em.gasResistance = fixed32ToFloat(v);
em.hasGasResistance = true;
break;
}
case 11: { // iaq: float (indoor air quality, newer field)
if (wire != 5) { return false; }
uint32_t v = 0;
if (!readFixed32(bytes, pos, v)) { return false; }
em.iaq = fixed32ToFloat(v);
em.hasIaq = true;
break;
}
default:
if (!skipField(bytes, pos, wire)) { return false; }
break;
}
}
return true;
}
static bool parseTelemetryPayload(const QByteArray& bytes, TelemetryFields& t)
{
int pos = 0;
while (pos < bytes.size())
{
uint64_t rawTag = 0;
if (!readVarint(bytes, pos, rawTag)) { return false; }
const uint32_t field = static_cast<uint32_t>(rawTag >> 3);
const uint32_t wire = static_cast<uint32_t>(rawTag & 0x7);
switch (field)
{
case 1: { // time: uint32
if (wire == 0)
{
uint64_t v = 0;
if (!readVarint(bytes, pos, v)) { return false; }
t.time = static_cast<uint32_t>(v);
t.hasTime = true;
}
else if (wire == 5)
{
uint32_t v = 0;
if (!readFixed32(bytes, pos, v)) { return false; }
t.time = v;
t.hasTime = true;
}
else
{
if (!skipField(bytes, pos, wire)) { return false; }
}
break;
}
case 2: { // device_metrics: DeviceMetrics (embedded message)
if (wire != 2) { return false; }
uint64_t len = 0;
if (!readVarint(bytes, pos, len)) { return false; }
if (len > static_cast<uint64_t>(bytes.size() - pos)) { return false; }
if (!parseDeviceMetrics(bytes.mid(pos, static_cast<int>(len)), t.deviceMetrics)) { return false; }
t.hasDeviceMetrics = true;
pos += static_cast<int>(len);
break;
}
case 3: { // environment_metrics: EnvironmentMetrics (embedded message)
if (wire != 2) { return false; }
uint64_t len = 0;
if (!readVarint(bytes, pos, len)) { return false; }
if (len > static_cast<uint64_t>(bytes.size() - pos)) { return false; }
if (!parseEnvironmentMetrics(bytes.mid(pos, static_cast<int>(len)), t.environmentMetrics)) { return false; }
t.hasEnvironmentMetrics = true;
pos += static_cast<int>(len);
break;
}
default:
if (!skipField(bytes, pos, wire)) { return false; }
break;
}
}
return t.hasTime || t.hasDeviceMetrics || t.hasEnvironmentMetrics;
}
static void appendTelemetryDecodeFields(const TelemetryFields& t, DecodeResult& result)
{
if (t.hasTime)
{
addDecodeField(result, "telemetry.time", QString::number(t.time));
const QDateTime dt = QDateTime::fromSecsSinceEpoch(static_cast<qint64>(t.time), Qt::UTC);
addDecodeField(result, "telemetry.datetime_utc", dt.toString(Qt::ISODate));
}
if (t.hasDeviceMetrics)
{
const DeviceMetrics& dm = t.deviceMetrics;
if (dm.hasBatteryLevel) {
addDecodeField(result, "telemetry.device.battery_level_pct",
QString::number(static_cast<double>(dm.batteryLevel), 'f', 1));
}
if (dm.hasVoltage) {
addDecodeField(result, "telemetry.device.voltage_v",
QString::number(static_cast<double>(dm.voltage), 'f', 3));
}
if (dm.hasChannelUtilization) {
addDecodeField(result, "telemetry.device.channel_util_pct",
QString::number(static_cast<double>(dm.channelUtilization), 'f', 2));
}
if (dm.hasAirUtilTx) {
addDecodeField(result, "telemetry.device.air_util_tx_pct",
QString::number(static_cast<double>(dm.airUtilTx), 'f', 2));
}
if (dm.hasUptimeSeconds) {
addDecodeField(result, "telemetry.device.uptime_s",
QString::number(dm.uptimeSeconds));
}
}
if (t.hasEnvironmentMetrics)
{
const EnvironmentMetrics& em = t.environmentMetrics;
if (em.hasTemperature) {
addDecodeField(result, "telemetry.env.temperature_c",
QString::number(static_cast<double>(em.temperature), 'f', 2));
}
if (em.hasRelativeHumidity) {
addDecodeField(result, "telemetry.env.humidity_pct",
QString::number(static_cast<double>(em.relativeHumidity), 'f', 1));
}
if (em.hasBarometricPressure) {
addDecodeField(result, "telemetry.env.pressure_hpa",
QString::number(static_cast<double>(em.barometricPressure), 'f', 2));
}
if (em.hasGasResistance) {
addDecodeField(result, "telemetry.env.gas_resistance_moh",
QString::number(static_cast<double>(em.gasResistance), 'f', 3));
}
if (em.hasIaq) {
addDecodeField(result, "telemetry.env.iaq",
QString::number(static_cast<double>(em.iaq), 'f', 1));
}
}
}
// =============================================================================
// TRACEROUTE_APP (portnum = 70)
// Proto: meshtastic/mesh.proto message RouteDiscovery
// =============================================================================
struct RouteDiscoveryFields
{
QVector<uint32_t> route; // node IDs on forward path (repeated fixed32 field 1)
QVector<int32_t> snrTowards; // SNR values forward path in 0.25 dB steps (sint32 field 2)
QVector<uint32_t> routeBack; // node IDs on return path (repeated fixed32 field 3)
QVector<int32_t> snrBack; // SNR values return path in 0.25 dB steps (sint32 field 4)
};
/** Parse a packed repeated fixed32 block (content only, length already consumed). */
static void parsePackedFixed32(const QByteArray& bytes, QVector<uint32_t>& out)
{
int pos = 0;
while ((pos + 4) <= bytes.size())
{
out.append(readU32LE(bytes.constData() + pos));
pos += 4;
}
}
/** Parse a packed repeated sint32 block (content only, length already consumed). */
static void parsePackedVarintSint32(const QByteArray& bytes, QVector<int32_t>& out)
{
int pos = 0;
while (pos < bytes.size())
{
uint64_t v = 0;
if (!readVarint(bytes, pos, v)) { break; }
out.append(zigzagDecode32(v));
}
}
static bool parseRouteDiscoveryPayload(const QByteArray& bytes, RouteDiscoveryFields& r)
{
int pos = 0;
while (pos < bytes.size())
{
uint64_t rawTag = 0;
if (!readVarint(bytes, pos, rawTag)) { return false; }
const uint32_t field = static_cast<uint32_t>(rawTag >> 3);
const uint32_t wire = static_cast<uint32_t>(rawTag & 0x7);
switch (field)
{
case 1: { // route: repeated fixed32 (packed wire=2 or individual wire=5)
if (wire == 2) {
uint64_t len = 0;
if (!readVarint(bytes, pos, len)) { return false; }
if (len > static_cast<uint64_t>(bytes.size() - pos)) { return false; }
parsePackedFixed32(bytes.mid(pos, static_cast<int>(len)), r.route);
pos += static_cast<int>(len);
} else if (wire == 5) {
uint32_t v = 0;
if (!readFixed32(bytes, pos, v)) { return false; }
r.route.append(v);
} else {
return false;
}
break;
}
case 2: { // snr_towards: repeated sint32 (packed wire=2 or individual wire=0)
if (wire == 2) {
uint64_t len = 0;
if (!readVarint(bytes, pos, len)) { return false; }
if (len > static_cast<uint64_t>(bytes.size() - pos)) { return false; }
parsePackedVarintSint32(bytes.mid(pos, static_cast<int>(len)), r.snrTowards);
pos += static_cast<int>(len);
} else if (wire == 0) {
uint64_t v = 0;
if (!readVarint(bytes, pos, v)) { return false; }
r.snrTowards.append(zigzagDecode32(v));
} else {
return false;
}
break;
}
case 3: { // route_back: repeated fixed32
if (wire == 2) {
uint64_t len = 0;
if (!readVarint(bytes, pos, len)) { return false; }
if (len > static_cast<uint64_t>(bytes.size() - pos)) { return false; }
parsePackedFixed32(bytes.mid(pos, static_cast<int>(len)), r.routeBack);
pos += static_cast<int>(len);
} else if (wire == 5) {
uint32_t v = 0;
if (!readFixed32(bytes, pos, v)) { return false; }
r.routeBack.append(v);
} else {
return false;
}
break;
}
case 4: { // snr_back: repeated sint32
if (wire == 2) {
uint64_t len = 0;
if (!readVarint(bytes, pos, len)) { return false; }
if (len > static_cast<uint64_t>(bytes.size() - pos)) { return false; }
parsePackedVarintSint32(bytes.mid(pos, static_cast<int>(len)), r.snrBack);
pos += static_cast<int>(len);
} else if (wire == 0) {
uint64_t v = 0;
if (!readVarint(bytes, pos, v)) { return false; }
r.snrBack.append(zigzagDecode32(v));
} else {
return false;
}
break;
}
default:
if (!skipField(bytes, pos, wire)) { return false; }
break;
}
}
return !r.route.isEmpty() || !r.routeBack.isEmpty();
}
static void appendRouteDiscoveryDecodeFields(const RouteDiscoveryFields& r, DecodeResult& result)
{
addDecodeField(result, "traceroute.forward_hops", QString::number(r.route.size()));
for (int i = 0; i < r.route.size(); ++i)
{
const QString prefix = QString("traceroute.route[%1]").arg(i);
addDecodeField(result, prefix + ".node_id",
QString("!%1").arg(r.route[i], 8, 16, QChar('0')));
if (i < r.snrTowards.size()) {
addDecodeField(result, prefix + ".snr_towards_db",
QString::number(r.snrTowards[i] / 4.0, 'f', 2));
}
}
if (!r.routeBack.isEmpty())
{
addDecodeField(result, "traceroute.back_hops", QString::number(r.routeBack.size()));
for (int i = 0; i < r.routeBack.size(); ++i)
{
const QString prefix = QString("traceroute.route_back[%1]").arg(i);
addDecodeField(result, prefix + ".node_id",
QString("!%1").arg(r.routeBack[i], 8, 16, QChar('0')));
if (i < r.snrBack.size()) {
addDecodeField(result, prefix + ".snr_back_db",
QString::number(r.snrBack[i] / 4.0, 'f', 2));
}
}
}
}
static QString summarizePortPayload(const DataFields& d)
{
if (d.payload.isEmpty()) {
return " payload=<empty>";
}
const auto appendPayloadHex = [&d]() -> QString {
const int n = std::min<int>(32, static_cast<int>(d.payload.size()));
QString text = QString(" payload_hex=%1").arg(QString(d.payload.left(n).toHex()));
if (d.payload.size() > n) {
text += "...";
}
return text;
};
const QString text = payloadToText(d.payload);
switch (d.portnum)
{
case 1: { // TEXT_MESSAGE_APP
if (!text.isEmpty()) {
return QString(" text=\"%1\"").arg(text);
}
return appendPayloadHex();
}
case 3: { // POSITION_APP
PositionFields p;
if (!parsePositionPayload(d.payload, p)) {
return appendPayloadHex();
}
QString out;
if (p.hasLatitude && p.hasLongitude) {
out += QString(" lat=%1 lon=%2")
.arg(p.latitude, 0, 'f', 5)
.arg(p.longitude, 0, 'f', 5);
}
if (p.hasAltitude) {
out += QString(" alt=%1m").arg(p.altitude);
}
if (p.hasGroundSpeed) {
out += QString(" spd=%1km/h").arg(p.groundSpeed);
}
if (p.hasSatsInView) {
out += QString(" sats=%1").arg(p.satsInView);
}
return out.isEmpty() ? appendPayloadHex() : out;
}
case 4: { // NODEINFO_APP
UserFields u;
if (!parseUserPayload(d.payload, u)) {
return appendPayloadHex();
}
QString out;
if (u.hasLongName) {
out += QString(" name=\"%1\"").arg(u.longName);
}
if (u.hasShortName) {
out += QString(" short=\"%1\"").arg(u.shortName);
}
if (u.hasId) {
out += QString(" id=%1").arg(u.id);
}
return out.isEmpty() ? appendPayloadHex() : out;
}
case 67: { // TELEMETRY_APP
TelemetryFields t;
if (!parseTelemetryPayload(d.payload, t))
{
DeviceMetrics dm;
EnvironmentMetrics em;
const bool hasDm = parseDeviceMetrics(d.payload, dm);
const bool hasEm = parseEnvironmentMetrics(d.payload, em);
if (!hasDm && !hasEm) {
return appendPayloadHex();
}
t.hasDeviceMetrics = hasDm;
t.deviceMetrics = dm;
t.hasEnvironmentMetrics = hasEm;
t.environmentMetrics = em;
}
QString out;
if (t.hasDeviceMetrics)
{
const DeviceMetrics& dm = t.deviceMetrics;
if (dm.hasBatteryLevel) {
out += QString(" batt=%1%").arg(static_cast<double>(dm.batteryLevel), 0, 'f', 1);
}
if (dm.hasVoltage) {
out += QString(" volt=%1V").arg(static_cast<double>(dm.voltage), 0, 'f', 2);
}
}
if (t.hasEnvironmentMetrics)
{
const EnvironmentMetrics& em = t.environmentMetrics;
if (em.hasTemperature) {
out += QString(" temp=%1°C").arg(static_cast<double>(em.temperature), 0, 'f', 1);
}
if (em.hasRelativeHumidity) {
out += QString(" hum=%1%").arg(static_cast<double>(em.relativeHumidity), 0, 'f', 0);
}
if (em.hasBarometricPressure) {
out += QString(" pres=%1hPa").arg(static_cast<double>(em.barometricPressure), 0, 'f', 1);
}
}
return out.isEmpty() ? appendPayloadHex() : out;
}
case 70: { // TRACEROUTE_APP
RouteDiscoveryFields r;
if (!parseRouteDiscoveryPayload(d.payload, r)) {
return appendPayloadHex();
}
QString out = QString(" fwd_hops=%1").arg(r.route.size());
if (!r.routeBack.isEmpty()) {
out += QString(" back_hops=%1").arg(r.routeBack.size());
}
return out;
}
default:
if (!text.isEmpty()) {
return QString(" text=\"%1\"").arg(text);
}
return appendPayloadHex();
}
}
// =============================================================================
// appendDataDecodeFields — dispatches to per-port decoders for known types,
// falls back to generic text / hex for everything else.
// =============================================================================
static void appendDataDecodeFields(const DataFields& d, DecodeResult& result)
{
addDecodeField(result, "data.port_name", portToName(d.portnum));
addDecodeField(result, "data.portnum", d.portnum);
addDecodeField(result, "data.want_response", d.wantResponse);
if (d.hasDest) {
addDecodeField(result, "data.dest", formatNode(d.dest));
}
if (d.hasSource) {
addDecodeField(result, "data.source", formatNode(d.source));
}
if (d.hasRequestId) {
addDecodeField(result, "data.request_id", QString("0x%1").arg(d.requestId, 8, 16, QChar('0')));
}
if (d.hasReplyId) {
addDecodeField(result, "data.reply_id", QString("0x%1").arg(d.replyId, 8, 16, QChar('0')));
}
if (d.hasEmoji) {
addDecodeField(result, "data.emoji", QString::number(d.emoji));
}
if (d.hasBitfield) {
addDecodeField(result, "data.bitfield", QString("0x%1").arg(d.bitfield, 0, 16));
}
addDecodeField(result, "data.payload_len", QString::number(d.payload.size()));
if (d.payload.isEmpty()) {
return;
}
switch (d.portnum)
{
case 1: { // TEXT_MESSAGE_APP
const QString text = payloadToText(d.payload);
if (!text.isEmpty()) {
addDecodeField(result, "data.text", text);
} else {
addDecodeField(result, "data.payload_hex", QString(d.payload.toHex()));
}
break;
}
case 3: { // POSITION_APP
PositionFields p;
if (parsePositionPayload(d.payload, p)) {
appendPositionDecodeFields(p, result);
} else {
addDecodeField(result, "data.payload_hex", QString(d.payload.toHex()));
}
break;
}
case 4: { // NODEINFO_APP
UserFields u;
if (parseUserPayload(d.payload, u)) {
appendUserDecodeFields(u, result);
} else {
addDecodeField(result, "data.payload_hex", QString(d.payload.toHex()));
}
break;
}
case 67: { // TELEMETRY_APP
TelemetryFields t;
if (parseTelemetryPayload(d.payload, t)) {
appendTelemetryDecodeFields(t, result);
} else {
DeviceMetrics dm;
EnvironmentMetrics em;
const bool hasDm = parseDeviceMetrics(d.payload, dm);
const bool hasEm = parseEnvironmentMetrics(d.payload, em);
if (hasDm || hasEm)
{
TelemetryFields direct;
direct.hasDeviceMetrics = hasDm;
direct.deviceMetrics = dm;
direct.hasEnvironmentMetrics = hasEm;
direct.environmentMetrics = em;
appendTelemetryDecodeFields(direct, result);
addDecodeField(result, "telemetry.decode_mode", QStringLiteral("direct_metrics_payload"));
}
else
{
addDecodeField(result, "data.payload_hex", QString(d.payload.toHex()));
}
}
break;
}
case 70: { // TRACEROUTE_APP
RouteDiscoveryFields r;
if (parseRouteDiscoveryPayload(d.payload, r)) {
appendRouteDiscoveryDecodeFields(r, result);
} else {
addDecodeField(result, "data.payload_hex", QString(d.payload.toHex()));
}
break;
}
default: {
const QString text = payloadToText(d.payload);
if (!text.isEmpty()) {
addDecodeField(result, "data.text", text);
} else {
addDecodeField(result, "data.payload_hex", QString(d.payload.toHex()));
}
break;
}
}
}
static bool deriveTxRadioSettingsFromConfig(const CommandConfig& cfg, TxRadioSettings& settings, QString& error)
{
settings = TxRadioSettings();
settings.hasCommand = true;
settings.syncWord = 0x2B;
QString presetName = cfg.presetName;
if (presetName.isEmpty()) {
presetName = "LONG_FAST";
}
bool wideLora = false;
const RegionBand* region = nullptr;
if (cfg.hasRegion) {
region = findRegionBand(cfg.regionName);
if (!region) {
error = "invalid region";
return false;
}
wideLora = region->wideLora;
}
int bandwidthHz = 0;
int spreadFactor = 0;
int parityBits = 0;
if (!presetToParams(presetName, wideLora, bandwidthHz, spreadFactor, parityBits)) {
error = "invalid preset";
return false;
}
if (wideLora) {
error = "LORA_24 wide LoRa presets are not supported by ChirpChat";
return false;
}
settings.hasLoRaParams = true;
settings.bandwidthHz = bandwidthHz;
settings.spreadFactor = spreadFactor;
settings.parityBits = parityBits;
settings.preambleChirps = wideLora ? 12 : 17;
const double symbolTimeSec = static_cast<double>(1u << spreadFactor) / static_cast<double>(bandwidthHz);
settings.deBits = (symbolTimeSec > 0.016) ? 2 : 0; // match classic LoRa low data rate optimization rule
if (cfg.hasOverrideFrequencyMHz)
{
const double freqMHz = cfg.overrideFrequencyMHz + (cfg.hasFrequencyOffsetMHz ? cfg.frequencyOffsetMHz : 0.0);
settings.hasCenterFrequency = true;
settings.centerFrequencyHz = static_cast<qint64>(std::llround(freqMHz * 1000000.0));
}
else if (region)
{
const double bwMHz = static_cast<double>(bandwidthHz) / 1000000.0;
const double slotWidthMHz = region->spacingMHz + bwMHz;
const double spanMHz = region->freqEndMHz - region->freqStartMHz;
const uint32_t numChannels = static_cast<uint32_t>(std::floor(spanMHz / slotWidthMHz));
if (numChannels == 0) {
error = "region span too narrow for selected preset bandwidth";
return false;
}
uint32_t channelIndex = 0;
if (cfg.hasChannelNum)
{
if (cfg.channelNum < 1 || cfg.channelNum > numChannels) {
error = QString("channel_num out of range 1..%1").arg(numChannels);
return false;
}
channelIndex = cfg.channelNum - 1;
}
else
{
const QString displayName = presetToDisplayName(presetName);
channelIndex = meshHashDjb2(displayName) % numChannels;
}
const double centerMHz = region->freqStartMHz + (bwMHz / 2.0) + (channelIndex * slotWidthMHz)
+ (cfg.hasFrequencyOffsetMHz ? cfg.frequencyOffsetMHz : 0.0);
settings.hasCenterFrequency = true;
settings.centerFrequencyHz = static_cast<qint64>(std::llround(centerMHz * 1000000.0));
}
settings.summary = QString("preset=%1 sf=%2 cr=4/%3 bw=%4kHz de=%5")
.arg(presetName)
.arg(settings.spreadFactor)
.arg(settings.parityBits + 4)
.arg(settings.bandwidthHz / 1000)
.arg(settings.deBits);
settings.summary += QString(" preamble=%1").arg(settings.preambleChirps);
if (region) {
settings.summary += QString(" region=%1").arg(region->name);
}
if (cfg.hasChannelNum) {
settings.summary += QString(" channel_num=%1").arg(cfg.channelNum);
}
if (settings.hasCenterFrequency) {
settings.summary += QString(" freq=%1MHz").arg(settings.centerFrequencyHz / 1000000.0, 0, 'f', 6);
}
return true;
}
} // namespace
bool Packet::isCommand(const QString& text)
{
return text.trimmed().startsWith("MESH:", Qt::CaseInsensitive);
}
bool Packet::buildFrameFromCommand(const QString& command, QByteArray& frame, QString& summary, QString& error)
{
CommandConfig cfg;
if (!parseCommand(command, cfg, error)) {
return false;
}
QByteArray payload = encodeData(cfg.data);
if (cfg.encrypt) {
payload = aesCtrCrypt(payload, cfg.key, cfg.header.from, cfg.header.id, CounterMode::BigEndian);
if (payload.isEmpty()) {
error = "failed to encrypt payload";
return false;
}
}
frame = encodeHeader(cfg.header);
frame.append(payload);
summary = QString("MESH TX|%1 key=%2 encrypt=%3 %4")
.arg(summarizeHeader(cfg.header))
.arg(cfg.keyLabel)
.arg(cfg.encrypt ? 1 : 0)
.arg(summarizeData(cfg.data));
return true;
}
bool Packet::decodeFrame(const QByteArray& frame, DecodeResult& result)
{
return decodeFrame(frame, result, QString());
}
bool Packet::decodeFrame(const QByteArray& frame, DecodeResult& result, const QString& keySpecList)
{
result = DecodeResult();
Header h;
if (!parseHeader(frame, h)) {
return false;
}
result.isFrame = true;
appendHeaderDecodeFields(h, result);
const QByteArray encryptedPayload = frame.mid(kHeaderLength);
addDecodeField(result, "decode.payload_encrypted_len", QString::number(encryptedPayload.size()));
// 1) Plain decode attempt first (unencrypted / key-none packets)
DataFields data;
if (parseData(encryptedPayload, data))
{
result.dataDecoded = true;
result.decrypted = false;
result.keyLabel = "none";
result.summary = QString("MESH RX|%1 key=none %2")
.arg(summarizeHeader(h))
.arg(summarizeData(data));
addDecodeField(result, "decode.path", QStringLiteral("plain"));
addDecodeField(result, "decode.key_label", result.keyLabel);
addDecodeField(result, "decode.decrypted", result.decrypted);
appendDataDecodeFields(data, result);
return true;
}
// 2) Try configured keys (hash-matched keys first)
std::vector<KeyEntry> keys;
if (!keySpecList.trimmed().isEmpty())
{
QString error;
int keyCount = 0;
if (!parseKeySpecList(keySpecList, keys, &error, &keyCount, true))
{
qWarning() << "Meshtastic::Packet::decodeFrame: invalid keySpecList:" << error;
keys = defaultKeysFromEnv();
}
else if (keyCount == 0)
{
keys = defaultKeysFromEnv();
}
}
else
{
keys = defaultKeysFromEnv();
}
std::stable_sort(keys.begin(), keys.end(), [h](const KeyEntry& a, const KeyEntry& b) {
const int as = (a.hasExpectedHash && a.expectedHash == h.channel) ? 1 : 0;
const int bs = (b.hasExpectedHash && b.expectedHash == h.channel) ? 1 : 0;
return as > bs;
});
std::set<QString> tested;
for (const KeyEntry& k : keys)
{
if (k.key.isEmpty()) {
continue;
}
const QString fingerprint = QString("%1:%2").arg(QString(k.key.toHex()), k.label);
if (tested.find(fingerprint) != tested.end()) {
continue;
}
tested.insert(fingerprint);
const QByteArray plainBe = aesCtrCrypt(encryptedPayload, k.key, h.from, h.id, CounterMode::BigEndian);
if (!plainBe.isEmpty() && parseData(plainBe, data))
{
result.dataDecoded = true;
result.decrypted = true;
result.keyLabel = k.label;
result.summary = QString("MESH RX|%1 key=%2 ctr=be %3")
.arg(summarizeHeader(h))
.arg(k.label)
.arg(summarizeData(data));
addDecodeField(result, "decode.path", QStringLiteral("aes_ctr_be"));
addDecodeField(result, "decode.key_label", result.keyLabel);
addDecodeField(result, "decode.decrypted", result.decrypted);
appendDataDecodeFields(data, result);
return true;
}
// Keep CTR mode strict to match Meshtastic reference decode path.
}
result.summary = QString("MESH RX|%1 undecoded payload_len=%2")
.arg(summarizeHeader(h))
.arg(encryptedPayload.size());
addDecodeField(result, "decode.path", QStringLiteral("undecoded"));
addDecodeField(result, "decode.key_label", QStringLiteral("none"));
addDecodeField(result, "decode.decrypted", false);
if (!encryptedPayload.isEmpty()) {
addDecodeField(result, "decode.payload_encrypted_hex", QString(encryptedPayload.toHex()));
}
return true;
}
bool Packet::validateKeySpecList(const QString& keySpecList, QString& error, int* keyCount)
{
std::vector<KeyEntry> keys;
if (!parseKeySpecList(keySpecList, keys, &error, keyCount, true)) {
return false;
}
if (keyCount && *keyCount == 0)
{
error = "no keys found";
return false;
}
return true;
}
bool Packet::deriveTxRadioSettings(const QString& command, TxRadioSettings& settings, QString& error)
{
CommandConfig cfg;
if (!parseCommand(command, cfg, error)) {
return false;
}
return deriveTxRadioSettingsFromConfig(cfg, settings, error);
}
} // namespace Meshtastic