# Running and updating a tower How a tower that already works is operated and updated: pushing a new version from the dev box, running the program detached, starting it at boot, reading its log. A **first** install - a freshly imaged board, or a tower not yet migrated - is [tower-installation.md](tower-installation.md), which `scripts/install-tower` automates. The fleet: tower **111** is a LattePanda 3 Delta (ATmega32U4 motor MCU, two axes), tower **121** a LattePanda IOTA (RP2040, yaw only). Nothing here differs between them; what differs lives in each tower's `hardware.ini`. ## Where things are on a tower | Path | What | Written by | |---|---|---| | `~ggs/projects/fwt_2a/software/` | this repository, and the program's build in `build/` | `deploy.sh` (sync + build), or the installer | | `~ggs/projects/fwt_2a/firmware/` | the firmware repository, built on the tower | `firmware/deploy.sh` | | `~ggs/.config/fire_gimbal_control/config.ini` | what the tower DOES: scan, capture, logging, calibration | the installer (seeded once), `gimbal ... setup` (calibration), a person | | `~ggs/.config/fire_gimbal_control/scan.csv` | the scan grid chosen for this tower (`[Scan] grid_file`) | the installer (the example grid), a person | | `/etc/fire_gimbal_control/hardware.ini` | what the tower IS: board, axes, sensors, pins, drivetrain | the installer; a person when the hardware changes | | `/etc/fire_gimbal_control/credentials.env` | `FGC_MQTT_USER=` / `FGC_MQTT_PW=`, mode 0640 root:ggs | the installer | | `/etc/fire_gimbal_control/install.log` | one line per installer run | the installer | | `~ggs/.local/share/fire_gimbal_control/logs/` | the program's log, test reports | the program | | `~ggs/.config/fire_gimbal_control/camera_profiles/rgb/*.xml` | the camera settings, exported ON the tower from its camera | the installer, or `scripts/export_camera_profile.py` | The rule: what a tower changes lives in `~/.config/fire_gimbal_control/`, what it never changes in `/etc/fire_gimbal_control/`, and the project folder holds only what a deploy or an update may replace. `deploy.sh` mirrors the project folder with `rsync --delete`, so nothing of the tower's own may live there - `rsync --delete` once erased a freshly exported profile that did. ### A tower set up before the tower folder existed Its `config.ini`, scan grid and camera profiles are still in the project folder. The program loads them from there and warns at every start, and every deploy prints how to move them. Until then `deploy.sh`'s excludes keep them safe. Move them once, on the tower: ```bash ssh ggs@ cd ~/projects/fwt_2a/software scripts/tower-config move --dry-run # what moves where, and what config.ini will say scripts/fgc stop # the program writes config.ini itself scripts/tower-config move scripts/fgc start # or: sudo systemctl start fire-gimbal-control ``` It copies `config.ini`, the scan grid `[Scan] grid_file` names and every camera's profiles into the tower folder, and rewrites those two paths (`config/scan.csv` becomes `scan.csv`). `fgc-config` checks the result loads before it is written. The old `config.ini` is renamed `config.ini.moved-`, and the old grid and profiles stay where they were. `scripts/install-tower` does the same move as one of its steps. ## 1. Updating the program from the dev box On the dev box, in `software/`, with a `.deploy.env` naming the tower: ```ini REMOTE_HOST=ggs@10.11.12.121 REMOTE_DIR=/home/ggs/projects/fwt_2a/software CMAKE_ARGS="-DWITH_MQTT=ON -DWITH_VIMBA=ON" RUN_ARGS="" ``` `RUN_ARGS` holds only extra flags (`--no-mqtt`, say). Whether the program homes and which mode it starts in are the tower's own `config.ini` `[Startup] home` / `mode`, the same for `--run` as for the boot service; `deploy.sh` refuses a `RUN_ARGS` that still has `--init` or `--start`. ```bash ./deploy.sh # sync the tree, build on the tower ./deploy.sh --check-deps # only check the tower's build dependencies ./deploy.sh --clean # wipe the tower's build dir first (fresh configure) ``` It syncs the tree (leaving `.git` behind, and writing the revision into `.revision` beside it instead), then configures and builds **on the tower**. The program already running keeps running the old binary until it is restarted: ```bash # on a tower started by its boot service ssh ggs@ sudo systemctl restart fire-gimbal-control # on a tower started by hand ./deploy.sh --stop && ./deploy.sh --run # homes / sweeps as its [Startup] says ``` To home once on a tower whose `[Startup] home` is off, attach and type `gimbal home` (key `h`); `deploy.sh --home` is gone. After building, `deploy.sh` also reads the tower's boot service: one that still passes `--init`/`--start` gets a loud warning, because the new program refuses them and **the tower would not start at its next boot**. Run `scripts/install-tower` on the tower to fix it (section 4). `--run` starts the program detached through `scripts/fgc` (section 2) and first refuses a `config.ini` that still holds the example's placeholders (`ExampleTower`, `CHANGE_ME`, `DEV_0000000000`); `--force` overrides that. On a tower whose service is running, `--run` fails on the single-instance lock (section 3) - restart it through systemd instead. After an update, check it: ```bash ssh ggs@ 'cd ~/projects/fwt_2a/software && scripts/install-tower --verify' ``` It changes nothing; it runs `test hardware` and `test camera` inside the running program (pausing and then resuming capture) and checks the files, the firmware's pins and the boot service. `test camera` takes 15-30 s: it grabs frames, then drives the camera's own settings - minimum exposure and gain must take every pixel black, maximum gain and a long exposure must make the frame measurably different - which holds whatever the camera is pointed at, and puts the profile back. `--push-config` also overwrites the tower's `config.ini` with the dev box's, backing the tower's copy up first. It exists for the development tower; a tower's `config.ini` holds its own calibration, so do not push one tower's to another. `hardware.ini` is never pushed. > **One-time: `git pull` can delete your `.deploy.env`.** It was tracked by > mistake until `b0127db`, which stopped tracking it and added it to > `.gitignore`. Pulling *across* that commit deletes the working copy, credentials > and all, with no warning. Only checkouts that predate `b0127db` are exposed, > and only once: copy it aside first (`cp .deploy.env ../deploy.env.bak`), pull, > copy it back. To recover one already lost: `git show b0127db^:.deploy.env`. ### Updating the firmware **Host first, firmware second**, always: a new firmware asks the host for its drivetrain at boot, and an older host cannot answer. In `firmware/`, with its own `.deploy.env` (`REMOTE_HOST`, `REMOTE_DIR`, `PIO_ENV` = `leonardo` or `iota_rp2040`, `SERIAL_PORT` = the MCU's `/dev/serial/by-id/` path): ```bash ./deploy.sh --compile-only # sync + build on the tower, flash nothing ./deploy.sh # build + flash ./deploy.sh --monitor # ... then watch it boot ``` The build takes the pins from the tower's `hardware.ini` (`[Pins]`) and fails without them. The flashing is `firmware/tools/flash.sh`, run on the tower: an RP2040 is put in its bootloader by the firmware's own `BOOTSEL` command first; a Leonardo uses its 1200-baud touch. **Stop the program first** - it holds the serial port - and restart it after; the new firmware then asks, the host answers, and `install-tower --verify` confirms the firmware holds what `hardware.ini` declares. ## 2. Running detached (`scripts/fgc`) The tower is operated over ssh, so the program has to survive the connection closing. `scripts/fgc` runs it inside a tmux session, which owns the pty: ```bash cd ~/projects/fwt_2a/software scripts/fgc start # starts detached, returns as soon as it is up scripts/fgc status # running? since when? which PID? scripts/fgc attach # the dashboard - Ctrl-b d detaches, leaves it running scripts/fgc stop # SIGTERM -> the same clean teardown as `exit` scripts/fgc peek # snapshot of the pane without attaching ``` The boot service (section 4) runs the program the same way, so all of this works on a tower started at boot too. **`Ctrl-b d` detaches. The dashboard's own `q` key opens an exit prompt** offering `q` (quit the program) or `b` (background - detach and leave it running, the same as `Ctrl-b d`), with `Esc` to cancel. `b` is offered only inside a `scripts/fgc` session; outside one there is nothing to detach into and it is shown dimmed. `scripts/fgc stop` sends SIGTERM and waits up to 120 s; the program handles it with the same shutdown path as the `exit` command (stop capture, close the camera, flush the image queue). Do **not** `kill -9` - an unclean exit leaves the camera wedged for the next run. If it will not stop, attach and look at why. `scripts/fgc start` refuses if a run is already up, and prints the previous run's last output if it died rather than silently discarding it. Choosing **quit** at that prompt while attached ends the tmux session too, so you land straight back in your shell. A run that *fails* is the exception: its pane is kept so you can read why (`remain-on-exit failed`), and `fgc status` shows it until `fgc stop` or the next `fgc start` clears it. A kept pane has **no process behind it** - keys and Ctrl-C do nothing, which reads as a hung ssh session. `Ctrl-b d` still detaches; as a last resort `Enter ~ .` closes the ssh client. MQTT credentials are read from `/etc/fire_gimbal_control/credentials.env` by `fgc` itself, inside the pane, as are the Vimba X paths: a tmux server that already exists hands a new session its own environment, not the caller's. > **Why `_run` waits on the app instead of `exec`ing it.** When the app is itself > the pane's process, tmux never records an exit status for it, so > `remain-on-exit failed` cannot tell success from failure and keeps *every* > finished pane. Interposing a shell that reaps the child and exits with its > status gives tmux a real one. Measured on the device: 3/3 panes kept when > exec'd, 4/4 sessions closed cleanly when parented. The wrapper forwards > SIGTERM/SIGINT/SIGHUP, so `fgc stop` still reaches the app. ## 3. The single-instance lock Two instances would fight over the motor's serial port and the camera. The binary prevents that itself: at startup it takes an exclusive `flock` on - `/run/user//fire_gimbal_control.lock` when that directory exists, else - `/tmp/fire_gimbal_control-.lock` and, if it is already held, exits with status **2** and: ``` Cannot start: another instance is already running (PID 1234); attach to it with: scripts/fgc attach ``` The path is derived from the uid and not from `$XDG_RUNTIME_DIR` on purpose: that variable is set in an interactive ssh shell but not in a non-interactive `ssh host cmd` one, and a lock the two launch routes disagree about would exclude nothing. For the same reason a tower with a boot service needs **lingering** (section 4). Because the check lives in the binary, it covers every route in - `scripts/fgc`, the service, or a bare `./build/fire_gimbal_control`. Nothing needs cleaning up after a crash or a power cut: the kernel releases an `flock` when the holder dies. Use `--no-lock` only to run a second mock-only instance during development, and `--lock-file ` to point at a different lockfile. ## 4. Starting at boot `scripts/install-tower` installs this (it asks first, and shows the unit it will write). The unit is the template `scripts/fire-gimbal-control.service`, and it runs the program **through `scripts/fgc`, inside tmux** - so a tower started at boot still has its dashboard, and `fgc status` / `peek` / `stop` work from any ssh session. ```bash sudo systemctl start|stop|restart fire-gimbal-control systemctl is-enabled fire-gimbal-control # starts at boot? sudo systemctl disable fire-gimbal-control # not any more ``` By hand, fill in `@USER@`, `@REPO@` and `@ARGS@` (normally empty) and: ```bash sudo cp fire-gimbal-control.service /etc/systemd/system/ sudo loginctl enable-linger ggs # NOT optional - see below sudo systemctl daemon-reload sudo systemctl enable fire-gimbal-control ``` **Lingering is required.** Without it `/run/user/` does not exist until someone logs in, so a program started at boot takes its single-instance lock in `/tmp`; a later ssh session looks in `/run/user`, finds nothing, reports "not running", and a second copy could start beside the first. The service passes no startup flags. What the program does at boot is `config.ini` `[Startup]` ([configuration.md](configuration.md#startup--homing-and-the-start-mode)): **`home = true` homes the gimbal unattended at every boot.** That is what a tower in service needs - the firmware keeps no position across a power cycle, so without it the gimbal sits un-homed and captures nothing - but it is a deliberate choice, and the installer asks for it (`startup_home`), as it does for the start mode (`startup_mode`, `manual` or `auto`). **Upgrading a tower from before `[Startup]`.** Its unit still passes `--init` / `--start`, which the program now refuses. Run `scripts/install-tower` on the tower **before its next reboot**: it moves them into `config.ini` (`--init` → `home = true`, `--start` → `mode = auto`) and rewrites the unit without them. `install-tower --verify` fails its `boot service args` check until that is done. | Tower | Service | Starts with | |---|---|---| | 111 | enabled 2026-09-24 | `--init --start`: homes and captures at boot - `[verified]` by a reboot 2026-09-24. **To migrate**: becomes `[Startup] home = true`, `mode = auto` | | 121 | enabled 2026-09-23 | no arguments and no `[Startup]`: starts idle, neither homes nor captures (the development tower) | `[verified]` on tower 121, 2026-09-23: the service came up by itself after a reboot with nobody logged in, with its lock under `/run/user/1000`; `fgc status` and the dashboard worked from a later ssh session; credentials reached the program intact (a password with `$`, a quote and a space); `systemctl stop` shut it down cleanly; and `install-tower --verify` passed after the reboot, USB preconditions included. A tower started this way is in service when: - images accumulate under `[Paths] output_dir` (default `~ggs/.local/share/fire_gimbal_control/images/RGB/`); - CamEvents reach the broker: `mosquitto_sub -h -t 'GGS/FWT//#' -v`. ## 5. Where the log is The program writes a persistent log alongside the self-test and diagnostics reports: ``` ~/.local/share/fire_gimbal_control/logs/fgc_..log ``` One file per run, rotated by size within a run (`.000`, `.001`, …), oldest pruned by count. Bounded at `[Logging] file_keep x file_max_mb`, 200 MB by default, at roughly 5 MB/day in steady state. This exists because a tower runs detached and, in TUI mode, every line otherwise goes to a 500-line on-screen ring buffer that dies with the tmux session. The file is **more verbose than the dashboard** - `info` on screen, `debug` on disk - so it holds detail the pane never showed. ```bash ls -t ~/.local/share/fire_gimbal_control/logs/fgc_*.log | head tail -F "$(ls -t ~/.local/share/fire_gimbal_control/logs/fgc_*.log | head -1)" ``` Use `tail -F`, not `-f`: rotation replaces the file and `-f` would follow the old inode into a file nothing writes to any more. Each file opens with a header naming the build, tower, PID, config and hardware paths and the full command line - repeated after every rotation, so an archive found on its own still says where it came from. What to grep when a tower will not come up: ```bash L=$(ls -t ~/.local/share/fire_gimbal_control/logs/fgc_*.log | head -1) grep -E "hardware:|NEED|firmware config|need config" "$L" # declaration + handshake grep -E "\[WARN |\[ERROR" "$L" # everything it complained about head -8 "$L" # which build, which config ``` > Wire traces (`[Logging] trace` / `--trace`) reach this file too. The serial > trace alone takes it from ~5 MB/day to ~41 MB/day, enough to age out the > startup record within days - turn it on to chase something, off afterwards. ## Troubleshooting Start with `scripts/install-tower --verify` on the tower: it checks most of the list below and says which. | Symptom | Check | |---------|-------| | `No config.ini found` | run from `~/projects/fwt_2a/software`, or `--config` / `$FGC_CONFIG` | | `Config error` naming a key | `build/fgc-config check --config config.ini --hardware /etc/fire_gimbal_control/hardware.ini` | | `Failed to open serial port` | MCU plugged in, `ggs` in `dialout`, `[Serial] device` in `hardware.ini` - a `/dev/serial/by-id/` path, which **changes when the MCU is flashed** | | An axis will not move, `ERR need config` | `hardware.ini` is missing a drivetrain value for it; the log names the key | | Camera opened, but no images and `no frame yet (stream warming up)` for minutes | the stream is stuck; the program restarts it and then resets the camera by itself (known-issues #15), or type `camera reset` | | `No camera found` / Vimba startup error | SDK installed, GenTL path set (`/etc/profile.d/*Vimba*GenTL*.sh` or `VIMBA_CTI_PATH`), `[Camera.rgb] id` in `hardware.ini` | | `MQTT connect failed` (degraded mode) | broker reachable, `credentials.env` readable by `ggs`, firewall | | Runs but no images | in auto mode (`[Startup] mode = auto`, or `mode auto`) and the gimbal homed; `output_dir` writable | | `another instance is already running` (exit 2) | `scripts/fgc status`; the service and a hand-started run both up (section 3) | | `fgc status` says not running, but the program is | the service started it before anyone logged in, without lingering (section 4) | | Program dies when ssh disconnects | started with `run.sh` / `deploy.sh --foreground` instead of `scripts/fgc start` |