fwt_software/docs/deployment.md

321 lines
16 KiB
Markdown

# 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` |
| `.../software/config.ini` | what the tower DOES: scan, capture, logging, calibration | the installer (seeded once), `gimbal ... setup` (calibration), 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 |
| `.../software/config/camera_profiles/rgb/*.xml` | the camera settings, exported ON the tower from its camera | the installer, or `scripts/export_camera_profile.py` |
`deploy.sh` never touches `config.ini`, `hardware.ini`, the camera profiles or
the images: they exist only on the tower, and `rsync --delete` once erased a
freshly exported profile before they were excluded.
## 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@<tower> 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@<tower> '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/<uid>/fire_gimbal_control.lock` when that directory exists, else
- `/tmp/fire_gimbal_control-<uid>.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 <path>` 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/<uid>` 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 <broker> -t 'GGS/FWT/<tower>/#' -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_<YYYYMMDD-HHMMSS>.<NNN>.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` |