Over-the-Air Updates¶
The XREG-010 updates its own firmware and dashboard over WiFi: the user picks a version, the device reboots into a dedicated recovery installer, and the installer downloads, cryptographically verifies, and writes the new software. No cable, no computer, and a failed update leaves the previous software in place.
What the user sees¶
The dashboard's software-update panel fetches the list of released versions, with release notes, from the project's update server. The current version is shown alongside; clicking a version starts the update. The device disables the alternator, reboots, installs, and comes back up on the new version — typically two to three minutes end to end.
There is also a forced update path: the cloud can flag a critical release for a device, in which case the dashboard shows an "update now" prompt. It never auto-installs — a person always clicks the button. (Firmware-side: the flag only raises the prompt; the same install trigger below does the work.)
How an update actually runs¶
The install trigger is a single request, /get?UpdateToVersion=<version>. It sits behind the settings arm gate (Networking and Web Server), like every other mutation: a stale browser tab or a replayed URL must not be able to reboot the device into an installer. The dashboard's two update flows arm the window just-in-time, on the user's confirm click, so the gate costs the user nothing. (The only thing the endpoint honors ahead of the gate is a request to turn the field off.)
The handler:
- Turns the alternator field off for safety.
- Records the requested version in non-volatile storage (the
update_reqrecord, written by theUpdateToVersionblock in the/gethandler), along with a wake flag so the device keeps WiFi up after the reboot. - Sets the next boot to the recovery installer and restarts.
As a decision tree (the rare first branch applies when the device is already running the installer, e.g. after a recovery boot):
Update requested (/get?UpdateToVersion=<version>)
├─ Already running the factory installer?
│ ├─ YES → install immediately:
│ │ download → SHA-256 + RSA verify → write updated slot (ota_0)
│ │ → record the verified install → reboot
│ │ → boot logic sees ota_0 matching that record → runs new firmware
│ └─ NO (running normal firmware from ota_0)
│ → write update request to NVS (target version + update flag + wake flag)
│ → set next boot to the factory installer → reboot
│ → installer finds the request → download → verify → write ota_0
│ → record the verified install → reboot
│ → boot logic sees ota_0 matching that record → runs new firmware
The recovery installer is the factory application — a complete, known-good copy of the firmware that ships in its own flash slot and is never overwritten by over-the-air updates. It serves as a "golden installer": because no OTA ever touches it, a bug in a new release cannot break the machinery that installs the next one. The only way to refresh this copy in the field is the USB Firmware Reset procedure, which rewrites both application slots together.
On boot, the installer finds the pending request (checkForPendingUpdateNonBlocking()) and runs the install (performOTAUpdateToVersion() → performStreamingOTAUpdate()) — but only once two conditions hold, and the flag stays armed until they do:
- WiFi is connected. An intermittent hotspot that is absent at this boot must not consume the request; the install fires whenever the network next appears.
- The alternator field is de-energized. The install blocks the loop task for the whole download-and-write, which would freeze the field's PWM at its last commanded duty with every software protection dead. With the field energized the installer logs "Staged update waiting: install starts when charging stops (field off)" and waits.
Then:
- The release is a single archive containing both the firmware image and the compressed dashboard web files, downloaded from the project's update server and unpacked as it streams (
StreamingExtractor) — nothing is buffered whole, so a multi-megabyte release installs within the device's modest RAM. - A SHA-256 digest is computed incrementally over the stream, and an RSA signature is verified on-device against a public key compiled into the firmware (
OTA_PUBLIC_KEY). Firmware goes to the spare application slot, web files to the production web area (erased first, so a renamed asset cannot leave its predecessor behind) — and the new slot is only selected for boot after the signature checks out. - Selecting it also writes a verified-install record: the SHA-256 of the whole image, stored in non-volatile storage next to the update flags. The identity is the image digest itself, never a version string or descriptor field — a descriptor can be copied into a forged image, the digest cannot. Boot selection later refuses to promote the updated slot unless the image sitting there still hashes to that record (see Boot selection and recovery).
- On success the device reboots into the new firmware. On any failure — bad signature, dropped connection, write error — the install is abandoned and the previously working software remains in place. If the firmware entry had already begun streaming, the half-written slot's image header is erased and the record dropped, so nothing partially installed can be promoted afterwards.
Trust model¶
Transport is HTTPS, but the trust anchor is the on-device signature check, not the connection. Even if the download path were redirected or tampered with, an image that does not verify against the embedded public key is refused. This is why the download client does not pin the server's certificate chain: integrity comes from the signature, and the server can be re-homed without touching devices in the field.
Released version numbers only move forward (monotonic), which is what lets the forced-update check treat an exact version match as "already installed" and clear the flag. The version list itself is not restricted to newer releases: it offers every published version except the one currently running, and picking an earlier one installs by the same path as any other.
Boot selection and recovery¶
Every boot, ensurePreferredBootPartition() decides which application runs:
- Already running the updated slot? Nothing is switched. The firmware only makes sure the verified-install record matches the image it is running, and writes it if it is missing or stale — which is how a USB-flashed unit, or one whose record was cleared, gets a record at all.
- Running the factory installer? It promotes the updated slot only when that slot holds a valid image and its digest matches the verified-install record. Structural validity on its own is not enough: a slot can hold a perfectly well-formed image that never passed the signature check — the download finished, verification then failed — and the bootloader's own checks cannot tell that apart from a good install.
- If the updated slot is empty, invalid, has no record, or does not match its record, the device stays on the factory installer automatically.
- Holding the recovery pin (GPIO41) low at boot forces the factory application regardless — a hardware override if an update misbehaves (strap wiring: Troubleshooting → Forced recovery). It also clears any pending update request and the verified-install record, so a bad request cannot cause a boot loop — and the device then keeps booting the factory application after the pin is released, until the next verified install.
As a matrix:
| GPIO41 (recovery pin) | Updated slot (ota_0) |
Application that runs | Dashboard files |
|---|---|---|---|
| Held low at boot | any | Factory installer (record cleared) | Factory copies (factory_fs) |
| Normal (high) | valid and matching the verified-install record | Updated firmware (ota_0) |
Production set (prod_fs) |
| Normal (high) | invalid, unrecorded, or not matching its record | Factory installer | Factory copies (factory_fs) |
The dashboard files have the same redundancy: validateWebFilesystem() falls back to the immutable factory copies if the production set fails, so there is always a working interface (details: Networking and Web Server).
Lifecycle, end to end¶
- As shipped — a new unit is programmed on both sides: the same firmware image in the factory slot and the updated slot, the same web bundle in both web areas, and the boot record already pointing at the updated slot. So a unit runs from
ota_0out of the box, with the factory slot held back as a pristine copy of that same version. (This is deliberate: the update checks that let us push a critical release to a device are skipped while it is running the factory application, so a unit that shipped booting the factory slot could never be reached by a forced update.) - First update — nothing special. Because the unit is already running from the updated slot, its first update takes the ordinary path below, like every later one.
- Every later update — the running firmware hands off to the factory installer via the NVS update request, the installer writes the updated slot, and the device boots back into it.
- Recovery — GPIO41 (or a corrupt updated slot) lands you on the factory firmware until the next successful update.
Invariants¶
- The factory installer is the fallback, always. A boot that starts in the factory installer moves to the updated slot only when that slot matches the verified-install record; anything else leaves the factory application running.
- Updates only ever write the updated slot. The factory installer is never overwritten over the air — it is the permanent, known-good recovery path.
- The trip through the factory installer exists only to get the running app out of the way. A flash slot cannot be rewritten while it is executing, so the installer — running from its own slot — does all download, verify, and write work.
- The new image only becomes bootable after its signature verifies. Any failure — bad signature, dropped connection, write error — leaves the previous software selected and running, erases the half-written slot, and drops the verified-install record, so a later factory boot cannot promote the abandoned attempt.
A note for contributors flashing over USB¶
A device boots the updated slot both from new (units are shipped with both slots programmed) and after any over-the-air update — and the Arduino IDE's USB upload writes the factory slot. The practical symptom: you flash your modified code over USB, the upload reports success, and the device behaves exactly as before, because your code is in the factory slot while the previously installed update keeps booting. The build is intact; it is not the one being selected. To run factory-slot code, hold the recovery pin (GPIO41) low at boot, which forces the factory application. That pin also clears the verified-install record, so the device keeps booting the factory slot after you release it — it returns to the updated slot only when an over-the-air install completes and records itself again. Conversely, anything you change in the download-and-verify machinery itself only takes effect on real updates after the factory slot is re-flashed, since the factory app is the one that performs installs.