Fusain OTA Updates
- Date:
2026-01-12
- Author:
Thermoquad
- Status:
Research Complete
- Related:
RP2350 Flash Usage, fusain-error-communication
Executive Summary
This document proposes Fusain protocol message types for Over-The-Air (OTA) firmware updates. The design supports both self-update and proxy update scenarios in multi-appliance networks.
Key Design Decisions:
Chunk-based transfer: Firmware sent in small chunks (≤96 bytes) to fit within Fusain’s 114-byte payload limit
Resumable uploads: Hash-based deduplication allows interrupted transfers to resume from last successful chunk
MCUboot compatible: Uses standard MCUboot image format (32-byte header, TLVs, SHA256 hash)
Multi-appliance safe: Each appliance addressed individually; no broadcast OTA commands
Error reporting: Integrates with extended error communication scheme
Message Types:
Message |
Type Code |
Purpose |
|---|---|---|
OTA_START |
0x40 |
Initiate firmware upload (size, hash, version) |
OTA_DATA |
0x41 |
Transfer firmware chunk (offset, data) |
OTA_VERIFY |
0x42 |
Request image verification |
OTA_ACTIVATE |
0x43 |
Mark image for boot (test or permanent) |
OTA_QUERY |
0x44 |
Request update status |
OTA_STATUS |
0x45 |
Report update status (response) |
OTA_ABORT |
0x4F |
Cancel in-progress update |
Background
MCUboot Image Format
MCUboot uses a standardized image format compatible with Fusain OTA:
Image Header (32-byte struct, typically 0x200 padded in image):
struct image_header {
uint32_t magic; // 0x96f3b83d
uint32_t load_addr; // Load address (or 0)
uint16_t header_size; // Header size in image (typically 0x200)
uint16_t protect_tlv_size; // Protected TLV size
uint32_t image_size; // Image size (excluding header)
uint32_t flags; // IMAGE_F_* flags
struct image_version version; // Version (major.minor.rev.build)
uint32_t _pad1;
};
Image Version:
struct image_version {
uint8_t major;
uint8_t minor;
uint16_t revision;
uint32_t build_num;
};
TLV Types (relevant for OTA):
IMAGE_TLV_SHA256(0x10): SHA256 hash of imageIMAGE_TLV_ECDSA_SIG(0x22): ECDSA signatureIMAGE_TLV_KEYHASH(0x01): Public key hash
Flash Partition Layout
From RP2350 Flash Usage, the MCUboot partition layout on 4MB flash:
Address Size Purpose
──────────────────────────────────────
0x00000000 64 KB MCUboot bootloader
0x00010000 832 KB slot0_partition (primary/active)
0x000E0000 832 KB slot1_partition (staging/upgrade)
0x001B0000 2.3 MB storage_partition (NVS/littlefs)
Current Thermoquad firmware sizes:
Helios ICU: ~124 KB (fits with headroom)
Slate Controller: ~397 KB (fits with headroom)
Fusain Addressing
Fusain uses 64-bit device addresses (see Packet Format):
Each appliance has a unique address (MAC, serial number, or UUID)
Commands include destination address
Appliances ignore packets not addressed to them
Broadcast (
0x0000000000000000) is NOT used for OTA
This addressing scheme inherently supports multi-appliance networks: each OTA transfer is addressed to a specific device.
Supported Transports
OTA updates require transports that support the full Fusain payload size (114 bytes):
UART/Serial: Primary transport for appliance updates (Slate → Helios)
WebSocket: Primary transport for controller updates (Roastee → Slate)
TCP: Supported for wired connections
Not supported for OTA:
BLE: OTA requires payloads larger than the default BLE MTU (20 bytes). While extended MTU negotiation exists, support varies across devices and cannot be guaranteed. Use UART or WebSocket instead.
OTA Scenarios
Scenario 1: Self-Update
A device receives firmware for itself over Fusain.
Example: Slate receives its own firmware update from Roastee via WebSocket.
Roastee (Web) ─── WebSocket ──→ Slate
Flow:
Roastee sends OTA_START with firmware metadata
Slate validates header, erases slot1
Roastee sends OTA_DATA chunks
Slate writes chunks to slot1
Roastee sends OTA_VERIFY
Slate validates image hash
Roastee sends OTA_ACTIVATE
Slate marks slot1 for boot and reboots
Scenario 2: Proxy Update (Slate → Helios)
Slate receives Helios firmware and relays it over Fusain serial.
Example: Roastee sends Helios firmware to Slate, which forwards to Helios.
Roastee (Web) ─── WebSocket ──→ Slate ─── Fusain/UART ──→ Helios
Flow:
Roastee sends Helios firmware to Slate
Slate stores firmware in littlefs (
/lfs/helios_update.bin)Slate verifies complete image (hash, optionally signature)
Slate sends OTA_START to Helios
Slate sends OTA_DATA chunks from stored file
Slate sends OTA_VERIFY to Helios
Helios validates and responds with status
Slate sends OTA_ACTIVATE to Helios
Helios marks slot1 for boot and reboots
Why buffer on Slate?
Allows verification before transfer
Enables resumable transfers if interrupted
Multiple retry attempts without re-downloading
Decouples network latency from UART timing
Scenario 3: Multi-Appliance Update
Update multiple appliances in a network sequentially.
Example: Update Helios-A, then Helios-B via Slate router.
Roastee ──→ Slate (Router) ──→ Helios-A (0x1234...)
└─→ Helios-B (0x5678...)
Constraints:
Only ONE appliance can be updated at a time
Each OTA command is addressed to specific device
Slate buffers firmware once, transfers to each appliance sequentially
Progress tracked per-device
Flow:
Roastee uploads Helios firmware to Slate (once)
Slate stores in littlefs
Slate sends OTA commands to Helios-A (full transfer)
Helios-A reboots and confirms
Slate sends OTA commands to Helios-B (full transfer)
Helios-B reboots and confirms
Slate deletes buffered firmware
Proposed Message Types
Message Type Allocation
OTA messages use a dedicated range (0x40-0x4F), extending Fusain’s message type
organization:
0x10-0x1F: Configuration commands0x20-0x2F: Control commands0x30-0x3F: Telemetry data0x40-0x4F: OTA messages (new)0xE0-0xEF: Error messages
Type |
Name |
Purpose |
|---|---|---|
0x40 |
OTA_START |
Begin firmware upload |
0x41 |
OTA_DATA |
Transfer firmware chunk |
0x42 |
OTA_VERIFY |
Request image verification |
0x43 |
OTA_ACTIVATE |
Mark image for boot |
0x44 |
OTA_QUERY |
Request update status |
0x45 |
OTA_STATUS |
Report update status |
0x46–0x4E |
Reserved |
Future OTA extensions |
0x4F |
OTA_ABORT |
Cancel update |
OTA_START (0x40)
Initiate a firmware upload session.
Payload Fields
Key |
Field |
Type |
Description |
|---|---|---|---|
0 |
size |
uint |
Total firmware image size in bytes |
1 |
hash |
bytes |
SHA256 hash of complete image (32 bytes) |
2 (?) |
version |
array |
Version [major, minor, rev, build] (optional) |
3 (?) |
slot |
uint |
Target slot (default 1 for upgrade slot, optional) |
Behavior:
Appliance validates size fits in slot
Appliance checks if upload already in progress with same hash (resume)
If new upload, appliance erases target slot
On success, appliance responds with OTA_STATUS (state: RECEIVING)
On error, appliance responds with ERROR_INVALID_CMD or ERROR_STATE_REJECT
Errors:
Size exceeds slot: ERROR_INVALID_CMD (error_code: 1, rejected_field: 0, constraint: IMAGE_TOO_LARGE)
Update in progress (different hash): ERROR_STATE_REJECT (error_code: <current_state>, rejection_reason: UPDATE_IN_PROGRESS)
Invalid state: ERROR_STATE_REJECT (error_code: <current_state>, rejection_reason: INVALID_IN_STATE)
OTA_DATA (0x41)
Transfer a chunk of firmware data.
Payload Fields
Key |
Field |
Type |
Description |
|---|---|---|---|
0 |
offset |
uint |
Byte offset in image (0-based) |
1 |
data |
bytes |
Chunk data (≤96 bytes recommended) |
Behavior:
Appliance validates offset matches expected position
Appliance writes data to flash at slot1 + offset
Appliance updates internal state (next expected offset)
On success, appliance responds with OTA_STATUS (state: RECEIVING, offset: next expected byte)
On error, appliance responds with ERROR_INVALID_CMD or ERROR_STATE_REJECT
Chunk Size Considerations:
Fusain payload limit: 114 bytes
CBOR overhead: ~10 bytes (type, map, keys, offset encoding)
Recommended chunk size: 96 bytes (allows for CBOR overhead)
Smaller chunks increase transfer time but reduce retry cost
Errors:
No upload in progress: ERROR_STATE_REJECT (error_code: <current_state>, rejection_reason: INVALID_IN_STATE)
Offset mismatch: ERROR_INVALID_CMD (error_code: 1, rejected_field: 0, constraint: VALUE_CONFLICT)
Flash write failed: ERROR_INVALID_CMD (error_code: 1, rejected_field: 1, constraint: FLASH_WRITE_FAILED)
OTA_VERIFY (0x42)
Request verification of uploaded image.
Payload Fields
Key |
Field |
Type |
Description |
|---|---|---|---|
0 (?) |
hash |
bytes |
Expected SHA256 hash (optional, for confirmation) |
Behavior:
Appliance computes SHA256 of received image
Appliance compares against hash from OTA_START (and payload if provided)
Appliance validates MCUboot header and TLVs
On success, appliance responds with OTA_STATUS (state: VERIFIED)
On error, appliance responds with ERROR_INVALID_CMD or ERROR_STATE_REJECT
Errors:
No upload in progress: ERROR_STATE_REJECT (error_code: <current_state>, rejection_reason: INVALID_IN_STATE)
Upload incomplete: ERROR_INVALID_CMD (error_code: 1, constraint: VALUE_TOO_LOW)
Hash mismatch: ERROR_INVALID_CMD (error_code: 1, constraint: HASH_MISMATCH)
Invalid header: ERROR_INVALID_CMD (error_code: 1, constraint: HEADER_INVALID)
Signature invalid: ERROR_INVALID_CMD (error_code: 1, constraint: SIGNATURE_INVALID) — controllers only, when not in root mode
OTA_ACTIVATE (0x43)
Mark uploaded image for boot.
Payload Fields
Key |
Field |
Type |
Description |
|---|---|---|---|
0 |
mode |
uint |
Activation mode (see values below) |
1 (?) |
reboot |
bool |
Reboot immediately after activation (default true) |
Activation Modes
Value |
Name |
Description |
|---|---|---|
0 |
TEST |
Test boot (reverts if not confirmed) |
1 |
PERMANENT |
Permanent boot (no revert) |
Behavior:
Appliance validates image has been verified
Appliance marks slot1 for boot (test or permanent)
If reboot=true, appliance schedules reboot after response
On success, appliance responds with OTA_STATUS (state: ACTIVATED)
On error, appliance responds with ERROR_STATE_REJECT
Errors:
Image not verified: ERROR_STATE_REJECT (error_code: <current_state>, rejection_reason: INVALID_IN_STATE)
Unsafe state (e.g., heating active): ERROR_STATE_REJECT (error_code: <current_state>, rejection_reason: UNSAFE_STATE)
OTA_QUERY (0x44)
Request current update status.
Payload Fields
Key |
Field |
Type |
Description |
|---|---|---|---|
(none) |
Empty payload |
Behavior:
Appliance responds with OTA_STATUS
OTA_STATUS (0x45)
Report update status. Sent by appliance in response to OTA commands or OTA_QUERY.
Payload (Appliance → Controller)
Key |
Field |
Type |
Description |
|---|---|---|---|
0 |
state |
uint |
Current OTA state (see values below) |
1 (?) |
offset |
uint |
Next expected byte offset (during upload) |
2 (?) |
version |
array |
Version of pending image (if applicable) |
OTA State Values
Value |
Name |
Description |
|---|---|---|
0 |
IDLE |
No update in progress |
1 |
RECEIVING |
Upload in progress |
2 |
RECEIVED |
Upload complete, awaiting verification |
3 |
VERIFIED |
Image verified, awaiting activation |
4 |
ACTIVATED |
Image marked for boot, awaiting reboot |
Errors are communicated via ERROR_INVALID_CMD or ERROR_STATE_REJECT messages as defined in fusain-error-communication. After an error, state returns to IDLE.
OTA_ABORT (0x4F)
Cancel an in-progress update.
Payload Fields
Key |
Field |
Type |
Description |
|---|---|---|---|
(none) |
Empty payload |
Behavior:
Appliance cancels any in-progress upload
Appliance clears upload state
Appliance does NOT erase partially written slot
Appliance responds with OTA_STATUS (state: IDLE)
Wire Format Examples
OTA_START Example
Initiate upload of 127,456 byte image:
CBOR: [0x40, {
0: 127456, // size
1: h'a1b2c3...32bytes...', // SHA256 hash
2: [1, 2, 0, 42] // version 1.2.0+42
}]
OTA_DATA Example
Send chunk at offset 4096:
CBOR: [0x41, {
0: 4096, // offset
1: h'0011223344...96bytes...' // data chunk
}]
OTA_STATUS Response Example
Upload in progress at 50% (63,728 bytes received):
CBOR: [0x45, {
0: 1, // state: RECEIVING
1: 63728 // next expected offset
}]
CDDL Schema
The following CDDL excerpt defines the OTA message payloads. This extends the
existing fusain.cddl schema.
; ===========================================================================
; OTA Message Payloads (0x40-0x4F)
; ===========================================================================
;
; Message type values:
; 0x40=ota-start, 0x41=ota-data, 0x42=ota-verify, 0x43=ota-activate
; 0x44=ota-query, 0x45=ota-status, 0x4F=ota-abort
; OTA-specific types
sha256-hash = bstr .size 32 ; SHA256 hash (32 bytes)
image-version = [ ; MCUboot version format
uint .size 1, ; major
uint .size 1, ; minor
uint .size 2, ; revision
uint .size 4, ; build number
]
; OTA state values: 0=idle, 1=receiving, 2=received, 3=verified, 4=activated
; Errors are communicated via ERROR_INVALID_CMD/ERROR_STATE_REJECT messages
ota-state = uint .le 4
; Activation mode: 0=test (reverts if not confirmed), 1=permanent
activation-mode = uint .le 1
; OTA_START (0x40) - Initiate firmware upload
ota-start-payload = {
0 => uint, ; size: Total image size in bytes
1 => sha256-hash, ; hash: SHA256 of complete image
? 2 => image-version, ; version: Image version (optional)
? 3 => uint .size 1, ; slot: Target slot, default 1 (optional)
}
; OTA_DATA (0x41) - Transfer firmware chunk
ota-data-payload = {
0 => uint, ; offset: Byte offset in image (0-based)
1 => bstr, ; data: Chunk data (≤96 bytes recommended)
}
; OTA_VERIFY (0x42) - Request image verification
ota-verify-payload = {
? 0 => sha256-hash, ; hash: Expected hash for confirmation (optional)
}
; OTA_ACTIVATE (0x43) - Mark image for boot
ota-activate-payload = {
0 => activation-mode, ; mode: 0=test, 1=permanent
? 1 => bool, ; reboot: Reboot after activation (default true)
}
; OTA_QUERY (0x44) - Request current status
; Empty payload (nil or empty map)
ota-query-payload = {
}
; OTA_STATUS (0x45) - Report update status
ota-status-payload = {
0 => ota-state, ; state: Current OTA state
? 1 => uint, ; offset: Next expected byte offset (during upload)
? 2 => image-version, ; version: Version of pending image
}
; OTA_ABORT (0x4F) - Cancel in-progress update
; Empty payload (nil or empty map)
ota-abort-payload = {
}
Error Handling
Integration with Error Communication
OTA errors use the extended error communication scheme from fusain-error-communication:
OTA-Specific Constraint Values (10-19):
Value |
Name |
Description |
|---|---|---|
10 |
FLASH_WRITE_FAILED |
Flash write operation failed |
11 |
IMAGE_TOO_LARGE |
Image exceeds slot size |
12 |
SIGNATURE_INVALID |
Signature verification failed |
13 |
VERSION_DOWNGRADE |
Version older than current (if blocked) |
14 |
HASH_MISMATCH |
Image hash doesn’t match expected |
15 |
HEADER_INVALID |
MCUboot header invalid |
OTA-Specific Rejection Reasons (4-7):
Value |
Name |
Description |
|---|---|---|
4 |
UPDATE_IN_PROGRESS |
Another update already in progress |
5 |
UNSAFE_STATE |
Device in state where update is unsafe (e.g., heating) |
Error Response Examples
Image too large:
CBOR: [0xE0, {0: 1, 1: 0, 2: 11}]
Meaning: Invalid parameter, field 0 (size), IMAGE_TOO_LARGE
Update already in progress:
CBOR: [0xE1, {0: 1, 1: 4}]
Meaning: Rejected in RECEIVING state, UPDATE_IN_PROGRESS
Flash write failed:
CBOR: [0xE0, {0: 1, 1: 1, 2: 10}]
Meaning: Invalid parameter, field 1 (data), FLASH_WRITE_FAILED
Multi-Appliance Considerations
Addressing
OTA commands MUST be unicast (addressed to specific device):
DO NOT use broadcast address for OTA
Each appliance processes only its own updates
Router (Slate) tracks update progress per-device
Sequential Updates
When updating multiple appliances:
One at a time: Only one OTA transfer active per physical link
Track progress: Controller maintains state for each device
Handle failures: Failed update on one device doesn’t affect others
Shared buffer: Slate can reuse stored firmware for multiple appliances
Update Coordination
For systems with dependencies (e.g., Slate depends on Helios):
Update appliances first (Helios)
Verify appliance boots successfully
Update controller (Slate)
Verify controller boots and reconnects
Version Compatibility:
Consider adding protocol version negotiation or compatibility checks if firmware versions have protocol-breaking changes.
Implementation Notes
Appliance Implementation (Zephyr)
Integrate with Zephyr’s img_mgmt module:
#include <zephyr/dfu/img_util.h>
#include <zephyr/storage/flash_map.h>
// State tracking
struct ota_state {
int area_id; // Flash area (-1 if idle)
size_t offset; // Next expected offset
size_t size; // Total image size
uint8_t hash[32]; // Expected SHA256
enum ota_state state;
};
static struct ota_state ota;
// Handle OTA_START
int handle_ota_start(uint32_t size, const uint8_t *hash) {
const struct flash_area *fa;
int rc;
// Open slot1
rc = flash_area_open(FIXED_PARTITION_ID(slot1_partition), &fa);
if (rc) return -1;
// Validate size
if (size > fa->fa_size) {
flash_area_close(fa);
return ERR_IMAGE_TOO_LARGE;
}
// Erase slot
rc = flash_area_erase(fa, 0, fa->fa_size);
if (rc) {
flash_area_close(fa);
return ERR_FLASH_ERASE_FAILED;
}
// Initialize state
ota.area_id = fa->fa_id;
ota.offset = 0;
ota.size = size;
memcpy(ota.hash, hash, 32);
ota.state = OTA_RECEIVING;
return 0;
}
// Handle OTA_DATA
int handle_ota_data(uint32_t offset, const uint8_t *data, size_t len) {
const struct flash_area *fa;
int rc;
if (ota.state != OTA_RECEIVING) {
return ERR_INVALID_STATE;
}
if (offset != ota.offset) {
return ERR_OFFSET_MISMATCH;
}
rc = flash_area_open(ota.area_id, &fa);
if (rc) return -1;
rc = flash_area_write(fa, offset, data, len);
flash_area_close(fa);
if (rc) {
return ERR_FLASH_WRITE_FAILED;
}
ota.offset += len;
if (ota.offset >= ota.size) {
ota.state = OTA_RECEIVED;
}
return 0;
}
Controller Implementation
For proxy updates, Slate buffers firmware and transfers:
// Transfer buffered firmware to appliance
int transfer_firmware_to_appliance(uint64_t address, const char *path) {
struct fs_file_t file;
uint8_t chunk[96];
size_t offset = 0;
ssize_t bytes;
int rc;
// Open buffered firmware file
fs_file_t_init(&file);
rc = fs_open(&file, path, FS_O_READ);
if (rc) return rc;
// Get file size and hash
// ... (read header, compute hash)
// Send OTA_START
rc = fusain_send_ota_start(address, size, hash);
if (rc) goto cleanup;
// Wait for OTA_STATUS response
// ...
// Send chunks
while ((bytes = fs_read(&file, chunk, sizeof(chunk))) > 0) {
rc = fusain_send_ota_data(address, offset, chunk, bytes);
if (rc) goto cleanup;
// Wait for OTA_STATUS response
// ...
offset += bytes;
}
// Send OTA_VERIFY
rc = fusain_send_ota_verify(address, hash);
// ...
// Send OTA_ACTIVATE
rc = fusain_send_ota_activate(address, OTA_MODE_TEST, true);
// ...
cleanup:
fs_close(&file);
return rc;
}
Transfer Time Estimates
With 96-byte chunks at 115200 baud:
Firmware Size |
Chunks |
Transfer Time |
With Overhead |
|---|---|---|---|
124 KB (Helios) |
~1,330 |
~15 sec |
~25 sec |
397 KB (Slate) |
~4,250 |
~47 sec |
~80 sec |
Overhead includes acknowledgments, processing time, and retries.
Security Considerations
Trust Model
Signature verification is enforced at the controller level, not the appliance level:
Controllers (Slate):
Verify image signatures before initiating OTA transfer
Enforce downgrade protection policies
Gate all firmware updates to connected appliances
Support “root mode” to allow unsigned images
Appliances (Helios):
Accept images from controller without signature verification
Trust the controller to have validated the image
Only verify image integrity (hash) not authenticity (signature)
Rationale:
Physical access bypasses everything — Hardware has accessible programming pins (SWD/JTAG) when disassembled for servicing. Preventing custom firmware is impossible by design, so the security model acknowledges this reality.
Controller as gateway — All OTA updates flow through the controller, making it the natural enforcement point.
Simpler appliances — Appliances don’t need crypto libraries for signature verification, reducing code size and complexity.
User choice — Users who want custom firmware can enable root mode on their controller, or use programming pins directly.
Root Mode
Root mode allows controllers to accept and forward unsigned firmware images.
Enabling Root Mode:
Root mode is enabled via a persistent flag in flash. To prevent accidental activation, enabling requires:
Physical button hold during boot (e.g., hold USER button for 5 seconds)
Controller displays warning and confirmation prompt
User confirms via UI or serial command
Flag is set in NVS/flash
Behavior When Enabled:
Controller accepts unsigned images for OTA
Controller displays “ROOT MODE” indicator in UI
Signature verification is skipped, hash verification still required
Downgrade protection can be optionally bypassed
Behavior When Disabled (default):
Controller rejects unsigned images with
SIGNATURE_INVALIDerrorOnly Thermoquad-signed official releases are accepted
Downgrade protection is enforced
Disabling Root Mode:
Toggle off via settings menu (requires confirmation)
Or flash official firmware via programming pins (resets to locked)
Image Signing
MCUboot supports image signing with:
ECDSA (P-256, recommended)
RSA (2048, 3072)
Ed25519
Recommendation: Use ECDSA P-256 for Thermoquad:
Compact signatures (~64 bytes)
Hardware acceleration on RP2350
Good balance of security and performance
Signing Workflow:
# Sign image with Thermoquad production key
imgtool sign --key thermoquad-prod.pem \
--align 4 --version 1.2.0 --header-size 0x200 \
--slot-size 0xD0000 \
zephyr.bin signed-firmware.bin
Downgrade Protection
Version downgrade blocking:
Enabled (default): Reject OTA if version < current
Disabled: Allow any version (requires root mode)
Recommendation: Enforce in production, allow bypass only in root mode.
Conclusion
The proposed OTA message types provide:
Complete update lifecycle: Start, data, verify, activate, status, abort
Resumable transfers: Hash-based deduplication for interrupted uploads
Multi-appliance support: Addressed commands, sequential updates
MCUboot integration: Compatible with standard Zephyr DFU
Error reporting: Integrated with extended error communication scheme
Proxy updates: Slate can buffer and forward to Helios
Next Steps:
Add OTA message types to Fusain specification
Implement OTA handler in Fusain C library
Add OTA support to Heliostat for testing
Implement proxy update in Slate firmware
References
RP2350 Flash Usage — Flash partitioning and storage options
fusain-error-communication — Extended error reporting scheme