From 76b9c3fef1465a0d4e4c2310869f14e00a1e936d Mon Sep 17 00:00:00 2001 From: Michael Sauer Date: Sat, 12 Sep 2026 01:21:42 +0200 Subject: [PATCH] =?UTF-8?q?test:=20nl=5Fmkimage=20f=C3=BCr=20OTA-Trailer-E?= =?UTF-8?q?rzeugung=20mit=20Tests=20anlegen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: nl_mkimage.py implementieren (Gegenstück zu nl_fwup_trailer.c aus Task 2) --- tools/mkimage/nl_mkimage.py | 48 +++++++++++++++++++ tools/mkimage/pyproject.toml | 7 +++ tools/mkimage/test_nl_mkimage.py | 55 ++++++++++++++++++++++ tools/mkimage/uv.lock | 79 ++++++++++++++++++++++++++++++++ 4 files changed, 189 insertions(+) create mode 100755 tools/mkimage/nl_mkimage.py create mode 100644 tools/mkimage/pyproject.toml create mode 100644 tools/mkimage/test_nl_mkimage.py create mode 100644 tools/mkimage/uv.lock diff --git a/tools/mkimage/nl_mkimage.py b/tools/mkimage/nl_mkimage.py new file mode 100755 index 0000000..5d5afc0 --- /dev/null +++ b/tools/mkimage/nl_mkimage.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Appends the ninlight OTA trailer to a `_gz.img` produced by wm_tool. + +Layout (512 bytes): "NINL" | version(1) | device[16] | fw_version[16] | sha256[32] | 0xFF padding. +The hash covers the complete `_gz.img` (T_BOOTER header + payload), which is exactly what the +firmware hashes while writing the image to flash. +""" +import argparse +import hashlib +import sys +from pathlib import Path + +TRAILER_SIZE = 512 +TRAILER_VERSION = 1 +FIELD = 16 + + +def field(value: str, name: str) -> bytes: + raw = value.encode() + if len(raw) >= FIELD: + sys.exit(f"error: {name} '{value}' is too long (max {FIELD - 1} bytes)") + return raw.ljust(FIELD, b"\x00") + + +def main() -> None: + p = argparse.ArgumentParser(description="append the ninlight OTA trailer") + p.add_argument("--image", required=True, type=Path, help="input _gz.img") + p.add_argument("--device", required=True, help="device id, e.g. e27_rgb") + p.add_argument("--version", required=True, help="firmware version") + p.add_argument("--out", required=True, type=Path, help="output _ota.img") + a = p.parse_args() + + data = a.image.read_bytes() + if len(data) > TRAILER_SIZE and data[-TRAILER_SIZE : -TRAILER_SIZE + 4] == b"NINL": + sys.exit("error: image already carries a trailer") + + trailer = bytearray(b"\xff" * TRAILER_SIZE) + trailer[0:4] = b"NINL" + trailer[4] = TRAILER_VERSION + trailer[5:21] = field(a.device, "device") + trailer[21:37] = field(a.version, "version") + trailer[37:69] = hashlib.sha256(data).digest() + a.out.write_bytes(data + bytes(trailer)) + print(f"{a.out} ({len(data) + TRAILER_SIZE} bytes, device={a.device}, version={a.version})") + + +if __name__ == "__main__": + main() diff --git a/tools/mkimage/pyproject.toml b/tools/mkimage/pyproject.toml new file mode 100644 index 0000000..aeee3bd --- /dev/null +++ b/tools/mkimage/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "nl-mkimage" +version = "0.1.0" +requires-python = ">=3.11" + +[dependency-groups] +dev = ["pytest>=8"] diff --git a/tools/mkimage/test_nl_mkimage.py b/tools/mkimage/test_nl_mkimage.py new file mode 100644 index 0000000..9609de1 --- /dev/null +++ b/tools/mkimage/test_nl_mkimage.py @@ -0,0 +1,55 @@ +import hashlib +import subprocess +import sys +from pathlib import Path + +SCRIPT = Path(__file__).with_name("nl_mkimage.py") + + +def build(tmp_path, payload=b"\x01\x02\x03" * 100, device="evk", version="1.2.3"): + src = tmp_path / "fw_gz.img" + src.write_bytes(payload) + out = tmp_path / "fw_ota.img" + subprocess.run( + [sys.executable, str(SCRIPT), "--image", str(src), "--device", device, + "--version", version, "--out", str(out)], + check=True, + ) + return src, out + + +def test_appends_512_byte_trailer(tmp_path): + src, out = build(tmp_path) + data = out.read_bytes() + assert len(data) == len(src.read_bytes()) + 512 + assert data[: len(src.read_bytes())] == src.read_bytes() + trailer = data[-512:] + assert trailer[0:4] == b"NINL" + assert trailer[4] == 1 + assert trailer[5:21].rstrip(b"\x00") == b"evk" + assert trailer[21:37].rstrip(b"\x00") == b"1.2.3" + assert trailer[37:69] == hashlib.sha256(src.read_bytes()).digest() + assert set(trailer[69:]) == {0xFF} + + +def test_rejects_long_fields(tmp_path): + src = tmp_path / "fw_gz.img" + src.write_bytes(b"x") + r = subprocess.run( + [sys.executable, str(SCRIPT), "--image", str(src), "--device", "0123456789abcdef", + "--version", "1", "--out", str(tmp_path / "o.img")], + capture_output=True, text=True, + ) + assert r.returncode != 0 + assert "device" in r.stderr + + +def test_refuses_image_that_already_has_a_trailer(tmp_path): + _, out = build(tmp_path) + r = subprocess.run( + [sys.executable, str(SCRIPT), "--image", str(out), "--device", "evk", + "--version", "1.2.3", "--out", str(tmp_path / "again.img")], + capture_output=True, text=True, + ) + assert r.returncode != 0 + assert "trailer" in r.stderr diff --git a/tools/mkimage/uv.lock b/tools/mkimage/uv.lock new file mode 100644 index 0000000..7030ede --- /dev/null +++ b/tools/mkimage/uv.lock @@ -0,0 +1,79 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "nl-mkimage" +version = "0.1.0" +source = { virtual = "." } + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8" }] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +]