OTA (espos_ota) — signed updates with rollback — M6¶
What happens¶
- Source. Either a direct URL (
POST /api/v1/ota {"url": …}, the OTA page's "From a URL") or a version manifest (ota.manifest_url+ota.channel), fetched ~20 s after WiFi comes up and then everyota.check_hhours whenota.auto_checkis on. A newer matching build shows up as available; withota.auto_installit is installed right away, otherwise someone clicks Install. - Download + verify.
esp_https_otastreams the image into the other OTA slot. Before writing, the image header is checked: it must be an ESP-IDF app whoseproject_nameequals the running one (anesposdevice does not accept amyotherappimage). At the end the image's SHA-256 and its RSA-3072 signature are verified (CONFIG_SECURE_SIGNED_ON_UPDATE_NO_SECURE_BOOT); the public key is compiled into the running firmware. Anything else is rejected withimage rejected: bad signature or corrupt, and nothing changes. - Reboot into the new slot ~1.5 s after the status says
ready. - Confirm or roll back. The new image boots as
pending_verify(CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE). It marks itself valid as soon as WiFi is connected (or, if no station is configured/enabled, after 60 s of uptime). If it panics before that, the bootloader boots the previous slot on the next reset. If it runs but cannot reach the network withinota.confirm_tmo_s(default 600 s), it marks itself invalid and reboots into the previous slot.POST /api/v1/ota/confirmconfirms by hand,POST /api/v1/ota/rollbackgoes back on purpose. The status showsrunning.rolled_backwhen the other slot holds an image that failed.
Plain http:// sources are allowed (CONFIG_ESP_HTTPS_OTA_ALLOW_HTTP):
integrity and authenticity come from the signature, not the transport.
https:// uses the ESP-IDF certificate bundle; ota.allow_insecure skips
the certificate check for self-signed boat servers.
Signing key¶
sdkconfig.d/espos.defaults turns on Require signed app images with the RSA
scheme and signs every build with secure_boot_signing_key.pem in the
project root. That file is git-ignored and never committed. When it is
missing the root CMakeLists.txt generates a development key
(espsecure generate_signing_key --version 2 --scheme rsa3072) with a
warning, so a fresh checkout builds — but a device flashed with a
dev-key build only accepts updates signed with that same dev key.
Every project directory gets its own key, because the key lives beside
the project and is generated when it is missing. espOS's own tree is one
project; so is each example under components/*/examples/; so is every
firmware built on espOS. Build the same application from two directories
and you get two keys, and an image from one is refused by a device flashed
from the other:
E (21719384) esp_image: Secure boot signature verification failed
W (21719545) esp_image: image valid, signature bad
E (21719546) esp_ota_ops: New image failed verification
E (21719547) espos_ota: install failed: image rejected: bad signature or
corrupt (ESP_ERR_OTA_VALIDATE_FAILED)
That is the device working correctly — the whole point of signing is that it refuses an image it was not told to trust — but "signature bad" says nothing about which key it wanted. To find out, compare the public key inside the running image with the one you signed with:
The fix is to name the key rather than let a directory invent one, which is
what SIGNING_KEY below is for.
For anything you ship:
espsecure generate_signing_key --version 2 --scheme rsa3072 secure_boot_signing_key.pem
# keep it outside the repo (password manager, CI secret) and copy it in for release builds
Or leave it where it is and name it: espos_project_prologue(... SIGNING_KEY
/path/to/key.pem). The prologue then hands that path to IDF's signing step
(CONFIG_SECURE_BOOT_SIGNING_KEY, through a generated defaults fragment) and
never generates a key there — a named key that is missing stops the build.
Because defaults only reach a fresh sdkconfig, an existing one that names a
different key also stops the configure with an explanation: it would
otherwise keep signing with the old key.
Every device that should take your updates must have been flashed at least
once (USB) with a build carrying that key's public part. Rotating the key
means one signed update built with the old key that already contains the
new public key. ESP32 (original) needs chip rev ≥ 3 for the RSA scheme
(sdkconfig.d/espos.defaults.esp32 pins ESP32_REV_MIN_3). This is signed apps
without Secure Boot: it protects against network-side tampering, not
against someone with the USB port. Hardware Secure Boot / flash encryption
are release-overlay decisions (security.md).
Swapping the key¶
ESP-IDF's signing step depends on the unsigned binary alone, so replacing
secure_boot_signing_key.pem and rebuilding keeps the previous
signature — no source changed, nothing re-links, and the build log looks
normal. The root CMakeLists.txt fingerprints the key and forces a
re-link when it differs, so this is handled; if you copy that block into
your own project, copy the fingerprinting with it. Verify before flashing
anything you cannot recover over the air:
Releasing, and forking¶
Signing is inherited by every project built on espOS, so this applies to them and to anyone who forks one.
Ordinary builds need nothing: the development key is generated automatically, so pushes and pull requests build on a fork unchanged. Publishing a release is the decision point, because that artefact is what other people install.
- You run devices that take OTA updates — generate a key, keep it, and give it to CI as a secret:
espsecure generate-signing-key --version 2 --scheme rsa3072 signing_key.pem
gh secret set <APP>_SIGNING_KEY_PEM < signing_key.pem
Losing that file means no already-flashed device can ever take another OTA. It is the one thing worth backing up.
- You just want a flashable binary — release without a key, and say so
in the release. The convention is a repository variable
<APP>_ALLOW_UNSIGNED_RELEASE=true: the build uses a throwaway key and the release notes are marked USB-only. Devices flashed from it reject every OTA, including later releases from the same fork.
A release workflow should fail when neither is set rather than quietly falling back to a throwaway key. That fallback produces a release which installs fine and then rejects every future update — a failure that surfaces on the device, over the air, long after CI went green.
There is deliberately no committed default key. A private key in a public repository is a key everyone has, and any device trusting it would accept firmware signed by anyone. Making the safe path opt-in would also mean most forks ship the shared key without noticing.
Manifest format (schema: 1)¶
{
"schema": 1,
"app": "espos",
"builds": [
{"version": "0.6.1", "target": "esp32p4", "channel": "stable",
"url": "espos-esp32p4-0.6.1.bin", "size": 1314816,
"sha256": "…hex…", "notes": "M6", "date": "2026-08-18"},
{"version": "0.7.0-beta.1", "target": "esp32p4", "channel": "beta",
"url": "https://fw.example.org/espos/espos-esp32p4-0.7.0-beta.1.bin"}
]
}
app(optional, top level or per build) must equal the device's project name when present.- Per build:
version,target(the IDF target name, compared verbatim with the device's own; CI buildsesp32 esp32s3 esp32c3 esp32c5 esp32c6 esp32p4),url(absolute, or relative to the manifest URL) are required;channeldefaults tostable;size,sha256,notes,dateare informational (sha256is not what protects the image — the signature is). - The device takes the highest version among builds matching its
target, channel and app, and calls it available when it is strictly
newer than the running version. Versions compare as dotted numbers with
a
-prereleasesuffix sorting below the release (1.2.10 > 1.2.9,1.0.0-beta.1 < 1.0.0); anything unparsable compares as a plain string. The running version isversion.txtin the project root (PROJECT_VER). - Content type does not matter; the manifest must be ≤ 16 KiB.
A boat-side distribution service only has to serve this file and the images (static hosting is enough) — that is the deferred "firmware distribution service" of the plan, and nothing in espOS ties it to a host.
Configuration (ota namespace)¶
manifest_src (url), manifest_url, manifest_path, channel (stable),
auto_check (on), check_h (24), auto_install (off), allow_insecure
(off), confirm_tmo_s (600).
Finding updates through the SignalK server¶
manifest_src = "signalk" derives the manifest URL from whichever server the
device is already connected to, appending manifest_path
(/plugins/signalk-espos-updates/manifest.json by default) to the server's
address.
The point is that nothing has to be typed per device. A fresh device that finds its server also finds its updates, and a server that moves does not strand a fleet on a URL that no longer resolves. It needs a plugin on that server actually serving a manifest at that path.
Two failures are reported plainly rather than papered over:
- No server selected yet — ordinary at boot, before discovery has run. The next scheduled check finds one.
- This firmware has no SignalK client — the setting asks for something
the build cannot do. It does not fall back to
manifest_url, because silently checking a different place than the one configured is worse than saying so.
espos_sk is an optional dependency of espos_ota for this, not a required
one: a firmware can do OTA with no SignalK at all, and requiring it for one
convenience would be the wrong trade.
API¶
GET /api/v1/ota/status, POST /api/v1/ota/check, POST /api/v1/ota
{"url"?}, POST /api/v1/ota/confirm, POST /api/v1/ota/rollback, SSE
ota — see rest-api.md.
Testing¶
- Host:
test/host/espos_ota_test(manifest selection, version compare, URL resolution) andrun_test.py OtaTests OtaRollbackTimeoutTests(sim port: manifest check, install with progress, rejected images — wrong project / bad signature / not an image / 404 —, busy handling, confirm-once-connected, rollback after the timeout, manual rollback). - Device (done on the ESP32-P4 panel, 2026-08-18): flash 0.6.0 by USB;
serve
build/espos.binof a 0.6.1 build plus a manifest withpython3 -m http.server; setota.manifest_url; check → available → install → reboot intoota_1→new image confirmed (network up).build/espos-unsigned.binfrom the same build →image rejected. A build withidf.py -DESPOS_BROKEN_BUILD=1 build(aborts inapp_main) → installed → boots, aborts, the bootloader boots the previous slot → statusrolled_back: true, other_version: 0.6.2.