#!/usr/bin/env bash
#
# install-tower - take a freshly imaged tower host to a working fire-watch tower,
# or check and repair one that is already configured. Run it ON the tower, as the
# user the program runs as (ggs), from a checkout of this repository with the
# firmware repository beside it (../firmware).
#
#   scripts/install-tower                  survey, ask what it cannot work out, show
#                                          the plan, then do it after one yes
#   scripts/install-tower --dry-run        survey and show the plan; change nothing
#   scripts/install-tower --verify         run the checks only; change nothing
#   scripts/install-tower --non-interactive --answers FILE
#                                          fail instead of prompting; FILE holds
#                                          key=value lines, as --answer does
#
# Every question has a key, so any of them can be answered up front with
# --answer KEY=VALUE (repeatable) - e.g. --answer tower_name=north
# --answer yaw.r_sense_ohm=0.075. A drivetrain value may be answered `unknown`,
# which leaves a commented ; TODO in hardware.ini and that axis unable to move.
# Nothing here ever makes one up: a wrong sense resistor cooks a driver, and a
# wrong homing current shears an endstop - both have happened on this project.
#
# What it does, in order (each step is skipped when it is already true):
#   packages, USB preconditions, Vimba X SDK, host build, hardware.ini,
#   config.ini (+ credentials.env), PlatformIO, camera profiles, firmware build +
#   flash, test hardware, [a person at the rig says yes] homing + a short capture,
#   boot service.
# The order is the safe one for an existing tower too: the host and the files it
# reads are in place and tested BEFORE any firmware is flashed, so a new firmware
# never boots into a host that cannot answer it.
#
set -euo pipefail

here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="$(cd "$here/.." && pwd)"
FW="$(cd "$REPO/.." && pwd)/firmware"
# The overrides exist to rehearse a FRESH install with --dry-run on a tower
# that is already configured, without its real files being in the way.
ETC="${INSTALL_TOWER_ETC:-/etc/fire_gimbal_control}"
HW="$ETC/hardware.ini"
CRED="$ETC/credentials.env"
INSTALL_LOG="$ETC/install.log"
CFG="${INSTALL_TOWER_CONFIG:-$REPO/config.ini}"
UNIT="${INSTALL_TOWER_UNIT:-/etc/systemd/system/fire-gimbal-control.service}"
LOGDIR="${XDG_DATA_HOME:-$HOME/.local/share}/fire_gimbal_control/logs"
BIN="$REPO/build/fire_gimbal_control"
FGC_CONFIG="$REPO/build/fgc-config"
FGC="$REPO/scripts/fgc"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT

MODE=install NONINTERACTIVE=0
declare -A ANS=()

usage() { sed -n '2,33p' "$0" | sed 's/^# \{0,1\}//'; }

while [[ $# -gt 0 ]]; do
    case "$1" in
        --dry-run)          MODE=dry-run ;;
        --verify)           MODE=verify ;;
        --non-interactive)  NONINTERACTIVE=1 ;;
        --answer)
            [[ ${2:-} == *=* ]] || { echo "install-tower: --answer takes KEY=VALUE" >&2; exit 1; }
            ANS[${2%%=*}]="${2#*=}"; shift ;;
        --answers)
            [[ -r ${2:-} ]] || { echo "install-tower: cannot read ${2:-}" >&2; exit 1; }
            while IFS= read -r line || [[ -n $line ]]; do
                [[ $line =~ ^[[:space:]]*(#|$) ]] && continue
                [[ $line == *=* ]] && ANS[${line%%=*}]="${line#*=}"
            done < "$2"; shift ;;
        -h|--help)          usage; exit 0 ;;
        *) echo "install-tower: unknown option: $1 (--help)" >&2; exit 1 ;;
    esac
    shift
done

# Everything it prints also goes to a log, so a long run can be read back and
# passed on whole - a terminal's scrollback does not keep it. The questions are
# asked on /dev/tty, so they are not affected.
mkdir -p "$LOGDIR"
RUN_LOG="$LOGDIR/install-$(date +%Y%m%d-%H%M%S).log"
exec > >(tee -a "$RUN_LOG") 2>&1
printf 'install-tower %s  (this output is also in %s)\n' "$*" "$RUN_LOG"

say()  { printf '>> %s\n' "$*"; }
warn() { printf '!! %s\n' "$*" >&2; }
die()  { printf 'install-tower: %s\n' "$*" >&2; exit 1; }
hr()   { printf '\n== %s ==\n' "$*"; }

[[ $EUID -ne 0 ]] || die "run as the tower's user (ggs), not root: builds and ~/.platformio must belong to it. It uses sudo where it has to."
# The motor MCU is a serial device: without dialout the program cannot open it
# and the flash cannot reach it - found here, not as a failure an hour in.
id -nG | tr ' ' '\n' | grep -qx dialout ||
    die "$USER is not in the dialout group, so it cannot use the motor MCU's serial port. Run: sudo usermod -aG dialout $USER - then log out and back in (the group applies to new logins) and run this again."

# ------------------------------------------------------------- questions ---
# Answers come from --answer / --answers first; otherwise from the terminal. With
# --non-interactive a question that has a default takes it, and one that has
# none stops the run naming the key to pass.
tty_ok() { [[ -r /dev/tty && -w /dev/tty ]] && { : > /dev/tty; } 2>/dev/null; }

REPLY_VAL=""
ask() {
    local key="$1" prompt="$2" def="${3-}"
    if [[ -n ${ANS[$key]+x} ]]; then REPLY_VAL="${ANS[$key]}"; return 0; fi
    if [[ $NONINTERACTIVE -eq 1 ]] || ! tty_ok; then
        [[ -n $def ]] && { REPLY_VAL="$def"; ANS[$key]="$def"; return 0; }
        die "no answer for '$key' ($prompt). Pass --answer $key=..."
    fi
    local a
    printf '%s%s: ' "$prompt" "${def:+ [$def]}" > /dev/tty
    IFS= read -r a < /dev/tty || die "no terminal to ask '$key' on"
    REPLY_VAL="${a:-$def}"
    ANS[$key]="$REPLY_VAL"
}

ask_secret() {
    local key="$1" prompt="$2"
    if [[ -n ${ANS[$key]+x} ]]; then REPLY_VAL="${ANS[$key]}"; return 0; fi
    [[ $NONINTERACTIVE -eq 0 ]] && tty_ok || die "no answer for '$key' ($prompt). Pass --answer $key=..."
    local a
    printf '%s: ' "$prompt" > /dev/tty
    IFS= read -rs a < /dev/tty || die "no terminal"
    printf '\n' > /dev/tty
    REPLY_VAL="$a"; ANS[$key]="$a"
}

# y/n, with a default. Returns 0 for yes.
ask_yn() {
    local key="$1" prompt="$2" def="$3"
    while :; do
        ask "$key" "$prompt (y/n)" "$def"
        case "${REPLY_VAL,,}" in
            y|yes) return 0 ;;
            n|no)  return 1 ;;
        esac
        unset "ANS[$key]"
        [[ $NONINTERACTIVE -eq 1 ]] && die "'$key' must be y or n"
    done
}

# A number, or "unknown". No default is ever offered for these.
ask_value() {
    local key="$1" prompt="$2" pattern="$3"
    while :; do
        ask "$key" "$prompt (or 'unknown')"
        [[ ${REPLY_VAL,,} == unknown ]] && { REPLY_VAL=""; return 0; }
        [[ $REPLY_VAL =~ $pattern ]] && return 0
        warn "'$REPLY_VAL' is not a valid value for $key"
        unset "ANS[$key]"
        [[ $NONINTERACTIVE -eq 1 ]] && die "bad value for '$key'"
    done
}

# One wrapped, indented explanation line before a question.
explain() { printf '%s\n' "$1" | fold -s -w 72 | sed '1s/^/  - /; 2,$s/^/    /'; }

# ----------------------------------------------------------------- reading ---
# A value as inih reads it: last assignment wins, and an inline comment is a ';'
# preceded by whitespace. For reading only - every write goes through fgc-config.
ini_get() {
    [[ -r $1 ]] || return 1
    awk -v s="$2" -v k="$3" '
        /^[[:space:]]*[;#]/ { next }
        /^[[:space:]]*\[/ { sec = $0; sub(/^[[:space:]]*\[/, "", sec); sub(/\].*$/, "", sec); next }
        sec == s {
            i = index($0, "="); if (!i) next
            key = substr($0, 1, i - 1); gsub(/^[ \t]+|[ \t]+$/, "", key)
            if (key != k) next
            v = substr($0, i + 1); sub(/[ \t]+;.*$/, "", v); gsub(/^[ \t]+|[ \t]+$/, "", v)
            val = v; found = 1
        }
        END { if (found) print val; else exit 1 }' "$1"
}

latest_log() { ls -t "$LOGDIR"/fgc_*.log 2>/dev/null | head -1; }

pio_path() {
    local c
    for c in "$(command -v pio 2>/dev/null || true)" "$HOME/.platformio/penv/bin/pio" \
             "$HOME/.local/bin/pio" /usr/local/bin/pio; do
        [[ -n $c && -x $c ]] && { echo "$c"; return 0; }
    done
    return 1
}

py_serial() {
    local c
    for c in "$HOME/.platformio/penv/bin/python" "$(command -v python3 2>/dev/null || true)"; do
        [[ -n $c && -x $c ]] && "$c" -c 'import serial' 2>/dev/null && { echo "$c"; return 0; }
    done
    return 1
}

app_running() { "$FGC" status >/dev/null 2>&1; }

# One command line into the running program, whether it shows the dashboard or
# the plain console: the dashboard needs ':' to open its command line first.
app_cmd() {
    local pane; pane="$(tmux list-panes -t "=${FGC_SESSION:-fgc}" -F '#{pane_id}' 2>/dev/null | head -1)"
    [[ -n $pane ]] || return 1
    if tmux capture-pane -p -t "$pane" | grep -q '╭'; then
        tmux send-keys -t "$pane" ':'; sleep 0.5
    fi
    tmux send-keys -t "$pane" -l "$1"; tmux send-keys -t "$pane" Enter
}

# The arguments the running app was started with, so a restart puts it back the
# way it was. From the process itself, not its log: an older build writes no log
# file, and restarting tower 111 without its --init --start would have left it
# running but capturing nothing.
app_args() {
    ps -u "$USER" -o args= 2>/dev/null | grep -m1 -E '^[^ ]*/fire_gimbal_control( |$)' |
        awk '{ for (i = 2; i <= NF; i++) printf "%s%s", $i, (i < NF ? " " : "") }' || true
}

# What the firmware on the board reports: "build=... spi=... Y:cs,en P:cs,en".
# From the running app's log when it holds the port, else asked directly.
firmware_report() {
    local text=""
    if app_running; then
        local l; l="$(latest_log)"
        # A just-started app has not dumped yet; give it the few seconds it needs.
        for _ in $(seq 1 15); do
            [[ -n $l ]] && grep -q '^DUMP BEGIN' "$l" && break
            sleep 1; l="$(latest_log)"
        done
        [[ -n $l ]] && text="$(grep -E '^DUMP (BEGIN|[YP] state|ENV )' "$l" | tail -4)"
    else
        local py port; py="$(py_serial)" || return 0
        port="$(ini_get "$HW" Serial device 2>/dev/null || true)"
        [[ -z $port || ! -e $port ]] && port="$(probe_mcu | head -1)"
        [[ -n $port && -e $port && $port != *MicroPython* ]] || return 0
        text="$("$py" - "$port" <<'PYEOF' 2>/dev/null || true
import sys, time, serial
s = serial.Serial(sys.argv[1], 115200, timeout=1)
time.sleep(1.5); s.reset_input_buffer(); s.write(b"DUMP\r\n")
end = time.time() + 3
while time.time() < end:
    l = s.readline().decode(errors="replace").strip()
    if l.startswith("DUMP BEGIN") or l.startswith("DUMP ENV ") or (l.startswith("DUMP ") and " state=" in l): print(l)
    if l.startswith("DUMP END"): break
PYEOF
)"
    fi
    [[ -n $text ]] || return 0
    local build spi y p
    build="$(sed -n 's/.*DUMP BEGIN build=\([^ ]*\).*/\1/p' <<< "$text" | tail -1)"
    spi="$(sed -n 's/.*DUMP BEGIN.* spi=\([0-9,]*\).*/\1/p' <<< "$text" | tail -1)"
    y="$(sed -n 's/^DUMP Y .* cs=\([0-9]*\) en=\([0-9]*\).*/\1,\2/p' <<< "$text" | tail -1)"
    p="$(sed -n 's/^DUMP P .* cs=\([0-9]*\) en=\([0-9]*\).*/\1,\2/p' <<< "$text" | tail -1)"
    # The parts on the MCU itself (legacy towers): dht,fan_pwm,fan_tach, - when absent.
    local e="" ln; ln="$(grep '^DUMP ENV ' <<< "$text" | tail -1)"
    if [[ -n $ln ]]; then
        local d f t
        d="$(sed -n 's/.* dht=\([0-9]*\).*/\1/p' <<< "$ln")"
        f="$(sed -n 's/.* fan_pwm=\([0-9]*\).*/\1/p' <<< "$ln")"
        t="$(sed -n 's/.* fan_tach=\([0-9]*\).*/\1/p' <<< "$ln")"
        e="${d:--},${f:--},${t:--}"
    fi
    echo "build=${build:-?} spi=${spi:--} Y=${y:--} P=${p:--} E=${e:--}"
}

# Does what the firmware reports match what hardware.ini declares? Every field
# counts - an axis or a DHT11 the firmware has and the file does not, or the
# other way round, needs a rebuild - except undeclared SPI pins: a Leonardo
# declares none (they are fixed), so whatever a build reports for them is moot.
pins_match() {
    local now="$1" want="$2" f
    [[ -n $now ]] || return 1
    for f in $want; do
        [[ $f == spi=- ]] && continue
        [[ " $now " == *" $f "* ]] || return 1
    done
}

# The same, as hardware.ini declares it, for comparison.
declared_pins() {
    local spi="-" y="-" p="-" a b c
    a="$(ini_get "$HW" Pins spi_sck 2>/dev/null || true)"
    b="$(ini_get "$HW" Pins spi_mosi 2>/dev/null || true)"
    c="$(ini_get "$HW" Pins spi_miso 2>/dev/null || true)"
    [[ -n $a ]] && spi="$a,$b,$c"
    a="$(ini_get "$HW" Pins yaw_cs 2>/dev/null || true)"; b="$(ini_get "$HW" Pins yaw_en 2>/dev/null || true)"
    [[ -n $a ]] && y="$a,$b"
    a="$(ini_get "$HW" Pins pitch_cs 2>/dev/null || true)"; b="$(ini_get "$HW" Pins pitch_en 2>/dev/null || true)"
    [[ -n $a ]] && p="$a,$b"
    local e="-"
    a="$(ini_get "$HW" Pins dht 2>/dev/null || true)"
    if [[ -n $a ]]; then
        b="$(ini_get "$HW" Pins fan_pwm 2>/dev/null || true)"; c="$(ini_get "$HW" Pins fan_tach 2>/dev/null || true)"
        e="$a,${b:--},${c:--}"
    fi
    echo "spi=$spi Y=$y P=$p E=$e"
}

# Each gap in hardware.ini, named with its section: "[Motor.pitch] sw_mode".
hw_todos() {
    [[ -r $HW ]] || return 0
    awk '/^\[/ { sec = $0; sub(/\].*/, "]", sec) } /^; TODO / { sub(/^; TODO /, ""); print sec " " $0 }' "$HW"
}

# ------------------------------------------------------------------ survey ---
hr "survey"
[[ -d $FW ]] || die "the firmware repository is not beside this one ($FW). Clone it there: git clone $(git -C "$REPO" remote get-url origin 2>/dev/null | sed 's/fwt_software/fwt_firmware/') $FW"

# The board decides the kit; the kit supplies the probes and the facts.
. "$REPO/hardware/common/kit.sh"
S_BOARD="$(probe_board)"
KIT="$REPO/hardware/$S_BOARD"
[[ -r $KIT/install.sh ]] || die "no setup kit for this board ('$S_BOARD'): hardware/$S_BOARD/ does not exist"
# shellcheck disable=SC1091
KIT_NO_MAIN=1 . "$KIT/install.sh"
set -euo pipefail

S_MCU="$(probe_mcu)"
S_MCU_N=$(grep -c . <<< "$S_MCU" || true)
# Another program on the motor MCU's port - a legacy tower's old program, started
# by start_gimbal.service - would fight this one for it, and the flash would
# fail half-way. Our own program is fine: it is stopped before anything needs
# the port.
if (( S_MCU_N == 1 )) && [[ -e $S_MCU ]]; then
    for pid in $(fuser "$(readlink -f "$S_MCU")" 2>/dev/null); do
        args="$(ps -o args= -p "$pid" 2>/dev/null)"
        [[ $args == */fire_gimbal_control* ]] && continue
        die "the motor MCU's port is held by another program: pid $pid ($args).
Stop it for good before installing. On a legacy tower it is started by
start_gimbal.service from ~/scripts/startup_gimbal.sh, which also runs the picture
transfer: comment out the old program's line there, keep the transfer, then
  sudo systemctl restart start_gimbal"
    done
fi
S_CAM="$(probe_camera_id)"
S_LPM="$(probe_camera_lpm_path)"
S_USBFS="$(cat /sys/module/usbcore/parameters/usbfs_memory_mb 2>/dev/null || echo '?')"
S_PIO="$(pio_path || true)"
S_RUNNING=0; app_running && S_RUNNING=1
S_ARGS="$(app_args)"
S_SVC_ENABLED="$(systemctl is-enabled fire-gimbal-control 2>/dev/null || true)"
S_SVC_ACTIVE="$(systemctl is-active fire-gimbal-control 2>/dev/null || true)"
S_LINGER="$(loginctl show-user "$USER" -p Linger --value 2>/dev/null || echo no)"
# A git checkout knows its revision; a tree synced by deploy.sh (which leaves
# .git behind) carries it in .revision.
revision_of() { git -C "$1" describe --always --dirty 2>/dev/null || cat "$1/.revision" 2>/dev/null || echo nogit; }
S_FW_REV="$(revision_of "$FW")"
S_SW_REV="$(revision_of "$REPO")"
S_FW_NOW="$(firmware_report)"
S_MODE="none"
case "$S_MCU" in
    *MicroPython*)             S_MODE="factory MicroPython image" ;;
    *Raspberry_Pi_Pico*|*Pico*) S_MODE="gimbal firmware" ;;
    *Arduino*|*Leonardo*)     S_MODE="firmware on the Leonardo" ;;
esac
lsusb -d 2e8a:0003 >/dev/null 2>&1 && S_MODE="in its bootloader"

cat <<EOF
board            : $S_BOARD  (kit hardware/$S_BOARD, firmware env $KIT_FW_ENV)
software / fw    : $S_SW_REV / $S_FW_REV
motor MCU        : ${S_MCU:-NOT FOUND}  [$S_MODE]
running firmware : ${S_FW_NOW:-unknown}
camera           : ${S_CAM:-not found}${S_LPM:+  (LPM $(cat "$S_LPM") at $S_LPM)}
usbfs_memory_mb  : $S_USBFS
hardware.ini     : $([[ -r $HW ]] && echo "present, $(hw_todos | grep -c . || true) TODO(s)" || echo absent)
config.ini       : $([[ -r $CFG ]] && echo "present, tower_name $(ini_get "$CFG" General tower_name 2>/dev/null || echo '?')" || echo absent)
app              : $([[ $S_RUNNING -eq 1 ]] && echo "running (${S_ARGS:-no arguments})" || echo "not running")
service          : ${S_SVC_ENABLED:-absent} / ${S_SVC_ACTIVE:-inactive}, linger ${S_LINGER}
PlatformIO       : ${S_PIO:-not installed}
EOF

# ----------------------------------------------------------------- verify ---
TEST_RC=""
# run_test SELECTION - `test hardware`, `test camera`, ... Sets TEST_RC.
run_test() {
    local sel="$1"
    # The app holds the serial port, so a test runs INSIDE it when it is up, and
    # as a one-shot run of the binary when it is not.
    if app_running; then
        local before after restore=""
        before="$(ls -t "$LOGDIR"/test_*.log 2>/dev/null | head -1)"
        # Tests run in manual mode only - auto and pan refuse them. Note the mode,
        # switch for the test, and put it back afterwards, pan's arguments and all.
        app_cmd "mode"; sleep 1
        local m; m="$(grep -oE '\] mode (auto|manual|pan: heading [^,]+, span [^,]+, pitch [^ ]+)' \
                      "$(latest_log)" 2>/dev/null | tail -1 | sed 's/^\] //')"
        case "$m" in
            "mode auto") restore="mode auto" ;;
            "mode pan: "*) restore="$(sed -E 's/mode pan: heading ([^,]+), span ([^,]+), pitch (.+)/mode pan \1 \2 \3/' <<< "$m")" ;;
        esac
        if [[ -n $restore ]]; then say "switching to manual for the test (was: $restore)"; app_cmd "mode manual"; sleep 2; fi
        app_cmd "test $sel"
        for _ in $(seq 1 60); do
            after="$(ls -t "$LOGDIR"/test_*.log 2>/dev/null | head -1)"
            [[ -n $after && $after != "$before" ]] && break
            sleep 1
        done
        if [[ -z $after || $after == "$before" ]]; then
            # The program refuses a test while it homes; say that, not "nothing happened".
            if grep -q "ignored 'test'" "$(latest_log)" 2>/dev/null; then
                warn "the program refused the test: $(grep "ignored 'test'" "$(latest_log)" | tail -1 | sed 's/.*ignored/ignored/')"
            else
                warn "no test report appeared"
            fi
            TEST_RC=5
            [[ -n $restore ]] && app_cmd "$restore"
            return
        fi
        grep -E '^\[|  ; ' "$after" | sed 's/^/   /'
        grep -q '^result = PASS' "$after" && TEST_RC=0 || TEST_RC=4
        if [[ -n $restore ]]; then sleep 2; app_cmd "$restore"; say "back to: $restore"; fi
    else
        local out="$WORK/test.out"
        TEST_RC=0
        ( cd "$REPO" && GENICAM_GENTL64_PATH="${GENICAM_GENTL64_PATH:-/opt/VimbaX/cti}" \
            "$BIN" --no-mqtt --test "$sel" ) > "$out" 2>&1 < /dev/null || TEST_RC=$?
        grep -E '\-\-test|test [a-z]+_[a-z]+:|test overall' "$out" | sed 's/^/   /'
        local rep; rep="$(ls -t "$LOGDIR"/test_*.log 2>/dev/null | head -1)"
        [[ -n $rep ]] && grep -E '  ; ' "$rep" | sed 's/^/   /'
    fi
}

verify() {
    hr "verify"
    local fails=0
    check() {  # name, ok(0/1), detail
        if [[ $2 -eq 0 ]]; then printf '  ok    %-22s %s\n' "$1" "$3"
        else printf '  FAIL  %-22s %s\n' "$1" "$3"; fails=$((fails + 1)); fi
    }
    check "usbfs_memory_mb" "$([[ $S_USBFS -ge 1000 ]] 2>/dev/null && echo 0 || echo 1)" "$S_USBFS (want >= 1000)"
    if [[ -n $S_LPM ]]; then
        check "camera USB3 LPM" "$([[ $(cat "$S_LPM") == 0 ]] && echo 0 || echo 1)" "$(cat "$S_LPM") (want 0)"
    fi
    if [[ -x $FGC_CONFIG && -r $CFG ]]; then
        local out rc=0
        out="$("$FGC_CONFIG" check --config "$CFG" --hardware "$HW" 2>&1)" || rc=$?
        local shadow; shadow="$(grep -c 'overrides' <<< "$out" || true)"
        check "config files load" "$rc" "$([[ $rc -eq 0 ]] && echo "cleanly, $shadow shadowed key(s)" || tail -1 <<< "$out")"
    else
        check "config files load" 1 "fgc-config or config.ini missing"
    fi
    local todo; todo="$(hw_todos | grep -c . || true)"
    check "hardware.ini complete" "$([[ $todo -eq 0 ]] && echo 0 || echo 1)" "$todo TODO(s)"
    local now; now="$(firmware_report)"
    local want_pins; want_pins="$(declared_pins)"
    check "firmware pins" "$(pins_match "$now" "$want_pins" && echo 0 || echo 1)" \
          "running ${now:-unknown}; declared $want_pins"
    run_test hardware
    check "test hardware" "$TEST_RC" "$([[ $TEST_RC == 0 ]] && echo PASS || echo "exit $TEST_RC")"
    # Frames, and every pixel following the camera's own settings to black and to
    # white - which holds whatever the camera is pointed at.
    run_test camera
    check "test camera" "$TEST_RC" "$([[ $TEST_RC == 0 ]] && echo PASS || echo "exit $TEST_RC")"
    # Only once there IS a service: a tower run by hand is a choice, not a fault.
    if [[ -r $UNIT ]]; then
        check "boot service" "$([[ $S_SVC_ENABLED == enabled ]] && echo 0 || echo 1)" "$S_SVC_ENABLED"
        check "user lingering" "$([[ $S_LINGER == yes ]] && echo 0 || echo 1)" \
              "$S_LINGER (without it a service-started app and an ssh session disagree on the lock file)"
    else
        printf '  --    %-22s %s\n' "boot service" "not installed: the program does not start at boot"
    fi
    echo
    [[ $fails -eq 0 ]] && say "all checks pass" || warn "$fails check(s) failed"
    return "$fails"
}

if [[ $MODE == verify ]]; then
    verify && exit 0 || exit 1
fi

# --------------------------------------------------------------- planning ---
# Each step records what it will do; nothing changes until the plan is accepted.
declare -a STEPS=()
declare -A DO=() WHAT=()
plan() { STEPS+=("$1"); DO[$1]="$2"; WHAT[$1]="$3"; }

hr "questions"

# Packages.
mapfile -t PKGS < <(kit_packages)
MISSING=()
for p in "${PKGS[@]}"; do
    dpkg-query -W -f='${Status}' "$p" 2>/dev/null | grep -q 'install ok installed' || MISSING+=("$p")
done
if (( ${#MISSING[@]} )); then plan packages change "install ${MISSING[*]}"
else plan packages skip "all ${#PKGS[@]} present"; fi

# Preconditions.
STALE=()
while read -r src dst; do cmp -s "$src" "$dst" || STALE+=("$(basename "$dst")"); done < <(kit_precondition_targets)
if (( ${#STALE[@]} )); then plan preconditions change "install ${STALE[*]}"
else plan preconditions skip "installed"; fi

# Vimba X SDK. Login-walled, so the operator brings a copy.
VIMBA_SRC=""
if [[ -d /opt/VimbaX/cti ]]; then
    plan vimba skip "$(readlink -f /opt/VimbaX)"
else
    # An SDK already installed under a versioned name - the legacy towers' own
    # software put it at /opt/VimbaX_2024-1, with no /opt/VimbaX - is used where it
    # is: installing it registers it and links /opt/VimbaX to it, copying nothing.
    existing="$(ls -d /opt/VimbaX_*/ 2>/dev/null | sed 's:/$::' | sort | tail -1 || true)"
    [[ -x ${existing:-/nonexistent}/cti/Install_GenTL_Path.sh ]] || existing=""
    staged="$(ls -d "$HOME"/VimbaX_* 2>/dev/null | head -1 || true)"
    if [[ -n $existing ]]; then
        echo "A Vimba X SDK is already on this machine at $existing, but not as /opt/VimbaX."
        echo "Take it (it is registered and linked, not copied), or give another directory."
    else
        echo "The Vimba X SDK is not installed. It cannot be downloaded by a script: copy it"
        echo "onto this machine (USB stick or scp) and give its directory, or 'none' for a"
        echo "tower with no camera yet."
    fi
    ask vimba_src "Vimba X directory" "${existing:-${staged:-none}}"
    if [[ $REPLY_VAL == none ]]; then plan vimba skip "no camera SDK (answered none)"
    else
        [[ -x $REPLY_VAL/cti/Install_GenTL_Path.sh ]] || die "$REPLY_VAL does not look like a Vimba X SDK"
        VIMBA_SRC="$REPLY_VAL"; plan vimba change "install $VIMBA_SRC"
    fi
fi
HAVE_VIMBA=0; [[ -d /opt/VimbaX/cti || -n $VIMBA_SRC ]] && HAVE_VIMBA=1

plan build change "cmake build of $S_SW_REV (MQTT on, Vimba $([[ $HAVE_VIMBA -eq 1 ]] && echo on || echo off))"

# hardware.ini: what this tower IS.
HW_NEW=""                 # a whole new file, when there is none
declare -a HW_SETS=()     # key edits, when there is one
declare -a HW_FILLS=()    # "axis key value": a ; TODO answered now, filled in place
TODO_LEFT=0
HW_APPEND_PINS=0
# Sets LOOM_A to the loom chosen (standard / legacy), or "" to give each pin.
# Returns 0 when a loom was chosen. Only the lines for what the tower declares
# are kept, so a yaw-only tower takes the standard loom without its pitch pins.
LOOM_A=""
choose_pins() {
    local names="standard" n
    [[ -r $KIT/pins-legacy.ini ]] && names+=" legacy"
    echo
    echo "Wiring ([Pins]) - the firmware is built from it, and a chip-select on the"
    echo "wrong pin writes into a driver that was never meant to hear it. This board's looms:"
    for n in $names; do
        echo "  $n  (hardware/$S_BOARD/$(basename "$(kit_loom_file "$n")")):"
        emit_loom "$(kit_loom_file "$n")" "${AXES_A:-$declared_axes_now}" "${ENV_A:-none}" \
                  "${COOLING_A:-none}" | sed -n '/^\[Pins\]/,$p' | sed 's/^/      /'
    done
    while :; do
        ask loom "Is this tower wired exactly to one of them? (${names// /, }, or n to give each pin)" n
        case "${REPLY_VAL,,}" in
            y|yes|standard) LOOM_A=standard; return 0 ;;
            legacy) [[ $names == *legacy* ]] && { LOOM_A=legacy; return 0; } ;;
            n|no)   LOOM_A=""; return 1 ;;
        esac
        warn "'$REPLY_VAL' is not one of: ${names// /, }, n"; unset "ANS[loom]"
        [[ $NONINTERACTIVE -eq 1 ]] && die "bad value for 'loom'"
    done
}
if [[ ! -r $HW ]]; then
    echo
    echo "There is no hardware.ini yet. The next questions are what was ASSEMBLED -"
    echo "declared, not probed: a missing part and a broken one look the same to a probe."
    ask axes "Axes fitted (yaw, or yaw,pitch)" "$KIT_DEFAULT_AXES"; AXES_A="${REPLY_VAL// /}"
    [[ $AXES_A == yaw || $AXES_A == yaw,pitch ]] || die "axes must be 'yaw' or 'yaw,pitch' (the firmware's axis 0 is yaw)"
    ask imu "IMU model, or none" none; IMU_A="$REPLY_VAL"
    ask env "Ambient sensor model (sht41 on the host's I2C, dht11 on the motor MCU), or none" none
    ENV_A="${REPLY_VAL,,}"
    COOLING_A=none
    if [[ $ENV_A == dht11 ]]; then
        explain "the legacy towers: the motor MCU reads the DHT11 and runs the enclosure fan from it - off to 25 C, full from 37.75 C, full whenever the sensor cannot be read"
        ask_yn fan "Does the motor MCU run an enclosure fan from it?" y && COOLING_A=fan
    fi
    ask cameras "Cameras (rgb, or none)" "$([[ $HAVE_VIMBA -eq 1 ]] && echo rgb || echo none)"; CAMS_A="$REPLY_VAL"
    KIT_ARGS=(--stdout --axes "$AXES_A" --imu "$IMU_A" --env "$ENV_A" --cooling "$COOLING_A"
              --cameras "$CAMS_A")
    if (( S_MCU_N > 1 )); then
        echo "Several MCU candidates:"; nl -w2 -s') ' <<< "$S_MCU"
        ask serial "Which is the motor MCU (full path)"
        KIT_ARGS+=(--serial "$REPLY_VAL")
    elif (( S_MCU_N == 0 )); then
        ask serial "No motor MCU found - its /dev/serial/by-id path"
        KIT_ARGS+=(--serial "$REPLY_VAL")
    fi
    if [[ $CAMS_A != none && -z $S_CAM ]]; then
        ask camera_id "Camera DEV_ id (none found; the SDK may need a reboot first)"
        KIT_ARGS+=(--camera-id "$REPLY_VAL")
    fi
    [[ $IMU_A != none ]] && { ask imu_device "IMU serial device" "$KIT_IMU_DEVICE"; KIT_ARGS+=(--imu-device "$REPLY_VAL"); }
    if [[ $ENV_A != none && $ENV_A != dht11 ]]; then
        ask i2c_device "Ambient sensor I2C bus (check it with i2cdetect -F)" "$KIT_ENV_DEVICE"
        KIT_ARGS+=(--i2c-device "$REPLY_VAL")
    fi
    if choose_pins; then KIT_ARGS+=(--loom "$LOOM_A")
    else
        echo "Then give each pin (GPn number):"
        keys=(); [[ $KIT_FW_ENV == *rp2040* ]] && keys+=(spi_sck spi_mosi spi_miso)
        for ax in ${AXES_A//,/ }; do keys+=("${ax}_cs" "${ax}_en"); done
        [[ $ENV_A == dht11 ]] && keys+=(dht)
        [[ $COOLING_A == fan ]] && keys+=(fan_pwm fan_tach)
        for k in "${keys[@]}"; do ask "pin.$k" "  $k"; KIT_ARGS+=(--pin "$k=$REPLY_VAL"); done
    fi
    echo
    echo "The drivetrain, per axis. None of these can be probed and none is guessed:"
    echo "answer 'unknown' for anything not yet measured, and the axis will refuse to"
    echo "move until it is filled in (a wrong value can cook a driver or shear a stop)."
    for ax in ${AXES_A//,/ }; do
        for key in "${KIT_AXIS_KEYS[@]}"; do
            case $key in
                has_encoder) pat='^(true|false)$' ;;
                sw_mode)     pat='^[0-9]+$' ;;   # decimal: the host reads no hex here
                *)           pat='^[0-9]+(\.[0-9]+)?$' ;;
            esac
            explain "$(kit_axis_todo "$key")"
            ask_value "$ax.$key" "  $ax $key" "$pat"
            [[ -n $REPLY_VAL ]] && KIT_ARGS+=(--motor "$ax.$key=$REPLY_VAL")
        done
    done
    "$KIT/install.sh" "${KIT_ARGS[@]}" > "$WORK/hardware.ini" 2> "$WORK/kit.err" || { cat "$WORK/kit.err" >&2; die "the kit could not generate hardware.ini"; }
    HW_NEW="$WORK/hardware.ini"
    if grep -q '^\[Pins\]' "$HW_NEW"; then
        python3 "$FW/tools/gen_wiring.py" --check "$HW_NEW" --env "$KIT_FW_ENV" > /dev/null ||
            die "those pins are not usable on this board (above); nothing was written"
    fi
    plan hardware.ini create "$HW ($(grep -c '^; TODO' "$HW_NEW" || true) TODO)"
else
    # An existing file is the tower's record: never regenerated, only topped up.
    if ! grep -q '^\[Pins\]' "$HW"; then
        echo; echo "hardware.ini has no [Pins], and the firmware cannot be built without it."
        declared_axes_now="$(ini_get "$HW" Hardware axes 2>/dev/null | tr -d ' ' || echo yaw)"
        choose_pins && HW_APPEND_PINS=1 ||
            warn "add [Pins] to $HW by hand (the format is in config/hardware.example.ini)"
    fi
    cur_serial="$(ini_get "$HW" Serial device 2>/dev/null || true)"
    if (( S_MCU_N == 1 )) && [[ $S_MCU != "$cur_serial" && $S_MCU != *MicroPython* ]]; then
        echo; echo "The motor MCU is now at $S_MCU, but hardware.ini says ${cur_serial:-nothing}."
        ask_yn fix_serial "Update [Serial] device?" y && HW_SETS+=("Serial.device=$S_MCU")
    fi
    mapfile -t TODOS < <(hw_todos | grep -v ' \[Pins\]' || true)
    TODO_LEFT=${#TODOS[@]}
    if (( ${#TODOS[@]} )); then
        echo; echo "hardware.ini still has gaps. An axis with any of these missing will not move."
        # Which [Motor.<axis>] each TODO sits in.
        while read -r ax key; do
            [[ -z $key ]] && continue
            case $key in
                has_encoder) pat='^(true|false)$' ;; sw_mode) pat='^[0-9]+$' ;;
                *) pat='^[0-9]+(\.[0-9]+)?$' ;;
            esac
            explain "$(kit_axis_todo "$key")"
            ask_value "$ax.$key" "  $ax $key" "$pat"
            [[ -n $REPLY_VAL ]] && { HW_FILLS+=("$ax $key $REPLY_VAL"); TODO_LEFT=$((TODO_LEFT - 1)); }
        done < <(awk '/^\[Motor\./ { ax = $0; gsub(/^\[Motor\.|\].*$/, "", ax) }
                      /^; TODO / && ax != "" { print ax, $3 }' "$HW")
    fi
    what=""
    [[ $HW_APPEND_PINS -eq 1 ]] && what+="add the standard [Pins]; "
    (( ${#HW_FILLS[@]} )) && what+="fill ${#HW_FILLS[@]} TODO(s); "
    (( ${#HW_SETS[@]} )) && what+="set ${HW_SETS[*]}"
    [[ -n $what ]] && plan hardware.ini change "$what" || plan hardware.ini skip "present, nothing to change"
fi

# What hardware.ini will say once written - later steps depend on it.
HW_VIEW="$HW"; [[ -n $HW_NEW ]] && HW_VIEW="$HW_NEW"
steps_for() { ini_get "$HW_VIEW" "Motor.$1" steps_per_rev 2>/dev/null || true; }
declare -A STEPS_OVR=()
for f in "${HW_FILLS[@]}"; do   # a TODO answered now counts too
    read -r fa fk fv <<< "$f"
    [[ $fk == steps_per_rev ]] && STEPS_OVR[$fa]="$fv"
done
declared_axes="$(ini_get "$HW_VIEW" Hardware axes 2>/dev/null | tr -d ' ' || echo yaw)"

# config.ini: what this tower DOES.
declare -a CFG_SETS=()
CFG_SEED=0 CRED_WRITE=0
if [[ ! -r $CFG ]]; then
    CFG_SEED=1
    echo; echo "There is no config.ini yet."
    while :; do
        ask tower_name "Tower name (it is in every MQTT topic)"
        case "${REPLY_VAL,,}" in
            ""|exampletower|change_me)
                warn "that is a placeholder, not a tower name"; unset "ANS[tower_name]"
                [[ $NONINTERACTIVE -eq 1 ]] && die "tower_name is a placeholder" ;;
            *) break ;;
        esac
    done
    [[ ${REPLY_VAL,,} == dev ]] && warn "tower_name 'dev' is also what a development box uses - make sure it is this tower's"
    CFG_SETS+=("General.tower_name=$REPLY_VAL")
    if ask_yn mqtt "Report over MQTT?" y; then
        CFG_SETS+=("Features.enable_mqtt=true")
        ask broker "Broker address (each tower runs its own RabbitMQ)" 127.0.0.1
        CFG_SETS+=("Network.zkms_server_ip=$REPLY_VAL")
        if [[ ! -r $CRED ]] && ! sudo test -e "$CRED" 2>/dev/null; then
            ask mqtt_user "MQTT user"; MQTT_USER="$REPLY_VAL"
            ask_secret mqtt_pw "MQTT password"; MQTT_PW="$REPLY_VAL"
            CRED_WRITE=1
        fi
    else
        CFG_SETS+=("Features.enable_mqtt=false")
    fi
else
    tn="$(ini_get "$CFG" General tower_name 2>/dev/null || true)"
    case "${tn,,}" in ""|dev|exampletower)
        # 'dev' can be real (tower 111 is called that), so it is asked, not refused.
        echo; echo "config.ini's tower_name is '$tn' - a placeholder, another rig's copy, or real."
        ask tower_name "Tower name" "$tn"
        [[ -z $REPLY_VAL || ${REPLY_VAL,,} == exampletower ]] && die "tower_name '$REPLY_VAL' is a placeholder"
        [[ $REPLY_VAL != "$tn" ]] && CFG_SETS+=("General.tower_name=$REPLY_VAL") ;;
    esac
fi
# The centre each axis needs: its range is centre +/- half the travel, so a
# centre below half the travel puts the negative end under zero.
for ax in ${declared_axes//,/ }; do
    steps="${STEPS_OVR[$ax]:-$(steps_for "$ax")}"
    [[ $steps =~ ^[0-9]+$ ]] || continue
    have="$(ini_get "$CFG" Motor "${ax}_center_count" 2>/dev/null || echo 500000)"
    if (( have <= steps / 2 )); then CFG_SETS+=("Motor.${ax}_center_count=$(kit_center_for "$steps")"); fi
done
# The LPM node, so `test hardware camera` checks it rather than skipping.
if [[ -n $S_LPM ]] && [[ -z $(ini_get "$CFG" Test usb3_lpm_path 2>/dev/null || true) ]]; then
    CFG_SETS+=("Test.usb3_lpm_path=$S_LPM")
fi
# In an existing config.ini, the keys hardware.ini now owns are dead copies the
# program warns about at every start. They are commented out, value and all.
declare -a CFG_DROPS=()
if [[ $CFG_SEED -eq 0 && -r $HW_VIEW ]]; then
    ini_keys() { awk '/^[[:space:]]*[;#]/ { next }
                      /^[[:space:]]*\[/ { sec = $0; sub(/^[[:space:]]*\[/, "", sec); sub(/\].*$/, "", sec); next }
                      index($0, "=") { k = substr($0, 1, index($0, "=") - 1); gsub(/^[ \t]+|[ \t]+$/, "", k); print sec "." k }' "$1" | sort -u; }
    while read -r k; do
        [[ -n $k ]] && CFG_DROPS+=("$k=is set in hardware.ini, which wins over this file")
    done < <(comm -12 <(ini_keys "$HW_VIEW") <(ini_keys "$CFG"))
    [[ -n $(ini_get "$HW_VIEW" Hardware imu 2>/dev/null) && -n $(ini_get "$CFG" Features enable_imu 2>/dev/null) ]] &&
        CFG_DROPS+=("Features.enable_imu=follows [Hardware] imu in hardware.ini")
    [[ -n $(ini_get "$HW_VIEW" Hardware env 2>/dev/null) && -n $(ini_get "$CFG" Features enable_env 2>/dev/null) ]] &&
        CFG_DROPS+=("Features.enable_env=follows [Hardware] env in hardware.ini")
fi
if [[ $CFG_SEED -eq 1 ]]; then plan config.ini create "seed from the example: ${CFG_SETS[*]}"
elif (( ${#CFG_SETS[@]} + ${#CFG_DROPS[@]} )); then
    plan config.ini change "${CFG_SETS[*]:+set ${CFG_SETS[*]}; }${CFG_DROPS[*]:+comment out ${#CFG_DROPS[@]} key(s) hardware.ini owns (${CFG_DROPS[*]%%=*})}"
else plan config.ini skip "present, nothing to change"; fi
[[ $CRED_WRITE -eq 1 ]] && plan credentials create "$CRED (0640 root:$(id -gn))"

# PlatformIO.
if [[ -n $S_PIO ]] && "$S_PIO" --version >/dev/null 2>&1; then plan platformio skip "$("$S_PIO" --version)"
else plan platformio change "install PlatformIO Core into ~/.platformio"; fi

# Camera profiles: the program will not start a real camera without them.
PROFDIR="$REPO/config/camera_profiles/rgb"
cams="$(ini_get "$HW_VIEW" Hardware cameras 2>/dev/null || echo none)"
if [[ $cams == none || -z $cams ]]; then plan profiles skip "no camera declared"
else
    # Only what is MISSING. An existing profile was tuned on this camera - tower
    # 111 has a hand-exported standard.xml and no night.xml - and exporting over
    # it would replace that tuning with the recipe's defaults.
    PROF_MISSING=()
    for prof in night standard; do [[ -r $PROFDIR/$prof.xml ]] || PROF_MISSING+=("$prof"); done
    if (( ${#PROF_MISSING[@]} == 0 )); then plan profiles skip "standard + night present"
    elif [[ $HAVE_VIMBA -eq 0 ]]; then plan profiles skip "no Vimba SDK: export ${PROF_MISSING[*]} later"
    else plan profiles change "export ${PROF_MISSING[*]} from the camera (existing profiles untouched)"; fi
fi

# Firmware: flash only what differs, and never onto an axis that would sit in
# NEEDCFG because hardware.ini cannot answer it.
FW_BLOCK=""
if [[ -n $HW_NEW ]]; then
    todo_n="$(grep -c '^; TODO' "$HW_NEW" || true)"
else
    todo_n=$TODO_LEFT
    grep -q '^\[Pins\]' "$HW" || [[ $HW_APPEND_PINS -eq 1 ]] || todo_n=$((todo_n + 1))
fi
(( todo_n > 0 )) && FW_BLOCK="hardware.ini still has $todo_n gap(s): the firmware would sit in NEEDCFG, or not build"
want_pins="$( (HW="$HW_VIEW"; [[ $HW_APPEND_PINS -eq 1 ]] && { cat "$HW" "$(kit_loom_file "$LOOM_A")" > "$WORK/hw_pins.ini"; HW="$WORK/hw_pins.ini"; }; declared_pins) )"
if [[ -n $FW_BLOCK ]]; then plan firmware blocked "$FW_BLOCK"
elif [[ -n $S_FW_NOW && ${S_FW_NOW%% *} == "build=$S_FW_REV" ]] && pins_match "$S_FW_NOW" "$want_pins"; then
    plan firmware skip "running $S_FW_REV, built with the declared pins"
else
    plan firmware change "build $S_FW_REV and flash it (running: ${S_FW_NOW:-unknown}; declared $want_pins)"
fi

plan test run "test hardware and test camera (moves nothing)"
plan homing ask "asked at the end: home the gimbal, take one picture, stay in manual"

# The unit is scripts/fire-gimbal-control.service with its @...@ fields filled.
render_unit() {
    sed -e "s|@USER@|$USER|g" -e "s|@REPO@|$REPO|g" -e "s|@ARGS@|$1|g" \
        "$REPO/scripts/fire-gimbal-control.service"
}
# What an installed unit starts the program with, so a re-run keeps it.
installed_args() {
    [[ -r $UNIT ]] && sed -n 's|^ExecStart=.*/scripts/fgc start *||p' "$UNIT" | head -1
}
UNIT_TEXT=""
# A new tower homes at every boot but captures nothing until a person has chosen
# auto: --init, not --init --start. Once it is proven, the operator makes it boot
# into auto by re-running with --answer service_args="--init --start". A service
# of ours keeps the arguments it has; one that is not ours (a legacy tower's old
# program) gets the same safe default.
SVC_ARGS_DEFAULT="--init"
[[ -r $UNIT && -n $(installed_args) ]] && SVC_ARGS_DEFAULT="$(installed_args)"
if [[ -r $UNIT && $S_SVC_ENABLED == enabled && $S_LINGER == yes && -z ${ANS[service_args]+x} ]] &&
   [[ "$(cat "$UNIT")" == "$(render_unit "$(installed_args)")" ]]; then
    plan service skip "installed and enabled (fgc start ${SVC_ARGS_DEFAULT/#none/})"
else
    echo
    echo "The boot service starts the program at every boot. With --init it HOMES THE"
    echo "GIMBAL UNATTENDED each time the tower powers up - which is what a tower in"
    echo "service needs, since the firmware keeps no position across a power cycle."
    echo "With --init alone it then WAITS IN MANUAL and captures nothing; add --start"
    echo "(--init --start) to boot straight into auto once the tower is proven."
    if ask_yn service "Install and enable it?" y; then
        ask service_args "Arguments it starts the program with ('none' for none)" "$SVC_ARGS_DEFAULT"
        svc_args="$REPLY_VAL"; [[ $svc_args == none ]] && svc_args=""
        UNIT_TEXT="$(render_unit "$svc_args")"
        plan service change "write $UNIT (fgc start $svc_args), enable it, enable lingering for $USER"
    else plan service skip "declined"; fi
fi

# ------------------------------------------------------------------- plan ---
hr "plan"
for s in "${STEPS[@]}"; do printf '  %-14s %-8s %s\n' "$s" "${DO[$s]}" "${WHAT[$s]}"; done
if [[ -n $HW_NEW ]]; then echo; echo "hardware.ini to be written:"; sed 's/^/    | /' "$HW_NEW"; fi
if [[ $MODE == dry-run ]]; then echo; say "dry run: nothing changed"; exit 0; fi
echo
ask_yn confirm "Apply this plan?" n || { say "nothing changed"; exit 0; }

# ------------------------------------------------------------------ apply ---
CHANGED=()
done_step() { CHANGED+=("$1"); }
# Not `sudo -v`: that asks for a password whenever ANY of the user's sudo rules
# needs one, even beside a NOPASSWD rule that covers every command used here.
sudo -n true 2>/dev/null || { tty_ok && sudo true < /dev/tty; } || die "sudo is needed for the system steps"

# The app holds the serial port and the camera. Stopped once, restarted once at
# the end, the way it was running.
WAS_RUNNING=$S_RUNNING
STOPPED_BY_US=0 STOPPED_SVC=0
stop_app() {
    if [[ $S_SVC_ACTIVE == active ]]; then
        sudo systemctl stop fire-gimbal-control; S_SVC_ACTIVE=inactive; STOPPED_SVC=1; STOPPED_BY_US=1
    elif app_running; then "$FGC" stop; STOPPED_BY_US=1; fi
}
# If anything fails after the program was stopped, put it back as it was: a
# tower left stopped by a half-finished install captures nothing, silently.
put_back() {
    local rc=$?
    rm -rf "$WORK"
    [[ $STOPPED_BY_US -eq 1 ]] && ! app_running || return $rc
    # The service stopped at the start may no longer be the one installed now: on
    # a legacy tower it was the old program's, and this run replaced its unit.
    # Starting the new one here would home the gimbal with nobody asked - the
    # step this installer only takes after a person at the rig says yes.
    if [[ $STOPPED_SVC -eq 1 && ${CHANGED[*]:-} == *service* ]]; then
        warn "the program is not running: the boot service was replaced by this run, and"
        warn "starting it homes the gimbal. With someone at the rig: sudo systemctl start fire-gimbal-control"
        return $rc
    fi
    warn "stopped before finishing - restarting the program as it was"
    if [[ $STOPPED_SVC -eq 1 ]]; then sudo systemctl start fire-gimbal-control
    else
        local a; read -r -a a <<< "${S_ARGS:-}"
        ( cd "$REPO" && "$FGC" start "${a[@]}" ) > /dev/null
    fi
    return $rc
}
trap put_back EXIT

if [[ ${DO[packages]} == change ]]; then hr packages; sudo "$KIT/install.sh" --deps; done_step packages; fi
if [[ ${DO[preconditions]} == change ]]; then hr preconditions; sudo "$KIT/install.sh" --preconditions; done_step preconditions; fi
if [[ ${DO[vimba]} == change ]]; then hr vimba; sudo "$KIT/install.sh" --vimba "$VIMBA_SRC"; done_step vimba; fi

hr "host build"
( cd "$REPO" && GIT_REV="$S_SW_REV" cmake -S . -B build -DWITH_MQTT=ON \
      -DWITH_VIMBA=$([[ -d /opt/VimbaX/cti ]] && echo ON || echo OFF) > "$WORK/cmake.log" 2>&1 ) ||
    { tail -20 "$WORK/cmake.log" >&2; die "cmake configure failed"; }
( cd "$REPO" && cmake --build build -j"$(nproc)" 2>&1 | tail -3 )
[[ -x $BIN && -x $FGC_CONFIG ]] || die "the build did not produce the program and fgc-config"
done_step build

if [[ ${DO[hardware.ini]} == create ]]; then
    hr hardware.ini
    sudo install -d -m 0755 "$ETC"
    sudo install -m 0644 "$HW_NEW" "$HW"
    say "wrote $HW"; done_step hardware.ini
elif [[ ${DO[hardware.ini]} == change ]]; then
    hr hardware.ini
    sudo cp -p "$HW" "$HW.bak-$(date +%Y%m%d-%H%M%S)"
    if [[ $HW_APPEND_PINS -eq 1 ]]; then
        { printf '\n'; emit_loom "$(kit_loom_file "$LOOM_A")" "$declared_axes_now" \
              "$(ini_get "$HW" Hardware env 2>/dev/null || echo none)" \
              "$(ini_get "$HW" Hardware cooling 2>/dev/null || echo none)"; } |
            sudo tee -a "$HW" > /dev/null; say "added [Pins]"
    fi
    if (( ${#HW_FILLS[@]} )); then
        # In place of the ; TODO and its explanation, so the value sits where the
        # gap was - not appended in a second [Motor.<axis>] block at the end.
        printf '%s\n' "${HW_FILLS[@]}" > "$WORK/fills"
        awk 'NR == FNR { v[$1 "." $2] = $3; next }
             /^\[/ { ax = ""; if ($0 ~ /^\[Motor\./) { ax = $0; gsub(/^\[Motor\.|\].*$/, "", ax) } skip = 0 }
             skip && /^;        / { next }
             { skip = 0 }
             /^; TODO / && ((ax "." $3) in v) { printf "%-18s = %s\n", $3, v[ax "." $3]; skip = 1; next }
             { print }' "$WORK/fills" "$HW" > "$WORK/hw_filled"
        sudo install -m 0644 "$WORK/hw_filled" "$HW"
        say "filled ${#HW_FILLS[@]} TODO(s)"
    fi
    if (( ${#HW_SETS[@]} )); then
        args=(); for s in "${HW_SETS[@]}"; do args+=(--set "$s"); done
        # hardware.ini is validated together with config.ini when there is one.
        cfg_for_check="$CFG"; [[ -r $CFG ]] || cfg_for_check="$REPO/config/config.example.ini"
        sudo "$FGC_CONFIG" set "$HW" --config "$cfg_for_check" --hardware "$HW" "${args[@]}" \
             --provenance "$(date -u +%Y-%m-%dT%H:%MZ), install-tower"
    fi
    done_step hardware.ini
fi

if [[ ${DO[credentials]:-} == create ]]; then
    hr credentials
    printf 'FGC_MQTT_USER=%s\nFGC_MQTT_PW=%s\n' "$MQTT_USER" "$MQTT_PW" > "$WORK/cred"
    sudo install -m 0640 -o root -g "$(id -gn)" "$WORK/cred" "$CRED"
    say "wrote $CRED"; done_step credentials
fi

if [[ ${DO[config.ini]} != skip ]]; then
    hr config.ini
    args=(); for s in "${CFG_SETS[@]}"; do args+=(--set "$s"); done
    if [[ $CFG_SEED -eq 1 ]]; then
        "$FGC_CONFIG" seed --example "$REPO/config/config.example.ini" --out "$CFG" --hardware "$HW" \
            "${args[@]}" \
            --drop "Network.mqtt_user=comes from $CRED (FGC_MQTT_USER)" \
            --drop "Network.mqtt_pw=comes from $CRED (FGC_MQTT_PW)"
    else
        for d in "${CFG_DROPS[@]}"; do args+=(--drop "$d"); done
        cp -p "$CFG" "$CFG.bak-$(date +%Y%m%d-%H%M%S)"
        "$FGC_CONFIG" set "$CFG" --config "$CFG" --hardware "$HW" "${args[@]}" \
            --provenance "$(date -u +%Y-%m-%dT%H:%MZ), install-tower"
    fi
    done_step config.ini
fi

if [[ ${DO[platformio]} == change ]]; then
    hr platformio
    curl -fsSL https://raw.githubusercontent.com/platformio/platformio-core-installer/master/get-platformio.py -o "$WORK/get-platformio.py"
    # Its temp-dir finalizer throws a harmless traceback after the work is done;
    # the install is judged by whether pio answers.
    python3 "$WORK/get-platformio.py" > "$WORK/pio.log" 2>&1 || true
    S_PIO="$(pio_path)" && "$S_PIO" --version || { tail -20 "$WORK/pio.log" >&2; die "PlatformIO did not install"; }
    done_step platformio
fi

if [[ ${DO[profiles]} == change ]]; then
    hr "camera profiles"
    stop_app
    # A missing profile is worth trying for, not worth abandoning the install over.
    if { [[ -x $HOME/.venvs/vmbpy/bin/python ]] || {
             python3 -m venv "$HOME/.venvs/vmbpy" &&
             "$HOME/.venvs/vmbpy/bin/pip" install -q /opt/VimbaX/api/python/vmbpy-*.whl; }; }; then
        for prof in "${PROF_MISSING[@]}"; do
            ( cd "$REPO" && GENICAM_GENTL64_PATH=/opt/VimbaX/cti "$HOME/.venvs/vmbpy/bin/python" \
                  scripts/export_camera_profile.py --profile "$prof" ) ||
                warn "could not export the $prof profile; export it later (docs/tower-installation.md section 8)"
        done
        done_step profiles
    else
        warn "no vmbpy for this Vimba install; camera profiles ${PROF_MISSING[*]} not exported"
    fi
fi

hr "config check"
"$FGC_CONFIG" check --config "$CFG" --hardware "$HW"

if [[ ${DO[firmware]} == change ]]; then
    hr firmware
    stop_app
    port="$(ini_get "$HW" Serial device 2>/dev/null || true)"
    [[ -e $port ]] || port="$(probe_mcu | head -1)"
    PIO="$S_PIO" GIT_REV="$S_FW_REV" "$FW/tools/flash.sh" --env "$KIT_FW_ENV" --port "$port"
    # The by-id name comes from the running image, so a first flash changes it.
    sleep 3
    new="$(probe_mcu)"
    if [[ $(grep -c . <<< "$new") -eq 1 && $new != "$port" ]]; then
        say "the MCU is now $new"
        sudo "$FGC_CONFIG" set "$HW" --config "$CFG" --hardware "$HW" --set "Serial.device=$new" \
             --provenance "$(date -u +%Y-%m-%dT%H:%MZ), install-tower, after flashing"
    fi
    done_step firmware
elif [[ ${DO[firmware]} == blocked ]]; then
    warn "firmware not flashed: $FW_BLOCK"
fi

hr "test camera"
run_test camera
[[ $TEST_RC == 0 ]] && say "test camera: PASS" || warn "test camera: exit $TEST_RC - read the report above"

hr "test hardware"
S_SVC_ENABLED="$(systemctl is-enabled fire-gimbal-control 2>/dev/null || true)"
# Last, so TEST_RC is this one's: it is what the homing gate below asks about.
run_test hardware
[[ $TEST_RC == 0 ]] && say "test hardware: PASS" || warn "test hardware: exit $TEST_RC - read the report above"

# Before the gate, so that homing - if a person says yes - is done by the
# service itself, and happens once rather than again at its first start.
if [[ ${DO[service]} == change ]]; then
    hr "boot service"
    printf '%s\n' "$UNIT_TEXT" > "$WORK/unit"
    sudo install -m 0644 "$WORK/unit" "$UNIT"
    # Without lingering, /run/user/<uid> does not exist until someone logs in, so
    # a service-started app takes its lock in /tmp, and an ssh session then looks
    # in /run/user, sees nothing, and `fgc status` says "not running".
    sudo loginctl enable-linger "$USER"
    sudo systemctl daemon-reload
    sudo systemctl enable fire-gimbal-control
    done_step service
fi
SVC_ON=0; [[ $(systemctl is-enabled fire-gimbal-control 2>/dev/null) == enabled ]] && SVC_ON=1

# ------------------------------------------------------------- the gate ---
HOMED="not attempted"
hr "homing"
echo "Homing drives each axis into its endstops. On a new or changed drivetrain it"
echo "is the step that can break something, so it needs a person at the rig."
if [[ $TEST_RC != 0 ]]; then
    warn "not offered: test hardware did not pass"
elif ask_yn home "Someone is at the rig with a hand on the power - home now and take one picture?" n; then
    stop_app
    before="$(latest_log)"
    # Through the service when the service is what homes it, so the tower ends
    # up running under it; otherwise the same run by hand. Never --start here:
    # the tower is left in manual, and auto is a person's choice (a service
    # already set to --start is the exception - that choice was made).
    if [[ $SVC_ON -eq 1 && " $(installed_args) " == *" --init "* ]]; then sudo systemctl start fire-gimbal-control
    else ( cd "$REPO" && "$FGC" start --init ) > /dev/null; fi
    say "homing (this can take a few minutes)"
    log=""; for _ in $(seq 1 30); do log="$(latest_log)"; [[ -n $log && $log != "$before" ]] && break; sleep 1; done
    outdir=""; ok=0; snapped=0
    for _ in $(seq 1 420); do
        grep -q 'Gimbal homed' "$log" 2>/dev/null && ok=1
        outdir="$(sed -n 's/.*capture output dir: //p' "$log" 2>/dev/null | tail -1)"
        # In manual, one snapshot proves the camera; in auto the sweep does.
        if [[ $ok -eq 1 && $snapped -eq 0 ]] && grep -q 'Entering control loop' "$log" 2>/dev/null &&
           ! grep -q '\] mode auto' "$log" 2>/dev/null; then
            sleep 2; app_cmd "snapshot" && snapped=1
        fi
        if [[ $ok -eq 1 && -n $outdir ]] && find "$outdir" -name '*.jxl' -newer "$log" 2>/dev/null | grep -q .; then ok=2; break; fi
        app_running || break
        sleep 1
    done
    grep -E 'homed .* travel|counts_per_deg|Gimbal homed|ERROR' "$log" 2>/dev/null | tail -6 | sed 's/^/   /'
    case $ok in
        2) HOMED="homed and captured ($(find "$outdir" -name '*.jxl' -newer "$log" | wc -l) image(s) in $outdir)"
           [[ $snapped -eq 1 ]] && HOMED+="; left in manual - nothing more is captured until someone chooses auto"
           say "$HOMED" ;;
        1) HOMED="homed, but no image within the wait"; warn "$HOMED" ;;
        *) HOMED="did not home - read the log above"; warn "$HOMED" ;;
    esac
    # Left running, in the mode it came up in.
fi

# Put the program back if it is not running. Not under the service unless it
# has just homed with someone watching: the service homes by itself.
hr "restart"
if app_running; then
    say "running"
elif [[ $WAS_RUNNING -eq 1 ]]; then
    read -r -a restart_args <<< "${S_ARGS:-}"
    before="$(latest_log)"
    ( cd "$REPO" && "$FGC" start "${restart_args[@]}" ) > /dev/null && say "restarted as it was: fgc start${S_ARGS:+ $S_ARGS}"
    # With --init it is homing now, and refuses a test until it is done: wait,
    # or the checks below report a busy program as a failed one.
    if [[ " ${S_ARGS:-} " == *" --init "* ]]; then
        say "waiting for it to home before checking"
        for _ in $(seq 1 420); do
            log="$(latest_log)"
            if [[ -n $log && $log != "$before" ]] && grep -qE 'Gimbal homed|homing (failed|timed out)|ERROR.*hom' "$log"; then break; fi
            app_running || break
            sleep 1
        done
        grep -hE 'Gimbal homed|homed .* travel' "$(latest_log)" 2>/dev/null | tail -3 | sed 's/^/   /'
    fi
else
    say "left stopped, as it was"
fi

# ----------------------------------------------------------------- report ---
S_SVC_ENABLED="$(systemctl is-enabled fire-gimbal-control 2>/dev/null || true)"
S_SVC_ACTIVE="$(systemctl is-active fire-gimbal-control 2>/dev/null || true)"
S_LINGER="$(loginctl show-user "$USER" -p Linger --value 2>/dev/null || echo no)"
S_USBFS="$(cat /sys/module/usbcore/parameters/usbfs_memory_mb 2>/dev/null || echo '?')"
S_LPM="$(probe_camera_lpm_path)"
verify_rc=0; verify || verify_rc=$?

hr "still open"
open=0
while read -r t; do [[ -n $t ]] && { echo "  - hardware.ini: $t"; open=1; }; done < <(hw_todos)
[[ $HOMED == homed* ]] || { echo "  - homing: $HOMED"; open=1; }
[[ $HOMED == homed* ]] && { echo "  - calibration: run 'gimbal heading setup' to tie yaw to true north"; open=1; }
if [[ -r $UNIT && " $(installed_args) " != *" --start "* ]]; then
    echo "  - capture: the tower waits in MANUAL at every boot and captures nothing. Choose auto"
    echo "    yourself (key a, or 'mode auto'); once it is proven, make it boot into auto:"
    echo "      scripts/install-tower --answer service_args=\"--init --start\""
    open=1
fi
[[ $(ini_get "$CFG" Features enable_mqtt 2>/dev/null) == true ]] &&
    { echo "  - MQTT: confirm topics reach the broker (a tower with no broker runs degraded, reporting to nobody)"; open=1; }
echo "  - reboot once and run: scripts/install-tower --verify   (the USB preconditions must survive it)"

printf '%s  %s  sw %s  fw %s  changed: %s  test %s  homing: %s\n' \
    "$(date -Is)" "$USER" "$S_SW_REV" "$S_FW_REV" "${CHANGED[*]:-nothing}" "${TEST_RC:-?}" "$HOMED" |
    sudo tee -a "$INSTALL_LOG" > /dev/null
say "recorded in $INSTALL_LOG"
exit $(( verify_rc > 0 ? 1 : 0 ))
