Keep each tower's config, scan grid and camera profiles in ~/.config/fire_gimbal_control, out of a deploy's reach

This commit is contained in:
pgdalmeida 2026-10-05 22:02:49 +02:00
parent 9f02b70dae
commit ce6ef3f79a
Signed by: pedro.almeida
GPG Key ID: D4A6C394DF13F1D7
16 changed files with 454 additions and 79 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 |
|---|---|---|---|
| `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
start-up. Full reference: [docs/configuration.md](docs/configuration.md) and
@ -481,7 +481,7 @@ settings the way the real firmware does.
| 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`) |
| `--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`) |

View File

@ -1,8 +1,10 @@
# RGB camera profiles
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`
selects it.
`X` is `X.xml` in the profile directory, and `[Camera.rgb] profile = X` in
`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
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
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
`*.txt` under this directory from its `rsync --delete` — without that, the next
deploy erases them. This README does sync; it is repo documentation.
they exist nowhere in the repo. They live in the tower's own folder,
`~/.config/fire_gimbal_control/`, which a deploy never reaches. A tower set up
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
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.
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`,
and logs every setting that moved.

View File

@ -1,7 +1,11 @@
; Fire Gimbal Control - example configuration.
;
; Copy this file to "config.ini" and edit for your deployment:
; cp config/config.example.ini config.ini
; A tower keeps its config.ini in its own folder, ~/.config/fire_gimbal_control/,
; 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
; offset`, `gimbal heading setup` and `gimbal pitch setup` rewrite the [Motor]
@ -69,7 +73,7 @@ enabled = true
driver = vimba ; only vimba is implemented
id = DEV_0000000000 ; USB DEV_ id, or a GigE IP
label = RGB ; output subdir + CamEvent "cam"
profile_dir = config/camera_profiles/rgb
profile_dir = camera_profiles/rgb
profile = standard ; <profile_dir>/standard.xml
; Clockwise rotation applied to saved images: 0, 90, 180 or 270.
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
; the tower inventing an arc nobody chose.
; 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
; 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
; in mind.
grid_file = config/scan.csv
grid_file = scan.csv
[Paths]
; 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 --clean wipe the remote build dir first (fresh configure)
# ./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 --run --force skip the placeholder-config guard before running
# ./deploy.sh --run --foreground run attached to this terminal instead (debugging)
#
# Everything under config/ (the example, the camera-mode files, scan grids) is
# mirrored on every deploy. The device's own config.ini at the repo root is NOT —
# it holds the live calibration, camera IDs and credentials for that rig, so a
# routine deploy must never clobber it. --push-config opts into overwriting it,
# backing the device copy up to config.ini.bak-<timestamp> first.
# What belongs to one tower - its config.ini (calibration, camera IDs), its scan
# grid, the camera profile XMLs exported on it - lives in the tower's own folder,
# ~/.config/fire_gimbal_control/, outside the project folder this script mirrors
# (scripts/tower-config). A deploy never reaches it. A tower set up before that
# folder existed still has them in the project folder; the excludes below keep
# them safe there until `scripts/tower-config move` is run on it, and every
# deploy says so.
#
# Everything under config/ in this repo (the example config, the example scan
# grids) is mirrored on every deploy. --push-config opts into overwriting the
# config.ini the tower reads with the local one, backing the tower's copy up to
# config.ini.bak-<timestamp> beside it first.
#
# --run starts the app inside a tmux session on the device (scripts/fgc), so it
# survives this ssh connection closing — reattach later with --attach. Only one
@ -94,7 +98,7 @@ if [[ -n "$SESSION_CMD" && "$SESSION_CMD" != "attach" && "$RUN" == "1" ]]; then
fi
if [[ "$PUSH_CONFIG" == "1" && ! -f config.ini ]]; then
echo "error: --push-config given but there is no local config.ini to push" >&2
echo " (config.ini is gitignored; the device keeps its own at \$REMOTE_DIR/config.ini)" >&2
echo " (config.ini is gitignored; the device keeps its own in ~/.config/fire_gimbal_control/)" >&2
exit 1
fi
if [[ "$PUSH_CONFIG" == "1" && -n "$SESSION_CMD" && "$SESSION_CMD" != "attach" ]]; then
@ -191,9 +195,12 @@ echo ">> build id: $GIT_REV"
echo ">> ensuring remote dir exists"
ssh "$REMOTE_HOST" "mkdir -p '$REMOTE_DIR'"
# config.ini, the camera profile files and images/ are device-local (runtime
# config, operator-exported camera settings, captured output) and are
# deliberately NOT mirrored, so a deploy never clobbers them.
# A tower's own files live in ~/.config/fire_gimbal_control/, out of reach of
# this rsync. The config.ini and camera profile excludes below are for a tower
# not moved there yet, which still keeps them in the project folder: they are
# device-local (runtime config, operator-exported camera settings) and must not
# be clobbered before `scripts/tower-config move` runs. config.ini.moved-* is
# what that move leaves behind. images/ is captured output.
#
# hardware.ini is excluded for the same reason but one step further: it normally
# lives in /etc/fire_gimbal_control/, out of reach of this rsync entirely, and
@ -212,20 +219,6 @@ ssh "$REMOTE_HOST" "mkdir -p '$REMOTE_DIR'"
# build*/ covers every local build tree, not just build/. They are machine-specific
# and the device regenerates its own build/ remotely, so shipping build-tui/ etc.
# just pushed tens of MB of x86 object files the device can never use.
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"
rsync -avz --delete \
--exclude 'build*/' \
@ -236,8 +229,9 @@ rsync -avz --delete \
--exclude '__pycache__' \
--exclude '.deploy.env' \
--exclude 'hardware.ini' \
"${CONFIG_EXCLUDES[@]}" \
--exclude 'config.ini' \
--exclude 'config.ini.bak-*' \
--exclude 'config.ini.moved-*' \
--exclude 'config/camera_profiles/**/*.xml' \
--exclude 'config/camera_profiles/**/*.txt' \
--exclude 'images/' \
@ -248,19 +242,37 @@ rsync -avz --delete \
# shellcheck disable=SC2029
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,
# so the first run finds a config. Never overwrites an existing device config.
# Skipped entirely under --push-config: the rsync above just placed one, and the
# "keeping existing device config.ini" line would be actively misleading there.
# The tower's folder, and the config.ini the program there will load - asked of
# the tower, which searches the same places the program does.
# shellcheck disable=SC2029
tower_config() { ssh "$REMOTE_HOST" "cd '$REMOTE_DIR' && scripts/tower-config $*"; }
TOWER_DIR="$(tower_config dir)"
if [[ "$PUSH_CONFIG" == "1" ]]; then
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
echo ">> ensuring remote config.ini exists"
ssh "$REMOTE_HOST" "cd '$REMOTE_DIR' && \
if [ ! -f config.ini ]; then \
cp config/config.example.ini config.ini && \
echo ' seeded config.ini from config/config.example.ini — edit it on the device'; \
else echo ' keeping existing device config.ini'; fi"
# A new tower gets its folder, with a config.ini from the example. Never
# overwrites: an existing config.ini is kept wherever it is.
echo ">> ensuring the tower has a config.ini"
tower_config seed | sed 's/^>> / /'
TOWER_CFG="$(tower_config where)"
fi
if [[ "$(dirname "$TOWER_CFG")" != "$TOWER_DIR" ]]; then
echo "!! this tower's config.ini is still in the project folder ($TOWER_CFG)." >&2
echo "!! Move it, with its scan grid and camera profiles, out of a deploy's reach:" >&2
echo "!! ssh $REMOTE_HOST 'cd $REMOTE_DIR && scripts/fgc stop && scripts/tower-config move'" >&2
fi
if [[ "$CLEAN" == "1" ]]; then
@ -277,6 +289,20 @@ ssh "$REMOTE_HOST" "$RESOLVE_CMAKE $CHECK_DEPS_CMD cd '$REMOTE_DIR' && \
\"\$CMAKE\" --build build $JOBS_FLAG"
echo ">> build complete: $REMOTE_DIR/build/fire_gimbal_control"
# A pushed config.ini was written on another machine: check that it loads on
# this one, with this tower's hardware.ini layered over it, before anyone runs it.
if [[ "$PUSH_CONFIG" == "1" ]]; then
echo ">> checking the pushed config.ini on the tower"
# shellcheck disable=SC2029
if ! ssh "$REMOTE_HOST" "cd '$REMOTE_DIR' && hw=/etc/fire_gimbal_control/hardware.ini && \
if [ -f \$hw ]; then build/fgc-config check --config '$TOWER_CFG' --hardware \$hw; \
else build/fgc-config check --config '$TOWER_CFG'; fi"; then
echo "!! the pushed config.ini does not load on the tower; the previous one is" >&2
echo "!! $TOWER_CFG.bak-<timestamp>" >&2
exit 1
fi
fi
# A boot service installed before [Startup] existed still starts the program
# with --init/--start, which this build refuses: the tower would not come up at
# its next boot. Only the installer can fix that (it moves them into config.ini
@ -306,7 +332,7 @@ if [[ "$RUN" == "1" ]]; then
echo ">> checking device config.ini for unedited placeholders"
# shellcheck disable=SC2029
if ! ssh "$REMOTE_HOST" "
cfg='$REMOTE_DIR/config.ini'
cfg='$TOWER_CFG'
[ -f \"\$cfg\" ] || { echo \"error: \$cfg missing on device\" >&2; exit 2; }
# Read an INI value (last assignment wins), strip inline ';' comment + ws, lowercase.
ini_val() { grep -iE \"^[[:space:]]*\$1[[:space:]]*=\" \"\$cfg\" | tail -1 \

View File

@ -112,5 +112,5 @@ homed this power cycle.
build/ CMake build output (gitignored)
include/fgc/, src/ headers and implementations (see modules-reference.md)
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
2. `$FGC_CONFIG` environment variable
3. `./config.ini` (current directory)
4. `<executable dir>/config.ini`
5. `$XDG_CONFIG_HOME/fire_gimbal_control/config.ini` (else `~/.config/...`)
3. `$XDG_CONFIG_HOME/fire_gimbal_control/config.ini` (else `~/.config/...`) - **the tower folder**
4. `./config.ini` (current directory)
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
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
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
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
empty) and the program logs why and leaves auto-sweep off, showing the reason in the TUI's scan-grid

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/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/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` |
| `~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 images: they exist only on the tower, and `rsync --delete` once erased a
freshly exported profile before they were excluded.
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@<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

View File

@ -214,7 +214,7 @@ config at all, because **none of those values trip the placeholder guard in
`DEV_0000000000`.
```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]`
@ -671,7 +671,7 @@ a 32-byte buffer).
## 8. Camera profiles `[verified]`
**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.
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.
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
// 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 = "");
// 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,
// readable file wins. Returns nullopt if none is found.
std::optional<std::string> resolveConfigPath(const std::string& cliArg = "");

View File

@ -120,8 +120,8 @@ int main(int argc, char* argv[]) {
if (!cfg_path) {
std::cerr << "No config.ini found. Searched:\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, "
"or set $FGC_CONFIG.\n";
std::cerr << "Run scripts/tower-config seed to create one in "
<< paths::towerConfigDir() << ", pass --config, or set $FGC_CONFIG.\n";
return 1;
}
@ -198,6 +198,10 @@ int main(int argc, char* argv[]) {
}
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
// 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:
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
... --camera DEV_xxx pick a camera explicitly
@ -153,7 +156,9 @@ def apply_feature(cam, name, value, dry_run):
def main():
ap = argparse.ArgumentParser()
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")
ap.add_argument("--camera", default=None, help="camera id (default: the first real one)")
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
# 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.
# -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
# inside the pane; run.sh finds the binary, one place only.
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"
CRED="$ETC/credentials.env"
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}"
LOGDIR="${XDG_DATA_HOME:-$HOME/.local/share}/fire_gimbal_control/logs"
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)}
usbfs_memory_mb : $S_USBFS
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")
service : ${S_SVC_ENABLED:-absent} / ${S_SVC_ACTIVE:-inactive}, linger ${S_LINGER}
PlatformIO : ${S_PIO:-not installed}
@ -451,6 +456,9 @@ verify() {
else
check "config files load" 1 "fgc-config or config.ini missing"
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)"
check "hardware.ini complete" "$([[ $todo -eq 0 ]] && echo 0 || echo 1)" "$todo TODO(s)"
local now; now="$(firmware_report)"
@ -692,6 +700,16 @@ for f in "${HW_FILLS[@]}"; do # a TODO answered now counts too
done
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.
declare -a CFG_SETS=()
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
# 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)"
if [[ $cams == none || -z $cams ]]; then plan profiles skip "no camera declared"
else
@ -1007,10 +1036,21 @@ if [[ ${DO[credentials]:-} == create ]]; then
say "wrote $CRED"; done_step credentials
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
hr config.ini
args=(); for s in "${CFG_SETS[@]}"; do args+=(--set "$s"); done
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" \
"${args[@]}" \
--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 ]] || {
python3 -m venv "$HOME/.venvs/vmbpy" &&
"$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
( 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)"
done
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

@ -68,6 +68,12 @@ std::string executableDir() {
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> paths;
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)
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
if (std::string dir = executableDir(); !dir.empty())
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;
}
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::error_code ec;
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");
}
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") {
unsetenv("FGC_HARDWARE");
auto p = paths::hardwareSearchPaths("", "/opt/fgc/config.ini");