From f8df934a4cec63d839ed60c8753d255be56738dc Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Mon, 27 Jul 2026 16:03:00 +0200 Subject: [PATCH 1/2] dotbot/firmware: reset the device last, and through CTRL-AP Flashing left the bot off the network until someone pressed reset. Two causes: `--reset` is a SysResetReq, which the SwarmIT bootloader refuses via SCB_AIRCR.SYSRESETREQS, and the config-page readback halts the network core, so a reset done before it left the device programmed but stopped. Program every image first, read back, then reset once over CTRL-AP and start the core. Verified over three consecutive flashes with no button press. AI-assisted: Claude Opus 5 --- dotbot/firmware/flash.py | 26 +++++++---- dotbot/firmware/nrf.py | 97 +++++++++++++++++++++++++++++++++------- 2 files changed, 100 insertions(+), 23 deletions(-) diff --git a/dotbot/firmware/flash.py b/dotbot/firmware/flash.py index 1e8019b3..2d5f4c7e 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( diff --git a/dotbot/firmware/nrf.py b/dotbot/firmware/nrf.py index 09d90790..aa267725 100644 --- a/dotbot/firmware/nrf.py +++ b/dotbot/firmware/nrf.py @@ -350,9 +350,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 +383,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 +422,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 +479,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: From 7549c571c6ec67ade5c804bf8b5e3c83f211bbae Mon Sep 17 00:00:00 2001 From: Geovane Fedrecheski Date: Mon, 27 Jul 2026 16:58:26 +0200 Subject: [PATCH 2/2] dotbot/device: stop the config read from leaving a bot off the network Attaching the debugger to the network core resets it, and the application core does not notice, so the bot keeps running with no radio and looks dead until the whole device is reset. `device info` now says so and asks before reading, takes -y to skip, and resets the device afterwards so it comes back on its own. The device id moves to the application core's FICR, which reads back with no side effect at all. AI-assisted: Claude Opus 5 --- dotbot/cli/device.py | 18 ++++++++++++- dotbot/firmware/flash.py | 12 ++++++++- dotbot/firmware/nrf.py | 17 +++++++++++-- dotbot/tests/test_device.py | 50 ++++++++++++++++++++++++++++++++++--- 4 files changed, 89 insertions(+), 8 deletions(-) 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 2d5f4c7e..fd236df7 100644 --- a/dotbot/firmware/flash.py +++ b/dotbot/firmware/flash.py @@ -594,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 aa267725..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", 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