Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion dotbot/cli/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,15 +251,31 @@ def flash_programmer(programmer_firmware, files_dir, probe_uid):

@cmd.command()
@_probe_option
def info(probe):
@click.option(
"--yes",
"-y",
is_flag=True,
help="Skip the confirmation prompt (the network id read resets the device).",
)
def info(probe, yes):
"""Read a device's provisioning state (chip id + network identity).

Reading the network id resets the device: the swarm config lives in the
network core's flash, and attaching the debugger there restarts that core.
On a DotBot mid-experiment that drops the radio until the device is reset,
so the command asks first. Pass -y to skip the prompt.

Never fails on a blank/unprovisioned board — reports 'not
provisioned' and how to fix it.
"""
from dotbot.firmware.flash import read_config_report
from dotbot.firmware.nrf import NET_CORE_READ_WARNING

ensure_nrfjprog()
if not yes:
click.secho(f"[WARN] {NET_CORE_READ_WARNING}", fg="yellow")
if not click.confirm("Read it anyway?", default=True):
raise click.ClickException("Aborted.")
try:
net_id, device_id = read_config_report(probe)
except RuntimeError as exc:
Expand Down
38 changes: 29 additions & 9 deletions dotbot/firmware/flash.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
pick_matching_jlink_snr,
read_device_id,
read_net_id,
reset_device,
)

try:
Expand Down Expand Up @@ -496,20 +497,30 @@ def flash_role(
else:
click.echo(f"[INFO] using existing config hex: {config_hex}")
click.echo()
flash_nrf_both_cores(app_hex, net_hex, nrfjprog_opt=None, snr_opt=snr)
flash_nrf_one_core(net_hex=config_hex, nrfjprog_opt=None, snr_opt=snr)
# Program every image first and reset once at the end. The two cores
# hand-shake over shared memory during bring-up, so they have to start
# together, and starting them before the config page is written boots the
# network core against an erased net_id.
flash_nrf_both_cores(app_hex, net_hex, nrfjprog_opt=None, snr_opt=snr, reset=False)
flash_nrf_one_core(net_hex=config_hex, nrfjprog_opt=None, snr_opt=snr, reset=False)
if default_app_hex is not None:
click.echo(f"[INFO] default app hex: {default_app_hex}")
flash_nrf_one_core(app_hex=default_app_hex, nrfjprog_opt=None, snr_opt=snr)
flash_nrf_one_core(
app_hex=default_app_hex, nrfjprog_opt=None, snr_opt=snr, reset=False
)
elif device == "dotbot-v3":
click.echo("[INFO] default app hex not found; skipping.")
click.secho("\n[INFO] ==== Flash Complete ====\n", fg="green")
time.sleep(0.2)
readback_net_id = readback_device_id = None
try:
readback_net_id = read_net_id(snr=snr)
readback_device_id = read_device_id(snr=snr)
except RuntimeError as exc:
click.echo(f"[WARN] readback failed: {exc}", err=True)
if readback_net_id is None or readback_device_id is None:
reset_device(snr=snr)
click.echo("[OK ] device reset (CTRL-AP); it should join on its own")
return
click.echo("[INFO] readback values:")
click.echo(f"[INFO] net_id: {readback_net_id}")
Expand All @@ -520,11 +531,10 @@ def flash_role(
click.echo(
f"[INFO] device_id: {readback_device_id} (last 6 digits: {last_6_digits_spaced})"
)
click.secho(
"[NOTE] you may need to press the reset button on the DotBot "
"for it to join the network",
fg="yellow",
)
# Reset last. Reading the config page back halts the network core, so a
# reset done any earlier leaves the device programmed but stopped.
reset_device(snr=snr)
click.echo("[OK ] device reset (CTRL-AP); it should join on its own")


def flash_app_image(
Expand Down Expand Up @@ -584,7 +594,17 @@ def read_config_report(sn_starting_digits: str | None = None) -> tuple[str, str]
"J-Link serial-number prefix (e.g. --probe 77)."
)
click.echo(f"[INFO] using J-Link with serial number: {snr}", err=True)
return read_net_id(snr=snr), read_device_id(snr=snr)
try:
return read_net_id(snr=snr), read_device_id(snr=snr)
finally:
# Reading the config page attaches the debugger to the network core,
# which resets it. The application core does not notice, so the device
# is left alive but with no radio - indistinguishable from dead. Reset
# the whole device so both cores come back up together.
reset_device(snr=snr)
click.echo(
"[INFO] device reset (CTRL-AP) after the network-core read", err=True
)


def flash_programmer(
Expand Down
114 changes: 97 additions & 17 deletions dotbot/firmware/nrf.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,9 +308,12 @@ def read_device_id(snr: str | None = None) -> str:
"/usr/bin/nrfjprog",
],
)
# Read FICR.INFO.DEVICEID from the APPLICATION core. The same value is
# mirrored in the network core's FICR, but attaching the debugger to that
# coprocessor resets it, which kills the radio on a running bot; the
# application core reads back with no side effect.
args = [nrfjprog, "-f", "NRF53"]
args += ["--coprocessor", "CP_NETWORK"]
args += ["--memrd", "0x01FF0204"]
args += ["--memrd", "0x00FF0204"]
args += ["--n", "8"]
if snr:
args += ["-s", str(snr)]
Expand All @@ -321,6 +324,16 @@ def read_device_id(snr: str | None = None) -> str:
return f"{words[1]}{words[0]}"


#: Reading the swarmit config page means attaching to the network core, which
#: resets it. A bot that was running loses its radio and looks dead until the
#: whole device is reset, so callers must say they accept that.
NET_CORE_READ_WARNING = (
"Reading the network id attaches the debugger to the network core, which "
"RESETS it. If this DotBot is running an experiment it will drop off the "
"network and stay off until the device is reset."
)


def read_net_id(snr: str | None = None) -> str:
nrfjprog = which_tool(
"nrfjprog.exe",
Expand Down Expand Up @@ -350,9 +363,19 @@ def read_net_id(snr: str | None = None) -> str:


def flash_nrf_both_cores(
app_hex: Path, net_hex: Path, nrfjprog_opt: str | None, snr_opt: str | None
app_hex: Path,
net_hex: Path,
nrfjprog_opt: str | None,
snr_opt: str | None,
reset: bool = True,
):
"""Flash nRF5340 application and network cores with full recover + chiperase."""
"""Flash nRF5340 application and network cores with full recover + chiperase.

``reset=False`` leaves both cores halted so a caller that still has pages to
write (the config page, a default app) can program everything first and
reset once at the end. Resetting between writes would boot the cores against
a half-provisioned device.
"""
if not app_hex.exists():
raise FileNotFoundError(f"App hex not found: {app_hex}")
if not net_hex.exists():
Expand All @@ -373,29 +396,37 @@ def flash_nrf_both_cores(

nrfjprog_recover(nrfjprog, snr=snr)

print("== Flashing nRF5340 application core with nrfjprog ==")
# The network core is programmed first so the application core, which waits
# on it during bring-up, never boots against a blank peer. Neither program
# step resets: the two cores hand-shake over shared memory at startup, so
# they have to start together, which the CTRL-AP reset below does.
print("== Flashing nRF5340 network core with nrfjprog ==")
nrfjprog_program(
nrfjprog,
app_hex,
network=False,
net_hex,
network=True,
verify=True,
reset=True,
reset=False,
chiperase=True,
snr=snr,
)
print("[OK] Application core programmed.")
print("[OK] Network core programmed.")

print("== Flashing nRF5340 network core with nrfjprog ==")
print("== Flashing nRF5340 application core with nrfjprog ==")
nrfjprog_program(
nrfjprog,
net_hex,
network=True,
app_hex,
network=False,
verify=True,
reset=True,
reset=False,
chiperase=True,
snr=snr,
)
print("[OK] Network core programmed.")
print("[OK] Application core programmed.")

if reset:
nrfjprog_debugreset(nrfjprog, snr=snr)
print("[OK] Device reset (CTRL-AP).")


def flash_nrf_one_core(
Expand All @@ -404,6 +435,7 @@ def flash_nrf_one_core(
family: str = "NRF53",
nrfjprog_opt: str | None = None,
snr_opt: str | None = None,
reset: bool = True,
):
"""Flash only one core; no recover and no chiperase.

Expand Down Expand Up @@ -460,16 +492,64 @@ def flash_nrf_one_core(
snr=snr,
)
print("[OK] Network core programmed.")
if not reset:
return
time.sleep(0.5)
if is_multicore_family(family):
# reset every core
nrfjprog_reset_core(nrfjprog, snr=snr, core="CP_NETWORK", family=family)
nrfjprog_reset_core(nrfjprog, snr=snr, core="CP_APPLICATION", family=family)
# One CTRL-AP reset for the whole device rather than a SysResetReq per
# core: the cores hand-shake over shared memory during bring-up, so
# restarting them one at a time strands whichever starts first, and the
# SwarmIT bootloader refuses SysResetReq outright (see
# nrfjprog_debugreset).
nrfjprog_debugreset(nrfjprog, snr=snr, family=family)
else:
# single-core family: one reset, no --coprocessor
nrfjprog_reset_core(nrfjprog, snr=snr, core=None, family=family)


def nrfjprog_debugreset(nrfjprog, snr=None, family="NRF53"):
"""Reset the whole device through CTRL-AP.

`--reset` issues a SysResetReq, which firmware can refuse: the SwarmIT
bootloader sets `SCB_AIRCR.SYSRESETREQS` to keep non-secure code from
resetting the SoC, and the request is then dropped. A device flashed that
way keeps running its pre-flash state until someone presses the button.
CTRL-AP resets from the debug domain instead, so firmware cannot veto it.
"""
args = [nrfjprog, "-f", family]
if snr:
args += ["-s", str(snr)]
args += ["--debugreset"]
rc, out = run(args, timeout=120)
if rc != 0 or "ERROR" in out.upper() or "failed" in out.lower():
raise RuntimeError("nrfjprog debug reset failed; see log above.")


def reset_device(snr=None, family="NRF53", nrfjprog_opt=None, settle: float = 2.0):
"""CTRL-AP reset of the whole device, for callers that staged writes.

``settle`` waits before resetting: a reset issued immediately after the last
programming step has been observed to leave the device down, while the same
reset a couple of seconds later brings it up.
"""
nrfjprog = which_tool(
"nrfjprog.exe",
nrfjprog_opt,
candidates=["/usr/local/bin/nrfjprog", "/usr/bin/nrfjprog"],
)
if settle:
time.sleep(settle)
nrfjprog_debugreset(nrfjprog, snr=snr, family=family)
# A CTRL-AP reset can leave the core halted, which looks exactly like a dead
# board: programmed, reset, and never running. Start it explicitly. Best
# effort - if the core is already running this is a no-op that some tool
# versions report as an error.
args = [nrfjprog, "-f", family]
if snr:
args += ["-s", str(snr)]
run(args + ["--run"], timeout=60)


def nrfjprog_reset_core(nrfjprog, snr=None, core="CP_APPLICATION", family="NRF53"):
args = [nrfjprog, "-f", family]
if snr:
Expand Down
50 changes: 46 additions & 4 deletions dotbot/tests/test_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ def test_info_reports_provisioned(runner, _no_nrfjprog_gate, monkeypatch):
"dotbot.firmware.flash.read_config_report",
lambda sn=None: ("1234", "BDF2B04BC00D2725"),
)
result = runner.invoke(device_cmd, ["info", "--probe", "77"])
result = runner.invoke(device_cmd, ["info", "--probe", "77", "-y"])
assert result.exit_code == 0, result.output
assert "provisioned" in result.output
assert "0x1234" in result.output
Expand All @@ -285,7 +285,7 @@ def test_info_reports_unprovisioned_without_failing(
"dotbot.firmware.flash.read_config_report",
lambda sn=None: ("unprovisioned", "BDF2B04BC00D2725"),
)
result = runner.invoke(device_cmd, ["info"])
result = runner.invoke(device_cmd, ["info", "-y"])
assert result.exit_code == 0, result.output
assert "not provisioned" in result.output
assert "flash-swarmit-sandbox" in result.output
Expand All @@ -296,15 +296,57 @@ def boom(sn=None):
raise RuntimeError("no probe")

monkeypatch.setattr("dotbot.firmware.flash.read_config_report", boom)
result = runner.invoke(device_cmd, ["info"])
result = runner.invoke(device_cmd, ["info", "-y"])
assert result.exit_code != 0
assert "Could not read the device" in result.output


def test_info_warns_and_aborts_without_confirmation(
runner, _no_nrfjprog_gate, monkeypatch
):
"""Reading the net id resets the device, so it must be confirmed."""
called = []
monkeypatch.setattr(
"dotbot.firmware.flash.read_config_report",
lambda sn=None: called.append(sn) or ("1234", "BDF2B04BC00D2725"),
)
result = runner.invoke(device_cmd, ["info"], input="n\n")
assert result.exit_code != 0
assert "RESETS it" in result.output
assert not called, "device must not be touched when the user declines"


def test_info_yes_flag_skips_the_prompt(runner, _no_nrfjprog_gate, monkeypatch):
monkeypatch.setattr(
"dotbot.firmware.flash.read_config_report",
lambda sn=None: ("1234", "BDF2B04BC00D2725"),
)
result = runner.invoke(device_cmd, ["info", "-y"])
assert result.exit_code == 0, result.output
assert "Read it anyway?" not in result.output


def test_read_device_id_does_not_touch_the_network_core(monkeypatch):
"""The device id comes from the app core; the net-core read resets it."""
from dotbot.firmware import nrf

seen = {}

def fake_run_capture(args):
seen["args"] = args
return "0x00FF0204: 596212AE A23EFBCB\n"

monkeypatch.setattr(nrf, "run_capture", fake_run_capture)
monkeypatch.setattr(nrf, "which_tool", lambda *a, **k: "nrfjprog")
assert nrf.read_device_id(snr="770394359") == "A23EFBCB596212AE"
assert "CP_NETWORK" not in seen["args"]
assert "0x00FF0204" in seen["args"]


def test_nrfjprog_missing_gives_friendly_error(runner, monkeypatch):
"""No nrfjprog → a clear install hint, not a stack trace."""
monkeypatch.setattr("dotbot.firmware.nrf.nrfjprog_available", lambda: False)
result = runner.invoke(device_cmd, ["info"])
result = runner.invoke(device_cmd, ["info", "-y"])
assert result.exit_code != 0
assert "nrfjprog" in result.output

Expand Down
Loading