feat(cmds): Expand input of all functions to file paths, bytes, or file-like objects

This commit is contained in:
Radim Karniš
2025-03-03 23:29:14 +01:00
parent 03b84a12ff
commit 46a9e31cfe
11 changed files with 292 additions and 199 deletions

View File

@@ -23,6 +23,7 @@ project = "esptool.py"
copyright = "2016 - {}, Espressif Systems (Shanghai) Co., Ltd".format(
datetime.datetime.now().year
)
autodoc_typehints_format = "short"
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.

View File

@@ -95,6 +95,40 @@ The following example demonstrates running a series of flash memory operations i
- This example doesn't use ``detect_chip()``, but instantiates a ``ESP32ROM`` class directly. This is useful when you know the target chip in advance. In this scenario ``esp.connect()`` is required to establish a connection with the device.
- Multiple operations can be chained together in a single context manager block.
------------
The Public API implements a custom ``ImageSource`` input type, which expands to ``str | bytes | IO[bytes]`` - a path to the firmware image file, an opened file-like object, or the image data as bytes.
As output, the API returns a ``bytes`` object representing the binary image or writes the image to a file if the ``output`` parameter is provided.
The following example converts an ELF file to a flashable binary, prints the image information, and flashes the image. The example demonstrates three different ways to achieve the same result, showcasing the flexibility of the API:
.. code-block:: python
ELF = "firmware.elf"
# var 1 - Loading ELF from a file, not writing binary to a file
bin_file = elf2image(ELF, "esp32c3")
image_info(bin_file)
with detect_chip(PORT) as esp:
attach_flash(esp)
write_flash(esp, [(0, bin_file)])
# var 2 - Loading ELF from an opened file object, not writing binary to a file
with open(ELF, "rb") as elf_file, detect_chip(PORT) as esp:
bin_file = elf2image(elf_file, "esp32c3")
image_info(bin_file)
attach_flash(esp)
write_flash(esp, [(0, bin_file)])
# var 3 - Loading ELF from a file, writing binary to a file
elf2image(ELF, "esp32c3", "image.bin")
image_info("image.bin")
with detect_chip(PORT) as esp:
attach_flash(esp)
write_flash(esp, [(0, "image.bin")])
------------
**The following section provides a detailed reference for the public API functions.**

View File

@@ -627,11 +627,11 @@ def run_cli(ctx):
@click.pass_context
def image_info_cli(ctx, filename):
"""Dump headers from a binary file (bootloader or application)"""
image_info(filename, ctx.obj["chip"])
image_info(filename, chip=None if ctx.obj["chip"] == "auto" else ctx.obj["chip"])
@cli.command("elf2image")
@click.argument("input", type=click.Path(exists=True))
@click.argument("filename", type=click.Path(exists=True))
@click.option(
"--output",
"-o",
@@ -721,14 +721,16 @@ def image_info_cli(ctx, filename):
)
@add_spi_flash_options(allow_keep=False, auto_detect=False)
@click.pass_context
def elf2image_cli(ctx, **kwargs):
def elf2image_cli(ctx, filename, **kwargs):
"""Create an application image from ELF file"""
if ctx.obj["chip"] == "auto":
raise FatalError(
f"Specify the --chip argument (choose from {', '.join(CHIP_LIST)})"
)
append_digest = not kwargs.pop("dont_append_digest", False)
# Default to ESP8266 for backwards compatibility
chip = "esp8266" if ctx.obj["chip"] == "auto" else ctx.obj["chip"]
output = kwargs.pop("output", None)
output = "auto" if output is None else output
elf2image(chip=chip, output=output, append_digest=append_digest, **kwargs)
elf2image(filename, ctx.obj["chip"], output, append_digest=append_digest, **kwargs)
@cli.command("read_mac")
@@ -917,6 +919,10 @@ def read_flash_sfdp_cli(ctx, address, bytes, **kwargs):
@click.pass_context
def merge_bin_cli(ctx, addr_filename, **kwargs):
"""Merge multiple raw binary files into a single file for later flashing"""
if ctx.obj["chip"] == "auto":
raise FatalError(
f"Specify the --chip argument (choose from {', '.join(CHIP_LIST)})"
)
merge_bin(addr_filename, chip=ctx.obj["chip"], **kwargs)

View File

@@ -32,7 +32,7 @@ from .targets import (
ESP32S3ROM,
ESP8266ROM,
)
from .util import FatalError, byte, pad_to
from .util import FatalError, byte, ImageSource, get_bytes, pad_to
def align_file_position(f, size):
@@ -62,48 +62,43 @@ def intel_hex_to_bin(file: IO[bytes], start_addr: int | None = None) -> IO[bytes
return file
def LoadFirmwareImage(chip, image_file):
def LoadFirmwareImage(chip: str, image_data: ImageSource):
"""
Load a firmware image. Can be for any supported SoC.
ESP8266 images will be examined to determine if they are original ROM firmware
images (ESP8266ROMFirmwareImage) or "v2" OTA bootloader images.
Returns a BaseFirmwareImage subclass, either ESP8266ROMFirmwareImage (v1)
or ESP8266V2FirmwareImage (v2).
Returns a BaseFirmwareImage subclass.
"""
def select_image_class(f, chip):
chip = re.sub(r"[-()]", "", chip.lower())
if chip != "esp8266":
return {
"esp32": ESP32FirmwareImage,
"esp32s2": ESP32S2FirmwareImage,
"esp32s3": ESP32S3FirmwareImage,
"esp32c3": ESP32C3FirmwareImage,
"esp32c2": ESP32C2FirmwareImage,
"esp32c6": ESP32C6FirmwareImage,
"esp32c61": ESP32C61FirmwareImage,
"esp32c5": ESP32C5FirmwareImage,
"esp32h2": ESP32H2FirmwareImage,
"esp32h21": ESP32H21FirmwareImage,
"esp32p4": ESP32P4FirmwareImage,
"esp32h4": ESP32H4FirmwareImage,
}[chip](f)
else: # Otherwise, ESP8266 so look at magic to determine the image type
magic = ord(f.read(1))
f.seek(0)
if magic == ESPLoader.ESP_IMAGE_MAGIC:
return ESP8266ROMFirmwareImage(f)
elif magic == ESP8266V2FirmwareImage.IMAGE_V2_MAGIC:
return ESP8266V2FirmwareImage(f)
else:
raise FatalError("Invalid image magic number: %d" % magic)
if isinstance(image_file, str):
with open(image_file, "rb") as f:
return select_image_class(f, chip)
return select_image_class(image_file, chip)
data, _ = get_bytes(image_data)
f = io.BytesIO(data)
chip = re.sub(r"[-()]", "", chip.lower())
if chip == "esp8266":
# Look at the magic number to determine the ESP8266 image type
magic = ord(f.read(1))
f.seek(0)
if magic == ESPLoader.ESP_IMAGE_MAGIC:
return ESP8266ROMFirmwareImage(f)
elif magic == ESP8266V2FirmwareImage.IMAGE_V2_MAGIC:
return ESP8266V2FirmwareImage(f)
else:
raise FatalError(f"Invalid image magic number: {magic}")
else:
return {
"esp32": ESP32FirmwareImage,
"esp32s2": ESP32S2FirmwareImage,
"esp32s3": ESP32S3FirmwareImage,
"esp32c3": ESP32C3FirmwareImage,
"esp32c2": ESP32C2FirmwareImage,
"esp32c6": ESP32C6FirmwareImage,
"esp32c61": ESP32C61FirmwareImage,
"esp32c5": ESP32C5FirmwareImage,
"esp32h2": ESP32H2FirmwareImage,
"esp32h21": ESP32H21FirmwareImage,
"esp32p4": ESP32P4FirmwareImage,
"esp32h4": ESP32H4FirmwareImage,
}[chip](f)
class ImageSegment(object):
@@ -1225,11 +1220,10 @@ class ELFFile(object):
SEG_TYPE_LOAD = 0x01
LEN_SEG_HEADER = 0x20
def __init__(self, name):
# Load sections from the ELF file
self.name = name
with open(self.name, "rb") as f:
self._read_elf_file(f)
def __init__(self, data):
self.data, self.name = get_bytes(data)
f = io.BytesIO(self.data)
self._read_elf_file(f)
def get_section(self, section_name):
for s in self.sections:
@@ -1240,6 +1234,7 @@ class ELFFile(object):
def _read_elf_file(self, f):
# read the ELF file header
LEN_FILE_HEADER = 0x34
source = "Image" if self.name is None else f"'{self.name}'"
try:
(
ident,
@@ -1257,25 +1252,23 @@ class ELFFile(object):
shnum,
shstrndx,
) = struct.unpack("<16sHHLLLLLHHHHHH", f.read(LEN_FILE_HEADER))
except struct.error as e:
raise FatalError(
"Failed to read a valid ELF header from %s: %s" % (self.name, e)
)
except struct.error as e:
raise FatalError(f"{source} does not have a valid ELF header: {e}")
if byte(ident, 0) != 0x7F or ident[1:4] != b"ELF":
raise FatalError("%s has invalid ELF magic header" % self.name)
raise FatalError(f"{source} has invalid ELF magic header")
if machine not in [0x5E, 0xF3]:
raise FatalError(
"%s does not appear to be an Xtensa or an RISCV ELF file. "
"e_machine=%04x" % (self.name, machine)
f"{source} does not appear to be an Xtensa or an RISCV ELF image. "
f"(e_machine = {machine:#06x})"
)
if shentsize != self.LEN_SEC_HEADER:
raise FatalError(
"%s has unexpected section header entry size 0x%x (not 0x%x)"
% (self.name, shentsize, self.LEN_SEC_HEADER)
f"{source} has unexpected section header entry size {shentsize:#x} "
f"(not {self.LEN_SEC_HEADER:#x})"
)
if shnum == 0:
raise FatalError("%s has 0 section headers" % (self.name))
raise FatalError(f"{source} has 0 section headers")
self._read_sections(f, shoff, shnum, shstrndx)
self._read_segments(f, _phoff, _phnum, shstrndx)
@@ -1285,13 +1278,13 @@ class ELFFile(object):
section_header = f.read(len_bytes)
if len(section_header) == 0:
raise FatalError(
"No section header found at offset %04x in ELF file."
% section_header_offs
f"No section header found at offset {section_header_offs:#06x} "
"in ELF image."
)
if len(section_header) != (len_bytes):
raise FatalError(
"Only read 0x%x bytes from section header (expected 0x%x.) "
"Truncated ELF file?" % (len(section_header), len_bytes)
f"Only read {len(section_header):#x} bytes from section header "
f"(expected {len_bytes:#x}). Truncated ELF image?"
)
# walk through the section header and extract all sections
@@ -1347,13 +1340,13 @@ class ELFFile(object):
segment_header = f.read(len_bytes)
if len(segment_header) == 0:
raise FatalError(
"No segment header found at offset %04x in ELF file."
% segment_header_offs
f"No segment header found at offset {segment_header_offs:#06x} "
"in ELF image."
)
if len(segment_header) != (len_bytes):
raise FatalError(
"Only read 0x%x bytes from segment header (expected 0x%x.) "
"Truncated ELF file?" % (len(segment_header), len_bytes)
f"Only read {len(segment_header):#x} bytes from segment header "
f"(expected {len_bytes:#x}). Truncated ELF image?"
)
# walk through the segment header and extract all segments
@@ -1389,6 +1382,6 @@ class ELFFile(object):
def sha256(self):
# return SHA256 hash of the input ELF file
sha256 = hashlib.sha256()
with open(self.name, "rb") as f:
sha256.update(f.read())
f = io.BytesIO(self.data)
sha256.update(f.read())
return sha256.digest()

View File

@@ -170,7 +170,7 @@ class AddrFilenamePairType(click.Path):
if sector_start < end:
raise click.BadParameter(
f"Detected overlap at address: "
f"0x{address:x} for file: {argfile.name}",
f"{address:#x} for file: {argfile.name}",
)
end = sector_end
return pairs

View File

@@ -14,7 +14,7 @@ import itertools
from intelhex import IntelHex
from serial import SerialException
from typing import BinaryIO, cast
from typing import cast
from .bin_image import ELFFile, LoadFirmwareImage
from .bin_image import (
@@ -42,8 +42,9 @@ from .util import (
from .util import (
div_roundup,
flash_size_bytes,
get_file_size,
hexify,
ImageSource,
get_bytes,
pad_to,
sanitize_string,
)
@@ -191,15 +192,17 @@ def detect_chip(
#####################################
def load_ram(esp: ESPLoader, filename: str) -> None:
def load_ram(esp: ESPLoader, input: ImageSource) -> None:
"""
Load a firmware image into RAM and execute it on the ESP device.
Args:
esp: Initiated esp object connected to a real device.
filename: Path to the firmware image file.
input: Path to the firmware image file, opened file-like object,
or the image data as bytes.
"""
image = LoadFirmwareImage(esp.CHIP_NAME, filename)
data, _ = get_bytes(input)
image = LoadFirmwareImage(esp.CHIP_NAME, data)
log.print("RAM boot...")
for seg in image.segments:
@@ -426,7 +429,7 @@ def _update_image_flash_params(esp, address, flash_freq, flash_mode, flash_size,
def write_flash(
esp: ESPLoader,
addr_filename: list[tuple[int, BinaryIO]],
addr_data: list[tuple[int, ImageSource]],
flash_freq: str = "keep",
flash_mode: str = "keep",
flash_size: str = "keep",
@@ -437,8 +440,9 @@ def write_flash(
Args:
esp: Initiated esp object connected to a real device.
addr_filename: List of (address, file) tuples specifying where
to write each file in flash memory.
addr_data: List of (address, data) tuples specifying where
to write each file or data in flash memory. The data can be
a file path (str), bytes, or a file-like object.
flash_freq: Flash frequency to set in the bootloader image header
(``"keep"`` to retain current).
flash_mode: Flash mode to set in the bootloader image header
@@ -449,19 +453,23 @@ def write_flash(
Keyword Args:
erase_all (bool): Erase the entire flash before writing.
encrypt (bool): Encrypt all files during flashing.
encrypt_files (list[tuple[int, BinaryIO]] | None): List of (address, file)
tuples for files to encrypt individually.
encrypt_files (list[tuple[int, ImageSource]] | None): List of
(address, data) tuples for files to encrypt individually.
compress (bool): Compress data before flashing.
no_compress (bool): Don't compress data before flashing.
force (bool): Ignore safety checks (e.g., overwriting bootloader, flash size).
ignore_flash_enc_efuse (bool): Ignore flash encryption eFuse settings.
no_progress (bool): Disable progress updates.
"""
# Normalize addr_data to use bytes
norm_addr_data = [(addr, get_bytes(data)) for addr, data in addr_data]
# Set default values of optional arguments
erase_all: bool = kwargs.get("erase_all", False)
encrypt: bool = kwargs.get("encrypt", False)
encrypt_files: list[tuple[int, BinaryIO]] | None = kwargs.get("encrypt_files", None)
encrypt_files: list[tuple[int, ImageSource]] | None = kwargs.get(
"encrypt_files", None
)
compress: bool = kwargs.get("compress", False)
no_compress: bool = kwargs.get("no_compress", False)
force: bool = kwargs.get("force", False)
@@ -477,7 +485,7 @@ def write_flash(
if not force and esp.CHIP_NAME != "ESP8266" and not esp.secure_download_mode:
# Check if secure boot is active
if esp.get_secure_boot_enabled():
for address, _ in addr_filename:
for address, _ in norm_addr_data:
if address < 0x8000:
raise FatalError(
"Secure Boot detected, writing to flash regions < 0x8000 "
@@ -486,16 +494,17 @@ def write_flash(
"please use with caution, otherwise it may brick your device!"
)
# Check if chip_id and min_rev in image are valid for the target in use
for _, argfile in addr_filename:
for _, (data, name) in norm_addr_data:
try:
image = LoadFirmwareImage(esp.CHIP_NAME, argfile)
image = LoadFirmwareImage(esp.CHIP_NAME, data)
except (FatalError, struct.error, RuntimeError):
continue
finally:
argfile.seek(0) # LoadFirmwareImage changes the file handle position
if image.chip_id != esp.IMAGE_CHIP_ID:
msg = (
"Input does not contain" if name is None else f"'{name}' is not an"
)
raise FatalError(
f"{argfile.name} is not an {esp.CHIP_NAME} image. "
f"{msg} an {esp.CHIP_NAME} image. "
"Use the force argument to flash anyway."
)
@@ -514,7 +523,7 @@ def write_flash(
if use_rev_full_fields:
rev = esp.get_chip_revision()
if rev < image.min_rev_full or rev > image.max_rev_full:
error_str = f"{argfile.name} requires chip revision in range "
error_str = f"'{name}' requires chip revision in range "
error_str += (
f"[v{image.min_rev_full // 100}.{image.min_rev_full % 100} - "
)
@@ -538,7 +547,7 @@ def write_flash(
rev = esp.get_major_chip_version()
if rev < image.min_rev:
raise FatalError(
f"{argfile.name} requires chip revision "
f"'{name}' requires chip revision "
f"{image.min_rev} or higher (this chip is revision {rev}). "
"Use the force argument to flash anyway."
)
@@ -570,17 +579,21 @@ def write_flash(
do_write = False
# Determine which files list contain the ones to encrypt
files_to_encrypt = addr_filename if encrypt else encrypt_files
files_to_encrypt = (
norm_addr_data
if encrypt is not None
else [(addr, get_bytes(data)) for addr, data in encrypt_files]
)
if files_to_encrypt is not None:
for address, argfile in files_to_encrypt:
for address, (data, name) in files_to_encrypt:
if address % esp.FLASH_ENCRYPTED_WRITE_ALIGN:
log.print(
f"File {argfile.name} address {address:#x} is not "
source = "Input image" if name is None else f"'{name}'"
log.warning(
f"{source} (address {address:#x}) is not "
f"{esp.FLASH_ENCRYPTED_WRITE_ALIGN} byte aligned, "
"can't flash encrypted"
)
do_write = False
if not do_write and not ignore_flash_enc_efuse:
@@ -634,23 +647,20 @@ def write_flash(
# Verify file sizes fit in the set flash_size, or real flash size if smaller
flash_end = min(set_flash_size, flash_end) if set_flash_size else flash_end
if flash_end is not None:
for address, argfile in addr_filename:
argfile.seek(0, os.SEEK_END)
if address + argfile.tell() > flash_end:
for address, (data, name) in norm_addr_data:
if address + len(data) > flash_end:
source = "Input image" if name is None else f"File '{name}'"
raise FatalError(
f"File {argfile.name} (length {argfile.tell()}) at offset "
f"{address} will not fit in {flash_end} bytes of flash. "
"Change the flash_size argument, or flashing address."
f"{source} (length {len(data)}) at offset "
f"{address:#010x} will not fit in {flash_end} bytes of flash. "
"Change the flash_size argument or flashing address."
)
argfile.seek(0)
if erase_all:
erase_flash(esp)
else:
for address, argfile in addr_filename:
argfile.seek(0, os.SEEK_END)
write_end = address + argfile.tell()
argfile.seek(0)
for address, (data, _) in norm_addr_data:
write_end = address + len(data)
bytes_over = address % esp.FLASH_SECTOR_SIZE
if bytes_over != 0:
log.note(
@@ -673,15 +683,15 @@ def write_flash(
Each entry holds an "encrypt" flag marking whether the file needs encryption or not.
This list needs to be sorted.
First, append to each entry of our addr_filename list the flag "encrypt"
E.g., if addr_filename is [(0x1000, "partition.bin"), (0x8000, "bootloader")],
First, append to each entry of our addr_data list the flag "encrypt"
E.g., if addr_data is [(0x1000, "partition.bin"), (0x8000, "bootloader")],
all_files will be [
(0x1000, "partition.bin", encrypt),
(0x8000, "bootloader", encrypt)
(0x1000, data, "partition.bin", encrypt),
(0x8000, data, "bootloader", encrypt)
],
where, of course, encrypt is either True or False
"""
all_files = [(offs, filename, encrypt) for (offs, filename) in addr_filename]
all_files = [(addr, data, name, encrypt) for (addr, (data, name)) in norm_addr_data]
"""
Now do the same with encrypt_files list, if defined.
@@ -689,7 +699,7 @@ def write_flash(
"""
if encrypt_files is not None:
encrypted_files_flag = [
(offs, filename, True) for (offs, filename) in encrypt_files
(addr, *get_bytes(data), True) for (addr, data) in encrypt_files
]
# Concatenate both lists and sort them.
@@ -698,20 +708,23 @@ def write_flash(
# let's use sorted.
all_files = sorted(all_files + encrypted_files_flag, key=lambda x: x[0])
for address, argfile, encrypted in all_files:
for address, data, name, encrypted in all_files:
compress = compress
# Check whether we can compress the current file before flashing
if compress and encrypted:
source = "input bytes" if name is None else f"'{name}'"
log.print("\n")
log.warning("Compress and encrypt options are mutually exclusive.")
log.print(f"Will flash {argfile.name} uncompressed.")
log.print(f"Will flash {source} uncompressed.")
compress = False
image = argfile.read()
image = data
if len(image) == 0:
log.warning(f"File {argfile.name} is empty")
log.warning(
"Input bytes are empty" if name is None else f"'{name}' is empty"
)
continue
image = pad_to(image, esp.FLASH_ENCRYPTED_WRITE_ALIGN if encrypted else 4)
@@ -752,7 +765,6 @@ def write_flash(
blocks = esp.flash_begin(
uncsize, address, begin_rom_encrypted=encrypted
)
argfile.seek(0) # in case we need it again
seq = 0
bytes_sent = 0 # bytes sent on wire
bytes_written = 0 # bytes written to flash
@@ -857,7 +869,7 @@ def write_flash(
try:
res = esp.flash_md5sum(address, uncsize)
if res != calcmd5:
log.print(f"File MD5: {calcmd5}")
log.print(f"Input MD5: {calcmd5}")
log.print(f"Flash MD5: {res}")
if res == hashlib.md5(b"\xff" * uncsize).hexdigest():
raise FatalError(
@@ -879,9 +891,9 @@ def write_flash(
esp.flash_begin(0, 0)
# Get the "encrypted" flag for the last file flashed
# Note: all_files list contains triplets like:
# (address: Integer, filename: String, encrypted: Boolean)
last_file_encrypted = all_files[-1][2]
# Note: all_files list contains quadruplets like:
# (address: int, filename: str | None, data: bytes, encrypted: bool)
last_file_encrypted = all_files[-1][3]
# Check whether the last file flashed was compressed or not
if compress and not last_file_encrypted:
@@ -1319,19 +1331,21 @@ def read_flash(
def verify_flash(
esp: ESPLoader,
addr_filename: list[tuple[int, BinaryIO]],
addr_data: list[tuple[int, ImageSource]],
flash_freq: str = "keep",
flash_mode: str = "keep",
flash_size: str = "keep",
diff: bool = False,
) -> None:
"""
Verify the contents of the SPI flash memory against the provided binary files.
Verify the contents of the SPI flash memory against the provided binary files
or byte data.
Args:
esp: Initiated esp object connected to a real device.
addr_filename: List of (address, file) tuples specifying what
parts of flash memory to verify.
addr_data: List of (address, data) tuples specifying what
parts of flash memory to verify. The data can be
a file path (str), bytes, or a file-like object.
flash_freq: Flash frequency setting (``"keep"`` to retain current).
flash_mode: Flash mode setting (``"keep"`` to retain current).
flash_size: Flash size setting (``"keep"`` to retain current).
@@ -1340,18 +1354,19 @@ def verify_flash(
flash_size = _set_flash_parameters(esp, flash_size) # Set flash size parameters
mismatch = False
for address, argfile in addr_filename:
image = pad_to(argfile.read(), 4)
argfile.seek(0) # rewind in case we need it again
for address, data in addr_data:
data, source = get_bytes(data)
image = pad_to(data, 4)
image = _update_image_flash_params(
esp, address, flash_freq, flash_mode, flash_size, image
)
image_size = len(image)
source = "input bytes" if source is None else f"'{source}'"
log.print(
f"Verifying {image_size:#x} ({image_size}) bytes "
f"at {address:#010x} in flash against {argfile.name}..."
f"at {address:#010x} in flash against {source}..."
)
# Try digest first, only read if there are differences.
digest = esp.flash_md5sum(address, image_size)
@@ -1689,52 +1704,57 @@ def _parse_bootloader_info(bootloader_info_segment):
}
def image_info(filename: str, chip: str | None = None) -> None:
def image_info(input: ImageSource, chip: str | None = None) -> None:
"""
Display detailed information about an ESP firmware image.
Args:
filename: Path to the firmware image file.
input: Path to the firmware image file, opened file-like object,
or the image data as bytes.
chip: Target ESP device type (e.g., ``"esp32"``). If None, the chip
type will be automatically detected from the image header.
"""
log.print(f"File size: {get_file_size(filename)} (bytes)")
with open(filename, "rb") as f:
# magic number
data, _ = get_bytes(input)
log.print(f"Image size: {len(data)} bytes")
stream = io.BytesIO(data)
common_header = stream.read(8)
if chip is None:
extended_header = stream.read(16)
stream.seek(0)
# Check magic number
try:
magic = common_header[0]
except IndexError:
raise FatalError("Image is empty")
if magic not in [
ESPLoader.ESP_IMAGE_MAGIC,
ESP8266V2FirmwareImage.IMAGE_V2_MAGIC,
]:
raise FatalError(
f"This is not a valid image (invalid magic number: {magic:#x})"
)
if chip is None:
try:
common_header = f.read(8)
magic = common_header[0]
except IndexError:
raise FatalError("File is empty")
if magic not in [
ESPLoader.ESP_IMAGE_MAGIC,
ESP8266V2FirmwareImage.IMAGE_V2_MAGIC,
]:
raise FatalError(
f"This is not a valid image (invalid magic number: {magic:#x})"
)
# append_digest, either 0 or 1
if extended_header[-1] not in [0, 1]:
raise FatalError("Append digest field not 0 or 1")
if chip is None:
try:
extended_header = f.read(16)
chip_id = int.from_bytes(extended_header[4:5], "little")
for rom in ROM_LIST:
if chip_id == rom.IMAGE_CHIP_ID:
chip = rom.CHIP_NAME
break
else:
raise FatalError(f"Unknown image chip ID ({chip_id})")
except FatalError:
chip = "esp8266"
# append_digest, either 0 or 1
if extended_header[-1] not in [0, 1]:
raise FatalError("Append digest field not 0 or 1")
log.print(f"Detected image type: {chip.upper()}")
chip_id = int.from_bytes(extended_header[4:5], "little")
for rom in [n for n in ROM_LIST if n.CHIP_NAME != "ESP8266"]:
if chip_id == rom.IMAGE_CHIP_ID:
chip = rom.CHIP_NAME
break
else:
raise FatalError(f"Unknown image chip ID ({chip_id})")
except FatalError:
chip = "esp8266"
log.print(f"Detected image type: {chip.upper()}")
image = LoadFirmwareImage(chip, filename)
image = LoadFirmwareImage(chip, data)
def get_key_from_value(dict, val):
"""Get key from value in dictionary"""
@@ -1912,7 +1932,7 @@ def image_info(filename: str, chip: str | None = None) -> None:
def merge_bin(
addr_filename: list[tuple[int, BinaryIO]],
addr_data: list[tuple[int, ImageSource]],
chip: str,
output: str | None = None,
flash_freq: str = "keep",
@@ -1929,8 +1949,9 @@ def merge_bin(
Also apply necessary flash parameters and ensure correct alignment for flashing.
Args:
addr_filename: List of (address, file) pairs specifying
memory offsets and corresponding binary files.
addr_data: List of (address, data) tuples specifying where
to write each file or data in flash memory. The data can be
a file path (str), bytes, or a file-like object.
chip: Target ESP device type (e.g., ``"esp32"``).
output: Path to the output file where the merged binary will be written.
If None, the merged binary will be returned as bytes.
@@ -1962,7 +1983,7 @@ def merge_bin(
if format not in ["raw", "uf2", "hex"]:
raise FatalError(f"Invalid format: '{format}', choose from 'raw', 'uf2', 'hex'")
if format in ["uf2", "hex"] and output is None:
if output is None and format in ["uf2", "hex"]:
raise FatalError(f"Output file must be specified with {format.upper()} format")
try:
@@ -1974,26 +1995,27 @@ def merge_bin(
# sort the files by offset.
# The AddrFilenamePairAction has already checked for overlap
input_files = sorted(addr_filename, key=lambda x: x[0])
if not input_files:
raise FatalError("No input files specified")
first_addr = input_files[0][0]
addr_data = sorted(addr_data, key=lambda x: x[0])
if not addr_data:
raise FatalError("No input data")
first_addr = addr_data[0][0]
if first_addr < target_offset:
raise FatalError(
f"Output file target offset is {target_offset:#x}. "
f"Input file offset {first_addr:#x} is before this."
f"Output data target offset is {target_offset:#x}. "
f"Input data offset {first_addr:#x} is before this."
)
if format == "uf2" and output is not None:
if output is not None and format == "uf2":
with UF2Writer(
chip_class.UF2_FAMILY_ID,
output,
chunk_size,
md5_enabled=not md5_disable,
) as writer:
for addr, argfile in input_files:
log.print(f"Adding {argfile.name} at {addr:#x}")
image = argfile.read()
for addr, data in addr_data:
image, source = get_bytes(data)
source = "bytes" if source is None else f"'{source}'"
log.print(f"Adding {source} at {addr:#x}")
image = _update_image_flash_params(
chip_class, addr, flash_freq, flash_mode, flash_size, image
)
@@ -2011,10 +2033,9 @@ def merge_bin(
# account for output file offset if there is any
of.write(b"\xff" * (flash_offs - target_offset - of.tell()))
for addr, argfile in input_files:
for addr, data in addr_data:
pad_to(addr)
image = argfile.read()
argfile.seek(0) # Rewind for possible future operations with the files
image, _ = get_bytes(data)
image = _update_image_flash_params(
chip_class, addr, flash_freq, flash_mode, flash_size, image
)
@@ -2038,18 +2059,18 @@ def merge_bin(
)
return None
elif format == "hex" and output is not None:
elif output is not None and format == "hex":
out = IntelHex()
if len(input_files) == 1:
if len(addr_data) == 1:
log.warning(
"Only one input file specified, output may include "
"additional padding if input file was previously merged. "
"Please refer to the documentation for more information: "
"https://docs.espressif.com/projects/esptool/en/latest/esptool/basic-commands.html#hex-output-format" # noqa E501
)
for addr, argfile in input_files:
for addr, data in addr_data:
ihex = IntelHex()
image = argfile.read()
image, _ = get_bytes(data)
image = _update_image_flash_params(
chip_class, addr, flash_freq, flash_mode, flash_size, image
)
@@ -2064,7 +2085,7 @@ def merge_bin(
def elf2image(
input: str,
input: ImageSource,
chip: str,
output: str | None = None,
flash_freq: str | None = None,
@@ -2073,10 +2094,11 @@ def elf2image(
**kwargs,
) -> bytes | tuple[bytes | None, bytes] | None:
"""
Convert an ELF file into a firmware image suitable for flashing onto an ESP device.
Convert ELF data into a firmware image suitable for flashing onto an ESP device.
Args:
input: Path to the ELF file.
input: Path to the ELF file to convert, opened file-like object,
or the ELF data as bytes.
chip: Target ESP device type.
output: Path to save the generated firmware image. If "auto", a default
pre-defined path is used. If None, the image is not written to a file,
@@ -2126,7 +2148,8 @@ def elf2image(
f"Invalid chip choice: '{chip}' (choose from {', '.join(CHIP_LIST)})"
)
e = ELFFile(input)
data, source = get_bytes(input)
e = ELFFile(data)
log.print(f"Creating {chip.upper()} image...")
if chip != "esp8266":
bootloader_image = CHIP_DEFS[chip].BOOTLOADER_IMAGE
@@ -2227,7 +2250,8 @@ def elf2image(
log.print(f"Successfully created {chip.upper()} image.")
if output == "auto":
output = image.default_output_name(input)
source = f"{chip}_image" if source is None else source
output = image.default_output_name(source)
return cast(bytes | tuple[bytes | None, bytes] | None, image.save(output))

View File

@@ -2,11 +2,16 @@
# Espressif Systems (Shanghai) CO LTD, other contributors as noted.
#
# SPDX-License-Identifier: GPL-2.0-or-later
from __future__ import annotations
import os
import re
import struct
from typing import IO, TypeAlias
# Define a custom type for the input
ImageSource: TypeAlias = str | bytes | IO[bytes]
def byte(bitstr, index):
return bitstr[index]
@@ -85,6 +90,34 @@ def sanitize_string(byte_string):
return byte_string.decode("utf-8").replace("\0", "")
def get_bytes(input: ImageSource) -> tuple[bytes, str | None]:
"""
Normalize the input (file path, bytes, or an opened file-like object) into bytes
and provide a name of the source.
Args:
input: The input file path, bytes, or an opened file-like object.
Returns:
A tuple containing the normalized bytes and the source of the input.
"""
if isinstance(input, str):
with open(input, "rb") as f:
data = f.read()
source = input
elif isinstance(input, bytes):
data = input
source = None
elif hasattr(input, "read") and hasattr(input, "write") and hasattr(input, "close"):
pos = input.tell()
data = input.read()
input.seek(pos) # Reset the file pointer
source = input.name
else:
raise FatalError(f"Invalid input type {type(input)}")
return data, source
class PrintOnce:
"""
Class for printing messages just once. Can be useful when running in a loop

View File

@@ -643,7 +643,7 @@ class TestFlashing(EsptoolTestCase):
"write_flash 0x10000 images/one_kb.bin 0x11000 images/zerolength.bin"
)
self.verify_readback(0x10000, 1024, "images/one_kb.bin")
assert "zerolength.bin is empty" in output
assert "'images/zerolength.bin' is empty" in output
@pytest.mark.quick_test
def test_single_byte(self):
@@ -674,7 +674,7 @@ class TestFlashing(EsptoolTestCase):
)
assert "Unexpected chip ID in image." in output
assert "value was 9. Is this image for a different chip model?" in output
assert "images/esp32s3_header.bin is not an " in output
assert "'images/esp32s3_header.bin' is not an " in output
assert "image. Use the force argument to flash anyway." in output
@pytest.mark.skipif(
@@ -687,7 +687,7 @@ class TestFlashing(EsptoolTestCase):
output = self.run_esptool_error(
"write_flash 0x0 images/one_kb.bin 0x1000 images/esp32s3_header.bin"
)
assert "images/esp32s3_header.bin requires chip revision 10" in output
assert "'images/esp32s3_header.bin' requires chip revision 10" in output
assert "or higher (this chip is revision" in output
assert "Use the force argument to flash anyway." in output
@@ -700,7 +700,7 @@ class TestFlashing(EsptoolTestCase):
"write_flash 0x0 images/one_kb.bin 0x1000 images/esp32c3_header_min_rev.bin"
)
assert (
"images/esp32c3_header_min_rev.bin "
"'images/esp32c3_header_min_rev.bin' "
"requires chip revision in range [v2.55 - max rev not set]" in output
)
assert "Use the force argument to flash anyway." in output
@@ -856,14 +856,14 @@ class TestFlashSizes(EsptoolTestCase):
output = self.run_esptool_error(
"write_flash -fs 1MB 0x280000 images/one_kb.bin"
)
assert "File images/one_kb.bin" in output
assert "File 'images/one_kb.bin'" in output
assert "will not fit" in output
def test_write_no_compression_past_end_fails(self):
output = self.run_esptool_error(
"write_flash -u -fs 1MB 0x280000 images/one_kb.bin"
)
assert "File images/one_kb.bin" in output
assert "File 'images/one_kb.bin'" in output
assert "will not fit" in output
@pytest.mark.skipif(
@@ -1779,5 +1779,5 @@ class TestESPObjectOperations(EsptoolTestCase):
output = fake_out.getvalue()
assert "Detected image type: ESP32" in output
assert "Checksum: 0x83 (valid)" in output
assert "Wrote 0x2000 bytes to file output.bin" in output
assert "Wrote 0x2400 bytes to file 'output.bin'" in output
assert esptool.__version__ in output

View File

@@ -160,7 +160,7 @@ class TestImageInfo:
# This bootloader binary is built from "hello_world" project
# with default settings, IDF version is v5.2.
out = self.run_image_info("esp32", "bootloader_esp32_v5_2.bin")
assert "File size: 26768 (bytes)" in out
assert "Image size: 26768 bytes" in out
assert "Bootloader information" in out
assert "Bootloader version: 1" in out
assert "ESP-IDF: v5.2-dev-254-g1950b15" in out
@@ -193,7 +193,7 @@ class TestImageInfo:
try:
convert_bin2hex(file)
out = self.run_image_info("esp32", file)
assert "File size: 26768 (bytes)" in out
assert "Image size: 26768 bytes" in out
assert "Bootloader information" in out
assert "Bootloader version: 1" in out
assert "ESP-IDF: v5.2-dev-254-g1950b15" in out

View File

@@ -173,9 +173,9 @@ class TestESP8266V1Image(BaseTestCase):
@classmethod
def teardown_class(self):
super(TestESP8266V1Image, self).teardown_class()
try_delete(self.BIN_LOAD)
try_delete(self.BIN_IROM)
super(TestESP8266V1Image, self).teardown_class()
def test_irom_bin(self):
with open(self.ELF, "rb") as f:

View File

@@ -374,7 +374,9 @@ class TestUF2:
print(output)
assert "warning" not in output.lower(), "merge_bin should not output warnings"
exp_list = [f"Adding {f} at {hex(addr)}" for addr, f in iter_addr_offset_tuples]
exp_list = [
f"Adding '{f}' at {hex(addr)}" for addr, f in iter_addr_offset_tuples
]
exp_list += [
f"bytes to file '{of_name}', ready to be flashed with any ESP USB Bridge"
]