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) — currently0x1, allowing 15 future versionskind(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
| Portnum | Kind | Name | Description |
|---|---|---|---|
| 260 | 0x1 | SM_PRESENCE | Node presence broadcast |
| 261 | 0x1 | SM_SIGNAL | Signal broadcast |
| 261 | 0x2 | SM_SIGNAL_ACK | Signal acknowledgement |
| 262 | 0x1 | SM_IDENTITY_REQ | Identity request |
| 262 | 0x2 | SM_IDENTITY_RES | Identity response |
Signal ID and Collision Probability
Each signal gets a unique 64-bit identifier. The probability of collision follows the birthday bound:
For signals:
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:
| Field | Max Bytes | Encoding |
|---|---|---|
| Presence status | 63 | UTF-8 |
| Signal content | 140 | UTF-8 |
| Identity display name | 39 | UTF-8 |
| Identity short name | 4 | UTF-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:
- Binary SM_SIGNAL on portnum 261
- 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.