Add an installer that sets up a tower from scratch or repairs one

This commit is contained in:
pgdalmeida 2026-09-23 21:15:00 +02:00
parent 86f930a671
commit 32ea820877
Signed by: pedro.almeida
GPG Key ID: D4A6C394DF13F1D7
6 changed files with 1483 additions and 510 deletions

View File

@ -218,6 +218,7 @@ echo ">> syncing to $REMOTE_HOST:$REMOTE_DIR"
rsync -avz --delete \ rsync -avz --delete \
--exclude 'build*/' \ --exclude 'build*/' \
--exclude '.git' \ --exclude '.git' \
--exclude '.revision' \
--exclude '.venv' \ --exclude '.venv' \
--exclude '.vscode' \ --exclude '.vscode' \
--exclude '__pycache__' \ --exclude '__pycache__' \
@ -230,6 +231,11 @@ rsync -avz --delete \
--exclude 'images/' \ --exclude 'images/' \
./ "$REMOTE_HOST:$REMOTE_DIR/" ./ "$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, # 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. # 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 # Skipped entirely under --push-config: the rsync above just placed one, and the

View File

@ -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 > the board's preconditions, the Vimba X SDK, `hardware.ini`, and the motor
> firmware. > 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: **Status legend** — each step says where it stands:
`[verified]` run end to end on a real tower and the result checked · `[verified]` run end to end on a real tower and the result checked ·
`[partial]` run, with something still open · `[untested]` written from the `[partial]` run, with something still open · `[untested]` written from the

551
hardware/common/kit.sh Normal file
View File

@ -0,0 +1,551 @@
# shellcheck shell=bash
#
# kit.sh - what every board's setup kit shares. Sourced, never run: each
# hardware/<board>/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.<name>] 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 <<EOF
; hardware.ini - what this machine IS. Generated by hardware/$KIT_BOARD/install.sh
; on $(date -Is) from what the machine reports, plus the assembly
; facts given to it. See config/hardware.example.ini for the full reference,
; and docs/hardware-config-plan.md for why this file exists.
[Board]
model = $board
firmware_env = $KIT_FW_ENV
[Hardware]
; DECLARED, not probed: these are what the tower was built with, so that a part
; that stops answering is a fault and not a silent reconfiguration.
axes = $AXES
imu = $IMU
env = $ENV_SENSOR
cameras = $CAMERAS
[Serial]
; The motor controller MCU, by stable id - /dev/ttyACM* moves when the modem
; enumerates first. The name changes when the MCU is flashed.
device = ${mcu:-NOT_FOUND_SET_THIS_BY_HAND}
baud = 115200
EOF
emit_pins
if [[ $IMU != none ]]; then
printf '\n[IMU]\n; %s\ndevice = %s\n' "$KIT_IMU_NOTE" "${IMU_DEVICE:-$KIT_IMU_DEVICE}"
fi
if [[ $ENV_SENSOR != none ]]; then
printf '\n[Env]\n'
printf '%s\n' "$KIT_ENV_NOTE" | sed 's/^/; /'
printf 'i2c_device = %s\ni2c_addr = 0x44\n' "${I2C_DEVICE:-${KIT_ENV_DEVICE:-SET_THIS_BY_HAND}}"
fi
# One [Motor.<axis>] 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 <<EOF
[Camera.$c]
; The Vimba DEV_ id of the physical camera fitted to this tower. Not the USB
; serial: read it with VimbaX's ListCameras, which is what this probe does.
id = ${cam:-NOT_FOUND_SET_THIS_BY_HAND}
EOF
done
}
do_hardware_ini() {
if [[ $TO_STDOUT -eq 1 ]]; then emit_hardware_ini; return; fi
need_root "--hardware-ini"
mkdir -p "$ETC_DIR"
if [[ -f $HW_FILE && $FORCE -ne 1 ]]; then
warn "$HW_FILE exists - leaving it alone (--force to replace)"
warn "the file it would write instead:"
emit_hardware_ini | sed 's/^/ /' >&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 <<EOF
hardware/$KIT_BOARD/install.sh - set up a $KIT_BOARD tower host. Run ON the device.
sudo ./install.sh everything: deps, preconditions, hardware.ini
sudo ./install.sh --deps apt packages only
sudo ./install.sh --preconditions udev + tmpfiles only, applied immediately
./install.sh --probe print what this machine looks like, change nothing
sudo ./install.sh --vimba DIR install a staged Vimba X SDK
./install.sh --stdout print the hardware.ini it would write
What was ASSEMBLED (a probe cannot tell missing from broken):
--axes yaw|yaw,pitch (default: $KIT_DEFAULT_AXES)
--imu none|mti --env none|sht41 --cameras rgb|none
Overrides for what is otherwise probed:
--serial PATH the MCU's /dev/serial/by-id path
--camera-id DEV_... the camera's Vimba id
--imu-device PATH --i2c-device PATH
Wiring ([Pins], which the firmware is built from):
--loom standard this kit's reference loom (pins.ini)
--pin KEY=N one pin at a time instead (yaw_cs=1, spi_sck=2, ...)
The drivetrain, per axis - anything not given is written as a ; TODO:
--motor AXIS.KEY=VALUE KEY one of: ${KIT_AXIS_KEYS[*]}
--r-sense, --run-current, --homing-current, --travel-deg, --steps-per-rev,
--sw-mode the same, for the FIRST axis in --axes
Other:
--force overwrite an existing hardware.ini (backs it up first)
--no-apt-update skip 'apt-get update'
EOF
}
kit_main() {
AXES="$KIT_DEFAULT_AXES" IMU="none" ENV_SENSOR="none" CAMERAS="rgb"
SERIAL_DEVICE="" CAMERA_ID="" IMU_DEVICE="" I2C_DEVICE="" LOOM=""
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=""
declare -gA MOTOR=() PINS=()
PIN_ORDER=()
local explicit=0
local -A first_axis=() # the legacy single-axis flags, applied once --axes is known
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 ;;
--vimba) VIMBA_SRC="$2"; DO_VIMBA=1; explicit=1; shift ;;
--axes) AXES="$2"; shift ;;
--imu) IMU="$2"; shift ;;
--env) ENV_SENSOR="$2"; shift ;;
--cameras) CAMERAS="$2"; shift ;;
--serial) SERIAL_DEVICE="$2"; shift ;;
--camera-id) CAMERA_ID="$2"; shift ;;
--imu-device) IMU_DEVICE="$2"; shift ;;
--i2c-device) I2C_DEVICE="$2"; shift ;;
--loom) LOOM="$2"; shift ;;
--pin)
[[ $2 == *=* ]] || die "--pin takes KEY=N"
[[ -z ${PINS[${2%%=*}]+x} ]] && PIN_ORDER+=("${2%%=*}")
PINS[${2%%=*}]="${2#*=}"; shift ;;
--motor)
[[ $2 == *.*=* ]] || die "--motor takes AXIS.KEY=VALUE"
MOTOR[${2%%=*}]="${2#*=}"; shift ;;
--r-sense) first_axis[r_sense_ohm]="$2"; shift ;;
--run-current) first_axis[run_current_ma]="$2"; shift ;;
--homing-current) first_axis[homing_current_ma]="$2"; shift ;;
--travel-deg) first_axis[travel_deg]="$2"; shift ;;
--steps-per-rev) first_axis[steps_per_rev]="$2"; shift ;;
--sw-mode) first_axis[sw_mode]="$2"; shift ;;
--force) FORCE=1 ;;
--no-apt-update) APT_UPDATE=0 ;;
-h|--help) kit_usage; exit 0 ;;
*) die "unknown option: $1 (--help lists them)" ;;
esac
shift
done
[[ $explicit -eq 0 ]] && { DO_DEPS=1; DO_PRE=1; DO_HW=1; }
[[ -z $LOOM || $LOOM == standard ]] || die "--loom takes 'standard' (or use --pin)"
[[ -n $LOOM && ${#PINS[@]} -gt 0 ]] && die "--loom and --pin are alternatives; give one"
local first key
first="$(echo "${AXES%%,*}" | tr -d '[:space:]')"
for key in "${!first_axis[@]}"; do
[[ -z ${MOTOR[$first.$key]+x} ]] && MOTOR[$first.$key]="${first_axis[$key]}"
done
for key in "${!MOTOR[@]}"; do
local ax="${key%%.*}" k="${key#*.}" ok=0 known
[[ ",${AXES// /}," == *",$ax,"* ]] || die "--motor $key: $ax is not in --axes $AXES"
for known in "${KIT_AXIS_KEYS[@]}"; do [[ $k == "$known" ]] && ok=1; done
[[ $ok -eq 1 ]] || die "--motor $key: unknown key (one of: ${KIT_AXIS_KEYS[*]})"
done
local board; board="$(probe_board)"
if [[ $board != "$KIT_BOARD" ]]; then
warn "this kit is for the $KIT_BOARD; this machine reports '$board'"
# --probe and --stdout change nothing on this machine. Refusing them on
# another board 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 $KIT_BOARD preconditions to a $board"
fi
if [[ $PROBE_ONLY -eq 1 ]]; then probe_report; exit 0; fi
# --stdout emits the file and nothing else: it is meant to be redirected into one.
if [[ $TO_STDOUT -eq 1 ]]; then do_hardware_ini; exit 0; fi
say "$KIT_BOARD 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"
}

View File

@ -1,519 +1,33 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# #
# install.sh - turn a freshly imaged LattePanda IOTA into a fire-watch tower host. # install.sh - turn a freshly imaged LattePanda IOTA into a fire-watch tower host.
# Run it ON the device; --help lists every option. scripts/install-tower drives
# this for a whole install, prompting for what it needs; this stays usable alone.
# #
# Run it ON the device: # sudo ./install.sh --deps --preconditions
# sudo ./install.sh # everything: deps, preconditions, hardware.ini # ./install.sh --probe change nothing, report
# sudo ./install.sh --deps # apt packages only # ./install.sh --stdout --axes yaw --loom standard --motor yaw.r_sense_ohm=0.075 ...
# sudo ./install.sh --preconditions # udev + tmpfiles only, applied immediately
# ./install.sh --probe # print what this machine looks like, change nothing
# sudo ./install.sh --vimba ~/VimbaX_2025-3 # install a staged Vimba X SDK
# ./install.sh --stdout # print the hardware.ini it would write
# #
# What it can work out for itself: the board, the motor MCU's stable by-id path, # Everything shared with the other boards' kits is in ../common/kit.sh. What is
# the camera's DEV_ id, whether the USB preconditions took. What it cannot, and # here is what differs on the IOTA.
# takes from flags, is what was ASSEMBLED - a missing pitch axis and a broken
# pitch axis look identical to a probe:
#
# --axes yaw (default: yaw,pitch) which axes the gimbal has
# --imu none (default: none) IMU model, or none
# --env none (default: none) ambient sensor model, or none
# --cameras rgb (default: rgb) expected [Camera.<name>] 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'
# #
set -euo pipefail set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)" KIT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ETC_DIR=/etc/fire_gimbal_control KIT_BOARD=lattepanda-iota
HW_FILE="$ETC_DIR/hardware.ini" 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 <n>\`.
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" # shellcheck source=../common/kit.sh
IMU="none" . "$KIT_DIR/../common/kit.sh"
ENV_SENSOR="none" # scripts/install-tower sources this file for the facts and probes alone.
CAMERAS="rgb" [[ ${KIT_NO_MAIN:-0} == 1 ]] || kit_main "$@"
# 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.<name>] 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@<tower>:~/ # 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 <<EOF
; hardware.ini - what this machine IS. Generated by hardware/$board/install.sh
; on $(date -Is) from what the machine reports, plus the assembly
; facts given on the command line. See config/hardware.example.ini for the full
; reference, and docs/hardware-config-plan.md for why this file exists.
[Board]
model = $board
firmware_env = $fw_env
[Hardware]
; DECLARED, not probed: these are what the tower was built with, so that a part
; that stops answering is a fault and not a silent reconfiguration.
axes = $AXES
imu = $IMU
env = $ENV_SENSOR
cameras = $CAMERAS
[Serial]
; The motor controller MCU, by stable id - /dev/ttyACM* moves when the modem
; enumerates first.
device = ${mcu:-NOT_FOUND_SET_THIS_BY_HAND}
baud = 115200
EOF
if [[ $IMU != none ]]; then
cat <<'EOF'
[IMU]
; On the IOTA the only PCH serial on the header is COM1. Confirm it enumerates
; and at what levels before trusting this path.
device = /dev/ttyS0
EOF
fi
if [[ $ENV_SENSOR != none ]]; then
cat <<'EOF'
[Env]
; MUST be a real I2C adapter, not SMBus-only: check with `i2cdetect -F <n>`.
; 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.<axis>] 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 <<EOF
[Camera.$c]
; The Vimba DEV_ id of the physical camera fitted to this tower. Not the USB
; serial: read it with VimbaX's ListCameras, which is what this probe does.
id = ${cam:-NOT_FOUND_SET_THIS_BY_HAND}
EOF
done
}
do_hardware_ini() {
if [[ $TO_STDOUT -eq 1 ]]; then emit_hardware_ini; return; fi
need_root "--hardware-ini"
mkdir -p "$ETC_DIR"
if [[ -f $HW_FILE && $FORCE -ne 1 ]]; then
warn "$HW_FILE exists - leaving it alone (--force to replace)"
warn "the file it would write instead:"
emit_hardware_ini | sed 's/^/ /' >&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"

View File

@ -106,6 +106,24 @@ resolve_vimba_env() {
fi 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() { cmd_start() {
need_tmux need_tmux
if running; then if running; then
@ -198,6 +216,7 @@ cmd_start() {
# exec'd, 3/3 clean when parented. # exec'd, 3/3 clean when parented.
cmd__run() { cmd__run() {
resolve_vimba_env resolve_vimba_env
load_credentials
# <&0 is required: bash redirects a background command's stdin from # <&0 is required: bash redirects a background command's stdin from
# /dev/null unless it is given one explicitly, which would leave the # /dev/null unless it is given one explicitly, which would leave the

869
scripts/install-tower Executable file
View File

@ -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 <<EOF
board : $S_BOARD (kit hardware/$S_BOARD, firmware env $KIT_FW_ENV)
software / fw : $S_SW_REV / $S_FW_REV
motor MCU : ${S_MCU:-NOT FOUND} [$S_MODE]
running firmware : ${S_FW_NOW:-unknown}
camera : ${S_CAM:-not found}${S_LPM:+ (LPM $(cat "$S_LPM") at $S_LPM)}
usbfs_memory_mb : $S_USBFS
hardware.ini : $([[ -r $HW ]] && echo "present, $(hw_todos | grep -c . || true) TODO(s)" || echo absent)
config.ini : $([[ -r $CFG ]] && echo "present, tower_name $(ini_get "$CFG" General tower_name 2>/dev/null || echo '?')" || echo absent)
app : $([[ $S_RUNNING -eq 1 ]] && echo "running (${S_ARGS:-no arguments})" || echo "not running")
service : ${S_SVC_ENABLED:-absent} / ${S_SVC_ACTIVE:-inactive}, linger ${S_LINGER}
PlatformIO : ${S_PIO:-not installed}
EOF
# ----------------------------------------------------------------- verify ---
TEST_RC=""
run_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.<axis>] each TODO sits in.
while read -r ax key; do
[[ -z $key ]] && continue
case $key in
has_encoder) pat='^(true|false)$' ;; sw_mode) pat='^[0-9]+$' ;;
*) pat='^[0-9]+(\.[0-9]+)?$' ;;
esac
explain "$(kit_axis_todo "$key")"
ask_value "$ax.$key" " $ax $key" "$pat"
[[ -n $REPLY_VAL ]] && { HW_FILLS+=("$ax $key $REPLY_VAL"); TODO_LEFT=$((TODO_LEFT - 1)); }
done < <(awk '/^\[Motor\./ { ax = $0; gsub(/^\[Motor\.|\].*$/, "", ax) }
/^; TODO / && ax != "" { print ax, $3 }' "$HW")
fi
what=""
[[ $HW_APPEND_PINS -eq 1 ]] && what+="add the standard [Pins]; "
(( ${#HW_FILLS[@]} )) && what+="fill ${#HW_FILLS[@]} TODO(s); "
(( ${#HW_SETS[@]} )) && what+="set ${HW_SETS[*]}"
[[ -n $what ]] && plan hardware.ini change "$what" || plan hardware.ini skip "present, nothing to change"
fi
# What hardware.ini will say once written - later steps depend on it.
HW_VIEW="$HW"; [[ -n $HW_NEW ]] && HW_VIEW="$HW_NEW"
steps_for() { ini_get "$HW_VIEW" "Motor.$1" steps_per_rev 2>/dev/null || true; }
declare -A STEPS_OVR=()
for f in "${HW_FILLS[@]}"; do # a TODO answered now counts too
read -r fa fk fv <<< "$f"
[[ $fk == steps_per_rev ]] && STEPS_OVR[$fa]="$fv"
done
declared_axes="$(ini_get "$HW_VIEW" Hardware axes 2>/dev/null | tr -d ' ' || echo yaw)"
# config.ini: what this tower DOES.
declare -a CFG_SETS=()
CFG_SEED=0 CRED_WRITE=0
if [[ ! -r $CFG ]]; then
CFG_SEED=1
echo; echo "There is no config.ini yet."
while :; do
ask tower_name "Tower name (it is in every MQTT topic)"
case "${REPLY_VAL,,}" in ""|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.<axis>] block at the end.
printf '%s\n' "${HW_FILLS[@]}" > "$WORK/fills"
awk 'NR == FNR { v[$1 "." $2] = $3; next }
/^\[/ { ax = ""; if ($0 ~ /^\[Motor\./) { ax = $0; gsub(/^\[Motor\.|\].*$/, "", ax) } skip = 0 }
skip && /^; / { next }
{ skip = 0 }
/^; TODO / && ((ax "." $3) in v) { printf "%-18s = %s\n", $3, v[ax "." $3]; skip = 1; next }
{ print }' "$WORK/fills" "$HW" > "$WORK/hw_filled"
sudo install -m 0644 "$WORK/hw_filled" "$HW"
say "filled ${#HW_FILLS[@]} TODO(s)"
fi
if (( ${#HW_SETS[@]} )); then
args=(); for s in "${HW_SETS[@]}"; do args+=(--set "$s"); done
# hardware.ini is validated together with config.ini when there is one.
cfg_for_check="$CFG"; [[ -r $CFG ]] || cfg_for_check="$REPO/config/config.example.ini"
sudo "$FGC_CONFIG" set "$HW" --config "$cfg_for_check" --hardware "$HW" "${args[@]}" \
--provenance "$(date -u +%Y-%m-%dT%H:%MZ), install-tower"
fi
done_step hardware.ini
fi
if [[ ${DO[credentials]:-} == create ]]; then
hr credentials
printf 'FGC_MQTT_USER=%s\nFGC_MQTT_PW=%s\n' "$MQTT_USER" "$MQTT_PW" > "$WORK/cred"
sudo install -m 0640 -o root -g "$(id -gn)" "$WORK/cred" "$CRED"
say "wrote $CRED"; done_step credentials
fi
if [[ ${DO[config.ini]} != skip ]]; then
hr config.ini
args=(); for s in "${CFG_SETS[@]}"; do args+=(--set "$s"); done
if [[ $CFG_SEED -eq 1 ]]; then
"$FGC_CONFIG" seed --example "$REPO/config/config.example.ini" --out "$CFG" --hardware "$HW" \
"${args[@]}" \
--drop "Network.mqtt_user=comes from $CRED (FGC_MQTT_USER)" \
--drop "Network.mqtt_pw=comes from $CRED (FGC_MQTT_PW)"
else
"$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/<uid> does not exist until someone logs in, so
# a service-started app takes its lock in /tmp, and an ssh session then looks
# in /run/user, sees nothing, and `fgc status` says "not running".
sudo loginctl enable-linger "$USER"
sudo systemctl daemon-reload
sudo systemctl enable fire-gimbal-control
done_step service
fi
SVC_ON=0; [[ $(systemctl is-enabled fire-gimbal-control 2>/dev/null) == enabled ]] && SVC_ON=1
# ------------------------------------------------------------- the gate ---
HOMED="not attempted"
hr "homing"
echo "Homing drives each axis into its endstops. On a new or changed drivetrain it"
echo "is the step that can break something, so it needs a person at the rig."
if [[ $TEST_RC != 0 ]]; then
warn "not offered: test hardware did not pass"
elif ask_yn home "Someone is at the rig with a hand on the power - home now and take 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 ))