Firmware Reference Flow
The flow a unit runs from its first boot for as long as it is in the field, written out as states, transitions and pseudo-code, so you can check your firmware against it step by step. Device Makers gives the short version and the reasons; Pilot Devices shows how to get each unit's key and how to make a pass from it.
Everything above Later: with a provisioning service works with OpenX today. Firmware updates run on your own update channel: OpenX does not deliver firmware.
What the unit starts with
| Written at flashing | Never on the unit |
|---|---|
| Its own key-based connection string (Device ConnStr (primary) from the connection's Inspector) | A pass or token, written to storage |
| The TLS trust roots for the hub (DigiCert Global Root G2 and Microsoft RSA Root CA 2017) | A key shared with another unit |
| Your update channel's public verification key (the signing key stays with you) | An OpenX API key, or your signing key |
The key string has this shape (placeholders):
HostName=<hub>.azure-devices.net;DeviceId=<device id>;SharedAccessKey=<device key>
The states
| State | What the unit does |
|---|---|
| BOOT | Reads the key string written at flashing. |
| NO_KEY | No usable key string in storage. It sends nothing and waits for one to be written (by hand, or through your update channel). |
| SYNC_CLOCK | Sets its clock from the network (NTP, or the cellular network's time). No pass is made before this. |
| CONNECT | Makes a 24-hour pass from its key, in memory, and connects over MQTT with TLS on port 8883. |
| RUN | Sends telemetry. Watches the pass's age and the connection. |
| BACKOFF | Waits a growing, jittered delay after a transient failure, then tries again. It still takes an update offered meanwhile. |
| WAIT_FOR_RECOVERY | The hub keeps refusing the key. It retries about once an hour and stays reachable on your update channel. |
| UPDATE | Checks, installs and proves a new firmware image (Firmware updates). |
Transitions
| From | Event | To |
|---|---|---|
| BOOT | a device key string is in storage | SYNC_CLOCK |
| BOOT | the key string is missing or is not a device key | NO_KEY |
| NO_KEY | a key string is written to the unit | SYNC_CLOCK |
| NO_KEY | your update channel offers an image | UPDATE |
| SYNC_CLOCK | the clock is set | CONNECT |
| SYNC_CLOCK | the clock cannot be set | BACKOFF |
| CONNECT | the hub accepts the pass (CONNACK 0) | RUN |
| CONNECT | the hub refuses (CONNACK 2, 4 or 5) for the first time | SYNC_CLOCK |
| CONNECT | the hub refuses again | WAIT_FOR_RECOVERY |
| CONNECT | the protocol version is refused (CONNACK 1) | WAIT_FOR_RECOVERY |
| CONNECT | CONNACK 3, closed with no CONNACK, TLS fails, no network | BACKOFF |
| RUN | the pass reaches 80% of its life | CONNECT |
| RUN | the hub or the network closes the connection | BACKOFF |
| RUN | your update channel offers an image | UPDATE |
| BACKOFF | the delay has passed, after a failed clock sync | SYNC_CLOCK |
| BACKOFF | the delay has passed, after any other failure | CONNECT |
| BACKOFF | your update channel offers an image | UPDATE |
| WAIT_FOR_RECOVERY | a new key string is written to the unit | SYNC_CLOCK |
| WAIT_FOR_RECOVERY | the hourly retry is due | SYNC_CLOCK |
| WAIT_FOR_RECOVERY | your update channel offers an image | UPDATE |
| UPDATE | the unit restarted, on the new image or the old one | BOOT |
| UPDATE | nothing was installed, and the unit has a key | SYNC_CLOCK |
| UPDATE | nothing was installed, and the unit has no key | NO_KEY |
Provision
Today a unit is provisioned when you write its key string at flashing: one Connection per unit, then
Deploy, and the hub gives that unit its own random key (One key per unit).
At boot the unit reads the key string. A string that is not a device key is not used: a pass or token
(it would expire in the field), or a string with SharedAccessKeyName (that is a hub policy, which
opens every device on the hub, never one unit).
Cache
The hub's host name is in the key string, so the unit already knows its hub and never looks it up anywhere else. Nothing else is cached: a pass is made for each connect and never written to storage.
Renew
- Each pass lasts 24 hours. The unit makes the next one when the current one reaches 80% of its life (about 19 hours after it connected), so it is never connected on an expired pass.
- On MQTT a renewal is a disconnect and a reconnect with the new pass: about one reconnect a day. On AMQP the pass is refreshed on the open connection.
- When the hub or the network closes the connection, the unit waits a backoff delay (a few seconds the first time), then makes a fresh pass and reconnects. A hub that accepts the unit and then drops it straight away (for example a second unit connected with the same device ID, as the hub keeps one MQTT connection per device) gets growing delays, not a tight reconnect loop.
- A unit that sleeps and wakes goes through SYNC_CLOCK and CONNECT at each wake, with a fresh pass.
The unit keeps its key for years. Renewing the pass never needs anyone to touch the unit.
Re-provision
Today there is no provisioning service, so "re-provision" means getting the unit working again with the key it has, or with its new key string.
- The hub refuses the pass once. The commonest cause is a wrong clock, so the unit sets its clock again, makes a fresh pass and tries once more.
- The hub refuses it again (MQTT CONNACK 2, 4 or 5): the stored key no longer opens the hub. The unit goes to WAIT_FOR_RECOVERY. Over MQTT a changed key, a switched-off device and a deleted device all look the same to the unit (refused), so it does not give up.
- In WAIT_FOR_RECOVERY it tries the same key about once an hour. A Connection that was switched off and is switched on and deployed again accepts the same key, so the unit heals by itself.
- When the key has really changed, the unit needs its new key string: open the unit's Inspector -> Connection Info, copy Device ConnStr (primary) and write it to the unit, by hand or through your update channel. As soon as a new key string is in storage, the unit starts again at SYNC_CLOCK. A unit in NO_KEY (its storage was wiped) gets its key back the same way: the hub still holds it.
What changes a unit's key in OpenX: deleting its Connection and adding it back, and a hub that is re-created under its old name (both say so before they run), and moving a Connection from the free shared environment to a hub of your own. Deploying again leaves a device's keys alone: it only re-tags the device and sets it on or off. A device missing from the hub is added back, with new keys.
Backoff
After a transient failure the unit waits, then tries again, and each wait is longer than the last:
delay = min(maximum, base * 2^attempt) * (0.5 + 0.5 * random())
A base of 5 seconds and a maximum of 1 hour suit a cellular unit. The jitter keeps a fleet that lost the network together from reconnecting all at once. A connection that stays up for 10 minutes resets the attempt count; a connect alone does not, so a connection the hub drops at once keeps the growing delay. While it waits, the unit still checks your update channel: if its trust roots no longer match the hub, every TLS handshake fails, and an update is the way out.
Pseudo-code
on boot:
state = BOOT
loop forever:
switch state:
BOOT:
keyString = readKeyStringFromStorage() # written once at flashing
if isDeviceKeyString(keyString): key = parse(keyString); state = SYNC_CLOCK
else: state = NO_KEY
NO_KEY: # sends nothing to the hub
if a device key string has been written: key = parse(it); state = SYNC_CLOCK
else if updateOffered(): state = UPDATE # the update channel has its own credentials
else: sleep(1 minute)
SYNC_CLOCK:
if setClockFromNetwork(): state = CONNECT
else: backoff(then = SYNC_CLOCK)
CONNECT:
pass = makePass(key, lifetime = 24 hours) # in memory only
result = connectMqtt(key.host, key.deviceId, pass)
forget(pass)
if result is accepted:
connectedAt = now; renewAt = now + 0.8 * 24 hours; refusals = 0; state = RUN
else if result is refused (CONNACK 2, 4 or 5):
refusals += 1
state = (refusals == 1) ? SYNC_CLOCK : WAIT_FOR_RECOVERY
else if result is protocol refused (CONNACK 1): # a firmware fault
state = WAIT_FOR_RECOVERY
else: # CONNACK 3, no CONNACK, TLS, no network
backoff(then = CONNECT)
RUN:
if now >= connectedAt + 10 minutes: attempt = 0 # the connection held
if updateOffered(): state = UPDATE
else if connectionClosed(): backoff(then = CONNECT) # never a tight reconnect loop
else if now >= renewAt: disconnect(); state = CONNECT
else: sendTelemetry()
BACKOFF:
if updateOffered(): state = UPDATE # e.g. new trust roots when TLS keeps failing
else if now >= retryAt: state = then # SYNC_CLOCK or CONNECT, where it failed
WAIT_FOR_RECOVERY:
if updateOffered(): state = UPDATE
else if a different key string is in storage: key = parse(it); refusals = 0; state = SYNC_CLOCK
else if an hour has passed since the last try: state = SYNC_CLOCK
UPDATE:
installed = runUpdate() # see Firmware updates
if installed: restart() # comes back at BOOT, on the new or the old image
else if key: state = SYNC_CLOCK # offer refused or download stopped
else: state = NO_KEY
backoff(then):
retryAt = now + min(1 hour, 5 seconds * 2^attempt) * (0.5 + 0.5 * random())
attempt += 1
state = BACKOFF
Never log the key or a pass. Log the state, the device ID, times and counts.
Firmware updates
OpenX does not deliver firmware today; the update runs on your board's update path or your own. These are the steps for an ESP32-S3 built with ESP-IDF; the same shape fits other microcontrollers.
- Check the offer. Accept an image only if its signature verifies with your public key (ESP-IDF
checks signed apps with Secure Boot v2, or with
CONFIG_SECURE_SIGNED_ON_UPDATE_NO_SECURE_BOOT). Refuse an image that is not newer than the running one, unless you sent it as a downgrade for safety, and log every downgrade. - Refuse anything below the security floor, even for safety. With
CONFIG_BOOTLOADER_APP_ANTI_ROLLBACK, each image carries a security version (CONFIG_BOOTLOADER_APP_SECURE_VERSION), and the bootloader refuses an image whose version is lower than the one recorded in the chip's eFuse. - Write to the spare slot. With ESP-IDF's OTA calls, begin the write to the spare OTA partition, write each block, then end it, which validates the written image. A download that stops part way leaves the unit on its old image; try again later.
- Boot it on trial. Set the spare partition as the boot partition, then restart. With
CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE, the new image starts in the pending-verify state (ESP_OTA_IMG_PENDING_VERIFY). - Prove it. The self-test checks what the image is responsible for: it starts, sets its clock, reaches your update channel, completes the TLS handshake with the hub and gets an answer to its connect. A refusal of the key (CONNACK 2, 4 or 5) is an answer, not the image's fault (the old image would be refused too), so it passes; a refused protocol version (CONNACK 1) is the image's fault, so it fails; no answer at all keeps the image on trial, and it tests again later. When it passes, mark the app valid (this cancels the rollback and raises the security version recorded in the chip). When it fails, mark it invalid and reboot, which rolls back. If the unit resets before either, the bootloader goes back to the previous image.
- Log every rollback, with both versions and the reason, and report it on your update channel when the unit next reaches it.
- Update the modem's firmware too when the board's MQTT runs on a cellular modem; see Firmware updates on Device Makers.
Keep the update channel working on its own credentials, apart from the hub: it is the only way to reach a unit in WAIT_FOR_RECOVERY, and the way to replace its trust roots if a root ever changes in an emergency.
Later: with a provisioning service
Coming, not available yet. When OpenX runs a device provisioning service, the flow changes in four places:
- Provision: on first boot the unit registers with the provisioning service using an identity held in hardware, and sends its printed ID and firmware version.
- Cache: it keeps the hub it was assigned and does not register again on every boot.
- Re-provision: after repeated connect failures that are not load, it registers again, which is how a move to another hub heals; it also accepts a request to register again, as a direct method, and as a setting it reads when it wakes if it sleeps.
- Renew: an identity held in hardware replaces the key string, so the key never sits in ordinary storage.
Sources
Read on 2026-10-20.
- Microsoft, Use MQTT to communicate with Azure IoT Hub: the client ID, user name and password, port 8883, one active MQTT connection per device.
- Microsoft, Troubleshoot IoT Hub error codes: 401003 (on MQTT the hub disconnects when the pass expires; AMQP refreshes in place).
- Microsoft, Azure IoT Hub TLS support: the two trust roots.
- Espressif, ESP-IDF over-the-air updates for the ESP32-S3: the OTA calls (begin, write, end, set the boot partition, mark valid or invalid), app rollback, anti-rollback and signed images.
- OASIS, MQTT 3.1.1: the CONNACK return codes (1 and 2: protocol or client ID refused; 3: server unavailable; 4 and 5: user name, password or authorization refused).