#!/bin/sh # som-program-nrf91 -- program an Emcraft SOM-NRF9151's flash over SWD through a # J-Link, from a brintOS machine, and say exactly what was written from where. # # The nRF91 counterpart of som-program-f7, built from what the hand runs of # 2026-10-05 found on an SOM-NRF9151-BSB-1A (the station guide's section 4.3 # records the same three traps): # # * asset_tracker_v2 1.2-114 runs a watchdog that resets the SoC a few # seconds into any debugger halt. Its shell can turn it off, so this script # does that over the console, and then refuses to erase anything until the # CPU has stayed halted without a reset for 12 s, or for the watchdog's own # timeout and 3 s more when the shell reports a longer one, or the nRF91's # own WDT0 counts while the core is halted (--gate: longer). # * An erased nRF91x1 locks debug access at its next reset. So the erase, # write and verify run in one openocd session with no reset inside it, and # the board is reset only once all three passed. openocd's own `program` is # not used: it starts with `reset init`. # * The UICR holds write-once OTP (b0's key hashes at 0xFF8130). It is never # written. An image's UICR records are compared with the chip's instead: a # mismatch means the image is signed for other keys and would not boot. # # Every run prints one `image:` line naming the file and its sha256, and ends # with a single PASS or FAIL line that also says how long the run took. That # output is the record of what a board was programmed with. Exit status: 0 PASS, # 1 FAIL, 2 usage, and 129, 130 or 143 when a HUP, INT (Ctrl-C) or TERM stopped # it. An interrupt is honoured between steps, never inside an openocd session: # a write cut short leaves an erased chip that must not be reset, so the session # in progress runs to its end and its outcome is reported before the stop. set -u PROG=som-program-nrf91 SCRIPTS="${SOM_OPENOCD_SCRIPTS:-/usr/share/openocd/scripts}" BOARD_CFG="${SOM_BOARD_CFG:-$SCRIPTS/board/emcraft-som-nrf9151.cfg}" MEDIA=/mnt/media/nrf9151/app.hex FW_DIR=/usr/share/brintos-som/nrf9151 # the shipped releases, one directory each CONSOLE_ID=0403:6015 # the BSB's FT230X FLASH_SIZE=0x100000 GATE_SECONDS=12 # the shortest halt gate GATE_MAX=600 image="" app="" addr="" speed="" adapter="" console="" use_console=1 expect_ver="" expect_from=argument modem_min="" erase=touched do_backup=0 # production time first: --backup reads out what the erase will clear gate_req="" ignore_retry=0 check_only=0 dry_run=0 modem_check=1 split_dir="" # --split-only: the build-time split of a release, then exit fw_apps() { # the releases this machine carries, or "none" l=$(ls "$FW_DIR" 2>/dev/null | tr '\n' ' ') echo "${l:-none}" } usage() { printf '%s\n' \ "usage: $PROG [--app NAME | --image FILE] [--addr ADDR] [--expect TEXT] [--speed KHZ]" \ " [--adapter NAME] [--console DEV | --no-console]" \ " [--erase all|touched] [--backup] [--gate SECONDS]" \ " [--no-modem-check] [--ignore-retry] [--check] [--dry-run]" \ "" \ " --app NAME a release this machine carries: $(fw_apps)" \ " --image FILE Intel HEX (preferred) or raw .bin to program" \ " --addr ADDR load address for a .bin (a .hex carries its own)" \ " --expect TEXT after the write, the board's \`version\` must contain TEXT" \ " --speed KHZ SWD clock (default: the board config's 1000)" \ " --adapter NAME another openocd interface config (default: jlink)" \ " --console DEV the SOM's console node (default: som-console --find --id $CONSOLE_ID)" \ " --no-console do not touch the console: no watchdog step, no boot check" \ " --erase MODE touched (default): only the 4 KiB pages the image covers;" \ " all: the whole 1 MiB first" \ " --backup first read out what the erase will clear (off by default)" \ " --no-backup accepted for older scripts; it changes nothing" \ " --gate SECONDS hold the core halted at least this long before erasing" \ " (default $GATE_SECONDS, or a watchdog's timeout and 3 s more, as the" \ " board's shell or the chip reports it; at most $GATE_MAX)" \ " --no-modem-check do not read the modem firmware before writing" \ " --ignore-retry run although a failed write left its retry session in" \ " \${TMPDIR:-/tmp} (the board was rewritten since)" \ " --check probe the chip (and an --image's UICR) only; write nothing" \ " --dry-run resolve and report the image, touch no hardware" \ "" \ "Image resolution: --app (in $FW_DIR), --image, then $MEDIA." \ "An image's PROVENANCE beside it supplies --expect, and the oldest modem firmware" \ "it runs on: the station cannot update the modem, so an older one is refused." \ "Backup (--backup): to \$SOM_BACKUP_DIR, default /tmp (RAM, never stored with the" \ "machine). Keep firmware in /tmp too: a clean Halt publishes the stored disk." \ "Never written: the UICR. Never run: nrf91_recover. Interrupts: honoured between" \ "steps; a write session in progress runs to its end first (exit 129/130/143)." } while [ $# -gt 0 ]; do case "$1" in --app) app=${2:-}; shift 2 || exit 2 ;; --image) image=${2:-}; shift 2 || exit 2 ;; --addr) addr=${2:-}; shift 2 || exit 2 ;; --expect) expect_ver=${2:-}; shift 2 || exit 2 ;; --speed) speed=${2:-}; shift 2 || exit 2 ;; --adapter) adapter=${2:-}; shift 2 || exit 2 ;; --console) console=${2:-}; shift 2 || exit 2 ;; --no-console) use_console=0; shift ;; --erase) erase=${2:-}; shift 2 || exit 2 ;; --backup) do_backup=1; shift ;; --no-backup) shift ;; # the default since the 2026-10-10 review --gate) gate_req=${2:-}; shift 2 || exit 2 ;; --ignore-retry) ignore_retry=1; shift ;; --no-modem-check) modem_check=0; shift ;; --check) check_only=1; shift ;; --dry-run) dry_run=1; shift ;; --split-only) split_dir=${2:-}; shift 2 || exit 2 ;; -h|--help) usage; exit 0 ;; *) echo "$PROG: unknown argument: $1" >&2; usage >&2; exit 2 ;; esac done now() { date +%s; } T_START=$(now) took() { # how long this run has taken so far, for the PASS/FAIL line t=$(( $(now) - T_START )) echo "$((t / 60)) min $((t % 60)) s" } fail() { echo "$PROG: FAIL -- $* [$(took)]"; exit 1; } say() { echo "$PROG: $*"; } case "$erase" in all|touched) ;; *) fail "--erase must be all or touched" ;; esac if [ -n "$speed" ]; then case "$speed" in *[!0-9]*|0*) fail "bad --speed '$speed': give kHz as a whole number" ;; esac [ "$speed" -le 2000 ] || fail "--speed $speed: flash programming fails above 2000 kHz on this chip" fi if [ -n "$gate_req" ]; then case "$gate_req" in *[!0-9]*|0*) fail "bad --gate '$gate_req': give whole seconds" ;; esac [ "$gate_req" -le "$GATE_MAX" ] || fail "--gate $gate_req: the most is $GATE_MAX s" fi # $((addr)) would read a word such as `size` as a variable, and a leading 0 as octal. num_ok() { case "$1" in 0[xX]?*) case "${1#0[xX]}" in *[!0-9a-fA-F]*) return 1 ;; esac ;; 0|[1-9]*) case "$1" in *[!0-9]*) return 1 ;; esac ;; *) return 1 ;; esac } # --- resolve the image -------------------------------------------------------- source_kind="" if [ -n "$app" ] && [ -n "$image" ]; then fail "--app and --image both name an image; give one" fi if [ -n "$app" ]; then case "$app" in */*|.*) fail "--app takes a release name: $(fw_apps)" ;; esac image=$FW_DIR/$app/$app.hex [ -r "$image" ] || fail "no release '$app' on this machine (it carries: $(fw_apps))" source_kind="release $app" elif [ -n "$image" ]; then source_kind="argument" elif [ -r "$MEDIA" ]; then image=$MEDIA; source_kind="media" elif [ "$check_only" -eq 0 ]; then fail "no image: no --image given and $MEDIA is not readable" fi kind="" if [ -n "$image" ]; then [ -r "$image" ] || fail "cannot read $image" size=$(stat -c %s "$image" 2>/dev/null) || fail "cannot stat $image" [ "$size" -gt 0 ] 2>/dev/null || fail "$image is empty" sum=$(sha256sum "$image" | cut -d' ' -f1) case "$image" in *.hex|*.HEX|*.ihex) kind=hex ;; *.bin|*.BIN) kind=bin ;; *) fail "cannot tell the format of $image: name it .hex or .bin" ;; esac if [ "$kind" = bin ]; then [ -n "$addr" ] || fail "a .bin needs --addr" num_ok "$addr" && [ $((addr)) -ge 0 ] || fail "bad --addr '$addr': give a decimal number, or 0x and hex digits" [ $((addr + size)) -le $((FLASH_SIZE)) ] || fail "$image at $addr runs past the 1 MiB application flash" fi say "image: $image ($source_kind, $kind, $size bytes)" say "sha256: $sum" prov=$(dirname "$image")/PROVENANCE if [ -r "$prov" ]; then say "provenance: $prov" sed 's/^/ | /' "$prov" if [ -z "$expect_ver" ]; then expect_ver=$(sed -n 's/^expect: *//p' "$prov" | sed -n 1p) [ -n "$expect_ver" ] && expect_from=provenance fi modem_min=$(sed -n 's/^modem-min: *//p' "$prov" | sed -n 1p) else say "provenance: none beside the image" fi fi say "target: nRF9151 application flash (1 MiB at 0x0) via $BOARD_CFG" if [ -z "$split_dir" ]; then [ -r "$BOARD_CFG" ] || fail "no board config at $BOARD_CFG" command -v openocd >/dev/null 2>&1 || fail "openocd is not installed" fi # --- working files ---------------------------------------------------------------- # Generated Tcl, logs and the UICR-free image live for this run only -- except # under --split-only, where the split is the product and is kept. if [ -n "$split_dir" ]; then [ "$kind" = hex ] || fail "--split-only takes an Intel HEX" W=$split_dir mkdir -p "$W" || fail "cannot create $W" else W=${TMPDIR:-/tmp}/$PROG.$$ mkdir -p "$W" || fail "cannot create $W" fi # --- interrupts and clean-up -------------------------------------------------------- # A HUP, INT or TERM is honoured between steps, never inside an openocd session. # openocd itself goes on through all three: its stdin is /dev/null, and openocd # 91bd43134 acts on a Ctrl-C only when its stdin is a terminal (server.c, # sigkey_handler), while a HUP or TERM only sets a flag that nothing in a running # session reads. (Upstream openocd now checks that flag in its flash and image # code: a newer one would cut a write short on a hangup.) The session in # progress therefore runs to its end -- a write cut short would leave an erased # chip that must not be reset -- and then the run stops at the next step # boundary, saying what it left behind. Without these traps a Ctrl-C killed the # script outright: the EXIT trap did not run, the console reader kept the port, # and a write's "do not reset" message and retry session were lost. step=setup # the step running now, for the stop message sig="" # the signal being honoured sig_step="" # the step it came in in_ocd=0 # an openocd session is running halted=0 # the flash is intact, and the gate may have left the core halted written=0 # the write session passed keep_w=0 # $W became a retry session: keep it rd="" # the console reader con_fd=0 # the console is open on fd 3 backup_stem="" con_close() { if [ -n "$rd" ]; then kill "$rd" 2>/dev/null; wait "$rd" 2>/dev/null; rd=""; fi if [ "$con_fd" -eq 1 ]; then exec 3<&-; con_fd=0; fi } cleanup() { con_close [ -n "$split_dir" ] || [ "$keep_w" -eq 1 ] || rm -rf "$W" } sig_code() { case "$1" in HUP) echo 129 ;; INT) echo 130 ;; *) echo 143 ;; esac; } on_signal() { # $1 = HUP, INT or TERM [ -n "$sig" ] && return 0 sig=$1; sig_step=$step # The first one is honoured, and any more are ignored, by the commands this # run starts from here on too: a second Ctrl-C used to reach the mv or sed # that saves a failed write's retry session, and cut it short. trap '' HUP INT TERM # Held, never acted on here, while a session runs -- and through the whole # write step: a shell may run this trap only once the session is over # (BusyBox ash did, intermittently, 2026-10-12), and then only the write's # own outcome, which the write step reads next, may decide what follows. if [ "$in_ocd" -eq 1 ] || [ "$step" = write ]; then echo "$PROG: $1: letting the $step session finish, then stopping. Do not reset, power-cycle or unplug the board." return 0 fi stop_on_signal } let_run() { # the flash intact: let a core the gate halted run again printf '%s\n' 'init' 'catch {reset run}' 'shutdown' >"$W/run.tcl" ocd "$W/run.tcl" "$W/run.log" halted=0 } stop_on_signal() { # outside the write step: say what is left, and exit trap '' HUP INT TERM con_close what="nothing was written" if [ "$written" -eq 1 ]; then what="the image was written and verified, and the board reset; the boot check was cut short" elif [ "$halted" -eq 1 ]; then # Nothing is erased before the write step, and the core is let run # again whether or not the gate got as far as halting it: a shell may # run this trap only after the gate's session is over, so in_ocd cannot # tell. (Waiting for the gate's verdict instead left the core halted, # its watchdog off, when a stop was acted on just after a passed gate.) [ -n "$backup_stem" ] && rm -f "$backup_stem".bin "$backup_stem".pages "$backup_stem"-0x*.bin let_run what="nothing was written; the core was let run again" fi echo "$PROG: FAIL -- stopped by $sig during the ${sig_step:-$step} step; $what [$(took)]" exit "$(sig_code "$sig")" } checkpoint() { [ -z "$sig" ] || stop_on_signal; } if [ -z "$split_dir" ]; then trap 'cleanup' EXIT trap 'on_signal HUP' HUP trap 'on_signal INT' INT trap 'on_signal TERM' TERM fi # --- split the image ---------------------------------------------------------------- # A .hex is checked record by record (shape, length, address) and its UICR # records (0xFF0000 and up) are taken out into a byte list for the compatibility # check; everything else must fall inside the 1 MiB application flash. Record # checksums are left to openocd, which refuses a bad one when it loads the file: # summing every byte here took minutes in the guest's awk. # A shipped release is split at build time (--split-only), into split/ beside # it: in the guest the awk below takes 18-39 s on the 1.2-114 files. The split is # used only while its recorded sha256 is the image's. pre=$(dirname "${image:-.}")/split if [ "$kind" = hex ] && [ -z "$split_dir" ] && [ -r "$pre/split.txt" ] && [ -r "$pre/image.hex" ] && [ -r "$pre/uicr.tcl" ] && [ "$(sed -n 's/^SHA //p' "$pre/split.txt")" = "$sum" ]; then hex_file=$pre/image.hex; uicr_tcl=$pre/uicr.tcl; split_txt=$pre/split.txt say "split: precomputed at build ($pre)" else hex_file=$W/image.hex; uicr_tcl=$W/uicr.tcl; split_txt=$W/split.txt fi if [ "$kind" = hex ] && [ "$split_txt" = "$W/split.txt" ]; then awk -v out="$W/image.hex" -v uicr="$W/uicr.tcl" ' function h(s) { return H[toupper(s)] } # 2 hex digits function h4(s) { return H[toupper(substr(s, 1, 2))] * 256 + H[toupper(substr(s, 3, 2))] } BEGIN { x = "0123456789ABCDEF" for (i = 0; i < 16; i++) for (j = 0; j < 16; j++) H[substr(x, i + 1, 1) substr(x, j + 1, 1)] = i * 16 + j base = 0; data = 0; hi = 0; nu = 0; bad = ""; printf "set uicr {" > uicr } { sub(/\r$/, "") if ($0 == "") next if (substr($0, 1, 1) != ":") { bad = "line " NR " is not a HEX record"; exit } n = h(substr($0, 2, 2)); t = substr($0, 8, 2) if (length($0) != 11 + 2 * n) { bad = "line " NR " has the wrong length"; exit } if (t == "04") base = h4(substr($0, 10, 4)) * 65536 if (t == "02") base = h4(substr($0, 10, 4)) * 16 if (t == "00") { a = base + h4(substr($0, 4, 4)) if (a >= 16711680) { # 0xFF0000: UICR, kept out for (i = 0; i < n; i++) printf " %d %d", a + i, h(substr($0, 10 + 2 * i, 2)) > uicr nu += n next } if (a + n > 1048576) { bad = sprintf("data at 0x%X is outside the application flash and the UICR", a); exit } data += n if (a + n > hi) hi = a + n for (p = int(a / 4096); p <= int((a + n - 1) / 4096); p++) P[p] = 1 } print > out } END { printf "}\n" > uicr if (bad != "") { print "BAD " bad; exit 1 } printf "DATA %d %d\nUICR %d\n", data, hi, nu np = 0; r = "" for (p = 0; p < 256; p++) if (p in P) { np++ if (!((p - 1) in P)) s0 = p if (!((p + 1) in P)) r = r sprintf(" %d:%d", s0 * 4096, (p - s0 + 1) * 4096) } printf "PAGES %d\nRANGES%s\n", np, r }' "$image" >"$W/split.txt" || fail "$image: $(sed -n 's/^BAD //p' "$W/split.txt")" if [ -n "$split_dir" ]; then echo "SHA $sum" >>"$W/split.txt" say "split: written to $W" exit 0 fi fi if [ "$kind" = hex ]; then data=$(sed -n 's/^DATA \([0-9]*\) .*/\1/p' "$split_txt") hi=$(sed -n 's/^DATA [0-9]* \([0-9]*\)/\1/p' "$split_txt") nu=$(sed -n 's/^UICR //p' "$split_txt") [ "${data:-0}" -gt 0 ] || fail "$image has no data inside the application flash" say "content: $data bytes up to $(printf 0x%x "$hi"); UICR records: $nu bytes (compared, never written)" pages=$(sed -n 's/^PAGES //p' "$split_txt") ranges=$(sed -n 's/^RANGES *//p' "$split_txt") write_file=$hex_file write_args="$hex_file" elif [ "$kind" = bin ]; then say "content: $size bytes at $addr" p0=$(( addr / 4096 )); p1=$(( (addr + size - 1) / 4096 )) pages=$(( p1 - p0 + 1 )); ranges="$(( p0 * 4096 )):$(( pages * 4096 ))" printf 'set uicr {}\n' >"$W/uicr.tcl" nu=0 write_file=$image write_args="$image $addr bin" else printf 'set uicr {}\n' >"$W/uicr.tcl" nu=0 fi # Overrides go in a prelude sourced BEFORE the board config, which reads them. : >"$W/prelude.tcl" if [ -n "$speed" ]; then echo "set BRINTOS_JTAG_SPEED $speed" >>"$W/prelude.tcl" say "speed: ${speed} kHz (override)" else say "speed: the board config's default (1000 kHz)" fi if [ -n "$adapter" ]; then iface="" case "$adapter" in */*) iface=$adapter ;; *.cfg) name=$adapter ;; *) name=$adapter.cfg ;; esac if [ -z "$iface" ]; then iface=interface/ftdi/$name [ -r "$SCRIPTS/interface/$name" ] && iface=interface/$name fi [ -r "$SCRIPTS/$iface" ] || fail "no such adapter config: $SCRIPTS/$iface" echo "set BRINTOS_JTAG_IFACE $iface" >>"$W/prelude.tcl" say "adapter: $iface (override)" else say "adapter: interface/jlink.cfg (the board config's default)" fi # Shared helpers for every openocd session: read a word, as a number. Words # only: byte and halfword reads of the UICR return 0xdead filler on this chip # (measured 2026-10-05), while word reads return its contents. The trailing # `return` keeps openocd from printing the last definition's name. printf '%s\n' \ 'proc r32 {a} { set s [mdw $a]; if {![regexp {:[ \t]*([0-9a-fA-F]+)} $s -> v]} { error "mdw $a: $s" }; return [expr 0x$v] }' \ 'return' \ >>"$W/prelude.tcl" ocd() { # $1 = phase Tcl, $2 = log # A background job, so that this shell's trap runs as soon as a signal # arrives: `wait` returns early, above 128. The session goes on (see # "interrupts"), so it is waited for again until it has really ended. in_ocd=1 openocd -f "$W/prelude.tcl" -f "$BOARD_CFG" -f "$1" >"$2" 2>&1 /dev/null || break; done ocd_pid="" in_ocd=0 } show() { # the useful part of a log grep -v -E '^(Open On-Chip|Licensed under|For bug reports|[[:space:]]+http|Warn : Could not determine executable|Info : (Listening on port|starting gdb server)|swd$)' "$1" | sed 's/^/ | /' } mark() { # the value of the first "SOM =" line in a log sed -n "s/^SOM $1=//p" "$2" | sed -n 1p } # --- the console ---------------------------------------------------------------------- if [ "$use_console" -eq 1 ]; then if [ -z "$console" ]; then console=$(som-console --find --id "$CONSOLE_ID" 2>/dev/null) || console="" fi if [ -n "$console" ]; then say "console: $console" else say "console: none found (no watchdog step, no boot check)" fi else console="" say "console: not used (--no-console)" fi if [ -n "$expect_ver" ] && [ -z "$console" ] && [ "$check_only" -eq 0 ]; then if [ "$expect_from" = provenance ]; then say "expect: '$expect_ver' (from the provenance) -- not checked, no console" expect_ver="" else fail "--expect needs the SOM's console, and there is none; nothing was written" fi fi # The console is driven from the shell, not Expect: this image's expect needs # shared libraries, and the site's runtime cannot fork such a program # (brintos/hardwarejs#50) -- every `spawn` dies with "parent: sync byte write: # broken pipe" (measured 2026-10-05). As in som-test-nrf91, the port is opened # once, on fd 3, by the first step that needs it (a dry run never does): one # background dd records everything the board says into con.raw for the whole # run, and printf writes to fd 3. Each command's reply is what arrived after the # offset the file had when it was sent. (Each command used to start its own dd # and wait for /proc/
/fd/3, which BusyBox dd never opens -- it reads on # fd 0 -- so every command cost 3 s more.) Waits are deadlines on `date`, # because a sleep right after the shell reaps a child can return at once # (brintos/linux-in-the-browser#120); nothing here uses cat (glibc#14). con_open() { # in the main shell only: the reader must be this shell's child [ "$con_fd" -eq 1 ] && return 0 [ -n "$console" ] || return 1 # A failed redirection on `exec` would end a non-interactive shell with no # FAIL line, so the port is first opened by a child, and `command` keeps a # failure of the real open from being fatal. open(2) of a tty can fail # EINTR here (brintos/linux#73): three tries. n=0 until ( : <>"$console" ) 2>/dev/null && { command exec 3<>"$console"; } 2>/dev/null; do n=$((n + 1)); [ "$n" -ge 3 ] && return 1 done con_fd=1 stty 115200 raw -echo -echoe -echok clocal <&3 2>/dev/null || return 1 : >"$W/con.raw" dd bs=256 <&3 >>"$W/con.raw" 2>/dev/null & rd=$! return 0 } con_cmd() { # $1 = a shell command line for the SOM, $2 = seconds to collect [ "$con_fd" -eq 1 ] || return 1 off=$(( $(wc -c <"$W/con.raw") )) n=0 until printf '\r%s\r' "$1" >&3 2>/dev/null; do # EINTR on a tty (brintos/linux#73) n=$((n + 1)); [ "$n" -ge 3 ] && break done end=$(( $(now) + $2 )) while [ "$(now)" -lt "$end" ]; do sleep 1; done # Colour codes go too: one between "timeout:" and its number used to hide # the watchdog's timeout, and leave the gate at 12 s with no warning. if [ "$off" -gt 0 ]; then dd if="$W/con.raw" bs="$off" skip=1 2>/dev/null else sed -n p "$W/con.raw" fi | tr -d '\r\033' | sed 's/\[[0-9;]*[A-Za-z]//g' } wdt_ms="" # the longest timeout the board's shell reported, in ms wdt_timeout() { # $1 = `watchdog status` output: note its "Watchdog timeout: N ms" t=$(printf '%s\n' "$1" | sed -n 's/.*[Tt]imeout:[^0-9]*\([0-9][0-9]*\) *ms.*/\1/p' | sed -n 1p) [ ${#t} -le 9 ] || t=999999999 # more than the longest gate, whatever it says if [ -n "$t" ] && { [ -z "$wdt_ms" ] || [ "$t" -gt "$wdt_ms" ]; }; then wdt_ms=$t; fi } wdt_off() { # prints disabled, already-disabled, absent, noshell or failed # Run in the main shell (not in $(...)), so that wdt_ms is kept. s=$(con_cmd "watchdog status" 2) wdt_timeout "$s" case "$s" in *"command not found"*) echo absent; return ;; *"status: disabled"*) echo already-disabled; return ;; *"status: enabled"*) ;; *) echo noshell; return ;; esac con_cmd "watchdog disable" 2 >/dev/null s=$(con_cmd "watchdog status" 2) wdt_timeout "$s" case "$s" in *"status: disabled"*) echo disabled ;; *) echo failed ;; esac } modem_fw() { # the modem firmware, e.g. nrf91x1_2.0.2, or ? s=$(con_cmd "at AT%SHORTSWVER" 3) v=$(printf '%s\n' "$s" | sed -n 's/.*%SHORTSWVER: *\([A-Za-z0-9_.-]*\).*/\1/p' | sed -n 1p) if [ -n "$v" ]; then echo "$v"; return; fi case "$s" in *"command not found"*) echo "?no AT shell in the running application" ;; *ERROR*) echo "?the modem answered ERROR" ;; *) echo "?no answer from the console" ;; esac } modem_at_least() { # $1 = the board's, $2 = the oldest the image runs on; both _ [ "${1%%_*}" = "${2%%_*}" ] || return 1 awk -v a="${1##*_}" -v b="${2##*_}" 'BEGIN { n = split(a, x, "."); m = split(b, y, "."); k = n > m ? n : m for (i = 1; i <= k; i++) { if (x[i] + 0 > y[i] + 0) exit 0; if (x[i] + 0 < y[i] + 0) exit 1 } exit 0 }' } boot_version() { # the text after "Firmware version: ", or ?noshell end=$(( $(now) + 40 )) while [ "$(now)" -lt "$end" ]; do v=$(con_cmd version 2 | sed -n 's/.*Firmware version: //p' | sed -n 1p) [ -n "$v" ] && { echo "$v"; return; } done echo "?noshell" } # A backup is taken only with --backup (the 2026-10-10 review: production time # comes first on the station, as for som-program-f7). Its directory is checked # before any hardware is touched. It defaults to /tmp, which is RAM in a brintOS # guest: the dump is the board's firmware, and anything on the machine's stored # filesystem is copied to `latest` by a clean Halt, which anyone can download # from a public machine (a station is public). SOM_BACKUP_DIR picks another # directory, e.g. to keep the dump. writable_dir() { [ -d "$1" ] && [ -w "$1" ] && ( : >"$1/.som-w.$$" ) 2>/dev/null && rm -f "$1/.som-w.$$" } backup_dir="" if [ "$do_backup" -eq 1 ] && [ "$check_only" -eq 0 ]; then backup_dir=${SOM_BACKUP_DIR:-/tmp} writable_dir "$backup_dir" || fail "the backup directory $backup_dir is not writable (set SOM_BACKUP_DIR); nothing was written" if [ "$backup_dir" = /tmp ]; then say "backup to: /tmp (RAM: it lasts only as long as this session; copy it off to keep it)" else say "backup to: $backup_dir (stored with the machine: a clean Halt publishes it if the machine is public)" fi elif [ "$check_only" -eq 0 ]; then say "backup: off (--backup to take one)" fi if [ -n "${pages:-}" ]; then set -- $ranges say "pages: $pages of 256 (4 KiB) in $# range(s); erase: $erase" fi if [ -n "$modem_min" ] && [ "$modem_check" -eq 1 ]; then say "modem: needs $modem_min or later" fi if [ "$dry_run" -eq 1 ]; then say "dry-run, nothing touched" say "PASS -- resolved ${image:-no image} [$(took)]" exit 0 fi # The console is opened by the first step that reads it, in this shell, so that # its reader is this shell's child for the rest of the run: a dry run never # opens it, and --check only to read the modem. con_need() { [ -n "$console" ] || return 0 con_open || fail "cannot open or set up the console $console; nothing was written" } # --- 0. the modem firmware ---------------------------------------------------------- # The station cannot update the modem (nrfutil can, natively), so an image that # needs a newer one than the board carries is refused before anything is # touched. It can be read only while the running application has an AT shell. step=modem if [ -n "$modem_min" ] && [ "$modem_check" -eq 1 ]; then if [ -z "$console" ]; then say "modem: not read (no console); check it is $modem_min or later" else con_need mfw=$(modem_fw) case "$mfw" in \?*) say "modem: not read (${mfw#?}); check it is $modem_min or later" ;; *) modem_at_least "$mfw" "$modem_min" || fail "the board's modem firmware is $mfw and this image needs $modem_min or later; update the modem natively (nrfutil device program --firmware mfw_nrf91x1_.zip), then retry; nothing was written" say "modem: $mfw (needs $modem_min or later)" ;; esac fi fi # --- 1. probe ------------------------------------------------------------------------- # Read-only: the chip, its protection, and the image's UICR against the chip's. # Only the bytes the image sets are compared. An 0xFF in the image's UICR means # "left unprogrammed", and b0 writes its monotonic counter into such OTP slots at # runtime (this board: 0xfffffffc at 0xFF8158 where the image has 0xffffffff). # openocd parses the whole image here (record checksums, the end record, record # types) without touching the target: the write session reads it only after the # erase. Words 0x0 and 0x4 both erased mean an earlier write did not finish. # The nRF91's own watchdog (WDT0) is read too. Once started, it is not stopped # by a soft reset (SYSRESETREQ), the only reset this station makes, and two of # its settings decide whether a write is safe from it: # * CONFIG.HALT = 1, it keeps counting while the debugger halts the CPU: # nothing feeds it from the gate's `reset halt` on, through the erase, so # the gate lasts at least its timeout and 3 s more. Unfed, it fires there, # before anything is erased, and the gate fails. A timeout longer than the # longest gate is refused. # * RREN: it reloads only when every enabled reload register is written, and # openocd's flash loader writes RR[0] alone while it runs (the backported # nrf5 driver's watchdog_refresh_addr, 0x50018600). Any other register # enabled leaves it counting through the write: a warning. # Addresses from the nRF9120 MDK (nrfx bsp/stable/mdk/nrf91/nrf9120): SPU # PERIPHID[24].PERM at 0x50003860 (SECATTR, bit 4, picks the secure 0x50018000 # or non-secure 0x40018000 alias); RUNSTATUS +0x400 (bit 0), CRV +0x504 (timeout # (CRV+1)/32768 s), RREN +0x508 (bit n enables RR[n]), CONFIG +0x50C (HALT, # bit 3). step=probe t0=$(now) img_test='echo "SOM image=none"' [ -n "$image" ] && img_test="if {[catch {test_image $write_args} e]} { echo \"SOM image=bad \$e\" } else { echo \"SOM image=ok\" }" printf '%s\n' \ 'init' \ "$img_test" \ 'echo [format "SOM part=0x%x" [r32 0x00FF0140]]' \ 'echo [format "SOM approtect=0x%08x" [r32 0x00FF8000]]' \ 'echo [format "SOM w0=0x%08x" [r32 0x0]]' \ 'echo [format "SOM w4=0x%08x" [r32 0x4]]' \ 'if {[catch {' \ ' set wb [expr {([r32 0x50003860] & 0x10) ? 0x50018000 : 0x40018000}]' \ ' echo [format "SOM wdt0=%d %d %d %d" [expr {[r32 [expr {$wb + 0x400}]] & 1}] [r32 [expr {$wb + 0x504}]] [expr {([r32 [expr {$wb + 0x50C}]] >> 3) & 1}] [expr {[r32 [expr {$wb + 0x508}]] & 0xff}]]' \ '}]} { echo "SOM wdt0=unknown" }' \ "source ${uicr_tcl:-$W/uicr.tcl}" \ 'set bad 0; set same 0; set free 0; set words [dict create]' \ 'foreach {a b} $uicr {' \ ' set wa [expr {$a & ~3}]' \ ' if {![dict exists $words $wa]} { dict set words $wa [r32 $wa] }' \ ' set got [expr {([dict get $words $wa] >> (8 * ($a & 3))) & 0xff}]' \ ' if {$b == 0xff} { incr free } elseif {$got == $b} { incr same } else {' \ ' incr bad' \ ' if {$bad <= 4} { echo [format "SOM uicr-diff: 0x%08x chip 0x%02x image 0x%02x" $a $got $b] }' \ ' }' \ '}' \ 'echo "SOM uicr-bad=$bad"' \ 'echo "SOM uicr-same=$same"' \ 'echo "SOM uicr-free=$free"' \ 'shutdown' >"$W/probe.tcl" ocd "$W/probe.tcl" "$W/probe.log" checkpoint show "$W/probe.log" grep -q 'AP lock engaged' "$W/probe.log" && fail "the chip's debug access is locked (APPROTECT); this script does not recover chips; nothing was written" vt=$(sed -n 's/.*VTarget = \([0-9.]*\) V.*/\1/p' "$W/probe.log" | sed -n 1p) case "$vt" in 0.0*|"") [ -n "$vt" ] && fail "VTarget is $vt V: the board is unpowered or the cable is on the wrong header; nothing was written" ;; esac part=$(mark part "$W/probe.log") [ -n "$part" ] || fail "could not talk to the chip (is the J-Link attached?); nothing was written" case "$part" in 0x91??) ;; *) fail "this is not an nRF91 (FICR part $part); nothing was written" ;; esac if [ -n "$image" ] && [ "$(mark image "$W/probe.log")" != ok ]; then fail "openocd cannot load $image ($(grep -E '^Error' "$W/probe.log" | sed -n 1p)); nothing was written" fi say "chip: nRF$(echo "$part" | sed 's/^0x//') (FICR part $part), UICR APPROTECT $(mark approtect "$W/probe.log"), VTarget ${vt:-?} V" if [ "$(mark w0 "$W/probe.log")" = 0xffffffff ] && [ "$(mark w4 "$W/probe.log")" = 0xffffffff ]; then say "flash: erased at 0x0 (an earlier write did not finish?)" if [ "$check_only" -eq 0 ]; then retry=$(ls -dt "${TMPDIR:-/tmp}"/som-nrf91-retry-* 2>/dev/null | sed -n 1p) if [ -n "$retry" ]; then how="retry the last failed write, which does not reset: openocd -f $retry/prelude.tcl -f $BOARD_CFG -f $retry/retry.tcl" else how="write it natively (nrfutil device program) or from a session that does not reset" fi fail "the application flash is erased, and this run would reset the chip, which locks an erased nRF91x1. Do not reset or power-cycle it; $how; nothing was written" fi fi # A failed or interrupted write that got past page 0 leaves words 0x0 and 0x4 # intact, so the check above cannot see it; its retry session can. Until that # session has been run (or the board rewritten another way), a run that starts # with `reset halt` must not go ahead. retry=$(ls -dt "${TMPDIR:-/tmp}"/som-nrf91-retry-* 2>/dev/null | sed -n 1p) if [ -n "$retry" ]; then if [ "$check_only" -eq 1 ] || [ "$ignore_retry" -eq 1 ]; then say "retry: a failed write's session is still in $retry" else fail "a failed write's retry session is still in $retry: the flash may be half-written, and this run would reset the chip, which locks an erased nRF91x1. Finish it first (openocd -f $retry/prelude.tcl -f $BOARD_CFG -f $retry/retry.tcl), then remove $retry; or, if the board was rewritten since, pass --ignore-retry; nothing was written" fi fi set -- $(mark wdt0 "$W/probe.log") isnum() { case "$1" in ''|*[!0-9]*) return 1 ;; esac; [ ${#1} -le 10 ]; } wdt0_gate="" # the gate a WDT0 that counts while the core is halted asks for if [ "${1:-}" = 0 ]; then say "wdt0: off" elif [ "${1:-}" = 1 ] && isnum "${2:-}" && isnum "${3:-}" && isnum "${4:-}"; then wt=$(( $2 / 32768 + 1 )) # (CRV+1)/32768 s, rounded up rr=$(printf 0x%02x "$4") if [ "$3" = 1 ]; then say "wdt0: running, timeout ${wt} s, keeps counting while the core is halted; reload registers $rr" wdt0_gate=$(( wt + 3 )) [ "$wdt0_gate" -le "$GATE_MAX" ] || fail "the nRF91's own watchdog (WDT0) runs, keeps counting while the core is halted, and times out after ${wt} s, longer than the longest halt gate ($GATE_MAX s): nothing shows it will not reset the chip in the middle of the erase, and this station's resets do not stop it; nothing was written" say "wdt0: the gate holds at least $wdt0_gate s: unfed while the core is halted, WDT0 fires there, before anything is erased" else say "wdt0: running, timeout ${wt} s, pauses while the core is halted; reload registers $rr" fi [ "$4" = 1 ] || say "WARN: WDT0 reloads only when every enabled reload register is written, and openocd's flash loader writes RR[0] alone: with $rr enabled it resets the chip once the write has run ${wt} s, and a reset in the middle of a write locks an erased nRF91x1" else say "wdt0: not read (${1:-no answer})" say "WARN: the nRF91's own watchdog (WDT0) could not be read; the gate rules out only one that fires within its length" fi ubad=$(mark uicr-bad "$W/probe.log") if [ "$nu" -gt 0 ]; then [ "$ubad" = 0 ] || fail "the image's UICR differs from the chip's in ${ubad:-?} bytes: it is built for other keys and would not boot; nothing was written" say "uicr: the image's $(mark uicr-same "$W/probe.log") set UICR bytes match the chip ($(mark uicr-free "$W/probe.log") left unprogrammed by the image, not compared)" fi say "probe: $(( $(now) - t0 )) s" if [ "$check_only" -eq 1 ]; then say "PASS -- check: nRF91 part $part reachable${image:+, $image compatible} [$(took)]" exit 0 fi # --- 2. the watchdog -------------------------------------------------------------------- # The gate below proves only that nothing resets the core within its length, so # it lasts at least as long as the watchdog the board's shell reports (and 3 s # more), in case `watchdog disable` did not take; --gate makes it longer still. step=watchdog wdt="" if [ -n "$console" ]; then con_need wdt_off >"$W/wdt.res" wdt=$(sed -n 1p "$W/wdt.res") say "watchdog: $wdt${wdt_ms:+ (its timeout: $wdt_ms ms)}" [ "$wdt" = failed ] && fail "the board's watchdog would not turn off; nothing was written" fi checkpoint gate=$GATE_SECONDS if [ -n "$wdt_ms" ]; then g=$(( (wdt_ms + 999) / 1000 + 3 )) [ "$g" -gt "$gate" ] && gate=$g fi if [ -n "$wdt0_gate" ] && [ "$wdt0_gate" -gt "$gate" ]; then gate=$wdt0_gate; fi if [ -n "$gate_req" ] && [ "$gate_req" -gt "$gate" ]; then gate=$gate_req; fi if [ "$gate" -gt "$GATE_MAX" ]; then say "gate: the watchdog's timeout asks for $gate s; held $GATE_MAX s, the most" gate=$GATE_MAX fi case "$wdt" in disabled|already-disabled) [ -n "$wdt_ms" ] || say "WARN: the board's shell did not say the watchdog's timeout; the gate rules out only one that fires within $gate s (--gate holds longer)" ;; *) say "WARN: the watchdog state is unknown (${wdt:-no console}); the gate rules out only one that fires within $gate s (--gate holds longer)" ;; esac # --- 3. the halt gate --------------------------------------------------------------------- # Whatever the console said: halt the CPU and watch it. A reset while halted # (DHCSR S_RESET_ST, or S_HALT gone) means something outside the SoC will reset # it in the middle of an erase. The first read clears the sticky bit the # `reset halt` itself set. The SPU must leave every flash region writable. The # body is one catch, so that a script error still ends in `reset run` rather # than a board left halted. (openocd's Tcl is Jim: `expr` takes one argument, # so every expression is braced.) step=gate t0=$(now) printf '%s\n' \ 'init' \ 'set ok 1' \ 'if {[catch {' \ ' reset halt' \ ' r32 0xE000EDF0' \ " for {set i 0} {\$i < $gate} {incr i} {" \ ' sleep 1000' \ ' if {[catch {set d [r32 0xE000EDF0]}]} { set ok 0; echo "SOM gate-why=lost the target at ${i} s"; break }' \ ' if {($d & 0x02000000) || !($d & 0x00020000)} { set ok 0; echo [format "SOM gate-why=reset at %d s (DHCSR 0x%08x)" $i $d]; break }' \ ' }' \ ' if {$ok} {' \ ' for {set r 0} {$r < 32} {incr r} {' \ ' set p [r32 [expr {0x50003600 + 4 * $r}]]' \ ' if {($p & 0x100) || !($p & 0x2)} { set ok 0; echo [format "SOM gate-why=SPU flash region %d is 0x%x (locked or read-only)" $r $p]; break }' \ ' }' \ ' }' \ '} err]} { set ok 0; echo "SOM gate-why=script error: $err" }' \ 'if {$ok} { echo "SOM gate=pass" } else { echo "SOM gate=fail"; catch {reset run} }' \ 'shutdown' >"$W/gate.tcl" halted=1 # from here a stop lets the core run again (stop_on_signal) ocd "$W/gate.tcl" "$W/gate.log" if [ "$(mark gate "$W/gate.log")" != pass ]; then halted=0 # the failed gate ran `reset run` itself checkpoint show "$W/gate.log" why=$(mark gate-why "$W/gate.log") [ -n "$wdt0_gate" ] && fail "the halt gate failed: ${why:-see above}. The nRF91's own watchdog (WDT0) runs and keeps counting while the core is halted, and this station's resets do not stop it; nothing was written" fail "the halt gate failed: ${why:-see above}. A watchdog? Turn it off on the console (\`watchdog disable\`); nothing was written" fi checkpoint say "gate: held halted ${gate} s with no reset, flash unlocked ($(( $(now) - t0 )) s)" # --- 4. back up the current flash ------------------------------------------------------- backup="" if [ "$do_backup" -eq 1 ]; then step=backup t0=$(now) stem="$backup_dir/nrf91-flash-$(date -u +%Y%m%dT%H%M%SZ)" backup_stem=$stem if [ "$erase" = all ] || [ -z "${ranges:-}" ]; then backup=$stem.bin; set -- "0:$(( FLASH_SIZE ))" say "backup: reading 1 MiB to $backup (about 1 min) ..." else backup=$stem.pages; set -- $ranges say "backup: reading the $pages pages the erase will clear, in $# range(s), to $stem-*.bin ..." fi # One session: each range dumped and read back against its dump. { echo 'init'; echo 'reset halt'; echo 'set ok 1' for r; do a=${r%%:*}; n=${r#*:} [ "$backup" = "$stem.bin" ] && f=$backup || f=$stem-$(printf 0x%08x "$a").bin echo "if {\$ok && [catch {dump_image $f $a $n} e]} { set ok 0; echo \"SOM backup=fail \$e\" }" echo "if {\$ok && [catch {verify_image $f $a bin} e]} { set ok 0; echo \"SOM backup=fail \$e\" }" done echo 'if {$ok} { echo "SOM backup=pass" }'; echo 'shutdown'; } >"$W/backup.tcl" ocd "$W/backup.tcl" "$W/backup.log" checkpoint complete=1 for r; do a=${r%%:*}; n=${r#*:} [ "$backup" = "$stem.bin" ] && f=$backup || f=$stem-$(printf 0x%08x "$a").bin [ "$(stat -c %s "$f" 2>/dev/null)" = "$n" ] || complete=0 done if [ "$(mark backup "$W/backup.log")" != pass ] || [ $complete = 0 ]; then show "$W/backup.log" rm -f "$stem.bin" "$stem".pages "$stem"-0x*.bin backup_stem="" let_run checkpoint fail "could not read the flash out; nothing was written" fi if [ "$backup" = "$stem.bin" ]; then say "backup sha256: $(sha256sum "$backup" | cut -d' ' -f1) ($(( $(now) - t0 )) s)" else for r; do a=${r%%:*}; n=${r#*:}; f=$stem-$(printf 0x%08x "$a").bin echo "$(basename "$f") $(printf 0x%08x "$a") $n $(sha256sum "$f" | cut -d' ' -f1)" done >"$backup" say "backup: $pages pages, list with sha256s in $backup ($(( $(now) - t0 )) s)" fi fi # --- 5. erase, write, verify -- one session, no reset inside ------------------------- # The first line is `reset halt` on the way in; a retry after a failure must use # a plain `halt` instead, because resetting an erased nRF91x1 locks it. mkwrite() { # $1 = "reset halt" or "halt", $2 = output file if [ "$erase" = all ]; then erase_step='flash erase_sector 0 0 last' write_step="flash write_image $write_args" else erase_step='echo "SOM note=erasing only the pages the image touches"' write_step="flash write_image erase $write_args" fi printf '%s\n' \ 'init' \ "$1" \ "set rc [catch {$erase_step} e]; echo \"SOM erase=\$rc \$e\"" \ "if {\$rc == 0} { set rc [catch {$write_step} e]; echo \"SOM write=\$rc \$e\" }" \ "if {\$rc == 0} { set rc [catch {verify_image $write_args} e]; echo \"SOM verify=\$rc \$e\" }" \ 'if {$rc == 0} { reset run; echo "SOM result=pass" } else { echo "SOM result=fail" }' \ 'shutdown' >"$2" } # save_retry keeps this run's working directory as the retry session: a rename # on the same filesystem (it keeps write.log as the evidence), or, failing that, # copies made with sed, not cp (busybox cp may use sendfile, which does not # complete on this runtime, glibc#14). The image in it is the UICR-free one the # write used: the original HEX would write the UICR bank, which is write-once. # retry.tcl is written last, and the message printed only once it exists. The # name is one no directory has yet: a PID comes round again, and an older retry # session can still be there (--ignore-retry runs beside one) -- mv would move # this one into it, and its retry.tcl would then write the older image. save_retry() { keep=${TMPDIR:-/tmp}/som-nrf91-retry-$$ n=0 while [ -e "$keep" ] || [ -L "$keep" ]; do n=$((n + 1)); keep=${TMPDIR:-/tmp}/som-nrf91-retry-$$-$n done con_close if mv "$W" "$keep" 2>/dev/null; then case "$hex_file" in "$W"/*) hex_file=$keep/${hex_file#"$W"/} ;; esac else mkdir "$keep" && sed -n p "$W/prelude.tcl" >"$keep/prelude.tcl" || return 1 sed -n p "$W/write.log" >"$keep/write.log" 2>/dev/null if [ "$kind" = hex ]; then sed -n p "$hex_file" >"$keep/image.hex" || return 1 hex_file=$keep/image.hex fi fi W=$keep; keep_w=1 [ "$kind" = hex ] && write_args=$hex_file mkwrite halt "$keep/retry.tcl" } write_failed() { # after a write session that did not pass show "$W/write.log" if save_retry; then echo "$PROG: the target was NOT reset: it is halted, or still running openocd's flash loader. Do not power-cycle or reset the board." echo "$PROG: retry the write without a reset (it starts with a plain \`halt'):" echo " openocd -f $keep/prelude.tcl -f $BOARD_CFG -f $keep/retry.tcl" else echo "$PROG: the target was NOT reset, and the retry session could not be saved in ${TMPDIR:-/tmp}. Do not power-cycle or reset the board." fi case "$backup" in *.bin) echo "$PROG: the board's previous contents are in $backup" ;; *.pages) echo "$PROG: the pages the erase cleared are listed in $backup; write each back with --image --addr
" ;; *) echo "$PROG: no backup was taken (--backup takes one)" ;; esac echo "$PROG: last resort, natively on a host: nrfutil device recover, then program the full .hex (UICR included)" } t0=$(now) mkwrite 'reset halt' "$W/write.tcl" checkpoint # an interrupt that came before the write never starts it step=write halted=0 # the flash is about to change: nothing from here may reset the core say "writing: erase ($erase), write, verify ..." ocd "$W/write.tcl" "$W/write.log" show "$W/write.log" | grep -E 'SOM (erase|write|verify)=|Error|Warn' if [ "$(mark result "$W/write.log")" != pass ]; then write_failed if [ -n "$sig" ]; then trap '' HUP INT TERM echo "$PROG: FAIL -- stopped by $sig during the write step: erase/write/verify failed, the flash is in an unknown state [$(took)]" exit "$(sig_code "$sig")" fi fail "erase/write/verify failed -- the flash is in an unknown state" fi written=1 step=boot # from here an interrupt stops the run at once (on_signal) checkpoint # ... and one held through the write stops it now say "written: verified and reset ($(( $(now) - t0 )) s)" # --- 6. did it boot? ------------------------------------------------------------------------ booted="not checked (no console)" if [ -n "$console" ]; then booted=$(boot_version) say "booted: $booted" if [ -n "$expect_ver" ]; then case "$booted" in *"$expect_ver"*) ;; *) fail "written and verified, but the board did not boot as '$expect_ver' (it says: ${booted:-nothing})" ;; esac fi fi checkpoint say "PASS -- wrote $image ($sum) to the nRF9151; booted: $booted [$(took)]"