diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 767d4cca3..67f019292 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -825,6 +825,7 @@ jobs: python3 -B test/test_t114_release_profiles.py -v python3 -B test/test_optional_display_profiles.py -v python3 -B test/test_consolidated_profiles.py -v + python3 -B test/test_rp2040_application_capabilities.py -v - name: Verify Full logging compiler flags and packaged capabilities run: | diff --git a/build_legacy.sh b/build_legacy.sh index 0a2ec84d3..271696d2a 100755 --- a/build_legacy.sh +++ b/build_legacy.sh @@ -4665,6 +4665,9 @@ write_build_capability_manifest() { --partitions "${build_output_dir}/partitions.bin") elif [ "$env_platform" = "NRF52_PLATFORM" ]; then checker_args+=(--dfu-package "${build_output_dir}/firmware.zip") + elif [ "$env_platform" = "RP2040_PLATFORM" ]; then + checker_args+=(--firmware-bin "${build_output_dir}/firmware.bin" + --uf2-package "${build_output_dir}/firmware.uf2") fi for item in "${BUILD_CAPABILITIES[@]}"; do diff --git a/mkdocs.yml b/mkdocs.yml index 8171d201d..24ecdedac 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -131,7 +131,7 @@ extra_javascript: - _javascript/hosted_doc_links.js?v=20261007-1 - _javascript/notification_builder.js?v=20261006-channel9 - https://unpkg.com/leaflet@1.9.4/dist/leaflet.js - - _javascript/firmware_picker.js?v=20261016-onboard-qspi-2 + - _javascript/firmware_picker.js?v=20261018-variant-consolidation-1 - _javascript/telemetry_decoder.js?v=20261007-hosted-form - _javascript/management_decoder.js - _javascript/filter_tool.js?v=20261002-hashbytes-2plus diff --git a/scripts/check_firmware_capabilities.py b/scripts/check_firmware_capabilities.py index e532e27e8..98fa89fe0 100644 --- a/scripts/check_firmware_capabilities.py +++ b/scripts/check_firmware_capabilities.py @@ -11,6 +11,7 @@ import sys import struct import zipfile import zlib +from rp2040_application import read_application as read_rp2040_application from package_meshtext_license import (CAPABILITY as MESHTEXT_CAPABILITY, meshtext_evidence, notice_bytes as meshtext_notice_bytes, notice_record as meshtext_notice_record) @@ -81,6 +82,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--firmware-bin", type=Path) parser.add_argument("--partitions", type=Path) parser.add_argument("--dfu-package", type=Path) + parser.add_argument("--uf2-package", type=Path) parser.add_argument( "--expect", action="append", @@ -96,7 +98,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "--expect-application", action="append", default=[], metavar="CAPABILITY=TEXT", - help="Require TEXT in the packaged firmware.bin or DFU application, not ELF metadata.", + help="Require TEXT in the packaged BIN, DFU application, or verified RP2040 UF2 application, not ELF metadata.", ) return parser.parse_args() @@ -124,6 +126,10 @@ def linked_gzip_assets(image): def read_application(args): + if args.platform == "RP2040_PLATFORM" or args.uf2_package is not None: + if args.platform != "RP2040_PLATFORM" or args.uf2_package is None or args.firmware_bin is None: + raise ValueError("RP2040 application checks require both --uf2-package and --firmware-bin") + return read_rp2040_application(args.uf2_package, args.firmware_bin) if args.firmware_bin is not None: return args.firmware_bin.read_bytes() if args.dfu_package is not None: diff --git a/scripts/rp2040_application.py b/scripts/rp2040_application.py new file mode 100644 index 000000000..06aa71a08 --- /dev/null +++ b/scripts/rp2040_application.py @@ -0,0 +1,49 @@ +"""Verify that an RP2040 UF2 carries the exact application offered as BIN.""" +from pathlib import Path +import struct + +BLOCK_BYTES = 512 +PAYLOAD_BYTES = 256 +FLASH_BASE = 0x10000000 +FLASH_END = 0x11000000 # RP2040's maximum 16 MiB XIP flash window. +FAMILY = 0xE48BFF56 + + +def read_application(uf2_path: Path, bin_path: Path) -> bytes: + """Return real flash bytes, excluding UF2 headers and alignment padding. + + Both public formats must agree. ELF strings, unused sector bytes, and + bytes outside the BIN's application extent cannot supply capability proof. + The board's ordinary linker/size checks retain its stricter flash limit. + """ + size = uf2_path.stat().st_size + if not size or size % BLOCK_BYTES or size > (FLASH_END - FLASH_BASE) * 2: + raise ValueError("RP2040 UF2 has an invalid sector extent") + application_size = bin_path.stat().st_size + if not 0 < application_size <= FLASH_END - FLASH_BASE: + raise ValueError("RP2040 BIN is empty or exceeds the XIP flash window") + data = uf2_path.read_bytes() + if len(data) != size: + raise ValueError("RP2040 UF2 changed during application qualification") + count = size // BLOCK_BYTES + payload = bytearray() + for index in range(count): + offset = index * BLOCK_BYTES + magic0, magic1, flags, address, length, number, total, family = struct.unpack_from("<8I", data, offset) + trailer = struct.unpack_from(" FLASH_END: + raise ValueError("RP2040 UF2 has gaps, overlaps, or addresses outside its XIP flash window") + payload.extend(data[offset + 32:offset + 32 + length]) + application = bin_path.read_bytes() + tail = payload[application_size:] + if len(payload) < application_size or len(tail) >= PAYLOAD_BYTES or payload[:application_size] != application: + raise ValueError("RP2040 UF2 application does not match the packaged BIN") + if tail and set(tail) not in ({0}, {255}): + raise ValueError("RP2040 UF2 has nonuniform application-alignment padding") + return bytes(payload[:application_size]) diff --git a/test/test_rp2040_application_capabilities.py b/test/test_rp2040_application_capabilities.py new file mode 100644 index 000000000..7eb16fb48 --- /dev/null +++ b/test/test_rp2040_application_capabilities.py @@ -0,0 +1,192 @@ +"""Actual RP2040 UF2/BIN application evidence, without PlatformIO or hardware.""" +from pathlib import Path +import json +import os +import shlex +import struct +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'scripts')) +from rp2040_application import FLASH_BASE, FLASH_END, read_application + +CAPABILITY = 'companion.picow.combined' +MARKER = b'meshcore.capability.companion.picow.combined.v1' + + +def uf2(application, padding=255): + count = (len(application) + 255) // 256 + payload = application + bytes([padding]) * (count * 256 - len(application)) + blocks = [] + for index in range(count): + block = bytearray(512) + struct.pack_into('<8I', block, 0, 0x0A324655, 0x9E5D5157, 0x2000, + FLASH_BASE + index * 256, 256, index, count, 0xE48BFF56) + block[32:288] = payload[index * 256:(index + 1) * 256] + struct.pack_into('&2; return 99; } +PIO_BUILD_DIR_OVERRIDE=@BUILD@ +OUTPUT_DIR=@OUTPUT@ +BUILD_PROFILE_FOR_TARGET=auto +BUILD_RECIPE_SHA256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +BUILD_CAPABILITIES=() BUILD_REDUCTIONS=() BUILD_EXPECTATIONS=() +BUILD_APPLICATION_EXPECTATIONS=('companion.picow.combined=meshcore.capability.companion.picow.combined.v1') +write_build_capability_manifest PicoW_companion_radio RP2040_PLATFORM PicoW_companion_radio fixture +''' + program = program.replace('@BUILD@', shlex.quote(str(self.path / 'build'))).replace('@OUTPUT@', shlex.quote(str(output))) + result = subprocess.run(['bash', '-c', program], cwd=ROOT, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + manifest = json.loads((output / 'fixture.capabilities.json').read_text()) + self.assertTrue(manifest['verified']) + self.assertIn(CAPABILITY, manifest['capabilities']) + self.assertEqual(manifest['ota_update_methods'], []) + + +if __name__ == '__main__': + unittest.main()