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
63 changes: 53 additions & 10 deletions src/nrfcloud_utils/nrf93_onboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import argparse
import re
import sys
import time
import logging
import requests
import coloredlogs
Expand Down Expand Up @@ -188,22 +189,53 @@ def gen_registration_jwt(cred_if, tenant_id):
logger.debug('Retrieved registration JWT from device')
return jwt_str

def get_bulk_ops_result(api_key, bulk_ops_req_id):
hdr = {'Authorization': 'Bearer ' + api_key}
req = api_url + "bulk-ops-requests/" + bulk_ops_req_id
return requests.get(req, headers=hdr)


def wait_for_onboarding_result(api_key, bulk_ops_req_id, max_attempts=12):
logger.info(f'Waiting for onboarding to complete (bulkOpsRequestId: {bulk_ops_req_id})')
for _ in range(max_attempts):
time.sleep(5)
result = get_bulk_ops_result(api_key, bulk_ops_req_id)
if result.status_code != 200:
logger.error('Failed to fetch onboarding result')
return None
result_json = result.json()
status = result_json.get('status', 'UNKNOWN')
logger.info(f'Onboarding status: {status}')
if status in ('IN_PROGRESS', 'PENDING'):
continue
if status == 'SUCCEEDED':
return result
if status == 'FAILED':
if 'errorSummaryUrl' in result_json:
error_resp = requests.get(result_json['errorSummaryUrl'])
if error_resp.status_code == 200:
logger.error(f'Error details: {error_resp.text}')
return result
return result
logger.error('Timeout waiting for onboarding result')
return None


def onboard_device(api_key, dev_id, sub_type, tags, fw_types, onboarding_token):
hdr = {
'Authorization': 'Bearer ' + api_key,
'Accept': 'application/json',
'content-type': 'text/plain',
}

req = api_url + "devices/" + dev_id
req = api_url + "devices"

payload = {
'onboardingToken': onboarding_token,
'subType': sub_type,
'tags': tags,
'supportedFirmwareTypes': fw_types,
}
tags_str = '|'.join(tags) if isinstance(tags, list) else tags
fw_str = '|'.join(fw_types) if isinstance(fw_types, list) else fw_types
# CSV format: deviceId,subType,tags,supportedFirmwareTypes,certificate,onboardingToken
# certificate left empty; onboardingToken used instead
payload = f'{dev_id},{sub_type},{tags_str},{fw_str},,{onboarding_token}\n'

return requests.post(req, json=payload, headers=hdr)
return requests.post(req, data=payload, headers=hdr)

def main(in_args):
args = parse_args(in_args)
Expand Down Expand Up @@ -273,9 +305,20 @@ def main(in_args):
sub_type = "nRF93M1"
fw_types = ["MODEM"]
onboard_response = onboard_device(args.api_key, dev_id, sub_type, args.tags, fw_types, registration_jwt)
if not onboard_response.ok:
if onboard_response.status_code != 202:
logger.error(f'Failed to onboard device: HTTP {onboard_response.status_code} - {onboard_response.text}')
sys.exit(6)

bulk_req_id = onboard_response.json().get('bulkOpsRequestId')
if not bulk_req_id:
logger.error('No bulkOpsRequestId in onboarding response')
sys.exit(6)

final_result = wait_for_onboarding_result(args.api_key, bulk_req_id)
if final_result is None or final_result.json().get('status') != 'SUCCEEDED':
status = final_result.json().get('status', 'UNKNOWN') if final_result else 'TIMEOUT'
logger.error(f'Onboarding did not succeed: {status}')
sys.exit(6)
logger.info('[OK] Device onboarded successfully')

except KeyboardInterrupt:
Expand Down
62 changes: 60 additions & 2 deletions tests/test_nRF93_onboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,41 @@ def test_tag_too_long_raises(self):
nrf93_onboard._valid_tag("a" * 800)


# ---------------------------------------------------------------------------
# onboard_device
# ---------------------------------------------------------------------------

class TestOnboardDevice:
@patch("nrfcloud_utils.nrf93_onboard.requests.post")
def test_csv_payload_format(self, mock_post):
mock_post.return_value = Mock(status_code=202)
nrf93_onboard.onboard_device(
"my-api-key", TEST_UUID, "nRF93M1", ["tag1", "tag2"], ["MODEM"], TEST_REGJWT
)
_, kwargs = mock_post.call_args
body = kwargs["data"]
assert body == f"{TEST_UUID},nRF93M1,tag1|tag2,MODEM,,{TEST_REGJWT}\n"

@patch("nrfcloud_utils.nrf93_onboard.requests.post")
def test_endpoint_is_devices_not_devices_id(self, mock_post):
mock_post.return_value = Mock(status_code=202)
nrf93_onboard.onboard_device(
"my-api-key", TEST_UUID, "nRF93M1", ["nRF93M1-EK"], ["MODEM"], TEST_REGJWT
)
url = mock_post.call_args[0][0]
assert url.endswith("/devices")
assert TEST_UUID not in url

@patch("nrfcloud_utils.nrf93_onboard.requests.post")
def test_content_type_is_text_plain(self, mock_post):
mock_post.return_value = Mock(status_code=202)
nrf93_onboard.onboard_device(
"my-api-key", TEST_UUID, "nRF93M1", ["nRF93M1-EK"], ["MODEM"], TEST_REGJWT
)
headers = mock_post.call_args[1]["headers"]
assert headers["content-type"] == "text/plain"


# ---------------------------------------------------------------------------
# fetch_tenant_id
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -199,14 +234,19 @@ def test_invalid_json(self, mock_get):

def _base_patches():
"""Return attribute-name keyed dict for use with patch.multiple(MODULE, ...)."""
onboard_resp = Mock(status_code=202)
onboard_resp.json.return_value = {'bulkOpsRequestId': 'test-bulk-id'}
wait_result = Mock()
wait_result.json.return_value = {'status': 'SUCCEEDED'}
return {
"Comms": MagicMock(),
"ATCommandInterface": MagicMock(),
"get_nrf93m1_uuid": Mock(return_value=TEST_UUID),
"get_nrf93m1_identity_key": Mock(return_value=TEST_IDENTITY_KEY),
"fetch_tenant_id": Mock(return_value=TEST_TENANT_ID),
"gen_registration_jwt": Mock(return_value=TEST_REGJWT),
"onboard_device": Mock(return_value=Mock(ok=True)),
"onboard_device": Mock(return_value=onboard_resp),
"wait_for_onboarding_result": Mock(return_value=wait_result),
}


Expand Down Expand Up @@ -266,7 +306,25 @@ def test_jwt_fails_exit_5(self):

def test_onboard_http_error_exit_6(self):
patches = _base_patches()
patches["onboard_device"] = Mock(return_value=Mock(ok=False, status_code=403, text="Forbidden"))
patches["onboard_device"] = Mock(return_value=Mock(status_code=400, text="Bad Request"))
with pytest.raises(SystemExit) as exc:
_run_main_with_patches(patches)
assert exc.value.code == 6

def test_onboard_no_bulk_id_exit_6(self):
patches = _base_patches()
resp = Mock(status_code=202)
resp.json.return_value = {}
patches["onboard_device"] = Mock(return_value=resp)
with pytest.raises(SystemExit) as exc:
_run_main_with_patches(patches)
assert exc.value.code == 6

def test_onboard_polling_failed_exit_6(self):
patches = _base_patches()
failed_result = Mock()
failed_result.json.return_value = {'status': 'FAILED'}
patches["wait_for_onboarding_result"] = Mock(return_value=failed_result)
with pytest.raises(SystemExit) as exc:
_run_main_with_patches(patches)
assert exc.value.code == 6
Expand Down
Loading