Skip to content

Commit 73c7a19

Browse files
committed
Include nRF93M1 device onboarding to nRF Cloud
Signed-off-by: Pascal Hernandez <pascal.hernandez@nordicsemi.no>
1 parent 6e6effb commit 73c7a19

4 files changed

Lines changed: 596 additions & 0 deletions

File tree

ADVANCED.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ These Python scripts are designed to assist users in provisioning devices with t
88
- [Create CA Cert](#create-ca-cert)
99
- [Device Credentials Installer](#device-credentials-installer)
1010
- [nRF Cloud Device Onboarding](#nrf-cloud-device-onboarding)
11+
- [nRF93M1 Device Onboarding](#nrf93m1-device-onboarding)
1112
- [Modem Credentials Parser](#modem-credentials-parser)
1213
- [Create Device Credentials](#create-device-credentials)
1314
- [Claim and Provision Device](#claim-and-provision-device)
@@ -89,6 +90,17 @@ nrf_cloud_onboard --api-key $API_KEY --csv onboard.csv
8990

9091
If the `--res` parameter is used, the onboarding result information will be saved to the specified file instead of printed to the output.
9192

93+
## nRF93M1 Device Onboarding
94+
95+
The `nrf93_onboard` script onboards an nRF93M1 device to nRF Cloud using a registration token JWT generated directly on the device. It connects over serial, retrieves the device UUID and identity key via AT commands, fetches the tenant ID from the nRF Cloud account, and then calls `AT%REGJWT` on the device to produce the JWT used as the onboarding token.
96+
97+
Your nRF Cloud REST API key is required and can be found on your [User Account page](https://app.nrfcloud.com/#/account).
98+
99+
### Example
100+
```
101+
nrf93_onboard --port /dev/ttyACM0 --api-key $API_KEY
102+
```
103+
92104
## Modem Credentials Parser
93105

94106
The script above, `device_credentials_installer` makes use of this script, `modem_credentials_parser`, so if you use the former, you do not need to also follow the directions below. If `device_credentials_installer` does not meet your needs, you can use `modem_credentials_parser` directly to take advantage of additional options.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ gather_attestation_tokens = "nrfcloud_utils.gather_attestation_tokens:run"
4242
modem_credentials_parser = "nrfcloud_utils.modem_credentials_parser:run"
4343
nrf_cloud_device_mgmt = "nrfcloud_utils.nrf_cloud_device_mgmt:run"
4444
nrf_cloud_onboard = "nrfcloud_utils.nrf_cloud_onboard:run"
45+
nrf93_onboard = "nrfcloud_utils.nRF93_onboard:run"
4546

4647
[tool.pytest.ini_options]
4748
addopts = ["--import-mode=importlib"]
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
#!/usr/bin/env python3
2+
#
3+
# Copyright (c) 2026 Nordic Semiconductor ASA
4+
#
5+
# SPDX-License-Identifier: BSD-3-Clause
6+
7+
import argparse
8+
import csv
9+
import re
10+
import sys
11+
import logging
12+
import tempfile
13+
import os
14+
import time
15+
import requests
16+
import coloredlogs
17+
from nrfcredstore.comms import Comms
18+
from nrfcredstore.command_interface import ATCommandInterface
19+
20+
logger = logging.getLogger(__name__)
21+
22+
_TAG_PATTERN = re.compile(r'^[a-zA-Z0-9_.,@\/:#-]{0,799}$')
23+
24+
def _valid_tag(value):
25+
if not _TAG_PATTERN.match(value):
26+
raise argparse.ArgumentTypeError(
27+
f"Invalid tag {value!r}. Must match /[a-zA-Z0-9_.,@\\/:#-]{{0,799}}/"
28+
)
29+
return value
30+
31+
DEV_STAGE_DICT = {'dev': '.dev.',
32+
'prod': '.',
33+
'': '.'}
34+
dev_stage_key = 'prod'
35+
36+
API_URL_START = 'https://api'
37+
API_URL_END = 'nrfcloud.com/v1/'
38+
api_url = API_URL_START + DEV_STAGE_DICT[dev_stage_key] + API_URL_END
39+
40+
def get_nrf93m1_uuid(cred_if):
41+
"""Get or create device UUID for nRF93M1."""
42+
result = cred_if.at_command('AT%DEVICEUUID', wait_for_result=False)
43+
if not result:
44+
logger.error('Failed to send AT%DEVICEUUID command')
45+
return None
46+
47+
# Expect response like: %DEVICEUUID: 988234bd-a066-a101-656e-684d6f5adad6
48+
retval, output = cred_if.comms.expect_response("OK", "ERROR", "%DEVICEUUID:")
49+
if not retval:
50+
logger.error('Failed to get device UUID from nRF93M1')
51+
return None
52+
53+
# Parse UUID from output
54+
lines = [line.strip() for line in output.split("\n") if "%DEVICEUUID:" in line]
55+
if not lines:
56+
logger.error('UUID not found in response')
57+
return None
58+
59+
# Extract UUID (format: "%DEVICEUUID: <uuid>" or "%DEVICEUUID: creating device uuid...")
60+
uuid_line = lines[-1] # Get the last line with UUID
61+
if ':' in uuid_line:
62+
uuid_str = uuid_line.split(':', 1)[1].strip()
63+
# Check if it's not a status message
64+
if 'creating' not in uuid_str.lower() and len(uuid_str) > 30:
65+
logger.debug(f'Retrieved device UUID: {uuid_str}')
66+
return uuid_str
67+
68+
logger.error('Failed to parse UUID from response')
69+
return None
70+
71+
def get_nrf93m1_identity_key(cred_if):
72+
"""Get or create identity key for nRF93M1."""
73+
result = cred_if.at_command('AT%CLOUDACCESSKEY', wait_for_result=False)
74+
if not result:
75+
logger.error('Failed to send AT%CLOUDACCESSKEY command')
76+
return None
77+
78+
# Expect response like: %CLOUDACCESSKEY: MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...
79+
retval, output = cred_if.comms.expect_response("OK", "ERROR", "%CLOUDACCESSKEY:")
80+
if not retval:
81+
logger.error('Failed to get identity key from nRF93M1')
82+
return None
83+
84+
# Parse identity key from output
85+
lines = [line.strip() for line in output.split("\n") if "%CLOUDACCESSKEY:" in line]
86+
if not lines:
87+
logger.error('Identity key not found in response')
88+
return None
89+
90+
# Extract identity key (format: "%CLOUDACCESSKEY: <base64_key>")
91+
identity_key_line = lines[-1] # Get the last line with key
92+
if ':' in identity_key_line:
93+
identity_key_str = identity_key_line.split(':', 1)[1].strip()
94+
# Check if it's not a status message
95+
if 'creating' not in identity_key_str.lower() and len(identity_key_str) > 50:
96+
logger.debug(f'Retrieved device identity key: {identity_key_str}')
97+
return identity_key_str
98+
99+
logger.error('Failed to parse identity key from response')
100+
return None
101+
102+
def parse_args(in_args):
103+
parser = argparse.ArgumentParser(
104+
description="nRF93M1 - Onboard Device using Registration token JWT",
105+
formatter_class=argparse.ArgumentDefaultsHelpFormatter
106+
)
107+
parser.add_argument("--port", type=str, required=True,
108+
help="Serial port for the nRF93M1 device (e.g., /dev/ttyACM0 or COM3)")
109+
parser.add_argument("--baudrate", type=int, help="Serial baudrate", default=115200)
110+
parser.add_argument("--api-key", type=str, required=True,
111+
help="nRF Cloud API key", default="")
112+
parser.add_argument('--log-level', default='info',
113+
choices=['debug', 'info', 'warning', 'error', 'critical'],
114+
help='Set the logging level')
115+
parser.add_argument("--stage", type=str,
116+
choices=['prod', 'dev'],
117+
help="For internal (Nordic) use only", default="")
118+
parser.add_argument("--tags", type=_valid_tag, nargs="+", default=["nRF93M1-EK"],
119+
metavar="TAG",
120+
help="Tags to assign to the device on nRF Cloud. "
121+
"Each tag must match /[a-zA-Z0-9_.,@\\/:#-]{0,799}/")
122+
123+
args = parser.parse_args(in_args)
124+
125+
# Setup logging
126+
if hasattr(args, 'log_level'):
127+
coloredlogs.install(level=args.log_level.upper(), fmt='%(levelname)-8s %(message)s')
128+
else:
129+
coloredlogs.install(level='INFO', fmt='%(levelname)-8s %(message)s')
130+
131+
return args
132+
133+
def set_dev_stage(stage = ''):
134+
global api_url
135+
global dev_stage_key
136+
137+
if stage in DEV_STAGE_DICT.keys():
138+
dev_stage_key = stage
139+
api_url = '{}{}{}'.format(API_URL_START, DEV_STAGE_DICT[dev_stage_key], API_URL_END)
140+
else:
141+
logger.error('Invalid stage')
142+
143+
return api_url
144+
145+
def fetch_tenantId(api_key):
146+
hdr = {'Authorization': 'Bearer ' + api_key}
147+
req = api_url + "account"
148+
response = requests.get(req, headers=hdr)
149+
if not response.ok:
150+
logger.error(f'Failed to fetch tenant ID: HTTP {response.status_code}')
151+
return None
152+
153+
try:
154+
account_info = response.json()
155+
except ValueError:
156+
logger.error('Failed to parse account response JSON')
157+
return None
158+
159+
tenant_id = account_info.get('team', {}).get('tenantId')
160+
if not tenant_id:
161+
logger.error('tenantId not found in account response')
162+
return None
163+
164+
return tenant_id
165+
166+
def gen_registration_jwt(cred_if, tenant_id):
167+
result = cred_if.at_command(f'AT%REGJWT="{tenant_id}"', wait_for_result=False)
168+
if not result:
169+
logger.error('Failed to send AT%REGJWT command')
170+
return None
171+
172+
retval, output = cred_if.comms.expect_response("OK", "ERROR", "%REGJWT:")
173+
if not retval:
174+
logger.error('Failed to get registration JWT from nRF93M1')
175+
return None
176+
177+
lines = [line.strip() for line in output.split("\n") if line.strip().startswith("%REGJWT:")]
178+
if not lines:
179+
logger.error('Registration JWT not found in response')
180+
return None
181+
182+
jwt_str = lines[-1].split(':', 1)[1].strip()
183+
if not jwt_str:
184+
logger.error('Registration JWT is empty')
185+
return None
186+
187+
logger.debug('Retrieved registration JWT from device')
188+
return jwt_str
189+
190+
def onboard_device(api_key, dev_id, sub_type, tags, fw_types, onboardingToken):
191+
hdr = {
192+
'Authorization': 'Bearer ' + api_key,
193+
'Accept': 'application/json',
194+
}
195+
196+
req = api_url + "devices/" + dev_id
197+
198+
payload = {
199+
'onboardingToken': onboardingToken,
200+
'subType': sub_type,
201+
'tags': tags,
202+
'supportedFirmwareTypes': fw_types,
203+
}
204+
205+
return requests.post(req, json=payload, headers=hdr)
206+
207+
def main(in_args):
208+
args = parse_args(in_args)
209+
210+
if args.stage:
211+
set_dev_stage(args.stage)
212+
213+
logger.info('nRF93M1 - Onboard Device using Registration token JWT')
214+
logger.info(f'Connecting to device on {args.port}...')
215+
216+
# Initialize serial communication
217+
try:
218+
serial_interface = Comms(
219+
port=args.port,
220+
baudrate=args.baudrate,
221+
)
222+
except Exception as e:
223+
logger.error(f'Failed to open serial port: {e}')
224+
sys.exit(1)
225+
226+
# Create AT command interface
227+
cred_if = ATCommandInterface(serial_interface)
228+
229+
try:
230+
# Verify device is responsive
231+
logger.info('Checking device connectivity...')
232+
resp = cred_if.at_command('AT', wait_for_result=True)
233+
if not resp:
234+
logger.error('No response from device. Check connection and try again.')
235+
sys.exit(1)
236+
logger.info('Device is responsive')
237+
238+
# Get device UUID
239+
logger.info('Retrieving device UUID...')
240+
dev_id = get_nrf93m1_uuid(cred_if)
241+
if not dev_id:
242+
logger.error('[Failed] Device UUID')
243+
sys.exit(2)
244+
logger.info('[OK] Device UUID')
245+
246+
# Get identity key
247+
logger.info('Retrieving identity key...')
248+
identity_key_base64 = get_nrf93m1_identity_key(cred_if)
249+
if not identity_key_base64:
250+
logger.error('[Failed] Device identity key')
251+
sys.exit(3)
252+
logger.info('[OK] Device identity key')
253+
254+
# Based on the stage specified, we query the tenantID from nRF Cloud using the user's API key.
255+
logger.info('Retrieving tenant ID from nRF Cloud account...')
256+
tenant_id = fetch_tenantId(args.api_key)
257+
if not tenant_id:
258+
logger.error('[Failed] Tenant ID')
259+
sys.exit(4)
260+
logger.info(f'[OK] Tenant ID')
261+
262+
# Generate the registration JWT on the device using the tenantId
263+
logger.info('Generating registration JWT...')
264+
registration_jwt = gen_registration_jwt(cred_if, tenant_id)
265+
if not registration_jwt:
266+
logger.error('[Failed] Registration JWT')
267+
sys.exit(5)
268+
logger.info('[OK] Registration JWT')
269+
270+
# Onboard the device
271+
logger.info('Onboarding device to nRF Cloud...')
272+
sub_type = "nRF93M1"
273+
fw_types = ["MODEM"]
274+
onboard_response = onboard_device(args.api_key, dev_id, sub_type, args.tags, fw_types, registration_jwt)
275+
if not onboard_response.ok:
276+
logger.error(f'Failed to onboard device: HTTP {onboard_response.status_code} - {onboard_response.text}')
277+
sys.exit(6)
278+
logger.info('[OK] Device onboarded successfully')
279+
280+
except KeyboardInterrupt:
281+
logger.warning('Interrupted by user')
282+
sys.exit(130)
283+
except Exception as e:
284+
logger.error(f'Unexpected error: {e}', exc_info=True)
285+
sys.exit(99)
286+
287+
def run():
288+
main(sys.argv[1:])
289+
290+
291+
if __name__ == '__main__':
292+
run()

0 commit comments

Comments
 (0)