2026-03-05 16:52:12 +01:00
|
|
|
|
///////////////////////////////////////////////////////////////////////////////////
|
2026-03-15 23:14:08 +01:00
|
|
|
|
// Copyright (C) 2026 Alejandro Aleman //
|
2026-03-15 21:40:33 +01:00
|
|
|
|
// 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/>. //
|
2026-03-05 16:52:12 +01:00
|
|
|
|
///////////////////////////////////////////////////////////////////////////////////
|
|
|
|
|
|
|
|
|
|
|
|
#include "meshtasticpacket.h"
|
|
|
|
|
|
|
|
|
|
|
|
#include <QByteArrayList>
|
2026-03-21 22:54:30 +01:00
|
|
|
|
#include <QDateTime>
|
2026-03-05 16:52:12 +01:00
|
|
|
|
#include <QDebug>
|
|
|
|
|
|
#include <QMap>
|
|
|
|
|
|
#include <QProcessEnvironment>
|
|
|
|
|
|
#include <QRandomGenerator>
|
|
|
|
|
|
#include <QStringList>
|
|
|
|
|
|
|
|
|
|
|
|
#include <algorithm>
|
|
|
|
|
|
#include <array>
|
|
|
|
|
|
#include <cmath>
|
|
|
|
|
|
#include <cstring>
|
|
|
|
|
|
#include <set>
|
|
|
|
|
|
#include <vector>
|
|
|
|
|
|
|
2026-03-15 21:40:33 +01:00
|
|
|
|
namespace modemmeshtastic
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
const std::array<uint8_t, 16> kDefaultChannelKey = {
|
2026-03-05 16:52:12 +01:00
|
|
|
|
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).
|
2026-03-27 21:17:46 +01:00
|
|
|
|
QMap<QString, uint32_t> makePortMap()
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
const QMap<QString, uint32_t> kPortMap = makePortMap();
|
2026-03-05 16:52:12 +01:00
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
const std::array<RegionBand, 26> kRegionBands = {{
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{"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}
|
2026-03-27 21:17:46 +01:00
|
|
|
|
}};
|
2026-03-05 16:52:12 +01:00
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
const int kRegionBandsCount = static_cast<int>(kRegionBands.size());
|
2026-03-05 16:52:12 +01:00
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
QString trimQuotes(const QString& s)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
QString out = s.trimmed();
|
|
|
|
|
|
if ((out.startsWith('"') && out.endsWith('"')) || (out.startsWith('\'') && out.endsWith('\''))) {
|
|
|
|
|
|
out = out.mid(1, out.size() - 2);
|
|
|
|
|
|
}
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
bool parseBool(const QString& s, bool& out)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
bool parseUInt(const QString& s, uint64_t& out)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
bool parseDouble(const QString& s, double& out)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
QString v = s.trimmed();
|
|
|
|
|
|
v.remove('_');
|
|
|
|
|
|
bool ok = false;
|
|
|
|
|
|
out = v.toDouble(&ok);
|
|
|
|
|
|
return ok;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
QString normalizeToken(const QString& value)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
QString v = value.trimmed().toUpper();
|
|
|
|
|
|
v.replace('-', '_');
|
|
|
|
|
|
v.replace(' ', '_');
|
|
|
|
|
|
return v;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
QByteArray normalizeHex(const QString& s)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
bool parseHexBytes(const QString& s, QByteArray& out)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
QByteArray hex = normalizeHex(s);
|
|
|
|
|
|
|
|
|
|
|
|
if (hex.isEmpty() || (hex.size() % 2) != 0) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
out = QByteArray::fromHex(hex);
|
|
|
|
|
|
return !out.isEmpty();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
QByteArray expandSimpleKey(unsigned int simple)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
if (simple == 0) {
|
|
|
|
|
|
return QByteArray();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (simple > 10) {
|
|
|
|
|
|
return QByteArray();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
QByteArray key(reinterpret_cast<const char*>(kDefaultChannelKey.data()), static_cast<int>(kDefaultChannelKey.size()));
|
2026-03-05 16:52:12 +01:00
|
|
|
|
|
|
|
|
|
|
if (simple > 1) {
|
2026-03-27 21:17:46 +01:00
|
|
|
|
const auto offset = static_cast<int>(simple - 1);
|
2026-03-05 16:52:12 +01:00
|
|
|
|
key[15] = static_cast<char>(static_cast<uint8_t>(kDefaultChannelKey[15] + offset));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return key;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
bool parseKeySpec(const QString& rawSpec, QByteArray& key, QString& label)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
uint32_t readU32LE(const char* p)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
void writeU32LE(char* p, uint32_t v)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
bool parseHeader(const QByteArray& frame, Header& h)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
QByteArray encodeHeader(const Header& h)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
bool readVarint(const QByteArray& bytes, int& pos, uint64_t& value)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
value = 0;
|
|
|
|
|
|
int shift = 0;
|
|
|
|
|
|
|
|
|
|
|
|
while (pos < bytes.size() && shift <= 63)
|
|
|
|
|
|
{
|
2026-03-27 21:17:46 +01:00
|
|
|
|
const auto b = static_cast<uint8_t>(bytes[pos++]);
|
2026-03-05 16:52:12 +01:00
|
|
|
|
value |= static_cast<uint64_t>(b & 0x7F) << shift;
|
|
|
|
|
|
|
|
|
|
|
|
if ((b & 0x80) == 0) {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
shift += 7;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
bool readFixed32(const QByteArray& bytes, int& pos, uint32_t& value)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
if ((pos + 4) > bytes.size()) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
value = readU32LE(bytes.constData() + pos);
|
|
|
|
|
|
pos += 4;
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
bool readFixed64(const QByteArray& bytes, int& pos, uint64_t& value)
|
2026-03-21 22:54:30 +01:00
|
|
|
|
{
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
bool skipField(const QByteArray& bytes, int& pos, uint32_t wireType)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
bool parseData(const QByteArray& bytes, DataFields& d)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
int pos = 0;
|
|
|
|
|
|
|
|
|
|
|
|
while (pos < bytes.size())
|
|
|
|
|
|
{
|
|
|
|
|
|
uint64_t rawTag = 0;
|
|
|
|
|
|
|
|
|
|
|
|
if (!readVarint(bytes, pos, rawTag)) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
const auto field = static_cast<uint32_t>(rawTag >> 3);
|
|
|
|
|
|
const auto wire = static_cast<uint32_t>(rawTag & 0x7);
|
2026-03-05 16:52:12 +01:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
void writeVarint(QByteArray& out, uint64_t value)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
while (true)
|
|
|
|
|
|
{
|
2026-03-27 21:17:46 +01:00
|
|
|
|
auto b = static_cast<uint8_t>(value & 0x7F);
|
2026-03-05 16:52:12 +01:00
|
|
|
|
value >>= 7;
|
|
|
|
|
|
|
|
|
|
|
|
if (value != 0) {
|
|
|
|
|
|
b |= 0x80;
|
|
|
|
|
|
out.append(static_cast<char>(b));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
out.append(static_cast<char>(b));
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
void writeTag(QByteArray& out, uint32_t field, uint32_t wire)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
const uint64_t tag = (static_cast<uint64_t>(field) << 3) | static_cast<uint64_t>(wire);
|
|
|
|
|
|
writeVarint(out, tag);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
void writeFixed32(QByteArray& out, uint32_t v)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
char b[4];
|
|
|
|
|
|
writeU32LE(b, v);
|
|
|
|
|
|
out.append(b, 4);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
QByteArray encodeData(const DataFields& d)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
uint8_t xorHash(const QByteArray& bytes)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
uint8_t h = 0;
|
|
|
|
|
|
|
|
|
|
|
|
for (char c : bytes) {
|
|
|
|
|
|
h ^= static_cast<uint8_t>(c);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return h;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
uint8_t generateChannelHash(const QString& channelName, const QByteArray& key)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
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
|
|
|
|
|
|
{
|
2026-03-27 21:17:46 +01:00
|
|
|
|
std::array<uint8_t, 16> state;
|
|
|
|
|
|
memcpy(state.data(), in, 16);
|
2026-03-05 16:52:12 +01:00
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
addRoundKey(state.data(), 0);
|
2026-03-05 16:52:12 +01:00
|
|
|
|
|
|
|
|
|
|
for (int round = 1; round < m_nr; ++round)
|
|
|
|
|
|
{
|
2026-03-27 21:17:46 +01:00
|
|
|
|
subBytes(state.data());
|
|
|
|
|
|
shiftRows(state.data());
|
|
|
|
|
|
mixColumns(state.data());
|
|
|
|
|
|
addRoundKey(state.data(), round);
|
2026-03-05 16:52:12 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
subBytes(state.data());
|
|
|
|
|
|
shiftRows(state.data());
|
|
|
|
|
|
addRoundKey(state.data(), m_nr);
|
2026-03-05 16:52:12 +01:00
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
memcpy(out, state.data(), 16);
|
2026-03-05 16:52:12 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
{
|
2026-03-27 21:17:46 +01:00
|
|
|
|
static const std::array<uint8_t, 256> sbox = {
|
2026-03-05 16:52:12 +01:00
|
|
|
|
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)
|
|
|
|
|
|
{
|
2026-03-27 21:17:46 +01:00
|
|
|
|
static const std::array<uint8_t, 11> rcon = {0x00,0x01,0x02,0x04,0x08,0x10,0x20,0x40,0x80,0x1B,0x36};
|
2026-03-05 16:52:12 +01:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
void incrementCounter4(uint8_t counter[16], CounterMode mode)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
void initNonce(uint8_t nonce[16], uint32_t fromNode, uint32_t packetId)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
memset(nonce, 0, 16);
|
|
|
|
|
|
|
|
|
|
|
|
const uint64_t packetId64 = packetId;
|
|
|
|
|
|
memcpy(nonce, &packetId64, sizeof(packetId64));
|
|
|
|
|
|
memcpy(nonce + sizeof(packetId64), &fromNode, sizeof(fromNode));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
QByteArray aesCtrCrypt(const QByteArray& in, const QByteArray& key, uint32_t fromNode, uint32_t packetId, CounterMode mode)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
QByteArray out(in);
|
|
|
|
|
|
|
|
|
|
|
|
if (key.isEmpty()) {
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
AesCtx aes;
|
|
|
|
|
|
|
|
|
|
|
|
if (!aes.init(key)) {
|
|
|
|
|
|
return QByteArray();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
std::array<uint8_t, 16> counter;
|
|
|
|
|
|
initNonce(counter.data(), fromNode, packetId);
|
2026-03-05 16:52:12 +01:00
|
|
|
|
|
|
|
|
|
|
int pos = 0;
|
|
|
|
|
|
while (pos < out.size())
|
|
|
|
|
|
{
|
2026-03-27 21:17:46 +01:00
|
|
|
|
std::array<uint8_t, 16> keystream;
|
|
|
|
|
|
aes.encryptBlock(counter.data(), keystream.data());
|
2026-03-05 16:52:12 +01:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-03-27 21:17:46 +01:00
|
|
|
|
incrementCounter4(counter.data(), mode);
|
2026-03-05 16:52:12 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
QString formatNode(uint32_t node)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
return QString("0x%1").arg(node, 8, 16, QChar('0'));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
QString payloadToText(const QByteArray& payload)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
QString s = QString::fromUtf8(payload);
|
|
|
|
|
|
|
|
|
|
|
|
if (s.isEmpty() && !payload.isEmpty()) {
|
|
|
|
|
|
return QString();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return s;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
QString portToName(uint32_t p)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
bool addKeyEntry(
|
2026-03-05 16:52:12 +01:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
bool parseKeyListEntry(
|
2026-03-05 16:52:12 +01:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
bool parseKeySpecList(
|
2026-03-05 16:52:12 +01:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
std::vector<KeyEntry> defaultKeysFromEnv()
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
bool parsePortValue(const QString& raw, uint32_t& port)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
bool parsePresetName(const QString& presetValue, QString& presetName)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
bool presetToChannelName(const QString& presetName, QString& channelName)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
bool presetToParams(const QString& presetName, bool wideLora, int& bandwidthHz, int& spreadFactor, int& parityBits)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
QString presetToDisplayName(const QString& presetName)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
QString channelName;
|
|
|
|
|
|
if (presetToChannelName(presetName, channelName)) {
|
|
|
|
|
|
return channelName;
|
|
|
|
|
|
}
|
|
|
|
|
|
return QString("LongFast");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
uint32_t meshHashDjb2(const QString& s)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
const QByteArray bytes = s.toUtf8();
|
|
|
|
|
|
uint32_t h = 5381u;
|
|
|
|
|
|
for (char c : bytes) {
|
|
|
|
|
|
h = ((h << 5) + h) + static_cast<uint8_t>(c);
|
|
|
|
|
|
}
|
|
|
|
|
|
return h;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
const RegionBand* findRegionBand(const QString& regionValue)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
bool parseCommand(const QString& command, CommandConfig& cfg, QString& error)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
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")
|
|
|
|
|
|
{
|
2026-03-27 21:17:46 +01:00
|
|
|
|
if (!parseUInt(value, u) || u < 1000000ULL) {
|
2026-03-05 16:52:12 +01:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-21 22:54:30 +01:00
|
|
|
|
// Forward declaration: summarizePortPayload is defined after the per-port parsers below.
|
2026-03-27 21:17:46 +01:00
|
|
|
|
QString summarizePortPayload(const DataFields& d);
|
2026-03-21 22:54:30 +01:00
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
QString summarizeHeader(const Header& h)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
QString summarizeData(const DataFields& d)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-21 22:54:30 +01:00
|
|
|
|
s += summarizePortPayload(d);
|
2026-03-05 16:52:12 +01:00
|
|
|
|
|
|
|
|
|
|
return s;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
void addDecodeField(DecodeResult& result, const QString& path, const QString& value)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
DecodeResult::Field f;
|
|
|
|
|
|
f.path = path;
|
|
|
|
|
|
f.value = value;
|
|
|
|
|
|
result.fields.append(f);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
void addDecodeField(DecodeResult& result, const QString& path, uint32_t value)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
addDecodeField(result, path, QString::number(value));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
void addDecodeField(DecodeResult& result, const QString& path, bool value)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
addDecodeField(result, path, QString(value ? "true" : "false"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
void appendHeaderDecodeFields(const Header& h, DecodeResult& result)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-21 22:54:30 +01:00
|
|
|
|
// =============================================================================
|
|
|
|
|
|
// 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. */
|
2026-03-27 21:17:46 +01:00
|
|
|
|
int32_t zigzagDecode32(uint64_t n)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
2026-03-21 22:54:30 +01:00
|
|
|
|
return static_cast<int32_t>((n >> 1) ^ static_cast<uint64_t>(-(static_cast<int64_t>(n & 1))));
|
|
|
|
|
|
}
|
2026-03-05 16:52:12 +01:00
|
|
|
|
|
2026-03-21 22:54:30 +01:00
|
|
|
|
/** Reinterpret the 4 bytes of a protobuf fixed32 field as an IEEE 754 float. */
|
2026-03-27 21:17:46 +01:00
|
|
|
|
float fixed32ToFloat(uint32_t v)
|
2026-03-21 22:54:30 +01:00
|
|
|
|
{
|
|
|
|
|
|
float f;
|
|
|
|
|
|
memcpy(&f, &v, 4);
|
|
|
|
|
|
return f;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
double fixed64ToDouble(uint64_t v)
|
2026-03-21 22:54:30 +01:00
|
|
|
|
{
|
|
|
|
|
|
double d;
|
|
|
|
|
|
memcpy(&d, &v, 8);
|
|
|
|
|
|
return d;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
bool decodeCoordinateFromFixed32(uint32_t raw, double& coordinate)
|
2026-03-21 22:54:30 +01:00
|
|
|
|
{
|
2026-03-27 21:17:46 +01:00
|
|
|
|
const auto fixedValue = static_cast<int32_t>(raw);
|
2026-03-21 22:54:30 +01:00
|
|
|
|
const double scaled = static_cast<double>(fixedValue) / 1e7;
|
|
|
|
|
|
|
|
|
|
|
|
if (std::isfinite(scaled) && std::fabs(scaled) <= 180.0)
|
|
|
|
|
|
{
|
|
|
|
|
|
coordinate = scaled;
|
|
|
|
|
|
return true;
|
2026-03-05 16:52:12 +01:00
|
|
|
|
}
|
2026-03-21 22:54:30 +01:00
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
const auto floatValue = static_cast<double>(fixed32ToFloat(raw));
|
2026-03-21 22:54:30 +01:00
|
|
|
|
if (std::isfinite(floatValue) && std::fabs(floatValue) <= 180.0)
|
|
|
|
|
|
{
|
|
|
|
|
|
coordinate = floatValue;
|
|
|
|
|
|
return true;
|
2026-03-05 16:52:12 +01:00
|
|
|
|
}
|
2026-03-21 22:54:30 +01:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
bool parsePositionPayload(const QByteArray& bytes, PositionFields& p)
|
2026-03-21 22:54:30 +01:00
|
|
|
|
{
|
|
|
|
|
|
int pos = 0;
|
|
|
|
|
|
|
|
|
|
|
|
while (pos < bytes.size())
|
|
|
|
|
|
{
|
|
|
|
|
|
uint64_t rawTag = 0;
|
|
|
|
|
|
if (!readVarint(bytes, pos, rawTag)) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
const auto field = static_cast<uint32_t>(rawTag >> 3);
|
|
|
|
|
|
const auto wire = static_cast<uint32_t>(rawTag & 0x7);
|
2026-03-21 22:54:30 +01:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-05 16:52:12 +01:00
|
|
|
|
}
|
2026-03-21 22:54:30 +01:00
|
|
|
|
|
|
|
|
|
|
if (!p.hasLatitude && p.hasLatitudeI) {
|
|
|
|
|
|
p.latitude = p.latitudeI / 1e7;
|
|
|
|
|
|
p.hasLatitude = true;
|
2026-03-05 16:52:12 +01:00
|
|
|
|
}
|
2026-03-21 22:54:30 +01:00
|
|
|
|
if (!p.hasLongitude && p.hasLongitudeI) {
|
|
|
|
|
|
p.longitude = p.longitudeI / 1e7;
|
|
|
|
|
|
p.hasLongitude = true;
|
2026-03-05 16:52:12 +01:00
|
|
|
|
}
|
2026-03-21 22:54:30 +01:00
|
|
|
|
|
|
|
|
|
|
return p.hasLatitude || p.hasLongitude || p.hasAltitude || p.hasTime || p.hasTimestamp;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
void appendPositionDecodeFields(const PositionFields& p, DecodeResult& result)
|
2026-03-21 22:54:30 +01:00
|
|
|
|
{
|
|
|
|
|
|
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));
|
2026-03-05 16:52:12 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-21 22:54:30 +01:00
|
|
|
|
// 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));
|
|
|
|
|
|
}
|
2026-03-05 16:52:12 +01:00
|
|
|
|
|
2026-03-21 22:54:30 +01:00
|
|
|
|
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)
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
bool parseUserPayload(const QByteArray& bytes, UserFields& u)
|
2026-03-21 22:54:30 +01:00
|
|
|
|
{
|
|
|
|
|
|
int pos = 0;
|
|
|
|
|
|
|
|
|
|
|
|
while (pos < bytes.size())
|
|
|
|
|
|
{
|
|
|
|
|
|
uint64_t rawTag = 0;
|
|
|
|
|
|
if (!readVarint(bytes, pos, rawTag)) { return false; }
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
const auto field = static_cast<uint32_t>(rawTag >> 3);
|
|
|
|
|
|
const auto wire = static_cast<uint32_t>(rawTag & 0x7);
|
2026-03-21 22:54:30 +01:00
|
|
|
|
|
|
|
|
|
|
switch (field)
|
|
|
|
|
|
{
|
|
|
|
|
|
case 1: { // id: string
|
2026-03-22 23:09:06 +01:00
|
|
|
|
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; }
|
|
|
|
|
|
u.id = QString::fromUtf8(bytes.constData() + pos, static_cast<int>(len));
|
|
|
|
|
|
u.hasId = true;
|
|
|
|
|
|
pos += static_cast<int>(len);
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!skipField(bytes, pos, wire)) { return false; }
|
|
|
|
|
|
}
|
2026-03-21 22:54:30 +01:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case 2: { // long_name: string
|
2026-03-22 23:09:06 +01:00
|
|
|
|
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; }
|
|
|
|
|
|
u.longName = QString::fromUtf8(bytes.constData() + pos, static_cast<int>(len));
|
|
|
|
|
|
u.hasLongName = true;
|
|
|
|
|
|
pos += static_cast<int>(len);
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!skipField(bytes, pos, wire)) { return false; }
|
|
|
|
|
|
}
|
2026-03-21 22:54:30 +01:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case 3: { // short_name: string
|
2026-03-22 23:09:06 +01:00
|
|
|
|
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; }
|
|
|
|
|
|
u.shortName = QString::fromUtf8(bytes.constData() + pos, static_cast<int>(len));
|
|
|
|
|
|
u.hasShortName = true;
|
|
|
|
|
|
pos += static_cast<int>(len);
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!skipField(bytes, pos, wire)) { return false; }
|
|
|
|
|
|
}
|
2026-03-21 22:54:30 +01:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case 4: { // macaddr: bytes
|
2026-03-22 23:09:06 +01:00
|
|
|
|
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; }
|
|
|
|
|
|
u.macaddr = bytes.mid(pos, static_cast<int>(len));
|
|
|
|
|
|
u.hasMacaddr = true;
|
|
|
|
|
|
pos += static_cast<int>(len);
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!skipField(bytes, pos, wire)) { return false; }
|
|
|
|
|
|
}
|
2026-03-21 22:54:30 +01:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case 5: { // hw_model: HardwareModel (enum → uint32)
|
2026-03-22 23:09:06 +01:00
|
|
|
|
if (wire == 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
uint64_t v = 0;
|
|
|
|
|
|
if (!readVarint(bytes, pos, v)) { return false; }
|
|
|
|
|
|
u.hwModel = static_cast<uint32_t>(v);
|
|
|
|
|
|
u.hasHwModel = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!skipField(bytes, pos, wire)) { return false; }
|
|
|
|
|
|
}
|
2026-03-21 22:54:30 +01:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-03-22 23:09:06 +01:00
|
|
|
|
case 6: // is_licensed: bool (current Meshtastic)
|
|
|
|
|
|
case 7: { // is_licensed: bool (legacy compatibility)
|
|
|
|
|
|
if (wire == 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
uint64_t v = 0;
|
|
|
|
|
|
if (!readVarint(bytes, pos, v)) { return false; }
|
|
|
|
|
|
u.isLicensed = (v != 0);
|
|
|
|
|
|
u.hasIsLicensed = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!skipField(bytes, pos, wire)) { return false; }
|
|
|
|
|
|
}
|
2026-03-21 22:54:30 +01:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
default:
|
|
|
|
|
|
if (!skipField(bytes, pos, wire)) { return false; }
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 23:09:06 +01:00
|
|
|
|
return u.hasId || u.hasLongName || u.hasShortName || u.hasMacaddr || u.hasHwModel || u.hasIsLicensed;
|
2026-03-21 22:54:30 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
void appendUserDecodeFields(const UserFields& u, DecodeResult& result)
|
2026-03-21 22:54:30 +01:00
|
|
|
|
{
|
|
|
|
|
|
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; // 0–100 %
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
bool parseDeviceMetrics(const QByteArray& bytes, DeviceMetrics& dm)
|
2026-03-21 22:54:30 +01:00
|
|
|
|
{
|
|
|
|
|
|
int pos = 0;
|
|
|
|
|
|
|
|
|
|
|
|
while (pos < bytes.size())
|
|
|
|
|
|
{
|
|
|
|
|
|
uint64_t rawTag = 0;
|
|
|
|
|
|
if (!readVarint(bytes, pos, rawTag)) { return false; }
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
const auto field = static_cast<uint32_t>(rawTag >> 3);
|
|
|
|
|
|
const auto wire = static_cast<uint32_t>(rawTag & 0x7);
|
2026-03-21 22:54:30 +01:00
|
|
|
|
|
|
|
|
|
|
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; }
|
2026-03-27 21:17:46 +01:00
|
|
|
|
const auto raw = static_cast<double>(v);
|
2026-03-21 22:54:30 +01:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
bool parseEnvironmentMetrics(const QByteArray& bytes, EnvironmentMetrics& em)
|
2026-03-21 22:54:30 +01:00
|
|
|
|
{
|
|
|
|
|
|
int pos = 0;
|
|
|
|
|
|
|
|
|
|
|
|
while (pos < bytes.size())
|
|
|
|
|
|
{
|
|
|
|
|
|
uint64_t rawTag = 0;
|
|
|
|
|
|
if (!readVarint(bytes, pos, rawTag)) { return false; }
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
const auto field = static_cast<uint32_t>(rawTag >> 3);
|
|
|
|
|
|
const auto wire = static_cast<uint32_t>(rawTag & 0x7);
|
2026-03-21 22:54:30 +01:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
bool parseTelemetryPayload(const QByteArray& bytes, TelemetryFields& t)
|
2026-03-21 22:54:30 +01:00
|
|
|
|
{
|
|
|
|
|
|
int pos = 0;
|
|
|
|
|
|
|
|
|
|
|
|
while (pos < bytes.size())
|
|
|
|
|
|
{
|
|
|
|
|
|
uint64_t rawTag = 0;
|
|
|
|
|
|
if (!readVarint(bytes, pos, rawTag)) { return false; }
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
const auto field = static_cast<uint32_t>(rawTag >> 3);
|
|
|
|
|
|
const auto wire = static_cast<uint32_t>(rawTag & 0x7);
|
2026-03-21 22:54:30 +01:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
void appendTelemetryDecodeFields(const TelemetryFields& t, DecodeResult& result)
|
2026-03-21 22:54:30 +01:00
|
|
|
|
{
|
|
|
|
|
|
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). */
|
2026-03-27 21:17:46 +01:00
|
|
|
|
void parsePackedFixed32(const QByteArray& bytes, QVector<uint32_t>& out)
|
2026-03-21 22:54:30 +01:00
|
|
|
|
{
|
|
|
|
|
|
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). */
|
2026-03-27 21:17:46 +01:00
|
|
|
|
void parsePackedVarintSint32(const QByteArray& bytes, QVector<int32_t>& out)
|
2026-03-21 22:54:30 +01:00
|
|
|
|
{
|
|
|
|
|
|
int pos = 0;
|
|
|
|
|
|
while (pos < bytes.size())
|
|
|
|
|
|
{
|
|
|
|
|
|
uint64_t v = 0;
|
|
|
|
|
|
if (!readVarint(bytes, pos, v)) { break; }
|
|
|
|
|
|
out.append(zigzagDecode32(v));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
bool parseRouteDiscoveryPayload(const QByteArray& bytes, RouteDiscoveryFields& r)
|
2026-03-21 22:54:30 +01:00
|
|
|
|
{
|
|
|
|
|
|
int pos = 0;
|
|
|
|
|
|
|
|
|
|
|
|
while (pos < bytes.size())
|
|
|
|
|
|
{
|
|
|
|
|
|
uint64_t rawTag = 0;
|
|
|
|
|
|
if (!readVarint(bytes, pos, rawTag)) { return false; }
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
const auto field = static_cast<uint32_t>(rawTag >> 3);
|
|
|
|
|
|
const auto wire = static_cast<uint32_t>(rawTag & 0x7);
|
2026-03-21 22:54:30 +01:00
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
void appendRouteDiscoveryDecodeFields(const RouteDiscoveryFields& r, DecodeResult& result)
|
2026-03-21 22:54:30 +01:00
|
|
|
|
{
|
|
|
|
|
|
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));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
QString summarizePortPayload(const DataFields& d)
|
2026-03-21 22:54:30 +01:00
|
|
|
|
{
|
|
|
|
|
|
if (d.payload.isEmpty()) {
|
|
|
|
|
|
return " payload=<empty>";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
const auto appendPayloadHex = [&d]() {
|
2026-03-21 22:54:30 +01:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-03-22 23:09:06 +01:00
|
|
|
|
if (u.hasHwModel) {
|
|
|
|
|
|
out += QString(" hw=%1").arg(u.hwModel);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (u.hasIsLicensed) {
|
|
|
|
|
|
out += QString(" licensed=%1").arg(u.isLicensed ? 1 : 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (u.hasMacaddr) {
|
|
|
|
|
|
out += QString(" mac=%1").arg(QString(u.macaddr.left(8).toHex()));
|
|
|
|
|
|
if (u.macaddr.size() > 8) {
|
|
|
|
|
|
out += "...";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-21 22:54:30 +01:00
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
void appendDataDecodeFields(const DataFields& d, DecodeResult& result)
|
2026-03-21 22:54:30 +01:00
|
|
|
|
{
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-05 16:52:12 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-27 21:17:46 +01:00
|
|
|
|
bool deriveTxRadioSettingsFromConfig(const CommandConfig& cfg, TxRadioSettings& settings, QString& error)
|
2026-03-05 16:52:12 +01:00
|
|
|
|
{
|
|
|
|
|
|
settings = TxRadioSettings();
|
|
|
|
|
|
settings.hasCommand = true;
|
2026-03-21 17:51:26 +01:00
|
|
|
|
settings.syncWord = 0x2B;
|
2026-03-05 16:52:12 +01:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-03-27 21:17:46 +01:00
|
|
|
|
settings.centerFrequencyHz = std::llround(freqMHz * 1000000.0);
|
2026-03-05 16:52:12 +01:00
|
|
|
|
}
|
|
|
|
|
|
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;
|
2026-03-27 21:17:46 +01:00
|
|
|
|
const auto numChannels = static_cast<uint32_t>(std::floor(spanMHz / slotWidthMHz));
|
2026-03-05 16:52:12 +01:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-03-27 21:17:46 +01:00
|
|
|
|
settings.centerFrequencyHz = std::llround(centerMHz * 1000000.0);
|
2026-03-05 16:52:12 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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));
|
2026-03-21 22:54:30 +01:00
|
|
|
|
addDecodeField(result, "decode.path", QStringLiteral("plain"));
|
2026-03-05 16:52:12 +01:00
|
|
|
|
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));
|
2026-03-21 22:54:30 +01:00
|
|
|
|
addDecodeField(result, "decode.path", QStringLiteral("aes_ctr_be"));
|
2026-03-05 16:52:12 +01:00
|
|
|
|
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());
|
2026-03-21 22:54:30 +01:00
|
|
|
|
addDecodeField(result, "decode.path", QStringLiteral("undecoded"));
|
|
|
|
|
|
addDecodeField(result, "decode.key_label", QStringLiteral("none"));
|
2026-03-05 16:52:12 +01:00
|
|
|
|
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
|