From 4488a2181da002cd0484ff91c8cb01f05db15a17 Mon Sep 17 00:00:00 2001 From: pgdalmeida Date: Wed, 30 Sep 2026 16:42:48 +0200 Subject: [PATCH] Set in config.ini whether the tower homes and which mode it starts in, instead of the --init and --start flags --- .deploy.env.example | 6 +- README.md | 21 ++-- config/config.example.ini | 15 +++ deploy.sh | 43 ++++++-- docs/build-and-setup.md | 11 +- docs/configuration.md | 45 +++++++- docs/deployment.md | 45 ++++++-- docs/mqtt-api.md | 4 +- docs/tower-installation.md | 29 ++++-- include/fgc/Application.h | 7 +- include/fgc/Config.h | 12 +++ include/fgc/OperatingMode.h | 7 +- main.cpp | 34 ++++-- scripts/fire-gimbal-control.service | 7 +- scripts/install-tower | 155 +++++++++++++++++++++------- scripts/run.sh | 9 +- src/camera/VimbaCameraSource.cpp | 2 +- src/core/Application.cpp | 19 ++-- src/core/Config.cpp | 12 +++ src/core/HelpText.cpp | 5 +- tests/CMakeLists.txt | 3 + tests/test_config.cpp | 36 +++++++ 22 files changed, 407 insertions(+), 120 deletions(-) diff --git a/.deploy.env.example b/.deploy.env.example index 70140a1..50935a7 100644 --- a/.deploy.env.example +++ b/.deploy.env.example @@ -7,8 +7,10 @@ REMOTE_DIR=/home/ggs/projects/fwt_2a/software # where the project is rsync' # backends are built in. Drop -DWITH_VIMBA=ON to build mock-camera only. CMAKE_ARGS="-DWITH_MQTT=ON -DWITH_VIMBA=ON" -# Args passed to the binary by `./deploy.sh --run`. -RUN_ARGS="--start" +# Extra args passed to the binary by `./deploy.sh --run` (e.g. --no-mqtt). +# Whether it homes and which mode it starts in are NOT args: they are [Startup] +# home / mode in the device's config.ini. +RUN_ARGS="" # Optional overrides: # BUILD_JOBS=4 # parallel build jobs (default: remote nproc) diff --git a/README.md b/README.md index 2243967..830fb05 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ On a tower the program runs **detached, inside `tmux`**, so it keeps running when you close your ssh session. `scripts/fgc` does that for you: ```bash -scripts/fgc start --init --start # start it: home the gimbal, then sweep +scripts/fgc start # start it; config.ini [Startup] says whether it homes and sweeps scripts/fgc attach # open the dashboard (Ctrl-b d to leave it running) scripts/fgc status # is it running? since when? which PID? scripts/fgc peek # a text snapshot of the dashboard, without attaching @@ -174,10 +174,19 @@ sudo systemctl status fire-gimbal-control # is the service running? sudo systemctl restart fire-gimbal-control # restart it (it homes the gimbal again) ``` -`--init` homes the gimbal at every start, which a tower in service needs: the -motor board forgets its position whenever it loses power. On tower 111 the -service starts it with `--init --start`, so after a power cut it is back to -capturing within about a minute, with nobody touching it. +What the program does as it starts is set in `config.ini`, not by flags: + +```ini +[Startup] +home = true ; home the gimbal at every start +mode = auto ; manual (wait for someone) or auto (sweep at once) +``` + +`home = true` is what a tower in service needs: the motor board forgets its +position whenever it loses power. Tower 111 homes and starts in auto, so after +a power cut it is back to capturing within about a minute, with nobody touching +it. A new tower starts with `mode = manual` until it is proven. See +[configuration.md](docs/configuration.md#startup--homing-and-the-start-mode). **Only one copy can run at a time.** Two would fight over the motor board and the camera, so a second one refuses to start and says which process holds the @@ -473,8 +482,6 @@ settings the way the real firmware does. |---|---| | `-c, --config ` | use this `config.ini` (otherwise `$FGC_CONFIG`, then `./config.ini`, ...) | | `--hardware ` | use this `hardware.ini` (otherwise `$FGC_HARDWARE`, then `/etc/fire_gimbal_control/hardware.ini`, then beside `config.ini`) | -| `-i, --init` | home the gimbal at start | -| `-s, --start` | start sweeping at once | | `--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`) | | `--mock-serial`, `--mock-camera`, `--mock-env` | stand-ins instead of the motor board, camera, ambient sensor | diff --git a/config/config.example.ini b/config/config.example.ini index 9f43e46..55887bc 100644 --- a/config/config.example.ini +++ b/config/config.example.ini @@ -19,6 +19,21 @@ image_interval = 5 ; 1 = print motor telemetry each loop tick, 0 = quiet. debug = 0 +[Startup] +; What the tower does as it comes up - the boot service passes no flags, so this +; is the one place it is decided. +; home: run the endstop-finding home sequence at every start. The firmware keeps +; no position across a power cycle, so without it the axes stay un-homed and +; refuse every move. A tower in service needs true - and then it HOMES +; UNATTENDED at every boot. Absent => false. +home = true +; mode: manual or auto. manual waits and captures nothing until someone chooses +; a mode (key a, or `mode auto` over MQTT); auto starts sweeping the scan grid +; at once. auto is refused, and the tower stays in manual, if the gimbal is not +; homed by then. pan needs a heading and a span, so it is by command only. +; Absent => manual. Choose auto once the tower is proven. +mode = manual + [Network] ; MQTT broker address (the ground-station / ZKMS server). zkms_server_ip = 127.0.0.1 diff --git a/deploy.sh b/deploy.sh index d0364ab..5c349f8 100755 --- a/deploy.sh +++ b/deploy.sh @@ -8,7 +8,6 @@ # Usage: # ./deploy.sh rsync + remote configure + build # ./deploy.sh --run ... then start the app on the device, detached, and return -# ./deploy.sh --run --home ... and run the endstop-finding home sequence at startup # ./deploy.sh --run --attach ... start it, then attach straight away (the usual one) # ./deploy.sh --attach attach to the app already running (no sync, no build) # ./deploy.sh --status is it running on the device? @@ -32,6 +31,11 @@ # survives this ssh connection closing — reattach later with --attach. Only one # instance can run at a time; the binary itself enforces that with a lockfile. # +# Whether the app homes the gimbal and which mode it starts in are NOT flags: +# they are [Startup] home / mode in the device's config.ini, read at every start +# (--run and the boot service alike). To home once without changing that, attach +# and type `gimbal home`. +# # Flags combine, e.g. ./deploy.sh --clean --run # # Config comes from .deploy.env (see .deploy.env.example). @@ -52,13 +56,12 @@ source "$ENV_FILE" : "${REMOTE_DIR:?set REMOTE_DIR in .deploy.env}" CMAKE_ARGS="${CMAKE_ARGS:--DWITH_MQTT=ON -DWITH_VIMBA=ON}" BUILD_JOBS="${BUILD_JOBS:-}" -RUN_ARGS="${RUN_ARGS:---start}" +RUN_ARGS="${RUN_ARGS:-}" CLEAN=0 RUN=0 CHECK_DEPS=0 FORCE=0 -HOME_INIT=0 FOREGROUND=0 PUSH_CONFIG=0 SESSION_CMD="" @@ -67,7 +70,10 @@ for arg in "$@"; do --clean) CLEAN=1 ;; --push-config|--with-config) PUSH_CONFIG=1 ;; --run|--start) RUN=1 ;; - --home|--init) HOME_INIT=1 ;; + --home|--init) + echo "error: $arg was removed: set [Startup] home = true in the device's config.ini," >&2 + echo " or attach and type 'gimbal home' to home once" >&2 + exit 1 ;; --check-deps) CHECK_DEPS=1 ;; --force) FORCE=1 ;; --foreground) FOREGROUND=1 ;; @@ -108,11 +114,17 @@ if [[ -n "$SESSION_CMD" ]] && [[ "$SESSION_CMD" != "attach" || "$RUN" != "1" ]]; "cd '$REMOTE_DIR' && exec scripts/fgc $SESSION_CMD" fi -# Homing is opt-in: append --init only when --home is passed, so a plain run -# brings up the TUI without driving the endstop-finding sequence. -if [[ $HOME_INIT -eq 1 && "$RUN_ARGS" != *--init* ]]; then - RUN_ARGS="--init $RUN_ARGS" -fi +# The program refuses --init/--start now; catch an old .deploy.env here, before +# a sync and a build, rather than at the very end. +for a in $RUN_ARGS; do + case "$a" in + --init|-i|--start|-s) + echo "error: RUN_ARGS in $ENV_FILE has '$a', which the program no longer takes." >&2 + echo " Homing and the start mode are [Startup] home / mode in the device's" >&2 + echo " config.ini: drop it from RUN_ARGS." >&2 + exit 1 ;; + esac +done # Locate cmake on the remote. Non-interactive ssh shells don't source .bashrc, # so allow an explicit REMOTE_CMAKE override in .deploy.env; otherwise search @@ -265,6 +277,19 @@ 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 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 +# and rewrites the unit), so say so loudly rather than touch the unit from here. +# shellcheck disable=SC2029 +if ssh "$REMOTE_HOST" "grep -qE '^ExecStart=.*fgc start.*[[:space:]](--init|--start|-i|-s)([[:space:]]|$)' \ + /etc/systemd/system/fire-gimbal-control.service 2>/dev/null"; then + echo "!! WARNING: the device's boot service still passes --init/--start, which this build" >&2 + echo "!! refuses - the program will NOT start at the next boot. On the device run" >&2 + echo "!! scripts/install-tower" >&2 + echo "!! which moves them into config.ini [Startup] and rewrites the service." >&2 +fi + if [[ "$RUN" == "1" ]]; then # Guard: a freshly seeded config.ini is the example template, with placeholders # that connect nowhere and silently drop to degraded mode. Refuse to launch on diff --git a/docs/build-and-setup.md b/docs/build-and-setup.md index 780ed73..ac0eae4 100644 --- a/docs/build-and-setup.md +++ b/docs/build-and-setup.md @@ -92,11 +92,16 @@ only shows up on the rig. ## Run ```bash -scripts/run.sh --init --start # real deployment: home, then start -scripts/run.sh --start # only if already homed this power cycle -scripts/run.sh --mock-serial --mock-camera --no-mqtt --start # dev, no hardware +scripts/run.sh # real deployment +scripts/run.sh --mock-serial --mock-camera --no-mqtt # dev, no hardware ``` +Whether it homes the gimbal and which mode it starts in come from `config.ini` +`[Startup] home` / `mode` ([configuration.md](configuration.md#startup--homing-and-the-start-mode)), +not from flags. A real deployment has `home = true`: the firmware keeps no position +across a power cycle. Skip homing (`home = false`) only if the gimbal is already +homed this power cycle. + [scripts/run.sh](../scripts/run.sh) locates the binary relative to itself, and [scripts/fire-gimbal-control.service](../scripts/fire-gimbal-control.service) is a systemd unit template (uses `WorkingDirectory` + `FGC_CONFIG`, so no paths are hardcoded in the binary). diff --git a/docs/configuration.md b/docs/configuration.md index 82e4a0f..756b7f5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -29,6 +29,8 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config | `General` | `tower_name` | string | `Unnamed` | Tower identity; used in all MQTT topics/payloads | | `General` | `image_interval` | int > 0 | `5` | Seconds between captures (→ `image_rate = 1/interval`) | | `General` | `debug` | bool | `false` | Start with debug-level logging | +| `Startup` | `home` | bool | `false` | Home the gimbal at every start (see [Startup](#startup--homing-and-the-start-mode)). A tower in service needs `true`, and then homes **unattended** at every boot | +| `Startup` | `mode` | `manual`/`auto` | `manual` | Mode the tower starts in. `auto` is refused, leaving it in manual, if the gimbal is not homed by then. `pan` is by command only | | `Network` | `zkms_server_ip` | string | `127.0.0.1` | MQTT broker host/IP, or a full `tcp://…` URI | | `Network` | `mqtt_port` | int 1..65535 | `1883` | Broker port (`8883` for TLS). Ignored when `zkms_server_ip` carries its own port or a scheme | | `Network` | `mqtt_user` / `mqtt_pw` | string | — | MQTT credentials (see secrets below) | @@ -88,6 +90,38 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config | `Motor.` | `sw_mode` / `has_encoder` | int / bool | — | TMC `SW_MODE` (physical endstop switches on REFL/REFR) and whether this axis's encoder is aligned and usable. **hardware.ini** | | `Scan` | `grid_file` | string | — | CSV of `yaw_deg,pitch_deg` waypoints (`yaw_deg` alone means pitch 0). The only source of a scan grid; empty → auto-sweep disabled | +### `[Startup]` — homing and the start mode + +What the tower does as it comes up. It is in `config.ini`, not in flags, so the +boot service, `deploy.sh --run` and a run by hand all do the same thing, and the +boot service starts the program with no arguments. + +```ini +[Startup] +home = true ; home the gimbal at every start +mode = manual ; manual or auto +``` + +- **`home`** runs the endstop-finding home sequence (see [Init sequence](#init-sequence-startup-home--true)) + before anything else. The firmware keeps no position across a power cycle, so + without it the axes stay in `RESET` and refuse every move; the one case for + `false` is a development tower, where nothing should move by itself. To home + once without changing the file, type `gimbal home` (key `h`). +- **`mode`** is the mode entered once startup (and homing) is done. `auto` needs + the gimbal homed; if it is not - homing off, or homing failed - the program + logs `mode auto refused: gimbal not homed` and stays in `manual`. `pan` needs a + heading and a span, so it is chosen by command only (`mode pan …`). +- Both absent is the idle start: no homing, manual. The example config ships + `home = true`, `mode = manual` - what a new tower gets from `install-tower`: + it homes at every boot, then waits for someone to choose auto. +- A mode changed at runtime (`mode auto` over MQTT, key `a`) is **not** written + back: after a reboot the tower is in its `[Startup]` mode again. +- A `--test` run ignores `[Startup]` entirely: it neither homes nor leaves manual. + +These replaced the `--init` / `--start` flags, which the program now refuses with +a message pointing here. `scripts/install-tower` moves a boot service's old +flags into this section (`--init` → `home = true`, `--start` → `mode = auto`). + ### `[Motor]` calibration & `[Scan]` grid The firmware reports only **encoder counts**; `[Motor]` maps them to the bearing/elevation degrees @@ -578,8 +612,6 @@ Parsed by Boost.Program_options ([main.cpp](../main.cpp)). Flags override `[Feat |------|--------| | `-h, --help` | Show help and exit | | `-c, --config ` | Explicit config file path | -| `-i, --init` | Run the endstop-finding init sequence before the loop | -| `-s, --start` | Start capture automatically | | `--no-mqtt` | Disable MQTT (use the null channel) | | `--mock-camera` | Use the simulated camera | | `--mock-serial` | Use the simulated motor controller | @@ -592,9 +624,14 @@ Parsed by Boost.Program_options ([main.cpp](../main.cpp)). Flags override `[Feat | `--log-file-level ` | File level, independent of `--log-level` (default `debug`) | | `--trace ` | Verbatim wire trace; comma list `serial,mqtt,camera,control,all,none` | -Typical headless dev run: `scripts/run.sh --mock-serial --mock-camera --no-mqtt --start`. +Homing and the start mode are not flags: they are [`[Startup]`](#startup--homing-and-the-start-mode) +`home` / `mode`. The old `-i, --init` and `-s, --start` are refused with a message saying so. +`--test` ignores `[Startup]`. -### Init sequence (`--init`) +Typical headless dev run: `scripts/run.sh --mock-serial --mock-camera --no-mqtt`, with +`[Startup] mode = auto` in the dev `config.ini` to start sweeping at once. + +### Init sequence (`[Startup] home = true`) Sends `ENABLE Y`, `ENABLE P`, `HOME`, then polls telemetry until both axes report `READY` (firmware `ST` state `A`), bounded by the firmware's 60 s homing timeout, then sets `SPEED Y/P` diff --git a/docs/deployment.md b/docs/deployment.md index f0a374f..a993875 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -35,9 +35,14 @@ On the dev box, in `software/`, with a `.deploy.env` naming the tower: REMOTE_HOST=ggs@10.11.12.121 REMOTE_DIR=/home/ggs/projects/fwt_2a/software CMAKE_ARGS="-DWITH_MQTT=ON -DWITH_VIMBA=ON" -RUN_ARGS="--start" +RUN_ARGS="" ``` +`RUN_ARGS` holds only extra flags (`--no-mqtt`, say). Whether the program homes +and which mode it starts in are the tower's own `config.ini` `[Startup] home` / +`mode`, the same for `--run` as for the boot service; `deploy.sh` refuses a +`RUN_ARGS` that still has `--init` or `--start`. + ```bash ./deploy.sh # sync the tree, build on the tower ./deploy.sh --check-deps # only check the tower's build dependencies @@ -52,9 +57,17 @@ program already running keeps running the old binary until it is restarted: # on a tower started by its boot service ssh ggs@ sudo systemctl restart fire-gimbal-control # on a tower started by hand -./deploy.sh --stop && ./deploy.sh --run # --run --home adds --init +./deploy.sh --stop && ./deploy.sh --run # homes / sweeps as its [Startup] says ``` +To home once on a tower whose `[Startup] home` is off, attach and type +`gimbal home` (key `h`); `deploy.sh --home` is gone. + +After building, `deploy.sh` also reads the tower's boot service: one that still +passes `--init`/`--start` gets a loud warning, because the new program refuses +them and **the tower would not start at its next boot**. Run +`scripts/install-tower` on the tower to fix it (section 4). + `--run` starts the program detached through `scripts/fgc` (section 2) and first refuses a `config.ini` that still holds the example's placeholders (`ExampleTower`, `CHANGE_ME`, `DEV_0000000000`); `--force` overrides that. On a @@ -115,7 +128,7 @@ closing. `scripts/fgc` runs it inside a tmux session, which owns the pty: ```bash cd ~/projects/fwt_2a/software -scripts/fgc start --start # starts detached, returns as soon as it is up +scripts/fgc start # starts detached, returns as soon as it is up scripts/fgc status # running? since when? which PID? scripts/fgc attach # the dashboard - Ctrl-b d detaches, leaves it running scripts/fgc stop # SIGTERM -> the same clean teardown as `exit` @@ -198,7 +211,7 @@ systemctl is-enabled fire-gimbal-control # starts at boot? sudo systemctl disable fire-gimbal-control # not any more ``` -By hand, fill in `@USER@`, `@REPO@` and `@ARGS@` (normally `--init --start`) and: +By hand, fill in `@USER@`, `@REPO@` and `@ARGS@` (normally empty) and: ```bash sudo cp fire-gimbal-control.service /etc/systemd/system/ @@ -212,15 +225,25 @@ someone logs in, so a program started at boot takes its single-instance lock in `/tmp`; a later ssh session looks in `/run/user`, finds nothing, reports "not running", and a second copy could start beside the first. -**`--init` homes the gimbal unattended at every boot.** That is what a tower in -service needs - the firmware keeps no position across a power cycle, so without -it the gimbal sits un-homed and captures nothing - but it is a deliberate -choice, and the installer asks for the arguments. +The service passes no startup flags. What the program does at boot is +`config.ini` `[Startup]` ([configuration.md](configuration.md#startup--homing-and-the-start-mode)): + +**`home = true` homes the gimbal unattended at every boot.** That is what a tower +in service needs - the firmware keeps no position across a power cycle, so +without it the gimbal sits un-homed and captures nothing - but it is a +deliberate choice, and the installer asks for it (`startup_home`), as it does +for the start mode (`startup_mode`, `manual` or `auto`). + +**Upgrading a tower from before `[Startup]`.** Its unit still passes `--init` / +`--start`, which the program now refuses. Run `scripts/install-tower` on the +tower **before its next reboot**: it moves them into `config.ini` (`--init` → +`home = true`, `--start` → `mode = auto`) and rewrites the unit without them. +`install-tower --verify` fails its `boot service args` check until that is done. | Tower | Service | Starts with | |---|---|---| -| 111 | enabled 2026-09-24 | `--init --start`: homes and captures at boot - `[verified]` by a reboot 2026-09-24 | -| 121 | enabled 2026-09-23 | no arguments: starts idle, neither homes nor captures (the development tower) | +| 111 | enabled 2026-09-24 | `--init --start`: homes and captures at boot - `[verified]` by a reboot 2026-09-24. **To migrate**: becomes `[Startup] home = true`, `mode = auto` | +| 121 | enabled 2026-09-23 | no arguments and no `[Startup]`: starts idle, neither homes nor captures (the development tower) | `[verified]` on tower 121, 2026-09-23: the service came up by itself after a reboot with nobody logged in, with its lock under `/run/user/1000`; `fgc status` @@ -291,7 +314,7 @@ list below and says which. | Camera opened, but no images and `no frame yet (stream warming up)` for minutes | the stream is stuck; the program restarts it and then resets the camera by itself (known-issues #15), or type `camera reset` | | `No camera found` / Vimba startup error | SDK installed, GenTL path set (`/etc/profile.d/*Vimba*GenTL*.sh` or `VIMBA_CTI_PATH`), `[Camera.rgb] id` in `hardware.ini` | | `MQTT connect failed` (degraded mode) | broker reachable, `credentials.env` readable by `ggs`, firewall | -| Runs but no images | in auto mode (`--start`, or `mode auto`) and the gimbal homed; `output_dir` writable | +| Runs but no images | in auto mode (`[Startup] mode = auto`, or `mode auto`) and the gimbal homed; `output_dir` writable | | `another instance is already running` (exit 2) | `scripts/fgc status`; the service and a hand-started run both up (section 3) | | `fgc status` says not running, but the program is | the service started it before anyone logged in, without lingering (section 4) | | Program dies when ssh disconnects | started with `run.sh` / `deploy.sh --foreground` instead of `scripts/fgc start` | diff --git a/docs/mqtt-api.md b/docs/mqtt-api.md index a4ea83e..029423d 100644 --- a/docs/mqtt-api.md +++ b/docs/mqtt-api.md @@ -35,9 +35,9 @@ dashboard's `a` / `p` / `m` keys - and reports it on `StatusCode`. | Mode | StatusCode | What the tower does | Command | |---|---|---|---| -| auto | `0` | Sweeps the scan grid front to back, a picture at every waypoint, then starts over. The normal state: the boot service starts here (`--start`). | `mode auto` | +| auto | `0` | Sweeps the scan grid front to back, a picture at every waypoint, then starts over. The normal state: a tower in service starts here (`config.ini` `[Startup] mode = auto`). | `mode auto` | | pan | `1` | Watches one direction: walks left to right across a span centred on a bearing, a picture at each position, then back to the left edge. | `mode pan []` | -| manual | `2` | Nothing by itself; a person drives it. The state without `--start`. | `mode manual` | +| manual | `2` | Nothing by itself; a person drives it. The start mode unless `[Startup] mode = auto`; also where `auto` falls back if the gimbal is not homed. | `mode manual` | **Pan positions.** `mode pan 90 30` covers 75°..105°. One picture covers `pan_step_deg` of bearing (hardware.ini `[Hardware]`, 6 until the lens is diff --git a/docs/tower-installation.md b/docs/tower-installation.md index c1b5d9b..22cf94a 100644 --- a/docs/tower-installation.md +++ b/docs/tower-installation.md @@ -102,26 +102,36 @@ one yes. In order: 1. packages, then the USB preconditions (section 3), then the Vimba X SDK (4) 2. builds the host 3. writes `hardware.ini` (5) - or, when one exists, only fills its gaps -4. seeds `config.ini` (9) and `credentials.env`, or edits only what is out of date +4. seeds `config.ini` (9) and `credentials.env`, or edits only what is out of date. + This is where it asks what the tower does as it starts - `[Startup] home` and + `mode` - and where a tower whose boot service still passes the old `--init` / + `--start` has them moved into `config.ini` 5. PlatformIO, then the camera profiles that are missing (8) 6. builds and flashes the firmware (6) - **blocked** while any drivetrain value is still a `; TODO`, because that axis would refuse to move 7. `test hardware` -8. the boot service ([deployment.md](deployment.md) section 4), if wanted. A new - tower gets `--init`: it homes at every boot, then **waits in manual and - captures nothing** until someone chooses auto +8. the boot service ([deployment.md](deployment.md) section 4), if wanted. It + passes no startup flags; a new tower gets `[Startup] home = true`, + `mode = manual`: it homes at every boot, then **waits in manual and captures + nothing** until someone chooses auto 9. asks whether someone is at the rig; only on a yes does it home the gimbal and take **one** picture (a snapshot), then leave it in manual - through the - service, when the service is what homes it + service, when `[Startup] home` has the service home it, and otherwise with + `gimbal home`, leaving `config.ini` as it is **Nothing starts capturing by itself.** Choose auto yourself: key `a` on the dashboard, or `mode auto` over MQTT. Once the tower is proven, make it boot into auto, as 111 does: ```bash -scripts/install-tower --answer service_args="--init --start" +scripts/install-tower --answer startup_mode=auto ``` +That writes `[Startup] mode = auto` into `config.ini`; editing the file by hand +does the same (with the program stopped - see the warning at the top of the +file). `--answer startup_home=y|n` sets homing the same way. `service_args` now +holds only other flags, and refuses `--init` / `--start`. + Every question has a key, so any can be answered up front - `--answer tower_name=north`, `--answer yaw.r_sense_ohm=0.075` - or from a file of `key=value` lines with `--answers FILE`. `--non-interactive` stops instead of @@ -637,7 +647,7 @@ On the dev box, in `software/`: REMOTE_HOST=ggs@10.11.12.121 REMOTE_DIR=/home/ggs/projects/fwt_2a/software CMAKE_ARGS="-DWITH_MQTT=ON -DWITH_VIMBA=ON" -RUN_ARGS="--start" +RUN_ARGS="" # extra flags only; homing and mode are config.ini [Startup] ``` ```bash @@ -922,7 +932,8 @@ reading. `[verified]` on tower 111, 2026-09-23: both. Only once every phase above is green: ```bash -scripts/fgc start --start # detached, inside tmux +# config.ini: [Startup] home = true, mode = auto +scripts/fgc start # detached, inside tmux scripts/fgc status # running? since when? which PID? ``` @@ -1010,7 +1021,7 @@ host must behave exactly as it did before — which is also the guarantee the wh Delta fleet depends on. ```bash -scripts/fgc start --start +scripts/fgc start scripts/fgc peek test hardware motor ``` diff --git a/include/fgc/Application.h b/include/fgc/Application.h index b65103f..5826f65 100644 --- a/include/fgc/Application.h +++ b/include/fgc/Application.h @@ -11,8 +11,8 @@ namespace fgc { // Command-line / runtime options that override config at launch. struct RuntimeOptions { - bool init = false; // run the endstop-finding init sequence - bool start = false; // begin capture automatically + // Whether to home and which mode to start in are NOT here: they are config.ini + // [Startup] home / mode (AppConfig::startup), so the boot service needs no flags. // Start anyway when hardware.ini declares something this rig does not have. // Field triage only: it overrides [Features] strict_presence, and the log // says so in those words so it cannot become the normal way to run a tower. @@ -35,7 +35,8 @@ struct RuntimeOptions { // --test: run this one self-test selection (the words after `test` at the // console, e.g. "hardware" or "hardware motor") once the motor link is up, // then exit with its verdict. Empty => normal operation. For scripts - the - // installer above all - which need an answer, not a dashboard. + // installer above all - which need an answer, not a dashboard. A --test run + // ignores [Startup]: it neither homes nor leaves manual. std::string run_test; }; diff --git a/include/fgc/Config.h b/include/fgc/Config.h index 3a587c2..2aed1ae 100644 --- a/include/fgc/Config.h +++ b/include/fgc/Config.h @@ -2,6 +2,7 @@ #include "fgc/ImuMount.h" #include "fgc/Geometry.h" +#include "fgc/OperatingMode.h" #include #include @@ -312,6 +313,16 @@ struct LoggingConfig { std::string file_dir; // empty => paths::defaultLogDir() }; +// [Startup]: what the tower does as it comes up. Absent keys are the idle +// start: no homing, manual. The firmware keeps no position across a power +// cycle, so a tower in service needs home = true - and then it homes UNATTENDED +// at every start. mode = auto is refused at startup (and the tower stays in +// manual) if the gimbal is not homed by then. +struct StartupConfig { + bool home = false; // run the endstop-finding home sequence at startup + Mode mode = Mode::Manual; // manual or auto; pan needs a spec, so it is by command only +}; + struct UiConfig { bool enable_tui = false; // false => headless line console (default); CLI --tui/--no-tui override }; @@ -366,6 +377,7 @@ struct AppConfig { FeaturesConfig features; LoggingConfig logging; UiConfig ui; // [UI] terminal dashboard toggle + StartupConfig startup; // [Startup] home + mode at startup Geometry geometry; // [Motor] degrees<->counts maps (yaw + pitch) DrivetrainConfig drivetrain; // [Motor.] from hardware.ini // [Motor] how long a full two-end homing may take before the host calls it diff --git a/include/fgc/OperatingMode.h b/include/fgc/OperatingMode.h index 46ee7b6..7df29d9 100644 --- a/include/fgc/OperatingMode.h +++ b/include/fgc/OperatingMode.h @@ -3,15 +3,16 @@ // The tower's three modes of operation, and what each one accepts. // // auto - sweep the scan grid, a picture at every waypoint (the normal, -// unattended state; the boot service starts here with --start); +// unattended state; config.ini [Startup] mode = auto starts here); // pan - watch one direction: centred on a heading, walk left to right // across a span, a picture at each position, then back to the left // edge and again; // manual - the gimbal does nothing by itself; a person drives it, over the // MQTT Command topic or at the console. // -// Modes change only by command (`mode auto|pan|manual`), and the tower reports -// its mode on StatusCode, as the numeric value of Mode. +// Modes change only by command (`mode auto|pan|manual`), apart from the one +// config.ini [Startup] mode sets as the tower comes up. The tower reports its +// mode on StatusCode, as the numeric value of Mode. #include #include diff --git a/main.cpp b/main.cpp index 31ce737..8de86b0 100644 --- a/main.cpp +++ b/main.cpp @@ -48,8 +48,6 @@ int main(int argc, char* argv[]) { ("hardware", po::value(), "path to hardware.ini (default: $FGC_HARDWARE, /etc/fire_gimbal_control/hardware.ini, " "or one beside config.ini)") - ("init,i", po::bool_switch(), "home the gimbal on startup (required: nothing is homed across a power cycle)") - ("start,s", po::bool_switch(), "start in auto mode (sweep and capture); without it, manual") ("no-mqtt", po::bool_switch(), "disable MQTT (use a null control channel)") ("mock-camera", po::bool_switch(), "use a simulated camera (no hardware)") ("mock-serial", po::bool_switch(), "use a simulated motor controller (no hardware)") @@ -72,13 +70,24 @@ int main(int argc, char* argv[]) { ("test", po::value(), "run one self-test once the motor link is up, then exit: 0 PASS, 4 FAIL, 5 could " "not start. Takes what follows `test` at the console, e.g. --test hardware or " - "--test \"hardware motor\". Headless; cannot be combined with --start") + "--test \"hardware motor\". Headless; ignores [Startup], so it neither homes nor " + "leaves manual") ("trace", po::value(), "verbatim wire trace, comma list: serial,mqtt,camera,control,all,none"); + // The removed startup flags, accepted only so that using one says where the + // setting went instead of boost's bare "unrecognised option". A boot service + // still carrying them must not look like a typo. + po::options_description removed; + removed.add_options() + ("init,i", po::bool_switch(), "") + ("start,s", po::bool_switch(), ""); + po::options_description all; + all.add(desc).add(removed); + po::variables_map vm; try { - po::store(po::parse_command_line(argc, argv, desc), vm); + po::store(po::parse_command_line(argc, argv, all), vm); po::notify(vm); } catch (const std::exception& e) { std::cerr << "Argument error: " << e.what() << "\n"; @@ -88,17 +97,22 @@ int main(int argc, char* argv[]) { std::cout << desc << "\n"; return 0; } + if (vm["init"].as() || vm["start"].as()) { + std::cerr << "Argument error: --init and --start were removed. Whether the tower homes " + "and which mode it starts in are set in config.ini:\n" + " [Startup]\n" + " home = true ; was --init\n" + " mode = auto ; was --start (manual otherwise)\n" + "A boot service still passing them: re-run scripts/install-tower, which " + "moves them into config.ini.\n"; + return 1; + } if (vm.count("test")) { const std::string t = vm["test"].as(); if (t.find_first_not_of(" \t") == std::string::npos) { std::cerr << "Argument error: --test needs a selection, e.g. --test hardware\n"; return 1; } - if (vm["start"].as()) { - // Capture would move the gimbal and hold the serial link the test needs. - std::cerr << "Argument error: --test cannot be combined with --start\n"; - return 1; - } } const std::string cli_cfg = vm.count("config") ? vm["config"].as() : ""; @@ -202,8 +216,6 @@ int main(int argc, char* argv[]) { } RuntimeOptions opts; - opts.init = vm["init"].as(); - opts.start = vm["start"].as(); if (vm["no-mqtt"].as()) opts.use_mqtt = false; if (vm["mock-camera"].as()) opts.mock_camera = true; if (vm["mock-serial"].as()) opts.mock_serial = true; diff --git a/scripts/fire-gimbal-control.service b/scripts/fire-gimbal-control.service index e678c30..50a44a4 100644 --- a/scripts/fire-gimbal-control.service +++ b/scripts/fire-gimbal-control.service @@ -14,9 +14,10 @@ # ssh session looks in /run/user, finds nothing, and `fgc status` says it is not # running - while a second copy could start beside it. # -# @ARGS@ is normally `--init --start`: the firmware keeps no position across a -# power cycle, so without --init the gimbal sits un-homed and captures nothing. -# --init also means the gimbal HOMES UNATTENDED at every boot. +# @ARGS@ is normally empty. Whether the program homes and the mode it starts in +# are config.ini [Startup] home / mode, not arguments (the program refuses the +# old --init / --start). The firmware keeps no position across a power cycle, so +# a tower in service has home = true - and HOMES UNATTENDED at every boot. # # MQTT credentials: /etc/fire_gimbal_control/credentials.env (FGC_MQTT_USER, # FGC_MQTT_PW), read by fgc inside the tmux pane rather than passed through this diff --git a/scripts/install-tower b/scripts/install-tower index c85726e..c9f0639 100755 --- a/scripts/install-tower +++ b/scripts/install-tower @@ -15,7 +15,9 @@ # # Every question has a key, so any of them can be answered up front with # --answer KEY=VALUE (repeatable) - e.g. --answer tower_name=north -# --answer yaw.r_sense_ohm=0.075. A drivetrain value may be answered `unknown`, +# --answer yaw.r_sense_ohm=0.075. Whether the tower homes at every start and the +# mode it starts in are config.ini [Startup] home / mode: --answer startup_home=y +# --answer startup_mode=auto. A drivetrain value may be answered `unknown`, # which leaves a commented ; TODO in hardware.ini and that axis unable to move. # Nothing here ever makes one up: a wrong sense resistor cooks a driver, and a # wrong homing current shears an endstop - both have happened on this project. @@ -194,6 +196,17 @@ py_serial() { app_running() { "$FGC" status >/dev/null 2>&1; } +# The flags [Startup] replaced, and an argument list without them. +is_startup_flag() { case "$1" in --init|-i|--start|-s) return 0 ;; *) return 1 ;; esac; } +has_flag() { # "args" flag... -> 0 if any of the flags is in args + local a f; for a in $1; do for f in "${@:2}"; do [[ $a == "$f" ]] && return 0; done; done; return 1 +} +strip_startup() { + local a out=(); for a in $1; do is_startup_flag "$a" || out+=("$a"); done; echo "${out[*]:-}" +} +# What the installed boot service passes to `fgc start`, as written in the unit. +unit_args() { [[ -r $UNIT ]] && sed -n 's|^ExecStart=.*/scripts/fgc start *||p' "$UNIT" | head -1; } + # One command line into the running program, whether it shows the dashboard or # the plain console: the dashboard needs ':' to open its command line first. app_cmd() { @@ -207,8 +220,9 @@ app_cmd() { # The arguments the running app was started with, so a restart puts it back the # way it was. From the process itself, not its log: an older build writes no log -# file, and restarting tower 111 without its --init --start would have left it -# running but capturing nothing. +# file. An older build may also carry --init/--start, which the program now +# refuses - they are config.ini [Startup] - so a restart drops them (strip_startup) +# and the survey moves them into config.ini instead (see [Startup] below). app_args() { ps -u "$USER" -o args= 2>/dev/null | grep -m1 -E '^[^ ]*/fire_gimbal_control( |$)' | awk '{ for (i = 2; i <= NF; i++) printf "%s%s", $i, (i < NF ? " " : "") }' || true @@ -361,7 +375,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 '?')" || 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) 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} @@ -452,6 +466,12 @@ verify() { # Only once there IS a service: a tower run by hand is a choice, not a fault. if [[ -r $UNIT ]]; then check "boot service" "$([[ $S_SVC_ENABLED == enabled ]] && echo 0 || echo 1)" "$S_SVC_ENABLED" + # A unit from before [Startup] passes flags the program now refuses: the + # tower would not come up at its next boot. + check "boot service args" "$(has_flag "$(unit_args || true)" --init -i --start -s && echo 1 || echo 0)" \ + "$(has_flag "$(unit_args || true)" --init -i --start -s && + echo "passes '$(unit_args)', which the program refuses - re-run install-tower to move it into config.ini [Startup]" || + echo "fgc start $(unit_args || true)")" check "user lingering" "$([[ $S_LINGER == yes ]] && echo 0 || echo 1)" \ "$S_LINGER (without it a service-started app and an ssh session disagree on the lock file)" else @@ -723,6 +743,45 @@ done if [[ -n $S_LPM ]] && [[ -z $(ini_get "$CFG" Test usb3_lpm_path 2>/dev/null || true) ]]; then CFG_SETS+=("Test.usb3_lpm_path=$S_LPM") fi +# [Startup]: whether the tower homes at every start, and the mode it starts in. +# These used to be the boot service's --init / --start; a tower still set up that +# way has them moved here, as they were, and the service rewritten without them +# (below) - the program refuses those flags now. Asked only when nothing says +# yet: a config.ini that has them keeps them unless an --answer changes them. +LEGACY_ARGS="$(unit_args || true)"; [[ -r $UNIT ]] || LEGACY_ARGS="${S_ARGS:-}" +ST_HOME="$(ini_get "$CFG" Startup home 2>/dev/null || true)" +ST_MODE="$(ini_get "$CFG" Startup mode 2>/dev/null || true)" +WANT_HOME="" WANT_MODE="" +if [[ -z $ST_HOME && -z $ST_MODE ]] && has_flag "$LEGACY_ARGS" --init -i --start -s; then + has_flag "$LEGACY_ARGS" --init -i && WANT_HOME=true || WANT_HOME=false + has_flag "$LEGACY_ARGS" --start -s && WANT_MODE=auto || WANT_MODE=manual + say "the program was started with '$LEGACY_ARGS': moving that into config.ini [Startup] (home = $WANT_HOME, mode = $WANT_MODE)" +fi +if [[ -n ${ANS[startup_home]+x} || ( -z $ST_HOME && -z $WANT_HOME ) ]]; then + echo + echo "With home = y the program HOMES THE GIMBAL UNATTENDED at every start - which a" + echo "tower in service needs, since the firmware keeps no position across a power cycle." + ask_yn startup_home "Home the gimbal at every start?" y && WANT_HOME=true || WANT_HOME=false +fi +if [[ -n ${ANS[startup_mode]+x} || ( -z $ST_MODE && -z $WANT_MODE ) ]]; then + echo + echo "manual waits and captures nothing until someone chooses a mode; auto starts" + echo "sweeping at once. Start a new tower in manual, and choose auto once it is proven." + while :; do + ask startup_mode "Mode it starts in (manual or auto)" manual + case "${REPLY_VAL,,}" in manual|auto) WANT_MODE="${REPLY_VAL,,}"; break ;; esac + warn "'$REPLY_VAL' is not manual or auto (pan is chosen by command only)"; unset "ANS[startup_mode]" + [[ $NONINTERACTIVE -eq 1 ]] && die "startup_mode must be manual or auto" + done +fi +[[ -n $WANT_HOME && $WANT_HOME != "$ST_HOME" ]] && CFG_SETS+=("Startup.home=$WANT_HOME") +[[ -n $WANT_MODE && $WANT_MODE != "$ST_MODE" ]] && CFG_SETS+=("Startup.mode=$WANT_MODE") +# What the program will do from here on, for the homing gate and the report. +START_HOME="${WANT_HOME:-${ST_HOME:-false}}"; START_MODE="${WANT_MODE:-${ST_MODE:-manual}}" +case "${START_HOME,,}" in 1|true|yes|on) START_HOME=true ;; *) START_HOME=false ;; esac +# A restart puts the program back with its arguments, minus the flags it refuses. +RESTART_ARGS="$(strip_startup "${S_ARGS:-}")" + # In an existing config.ini, the keys hardware.ini now owns are dead copies the # program warns about at every start. They are commented out, value and all. declare -a CFG_DROPS=() @@ -789,33 +848,38 @@ render_unit() { sed -e "s|@USER@|$USER|g" -e "s|@REPO@|$REPO|g" -e "s|@ARGS@|$1|g" \ "$REPO/scripts/fire-gimbal-control.service" } -# What an installed unit starts the program with, so a re-run keeps it. -installed_args() { - [[ -r $UNIT ]] && sed -n 's|^ExecStart=.*/scripts/fgc start *||p' "$UNIT" | head -1 -} +# What an installed unit starts the program with, so a re-run keeps it - minus +# --init/--start, which the program refuses: they are config.ini [Startup] now. +installed_args() { strip_startup "$(unit_args || true)"; } UNIT_TEXT="" -# A new tower homes at every boot but captures nothing until a person has chosen -# auto: --init, not --init --start. Once it is proven, the operator makes it boot -# into auto by re-running with --answer service_args="--init --start". A service -# of ours keeps the arguments it has; one that is not ours (a legacy tower's old -# program) gets the same safe default. -SVC_ARGS_DEFAULT="--init" -[[ -r $UNIT && -n $(installed_args) ]] && SVC_ARGS_DEFAULT="$(installed_args)" +# The service passes no startup flags: whether it homes and the mode it starts in +# come from config.ini [Startup], asked above. Only other arguments (--no-mqtt, +# say) belong here. A service of ours keeps the ones it has. +SVC_ARGS_DEFAULT="$(installed_args)" +if [[ ${ANS[service_args]+x} ]] && has_flag "${ANS[service_args]}" --init -i --start -s; then + die "service_args may not hold --init/--start any more: pass --answer startup_home=y / startup_mode=auto" +fi if [[ -r $UNIT && $S_SVC_ENABLED == enabled && $S_LINGER == yes && -z ${ANS[service_args]+x} ]] && [[ "$(cat "$UNIT")" == "$(render_unit "$(installed_args)")" ]]; then - plan service skip "installed and enabled (fgc start ${SVC_ARGS_DEFAULT/#none/})" + plan service skip "installed and enabled (fgc start${SVC_ARGS_DEFAULT:+ $SVC_ARGS_DEFAULT})" +elif [[ -r $UNIT && $S_SVC_ENABLED == enabled && -z ${ANS[service_args]+x} ]] && + has_flag "$(unit_args || true)" --init -i --start -s; then + # Ours, and set up before [Startup]: the program would refuse to start at the + # next boot. No question - the flags have been moved into config.ini above. + UNIT_TEXT="$(render_unit "$(installed_args)")" + plan service change "drop '$(unit_args)' from $UNIT (now config.ini [Startup]); lingering for $USER" else echo - echo "The boot service starts the program at every boot. With --init it HOMES THE" - echo "GIMBAL UNATTENDED each time the tower powers up - which is what a tower in" - echo "service needs, since the firmware keeps no position across a power cycle." - echo "With --init alone it then WAITS IN MANUAL and captures nothing; add --start" - echo "(--init --start) to boot straight into auto once the tower is proven." + echo "The boot service starts the program at every boot. What it does then is" + echo "config.ini [Startup]: this tower will $([[ $START_HOME == true ]] && echo "HOME THE GIMBAL UNATTENDED at every boot" || echo "NOT home at boot (it cannot move until someone homes it)")," + echo "then $([[ $START_MODE == auto ]] && echo "sweep in auto" || echo "wait in manual, capturing nothing until someone chooses auto")." if ask_yn service "Install and enable it?" y; then - ask service_args "Arguments it starts the program with ('none' for none)" "$SVC_ARGS_DEFAULT" + ask service_args "Other arguments it starts the program with ('none' for none)" "${SVC_ARGS_DEFAULT:-none}" svc_args="$REPLY_VAL"; [[ $svc_args == none ]] && svc_args="" + has_flag "$svc_args" --init -i --start -s && + die "--init/--start are config.ini [Startup] now: answer startup_home / startup_mode instead" UNIT_TEXT="$(render_unit "$svc_args")" - plan service change "write $UNIT (fgc start $svc_args), enable it, enable lingering for $USER" + plan service change "write $UNIT (fgc start${svc_args:+ $svc_args}), enable it, enable lingering for $USER" else plan service skip "declined"; fi fi @@ -861,7 +925,7 @@ put_back() { warn "stopped before finishing - restarting the program as it was" if [[ $STOPPED_SVC -eq 1 ]]; then sudo systemctl start fire-gimbal-control else - local a; read -r -a a <<< "${S_ARGS:-}" + local a; read -r -a a <<< "${RESTART_ARGS:-}" ( cd "$REPO" && "$FGC" start "${a[@]}" ) > /dev/null fi return $rc @@ -1046,14 +1110,19 @@ if [[ $TEST_RC != 0 ]]; then elif ask_yn home "Someone is at the rig with a hand on the power - home now and take one picture?" n; then stop_app before="$(latest_log)" - # Through the service when the service is what homes it, so the tower ends - # up running under it; otherwise the same run by hand. Never --start here: - # the tower is left in manual, and auto is a person's choice (a service - # already set to --start is the exception - that choice was made). - if [[ $SVC_ON -eq 1 && " $(installed_args) " == *" --init "* ]]; then sudo systemctl start fire-gimbal-control - else ( cd "$REPO" && "$FGC" start --init ) > /dev/null; fi - say "homing (this can take a few minutes)" + # When config.ini [Startup] homes, the program homes as it starts - through + # the service if there is one, so the tower ends up running under it. When it + # does not, start it and home by command: config.ini is not changed for this. + # It comes up in its [Startup] mode; auto stays a person's choice unless that + # choice has already been made there. + if [[ $START_HOME == true && $SVC_ON -eq 1 ]]; then sudo systemctl start fire-gimbal-control + else ( cd "$REPO" && "$FGC" start ) > /dev/null; fi log=""; for _ in $(seq 1 30); do log="$(latest_log)"; [[ -n $log && $log != "$before" ]] && break; sleep 1; done + if [[ $START_HOME != true ]]; then + for _ in $(seq 1 60); do grep -q 'Entering control loop' "$log" 2>/dev/null && break; sleep 1; done + app_cmd "gimbal home" + fi + say "homing (this can take a few minutes)" outdir=""; ok=0; snapped=0 for _ in $(seq 1 420); do grep -q 'Gimbal homed' "$log" 2>/dev/null && ok=1 @@ -1084,12 +1153,12 @@ hr "restart" if app_running; then say "running" elif [[ $WAS_RUNNING -eq 1 ]]; then - read -r -a restart_args <<< "${S_ARGS:-}" + read -r -a restart_args <<< "${RESTART_ARGS:-}" before="$(latest_log)" - ( cd "$REPO" && "$FGC" start "${restart_args[@]}" ) > /dev/null && say "restarted as it was: fgc start${S_ARGS:+ $S_ARGS}" - # With --init it is homing now, and refuses a test until it is done: wait, - # or the checks below report a busy program as a failed one. - if [[ " ${S_ARGS:-} " == *" --init "* ]]; then + ( cd "$REPO" && "$FGC" start "${restart_args[@]}" ) > /dev/null && say "restarted as it was: fgc start${RESTART_ARGS:+ $RESTART_ARGS}" + # With [Startup] home it is homing now, and refuses a test until it is done: + # wait, or the checks below report a busy program as a failed one. + if [[ $START_HOME == true ]]; then say "waiting for it to home before checking" for _ in $(seq 1 420); do log="$(latest_log)" @@ -1116,10 +1185,16 @@ open=0 while read -r t; do [[ -n $t ]] && { echo " - hardware.ini: $t"; open=1; }; done < <(hw_todos) [[ $HOMED == homed* ]] || { echo " - homing: $HOMED"; open=1; } [[ $HOMED == homed* ]] && { echo " - calibration: run 'gimbal heading setup' to tie yaw to true north"; open=1; } -if [[ -r $UNIT && " $(installed_args) " != *" --start "* ]]; then - echo " - capture: the tower waits in MANUAL at every boot and captures nothing. Choose auto" - echo " yourself (key a, or 'mode auto'); once it is proven, make it boot into auto:" - echo " scripts/install-tower --answer service_args=\"--init --start\"" +if [[ $START_HOME != true ]]; then + echo " - homing: config.ini [Startup] home is off, so after a power cut the gimbal cannot" + echo " move until someone homes it ('gimbal home'). A tower in service homes at every start:" + echo " scripts/install-tower --answer startup_home=y" + open=1 +fi +if [[ $START_MODE != auto ]]; then + echo " - capture: the tower waits in MANUAL at every start and captures nothing. Choose auto" + echo " yourself (key a, or 'mode auto'); once it is proven, make it start in auto:" + echo " scripts/install-tower --answer startup_mode=auto" open=1 fi [[ $(ini_get "$CFG" Features enable_mqtt 2>/dev/null) == true ]] && diff --git a/scripts/run.sh b/scripts/run.sh index 0da6402..255c13f 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -2,11 +2,12 @@ # Launch fire_gimbal_control, resolving the binary relative to this script so it # works regardless of where the repo is checked out. Extra args are forwarded. # -# scripts/run.sh --init --start # homes first -# scripts/run.sh --mock-serial --mock-camera --no-mqtt --start # dev, no hardware +# scripts/run.sh # real deployment +# scripts/run.sh --mock-serial --mock-camera --no-mqtt # dev, no hardware # -# --init homes the gimbal. The firmware keeps nothing across a power cycle, so -# without it the axes stay in RESET and refuse every move. +# Homing and the start mode come from config.ini [Startup] home / mode. The +# firmware keeps nothing across a power cycle, so without home = true the axes +# stay in RESET and refuse every move. set -euo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" diff --git a/src/camera/VimbaCameraSource.cpp b/src/camera/VimbaCameraSource.cpp index f738e42..bf5cc0d 100644 --- a/src/camera/VimbaCameraSource.cpp +++ b/src/camera/VimbaCameraSource.cpp @@ -347,7 +347,7 @@ void VimbaCameraSource::close() { // happened at open(), and changes only through setProfile(). void VimbaCameraSource::start() { // Idempotent. Several callers reach here without knowing whether another - // already has - `start` (scan), `snapshot`, and --start on the command line - + // already has - `start` (scan), `snapshot`, and a [Startup] mode = auto - // and re-entering would restart acquisition under the SDK's feet. if (impl_->started) return; impl_->startAcquisition(); diff --git a/src/core/Application.cpp b/src/core/Application.cpp index 1ae8e7b..5790f66 100644 --- a/src/core/Application.cpp +++ b/src/core/Application.cpp @@ -1774,7 +1774,7 @@ struct Application::Impl { : ": reset FAILED (see above)"); } - // The one way the mode changes - `mode ...`, the a / p / m keys, --start, and a + // The one way the mode changes - `mode ...`, the a / p / m keys, [Startup] mode, and a // test pausing the scan all come here. Returns false, having said why and // leaving the mode as it was, when the gimbal cannot do what was asked. // StatusCode is published on every change, so it always says what the tower @@ -2338,7 +2338,7 @@ struct Application::Impl { motor->sendCommand(raw); } else if (sub == "home" && tok.size() <= 2) { // Full re-home: enable + HOME + wait for READY + re-anchor the yaw zero - // (same path as startup --init), so the homing key behaves like a boot + // (same path as the startup home, [Startup] home = true), so the homing key behaves like a boot // home rather than a fire-and-forget HOME that skips the re-anchor. runInitSequence(); } else if (sub == "home" || sub == "stop" || sub == "reset" || sub == "enable" || @@ -3071,7 +3071,7 @@ struct Application::Impl { scheduler->setSettleWait(cfg.camera.maxSettleWaitMs(), cfg.camera.maxFrameTimeoutMs()); // Before the firmware is talked to: the registers are wanted by the very - // first sendAnchorConfig(), on both the --init and the no---init path. + // first sendAnchorConfig(), on both the homing and the no-homing startup path. planMotorCurrents(); motor->start(); if (imu) imu->start(); @@ -3091,7 +3091,7 @@ struct Application::Impl { } pipeline->start(); - channel->publishStatus(static_cast(Mode::Manual)); // until --start says otherwise + channel->publishStatus(static_cast(Mode::Manual)); // until [Startup] mode says otherwise // Seed an initial snapshot so the UI's first frame is populated, then // start the interface (headless console or TUI) on its own thread. The @@ -3111,7 +3111,14 @@ struct Application::Impl { cmd_queue.push(line); }); - if (opts.init) { + // [Startup] decides; a --test run ignores it, so a test never homes the + // gimbal or starts a sweep that nobody asked the test for. + const bool test_run = !opts.run_test.empty(); + const bool home = cfg.startup.home && !test_run; + const Mode start_in = test_run ? Mode::Manual : cfg.startup.mode; + if (test_run && (cfg.startup.home || cfg.startup.mode != Mode::Manual)) + LOG_INFO << "--test: ignoring [Startup] (no homing, staying in manual)"; + if (home) { runInitSequence(); // homes, then derives the travel window } else { // No homing this launch. The firmware keeps nothing across a power @@ -3121,7 +3128,7 @@ struct Application::Impl { sendAnchorConfig(); applyHomedTravel(); } - if (opts.start) setMode(Mode::Auto); + if (start_in != Mode::Manual) setMode(start_in); // Clean up on Ctrl-C / kill / systemd stop, not just on "exit"/Ctrl-D - so the // camera is always stopped and closed (an unclean exit wedges it for next time). diff --git a/src/core/Config.cpp b/src/core/Config.cpp index 1595801..6864d9a 100644 --- a/src/core/Config.cpp +++ b/src/core/Config.cpp @@ -477,6 +477,18 @@ AppConfig ConfigLoader::fromMap(const std::map& kv) { cfg.ui.enable_tui = getBool(kv, "UI.enable_tui", cfg.ui.enable_tui); + cfg.startup.home = getBool(kv, "Startup.home", cfg.startup.home); + if (auto it = kv.find("Startup.mode"); it != kv.end() && !it->second.empty()) { + if (it->second == "manual") cfg.startup.mode = Mode::Manual; + else if (it->second == "auto") cfg.startup.mode = Mode::Auto; + else + throw std::runtime_error("Config key 'Startup.mode' must be manual or auto, got '" + + it->second + "'" + + (it->second == "pan" ? " (pan is chosen by command: " + "`mode pan `)" + : "")); + } + // [Motor]: the degrees<->counts maps, anchored on the homed mechanical centre // (see Geometry). min_deg/max_deg are NOT configured - they are derived from // the firmware's homed soft limits, the only place they can be correct. diff --git a/src/core/HelpText.cpp b/src/core/HelpText.cpp index 663c849..d33370c 100644 --- a/src/core/HelpText.cpp +++ b/src/core/HelpText.cpp @@ -27,8 +27,9 @@ const std::vector& helpCatalog() { {"Modes", "What the tower is doing. MQTT StatusCode: 0 auto, 1 pan, 2 manual.", { {"mode", "Report the current mode.", {}}, {"mode auto", "Sweep the scan grid, one image per waypoint (key a).", { - "The unattended state; --start boots into it. In auto and pan only", - "status, logging, 'mode', 'cancel' and 'exit' are accepted."}}, + "The unattended state; config [Startup] mode = auto boots into it.", + "In auto and pan only status, logging, 'mode', 'cancel' and 'exit'", + "are accepted."}}, {"mode pan []", "Sweep a span centred on a bearing, repeatedly (key p).", { "One image every pan_step_deg (hardware.ini), both edges included.", "Pitch defaults to 0 (level); a yaw-only tower ignores it. Key p pans", diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 80ffc04..d30e0c2 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -59,6 +59,9 @@ add_executable(fgc_tests test_testrunner.cpp ) target_link_libraries(fgc_tests PRIVATE fgc_core doctest::doctest) +# So a test can read the shipped config/config.example.ini and hold it to what +# the docs promise a new tower gets. +target_compile_definitions(fgc_tests PRIVATE FGC_SOURCE_DIR="${PROJECT_SOURCE_DIR}") include(${doctest_SOURCE_DIR}/scripts/cmake/doctest.cmake) doctest_discover_tests(fgc_tests) diff --git a/tests/test_config.cpp b/tests/test_config.cpp index 151c015..66bf2ee 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -135,6 +135,42 @@ TEST_CASE("brokerUri builds a Paho serverURI from host + port") { .network.brokerUri() == "tcp://10.0.0.5:8883"); } +TEST_CASE("[Startup] defaults to the idle start: no homing, manual") { + AppConfig d = ConfigLoader::fromMap({}); + CHECK(d.startup.home == false); + CHECK(d.startup.mode == Mode::Manual); + + AppConfig e = ConfigLoader::fromMap({{"Startup.home", ""}, {"Startup.mode", ""}}); + CHECK(e.startup.home == false); + CHECK(e.startup.mode == Mode::Manual); +} + +TEST_CASE("[Startup] home and mode are read") { + AppConfig c = ConfigLoader::fromMap({{"Startup.home", "true"}, {"Startup.mode", "auto"}}); + CHECK(c.startup.home == true); + CHECK(c.startup.mode == Mode::Auto); + + AppConfig m = ConfigLoader::fromMap({{"Startup.home", "false"}, {"Startup.mode", "manual"}}); + CHECK(m.startup.home == false); + CHECK(m.startup.mode == Mode::Manual); +} + +TEST_CASE("[Startup] refuses a mode it cannot start in") { + CHECK_THROWS(ConfigLoader::fromMap({{"Startup.mode", "atuo"}})); + CHECK_THROWS(ConfigLoader::fromMap({{"Startup.mode", "pan"}})); // needs a spec + CHECK_THROWS(ConfigLoader::fromMap({{"Startup.home", "maybe"}})); +} + +TEST_CASE("the example config starts idle-but-homed, like a new tower") { + const auto kv = parseIniString([] { + std::ifstream f(std::string(FGC_SOURCE_DIR) + "/config/config.example.ini"); + return std::string(std::istreambuf_iterator(f), {}); + }()); + AppConfig c = ConfigLoader::fromMap(kv); + CHECK(c.startup.home == true); + CHECK(c.startup.mode == Mode::Manual); +} + TEST_CASE("Network.mqtt_port is range-checked") { CHECK(ConfigLoader::fromMap({}).network.mqtt_port == 1883); CHECK(ConfigLoader::fromMap({{"Network.mqtt_port", "8883"}}).network.mqtt_port == 8883);