dotbot-provision 0.1.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.
@@ -0,0 +1,31 @@
1
+ # Segger Studio specific files
2
+
3
+ *.emSession
4
+ *.jlink
5
+
6
+ # Python compiled files
7
+ *.pyc
8
+
9
+ # Git conflict files
10
+ *.orig
11
+
12
+ # Visual Studio specific files
13
+ .vscode/
14
+
15
+ # Segger Studio output directory
16
+ Output/
17
+
18
+ # Python package dist
19
+ dist/
20
+
21
+ # Virtual env folder
22
+ .venv/
23
+
24
+ # Tox
25
+ .tox/
26
+
27
+ # Mac OS files
28
+ .DS_Store
29
+
30
+ # folder with binaries
31
+ bin
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: dotbot-provision
3
+ Version: 0.1.0
4
+ Summary: A command-line tool for provisioning DotBot devices and gateways.
5
+ Author-email: Geovane Fedrecheski <geovane.fedrecheski@inria.fr>
6
+ Classifier: Operating System :: MacOS
7
+ Classifier: Operating System :: Microsoft :: Windows
8
+ Classifier: Operating System :: POSIX :: Linux
9
+ Classifier: Programming Language :: Python :: 3
10
+ Requires-Python: >=3.8
11
+ Requires-Dist: click>=8.1.7
12
+ Requires-Dist: intelhex>=2.3.0
13
+ Requires-Dist: tomli>=2.0.1; python_version < '3.11'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # `dotbot-provision`
17
+
18
+ A command-line tool for provisioning DotBot devices and gateways.
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ pip install dotbot-provision
24
+ ```
25
+
26
+ ```
27
+ Usage: dotbot-provision [OPTIONS] COMMAND [ARGS]...
28
+
29
+ A tool for provisioning DotBot devices and gateways.
30
+
31
+ Options:
32
+ --help Show this message and exit.
33
+
34
+ Commands:
35
+ fetch Fetch firmware assets into bin/<fw-version>/.
36
+ flash Flash firmware + config using versioned bin layout.
37
+ flash-bringup Flash J-Link OB or DAPLink programmer firmware.
38
+ flash-hex Flash explicit app/net hex files.
39
+ read-config Read config from the device.
40
+ ```
41
+
42
+ ## Deploying a testbed
43
+
44
+ First, download firmware assets:
45
+
46
+ ```bash
47
+ dotbot-provision fetch --fw-version v0.7.0
48
+ ```
49
+
50
+ Then, to flash a DotBot-v3 while specifying a certain Network ID:
51
+
52
+ ```bash
53
+ dotbot-provision flash --device dotbot-v3 --fw-version v0.7.0 --network-id 0100
54
+ ```
55
+
56
+ And to flash a Mari Gateway:
57
+
58
+ ```bash
59
+ dotbot-provision flash --device gateway --fw-version v0.7.0 --network-id 0100
60
+ ```
61
+
62
+ ... and it's done!
63
+
64
+ ## Deploying a testbed on fresh robots
65
+
66
+ If your robot just arrived from factory, first you have to run the `flash-bringup` command.
67
+ You can concatenate it with a regular `flash` command so that all happens in sequence with minimal manual work.
68
+ Like this:
69
+
70
+ ```bash
71
+ dotbot-provision flash-bringup --programmer-firmware jlink -d ../../../programer-files-dotbot && \
72
+ dotbot-provision flash -d dotbot-v3 -f local -n 0100 -s 77
73
+ ```
74
+ ... where the `-s` flag stands for `--sn-starting-digits` and serves as a pattern to identify the connected programming probe. In this case it solves a problem where the flash command incorrectly selects the external J-Link probe instead of the dotbot's (most DotBots come from factory with a serial number starting by 77).
@@ -0,0 +1,59 @@
1
+ # `dotbot-provision`
2
+
3
+ A command-line tool for provisioning DotBot devices and gateways.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install dotbot-provision
9
+ ```
10
+
11
+ ```
12
+ Usage: dotbot-provision [OPTIONS] COMMAND [ARGS]...
13
+
14
+ A tool for provisioning DotBot devices and gateways.
15
+
16
+ Options:
17
+ --help Show this message and exit.
18
+
19
+ Commands:
20
+ fetch Fetch firmware assets into bin/<fw-version>/.
21
+ flash Flash firmware + config using versioned bin layout.
22
+ flash-bringup Flash J-Link OB or DAPLink programmer firmware.
23
+ flash-hex Flash explicit app/net hex files.
24
+ read-config Read config from the device.
25
+ ```
26
+
27
+ ## Deploying a testbed
28
+
29
+ First, download firmware assets:
30
+
31
+ ```bash
32
+ dotbot-provision fetch --fw-version v0.7.0
33
+ ```
34
+
35
+ Then, to flash a DotBot-v3 while specifying a certain Network ID:
36
+
37
+ ```bash
38
+ dotbot-provision flash --device dotbot-v3 --fw-version v0.7.0 --network-id 0100
39
+ ```
40
+
41
+ And to flash a Mari Gateway:
42
+
43
+ ```bash
44
+ dotbot-provision flash --device gateway --fw-version v0.7.0 --network-id 0100
45
+ ```
46
+
47
+ ... and it's done!
48
+
49
+ ## Deploying a testbed on fresh robots
50
+
51
+ If your robot just arrived from factory, first you have to run the `flash-bringup` command.
52
+ You can concatenate it with a regular `flash` command so that all happens in sequence with minimal manual work.
53
+ Like this:
54
+
55
+ ```bash
56
+ dotbot-provision flash-bringup --programmer-firmware jlink -d ../../../programer-files-dotbot && \
57
+ dotbot-provision flash -d dotbot-v3 -f local -n 0100 -s 77
58
+ ```
59
+ ... where the `-s` flag stands for `--sn-starting-digits` and serves as a pattern to identify the connected programming probe. In this case it solves a problem where the flash command incorrectly selects the external J-Link probe instead of the dotbot's (most DotBots come from factory with a serial number starting by 77).
@@ -0,0 +1,3 @@
1
+ """DotBot provisioning CLI package."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,572 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import shutil
7
+ import sys
8
+ import time
9
+ import urllib.error
10
+ import urllib.request
11
+ from pathlib import Path
12
+
13
+ import click
14
+
15
+ from .nrf_flash import (
16
+ do_daplink,
17
+ do_daplink_if,
18
+ do_jlink,
19
+ flash_nrf_both_cores,
20
+ flash_nrf_one_core,
21
+ pick_last_jlink_snr,
22
+ pick_matching_jlink_snr,
23
+ read_device_id,
24
+ read_net_id,
25
+ )
26
+
27
+ try:
28
+ from intelhex import IntelHex
29
+ except ModuleNotFoundError: # pragma: no cover - optional dependency
30
+ IntelHex = None
31
+ try:
32
+ import tomllib # Python 3.11+
33
+ except ModuleNotFoundError: # pragma: no cover - fallback for older Pythons
34
+ tomllib = None
35
+
36
+
37
+ DEFAULT_BIN_DIR = Path("bin")
38
+ VALID_DEVICES = ("dotbot-v3", "gateway")
39
+ VALID_PROGRAMMERS = ("jlink", "daplink")
40
+ CONFIG_ADDR = 0x0103F800
41
+ CONFIG_MAGIC = 0x5753524D
42
+ CONFIG_MANIFEST_NAME = "config-manifest.json"
43
+ RELEASE_BASE_URL = "https://github.com/DotBots/swarmit/releases/download"
44
+ # Programmer bring-up files
45
+ GEEHY_PACK_NAME = "Geehy.APM32F1xx_DFP.1.1.0.pack"
46
+ JLINK_REQUIRED_FILES = ("JLink-ob.bin", "stm32f103xb_bl.hex", GEEHY_PACK_NAME)
47
+ DAPLINK_REQUIRED_FILES = (
48
+ "stm32f103xb_bl.hex",
49
+ "stm32f103xb_if.hex",
50
+ GEEHY_PACK_NAME,
51
+ )
52
+ APM_DEVICE = "APM32F103CB"
53
+ # it seems to always start with 77
54
+ DOTBOT_V3_SERIAL_PATTERN = r"77[0-9A-F]{7}"
55
+
56
+ DEVICE_ASSETS: dict[str, dict[str, str]] = {
57
+ "dotbot-v3": {
58
+ "app": "bootloader-dotbot-v3.hex",
59
+ "net": "netcore-nrf5340-net.hex",
60
+ "examples": ["rgbled-dotbot-v3.bin", "dotbot-dotbot-v3.bin"],
61
+ },
62
+ "gateway": {
63
+ "app": "03app_gateway_app-nrf5340-app.hex",
64
+ "net": "03app_gateway_net-nrf5340-net.hex",
65
+ "examples": [],
66
+ },
67
+ }
68
+
69
+
70
+ def load_config(path: Path) -> dict:
71
+ if tomllib is None:
72
+ raise click.ClickException(
73
+ "tomllib not available; install Python 3.11+ or add tomli."
74
+ )
75
+ try:
76
+ return tomllib.loads(path.read_text())
77
+ except FileNotFoundError as exc:
78
+ raise click.ClickException(f"Config file not found: {path}") from exc
79
+ except Exception as exc: # noqa: BLE001 - surface parse errors
80
+ raise click.ClickException(
81
+ f"Failed to parse config file {path}: {exc}"
82
+ ) from exc
83
+
84
+
85
+ def normalize_network_id(raw: str | None) -> tuple[int, str] | None:
86
+ if raw is None:
87
+ return None
88
+ s = raw.strip().lower()
89
+ if s.startswith("0x"):
90
+ s = s[2:]
91
+ try:
92
+ value = int(s, 16)
93
+ except ValueError as exc:
94
+ raise click.ClickException(
95
+ f"Invalid network_id '{raw}' (expected hex)."
96
+ ) from exc
97
+ if not (0x0000 <= value <= 0xFFFF):
98
+ raise click.ClickException(
99
+ "network_id must be 16-bit (0x0000..0xFFFF)."
100
+ )
101
+ return value, f"{value:04X}"
102
+
103
+
104
+ def resolve_fw_root(bin_dir: Path, fw_version: str) -> Path:
105
+ return bin_dir / fw_version
106
+
107
+
108
+ def download_file(url: str, dest: Path) -> None:
109
+ click.echo(f"[GET ] {url}")
110
+ try:
111
+ with urllib.request.urlopen(url) as resp:
112
+ status = getattr(resp, "status", 200)
113
+ if status != 200:
114
+ raise click.ClickException(
115
+ f"HTTP {status} while downloading {url}"
116
+ )
117
+ data = resp.read()
118
+ except urllib.error.HTTPError as exc:
119
+ raise click.ClickException(
120
+ f"HTTP error while downloading {url}: {exc}"
121
+ ) from exc
122
+ except urllib.error.URLError as exc:
123
+ raise click.ClickException(
124
+ f"Network error while downloading {url}: {exc}"
125
+ ) from exc
126
+
127
+ dest.write_bytes(data)
128
+ click.echo(f"[OK ] wrote {dest} ({len(data)} bytes)")
129
+
130
+
131
+ def find_existing_config_hex(fw_root: Path) -> Path | None:
132
+ candidates = sorted(
133
+ fw_root.glob("config-*.hex"),
134
+ key=lambda p: p.stat().st_mtime,
135
+ reverse=True,
136
+ )
137
+ return candidates[0] if candidates else None
138
+
139
+
140
+ def make_config_hex_path(
141
+ fw_root: Path, device: str, fw_version: str, net_id_hex: str
142
+ ) -> Path:
143
+ ts = time.strftime("%Y%b%d-%H%M%S")
144
+ return fw_root / f"config-{device}-{fw_version}-{net_id_hex}-{ts}.hex"
145
+
146
+
147
+ def create_config_hex(dest: Path, net_id_value: int) -> None:
148
+ if IntelHex is None:
149
+ raise click.ClickException(
150
+ "intelhex not available; install it to build config hex."
151
+ )
152
+ ih = IntelHex()
153
+ for offset, word in enumerate((CONFIG_MAGIC, net_id_value)):
154
+ addr = CONFIG_ADDR + offset * 4
155
+ ih[addr + 0] = (word >> 0) & 0xFF
156
+ ih[addr + 1] = (word >> 8) & 0xFF
157
+ ih[addr + 2] = (word >> 16) & 0xFF
158
+ ih[addr + 3] = (word >> 24) & 0xFF
159
+ dest.parent.mkdir(parents=True, exist_ok=True)
160
+ ih.tofile(str(dest), "hex")
161
+
162
+
163
+ def load_config_manifest(path: Path) -> dict | None:
164
+ if not path.exists():
165
+ return None
166
+ try:
167
+ return json.loads(path.read_text())
168
+ except Exception as exc: # noqa: BLE001 - surface parse errors
169
+ raise click.ClickException(
170
+ f"Failed to parse config manifest {path}: {exc}"
171
+ ) from exc
172
+
173
+
174
+ def write_config_manifest(path: Path, payload: dict) -> None:
175
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
176
+
177
+
178
+ def build_manifest_payload(
179
+ config_hex: Path,
180
+ device: str,
181
+ fw_version: str,
182
+ net_id_hex: str,
183
+ ) -> dict:
184
+ created_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
185
+ return {
186
+ "config_hex": config_hex.name,
187
+ "device": device,
188
+ "fw_version": fw_version,
189
+ "network_id": net_id_hex,
190
+ "config_addr": f"0x{CONFIG_ADDR:08X}",
191
+ "magic": f"0x{CONFIG_MAGIC:08X}",
192
+ "created_at": created_at,
193
+ }
194
+
195
+
196
+ def manifest_matches(
197
+ payload: dict, device: str, fw_version: str, net_id_hex: str
198
+ ) -> bool:
199
+ if not isinstance(payload, dict):
200
+ return False
201
+ return (
202
+ payload.get("device") == device
203
+ and payload.get("fw_version") == fw_version
204
+ and payload.get("network_id") == net_id_hex
205
+ and payload.get("config_addr") == f"0x{CONFIG_ADDR:08X}"
206
+ and payload.get("magic") == f"0x{CONFIG_MAGIC:08X}"
207
+ and isinstance(payload.get("config_hex"), str)
208
+ )
209
+
210
+
211
+ @click.group(
212
+ help="A tool for provisioning DotBot devices and gateways in the context of a SwarmIT-enabled testbed."
213
+ )
214
+ def cli() -> None:
215
+ pass
216
+
217
+
218
+ @cli.command("fetch", help="Fetch firmware assets into bin/<fw-version>/.")
219
+ @click.option(
220
+ "--fw-version",
221
+ "-f",
222
+ required=True,
223
+ help="Firmware version tag or 'local'.",
224
+ )
225
+ @click.option(
226
+ "--local-root",
227
+ type=click.Path(path_type=Path, file_okay=False, dir_okay=True),
228
+ help="Root directory for local builds (used with --fw-version local).",
229
+ )
230
+ @click.option(
231
+ "--bin-dir",
232
+ default=DEFAULT_BIN_DIR,
233
+ type=click.Path(path_type=Path, file_okay=False, dir_okay=True),
234
+ show_default=True,
235
+ help="Destination bin directory.",
236
+ )
237
+ def cmd_fetch(fw_version: str, local_root: Path | None, bin_dir: Path) -> None:
238
+ if fw_version == "local" and not local_root:
239
+ raise click.ClickException(
240
+ "--local-root is required when --fw-version=local."
241
+ )
242
+ if fw_version != "local" and local_root:
243
+ click.echo(
244
+ "[WARN] --local-root ignored when --fw-version is not 'local'.",
245
+ err=True,
246
+ )
247
+
248
+ out_dir = resolve_fw_root(bin_dir, fw_version)
249
+ out_dir.mkdir(parents=True, exist_ok=True)
250
+ click.echo(f"[INFO] target dir: {out_dir}")
251
+
252
+ if fw_version == "local":
253
+ local_root = local_root.expanduser().resolve()
254
+ mapping = {
255
+ "bootloader-dotbot-v3.hex": local_root
256
+ / "device/bootloader/Output/dotbot-v3/Debug/Exe/bootloader-dotbot-v3.hex",
257
+ "netcore-nrf5340-net.hex": local_root
258
+ / "device/network_core/Output/nrf5340-net/Debug/Exe/netcore-nrf5340-net.hex",
259
+ "03app_gateway_app-nrf5340-app.hex": local_root
260
+ / "mari/app/03app_gateway_app/Output/nrf5340-app/Debug/Exe/03app_gateway_app-nrf5340-app.hex",
261
+ "03app_gateway_net-nrf5340-net.hex": local_root
262
+ / "mari/app/03app_gateway_net/Output/nrf5340-net/Debug/Exe/03app_gateway_net-nrf5340-net.hex",
263
+ }
264
+
265
+ missing = [name for name, src in mapping.items() if not src.exists()]
266
+ if missing:
267
+ missing_list = ", ".join(missing)
268
+ raise click.ClickException(
269
+ f"Missing local build artifacts: {missing_list}"
270
+ )
271
+
272
+ for name, src in mapping.items():
273
+ dest = out_dir / name
274
+ if dest.exists() or dest.is_symlink():
275
+ dest.unlink()
276
+ try:
277
+ os.symlink(src, dest)
278
+ click.echo(f"[LINK] {dest} -> {src}")
279
+ except OSError:
280
+ shutil.copy2(src, dest)
281
+ click.echo(f"[COPY] {dest} <- {src}")
282
+ return
283
+
284
+ assets = [
285
+ "bootloader-dotbot-v3.hex",
286
+ "netcore-nrf5340-net.hex",
287
+ "03app_gateway_app-nrf5340-app.hex",
288
+ "03app_gateway_net-nrf5340-net.hex",
289
+ ]
290
+ for name in assets:
291
+ url = f"{RELEASE_BASE_URL}/{fw_version}/{name}"
292
+ dest = out_dir / name
293
+ download_file(url, dest)
294
+
295
+
296
+ @cli.command(
297
+ "flash",
298
+ help="Flash firmware + config using versioned bin layout.",
299
+ )
300
+ @click.option(
301
+ "--device", "-d", type=click.Choice(VALID_DEVICES), required=True
302
+ )
303
+ @click.option("--fw-version", "-f", help="Firmware version tag or 'local'.")
304
+ @click.option(
305
+ "--config",
306
+ "-c",
307
+ "config_path",
308
+ type=click.Path(path_type=Path, dir_okay=False),
309
+ )
310
+ @click.option("--network-id", "-n", help="16-bit hex network ID, e.g. 0100.")
311
+ @click.option(
312
+ "--sn-starting-digits",
313
+ "-s",
314
+ help="Serial number pattern to use for auto-selection, e.g. 77.",
315
+ )
316
+ @click.option(
317
+ "--bin-dir",
318
+ default=DEFAULT_BIN_DIR,
319
+ type=click.Path(path_type=Path, file_okay=False, dir_okay=True),
320
+ show_default=True,
321
+ help="Bin directory containing firmware files.",
322
+ )
323
+ def cmd_flash(
324
+ device: str,
325
+ fw_version: str | None,
326
+ config_path: Path | None,
327
+ network_id: str | None,
328
+ sn_starting_digits: str | None,
329
+ bin_dir: Path,
330
+ ) -> None:
331
+ assets = DEVICE_ASSETS[device]
332
+
333
+ if sn_starting_digits:
334
+ snr = pick_matching_jlink_snr(sn_starting_digits)
335
+ else:
336
+ snr = pick_last_jlink_snr()
337
+ if snr is None:
338
+ raise click.ClickException(
339
+ "Unable to auto-select J-Link; provide --snr explicitly."
340
+ )
341
+ click.echo(f"[INFO] using J-Link with serial number: {snr}")
342
+
343
+ if device == "dotbot-v3" and not snr.startswith("77"):
344
+ click.secho(
345
+ f"[WARN] Serial number {snr} seems to not be a DotBot, but you are trying to flash a {device} firmware to it.",
346
+ fg="yellow",
347
+ )
348
+ if not click.confirm(
349
+ "Do you want to continue? (you can check or plug the right board)",
350
+ default=True,
351
+ ):
352
+ raise click.ClickException("Aborting.")
353
+ elif device == "gateway" and snr.startswith("77"):
354
+ click.secho(
355
+ f"[WARN] Serial number {snr} seems to be a DotBot, but you are trying to flash a {device} firmware to it.",
356
+ fg="yellow",
357
+ )
358
+ if not click.confirm(
359
+ "Do you want to continue? (you can check or plug the right board)",
360
+ default=True,
361
+ ):
362
+ raise click.ClickException("Aborting.")
363
+
364
+ config = {}
365
+ if config_path:
366
+ config = load_config(config_path)
367
+
368
+ provisioning = (
369
+ config.get("provisioning", {}) if isinstance(config, dict) else {}
370
+ )
371
+ fw_version = fw_version or provisioning.get("firmware_version")
372
+ net_raw = network_id or provisioning.get("network_id")
373
+
374
+ if not fw_version:
375
+ raise click.ClickException(
376
+ "Missing --fw-version (or provisioning.firmware_version in config)."
377
+ )
378
+ net_id = normalize_network_id(net_raw)
379
+ if net_id is None:
380
+ raise click.ClickException(
381
+ "Missing --network-id (or provisioning.network_id in config)."
382
+ )
383
+
384
+ net_id_val, net_id_hex = net_id
385
+ fw_root = resolve_fw_root(bin_dir, fw_version)
386
+ if not fw_root.exists():
387
+ raise click.ClickException(f"Firmware root not found: {fw_root}")
388
+
389
+ app_hex = fw_root / assets["app"]
390
+ net_hex = fw_root / assets["net"]
391
+ manifest_path = fw_root / CONFIG_MANIFEST_NAME
392
+ manifest = load_config_manifest(manifest_path)
393
+ config_hex = None
394
+ if manifest:
395
+ click.echo(
396
+ f"[INFO] loaded manifest {manifest_path}: {json.dumps(manifest, indent=2)}"
397
+ )
398
+ if manifest_matches(manifest, device, fw_version, net_id_hex):
399
+ candidate = fw_root / manifest["config_hex"]
400
+ if candidate.exists():
401
+ config_hex = candidate
402
+ click.secho(
403
+ f"[NOTE] using config hex from manifest: {config_hex}",
404
+ fg="yellow",
405
+ )
406
+ else:
407
+ click.secho(
408
+ "[INFO] manifest does not match, will create new config hex",
409
+ fg="yellow",
410
+ )
411
+
412
+ if config_hex is None:
413
+ config_hex = make_config_hex_path(
414
+ fw_root, device, fw_version, net_id_hex
415
+ )
416
+ click.secho(f"[INFO] created new config hex: {config_hex}", fg="green")
417
+
418
+ missing = [str(p) for p in (app_hex, net_hex) if not p.exists()]
419
+ if missing:
420
+ missing_list = ", ".join(missing)
421
+ raise click.ClickException(f"Missing firmware files: {missing_list}")
422
+
423
+ click.echo(f"[INFO] device: {device}")
424
+ click.echo(f"[INFO] fw_version: {fw_version}")
425
+ click.echo(f"[INFO] network_id: 0x{net_id_hex}")
426
+ click.echo(f"[INFO] app hex: {app_hex}")
427
+ click.echo(f"[INFO] net hex: {net_hex}")
428
+ click.echo(f"[INFO] config hex: {config_hex}")
429
+
430
+ if not config_hex.exists():
431
+ create_config_hex(config_hex, net_id_val)
432
+ click.echo(f"[OK ] wrote config hex: {config_hex}")
433
+ manifest_payload = build_manifest_payload(
434
+ config_hex, device, fw_version, net_id_hex
435
+ )
436
+ write_config_manifest(manifest_path, manifest_payload)
437
+ click.echo(f"[OK ] wrote config manifest: {manifest_path}")
438
+ click.echo(
439
+ f"[INFO] manifest: {json.dumps(manifest_payload, indent=2)}"
440
+ )
441
+ else:
442
+ click.echo(f"[INFO] using existing config hex: {config_hex}")
443
+ flash_nrf_both_cores(app_hex, net_hex, nrfjprog_opt=None, snr_opt=snr)
444
+ flash_nrf_one_core(net_hex=config_hex, nrfjprog_opt=None, snr_opt=snr)
445
+ click.echo("\n[INFO] ==== Flash Complete ====\n")
446
+ try:
447
+ readback_net_id = read_net_id(snr=snr)
448
+ readback_device_id = read_device_id(snr=snr)
449
+ except RuntimeError as exc:
450
+ click.echo(f"[WARN] readback failed: {exc}", err=True)
451
+ return
452
+ click.echo(f"[INFO] readback net_id: {readback_net_id}")
453
+ click.echo(f"[INFO] readback device_id: {readback_device_id}")
454
+
455
+
456
+ @cli.command("flash-hex", help="Flash explicit app/net hex files.")
457
+ @click.option(
458
+ "--app", "app_hex", type=click.Path(path_type=Path, dir_okay=False)
459
+ )
460
+ @click.option(
461
+ "--net", "net_hex", type=click.Path(path_type=Path, dir_okay=False)
462
+ )
463
+ def cmd_flash_hex(app_hex: Path | None, net_hex: Path | None) -> None:
464
+ if not app_hex and not net_hex:
465
+ raise click.ClickException("Provide at least one of --app or --net.")
466
+ if app_hex:
467
+ click.echo(f"[TODO] flash app core: {app_hex}")
468
+ if net_hex:
469
+ click.echo(f"[TODO] flash net core: {net_hex}")
470
+
471
+
472
+ @cli.command("read-config", help="Read config from the device.")
473
+ @click.option(
474
+ "--sn-starting-digits",
475
+ "-s",
476
+ help="Serial number pattern to use for auto-selection, e.g. 77.",
477
+ )
478
+ def cmd_read_config(sn_starting_digits: str | None) -> None:
479
+ if sn_starting_digits:
480
+ snr = pick_matching_jlink_snr(sn_starting_digits)
481
+ else:
482
+ snr = pick_last_jlink_snr()
483
+ if snr is None:
484
+ raise click.ClickException(
485
+ "Unable to auto-select J-Link; provide --snr explicitly."
486
+ )
487
+ click.echo(f"[INFO] using J-Link with serial number: {snr}")
488
+ try:
489
+ readback_net_id = read_net_id(snr=snr)
490
+ readback_device_id = read_device_id(snr=snr)
491
+ except RuntimeError as exc:
492
+ click.echo(f"[WARN] readback failed: {exc}", err=True)
493
+ return
494
+ click.echo(f"[INFO] readback net_id: {readback_net_id}")
495
+ click.echo(f"[INFO] readback device_id: {readback_device_id}")
496
+
497
+
498
+ @cli.command(
499
+ "flash-bringup",
500
+ help="Flash J-Link OB or DAPLink programmer firmware.",
501
+ )
502
+ @click.option(
503
+ "--programmer-firmware",
504
+ "-p",
505
+ type=click.Choice(VALID_PROGRAMMERS),
506
+ required=True,
507
+ )
508
+ @click.option(
509
+ "--files-dir",
510
+ "-d",
511
+ type=click.Path(path_type=Path, file_okay=False, dir_okay=True),
512
+ required=True,
513
+ )
514
+ def cmd_flash_bringup(programmer_firmware: str, files_dir: Path) -> None:
515
+ files_dir = files_dir.expanduser().resolve()
516
+ if not files_dir.exists():
517
+ raise click.ClickException(f"files-dir does not exist: {files_dir}")
518
+
519
+ required = {
520
+ "jlink": JLINK_REQUIRED_FILES,
521
+ "daplink": DAPLINK_REQUIRED_FILES,
522
+ }[programmer_firmware]
523
+
524
+ missing = [name for name in required if not (files_dir / name).exists()]
525
+ if missing:
526
+ missing_list = ", ".join(missing)
527
+ raise click.ClickException(
528
+ f"Missing required files in {files_dir}: {missing_list}"
529
+ )
530
+
531
+ click.echo(f"[INFO] programmer: {programmer_firmware}")
532
+ click.echo(f"[INFO] files-dir: {files_dir}")
533
+ if programmer_firmware == "jlink":
534
+ jlink_bin = (files_dir / "JLink-ob.bin").resolve()
535
+ bl_hex = (files_dir / "stm32f103xb_bl.hex").resolve()
536
+ pack_path = str((files_dir / GEEHY_PACK_NAME).resolve())
537
+ do_jlink(
538
+ jlink_bin,
539
+ bl_hex,
540
+ apm_device=APM_DEVICE,
541
+ jlinktool=None,
542
+ pack_path=pack_path,
543
+ )
544
+ elif programmer_firmware == "daplink":
545
+ bl_hex = (files_dir / "stm32f103xb_bl.hex").resolve()
546
+ if_hex = (files_dir / "stm32f103xb_if.hex").resolve()
547
+ pack_path = str((files_dir / GEEHY_PACK_NAME).resolve())
548
+ do_daplink(
549
+ bl_hex, apm_device=APM_DEVICE, jlinktool=None, pack_path=pack_path
550
+ )
551
+ time.sleep(1.0)
552
+ do_daplink_if(if_hex, apm_device=APM_DEVICE, pack_path=pack_path)
553
+ else:
554
+ raise click.ClickException(
555
+ f"Invalid programmer firmware: {programmer_firmware}"
556
+ )
557
+
558
+ # small delay to let the target settle if needed
559
+ time.sleep(1.0)
560
+ click.secho(
561
+ f"[OK ] ==== {programmer_firmware} programmer firmware flashed ====",
562
+ fg="green",
563
+ )
564
+
565
+
566
+ def main() -> int:
567
+ cli(standalone_mode=True)
568
+ return 0
569
+
570
+
571
+ if __name__ == "__main__":
572
+ sys.exit(main())
@@ -0,0 +1,428 @@
1
+ #!/usr/bin/env python3
2
+ import os
3
+ import shutil
4
+ import subprocess
5
+ import tempfile
6
+ import time
7
+ from pathlib import Path
8
+
9
+ # Timings
10
+ POLL_INTERVAL = 1.0
11
+ TIMEOUT_JLINK_SEC = 120
12
+ TIMEOUT_BUILD_SEC = 900
13
+ TIMEOUT_MAINTENANCE_SEC = 300
14
+
15
+ DEFAULT_SWD_SPEED_KHZ = 4000
16
+
17
+
18
+ def run(cmd, timeout=None, cwd=None):
19
+ print(f"[CMD] {' '.join(cmd)}")
20
+ proc = subprocess.run(
21
+ cmd,
22
+ stdout=subprocess.PIPE,
23
+ stderr=subprocess.STDOUT,
24
+ text=True,
25
+ timeout=timeout,
26
+ cwd=cwd,
27
+ )
28
+ print(proc.stdout)
29
+ return proc.returncode, proc.stdout
30
+
31
+
32
+ def run_capture(cmd):
33
+ proc = subprocess.run(
34
+ cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True
35
+ )
36
+ if proc.returncode != 0:
37
+ raise RuntimeError(
38
+ proc.stdout.strip() or f"Command failed: {' '.join(cmd)}"
39
+ )
40
+ return proc.stdout
41
+
42
+
43
+ def which_tool(exe_name, user_supplied=None, candidates=None):
44
+ if user_supplied:
45
+ return user_supplied
46
+ p = shutil.which(exe_name)
47
+ if p:
48
+ return p
49
+ for c in candidates or []:
50
+ if Path(c).exists():
51
+ return c
52
+ return exe_name
53
+
54
+
55
+ # ---------- JLink / DAPLink (APM32F103) ----------
56
+ def make_jlink_script(device, speed_khz, hex_path):
57
+ lines = []
58
+ lines.append(f"device {device}")
59
+ lines.append("si SWD")
60
+ if speed_khz:
61
+ lines.append(f"speed {speed_khz}")
62
+ lines.append("connect")
63
+ lines.append("h")
64
+ lines.append("r")
65
+ lines.append("erase")
66
+ lines.append(f"loadfile {hex_path}")
67
+ lines.append("verify")
68
+ lines.append("r")
69
+ lines.append("g")
70
+ lines.append("exit")
71
+ return "\n".join(lines)
72
+
73
+
74
+ def jlink_flash_hex(jlink_exe, device, image_hex, timeout=TIMEOUT_JLINK_SEC):
75
+ speed_khz = DEFAULT_SWD_SPEED_KHZ
76
+ with tempfile.NamedTemporaryFile("w", delete=False, suffix=".jlink") as tf:
77
+ tf.write(make_jlink_script(device, speed_khz, str(image_hex)))
78
+ script_path = tf.name
79
+ try:
80
+ rc, out = run(
81
+ [jlink_exe, "-CommanderScript", script_path], timeout=timeout
82
+ )
83
+ finally:
84
+ try:
85
+ os.unlink(script_path)
86
+ except OSError:
87
+ pass
88
+ if rc != 0 or "ERROR" in out.upper() or "FAILED" in out.upper():
89
+ raise RuntimeError("J-Link flash failed; see log above.")
90
+
91
+
92
+ def pyocd_flash_hex(jlink_bin, device, pack_path: str):
93
+ erase_args = [
94
+ "pyocd",
95
+ "erase",
96
+ "--chip",
97
+ "--pack",
98
+ pack_path,
99
+ "-t",
100
+ str(device),
101
+ "--uid",
102
+ "261006773",
103
+ ]
104
+ rc, out = run(erase_args, timeout=60)
105
+ args = ["pyocd", "flash", str(jlink_bin)]
106
+ args += ["--pack", pack_path]
107
+ args += ["-t", str(device)]
108
+ rc, out = run(args, timeout=120)
109
+
110
+
111
+ def do_daplink(
112
+ bl_hex: Path, apm_device: str, jlinktool: str | None, pack_path: str
113
+ ):
114
+ """Flash STM32 bootloader (DAPLink) using external J-Link."""
115
+ jlink_tool = which_tool(
116
+ "JLink.exe",
117
+ jlinktool,
118
+ candidates=[
119
+ # r"C:\Program Files\SEGGER\JLink_V818\JLink.exe",
120
+ "/usr/local/bin/JLinkExe",
121
+ ],
122
+ )
123
+ if not bl_hex.exists():
124
+ raise FileNotFoundError(f"Bootloader image not found: {bl_hex}")
125
+
126
+ print("== Flashing STM32 bootloader (DAPLink) to APM32F103CB ==")
127
+ jlink_flash_hex(jlink_tool, apm_device, bl_hex)
128
+ print("[OK] DAPLink bootloader programmed.")
129
+
130
+
131
+ def do_daplink_if(if_hex: Path, apm_device: str, pack_path: str):
132
+ """Flash DAPLink interface firmware over SWD using pyOCD."""
133
+ if not if_hex.exists():
134
+ raise FileNotFoundError(f"DAPLink interface image not found: {if_hex}")
135
+
136
+ print("== Flashing DAPLink interface image via pyOCD ==")
137
+ pyocd_flash_hex(if_hex, apm_device, pack_path)
138
+ print("[OK] DAPLink interface programmed.")
139
+
140
+
141
+ def do_jlink(
142
+ jlink_bin: Path,
143
+ bl_hex: Path,
144
+ apm_device: str,
145
+ jlinktool: str | None,
146
+ pack_path: str,
147
+ ):
148
+ """Flash STM32 bootloader, then J-Link OB image (overwrites BL)."""
149
+ if not jlink_bin.exists():
150
+ raise FileNotFoundError(f"J-Link OB image not found: {jlink_bin}")
151
+
152
+ do_daplink(
153
+ bl_hex=bl_hex,
154
+ apm_device=apm_device,
155
+ jlinktool=jlinktool,
156
+ pack_path=pack_path,
157
+ )
158
+
159
+ print("[INFO] Waiting 5 seconds for STM32 bootloader to enumerate...")
160
+ time.sleep(5)
161
+
162
+ print("== Flashing J-Link OB image via pyOCD ==")
163
+ pyocd_flash_hex(jlink_bin, apm_device, pack_path)
164
+ print("[OK] J-Link OB programmed.")
165
+
166
+
167
+ # ---------- Flash nRF5340 with nrfjprog ----------
168
+ def pick_last_jlink_snr(nrfjprog_opt=None):
169
+ nrfjprog = which_tool(
170
+ "nrfjprog.exe",
171
+ nrfjprog_opt,
172
+ candidates=[
173
+ # r"C:\Program Files\Nordic Semiconductor\nrf-command-line-tools\bin\nrfjprog.exe"
174
+ "/usr/local/bin/nrfjprog",
175
+ ],
176
+ )
177
+
178
+ rc2, out2 = run([nrfjprog, "--ids"], timeout=10)
179
+ ids = (
180
+ [line.strip() for line in out2.splitlines() if line.strip().isdigit()]
181
+ if rc2 == 0
182
+ else []
183
+ )
184
+ print(f"[DEBUG] Found J-Link IDs: {ids}")
185
+ if ids:
186
+ return ids[-1]
187
+ raise RuntimeError(
188
+ "Unable to auto-select J-Link; provide --snr explicitly."
189
+ )
190
+
191
+
192
+ def pick_matching_jlink_snr(
193
+ sn_starting_digits: str, nrfjprog_opt: str | None = None
194
+ ):
195
+ nrfjprog = which_tool(
196
+ "nrfjprog.exe",
197
+ nrfjprog_opt,
198
+ candidates=[
199
+ # r"C:\Program Files\Nordic Semiconductor\nrf-command-line-tools\bin\nrfjprog.exe"
200
+ "/usr/local/bin/nrfjprog",
201
+ ],
202
+ )
203
+ rc2, out2 = run([nrfjprog, "--ids"], timeout=10)
204
+ ids = (
205
+ [
206
+ line.strip()
207
+ for line in out2.splitlines()
208
+ if line.strip().isdigit()
209
+ and line.strip().startswith(sn_starting_digits)
210
+ ]
211
+ if rc2 == 0
212
+ else []
213
+ )
214
+ print(f"[DEBUG] Found J-Link IDs: {ids}")
215
+ if not ids:
216
+ raise RuntimeError(
217
+ f"No J-Link found with serial number starting with {sn_starting_digits}"
218
+ )
219
+ return ids[0]
220
+
221
+
222
+ def nrfjprog_recover(nrfjprog, snr=None):
223
+ args = [nrfjprog, "-f", "NRF53"]
224
+ if snr:
225
+ args += ["-s", str(snr)]
226
+ print(f"[INFO] Recovering both cores of nRF5340 (SNR={snr})...")
227
+ rc, out = run(
228
+ args + ["--recover", "--coprocessor", "CP_APPLICATION"], timeout=120
229
+ )
230
+ rc, out = run(
231
+ args + ["--recover", "--coprocessor", "CP_NETWORK"], timeout=120
232
+ )
233
+ print(f"[INFO] Erasing both cores of nRF5340 (SNR={snr})...")
234
+ rc, out = run(args + ["-e"], timeout=120)
235
+
236
+
237
+ def nrfjprog_program(
238
+ nrfjprog,
239
+ hex_path,
240
+ network=False,
241
+ verify=True,
242
+ reset=True,
243
+ chiperase=True,
244
+ snr=None,
245
+ ):
246
+ args = [nrfjprog, "-f", "NRF53"]
247
+ if snr:
248
+ args += ["-s", str(snr)]
249
+ if network:
250
+ args += ["--coprocessor", "CP_NETWORK"]
251
+ else:
252
+ args += ["--coprocessor", "CP_APPLICATION"]
253
+ args += ["--program", str(hex_path)]
254
+ if verify:
255
+ args += ["--verify"]
256
+ if chiperase:
257
+ args += ["--chiperase"]
258
+ if reset:
259
+ args += ["--reset"]
260
+ rc, out = run(args, timeout=120)
261
+ if rc != 0 or "ERROR" in out.upper() or "failed" in out.lower():
262
+ raise RuntimeError("nrfjprog programming failed; see log above.")
263
+
264
+
265
+ def _parse_memrd_words(output: str) -> list[str]:
266
+ line = output.strip().splitlines()[0] if output.strip() else ""
267
+ if ":" not in line:
268
+ raise RuntimeError(f"Unexpected memrd output: {output.strip()}")
269
+ _, rest = line.split(":", 1)
270
+ words = [w for w in rest.strip().split() if not w.startswith(("0x", "0X"))]
271
+ return words
272
+
273
+
274
+ def read_device_id(snr: str | None = None) -> str:
275
+ nrfjprog = which_tool(
276
+ "nrfjprog.exe",
277
+ candidates=[
278
+ # r"C:\Program Files\Nordic Semiconductor\nrf-command-line-tools\bin\nrfjprog.exe"
279
+ "/usr/local/bin/nrfjprog",
280
+ ],
281
+ )
282
+ args = [nrfjprog, "-f", "NRF53"]
283
+ args += ["--coprocessor", "CP_NETWORK"]
284
+ args += ["--memrd", "0x01FF0204"]
285
+ args += ["--n", "8"]
286
+ if snr:
287
+ args += ["-s", str(snr)]
288
+ out = run_capture(args)
289
+ words = _parse_memrd_words(out)
290
+ if len(words) < 2:
291
+ raise RuntimeError(f"Unexpected device ID output: {out.strip()}")
292
+ return f"{words[1]}{words[0]}"
293
+
294
+
295
+ def read_net_id(snr: str | None = None) -> str:
296
+ nrfjprog = which_tool(
297
+ "nrfjprog.exe",
298
+ candidates=[
299
+ # r"C:\Program Files\Nordic Semiconductor\nrf-command-line-tools\bin\nrfjprog.exe"
300
+ "/usr/local/bin/nrfjprog",
301
+ ],
302
+ )
303
+ args = [nrfjprog, "-f", "NRF53"]
304
+ args += ["--coprocessor", "CP_NETWORK"]
305
+ args += ["--memrd", "0x0103F804"]
306
+ args += ["--n", "4"]
307
+ if snr:
308
+ args += ["-s", str(snr)]
309
+ out = run_capture(args)
310
+ words = _parse_memrd_words(out)
311
+ if len(words) < 1:
312
+ raise RuntimeError(f"Unexpected net ID output: {out.strip()}")
313
+ return f"{words[0][-4:]}"
314
+
315
+
316
+ def flash_nrf_both_cores(
317
+ app_hex: Path, net_hex: Path, nrfjprog_opt: str | None, snr_opt: str | None
318
+ ):
319
+ """Flash nRF5340 application and network cores with full recover + chiperase."""
320
+ if not app_hex.exists():
321
+ raise FileNotFoundError(f"App hex not found: {app_hex}")
322
+ if not net_hex.exists():
323
+ raise FileNotFoundError(f"Net hex not found: {net_hex}")
324
+
325
+ nrfjprog = which_tool(
326
+ "nrfjprog.exe",
327
+ nrfjprog_opt,
328
+ candidates=[
329
+ # r"C:\Program Files\Nordic Semiconductor\nrf-command-line-tools\bin\nrfjprog.exe"
330
+ "/usr/local/bin/nrfjprog",
331
+ ],
332
+ )
333
+
334
+ snr = snr_opt or pick_last_jlink_snr(nrfjprog)
335
+ print(f"[INFO] Using J-Link with serial number: {snr}")
336
+
337
+ nrfjprog_recover(nrfjprog, snr=snr)
338
+
339
+ print("== Flashing nRF5340 application core with nrfjprog ==")
340
+ nrfjprog_program(
341
+ nrfjprog,
342
+ app_hex,
343
+ network=False,
344
+ verify=True,
345
+ reset=True,
346
+ chiperase=True,
347
+ snr=snr,
348
+ )
349
+ print("[OK] Application core programmed.")
350
+
351
+ print("== Flashing nRF5340 network core with nrfjprog ==")
352
+ nrfjprog_program(
353
+ nrfjprog,
354
+ net_hex,
355
+ network=True,
356
+ verify=True,
357
+ reset=True,
358
+ chiperase=True,
359
+ snr=snr,
360
+ )
361
+ print("[OK] Network core programmed.")
362
+
363
+
364
+ def flash_nrf_one_core(
365
+ app_hex: Path | None = None,
366
+ net_hex: Path | None = None,
367
+ nrfjprog_opt: str | None = None,
368
+ snr_opt: str | None = None,
369
+ ):
370
+ """Flash only one core; no recover and no chiperase."""
371
+ if app_hex is None and net_hex is None:
372
+ raise FileNotFoundError("Provide app_hex or net_hex.")
373
+ if app_hex is not None and net_hex is not None:
374
+ raise FileNotFoundError("Provide only one of app_hex or net_hex.")
375
+ if app_hex is not None and not app_hex.exists():
376
+ raise FileNotFoundError(f"App hex not found: {app_hex}")
377
+ if net_hex is not None and not net_hex.exists():
378
+ raise FileNotFoundError(f"Net hex not found: {net_hex}")
379
+
380
+ nrfjprog = which_tool(
381
+ "nrfjprog.exe",
382
+ nrfjprog_opt,
383
+ candidates=[
384
+ # r"C:\Program Files\Nordic Semiconductor\nrf-command-line-tools\bin\nrfjprog.exe"
385
+ "/usr/local/bin/nrfjprog",
386
+ ],
387
+ )
388
+
389
+ snr = snr_opt or pick_last_jlink_snr(nrfjprog)
390
+ print(f"[INFO] Using J-Link with serial number: {snr}")
391
+
392
+ if app_hex is not None:
393
+ print("== Flashing nRF5340 application core with nrfjprog ==")
394
+ nrfjprog_program(
395
+ nrfjprog,
396
+ app_hex,
397
+ network=False,
398
+ verify=True,
399
+ reset=True,
400
+ chiperase=False,
401
+ snr=snr,
402
+ )
403
+ print("[OK] Application core programmed.")
404
+ else:
405
+ print("== Flashing nRF5340 network core with nrfjprog ==")
406
+ nrfjprog_program(
407
+ nrfjprog,
408
+ net_hex,
409
+ network=True,
410
+ verify=True,
411
+ reset=True,
412
+ chiperase=False,
413
+ snr=snr,
414
+ )
415
+ print("[OK] Network core programmed.")
416
+ # also need to reset the application core (without programming, just reset)
417
+ nrfjprog_reset_core(nrfjprog, snr=snr, core="CP_APPLICATION")
418
+ print("[OK] Application core reset.")
419
+
420
+
421
+ def nrfjprog_reset_core(nrfjprog, snr=None, core="CP_APPLICATION"):
422
+ args = [nrfjprog, "-f", "NRF53"]
423
+ if snr:
424
+ args += ["-s", str(snr)]
425
+ args += ["--reset", "--coprocessor", core]
426
+ rc, out = run(args, timeout=120)
427
+ if rc != 0 or "ERROR" in out.upper() or "failed" in out.lower():
428
+ raise RuntimeError("nrfjprog reset failed; see log above.")
@@ -0,0 +1,3 @@
1
+ [provisioning]
2
+ network_id = "0100"
3
+ # firmware_version = "v0.6.0"
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = [
3
+ "hatchling>=1.4.1",
4
+ ]
5
+ build-backend = "hatchling.build"
6
+
7
+ [tool.hatch.build]
8
+ include = [
9
+ "dotbot_provision/**",
10
+ "README.md",
11
+ "provision-config-sample.toml",
12
+ ]
13
+ exclude = [
14
+ ]
15
+
16
+ [tool.hatch.version]
17
+ path = "dotbot_provision/__init__.py"
18
+
19
+ [project]
20
+ name = "dotbot-provision"
21
+ dynamic = ["version"]
22
+ authors = [
23
+ { name="Geovane Fedrecheski", email="geovane.fedrecheski@inria.fr" },
24
+ ]
25
+ dependencies = [
26
+ "click >= 8.1.7",
27
+ "intelhex >= 2.3.0",
28
+ "tomli >= 2.0.1; python_version < '3.11'",
29
+ ]
30
+ description = "A command-line tool for provisioning DotBot devices and gateways."
31
+ readme = "README.md"
32
+ requires-python = ">=3.8"
33
+ classifiers = [
34
+ "Programming Language :: Python :: 3",
35
+ "Operating System :: MacOS",
36
+ "Operating System :: POSIX :: Linux",
37
+ "Operating System :: Microsoft :: Windows",
38
+ ]
39
+
40
+ [project.scripts]
41
+ dotbot-provision = "dotbot_provision.cli:main"