pamoja

An SDK for IoT, robotics, and drones

pamojaRev 0.2.0MIT licensepamoja.molex.cloud

Mesh frames#

Where the infrastructure is gone or was never there, devices carry each other's traffic: every node relays what it hears, so a message crosses ground no single node can reach. The radios that do that cheaply, the connectionless ESP-NOW of an ESP32 swarm or a pennies-per-node nRF24, hand you a payload and nothing else: no addressing, no hop count, no integrity. pamoja supplies that layer as pure logic with no allocation, so the same code runs on a node with a radio and in a test with none.

What the example does#

It builds the frame a river gauge floods into the mesh, then takes the part of the node that hears it: parse the frame, drop the second copy, spend a hop, and stop once the hops run out. Then it inverts a payload byte and confirms the checksum rejects the frame.

The second part floods the same reading down a valley of six nodes, each in range of its neighbors only, and counts what happens: who took the reading, who relayed it, how many copies came back up the valley as echoes, and how far it got before the hops ran out. It ends with two mistakes: a node whose memory of packets is too small, and a payload too large for a frame.

The header layout is fixed and big-endian, and the byte-for-byte frame is pinned in the conformance vectors every binding checks itself against, so this page shows what a node does with a frame rather than restating one. Nothing about the frame is typed out in hex: the destination is checked against the exported BROADCAST address, the hop limit a new frame starts with comes from the crate's default, and the size on the air is measured off the frame that was built.

It proves:

  • A broadcast frame parses back out of the bytes that go on the air with its payload intact and its destination equal to BROADCAST.
  • A packet is identified as it floods by its source and sequence id, so the second copy to arrive is dropped instead of relayed again.
  • Relaying spends exactly one hop, and the forwarded bytes still parse and carry the same payload, because the checksum covers every byte except the hop limit.
  • A packet whose hops have run out is not relayed, which is what keeps a flood finite, and a flood that starts with a hop limit of 3 reaches four radio hops from its source.
  • Each node takes the reading once and relays it once. The copies its neighbor sends back are echoes, and the node's memory drops them.
  • A memory that holds too few packets forgets one while copies of it are still in the air, and relays a late copy again.
  • An inverted payload byte fails the checksum instead of arriving as a plausible reading, and a payload past what a frame carries is refused before it is built.

Run it#

The example below is a program CI runs on every change, in each language, from a clone of the repository:

Rust

cargo run -p pamoja-examples --example mesh

TypeScript

npm --prefix bindings/node run guides -- mesh

Python

python bindings/python/guides/mesh.py

C#

dotnet run --project bindings/dotnet/samples/Pamoja.Guides -- mesh

Rust#

In Rust, pamoja-mesh is no_std and never allocates. Frame builds a frame with new or broadcast, reads one with parse, and gives a relay its next copy with relayed, which is None once the hops run out. SeenCache<N> is a node's memory of the last N packets, sized at compile time; DynamicSeenCache takes its size at run time. crc16 and Crc16 are the checksum on their own. A frame that cannot be built or read is an Err holding a MeshError.

From examples/guides/mesh.rs:

Rust
use pamoja_mesh::{Frame, SeenCache, BROADCAST};

// A river gauge floods a level reading to every node in range. The header is fixed
// and big-endian: version, source, destination, sequence id, hop limit, then the
// payload and a checksum over everything but the hop limit.
const RIVER_GAUGE: u32 = 305_419_896;
let reading = Frame::broadcast(RIVER_GAUGE, 1, b"level=high")?;
let to = if reading.dst() == BROADCAST {
    "every node in range"
} else {
    "one node"
};
println!(
    "sent      {} bytes to {to}, hop limit {}",
    reading.as_bytes().len(),
    reading.hop_limit()
);

// A neighbor hears it. Every node in range rebroadcasts, so the same packet arrives
// several times over; the source and sequence id decide which copy is the first.
let received = Frame::parse(reading.as_bytes())?;
println!("payload   {}", String::from_utf8_lossy(received.payload()));
let mut seen: SeenCache<64> = SeenCache::new();
let first = seen.record(received.dedup_key());
let again = seen.record(received.dedup_key());
if first && !again {
    println!("dedup     the first copy is relayed, and the second is dropped");
}

// Relaying spends one hop. The checksum skips the hop-limit byte, so a relay forwards
// the frame without recomputing it and the check stays end to end.
let forwarded = received.relayed().expect("hops remain");
let onward = Frame::parse(forwarded.as_bytes())?;
println!(
    "relayed   hop limit {}, and the checksum still holds: {}",
    forwarded.hop_limit(),
    String::from_utf8_lossy(onward.payload())
);

// A frame that has run out of hops is not relayed again, which is what ends the flood.
if received.with_hop_limit(0).relayed().is_none() {
    println!("spent     at hop limit 0 the frame goes no further");
}

// A payload byte the air mangled fails the checksum rather than reaching the
// application as a plausible reading. The header is a fixed width, so the first byte
// past it is the first byte of the reading itself.
let mut mangled = reading.as_bytes().to_vec();
mangled[Frame::HEADER_LEN] ^= 0xFF;
match Frame::parse(&mangled) {
    Ok(_) => println!("a mangled frame was accepted, which should never happen"),
    Err(error) => println!("mangled   rejected: {error}"),
}

The flood down the valley, continuing from above:

From examples/guides/mesh.rs:

Rust
// Six nodes down a river valley, each in range of its neighbors only. The gauge at the
// top, node 0, floods a reading, and every node that hears a packet for the first time
// takes it and relays it while hops remain. Copies that come back up the valley are
// echoes, which the memory of each node drops.
let nodes = 6;
let mut memory = vec![SeenCache::<64>::new(); nodes];
let flooded = Frame::broadcast(0, 1, b"level=high")?;
memory[0].record(flooded.dedup_key());
let mut on_the_air = std::collections::VecDeque::from([(0usize, flooded)]);
let (mut delivered, mut relays, mut echoes, mut farthest) = (0, 0, 0, 0);
while let Some((from, frame)) = on_the_air.pop_front() {
    for node in [from.checked_sub(1), Some(from + 1)].into_iter().flatten() {
        if node >= nodes {
            continue;
        }
        let heard = Frame::parse(frame.as_bytes())?;
        if !memory[node].record(heard.dedup_key()) {
            echoes += 1;
            continue;
        }
        delivered += 1;
        farthest = farthest.max(node);
        if let Some(onward) = heard.relayed() {
            relays += 1;
            on_the_air.push_back((node, onward));
        }
    }
}
println!(
    "flood     {delivered} nodes took the reading, {relays} relayed it, {echoes} echoes were dropped"
);
println!(
    "reach     node {farthest} was the farthest, {farthest} hops out, and node {} never heard it",
    nodes - 1
);

// A node whose memory holds two packets hears the reading, then two packets from
// other nodes, then a late copy of the reading by a longer path. It has forgotten the
// reading by then and relays it again; on a busy mesh that repeats without end.
let mut small: SeenCache<2> = SeenCache::new();
let rain = Frame::broadcast(7, 1, b"rain=4mm")?;
let wind = Frame::broadcast(8, 1, b"wind=12")?;
small.record(flooded.dedup_key());
small.record(rain.dedup_key());
small.record(wind.dedup_key());
if small.record(flooded.dedup_key()) {
    println!("forgot    a memory of two packets relays the late copy again");
}

// A payload one byte past what a frame carries is refused before it is built. A frame
// is sized to the 250 bytes ESP-NOW carries, less the header and the checksum.
match Frame::broadcast(0, 2, &[0u8; Frame::MAX_PAYLOAD + 1]) {
    Ok(_) => println!("an oversized payload was framed, which should never happen"),
    Err(error) => println!(
        "too long  {} bytes refused: {error}",
        Frame::MAX_PAYLOAD + 1
    ),
}

TypeScript#

In TypeScript, @pamoja/mesh builds a frame with frame or broadcast, reads one with parse, and relays it with relayed, which returns null once the hops run out. A frame is a plain object with its bytes and each field. SeenPackets is a node's memory, sized when it is made, and record answers whether a packet is new. A frame that cannot be built or read throws an Error saying why.

From bindings/node/guides/mesh.ts:

TypeScript
import { BROADCAST, HEADER_LEN, SeenPackets, broadcast, parse, relayed } from '@pamoja/mesh'

// A river gauge floods a level reading to every node in range. The header is fixed and
// big-endian: version, source, destination, sequence id, hop limit, then the payload and
// a checksum over everything but the hop limit.
const RIVER_GAUGE = 305419896
const reading = broadcast(RIVER_GAUGE, 1, Buffer.from('level=high'))
const to = reading.dst === BROADCAST ? 'every node in range' : 'one node'
console.log(`sent      ${reading.bytes.length} bytes to ${to}, hop limit ${reading.hopLimit}`)

// A neighbor hears it. Every node in range rebroadcasts, so the same packet arrives
// several times over; the source and sequence id decide which copy is the first.
const received = parse(reading.bytes)
console.log(`payload   ${received.payload.toString()}`)
const seen = new SeenPackets(64)
const first = seen.record(received.src, received.id)
const again = seen.record(received.src, received.id)
if (first && !again) {
  console.log('dedup     the first copy is relayed, and the second is dropped')
}

// Relaying spends one hop. The checksum skips the hop-limit byte, so a relay forwards the
// frame without recomputing it and the check stays end to end.
const forwarded = relayed(received.bytes)!
const onward = parse(forwarded.bytes)
console.log(
  `relayed   hop limit ${forwarded.hopLimit}, and the checksum still holds: ${onward.payload.toString()}`,
)

// A frame that has run out of hops is not relayed again, which is what ends the flood.
if (relayed(broadcast(RIVER_GAUGE, 1, Buffer.from('level=high'), 0).bytes) === null) {
  console.log('spent     at hop limit 0 the frame goes no further')
}

// A payload byte the air mangled fails the checksum rather than reaching the application
// as a plausible reading. The header is a fixed width, so the first byte past it is the
// first byte of the reading itself.
const mangled = Buffer.from(reading.bytes)
mangled[HEADER_LEN] ^= 0xff
try {
  parse(mangled)
  console.log('a mangled frame was accepted, which should never happen')
} catch (error) {
  console.log(`mangled   rejected: ${(error as Error).message}`)
}

The flood down the valley, continuing from above:

From bindings/node/guides/mesh.ts:

TypeScript
import { DEFAULT_HOP_LIMIT, MAX_PAYLOAD } from '@pamoja/mesh'

// Six nodes down a river valley, each in range of its neighbors only. The gauge at the top,
// node 0, floods a reading, and every node that hears a packet for the first time takes it
// and relays it while hops remain. Copies that come back up the valley are echoes, which the
// memory of each node drops.
const nodes = 6
const memory = Array.from({ length: nodes }, () => new SeenPackets(64))
const flooded = broadcast(0, 1, Buffer.from('level=high'))
memory[0].record(flooded.src, flooded.id)
const onTheAir = [{ from: 0, bytes: flooded.bytes }]
let delivered = 0
let relays = 0
let echoes = 0
let farthest = 0
for (let sent = onTheAir.shift(); sent; sent = onTheAir.shift()) {
  for (const node of [sent.from - 1, sent.from + 1]) {
    if (node < 0 || node >= nodes) {
      continue
    }
    const heard = parse(sent.bytes)
    if (!memory[node].record(heard.src, heard.id)) {
      echoes += 1
      continue
    }
    delivered += 1
    farthest = Math.max(farthest, node)
    const onwardFrame = relayed(heard.bytes)
    if (onwardFrame) {
      relays += 1
      onTheAir.push({ from: node, bytes: onwardFrame.bytes })
    }
  }
}
console.log(`flood     ${delivered} nodes took the reading, ${relays} relayed it, ${echoes} echoes were dropped`)
console.log(
  `reach     node ${farthest} was the farthest, ${farthest} hops out, and node ${nodes - 1} never heard it`,
)

// A node whose memory holds two packets hears the reading, then two packets from other
// nodes, then a late copy of the reading by a longer path. It has forgotten the reading by
// then and relays it again; on a busy mesh that repeats without end.
const small = new SeenPackets(2)
const rain = broadcast(7, 1, Buffer.from('rain=4mm'))
const wind = broadcast(8, 1, Buffer.from('wind=12'))
small.record(flooded.src, flooded.id)
small.record(rain.src, rain.id)
small.record(wind.src, wind.id)
if (small.record(flooded.src, flooded.id)) {
  console.log('forgot    a memory of two packets relays the late copy again')
}

// A payload one byte past what a frame carries is refused before it is built. A frame is
// sized to the 250 bytes ESP-NOW carries, less the header and the checksum.
try {
  broadcast(0, 2, Buffer.alloc(MAX_PAYLOAD + 1))
  console.log('an oversized payload was framed, which should never happen')
} catch (error) {
  console.log(`too long  ${MAX_PAYLOAD + 1} bytes refused: ${(error as Error).message}`)
}

Python#

In Python, pamoja.mesh has frame, broadcast, parse, and relayed, which returns None once the hops run out, and SeenPackets for a node's memory. A frame's bytes and payload are bytes, and a hop limit can be set when a frame is built. A frame that cannot be built or read raises PamojaError.

From bindings/python/guides/mesh.py:

Python
from pamoja.core import PamojaError
from pamoja.mesh import BROADCAST, HEADER_LEN, SeenPackets, broadcast, parse, relayed

# A river gauge floods a level reading to every node in range. The header is fixed and
# big-endian: version, source, destination, sequence id, hop limit, then the payload and a
# checksum over everything but the hop limit.
RIVER_GAUGE = 305419896
reading = broadcast(RIVER_GAUGE, 1, b"level=high")
to = "every node in range" if reading.dst == BROADCAST else "one node"
print(f"sent      {len(reading.bytes)} bytes to {to}, hop limit {reading.hop_limit}")

# A neighbor hears it. Every node in range rebroadcasts, so the same packet arrives several
# times over; the source and sequence id decide which copy is the first.
received = parse(reading.bytes)
print(f"payload   {received.payload.decode()}")
seen = SeenPackets(64)
first = seen.record(received.src, received.id)
again = seen.record(received.src, received.id)
if first and not again:
    print("dedup     the first copy is relayed, and the second is dropped")

# Relaying spends one hop. The checksum skips the hop-limit byte, so a relay forwards the
# frame without recomputing it and the check stays end to end.
forwarded = relayed(received.bytes)
onward = parse(forwarded.bytes)
print(
    f"relayed   hop limit {forwarded.hop_limit}, and the checksum still holds: "
    f"{onward.payload.decode()}"
)

# A frame that has run out of hops is not relayed again, which is what ends the flood.
if relayed(broadcast(RIVER_GAUGE, 1, b"level=high", hop_limit=0).bytes) is None:
    print("spent     at hop limit 0 the frame goes no further")

# A payload byte the air mangled fails the checksum rather than reaching the application as a
# plausible reading. The header is a fixed width, so the first byte past it is the first byte
# of the reading itself.
mangled = bytearray(reading.bytes)
mangled[HEADER_LEN] ^= 0xFF
try:
    parse(bytes(mangled))
    print("a mangled frame was accepted, which should never happen")
except PamojaError as error:
    print(f"mangled   rejected: {error}")

The flood down the valley, continuing from above:

From bindings/python/guides/mesh.py:

Python
from collections import deque

from pamoja.mesh import DEFAULT_HOP_LIMIT, MAX_PAYLOAD

# Six nodes down a river valley, each in range of its neighbors only. The gauge at the top,
# node 0, floods a reading, and every node that hears a packet for the first time takes it
# and relays it while hops remain. Copies that come back up the valley are echoes, which the
# memory of each node drops.
nodes = 6
memory = [SeenPackets(64) for _ in range(nodes)]
flooded = broadcast(0, 1, b"level=high")
memory[0].record(flooded.src, flooded.id)
on_the_air = deque([(0, flooded.bytes)])
delivered = relays = echoes = farthest = 0
while on_the_air:
    sender, sent = on_the_air.popleft()
    for node in (sender - 1, sender + 1):
        if not 0 <= node < nodes:
            continue
        heard = parse(sent)
        if not memory[node].record(heard.src, heard.id):
            echoes += 1
            continue
        delivered += 1
        farthest = max(farthest, node)
        onward_frame = relayed(heard.bytes)
        if onward_frame is not None:
            relays += 1
            on_the_air.append((node, onward_frame.bytes))
print(f"flood     {delivered} nodes took the reading, {relays} relayed it, {echoes} echoes were dropped")
print(
    f"reach     node {farthest} was the farthest, {farthest} hops out, "
    f"and node {nodes - 1} never heard it"
)

# A node whose memory holds two packets hears the reading, then two packets from other nodes,
# then a late copy of the reading by a longer path. It has forgotten the reading by then and
# relays it again; on a busy mesh that repeats without end.
small = SeenPackets(2)
rain = broadcast(7, 1, b"rain=4mm")
wind = broadcast(8, 1, b"wind=12")
small.record(flooded.src, flooded.id)
small.record(rain.src, rain.id)
small.record(wind.src, wind.id)
if small.record(flooded.src, flooded.id):
    print("forgot    a memory of two packets relays the late copy again")

# A payload one byte past what a frame carries is refused before it is built. A frame is
# sized to the 250 bytes ESP-NOW carries, less the header and the checksum.
try:
    broadcast(0, 2, bytes(MAX_PAYLOAD + 1))
    print("an oversized payload was framed, which should never happen")
except PamojaError as error:
    print(f"too long  {MAX_PAYLOAD + 1} bytes refused: {error}")

C##

In C#, the static Mesh class builds a frame with Frame or BroadcastFrame, reads one with Parse, and relays it with Relayed, which returns null once the hops run out; the limits are constants on it. A MeshFrame is a record of the bytes and each field. SeenPackets is a node's memory, which holds native state and is disposed with using. A frame that cannot be built or read throws PamojaException.

From bindings/dotnet/samples/Pamoja.Guides/MeshGuide.cs:

C#
// A river gauge floods a level reading to every node in range. The header is fixed
// and big-endian: version, source, destination, sequence id, hop limit, then the
// payload and a checksum over everything but the hop limit.
const uint RiverGauge = 305_419_896;
MeshFrame reading = Mesh.BroadcastFrame(RiverGauge, 1, "level=high"u8);
string to = reading.Dst == Mesh.Broadcast ? "every node in range" : "one node";
Console.WriteLine($"sent      {reading.Bytes.Length} bytes to {to}, hop limit {reading.HopLimit}");

// A neighbor hears it. Every node in range rebroadcasts, so the same packet
// arrives several times over; the source and sequence id decide which copy is
// the first.
MeshFrame received = Mesh.Parse(reading.Bytes);
Console.WriteLine($"payload   {Encoding.UTF8.GetString(received.Payload)}");
using SeenPackets seen = new(64);
bool first = seen.Record(received.Src, received.Id);
bool again = seen.Record(received.Src, received.Id);
if (first && !again)
{
    Console.WriteLine("dedup     the first copy is relayed, and the second is dropped");
}

// Relaying spends one hop. The checksum skips the hop-limit byte, so a relay
// forwards the frame without recomputing it and the check stays end to end.
MeshFrame forwarded = Mesh.Relayed(received.Bytes)!;
MeshFrame onward = Mesh.Parse(forwarded.Bytes);
Console.WriteLine(
    $"relayed   hop limit {forwarded.HopLimit}, and the checksum still holds: " +
    Encoding.UTF8.GetString(onward.Payload));

// A frame that has run out of hops is not relayed again, which is what ends the
// flood.
if (Mesh.Relayed(Mesh.BroadcastFrame(RiverGauge, 1, "level=high"u8, hopLimit: 0).Bytes) is null)
{
    Console.WriteLine("spent     at hop limit 0 the frame goes no further");
}

// A payload byte the air mangled fails the checksum rather than reaching the
// application as a plausible reading. The header is a fixed width, so the first
// byte past it is the first byte of the reading itself.
byte[] mangled = reading.Bytes.ToArray();
mangled[Mesh.HeaderLen] ^= 0xFF;
try
{
    Mesh.Parse(mangled);
    Console.WriteLine("a mangled frame was accepted, which should never happen");
}
catch (PamojaException error)
{
    Console.WriteLine($"mangled   rejected: {error.Message}");
}

The flood down the valley, continuing from above:

From bindings/dotnet/samples/Pamoja.Guides/MeshGuide.cs:

C#
// Six nodes down a river valley, each in range of its neighbors only. The gauge at
// the top, node 0, floods a reading, and every node that hears a packet for the
// first time takes it and relays it while hops remain. Copies that come back up the
// valley are echoes, which the memory of each node drops.
const int Nodes = 6;
SeenPackets[] memory = Enumerable.Range(0, Nodes).Select(_ => new SeenPackets(64)).ToArray();
MeshFrame flooded = Mesh.BroadcastFrame(0, 1, "level=high"u8);
memory[0].Record(flooded.Src, flooded.Id);
var onTheAir = new Queue<(int From, byte[] Bytes)>();
onTheAir.Enqueue((0, flooded.Bytes));
int delivered = 0;
int relays = 0;
int echoes = 0;
int farthest = 0;
while (onTheAir.TryDequeue(out var sent))
{
    foreach (int node in new[] { sent.From - 1, sent.From + 1 })
    {
        if (node < 0 || node >= Nodes)
        {
            continue;
        }

        MeshFrame heard = Mesh.Parse(sent.Bytes);
        if (!memory[node].Record(heard.Src, heard.Id))
        {
            echoes++;
            continue;
        }

        delivered++;
        farthest = Math.Max(farthest, node);
        if (Mesh.Relayed(heard.Bytes) is { } onwardFrame)
        {
            relays++;
            onTheAir.Enqueue((node, onwardFrame.Bytes));
        }
    }
}

foreach (SeenPackets cache in memory)
{
    cache.Dispose();
}

Console.WriteLine($"flood     {delivered} nodes took the reading, {relays} relayed it, {echoes} echoes were dropped");
Console.WriteLine(
    $"reach     node {farthest} was the farthest, {farthest} hops out, and node {Nodes - 1} never heard it");

// A node whose memory holds two packets hears the reading, then two packets from
// other nodes, then a late copy of the reading by a longer path. It has forgotten
// the reading by then and relays it again; on a busy mesh that repeats without end.
using SeenPackets small = new(2);
MeshFrame rain = Mesh.BroadcastFrame(7, 1, "rain=4mm"u8);
MeshFrame wind = Mesh.BroadcastFrame(8, 1, "wind=12"u8);
small.Record(flooded.Src, flooded.Id);
small.Record(rain.Src, rain.Id);
small.Record(wind.Src, wind.Id);
if (small.Record(flooded.Src, flooded.Id))
{
    Console.WriteLine("forgot    a memory of two packets relays the late copy again");
}

// A payload one byte past what a frame carries is refused before it is built. A
// frame is sized to the 250 bytes ESP-NOW carries, less the header and the checksum.
try
{
    Mesh.BroadcastFrame(0, 2, new byte[Mesh.MaxPayload + 1]);
    Console.WriteLine("an oversized payload was framed, which should never happen");
}
catch (PamojaException error)
{
    Console.WriteLine($"too long  {Mesh.MaxPayload + 1} bytes refused: {error.Message}");
}

Values at a glance#

A frame on the air, big-endian:

BytesField
0version1
1 to 4source nodethe address of the node the packet started at
5 to 8destination nodeone node's address, or BROADCAST, 0xFFFFFFFF, for every node
9 to 10sequence idthe source's number for the packet, rising with each one
11hop limithow many more relays the packet may take
12 onwardpayloadup to 236 bytes
last 2checksumCRC-16 over every byte except the hop limit

The limits:

LimitValue
Largest frame250 bytes, what one ESP-NOW frame carries
Header and checksum14 bytes
Largest payload236 bytes
Hop limit a new frame starts with3
Radio hops a flood reachesone more than the hop limit it starts with
Packets a node remembers, when nothing says otherwise64

The checksum is CRC-16/CCITT-FALSE: polynomial 0x1021, starting from 0xFFFF, with no reflection and no final inversion. Over the nine characters 123456789 it comes to 0x29B1, the check value every implementation of it prints. It skips the hop limit, so a relay changes that byte without recomputing the checksum, and the check holds from the source to every node the flood reaches.

What a node does with a frame it hears:

The frameThe node
fails its checksum, or names another versiondrops it
is a packet it has seen beforedrops it as a duplicate
is new, with hops lefttakes it, and relays a copy with one hop fewer
is new, with no hops lefttakes it, and relays nothing

What a frame can be refused for:

RefusalMeans
mesh payload is larger than a single frame can carrymore than 236 bytes to send
mesh frame is shorter than its header and checksumfewer than 14 bytes arrived
mesh frame is larger than the maximum frame sizemore than 250 bytes arrived
unsupported mesh protocol versiona frame from a newer build
mesh CRC mismatchthe frame changed on the way

The calls in each language:

Rust#

ToCall
build a frameFrame::new(src, dst, id, payload), Frame::broadcast(src, id, payload), with_hop_limit(hops)
read oneFrame::parse(bytes), then src(), dst(), id(), hop_limit(), payload(), is_broadcast()
relay itframe.relayed(), None once the hops run out
remember packetsSeenCache::<64>::new(), DynamicSeenCache::new(capacity), record(frame.dedup_key()), contains(key)
check bytescrc16(data), Crc16::new(), update(part), finish()

TypeScript#

ToCall
build a frameframe(src, dst, id, payload, hopLimit?), broadcast(src, id, payload, hopLimit?)
read oneparse(bytes), then src, dst, id, hopLimit, payload
relay itrelayed(bytes), null once the hops run out
remember packetsnew SeenPackets(capacity), record(src, id)
check bytescrc16(data)

Python#

ToCall
build a frameframe(src, dst, id, payload, hop_limit=None), broadcast(src, id, payload, hop_limit=None)
read oneparse(data), then src, dst, id, hop_limit, payload
relay itrelayed(data), None once the hops run out
remember packetsSeenPackets(capacity), record(src, id)
check bytescrc16(data)

C##

ToCall
build a frameMesh.Frame(src, dst, id, payload, hopLimit), Mesh.BroadcastFrame(src, id, payload, hopLimit)
read oneMesh.Parse(bytes), then Src, Dst, Id, HopLimit, Payload
relay itMesh.Relayed(bytes), null once the hops run out
remember packetsnew SeenPackets(capacity), Record(src, id)
check bytesMesh.Crc16(data)

When it goes wrong#

A frame that cannot be read is refused with the reason, and nothing else in a mesh reports an error: its mistakes show up as traffic. The ones that cost an afternoon:

  • The mesh drowns in its own traffic. A node that relays without recording what it has seen, or with a memory of none, relays every copy, and each relay makes more. Record every packet a node hears before it relays anything.
  • A packet comes round again and again. A node's memory is too small for the traffic, so it forgets a packet while copies of it are still in the air. Size it for every packet that can be in flight at once across the mesh, not for the node's own.
  • Nodes at the edge never hear. A flood reaches one radio hop more than the hop limit it starts with. Raise the limit for a longer chain of nodes, rather than relaying without one.
  • The air is always busy. Every node relays every new packet once, so a dense mesh spends its airtime on relays. Lower the hop limit where nodes are close together, and keep the payload short.
  • Every packet from one node is dropped as a duplicate. A packet is known by its source and sequence id, so a node that restarts and counts from 1 again sends ids its neighbors still remember. Keep the sequence counting across restarts, or give the node a new address.
  • A payload is refused, or never arrives. A frame holds 236 bytes of payload, sized to ESP-NOW. An nRF24 carries 32 bytes a packet, so on that radio a payload must stay at 18 bytes.
  • A forged reading passes the checksum. A CRC catches what the air does, not what a person does: anyone can recompute it. Carry readings that matter in a secured session and use the mesh to move its frames.

Where next#

  • Routing: Reverse-path routing that learns the cheapest route from overheard traffic.
  • Store and forward: Offline-first queues in memory or on disk, bounded or not, the on-disk one surviving power loss, and the drain that forwards them in order when a link returns.
  • LoRa radios: The Semtech SX126x and SX127x LoRa radios and the SX1302 and SX1303 gateway concentrators.
  • Also in Radio and reach: LoRa airtime and range, LoRaWAN, LoRaWAN gateways.

Reference#

Edit this page on GitHub