Install downloaded firmware during OTA update

check_for_update() wrote the download to main_ota_temp.py, stamped the new
version in NVS and reset, but never replaced main.py. Devices therefore kept
running the old program while reporting the newest version, so no OTA release
ever reached the field.

The routine now validates the download (HTTP 200, minimum size, setup/loop
present, compiles), keeps the running program as main_prev.py for manual
rollback, renames the download to main.py, and records the version only after
the swap succeeds. A rejected download or failed swap leaves the old program
and version in place so the next boot retries.

CLAUDE.md describes the new flow and the field caveat: devices on the old
build need one manual USB copy first, and the next version.txt must exceed any
number already published because their NVS may claim a version they never ran.

.gitignore drops the __pycache__ left by PC-side syntax checks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main
muhammad.faique 2026-09-11 16:42:07 +05:00
parent 7050a7ef51
commit 4d487d3c58
3 changed files with 75 additions and 15 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
# Byte-code from PC-side syntax checks (python -m py_compile); nothing here runs on the PC
__pycache__/
*.pyc

View File

@ -12,10 +12,10 @@ The server that talks to this device is the UiMetrix ASP.NET Core app in `D:\Pro
## Releasing to devices (OTA)
Devices update themselves at boot from the GitHub repo `husbananjum/m5core-s3-UIMETRIX-Z87` (`main` branch): `check_for_update()` fetches `version.txt`, compares it as a float against the `fw_version` string in NVS namespace `storage` (default `"1.0"`), and if newer downloads that repo's `main.py` to `/flash/main_ota_temp.py`, stores the new version in NVS and resets. A release is therefore: push the new `main.py` and bump `version.txt`. Two things to keep in mind:
Devices update themselves at boot from the GitHub repo `husbananjum/m5core-s3-UIMETRIX-Z87` (`main` branch): `check_for_update()` fetches `version.txt`, compares it as a float against the `fw_version` string in NVS namespace `storage` (default `"1.0"`), and if newer downloads that repo's `main.py` and hands it to `install_update()`. The download must be HTTP 200, at least 1000 bytes, contain `def setup` and `def loop`, and compile; otherwise it is rejected. Installation writes it to `/flash/main_ota_temp.py`, renames the running program to `/flash/main_prev.py` (a rollback copy for manual recovery over USB), renames the download to `/flash/main.py`, and only then stores the new version in NVS and resets. A rejected download or a failed swap leaves the old program and the old version in place, so the next boot retries. A release is therefore: push the new `main.py` and bump `version.txt`. Things to keep in mind:
- Versions compare as floats, so `1.10` is older than `1.9`.
- Nothing in this file copies `main_ota_temp.py` over `main.py`; that promotion has to happen elsewhere on the device (e.g. its `boot.py`). Confirm it exists before relying on OTA, because the new version is stamped in NVS before the reset either way, after which the device reports "Already latest version".
- Builds before 2026-09-11 downloaded the file but never installed it, while still stamping the new version into NVS. Devices running such a build never pick up an OTA release: they need one manual USB copy of the fixed `main.py` first, and because their NVS may already claim a version they never ran, the next `version.txt` must be higher than any number published so far.
## How the program works

View File

@ -1,4 +1,5 @@
import os, sys, io
import gc
import M5
from M5 import *
import network
@ -63,40 +64,96 @@ def set_local_version(version):
local_version = get_local_version()
def _is_valid_program(code):
"""Reject error pages, truncated downloads and anything that does not compile."""
if not code or len(code) < 1000:
return False
if "def setup" not in code or "def loop" not in code:
return False
try:
compile(code, "main.py", "exec")
except SyntaxError:
return False
except MemoryError:
print("Not enough memory to verify the update")
return False
return True
def install_update(new_code, remote_version):
"""Write the new program to flash, swap it in as main.py, then record the version."""
with open(OTA_TEMP_PATH, "w") as f:
f.write(new_code)
# Keep the current program as a rollback copy (restore it over USB if the new one misbehaves).
try:
os.remove(OTA_BACKUP_PATH)
except OSError:
pass
try:
os.rename(OTA_MAIN_PATH, OTA_BACKUP_PATH)
except OSError:
pass # no main.py yet
try:
os.rename(OTA_TEMP_PATH, OTA_MAIN_PATH)
except OSError:
# Put the old program back so the device still boots. The old version stays
# in NVS, so the update is retried on the next boot.
try:
os.rename(OTA_BACKUP_PATH, OTA_MAIN_PATH)
except OSError:
pass
raise
set_local_version(remote_version)
def check_for_update():
try:
print("Checking for update...")
r = requests.get(UPDATE_VERSION_URL)
try:
status = getattr(r, "status_code", 200)
if status != 200:
print("Version check failed: HTTP", status)
return
remote_version = r.text.strip()
finally:
r.close()
print("Remote:", remote_version)
print("Local :", local_version)
if float(remote_version) > float(local_version):
print("New version found. Updating...")
if float(remote_version) <= float(local_version):
print("Already latest version")
return
print("New version found. Downloading...")
gc.collect()
r = requests.get(UPDATE_FILE_URL)
try:
status = getattr(r, "status_code", 200)
if status != 200:
print("Download failed: HTTP", status)
return
new_code = r.text
finally:
r.close()
with open("/flash/main_ota_temp.py", "w") as f:
f.write(new_code)
if not _is_valid_program(new_code):
print("Update rejected: downloaded file is not a valid program")
return
print("Update downloaded. Restarting...")
set_local_version(remote_version)
install_update(new_code, remote_version)
print("Update installed. Restarting...")
machine.reset()
else:
print("Already latest version")
except Exception as e:
print("Update check failed:", e)
UPDATE_VERSION_URL = "https://raw.githubusercontent.com/husbananjum/m5core-s3-UIMETRIX-Z87/refs/heads/main/version.txt"
UPDATE_FILE_URL = "https://raw.githubusercontent.com/husbananjum/m5core-s3-UIMETRIX-Z87/refs/heads/main/main.py"
OTA_MAIN_PATH = "/flash/main.py" # the program the device runs at boot
OTA_TEMP_PATH = "/flash/main_ota_temp.py" # a download lands here first
OTA_BACKUP_PATH = "/flash/main_prev.py" # the previous program, kept for manual rollback
MQTT_CLIENT_ID = serial