Firmware Extensions: Binary Encoding Over LoRa Mesh

Socialmesh extends Meshtastic’s over-the-air protocol using a Pure Extension strategy. We define three custom application portnums (260, 261, 262) that carry compact binary payloads alongside the existing portnum 256 JSON format. No firmware fork required.

The Problem

Meshtastic’s built-in application portnums don’t cover everything a mesh companion app needs. We need to broadcast:

  • Presence — “I’m here, battery at 73%, status: hiking”
  • Signals — short-lived broadcast messages with expiry
  • Identity — node profile cards (name, call sign, avatar hash)

The legacy approach was JSON over portnum 256. It works, but JSON is wasteful on a bandwidth-constrained LoRa link where every byte matters.

Wire Format

All three SM packet types share a common header byte (hdr0):

hdr0 = (version << 4) | kind

Where:

  • version (4 bits) — currently 0x1, allowing 15 future versions
  • kind (4 bits) — packet type discriminator
// Header construction
int buildHdr0(int version, int kind) => (version << 4) | kind;

// Header parsing
int versionFromHdr0(int hdr0) => (hdr0 >> 4) & 0x0F;
int kindFromHdr0(int hdr0) => hdr0 & 0x0F;

Packet Types

PortnumKindNameDescription
2600x1SM_PRESENCENode presence broadcast
2610x1SM_SIGNALSignal broadcast
2610x2SM_SIGNAL_ACKSignal acknowledgement
2620x1SM_IDENTITY_REQIdentity request
2620x2SM_IDENTITY_RESIdentity response

Signal ID and Collision Probability

Each signal gets a unique 64-bit identifier. The probability of collision follows the birthday bound:

Pn2265P \approx \frac{n^2}{2^{65}}

For n=1,000,000n = 1{,}000{,}000 signals:

P10123.69×10192.7×108P \approx \frac{10^{12}}{3.69 \times 10^{19}} \approx 2.7 \times 10^{-8}

That is a 1-in-37-million chance — practically zero for a mesh network.

Signal ID Encoding

The 64-bit ID is split into two 32-bit halves and encoded little-endian:

Uint8List encodeSignalId(int id) {
  final bytes = Uint8List(8);
  final view = ByteData.sublistView(bytes);
  view.setUint32(0, id & 0xFFFFFFFF, Endian.little);
  view.setUint32(4, (id >> 32) & 0xFFFFFFFF, Endian.little);
  return bytes;
}

The string representation uses hex: "sm-" prefix followed by 16 lowercase hex characters.

Byte Budgets

LoRa frames are precious. Every byte increases airtime and reduces battery life. Our limits:

FieldMax BytesEncoding
Presence status63UTF-8
Signal content140UTF-8
Identity display name39UTF-8
Identity short name4UTF-8

These are wire limits, not UI limits. The app enforces them at the encoding layer.

Dual-Send Migration

During migration from JSON to binary, the app dual-sends both formats:

  1. Binary SM_SIGNAL on portnum 261
  2. Legacy JSON on portnum 256

The receiver deduplicates using the shared signalId ("sm-" + hex). Both formats feed the same signalController stream, so the UI always shows exactly one item per signal regardless of which encoding the peer supports.

Phase 1: dual-send both formats (current)
Phase 2: binary-only for known-capable peers, dual for unknown
Phase 3: legacy sunset

Rate Limiting

To protect the mesh from congestion, all SM packet types are rate-limited:

class SmRateLimit {
  static const presenceInterval = Duration(minutes: 5);
  static const signalMinInterval = Duration(seconds: 30);
  static const identityRequestCooldown = Duration(minutes: 10);
}

The identity request cooldown prevents “request storms” where nodes repeatedly ask each other for profiles.

Further Reading

The full protocol specification is published at socialmesh.app/protocol.