diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1040f89 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# Byte-code from PC-side syntax checks (python -m py_compile); nothing here runs on the PC +__pycache__/ +*.pyc diff --git a/CLAUDE.md b/CLAUDE.md index 341697c..7bc3b90 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/M5-Stack Code for Uimetrix.txt b/M5-Stack Code for Uimetrix.txt index 47cfebf..26809ca 100644 --- a/M5-Stack Code for Uimetrix.txt +++ b/M5-Stack Code for Uimetrix.txt @@ -1,4 +1,5 @@ import os, sys, io +import gc import M5 from M5 import * import network @@ -63,33 +64,86 @@ 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) - remote_version = r.text.strip() - r.close() + 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 - r = requests.get(UPDATE_FILE_URL) + 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) - machine.reset() - - else: - print("Already latest version") + install_update(new_code, remote_version) + print("Update installed. Restarting...") + machine.reset() except Exception as e: print("Update check failed:", e) @@ -97,6 +151,9 @@ def check_for_update(): 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