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.
- dotbot_provision-0.1.0/.gitignore +31 -0
- dotbot_provision-0.1.0/PKG-INFO +74 -0
- dotbot_provision-0.1.0/README.md +59 -0
- dotbot_provision-0.1.0/dotbot_provision/__init__.py +3 -0
- dotbot_provision-0.1.0/dotbot_provision/cli.py +572 -0
- dotbot_provision-0.1.0/dotbot_provision/nrf_flash.py +428 -0
- dotbot_provision-0.1.0/provision-config-sample.toml +3 -0
- dotbot_provision-0.1.0/pyproject.toml +41 -0
|
@@ -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,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,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"
|