nrfcloud-utils 3.0.12__tar.gz → 3.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. {nrfcloud_utils-3.0.12 → nrfcloud_utils-3.2.0}/PKG-INFO +11 -12
  2. {nrfcloud_utils-3.0.12 → nrfcloud_utils-3.2.0}/pyproject.toml +34 -23
  3. {nrfcloud_utils-3.0.12 → nrfcloud_utils-3.2.0}/src/nrfcloud_utils/claim_and_provision_device.py +14 -11
  4. {nrfcloud_utils-3.0.12 → nrfcloud_utils-3.2.0}/src/nrfcloud_utils/claim_devices.py +11 -8
  5. nrfcloud_utils-3.2.0/src/nrfcloud_utils/cli.py +74 -0
  6. {nrfcloud_utils-3.0.12 → nrfcloud_utils-3.2.0}/src/nrfcloud_utils/cli_helpers.py +35 -1
  7. {nrfcloud_utils-3.0.12 → nrfcloud_utils-3.2.0}/src/nrfcloud_utils/create_ca_cert.py +11 -7
  8. {nrfcloud_utils-3.0.12 → nrfcloud_utils-3.2.0}/src/nrfcloud_utils/create_device_credentials.py +11 -12
  9. {nrfcloud_utils-3.0.12 → nrfcloud_utils-3.2.0}/src/nrfcloud_utils/create_proxy_jwt.py +11 -6
  10. {nrfcloud_utils-3.0.12 → nrfcloud_utils-3.2.0}/src/nrfcloud_utils/device_credentials_installer.py +47 -16
  11. {nrfcloud_utils-3.0.12 → nrfcloud_utils-3.2.0}/src/nrfcloud_utils/gather_attestation_tokens.py +12 -9
  12. {nrfcloud_utils-3.0.12 → nrfcloud_utils-3.2.0}/src/nrfcloud_utils/modem_credentials_parser.py +139 -27
  13. nrfcloud_utils-3.2.0/src/nrfcloud_utils/nrf93_onboard.py +293 -0
  14. {nrfcloud_utils-3.0.12 → nrfcloud_utils-3.2.0}/src/nrfcloud_utils/nrf_cloud_device_mgmt.py +7 -2
  15. {nrfcloud_utils-3.0.12 → nrfcloud_utils-3.2.0}/src/nrfcloud_utils/nrf_cloud_diap.py +1 -1
  16. {nrfcloud_utils-3.0.12 → nrfcloud_utils-3.2.0}/src/nrfcloud_utils/nrf_cloud_onboard.py +12 -12
  17. {nrfcloud_utils-3.0.12 → nrfcloud_utils-3.2.0}/LICENSE.txt +0 -0
  18. {nrfcloud_utils-3.0.12 → nrfcloud_utils-3.2.0}/README.md +0 -0
  19. {nrfcloud_utils-3.0.12 → nrfcloud_utils-3.2.0}/src/nrfcloud_utils/__init__.py +0 -0
  20. {nrfcloud_utils-3.0.12 → nrfcloud_utils-3.2.0}/src/nrfcloud_utils/ca_certs.py +0 -0
@@ -1,27 +1,26 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nrfcloud-utils
3
- Version: 3.0.12
3
+ Version: 3.2.0
4
4
  Summary: Scripts and utilities for working with the nRF Cloud
5
- License: BSD-3-Clause
5
+ License-Expression: BSD-3-Clause
6
6
  License-File: LICENSE.txt
7
7
  Author: Nordic Semiconductor ASA
8
8
  Requires-Python: >=3.10,<4.0
9
- Classifier: License :: OSI Approved :: BSD License
10
9
  Classifier: Programming Language :: Python :: 3
11
10
  Classifier: Programming Language :: Python :: 3.10
12
11
  Classifier: Programming Language :: Python :: 3.11
13
12
  Classifier: Programming Language :: Python :: 3.12
14
13
  Classifier: Programming Language :: Python :: 3.13
15
14
  Classifier: Programming Language :: Python :: 3.14
16
- Requires-Dist: cbor2 (>=5.4.2.post1,<6.0.0)
17
- Requires-Dist: cffi (>=2.0.0,<3.0.0)
18
- Requires-Dist: cryptography (>=46.0.5,<47.0.0)
19
- Requires-Dist: inquirer (>=3.4.0,<4.0.0)
20
- Requires-Dist: nrfcredstore (>=2.0.9,<3.0.0)
21
- Requires-Dist: pyjwt (>=2.3.0,<3.0.0)
22
- Requires-Dist: requests (>=2.32.0,<3.0.0)
23
- Requires-Dist: semver (>=3.0.0,<4.0.0)
24
- Requires-Dist: urllib3 (>=2.2.2,<3.0.0)
15
+ Requires-Dist: cbor2 (>=5.4.2.post1)
16
+ Requires-Dist: cffi (>=2.0.0)
17
+ Requires-Dist: cryptography (>=46.0.5)
18
+ Requires-Dist: inquirer (>=3.4.0)
19
+ Requires-Dist: nrfcredstore (>=2.1.0)
20
+ Requires-Dist: pyjwt (>=2.3.0)
21
+ Requires-Dist: requests (>=2.32.0)
22
+ Requires-Dist: semver (>=3.0.0)
23
+ Requires-Dist: urllib3 (>=2.7.0)
25
24
  Project-URL: Repository, https://github.com/nRFCloud/utils
26
25
  Description-Content-Type: text/markdown
27
26
 
@@ -2,36 +2,33 @@
2
2
  requires = ["poetry-core"]
3
3
  build-backend = "poetry.core.masonry.api"
4
4
 
5
- [tool.poetry]
5
+ [project]
6
6
  name = "nrfcloud-utils"
7
- version = "3.0.12"
7
+ version = "3.2.0"
8
8
  description = "Scripts and utilities for working with the nRF Cloud"
9
- authors = ["Nordic Semiconductor ASA"]
10
9
  license = "BSD-3-Clause"
11
10
  readme = "README.md"
12
- repository = "https://github.com/nRFCloud/utils"
13
- packages = [
14
- { include = "nrfcloud_utils", from = "src" }
11
+ requires-python = ">=3.10,<4.0"
12
+ authors = [
13
+ { name = "Nordic Semiconductor ASA" }
14
+ ]
15
+ dependencies = [
16
+ "cbor2>=5.4.2.post1",
17
+ "cryptography>=46.0.5",
18
+ "cffi>=2.0.0",
19
+ "requests>=2.32.0",
20
+ "urllib3>=2.7.0",
21
+ "semver>=3.0.0",
22
+ "pyjwt>=2.3.0",
23
+ "inquirer>=3.4.0",
24
+ "nrfcredstore>=2.1.0",
15
25
  ]
16
26
 
17
- [tool.poetry.dependencies]
18
- python = "^3.10"
19
- cbor2 = "^5.4.2.post1"
20
- cryptography = "^46.0.5"
21
- cffi = "^2.0.0"
22
- requests = "^2.32.0"
23
- urllib3 = "^2.2.2"
24
- semver = "^3.0.0"
25
- pyjwt = "^2.3.0"
26
- inquirer = "^3.4.0"
27
- nrfcredstore = "^2.0.9"
28
-
29
- [tool.poetry.group.dev.dependencies]
30
- pytest = "^7.3.1"
31
- pytest-cov = "^4.0.0"
32
- pytest-watch = "^4.2.0"
27
+ [project.urls]
28
+ repository = "https://github.com/nRFCloud/utils"
33
29
 
34
- [tool.poetry.scripts]
30
+ [project.scripts]
31
+ nrfcloud-utils = "nrfcloud_utils.cli:run"
35
32
  claim_and_provision_device = "nrfcloud_utils.claim_and_provision_device:run"
36
33
  claim_devices = "nrfcloud_utils.claim_devices:run"
37
34
  create_ca_cert = "nrfcloud_utils.create_ca_cert:run"
@@ -42,6 +39,20 @@ gather_attestation_tokens = "nrfcloud_utils.gather_attestation_tokens:run"
42
39
  modem_credentials_parser = "nrfcloud_utils.modem_credentials_parser:run"
43
40
  nrf_cloud_device_mgmt = "nrfcloud_utils.nrf_cloud_device_mgmt:run"
44
41
  nrf_cloud_onboard = "nrfcloud_utils.nrf_cloud_onboard:run"
42
+ nrf93_onboard = "nrfcloud_utils.nrf93_onboard:run"
43
+
44
+ [dependency-groups]
45
+ dev = [
46
+ "pytest>=7.3.1",
47
+ "pytest-cov>=4.0.0",
48
+ "pytest-watch>=4.2.0",
49
+ "poetry (>=2.4.1,<3.0.0)", # used for development today. might be migrated to uv in the future.
50
+ ]
51
+
52
+ [tool.poetry]
53
+ packages = [
54
+ { include = "nrfcloud_utils", from = "src" }
55
+ ]
45
56
 
46
57
  [tool.pytest.ini_options]
47
58
  addopts = ["--import-mode=importlib"]
@@ -7,7 +7,7 @@ import sys
7
7
  import time
8
8
  import json
9
9
  import argparse
10
- import coloredlogs, logging
10
+ import logging
11
11
  from nrfcloud_utils import (
12
12
  ca_certs,
13
13
  nrf_cloud_onboard,
@@ -15,8 +15,8 @@ from nrfcloud_utils import (
15
15
  nrf_cloud_diap,
16
16
  create_device_credentials
17
17
  )
18
- from nrfcloud_utils.cli_helpers import is_linux, is_windows, is_macos
19
- from nrfcloud_utils.cli_helpers import CMD_TERM_DICT, CMD_TYPE_AUTO, CMD_TYPE_AT, CMD_TYPE_AT_SHELL, CMD_TYPE_TLS_SHELL, parser_add_comms_args
18
+ from nrfcloud_utils.cli_helpers import setup_logging
19
+ from nrfcloud_utils.cli_helpers import CMD_TERM_DICT, CMD_TYPE_AUTO, CMD_TYPE_AT, CMD_TYPE_AT_SHELL, parser_add_comms_args
20
20
  from cryptography import x509
21
21
  from cryptography.hazmat.primitives import serialization
22
22
  from nrfcredstore.comms import Comms
@@ -27,9 +27,10 @@ logger = logging.getLogger(__name__)
27
27
  IMEI_LEN = 15
28
28
  DEV_ID_MAX_LEN = 64
29
29
 
30
- def parse_args(in_args):
30
+ def get_parser():
31
31
  parser = argparse.ArgumentParser(description="nRF Cloud Claim and Provision",
32
- formatter_class=argparse.ArgumentDefaultsHelpFormatter)
32
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
33
+ add_help=False)
33
34
  parser_add_comms_args(parser)
34
35
  parser.add_argument("--dv", type=int, help="Number of days cert is valid",
35
36
  default=(10 * 365))
@@ -81,11 +82,13 @@ def parse_args(in_args):
81
82
  choices=['debug', 'info', 'warning', 'error', 'critical'],
82
83
  help='Set the logging level'
83
84
  )
85
+ return parser
86
+
87
+ def parse_args(in_args):
88
+ _p = get_parser()
89
+ parser = argparse.ArgumentParser(parents=[_p], description=_p.description, formatter_class=_p.formatter_class)
84
90
  args = parser.parse_args(in_args)
85
- if args.plain:
86
- logging.basicConfig(level=args.log_level.upper())
87
- else:
88
- coloredlogs.install(level=args.log_level.upper(), fmt='%(levelname)-8s %(message)s')
91
+ setup_logging(level=args.log_level, use_color=not args.plain)
89
92
  return args
90
93
 
91
94
  def error_exit(err_msg):
@@ -132,7 +135,7 @@ def main(in_args):
132
135
  error_exit(f'Device ID must not exceed {DEV_ID_MAX_LEN} characters')
133
136
 
134
137
  if 'CN=' in args.csr_attr:
135
- error_exit(f'Do not include CN in --csr_attr. The device ID will be used as the CN')
138
+ error_exit('Do not include CN in --csr_attr. The device ID will be used as the CN')
136
139
 
137
140
  # load local CA cert and key if needed; assume not needed if using provisioning tags
138
141
  if args.provisioning_tags is None:
@@ -195,7 +198,7 @@ def main(in_args):
195
198
  logger.info(f'Unclaiming device {dev_uuid}...')
196
199
  api_res = nrf_cloud_diap.unclaim_device(args.api_key, dev_uuid)
197
200
  if api_res.status_code == 204:
198
- logger.info(f'...success')
201
+ logger.info('...success')
199
202
  else:
200
203
  nrf_cloud_diap.print_api_result("Unclaim device response", api_res)
201
204
  logger.info("Device may not have been claimed before, continuing...")
@@ -7,9 +7,9 @@ import io
7
7
  import sys
8
8
  import csv
9
9
  import argparse
10
- import coloredlogs, logging
10
+ import logging
11
11
  from nrfcloud_utils import nrf_cloud_diap
12
- from nrfcloud_utils.cli_helpers import is_linux, is_windows, is_macos
12
+ from nrfcloud_utils.cli_helpers import setup_logging
13
13
 
14
14
  logger = logging.getLogger(__name__)
15
15
 
@@ -17,9 +17,10 @@ args = None
17
17
  IMEI_LEN = 15
18
18
  MAX_CSV_ROWS = 1000
19
19
 
20
- def parse_args(in_args):
20
+ def get_parser():
21
21
  parser = argparse.ArgumentParser(description="nRF Cloud Claim Devices",
22
- formatter_class=argparse.ArgumentDefaultsHelpFormatter)
22
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
23
+ add_help=False)
23
24
 
24
25
  parser.add_argument("--csv", type=str,
25
26
  help="Filepath to attestation token CSV file",
@@ -39,11 +40,13 @@ def parse_args(in_args):
39
40
  choices=['debug', 'info', 'warning', 'error', 'critical'],
40
41
  help='Set the logging level'
41
42
  )
43
+ return parser
44
+
45
+ def parse_args(in_args):
46
+ _p = get_parser()
47
+ parser = argparse.ArgumentParser(parents=[_p], description=_p.description, formatter_class=_p.formatter_class)
42
48
  args = parser.parse_args(in_args)
43
- if args.plain:
44
- logging.basicConfig(level=args.log_level.upper())
45
- else:
46
- coloredlogs.install(level=args.log_level.upper(), fmt='%(levelname)-8s %(message)s')
49
+ setup_logging(level=args.log_level, use_color=not args.plain)
47
50
  return args
48
51
 
49
52
  def bulk_claim(api_key, array_of_claims):
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env python3
2
+ #
3
+ # Copyright (c) 2025 Nordic Semiconductor ASA
4
+ #
5
+ # SPDX-License-Identifier: BSD-3-Clause
6
+ import argparse
7
+ import sys
8
+
9
+ from nrfcloud_utils import (
10
+ claim_and_provision_device,
11
+ claim_devices,
12
+ create_ca_cert,
13
+ create_device_credentials,
14
+ create_proxy_jwt,
15
+ device_credentials_installer,
16
+ gather_attestation_tokens,
17
+ modem_credentials_parser,
18
+ nrf_cloud_device_mgmt,
19
+ nrf_cloud_onboard,
20
+ nrf93_onboard,
21
+ )
22
+
23
+ _MODULES = {
24
+ "claim_and_provision_device": claim_and_provision_device,
25
+ "claim_devices": claim_devices,
26
+ "create_ca_cert": create_ca_cert,
27
+ "create_device_credentials": create_device_credentials,
28
+ "create_proxy_jwt": create_proxy_jwt,
29
+ "device_credentials_installer": device_credentials_installer,
30
+ "gather_attestation_tokens": gather_attestation_tokens,
31
+ "modem_credentials_parser": modem_credentials_parser,
32
+ "nrf_cloud_device_mgmt": nrf_cloud_device_mgmt,
33
+ "nrf_cloud_onboard": nrf_cloud_onboard,
34
+ "nrf93_onboard": nrf93_onboard,
35
+ }
36
+
37
+
38
+ def _build_parser():
39
+ parser = argparse.ArgumentParser(
40
+ prog="nrfcloud-utils",
41
+ description="Scripts and utilities for working with nRF Cloud",
42
+ )
43
+ subparsers = parser.add_subparsers(dest="command", metavar="command", required=False)
44
+ for name, mod in _MODULES.items():
45
+ _p = mod.get_parser()
46
+ subparsers.add_parser(
47
+ name,
48
+ parents=[_p],
49
+ description=_p.description,
50
+ formatter_class=_p.formatter_class,
51
+ help=_p.description,
52
+ )
53
+ return parser
54
+
55
+
56
+ def run():
57
+ parser = _build_parser()
58
+ # parse_args() handles --help at both levels and validates the subcommand name
59
+ # and its args (using each module's get_parser() for the subparser definition).
60
+ # We then re-dispatch to the module's own main() so setup_logging and other
61
+ # module init runs exactly as it does when the command is called standalone.
62
+ args = parser.parse_args()
63
+
64
+ if not args.command:
65
+ parser.print_help()
66
+ sys.exit(0)
67
+
68
+ cmd = args.command
69
+ sys.argv = [cmd] + sys.argv[2:]
70
+ _MODULES[cmd].main(sys.argv[1:])
71
+
72
+
73
+ if __name__ == "__main__":
74
+ run()
@@ -10,6 +10,7 @@ import platform
10
10
  import os
11
11
  import csv
12
12
  import logging
13
+ import coloredlogs
13
14
 
14
15
  MAX_CSV_ROWS = 1000
15
16
 
@@ -20,6 +21,39 @@ full_encoding = 'mbcs' if is_windows else 'ascii'
20
21
 
21
22
  logger = logging.getLogger(__name__)
22
23
 
24
+ def setup_logging(level='info', fmt='%(levelname)-8s %(message)s', use_color=True):
25
+ """
26
+ Configure colored logging with dark-terminal-friendly colors.
27
+
28
+ Args:
29
+ level: logging level as string (debug, info, warning, error, critical)
30
+ fmt: log format string
31
+ use_color: whether to use colored output (False for --plain mode)
32
+ """
33
+ log_level = getattr(logging, level.upper(), logging.INFO)
34
+
35
+ if use_color:
36
+ # Define colors for dark terminals with better contrast
37
+ level_styles = {
38
+ 'debug': {'color': 'cyan'},
39
+ 'info': {'color': 'green'},
40
+ 'warning': {'color': 'yellow'},
41
+ 'error': {'color': 'red'},
42
+ 'critical': {'color': 'red', 'bold': True},
43
+ }
44
+ field_styles = {
45
+ 'asctime': {'color': 'white'},
46
+ 'levelname': {'color': 'white', 'bold': True},
47
+ }
48
+ coloredlogs.install(
49
+ level=log_level,
50
+ fmt=fmt,
51
+ level_styles=level_styles,
52
+ field_styles=field_styles
53
+ )
54
+ else:
55
+ logging.basicConfig(level=log_level, format=fmt)
56
+
23
57
  def write_file(pathname, filename, bytes):
24
58
  """
25
59
  save bytes to file
@@ -28,7 +62,7 @@ def write_file(pathname, filename, bytes):
28
62
  if not path.isdir(pathname):
29
63
  try:
30
64
  makedirs(pathname, exist_ok=True)
31
- except OSError as e:
65
+ except OSError:
32
66
  raise RuntimeError(f"Error creating file path [{pathname}]")
33
67
 
34
68
  full_path = path.join(pathname, filename)
@@ -22,13 +22,14 @@ from cryptography.x509 import (
22
22
  SubjectKeyIdentifier,
23
23
  )
24
24
 
25
- from nrfcloud_utils.cli_helpers import write_file
26
- import coloredlogs, logging
25
+ from nrfcloud_utils.cli_helpers import write_file, setup_logging
26
+ import logging
27
27
 
28
28
  logger = logging.getLogger(__name__)
29
29
 
30
- def parse_args(in_args):
31
- parser = argparse.ArgumentParser(description="Create CA Certificate")
30
+ def get_parser():
31
+ parser = argparse.ArgumentParser(description="Create CA Certificate",
32
+ add_help=False)
32
33
  parser.add_argument("-c", type=str, help="2 character country code", default="NO")
33
34
  parser.add_argument("--st", type=str, help="State or Province", default="")
34
35
  parser.add_argument("-l", type=str, help="Locality", default="")
@@ -50,10 +51,13 @@ def parse_args(in_args):
50
51
  choices=['debug', 'info', 'warning', 'error', 'critical'],
51
52
  help='Set the logging level'
52
53
  )
54
+ return parser
55
+
56
+ def parse_args(in_args):
57
+ _p = get_parser()
58
+ parser = argparse.ArgumentParser(parents=[_p], description=_p.description, formatter_class=_p.formatter_class)
53
59
  args = parser.parse_args(in_args)
54
- level = getattr(logging, args.log_level.upper(), logging.INFO)
55
- fmt = '%(levelname)-8s %(message)s'
56
- coloredlogs.install(level=level, fmt=fmt)
60
+ setup_logging(level=args.log_level)
57
61
  return args
58
62
 
59
63
 
@@ -15,22 +15,18 @@ from cryptography.x509.oid import NameOID
15
15
  from cryptography import x509
16
16
  import uuid
17
17
  from cryptography.x509 import (
18
- Name,
19
- NameAttribute,
20
- BasicConstraints,
21
18
  KeyUsage,
22
- AuthorityKeyIdentifier,
23
- SubjectKeyIdentifier,
24
19
  )
25
20
 
26
- from nrfcloud_utils.cli_helpers import write_file, save_onboarding_csv
21
+ from nrfcloud_utils.cli_helpers import write_file, save_onboarding_csv, setup_logging
27
22
  from nrfcloud_utils import ca_certs
28
- import coloredlogs, logging
23
+ import logging
29
24
 
30
25
  logger = logging.getLogger(__name__)
31
26
 
32
- def parse_args(in_args):
33
- parser = argparse.ArgumentParser(description="Create Device Credentials")
27
+ def get_parser():
28
+ parser = argparse.ArgumentParser(description="Create Device Credentials",
29
+ add_help=False)
34
30
  parser.add_argument("--ca", type=str, required=True, help="Filepath to your CA cert PEM", default="")
35
31
  parser.add_argument("--ca-key", type=str, required=True, help="Filepath to your CA's private key PEM", default="")
36
32
  parser.add_argument("-c", type=str, help="2 character country code; required if CSR is not provided", default="NO")
@@ -60,10 +56,13 @@ def parse_args(in_args):
60
56
  choices=['debug', 'info', 'warning', 'error', 'critical'],
61
57
  help='Set the logging level'
62
58
  )
59
+ return parser
60
+
61
+ def parse_args(in_args):
62
+ _p = get_parser()
63
+ parser = argparse.ArgumentParser(parents=[_p], description=_p.description, formatter_class=_p.formatter_class)
63
64
  args = parser.parse_args(in_args)
64
- level = getattr(logging, args.log_level.upper(), logging.INFO)
65
- fmt = '%(levelname)-8s %(message)s'
66
- coloredlogs.install(level=level, fmt=fmt)
65
+ setup_logging(level=args.log_level)
67
66
  if len(args.csr) == 0 and len(args.cn) == 0:
68
67
  args.cn = str(uuid.uuid4())
69
68
  return args
@@ -9,13 +9,15 @@ import sys
9
9
  import jwt
10
10
  from datetime import datetime, timezone, timedelta
11
11
  from os import path
12
- import coloredlogs, logging
12
+ import logging
13
+ from nrfcloud_utils.cli_helpers import setup_logging
13
14
 
14
15
  logger = logging.getLogger(__name__)
15
16
 
16
- def parse_args(in_args):
17
+ def get_parser():
17
18
  parser = argparse.ArgumentParser(description="Create JWT for proxy (cloud-to-cloud) requests to nRF Cloud",
18
- formatter_class=argparse.ArgumentDefaultsHelpFormatter)
19
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
20
+ add_help=False)
19
21
 
20
22
  parser.add_argument("--key", type=str, required=True,
21
23
  help="Required filepath to the ES256 private key PEM (Service Key) used for JWT signing. \
@@ -42,10 +44,13 @@ def parse_args(in_args):
42
44
  choices=['debug', 'info', 'warning', 'error', 'critical'],
43
45
  help='Set the logging level'
44
46
  )
47
+ return parser
48
+
49
+ def parse_args(in_args):
50
+ _p = get_parser()
51
+ parser = argparse.ArgumentParser(parents=[_p], description=_p.description, formatter_class=_p.formatter_class)
45
52
  args = parser.parse_args(in_args)
46
- level = getattr(logging, args.log_level.upper(), logging.INFO)
47
- fmt = '%(levelname)-8s %(message)s'
48
- coloredlogs.install(level=level, fmt=fmt)
53
+ setup_logging(level=args.log_level)
49
54
  return args
50
55
 
51
56
  def create_nrf_cloud_jwt(prv_key_bytes, team_id, dev_id, days_valid):
@@ -10,9 +10,9 @@ import os
10
10
  import sys
11
11
  import uuid
12
12
  import semver
13
- import coloredlogs, logging
13
+ import logging
14
14
  from nrfcloud_utils import create_device_credentials, ca_certs, modem_credentials_parser
15
- from nrfcloud_utils.cli_helpers import write_file, save_devinfo_csv, save_onboarding_csv, is_linux, is_windows, is_macos, full_encoding
15
+ from nrfcloud_utils.cli_helpers import write_file, save_devinfo_csv, save_onboarding_csv, full_encoding, setup_logging
16
16
  from nrfcloud_utils.cli_helpers import CMD_TERM_DICT, CMD_TYPE_AUTO, CMD_TYPE_AT, CMD_TYPE_AT_SHELL, CMD_TYPE_TLS_SHELL, parser_add_comms_args
17
17
  from nrfcredstore.command_interface import ATCommandInterface, TLSCredShellInterface
18
18
  from nrfcredstore.comms import Comms
@@ -27,9 +27,10 @@ DEV_ID_MAX_LEN = 64
27
27
  MIN_REQD_MFW_VER = "1.3.0"
28
28
  MIN_REQD_MFW_VER_FOR_VERIFY = "1.3.2"
29
29
 
30
- def parse_args(in_args):
30
+ def get_parser():
31
31
  parser = argparse.ArgumentParser(description="Device Credentials Installer",
32
- formatter_class=argparse.ArgumentDefaultsHelpFormatter)
32
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
33
+ add_help=False)
33
34
  parser_add_comms_args(parser)
34
35
  parser.add_argument("--dv", type=int, help="Number of days cert is valid",
35
36
  default=(10 * 365))
@@ -105,11 +106,13 @@ def parse_args(in_args):
105
106
  choices=['debug', 'info', 'warning', 'error', 'critical'],
106
107
  help='Set the logging level'
107
108
  )
109
+ return parser
110
+
111
+ def parse_args(in_args):
112
+ _p = get_parser()
113
+ parser = argparse.ArgumentParser(parents=[_p], description=_p.description, formatter_class=_p.formatter_class)
108
114
  args = parser.parse_args(in_args)
109
- if args.plain:
110
- logging.basicConfig(level=args.log_level.upper())
111
- else:
112
- coloredlogs.install(level=args.log_level.upper(), fmt='%(levelname)-8s %(message)s')
115
+ setup_logging(level=args.log_level, use_color=not args.plain)
113
116
  return args
114
117
 
115
118
  def parse_mfw_ver(ver_str):
@@ -380,17 +383,32 @@ def main(in_args):
380
383
  # write CA cert(s) to device
381
384
  nrf_ca_cert_text = format_cred(ca_certs.get_ca_certs(args.coap, stage=args.stage))
382
385
 
383
- logger.info(f'Writing CA cert(s) to device...')
384
- cred_if.write_credential(args.sectag, 0, nrf_ca_cert_text)
386
+ logger.info('Writing CA cert(s) to device...')
387
+ try:
388
+ cred_if.write_credential(args.sectag, 0, nrf_ca_cert_text)
389
+ except Exception as e:
390
+ logger.error('Failed to write CA certificate to device')
391
+ logger.debug(f'Error details: {e}')
392
+ raise
385
393
 
386
394
  # write dev cert to device
387
- logger.info(f'Writing dev cert to device...')
388
- cred_if.write_credential(args.sectag, 1, dev_text)
395
+ logger.info('Writing dev cert to device...')
396
+ try:
397
+ cred_if.write_credential(args.sectag, 1, dev_text)
398
+ except Exception as e:
399
+ logger.error('Failed to write device certificate to device')
400
+ logger.debug(f'Error details: {e}')
401
+ raise
389
402
 
390
403
  # If the private key was locally generated, write it to the device
391
404
  if prv_text is not None:
392
- logger.info(f'Writing private key to device...')
393
- cred_if.write_credential(args.sectag, 2, prv_text)
405
+ logger.info('Writing private key to device...')
406
+ try:
407
+ cred_if.write_credential(args.sectag, 2, prv_text)
408
+ except Exception as e:
409
+ logger.error('Failed to write private key to device')
410
+ logger.debug(f'Error details: {e}')
411
+ raise
394
412
 
395
413
  if args.verify:
396
414
  logger.info('Verifying credentials...')
@@ -451,7 +469,14 @@ def verify_credential(cred_if, sec_tag, cred_type, cred = None, get_hash = False
451
469
  logger.error('Invalid credential string')
452
470
  return False
453
471
 
454
- present, hash = cred_if.check_credential_exists(sec_tag, cred_type, get_hash = get_hash)
472
+ try:
473
+ present, hash = cred_if.check_credential_exists(sec_tag, cred_type, get_hash = get_hash)
474
+ except IndexError as e:
475
+ logger.error(f'...Failed to verify {cred_type_name}: Modem returned malformed response')
476
+ logger.debug(f'Error details: {e}')
477
+ logger.error('This may indicate the credential was not properly written to the device.')
478
+ logger.error('Try re-running the provisioning process.')
479
+ return False
455
480
 
456
481
  if not present:
457
482
  logger.error(f'...{cred_type_name} not found')
@@ -462,7 +487,13 @@ def verify_credential(cred_if, sec_tag, cred_type, cred = None, get_hash = False
462
487
  return False
463
488
 
464
489
  if verify_hash:
465
- expected_hash = cred_if.calculate_expected_hash(cred)
490
+ try:
491
+ expected_hash = cred_if.calculate_expected_hash(cred)
492
+ except (IndexError, Exception) as e:
493
+ logger.error(f'...Failed to calculate hash for {cred_type_name}')
494
+ logger.debug(f'Error details: {e}')
495
+ return False
496
+
466
497
  if hash != expected_hash:
467
498
  logger.error(f'{cred_type_name} - SHA mismatch:')
468
499
  logger.error(f'\tDevice : {hash}')
@@ -8,20 +8,21 @@ import sys
8
8
  import csv
9
9
  import argparse
10
10
  from nrfcloud_utils import modem_credentials_parser
11
- from nrfcloud_utils.cli_helpers import is_linux, is_windows, is_macos
12
- from nrfcloud_utils.cli_helpers import CMD_TERM_DICT, CMD_TYPE_AUTO, CMD_TYPE_AT, CMD_TYPE_AT_SHELL, CMD_TYPE_TLS_SHELL, parser_add_comms_args
11
+ from nrfcloud_utils.cli_helpers import setup_logging
12
+ from nrfcloud_utils.cli_helpers import CMD_TERM_DICT, CMD_TYPE_AUTO, CMD_TYPE_AT, CMD_TYPE_AT_SHELL, parser_add_comms_args
13
13
  from nrfcredstore.comms import Comms
14
14
  from nrfcredstore.command_interface import ATCommandInterface
15
15
  from datetime import datetime, timezone
16
- import coloredlogs, logging
16
+ import logging
17
17
 
18
18
  logger = logging.getLogger(__name__)
19
19
 
20
20
  IMEI_LEN = 15
21
21
 
22
- def parse_args(in_args):
22
+ def get_parser():
23
23
  parser = argparse.ArgumentParser(description="Gather Attestation Tokens",
24
- formatter_class=argparse.ArgumentDefaultsHelpFormatter)
24
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
25
+ add_help=False)
25
26
  parser_add_comms_args(parser)
26
27
  parser.add_argument("--csv", type=str,
27
28
  help="Filepath to attestation token CSV file",
@@ -40,11 +41,13 @@ def parse_args(in_args):
40
41
  choices=['debug', 'info', 'warning', 'error', 'critical'],
41
42
  help='Set the logging level'
42
43
  )
44
+ return parser
45
+
46
+ def parse_args(in_args):
47
+ _p = get_parser()
48
+ parser = argparse.ArgumentParser(parents=[_p], description=_p.description, formatter_class=_p.formatter_class)
43
49
  args = parser.parse_args(in_args)
44
- if args.plain:
45
- logging.basicConfig(level=args.log_level.upper())
46
- else:
47
- coloredlogs.install(level=args.log_level.upper(), fmt='%(levelname)-8s %(message)s')
50
+ setup_logging(level=args.log_level, use_color=not args.plain)
48
51
  return args
49
52
 
50
53
  def check_if_device_exists_in_csv(csv_filename, uuid, delete_duplicates):
@@ -6,15 +6,14 @@
6
6
 
7
7
  import argparse
8
8
  import sys
9
- from os import path
10
- from os import makedirs
11
9
  from cbor2 import loads
12
10
  import base64
13
11
  import hashlib
14
- import coloredlogs, logging
12
+ import logging
13
+ import textwrap
15
14
  from cryptography.hazmat.primitives import serialization
16
15
  from cryptography import x509
17
- from nrfcloud_utils.cli_helpers import write_file
16
+ from nrfcloud_utils.cli_helpers import write_file, setup_logging
18
17
 
19
18
  logger = logging.getLogger(__name__)
20
19
 
@@ -43,8 +42,9 @@ header_key_type_dict = {
43
42
  -5: 'nordic_base_rd_key'
44
43
  }
45
44
 
46
- def parse_args(in_args):
47
- parser = argparse.ArgumentParser(description="Modem Credentials Parser")
45
+ def get_parser():
46
+ parser = argparse.ArgumentParser(description="Modem Credentials Parser",
47
+ add_help=False)
48
48
  parser.add_argument("-k", "--keygen", type=str, help="base64url string: KEYGEN output", default="")
49
49
  parser.add_argument("-a", "--attest", type=str, help="base64url string: ATTESTTOKEN output", default="")
50
50
  parser.add_argument("-s", "--save", action='store_true', help="Save PEM file(s): <UUID>_<sec_tag>_<type>.pem")
@@ -58,21 +58,137 @@ def parse_args(in_args):
58
58
  parser.add_argument("-P", "--plain",
59
59
  help="bool: Plain output (no colors)",
60
60
  action='store_true', default=False)
61
+ return parser
62
+
63
+ def parse_args(in_args):
64
+ _p = get_parser()
65
+ parser = argparse.ArgumentParser(parents=[_p], description=_p.description, formatter_class=_p.formatter_class)
61
66
  args = parser.parse_args(in_args)
62
- if args.plain:
63
- logging.basicConfig(level=args.log_level.upper())
64
- else:
65
- coloredlogs.install(level=args.log_level.upper(), fmt='%(levelname)-8s %(message)s')
67
+ setup_logging(level=args.log_level, use_color=not args.plain)
66
68
  return args
67
69
 
68
70
  def base64_decode(string):
69
71
  """
70
72
  add padding before decoding.
71
73
  """
72
- padding = 4 - (len(string) % 4)
74
+ # Base64url data can omit padding; only add what is required.
75
+ padding = (-len(string)) % 4
73
76
  string = string + ("=" * padding)
74
77
  return base64.urlsafe_b64decode(string)
75
78
 
79
+ def _decode_tlv_length(data, offset):
80
+ """Decode DER length and return (value_length, header_length)."""
81
+ first = data[offset]
82
+ if first < 0x80:
83
+ return first, 1
84
+
85
+ nbytes = first & 0x7F
86
+ if nbytes == 0:
87
+ raise ValueError("Indefinite DER length is not supported")
88
+
89
+ value_len = 0
90
+ for i in range(nbytes):
91
+ value_len = (value_len << 8) | data[offset + 1 + i]
92
+ return value_len, 1 + nbytes
93
+
94
+ def _encode_tlv_length(value_len):
95
+ """Encode DER length bytes for value_len."""
96
+ if value_len < 0x80:
97
+ return bytes([value_len])
98
+
99
+ out = []
100
+ value = value_len
101
+ while value > 0:
102
+ out.append(value & 0xFF)
103
+ value >>= 8
104
+ out.reverse()
105
+ return bytes([0x80 | len(out), *out])
106
+
107
+ def _normalize_ecdsa_csr_der(der_bytes):
108
+ """
109
+ Normalize CSR DER that encodes ecdsa-with-SHA256 with NULL params.
110
+
111
+ Older modem output may include signatureAlgorithm = SEQUENCE(OID, NULL).
112
+ For ECDSA-with-SHA* this must be encoded without parameters.
113
+ """
114
+ # ecdsa-with-SHA256 OID (1.2.840.10045.4.3.2)
115
+ ecdsa_sha256_oid_tlv = b"\x06\x08\x2A\x86\x48\xCE\x3D\x04\x03\x02"
116
+
117
+ if len(der_bytes) < 8 or der_bytes[0] != 0x30:
118
+ return None
119
+
120
+ try:
121
+ outer_len, outer_len_hdr = _decode_tlv_length(der_bytes, 1)
122
+ except (IndexError, ValueError):
123
+ return None
124
+
125
+ outer_start = 1 + outer_len_hdr
126
+ outer_end = outer_start + outer_len
127
+ if outer_end != len(der_bytes):
128
+ return None
129
+
130
+ # certificationRequestInfo
131
+ cri_tag_idx = outer_start
132
+ if der_bytes[cri_tag_idx] != 0x30:
133
+ return None
134
+ try:
135
+ cri_len, cri_len_hdr = _decode_tlv_length(der_bytes, cri_tag_idx + 1)
136
+ except (IndexError, ValueError):
137
+ return None
138
+ cri_start = cri_tag_idx
139
+ cri_value_start = cri_tag_idx + 1 + cri_len_hdr
140
+ cri_end = cri_value_start + cri_len
141
+
142
+ # signatureAlgorithm
143
+ sig_alg_tag_idx = cri_end
144
+ if sig_alg_tag_idx >= outer_end or der_bytes[sig_alg_tag_idx] != 0x30:
145
+ return None
146
+ try:
147
+ sig_alg_len, sig_alg_len_hdr = _decode_tlv_length(der_bytes, sig_alg_tag_idx + 1)
148
+ except (IndexError, ValueError):
149
+ return None
150
+ sig_alg_value_start = sig_alg_tag_idx + 1 + sig_alg_len_hdr
151
+ sig_alg_end = sig_alg_value_start + sig_alg_len
152
+ if sig_alg_end > outer_end:
153
+ return None
154
+
155
+ sig_alg_value = der_bytes[sig_alg_value_start:sig_alg_end]
156
+ # Only normalize the exact legacy ECDSA+NULL form.
157
+ if not (sig_alg_value.startswith(ecdsa_sha256_oid_tlv) and sig_alg_value.endswith(b"\x05\x00")):
158
+ return None
159
+ if len(sig_alg_value) != len(ecdsa_sha256_oid_tlv) + 2:
160
+ return None
161
+
162
+ # signature BIT STRING (keep as-is)
163
+ sig_tag_idx = sig_alg_end
164
+ if sig_tag_idx >= outer_end or der_bytes[sig_tag_idx] != 0x03:
165
+ return None
166
+ try:
167
+ sig_len, sig_len_hdr = _decode_tlv_length(der_bytes, sig_tag_idx + 1)
168
+ except (IndexError, ValueError):
169
+ return None
170
+ sig_value_start = sig_tag_idx + 1 + sig_len_hdr
171
+ sig_end = sig_value_start + sig_len
172
+ if sig_end != outer_end:
173
+ return None
174
+
175
+ cri_tlv = der_bytes[cri_start:cri_end]
176
+ normalized_sig_alg = b"\x30" + _encode_tlv_length(len(ecdsa_sha256_oid_tlv)) + ecdsa_sha256_oid_tlv
177
+ sig_tlv = der_bytes[sig_tag_idx:sig_end]
178
+
179
+ new_outer_value = cri_tlv + normalized_sig_alg + sig_tlv
180
+ return b"\x30" + _encode_tlv_length(len(new_outer_value)) + new_outer_value
181
+
182
+ def _csr_der_to_pem(der_bytes):
183
+ """Convert CSR DER bytes to PEM without changing the original DER payload."""
184
+ b64 = base64.b64encode(der_bytes).decode("ascii")
185
+ wrapped = "\n".join(textwrap.wrap(b64, 64))
186
+ return (
187
+ "-----BEGIN CERTIFICATE REQUEST-----\n"
188
+ + wrapped
189
+ + "\n-----END CERTIFICATE REQUEST-----\n"
190
+ ).encode("ascii")
191
+
76
192
  def format_uuid(hex_str):
77
193
  return '{0}-{1}-{2}-{3}-{4}'.format(hex_str[:8], hex_str[8:12],
78
194
  hex_str[12:16], hex_str[16:20],
@@ -178,30 +294,26 @@ def parse_keygen_output(keygen_str):
178
294
  body = body_cose[0]
179
295
 
180
296
  # Decode base64url to binary
181
- body_bytes = base64_decode(body)
297
+ payload_body_bytes = base64_decode(body)
298
+ body_bytes = payload_body_bytes
182
299
 
183
- # This can be either a CSR or device public key
184
300
  try:
185
- # Try to load CSR, if it fails, assume public key
186
301
  csr = x509.load_der_x509_csr(body_bytes)
302
+ except:
303
+ logger.warning("normalizing CSR")
304
+ normalized = _normalize_ecdsa_csr_der(body_bytes)
305
+ logger.warning(f"keygen_str: {keygen_str}, normalized: {base64.urlsafe_b64encode(normalized)}")
306
+ csr = x509.load_der_x509_csr(normalized)
187
307
 
188
- except ValueError:
189
- # Handle public key only
190
- pub_key = serialization.load_der_public_key(body_bytes)
191
- pub_key_bytes = pub_key.public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)
192
-
193
- else:
194
- # CSR loaded, logger.info it
195
- csr_pem_bytes = csr.public_bytes(serialization.Encoding.PEM)
196
- csr_pem_list = str(csr_pem_bytes.decode()).split('\n')
197
- logger.info(csr_pem_bytes.decode().replace('\n', '\\n'))
308
+ csr_pem_bytes = csr.public_bytes(serialization.Encoding.PEM)
309
+ logger.info(csr_pem_bytes.decode().replace('\n', '\\n'))
198
310
 
199
- # Extract public key
200
- pub_key_bytes = csr.public_key().public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)
311
+ # Extract public key
312
+ pub_key_bytes = csr.public_key().public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)
201
313
 
202
314
  logger.info("Device public key: {}".format(pub_key_bytes.decode().replace('\n', '\\n')))
203
315
 
204
- payload_digest = hashlib.sha256(body_bytes).hexdigest()
316
+ payload_digest = hashlib.sha256(payload_body_bytes).hexdigest()
205
317
  logger.info(f"SHA256 Digest: {payload_digest}")
206
318
 
207
319
  # Get optional cose
@@ -0,0 +1,293 @@
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 get_parser():
99
+ parser = argparse.ArgumentParser(
100
+ description="nRF93M1 - Onboard Device using Registration token JWT",
101
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
102
+ add_help=False
103
+ )
104
+ parser.add_argument("--port", type=str, required=True,
105
+ help="Serial port for the nRF93M1 device (e.g., /dev/ttyACM0 or COM3)")
106
+ parser.add_argument("--baudrate", type=int, help="Serial baudrate", default=115200)
107
+ parser.add_argument("--api-key", type=str, required=True,
108
+ help="nRF Cloud API key", default="")
109
+ parser.add_argument('--log-level', default='info',
110
+ choices=['debug', 'info', 'warning', 'error', 'critical'],
111
+ help='Set the logging level')
112
+ parser.add_argument("--stage", type=str,
113
+ choices=['prod', 'dev'],
114
+ help="For internal (Nordic) use only", default="")
115
+ parser.add_argument("--tags", type=_valid_tag, nargs="+", default=["nRF93M1-EK"],
116
+ metavar="TAG",
117
+ help="Tags to assign to the device on nRF Cloud. "
118
+ "Each tag must match /[a-zA-Z0-9_.,@\\/:#-]{0,799}/")
119
+ return parser
120
+
121
+ def parse_args(in_args):
122
+ _p = get_parser()
123
+ parser = argparse.ArgumentParser(parents=[_p], description=_p.description, formatter_class=_p.formatter_class)
124
+ args = parser.parse_args(in_args)
125
+
126
+ # Setup logging
127
+ if hasattr(args, 'log_level'):
128
+ coloredlogs.install(level=args.log_level.upper(), fmt='%(levelname)-8s %(message)s')
129
+ else:
130
+ coloredlogs.install(level='INFO', fmt='%(levelname)-8s %(message)s')
131
+
132
+ return args
133
+
134
+ def set_dev_stage(stage = ''):
135
+ global api_url
136
+ global dev_stage_key
137
+
138
+ if stage in DEV_STAGE_DICT.keys():
139
+ dev_stage_key = stage
140
+ api_url = '{}{}{}'.format(API_URL_START, DEV_STAGE_DICT[dev_stage_key], API_URL_END)
141
+ else:
142
+ logger.error('Invalid stage')
143
+
144
+ return api_url
145
+
146
+ def fetch_tenant_id(api_key):
147
+ hdr = {'Authorization': 'Bearer ' + api_key}
148
+ req = api_url + "account"
149
+ response = requests.get(req, headers=hdr)
150
+ if not response.ok:
151
+ logger.error(f'Failed to fetch tenant ID: HTTP {response.status_code}')
152
+ return None
153
+
154
+ try:
155
+ account_info = response.json()
156
+ except ValueError:
157
+ logger.error('Failed to parse account response JSON')
158
+ return None
159
+
160
+ tenant_id = account_info.get('team', {}).get('tenantId')
161
+ if not tenant_id:
162
+ logger.error('tenantId not found in account response')
163
+ return None
164
+
165
+ return tenant_id
166
+
167
+ def gen_registration_jwt(cred_if, tenant_id):
168
+ result = cred_if.at_command(f'AT%REGJWT="{tenant_id}"', wait_for_result=False)
169
+ if not result:
170
+ logger.error('Failed to send AT%REGJWT command')
171
+ return None
172
+
173
+ retval, output = cred_if.comms.expect_response("OK", "ERROR", "%REGJWT:")
174
+ if not retval:
175
+ logger.error('Failed to get registration JWT from nRF93M1')
176
+ return None
177
+
178
+ lines = [line.strip() for line in output.split("\n") if line.strip().startswith("%REGJWT:")]
179
+ if not lines:
180
+ logger.error('Registration JWT not found in response')
181
+ return None
182
+
183
+ jwt_str = lines[-1].split(':', 1)[1].strip()
184
+ if not jwt_str:
185
+ logger.error('Registration JWT is empty')
186
+ return None
187
+
188
+ logger.debug('Retrieved registration JWT from device')
189
+ return jwt_str
190
+
191
+ def onboard_device(api_key, dev_id, sub_type, tags, fw_types, onboarding_token):
192
+ hdr = {
193
+ 'Authorization': 'Bearer ' + api_key,
194
+ 'Accept': 'application/json',
195
+ }
196
+
197
+ req = api_url + "devices/" + dev_id
198
+
199
+ payload = {
200
+ 'onboardingToken': onboarding_token,
201
+ 'subType': sub_type,
202
+ 'tags': tags,
203
+ 'supportedFirmwareTypes': fw_types,
204
+ }
205
+
206
+ return requests.post(req, json=payload, headers=hdr)
207
+
208
+ def main(in_args):
209
+ args = parse_args(in_args)
210
+
211
+ if args.stage:
212
+ set_dev_stage(args.stage)
213
+
214
+ logger.info('nRF93M1 - Onboard Device using Registration token JWT')
215
+ logger.info(f'Connecting to device on {args.port}...')
216
+
217
+ # Initialize serial communication
218
+ try:
219
+ serial_interface = Comms(
220
+ port=args.port,
221
+ baudrate=args.baudrate,
222
+ )
223
+ except Exception as e:
224
+ logger.error(f'Failed to open serial port: {e}')
225
+ sys.exit(1)
226
+
227
+ # Create AT command interface
228
+ cred_if = ATCommandInterface(serial_interface)
229
+
230
+ try:
231
+ # Verify device is responsive
232
+ logger.info('Checking device connectivity...')
233
+ resp = cred_if.at_command('AT', wait_for_result=True)
234
+ if not resp:
235
+ logger.error('No response from device. Check connection and try again.')
236
+ sys.exit(1)
237
+ logger.info('Device is responsive')
238
+
239
+ # Get device UUID
240
+ logger.info('Retrieving device UUID...')
241
+ dev_id = get_nrf93m1_uuid(cred_if)
242
+ if not dev_id:
243
+ logger.error('[Failed] Device UUID')
244
+ sys.exit(2)
245
+ logger.info('[OK] Device UUID')
246
+
247
+ # Get identity key
248
+ logger.info('Retrieving identity key...')
249
+ identity_key_base64 = get_nrf93m1_identity_key(cred_if)
250
+ if not identity_key_base64:
251
+ logger.error('[Failed] Device identity key')
252
+ sys.exit(3)
253
+ logger.info('[OK] Device identity key')
254
+
255
+ # Based on the stage specified, we query the tenantID from nRF Cloud using the user's API key.
256
+ logger.info('Retrieving tenant ID from nRF Cloud account...')
257
+ tenant_id = fetch_tenant_id(args.api_key)
258
+ if not tenant_id:
259
+ logger.error('[Failed] Tenant ID')
260
+ sys.exit(4)
261
+ logger.info(f'[OK] Tenant ID')
262
+
263
+ # Generate the registration JWT on the device using the tenantId
264
+ logger.info('Generating registration JWT...')
265
+ registration_jwt = gen_registration_jwt(cred_if, tenant_id)
266
+ if not registration_jwt:
267
+ logger.error('[Failed] Registration JWT')
268
+ sys.exit(5)
269
+ logger.info('[OK] Registration JWT')
270
+
271
+ # Onboard the device
272
+ logger.info('Onboarding device to nRF Cloud...')
273
+ sub_type = "nRF93M1"
274
+ fw_types = ["MODEM"]
275
+ onboard_response = onboard_device(args.api_key, dev_id, sub_type, args.tags, fw_types, registration_jwt)
276
+ if not onboard_response.ok:
277
+ logger.error(f'Failed to onboard device: HTTP {onboard_response.status_code} - {onboard_response.text}')
278
+ sys.exit(6)
279
+ logger.info('[OK] Device onboarded successfully')
280
+
281
+ except KeyboardInterrupt:
282
+ logger.warning('Interrupted by user')
283
+ sys.exit(130)
284
+ except Exception as e:
285
+ logger.error(f'Unexpected error: {e}', exc_info=True)
286
+ sys.exit(99)
287
+
288
+ def run():
289
+ main(sys.argv[1:])
290
+
291
+
292
+ if __name__ == '__main__':
293
+ run()
@@ -182,9 +182,10 @@ class nRFCloudDevice:
182
182
  def url_encode(token):
183
183
  return urllib.parse.quote_plus(token)
184
184
 
185
- def parse_args(in_args):
185
+ def get_parser():
186
186
  parser = argparse.ArgumentParser(description="nRF Cloud Manage FOTA Update Jobs",
187
- formatter_class=argparse.ArgumentDefaultsHelpFormatter)
187
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
188
+ add_help=False)
188
189
  parser.add_argument("--api-key",
189
190
  help="nRF Cloud API key",
190
191
  type=str, required=True, default="")
@@ -218,7 +219,11 @@ def parse_args(in_args):
218
219
  parser.add_argument("--desc",
219
220
  help="The description of the created updated.",
220
221
  type=str, required=False, default="")
222
+ return parser
221
223
 
224
+ def parse_args(in_args):
225
+ _p = get_parser()
226
+ parser = argparse.ArgumentParser(parents=[_p], description=_p.description, formatter_class=_p.formatter_class)
222
227
  args = parser.parse_args(in_args)
223
228
  return args
224
229
 
@@ -5,7 +5,7 @@
5
5
  # SPDX-License-Identifier: BSD-3-Clause
6
6
 
7
7
  import requests
8
- import coloredlogs, logging
8
+ import logging
9
9
  from nrfcloud_utils import ca_certs
10
10
 
11
11
  DEV_STAGE_DICT = {'dev': '.dev.',
@@ -10,15 +10,12 @@ import requests
10
10
  import csv
11
11
  import time
12
12
  import json
13
- import requests
14
13
  import os
15
14
  import io
16
- import coloredlogs, logging
17
- from os import path
15
+ import logging
18
16
  from os import makedirs
19
- from ast import literal_eval
20
17
  from enum import Enum
21
- from nrfcloud_utils.cli_helpers import write_file
18
+ from nrfcloud_utils.cli_helpers import write_file, setup_logging
22
19
 
23
20
  logger = logging.getLogger(__name__)
24
21
 
@@ -52,9 +49,10 @@ DEV_LIST_ID_IDX = 0
52
49
  DEV_LIST_RES_IDX = 1
53
50
  BULK_OP_REQ_ID = "bulkOpsRequestId"
54
51
 
55
- def parse_args(in_args):
52
+ def get_parser():
56
53
  parser = argparse.ArgumentParser(description="nRF Cloud Device Onboarding",
57
- formatter_class=argparse.ArgumentDefaultsHelpFormatter)
54
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
55
+ add_help=False)
58
56
  parser.add_argument("--api-key", type=str, required=True,
59
57
  help="nRF Cloud API key", default="")
60
58
  parser.add_argument("--chk", action='store_true', default=False,
@@ -85,11 +83,13 @@ def parse_args(in_args):
85
83
  parser.add_argument("-P", "--plain",
86
84
  help="bool: Plain output (no colors)",
87
85
  action='store_true', default=False)
86
+ return parser
87
+
88
+ def parse_args(in_args):
89
+ _p = get_parser()
90
+ parser = argparse.ArgumentParser(parents=[_p], description=_p.description, formatter_class=_p.formatter_class)
88
91
  args = parser.parse_args(in_args)
89
- if args.plain:
90
- logging.basicConfig(level=args.log_level.upper())
91
- else:
92
- coloredlogs.install(level=args.log_level.upper(), fmt='%(levelname)-8s %(message)s')
92
+ setup_logging(level=args.log_level, use_color=not args.plain)
93
93
  return args
94
94
 
95
95
  def set_dev_stage(stage = ''):
@@ -353,7 +353,7 @@ def check_file_path(file):
353
353
  if not os.path.exists(path):
354
354
  try:
355
355
  makedirs(path, exist_ok=True)
356
- except OSError as e:
356
+ except OSError:
357
357
  logger.error("Error creating file path: " + path)
358
358
  return ''
359
359