diff --git a/dotbot/cli/device.py b/dotbot/cli/device.py index 6448d40b..2ae00d51 100644 --- a/dotbot/cli/device.py +++ b/dotbot/cli/device.py @@ -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: diff --git a/dotbot/firmware/flash.py b/dotbot/firmware/flash.py index 1e8019b3..fd236df7 100644 --- a/dotbot/firmware/flash.py +++ b/dotbot/firmware/flash.py @@ -28,6 +28,7 @@ pick_matching_jlink_snr, read_device_id, read_net_id, + reset_device, ) try: @@ -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}") @@ -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( @@ -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( diff --git a/dotbot/firmware/nrf.py b/dotbot/firmware/nrf.py index 09d90790..8c311187 100644 --- a/dotbot/firmware/nrf.py +++ b/dotbot/firmware/nrf.py @@ -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)] @@ -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", @@ -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(): @@ -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( @@ -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. @@ -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: diff --git a/dotbot/tests/test_device.py b/dotbot/tests/test_device.py index 62f850c8..9e003883 100644 --- a/dotbot/tests/test_device.py +++ b/dotbot/tests/test_device.py @@ -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 @@ -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 @@ -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