From ce6ef3f79a868bdc8b20df6de16f32e05a1db9be Mon Sep 17 00:00:00 2001 From: pgdalmeida Date: Mon, 5 Oct 2026 22:02:49 +0200 Subject: [PATCH] Keep each tower's config, scan grid and camera profiles in ~/.config/fire_gimbal_control, out of a deploy's reach --- README.md | 4 +- config/camera_profiles/rgb/README.md | 17 ++- config/config.example.ini | 14 ++- deploy.sh | 104 ++++++++++------ docs/build-and-setup.md | 2 +- docs/configuration.md | 39 +++++- docs/deployment.md | 36 +++++- docs/tower-installation.md | 4 +- include/fgc/Paths.h | 18 +++ main.cpp | 8 +- scripts/export_camera_profile.py | 9 +- scripts/fgc | 3 +- scripts/install-tower | 50 +++++++- scripts/tower-config | 176 +++++++++++++++++++++++++++ src/core/Paths.cpp | 26 +++- tests/test_paths.cpp | 23 ++++ 16 files changed, 454 insertions(+), 79 deletions(-) create mode 100755 scripts/tower-config diff --git a/README.md b/README.md index 49a4c9f..aeb50c7 100644 --- a/README.md +++ b/README.md @@ -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 ` | use this `config.ini` (otherwise `$FGC_CONFIG`, then `./config.ini`, ...) | +| `-c, --config ` | use this `config.ini` (otherwise `$FGC_CONFIG`, then `~/.config/fire_gimbal_control/config.ini`, then `./config.ini`, ...) | | `--hardware ` | use this `hardware.ini` (otherwise `$FGC_HARDWARE`, then `/etc/fire_gimbal_control/hardware.ini`, then beside `config.ini`) | | `--test ` | 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`) | diff --git a/config/camera_profiles/rgb/README.md b/config/camera_profiles/rgb/README.md index 85ff890..3bfbf5e 100644 --- a/config/camera_profiles/rgb/README.md +++ b/config/camera_profiles/rgb/README.md @@ -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 `.xml` here. +3. Save the settings XML over `.xml` in the profile directory. 4. Restart. The program notices the file changed, regenerates `.txt`, and logs every setting that moved. diff --git a/config/config.example.ini b/config/config.example.ini index 0aaf9ce..7694c13 100644 --- a/config/config.example.ini +++ b/config/config.example.ini @@ -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 ; /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. diff --git a/deploy.sh b/deploy.sh index 5c349f8..27cf72d 100755 --- a/deploy.sh +++ b/deploy.sh @@ -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- 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- 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-'; \ - 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- 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-" >&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 \ diff --git a/docs/build-and-setup.md b/docs/build-and-setup.md index ac0eae4..ee79f14 100644 --- a/docs/build-and-setup.md +++ b/docs/build-and-setup.md @@ -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/) ``` diff --git a/docs/configuration.md b/docs/configuration.md index a4ad64e..fae768c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -9,16 +9,42 @@ There are no hardcoded paths. The file is resolved in this order ([src/core/Path 1. `--config ` CLI flag 2. `$FGC_CONFIG` environment variable -3. `./config.ini` (current directory) -4. `/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. `/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 diff --git a/docs/deployment.md b/docs/deployment.md index a993875..6816f8e 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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@ +cd ~/projects/fwt_2a/software +scripts/tower-config move --dry-run # what moves where, and what config.ini will say +scripts/fgc stop # the program writes config.ini itself +scripts/tower-config move +scripts/fgc start # or: sudo systemctl start fire-gimbal-control +``` + +It copies `config.ini`, the scan grid `[Scan] grid_file` names and every +camera's profiles into the tower folder, and rewrites those two paths +(`config/scan.csv` becomes `scan.csv`). `fgc-config` checks the result loads +before it is written. The old `config.ini` is renamed +`config.ini.moved-`, and the old grid and profiles stay where they +were. `scripts/install-tower` does the same move as one of its steps. ## 1. Updating the program from the dev box diff --git a/docs/tower-installation.md b/docs/tower-installation.md index 22cf94a..f7f5e47 100644 --- a/docs/tower-installation.md +++ b/docs/tower-installation.md @@ -214,7 +214,7 @@ config at all, because **none of those values trip the placeholder guard in `DEV_0000000000`. ```bash -ssh ggs@ 'grep -E "tower_name|zkms_server_ip|^id" ~/projects/fwt_2a/software/config.ini' +ssh ggs@ '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 diff --git a/include/fgc/Paths.h b/include/fgc/Paths.h index 17891b7..016df99 100644 --- a/include/fgc/Paths.h +++ b/include/fgc/Paths.h @@ -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 explicit +// $FGC_CONFIG the environment +// /config.ini the normal answer on a tower +// ./config.ini the old place, and a development checkout +// /config.ini std::vector 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 resolveConfigPath(const std::string& cliArg = ""); diff --git a/main.cpp b/main.cpp index 8de86b0..ba4a125 100644 --- a/main.cpp +++ b/main.cpp @@ -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 diff --git a/scripts/export_camera_profile.py b/scripts/export_camera_profile.py index 39b1918..6aa495a 100755 --- a/scripts/export_camera_profile.py +++ b/scripts/export_camera_profile.py @@ -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 .xml into") ap.add_argument("--camera", default=None, help="camera id (default: the first real one)") ap.add_argument("--dry-run", action="store_true") diff --git a/scripts/fgc b/scripts/fgc index 720b8e5..262920d 100755 --- a/scripts/fgc +++ b/scripts/fgc @@ -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' diff --git a/scripts/install-tower b/scripts/install-tower index c9f0639..f3f9b5c 100755 --- a/scripts/install-tower +++ b/scripts/install-tower @@ -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 diff --git a/scripts/tower-config b/scripts/tower-config new file mode 100755 index 0000000..1acf0ef --- /dev/null +++ b/scripts/tower-config @@ -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-, 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-" + [[ $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 diff --git a/src/core/Paths.cpp b/src/core/Paths.cpp index af5b815..2c5c743 100644 --- a/src/core/Paths.cpp +++ b/src/core/Paths.cpp @@ -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 configSearchPaths(const std::string& cliArg) { std::vector paths; if (!cliArg.empty()) paths.push_back(expandUser(cliArg)); @@ -75,18 +81,30 @@ std::vector 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 resolveConfigPath(const std::string& cliArg) { std::error_code ec; for (const auto& p : configSearchPaths(cliArg)) { diff --git a/tests/test_paths.cpp b/tests/test_paths.cpp index 8df2ff7..8655667 100644 --- a/tests/test_paths.cpp +++ b/tests/test_paths.cpp @@ -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");