#!/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. Must run under Python 3.8.10: CMake invokes this script via plain `python3` inside the x86_64 build container (see docker/Dockerfile, cmake/run_mkimage.cmake) on every firmware build, and that container has not been upgraded past 3.8. This is independent of the "Python >= 3.11" requirement elsewhere in this repo, which applies only to the tooling that runs on the Mac and the Pi (tools/station, tools/mkimage's own pytest suite), never to this file. Keep `from __future__ import annotations` below so that PEP 604 unions (`str | None`) in annotations stay text and are never evaluated - without it, that syntax raises `TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'` on 3.8/3.9. See test_nl_mkimage.py::test_stays_python38_compatible, which fails the build if this drifts. """ from __future__ import annotations 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)") if any(c.isspace() for c in value): sys.exit(f"error: {name} '{value}' contains whitespace") return raw.ljust(FIELD, b"\x00") def verify(path: Path, expect_device: str | None) -> int: """Checks an _ota.img the way the firmware does before accepting it.""" try: data = path.read_bytes() except OSError as e: print(f"error: {e}", file=sys.stderr) return 1 if len(data) <= TRAILER_SIZE: print(f"error: {path} is too short to carry a trailer", file=sys.stderr) return 1 payload, trailer = data[:-TRAILER_SIZE], data[-TRAILER_SIZE:] if trailer[0:4] != b"NINL": print(f"error: {path} has no ninlight trailer", file=sys.stderr) return 1 if trailer[4] != TRAILER_VERSION: print(f"error: {path} has trailer version {trailer[4]}, expected {TRAILER_VERSION}", file=sys.stderr) return 1 # Device field must be null-terminated within its 16 bytes (same as firmware check) device_field = trailer[5:21] if b"\x00" not in device_field: print(f"error: {path} has unterminated device field", file=sys.stderr) return 1 device = device_field.rstrip(b"\x00").decode("ascii", "replace") version = trailer[21:37].rstrip(b"\x00").decode("ascii", "replace") if expect_device is not None and device != expect_device: print(f"error: {path} carries device '{device}', expected '{expect_device}'", file=sys.stderr) return 1 if trailer[37:69] != hashlib.sha256(payload).digest(): print(f"error: {path} fails its sha256 - the payload does not match the trailer", file=sys.stderr) return 1 print(f"{device} {version} {len(data)}") return 0 def main() -> None: p = argparse.ArgumentParser(description="append the ninlight OTA trailer") p.add_argument("--image", type=Path, help="input _gz.img") p.add_argument("--device", help="device id, e.g. e27_rgb") p.add_argument("--version", help="firmware version") p.add_argument("--out", type=Path, help="output _ota.img") p.add_argument("--verify", type=Path, help="check an existing _ota.img instead of building one") p.add_argument("--expect-device", help="with --verify: require this device id") a = p.parse_args() if a.verify is not None: # Verify mode: check that build arguments are not provided build_args = ("image", "device", "version", "out") if any(getattr(a, name) is not None for name in build_args): p.error("--verify cannot be combined with build mode arguments (--image, --device, --version, --out)") raise SystemExit(verify(a.verify, a.expect_device)) # Build mode: require all arguments for name in ("image", "device", "version", "out"): if getattr(a, name) is None: p.error(f"--{name} is required unless --verify is given") try: data = a.image.read_bytes() except OSError as e: sys.exit(f"error: {e}") 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()