How OTA updates work via microSD in TentacleOS

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:

  1. Sends SPI_ID_SYSTEM_VERSION to the C5
  2. Compares the response with FIRMWARE_VERSION (compiled into the binary)
  3. 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 as FIRMWARE_VERSION
  • firmware.json in P4 assets — read at runtime for display and metadata
  • firmware.json in 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:

  1. Download TentacleOS_v1.2.0.bin from GitHub Releases
  2. Copy it to /update/tentacleos.bin on the MicroSD card
  3. Insert the SD card and trigger the update from the device menu
  4. Watch the progress bar
  5. 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

labubu-labubu-doll.gif