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
+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