feat(assets): embed LCD and audio resources in OTA app

This commit is contained in:
chenjintao
2026-10-08 16:58:33 +08:00
parent 3160a33f50
commit 7bc9a3dc76
43 changed files with 443 additions and 243 deletions
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""将素材确定性地打包为只读资源,随应用镜像一起参与 OTA。"""
from __future__ import annotations
import argparse
import struct
from pathlib import Path
MAGIC = b"TQASSET1"
# 与 platform_asset_bundle.c 的解析格式一致:小端包头及条目头,后接名称和原始字节。
HEADER = struct.Struct("<8sI")
ENTRY = struct.Struct("<HI")
ALLOWED = {"lcd": ".png", "audio_prompts": ".opus"}
def pack(source: Path) -> bytes:
# 排序固定条目顺序,保证相同输入生成相同的包;只接受当前运行时使用的两类素材。
files = sorted(path for path in source.rglob("*") if path.is_file())
if not files:
raise ValueError("asset staging directory is empty")
records: list[tuple[bytes, bytes]] = []
names: set[str] = set()
for path in files:
relative = path.relative_to(source)
if len(relative.parts) != 2 or relative.parts[0] not in ALLOWED:
raise ValueError(f"unexpected asset path: {relative}")
if path.suffix.lower() != ALLOWED[relative.parts[0]]:
raise ValueError(f"unexpected asset type: {relative}")
name = relative.as_posix()
if name in names:
raise ValueError(f"duplicate asset: {name}")
names.add(name)
encoded = name.encode("utf-8")
payload = path.read_bytes()
if not encoded or len(encoded) > 0xFFFF or not payload or len(payload) > 0xFFFFFFFF:
raise ValueError(f"invalid asset size: {name}")
records.append((encoded, payload))
output = bytearray(HEADER.pack(MAGIC, len(records)))
for name, payload in records:
output.extend(ENTRY.pack(len(name), len(payload)))
output.extend(name)
output.extend(payload)
print(f"Embedded assets: {len(records)} files, {sum(len(data) for _, data in records)} payload bytes, {len(output)} bundle bytes")
return bytes(output)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("source", type=Path)
parser.add_argument("output", type=Path)
args = parser.parse_args()
data = pack(args.source)
args.output.parent.mkdir(parents=True, exist_ok=True)
if not args.output.exists() or args.output.read_bytes() != data:
args.output.write_bytes(data)
if __name__ == "__main__":
main()
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""每次构建后报告分区和镜像空间占用。"""
from __future__ import annotations
import argparse
import struct
from pathlib import Path
ENTRY = struct.Struct("<2sBBII16sI")
def partitions(path: Path) -> list[tuple[str, int, int]]:
# 读取本次构建生成的分区表二进制条目;报告不代表设备当前烧录状态。
data = path.read_bytes()
result = []
for offset in range(0, len(data) - ENTRY.size + 1, ENTRY.size):
magic, _type, _subtype, start, size, raw_name, _flags = ENTRY.unpack_from(data, offset)
if magic != b"\xaa\x50":
break
result.append((raw_name.split(b"\0", 1)[0].decode("ascii"), start, size))
if not result:
raise ValueError(f"no partitions found in {path}")
return result
def mib(size: int) -> str:
return f"{size / 1048576:.2f} MiB"
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--partition-table", required=True, type=Path)
parser.add_argument("--app", required=True, type=Path)
parser.add_argument("--asset-pack", required=True, type=Path)
parser.add_argument("--model", required=True, type=Path)
parser.add_argument("--flash-size", required=True)
args = parser.parse_args()
flash_size = int(args.flash_size.upper().removesuffix("MB")) * 1048576
entries = partitions(args.partition_table)
by_name = {name: (start, size) for name, start, size in entries}
# 素材包已计入应用镜像,单独显示仅用于分析,不能再加到应用占用中。
app_size = args.app.stat().st_size
asset_size = args.asset_pack.stat().st_size
model_size = args.model.stat().st_size
for name in ("ota_0", "ota_1", "model"):
if name not in by_name:
raise ValueError(f"missing required partition: {name}")
if app_size > min(by_name[name][1] for name in ("ota_0", "ota_1")):
raise ValueError("application image exceeds an OTA slot")
if model_size > by_name["model"][1]:
raise ValueError("model image exceeds model partition")
last_end = max(start + size for _, start, size in entries)
if last_end > flash_size:
raise ValueError("partition table exceeds configured Flash size")
print("\nFlash usage (local build images; device contents may differ):")
print(f" Flash capacity: {mib(flash_size)}")
for name, start, size in entries:
print(f" {name:9s} 0x{start:06x}-0x{start + size:06x} {mib(size)}")
slot_size = min(by_name[name][1] for name in ("ota_0", "ota_1"))
print(f" App image: {app_size:,} B / {slot_size:,} B ({app_size / slot_size:.1%}); free {mib(slot_size - app_size)} per OTA slot")
print(f" Embedded assets: {asset_size:,} B (included in app image)")
print(f" Model image: {model_size:,} B / {by_name['model'][1]:,} B ({model_size / by_name['model'][1]:.1%})")
print(f" Unpartitioned tail: {mib(flash_size - last_end)}\n")
if __name__ == "__main__":
main()