MoonBase¶
A small maintenance image in the factory slot that installs updates into one large app slot, instead of spending half the flash on a second copy of the firmware. A power cut mid-update lands back in MoonBase rather than a half-written app.
What it replaces comes first, then the update cycle, then how MoonBase itself is updated and how the two images are told apart.
flowchart LR
subgraph flash["one flash, two images"]
direction TB
base["factory slot<br/>MoonBase, ~750 KB<br/>small and rarely changing"]
app["app slot<br/>projectMM<br/>one copy, not two"]
fs["filesystem<br/>the space the second<br/>app slot used to hold"]
end
base -->|"installs the app<br/>while running from factory"| app
app -->|"installs MoonBase<br/>while running from ota_0"| base
classDef recovery fill:#4d3d1f,stroke:#c9a95f,color:#fff
classDef running fill:#1f4d3d,stroke:#5fb89a,color:#fff
classDef gained fill:#2d3561,stroke:#7b88c9,color:#fff
class base recovery
class app running
class fs gained
Neither image can rewrite the partition it is executing from, so each installs the other. That is the whole scheme: the arrows are the only two write paths, and the app is the only thing that can repair a broken recovery image. The two directions fail differently, and both fail safe. An app update points the bootloader at MoonBase first, so a power cut anywhere in it lands in MoonBase, which a user retries from over the network. A MoonBase update writes and verifies the factory slot without touching otadata, so a cut there leaves the still-valid app in ota_0 to boot and try again.
What it replaces¶
Dual-OTA spends half the app area on a second copy of the firmware that is idle except during an
update. MoonBase replaces it: a small, rarely-changing image in the partition table's
factory slot that owns the device while the application is being replaced, since a board
cannot rewrite the partition it is executing from. One app slot then suffices, and the flash the
second slot held goes elsewhere.
A 4 MB board has no choice, having room for one application and not two, and its app slot grows by a third in exchange. On a 16 MB board the choice is deliberate rather than forced, and the freed 4 MB goes to the filesystem, 11 MB rather than 7.
Which boards use MoonBase is a per-variant decision recorded in
moondeck/build/build_esp32.py rather than a property of flash size. Today the 4 MB classic, the
S3-Zero and esp32-16mb use it, and it may become the default everywhere.
The update cycle¶
The update cycle runs in three moves. The app stages the install URL in NVS, or nothing for a
browser upload, points the bootloader at MoonBase and reboots. MoonBase joins the network with
the app's stored credentials, falling back to an AP at 4.3.2.1, and installs into the single app
slot, either from the staged URL unattended or from an upload. Then it reboots back. The UI covers the whole cycle with one
"updating firmware" overlay, telling the two images apart via GET /moonbase (MoonBase answers
with its live status; the app 404s it). Pointing the bootloader at a factory partition erases
otadata, so a power cut anywhere mid-install boots MoonBase and the user retries over the
network, a stronger power-fail story than dual-OTA's. A failed install deliberately stays in
MoonBase, visibly, rather than silently reverting to the old app; the way back is its explicit
"Boot the app" action, which only boots an image that validates.
Updating MoonBase itself¶
Updating MoonBase itself runs the same cycle backwards: the app writes the factory slot while
running from ota_0, exactly as MoonBase writes the app slot while running from factory. Neither
image can rewrite the partition it executes from, so each installs the other and the app is the
only thing that can repair a broken recovery image. Without it a bad MoonBase means a cable, which
is the failure MoonBase exists to prevent.
Two things make that safe enough to offer. esp_ota_* refuses a factory partition, so this is a
raw esp_partition_erase_range + esp_partition_write, which also forfeits the validation
esp_ota_end performs: esp_image_verify replaces it after the write. A 4 MB board also has nowhere to stage 743 KB before erasing, so the image streams straight in.
Everything that can reject it is therefore decided from its FIRST CHUNK, before a byte is erased:
the image magic, the chip id, and the descriptor naming projectMM-moonbase rather than the app.
The chip id matters because there is one MoonBase per chip, one paste apart, and a checksum does
not catch a swap. Those rules live in src/core/FirmwareImage.h so
a host test can drive them. What remains is a window, during the write, in which the device holds
no recovery image; the app keeps running throughout, so the answer to a failure is to retry.
Telling the two images apart¶
Each image reports its version from the app descriptor IDF puts in every binary, with
PROJECT_VER set to the same computed version for both. The app can therefore read the factory
partition's version without booting it, and say when the two were built apart. A device that cannot name its
own recovery image cannot be diagnosed: two boards that looked identical, one of which could not
install firmware, took a bisect of the git log to tell apart.
MoonBase is a standalone ESP-IDF project (moonbase/, ~750 KB against an 896 KB slot) sharing
no sources with the app, the deliberate trade for an image that must stay small and, once
working, hardly change. moondeck/build/build_esp32.py builds it alongside every variant that opts in, and owns the
flash-layout helpers every consumer uses: serial flash, mooninstaller manifests, release preview
and the QEMU image. IDF's own flasher_args.json knows nothing of the two-image scheme and
stages the app at the factory offset, so each of those paths applies the same correction from one
place. Prior art: Tasmota's safeboot scheme and
MycilaSafeBoot proved the single-slot +
recovery-image pattern; MoonBase is our from-scratch, minimal take on it.