Skip to content

OTA Updates for MicroPython Fleets

Shipping firmware once is easy; shipping the next version to devices already deployed in the field, without a truck roll and without bricking any of them, is the actual hard problem. MicroPython devices generally have two distinct update surfaces — updating the frozen firmware image itself, and updating files on the device's own filesystem — and production fleets need a considered answer for both, plus versioning, staged rollout, and integrity verification before committing to a new version. Reviewed against documented ESP32/RP2040 OTA partition schemes and MicroPython's filesystem update patterns; the version-comparison and rollout-percentage logic below is verified with python3.

Two different kinds of "update"

File-level updates replace one or more .py/.mpy files on the device's existing filesystem, without touching the underlying MicroPython interpreter/firmware itself. This is the update path for application logic that lives on the filesystem (see the frozen modules module for what does and doesn't live there) — conceptually a targeted file copy, done safely (see below).

Firmware-image updates replace the entire MicroPython binary, including any frozen modules — this is what's needed to pick up a new MicroPython version, a new frozen module payload, or a change to compiled C-module code. This requires the chip's own OTA mechanism: the ESP32's dual-OTA-partition scheme (an "ota_0"/"ota_1" pair, with a bootloader flag selecting which one boots) or, on RP2040, typically a full reflash via the bootloader's UF2 mechanism, since RP2040 has no built-in dual-partition A/B scheme the way ESP-IDF does.

The A/B partition idea (ESP32)

┌────────────┐   ┌────────────┐   ┌────────────┐
│ Bootloader  │──▶│  ota_0      │   │  ota_1      │
│ picks active│   │ (currently  │   │ (write new  │
│ partition   │   │  running)   │   │  image here)│
└────────────┘   └────────────┘   └────────────┘

The device boots from whichever OTA partition is currently marked active. An update writes the new image into the other, inactive partition — the running firmware is untouched and continues serving the device throughout the download, which can take a while over a slow or intermittent connection. Only once the new image is fully written and verified does the bootloader's active-partition pointer flip. This is what makes power loss mid-download survivable: an interrupted write into the inactive partition just leaves it incomplete; the device reboots into the still-intact partition it was already running.

Versioning and rollback

# app/version.py
CURRENT_VERSION = (2, 3, 1)   # (major, minor, patch)

def is_newer(remote_version, local_version=CURRENT_VERSION):
    return tuple(remote_version) > tuple(local_version)
print(is_newer((2, 4, 0)))   # True
print(is_newer((2, 3, 0)))   # False
print(is_newer((2, 3, 1)))   # False — equal is not newer

Tuple comparison gives correct semantic-version-style ordering for free in Python — (2, 10, 0) > (2, 9, 9) compares correctly element-by-element, unlike comparing version strings lexicographically (where "2.10.0" < "2.9.9" as strings, which is wrong).

Rollback needs a boot-success confirmation step, not just a successful flash: the newly-flashed partition should be marked "pending" rather than immediately and permanently active, and only promoted to the confirmed/default boot target after the new firmware has proven it can actually boot and run its own self-check (a watchdog feed, a successful network connection, whatever the product defines as "healthy"). Without this, a bad update that flashes successfully but crashes on boot leaves the device stuck reflashing itself into the same bad image forever — the rollback module and the watchdog module's material are meant to be combined here directly.

# Sketch of the confirm-or-rollback logic, run early after a boot
# that followed an update
def confirm_or_rollback(health_check, mark_confirmed, mark_rollback):
    if health_check():
        mark_confirmed()
    else:
        mark_rollback()   # boot into the previous (still-intact) partition

Staged rollouts

Pushing a new version to 100% of a fleet simultaneously is how one bad release becomes a fleet-wide outage. A staged rollout gates which devices see the update first, typically by a deterministic per-device hash so the same device consistently lands in the same rollout wave across checks:

import uhashlib

def rollout_bucket(device_id, num_buckets=100):
    digest = uhashlib.sha256(device_id.encode()).digest()
    return digest[0] % num_buckets   # 0-99, stable per device

def should_update(device_id, rollout_percent):
    return rollout_bucket(device_id) < rollout_percent
# Wave 1: 5% of the fleet
print(should_update("device-001a", 5))
# Wave 2, a day later, if wave 1 looked healthy: 25%
print(should_update("device-001a", 25))

Using a hash of the device ID rather than random chance each check means a given device's bucket is stable — it doesn't randomly flip in and out of the rollout on every poll, which would make "is this device part of the canary group" unanswerable.

Verifying integrity before switching

Never flip the active partition, or overwrite a filesystem file in place, before confirming what was written matches what was intended. For firmware images, this is normally the chip's own OTA library verifying a signature/hash as part of the flash-write API (ESP-IDF's OTA update APIs do this). For filesystem-level file updates, the same principle applies at a smaller scale:

import uhashlib
import ubinascii

def verify_download(data, expected_sha256_hex):
    actual = ubinascii.hexlify(uhashlib.sha256(data).digest()).decode()
    return actual == expected_sha256_hex

def apply_update_file(path, data, expected_hash):
    if not verify_download(data, expected_hash):
        raise ValueError("downloaded update failed integrity check")
    tmp_path = path + ".tmp"
    with open(tmp_path, "wb") as f:
        f.write(data)
    import os
    os.rename(tmp_path, path)   # atomic on most filesystems — no partial file visible

Writing to a temporary path and renaming into place, rather than overwriting the target file directly, is the detail that prevents a power loss mid-write from leaving a half-written, unusable file where a working one used to be — os.rename on a single filesystem is effectively atomic, while streaming new bytes directly on top of the existing file is not.

How It Actually Works

Both update paths lean on a mechanism this course keeps returning to: never mutate the thing currently in use — write the new version somewhere else, verify it, then flip a single pointer.

  • The ESP32's A/B partition scheme is enforced by the boot ROM itself, not by MicroPython. The chip's first-stage bootloader (burned into read-only mask ROM, running before any of your firmware) reads a small "otadata" partition containing a sequence number and validity flags for each OTA slot, and jumps to whichever partition that data currently designates active. Espressif's OTA API writes the new image into the inactive partition's flash region — physically separate flash sectors from the ones the running firmware occupies — and only as its final step updates the otadata partition's sequence number. Because the currently-executing code is being read from flash sectors the update never touches, a power loss at any point during the download leaves those sectors — and the otadata pointer — exactly as they were: the ROM bootloader boots the same partition it always did.
  • Version-tuple comparison working correctly for free is Python's built-in lexicographic tuple ordering, applied to something where that happens to be exactly the right semantics(2, 10, 0) > (2, 9, 9) compares the first elements (2 == 2), then the second (10 > 9), and stops, exactly the way semantic versioning defines "newer." String comparison instead compares byte values character-by-character, so "2.10.0" loses to "2.9.9" because the character '1' (0x31) sorts before '9' (0x39) at the very first differing position — the bug isn't in version numbering at all, it's a byte-ordering artifact of using the wrong data type for the comparison.
  • os.rename being atomic on a single filesystem is a real LittleFS (and most filesystem) guarantee rooted in how directory-entry updates work at the block level — renaming a file only needs to update the directory metadata block that maps a name to a data location, a single, small, journaled write LittleFS can complete or not complete as one unit (recall module 7, Level 1's copy-on-write log structure). Writing new bytes directly on top of an existing file, by contrast, touches potentially many data blocks over an extended period — a power loss partway through leaves some blocks new and some old, an inconsistent mix no rename-based scheme ever exposes because the old file's blocks are never touched until the rename swaps which directory entry points where.
  • The rollout hash bucketing works because SHA-256 output is (for practical purposes) uniformly distributed across its output space — taking the first byte of a cryptographic hash and reducing it modulo 100 spreads device IDs evenly across buckets 0-99 regardless of how device IDs are actually named (sequential, random, whatever), because a cryptographic hash function is specifically designed so that small, patterned changes in input (like an incrementing device counter) produce effectively unpredictable, uncorrelated output bits — the same property that makes hash functions useful for integrity verification is what makes them useful for stable, pseudo-random-but-deterministic fleet bucketing here.

Cheat sheet

Concept Detail
File-level update Replace .py/.mpy on the filesystem; doesn't touch firmware
Firmware-image update Full OTA flash; ESP32 uses dual A/B partitions, RP2040 typically a full UF2 reflash
Tuple version comparison Correct ordering without string-comparison pitfalls
Confirm-or-rollback Mark new firmware pending until it proves it boots and runs healthily
Staged rollout via hashed device ID Stable per-device bucket, no flip-flopping between checks
Verify-then-atomic-rename Never leave a partially-written file as the live one

Exercise

In plain Python, implement rollout_bucket(device_id, num_buckets=100) using hashlib.sha256 (the desktop equivalent of uhashlib), and should_update. Generate 1000 fake device IDs (e.g. f"device-{i:04d}") and, for a rollout percentage of 10, count how many fall into the update group — confirm it's reasonably close to 10% (some natural variance from hashing is expected; note the actual count you get). Then implement is_newer with tuple comparison and write test assertions confirming (1, 12, 0) is correctly treated as newer than (1, 9, 5) — the case a naive string comparison would get wrong.