Skip to content

Commit fad623b

Browse files
committed
tests: fota: add MCUboot bootloader FOTA test
Add `test_bootloader_fota` to verify B1 MCUboot FOTA on Thingy:91 X. Checks `bootloaderVersion` in nRF Cloud shadow before (v2) and after (v3) the update, and verifies B0 UART output post-swap. Also refactor the FOTA poll loop into `trigger_fota_poll`, pass `MCUBOOT_BUNDLEID` env var through CI, and set a default signing key for nRF9151 DK in sysbuild config. Setting the signing key will allow us to run the test on the DK as well. This will be added once the new DTS partition schema is in place. Signed-off-by: Simen S. Røstad <simen.rostad@nordicsemi.no>
1 parent 776cdd1 commit fad623b

4 files changed

Lines changed: 119 additions & 13 deletions

File tree

.github/workflows/target-test.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ jobs:
188188
MEMFAULT_ORGANIZATION_SLUG: ${{ vars.MEMFAULT_ORGANIZATION_SLUG }}
189189
MEMFAULT_PROJECT_SLUG: ${{ vars.MEMFAULT_PROJECT_SLUG }}
190190
APP_BUNDLEID: ${{ vars.APP_BUNDLEID }}
191+
MCUBOOT_BUNDLEID: ${{ vars.MCUBOOT_BUNDLEID }}
191192

192193
- name: Generate and Push Power Badge
193194
if: ${{ always() && matrix.device == 'ppk_thingy91x' }}

app/Kconfig.sysbuild

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ endchoice
1111
config SECURE_BOOT_APPCORE
1212
default y if BOARD_NRF9151DK_NRF9151_NS
1313

14+
config SECURE_BOOT_SIGNING_KEY_FILE
15+
default "$(ZEPHYR_NRF_MODULE_DIR)/boards/nordic/thingy91x/nsib_signing_key.pem" if BOARD_NRF9151DK_NRF9151_NS
16+
1417
config WIFI_NRF70
1518
default y if BOARD_THINGY91X_NRF9151_NS
1619

docs/common/test_and_ci_setup.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ The CI pipeline is triggered as follows:
1919

2020
- Pull Request: `build.yml`, `sonarcloud.yml`, `compliance.yml`. No target tests are run on PR to avoid instabilities.
2121
- Push to main: `build-and-target-test.yml`, `sonarcloud.yml`, `doc-sync.yml`. Only "fast" target tests are run. Avoiding excessively time-consuming tests.
22-
- Nightly: `build-and-target-test.yml`. Full set of target tests. Includes slow tests such as the full modem FOTA test and the power consumption test.
22+
- Nightly: `build-and-target-test.yml`. Full set of target tests. Includes slow tests such as the full modem FOTA test, application and bootloader (MCUboot) FOTA.
2323

2424
### Hardware Tests
2525

tests/on_target/tests/test_functional/test_fota.py

Lines changed: 114 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,22 @@
2727
MFW_VERSION = "mfw_nrf91x1_2.0.4"
2828

2929
APP_BUNDLEID = os.getenv("APP_BUNDLEID")
30+
MCUBOOT_BUNDLEID = os.getenv("MCUBOOT_BUNDLEID")
3031

3132
TEST_APP_BIN = {
3233
"thingy91x": "artifacts/stable_version_jan_2025-update-signed.bin",
3334
"nrf9151dk": "artifacts/nrf9151dk_mar_2025_update_signed.bin"
3435
}
3536

3637
DEVICE_MSG_TIMEOUT = 60 * 5
37-
APP_FOTA_TIMEOUT = 60 * 10
38+
APP_FOTA_TIMEOUT = 60 * 15
39+
BOOTLOADER_FOTA_TIMEOUT = 60 * 20
3840
FULL_MFW_FOTA_TIMEOUT = 60 * 30
3941

42+
BOOTLOADER_VERSION_BASELINE = "2"
43+
BOOTLOADER_VERSION_UPDATED = "3"
44+
BOOTLOADER_FIRMWARE_VERSION_LOG = "Firmware version 3"
45+
4046
def await_nrfcloud(func, expected, field, timeout):
4147
start = time.time()
4248
logger.info(f"Awaiting {field} == {expected} in nrfcloud shadow...")
@@ -61,6 +67,41 @@ def get_modemversion(dut_fota):
6167
shadow = dut_fota.fota.get_device(dut_fota.device_id)
6268
return shadow["state"]["reported"]["device"]["deviceInfo"]["modemFirmware"]
6369

70+
def get_bootloaderversion(dut_fota):
71+
shadow = dut_fota.fota.get_device(dut_fota.device_id)
72+
return shadow["state"]["reported"]["device"]["deviceInfo"]["bootloaderVersion"]
73+
74+
def await_bootloader_version(dut_fota, expected, timeout=DEVICE_MSG_TIMEOUT):
75+
start = time.time()
76+
logger.info(f"Awaiting bootloaderVersion == {expected} in nrfcloud shadow...")
77+
while True:
78+
time.sleep(5)
79+
if time.time() - start > timeout:
80+
raise RuntimeError(
81+
f"Timeout awaiting bootloaderVersion == {expected}")
82+
try:
83+
version = get_bootloaderversion(dut_fota)
84+
except (KeyError, TypeError) as e:
85+
logger.warning(f"bootloaderVersion not in shadow yet: {e}")
86+
continue
87+
except Exception as e:
88+
logger.warning(f"Exception getting bootloaderVersion: {e}")
89+
continue
90+
logger.debug(f"Reported bootloaderVersion: {version}")
91+
if version == expected:
92+
return
93+
94+
def trigger_fota_poll(dut_fota, max_attempts=3):
95+
for i in range(max_attempts):
96+
try:
97+
time.sleep(10)
98+
dut_fota.uart.write("att_button press 1\r\n")
99+
dut_fota.uart.wait_for_str("nrf_cloud_fota_poll: Starting FOTA download", timeout=30)
100+
return
101+
except AssertionError:
102+
continue
103+
raise AssertionError(f"Fota update not available after {max_attempts} attempts")
104+
64105
def perform_disconnect_reconnect(dut_fota, expected_percentage):
65106
"""Helper function to perform a disconnect/reconnect sequence and verify resumption at expected percentage"""
66107
patterns_lte_offline = ["network: lte_lc_evt_handler: PDN connection network detached"]
@@ -170,17 +211,7 @@ def _run_fota(bundle_id="", fota_type="app", fotatimeout=APP_FOTA_TIMEOUT, new_v
170211
pytest.skip(f"FOTA create_job REST API error: {e}")
171212
logger.info(f"Created FOTA Job (ID: {dut_fota.data['job_id']})")
172213

173-
# Sleep a bit and trigger fota poll
174-
for i in range(3):
175-
try:
176-
time.sleep(10)
177-
dut_fota.uart.write("att_button press 1\r\n")
178-
dut_fota.uart.wait_for_str("nrf_cloud_fota_poll: Starting FOTA download", timeout=30)
179-
break
180-
except AssertionError:
181-
continue
182-
else:
183-
raise AssertionError(f"Fota update not available after {i} attempts")
214+
trigger_fota_poll(dut_fota)
184215

185216
if reschedule:
186217
run_fota_reschedule(dut_fota, fota_type)
@@ -284,6 +315,77 @@ def test_app_fota(run_fota_fixture):
284315
bundle_id=APP_BUNDLEID,
285316
)
286317

318+
@pytest.mark.slow
319+
def test_bootloader_fota(dut_fota, hex_file):
320+
'''
321+
Test MCUboot bootloader (B1) FOTA on Thingy:91 X.
322+
323+
Verifies deviceInfo.bootloaderVersion in the nRF Cloud shadow (baseline v2,
324+
v3 after FOTA) and B0 UART "Firmware version 3" after the swap reboot.
325+
326+
The finally block re-flashes baseline merged.hex to restore the DUT.
327+
'''
328+
if os.getenv("DUT_DEVICE_TYPE") != "thingy91x":
329+
pytest.skip("Bootloader FOTA test runs on thingy91x only")
330+
if not MCUBOOT_BUNDLEID:
331+
pytest.skip("MCUBOOT_BUNDLEID environment variable not set")
332+
333+
try:
334+
flash_device(os.path.abspath(hex_file))
335+
dut_fota.uart.xfactoryreset()
336+
dut_fota.uart.flush()
337+
reset_device()
338+
339+
dut_fota.uart.wait_for_str_with_retries(
340+
"Connected to Cloud", max_retries=3, timeout=240, reset_func=reset_device)
341+
342+
await_bootloader_version(dut_fota, BOOTLOADER_VERSION_BASELINE)
343+
344+
try:
345+
dut_fota.data["job_id"] = dut_fota.fota.create_fota_job(
346+
dut_fota.device_id, MCUBOOT_BUNDLEID)
347+
dut_fota.data["bundle_id"] = MCUBOOT_BUNDLEID
348+
except NRFCloudFOTAError as e:
349+
pytest.skip(f"FOTA create_job REST API error: {e}")
350+
logger.info(f"Created bootloader FOTA job (ID: {dut_fota.data['job_id']})")
351+
352+
trigger_fota_poll(dut_fota)
353+
354+
dut_fota.uart.wait_for_str("fota_download: B1 update, selected", timeout=120)
355+
dut_fota.uart.wait_for_str("Download complete", timeout=BOOTLOADER_FOTA_TIMEOUT)
356+
post_download_pos = dut_fota.uart.get_size()
357+
358+
# B1 FOTA: reboot, MCUboot test-swap, then B0 boots slot 1 with new fw_info.
359+
dut_fota.uart.wait_for_str(
360+
BOOTLOADER_FIRMWARE_VERSION_LOG,
361+
timeout=BOOTLOADER_FOTA_TIMEOUT,
362+
start_pos=post_download_pos,
363+
error_msg="Expected B0 fw_info v3 after download",
364+
)
365+
366+
await_nrfcloud(
367+
functools.partial(dut_fota.fota.get_fota_status, dut_fota.data["job_id"]),
368+
"IN_PROGRESS",
369+
"FOTA status",
370+
BOOTLOADER_FOTA_TIMEOUT,
371+
)
372+
await_nrfcloud(
373+
functools.partial(dut_fota.fota.get_fota_status, dut_fota.data["job_id"]),
374+
"COMPLETED",
375+
"FOTA status",
376+
BOOTLOADER_FOTA_TIMEOUT,
377+
)
378+
if not dut_fota.fota.get_fota_completed_executions(dut_fota.data["job_id"]) > 0:
379+
raise AssertionError("Bootloader FOTA job completed but no devices succeeded")
380+
381+
dut_fota.uart.wait_for_str_with_retries(
382+
"Connected to Cloud", max_retries=5, timeout=300, reset_func=reset_device)
383+
384+
await_bootloader_version(dut_fota, BOOTLOADER_VERSION_UPDATED,
385+
timeout=BOOTLOADER_FOTA_TIMEOUT)
386+
finally:
387+
flash_device(os.path.abspath(hex_file))
388+
287389
def test_delta_mfw_fota(run_fota_fixture):
288390
'''
289391
Test delta modem FOTA on nrf9151

0 commit comments

Comments
 (0)