fwt_software/hardware/lattepanda-iota/install.sh

520 lines
23 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# install.sh - turn a freshly imaged LattePanda IOTA into a fire-watch tower host.
#
# Run it 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 ~/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,
# 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
# 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
HERE="$(cd "$(dirname "$0")" && pwd)"
ETC_DIR=/etc/fire_gimbal_control
HW_FILE="$ETC_DIR/hardware.ini"
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.<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"