fwt_software/scripts/install-tower

1134 lines
55 KiB
Bash
Executable File

#!/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
# Optional keys are taken from the answers file only (yaw.enc_const=12.5):
# asking every install about a default nearly every tower keeps is noise.
for key in "${KIT_AXIS_OPTIONAL[@]}"; do
[[ -n ${ANS[$ax.$key]:-} ]] || continue
[[ ${ANS[$ax.$key]} =~ ^[0-9]+(\.[0-9]{1,4})?$ ]] || die "bad value for '$ax.$key': ${ANS[$ax.$key]}"
say "$ax $key = ${ANS[$ax.$key]} (from the answers)"
KIT_ARGS+=(--motor "$ax.$key=${ANS[$ax.$key]}")
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"
build_start=$SECONDS
say "configuring (a first build also downloads the MQTT and dashboard libraries)"
( 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"; }
# Progress, not silence: a first build on a LattePanda takes 10-25 minutes, and a
# screen that says nothing for that long reads as a hang - someone pressed Ctrl-C
# on staeffelsberg. A line every 5 %, any error as it happens; the whole output
# is kept, and its tail shown if the build fails.
say "building on $(nproc) cores - a first build takes 10-25 minutes, a rebuild a few"
if ! ( cd "$REPO" && cmake --build build -j"$(nproc)" 2>&1 ) | tee "$WORK/build.log" | awk '
match($0, /^\[ *[0-9]+%\]/) {
p = substr($0, 2, RLENGTH - 3) + 0
if (p >= last + 5 || (p == 100 && last != 100)) {
printf " %3d%% %s\n", p, substr($0, RLENGTH + 2); last = p; fflush()
}
next
}
/[Ee]rror/ { print " " $0; fflush() }'; then
tail -30 "$WORK/build.log" >&2
die "the build failed (the last lines are above)"
fi
say "built in $(( (SECONDS - build_start) / 60 )) min $(( (SECONDS - build_start) % 60 )) s"
[[ -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 ))