Protocol
This page is everything a server needs to implement. Over MQTT that is about
60 lines in any language with an MQTT client; OtaServer in this library is
the same server side in C++.
Binary frames
Every transport uses the same frames. They're little-endian, and every frame
starts with the same six bytes:
| Offset | Size | Field |
|---|---|---|
| 0 | 1 | type: 1 Offer, 2 Request, 3 Chunk, 4 Status, 5 Command |
| 1 | 1 | protocol revision, currently 1. A receiver refuses any other revision |
| 2 | 4 | job: the server's number for this transfer; never 0 |
Request (node → server), 16 bytes
| Offset | Size | Field |
|---|---|---|
| 6 | 4 | offset |
| 10 | 4 | length: send [offset, offset + length) |
| 14 | 2 | max chunk: the largest chunk payload the node's link carries |
The server slices the range into chunks no bigger than max chunk (and its own
limit) and sends them in order. A node may send a new request before the last
one is finished. If its offset is where the previous range ended, it extends
that range; otherwise it replaces it (the node is asking again from where it
really is).
Chunk (server → node), 14 + n bytes
| Offset | Size | Field |
|---|---|---|
| 6 | 4 | offset of the first byte |
| 10 | 4 | CRC-32 (zlib, 0xEDB88320 reflected) of the payload |
| 14 | n | payload |
A node keeps a chunk only if its offset is exactly the next one expected and
its CRC matches. Anything else is dropped; a gap triggers an immediate
re-request.
Offer (server → node)
| Offset | Size | Field |
|---|---|---|
| 6 | 4 | image size |
| 10 | 1 | flags: 0x01 manual apply, 0x02 force |
| 11 | 1 | signature length: 0 (unsigned) or 64 |
| 12 | 32 | SHA-256 of the image |
| 44 | 1 + v | version, length-prefixed, < 32 bytes |
| … | 1 + b | board, length-prefixed, < 32 bytes |
| … | s | signature |
The frame ends exactly after the signature; a frame with bytes left over is
refused.
Status (node → server)
| Offset | Size | Field |
|---|---|---|
| 6 | 1 | state: 0 idle, 1 offered, 2 downloading, 3 verifying, 4 ready, 5 applying, 6 testing, 7 checking |
| 7 | 1 | outcome: 0 none, 1 success, 2 failed |
| 8 | 1 | result: OtaResult (see below) |
| 9 | 1 | reserved, 0 |
| 10 | 4 | bytes received |
| 14 | 4 | image size |
| 18 | 1 + r | running version, length-prefixed |
Command (server → node), 7 bytes
| Offset | Size | Field |
|---|---|---|
| 6 | 1 | 1 abort, 2 apply, 3 confirm, 4 rollback, 5 status |
Job 0 means "whatever is running". A command for any other job is ignored,
except status, which is always answered.
The signature
The signature covers exactly these 114 bytes:
"modbee-ota-v1" 0x00 | sha256[32] | size u32 LE | board[32] | version[32]
Board and version are zero padded (so each is at most 31 bytes).
ota_signedMessage() builds it on the node. The node's verifier decides the
scheme; both signatures are 64 bytes:
| Verifier | Signature | Public key on the node |
|---|---|---|
OtaEd25519Verifier |
Ed25519 over the 114 bytes | 32 bytes, raw |
OtaEcdsaP256Verifier |
ECDSA P-256 over SHA-256 of the 114 bytes, r then s, 32 bytes each, big-endian |
64 bytes, X then Y |
Put it in the offer as hex (JSON "signature") or raw (binary Offer frame).
Make a key once (openssl genpkey -algorithm ed25519 -out ota-key.pem, or
-algorithm EC -pkeyopt ec_paramgen_curve:P-256 for P-256), then sign each
image. In Python, with the cryptography package:
import hashlib, struct
from cryptography.hazmat.primitives import serialization
def signed_message(image: bytes, board: str, version: str) -> bytes:
pad = lambda text: text.encode().ljust(32, b"\0")
return (b"modbee-ota-v1\0" + hashlib.sha256(image).digest()
+ struct.pack("<I", len(image)) + pad(board) + pad(version))
image = open("firmware.bin", "rb").read()
key = serialization.load_pem_private_key(open("ota-key.pem", "rb").read(), None)
signature = key.sign(signed_message(image, "my-board", "1.0.1")) # Ed25519
public_key = key.public_key().public_bytes(serialization.Encoding.Raw,
serialization.PublicFormat.Raw)
Keep the private key off the devices and out of version control.
MQTT topics
Under a base chosen per node, for example modbee/node-uio-1/ota:
| Topic | Direction | Payload | QoS / retain |
|---|---|---|---|
<base>/offer |
in | JSON offer, or a binary Offer frame | 1, may be retained |
<base>/data |
in | binary Chunk frames | 0 or 1 |
<base>/cmd |
in | abort apply confirm rollback status, or a Command frame |
1 |
<base>/req |
out | binary Request frames | 1, series (never superseded) |
<base>/status |
out | JSON status | 1, retained |
A server subscribes to +/ota/req and +/ota/status (or the equivalent
wildcard for its layout) to serve every node at once.
JSON offer
{"job": 17, "version": "1.4.0", "board": "modbee-node-uio", "size": 1048576,
"sha256": "<64 hex>", "signature": "<128 hex>", "apply": "auto", "force": false}
signature, apply ("auto" or "manual") and force are optional.
Unknown keys are skipped, so a platform can add its own (notes, url, …).
JSON status
{"state":"downloading","outcome":"none","result":"ok","job":17,
"received":524288,"size":1048576,"progress":50,"board":"modbee-node-uio",
"running":"1.3.2","offered":"1.4.0"}
The status is published on every state change, every second or so while
downloading, on every reconnect, and once after boot. After boot it reports
how the last job ended.
Sparkplug B metrics
Two ways to use them. Beside the MQTT topics (OpenPLC's OTA_SPARKPLUG):
the image travels on <base>/data over the same connection, and Sparkplug
carries the status and the controls:
| Metric | Type | Direction |
|---|---|---|
Firmware/Version, Firmware/State, Firmware/Offered |
String | NDATA |
Firmware/Progress |
Int32, whole percent | NDATA |
Firmware/Status |
String (JSON status) | NDATA |
Node Control/Firmware Accept, Reject, Apply, Abort, Confirm, Rollback |
Boolean, TRUE to act | NCMD |
Instead of them (OtaSparkplugLink), for a host that can only speak
Sparkplug - the image rides in metrics:
| Metric | Type | Direction |
|---|---|---|
Node Control/Firmware Offer |
String (JSON offer) | NCMD |
Node Control/Firmware Chunk |
Bytes (Chunk frame) | NCMD |
Node Control/Firmware Command |
String | NCMD |
Firmware/Request |
Bytes (Request frame) | NDATA |
Firmware/Status |
String (JSON status) | NDATA |
Firmware/State, Firmware/Progress, Firmware/Version |
String, Int32, String | NDATA |
The host writes one chunk per request it sees. A metric only holds its latest
write, so the node never has more than one chunk in flight on this link.
Security
A signature protects the image: with a verifier set, the node installs only
what the key signed, for its board, at that version. It does not protect the
commands (abort, apply, confirm, rollback), or the status the node
publishes. Use the broker's access control so only the server can write a
node's offer, data and cmd topics, and TLS where the network is not
trusted.
Result codes
0 ok, 1 pending, 2 busy, 3 invalid_argument, 4 not_ready, 5 no_memory, 6 unsupported, 7 io_error, 8 wrong_board, 9 too_large, 10 same_version, 11 downgrade, 12 unsigned, 13 bad_signature, 14 refused, 15 timeout, 16 bad_chunk, 17 hash_mismatch, 18 bad_image, 19 aborted, 20 rolled_back, 21 not_confirmed.
Append only: the numbers are in Status frames and in the OpenPLC
OTA_RESULT datatype.