Files

563 lines
20 KiB
Python

#!/usr/bin/python3
#coding:utf-8
#
# Copyright (c) 2021-2022 Amlogic, Inc. All rights reserved.
#
# SPDX-License-Identifier: MIT
#
r"""
OTA Packer: A Packer for Amlogic OTA image.
It takes multiple payload files, packs them into a single OTA image with a
header describing each payload. The header and payloads are aligned to 512
bytes. The payload types can be kernel, dsp firmware, bluetooth firmware, or
wifi firmware. Each payload is signed using mcuboot's imgtool before being
included in the OTA image.
The usage is as follows:
python3 ota_img_packer.py
-f The file path of the payload.
-p The partition name of the payload.
-t The target directory to store the final OTA image.
-o The output file name of the OTA image.
"""
import click
import subprocess
import os
import re
from dataclasses import dataclass, fields
from typing import List, Optional, Any, Tuple, Dict
class Field:
def __init__(self, val: Optional[Any]=None, length: int=0):
self.val = val
self.length : int = length
"""
The OTA package image layout is:
+--------+----------------------+
| | OTA image header |
| Header |----------------------+
| | Payload header |
+--------+----------------------+
| Payload data |
+-------------------------------+
| ... ... |
+-------------------------------+
OTA image header represent that the full ota package information which abstract
by class OTAImgHeader.
Payload header represent that individual payload data information which abstract
by class PayloadHeader.
"""
MCUBOOT_PARA={
"hd_size" : 0x200, # MCUBoot Head Size
"al_size" : 0x1000, # MCUBoot Head Alignment Size
}
OTA_HEADER_SIZE=0x200
@dataclass
class PayloadHeader:
img_type: Field = Field(0, 4)
img_name: Field = Field("", 16)
img_size: Field = Field(0, 4)
off_in_payload: Field = Field(0, 4)
@dataclass
class OTAImgHeader:
magic: Field = Field("AMLOGIC_UPGRADE",int(16))
header_size: Field = Field(int(OTA_HEADER_SIZE), int(4))
package_size: Field = Field(int(0), int(4)) # header + data
img_desc_table_offset: Field = Field(int(0), int(4))
payload_offset: Field = Field(int(OTA_HEADER_SIZE), int(4))
image_count: Field = Field(0, int(4))
class Platform(object):
"""
Encapsulates the hardware and software platform configurations
retrieved from the system environment.
This class serves as a centralized interface to validate and resolve
project directories, target architectures, and build environments.
"""
def __init__(self) -> None:
self.board = os.environ.get("BOARD")
self.arch = os.environ.get("ARCH")
self.kernel = os.environ.get("KERNEL")
self.product = os.environ.get("PRODUCT")
self.scr_dir = os.path.dirname(os.path.abspath(__file__))
self.prj_dir = os.path.dirname(self.scr_dir)
def get_board(self) -> str:
if self.board is None:
click.secho("Please executing `source scripts/env.sh` firstly!",
fg="red")
raise Exception("ERROR")
return self.board
def get_arch(self) -> str:
if self.arch is None:
click.secho("Please executing `source scripts/env.sh` firstly!",
fg="red")
raise Exception("ERROR")
return self.arch
def get_kernel(self) -> str:
if self.kernel is None:
click.secho("Please executing `source scripts/env.sh` firstly!",
fg="red")
raise Exception("ERROR")
return self.kernel
def get_product(self) -> str:
if self.product is None:
click.secho("Please executing `source scripts/env.sh` firstly!",
fg="red")
raise Exception("ERROR")
return self.product
def get_prf_dir(self) -> str:
return self.prj_dir
def get_board_dir(self) -> str:
return f"{self.prj_dir}/boards/{self.get_arch()}/{self.get_board()}"
def get_out_dir(self) -> str:
return (f"{self.prj_dir}/output/{self.get_arch()}-{self.get_board()}-"
f"{self.get_product()}/{self.get_kernel()}")
class PartParser(object):
"""
Parses partition configurations by resolving dependencies between build
artifacts, preprocessor directives, and partition source files.
This class extracts structured metadata from C-based partition tables by
leveraging the designated C cross-compiler for macro expansion.
"""
def __init__(self) -> None:
self.platform = Platform()
self.autoconf = f"{self.platform.get_out_dir()}/autoconf.h"
self.cmakeche = f"{self.platform.get_out_dir()}/CMakeCache.txt"
self.partfile = f"{self.platform.get_board_dir()}/partition_tbl.c"
self.compiler = ""
self.partname = []
def extract_names(self, lines: List[str]) -> List[str]:
"""
Extract partition name attributes from localized C-struct code blocks.
param lines: A list of code strings representing distinct token blocks.
return: A list of extracted partition name literals.
"""
return [
match.group(1)
for line in lines
if ".name" in line
if (match := re.search(r'\.name\s*=\s*"([^"]+)"', line))
]
def parser(self) -> List[str]:
"""
Execute the pipeline to resolve, preprocess, and parse partition tables.
The method extracts the C compiler path from the CMake cache, executes
the compiler preprocessor (`-E`) to expand conditional macros using
autoconf definitions, tracks brace scope depth to isolate structs, and
extracts partition identifiers.
return: An ordered sequence of partition names including the implicit
bootloader.
raises subprocess.CalledProcessError: If sub-process execution
terminates abnormally.
"""
blocks: List[str] = []
buf: List[str] = []
depth = 0
try:
with open(self.cmakeche, 'r', encoding='utf-8') as f:
for line in f:
if line.startswith("CMAKE_C_COMPILER:"):
self.compiler = line.split('=', 1)[1].strip()
break
except IOError:
self.compiler = ""
# Construct sub-process arguments for macro expansion via GCC/Clang
# preprocessor
cmd = [self.compiler, "-E", "-include", self.autoconf, self.partfile]
ret = subprocess.run(cmd, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True, check=True)
lines = ret.stdout.splitlines()
# Tokenize and track scope depth to isolate structured definition
# boundaries
for line in lines:
if line.lstrip().startswith("#"):
continue
if "{" in line:
depth += line.count("{")
if depth > 0:
buf.append(line)
if "}" in line:
depth -= line.count("}")
if depth == 0:
blocks.append(" ".join(buf))
buf = []
# Extract name properties and normalize the sequencing structure
self.partname = self.extract_names(blocks)
self.partname.insert(0, 'bootloader')
return self.partname
def listp(self) -> None:
"""
Output the compiled partition configuration matrix to stdout.
Iterates through the verified partition dataset and renders
formatted structural records via standard terminal streams.
"""
click.secho("[ List Partition Table ]", fg="green")
for idx, name in enumerate(self.parser()):
click.secho(f"\t[{idx}] : {name}", fg="yellow")
print()
class OTAImgPacker(object):
"""
A Packer for Amlogic OTA image. It takes multiple payload files, packs them
into a single OTA image with a header describing each payload. The header
and payloads are aligned to 512 bytes. The payload types can be kernel,
dsp firmware, bluetooth firmware, or wifi firmware. Each payload is signed
using mcuboot's imgtool before being included in the OTA image.
"""
def __init__(self,
ota_inputs: List[Tuple[str, str]],
img: str,
target_dir: str,
list: bool) -> None:
self.ota_inputs = ota_inputs # [(file, partition)]
self.ota_outputs = {"output_dir":target_dir, "output_img":img}
self.ota_tool_dir = os.path.dirname(
os.path.dirname(os.path.abspath(__file__)))
"""
This variable represents an extraction of each payload file that
constitutes full OTA package image.
[
{
"name" : payload name,
"path" : payload file path,
"header": PayloadHeader(),
},
...,
]
"""
self.payload_info: List[Dict[str, Any]] = []
self.ota_img_header = OTAImgHeader()
# The offset represent that the offset from payload data.
self.offset: int = 0
self.mcuboot_header_size = MCUBOOT_PARA['hd_size']
self.mcuboot_align_size = MCUBOOT_PARA['al_size']
self.garbage_file: List[str] = []
self.list = list
if self.list:
self.partition = PartParser()
def mcuboot_imgtool(self, in_payload: str, out_signed: str,
slot_size: str) -> bool:
""" Sign the payload using mcuboot's imgtool. """
cmd = [
"python3",
f"{self.ota_tool_dir}/lib/mcuboot/scripts/imgtool.py",
"sign",
"--key", f"{self.ota_tool_dir}/lib/mcuboot/root-rsa-2048.pem",
"--header-size", f"{MCUBOOT_PARA['hd_size']}",
"--align", "4",
"--slot-size", slot_size,
"--pad",
"--version", "1.0.0",
"--pad-header",
in_payload,
out_signed
]
result = subprocess.run(cmd, check=True)
if result.returncode == 0:
return True
return False
def advance(self, val: int):
""" Update the payload file offset. """
self.offset += val
def fill_payload_header(self,
payload: Dict[str, Any],
img_type: int,
img_name: str,
img_size: int) -> None:
""" Fill the payload header with the given information. """
# Fill payload header
payload["header"].img_type.val = img_type # payload type(-f)
payload["header"].img_name.val = img_name # payload name(-p)
payload["header"].img_size.val = img_size # payload size(calculate)
payload["header"].off_in_payload.val = self.offset # payload offset
curret_payload_size = int(payload["header"].img_size.val)
self.advance(curret_payload_size) # Update the offset
# Fill OTA package header
# Update the package size, which initial value is zero. The updated value
# is the current package value + payload size.
package_size_val = self.ota_img_header.package_size.val or 0
val = curret_payload_size + int(package_size_val)
self.ota_img_header.package_size.val = val
def pack_ota_img(self, debug: bool=False) -> bool:
""" Packaging all payload artifacts into an OTA package image. """
out_dir=self.ota_outputs.get('output_dir')
out_img=self.ota_outputs.get('output_img')
ota_img = f"{out_dir}/{out_img}"
ota_payload = f"{self.ota_outputs.get('output_dir')}/ota_payload"
self.garbage_collect(ota_payload)
ota_img_handle = open(ota_img, "wb")
ota_payload_handle = open(ota_payload, "wb")
payload_header = b''
# Fill the payload header one by one firstly and then serialized it to
# a binary file.
# The payload data also can be serialized into a binary file that handle
# is the ota_payload_handle.
for i, payload in enumerate(self.payload_info):
pd_type = payload["name"]
pd_size = os.path.getsize(payload["path"])
if self.list:
pd_part = self.partition.parser()
if pd_type in pd_part:
pd_indx = pd_part.index(pd_type)
else:
self.error(f"The {pd_type} is not in partition table,"
f"please check the supported partition table "
f"with `-l` option")
else:
pd_indx = i
self.fill_payload_header(payload, pd_indx, pd_type, pd_size)
if debug:
click.secho("[ Fill payload header ]", fg="green")
click.echo(f"\t{click.style('File', fg='yellow')}: "
f"{payload.get('path')} \n\t"
f"{click.style('Payload Type', fg='yellow')}: "
f"{pd_type} {pd_indx}\n\t"
f"{click.style('Payload Size', fg='yellow')}: "
f"{hex(pd_size)}\n")
# Serialize payload header + data.
for f in fields(payload["header"]):
# The f.name is item of class. The field_obj.val is the
# connect which should be serialized.
field_obj = getattr(payload.get("header"), f.name)
serialied = self.serialize_field(payload["name"], field_obj)
payload_header += serialied
# Write the payload data into payload file.
with open(payload['path'], "rb") as pf:
ota_payload_handle.write(pf.read())
# Update the image count
self.ota_img_header.image_count.val += int(1) # type: ignore
# Fill the ota image header
package_size_val = self.ota_img_header.package_size.val or 0
# The 512B is the size of header which contain both OTA header and
# payload header.
val = int(OTA_HEADER_SIZE) + int(package_size_val)
self.ota_img_header.package_size.val = int(val) # Package size
self.ota_img_header.img_desc_table_offset.val = int(36) # image offset
# Completely generate the payload binary file.
ota_payload_handle.close()
ota_header = b''
# Serialize the ota image header
for f in fields(self.ota_img_header):
field_obj = getattr(self.ota_img_header, f.name)
serialized = self.serialize_field(f.name, field_obj)
ota_header += serialized
# Write the ota image header
ota_img_handle.write(ota_header)
# Write the payload header
ota_img_handle.write(payload_header)
ota_img_handle.close()
# Pad the remaining space with 0xFF.
pad_data = b''
with open(ota_img, "rb") as handle:
pad_data = handle.read()
pad_len = OTA_HEADER_SIZE - len(pad_data)
if pad_len > 0:
pad_data += b'\xFF' * pad_len
else:
self.error("Pad failed!")
# Write payload data to ota image
ota_img_handle = open(ota_img, "wb")
ota_img_handle.write(pad_data)
with open(ota_payload, "rb") as handle:
ota_img_handle.write(handle.read())
ota_img_handle.close()
click.secho("[ OTA package successful! ]", fg="green")
self.garbage_clean()
return True
def serialize_field(self, name: str, field_obj: Field) -> bytes:
"""
Serialized the field_obj.val, the first parameter only as a debug
information. """
val = field_obj.val
length = field_obj.length
if isinstance(val, int):
max_val = 2 ** (length * 8) - 1
if val > max_val:
raise ValueError(f"{name}: {val} exceeds max {max_val}")
return val.to_bytes(length, byteorder='little', signed=False)
elif isinstance(val, str):
if val.isdigit():
num = int(val)
max_val = 2 ** (length * 8) - 1
if num > max_val:
raise ValueError(f"{name}: {val} exceeds max {max_val}")
return num.to_bytes(length, byteorder='little', signed=False)
else:
data = val.encode('utf-8')
if len(data) > length:
raise ValueError(f"{name}: string too long ({val})")
return data.ljust(length, b'\x00')
else:
raise TypeError(f"{name}: unsupported type: {type(val)}")
def error(self, msg: str):
click.secho("[ Error ]", fg="reg")
raise Exception(msg)
def garbage_collect(self, garbage: str):
"""
This function collects all temporary files generated during execution.
"""
self.garbage_file.append(garbage)
def garbage_clean(self):
"""
This function purges all temporary files identified by garbage collector
"""
for f in self.garbage_file:
if os.path.exists(f):
os.remove(f)
def pack(self, debug: bool=False):
""" Pack the OTA image with the payload files. """
# Sign input files using mcuboot tool to prepare them as payload
# artifacts.
for pyload_file, part_name in self.ota_inputs:
mcuboot_target = f"{self.ota_outputs.get('output_dir')}/{part_name}"
self.garbage_collect(mcuboot_target)
# Preparation of payload image components.
self.payload_info.append(
{
"name": part_name,
"path":mcuboot_target,
"header": PayloadHeader()
})
# The mt means mcuboot target
mt_size = os.path.getsize(pyload_file)
mt_signed_size = mt_size + MCUBOOT_PARA['hd_size']
slot_size = ((mt_signed_size + MCUBOOT_PARA['al_size'] - 1) \
& ~MCUBOOT_PARA['al_size'] - 1) + MCUBOOT_PARA['al_size']
if debug:
click.secho("[ MCUBOOT Sign ]", fg="green")
print(f"\t{click.style('File', fg='yellow')}: {pyload_file}\n\t"
f"{click.style('Size', fg='yellow')}: {hex(mt_size)}\n\t"
f"{click.style('Signed Size', fg='yellow')}: "
f"{hex(mt_signed_size)}\n\t"
f"{click.style('Slot Size', fg='yellow')}: "
f"{hex(slot_size)}\n")
self.mcuboot_imgtool(pyload_file, mcuboot_target, hex(slot_size))
# Packagimg all payload artifacts into an OTA package image.
self.pack_ota_img(debug)
@click.command()
@click.option("-f", "--file_name", multiple=True, required=True,
help="File names")
@click.option("-p", "--partition", multiple=True, required=True,
help="Partitions")
@click.option("-t", "--target_dir", required=True,
help="Target directory")
@click.option("-o", "--target_name", required=True,
help="Output file name")
@click.option('-d', "--debug", is_flag=True, help='Enable debug log')
@click.option('-l', "--list", is_flag=True, help='List the valid partition')
def main(file_name: List[str],
partition: List[str],
target_dir: str,
target_name: str,
debug: bool,
list: bool):
"""
Amlogic OTA packer. It takes multiple payload files, packs them into a
single OTA image with a header describing each payload.
"""
if len(file_name) != len(partition):
raise click.UsageError("The number of files (-f) must match the " \
"number of partitions (-p).")
# pairs [Tuple(file_path: str, partition_name: str), ...]
pairs = [(os.path.abspath(f), p) for f, p in zip(file_name, partition)]
if debug:
click.secho("[ Input Parameters ]", fg="green")
for f, p in pairs:
click.echo(f"\t{click.style('File', fg='yellow')}: "
f"{f}\n\t{click.style('Partition', fg='yellow')}: {p}\t")
click.echo(f"\t{click.style('Output Directory', fg='yellow')}: "
f"{os.path.abspath(target_dir)}")
click.echo(f"\t{click.style('OTA Image Name', fg='yellow')}: "
f"{target_name}\n")
if list:
part = PartParser()
part.listp()
ota = OTAImgPacker(pairs, target_name, os.path.abspath(target_dir), list)
ota.pack(debug)
if __name__ == "__main__":
main()