diff --git a/tools/export_pkg_repo.py b/tools/export_pkg_repo.py index 36d61143416..2f2c674fe22 100644 --- a/tools/export_pkg_repo.py +++ b/tools/export_pkg_repo.py @@ -17,14 +17,7 @@ # License for the specific language governing permissions and limitations # under the License. -"""Export built binaries into a package repository layout. - -The script copies built application or library artifacts into a -server-friendly repository directory, computes SHA-256 digests, and emits an -`index.json` compatible with the current `nxpkg` metadata parser. - -The repository can then be served by FTP, HTTP, or any static file transport. -""" +"""Build an nxpkg repository from compiled artifacts.""" from __future__ import annotations @@ -32,6 +25,7 @@ import hashlib import json import shutil +import struct from dataclasses import dataclass from pathlib import Path from typing import Dict, List @@ -41,6 +35,13 @@ "package must look like " ":::" ) +# Keep icons small for target downloads. +ICON_SIZE = 48 + +# LVGL v9 lv_image_header_t fields, followed by raw RGB565 pixels. +LV_IMAGE_HEADER_MAGIC = 0x19 +LV_COLOR_FORMAT_RGB565 = 0x12 + @dataclass class PackageSpec: @@ -63,12 +64,21 @@ def sha256_file(path: Path) -> str: return digest.hexdigest() +def parse_component(value: str) -> str: + if not value or value in {".", ".."} or "/" in value or "\\" in value: + raise argparse.ArgumentTypeError("value must be one path component") + + return value + + def parse_package_spec(value: str) -> PackageSpec: parts = value.split(":", 3) if len(parts) != 4: raise argparse.ArgumentTypeError(PACKAGE_SPEC_HELP) name, version, payload_type, source = parts + name = parse_component(name) + version = parse_component(version) if payload_type not in VALID_TYPES: raise argparse.ArgumentTypeError( f"unsupported package type '{payload_type}', expected one of " @@ -88,6 +98,25 @@ def parse_package_spec(value: str) -> PackageSpec: ) +def parse_name_value(value: str) -> tuple: + parts = value.split("=", 1) + if len(parts) != 2 or not parts[0] or not parts[1]: + raise argparse.ArgumentTypeError("value must look like =") + + return parts[0], parts[1] + + +def parse_icon_spec(value: str) -> tuple: + name, source = parse_name_value(value) + name = parse_component(name) + source_path = Path(source).expanduser().resolve() + if not source_path.is_file(): + msg = f"icon source does not exist: {source_path}" + raise argparse.ArgumentTypeError(msg) + + return name, source_path + + def artifact_relpath(arch: str, chip: str, compat: str, spec: PackageSpec) -> Path: # fmt: skip filename = spec.source.name @@ -96,6 +125,42 @@ def artifact_relpath(arch: str, chip: str, compat: str, return path +def icon_relpath(arch: str, chip: str, compat: str, name: str) -> Path: + # The client refreshes this object when the catalog version changes. + return Path("icons") / arch / chip / compat / f"{name}.bin" + + +def encode_icon_rgb565(source: Path, size: int = ICON_SIZE) -> bytes: + """Encode an image as an LVGL header and RGB565 pixels.""" + + from PIL import Image + + with Image.open(source) as img: + img = img.convert("RGB").resize((size, size), Image.Resampling.LANCZOS) + pixels = list(img.getdata()) + + stride = size * 2 + header = struct.pack( + "> 3) + struct.pack_into(" tuple: return ( package["name"], @@ -147,16 +212,19 @@ def main() -> int: parser.add_argument( "--arch", required=True, + type=parse_component, help="Target architecture string, for example xtensa", ) parser.add_argument( "--chip", required=True, + type=parse_component, help="Target chip/family string, for example esp32s3", ) parser.add_argument( "--compat", required=True, + type=parse_component, help="Target board/runtime identity, for example esp32s3-xiao", ) parser.add_argument( @@ -171,6 +239,30 @@ def main() -> int: type=parse_package_spec, help=PACKAGE_SPEC_HELP, ) + parser.add_argument( + "--package-description", + action="append", + default=[], + type=parse_name_value, + metavar="NAME=TEXT", + help="Optional package description, keyed by package name", + ) + parser.add_argument( + "--package-category", + action="append", + default=[], + type=parse_name_value, + metavar="NAME=TEXT", + help="Optional package category, keyed by package name", + ) + parser.add_argument( + "--package-icon", + action="append", + default=[], + type=parse_icon_spec, + metavar="NAME=PATH", + help="Optional source image for a package icon", + ) args = parser.parse_args() repo_dir = args.repo_dir.expanduser().resolve() @@ -181,6 +273,9 @@ def main() -> int: for package in packages: packages_by_id[package_identity(package)] = package prefix = args.artifact_prefix.rstrip("/") + descriptions = dict(args.package_description) + categories = dict(args.package_category) + icons = dict(args.package_icon) for spec in args.package: relpath = artifact_relpath(args.arch, args.chip, args.compat, spec) @@ -201,6 +296,26 @@ def main() -> int: "sha256": sha256_file(destination), "type": spec.payload_type, } + + if spec.name in descriptions: + package["description"] = descriptions[spec.name] + + if spec.name in categories: + package["category"] = categories[spec.name] + + if spec.name in icons: + icon_bytes = encode_icon_rgb565(icons[spec.name]) + icon_dest = repo_dir / icon_relpath( + args.arch, args.chip, args.compat, spec.name + ) + icon_dest.parent.mkdir(parents=True, exist_ok=True) + icon_dest.write_bytes(icon_bytes) + + icon_rel = icon_relpath( + args.arch, args.chip, args.compat, spec.name + ).as_posix() + package["icon"] = f"{prefix}/{icon_rel}" if prefix else icon_rel + packages_by_id[package_identity(package)] = package packages = sorted(