mirror of
https://github.com/emsesp/EMS-ESP32.git
synced 2026-10-28 11:14:58 +00:00
add recovery firmware with OTA updater and recovery web interface
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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")
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user