How OTA updates work via microSD in TentacleOS
Updating firmware on an embedded device isn't as simple as clicking "Update" on your phone. There's no app store, no background download, and if something goes wrong mid-update, you could end up with a bricked device. In this post, we'll walk through how we designed and implemented the OTA (Over-The-Air) update system for TentacleOS using a MicroSD card.
The Challenge: Two Chips, One Firmware
TentacleOS runs on a dual-chip architecture:
- ESP32-P4 - the main processor that runs the OS, UI, storage, SubGHz communication and all application logic
- ESP32-C5 - a radio co-processor dedicated to WiFi and Bluetooth.
These two chips talk to each other via a custom SPI bridge protocol. The P4 is the brain; the C5 is the radio. They're separate chips, but from the user's perspective, it's one device with one firmware version.
This means our update system needs to handle both chips seamlessly. The user shouldn't have to think about which chip needs updating.
The Solution: One Binary to Rule Them All
At build time, the C5 firmware is embedded inside the P4 binary using ESP-IDF's target_add_binary_data. The linker places the entire C5 .bin file as a data section inside the P4 firmware:
extern const uint8_t c5_firmware_start[] asm("_binary_TentacleOS_C5_bin_start");
extern const uint8_t c5_firmware_end[] asm("_binary_TentacleOS_C5_bin_end");
This means a single file — TentacleOS_v1.2.0.bin — contains everything needed to update both chips. The user downloads one file, puts it on the SD card, and the device takes care of the rest.
Partition Layout
The ESP32-P4 has 32MB of flash. We use an A/B partition scheme for safe updates with automatic rollback:
# Name, Type, SubType, Offset, Size
nvs, data, nvs, 0x9000, 24K
otadata, data, ota, , 8K
phy_init, data, phy, , 4K
ota_0, app, ota_0, 0x20000, 4MB
ota_1, app, ota_1, , 4MB
storage, data, fat, , 6MB
assets, data, littlefs, , 16MB
The key here is two app partitions (ota_0 and ota_1). The device runs from one while writing the update to the other. If the update fails, the old firmware is still intact on the other partition.
The otadata partition tracks which slot is active and whether the current firmware has been verified. This is what enables automatic rollback.
The Update Flow
Step 1: Detect the Update File
The user places the firmware binary at a specific path on the SD card:
/sdcard/update/tentacleos.bin
The device checks for this file with a simple function:
#define OTA_UPDATE_PATH "/sdcard/update/tentacleos.bin"
bool ota_update_available(void) {
if (!sd_is_mounted()) {
return false;
}
struct stat st;
return (stat(OTA_UPDATE_PATH, &st) == 0 && st.st_size > 0);
}
No file? No update. Simple.
Step 2: Validate Before Writing
Before touching the flash, we validate the update file:
const esp_partition_t *update_partition = esp_ota_get_next_update_partition(NULL);
if (file_size > update_partition->size) {
ESP_LOGE(TAG, "Update file exceeds partition size");
return ESP_ERR_INVALID_SIZE;
}
esp_ota_get_next_update_partition(NULL) automatically returns the inactive partition — the one we're not currently running from. We check if the file fits before writing a single byte.
Step 3: Write to the Inactive Partition
The actual write happens in 4KB chunks. This keeps memory usage low, which matters on an embedded device:
esp_ota_handle_t ota_handle;
esp_ota_begin(update_partition, OTA_WITH_SEQUENTIAL_WRITES, &ota_handle);
uint8_t *buffer = malloc(4096);
long bytes_written = 0;
while (bytes_written < file_size) {
size_t bytes_read = fread(buffer, 1, 4096, file);
esp_ota_write(ota_handle, buffer, bytes_read);
bytes_written += bytes_read;
}
esp_ota_end(ota_handle);
During this process, a progress callback reports status to the UI so the user can see the update progressing:
typedef void (*ota_progress_cb_t)(int percent, const char *message);
The progress ranges from 0-5% (validating), 5-90% (writing), 90-95% (finalizing), and 95% right before reboot.
Step 4: Switch and Reboot
Once the write is complete and verified, we set the boot partition and restart:
esp_ota_set_boot_partition(update_partition);
remove(OTA_UPDATE_PATH); // Delete the file so we don't re-apply
esp_restart();
The update file is deleted from the SD card before rebooting. This prevents the device from trying to re-apply the same update on every boot.
Post-Boot: The Critical Verification
This is where it gets interesting. After rebooting into the new firmware, the device does not immediately trust it. The bootloader marks the new firmware as ESP_OTA_IMG_PENDING_VERIFY — it's on probation.
Early in main.c, before anything else, we run the post-boot check:
esp_err_t ota_post_boot_check(void) {
const esp_partition_t *running = esp_ota_get_running_partition();
esp_ota_img_states_t ota_state;
esp_ota_get_state_partition(running, &ota_state);
if (ota_state == ESP_OTA_IMG_PENDING_VERIFY) {
// New firmware — needs verification
esp_err_t ret = bridge_manager_init();
if (ret != ESP_OK) {
// C5 sync failed — DON'T confirm
// Bootloader will rollback on next reboot
return ESP_FAIL;
}
// Everything OK — confirm the update
esp_ota_mark_app_valid_cancel_rollback();
sync_version_to_assets();
}
return ESP_OK;
}
The verification includes synchronizing the C5 co-processor. The bridge_manager queries the C5's version via SPI and compares it with the version embedded in the new P4 firmware. If they don't match, it flashes the C5 via UART using the embedded binary.
Only after the C5 is confirmed to be in sync does the P4 call esp_ota_mark_app_valid_cancel_rollback(). If this function is never called — because the firmware crashed, the C5 flash failed, or anything went wrong — the bootloader automatically reverts to the previous firmware on the next reboot.
Keeping Both Chips in Sync
The bridge_manager is the component responsible for chip synchronization. On every boot, it:
- Sends
SPI_ID_SYSTEM_VERSIONto the C5 - Compares the response with
FIRMWARE_VERSION(compiled into the binary) - If they differ, puts the C5 into bootloader mode via GPIO and flashes it
esp_err_t bridge_manager_init(void) {
spi_bridge_master_init();
uint8_t resp_ver[32] = {0};
esp_err_t ret = spi_bridge_send_command(
SPI_ID_SYSTEM_VERSION, NULL, 0, &resp_header, resp_ver, 1000
);
if (ret != ESP_OK || strcmp((char*)resp_ver, FIRMWARE_VERSION) != 0) {
// C5 needs updating
c5_flasher_init();
c5_flasher_update(NULL, 0);
}
return ESP_OK;
}
This handles several edge cases:
- C5 not responding (corrupted firmware) — the ROM bootloader is in ROM, always accessible. The P4 forces bootloader mode via GPIO pins and re-flashes.
- Rollback after C5 was already updated — the rolled-back P4 contains the old C5 binary. The version mismatch triggers a re-flash, bringing the C5 back in sync.
- Power loss during C5 flash — same as "not responding". Next boot re-attempts the flash.
Versioning: Fully Automated
Both chips share a single version string. The version lives in three places:
ota_version.h— compiled into the P4 binary asFIRMWARE_VERSIONfirmware.jsonin P4 assets — read at runtime for display and metadatafirmware.jsonin C5 assets — the C5 responds with this when queried
We use Semantic Versioning with Conventional Commits. The version is bumped automatically by semantic-release in our CI/CD pipeline:
fix(spi): corrected bridge timeout= patch bump (0.0.1 -> 0.0.2)feat(ota): add SD card update= minor bump (0.1.0 -> 0.2.0)feat(protocol)!: redesigned SPI bridge= major bump (0.2.0 -> 1.0.0)
No one manually edits version numbers. Ever.
After an OTA update, the firmware.json in the assets partition (which wasn't updated by the OTA — only the app partition was) gets synchronized with the binary's compiled version:
static void sync_version_to_assets(void) {
uint8_t *json_data = storage_assets_load_file("config/OTA/firmware.json", &size);
cJSON *root = cJSON_ParseWithLength((const char *)json_data, size);
cJSON *version = cJSON_GetObjectItem(root, "version");
if (strcmp(version->valuestring, FIRMWARE_VERSION) != 0) {
cJSON_SetValuestring(version, FIRMWARE_VERSION);
char *updated = cJSON_PrintUnformatted(root);
storage_assets_write_file("config/OTA/firmware.json", updated);
}
}
What Doesn't Get Erased
An important detail: OTA only writes to the app partition. Everything else is preserved:
- NVS — user settings, calibration data
- Storage — internal FAT filesystem
- Assets — LittleFS with UI assets, config files
- SD Card — obviously untouched
Your settings, saved files, and configuration survive the update.
Rollback Scenarios at a Glance
| Scenario | What Happens |
|---|---|
| P4 crashes before confirming | Bootloader reverts to previous partition automatically |
| C5 flash fails | P4 doesn't confirm, rollback on next reboot restores both chips |
| Power loss during C5 flash | C5 ROM bootloader always accessible, P4 re-flashes on next boot |
| Rollback after C5 was updated | Old P4 has old C5 embedded, version mismatch triggers re-flash |
| Update file corrupted | Validation fails before writing, nothing changes |
| File too large for partition | Size check fails, update aborted |
The User Experience
From the user's perspective, it's straightforward:
- Download
TentacleOS_v1.2.0.binfrom GitHub Releases - Copy it to
/update/tentacleos.binon the MicroSD card - Insert the SD card and trigger the update from the device menu
- Watch the progress bar
- Device reboots, done
If anything goes wrong, the device simply boots back into the previous working firmware. No bricks, no recovery mode, no connecting to a computer.
What's Next: OTA via WiFi
In a future post, we'll cover how TentacleOS handles firmware updates over WiFi. The interesting part: the ESP32-P4 doesn't have WiFi. The C5 downloads the binary over the air and streams it to the P4 via our SPI bridge protocol. The P4 saves it to the SD card, and from there the update process is exactly the same as described in this post.
Same update engine, different delivery method. Stay tuned.
Thank you, guys
