Skip to content
Merged
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 devices.'
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 verbatim 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 verbatim 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
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
120 changes: 108 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,14 +27,20 @@
MFW_VERSION = "mfw_nrf91x1_2.0.4"

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

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

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

def await_nrfcloud(func, expected, field, timeout):
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,71 @@ 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
'''
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()

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