Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/build-and-target-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ on:
type: boolean
required: false
default: false
test_filter:
description: 'Pytest -k expression to isolate tests (e.g. "test_app_fota").
Leave empty to run all tests for the selected boards.'
type: string
required: false
default: ""

workflow_call:
inputs:
Expand Down Expand Up @@ -157,3 +163,4 @@ jobs:
artifact_run_id: ${{ needs.build.outputs.run_id }}
devices: ${{ needs.setup.outputs.devices }}
run_nightly_tests: ${{ needs.setup.outputs.run_nightly_tests == 'true' }}
pytest_args: ${{ inputs.test_filter != '' && format('-k "{0}"', inputs.test_filter) || '' }}
16 changes: 15 additions & 1 deletion .github/workflows/target-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ on:
type: string
required: true
default: thingy91x
pytest_args:
description: Extra arguments appended to the pytest invocation
(e.g. "-k test_app_fota"). Leave empty for the default behaviour.
type: string
required: false
default: ""

workflow_dispatch:
inputs:
Expand All @@ -48,6 +54,12 @@ on:
type: string
required: true
default: thingy91x
pytest_args:
description: Extra arguments appended to the pytest invocation
(e.g. "-k test_app_fota"). Leave empty for the default behaviour.
type: string
required: false
default: ""

permissions:
contents: read
Expand Down Expand Up @@ -161,7 +173,8 @@ jobs:
pytest -v "${pytest_marker[@]}" \
--junit-xml=results/test-results.xml \
--html=results/test-results.html --self-contained-html \
${PYTEST_PATH}
${PYTEST_PATH} \
${{ inputs.pytest_args }}

shell: bash
env:
Expand All @@ -175,6 +188,7 @@ jobs:
MEMFAULT_ORGANIZATION_SLUG: ${{ vars.MEMFAULT_ORGANIZATION_SLUG }}
MEMFAULT_PROJECT_SLUG: ${{ vars.MEMFAULT_PROJECT_SLUG }}
APP_BUNDLEID: ${{ vars.APP_BUNDLEID }}
MCUBOOT_BUNDLEID: ${{ vars.MCUBOOT_BUNDLEID }}

- name: Generate and Push Power Badge
if: ${{ always() && matrix.device == 'ppk_thingy91x' }}
Expand Down
3 changes: 3 additions & 0 deletions app/Kconfig.sysbuild
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ endchoice
config SECURE_BOOT_APPCORE
default y if BOARD_NRF9151DK_NRF9151_NS

config SECURE_BOOT_SIGNING_KEY_FILE
default "$(ZEPHYR_NRF_MODULE_DIR)/boards/nordic/thingy91x/nsib_signing_key.pem" if BOARD_NRF9151DK_NRF9151_NS

config WIFI_NRF70
default y if BOARD_THINGY91X_NRF9151_NS

Expand Down
33 changes: 33 additions & 0 deletions app/src/modules/fota/fota.c
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
#include <net/nrf_cloud_fota_poll.h>
#include <zephyr/sys/reboot.h>
#include <zephyr/dfu/mcuboot.h>
#include <zephyr/storage/flash_map.h>
#include <zephyr/task_wdt/task_wdt.h>
#include <nrf_cloud_fota.h>
#include <zephyr/smf.h>
Expand Down Expand Up @@ -513,11 +514,43 @@ static enum smf_state_result state_polling_for_update_run(void *obj)
return SMF_EVENT_PROPAGATE;
}

/* Erase the MCUboot secondary-slot trailer (last 4 KiB) before each download.
*
* stream_flash only erases sectors it writes; the trailer sector is skipped.
* On external SPI-NOR secondaries, J-Link does not clear it, so a stale trailer
* makes boot_set_pending() fail (-EFAULT) after a good download.
* One trailer page (~50 ms) avoids erasing the full slot (~800 KiB).
*/
static void erase_secondary_trailer(void)
{
const struct flash_area *fa;
int err = flash_area_open(PARTITION_ID(slot1_partition), &fa);

if (err) {
LOG_WRN("flash_area_open(slot1) failed: %d, skipping trailer erase", err);
return;
}

const size_t page_size = 4096;
const off_t trailer_off = fa->fa_size - page_size;

err = flash_area_erase(fa, trailer_off, page_size);
if (err) {
LOG_WRN("flash_area_erase(slot1 trailer) failed: %d", err);
} else {
LOG_DBG("Erased slot1 trailer page at offset 0x%lx", (long)trailer_off);
}

flash_area_close(fa);
}

static void state_downloading_update_entry(void *obj)
{
ARG_UNUSED(obj);

LOG_DBG("%s", __func__);

erase_secondary_trailer();
}

static enum smf_state_result state_downloading_update_run(void *obj)
Expand Down
2 changes: 1 addition & 1 deletion docs/common/test_and_ci_setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ The CI pipeline is triggered as follows:

- Pull Request: `build.yml`, `sonarcloud.yml`, `compliance.yml`. No target tests are run on PR to avoid instabilities.
- Push to main: `build-and-target-test.yml`, `sonarcloud.yml`, `doc-sync.yml`. Only "fast" target tests are run. Avoiding excessively time-consuming tests.
- 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.
- 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.

### Hardware Tests

Expand Down
1 change: 1 addition & 0 deletions tests/module/fota/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ zephyr_include_directories(${ZEPHYR_BASE}/include/zephyr/)
zephyr_include_directories(${ZEPHYR_BASE}/subsys/testsuite/include)
zephyr_include_directories(${ZEPHYR_BASE}/../nrfxlib/nrf_modem/include)

zephyr_include_directories(${NRF_DIR}/include)
zephyr_include_directories(${NRF_DIR}/include/net)
zephyr_include_directories(${NRF_DIR}/subsys/net/lib/nrf_cloud/include)
zephyr_include_directories(${NRF_DIR}/subsys/net/lib/nrf_cloud/common/include)
Expand Down
24 changes: 24 additions & 0 deletions tests/module/fota/src/fota_module_test.c
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
#include <zephyr/zbus/zbus.h>
#include <zephyr/task_wdt/task_wdt.h>
#include <zephyr/logging/log.h>
#include <zephyr/storage/flash_map.h>
#include <dfu/dfu_target.h>
#include <net/nrf_cloud.h>
#include <net/nrf_cloud_fota_poll.h>

#include "app_common.h"
Expand All @@ -22,8 +25,23 @@ FAKE_VALUE_FUNC(int, nrf_cloud_fota_poll_process_pending, struct nrf_cloud_fota_
FAKE_VALUE_FUNC(int, nrf_cloud_fota_poll_process, struct nrf_cloud_fota_poll_ctx *);
FAKE_VALUE_FUNC(int, nrf_cloud_fota_poll_update_apply, struct nrf_cloud_fota_poll_ctx *);
FAKE_VALUE_FUNC(int, fota_download_cancel);
FAKE_VALUE_FUNC(int, flash_area_open, uint8_t, const struct flash_area **);
FAKE_VALUE_FUNC(int, flash_area_erase, const struct flash_area *, off_t, size_t);
FAKE_VOID_FUNC(flash_area_close, const struct flash_area *);
FAKE_VOID_FUNC1(callback_t, int);

static struct flash_area fake_slot1_area = {
.fa_size = 800 * 1024,
};

static int flash_area_open_fake_impl(uint8_t id, const struct flash_area **fa)
{
ARG_UNUSED(id);

*fa = &fake_slot1_area;
return 0;
}

ZBUS_MSG_SUBSCRIBER_DEFINE(fota_subscriber);
ZBUS_CHAN_ADD_OBS(fota_chan, fota_subscriber, 0);

Expand Down Expand Up @@ -63,6 +81,12 @@ void setUp(void)
RESET_FAKE(nrf_cloud_fota_poll_process);
RESET_FAKE(nrf_cloud_fota_poll_update_apply);
RESET_FAKE(fota_download_cancel);
RESET_FAKE(flash_area_open);
RESET_FAKE(flash_area_erase);
RESET_FAKE(flash_area_close);

flash_area_open_fake.custom_fake = flash_area_open_fake_impl;
flash_area_erase_fake.return_val = 0;

FFF_RESET_HISTORY();

Expand Down
126 changes: 114 additions & 12 deletions tests/on_target/tests/test_functional/test_fota.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,22 @@
MFW_VERSION = "mfw_nrf91x1_2.0.4"

APP_BUNDLEID = os.getenv("APP_BUNDLEID")
MCUBOOT_BUNDLEID = os.getenv("MCUBOOT_BUNDLEID")

TEST_APP_BIN = {
"thingy91x": "artifacts/stable_version_jan_2025-update-signed.bin",
"nrf9151dk": "artifacts/nrf9151dk_mar_2025_update_signed.bin"
}

DEVICE_MSG_TIMEOUT = 60 * 5
APP_FOTA_TIMEOUT = 60 * 10
APP_FOTA_TIMEOUT = 60 * 15
BOOTLOADER_FOTA_TIMEOUT = 60 * 20
FULL_MFW_FOTA_TIMEOUT = 60 * 30

BOOTLOADER_VERSION_BASELINE = "2"
BOOTLOADER_VERSION_UPDATED = "3"
BOOTLOADER_FIRMWARE_VERSION_LOG = "Firmware version 3"

def await_nrfcloud(func, expected, field, timeout):
start = time.time()
logger.info(f"Awaiting {field} == {expected} in nrfcloud shadow...")
Expand All @@ -61,6 +67,41 @@ def get_modemversion(dut_fota):
shadow = dut_fota.fota.get_device(dut_fota.device_id)
return shadow["state"]["reported"]["device"]["deviceInfo"]["modemFirmware"]

def get_bootloaderversion(dut_fota):
shadow = dut_fota.fota.get_device(dut_fota.device_id)
return shadow["state"]["reported"]["device"]["deviceInfo"]["bootloaderVersion"]

def await_bootloader_version(dut_fota, expected, timeout=DEVICE_MSG_TIMEOUT):
start = time.time()
logger.info(f"Awaiting bootloaderVersion == {expected} in nrfcloud shadow...")
while True:
time.sleep(5)
if time.time() - start > timeout:
raise RuntimeError(
f"Timeout awaiting bootloaderVersion == {expected}")
try:
version = get_bootloaderversion(dut_fota)
except (KeyError, TypeError) as e:
logger.warning(f"bootloaderVersion not in shadow yet: {e}")
continue
except Exception as e:
logger.warning(f"Exception getting bootloaderVersion: {e}")
continue
logger.debug(f"Reported bootloaderVersion: {version}")
if version == expected:
return

def trigger_fota_poll(dut_fota, max_attempts=3):
for i in range(max_attempts):
try:
time.sleep(10)
dut_fota.uart.write("att_button press 1\r\n")
dut_fota.uart.wait_for_str("nrf_cloud_fota_poll: Starting FOTA download", timeout=30)
return
except AssertionError:
continue
raise AssertionError(f"Fota update not available after {max_attempts} attempts")

def perform_disconnect_reconnect(dut_fota, expected_percentage):
"""Helper function to perform a disconnect/reconnect sequence and verify resumption at expected percentage"""
patterns_lte_offline = ["network: lte_lc_evt_handler: PDN connection network detached"]
Expand Down Expand Up @@ -170,17 +211,7 @@ def _run_fota(bundle_id="", fota_type="app", fotatimeout=APP_FOTA_TIMEOUT, new_v
pytest.skip(f"FOTA create_job REST API error: {e}")
logger.info(f"Created FOTA Job (ID: {dut_fota.data['job_id']})")

# Sleep a bit and trigger fota poll
for i in range(3):
try:
time.sleep(10)
dut_fota.uart.write("att_button press 1\r\n")
dut_fota.uart.wait_for_str("nrf_cloud_fota_poll: Starting FOTA download", timeout=30)
break
except AssertionError:
continue
else:
raise AssertionError(f"Fota update not available after {i} attempts")
trigger_fota_poll(dut_fota)

if reschedule:
run_fota_reschedule(dut_fota, fota_type)
Expand Down Expand Up @@ -284,6 +315,77 @@ def test_app_fota(run_fota_fixture):
bundle_id=APP_BUNDLEID,
)

@pytest.mark.slow
def test_bootloader_fota(dut_fota, hex_file):
'''
Test MCUboot bootloader (B1) FOTA on Thingy:91 X.

Verifies deviceInfo.bootloaderVersion in the nRF Cloud shadow (baseline v2,
v3 after FOTA) and B0 UART "Firmware version 3" after the swap reboot.

The finally block re-flashes baseline merged.hex to restore the DUT.
'''
if os.getenv("DUT_DEVICE_TYPE") != "thingy91x":
pytest.skip("Bootloader FOTA test runs on thingy91x only")
if not MCUBOOT_BUNDLEID:
pytest.skip("MCUBOOT_BUNDLEID environment variable not set")

try:
flash_device(os.path.abspath(hex_file))
dut_fota.uart.xfactoryreset()
dut_fota.uart.flush()
reset_device()

dut_fota.uart.wait_for_str_with_retries(
"Connected to Cloud", max_retries=3, timeout=240, reset_func=reset_device)

await_bootloader_version(dut_fota, BOOTLOADER_VERSION_BASELINE)

try:
dut_fota.data["job_id"] = dut_fota.fota.create_fota_job(
dut_fota.device_id, MCUBOOT_BUNDLEID)
dut_fota.data["bundle_id"] = MCUBOOT_BUNDLEID
except NRFCloudFOTAError as e:
pytest.skip(f"FOTA create_job REST API error: {e}")
logger.info(f"Created bootloader FOTA job (ID: {dut_fota.data['job_id']})")

trigger_fota_poll(dut_fota)

dut_fota.uart.wait_for_str("fota_download: B1 update, selected", timeout=120)
dut_fota.uart.wait_for_str("Download complete", timeout=BOOTLOADER_FOTA_TIMEOUT)
post_download_pos = dut_fota.uart.get_size()

# B1 FOTA: reboot, MCUboot test-swap, then B0 boots slot 1 with new fw_info.
dut_fota.uart.wait_for_str(
BOOTLOADER_FIRMWARE_VERSION_LOG,
timeout=BOOTLOADER_FOTA_TIMEOUT,
start_pos=post_download_pos,
error_msg="Expected B0 fw_info v3 after download",
)

await_nrfcloud(
functools.partial(dut_fota.fota.get_fota_status, dut_fota.data["job_id"]),
"IN_PROGRESS",
"FOTA status",
BOOTLOADER_FOTA_TIMEOUT,
)
await_nrfcloud(
functools.partial(dut_fota.fota.get_fota_status, dut_fota.data["job_id"]),
"COMPLETED",
"FOTA status",
BOOTLOADER_FOTA_TIMEOUT,
)
if not dut_fota.fota.get_fota_completed_executions(dut_fota.data["job_id"]) > 0:
raise AssertionError("Bootloader FOTA job completed but no devices succeeded")

dut_fota.uart.wait_for_str_with_retries(
"Connected to Cloud", max_retries=5, timeout=300, reset_func=reset_device)

await_bootloader_version(dut_fota, BOOTLOADER_VERSION_UPDATED,
timeout=BOOTLOADER_FOTA_TIMEOUT)
finally:
flash_device(os.path.abspath(hex_file))

def test_delta_mfw_fota(run_fota_fixture):
'''
Test delta modem FOTA on nrf9151
Expand Down
Loading