#!/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 two 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=/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 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; } # 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) 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*) S_MODE="gimbal firmware (Leonardo)" ;; esac lsusb -d 2e8a:0003 >/dev/null 2>&1 && S_MODE="in its bootloader" cat </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 pane before after restore="" pane="$(tmux list-panes -t "=${FGC_SESSION:-fgc}" -F '#{pane_id}' | head -1)" before="$(ls -t "$LOGDIR"/test_*.log 2>/dev/null | head -1)" app_cmd() { # one command line into the running program, dashboard or console 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 } # 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.] 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 and take a short capture" # 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="" SVC_ARGS_DEFAULT="--init --start" [[ -r $UNIT ]] && SVC_ARGS_DEFAULT="$(installed_args)" [[ -z $SVC_ARGS_DEFAULT ]] && SVC_ARGS_DEFAULT=none 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." 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 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.] 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/ 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 a short capture?" 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. if [[ $SVC_ON -eq 1 && " $(installed_args) " == *" --init "* ]]; then sudo systemctl start fire-gimbal-control else ( cd "$REPO" && "$FGC" start --init --start ) > /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 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)" 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)"; 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: that is the tower in service. 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; } [[ $(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 ))