From e22d5d7d0a8c85fa0c9f69de0054597f9787ac18 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 20 Oct 2026 19:42:41 -0700 Subject: [PATCH] Let motatool size authenticated nRF52 delta workspaces Forward --inplace-memory only when explicitly supplied, preserving authenticated layout bounds and the conservative legacy fallback. Validation: MOTATOOL_TEST_BIN=/home/mesh/git/motatool/target/release/motatool python3 -m unittest test_lora_ota (318 passed). Both new auto-sizing regressions fail against the prior runner. --- tools/lora_ota/lora_ota.py | 18 ++++----- tools/lora_ota/test_lora_ota.py | 67 +++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 9 deletions(-) diff --git a/tools/lora_ota/lora_ota.py b/tools/lora_ota/lora_ota.py index f1685c378..eac57233c 100755 --- a/tools/lora_ota/lora_ota.py +++ b/tools/lora_ota/lora_ota.py @@ -1560,14 +1560,11 @@ def prepare_package( "--patch-type", "in-place", ]) - # External staging leaves the application region available as the - # detools workspace. 0xC6000 is safe for both S140 v7 (app starts - # at 0x27000) and older v6 layouts (0x26000). Internal staging - # retains its deliberately smaller legacy workspace. - inplace_memory = args.inplace_memory or ( - "0xC6000" if target.nrf_external else "0x98000" - ) - command.extend(["--inplace-memory", inplace_memory]) + # Let motatool size the workspace from both authenticated layout + # records and validate the application/staging bounds. Images + # without those records retain motatool's conservative fallback. + if args.inplace_memory is not None: + command.extend(["--inplace-memory", args.inplace_memory]) if args.sign_key: command.extend(["--sign", str(args.sign_key.resolve())]) result = run_checked( @@ -7949,7 +7946,10 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--public-key", type=Path, help="require this signer when verifying") parser.add_argument( "--inplace-memory", - help="nRF52 OTAFIX workspace (auto: 0x98000 internal, 0xC6000 external SD/QSPI)", + help=( + "explicit nRF52 OTAFIX workspace override; default: motatool " + "authenticated-layout sizing, or its conservative legacy fallback" + ), ) parser.add_argument( "--platform", choices=("esp32", "nrf52"), diff --git a/tools/lora_ota/test_lora_ota.py b/tools/lora_ota/test_lora_ota.py index 6c72ceb9a..ef4be8e61 100644 --- a/tools/lora_ota/test_lora_ota.py +++ b/tools/lora_ota/test_lora_ota.py @@ -8065,6 +8065,73 @@ class MotatoolIntegrationTests(unittest.TestCase): ) subprocess.run([cls.motatool, "--version"], check=True, capture_output=True) + def prepare_layout_delta( + self, *, external: bool = False, memory: str | None = None, + authenticated: bool = True, + ) -> tuple[ota.MotaInfo, list[mock._Call]]: + # The real tool must size an image larger than its legacy 0x98000 + # workspace from the EndF-authenticated geometry, including two spare + # detools segments. Only a small content change needs transferring. + layout = struct.pack( + "<8sBBHIII", b"mOTALay1", 1, 1 if external else 0, 24, + 0x27000, 0xED000, 0xED000, + ) if authenticated else b"" + body = b"A" * (0x99000 - 56 - len(layout)) + layout + base_image = firmware(body, VERSION_OLD) + new_image = firmware(b"B" * 32 + body[32:], VERSION_NEW) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + base_path = root / "running.bin" + base_path.write_bytes(base_image) + archive_path = root / "release.zip" + with zipfile.ZipFile(archive_path, "w") as archive: + archive.writestr("firmware.bin", new_image) + args = prepare_args(archive_path, self.motatool, base_path) + args.inplace_memory = memory + work = root / "work" + work.mkdir() + with mock.patch.object(ota, "run_checked", wraps=ota.run_checked) as run: + _path, package, expected = ota.prepare_package( + args, target( + platform="nrf52", nrf_sd=external, + base_hash=ota.parse_endf(base_image).body_hash, + boot_codecs=1 << ota.MOTA_CODEC_IN_PLACE, + ), work, + ) + self.assertFalse(package.is_full) + self.assertEqual(package.codec_id, ota.MOTA_CODEC_IN_PLACE) + self.assertEqual(package.image_size, len(new_image)) + self.assertEqual(package.base_hash, ota.parse_endf(base_image).body_hash) + self.assertEqual(expected, ota.parse_endf(new_image).body_hash) + return package, run.call_args_list + + def test_authenticated_internal_layout_selects_large_workspace(self) -> None: + with contextlib.redirect_stdout(io.StringIO()) as output: + _package, calls = self.prepare_layout_delta() + build = next(call.args[0] for call in calls if call.kwargs["label"] == "build mOTA") + self.assertNotIn("--inplace-memory", build) + self.assertIn("in-place memory=0x9B000", output.getvalue()) + + def test_authenticated_external_layout_uses_minimum_workspace(self) -> None: + with contextlib.redirect_stdout(io.StringIO()) as output: + _package, calls = self.prepare_layout_delta(external=True) + build = next(call.args[0] for call in calls if call.kwargs["label"] == "build mOTA") + self.assertNotIn("--inplace-memory", build) + self.assertIn("in-place memory=0x9B000", output.getvalue()) + + def test_explicit_workspace_is_preserved_and_layout_checked(self) -> None: + with contextlib.redirect_stdout(io.StringIO()) as output: + _package, calls = self.prepare_layout_delta(memory="0xA0000") + build = next(call.args[0] for call in calls if call.kwargs["label"] == "build mOTA") + self.assertEqual(build[build.index("--inplace-memory") + 1], "0xA0000") + self.assertIn("in-place memory=0xA0000", output.getvalue()) + with self.assertRaisesRegex(ota.OtaError, "exceeds authenticated nRF52 workspace"): + self.prepare_layout_delta(memory="0xD0000") + + def test_legacy_image_retains_conservative_workspace_limit(self) -> None: + with self.assertRaisesRegex(ota.OtaError, "legacy default memory 0x98000 is too small"): + self.prepare_layout_delta(authenticated=False) + def test_raw_esp32_zip_becomes_full_mota(self) -> None: image = firmware(b"ESP32-new" * 900, VERSION_NEW) with tempfile.TemporaryDirectory() as directory: