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:
parent
9f02b70dae
commit
ce6ef3f79a
|
|
@ -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`) |
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
104
deploy.sh
|
|
@ -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 \
|
||||||
|
|
|
||||||
|
|
@ -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/)
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 = "");
|
||||||
|
|
|
||||||
8
main.cpp
8
main.cpp
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue