Skip to content

Commit 8026f33

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

4 files changed

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

0 commit comments

Comments
 (0)