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 never leaves the device without working firmware.
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 a banner with the deadline and an Update Now button; once the deadline passes, or when none was set, the prompt covers the whole dashboard and its controls are disabled. 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 runs¶
The install trigger is a single request, /get?UpdateToVersion=<version>. It requires the settings arm gate to be open (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 on the user's confirm click, so the gate adds no extra step for the user. (The only things the endpoint honors ahead of the gate are a request to turn the field off and a request to silence the alarm.)
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 into the updated slot (ota_0) → SHA-256 + RSA verify
│ │ → select 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 into ota_0 → verify
│ → select 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 is the "golden installer": because no OTA ever writes it, a bug in a new release cannot break the code 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 no software protection running. With the field energized the installer logs "Staged update waiting: install starts when charging stops (field off)" and waits.
Once both hold, the request is consumed and the installer checks two more things before downloading: a WiFi signal of at least −90 dBm and a short internet reachability test. Failing either abandons the attempt, and the update has to be started again.
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 verifies. - 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 stored 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 new image is never selected. A failure before the firmware entry starts streaming leaves the updated slot untouched, so the previous version boots again at the next restart. Once the firmware entry has begun streaming, the slot's previous contents are already gone, because opening the slot for writing erases it. The half-written image header is then erased and the record dropped, so nothing partially installed can be promoted, and the device runs the factory installer, itself a complete firmware, until the next successful install.
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 moved to a new host without updating devices in the field.
Released version numbers never go backward, which is what lets the forced-update check treat a running version at or past the flagged one 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 for an update that installs and starts but does not work correctly (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.
| 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: if validateWebFilesystem() finds the production set missing or truncated, the device rolls back to the factory firmware and its immutable factory copies, 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 new unit runs from
ota_0as shipped, 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) boots 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.
- Booting the factory installer exists only so the updated slot is not executing while it is rewritten. 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 new image unselected, 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 code itself only takes effect on real updates after the factory slot is re-flashed, since the factory app is the one that performs installs.