Architecture
ModBee OTA updates a node's firmware over whatever link reaches it. It has
three kinds of part: one engine, and two kinds of plug-in that keep the engine
independent of hardware and transport.
server side node side
┌──────────────────────────────┐ ┌──────────────────────────────────┐
│ platform (any MQTT server) │ │ OtaUpdater │
│ or OtaServer (gateway, test) │ │ offer → check → pull → verify → │
└──────────────┬───────────────┘ │ activate → reboot → confirm │
│ offer, chunks, commands └───────┬──────────────────┬───────┘
│◄──── requests, status ──── │ │
▼ ▼ ▼
┌───────────────┐ one wire format ┌──────────┐ ┌─────────────┐
│ any broker / │◄───────────────────►│ OtaLink │ │ OtaTarget │
│ bus / cable │ └──────────┘ └─────────────┘
└───────────────┘ MQTT ESP32 A/B slots
Sparkplug B Staged + bootloader
BeeBus (next) (STM32, RP2040, ...)
| Part | What it is | Exists today |
|---|---|---|
OtaUpdater |
The node's engine. A state machine that does a bounded slice of work per update(). |
yes |
OtaTarget |
Where the image goes. One per kind of microcontroller. | OtaEsp32Target, OtaStagedTarget |
OtaLink |
How the node is reached. One per transport. | OtaMqttDeviceLink, OtaMqttClientLink, OtaSparkplugLink |
OtaVerifier |
Signature check. | OtaEd25519Verifier (ESP32, libsodium), OtaEcdsaP256Verifier (wolfSSL) |
OtaStore |
Remembers a job across the install reboot. | OtaEsp32Store (NVS) |
OtaServer |
The sender side, for a gateway or a node feeding a neighbour. | yes |
OtaImageSource |
Where a server reads an image. It may answer later, which is what makes a relay possible. | OtaMemorySource |
The core (src/*.cpp, src/staged/) has no Arduino or ESP-IDF code in it and
is tested on the host. Platform code lives only in src/esp32/ and in the
header-only links (src/links/), which are included after the library they
adapt.
The life of a job
Idle ──offer──► [Offered] ──accept──► Downloading ──all bytes──► Verifying
▲ │ reject │ abort/timeout/IO │ hash, image check
│ ▼ ▼ ▼
│ Idle (Failed: refused) Idle (Failed: reason) Ready ──apply──► Applying
│ │ boot slot switched,
│ │ record saved, reboot
│ ▼
└──── confirm (Success) ◄──── Testing ◄───── new image boots on trial ◄────── (reboot)
│ window expires / app rejects
▼
rollback → old image boots → reports RolledBack / NotConfirmed
- Offered and Ready are only visited when the application asks for
them (autoAccept = false,autoApply = false, or"apply":"manual"in the
offer). A PLC can hold an update until the machine is idle. - Checking comes between the offer and the download when a verifier is
set: the signature is checked before a byte is pulled, and a bad one ends
the job as a refusal. - Abort works up to Ready. Once Applying, the boot slot is switching and
abort()answersBusy. - Refusals are outcomes. A wrong board, a downgrade or a bad signature is
reported in a status with the job's number, so the server needs nothing
more than the status topic to learn why. - The record (
OtaStore) is written once before the reboot and once when
the outcome is known. After a rollback, the old firmware reads it and
reportsrolled_backfor the job. Without a record, the server would see
the old version come back and have to guess why. - No retry loops. A job that installed or rolled back is never installed
again under the same job number. A retained offer cannot cause an
install, roll back, install cycle. Transient failures (timeout, bad hash)
can be retried with the same number.
Why the node pulls
The node asks for [offset, offset + length) and the server answers with
chunks that carry their own offset and CRC-32. Everything else follows from
that:
- A lost message costs one request. A gap in the offsets is noticed at
once and asked for again; no chunk is ever "acknowledged". - Resume is free. A link that drops pauses the job (no timeout runs while
ready()is false), and the next request continues where the node is.
A server that restarts loses nothing: the node's next request rebuilds its
state. - Flow control is the node's buffer. The node never asks for more than its
ring has room for. The server can't overrun it, and a slow flash write
slows the transfer instead of dropping bytes. - Latest-value links are safe. BeeBus topics and Sparkplug metrics keep
only the newest write. A superseded request is harmless, because the next
one asks from the right place. Those links setmaxWindow()to one chunk. - A gateway can relay without storing. It forwards a node's requests
upstream (or serves them from a small block cache) and never needs the whole
image. See ROADMAP.md.
Running beside a PLC: measured on the Node UIO
update() never waits on the network, and on the ESP32 it never waits on
flash either. Getting there took measurement, because ESP32 flash has three
behaviours that matter to a PLC. All were measured on the Node UIO (ESP32-S3,
Arduino core 3.3, ESP-IDF 5.5), with PLC-like probe tasks on both cores: tiny,
data-cache-missing, and instruction-cache-missing.
| What | Measured | How the library handles it |
|---|---|---|
| A 4 kB sector erase, an image check, a slot switch | 45–345 ms of CPU | On a lowest-priority worker (OtaAsyncTarget + OtaEsp32Worker). The caller does a memcpy |
| Every rewrite of otadata, the boot record | both cores frozen 45–55 ms | Only ever written at boot, before setup() (ota_esp32Boot()). The slot switch goes through NVS, confirm is a software trial |
| The first erase after the program starts | both cores frozen 40–50 ms | Spent on purpose on a blank sector (ota_esp32WarmUp()), in OpenPLC in the block's first scan, which the watchdog does not time |
What a PLC sees, measured with OpenPLC on the Node UIO in a typical layout
(logic task at T#20ms priority 0; MQTT and OTA at T#50ms priority 20),
pushing a 1 MB update:
| Logic task (20 ms) | Comms task (50 ms) | |
|---|---|---|
| Idle | scan max 0.13 ms, 0 overruns | scan max 1.6 ms |
| 40 KB/s of MQTT traffic, no flash | scan max 0.14 ms, 0 overruns | scan max 6.5 ms |
| OTA download + verify (~25 s) | scan max 2.6 ms, 309 skipped releases of 2,030 | scan max 77 ms, limit 500 ms |
| Switch, reboot, confirm | the PLC restarts, as any firmware update must |
The PLC never faulted, and the logic's own scans stayed short. But each
sector erase made the logic task miss about one release: the flash work
itself, since the same traffic without it caused none. The stock Arduino core
is built without CONFIG_SPI_FLASH_AUTO_SUSPEND, so an erase holds the
system up for its whole ~45 ms. Until a core with auto-suspend is in use:
- let the program choose when.
AUTO_ACCEPT := FALSEandACCEPTonly
while the machine is in a safe state, which is industry practice for
firmware updates anyway; - keep OTA in the comms task, never the logic task, at T#50ms, which is
the layout tested. Network calls in
that task stretched to ~200 ms during a download, and the watchdog stops a
task stuck for 10 periods; - an earlier test layout tripped the watchdog: MQTT and OTA in a 10 ms
task, withMQTT_CLIENTalso bringing up WiFi. That configuration is not
suitable for OTA.
The updater's own ring (bufferSize, default 8 kB) is allocated when a job
starts and freed when it ends; OtaAsyncTarget keeps its own from the first
job on.
Slow and unreliable links
ModBee nodes are reached over cellular, NB-IoT and radio as well as WiFi. Every
repeated byte costs airtime and money there, so the transfer is built to
learn the link instead of assuming it:
- The request timeout adapts. The updater times requests (RFC 6298, as TCP
does) and waits round trip + 4 × variation, betweenminRequestTimeoutMs
andmaxRequestTimeoutMs. NB-IoT with 1.5 s each way settled on a 4.4 s
timeout by itself and repeated nothing. - After a timeout it backs off and starts small. The timeout doubles, and
the window drops to one chunk and grows back a chunk per chunk received.
A link that just choked is not flooded again with a whole window of
repeats. The backoff clears as soon as data flows. - A stall is not a failure. The job ends with
Timeoutonly after
stallTimeoutMs(10 minutes) without progress while the link is up. While
the link is down the job just waits. - A power cut costs only what was in flight. Progress is saved every
resumeEveryBytes(64 kB). When the same job is offered again (a retained
offer does that by itself), the node carries on from the last saved sector,
re-hashing what it already has from flash. If the final hash fails after a
resume, it starts once more from scratch before reporting a failure. - Buffer ≥ bandwidth × round trip. The node never has more in flight than
its buffer holds, so on a high-latency link a small buffer, not the link,
sets the speed. On the Node UIO, a 200 kbit/s link with 1 s latency ran
at ~6 KB/s with the 8 kB default. Use 32 kB for cellular.
Measured on the host with simulated links, and on the Node UIO over a
throttled, high-latency link:
| Link | Result |
|---|---|
| NB-IoT, 20 kbit/s, 1.5 s each way | 1.01× the image, no repeats |
| Cat-M1, 100 kbit/s, 90 s dead zones every 2 min while "up" | 1.02×, completes through 8 dead zones |
| 5 min outage mid-transfer | waits, finishes |
| link up but dead | Timeout after the stall time, 6 repeats in 5 min |
| Node UIO, power cut at 49 % | resumed from 58 %, 1.01× in total (1.46× before resume existed) |
LoRaWAN is not a firmware link. Downlinks are 51–242 bytes, limited by
duty cycle, and often capped at a handful a day by the network. A 1 MB image
would take days to weeks. Use LoRaWAN for status and for the command that
tells a node to update over another link. LoRaWAN's own FUOTA (multicast
fragmentation, TS004/TS005) suits small images on chips built for it, not an
ESP32 app.
Safety, in layers
- CRC-32 per chunk: a damaged chunk is re-requested, not written.
- SHA-256 of the whole image, streamed as it is written, compared
before anything is switched. - Ed25519 or ECDSA P-256 signature (optional) over the digest, size,
board and version, so a signed image cannot be relabelled or replayed onto
another board. - The target's own check: the ESP32 IDF validates the app image; the
staged target reads the image back from storage and re-hashes it. - Board and version policy: wrong board, same version (unless forced)
and downgrade (unless allowed) are refused before a byte moves. - Trial boot: the new image must confirm, by default once a link is up
again. If it crashes before the boot hook runs, the bootloader rolls it
back. After that, the software trial does: not confirmed within the
window (two minutes by default), orMODBEE_OTA_TRIAL_BOOTS(3) boots
without confirming, and the boot hook switches back to the old slot.
On the ESP32 under Arduino the boot hook runs from verifyRollbackLater(),
which OtaEsp32Target.cpp defines. Any firmware that links the library gets
it, used or not: it then confirms pending images at boot, as the core itself
would. Define MODBEE_OTA_NO_ROLLBACK_HOOK to opt out; the target then falls
back to ESP-IDF's runtime calls, and their freezes.
Conventions
The library follows modbee-mqtt and BeeBus:
- No namespace. Types use the
Otaprefix, free functions areota_camelCase,
and macros areMODBEE_OTA_*. It never usesOTA_*, which ArduinoOTA and
ESP-IDF both define. - One result enum (
OtaResult), append-only, mirrored by the OpenPLC
OTA_RESULTdatatype. - Every event is also polled state (
state(),result(),progress()),
because generated IEC code reads values and can't register a callback. - No exceptions thrown or caught, and no exception tables on a board
(OtaNoExcept.h); no RTTI use, and nostd::functionon the update path. The core compiles as C++14.