diff --git a/deploy.sh b/deploy.sh index 7f5d5f6..d0364ab 100755 --- a/deploy.sh +++ b/deploy.sh @@ -218,6 +218,7 @@ echo ">> syncing to $REMOTE_HOST:$REMOTE_DIR" rsync -avz --delete \ --exclude 'build*/' \ --exclude '.git' \ + --exclude '.revision' \ --exclude '.venv' \ --exclude '.vscode' \ --exclude '__pycache__' \ @@ -230,6 +231,11 @@ rsync -avz --delete \ --exclude 'images/' \ ./ "$REMOTE_HOST:$REMOTE_DIR/" +# .git is not synced, so leave the revision beside the tree for the tools that +# run THERE and need to know what they are building (install-tower, flash.sh). +# shellcheck disable=SC2029 +ssh "$REMOTE_HOST" "printf '%s\\n' '$GIT_REV' > '$REMOTE_DIR/.revision'" + # Seed a config.ini on the device from the example if it doesn't have one yet, # so the first run finds a config. Never overwrites an existing device config. # Skipped entirely under --push-config: the rsync above just placed one, and the diff --git a/docs/tower-installation.md b/docs/tower-installation.md index 9ff519b..7c3f30b 100644 --- a/docs/tower-installation.md +++ b/docs/tower-installation.md @@ -10,6 +10,20 @@ actually been exercised and which are still on paper. > the board's preconditions, the Vimba X SDK, `hardware.ini`, and the motor > firmware. +> **`scripts/install-tower` now does the steps below**, on the tower, in this +> order: it surveys the machine, asks for what it cannot work out (every +> question can also be answered with `--answer KEY=VALUE`), shows the plan, and +> applies it after one yes. `--dry-run` shows the plan and changes nothing; +> `--verify` runs the checks only. It never invents a drivetrain value: answer +> `unknown` and it writes a `; TODO`, and refuses to flash firmware an axis of +> which would then sit in `NEEDCFG`. Homing is offered only at the end, and only +> to someone who says they are at the rig. +> +> `[verified]` as a re-run on the already-configured tower 121 (2026-09-23): it +> changed only what was out of date and passed `test hardware`. `[untested]` on a +> freshly imaged board - the fresh path has been rehearsed with `--dry-run` only. +> What follows stays the reference for what each step does and why. + **Status legend** — each step says where it stands: `[verified]` run end to end on a real tower and the result checked · `[partial]` run, with something still open · `[untested]` written from the diff --git a/hardware/common/kit.sh b/hardware/common/kit.sh new file mode 100644 index 0000000..9fd33da --- /dev/null +++ b/hardware/common/kit.sh @@ -0,0 +1,551 @@ +# shellcheck shell=bash +# +# kit.sh - what every board's setup kit shares. Sourced, never run: each +# hardware//install.sh sets the KIT_* facts below and calls kit_main "$@". +# +# The facts a kit must set before sourcing: +# KIT_BOARD lattepanda-iota | lattepanda-3-delta (matches probe_board) +# KIT_FW_ENV the PlatformIO env for this board's MCU +# KIT_MCU_VID USB vendor of the motor MCU (2e8a RP2040, 2341 Arduino) +# KIT_DEFAULT_AXES what this board's towers are usually built with +# KIT_IMU_DEVICE where an IMU normally is on this board +# KIT_IMU_NOTE one comment line on that +# KIT_ENV_DEVICE the I2C bus an ambient sensor normally uses, or "" if none +# KIT_ENV_NOTE comment lines on that (newline-separated) +# KIT_DIR the kit's own directory (packages.txt, preconditions/, pins.ini) +# +# What it can work out for itself: the board, the motor MCU's stable by-id path, +# the camera's DEV_ id, whether the USB preconditions took. What it cannot, and +# takes from flags, is what was ASSEMBLED - a missing pitch axis and a broken one +# look identical to a probe - and every drivetrain value, which is written as a +# commented `; TODO` naming what to measure when it is not given. It never +# invents one: a wrong sense resistor cooks a driver, and a wrong homing current +# shears an endstop. Both have happened on this project. + +ETC_DIR=/etc/fire_gimbal_control +HW_FILE="$ETC_DIR/hardware.ini" + +say() { printf '>> %s\n' "$*"; } +warn() { printf '!! %s\n' "$*" >&2; } +die() { printf 'error: %s\n' "$*" >&2; exit 1; } +need_root() { [[ $EUID -eq 0 ]] || die "$1 needs root - re-run with sudo"; } + +# The per-axis keys this kit writes, in file order. Every one is a fact about the +# drivetrain that no probe can read. +KIT_AXIS_KEYS=(r_sense_ohm run_current_ma homing_current_ma travel_deg steps_per_rev + sw_mode enc_deviation has_encoder) + +# What to measure, per key - the text of each `; TODO`. +kit_axis_todo() { + case "$1" in + r_sense_ohm) echo "the sense resistor fitted to THIS axis's driver board - read it off the board, do not assume" ;; + run_current_ma) echo "coil current for normal motion, mA RMS, from the motor's nameplate" ;; + homing_current_ma) echo "current while hunting an endstop. LOWER than run_current wherever the stop can be out-pushed: on tower 121 a screw-head stop was sheared straight past its mate at full torque" ;; + travel_deg) echo "mechanical angle between the hard stops. Rough is fine to start; every bearing inherits its error proportionally until 'gimbal heading setup' supersedes it" ;; + steps_per_rev) echo "measured full travel IN COUNTS - read it off a homing run. The only guard against a PHANTOM endstop" ;; + sw_mode) echo "TMC SW_MODE. 0 = no physical switches (StallGuard homing); set the bits if this axis has REFL/REFR wired" ;; + enc_deviation) echo "|XACTUAL - XENC| that means 'position untrustworthy'. Measure the free-running gap on THIS axis and set ~4-5x it (tower 121: gap ~200, set 1000). Re-measure whenever the homing current changes" ;; + has_encoder) echo "true if an encoder is fitted to this axis and aligned to XACTUAL (usable for hold and DIAG), else false" ;; + esac +} + +# ---------------------------------------------------------------- probing --- +# Board, from DMI. A kit applied to the wrong board is a real mistake, not a +# nuisance: the pin map and the device paths are board-specific. +probe_board() { + local dmi=/sys/class/dmi/id/product_name + [[ -r $dmi ]] || { echo "unknown"; return; } + case "$(tr -d '\0' < "$dmi")" in + *IOTA*|*Iota*|*iota*) echo "lattepanda-iota" ;; + *"3 Delta"*) echo "lattepanda-3-delta" ;; + *Sigma*) echo "lattepanda-sigma" ;; + *) echo "unknown" ;; + esac +} + +# The motor MCU's stable path. /dev/ttyACM0 is enumeration order and moves when +# any other USB serial device appears - and these towers have a 5G modem that +# claims three ttyUSB nodes. Match on what the MCU calls itself: +# RP2040 running the gimbal firmware (arduino-pico) : "Raspberry_Pi_Pico" +# RP2040 as shipped, still on MicroPython : "MicroPython_Board" +# ATmega32U4 (Delta fleet) : "Arduino_LLC_Arduino_Leonardo" +probe_mcu() { + local d found=() + for d in /dev/serial/by-id/*; do + [[ -e $d ]] || continue + case "$d" in + *Raspberry_Pi_Pico*|*MicroPython_Board*|*Arduino_LLC_Arduino*|*Pico*) found+=("$d") ;; + esac + done + (( ${#found[@]} == 1 )) && { echo "${found[0]}"; return; } + (( ${#found[@]} == 0 )) && { echo ""; return; } + printf '%s\n' "${found[@]}" +} + +# The camera's DEV_ id, which is what [Camera.] id takes. Only Vimba knows +# it - it is NOT the USB serial number - so this needs the SDK installed. +# +# Two traps, both hit on tower 121: +# - The example is called ListCameras_VmbC, not ListCameras. +# - The SDK ships a Camera Simulator transport layer that is enabled by +# default and reports three cameras that do not exist (DEV_Cam1..3). Ids are +# therefore taken only from a block whose transport layer is the USB or GigE +# TL. A tower configured with DEV_Cam1 would "find its camera" and capture +# synthetic images forever. +probe_camera_id() { + local tool + for tool in /opt/VimbaX*/bin/ListCameras_VmbC /opt/VimbaX*/bin/ListCameras; do + [[ -x $tool ]] || continue + # Non-interactive shells do not source /etc/profile.d, so the GenTL path + # has to be set here or VmbStartup() fails with error -16 (no transport + # layer) on a perfectly good installation. + local cti + for cti in "${GENICAM_GENTL64_PATH:-}" /opt/VimbaX/cti /opt/VimbaX_*/cti; do + [[ -d $cti ]] && { export GENICAM_GENTL64_PATH="$cti"; break; } + done + "$tool" 2>/dev/null | awk ' + /Camera ID/ { id = $NF } + /Transport Layer Path/ { if (id != "" && $NF !~ /Simulator/) { print id; exit } } + ' + return + done + echo "" +} + +# The camera's USB3 port node, whose usb3_lpm_permit must read 0. `test hardware +# camera` needs this path in [Test] usb3_lpm_path to check it. +probe_camera_lpm_path() { + local p + for p in /sys/bus/usb/devices/*/port/usb3_lpm_permit; do + [[ -r $p ]] || continue + local dev; dev="$(basename "$(dirname "$(dirname "$p")")")" + [[ "$(cat "/sys/bus/usb/devices/$dev/idVendor" 2>/dev/null)" == 1ab2 ]] && { echo "$p"; return; } + done + echo "" +} + +probe_report() { + local board mcu cam lpm + board="$(probe_board)"; mcu="$(probe_mcu)"; cam="$(probe_camera_id)"; lpm="$(probe_camera_lpm_path)" + echo "board : $board" + echo "os : $(. /etc/os-release && echo "$PRETTY_NAME") ($(uname -r))" + echo "motor MCU : ${mcu:-NOT FOUND}" + echo "camera DEV_ id : ${cam:-NOT FOUND (needs the Vimba X SDK installed)}" + echo "alvium on usb : $(lsusb 2>/dev/null | grep -c '1ab2:' || true) device(s)" + echo "usbfs_memory_mb : $(cat /sys/module/usbcore/parameters/usbfs_memory_mb 2>/dev/null || echo '?') (want >= 1000)" + [[ -n $lpm ]] && echo "camera lpm_permit: $(cat "$lpm") (want 0) $lpm" + echo "i2c buses : $(ls /dev/i2c-* 2>/dev/null | tr '\n' ' ')" + echo "ttyS : $(ls /dev/ttyS[0-9] 2>/dev/null | tr '\n' ' ')" +} + +# ------------------------------------------------------------------- deps --- +kit_packages() { + local line + while read -r line; do + line="${line%%#*}"; line="$(echo "$line" | tr -d '[:space:]')" + [[ -n $line ]] && echo "$line" + done < "$KIT_DIR/packages.txt" +} + +do_deps() { + need_root "--deps" + local pkgs; mapfile -t pkgs < <(kit_packages) + say "installing ${#pkgs[@]} packages" + [[ $APT_UPDATE -eq 1 ]] && apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y "${pkgs[@]}" + say "packages done" +} + +# ---------------------------------------------------------- preconditions --- +# Everything in the kit's preconditions/: *.rules go to udev, *.conf to tmpfiles. +kit_precondition_targets() { + local f + for f in "$KIT_DIR"/preconditions/*; do + case "$f" in + *.rules) echo "$f /etc/udev/rules.d/$(basename "$f")" ;; + *.conf) echo "$f /etc/tmpfiles.d/$(basename "$f")" ;; + esac + done +} + +do_preconditions() { + need_root "--preconditions" + local src dst n=0 + while read -r src dst; do + install -m 0644 "$src" "$dst"; n=$((n + 1)) + done < <(kit_precondition_targets) + say "installed $n precondition file(s)" + + udevadm control --reload + udevadm trigger --subsystem-match=usb --action=add + local conf + for conf in "$KIT_DIR"/preconditions/*.conf; do + [[ -e $conf ]] && systemd-tmpfiles --create "/etc/tmpfiles.d/$(basename "$conf")" + done + say "applied to the running system" + + # Report rather than assume: a rule that did not take is the whole point of + # known-issues #19, and it fails quietly. + local now; now="$(cat /sys/module/usbcore/parameters/usbfs_memory_mb 2>/dev/null || echo '?')" + [[ $now == 1000 ]] && say "usbfs_memory_mb = $now" || warn "usbfs_memory_mb is $now, expected 1000" + local lpm; lpm="$(probe_camera_lpm_path)" + if [[ -n $lpm ]]; then + [[ "$(cat "$lpm")" == 0 ]] && say "camera USB3 LPM disabled ($lpm)" \ + || warn "camera USB3 LPM still $(cat "$lpm") at $lpm" + else + warn "no Alvium camera on the bus - LPM rule untested until one is plugged in" + fi + + # The MCU has to be writable for flashing over ssh; check it the same way + # rather than trusting that the rule took. + local mcu_dev + mcu_dev="$(grep -l "^$KIT_MCU_VID\$" /sys/bus/usb/devices/*/idVendor 2>/dev/null | head -1)" + if [[ -n $mcu_dev ]]; then + local node + node="$(printf '/dev/bus/usb/%03d/%03d' "$(cat "$(dirname "$mcu_dev")/busnum")" \ + "$(cat "$(dirname "$mcu_dev")/devnum")")" + if [[ -w $node ]] || [[ $KIT_MCU_VID == 2341 ]]; then + say "MCU ($KIT_MCU_VID) at $node" + else + warn "MCU ($KIT_MCU_VID) at $node is not writable; flashing will need sudo" + ls -l "$node" >&2 + fi + else + warn "no $KIT_MCU_VID MCU on the bus - the flashing rule is untested until one is plugged in" + fi + return 0 +} + +# ------------------------------------------------------------------ vimba --- +# The Vimba X SDK is proprietary and login-walled, so it cannot be fetched by a +# script. Stage it on the device first (USB stick, or rsync from a machine that +# has it), then point this at the staged directory. +do_vimba() { + need_root "--vimba" + [[ -d $VIMBA_SRC ]] || die "--vimba: no such directory: $VIMBA_SRC" + [[ -x $VIMBA_SRC/cti/Install_GenTL_Path.sh ]] || die "--vimba: $VIMBA_SRC does not look like a Vimba X SDK (no cti/Install_GenTL_Path.sh)" + + local name dest + name="$(basename "$VIMBA_SRC")" + dest="/opt/$name" + if [[ -d $dest && $FORCE -ne 1 ]]; then + say "$dest already present - not re-copying (--force to replace)" + else + say "installing $name to $dest" + rm -rf "$dest.new" + cp -a "$VIMBA_SRC" "$dest.new" + rm -rf "$dest" + mv "$dest.new" "$dest" + fi + # cp -a preserves the staging copy's ownership. The SDK is system software. + chown -R root:root "$dest" + ln -sfn "$dest" /opt/VimbaX + say "/opt/VimbaX -> $dest" + + # GENICAM_GENTL64_PATH, or VmbStartup() fails with "Could not start Vimba X + # API". The SDK's own installer writes /etc/profile.d + the 1ab2 udev rule. + "$dest/cti/Install_GenTL_Path.sh" + + # Non-interactive ssh shells do not source /etc/profile.d, but the loader + # needs the libraries findable regardless of how the app was started. + echo "$dest/api/lib" > /etc/ld.so.conf.d/vimbax.conf + ldconfig + say "registered $dest/api/lib with the dynamic loader" + + udevadm control --reload && udevadm trigger --subsystem-match=usb --action=add + + local id; id="$(probe_camera_id)" + if [[ -n $id ]]; then + say "camera found: $id" + else + warn "no camera reported by ListCameras yet" + warn "the SDK asks for a reboot before the transport layers are usable" + fi + return 0 +} + +# ------------------------------------------------------------ hardware.ini --- +# The centre lives in config.ini, not here - it is calibration, and hardware.ini +# is merged OVER config.ini, so a shared key would silently discard whatever +# `gimbal heading setup` had saved. All this file can do is say what the value +# has to be, which it can work out from the travel: the range is centre +/- half +# the travel, so a centre below half-travel puts the negative end under zero. +kit_center_for() { + local steps="$1" + echo $(( ((steps / 2) / 1000000 + 1) * 1000000 )) +} + +center_note() { + local steps="$1" ax="$2" + [[ -z $steps ]] && return 0 + local half=$(( steps / 2 )) + echo "; NOTE: half this travel is $half counts, so config.ini needs" + echo "; [Motor] ${ax}_center_count = $(kit_center_for "$steps") (or anything above $half)" + echo "; The 500000 default would put the negative end at $(( 500000 - half ))." +} + +# Emit "key = value", or a commented-out line naming what has to be measured. +# +# Commented rather than a placeholder string on purpose: these keys are numeric, +# and a non-numeric value makes the program refuse to start with a message about +# a config key. Absent, it starts and reports the axis as undeclared - naming the +# missing keys and which axis will not move, which is the useful failure. +kv_or_todo() { + local key="$1" value="$2" why="$3" + if [[ -n $value ]]; then + printf '%-18s = %s\n' "$key" "$value" + return + fi + # Wrapped, and not merely for looks: ini.h sets INI_MAX_LINE to 200, and a + # longer line is SPLIT rather than truncated, so its tail becomes a bogus + # line and the whole file fails to parse. + printf '; TODO %s\n' "$key" + printf '%s\n' "$why" | fold -s -w 72 | sed 's/^/; /' | sed 's/[[:space:]]*$//' +} + +# `--pin k=v` values as a [Pins] block, or the kit's reference loom, or a TODO. +emit_pins() { + if [[ $LOOM == standard ]]; then + [[ -r $KIT_DIR/pins.ini ]] || die "--loom standard: this kit has no pins.ini" + cat "$KIT_DIR/pins.ini" + return + fi + if (( ${#PINS[@]} > 0 )); then + echo "; How the MCU is wired to each driver board. The firmware is BUILT from this" + echo "; (firmware/tools/gen_wiring.py); the reference loom is hardware/$KIT_BOARD/pins.ini." + echo "[Pins]" + local k + for k in "${PIN_ORDER[@]}"; do printf '%-8s = %s\n' "$k" "${PINS[$k]}"; done + return + fi + echo "; TODO [Pins]: the driver wiring. The firmware build REFUSES to run without it." + echo "; Copy hardware/$KIT_BOARD/pins.ini here if this tower follows that loom." +} + +emit_hardware_ini() { + local board mcu cam + board="$(probe_board)" + mcu="${SERIAL_DEVICE:-$(probe_mcu)}" + cam="${CAMERA_ID:-$(probe_camera_id)}" + if [[ $(wc -l <<< "$mcu") -gt 1 ]]; then + warn "several candidate MCU ports found; pick one with --serial:" + warn "$mcu" + mcu="CHOOSE_ONE" + fi + + cat <] block per axis. The firmware holds none of these itself - + # it asks, and refuses to move until answered - so an axis without a block + # here is an axis that will not turn. + local ax key + for ax in ${AXES//,/ }; do + ax="$(echo "$ax" | tr -d '[:space:]')" + [[ -z $ax ]] && continue + echo + echo "[Motor.$ax]" + for key in "${KIT_AXIS_KEYS[@]}"; do + kv_or_todo "$key" "${MOTOR[$ax.$key]:-}" "$(kit_axis_todo "$key")" + [[ $key == steps_per_rev ]] && center_note "${MOTOR[$ax.$key]:-}" "$ax" + done + cat <<'EOF' +; Speeds are DEGREES PER SECOND, not VMAX: a register value means different +; things on different gear ratios, so it is not portable between towers. These +; are deliberately gentle starting points; the host converts them per tower. +scan_speed_deg_s = 8.0 +homing_speed_deg_s = 4.0 +center_speed_deg_s = 6.0 +EOF + done + + local c + for c in ${CAMERAS//,/ }; do + c="$(echo "$c" | tr -d '[:space:]')" + [[ -z $c || $c == none ]] && continue + cat <&2 + return + fi + if [[ -f $HW_FILE ]]; then + cp -p "$HW_FILE" "$HW_FILE.bak-$(date +%Y%m%d-%H%M%S)" + say "backed the previous hardware.ini up" + fi + emit_hardware_ini > "$HW_FILE" + chmod 0644 "$HW_FILE" + say "wrote $HW_FILE" + if grep -n 'SET_THIS_BY_HAND\|CHOOSE_ONE\|^; TODO' "$HW_FILE" >/dev/null 2>&1; then + warn "it still has gaps - fill them before starting the app:" + grep -n 'SET_THIS_BY_HAND\|CHOOSE_ONE\|^; TODO' "$HW_FILE" | sed 's/^/ /' >&2 + fi + return 0 +} + +# ------------------------------------------------------------------- main --- +kit_usage() { + cat <] blocks -# -# The drivetrain, per axis. The firmware REFUSES TO MOVE until it has been told -# these (docs/configuration.md, the config handshake), so an axis left undeclared -# is an axis that will sit in NEEDCFG. Pass what you know; what you omit is -# written as a commented line naming what has to be measured: -# -# --r-sense 0.075 sense resistor on that axis's TMC board (ohms) -# --run-current 756 coil current for normal motion (mA RMS) -# --homing-current 200 coil current while hunting an endstop (mA RMS) -# --travel-deg 340 mechanical angle between the hard stops -# --steps-per-rev 1210778 measured full travel, in counts -# --sw-mode 0 TMC SW_MODE: 0 = no switches (StallGuard homing) -# -# These take ONE value, applied to the first axis in --axes. A second axis gets a -# commented template: nothing here can be probed, and inventing a sense resistor -# is how a driver gets cooked. -# -# Other flags: -# --force overwrite an existing hardware.ini (backs it up first) -# --no-apt-update skip 'apt-get update' +# Everything shared with the other boards' kits is in ../common/kit.sh. What is +# here is what differs on the IOTA. # set -euo pipefail -HERE="$(cd "$(dirname "$0")" && pwd)" -ETC_DIR=/etc/fire_gimbal_control -HW_FILE="$ETC_DIR/hardware.ini" +KIT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +KIT_BOARD=lattepanda-iota +KIT_FW_ENV=iota_rp2040 +KIT_MCU_VID=2e8a +KIT_DEFAULT_AXES=yaw +KIT_IMU_DEVICE=/dev/ttyS0 +KIT_IMU_NOTE="On the IOTA the only PCH serial on the header is COM1; confirm it enumerates first." +# No PCH-side I2C reaches the header, so an ambient sensor needs a USB-I2C bridge +# (MCP2221A) whose bus number depends on enumeration order: nothing to default to. +KIT_ENV_DEVICE="" +KIT_ENV_NOTE="MUST be a real I2C adapter, not SMBus-only: check with \`i2cdetect -F \`. +The IOTA exposes no PCH-side I2C on the header, so this is normally a USB-I2C +bridge (MCP2221A), whose bus number depends on enumeration order." -AXES="yaw,pitch" -IMU="none" -ENV_SENSOR="none" -CAMERAS="rgb" -# Empty means "not known at install time" -> written commented out, never as a -# sentinel: these are numeric keys, and a non-numeric value makes the program -# refuse to start rather than report the axis as undeclared. -R_SENSE="" RUN_CURRENT="" HOMING_CURRENT="" TRAVEL_DEG="" STEPS_PER_REV="" SW_MODE="" -DO_DEPS=0 DO_PRE=0 DO_HW=0 DO_VIMBA=0 PROBE_ONLY=0 TO_STDOUT=0 FORCE=0 APT_UPDATE=1 -VIMBA_SRC="" -EXPLICIT=0 - -while [[ $# -gt 0 ]]; do - case "$1" in - --deps) DO_DEPS=1; EXPLICIT=1 ;; - --preconditions) DO_PRE=1; EXPLICIT=1 ;; - --hardware-ini) DO_HW=1; EXPLICIT=1 ;; - --probe) PROBE_ONLY=1; EXPLICIT=1 ;; - --stdout) TO_STDOUT=1; DO_HW=1; EXPLICIT=1 ;; - --axes) AXES="$2"; shift ;; - --imu) IMU="$2"; shift ;; - --env) ENV_SENSOR="$2"; shift ;; - --cameras) CAMERAS="$2"; shift ;; - --r-sense) R_SENSE="$2"; shift ;; - --run-current) RUN_CURRENT="$2"; shift ;; - --homing-current) HOMING_CURRENT="$2"; shift ;; - --travel-deg) TRAVEL_DEG="$2"; shift ;; - --steps-per-rev) STEPS_PER_REV="$2"; shift ;; - --sw-mode) SW_MODE="$2"; shift ;; - --vimba) VIMBA_SRC="$2"; DO_VIMBA=1; EXPLICIT=1; shift ;; - --force) FORCE=1 ;; - --no-apt-update) APT_UPDATE=0 ;; - -h|--help) sed -n '2,30p' "$0"; exit 0 ;; - *) echo "unknown option: $1" >&2; exit 1 ;; - esac - shift -done -if [[ $EXPLICIT -eq 0 ]]; then DO_DEPS=1; DO_PRE=1; DO_HW=1; fi - -say() { printf '>> %s\n' "$*"; } -warn() { printf '!! %s\n' "$*" >&2; } -die() { printf 'error: %s\n' "$*" >&2; exit 1; } -need_root() { [[ $EUID -eq 0 ]] || die "$1 needs root - re-run with sudo"; } - -# ---------------------------------------------------------------- probing --- -# Board, from DMI. A kit applied to the wrong board is a real mistake, not a -# nuisance: the pin map and the device paths below are IOTA-specific. -probe_board() { - local dmi=/sys/class/dmi/id/product_name - [[ -r $dmi ]] || { echo "unknown"; return; } - case "$(tr -d '\0' < "$dmi")" in - *IOTA*|*Iota*|*iota*) echo "lattepanda-iota" ;; - *"3 Delta"*) echo "lattepanda-3-delta" ;; - *Sigma*) echo "lattepanda-sigma" ;; - *) echo "unknown" ;; - esac -} - -# The motor MCU's stable path. /dev/ttyACM0 is enumeration order and moves when -# any other USB serial device appears - and this tower has a 5G modem that -# claims three ttyUSB nodes. Match on what the MCU calls itself: -# RP2040 running the gimbal firmware (arduino-pico) : "Raspberry_Pi_Pico" -# RP2040 as shipped, still on MicroPython : "MicroPython_Board" -# ATmega32U4 (Delta fleet) : "Arduino_LLC_Arduino_Leonardo" -probe_mcu() { - local d found=() - for d in /dev/serial/by-id/*; do - [[ -e $d ]] || continue - case "$d" in - *Raspberry_Pi_Pico*|*MicroPython_Board*|*Arduino_LLC_Arduino*|*Pico*) found+=("$d") ;; - esac - done - (( ${#found[@]} == 1 )) && { echo "${found[0]}"; return; } - (( ${#found[@]} == 0 )) && { echo ""; return; } - printf '%s\n' "${found[@]}" -} - -# The camera's DEV_ id, which is what [Camera.] id takes. Only Vimba knows -# it - it is NOT the USB serial number - so this needs the SDK installed. -# -# Two traps, both hit on tower 121: -# - The example is called ListCameras_VmbC, not ListCameras. -# - The SDK ships a Camera Simulator transport layer that is enabled by -# default and reports three cameras that do not exist (DEV_Cam1..3). Ids are -# therefore taken only from a block whose transport layer is the USB or GigE -# TL. A tower configured with DEV_Cam1 would "find its camera" and capture -# synthetic images forever. -probe_camera_id() { - local tool - for tool in /opt/VimbaX*/bin/ListCameras_VmbC /opt/VimbaX*/bin/ListCameras; do - [[ -x $tool ]] || continue - # Non-interactive shells do not source /etc/profile.d, so the GenTL path - # has to be set here or VmbStartup() fails with error -16 (no transport - # layer) on a perfectly good installation. - local cti - for cti in "${GENICAM_GENTL64_PATH:-}" /opt/VimbaX/cti /opt/VimbaX_*/cti; do - [[ -d $cti ]] && { export GENICAM_GENTL64_PATH="$cti"; break; } - done - "$tool" 2>/dev/null | awk ' - /Camera ID/ { id = $NF } - /Transport Layer Path/ { if (id != "" && $NF !~ /Simulator/) { print id; exit } } - ' - return - done - echo "" -} - -probe_report() { - local board mcu cam - board="$(probe_board)"; mcu="$(probe_mcu)"; cam="$(probe_camera_id)" - echo "board : $board" - echo "os : $(. /etc/os-release && echo "$PRETTY_NAME") ($(uname -r))" - echo "motor MCU : ${mcu:-NOT FOUND}" - echo "camera DEV_ id : ${cam:-NOT FOUND (needs the Vimba X SDK installed)}" - echo "alvium on usb : $(lsusb 2>/dev/null | grep -c '1ab2:' || true) device(s)" - echo "usbfs_memory_mb : $(cat /sys/module/usbcore/parameters/usbfs_memory_mb 2>/dev/null || echo '?') (want >= 1000)" - local p - for p in /sys/bus/usb/devices/*/port/usb3_lpm_permit; do - [[ -r $p ]] || continue - local dev; dev="$(basename "$(dirname "$(dirname "$p")")")" - local vid; vid="$(cat "/sys/bus/usb/devices/$dev/idVendor" 2>/dev/null || true)" - [[ $vid == 1ab2 ]] && echo "camera lpm_permit: $(cat "$p") (want 0)" - done - echo "i2c buses : $(ls /dev/i2c-* 2>/dev/null | tr '\n' ' ')" - echo "ttyS : $(ls /dev/ttyS[0-9] 2>/dev/null | tr '\n' ' ')" -} - -# ------------------------------------------------------------------- deps --- -do_deps() { - need_root "--deps" - local pkgs=() - while read -r line; do - line="${line%%#*}"; line="$(echo "$line" | tr -d '[:space:]')" - [[ -n $line ]] && pkgs+=("$line") - done < "$HERE/packages.txt" - say "installing ${#pkgs[@]} packages" - [[ $APT_UPDATE -eq 1 ]] && apt-get update - DEBIAN_FRONTEND=noninteractive apt-get install -y "${pkgs[@]}" - say "packages done" -} - -# ---------------------------------------------------------- preconditions --- -do_preconditions() { - need_root "--preconditions" - install -m 0644 "$HERE/preconditions/99-fwt-alvium-usb.rules" /etc/udev/rules.d/ - install -m 0644 "$HERE/preconditions/99-fwt-rp2040.rules" /etc/udev/rules.d/ - install -m 0644 "$HERE/preconditions/10-fwt-usbfs.conf" /etc/tmpfiles.d/ - say "installed 2 udev rules + tmpfiles.d entry" - - udevadm control --reload - udevadm trigger --subsystem-match=usb --action=add - systemd-tmpfiles --create /etc/tmpfiles.d/10-fwt-usbfs.conf - say "applied to the running system" - - # Report rather than assume: a rule that did not take is the whole point of - # known-issues #19, and it fails quietly. - local now; now="$(cat /sys/module/usbcore/parameters/usbfs_memory_mb 2>/dev/null || echo '?')" - [[ $now == 1000 ]] && say "usbfs_memory_mb = $now" || warn "usbfs_memory_mb is $now, expected 1000" - local p any=0 - for p in /sys/bus/usb/devices/*/port/usb3_lpm_permit; do - [[ -r $p ]] || continue - local dev vid; dev="$(basename "$(dirname "$(dirname "$p")")")" - vid="$(cat "/sys/bus/usb/devices/$dev/idVendor" 2>/dev/null || true)" - [[ $vid == 1ab2 ]] || continue - any=1 - [[ "$(cat "$p")" == 0 ]] && say "camera USB3 LPM disabled ($dev)" \ - || warn "camera USB3 LPM still $(cat "$p") on $dev" - done - [[ $any -eq 0 ]] && warn "no Alvium camera on the bus - LPM rule untested until one is plugged in" - - # The MCU rule is what makes the firmware flashable over ssh, so check it the - # same way rather than trusting that the rule took. - local mcu_dev - mcu_dev="$(grep -l '^2e8a$' /sys/bus/usb/devices/*/idVendor 2>/dev/null | head -1)" - if [[ -n $mcu_dev ]]; then - local devnum busnum node - busnum="$(cat "$(dirname "$mcu_dev")/busnum" 2>/dev/null)" - devnum="$(cat "$(dirname "$mcu_dev")/devnum" 2>/dev/null)" - node="$(printf '/dev/bus/usb/%03d/%03d' "$busnum" "$devnum")" - if [[ -w $node ]]; then - say "MCU (2e8a) writable at $node - picotool can flash it" - else - warn "MCU (2e8a) at $node is not writable; flashing will need sudo" - ls -l "$node" >&2 - fi - else - warn "no 2e8a MCU on the bus - the flashing rule is untested until one is plugged in" - fi - return 0 -} - -# ------------------------------------------------------------------ vimba --- -# The Vimba X SDK is proprietary and login-walled, so it cannot be fetched by a -# script. Stage it on the device first (rsync it from a machine that has it), -# then point this at the staged directory: -# -# rsync -a ~/Programs/VimbaX_2025-3 ggs@:~/ # from the dev box -# sudo ./install.sh --vimba ~ggs/VimbaX_2025-3 # on the tower -# -do_vimba() { - need_root "--vimba" - [[ -d $VIMBA_SRC ]] || die "--vimba: no such directory: $VIMBA_SRC" - [[ -x $VIMBA_SRC/cti/Install_GenTL_Path.sh ]] || die "--vimba: $VIMBA_SRC does not look like a Vimba X SDK (no cti/Install_GenTL_Path.sh)" - - local name dest - name="$(basename "$VIMBA_SRC")" - dest="/opt/$name" - if [[ -d $dest && $FORCE -ne 1 ]]; then - say "$dest already present - not re-copying (--force to replace)" - else - say "installing $name to $dest" - rm -rf "$dest.new" - cp -a "$VIMBA_SRC" "$dest.new" - rm -rf "$dest" - mv "$dest.new" "$dest" - fi - # cp -a preserves the staging copy's ownership, which is the ggs user. The - # SDK is read-only system software and belongs to root. - chown -R root:root "$dest" - ln -sfn "$dest" /opt/VimbaX - say "/opt/VimbaX -> $dest" - - # GENICAM_GENTL64_PATH, or VmbStartup() fails with "Could not start Vimba X - # API". The SDK's own installer writes /etc/profile.d + the 1ab2 udev rule. - "$dest/cti/Install_GenTL_Path.sh" - - # Non-interactive ssh shells do not source /etc/profile.d, which is why - # deploy.sh resolves the path itself - but the loader needs the libraries - # findable regardless of how the app was started. - echo "$dest/api/lib" > /etc/ld.so.conf.d/vimbax.conf - ldconfig - say "registered $dest/api/lib with the dynamic loader" - - udevadm control --reload && udevadm trigger --subsystem-match=usb --action=add - - local id - id="$(probe_camera_id)" - if [[ -n $id ]]; then - say "camera found: $id" - else - warn "no camera reported by ListCameras yet" - warn "the SDK asks for a reboot before the transport layers are usable" - fi - return 0 -} - -# ------------------------------------------------------------ hardware.ini --- -# Emit "key = value", or a commented-out line naming what has to be measured. -# -# Commented rather than a placeholder string on purpose: these keys are numeric, -# and a non-numeric value makes the program refuse to start with a message about -# a config key. Absent, it starts and reports the axis as undeclared - naming the -# missing keys and which axis will not move, which is the useful failure. -# The centre lives in config.ini, not here - it is calibration, and hardware.ini -# is merged OVER config.ini, so a shared key would silently discard whatever -# `gimbal heading setup` had saved. All this file can do is say what the value -# has to be, which it can work out from the travel. -# -# The range is centre +/- half the travel, so a centre below half-travel puts the -# negative end under zero. The long-standing 500000 default suits the Delta's -# ~89k half-travel and does not survive a gear ratio several times higher. -center_note() { - local steps="$1" ax="$2" - [[ -z $steps ]] && return 0 - local half=$(( steps / 2 )) - local want=$(( (half / 1000000 + 1) * 1000000 )) - echo "; NOTE: half this travel is $half counts, so config.ini needs" - echo "; [Motor] ${ax}_center_count = $want (or anything above $half)" - echo "; The 500000 default would put the negative end at $(( 500000 - half ))." -} - -kv_or_todo() { - local key="$1" value="$2" why="$3" - if [[ -n $value ]]; then - printf '%-18s = %s\n' "$key" "$value" - return - fi - # Wrapped, and not merely for looks: ini.h sets INI_MAX_LINE to 200, and a - # longer line is SPLIT rather than truncated, so its tail becomes a bogus - # line and the whole file fails to parse. A generated file that cannot be - # read is worse than one with a gap in it. - printf '; TODO %s\n' "$key" - printf '%s\n' "$why" | fold -s -w 72 | sed 's/^/; /' | sed 's/[[:space:]]*$//' -} - -emit_hardware_ini() { - local board mcu cam fw_env - board="$(probe_board)" - mcu="$(probe_mcu)" - cam="$(probe_camera_id)" - case "$board" in - lattepanda-iota) fw_env="iota_rp2040" ;; - lattepanda-3-delta|lattepanda-sigma) fw_env="leonardo" ;; - *) fw_env="UNKNOWN" ;; - esac - if [[ $(wc -l <<< "$mcu") -gt 1 ]]; then - warn "several candidate MCU ports found; pick one by hand:" - warn "$mcu" - mcu="CHOOSE_ONE" - fi - - cat <`. -; The IOTA exposes no PCH-side I2C on the header, so this is normally a USB-I2C -; bridge (MCP2221A), whose bus number depends on enumeration order. -i2c_device = SET_THIS_BY_HAND -i2c_addr = 0x44 -EOF - fi - - # One [Motor.] block per axis. The firmware holds none of these itself - # any more - it asks, and refuses to move until answered - so an axis without - # a block here is an axis that will not turn. - # - # A value we were not given is written COMMENTED OUT, with what to measure. - # Not as a placeholder string: these keys are numeric, and a non-numeric value - # makes the program refuse to start, which is a worse failure than the axis - # reporting itself undeclared. - local ax first=1 - for ax in ${AXES//,/ }; do - ax="$(echo "$ax" | tr -d '[:space:]')" - [[ -z $ax ]] && continue - echo - echo "[Motor.$ax]" - if [[ $first -eq 1 ]]; then - kv_or_todo "r_sense_ohm" "$R_SENSE" \ - "the resistor fitted to THIS driver board - read it off the board, do not assume" - kv_or_todo "run_current_ma" "$RUN_CURRENT" \ - "coil current for normal motion, mA RMS, from the motor's nameplate" - kv_or_todo "homing_current_ma" "$HOMING_CURRENT" \ - "current while hunting an endstop. LOWER than run_current wherever the stop can be out-pushed: on this rig a screw-head stop was sheared straight past its mate at full torque" - kv_or_todo "travel_deg" "$TRAVEL_DEG" \ - "mechanical angle between the hard stops. Rough is fine to start; every bearing inherits its error proportionally until 'gimbal heading setup' supersedes it" - kv_or_todo "steps_per_rev" "$STEPS_PER_REV" \ - "measured full travel IN COUNTS - read it off a homing run. The only guard against a PHANTOM endstop" - center_note "$STEPS_PER_REV" "$ax" - kv_or_todo "sw_mode" "$SW_MODE" \ - "TMC SW_MODE. 0 = no physical switches (StallGuard homing); set the bits if this axis has REFL/REFR wired" - else - echo "; Nothing here can be probed, and this is not the first axis, so the" - echo "; values below are a template. Measure them on THIS axis." - kv_or_todo "r_sense_ohm" "" "sense resistor on this axis's driver board" - kv_or_todo "run_current_ma" "" "coil current for normal motion, mA RMS" - kv_or_todo "homing_current_ma" "" "current while hunting an endstop" - kv_or_todo "travel_deg" "" "mechanical angle between the hard stops" - kv_or_todo "steps_per_rev" "" "measured full travel, in counts" - kv_or_todo "sw_mode" "" "TMC SW_MODE (0 = no physical switches)" - fi - cat <<'EOF' -; Speeds are DEGREES PER SECOND, not VMAX: a register value means different -; things on different gear ratios, so it is not portable between towers. The -; host converts through counts_per_deg and sends SPEED. -scan_speed_deg_s = 8.0 -homing_speed_deg_s = 4.0 -center_speed_deg_s = 6.0 -; |XACTUAL - XENC| that means "position untrustworthy". 1000 on this mechanism: -; the free-running gap measured 199-229 counts on tower 121, so this is ~4x -; headroom. Re-measure whenever the homing CURRENT changes - the gap is coupled -; to it, and notably NOT to speed (229 counts at VMAX 6000, 199 at 30000). -; Lowering is the safe direction; raising it blunts the only stall signal there is. -enc_deviation = 1000 -; Is this axis's encoder aligned to XACTUAL and usable for hold and DIAG? -has_encoder = true -EOF - first=0 - done - - local c - for c in ${CAMERAS//,/ }; do - c="$(echo "$c" | tr -d '[:space:]')" - cat <&2 - return - fi - if [[ -f $HW_FILE ]]; then - cp -p "$HW_FILE" "$HW_FILE.bak-$(date +%Y%m%d-%H%M%S)" - say "backed the previous hardware.ini up" - fi - emit_hardware_ini > "$HW_FILE" - chmod 0644 "$HW_FILE" - say "wrote $HW_FILE" - grep -n 'SET_THIS_BY_HAND\|CHOOSE_ONE\|UNKNOWN' "$HW_FILE" >/dev/null 2>&1 && { - warn "it still has placeholders - edit them before starting the app:" - grep -n 'SET_THIS_BY_HAND\|CHOOSE_ONE\|UNKNOWN' "$HW_FILE" | sed 's/^/ /' >&2 - } - return 0 -} - -# ------------------------------------------------------------------- main --- -board="$(probe_board)" -if [[ $board != lattepanda-iota ]]; then - warn "this kit is for the LattePanda IOTA; this machine reports '$board'" - # --probe and --stdout change nothing on this machine: one reports what it - # found, the other prints the file it WOULD write. Refusing them on a - # non-IOTA only means the generator can never be exercised off a tower, - # which is how a setup kit ends up untested. - [[ $PROBE_ONLY -eq 1 || $TO_STDOUT -eq 1 ]] || - die "refusing to apply IOTA preconditions to a $board" -fi - -if [[ $PROBE_ONLY -eq 1 ]]; then probe_report; exit 0; fi - -# --stdout has to emit the file and nothing else: its whole purpose is being -# redirected into one. The banner belongs on stderr in that case, or it lands in -# the middle of the hardware.ini somebody just generated. -if [[ $TO_STDOUT -eq 1 ]]; then - do_hardware_ini - exit 0 -fi -say "LattePanda IOTA setup kit" -probe_report | sed 's/^/ /' -# if/fi rather than '[[ ]] && cmd': under `set -e` a false test as the last -# command of an AND list exits the script. -if [[ $DO_DEPS -eq 1 ]]; then do_deps; fi -if [[ $DO_VIMBA -eq 1 ]]; then do_vimba; fi -if [[ $DO_PRE -eq 1 ]]; then do_preconditions; fi -if [[ $DO_HW -eq 1 ]]; then do_hardware_ini; fi -say "done" +# shellcheck source=../common/kit.sh +. "$KIT_DIR/../common/kit.sh" +# scripts/install-tower sources this file for the facts and probes alone. +[[ ${KIT_NO_MAIN:-0} == 1 ]] || kit_main "$@" diff --git a/scripts/fgc b/scripts/fgc index 0dc8e40..99bfffc 100755 --- a/scripts/fgc +++ b/scripts/fgc @@ -106,6 +106,24 @@ resolve_vimba_env() { fi } +# MQTT credentials, from the same file the systemd unit names as its +# EnvironmentFile. Read here, inside the pane, for the reason above: a tmux server +# that already exists gives a new session ITS environment, not the caller's, so a +# unit's EnvironmentFile would silently not reach the app. Parsed as KEY=VALUE +# lines rather than sourced, so a password with $ or quotes in it stays literal. +# Only the variables the program reads are taken. +load_credentials() { + local f=/etc/fire_gimbal_control/credentials.env line k v + [[ -r "$f" ]] || return 0 + while IFS= read -r line || [[ -n "$line" ]]; do + [[ "$line" =~ ^[[:space:]]*(#|$) ]] && continue + k="${line%%=*}"; v="${line#*=}" + case "$k" in + FGC_MQTT_USER|FGC_MQTT_PW) export "$k=$v" ;; + esac + done < "$f" +} + cmd_start() { need_tmux if running; then @@ -198,6 +216,7 @@ cmd_start() { # exec'd, 3/3 clean when parented. cmd__run() { resolve_vimba_env + load_credentials # <&0 is required: bash redirects a background command's stdin from # /dev/null unless it is given one explicitly, which would leave the diff --git a/scripts/install-tower b/scripts/install-tower new file mode 100755 index 0000000..f3ff2f6 --- /dev/null +++ b/scripts/install-tower @@ -0,0 +1,869 @@ +#!/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." + +# ------------------------------------------------------------- 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, from its log header, so a +# restart puts it back the way it was. +app_args() { + local l; l="$(latest_log)" || return 0 + [[ -n $l ]] || return 0 + sed -n 's/^argv *//p' "$l" | head -1 | awk '{ for (i = 2; i <= NF; i++) printf "%s%s", $i, (i < NF ? " " : "") }' +} + +# 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)' "$l" | tail -3)" + 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 ") 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)" + echo "build=${build:-?} spi=${spi:--} Y=${y:--} P=${p:--}" +} + +# 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" + echo "spi=$spi Y=$y P=$p" +} + +hw_todos() { [[ -r $HW ]] && grep -E '^; TODO' "$HW" | sed 's/^; TODO //' || true; } + +# ------------------------------------------------------------------ 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_hardware_test() { + # 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 + pane="$(tmux list-panes -t "=${FGC_SESSION:-fgc}" -F '#{pane_id}' | head -1)" + before="$(ls -t "$LOGDIR"/test_*.log 2>/dev/null | head -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" 'test hardware' Enter + 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 + [[ -n $after && $after != "$before" ]] || { warn "no test report appeared"; TEST_RC=5; return; } + grep -E '^\[| ; ' "$after" | sed 's/^/ /' + grep -q '^result = PASS' "$after" && TEST_RC=0 || TEST_RC=4 + else + local out="$WORK/test.out" + TEST_RC=0 + ( cd "$REPO" && GENICAM_GENTL64_PATH="${GENICAM_GENTL64_PATH:-/opt/VimbaX/cti}" \ + "$BIN" --no-mqtt --test hardware ) > "$out" 2>&1 < /dev/null || TEST_RC=$? + grep -E '\-\-test|test hw_|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" "$([[ -n $now && ${now#* } == "$want_pins" ]] && echo 0 || echo 1)" \ + "running ${now:-unknown}; declared $want_pins" + run_hardware_test + check "test hardware" "$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 + staged="$(ls -d "$HOME"/VimbaX_* 2>/dev/null | head -1 || true)" + 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." + ask vimba_src "Staged Vimba X directory" "${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 +choose_pins() { + 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. The standard" + echo "loom for this board is hardware/$S_BOARD/pins.ini:" + sed -n '/^\[Pins\]/,$p' "$KIT/pins.ini" | sed 's/^/ /' + ask_yn loom "Is this tower wired exactly to that loom?" n +} +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, or none" none; ENV_A="$REPLY_VAL" + 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" --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 ]]; 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 standard) + 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 + 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." + 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 ""|dev|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 + 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) + echo; echo "config.ini's tower_name is '$tn' - a placeholder, or another rig's copy." + ask tower_name "Tower name"; 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 +if [[ $CFG_SEED -eq 1 ]]; then plan config.ini create "seed from the example: ${CFG_SETS[*]}" +elif (( ${#CFG_SETS[@]} )); then plan config.ini change "set ${CFG_SETS[*]}" +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" +elif [[ -r $PROFDIR/standard.xml && -r $PROFDIR/night.xml ]]; then plan profiles skip "standard + night present" +elif [[ $HAVE_VIMBA -eq 0 ]]; then plan profiles skip "no Vimba SDK: export later" +else plan profiles change "export night, then standard, from the camera"; 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/pins.ini" > "$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" && ${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 (moves nothing)" +plan homing ask "asked at the end: home the gimbal and take a short capture" + +UNIT_TEXT="[Unit] +Description=Fire Gimbal Control +After=network-online.target +Wants=network-online.target + +[Service] +# fgc runs the program in a tmux session, so 'scripts/fgc attach' still opens +# the dashboard. oneshot + RemainAfterExit: fgc start returns once it is up. +Type=oneshot +RemainAfterExit=yes +User=$USER +WorkingDirectory=$REPO +# --init homes at every start: the firmware keeps no position across a power cycle. +ExecStart=$REPO/scripts/fgc start --init --start +ExecStop=$REPO/scripts/fgc stop +TimeoutStartSec=120 +TimeoutStopSec=60 + +[Install] +WantedBy=multi-user.target" +if [[ -r $UNIT ]] && [[ "$(cat "$UNIT")" == "$UNIT_TEXT" ]] && [[ $S_SVC_ENABLED == enabled && $S_LINGER == yes ]]; then + plan service skip "installed and enabled" +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." + if ask_yn service "Install and enable it?" y; then + plan service change "write $UNIT, 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 +stop_app() { + if [[ $S_SVC_ACTIVE == active ]]; then sudo systemctl stop fire-gimbal-control; S_SVC_ACTIVE=inactive + elif app_running; then "$FGC" stop; fi +} + +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'; cat "$KIT/pins.ini"; } | 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 + "$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 + [[ -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 + } + for prof in night standard; do + ( cd "$REPO" && GENICAM_GENTL64_PATH=/opt/VimbaX/cti "$HOME/.venvs/vmbpy/bin/python" \ + scripts/export_camera_profile.py --profile "$prof" ) + done + done_step profiles +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 hardware" +S_SVC_ENABLED="$(systemctl is-enabled fire-gimbal-control 2>/dev/null || true)" +run_hardware_test +[[ $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)" + if [[ $SVC_ON -eq 1 ]]; 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:-}" + ( cd "$REPO" && "$FGC" start "${restart_args[@]}" ) > /dev/null && say "restarted as it was: fgc start${S_ARGS:+ $S_ARGS}" +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 ))