diff --git a/sdrbench/CMakeLists.txt b/sdrbench/CMakeLists.txt index eb21d3eee..7e3eadca2 100644 --- a/sdrbench/CMakeLists.txt +++ b/sdrbench/CMakeLists.txt @@ -14,6 +14,7 @@ set(sdrbench_SOURCES test_ft8protocols.cpp test_fftrrc.cpp test_firrrc.cpp + test_meshtastic.cpp ) set(sdrbench_HEADERS @@ -31,6 +32,7 @@ include_directories( ${CMAKE_SOURCE_DIR}/exports ${CMAKE_SOURCE_DIR}/sdrbase ${CMAKE_SOURCE_DIR}/logging + ${CMAKE_SOURCE_DIR}/modemmeshtastic ${CMAKE_SOURCE_DIR} ) @@ -45,6 +47,7 @@ target_link_libraries(sdrbench Qt::Gui sdrbase logging + modemmeshtastic ${sdrbench_FT8_LIB} ) diff --git a/sdrbench/mainbench.cpp b/sdrbench/mainbench.cpp index a989d5344..d6f05e22a 100644 --- a/sdrbench/mainbench.cpp +++ b/sdrbench/mainbench.cpp @@ -79,6 +79,8 @@ void MainBench::run() testFFTRRCFilter(); } else if (m_parser.getTestType() == ParserBench::TestFIRRRCFilter) { testFIRRRCFilter(); + } else if (m_parser.getTestType() == ParserBench::TestMeshtastic) { + testMeshtastic(m_parser.getArgsStr()); } else { qDebug() << "MainBench::run: unknown test type: " << m_parser.getTestType(); } diff --git a/sdrbench/mainbench.h b/sdrbench/mainbench.h index 6d125bfaa..23ad5c588 100644 --- a/sdrbench/mainbench.h +++ b/sdrbench/mainbench.h @@ -59,6 +59,7 @@ private: void testGolay2312(); void testFFTRRCFilter(); void testFIRRRCFilter(); + void testMeshtastic(const QString& argsStr); void testFT4(const QString& wavFile, const QString& argsStr); //!< use with sdrbench/samples/ft4/20260304_180052.wav in -f option void testFT8(const QString& wavFile, const QString& argsStr); //!< use with sdrbench/samples/ft8/230105_091630.wav in -f option void testFT8Protocols(const QString& argsStr); diff --git a/sdrbench/parserbench.cpp b/sdrbench/parserbench.cpp index 378c80851..fdd1b1fcf 100644 --- a/sdrbench/parserbench.cpp +++ b/sdrbench/parserbench.cpp @@ -24,7 +24,7 @@ ParserBench::ParserBench() : m_testOption(QStringList() << "t" << "test", - "Test type: decimateii, decimatefi, decimateff, decimateif, decimateinfii, decimatesupii, ambe, golay2312, ft8, ft4, ft8protocols, callsign, fftrrcfilter, firrrcfilter.", + "Test type: decimateii, decimatefi, decimateff, decimateif, decimateinfii, decimatesupii, ambe, golay2312, ft8, ft4, ft8protocols, callsign, fftrrcfilter, firrrcfilter, meshtastic.", "test", "decimateii"), m_nbSamplesOption(QStringList() << "n" << "nb-samples", @@ -157,6 +157,8 @@ ParserBench::TestType ParserBench::getTestType() const return TestFFTRRCFilter; } else if (m_testStr == "firrrcfilter") { return TestFIRRRCFilter; + } else if (m_testStr == "meshtastic") { + return TestMeshtastic; } else { return TestDecimatorsII; } diff --git a/sdrbench/parserbench.h b/sdrbench/parserbench.h index af1a385ec..5bcc0472f 100644 --- a/sdrbench/parserbench.h +++ b/sdrbench/parserbench.h @@ -43,7 +43,8 @@ public: TestCallsign, TestFT8Protocols, TestFFTRRCFilter, - TestFIRRRCFilter + TestFIRRRCFilter, + TestMeshtastic } TestType; ParserBench(); diff --git a/sdrbench/test_meshtastic.cpp b/sdrbench/test_meshtastic.cpp new file mode 100644 index 000000000..bdf5c0e80 --- /dev/null +++ b/sdrbench/test_meshtastic.cpp @@ -0,0 +1,282 @@ +/////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) 2026 Edouard Griffiths, F4EXB // +// // +// 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 . // +/////////////////////////////////////////////////////////////////////////////////// + +#include +#include +#include + +#include "mainbench.h" +#include "modemmeshtastic/meshtasticpacket.h" + +namespace +{ + +using modemmeshtastic::DecodeResult; +using modemmeshtastic::Packet; + +static QMap toFieldMap(const DecodeResult& result) +{ + QMap map; + + for (const DecodeResult::Field& field : result.fields) + { + if (!map.contains(field.path)) { + map.insert(field.path, field.value); + } + } + + return map; +} + +static bool compareField( + const QMap& fields, + const QString& path, + const QString& expected, + QStringList& failures) +{ + if (!fields.contains(path)) + { + failures.append(QString("missing field '%1' (expected '%2')").arg(path, expected)); + return false; + } + + const QString value = fields.value(path); + + if (value != expected) + { + failures.append(QString("field '%1' mismatch: got '%2' expected '%3'").arg(path, value, expected)); + return false; + } + + return true; +} + +struct RegressionCase +{ + QString name; + QString command; + QString keySpecList; + bool expectDataDecoded = true; + bool expectDecrypted = false; + QMap expectedFields; +}; + +} // namespace + +void MainBench::testMeshtastic(const QString& argsStr) +{ + qInfo() << "MainBench::testMeshtastic: start args=" << argsStr; + + const QStringList selectedCases = argsStr.trimmed().isEmpty() + ? QStringList() + : argsStr.split(',', Qt::SkipEmptyParts); + + const QList cases = { + { + "plain_text", + "MESH:from=0x11223344;id=0x00000001;port=TEXT;encrypt=0;text=hello-mesh", + "", + true, + false, + { + {"decode.path", "plain"}, + {"data.port_name", "TEXT_MESSAGE_APP"}, + {"data.text", "hello-mesh"} + } + }, + { + "encrypted_text", + "MESH:from=0x11223344;id=0x00000002;port=TEXT;encrypt=1;key=default;text=secret-mesh", + "LongFast=default", + true, + true, + { + {"decode.path", "aes_ctr_be"}, + {"data.port_name", "TEXT_MESSAGE_APP"}, + {"data.text", "secret-mesh"} + } + }, + { + "position_fixed32", + "MESH:from=0x11223344;id=0x00000003;port=POSITION_APP;encrypt=0;payload_hex=0df0ec1e1d15d0ea6601187b", + "", + true, + false, + { + {"data.port_name", "POSITION_APP"}, + {"position.latitude", "48.8566000"}, + {"position.longitude", "2.3522000"}, + {"position.altitude_m", "123"} + } + }, + { + "nodeinfo_user", + "MESH:from=0x11223344;id=0x00000004;port=NODEINFO_APP;encrypt=0;payload_hex=0a0921313233346162636412064e6f646520411a024e41", + "", + true, + false, + { + {"data.port_name", "NODEINFO_APP"}, + {"nodeinfo.id", "!1234abcd"}, + {"nodeinfo.long_name", "Node A"}, + {"nodeinfo.short_name", "NA"} + } + }, + { + "telemetry_wrapped", + "MESH:from=0x11223344;id=0x00000005;port=TELEMETRY_APP;encrypt=0;payload_hex=1208083710f41c28901c", + "", + true, + false, + { + {"data.port_name", "TELEMETRY_APP"}, + {"telemetry.device.battery_level_pct", "55.0"}, + {"telemetry.device.voltage_v", "3.700"}, + {"telemetry.device.uptime_s", "3600"} + } + }, + { + "telemetry_direct_metrics", + "MESH:from=0x11223344;id=0x00000006;port=TELEMETRY_APP;encrypt=0;payload_hex=083710f41c28901c", + "", + true, + false, + { + {"data.port_name", "TELEMETRY_APP"}, + {"telemetry.device.battery_level_pct", "55.0"}, + {"telemetry.device.voltage_v", "3.700"}, + {"telemetry.device.uptime_s", "3600"}, + {"telemetry.decode_mode", "direct_metrics_payload"} + } + }, + { + "traceroute_simple", + "MESH:from=0x11223344;id=0x00000007;port=TRACEROUTE_APP;encrypt=0;payload_hex=0d040302011018", + "", + true, + false, + { + {"data.port_name", "TRACEROUTE_APP"}, + {"traceroute.forward_hops", "1"}, + {"traceroute.route[0].node_id", "!01020304"}, + {"traceroute.route[0].snr_towards_db", "3.00"} + } + }, + { + "telemetry_malformed_fallback", + "MESH:from=0x11223344;id=0x00000008;port=TELEMETRY_APP;encrypt=0;payload_hex=ff", + "", + true, + false, + { + {"data.port_name", "TELEMETRY_APP"}, + {"data.payload_hex", "ff"} + } + }, + { + "wrong_key_undecoded", + "MESH:from=0x11223344;id=0x00000009;port=TEXT;encrypt=1;key=default;text=wrong-key", + "LongFast=simple2", + false, + false, + { + {"decode.path", "undecoded"}, + {"decode.decrypted", "false"} + } + } + }; + + int selectedCount = 0; + int failedCount = 0; + + for (const RegressionCase& regressionCase : cases) + { + if (!selectedCases.isEmpty() && !selectedCases.contains(regressionCase.name)) { + continue; + } + + selectedCount++; + QStringList failures; + + QByteArray frame; + QString txSummary; + QString txError; + + if (!Packet::buildFrameFromCommand(regressionCase.command, frame, txSummary, txError)) + { + failures.append(QString("buildFrameFromCommand failed: %1").arg(txError)); + } + else + { + DecodeResult result; + const bool decodeOk = regressionCase.keySpecList.isEmpty() + ? Packet::decodeFrame(frame, result) + : Packet::decodeFrame(frame, result, regressionCase.keySpecList); + + if (!decodeOk) + { + failures.append("decodeFrame returned false"); + } + else + { + if (result.dataDecoded != regressionCase.expectDataDecoded) + { + failures.append(QString("dataDecoded mismatch: got %1 expected %2") + .arg(result.dataDecoded ? "true" : "false", + regressionCase.expectDataDecoded ? "true" : "false")); + } + + if (result.decrypted != regressionCase.expectDecrypted) + { + failures.append(QString("decrypted mismatch: got %1 expected %2") + .arg(result.decrypted ? "true" : "false", + regressionCase.expectDecrypted ? "true" : "false")); + } + + const QMap fields = toFieldMap(result); + + for (auto it = regressionCase.expectedFields.constBegin(); it != regressionCase.expectedFields.constEnd(); ++it) { + compareField(fields, it.key(), it.value(), failures); + } + } + } + + if (!failures.isEmpty()) + { + failedCount++; + qWarning().noquote() << QString("MainBench::testMeshtastic: case '%1' FAILED").arg(regressionCase.name); + for (const QString& failure : failures) { + qWarning().noquote() << QString(" - %1").arg(failure); + } + } + else + { + qInfo().noquote() << QString("MainBench::testMeshtastic: case '%1' PASSED").arg(regressionCase.name); + } + } + + if (selectedCount == 0) + { + qWarning() << "MainBench::testMeshtastic: no matching cases selected"; + return; + } + + qInfo() << "MainBench::testMeshtastic: completed" + << "selected=" << selectedCount + << "failed=" << failedCount + << "passed=" << (selectedCount - failedCount); +}