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
+52
View File
@@ -0,0 +1,52 @@
#include "loratap.h"
#include <algorithm>
#include <cmath>
namespace roro::lora {
static void le16(std::vector<uint8_t>& o, uint16_t v) { o.insert(o.end(), {uint8_t(v), uint8_t(v >> 8)}); }
static void le32(std::vector<uint8_t>& o, uint32_t v) {
o.insert(o.end(), {uint8_t(v), uint8_t(v >> 8), uint8_t(v >> 16), uint8_t(v >> 24)});
}
// LoRaTap's dBm encoding: -139 dBm plus the byte, clamped to what a byte holds.
static uint8_t dbmByte(float dbm) {
long v = std::lround(dbm + 139);
return static_cast<uint8_t>(std::clamp(v, 0L, 255L));
}
void appendPcapHeader(std::vector<uint8_t>& out) {
le32(out, 0xA1B2C3D4);
le16(out, 2);
le16(out, 4);
le32(out, 0); // time zone
le32(out, 0); // timestamp accuracy
le32(out, 65535); // snap length
le32(out, 270); // LINKTYPE_LORATAP
}
void appendRecord(std::vector<uint8_t>& out, uint32_t seconds, uint32_t micros, const RxInfo& rx, const uint8_t* data,
size_t len) {
uint32_t captured = static_cast<uint32_t>(kLoraTapSize + len);
le32(out, seconds);
le32(out, micros);
le32(out, captured);
le32(out, captured);
uint32_t f = rx.frequencyHz;
// The spec puts packet RSSI in quarter dB below 0 dB SNR (an SX127x formula), but Wireshark
// reads it as -139 dBm plus the byte either way, and the SX1262 already gives dBm.
uint8_t packetRssi = dbmByte(rx.rssi);
long snr = std::clamp(std::lround(rx.snr * 4), -128L, 127L);
out.insert(out.end(), {
0, 0, 0, uint8_t(kLoraTapSize), // version 0, padding, length
uint8_t(f >> 24), uint8_t(f >> 16), uint8_t(f >> 8), uint8_t(f),
uint8_t(std::lround(rx.bandwidthKHz / 125)), rx.spreadingFactor,
packetRssi, dbmByte(rx.rssi), dbmByte(rx.noiseFloor),
static_cast<uint8_t>(static_cast<int8_t>(snr)), rx.syncWord,
});
if (len) out.insert(out.end(), data, data + len);
}
} // namespace roro::lora
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <vector>
namespace roro::lora {
// What the radio knew about one received packet.
struct RxInfo {
uint32_t frequencyHz = 0;
float bandwidthKHz = 0;
uint8_t spreadingFactor = 0;
float rssi = 0; // dBm
float snr = 0; // dB
float noiseFloor = 0; // dBm, instantaneous RSSI just before the packet, when known
uint8_t syncWord = 0;
};
// A Capture file (M3, Q97): pcap with LoRaTap v0 headers (LINKTYPE_LORATAP, 270), which Wireshark
// reads. pcap fields are little-endian, LoRaTap fields big-endian.
void appendPcapHeader(std::vector<uint8_t>& out);
void appendRecord(std::vector<uint8_t>& out, uint32_t seconds, uint32_t micros, const RxInfo& rx, const uint8_t* data,
size_t len);
constexpr size_t kLoraTapSize = 15;
constexpr size_t kRecordOverhead = 16 + kLoraTapSize;
} // namespace roro::lora
+28
View File
@@ -0,0 +1,28 @@
#include "meshtastic_header.h"
#include <cstdio>
namespace roro::meshtastic {
static uint32_t le32(const uint8_t* p) { return p[0] | p[1] << 8 | p[2] << 16 | static_cast<uint32_t>(p[3]) << 24; }
bool parseHeader(const uint8_t* data, size_t len, PacketHeader& out) {
if (!data || len < kHeaderSize) return false;
out.to = le32(data);
out.from = le32(data + 4);
out.id = le32(data + 8);
out.flags = data[12];
out.channelHash = data[13];
out.nextHop = data[14];
out.relayNode = data[15];
return true;
}
std::string nodeId(uint32_t node) {
if (node == kBroadcast) return "all";
char s[10];
std::snprintf(s, sizeof s, "!%08x", static_cast<unsigned>(node));
return s;
}
} // namespace roro::meshtastic
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
namespace roro::meshtastic {
constexpr uint32_t kBroadcast = 0xFFFFFFFF;
// The 16 bytes every Meshtastic packet starts with, sent in clear (little-endian). The payload
// after it is encrypted with the Channel's key.
struct PacketHeader {
uint32_t to = 0, from = 0, id = 0;
uint8_t flags = 0;
uint8_t channelHash = 0; // the Channel's name and key folded into a byte (see channelHash())
uint8_t nextHop = 0; // last byte of the Node meant to relay it next; 0 when flooding
uint8_t relayNode = 0; // last byte of the Node that relayed it to us
uint8_t hopLimit() const { return flags & 0x07; }
bool wantAck() const { return flags & 0x08; }
bool viaMqtt() const { return flags & 0x10; }
uint8_t hopStart() const { return flags >> 5; }
bool broadcast() const { return to == kBroadcast; }
// How many times it was relayed before we heard it; -1 when the sender didn't say (hop start 0).
int hopsAway() const { return hopStart() == 0 ? -1 : hopStart() - hopLimit(); }
};
constexpr size_t kHeaderSize = 16;
bool parseHeader(const uint8_t* data, size_t len, PacketHeader& out);
// "!12345678", as Meshtastic writes node numbers; "all" for broadcast.
std::string nodeId(uint32_t node);
} // namespace roro::meshtastic
+47
View File
@@ -0,0 +1,47 @@
#include "meshtastic_presets.h"
#include <cmath>
#include <cstring>
namespace roro::meshtastic {
const uint8_t kDefaultKey[16] = {0xd4, 0xf1, 0xbb, 0x3a, 0x20, 0x29, 0x07, 0x59,
0xf0, 0xbc, 0xff, 0xab, 0xcf, 0x4e, 0x69, 0x01};
// firmware src/mesh/MeshRadio.h (modemPresetToParams) and RadioInterface.cpp (PRESETS_EU_868).
const Preset kEu868Presets[] = {
{"LongFast", 250, 11, 5}, {"LongSlow", 125, 12, 8}, {"MediumSlow", 250, 10, 5}, {"MediumFast", 250, 9, 5},
{"ShortSlow", 250, 8, 5}, {"ShortFast", 250, 7, 5}, {"LongMod", 125, 11, 8},
};
const size_t kEu868PresetCount = sizeof kEu868Presets / sizeof kEu868Presets[0];
const Preset* findPreset(const char* name) {
for (const Preset& p : kEu868Presets)
if (std::strcmp(p.name, name) == 0) return &p;
return nullptr;
}
uint32_t djb2(const char* s) {
uint32_t h = 5381;
for (; *s; ++s) h = (h << 5) + h + static_cast<unsigned char>(*s);
return h;
}
uint8_t channelHash(const char* name, const uint8_t* key, size_t keyLen) {
uint8_t h = 0;
for (; *name; ++name) h ^= static_cast<uint8_t>(*name);
for (size_t i = 0; i < keyLen; ++i) h ^= key[i];
return h;
}
uint32_t eu868FrequencyHz(const Preset& preset, const char* channelName) {
constexpr double kStartMHz = 869.4, kEndMHz = 869.65;
double slotMHz = preset.bwKHz / 1000.0;
uint32_t slots = static_cast<uint32_t>(std::lround((kEndMHz - kStartMHz) / slotMHz));
const char* name = channelName && *channelName ? channelName : preset.name;
uint32_t slot = slots ? djb2(name) % slots : 0;
double mhz = kStartMHz + slotMHz / 2 + slot * slotMHz;
return static_cast<uint32_t>(std::lround(mhz * 1e6));
}
} // namespace roro::meshtastic
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <cstddef>
#include <cstdint>
namespace roro::meshtastic {
// Radio settings shared by every Meshtastic preset (firmware src/mesh/RadioInterface.h).
constexpr uint8_t kSyncWord = 0x2B;
constexpr uint16_t kPreambleLength = 16;
// The default Channel key ("AQ==", expanded): public, so the default Channel is readable by anyone.
extern const uint8_t kDefaultKey[16];
// A modem preset: bandwidth, spreading factor and coding rate (4/cr). Named as Meshtastic shows them.
struct Preset {
const char* name;
float bwKHz;
uint8_t sf;
uint8_t cr;
};
// The presets Meshtastic allows in EU_868, its order, LongFast (the default) first.
extern const Preset kEu868Presets[];
extern const size_t kEu868PresetCount;
const Preset* findPreset(const char* name); // nullptr when EU_868 doesn't allow it
// djb2, as Meshtastic hashes a Channel's name to pick a frequency slot.
uint32_t djb2(const char* s);
// The byte in every packet header naming its Channel: the name's bytes XORed with the key's.
uint8_t channelHash(const char* name, const uint8_t* key, size_t keyLen);
// EU_868 is 869.4 to 869.65 MHz: the slot comes from the Channel's name (the preset's name for
// an unnamed Channel, which is the default).
uint32_t eu868FrequencyHz(const Preset& preset, const char* channelName = nullptr);
} // namespace roro::meshtastic