PPNet (pp_net v0.1.5)

Copy Markdown View Source

Message protocol with error correction (Reed-Solomon) and framing (COBS).

For the full protocol specification, see the README.

Encoding

encode_message/2 encodes a message struct into a binary frame ready to be sent. When the message exceeds the frame limit (default 254 bytes), it is automatically split into chunks and a list [header_binary | chunk_binaries] is returned.

iex> %PPNet.Message.Hello{
...>   unique_id: "TestRunner",
...>   board_identifier: "Tester",
...>   version: 4660,
...>   board_version: 17_185,
...>   boot_id: 87_372_886,
...>   ppnet_version: 1,
...>   datetime: ~U[2026-03-26 21:00:55.352750Z]
...> } |> PPNet.encode_message()
<<46, 1, 151, 170, 84, 101, 115, 116, 82, 117, 110, 110, 101, 114, 166, 84, 101,
  115, 116, 101, 114, 205, 18, 52, 205, 67, 33, 206, 5, 53, 52, 86, 1, 206, 105,
  197, 158, 135, 37, 216, 194, 76, 126, 139, 15, 150, 0>>

iex> %PPNet.Message.SingleCounter{
...>   kind: "bar",
...>   value: 42,
...>   pulses: 0,
...>   duration_ms: 1500,
...>   datetime: ~U[2026-03-27 12:58:06Z]
...> } |> PPNet.encode_message()
<<8, 2, 149, 163, 98, 97, 114, 42, 17, 205, 5, 220, 206, 105, 198, 126, 222, 52,
  64, 141, 44, 85, 238, 249, 45, 0>>

iex> %PPNet.Message.Ping{
...>   session_id: "5388724c-457e-4332-a98c-e67b2053662c",
...>   temperature: 25.0,
...>   uptime_ms: 3_600_000,
...>   location: %{lat: 40.7128, lon: -74.0060, accuracy: 10_000},
...>   cpu: 0.5,
...>   tpu_memory_percent: 50,
...>   tpu_ping_ms: 100,
...>   wifi: [%{mac: "00:1A:2B:3C:4D:5E", rssi: -42}],
...>   datetime: ~U[2026-03-27 16:25:12Z],
...>   storage: %{total: 512_000, used: 128_000}
...> } |> PPNet.encode_message()
<<4, 3, 155, 220, 25, 16, 83, 204, 136, 114, 76, 69, 126, 67, 50, 204, 169, 204,
  140, 204, 230, 123, 32, 83, 102, 44, 203, 64, 57, 1, 1, 1, 1, 1, 2, 206, 29,
  54, 238, 128, 147, 203, 64, 68, 91, 61, 7, 200, 75, 94, 203, 192, 82, 128, 98,
  77, 210, 241, 170, 205, 39, 16, 203, 63, 224, 1, 1, 1, 1, 1, 5, 50, 100, 145,
  167, 9, 26, 43, 60, 77, 94, 214, 146, 206, 3, 7, 208, 2, 206, 3, 1, 244, 15,
  206, 105, 198, 175, 104, 128, 140, 122, 226, 25, 11, 92, 136, 195, 0>>

iex> %PPNet.Message.Event{
...>   kind: :detection,
...>   data: %{"sensor_id" => 1, "value" => 100},
...>   datetime: ~U[2026-03-27 18:46:39Z]
...> } |> PPNet.encode_message()
<<36, 4, 147, 1, 130, 165, 118, 97, 108, 117, 101, 100, 169, 115, 101, 110, 115,
  111, 114, 95, 105, 100, 1, 206, 105, 198, 208, 143, 218, 29, 196, 116, 1, 118,
  175, 195, 0>>

Event can also be chunked when the data payload is large:

# [header_bin | chunk_bins] = PPNet.encode_message(%PPNet.Message.Event{
#   kind: :detection,
#   data: %{"image_id" => image_id, "d" => detections}
# })

Image is usually large enough to be chunked:

iex> image_data = File.read!("test/support/static/image.webp")
iex> image = %PPNet.Message.Image{
...>   id: "00000000-0000-0000-0000-000000000000",
...>   data: image_data,
...>   format: :webp,
...>   datetime: ~U[2026-03-27 20:15:41Z]
...> }
iex> [header_bin | chunks_bin] = PPNet.encode_message(image)
iex> %{messages: [header | chunks], errors: []} = PPNet.parse([header_bin | chunks_bin])
iex> PPNet.chunked_to_message([header | chunks])
{:ok, %PPNet.Message.Image{id: "00000000-0000-0000-0000-000000000000", format: :webp, data: image_data, datetime: ~U[2026-03-27 20:15:41Z]}}

The optional limit parameter controls the maximum frame size in bytes (default 254). Use it to produce smaller chunks when the channel has tighter constraints:

iex> image_data = File.read!("test/support/static/image.webp")
iex> [_header | chunks] = PPNet.encode_message(
...>   %PPNet.Message.Image{
...>     id: "00000000-0000-0000-0000-000000000000",
...>     data: image_data,
...>     format: :webp,
...>     datetime: ~U[2026-03-27 20:15:41Z]
...>   },
...>   limit: 100
...> )
iex> Enum.all?(chunks, &(byte_size(&1) <= 100))
true

Parsing

parse/1 accepts a binary or iodata and returns %{messages: [...], errors: [...]}.

Each encoded message is wrapped with COBS (Consistent Overhead Byte Stuffing), which escapes the payload so it never contains the byte 0x00. A 0x00 delimiter is then appended after each frame, allowing multiple messages to be concatenated in a stream and reliably separated on the receiver side. parse/1 splits on 0x00 and decodes each frame automatically.

Every binary returned by encode_message/2 — including each chunk in a chunked message — already includes the 0x00 delimiter at the end. Concatenating them with Enum.join/1 is therefore sufficient; no extra separator is needed.

Before parsing the message body, each frame is passed through Reed-Solomon error correction (8 parity bytes), which can transparently recover up to 4 corrupted bytes per frame. When corrections are made, a warning is logged. If corruption exceeds 4 bytes, the frame is discarded and an error is added to the result — but the remaining frames in the stream are still processed normally. Errors never interrupt the parsing of other messages.

iex> hello = %PPNet.Message.Hello{
...>   unique_id: "TestRunner",
...>   board_identifier: "Tester",
...>   version: 4660,
...>   board_version: 17_185,
...>   boot_id: 87_372_886,
...>   ppnet_version: 1,
...>   datetime: ~U[2026-03-26 21:00:55Z]
...> }
iex> hello |> PPNet.encode_message() |> PPNet.parse() |> Map.get(:messages) |> hd() == hello
true

Reed-Solomon corrects up to 4 corrupted bytes transparently — a warning is logged when corrections are made. If corruption exceeds 4 bytes, the frame is discarded and an error is returned, but remaining frames in the stream are still processed normally:

# %{
#   messages: [%PPNet.Message.Ping{}],
#   errors: [%PPNet.ParseError{reason: {:reed_solomon, "decode_failed"}}]
# } = PPNet.parse(stream_with_one_bad_frame)

# %{
#   messages: [],
#   errors: [%PPNet.ParseError{reason: {:cobs, "Offset byte specifies more bytes than available"}}]
# } = PPNet.parse(garbage_binary)

PPNet.ParseError

Errors are returned as %PPNet.ParseError{} structs with the following fields:

  • message - Human-readable description of the error
  • reason - Machine-readable reason, e.g. {:reed_solomon, "decode_failed"}, {:cobs, reason}, :unknown_format, :unknown_type
  • data - Raw data associated with the failure, useful for debugging

Limitations

  • Maximum frame size: 254 bytes (COBS limit). Larger messages are automatically chunked.
  • Minimum chunk size: 22 bytes — the encoded size of a ChunkedMessageHeader frame. Going below this would cause the header itself to be chunked, which is not supported.
  • Reed-Solomon can correct up to 4 corrupted bytes per frame (8 parity bytes, GF(2⁸)).

Reassembling chunked messages

After parsing a stream that contains chunked frames, use chunked_to_message/1 to reassemble the original message:

payload = Enum.join([header_bin | chunk_bins])

%{messages: [header | chunks], errors: []} = PPNet.parse(payload)

{:ok, %PPNet.Message.Image{data: ^raw_binary, format: :webp}} =
  PPNet.chunked_to_message([header | chunks])

Message types

TypeModule
1PPNet.Message.Hello
2PPNet.Message.SingleCounter
3PPNet.Message.Ping
4PPNet.Message.Event
5PPNet.Message.Image
6PPNet.Message.ChunkedMessageHeader
7PPNet.Message.ChunkedMessageBody

Summary

Functions

Reassembles a chunked message from a ChunkedMessageHeader and its ChunkedMessageBody fragments.

Encodes a message struct into a COBS-framed, Reed-Solomon-protected binary.

Parses a binary or iodata stream of COBS-framed messages.

Functions

chunked_to_message(chunked_message)

Reassembles a chunked message from a ChunkedMessageHeader and its ChunkedMessageBody fragments.

Expects a list starting with a %ChunkedMessageHeader{} followed by all corresponding %ChunkedMessageBody{} chunks. Fragments are sorted by chunk_index before reassembly. Returns {:ok, message} or {:error, reason}.

encode_message(message, opts \\ [])

Encodes a message struct into a COBS-framed, Reed-Solomon-protected binary.

Returns a single binary when the message fits within limit bytes (default 254), or a list [header_binary | chunk_binaries] when the message is too large and must be split into chunks.

Options

  • :limit - Maximum frame size in bytes. Defaults to 254. Clamped to the range 22..254. The minimum of 22 matches the encoded size of a ChunkedMessageHeader frame — going below that would cause the header itself to be chunked. 254 is the COBS limit.

parse(data)

Parses a binary or iodata stream of COBS-framed messages.

Splits the input on 0x00 delimiters, decodes each frame with COBS, applies Reed-Solomon error correction, and parses the message body. Returns a map with :messages and :errors. Failed frames are added to :errors without interrupting the processing of other frames.