Compare commits

...

4 Commits

17 changed files with 504 additions and 89 deletions

View File

@ -88,7 +88,7 @@ Everything that makes one tower different from another is in two files:
| File | Where | What goes in it | Who writes it | | File | Where | What goes in it | Who writes it |
|---|---|---|---| |---|---|---|---|
| `hardware.ini` | `/etc/fire_gimbal_control/` | **What the tower IS** - the board, which axes and sensors are fitted, how the motor board is wired, each motor's currents, travel and speeds, the camera's id | the installer, once; a person when the hardware changes | | `hardware.ini` | `/etc/fire_gimbal_control/` | **What the tower IS** - the board, which axes and sensors are fitted, how the motor board is wired, each motor's currents, travel and speeds, the camera's id | the installer, once; a person when the hardware changes |
| `config.ini` | beside the program | **What the tower DOES** - its name, the scan grid, capture settings, the broker, logging, and calibration such as which way is north | the installer (a first copy), the calibration commands, a person | | `config.ini` | `~/.config/fire_gimbal_control/`, with the scan grid and camera profiles | **What the tower DOES** - its name, the scan grid, capture settings, the broker, logging, and calibration such as which way is north | the installer (a first copy), the calibration commands, a person |
Where both set the same thing, `hardware.ini` wins, and the program says so at Where both set the same thing, `hardware.ini` wins, and the program says so at
start-up. Full reference: [docs/configuration.md](docs/configuration.md) and start-up. Full reference: [docs/configuration.md](docs/configuration.md) and
@ -481,7 +481,7 @@ settings the way the real firmware does.
| Flag | Effect | | Flag | Effect |
|---|---| |---|---|
| `-c, --config <path>` | use this `config.ini` (otherwise `$FGC_CONFIG`, then `./config.ini`, ...) | | `-c, --config <path>` | use this `config.ini` (otherwise `$FGC_CONFIG`, then `~/.config/fire_gimbal_control/config.ini`, then `./config.ini`, ...) |
| `--hardware <path>` | use this `hardware.ini` (otherwise `$FGC_HARDWARE`, then `/etc/fire_gimbal_control/hardware.ini`, then beside `config.ini`) | | `--hardware <path>` | use this `hardware.ini` (otherwise `$FGC_HARDWARE`, then `/etc/fire_gimbal_control/hardware.ini`, then beside `config.ini`) |
| `--test <selection>` | run one self-test and exit: 0 PASS, 4 FAIL, 5 could not start | | `--test <selection>` | run one self-test and exit: 0 PASS, 4 FAIL, 5 could not start |
| `--tui` / `--no-tui` | the dashboard / the plain console (overrides `[UI] enable_tui`) | | `--tui` / `--no-tui` | the dashboard / the plain console (overrides `[UI] enable_tui`) |

View File

@ -1,8 +1,10 @@
# RGB camera profiles # RGB camera profiles
A profile is a **settings XML exported from the VimbaX Viewer**. A profile named A profile is a **settings XML exported from the VimbaX Viewer**. A profile named
`X` is `X.xml` in this directory, and `[Camera.rgb] profile = X` in `config.ini` `X` is `X.xml` in the profile directory, and `[Camera.rgb] profile = X` in
selects it. `config.ini` selects it. On a tower that directory is
`~/.config/fire_gimbal_control/camera_profiles/rgb/` (`[Camera.rgb] profile_dir`,
relative to `config.ini`), not this one: this README is all that lives here.
The program hands the XML straight to the Vimba X SDK (`LoadSettings`). Nothing The program hands the XML straight to the Vimba X SDK (`LoadSettings`). Nothing
else writes camera settings, and there is no fallback — a missing `X.xml` is a else writes camera settings, and there is no fallback — a missing `X.xml` is a
@ -16,9 +18,12 @@ combinations as you configure; that is the guardrail this design relies on.
## These files live on the device only ## These files live on the device only
The XMLs are exported on the LattePanda, because that is where the camera is, so The XMLs are exported on the LattePanda, because that is where the camera is, so
they exist nowhere in the repo. `deploy.sh` therefore excludes `*.xml` and they exist nowhere in the repo. They live in the tower's own folder,
`*.txt` under this directory from its `rsync --delete` — without that, the next `~/.config/fire_gimbal_control/`, which a deploy never reaches. A tower set up
deploy erases them. This README does sync; it is repo documentation. before that folder existed still has them in this directory; `deploy.sh`
excludes `*.xml` and `*.txt` here from its `rsync --delete` until
`scripts/tower-config move` has moved them. This README does sync; it is repo
documentation.
Keep a copy somewhere durable if a profile represents real tuning effort: a Keep a copy somewhere durable if a profile represents real tuning effort: a
reflash or a rebuilt device starts with an empty directory. reflash or a rebuilt device starts with an empty directory.
@ -27,7 +32,7 @@ reflash or a rebuilt device starts with an empty directory.
1. Open the VimbaX Viewer and connect to the camera. 1. Open the VimbaX Viewer and connect to the camera.
2. Configure it. The Viewer will not let you pick an invalid combination. 2. Configure it. The Viewer will not let you pick an invalid combination.
3. Save the settings XML over `<profile>.xml` here. 3. Save the settings XML over `<profile>.xml` in the profile directory.
4. Restart. The program notices the file changed, regenerates `<profile>.txt`, 4. Restart. The program notices the file changed, regenerates `<profile>.txt`,
and logs every setting that moved. and logs every setting that moved.

View File

@ -1,7 +1,11 @@
; Fire Gimbal Control - example configuration. ; Fire Gimbal Control - example configuration.
; ;
; Copy this file to "config.ini" and edit for your deployment: ; A tower keeps its config.ini in its own folder, ~/.config/fire_gimbal_control/,
; cp config/config.example.ini config.ini ; together with its scan grid and camera profiles. That folder is outside the
; project folder, so a deploy never overwrites or deletes anything in it.
; Create it from this file with:
; scripts/tower-config seed
; The paths below (grid_file, profile_dir) are relative to that folder.
; ;
; WARNING: do not edit config.ini while the program is running. `gimbal heading ; WARNING: do not edit config.ini while the program is running. `gimbal heading
; offset`, `gimbal heading setup` and `gimbal pitch setup` rewrite the [Motor] ; offset`, `gimbal heading setup` and `gimbal pitch setup` rewrite the [Motor]
@ -69,7 +73,7 @@ enabled = true
driver = vimba ; only vimba is implemented driver = vimba ; only vimba is implemented
id = DEV_0000000000 ; USB DEV_ id, or a GigE IP id = DEV_0000000000 ; USB DEV_ id, or a GigE IP
label = RGB ; output subdir + CamEvent "cam" label = RGB ; output subdir + CamEvent "cam"
profile_dir = config/camera_profiles/rgb profile_dir = camera_profiles/rgb
profile = standard ; <profile_dir>/standard.xml profile = standard ; <profile_dir>/standard.xml
; Clockwise rotation applied to saved images: 0, 90, 180 or 270. ; Clockwise rotation applied to saved images: 0, 90, 180 or 270.
rotation_deg = 0 rotation_deg = 0
@ -239,12 +243,12 @@ homing_timeout_s = 65
; leave it blank and auto-sweep stays off with a message saying so, rather than ; leave it blank and auto-sweep stays off with a message saying so, rather than
; the tower inventing an arc nobody chose. ; the tower inventing an arc nobody chose.
; A relative path resolves against the directory of the config file it is ; A relative path resolves against the directory of the config file it is
; written in (config.ini sits at the repo root, so: config/scan.csv). ; written in (on a tower ~/.config/fire_gimbal_control/, so: scan.csv there).
; The sweep always runs front to back, then returns to the first waypoint and ; The sweep always runs front to back, then returns to the first waypoint and
; repeats, so every pass captures the waypoints in the same order. The return ; repeats, so every pass captures the waypoints in the same order. The return
; leg is one long slew (the yaw arc cannot wrap), so order the grid with that ; leg is one long slew (the yaw arc cannot wrap), so order the grid with that
; in mind. ; in mind.
grid_file = config/scan.csv grid_file = scan.csv
[Paths] [Paths]
; Directory for saved .jxl images. Supports leading ~ and $ENV expansion. ; Directory for saved .jxl images. Supports leading ~ and $ENV expansion.

104
deploy.sh
View File

@ -14,18 +14,22 @@
# ./deploy.sh --stop shut the running app down cleanly # ./deploy.sh --stop shut the running app down cleanly
# ./deploy.sh --clean wipe the remote build dir first (fresh configure) # ./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 --push-config ALSO overwrite the device's config.ini with the local one
#
# NOTE: camera profile XMLs under config/camera_profiles/ are device-local and
# are never synced or deleted - export them on the device with the VimbaX Viewer.
# ./deploy.sh --check-deps only check the remote build dependencies # ./deploy.sh --check-deps only check the remote build dependencies
# ./deploy.sh --run --force skip the placeholder-config guard before running # ./deploy.sh --run --force skip the placeholder-config guard before running
# ./deploy.sh --run --foreground run attached to this terminal instead (debugging) # ./deploy.sh --run --foreground run attached to this terminal instead (debugging)
# #
# Everything under config/ (the example, the camera-mode files, scan grids) is # What belongs to one tower - its config.ini (calibration, camera IDs), its scan
# mirrored on every deploy. The device's own config.ini at the repo root is NOT — # grid, the camera profile XMLs exported on it - lives in the tower's own folder,
# it holds the live calibration, camera IDs and credentials for that rig, so a # ~/.config/fire_gimbal_control/, outside the project folder this script mirrors
# routine deploy must never clobber it. --push-config opts into overwriting it, # (scripts/tower-config). A deploy never reaches it. A tower set up before that
# backing the device copy up to config.ini.bak-<timestamp> first. # 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-<timestamp> beside it first.
# #
# --run starts the app inside a tmux session on the device (scripts/fgc), so it # --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 # survives this ssh connection closing — reattach later with --attach. Only one
@ -94,7 +98,7 @@ if [[ -n "$SESSION_CMD" && "$SESSION_CMD" != "attach" && "$RUN" == "1" ]]; then
fi fi
if [[ "$PUSH_CONFIG" == "1" && ! -f config.ini ]]; then if [[ "$PUSH_CONFIG" == "1" && ! -f config.ini ]]; then
echo "error: --push-config given but there is no local config.ini to push" >&2 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 at \$REMOTE_DIR/config.ini)" >&2 echo " (config.ini is gitignored; the device keeps its own in ~/.config/fire_gimbal_control/)" >&2
exit 1 exit 1
fi fi
if [[ "$PUSH_CONFIG" == "1" && -n "$SESSION_CMD" && "$SESSION_CMD" != "attach" ]]; then if [[ "$PUSH_CONFIG" == "1" && -n "$SESSION_CMD" && "$SESSION_CMD" != "attach" ]]; then
@ -191,9 +195,12 @@ echo ">> build id: $GIT_REV"
echo ">> ensuring remote dir exists" echo ">> ensuring remote dir exists"
ssh "$REMOTE_HOST" "mkdir -p '$REMOTE_DIR'" ssh "$REMOTE_HOST" "mkdir -p '$REMOTE_DIR'"
# config.ini, the camera profile files and images/ are device-local (runtime # A tower's own files live in ~/.config/fire_gimbal_control/, out of reach of
# config, operator-exported camera settings, captured output) and are # this rsync. The config.ini and camera profile excludes below are for a tower
# deliberately NOT mirrored, so a deploy never clobbers them. # 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 # 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 # lives in /etc/fire_gimbal_control/, out of reach of this rsync entirely, and
@ -212,20 +219,6 @@ ssh "$REMOTE_HOST" "mkdir -p '$REMOTE_DIR'"
# build*/ covers every local build tree, not just build/. They are machine-specific # 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. # 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. # just pushed tens of MB of x86 object files the device can never use.
CONFIG_EXCLUDES=(--exclude 'config.ini')
if [[ "$PUSH_CONFIG" == "1" ]]; then
# The device config holds that rig's calibration, camera IDs and credentials -
# things that exist nowhere else. Keep a copy before overwriting it.
echo ">> backing up the device config.ini before overwriting it"
ssh "$REMOTE_HOST" "cd '$REMOTE_DIR' && \
if [ -f config.ini ]; then \
cp -p config.ini \"config.ini.bak-\$(date +%Y%m%d-%H%M%S)\" && \
echo ' saved as config.ini.bak-<timestamp>'; \
else echo ' no existing device config.ini to back up'; fi"
CONFIG_EXCLUDES=()
echo ">> pushing local config.ini (overwrites the device's)"
fi
echo ">> syncing to $REMOTE_HOST:$REMOTE_DIR" echo ">> syncing to $REMOTE_HOST:$REMOTE_DIR"
rsync -avz --delete \ rsync -avz --delete \
--exclude 'build*/' \ --exclude 'build*/' \
@ -236,8 +229,9 @@ rsync -avz --delete \
--exclude '__pycache__' \ --exclude '__pycache__' \
--exclude '.deploy.env' \ --exclude '.deploy.env' \
--exclude 'hardware.ini' \ --exclude 'hardware.ini' \
"${CONFIG_EXCLUDES[@]}" \ --exclude 'config.ini' \
--exclude 'config.ini.bak-*' \ --exclude 'config.ini.bak-*' \
--exclude 'config.ini.moved-*' \
--exclude 'config/camera_profiles/**/*.xml' \ --exclude 'config/camera_profiles/**/*.xml' \
--exclude 'config/camera_profiles/**/*.txt' \ --exclude 'config/camera_profiles/**/*.txt' \
--exclude 'images/' \ --exclude 'images/' \
@ -248,19 +242,37 @@ rsync -avz --delete \
# shellcheck disable=SC2029 # shellcheck disable=SC2029
ssh "$REMOTE_HOST" "printf '%s\\n' '$GIT_REV' > '$REMOTE_DIR/.revision'" 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, # The tower's folder, and the config.ini the program there will load - asked of
# so the first run finds a config. Never overwrites an existing device config. # the tower, which searches the same places the program does.
# Skipped entirely under --push-config: the rsync above just placed one, and the # shellcheck disable=SC2029
# "keeping existing device config.ini" line would be actively misleading there. tower_config() { ssh "$REMOTE_HOST" "cd '$REMOTE_DIR' && scripts/tower-config $*"; }
TOWER_DIR="$(tower_config dir)"
if [[ "$PUSH_CONFIG" == "1" ]]; then if [[ "$PUSH_CONFIG" == "1" ]]; then
echo ">> device config.ini is the one just pushed (previous copy: config.ini.bak-*)" # 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-<timestamp> 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 else
echo ">> ensuring remote config.ini exists" # A new tower gets its folder, with a config.ini from the example. Never
ssh "$REMOTE_HOST" "cd '$REMOTE_DIR' && \ # overwrites: an existing config.ini is kept wherever it is.
if [ ! -f config.ini ]; then \ echo ">> ensuring the tower has a config.ini"
cp config/config.example.ini config.ini && \ tower_config seed | sed 's/^>> / /'
echo ' seeded config.ini from config/config.example.ini — edit it on the device'; \ TOWER_CFG="$(tower_config where)"
else echo ' keeping existing device config.ini'; fi" 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 fi
if [[ "$CLEAN" == "1" ]]; then if [[ "$CLEAN" == "1" ]]; then
@ -277,6 +289,20 @@ ssh "$REMOTE_HOST" "$RESOLVE_CMAKE $CHECK_DEPS_CMD cd '$REMOTE_DIR' && \
\"\$CMAKE\" --build build $JOBS_FLAG" \"\$CMAKE\" --build build $JOBS_FLAG"
echo ">> build complete: $REMOTE_DIR/build/fire_gimbal_control" 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-<timestamp>" >&2
exit 1
fi
fi
# A boot service installed before [Startup] existed still starts the program # 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 # 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 # its next boot. Only the installer can fix that (it moves them into config.ini
@ -306,7 +332,7 @@ if [[ "$RUN" == "1" ]]; then
echo ">> checking device config.ini for unedited placeholders" echo ">> checking device config.ini for unedited placeholders"
# shellcheck disable=SC2029 # shellcheck disable=SC2029
if ! ssh "$REMOTE_HOST" " if ! ssh "$REMOTE_HOST" "
cfg='$REMOTE_DIR/config.ini' cfg='$TOWER_CFG'
[ -f \"\$cfg\" ] || { echo \"error: \$cfg missing on device\" >&2; exit 2; } [ -f \"\$cfg\" ] || { echo \"error: \$cfg missing on device\" >&2; exit 2; }
# Read an INI value (last assignment wins), strip inline ';' comment + ws, lowercase. # Read an INI value (last assignment wins), strip inline ';' comment + ws, lowercase.
ini_val() { grep -iE \"^[[:space:]]*\$1[[:space:]]*=\" \"\$cfg\" | tail -1 \ ini_val() { grep -iE \"^[[:space:]]*\$1[[:space:]]*=\" \"\$cfg\" | tail -1 \

View File

@ -112,5 +112,5 @@ homed this power cycle.
build/ CMake build output (gitignored) build/ CMake build output (gitignored)
include/fgc/, src/ headers and implementations (see modules-reference.md) include/fgc/, src/ headers and implementations (see modules-reference.md)
tests/ unit tests tests/ unit tests
config/config.example.ini template (copy to config.ini) config/config.example.ini template (scripts/tower-config seed copies it to ~/.config/fire_gimbal_control/)
``` ```

View File

@ -9,16 +9,42 @@ There are no hardcoded paths. The file is resolved in this order ([src/core/Path
1. `--config <path>` CLI flag 1. `--config <path>` CLI flag
2. `$FGC_CONFIG` environment variable 2. `$FGC_CONFIG` environment variable
3. `./config.ini` (current directory) 3. `$XDG_CONFIG_HOME/fire_gimbal_control/config.ini` (else `~/.config/...`) - **the tower folder**
4. `<executable dir>/config.ini` 4. `./config.ini` (current directory)
5. `$XDG_CONFIG_HOME/fire_gimbal_control/config.ini` (else `~/.config/...`) 5. `<executable dir>/config.ini`
If none exist, the program prints every location it searched and exits. Start from the template: If none exist, the program prints every location it searched and exits.
### The tower folder
Everything that belongs to one tower and is changed on it lives in one folder,
`~/.config/fire_gimbal_control/`:
| File | What |
|---|---|
| `config.ini` | what the tower does, and its calibration |
| `scan.csv` (or whatever `[Scan] grid_file` names) | the scan grid chosen for this tower's view |
| `camera_profiles/rgb/*.xml`, `*.txt` | the camera profiles exported on this tower |
The folder is outside the project folder, which is what a deploy (`rsync --delete`) and an update
replace, so neither can overwrite or delete anything in it. `hardware.ini`, which describes the
assembly and is not changed, stays in `/etc/fire_gimbal_control/`. Relative paths in `config.ini`
(`grid_file`, `profile_dir`) resolve from the folder `config.ini` is in.
[scripts/tower-config](../scripts/tower-config) manages it:
```bash ```bash
cp config/config.example.ini config.ini scripts/tower-config seed # a new tower: config.ini, scan.csv and camera_profiles/rgb from the examples
scripts/tower-config where # the config.ini the program will load
scripts/tower-config move --dry-run # a tower set up before the folder existed: what would move
scripts/tower-config move # ... and move it (stop the program first)
``` ```
Places 4 and 5 are the old ones: a tower not moved yet still has `config.ini` in the project
folder, and the program loads it from there and warns at every start. A development checkout may
keep its own `./config.ini` there too; the program only warns on a machine with
`/etc/fire_gimbal_control/hardware.ini`.
## `config.ini` keys ## `config.ini` keys
Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config.cpp)) into a typed Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config.cpp)) into a typed
@ -216,7 +242,8 @@ the tower can measure it. Yaw is untouched by it.
The capture **scan grid** is the ordered `(yaw,pitch)` waypoints auto-sweep visits, always front to The capture **scan grid** is the ordered `(yaw,pitch)` waypoints auto-sweep visits, always front to
back, then round to the first again — never in reverse. `[Scan] grid_file` points at an editable CSV back, then round to the first again — never in reverse. `[Scan] grid_file` points at an editable CSV
([config/scan.csv](../config/scan.csv)) and is the **only** source of waypoints. in the tower folder (`scan.csv`, first copied from the example [config/scan.csv](../config/scan.csv))
and is the **only** source of waypoints.
**Nothing is generated.** Leave `grid_file` blank (or name a file that is missing, malformed or **Nothing is generated.** Leave `grid_file` blank (or name a file that is missing, malformed or
empty) and the program logs why and leaves auto-sweep off, showing the reason in the TUI's scan-grid empty) and the program logs why and leaves auto-sweep off, showing the reason in the TUI's scan-grid
@ -722,7 +749,8 @@ It is modular (`test gimbal`, `test imu drift`, `test host`, …) and driven by
**profiles** configured under `[Test]` / `[TestProfile.<name>]` in `config.ini` **profiles** configured under `[Test]` / `[TestProfile.<name>]` in `config.ini`
(repetition counts, sampling windows, pass thresholds; default `standard`). While a (repetition counts, sampling windows, pass thresholds; default `standard`). While a
test (or calibration, or homing) runs, interfering commands are ignored and **Esc** test (or calibration, or homing) runs, interfering commands are ignored and **Esc**
cancels. Full reference: [test-command.md](test-command.md). Replaces the former cancels. A stop is not ignored: `exit`, Ctrl-C or SIGTERM (`scripts/fgc stop`) during homing
cancels the homing, as Esc does, and shuts the program down. Full reference: [test-command.md](test-command.md). Replaces the former
`gimbal diag` (now folded into `test gimbal encoder`). `gimbal diag` (now folded into `test gimbal encoder`).
## Terminal dashboard (TUI) ## Terminal dashboard (TUI)

View File

@ -16,16 +16,42 @@ between them; what differs lives in each tower's `hardware.ini`.
|---|---|---| |---|---|---|
| `~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/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/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 | | `~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/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/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 | | `/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/.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` | | `~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` |
`deploy.sh` never touches `config.ini`, `hardware.ini`, the camera profiles or The rule: what a tower changes lives in `~/.config/fire_gimbal_control/`, what
the images: they exist only on the tower, and `rsync --delete` once erased a it never changes in `/etc/fire_gimbal_control/`, and the project folder holds
freshly exported profile before they were excluded. 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@<tower>
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-<timestamp>`, 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 ## 1. Updating the program from the dev box
@ -146,8 +172,11 @@ it is shown dimmed.
`scripts/fgc stop` sends SIGTERM and waits up to 120 s; the program handles it `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 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, flush the image queue). A stop while the gimbal is homing cancels the
camera wedged for the next run. If it will not stop, attach and look at why. homing rather than waiting for it - a full home takes minutes on some towers,
longer than this wait - so the gimbal is left un-homed, and the next start homes
it again. 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 `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. run's last output if it died rather than silently discarding it.

View File

@ -214,7 +214,7 @@ config at all, because **none of those values trip the placeholder guard in
`DEV_0000000000`. `DEV_0000000000`.
```bash ```bash
ssh ggs@<tower-ip> 'grep -E "tower_name|zkms_server_ip|^id" ~/projects/fwt_2a/software/config.ini' ssh ggs@<tower-ip> 'grep -E "tower_name|zkms_server_ip|^id" ~/.config/fire_gimbal_control/config.ini'
``` ```
## 3. Host preconditions and build dependencies `[verified]` ## 3. Host preconditions and build dependencies `[verified]`
@ -671,7 +671,7 @@ a 32-byte buffer).
## 8. Camera profiles `[verified]` ## 8. Camera profiles `[verified]`
**Without a profile the program does not start** - `no camera profiles (*.xml) **Without a profile the program does not start** - `no camera profiles (*.xml)
in config/camera_profiles/rgb` is a startup error, by design. A fresh tower has in ~/.config/fire_gimbal_control/camera_profiles/rgb` is a startup error, by design. A fresh tower has
an empty profile directory, so this step is not optional. an empty profile directory, so this step is not optional.
The documented way to make one is the VimbaX Viewer, which is a GUI - awkward on The documented way to make one is the VimbaX Viewer, which is a GUI - awkward on

View File

@ -14,10 +14,28 @@ std::string expandUser(const std::string& path);
// Returns empty string if it cannot be determined. // Returns empty string if it cannot be determined.
std::string executableDir(); std::string executableDir();
// The folder that holds what belongs to ONE tower and is changed on it:
// config.ini, the scan grid, the camera profile XMLs
// ($XDG_CONFIG_HOME/fire_gimbal_control, else ~/.config/fire_gimbal_control).
// It is outside the deployed project folder, so a deploy or an update never
// overwrites or deletes anything in it. Empty if it cannot be determined.
std::string towerConfigDir();
// The ordered list of locations searched for a config file, given an optional // The ordered list of locations searched for a config file, given an optional
// explicit --config argument. Exposed so callers can report what was tried. // explicit --config argument. Exposed so callers can report what was tried.
//
// --config <path> explicit
// $FGC_CONFIG the environment
// <towerConfigDir()>/config.ini the normal answer on a tower
// ./config.ini the old place, and a development checkout
// <dir of the executable>/config.ini
std::vector<std::string> configSearchPaths(const std::string& cliArg = ""); std::vector<std::string> configSearchPaths(const std::string& cliArg = "");
// True when this is a tower (it has /etc/fire_gimbal_control/hardware.ini) and
// the config was found by searching, but outside towerConfigDir(): a tower whose
// files have not been moved yet (scripts/tower-config move).
bool configInOldPlace(const std::string& cliArg, const std::string& configPath);
// Resolve the config file path using configSearchPaths(): the first existing, // Resolve the config file path using configSearchPaths(): the first existing,
// readable file wins. Returns nullopt if none is found. // readable file wins. Returns nullopt if none is found.
std::optional<std::string> resolveConfigPath(const std::string& cliArg = ""); std::optional<std::string> resolveConfigPath(const std::string& cliArg = "");

View File

@ -120,8 +120,8 @@ int main(int argc, char* argv[]) {
if (!cfg_path) { if (!cfg_path) {
std::cerr << "No config.ini found. Searched:\n"; std::cerr << "No config.ini found. Searched:\n";
for (const auto& p : paths::configSearchPaths(cli_cfg)) std::cerr << " - " << p << "\n"; for (const auto& p : paths::configSearchPaths(cli_cfg)) std::cerr << " - " << p << "\n";
std::cerr << "Copy config/config.example.ini to one of these, pass --config, " std::cerr << "Run scripts/tower-config seed to create one in "
"or set $FGC_CONFIG.\n"; << paths::towerConfigDir() << ", pass --config, or set $FGC_CONFIG.\n";
return 1; return 1;
} }
@ -198,6 +198,10 @@ int main(int argc, char* argv[]) {
} }
LOG_INFO << "Loaded config: " << *cfg_path; LOG_INFO << "Loaded config: " << *cfg_path;
if (paths::configInOldPlace(cli_cfg, *cfg_path))
LOG_WARN << "this tower's config.ini is still in the project folder, where a deploy "
"can reach it: run scripts/tower-config move to put it in "
<< paths::towerConfigDir();
// Only one instance may run: a second would fight the first over the motor's // Only one instance may run: a second would fight the first over the motor's
// serial port, the camera, and the exposure store. The lock is held for the // serial port, the camera, and the exposure store. The lock is held for the

View File

@ -20,7 +20,10 @@ anything the camera refused is reported rather than silently kept.
Usage, on the tower: Usage, on the tower:
GENICAM_GENTL64_PATH=/opt/VimbaX/cti ~/.venvs/vmbpy/bin/python \ GENICAM_GENTL64_PATH=/opt/VimbaX/cti ~/.venvs/vmbpy/bin/python \
scripts/export_camera_profile.py --profile standard --out config/camera_profiles/rgb scripts/export_camera_profile.py --profile standard
It writes into the tower's own folder, ~/.config/fire_gimbal_control/
camera_profiles/rgb, where a deploy cannot reach it; --out picks another.
... --dry-run report what would change, write nothing ... --dry-run report what would change, write nothing
... --camera DEV_xxx pick a camera explicitly ... --camera DEV_xxx pick a camera explicitly
@ -153,7 +156,9 @@ def apply_feature(cam, name, value, dry_run):
def main(): def main():
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("--profile", default="standard", choices=sorted(RECIPES)) ap.add_argument("--profile", default="standard", choices=sorted(RECIPES))
ap.add_argument("--out", default="config/camera_profiles/rgb", ap.add_argument("--out", default=os.path.join(
os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config"),
"fire_gimbal_control", "camera_profiles", "rgb"),
help="directory to write <profile>.xml into") help="directory to write <profile>.xml into")
ap.add_argument("--camera", default=None, help="camera id (default: the first real one)") ap.add_argument("--camera", default=None, help="camera id (default: the first real one)")
ap.add_argument("--dry-run", action="store_true") ap.add_argument("--dry-run", action="store_true")

View File

@ -152,7 +152,8 @@ cmd_start() {
# It is a *window* option, and a pane that dies immediately takes the window # It is a *window* option, and a pane that dies immediately takes the window
# with it before the option can be set — so create the session on a # with it before the option can be set — so create the session on a
# placeholder, set the option, then respawn the pane with the real command. # placeholder, set the option, then respawn the pane with the real command.
# -c: the program resolves ./config.ini and the output dir from the cwd. The # -c: a tower not moved to ~/.config/fire_gimbal_control yet still has its
# config.ini here, which the program finds as ./config.ini. The
# pane runs this script's hidden `_run`, so the Vimba environment is resolved # pane runs this script's hidden `_run`, so the Vimba environment is resolved
# inside the pane; run.sh finds the binary, one place only. # inside the pane; run.sh finds the binary, one place only.
tmux new-session -d -s "$session" -c "$repo" 'sleep 86400' tmux new-session -d -s "$session" -c "$repo" 'sleep 86400'

View File

@ -42,7 +42,12 @@ ETC="${INSTALL_TOWER_ETC:-/etc/fire_gimbal_control}"
HW="$ETC/hardware.ini" HW="$ETC/hardware.ini"
CRED="$ETC/credentials.env" CRED="$ETC/credentials.env"
INSTALL_LOG="$ETC/install.log" INSTALL_LOG="$ETC/install.log"
CFG="${INSTALL_TOWER_CONFIG:-$REPO/config.ini}" # config.ini lives in the tower's own folder, outside the project folder a deploy
# replaces (scripts/tower-config). A tower set up before that folder existed
# still has it in the project folder: found there, and moved by the plan below.
TOWER_CONFIG="$REPO/scripts/tower-config"
TOWER_DIR="$("$TOWER_CONFIG" dir)"
CFG="${INSTALL_TOWER_CONFIG:-$("$TOWER_CONFIG" where || echo "$TOWER_DIR/config.ini")}"
UNIT="${INSTALL_TOWER_UNIT:-/etc/systemd/system/fire-gimbal-control.service}" UNIT="${INSTALL_TOWER_UNIT:-/etc/systemd/system/fire-gimbal-control.service}"
LOGDIR="${XDG_DATA_HOME:-$HOME/.local/share}/fire_gimbal_control/logs" LOGDIR="${XDG_DATA_HOME:-$HOME/.local/share}/fire_gimbal_control/logs"
BIN="$REPO/build/fire_gimbal_control" BIN="$REPO/build/fire_gimbal_control"
@ -375,7 +380,7 @@ running firmware : ${S_FW_NOW:-unknown}
camera : ${S_CAM:-not found}${S_LPM:+ (LPM $(cat "$S_LPM") at $S_LPM)} camera : ${S_CAM:-not found}${S_LPM:+ (LPM $(cat "$S_LPM") at $S_LPM)}
usbfs_memory_mb : $S_USBFS usbfs_memory_mb : $S_USBFS
hardware.ini : $([[ -r $HW ]] && echo "present, $(hw_todos | grep -c . || true) TODO(s)" || echo absent) 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 '?'), [Startup] home $(ini_get "$CFG" Startup home 2>/dev/null || echo unset), mode $(ini_get "$CFG" Startup mode 2>/dev/null || echo unset)" || echo absent) config.ini : $([[ -r $CFG ]] && echo "$CFG, tower_name $(ini_get "$CFG" General tower_name 2>/dev/null || echo '?'), [Startup] home $(ini_get "$CFG" Startup home 2>/dev/null || echo unset), mode $(ini_get "$CFG" Startup mode 2>/dev/null || echo unset)" || echo absent)
app : $([[ $S_RUNNING -eq 1 ]] && echo "running (${S_ARGS:-no arguments})" || echo "not running") 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} service : ${S_SVC_ENABLED:-absent} / ${S_SVC_ACTIVE:-inactive}, linger ${S_LINGER}
PlatformIO : ${S_PIO:-not installed} PlatformIO : ${S_PIO:-not installed}
@ -451,6 +456,9 @@ verify() {
else else
check "config files load" 1 "fgc-config or config.ini missing" check "config files load" 1 "fgc-config or config.ini missing"
fi fi
check "tower files" "$([[ $(dirname "$CFG") == "$TOWER_DIR" ]] && echo 0 || echo 1)" \
"$([[ $(dirname "$CFG") == "$TOWER_DIR" ]] && echo "in $TOWER_DIR" ||
echo "config.ini is in the project folder, where a deploy reaches: scripts/tower-config move")"
local todo; todo="$(hw_todos | grep -c . || true)" local todo; todo="$(hw_todos | grep -c . || true)"
check "hardware.ini complete" "$([[ $todo -eq 0 ]] && echo 0 || echo 1)" "$todo TODO(s)" check "hardware.ini complete" "$([[ $todo -eq 0 ]] && echo 0 || echo 1)" "$todo TODO(s)"
local now; now="$(firmware_report)" local now; now="$(firmware_report)"
@ -692,6 +700,16 @@ for f in "${HW_FILLS[@]}"; do # a TODO answered now counts too
done done
declared_axes="$(ini_get "$HW_VIEW" Hardware axes 2>/dev/null | tr -d ' ' || echo yaw)" declared_axes="$(ini_get "$HW_VIEW" Hardware axes 2>/dev/null | tr -d ' ' || echo yaw)"
# The tower's own files: a tower set up before ~/.config/fire_gimbal_control
# existed keeps config.ini, its scan grid and its camera profiles in the project
# folder, where a deploy can overwrite or delete them. Moved first, so every step
# after works on the moved copy. Not when --config-like overrides point elsewhere.
if [[ -z ${INSTALL_TOWER_CONFIG:-} && -r $CFG && $(dirname "$CFG") != "$TOWER_DIR" ]]; then
plan "tower files" change "move config.ini, its scan grid and camera profiles to $TOWER_DIR"
else
plan "tower files" skip "$([[ -r $CFG ]] && echo "in $(dirname "$CFG")" || echo "new tower: created with config.ini")"
fi
# config.ini: what this tower DOES. # config.ini: what this tower DOES.
declare -a CFG_SETS=() declare -a CFG_SETS=()
CFG_SEED=0 CRED_WRITE=0 CFG_SEED=0 CRED_WRITE=0
@ -808,7 +826,18 @@ if [[ -n $S_PIO ]] && "$S_PIO" --version >/dev/null 2>&1; then plan platformio s
else plan platformio change "install PlatformIO Core into ~/.platformio"; fi else plan platformio change "install PlatformIO Core into ~/.platformio"; fi
# Camera profiles: the program will not start a real camera without them. # Camera profiles: the program will not start a real camera without them.
PROFDIR="$REPO/config/camera_profiles/rgb" # Where the program looks for them: [Camera.rgb] profile_dir, relative to the
# folder config.ini is in - the tower folder for a new tower.
prof_dir() {
local pd; pd="$(ini_get "$CFG" Camera.rgb profile_dir 2>/dev/null || true)"
case "$pd" in
"") echo "$TOWER_DIR/camera_profiles/rgb" ;;
/*) echo "$pd" ;;
\~*) echo "$HOME${pd#\~}" ;;
*) echo "$(dirname "$CFG")/$pd" ;;
esac
}
PROFDIR="$(prof_dir)"
cams="$(ini_get "$HW_VIEW" Hardware cameras 2>/dev/null || echo none)" cams="$(ini_get "$HW_VIEW" Hardware cameras 2>/dev/null || echo none)"
if [[ $cams == none || -z $cams ]]; then plan profiles skip "no camera declared" if [[ $cams == none || -z $cams ]]; then plan profiles skip "no camera declared"
else else
@ -1007,10 +1036,21 @@ if [[ ${DO[credentials]:-} == create ]]; then
say "wrote $CRED"; done_step credentials say "wrote $CRED"; done_step credentials
fi fi
if [[ ${DO[tower files]} == change ]]; then
hr "tower files"
stop_app
"$TOWER_CONFIG" move
CFG="$TOWER_DIR/config.ini"
done_step "tower files"
fi
if [[ ${DO[config.ini]} != skip ]]; then if [[ ${DO[config.ini]} != skip ]]; then
hr config.ini hr config.ini
args=(); for s in "${CFG_SETS[@]}"; do args+=(--set "$s"); done args=(); for s in "${CFG_SETS[@]}"; do args+=(--set "$s"); done
if [[ $CFG_SEED -eq 1 ]]; then if [[ $CFG_SEED -eq 1 ]]; then
# The folder, the example scan grid and an empty camera_profiles/rgb;
# the config.ini itself is written next, from the answers.
[[ -n ${INSTALL_TOWER_CONFIG:-} ]] || "$TOWER_CONFIG" seed --no-config
"$FGC_CONFIG" seed --example "$REPO/config/config.example.ini" --out "$CFG" --hardware "$HW" \ "$FGC_CONFIG" seed --example "$REPO/config/config.example.ini" --out "$CFG" --hardware "$HW" \
"${args[@]}" \ "${args[@]}" \
--drop "Network.mqtt_user=comes from $CRED (FGC_MQTT_USER)" \ --drop "Network.mqtt_user=comes from $CRED (FGC_MQTT_USER)" \
@ -1041,9 +1081,11 @@ if [[ ${DO[profiles]} == change ]]; then
if { [[ -x $HOME/.venvs/vmbpy/bin/python ]] || { if { [[ -x $HOME/.venvs/vmbpy/bin/python ]] || {
python3 -m venv "$HOME/.venvs/vmbpy" && python3 -m venv "$HOME/.venvs/vmbpy" &&
"$HOME/.venvs/vmbpy/bin/pip" install -q /opt/VimbaX/api/python/vmbpy-*.whl; }; }; then "$HOME/.venvs/vmbpy/bin/pip" install -q /opt/VimbaX/api/python/vmbpy-*.whl; }; }; then
PROFDIR="$(prof_dir)" # the config may have moved since the plan was made
mkdir -p "$PROFDIR"
for prof in "${PROF_MISSING[@]}"; do for prof in "${PROF_MISSING[@]}"; do
( cd "$REPO" && GENICAM_GENTL64_PATH=/opt/VimbaX/cti "$HOME/.venvs/vmbpy/bin/python" \ ( cd "$REPO" && GENICAM_GENTL64_PATH=/opt/VimbaX/cti "$HOME/.venvs/vmbpy/bin/python" \
scripts/export_camera_profile.py --profile "$prof" ) || scripts/export_camera_profile.py --profile "$prof" --out "$PROFDIR" ) ||
warn "could not export the $prof profile; export it later (docs/tower-installation.md section 8)" warn "could not export the $prof profile; export it later (docs/tower-installation.md section 8)"
done done
done_step profiles done_step profiles

176
scripts/tower-config Executable file
View File

@ -0,0 +1,176 @@
#!/usr/bin/env bash
#
# tower-config - the folder that holds what belongs to ONE tower: its config.ini,
# its scan grid and its camera profile XMLs. Run it ON the tower, as the user the
# program runs as (ggs).
#
# scripts/tower-config dir print the folder
# (~/.config/fire_gimbal_control)
# scripts/tower-config where print the config.ini the program will
# load; exit 1 if there is none
# scripts/tower-config seed [--no-config]
# a new tower: create the folder with a
# config.ini from the example, the example
# scan grid and an empty camera_profiles/rgb.
# Never overwrites; --no-config leaves the
# config.ini out (install-tower writes its own)
# scripts/tower-config move [--dry-run] one time, on a tower set up before this
# folder existed: move config.ini, the scan
# grid and the camera profiles out of the
# project folder and into it
#
# Why a folder of its own: the project folder is what a deploy (rsync --delete)
# and an update replace. Anything the tower changes - its calibration in
# config.ini, the scan grid chosen for its view, the profiles exported from its
# camera - must live where neither can reach it. hardware.ini, which describes
# the assembly and is never changed, stays in /etc/fire_gimbal_control/.
#
# `move` copies config.ini into the folder and rewrites the paths in it that
# pointed into the project folder (grid_file, each camera's profile_dir), with
# fgc-config, so the result is checked to load before it is written. The old
# config.ini is then renamed config.ini.moved-<timestamp>, so nobody edits a copy
# the program no longer reads. The old scan grid and profiles stay where they
# were. It refuses while the program is running: the program writes config.ini
# itself, and would write to the old copy.
#
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="$(cd "$here/.." && pwd)"
DIR="${XDG_CONFIG_HOME:-$HOME/.config}/fire_gimbal_control"
OLD="$REPO/config.ini"
HW=/etc/fire_gimbal_control/hardware.ini
FGC_CONFIG_BIN="$REPO/build/fgc-config"
say() { printf '>> %s\n' "$*"; }
die() { printf 'tower-config: %s\n' "$*" >&2; exit 1; }
usage() { awk 'NR>2 && /^#/ {sub(/^# ?/, ""); print; next} NR>2 {exit}' "${BASH_SOURCE[0]}"; }
# The same order the program searches, minus --config (src/core/Paths.cpp).
cmd_where() {
local c
for c in "${FGC_CONFIG:-}" "$DIR/config.ini" "$OLD"; do
[[ -n $c && -f $c ]] && { echo "$c"; return 0; }
done
return 1
}
cmd_seed() {
local with_config=1
[[ ${1:-} == --no-config ]] && with_config=0
if [[ $with_config -eq 1 ]]; then
local have
# An existing tower, wherever its config.ini is, is left alone: one not
# moved yet must not get a second scan grid that `move` would trip over.
if have="$(cmd_where)"; then
say "keeping the existing $have"
return 0
fi
mkdir -p "$DIR"
cp "$REPO/config/config.example.ini" "$DIR/config.ini"
say "created $DIR/config.ini from the example - edit it for this tower"
fi
mkdir -p "$DIR/camera_profiles/rgb"
if [[ -e $DIR/scan.csv ]]; then
say "keeping the existing $DIR/scan.csv"
else
cp "$REPO/config/scan.csv" "$DIR/scan.csv"
say "created $DIR/scan.csv from the example grid - replace it with this tower's"
fi
}
# A path from config.ini that pointed into the project folder, as it reads from
# the tower folder: the same path without its leading "config/".
moved_path() { local p="$1"; echo "${p#config/}"; }
is_relative() { [[ -n $1 && $1 != /* && $1 != \~* && $1 != \$* ]]; }
cmd_move() {
local dry=0
[[ ${1:-} == --dry-run ]] && dry=1
if [[ -f $DIR/config.ini ]]; then
if [[ -f $OLD ]]; then
die "$DIR/config.ini already exists, and so does $OLD.
The program reads $DIR/config.ini; the copy in the project folder is unused.
Compare the two, keep what is right in $DIR/config.ini, then rename the other."
fi
say "already moved: the program reads $DIR/config.ini"
return 0
fi
[[ -f $OLD ]] || die "there is no $OLD to move (a new tower: scripts/tower-config seed)"
[[ -x $FGC_CONFIG_BIN ]] || die "no $FGC_CONFIG_BIN - build first (./deploy.sh does)"
if [[ -f $REPO/hardware.ini && ! -f $HW ]]; then
die "this tower's hardware.ini is in the project folder ($REPO/hardware.ini).
Put it in /etc first, where it belongs:
sudo install -D -m 0644 $REPO/hardware.ini $HW"
fi
if [[ $dry -eq 0 ]] && "$here/fgc" status >/dev/null 2>&1; then
die "the program is running and writes config.ini itself. Stop it first:
scripts/fgc stop"
fi
# What has to come along, and what config.ini will say afterwards.
local -a sets=() copies=()
local grid; grid="$("$FGC_CONFIG_BIN" get "$OLD" Scan.grid_file 2>/dev/null || true)"
if is_relative "$grid"; then
[[ -f $REPO/$grid ]] || die "[Scan] grid_file = $grid, but $REPO/$grid does not exist. Fix config.ini first."
copies+=("$REPO/$grid|$DIR/$(moved_path "$grid")")
[[ $(moved_path "$grid") != "$grid" ]] && sets+=("Scan.grid_file=$(moved_path "$grid")")
fi
local cam
while read -r cam; do
[[ -n $cam ]] || continue
local pd; pd="$("$FGC_CONFIG_BIN" get "$OLD" "Camera.$cam.profile_dir" 2>/dev/null || true)"
is_relative "$pd" || continue
local f
for f in "$REPO/$pd"/*.xml "$REPO/$pd"/*.txt; do
[[ -f $f ]] && copies+=("$f|$DIR/$(moved_path "$pd")/$(basename "$f")")
done
[[ $(moved_path "$pd") != "$pd" ]] && sets+=("Camera.$cam.profile_dir=$(moved_path "$pd")")
done < <(sed -nE 's/^[[:space:]]*\[Camera\.([^]]+)\][[:space:]]*$/\1/p' "$OLD")
say "moving this tower's files to $DIR"
echo " $OLD -> $DIR/config.ini"
local c
for c in ${copies[@]+"${copies[@]}"}; do echo " ${c%%|*} -> ${c#*|}"; done
for c in ${sets[@]+"${sets[@]}"}; do echo " config.ini: set ${c%%=*} = ${c#*=}"; done
echo " $OLD renamed to config.ini.moved-<timestamp>"
[[ $dry -eq 1 ]] && { say "dry run: nothing changed"; return 0; }
for c in ${copies[@]+"${copies[@]}"}; do
local src="${c%%|*}" dst="${c#*|}"
if [[ -e $dst ]] && ! cmp -s "$src" "$dst"; then
die "$dst already exists and differs from $src. Nothing was moved; sort that out first."
fi
done
mkdir -p "$DIR"
for c in ${copies[@]+"${copies[@]}"}; do
mkdir -p "$(dirname "${c#*|}")"
cp -p "${c%%|*}" "${c#*|}"
done
cp -p "$OLD" "$DIR/config.ini"
if (( ${#sets[@]} )); then
local -a args=() hw_args=()
for c in "${sets[@]}"; do args+=(--set "$c"); done
[[ -f $HW ]] && hw_args=(--hardware "$HW")
if ! "$FGC_CONFIG_BIN" set "$DIR/config.ini" --config "$DIR/config.ini" ${hw_args[@]+"${hw_args[@]}"} \
"${args[@]}" --provenance "$(date -u +%Y-%m-%dT%H:%MZ), tower-config move"; then
rm -f "$DIR/config.ini"
die "the moved config.ini would not load; nothing was changed in $REPO"
fi
fi
local moved; moved="config.ini.moved-$(date +%Y%m%d-%H%M%S)"
mv "$OLD" "$REPO/$moved"
say "done. The program now reads $DIR/config.ini"
echo " the old copy is $REPO/$moved; the old scan grid and profiles are left where they were"
}
sub="${1:-}"; shift || true
case "$sub" in
dir) echo "$DIR" ;;
where) cmd_where ;;
seed) cmd_seed "$@" ;;
move) cmd_move "$@" ;;
""|-h|--help|help) usage; [[ -z $sub ]] && exit 1 || exit 0 ;;
*) die "unknown command: $sub (try 'tower-config --help')" ;;
esac

View File

@ -84,8 +84,18 @@ constexpr long kEncoderQuantumCounts = 13;
// (StopContinuousImageAcquisition + Close). Without this, a Ctrl-C / kill / systemd // (StopContinuousImageAcquisition + Close). Without this, a Ctrl-C / kill / systemd
// stop skips teardown and leaves the camera acquiring - which wedges it so the NEXT // stop skips teardown and leaves the camera acquiring - which wedges it so the NEXT
// run gets no frames. Only an atomic flag is set here (async-signal-safe). // run gets no frames. Only an atomic flag is set here (async-signal-safe).
//
// Installed as the very first thing run() does, so a stop that arrives while the
// camera opens or the gimbal homes is not the default action - which kills the
// process on the spot and wedges the camera. A second signal of the same kind
// goes back to that default: Ctrl-C twice still gets out of a start that hangs.
std::atomic<bool> g_shutdown_requested{false}; std::atomic<bool> g_shutdown_requested{false};
void handleShutdownSignal(int) { g_shutdown_requested.store(true); } void handleShutdownSignal(int sig) {
if (g_shutdown_requested.exchange(true)) {
std::signal(sig, SIG_DFL);
std::raise(sig);
}
}
long long nowEpochMs() { long long nowEpochMs() {
return std::chrono::duration_cast<std::chrono::milliseconds>( return std::chrono::duration_cast<std::chrono::milliseconds>(
@ -1195,6 +1205,12 @@ struct Application::Impl {
} }
} }
// A stop that came in while the program was starting up: do not begin a
// home that would only be cancelled.
if (shutdownRequested()) {
LOG_INFO << "stop requested: not homing";
return;
}
motor->sendCommand("HOME"); // homes all axes; firmware runs it non-blocking motor->sendCommand("HOME"); // homes all axes; firmware runs it non-blocking
auto allReady = [](const MotorTelemetry& t) { auto allReady = [](const MotorTelemetry& t) {
@ -2494,12 +2510,28 @@ struct Application::Impl {
Command c = parseCommand(line); Command c = parseCommand(line);
if (c.verb == "cancel" || (c.verb == "mode" && c.device == "manual")) if (c.verb == "cancel" || (c.verb == "mode" && c.device == "manual"))
cancel_procedure_ = true; cancel_procedure_ = true;
else if (c.verb == "exit")
running = false; // not an interfering command: a stop
else if (!c.empty()) else if (!c.empty())
LOG_WARN << "ignored '" << c.verb << "': homing in progress (Esc to cancel)"; LOG_WARN << "ignored '" << c.verb << "': homing in progress (Esc to cancel)";
local.pop(); local.pop();
} }
// A stop cancels homing, as Esc does, rather than waiting for it to finish:
// a full home takes minutes on some towers - longer than `fgc stop` (120 s)
// and systemd (150 s) wait before giving up or killing the program outright,
// which wedges the camera. Dropping the stop instead, as this once did, left
// the program running with its dashboard already closed.
if (shutdownRequested() && !cancel_procedure_) {
LOG_INFO << "stop requested: cancelling homing to shut down";
cancel_procedure_ = true;
}
} }
// A stop asked for in any of the ways there are: the `exit` command - which is
// also what the dashboard sends when it closes, on Ctrl-C and SIGTERM too - or
// SIGINT/SIGTERM caught by handleShutdownSignal() when there is no dashboard.
bool shutdownRequested() const { return !running || g_shutdown_requested.load(); }
// ---- `test` command ---- // ---- `test` command ----
static std::string lowerStr(std::string s) { static std::string lowerStr(std::string s) {
for (char& ch : s) ch = static_cast<char>(std::tolower((unsigned char)ch)); for (char& ch : s) ch = static_cast<char>(std::tolower((unsigned char)ch));
@ -2977,6 +3009,14 @@ struct Application::Impl {
int run() { int run() {
start_time = std::chrono::steady_clock::now(); start_time = std::chrono::steady_clock::now();
// Clean up on Ctrl-C / kill / systemd stop, not just on "exit"/Ctrl-D - so the
// camera is always stopped and closed (an unclean exit wedges it for next time).
// First, before anything slow: homing alone takes minutes on some towers.
// The TUI's own handler takes over while the dashboard runs (it turns the
// signal into "exit") and puts this one back when the dashboard closes.
std::signal(SIGINT, handleShutdownSignal);
std::signal(SIGTERM, handleShutdownSignal);
// Log level: config first, CLI overrides. // Log level: config first, CLI overrides.
if (!cfg.logging.level.empty() && !Logger::setLevelFromString(cfg.logging.level)) if (!cfg.logging.level.empty() && !Logger::setLevelFromString(cfg.logging.level))
LOG_WARN << "unknown Logging.level '" << cfg.logging.level << "', keeping default"; LOG_WARN << "unknown Logging.level '" << cfg.logging.level << "', keeping default";
@ -3162,12 +3202,8 @@ struct Application::Impl {
sendAnchorConfig(); sendAnchorConfig();
applyHomedTravel(); applyHomedTravel();
} }
if (start_in != Mode::Manual) setMode(start_in); // A stop that came in during homing: do not start sweeping on the way out.
if (start_in != Mode::Manual && !shutdownRequested()) setMode(start_in);
// Clean up on Ctrl-C / kill / systemd stop, not just on "exit"/Ctrl-D - so the
// camera is always stopped and closed (an unclean exit wedges it for next time).
std::signal(SIGINT, handleShutdownSignal);
std::signal(SIGTERM, handleShutdownSignal);
LOG_INFO << "Entering control loop (type 'exit' to quit, or Ctrl-C)"; LOG_INFO << "Entering control loop (type 'exit' to quit, or Ctrl-C)";
oneshot_since_ = std::chrono::steady_clock::now(); oneshot_since_ = std::chrono::steady_clock::now();

View File

@ -68,6 +68,12 @@ std::string executableDir() {
return self.parent_path().string(); return self.parent_path().string();
} }
std::string towerConfigDir() {
std::string xdg = envOr("XDG_CONFIG_HOME", expandUser("~/.config"));
if (xdg.empty()) return {};
return (fs::path(xdg) / "fire_gimbal_control").string();
}
std::vector<std::string> configSearchPaths(const std::string& cliArg) { std::vector<std::string> configSearchPaths(const std::string& cliArg) {
std::vector<std::string> paths; std::vector<std::string> paths;
if (!cliArg.empty()) paths.push_back(expandUser(cliArg)); if (!cliArg.empty()) paths.push_back(expandUser(cliArg));
@ -75,18 +81,30 @@ std::vector<std::string> configSearchPaths(const std::string& cliArg) {
if (const char* env = std::getenv("FGC_CONFIG"); env && *env) if (const char* env = std::getenv("FGC_CONFIG"); env && *env)
paths.push_back(expandUser(env)); paths.push_back(expandUser(env));
if (std::string dir = towerConfigDir(); !dir.empty())
paths.push_back((fs::path(dir) / "config.ini").string());
// The old places: a tower whose files have not been moved yet, and a
// development checkout with its own config.ini.
paths.push_back("config.ini"); // current working directory paths.push_back("config.ini"); // current working directory
if (std::string dir = executableDir(); !dir.empty()) if (std::string dir = executableDir(); !dir.empty())
paths.push_back((fs::path(dir) / "config.ini").string()); paths.push_back((fs::path(dir) / "config.ini").string());
std::string xdg = envOr("XDG_CONFIG_HOME", expandUser("~/.config"));
if (!xdg.empty())
paths.push_back((fs::path(xdg) / "fire_gimbal_control" / "config.ini").string());
return paths; return paths;
} }
bool configInOldPlace(const std::string& cliArg, const std::string& configPath) {
if (!cliArg.empty()) return false;
if (const char* env = std::getenv("FGC_CONFIG"); env && *env) return false;
std::error_code ec;
if (!fs::is_regular_file("/etc/fire_gimbal_control/hardware.ini", ec)) return false;
const std::string dir = towerConfigDir();
if (dir.empty()) return false;
return fs::absolute(fs::path(configPath), ec).parent_path().lexically_normal() !=
fs::path(dir).lexically_normal();
}
std::optional<std::string> resolveConfigPath(const std::string& cliArg) { std::optional<std::string> resolveConfigPath(const std::string& cliArg) {
std::error_code ec; std::error_code ec;
for (const auto& p : configSearchPaths(cliArg)) { for (const auto& p : configSearchPaths(cliArg)) {

View File

@ -29,6 +29,29 @@ TEST_CASE("configSearchPaths honours the CLI argument first") {
CHECK(p.front() == "/explicit/config.ini"); CHECK(p.front() == "/explicit/config.ini");
} }
TEST_CASE("the tower folder is searched before the old places") {
unsetenv("FGC_CONFIG");
setenv("XDG_CONFIG_HOME", "/home/tester/.config", 1);
auto p = paths::configSearchPaths("");
unsetenv("XDG_CONFIG_HOME");
// A copy left in the project folder - where a deploy can reach it - must
// never win over the tower's own.
REQUIRE(p.size() >= 2);
CHECK(p[0] == "/home/tester/.config/fire_gimbal_control/config.ini");
CHECK(p[1] == "config.ini");
}
TEST_CASE("towerConfigDir falls back to ~/.config") {
unsetenv("XDG_CONFIG_HOME");
setenv("HOME", "/home/tester", 1);
CHECK(paths::towerConfigDir() == "/home/tester/.config/fire_gimbal_control");
}
TEST_CASE("an explicit config is never reported as being in the old place") {
CHECK_FALSE(paths::configInOldPlace("/somewhere/config.ini", "/somewhere/config.ini"));
}
TEST_CASE("hardwareSearchPaths looks in the documented order") { TEST_CASE("hardwareSearchPaths looks in the documented order") {
unsetenv("FGC_HARDWARE"); unsetenv("FGC_HARDWARE");
auto p = paths::hardwareSearchPaths("", "/opt/fgc/config.ini"); auto p = paths::hardwareSearchPaths("", "/opt/fgc/config.ini");