#!/usr/bin/env bash # # deploy.sh — sync this project to the LattePanda and build/run it there. # The LattePanda is the machine wired to the gimbal + camera, so the actual # cmake build (and the live run) happen remotely over ssh. We edit locally and # mirror. Mirrors ../firmware/deploy.sh, but for the CMake/C++ side. # # Usage: # ./deploy.sh rsync + remote configure + build # ./deploy.sh --run ... then start the app on the device, detached, and return # ./deploy.sh --run --attach ... start it, then attach straight away (the usual one) # ./deploy.sh --attach attach to the app already running (no sync, no build) # ./deploy.sh --status is it running on the device? # ./deploy.sh --stop shut the running app down cleanly # ./deploy.sh --clean wipe the remote build dir first (fresh configure) # ./deploy.sh --push-config ALSO overwrite the device's config.ini with the local one # ./deploy.sh --check-deps only check the remote build dependencies # ./deploy.sh --run --force skip the placeholder-config guard before running # ./deploy.sh --run --foreground run attached to this terminal instead (debugging) # # What belongs to one tower - its config.ini (calibration, camera IDs), its scan # grid, the camera profile XMLs exported on it - lives in the tower's own folder, # ~/.config/fire_gimbal_control/, outside the project folder this script mirrors # (scripts/tower-config). A deploy never reaches it. A tower set up before that # folder existed still has them in the project folder; the excludes below keep # them safe there until `scripts/tower-config move` is run on it, and every # deploy says so. # # Everything under config/ in this repo (the example config, the example scan # grids) is mirrored on every deploy. --push-config opts into overwriting the # config.ini the tower reads with the local one, backing the tower's copy up to # config.ini.bak- beside it first. # # --run starts the app inside a tmux session on the device (scripts/fgc), so it # survives this ssh connection closing — reattach later with --attach. Only one # instance can run at a time; the binary itself enforces that with a lockfile. # # Whether the app homes the gimbal and which mode it starts in are NOT flags: # they are [Startup] home / mode in the device's config.ini, read at every start # (--run and the boot service alike). To home once without changing that, attach # and type `gimbal home`. # # Flags combine, e.g. ./deploy.sh --clean --run # # Config comes from .deploy.env (see .deploy.env.example). # set -euo pipefail cd "$(dirname "$0")" ENV_FILE=".deploy.env" if [[ ! -f "$ENV_FILE" ]]; then echo "error: $ENV_FILE not found (copy .deploy.env.example and edit it)" >&2 exit 1 fi # shellcheck disable=SC1090 source "$ENV_FILE" : "${REMOTE_HOST:?set REMOTE_HOST in .deploy.env}" : "${REMOTE_DIR:?set REMOTE_DIR in .deploy.env}" CMAKE_ARGS="${CMAKE_ARGS:--DWITH_MQTT=ON -DWITH_VIMBA=ON}" BUILD_JOBS="${BUILD_JOBS:-}" RUN_ARGS="${RUN_ARGS:-}" CLEAN=0 RUN=0 CHECK_DEPS=0 FORCE=0 FOREGROUND=0 PUSH_CONFIG=0 SESSION_CMD="" for arg in "$@"; do case "$arg" in --clean) CLEAN=1 ;; --push-config|--with-config) PUSH_CONFIG=1 ;; --run|--start) RUN=1 ;; --home|--init) echo "error: $arg was removed: set [Startup] home = true in the device's config.ini," >&2 echo " or attach and type 'gimbal home' to home once" >&2 exit 1 ;; --check-deps) CHECK_DEPS=1 ;; --force) FORCE=1 ;; --foreground) FOREGROUND=1 ;; --attach) SESSION_CMD="attach" ;; --status) SESSION_CMD="status" ;; --stop) SESSION_CMD="stop" ;; *) echo "unknown option: $arg" >&2; exit 1 ;; esac done if [[ "$SESSION_CMD" == "attach" && "$FOREGROUND" == "1" ]]; then echo "error: --attach and --foreground are contradictory (--foreground never detaches)" >&2 exit 1 fi if [[ -n "$SESSION_CMD" && "$SESSION_CMD" != "attach" && "$RUN" == "1" ]]; then echo "error: --$SESSION_CMD cannot be combined with --run" >&2 exit 1 fi if [[ "$PUSH_CONFIG" == "1" && ! -f config.ini ]]; then echo "error: --push-config given but there is no local config.ini to push" >&2 echo " (config.ini is gitignored; the device keeps its own in ~/.config/fire_gimbal_control/)" >&2 exit 1 fi if [[ "$PUSH_CONFIG" == "1" && -n "$SESSION_CMD" && "$SESSION_CMD" != "attach" ]]; then echo "error: --push-config cannot be combined with --$SESSION_CMD (which does not sync)" >&2 exit 1 fi # Talk to the app already on the device — no sync, no build. `--run --attach` is # the exception: there the attach happens at the very end, after the build and # start, so fall through to those instead of exiting here. if [[ -n "$SESSION_CMD" ]] && [[ "$SESSION_CMD" != "attach" || "$RUN" != "1" ]]; then # -t only for attach: it needs a real tty for the dashboard. status/stop # produce plain output and should stay pipeable. TTY_FLAG=(); [[ "$SESSION_CMD" == "attach" ]] && TTY_FLAG=(-t) # shellcheck disable=SC2029 exec ssh "${TTY_FLAG[@]}" "$REMOTE_HOST" \ "cd '$REMOTE_DIR' && exec scripts/fgc $SESSION_CMD" fi # The program refuses --init/--start now; catch an old .deploy.env here, before # a sync and a build, rather than at the very end. for a in $RUN_ARGS; do case "$a" in --init|-i|--start|-s) echo "error: RUN_ARGS in $ENV_FILE has '$a', which the program no longer takes." >&2 echo " Homing and the start mode are [Startup] home / mode in the device's" >&2 echo " config.ini: drop it from RUN_ARGS." >&2 exit 1 ;; esac done # Locate cmake on the remote. Non-interactive ssh shells don't source .bashrc, # so allow an explicit REMOTE_CMAKE override in .deploy.env; otherwise search # the usual locations. RESOLVE_CMAKE=" CMAKE='${REMOTE_CMAKE:-}' if [ -z \"\$CMAKE\" ]; then for c in \"\$(command -v cmake 2>/dev/null)\" /usr/bin/cmake /usr/local/bin/cmake; do if [ -x \"\$c\" ]; then CMAKE=\"\$c\"; break; fi done fi if [ -z \"\$CMAKE\" ]; then echo 'error: cmake not found on remote; set REMOTE_CMAKE in .deploy.env' >&2 exit 127 fi " # Vimba X needs the GenTL transport-layer dir on GENICAM_GENTL64_PATH at runtime, # or VmbStartup() fails with "Could not start Vimba X API". The SDK installs that # via /etc/profile.d, which non-interactive ssh shells don't source — so set it # ourselves before launching. Optional VIMBA_CTI_PATH override in .deploy.env. RESOLVE_VIMBA_ENV=" for s in /etc/profile.d/*Vimba*GenTL*.sh; do [ -f \"\$s\" ] && . \"\$s\" done if [ -z \"\${GENICAM_GENTL64_PATH:-}\" ]; then for d in '${VIMBA_CTI_PATH:-}' /opt/VimbaX/cti /opt/VimbaX_*/cti; do if [ -d \"\$d\" ]; then export GENICAM_GENTL64_PATH=\"\$d\"; break; fi done fi " # Preflight: the two heavyweight deps that aren't auto-fetched (Paho is). OpenCV # and Boost.program_options must be present or the configure step fails opaquely. CHECK_DEPS_CMD=" miss='' pkg-config --exists opencv4 2>/dev/null || miss=\"\$miss opencv4\" pkg-config --exists libjxl libjxl_threads 2>/dev/null || miss=\"\$miss libjxl\" [ -f /usr/include/boost/version.hpp ] || ls /usr/include/boost*/boost/version.hpp >/dev/null 2>&1 || miss=\"\$miss boost\" if [ -n \"\$miss\" ]; then echo \"error: missing remote build deps:\$miss\" >&2 echo 'install on the LattePanda (Ubuntu):' >&2 echo ' sudo apt-get install -y build-essential cmake git libopencv-dev libboost-program-options-dev libjxl-dev' >&2 exit 1 fi echo 'remote deps OK (OpenCV, Boost, libjxl; Vimba via cmake/FindVmb; Paho fetched)' " if [[ "$CHECK_DEPS" == "1" ]]; then echo ">> checking remote build deps on $REMOTE_HOST" # shellcheck disable=SC2029 ssh "$REMOTE_HOST" "$CHECK_DEPS_CMD" exit 0 fi # .git is excluded from rsync, so capture build identity locally and pass it on. if git rev-parse --git-dir >/dev/null 2>&1; then GIT_REV="$(git describe --always --dirty 2>/dev/null || echo nogit)" else GIT_REV="nogit" fi echo ">> build id: $GIT_REV" echo ">> ensuring remote dir exists" ssh "$REMOTE_HOST" "mkdir -p '$REMOTE_DIR'" # A tower's own files live in ~/.config/fire_gimbal_control/, out of reach of # this rsync. The config.ini and camera profile excludes below are for a tower # not moved there yet, which still keeps them in the project folder: they are # device-local (runtime config, operator-exported camera settings) and must not # be clobbered before `scripts/tower-config move` runs. config.ini.moved-* is # what that move leaves behind. images/ is captured output. # # hardware.ini is excluded for the same reason but one step further: it normally # lives in /etc/fire_gimbal_control/, out of reach of this rsync entirely, and # the exclude covers the fallback location (beside config.ini) that the # resolution chain also accepts. Without it, --delete would erase a machine's # assembly description on the next deploy - the same way it once erased a # freshly exported camera profile. --push-config does NOT override this: the # assembly of a tower is not something you push from a dev box. # # The profile XMLs matter especially: they are exported on the DEVICE from the # VimbaX Viewer, which is where the camera is, so they exist nowhere in this # repo. Without these excludes `--delete` erases them on the next deploy - which # it did once, taking a freshly exported standard.xml with it. The README.md in # that directory IS repo documentation and still syncs. # # build*/ covers every local build tree, not just build/. They are machine-specific # and the device regenerates its own build/ remotely, so shipping build-tui/ etc. # just pushed tens of MB of x86 object files the device can never use. echo ">> syncing to $REMOTE_HOST:$REMOTE_DIR" rsync -avz --delete \ --exclude 'build*/' \ --exclude '.git' \ --exclude '.revision' \ --exclude '.venv' \ --exclude '.vscode' \ --exclude '__pycache__' \ --exclude '.deploy.env' \ --exclude 'hardware.ini' \ --exclude 'config.ini' \ --exclude 'config.ini.bak-*' \ --exclude 'config.ini.moved-*' \ --exclude 'config/camera_profiles/**/*.xml' \ --exclude 'config/camera_profiles/**/*.txt' \ --exclude 'images/' \ ./ "$REMOTE_HOST:$REMOTE_DIR/" # .git is not synced, so leave the revision beside the tree for the tools that # run THERE and need to know what they are building (install-tower, flash.sh). # shellcheck disable=SC2029 ssh "$REMOTE_HOST" "printf '%s\\n' '$GIT_REV' > '$REMOTE_DIR/.revision'" # The tower's folder, and the config.ini the program there will load - asked of # the tower, which searches the same places the program does. # shellcheck disable=SC2029 tower_config() { ssh "$REMOTE_HOST" "cd '$REMOTE_DIR' && scripts/tower-config $*"; } TOWER_DIR="$(tower_config dir)" if [[ "$PUSH_CONFIG" == "1" ]]; then # The tower's config holds that rig's calibration, camera IDs and credentials - # things that exist nowhere else. Keep a copy before overwriting it. TOWER_CFG="$(tower_config where || echo "$TOWER_DIR/config.ini")" echo ">> pushing local config.ini to $TOWER_CFG (overwrites the tower's)" # shellcheck disable=SC2029 ssh "$REMOTE_HOST" "mkdir -p \"\$(dirname '$TOWER_CFG')\" && \ if [ -f '$TOWER_CFG' ]; then \ cp -p '$TOWER_CFG' '$TOWER_CFG.bak-'\$(date +%Y%m%d-%H%M%S) && \ echo ' previous copy saved as config.ini.bak- beside it'; \ else echo ' the tower had no config.ini to back up'; fi" scp -q config.ini "$REMOTE_HOST:$TOWER_CFG" echo " relative paths in it (grid_file, profile_dir) now resolve from $(dirname "$TOWER_CFG")" else # A new tower gets its folder, with a config.ini from the example. Never # overwrites: an existing config.ini is kept wherever it is. echo ">> ensuring the tower has a config.ini" tower_config seed | sed 's/^>> / /' TOWER_CFG="$(tower_config where)" fi if [[ "$(dirname "$TOWER_CFG")" != "$TOWER_DIR" ]]; then echo "!! this tower's config.ini is still in the project folder ($TOWER_CFG)." >&2 echo "!! Move it, with its scan grid and camera profiles, out of a deploy's reach:" >&2 echo "!! ssh $REMOTE_HOST 'cd $REMOTE_DIR && scripts/fgc stop && scripts/tower-config move'" >&2 fi if [[ "$CLEAN" == "1" ]]; then echo ">> wiping remote build dir" ssh "$REMOTE_HOST" "rm -rf '$REMOTE_DIR/build'" fi echo ">> remote preflight + configure + build ($CMAKE_ARGS)" JOBS_FLAG="" [[ -n "$BUILD_JOBS" ]] && JOBS_FLAG="-j$BUILD_JOBS" || JOBS_FLAG="-j\$(nproc)" # shellcheck disable=SC2029 ssh "$REMOTE_HOST" "$RESOLVE_CMAKE $CHECK_DEPS_CMD cd '$REMOTE_DIR' && \ GIT_REV='$GIT_REV' \"\$CMAKE\" -S . -B build $CMAKE_ARGS && \ \"\$CMAKE\" --build build $JOBS_FLAG" echo ">> build complete: $REMOTE_DIR/build/fire_gimbal_control" # A pushed config.ini was written on another machine: check that it loads on # this one, with this tower's hardware.ini layered over it, before anyone runs it. if [[ "$PUSH_CONFIG" == "1" ]]; then echo ">> checking the pushed config.ini on the tower" # shellcheck disable=SC2029 if ! ssh "$REMOTE_HOST" "cd '$REMOTE_DIR' && hw=/etc/fire_gimbal_control/hardware.ini && \ if [ -f \$hw ]; then build/fgc-config check --config '$TOWER_CFG' --hardware \$hw; \ else build/fgc-config check --config '$TOWER_CFG'; fi"; then echo "!! the pushed config.ini does not load on the tower; the previous one is" >&2 echo "!! $TOWER_CFG.bak-" >&2 exit 1 fi fi # A boot service installed before [Startup] existed still starts the program # with --init/--start, which this build refuses: the tower would not come up at # its next boot. Only the installer can fix that (it moves them into config.ini # and rewrites the unit), so say so loudly rather than touch the unit from here. # shellcheck disable=SC2029 if ssh "$REMOTE_HOST" "grep -qE '^ExecStart=.*fgc start.*[[:space:]](--init|--start|-i|-s)([[:space:]]|$)' \ /etc/systemd/system/fire-gimbal-control.service 2>/dev/null"; then echo "!! WARNING: the device's boot service still passes --init/--start, which this build" >&2 echo "!! refuses - the program will NOT start at the next boot. On the device run" >&2 echo "!! scripts/install-tower" >&2 echo "!! which moves them into config.ini [Startup] and rewrites the service." >&2 fi if [[ "$RUN" == "1" ]]; then # Guard: a freshly seeded config.ini is the example template, with placeholders # that connect nowhere and silently drop to degraded mode. Refuse to launch on # placeholders (unless --force) so it's an explicit failure, not a confusing # half-run. Only enforce placeholders for subsystems this run actually uses, # mirroring Application.cpp's resolution: MQTT off if --no-mqtt, a WITH_MQTT=OFF # build, OR [Features] enable_mqtt is falsey; camera mocked if --mock-camera OR # [Features] mock_camera true / enable_camera false. CLI/build signals are # computed here; the config's own flags are parsed remotely where the file is. if [[ "$FORCE" != "1" ]]; then MQTT_OFF_CLI=0; CAM_MOCK_CLI=0 [[ "$RUN_ARGS" == *--no-mqtt* || "$CMAKE_ARGS" == *WITH_MQTT=OFF* ]] && MQTT_OFF_CLI=1 [[ "$RUN_ARGS" == *--mock-camera* ]] && CAM_MOCK_CLI=1 echo ">> checking device config.ini for unedited placeholders" # shellcheck disable=SC2029 if ! ssh "$REMOTE_HOST" " cfg='$TOWER_CFG' [ -f \"\$cfg\" ] || { echo \"error: \$cfg missing on device\" >&2; exit 2; } # Read an INI value (last assignment wins), strip inline ';' comment + ws, lowercase. ini_val() { grep -iE \"^[[:space:]]*\$1[[:space:]]*=\" \"\$cfg\" | tail -1 \ | sed -E 's/^[^=]*=[[:space:]]*//; s/[[:space:]]*(;.*)?\$//' | tr 'A-Z' 'a-z'; } is_false() { case \"\$(ini_val \"\$1\")\" in 0|false|no|off) return 0;; *) return 1;; esac; } is_true() { case \"\$(ini_val \"\$1\")\" in 1|true|yes|on) return 0;; *) return 1;; esac; } mqtt_on=1; [ '$MQTT_OFF_CLI' = 1 ] && mqtt_on=0; is_false enable_mqtt && mqtt_on=0 cam_real=1; [ '$CAM_MOCK_CLI' = 1 ] && cam_real=0 is_true mock_camera && cam_real=0; is_false enable_camera && cam_real=0 pat='ExampleTower' # tower identity: always [ \"\$mqtt_on\" = 1 ] && pat=\"\$pat|CHANGE_ME\" # MQTT creds: only if MQTT on [ \"\$cam_real\" = 1 ] && pat=\"\$pat|DEV_0000000000\" # camera id: only if real cam if grep -Eq \"\$pat\" \"\$cfg\"; then echo 'error: device config.ini still has example placeholders:' >&2 grep -En \"\$pat\" \"\$cfg\" | sed 's/^/ /' >&2 echo 'edit them on the device, or pass --force to run anyway.' >&2 exit 3 fi "; then echo ">> aborting run; fix the config or re-run with --force" >&2 exit 1 fi fi if [[ "$FOREGROUND" == "1" ]]; then echo ">> running on $REMOTE_HOST, attached to this terminal (ctrl-c to stop)" # -t for a real tty so the capture loop's logs stream and ctrl-c propagates. # cwd = REMOTE_DIR so ./config.ini and the output dir resolve. # shellcheck disable=SC2029 ssh -t "$REMOTE_HOST" "$RESOLVE_VIMBA_ENV cd '$REMOTE_DIR' && exec ./build/fire_gimbal_control $RUN_ARGS" exit fi echo ">> starting on $REMOTE_HOST, detached" # No -t and no RESOLVE_VIMBA_ENV here: scripts/fgc puts the app in a tmux # session on the device and resolves the Vimba environment inside the pane, # so it neither needs this ssh session's tty nor inherits its environment. # This ssh call returns as soon as the app is up (or reports why it isn't). # shellcheck disable=SC2029 ssh "$REMOTE_HOST" "cd '$REMOTE_DIR' && exec scripts/fgc start $RUN_ARGS" if [[ "$SESSION_CMD" == "attach" ]]; then echo ">> attaching (Ctrl-b d detaches and leaves it running; 'q' quits the app)" # shellcheck disable=SC2029 exec ssh -t "$REMOTE_HOST" "cd '$REMOTE_DIR' && exec scripts/fgc attach" fi echo ">> attach with: ./deploy.sh --attach (Ctrl-b d detaches, 'q' quits the app)" echo ">> stop with: ./deploy.sh --stop" fi