Skip to content

Embedded device storage layout (C++ reference)

This section shows how configuration and runtime state can be structured in firmware. Namespaces keep persistent NVM (flash) separate from runtime RAM tables, and keep wire parsing helpers (ParsedBaseHeader, modular tail parsers) separate from both — do not model the full Mesh PDU as one rigid historical MeshHeader packed struct: optional dstId, HAS_REGION, fragment, and routing blocks require pointer arithmetic / dedicated parse* functions (see Packet layout).

Field naming: The protocol specifies camelCase identifiers end-to-end: the on-air layouts, the markdown field tables in this documentation set, and the C++ reference structs below all use the same spellings (for example channelHash, payloadType, longNameUtf8, batteryMv). Bit flags (FLOOD, ACK_REQ, …) stay in UPPER_SNAKE_CASE so they read as discrete constants, not variables.

Namespaces: Types on this page live under the top-level lowmesh namespace, subdivided into:

  • lowmesh::nvm: flash/EEPROM persistent structs
  • lowmesh::runtime: RAM tables (neighbors, contacts) — no distance-vector route cache; paths live on-wire in the routing block
  • lowmesh::wire: header views + encode/decode helpers for TX/RX buffers (dynamic offsets, not one fixed C struct for the whole PDU)

Companion firmware and host tools SHOULD use qualified names (for example lowmesh::nvm::DeviceStoreV1, lowmesh::wire::ParsedBaseHeader, lowmesh::wire::parseBaseHeader, lowmesh::buildMeshPduCh0_Example) rather than using namespace lowmesh in shared headers, to keep symbol boundaries clear.

Namespace layout

cpp
#pragma once
#include <array>
#include <cstdint>
#include <cstring>
#include <type_traits>

namespace lowmesh {

// --- 1. Persistent Storage (Flash/EEPROM) ---
namespace nvm {

#pragma pack(push, 1)

struct NodeIdentity {
  /// Stable **8-byte** identity (EUI-64-like or provisioning-random). See node-identity-advertisement.md.
  std::array<std::uint8_t, 8> nodeIdentity64{};
  /// On-air **`srcId` / `dstId`** (**`nodeId32`**). Often derived from `nodeIdentity64` and/or Ed25519 material.
  std::uint32_t deviceId{ 0 }; // alias: nodeId32 — LE
  /// Persistent transport **`sessionId`** (**LE `uint32`**) — mandatory on wire at base offset **0x09**; increment on reboot before **`messageId`** reuse.
  std::uint32_t sessionId{ 0 };
  /// Permanent **Ed25519** private key material (**64 bytes**; NaCl “secret key” layout seed||pub is typical).
  std::array<std::uint8_t, 64> ed25519PrivateKey{};
  /// Permanent **Ed25519** public key (**32 bytes**).
  std::array<std::uint8_t, 32> ed25519PublicKey{};
  std::array<std::uint8_t, 4> shortName{}; // ASCII, zero-padded
  std::uint8_t longNameLen{ 0 };
  std::array<std::uint8_t, 48> longNameUtf8{};
};

struct ChannelSlot {
  std::uint8_t enabled{ 0 }; // 0 = empty slot; 1 = valid entry
  std::uint8_t nameLen{ 0 }; // 1–63
  std::array<std::uint8_t, 63> nameUtf8{}; // UTF-8 channel name (no BOM)
  std::array<std::uint8_t, 32> psk{};      // AES-256 key for this channel name
};

struct Crypto {
  // Optional convenience root for provisioning-time HKDF (see crypto-keys.md). Not carried on-air.
  std::array<std::uint8_t, 32> networkMasterKey{};
  std::uint16_t keyEpoch{ 0 };

  /// Named channels used for RX matching against `channelHash` (see channels-security.md).
  std::array<ChannelSlot, 16> channels{};

  /// Admin mesh key used with the fixed UTF-8 name `lowmesh/admin` (see admin-remote.md).
  std::array<std::uint8_t, 32> adminChannelPsk{};
};

struct ChannelPrefs {
  std::uint16_t notifyMuteMask{ 0 }; // bit i corresponds to `channels[i]` when enabled
  std::array<std::uint8_t, 16> buzzerProfileId{};
};

struct RadioProfile {
  std::uint8_t profileId{ 0 };
  std::uint8_t regionCode{ 0 };
  std::uint8_t sf{ 0 };
  std::uint16_t bwKhz{ 125 }; // US915 FCC-targeted builds: force 500 — see fcc-us-compliance.md
  std::uint8_t cr{ 5 };       // 4/5 if stored as 5 per Semtech convention
  std::uint16_t preambleSyms{ 8 };
  // Persisted NVM MUST NOT rely on compiler-specific `bool` size/alignment.
  // Use uint8_t for stable cross-compiler layout.
  std::uint8_t iqInverted{ 0 };     // 0/1
  std::uint8_t explicitHeader{ 1 }; // 0/1
  std::uint8_t crcEnabled{ 1 };     // 0/1, LoRa CRC16 at PHY
  std::uint8_t ldro{ 0 };
  std::uint16_t syncWord{ 0x1424 };
  std::int8_t txPowerDbm{ 14 };
  std::int8_t antennaGainDbi{ 2 };
  /// Regional duty-cycle limit in permille (e.g. 100 = 10%). **0** = no regulatory cap (USA profile).
  std::uint16_t regionalDutyCycleLimitPermille{ 0 };
  /// Rolling window for duty-cycle accounting (seconds). Typical **3600** when enabled.
  std::uint16_t regionalDutyCycleWindowSec{ 0 };
  /// Fraction of relay airtime budget reserved for own traffic (permille). Congestion fairness.
  std::uint16_t relayAirtimeBudgetPermille{ 0 };
  std::uint8_t reserved{ 0 }; // packing / future profile flags — keeps **`sizeof(RadioProfile) == 22`**
};

struct HardwareCaps {
  std::uint16_t boardId{ 0 };
  std::uint8_t radioKind{ 0 }; // enum RadioKind : uint8_t { SX1262 = 1, ... };
  std::uint8_t tcxoPpm{ 0 };
  std::uint8_t hasLna{ 0 }; // 0/1
};

struct DeviceRole {
  std::uint8_t primaryRole{ 2 }; // 0=gateway 1=repeater 2=client // legacy alias: edge
  /// Client behavior profile when `primaryRole == client`: **0** = Standard, **1** = IoT/Sensor — see [Device roles](./device-roles.md)
  std::uint8_t clientProfile{ 0 };
  /// **1** = Dynamic Repeater Mode enabled; **0** = disabled. Default **1** for Standard profile, **0** for Sensor unless overridden
  std::uint8_t dynamicRepeaterModeEnabled{ 1 };
  std::uint16_t tempRelayMinutes{ 0 };
  /// Max outbound `ttl` when forwarding **flood** frames in temporary relay state (`TempRelay`); **1–63**
  /// mesh-wide storms
  std::uint8_t tempRelayFloodTtlCap{ 6 };
  /// Max third-party **flood** forwards initiated or relayed per rolling minute in temp mode; `0` = firmware
  /// default
  std::uint16_t tempRelayMaxForwardsPerMin{ 0 };
  /// **1** = *Community*: unknown **`channelHash`** MAY be retransmitted as ciphertext without local decrypt
  /// (**dumb-pipe**). **0** = *Fortress*: unknown hash **MUST** be dropped. Known hashes (**including `0x0000`**)
  /// always require AES-CCM-4 MIC verification before relay — see [Channels & security](./channels-security.md).
  std::uint8_t relayCommunityTraffic{ 1 };
  struct RegionFilter {
    std::uint32_t spatialId{ 0 };
    std::uint8_t spatialMask{ 0x40 }; // default: center-only
  };
  /// Optional spatial allow-list (H3-ish Spatial Region block); `allowedRegionCount` ≤ 3.
  std::array<RegionFilter, 3> allowedRegions{};
  std::uint8_t allowedRegionCount{ 0 };
  /// If **1** and **`allowedRegionCount > 0`**, the device MUST drop any packet that does not have **`HAS_REGION`**
  /// set in `verFlags` (see Routing).
  std::uint8_t forceRegionEnforcement{ 0 };
  /// Minimum spacing (minutes) for appending **`HAS_TIME_SYNC`** to **`NODE_ADVERT`** beacons on infrastructure
  /// nodes; default **1440** (once per day). **`0`** = use firmware default (same as **1440** unless overridden). See [Node identity, advertisement](./node-identity-advertisement.md).
  std::uint16_t timeSyncAdvertIntervalMinutes{ 1440 };
};

/// Personal away message + presence (NVM); queried remotely via AWAY_MSG_REQ/RSP
struct AwayMessage {
  std::uint8_t awayLen{ 0 }; // 0–128, 0 = cleared
  std::array<std::uint8_t, 128> awayUtf8{};
  std::uint8_t presence{ 1 }; // 1=here 2=away 3=busy 4=dnd — see contacts-local-away-status.md
};

/// Contacts list policy (NVM); RAM table size should respect maxContacts
struct ContactListPrefs {
  std::uint16_t maxContacts{ 96 }; // clamp UI aggregate rows; 0 = firmware default
  std::uint8_t maxHopsKeep{ 0 };   // 0 = no hop-based eviction bias; else prefer drop if hopCount > this
};

// Single flash blob versioned for migration
struct DeviceStoreV1 {
  // ASCII "LM1\0" as little-endian u32 — change on schema break
  static constexpr std::uint32_t kMagic = 0x00314D4C;
  std::uint32_t magic{ kMagic };
  std::uint16_t schemaVersion{ 10 }; // 10 = spatial region filters + forceRegionEnforcement + time sync interval; migrate older blobs
  NodeIdentity identity{};
  Crypto crypto{};
  ChannelPrefs chPrefs{};
  RadioProfile radio{};
  HardwareCaps hw{};
  DeviceRole role{};
  AwayMessage away{};
  ContactListPrefs contactPrefs{};
  std::array<std::uint8_t, 32> hmacOverPrior{}; // optional integrity
};

static_assert(std::is_trivially_copyable_v<DeviceStoreV1>, "DeviceStoreV1 must be trivially copyable for blob I/O");
static_assert(sizeof(HardwareCaps) == 5, "NVM: HardwareCaps packed size");
static_assert(sizeof(RadioProfile) == 22, "NVM: RadioProfile packed size (includes regional duty-cycle fields)");
// static_assert(sizeof(DeviceRole) == …); // validate packed size in firmware CI when fields change
static_assert(sizeof(ContactListPrefs) == 3, "NVM: ContactListPrefs packed size");

struct BulletinHostPrefs {
  std::uint16_t maxEntriesTotal{ 256 };     // nMaxTotal — see bulletin-board.md
  std::uint16_t maxEntriesPerChannel{ 64 }; // nMaxPerChannel; 0 = only total cap applies
};

#pragma pack(pop)

} // namespace nvm

// --- 2. Runtime (RAM): neighbors (strict source routing / flood path is on-wire only) ---
namespace runtime {

struct Neighbor {
  std::uint32_t nodeId{ 0 };
  std::int16_t lastRssiDbm{ 0 };
  std::int16_t lastSnrDb{ 0 };
  std::uint32_t lastSeenMs{ 0 };
  std::int16_t ewmaSnrDb{ 0 };
  std::uint8_t radioProfileId{ 0 };
};

/// UI-facing merge of neighbor + identity cache + last trace (RAM)
struct Contact {
  std::uint32_t deviceId{ 0 };
  /// Last known peer **Ed25519** public key (**32** bytes); all-zero if unknown (DM requires discovery).
  std::array<std::uint8_t, 32> peerEd25519PublicKey{};
  std::array<std::uint8_t, 4> shortName{};
  std::uint8_t longNameLen{ 0 };
  std::array<std::uint8_t, 48> longNameUtf8{};
  std::uint16_t hwBoardId{ 0 };
  std::uint8_t radioProfileId{ 0 };
  std::int16_t lastRssiDbm{ 0 };
  std::int16_t lastSnrDb{ 0 };
  std::uint16_t batteryMv{ 0 };       // absolute terminal mV; 0 / 0xFFFF = unknown (wire uses 0xFFFF)
  std::uint8_t batteryPercent{ 255 }; // 0–100; 255 = unknown
  std::uint8_t hopCount{ 0 };
  std::uint8_t hopPathLen{ 0 };
  std::array<std::uint32_t, 8> hopPath{}; // last known hop IDs (up to hopPathLen)
  std::uint8_t reachability{ 0 };
  std::uint32_t lastSeenMs{ 0 };
  bool pinned{ false };
};

/// RAM-only pairwise DM session (see crypto-keys.md / channels-security.md).
struct DmPairwiseSession {
  std::uint32_t peerDeviceId{ 0 };
  /// Lower **16** bits of the DM digest (**LE**), matching on-air **`channelHash`** (**`uint16_t`**).
  std::array<std::uint8_t, 2> channelHashLe{};
  std::array<std::uint8_t, 32> sharedSecret{}; // X25519 ECDH output (HKDF input — not used raw as psk32)
  std::array<std::uint8_t, 32> psk32{};       // HKDF-derived AES-CCM key
  std::uint8_t trustState{ 0 };               // 0=UNVERIFIED 1=TOFU 2=VERIFIED — see crypto-keys.md
  std::array<std::uint8_t, 32> peerEd25519PublicKey{};
  std::uint32_t lastUsedMs{ 0 };
};

class State {
public:
  std::array<Neighbor, 64> neighbors{};
  std::array<Contact, 96> contacts{}; // optional; effective count ≤ contactPrefs.maxContacts
  std::array<DmPairwiseSession, 32> dmSessions{}; // pairwise **psk32** cache for 1:1 DMs
  std::uint32_t nowMs{ 0 };

  /// MAC-layer fast-drop list (**`srcId`**); managed remotely via **`ADMIN_CMD`** on **`lowmesh/admin`** — see
  /// [Mesh admin](./admin-remote.md). **No** distance-vector **`routes[]`** / **`Route`** table: repeaters are
  /// stateless dumb-pipes; path state is on-wire in the **routing block**.
  std::array<std::uint32_t, 16> blocklist{};
  std::uint8_t blocklistCount{ 0 };

  Neighbor *findOrAllocNeighbor(std::uint32_t id);
  void onPacketRx(std::uint32_t srcId, std::int16_t rssi, std::int16_t snr);
  /// Duplicate suppression: **true** if `(channelHash, srcId, sessionId, messageId, fragmentWord)` already seen (drop echo).
  bool isDuplicateAndRemember(std::uint16_t channelHash, std::uint32_t srcId, std::uint32_t sessionId,
                              std::uint16_t messageId, std::uint16_t fragmentWord);
};

} // namespace runtime

// --- 3. Wire structs mirror docs/packet-layout.md (in-memory before TX/RX) ---
namespace wire {

#pragma pack(push, 1)
/// On-air base header is **dynamic 16 or 20 bytes** (LE **`uint16_t verFlags`**, optional **`dstId`**). Firmware
/// models it as **`lowmesh::wire::ParsedBaseHeader`** + **`encodeBaseHeader` / `parseBaseHeader`** — see
/// `mesh_header.hpp` / `mesh_pdu.hpp`. **`channelHash`** is **16 bits** (truncate SHA-256 digest for provisioning).
// Mirrors `lowmesh::wire::ParsedBaseHeader` (see firmware): verFlags LE, 16-bit channel hash, optional dstId.

// --- V2 payloads (no single packed “whole body” struct) ---
//
// Normative layout **`PAYLOAD_BODY_V2`** (`payloadType = 0x04`) is documented in
// docs/protocol/payloads.md. Optional **region** (5 B), **fragment** (2 B), and **routing** (`pathCount` + path
// hashes) blocks precede the sealed inner payload per **`verFlags`** — see `lowmesh::wire::mesh_pdu.hpp`.
//
// **`PAYLOAD_BODY_V1`** (8-byte `pathHops`, `opaqueLen`, `opaqueBytes`) is removed from the spec —
// do not model it as one rigid packed struct.
//
// Both V2 envelopes use **variable-length** regions controlled by flag bits:
// - **`PAYLOAD_BODY_V2`**: fixed **8-byte** prefix — `bodyVersion`, `bodyFlags`, `fileType`, `currentIndex` (2 LE),
//   `totalCount` (2 LE), `chunkLen`, `chunkBytes` — see docs/protocol/payloads.md.
//
// Firmware SHOULD use **small fixed prefix structs** (`#pragma pack(push,1)`) for the known
// leading fields, then **pointer arithmetic** (or dedicated `parse` / `build` functions) for
// the variable tail. **No dynamic allocation:** no **`malloc`**, **`new`**, **`std::vector`**,
// or **`std::string`** on the hot path — only **`std::array`**, stack buffers, and **`uint8_t*`
// views into TX/RX memory.

} // namespace wire
} // namespace lowmesh

Default community channel (channelHash == 0x0000) and MAC relay

  • 0x0000 is the default community domain: not cleartext. Every device ships the same ROM AES-256 key defaultCommunityPsk32 (SHA256("LowMesh/v2/defaultCommunityPsk32"), first 32 bytes — C++ lowmesh::crypto::defaultCommunityPsk32). Receivers MUST run AES-256-CCM-4 decrypt+MIC verify on the inner tail exactly like a named channel. A future Ham operator / cleartext mode is out of scope for this revision.
  • DeviceRole::relayCommunityTraffic selects third-party behavior for unknown channelHash values: 1 (Community) MAY retransmit ciphertext without local decrypt; 0 (Fortress) MUST drop. For any hash the device has a key for (named, DM, or 0x0000), relays MUST verify the MIC and only forward on success — see lowmesh::runtime::shouldForwardAsThirdPartyRelay.

Using lowmesh::wire::ParsedBaseHeader with payload buffer

cpp
#include <cstring>

namespace lowmesh {

inline std::size_t meshPduPhysicalSize(std::size_t baseBytes, std::size_t tailBytes) {
  return baseBytes + tailBytes;
}

// Example: base + inner tail before CCM (`channelHash = 0` still requires AES-CCM-4 + MIC on air).
inline bool buildMeshPduCh0_Example(std::array<std::uint8_t, 256> &out, std::size_t &outLen, const nvm::NodeIdentity &id,
                                    std::uint16_t msgId, const char *text) {
  wire::ParsedBaseHeader hdr{};
  hdr.verFlags = static_cast<std::uint16_t>(wire::vfEncodeVersion(2)); // v2 only; add flags as needed
  hdr.channelHash = 0;
  hdr.ttlRaw = static_cast<std::uint8_t>(4u & 0x3Fu);
  hdr.srcId = id.deviceId;
  hdr.sessionId = id.sessionId;
  hdr.hasDstId = true;
  hdr.dstId = 0xFFFFFFFFu;
  hdr.messageId = msgId;
  hdr.payloadType = 0x01;
  hdr.baseWireBytes = 20;
  const std::size_t tlen = std::strlen(text);
  if (tlen > 255u) return false;
  const std::size_t tailBytes = 1u + tlen;

  std::uint8_t *p = out.data();
  std::size_t baseWritten = 0;
  if (!wire::encodeBaseHeader(p, out.size(), hdr, baseWritten)) return false;
  p += baseWritten;
  *p++ = static_cast<std::uint8_t>(tlen);
  std::memcpy(p, text, tlen);
  p += tlen;

  outLen = static_cast<std::size_t>(p - out.data());
  return outLen <= out.size() && baseWritten + tailBytes == outLen;
}

} // namespace lowmesh

Note: On real firmware, avoid memcpy into packed structs across compilers; prefer serialize/deserialize functions or std::bit_cast where available. For PAYLOAD_BODY_V2, extended headers, and Ed25519/X25519 math, use vetted libraries (mbedTLS, TweetNaCl, PSA Crypto, etc.) and keep curve operations off the radio IRQ where possible (async job queue).

Ed25519 identity and nodeId32 (normative)

The C++ field deviceId is a legacy/code alias for nodeId32. User-facing protocol docs use nodeId32 for the 4-byte on-air srcId / dstId identity.

  • Each device stores ed25519PrivateKey (64 bytes) and ed25519PublicKey (32 bytes) in NVM (NodeIdentity).
  • The wire nodeId32 / header srcId / dstId (LE uint32) MUST be:
digest[0..31] = SHA256( ed25519PublicKey[0..31] )
nodeId32      = read_u32_le( digest[0..3] )   // C++ alias: deviceId
  • On identity generation or key rotation, firmware MUST recompute nodeId32 ( deviceId in C++ ) and persist it consistently with ed25519PublicKey.
  • Collision: if two different pkEd values share nodeId32 on the same domain, do not pick an arbitrary new nodeId32. Generate a new Ed25519 keypair on the node selected to move, recompute pkEd / nodeId32, and advertise the new identity — see Node identity.
  • Provisioned/static identity is a separate product mode and MUST NOT silently violate Ed25519 linkage when HAS_ED25519_PUBKEY is advertised.

Implementation note (ESP32-S3, nRF52840): Curve25519 / Ed25519 and SHA-256 SHOULD use constant-time, MCU-optimized library code; do not block RF driver tasks with long synchronous keygen on every boot — defer to provisioned keys or a low-priority worker.

Where things live — summary

ConcernNVM (lowmesh::nvm::DeviceStoreV1)RAM (lowmesh::runtime::State)
Long/short namelowmesh::nvm::DeviceStoreV1::identity (longNameUtf8, …)Cached copy for UI
Network + channel keyslowmesh::nvm::DeviceStoreV1::cryptoPairwise DM sharedSecret + AES-CCM keys (RAM); see DmPairwiseSession
Ed25519 / X25519lowmesh::nvm::DeviceStoreV1::identityPeer keys on Contact; runtime::DmPairwiseSession
LoRa + hardwarelowmesh::nvm::DeviceStoreV1::radio, lowmesh::nvm::DeviceStoreV1::hwCurrent lowmesh::nvm::RadioProfile snapshot
Per-channel mute/buzzerlowmesh::nvm::DeviceStoreV1::chPrefsFast bitmasks
RSSI/SNR per peerlowmesh::runtime::Neighbor
Away message / presencelowmesh::nvm::DeviceStoreV1::awayCopy for fast UI
Contacts row (merged view)lowmesh::nvm::DeviceStoreV1::contactPrefs, optional pins/noteslowmesh::runtime::Contact in RAM
Traceroute historyoptional log ringLast mesh trace (see Traceroute)

Bulletin host capacity (gateway / repeater builds)

Optional NVM for nodes that host a bulletin — not required on pure Client devices.

This capacity struct is separate from the main DeviceStoreV1 blob; gateways can store it as an additional NVM blob or as a separate flash region.

cpp
namespace lowmesh::nvm {

struct BulletinHostPrefs {
  std::uint16_t maxEntriesTotal{ 256 };     // nMaxTotal — see bulletin-board.md
  std::uint16_t maxEntriesPerChannel{ 64 }; // nMaxPerChannel; 0 = only total cap applies
};

} // namespace lowmesh::nvm

Integrity of NVM

Use CRC32 or HMAC over lowmesh::nvm::DeviceStoreV1 excluding the tag field to detect flash corruption. This is device-local integrity, separate from RF LoRa CRC and AES-256-CCM-4 on the air.

LowMeshOS — always open-source mesh protocol documentation