add recovery firmware with OTA updater and recovery web interface

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
proddy
2026-10-06 22:57:25 +02:00
co-authored by Cursor
parent 1375f87163
commit 6411b6ffbd
59 changed files with 3595 additions and 339 deletions
+59
View File
@@ -0,0 +1,59 @@
# Builds the Recovery WebUI into recovery/WWWData.h when it's missing or older than its sources
from pathlib import Path
import os
import shutil
import subprocess
Import("env")
INTERFACE_DIR = Path("interface")
OUTPUT_FILE = Path("recovery") / "WWWData.h"
SOURCES = [
INTERFACE_DIR / "recovery.html",
INTERFACE_DIR / "vite.config.ts",
INTERFACE_DIR / "progmem-generator.js",
INTERFACE_DIR / "src" / "recovery",
INTERFACE_DIR / "src" / "components",
INTERFACE_DIR / "src" / "CustomTheme.tsx",
INTERFACE_DIR / "public",
]
def newest_mtime(paths):
newest = 0.0
for path in paths:
if path.is_dir():
for f in path.rglob("*"):
if f.is_file():
newest = max(newest, f.stat().st_mtime)
elif path.exists():
newest = max(newest, path.stat().st_mtime)
return newest
def is_stale():
if not OUTPUT_FILE.exists():
return True
return newest_mtime(SOURCES) > OUTPUT_FILE.stat().st_mtime
def build():
pnpm = next((n for n in ("pnpm", "pnpm.cmd", "pnpm.exe") if shutil.which(n)), None)
if pnpm is None:
print("Error: pnpm not found in PATH, cannot build the Recovery WebUI")
env.Exit(1)
os.environ["CI"] = "true"
for command in (f"{pnpm} install", f"{pnpm} build-recovery"):
print(f"Running: {command}")
result = subprocess.run(command, shell=True, cwd=INTERFACE_DIR)
if result.returncode != 0:
print(f"Error: '{command}' failed")
env.Exit(1)
if is_stale():
print("Building Recovery WebUI...")
build()
else:
print("Recovery WebUI is up to date")
+8 -6
View File
@@ -11,14 +11,16 @@ OUTPUT_DIR = Path("build")
def bin_copy(source, target, env):
"""Optimized firmware renaming and copying function."""
# Get the application version from emsesp_version.h
version_file = Path('./src/emsesp_version.h')
# Get the application version, from emsesp_version.h unless overridden in the env
fw_name = env.GetProjectOption("custom_fw_name", "EMS-ESP")
version_file = Path(env.GetProjectOption("custom_version_file", "./src/emsesp_version.h"))
version_define = env.GetProjectOption("custom_version_define", "EMSESP_APP_VERSION")
if not version_file.exists():
print("Error: emsesp_version.h not found!")
print(f"Error: {version_file} not found!")
return
app_version = None
version_pattern = re.compile(r'^#define EMSESP_APP_VERSION\s+"(\S+)"')
version_pattern = re.compile(rf'^#define {version_define}\s+"(\S+)"')
with version_file.open('r') as f:
for line in f:
@@ -28,7 +30,7 @@ def bin_copy(source, target, env):
break
if not app_version:
print("Error: Could not find EMSESP_APP_VERSION in emsesp_version.h!")
print(f"Error: Could not find {version_define} in {version_file}!")
return
# Get the chip type, in uppercase
@@ -61,7 +63,7 @@ def bin_copy(source, target, env):
# Convert . to _ so Windows doesn't complain
# Format is EMS-ESP-<version>-<mcu>-<flash> with + at the end if it has PSRAM
variant = f"EMS-ESP-{app_version.replace('.', '_')}-{mcu}-{flash_mem}{'+' if psram else ''}"
variant = f"{fw_name}-{app_version.replace('.', '_')}-{mcu}-{flash_mem}{'+' if psram else ''}"
# Create output directories
firmware_dir = OUTPUT_DIR / "firmware"
+69
View File
@@ -0,0 +1,69 @@
# Writes the project name (custom_fw_name) and version (custom_version_file/custom_version_define) into the
# esp_app_desc_t of firmware.bin, which the prebuilt Arduino libraries leave empty. The application can then
# identify the image with esp_ota_get_partition_description(), or from the first bytes of an upload.
# Must run before rename_fw.py so the renamed copy and its MD5 include the change.
import hashlib
import re
import struct
from pathlib import Path
Import("env")
IMAGE_HEADER_LEN = 24
SEGMENT_HEADER_LEN = 8
APP_DESC_OFFSET = IMAGE_HEADER_LEN + SEGMENT_HEADER_LEN
APP_DESC_MAGIC = 0xABCD5432
HASH_APPENDED_OFFSET = 23
def read_version(env):
version_file = Path(env.GetProjectOption("custom_version_file", "./src/emsesp_version.h"))
version_define = env.GetProjectOption("custom_version_define", "EMSESP_APP_VERSION")
pattern = re.compile(rf'^#define {version_define}\s+"(\S+)"')
for line in version_file.read_text().splitlines():
match = pattern.match(line)
if match:
return match.group(1)
raise ValueError(f"{version_define} not found in {version_file}")
def put_string(image, offset, value):
encoded = value.encode()[:31]
image[offset:offset + 32] = encoded + b"\0" * (32 - len(encoded))
def set_app_desc(source, target, env):
path = Path(target[0].get_abspath())
image = bytearray(path.read_bytes())
(magic,) = struct.unpack_from("<I", image, APP_DESC_OFFSET)
if magic != APP_DESC_MAGIC:
print(f"Error: no app description found in {path}")
env.Exit(1)
name = env.GetProjectOption("custom_fw_name", "EMS-ESP")
version = read_version(env)
put_string(image, APP_DESC_OFFSET + 16, version)
put_string(image, APP_DESC_OFFSET + 48, name)
# the description lives in the first segment, so the image checksum and SHA256 need updating
checksum = 0xEF
pos = IMAGE_HEADER_LEN
for _ in range(image[1]):
_, length = struct.unpack_from("<II", image, pos)
pos += SEGMENT_HEADER_LEN
for b in image[pos:pos + length]:
checksum ^= b
pos += length
pos += 15 - (pos % 16)
image[pos] = checksum
pos += 1
if image[HASH_APPENDED_OFFSET] == 1:
image[pos:pos + 32] = hashlib.sha256(image[:pos]).digest()
path.write_bytes(image)
print(f"App description set to {name} {version}")
env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", set_app_desc)
+11 -2
View File
@@ -6,9 +6,18 @@
# abort on first error so a failed lint/build isn't silently swallowed
set -e
# pnpm refuses to self-update when it's a Corepack shim; Corepack pins the version via packageManager instead
update_pnpm() {
if readlink "$(command -v pnpm)" | grep -q corepack; then
corepack use pnpm@latest
else
pnpm self-update
fi
}
cd interface
rm -rf node_modules
pnpm self-update
update_pnpm
pnpm update
pnpm install
pnpm format
@@ -17,7 +26,7 @@ pnpm lint
cd ../mock-api
rm -rf node_modules
pnpm self-update
update_pnpm
pnpm update
pnpm install
pnpm format
+28
View File
@@ -0,0 +1,28 @@
# Makes `pio run -t upload` write the app into the factory partition instead of ota_0.
#
# Set `custom_app_offset` to the factory partition offset. The otadata image (boot_app0.bin) that
# PlatformIO normally writes selects ota_0, so it's swapped for a blank one, which makes the
# bootloader start the factory app.
from pathlib import Path
Import("env")
OTADATA_SIZE = 0x2000
offset = env.GetProjectOption("custom_app_offset", "")
if offset:
# ESP32_APP_OFFSET itself is recalculated from the partition table later in the build
env.Replace(UPLOADCMD=env.get("UPLOADCMD", "").replace("$ESP32_APP_OFFSET", offset))
blank_otadata = Path(env.subst("$BUILD_DIR")) / "otadata_blank.bin"
blank_otadata.parent.mkdir(parents=True, exist_ok=True)
blank_otadata.write_bytes(b"\xff" * OTADATA_SIZE)
def swap(path):
return str(blank_otadata) if Path(env.subst(str(path))).name == "boot_app0.bin" else path
# the platform has already expanded FLASH_EXTRA_IMAGES into UPLOADERFLAGS, so patch both
env.Replace(FLASH_EXTRA_IMAGES=[(o, swap(p)) for o, p in env.get("FLASH_EXTRA_IMAGES", [])])
env.Replace(UPLOADERFLAGS=[swap(flag) for flag in env.get("UPLOADERFLAGS", [])])
print(f"Upload: app at {offset}, otadata blanked")