M3 step 2: Meshtastic header and presets, pcap with LoRaTap (host-tested)

The 16-byte clear header (hops away, channel hash, relay node), the EU_868
presets and their frequency slots, and the channel hash, all checked
against Meshtastic's source. Captures are pcap with LoRaTap v0, read back
with TShark 4.2.5; packet RSSI is plain dBm, as Wireshark reads it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-05 18:59:23 +02:00
co-authored by Claude Opus 5.5
parent fed065a6f9
commit 3242475699
9 changed files with 457 additions and 1 deletions
+93
View File
@@ -0,0 +1,93 @@
#include <unity.h>
#include <vector>
#include "loratap.h"
using namespace roro::lora;
void setUp() {}
void tearDown() {}
static uint32_t le32(const std::vector<uint8_t>& b, size_t at) {
return b[at] | b[at + 1] << 8 | b[at + 2] << 16 | (uint32_t)b[at + 3] << 24;
}
void test_pcap_global_header() {
std::vector<uint8_t> out;
appendPcapHeader(out);
TEST_ASSERT_EQUAL_size_t(24, out.size());
TEST_ASSERT_EQUAL_HEX32(0xA1B2C3D4, le32(out, 0)); // microsecond timestamps, little-endian file
TEST_ASSERT_EQUAL_UINT8(2, out[4]); // version 2.4
TEST_ASSERT_EQUAL_UINT8(4, out[6]);
TEST_ASSERT_EQUAL_UINT32(65535, le32(out, 16)); // snap length
TEST_ASSERT_EQUAL_UINT32(270, le32(out, 20)); // LINKTYPE_LORATAP
}
void test_record_with_loratap_v0() {
RxInfo rx;
rx.frequencyHz = 869525000;
rx.bandwidthKHz = 250;
rx.spreadingFactor = 11;
rx.rssi = -97.0f;
rx.snr = 6.25f;
rx.noiseFloor = -110.0f;
rx.syncWord = 0x2B;
const uint8_t payload[] = {1, 2, 3};
std::vector<uint8_t> out;
appendRecord(out, 1791230400u, 250000u, rx, payload, sizeof payload);
TEST_ASSERT_EQUAL_size_t(16 + 15 + 3, out.size());
TEST_ASSERT_EQUAL_UINT32(1791230400u, le32(out, 0));
TEST_ASSERT_EQUAL_UINT32(250000u, le32(out, 4));
TEST_ASSERT_EQUAL_UINT32(18, le32(out, 8)); // captured: LoRaTap header + payload
TEST_ASSERT_EQUAL_UINT32(18, le32(out, 12));
const uint8_t* t = out.data() + 16; // LoRaTap v0, big-endian
TEST_ASSERT_EQUAL_UINT8(0, t[0]); // version
TEST_ASSERT_EQUAL_UINT8(0, t[1]); // padding
TEST_ASSERT_EQUAL_UINT16(15, t[2] << 8 | t[3]);
TEST_ASSERT_EQUAL_UINT32(869525000u, (uint32_t)t[4] << 24 | t[5] << 16 | t[6] << 8 | t[7]);
TEST_ASSERT_EQUAL_UINT8(2, t[8]); // bandwidth in 125 kHz steps
TEST_ASSERT_EQUAL_UINT8(11, t[9]);
TEST_ASSERT_EQUAL_UINT8(42, t[10]); // packet RSSI: -139 + 42 = -97 dBm (SNR >= 0)
TEST_ASSERT_EQUAL_UINT8(42, t[11]); // max RSSI: the packet's
TEST_ASSERT_EQUAL_UINT8(29, t[12]); // current RSSI: the noise floor, -139 + 29 = -110 dBm
TEST_ASSERT_EQUAL_UINT8(25, t[13]); // SNR in quarter dB
TEST_ASSERT_EQUAL_HEX8(0x2B, t[14]);
TEST_ASSERT_EQUAL_UINT8(3, t[17]);
}
// Below 0 dB SNR the spec says quarter dB, but Wireshark (checked with tshark 4.2) reads plain dBm.
void test_negative_snr() {
RxInfo rx;
rx.bandwidthKHz = 125;
rx.rssi = -120.5f;
rx.snr = -7.5f;
std::vector<uint8_t> out;
appendRecord(out, 0, 0, rx, nullptr, 0);
const uint8_t* t = out.data() + 16;
TEST_ASSERT_EQUAL_UINT8(1, t[8]);
TEST_ASSERT_EQUAL_UINT8(19, t[10]); // -139 + 19 = -120 dBm (rounded half away)
TEST_ASSERT_EQUAL_INT8(-30, static_cast<int8_t>(t[13])); // -7.5 dB
}
void test_rssi_clamped() {
RxInfo rx;
rx.rssi = -150.0f; // below what LoRaTap can say
rx.snr = 1.0f;
rx.noiseFloor = 20.0f;
std::vector<uint8_t> out;
appendRecord(out, 0, 0, rx, nullptr, 0);
TEST_ASSERT_EQUAL_UINT8(0, out[16 + 10]);
TEST_ASSERT_EQUAL_UINT8(159, out[16 + 12]);
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_pcap_global_header);
RUN_TEST(test_record_with_loratap_v0);
RUN_TEST(test_negative_snr);
RUN_TEST(test_rssi_clamped);
return UNITY_END();
}
+132
View File
@@ -0,0 +1,132 @@
#include <unity.h>
#include <cstring>
#include <string>
#include "meshtastic_header.h"
#include "meshtastic_presets.h"
using namespace roro::meshtastic;
void setUp() {}
void tearDown() {}
// The 16 bytes every Meshtastic packet starts with, little-endian, never encrypted.
static const uint8_t kHeader[] = {
0xFF, 0xFF, 0xFF, 0xFF, // to: broadcast
0x78, 0x56, 0x34, 0x12, // from: !12345678
0xEF, 0xBE, 0xAD, 0xDE, // id
0x6B, // flags: hop start 3, want ack, hop limit 3
0x08, // channel hash: LongFast with the default key
0x00, // next hop: none
0x42, // relay node: last byte of the Node that relayed it
};
void test_header_fields() {
PacketHeader h;
TEST_ASSERT_TRUE(parseHeader(kHeader, sizeof kHeader, h));
TEST_ASSERT_EQUAL_HEX32(0xFFFFFFFF, h.to);
TEST_ASSERT_EQUAL_HEX32(0x12345678, h.from);
TEST_ASSERT_EQUAL_HEX32(0xDEADBEEF, h.id);
TEST_ASSERT_EQUAL_UINT8(0x08, h.channelHash);
TEST_ASSERT_EQUAL_UINT8(0x00, h.nextHop);
TEST_ASSERT_EQUAL_UINT8(0x42, h.relayNode);
TEST_ASSERT_TRUE(h.broadcast());
}
void test_header_flags() {
PacketHeader h;
parseHeader(kHeader, sizeof kHeader, h);
TEST_ASSERT_EQUAL_UINT8(3, h.hopLimit());
TEST_ASSERT_EQUAL_UINT8(3, h.hopStart());
TEST_ASSERT_TRUE(h.wantAck());
TEST_ASSERT_FALSE(h.viaMqtt());
TEST_ASSERT_EQUAL_INT(0, h.hopsAway());
uint8_t relayed[16];
std::memcpy(relayed, kHeader, 16);
relayed[12] = 0xF1 | 0x10; // hop start 7, via MQTT, hop limit 1
parseHeader(relayed, 16, h);
TEST_ASSERT_EQUAL_UINT8(1, h.hopLimit());
TEST_ASSERT_EQUAL_UINT8(7, h.hopStart());
TEST_ASSERT_TRUE(h.viaMqtt());
TEST_ASSERT_FALSE(h.wantAck());
TEST_ASSERT_EQUAL_INT(6, h.hopsAway());
}
void test_header_from_old_firmware_has_no_hop_start() {
uint8_t old[16];
std::memcpy(old, kHeader, 16);
old[12] = 0x02; // hop limit 2, hop start 0: firmware before 2.3 didn't set it
PacketHeader h;
parseHeader(old, 16, h);
TEST_ASSERT_EQUAL_INT(-1, h.hopsAway()); // unknown, not "2 hops below zero"
}
void test_header_too_short() {
PacketHeader h;
TEST_ASSERT_FALSE(parseHeader(kHeader, 15, h));
TEST_ASSERT_FALSE(parseHeader(nullptr, 0, h));
}
void test_node_ids() {
TEST_ASSERT_EQUAL_STRING("!12345678", nodeId(0x12345678).c_str());
TEST_ASSERT_EQUAL_STRING("!0000abcd", nodeId(0xABCD).c_str());
TEST_ASSERT_EQUAL_STRING("all", nodeId(kBroadcast).c_str());
}
// Meshtastic shows these hashes for the default key ("AQ==").
void test_channel_hash_with_default_key() {
TEST_ASSERT_EQUAL_UINT8(8, channelHash("LongFast", kDefaultKey, sizeof kDefaultKey));
TEST_ASSERT_EQUAL_UINT8(31, channelHash("MediumFast", kDefaultKey, sizeof kDefaultKey));
}
void test_djb2() {
TEST_ASSERT_EQUAL_UINT32(5381, djb2(""));
TEST_ASSERT_EQUAL_UINT32(130429955u, djb2("LongFast"));
}
void test_eu868_presets() {
TEST_ASSERT_EQUAL_size_t(7, kEu868PresetCount);
const Preset& lf = kEu868Presets[0]; // the default comes first
TEST_ASSERT_EQUAL_STRING("LongFast", lf.name);
TEST_ASSERT_EQUAL_FLOAT(250.0f, lf.bwKHz);
TEST_ASSERT_EQUAL_UINT8(11, lf.sf);
TEST_ASSERT_EQUAL_UINT8(5, lf.cr);
const Preset* ls = findPreset("LongSlow");
TEST_ASSERT_NOT_NULL(ls);
TEST_ASSERT_EQUAL_FLOAT(125.0f, ls->bwKHz);
TEST_ASSERT_EQUAL_UINT8(12, ls->sf);
TEST_ASSERT_EQUAL_UINT8(8, ls->cr);
TEST_ASSERT_NULL(findPreset("ShortTurbo")); // 500 kHz doesn't fit the 250 kHz sub-band
}
// The frequency slot comes from the channel name's hash; an unnamed channel uses the preset's name.
void test_eu868_frequencies() {
TEST_ASSERT_EQUAL_UINT32(869525000u, eu868FrequencyHz(*findPreset("LongFast")));
TEST_ASSERT_EQUAL_UINT32(869525000u, eu868FrequencyHz(*findPreset("MediumFast")));
TEST_ASSERT_EQUAL_UINT32(869462500u, eu868FrequencyHz(*findPreset("LongSlow")));
TEST_ASSERT_EQUAL_UINT32(869587500u, eu868FrequencyHz(*findPreset("LongMod")));
// A 250 kHz preset has one slot, whatever the channel's name.
TEST_ASSERT_EQUAL_UINT32(869525000u, eu868FrequencyHz(*findPreset("LongFast"), "Belgium"));
}
void test_radio_constants() {
TEST_ASSERT_EQUAL_HEX8(0x2B, kSyncWord);
TEST_ASSERT_EQUAL_UINT16(16, kPreambleLength);
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_header_fields);
RUN_TEST(test_header_flags);
RUN_TEST(test_header_from_old_firmware_has_no_hop_start);
RUN_TEST(test_header_too_short);
RUN_TEST(test_node_ids);
RUN_TEST(test_channel_hash_with_default_key);
RUN_TEST(test_djb2);
RUN_TEST(test_eu868_presets);
RUN_TEST(test_eu868_frequencies);
RUN_TEST(test_radio_constants);
return UNITY_END();
}