Add a command to keep taking pictures where the gimbal points, drop unused camera commands, and fit the help on one page

This commit is contained in:
pgdalmeida 2026-09-29 15:48:07 +02:00
parent a21be0d4e8
commit 895c2f22fd
Signed by: pedro.almeida
GPG Key ID: D4A6C394DF13F1D7
23 changed files with 461 additions and 415 deletions

View File

@ -24,7 +24,7 @@ endif()
# Always-required dependencies # Always-required dependencies
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
find_package(Threads REQUIRED) find_package(Threads REQUIRED)
find_package(OpenCV REQUIRED COMPONENTS core highgui imgproc) find_package(OpenCV REQUIRED COMPONENTS core imgproc)
# Boost.Asio is header-only (Boost >= 1.69); only program_options is a compiled lib. # Boost.Asio is header-only (Boost >= 1.69); only program_options is a compiled lib.
if(POLICY CMP0167) if(POLICY CMP0167)
cmake_policy(SET CMP0167 NEW) cmake_policy(SET CMP0167 NEW)

View File

@ -221,12 +221,13 @@ press a key that acts.
| `a` / `p` / `m` | auto mode (sweep the scan grid) / pan 30° around where the gimbal points now / manual mode (stop, take it by hand) | | `a` / `p` / `m` | auto mode (sweep the scan grid) / pan 30° around where the gimbal points now / manual mode (stop, take it by hand) |
| `h` | home the gimbal | | `h` | home the gimbal |
| `g` `s` `c` `n` | open the full-screen gimbal, sensors, cameras or connectivity view; press it again to close | | `g` `s` `c` `n` | open the full-screen gimbal, sensors, cameras or connectivity view; press it again to close |
| `?` | the built-in help: every command, with examples (`j`/`k` change section) | | `?` | the built-in help: every command on one page (`:help <topic>` for examples) |
| `:` | type a command, exactly as in the [command list](#everyday-commands) | | `:` | type a command, exactly as in the [command list](#everyday-commands) |
| `f` | freeze the display, to select and copy text or to page back through the log (the program keeps running) | | `f` | freeze the display, to select and copy text or to page back through the log (the program keeps running) |
| `PgUp` `PgDn` | scroll the log, or the open view - the only scroll keys | | `PgUp` `PgDn` | scroll the log, or the open view - the only scroll keys |
| arrows | nudge the gimbal by hand, from any view - manual mode only | | arrows | nudge the gimbal by hand, from any view - manual mode only |
| `Space` | take one picture now - manual mode only | | `Space` | take one picture now (`snapshot`) - manual mode only |
| `r` | continuous capture on/off (`capture on` / `capture off`) - manual mode only |
| `Esc` | close a view; with no view open, **cancel** whatever is running (a test, a homing, a calibration, a heading setup) | | `Esc` | close a view; with no view open, **cancel** whatever is running (a test, a homing, a calibration, a heading setup) |
| `q` | quit or background the program - it asks which | | `q` | quit or background the program - it asks which |
| `Ctrl-b d` | leave the dashboard and keep the program running (a `tmux` key) | | `Ctrl-b d` | leave the dashboard and keep the program running (a `tmux` key) |
@ -317,10 +318,10 @@ job. Details, payloads and safety rules: [docs/mqtt-api.md](docs/mqtt-api.md).
| `gimbal move <yaw>,<pitch>` | point at a **true bearing** (0 = north) and an elevation, in degrees | | `gimbal move <yaw>,<pitch>` | point at a **true bearing** (0 = north) and an elevation, in degrees |
| `gimbal nudge <yaw\|pitch> <+/-percent>` | a small move by hand | | `gimbal nudge <yaw\|pitch> <+/-percent>` | a small move by hand |
| `snapshot` | take one picture now (manual mode) | | `snapshot` | take one picture now (manual mode) |
| `capture on` / `capture off` | keep taking pictures where the gimbal is aimed, at the sweep's pace (manual mode; the gimbal can still be nudged) |
| `test [<subsystem> [<test>]]` | run self-tests - see [below](#is-the-tower-healthy) | | `test [<subsystem> [<test>]]` | run self-tests - see [below](#is-the-tower-healthy) |
| `gimbal heading setup` | tie the yaw axis to true north by sighting landmarks | | `gimbal heading setup` | tie the yaw axis to true north by sighting landmarks |
| `set camera profile <name>` | switch the camera profile, e.g. `night` | | `set camera profile <name>` | switch the camera profile, e.g. `night` |
| `set fps <n>` | how many pictures per second the sweep aims for |
| `camera reset` | reboot the camera and put it back on its profile - for a camera that has stopped sending pictures | | `camera reset` | reboot the camera and put it back on its profile - for a camera that has stopped sending pictures |
| `gimbal dump` | show everything the firmware is holding | | `gimbal dump` | show everything the firmware is holding |
| `debug` | more detail in the log, or back to normal | | `debug` | more detail in the log, or back to normal |
@ -479,7 +480,6 @@ settings the way the real firmware does.
| `--mock-serial`, `--mock-camera`, `--mock-env` | stand-ins instead of the motor board, camera, ambient sensor | | `--mock-serial`, `--mock-camera`, `--mock-env` | stand-ins instead of the motor board, camera, ambient sensor |
| `--no-mqtt` | no broker | | `--no-mqtt` | no broker |
| `--degraded` | start even though `hardware.ini` declares a part that is missing (field triage) | | `--degraded` | start even though `hardware.ini` declares a part that is missing (field triage) |
| `-d, --demo` | save a placeholder picture instead of encoding live frames |
| `--log-level <level>` | `trace`, `debug`, `info`, `warn`, `error`, `off` - for the screen | | `--log-level <level>` | `trace`, `debug`, `info`, `warn`, `error`, `off` - for the screen |
| `--log-file-level <level>`, `--no-log-file` | the log file's level / no log file this run | | `--log-file-level <level>`, `--no-log-file` | the log file's level / no log file this run |
| `--trace <parts>` | log every message to and from `serial`, `mqtt`, `camera`, `control`, `all` | | `--trace <parts>` | log every message to and from `serial`, `mqtt`, `camera`, `control`, `all` |

Binary file not shown.

View File

@ -55,7 +55,7 @@ thread so all geometry/state mutation stays single-threaded.
| MQTT client | Paho (internal) | delivers callbacks; auto-reconnects (no busy-wait loop) | | MQTT client | Paho (internal) | delivers callbacks; auto-reconnects (no busy-wait loop) |
| Camera acquisition | Vimba X (internal) | free-runs at the mode's frame rate and delivers frames via the observer, which keeps the latest complete one | | Camera acquisition | Vimba X (internal) | free-runs at the mode's frame rate and delivers frames via the observer, which keeps the latest complete one |
Shared state is mutex-guarded: latest `MotorTelemetry` (serial), `ControlCommand` (channel), the frame queue Shared state is mutex-guarded: latest `MotorTelemetry` (serial), `RemoteCommand` queue (channel), the frame queue
(pipeline), the console command queue, and the latest `UiSnapshot`. The `CaptureScheduler` runs only on the (pipeline), the console command queue, and the latest `UiSnapshot`. The `CaptureScheduler` runs only on the
main thread; the UI is a pure observer + command source (it copies a snapshot to render and pushes command main thread; the UI is a pure observer + command source (it copies a snapshot to render and pushes command
strings back through the same queue the console uses, so it never touches live logic objects). In TUI mode strings back through the same queue the console uses, so it never touches live logic objects). In TUI mode
@ -76,16 +76,17 @@ corrupted.
- auto: `MOVE <yaw>,<pitch>` to the next `ScanGrid` waypoint (front to back, then wrap). - auto: `MOVE <yaw>,<pitch>` to the next `ScanGrid` waypoint (front to back, then wrap).
- pan: `MOVE` to the next of `panHeadings()` (left to right, then back to the left edge) at the - pan: `MOVE` to the next of `panHeadings()` (left to right, then back to the left edge) at the
pan's pitch, converted to counts via `Geometry`. pan's pitch, converted to counts via `Geometry`.
- manual: nothing, bar a requested snapshot. - manual: nothing, bar a requested snapshot or continuous capture (`capture on`), which skip the
move and wait only for standstill.
- Once **both axes report standstill at the target**, wait `settle_wait_ms`, then keep the newest - Once **both axes report standstill at the target**, wait `settle_wait_ms`, then keep the newest
frame and advance the grid or pan position. frame and advance the grid or pan position.
5. **Acquisition** — the camera free-runs at the active mode's `AcquisitionFrameRate` with its own 5. **Acquisition** — the camera free-runs at the active profile's `AcquisitionFrameRate` with its own
auto-exposure tracking the light; `trigger()` hands over the freshest complete frame. See *Image auto-exposure tracking the light; `trigger()` hands over the freshest complete frame. See *Image
capture* below. capture* below.
6. **Frame handling** — the kept `Frame` goes to the callback, which `submit()`s it to the 6. **Frame handling** — the kept `Frame` goes to the callback, which `submit()`s it to the
`ImagePipeline`. `submit()` samples the gimbal pose there and then — yaw from the encoder, pitch from the IMU — `ImagePipeline`. `submit()` samples the gimbal pose there and then — yaw from the encoder, pitch from the IMU —
and the pose travels through the queue with the frame, so encode latency cannot attribute a later pose to earlier and the pose travels through the queue with the frame, so encode latency cannot attribute a later pose to earlier
pixels. The worker rotates it by `Camera.rotation_deg`, encodes JPEG XL (or copies the demo image), writes pixels. The worker rotates it by `Camera.rotation_deg`, encodes JPEG XL, writes
`<output_dir>/<label>/<unix_ms>.jxl`, and publishes a `CamEvent` carrying that pose. `<output_dir>/<label>/<unix_ms>.jxl`, and publishes a `CamEvent` carrying that pose.
## Capture state machine ## Capture state machine
@ -132,7 +133,7 @@ At each waypoint the scheduler therefore:
The wait is the whole mechanism. It guarantees the kept frame was exposed with the gimbal **at rest** (no The wait is the whole mechanism. It guarantees the kept frame was exposed with the gimbal **at rest** (no
slew blur) and after the auto-exposure has seen this waypoint's light for several frames. It also sets the slew blur) and after the auto-exposure has seen this waypoint's light for several frames. It also sets the
floor on mode frame rate: a mode must produce comfortably more than one frame inside its own settle wait, floor on a profile's frame rate: a profile must produce comfortably more than one frame inside its own settle wait,
which is why `night` at 1 Hz waits 2.5 s where `standard` at 10 Hz waits 1 s. which is why `night` at 1 Hz waits 2.5 s where `standard` at 10 Hz waits 1 s.
If no fresh frame has arrived by `frame_timeout_ms` past the wait, the waypoint is logged and skipped. A If no fresh frame has arrived by `frame_timeout_ms` past the wait, the waypoint is logged and skipped. A

View File

@ -32,7 +32,7 @@ CMake exports `compile_commands.json` into the build dir for clangd/IDEs.
| Dependency | Needed for | How it's provided | | Dependency | Needed for | How it's provided |
|------------|-----------|-------------------| |------------|-----------|-------------------|
| **CMake ≥ 3.20**, g++/clang with C++17 | always | system package (`cmake`, official repo) | | **CMake ≥ 3.20**, g++/clang with C++17 | always | system package (`cmake`, official repo) |
| **OpenCV** (`core`, `highgui`, `imgproc`) | image rotate/display | `find_package(OpenCV)` — system package | | **OpenCV** (`core`, `imgproc`) | image rotate | `find_package(OpenCV)` — system package |
| **libjxl** (`libjxl`, `libjxl_threads`) | JPEG XL encoding | pkg-config — system package | | **libjxl** (`libjxl`, `libjxl_threads`) | JPEG XL encoding | pkg-config — system package |
| **Boost** (`program_options`; header-only Asio) | CLI parsing, serial I/O | `find_package(Boost CONFIG)` — system package | | **Boost** (`program_options`; header-only Asio) | CLI parsing, serial I/O | `find_package(Boost CONFIG)` — system package |
| **Threads** | concurrency | system | | **Threads** | concurrency | system |

View File

@ -575,7 +575,6 @@ Parsed by Boost.Program_options ([main.cpp](../main.cpp)). Flags override `[Feat
| `-c, --config <path>` | Explicit config file path | | `-c, --config <path>` | Explicit config file path |
| `-i, --init` | Run the endstop-finding init sequence before the loop | | `-i, --init` | Run the endstop-finding init sequence before the loop |
| `-s, --start` | Start capture automatically | | `-s, --start` | Start capture automatically |
| `-d, --demo` | Demo mode: copy the placeholder image instead of encoding |
| `--no-mqtt` | Disable MQTT (use the null channel) | | `--no-mqtt` | Disable MQTT (use the null channel) |
| `--mock-camera` | Use the simulated camera | | `--mock-camera` | Use the simulated camera |
| `--mock-serial` | Use the simulated motor controller | | `--mock-serial` | Use the simulated motor controller |
@ -612,12 +611,10 @@ Handled in `Application::Impl::handleCommand`.
| `trace <cat> [on\|off]` | Toggle a wire-trace category (`serial`/`mqtt`/`camera`/`control`) | | `trace <cat> [on\|off]` | Toggle a wire-trace category (`serial`/`mqtt`/`camera`/`control`) |
| `trace all` / `trace off` | Enable every category / silence all | | `trace all` / `trace off` | Enable every category / silence all |
| `set camera profile [<camera>] <name>` | Switch a camera to another profile from its `profile_dir` (the camera may be omitted when only one is configured) | | `set camera profile [<camera>] <name>` | Switch a camera to another profile from its `profile_dir` (the camera may be omitted when only one is configured) |
| `set camera jxlq <v>` | JPEG XL distance (0 = lossless) | | `snapshot` | One image now, through the scan's settle-and-trigger path (manual mode, homed) |
| `set camera jxle <v>` | JPEG XL effort | | `capture [on\|off]` | Continuous capture where the gimbal is aimed: one image per `image_interval` + standstill + `settle_wait_ms`, kept only at rest. Manual mode, homed; ends on any mode change, homing, heading setup or test. Bare: report |
| `set camera display <0\|1>` | Toggle OpenCV preview window | | `camera reset` | Reboot the cameras and put them back on their profiles |
| `set camera fps <v>` | Camera acquisition frame rate (real camera only) | | `test camera [frames\|dark\|bright]` | Check the camera delivers frames and responds to exposure/gain |
| `set fps <v>` | Capture interval rate (images/second) |
| `set motorctl <cmd>` | Forward a raw command to the motor controller (e.g. `set motorctl MOVE Y 20000`) |
| `test [<subsystem> [<leaf>]] [<profile>]` | Run the hardware self-test suite (see below) | | `test [<subsystem> [<leaf>]] [<profile>]` | Run the hardware self-test suite (see below) |
| `test list` / `test baseline` | List profiles / pin the last report as the comparison baseline | | `test list` / `test baseline` | List profiles / pin the last report as the comparison baseline |
| `imu status` | One-line orientation + sample age | | `imu status` | One-line orientation + sample age |
@ -625,7 +622,6 @@ Handled in `Application::Impl::handleCommand`.
| `imu profile [<type\|label>]` | Show the available XKF profiles, or switch to one (by numeric type or by name, e.g. `VRU_general`) | | `imu profile [<type\|label>]` | Show the available XKF profiles, or switch to one (by numeric type or by name, e.g. `VRU_general`) |
| `imu zero` | Heading reset — make the current yaw the zero reference | | `imu zero` | Heading reset — make the current yaw the zero reference |
| `imu norotation [<seconds>]` | Gyro-bias update; the sensor must be still for the whole window | | `imu norotation [<seconds>]` | Gyro-bias update; the sensor must be still for the whole window |
| `refresh` | Re-read the IMU configuration and request a fresh firmware dump |
| `help [topic]` | The command catalog, or one section/verb expanded | | `help [topic]` | The command catalog, or one section/verb expanded |
| `exit` | Quit (Ctrl-D also works) | | `exit` | Quit (Ctrl-D also works) |
@ -709,8 +705,9 @@ the original, so an interrupted save cannot truncate your config.
binding. `a` auto, `p` pan (30° around where the gimbal points now) and `m` manual lead everywhere — binding. `a` auto, `p` pan (30° around where the gimbal points now) and `m` manual lead everywhere —
the mode can be changed from any view — and `:` command, `?` help and `q` quit close every set. The the mode can be changed from any view — and `:` command, `?` help and `q` quit close every set. The
overview adds `h` home, `g s c n` views and `f` pause; an expanded view offers its own (`d` dump in overview adds `h` home, `g s c n` views and `f` pause; an expanded view offers its own (`d` dump in
the gimbal view, `j`/`k` sections in help, `Esc` close). In manual mode the arrow keys nudge the gimbal the gimbal view, `Esc` close). In manual mode the arrow keys nudge the gimbal
(yaw ±5 % / pitch ±10 %) from any view, and `Space` takes a picture. Every binding still *works* (yaw ±5 % / pitch ±10 %) from any view, `Space` takes a picture (`snapshot`) and `r` toggles continuous
capture (`capture on|off`). Every key that acts sends a command, so nothing is dashboard-only. Every binding still *works*
everywhere; only the advertising changes. everywhere; only the advertising changes.
**Scrolling is `PgUp`/`PgDn` only**, the arrows being the gimbal's. The log, the message history and a **Scrolling is `PgUp`/`PgDn` only**, the arrows being the gimbal's. The log, the message history and a
@ -767,7 +764,7 @@ The firmware speaks **full-word, newline-terminated** commands in absolute **enc
| `CENTER Y\|P <count>` | init, before `HOME` | the count homing anchors the travel midpoint to | | `CENTER Y\|P <count>` | init, before `HOME` | the count homing anchors the travel midpoint to |
| `MARGIN Y\|P <counts>` | init, before `HOME` | clearance kept from each hard endstop | | `MARGIN Y\|P <counts>` | init, before `HOME` | clearance kept from each hard endstop |
| `MOVE <yaw>,<pitch>` | each capture point | drive both axes to absolute counts (combined form) | | `MOVE <yaw>,<pitch>` | each capture point | drive both axes to absolute counts (combined form) |
| `MOVE Y\|P <pos>` | — | single-axis absolute move (`set motorctl`) | | `MOVE Y\|P <pos>` | — | single-axis absolute move (`gimbal raw`) |
| `STOP Y\|P\|ALL` | — | ramp to a controlled stop | | `STOP Y\|P\|ALL` | — | ramp to a controlled stop |
### Motor current, and why the host computes it ### Motor current, and why the host computes it

View File

@ -59,7 +59,7 @@ applies white balance before de-Bayering). `DeviceLinkThroughputLimit` is kept c
≥450 caused incomplete frames). Full 20 MP is possible via BayerRG8 (20 MB) + a host de-Bayer step if ≥450 caused incomplete frames). Full 20 MP is possible via BayerRG8 (20 MB) + a host de-Bayer step if
reduced resolution proves insufficient. reduced resolution proves insufficient.
**Acquisition is a continuous free-running stream** at the active mode's `AcquisitionFrameRate`; the **Acquisition is a continuous free-running stream** at the active profile's `AcquisitionFrameRate`; the
scheduler stops at each waypoint, waits `settle_wait_ms`, and keeps the newest frame. This replaced a scheduler stops at each waypoint, waits `settle_wait_ms`, and keeps the newest frame. This replaced a
per-waypoint software trigger, which worked on the rig but existed to serve a quality gate that no longer per-waypoint software trigger, which worked on the rig but existed to serve a quality gate that no longer
does. Free-running is what keeps the camera's own auto-exposure converged, and the frames it discards does. Free-running is what keeps the camera's own auto-exposure converged, and the frames it discards
@ -114,8 +114,6 @@ scripts for this are worth keeping; note that wrapping them in `timeout` SIGTERM
`include/fgc/HardwareChecks.h`. `include/fgc/HardwareChecks.h`.
- **Makefile parity** is moot: the Makefile was removed in favour of CMake, and the full - **Makefile parity** is moot: the Makefile was removed in favour of CMake, and the full
`WITH_VIMBA=ON WITH_MQTT=ON` CMake build now runs on the device. `WITH_VIMBA=ON WITH_MQTT=ON` CMake build now runs on the device.
- **Demo mode** copies `bin/x64/Release/test_smoke.jxl`, resolved relative to the working directory. Run from a
directory where that path exists, or extend `ImagePipeline::Params::demo_image`.
## Possible follow-ups (not done) ## Possible follow-ups (not done)

View File

@ -45,7 +45,7 @@ Per-file reference for the refactored tree, plus the shared data structures.
| File | Interface | Shared structs | | File | Interface | Shared structs |
|------|-----------|----------------| |------|-----------|----------------|
| [include/fgc/IMotorController.h](../include/fgc/IMotorController.h) | `IMotorController` | `MotorTelemetry` | | [include/fgc/IMotorController.h](../include/fgc/IMotorController.h) | `IMotorController` | `MotorTelemetry` |
| [include/fgc/IControlChannel.h](../include/fgc/IControlChannel.h) | `IControlChannel` | `ControlCommand`, `CamEvent` | | [include/fgc/IControlChannel.h](../include/fgc/IControlChannel.h) | `IControlChannel` | `RemoteCommand`, `CommandReply`, `CamEvent` |
| [include/fgc/ICameraSource.h](../include/fgc/ICameraSource.h) | `ICameraSource` | `Frame` | | [include/fgc/ICameraSource.h](../include/fgc/ICameraSource.h) | `ICameraSource` | `Frame` |
| [include/fgc/IImuSource.h](../include/fgc/IImuSource.h) | `IImuSource` (incl. `config()`, `configProblems()`, `refreshConfig()`, `noRotation()`, `headingReset()`, `setFilterProfile()`) | `ImuSample` (from `MtiProtocol.h`) | | [include/fgc/IImuSource.h](../include/fgc/IImuSource.h) | `IImuSource` (incl. `config()`, `configProblems()`, `refreshConfig()`, `noRotation()`, `headingReset()`, `setFilterProfile()`) | `ImuSample` (from `MtiProtocol.h`) |
| [include/fgc/IEnvSensor.h](../include/fgc/IEnvSensor.h) | `IEnvSensor` (`connected()`, `sample()`, `name()`) | `EnvSample` | | [include/fgc/IEnvSensor.h](../include/fgc/IEnvSensor.h) | `IEnvSensor` (`connected()`, `sample()`, `name()`) | `EnvSample` |
@ -137,7 +137,6 @@ Surfaced read-only in the TUI's `s` view (IMU CONFIG section); the legacy "scena
| Artifact | Path | Format | | Artifact | Path | Format |
|----------|------|--------| |----------|------|--------|
| Captured images | `<output_dir>/<label>/<unix_ms>.jxl` | JPEG XL, rotated per that camera's `rotation_deg`; `<label>` is `[Camera.<name>] label` | | Captured images | `<output_dir>/<label>/<unix_ms>.jxl` | JPEG XL, rotated per that camera's `rotation_deg`; `<label>` is `[Camera.<name>] label` |
| Demo placeholder | `bin/x64/Release/test_smoke.jxl` | copied verbatim in demo mode |
| Diagnostics log | `<data_dir>/fire_gimbal_control/logs/diag_<ts>.log` | raw `DG` stream + parsed summary | | Diagnostics log | `<data_dir>/fire_gimbal_control/logs/diag_<ts>.log` | raw `DG` stream + parsed summary |
| Calibration log | `<data_dir>/fire_gimbal_control/logs/calib_<ts>.log` | per-axis samples + the least-squares fit | | Calibration log | `<data_dir>/fire_gimbal_control/logs/calib_<ts>.log` | per-axis samples + the least-squares fit |

View File

@ -142,7 +142,7 @@ web maintenance interface; a tower in normal service simply never receives any.
**How a command runs.** Exactly as if typed: the same handler **How a command runs.** Exactly as if typed: the same handler
(`Application::handleCommand()`), on the same control thread, with the same (`Application::handleCommand()`), on the same control thread, with the same
refusals. `gimbal move` during a sweep is refused as it is at the console refusals. `gimbal move` during a sweep is refused as it is at the console
(send `stop` first); a command sent while a test or homing runs gets "ignored (send `mode manual` first); a command sent while a test or homing runs gets "ignored
... in progress". Commands run in the order they arrive, one at a time, each ... in progress". Commands run in the order they arrive, one at a time, each
answered before the next starts. answered before the next starts.
@ -219,7 +219,8 @@ So a web interface either:
{"id":"w3","cmd":"mode manual"} # StatusCode "2"; now anything goes {"id":"w3","cmd":"mode manual"} # StatusCode "2"; now anything goes
{"id":"w4","cmd":"gimbal move 95,5"} {"id":"w4","cmd":"gimbal move 95,5"}
{"id":"w5","cmd":"snapshot"} {"id":"w5","cmd":"snapshot"} # starts the camera itself; one image
{"id":"w5b","cmd":"capture on"} # or: keep one image per cycle here until 'capture off'
{"id":"w6","cmd":"mode auto"} # back to the sweep; StatusCode "0" {"id":"w6","cmd":"mode auto"} # back to the sweep; StatusCode "0"
``` ```

View File

@ -13,7 +13,6 @@ namespace fgc {
struct RuntimeOptions { struct RuntimeOptions {
bool init = false; // run the endstop-finding init sequence bool init = false; // run the endstop-finding init sequence
bool start = false; // begin capture automatically bool start = false; // begin capture automatically
bool demo = false; // demo mode (copy placeholder image instead of encoding)
// Start anyway when hardware.ini declares something this rig does not have. // Start anyway when hardware.ini declares something this rig does not have.
// Field triage only: it overrides [Features] strict_presence, and the log // 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. // says so in those words so it cannot become the normal way to run a tower.

View File

@ -26,7 +26,8 @@ namespace fgc {
// //
// What it captures is the mode (OperatingMode.h): auto sweeps the ScanGrid front // What it captures is the mode (OperatingMode.h): auto sweeps the ScanGrid front
// to back, repeating; pan walks panHeadings() left to right at the pan's pitch, // to back, repeating; pan walks panHeadings() left to right at the pan's pitch,
// then jumps back to the left edge; manual captures nothing (bar a snapshot). // then jumps back to the left edge; manual captures nothing, bar a snapshot or
// continuous capture - both of which shoot wherever the operator has aimed.
// Degrees are converted to encoder counts via Geometry. // Degrees are converted to encoder counts via Geometry.
class CaptureScheduler { class CaptureScheduler {
public: public:
@ -57,8 +58,14 @@ public:
void requestSnapshot(); void requestSnapshot();
bool snapshotPending() const { return snapshot_pending_; } bool snapshotPending() const { return snapshot_pending_; }
void setImageRate(double rate); // images per second // Continuous capture (`capture on|off`): in manual, keep a frame on the scan's
double imageRate() const { return image_rate_; } // own cycle - the interval, then standstill + settle wait - wherever the
// operator has aimed. The gimbal stays free to nudge; a frame is only kept at
// rest. Ignored outside manual, and cleared by any setMode().
void setContinuous(bool on);
bool continuous() const { return continuous_; }
double imageRate() const { return image_rate_; } // images per second
// Per-camera-profile timing (see CameraProfile): how long to wait after standstill // Per-camera-profile timing (see CameraProfile): how long to wait after standstill
// before keeping a frame, and how long to keep retrying before abandoning the // before keeping a frame, and how long to keep retrying before abandoning the
@ -85,6 +92,13 @@ private:
void sendMove(); // emit MOVE to (yaw_target_, pitch_target_) void sendMove(); // emit MOVE to (yaw_target_, pitch_target_)
bool settledAt(const MotorTelemetry& t) const; bool settledAt(const MotorTelemetry& t) const;
void serviceSnapshot(const MotorTelemetry& t); void serviceSnapshot(const MotorTelemetry& t);
void serviceContinuous(const MotorTelemetry& t);
// The part a snapshot and a continuous-capture frame share: wait for the
// gimbal to stand still, then the settle wait, then trigger until the frame
// budget runs out. `settled_ms` is that request's own settle clock.
enum class Shot { Waiting, Captured, TimedOut };
Shot shootAtRest(const MotorTelemetry& t, long long& settled_ms);
IMotorController& motor_; IMotorController& motor_;
ICameraSource& camera_; ICameraSource& camera_;
@ -113,6 +127,8 @@ private:
bool snapshot_pending_ = false; // a manual snapshot is armed bool snapshot_pending_ = false; // a manual snapshot is armed
long long snapshot_settled_ms_ = 0; // when standstill was first seen (0 = not yet) long long snapshot_settled_ms_ = 0; // when standstill was first seen (0 = not yet)
bool continuous_ = false; // `capture on`
long long continuous_settled_ms_ = 0;
long yaw_target_ = 0; // current target, encoder counts long yaw_target_ = 0; // current target, encoder counts
long pitch_target_ = 0; long pitch_target_ = 0;
}; };

View File

@ -6,10 +6,10 @@ namespace fgc {
// A parsed console command line: "<verb> [device] [option] [value]". // A parsed console command line: "<verb> [device] [option] [value]".
// Examples: // Examples:
// "start" -> verb=start // "snapshot" -> verb=snapshot
// "set fps 5" -> verb=set device=fps value=5 // "capture on" -> verb=capture device=on
// "set camera jxlq 2" -> verb=set device=camera option=jxlq value=2 // "gimbal nudge yaw 5" -> verb=gimbal device=nudge option=yaw value=5
// "set motorctl kd180" -> verb=set device=motorctl option=kd180 // "set camera profile night" -> verb=set device=camera option="profile night"
struct Command { struct Command {
std::string verb; std::string verb;
std::string device; std::string device;

View File

@ -42,9 +42,6 @@ public:
std::string output_dir; std::string output_dir;
std::vector<CameraOutput> cameras; // by camera index, in open order std::vector<CameraOutput> cameras; // by camera index, in open order
std::string tower; std::string tower;
bool demo = false; // copy demo_image instead of encoding
std::string demo_image = "test_smoke.jxl";
bool display = false; // show an OpenCV preview window
}; };
// orientation: returns the current gimbal yaw+pitch (degrees) for CamEvent. // orientation: returns the current gimbal yaw+pitch (degrees) for CamEvent.
@ -67,16 +64,6 @@ public:
// different pose than the pixels. // different pose than the pixels.
void submit(const Frame& frame); void submit(const Frame& frame);
// Runtime-adjustable encoder settings (console "set camera ..." commands).
// These apply to every camera: they exist for quick experiments, and asking
// which camera on every keystroke would make that worse, not better.
void setDistance(double d) { for (auto& c : params_.cameras) c.jxl_distance = d; }
void setEffort(int e) { for (auto& c : params_.cameras) c.jxl_effort = e; }
// Toggled from the control thread while the worker reads it, hence atomic.
// The worker also CLEARS it when the display turns out to be unusable, so this
// is genuinely shared state rather than a one-way setting.
void setDisplay(bool on) { display_.store(on); }
// Last CamEvent published (camera label, heading/pitch, timestamp), for the // Last CamEvent published (camera label, heading/pitch, timestamp), for the
// UI's camera panel. Thread-safe; nullopt until the first capture is saved. // UI's camera panel. Thread-safe; nullopt until the first capture is saved.
std::optional<CamEvent> lastEvent() const; std::optional<CamEvent> lastEvent() const;
@ -108,10 +95,6 @@ private:
std::atomic<bool> running_{false}; std::atomic<bool> running_{false};
std::thread worker_; std::thread worker_;
// Live preview state. Seeded from Params::display, then owned by setDisplay()
// and by process()'s self-disable on a display failure.
std::atomic<bool> display_{false};
mutable std::mutex last_event_mutex_; mutable std::mutex last_event_mutex_;
std::optional<CamEvent> last_event_; std::optional<CamEvent> last_event_;
std::atomic<long long> saved_{0}; // images successfully written this session std::atomic<long long> saved_{0}; // images successfully written this session

View File

@ -147,6 +147,7 @@ struct CaptureView {
// No scan running, so hand aiming (arrows) and `snapshot` are accepted. // No scan running, so hand aiming (arrows) and `snapshot` are accepted.
bool manual = true; bool manual = true;
bool snapshot_armed = false; // a snapshot is waiting to fire bool snapshot_armed = false; // a snapshot is waiting to fire
bool continuous = false; // `capture on` (manual mode)
double image_rate = 0.0; // img/s double image_rate = 0.0; // img/s
int camera_count = 0; int camera_count = 0;
std::vector<std::string> labels; std::vector<std::string> labels;

View File

@ -50,7 +50,6 @@ int main(int argc, char* argv[]) {
"or one beside config.ini)") "or one beside config.ini)")
("init,i", po::bool_switch(), "home the gimbal on startup (required: nothing is homed across a power cycle)") ("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") ("start,s", po::bool_switch(), "start in auto mode (sweep and capture); without it, manual")
("demo,d", po::bool_switch(), "demo mode (copy placeholder image instead of encoding)")
("no-mqtt", po::bool_switch(), "disable MQTT (use a null control channel)") ("no-mqtt", po::bool_switch(), "disable MQTT (use a null control channel)")
("mock-camera", po::bool_switch(), "use a simulated camera (no hardware)") ("mock-camera", po::bool_switch(), "use a simulated camera (no hardware)")
("mock-serial", po::bool_switch(), "use a simulated motor controller (no hardware)") ("mock-serial", po::bool_switch(), "use a simulated motor controller (no hardware)")
@ -205,7 +204,6 @@ int main(int argc, char* argv[]) {
RuntimeOptions opts; RuntimeOptions opts;
opts.init = vm["init"].as<bool>(); opts.init = vm["init"].as<bool>();
opts.start = vm["start"].as<bool>(); opts.start = vm["start"].as<bool>();
opts.demo = vm["demo"].as<bool>();
if (vm["no-mqtt"].as<bool>()) opts.use_mqtt = false; if (vm["no-mqtt"].as<bool>()) opts.use_mqtt = false;
if (vm["mock-camera"].as<bool>()) opts.mock_camera = true; if (vm["mock-camera"].as<bool>()) opts.mock_camera = true;
if (vm["mock-serial"].as<bool>()) opts.mock_serial = true; if (vm["mock-serial"].as<bool>()) opts.mock_serial = true;

View File

@ -5,7 +5,8 @@
#include <filesystem> #include <filesystem>
#include <opencv2/opencv.hpp> #include <opencv2/core.hpp>
#include <opencv2/imgproc.hpp>
namespace fs = std::filesystem; namespace fs = std::filesystem;
@ -28,9 +29,7 @@ bool rotateCode(int deg, int& code) {
ImagePipeline::ImagePipeline(IControlChannel& channel, std::function<Orientation()> orientation, ImagePipeline::ImagePipeline(IControlChannel& channel, std::function<Orientation()> orientation,
Params params) Params params)
: channel_(channel), orientation_(std::move(orientation)), params_(std::move(params)) { : channel_(channel), orientation_(std::move(orientation)), params_(std::move(params)) {}
display_.store(params_.display);
}
ImagePipeline::~ImagePipeline() { stop(); } ImagePipeline::~ImagePipeline() { stop(); }
@ -98,29 +97,6 @@ void ImagePipeline::process(const QueuedFrame& queued) {
// src only wraps frame.data, so an unrotated frame costs no copy at all. // src only wraps frame.data, so an unrotated frame costs no copy at all.
const cv::Mat& img = rotate ? rotated : src; const cv::Mat& img = rotate ? rotated : src;
if (display_.load()) {
// This runs on the worker thread, so anything escaping here terminates the
// whole program - and OpenCV's GUI calls throw whenever there is no usable
// display: no X/Wayland, DISPLAY unset or pointing at a dead server (an SSH
// session without X11 forwarding), or a highgui built with no GUI backend.
// A preview window is never worth taking a tower down for, so failure
// disables it and capture carries on untouched.
try {
cv::namedWindow("Display Image", cv::WINDOW_NORMAL);
cv::resizeWindow("Display Image", img.cols / 4, img.rows / 4);
cv::imshow("Display Image", img);
cv::waitKey(10);
} catch (const std::exception& e) {
display_.store(false);
LOG_WARN << "local display unavailable; preview disabled (capture is unaffected): "
<< e.what();
} catch (...) {
display_.store(false);
LOG_WARN << "local display failed for an unknown reason; preview disabled "
"(capture is unaffected)";
}
}
const std::string label = out.label.empty() ? ("cam" + std::to_string(frame.cam_id)) const std::string label = out.label.empty() ? ("cam" + std::to_string(frame.cam_id))
: out.label; : out.label;
const fs::path dir = fs::path(params_.output_dir) / label; const fs::path dir = fs::path(params_.output_dir) / label;
@ -132,17 +108,10 @@ void ImagePipeline::process(const QueuedFrame& queued) {
} }
const fs::path file = dir / (std::to_string(frame.timestamp_ms) + ".jxl"); const fs::path file = dir / (std::to_string(frame.timestamp_ms) + ".jxl");
if (params_.demo) { if (!JpegXlEncoder::encodeToFile(file.string(), img.data, img.cols, img.rows,
fs::copy_file(params_.demo_image, file, fs::copy_options::overwrite_existing, ec); frame.channels, out.jxl_distance, out.jxl_effort)) {
if (ec) LOG_ERROR << "Demo copy failed: " << ec.message(); LOG_ERROR << "Failed to encode " << file.string();
} else { return;
bool ok = JpegXlEncoder::encodeToFile(file.string(), img.data, img.cols, img.rows,
frame.channels, out.jxl_distance,
out.jxl_effort);
if (!ok) {
LOG_ERROR << "Failed to encode " << file.string();
return;
}
} }
// Sampled at submit(), not here: by this point the encode above has run and // Sampled at submit(), not here: by this point the encode above has run and

View File

@ -224,7 +224,7 @@ struct Application::Impl {
std::unique_ptr<IControlChannel> channel; std::unique_ptr<IControlChannel> channel;
std::unique_ptr<IMotorController> motor; std::unique_ptr<IMotorController> motor;
std::unique_ptr<ICameraSource> camera; std::unique_ptr<ICameraSource> camera;
// Camera modes, loaded once at startup and kept so a live switch needs no disk // Camera profiles, loaded once at startup and kept so a live switch needs no disk
// access. Keyed by camera name, then profile name; `active_profile` tracks // access. Keyed by camera name, then profile name; `active_profile` tracks
// which profile each camera is currently on. // which profile each camera is currently on.
std::map<std::string, std::map<std::string, CameraProfile>> camera_profiles; std::map<std::string, std::map<std::string, CameraProfile>> camera_profiles;
@ -869,7 +869,8 @@ struct Application::Impl {
// --- Camera / capture --- // --- Camera / capture ---
s.capture.present = true; s.capture.present = true;
s.capture.active = scheduler && scheduler->captureActive(); s.capture.continuous = scheduler && scheduler->continuous();
s.capture.active = scheduler && (scheduler->captureActive() || s.capture.continuous);
// Manual = no scan running. Drives what the key bar advertises; the // Manual = no scan running. Drives what the key bar advertises; the
// enforcement is in requireManual(), not in the UI. // enforcement is in requireManual(), not in the UI.
s.capture.manual = manualMode(); s.capture.manual = manualMode();
@ -1033,6 +1034,11 @@ struct Application::Impl {
a.title = "AUTO"; a.title = "AUTO";
a.status = "no scan grid — auto-sweep disabled"; a.status = "no scan grid — auto-sweep disabled";
} }
} else if (scheduler && scheduler->continuous()) {
a.active = true;
a.title = "CAPTURE";
a.status = "continuous · one image per cycle while the gimbal stands still · "
"'capture off' (r) to stop";
} }
// A running procedure is cancelable with Esc. // A running procedure is cancelable with Esc.
@ -1123,6 +1129,7 @@ struct Application::Impl {
void runInitSequence() { void runInitSequence() {
using namespace std::chrono_literals; using namespace std::chrono_literals;
LOG_INFO << "Running gimbal init sequence (enable + home)"; LOG_INFO << "Running gimbal init sequence (enable + home)";
stopContinuous("homing");
// Mark homing active so interfering commands are ignored and Esc cancels. // Mark homing active so interfering commands are ignored and Esc cancels.
// This blocks the control thread, so commands are drained+discarded here. // This blocks the control thread, so commands are drained+discarded here.
homing_active_ = true; homing_active_ = true;
@ -1790,6 +1797,7 @@ struct Application::Impl {
if (m == Mode::Pan && !checkPan(pan)) return false; if (m == Mode::Pan && !checkPan(pan)) return false;
const Mode was = scheduler->mode(); const Mode was = scheduler->mode();
if (scheduler->continuous()) LOG_INFO << "continuous capture off (mode change)";
if (m == Mode::Manual) camera->stop(); if (m == Mode::Manual) camera->stop();
else camera->start(); else camera->start();
scheduler->setMode(m, pan); scheduler->setMode(m, pan);
@ -1864,7 +1872,8 @@ struct Application::Impl {
<< (h.size() == 1 ? " position" : " positions") << " (" << panRangeText(h) << (h.size() == 1 ? " position" : " positions") << " (" << panRangeText(h)
<< "), at " << (scheduler->panIndex() + 1) << "/" << h.size(); << "), at " << (scheduler->panIndex() + 1) << "/" << h.size();
} else { } else {
LOG_INFO << "mode " << modeName(m); LOG_INFO << "mode " << modeName(m)
<< (scheduler->continuous() ? " (continuous capture on)" : "");
} }
} }
@ -1884,6 +1893,7 @@ struct Application::Impl {
return; return;
} }
if (!requireManual("gimbal heading setup")) return; if (!requireManual("gimbal heading setup")) return;
stopContinuous("heading setup");
heading_setup_active_ = true; heading_setup_active_ = true;
heading_marks_.clear(); heading_marks_.clear();
LOG_INFO << "heading setup: aim at a landmark whose true bearing you know, then " LOG_INFO << "heading setup: aim at a landmark whose true bearing you know, then "
@ -2016,6 +2026,51 @@ struct Application::Impl {
LOG_INFO << "snapshot requested"; LOG_INFO << "snapshot requested";
} }
// `capture on|off` - keep frames on the scan's own cycle wherever the gimbal
// is aimed, while leaving it free to nudge (manual mode). Bare: report.
void handleCapture(const std::string& what) {
if (!scheduler || !camera) { LOG_WARN << "capture: no camera configured"; return; }
if (what.empty()) {
LOG_INFO << "continuous capture " << (scheduler->continuous() ? "on" : "off");
return;
}
if (what == "off") {
if (!scheduler->continuous()) { LOG_INFO << "continuous capture already off"; return; }
scheduler->setContinuous(false);
camera->stop();
LOG_INFO << "continuous capture off";
return;
}
if (what != "on") { LOG_WARN << "usage: capture [on|off]"; return; }
if (!requireManual("capture on")) return;
if (heading_setup_active_) {
LOG_WARN << "capture on: heading setup in progress ('gimbal heading cancel' first)";
return;
}
MotorTelemetry t = motor->telemetry();
if (!t.yaw.ready() || (t.pitch_present && !t.pitch.ready())) {
LOG_WARN << "capture on: gimbal not homed (press h / run 'gimbal home' first)";
return;
}
camera->start();
scheduler->setContinuous(true);
const double interval_s =
scheduler->imageRate() > 0.0 ? 1.0 / scheduler->imageRate() : 0.0;
LOG_INFO << "continuous capture on: one image every ~"
<< interval_s + scheduler->settleWaitMs() / 1000.0 << " s ("
<< interval_s << " s interval + " << scheduler->settleWaitMs()
<< " ms settle), kept only while the gimbal stands still";
}
// End continuous capture because something else is taking the gimbal or the
// camera. Says so, so the operator is not left thinking it is still running.
void stopContinuous(const char* why) {
if (!scheduler || !scheduler->continuous()) return;
scheduler->setContinuous(false);
if (camera) camera->stop();
LOG_INFO << "continuous capture off (" << why << ")";
}
// Hand aiming and a scan are mutually exclusive: both drive the same axes, // Hand aiming and a scan are mutually exclusive: both drive the same axes,
// and a nudge mid-sweep would silently corrupt the waypoint the next frame is // and a nudge mid-sweep would silently corrupt the waypoint the next frame is
// published under. The mode gate in handleCommand() already refuses these // published under. The mode gate in handleCommand() already refuses these
@ -2413,7 +2468,7 @@ struct Application::Impl {
std::string err; std::string err;
if (!resolveTestSelection(subsystem, leaf, mask, err)) { if (!resolveTestSelection(subsystem, leaf, mask, err)) {
LOG_WARN << "test: " << err; LOG_WARN << "test: " << err;
LOG_INFO << "usage: test [gimbal|imu|host [<leaf>]] [<profile>] (also: test list, test baseline)"; LOG_INFO << "usage: test [gimbal|imu|host|camera|hardware [<leaf>]] [<profile>] (also: test list, test baseline)";
return; return;
} }
startTest(mask, profileName); startTest(mask, profileName);
@ -2449,6 +2504,7 @@ struct Application::Impl {
// --verify does exactly that) must not leave it idle afterwards. // --verify does exactly that) must not leave it idle afterwards.
// Typed tests only run in manual (allowedIn); a --test at startup may find // Typed tests only run in manual (allowedIn); a --test at startup may find
// a scan running, and gets it back afterwards. // a scan running, and gets it back afterwards.
stopContinuous("test");
resume_mode_after_test_ = scheduler->mode(); resume_mode_after_test_ = scheduler->mode();
resume_pan_after_test_ = scheduler->pan(); resume_pan_after_test_ = scheduler->pan();
if (resume_mode_after_test_ != Mode::Manual) if (resume_mode_after_test_ != Mode::Manual)
@ -2666,7 +2722,7 @@ struct Application::Impl {
// long op, or a DUMP over the same serial link). Ignored while busy. // long op, or a DUMP over the same serial link). Ignored while busy.
static bool interferingVerb(const std::string& verb) { static bool interferingVerb(const std::string& verb) {
return verb == "gimbal" || verb == "mode" || verb == "test" || return verb == "gimbal" || verb == "mode" || verb == "test" ||
verb == "snapshot" || verb == "camera"; verb == "snapshot" || verb == "capture" || verb == "camera";
} }
void handleCommand(const std::string& line) { void handleCommand(const std::string& line) {
@ -2723,6 +2779,8 @@ struct Application::Impl {
else LOG_WARN << "usage: camera reset"; else LOG_WARN << "usage: camera reset";
} else if (c.verb == "snapshot") { } else if (c.verb == "snapshot") {
takeSnapshot(); takeSnapshot();
} else if (c.verb == "capture") {
handleCapture(c.device);
} else if (c.verb == "mode") { } else if (c.verb == "mode") {
handleMode(line); handleMode(line);
} else if (c.verb == "debug") { } else if (c.verb == "debug") {
@ -2746,25 +2804,9 @@ struct Application::Impl {
else else
LOG_WARN << "more than one camera configured: " LOG_WARN << "more than one camera configured: "
"usage: set camera profile <camera> <name>"; "usage: set camera profile <camera> <name>";
} else if (c.option == "profile") { } else {
LOG_WARN << "usage: set camera profile [<camera>] <name>"; LOG_WARN << "usage: set camera profile [<camera>] <name>";
} }
else if (c.option == "jxlq" && c.has_value) pipeline->setDistance(c.value);
else if (c.option == "jxle" && c.has_value) pipeline->setEffort(static_cast<int>(c.value));
else if (c.option == "display") {
const bool on = c.value != 0.0;
pipeline->setDisplay(on);
// Confirm it: the preview only repaints when a frame goes
// through the pipeline, so with no capture running there is
// nothing on screen and silence is indistinguishable from the
// command having been rejected.
LOG_INFO << "local display " << (on ? "on" : "off")
<< " (updates on the next captured frame)";
}
else LOG_WARN << "unknown 'set camera' option: " << c.option;
} else if (c.device == "fps" && c.has_value) {
scheduler->setImageRate(c.value);
LOG_INFO << "capture rate set to " << c.value << " img/s";
} else { } else {
LOG_WARN << "unknown 'set' target: " << c.device; LOG_WARN << "unknown 'set' target: " << c.device;
} }
@ -2929,7 +2971,6 @@ struct Application::Impl {
ImagePipeline::Params pp; ImagePipeline::Params pp;
pp.output_dir = cfg.paths.output_dir; pp.output_dir = cfg.paths.output_dir;
pp.tower = cfg.general.tower_name; pp.tower = cfg.general.tower_name;
pp.demo = opts.demo;
// In open order, matching Frame::cam_id: the source opens the active // In open order, matching Frame::cam_id: the source opens the active
// cameras in this order too. // cameras in this order too.
for (const auto* c : cfg.camera.active()) for (const auto* c : cfg.camera.active())

View File

@ -46,9 +46,20 @@ void CaptureScheduler::setMode(Mode mode, const PanSpec& pan) {
// keeping it would fire an image at some unrelated later moment when the scan // keeping it would fire an image at some unrelated later moment when the scan
// stops - attributed to whatever the gimbal happened to be pointing at then. // stops - attributed to whatever the gimbal happened to be pointing at then.
snapshot_pending_ = false; snapshot_pending_ = false;
// Continuous capture is a manual-mode activity: any mode change ends it, so
// a later return to manual never resumes shooting nobody asked for.
continuous_ = false;
continuous_settled_ms_ = 0;
resetTimer();
}
void CaptureScheduler::setContinuous(bool on) {
continuous_ = on;
continuous_settled_ms_ = 0;
// The first frame comes one interval after switching on, as the first scan
// waypoint does after `mode auto`.
resetTimer(); resetTimer();
} }
void CaptureScheduler::setImageRate(double rate) { image_rate_ = rate; }
void CaptureScheduler::setSettleWait(int settle_ms, int timeout_ms) { void CaptureScheduler::setSettleWait(int settle_ms, int timeout_ms) {
settle_wait_ms_ = settle_ms < 0 ? 0 : settle_ms; settle_wait_ms_ = settle_ms < 0 ? 0 : settle_ms;
@ -82,38 +93,66 @@ void CaptureScheduler::requestSnapshot() {
snapshot_settled_ms_ = 0; snapshot_settled_ms_ = 0;
} }
// One frame, on request, through exactly the path a scan waypoint takes: the // Wait for rest, settle, then shoot - the tail of a scan waypoint without the
// same standstill + settle wait, the same camera trigger, and therefore the same // move. A snapshot and a continuous-capture frame both come through here, so
// .jxl on disk and the same CamEvent on MQTT. Reusing the path rather than // either is exactly what a scan waypoint produces: same file, same CamEvent.
// triggering the camera directly is the point - a snapshot that differed from a //
// scan frame would be useless as a reference for aiming. // Standstill only, no target check: the operator aims, so there is no target.
void CaptureScheduler::serviceSnapshot(const MotorTelemetry& t) { // The frame budget is measured from settling, not from the request - a request
// made mid-slew must not spend the camera's budget on the slew.
CaptureScheduler::Shot CaptureScheduler::shootAtRest(const MotorTelemetry& t,
long long& settled_ms) {
const bool standstill = t.yaw.standstill && (!pitch_present_ || t.pitch.standstill); const bool standstill = t.yaw.standstill && (!pitch_present_ || t.pitch.standstill);
if (!standstill) { if (!standstill) {
snapshot_settled_ms_ = 0; // still slewing (e.g. a jog is in flight) settled_ms = 0; // still slewing (e.g. a jog is in flight)
return; return Shot::Waiting;
} }
if (snapshot_settled_ms_ == 0) { if (settled_ms == 0) {
snapshot_settled_ms_ = now_ms_(); settled_ms = now_ms_();
return; return Shot::Waiting;
} }
const long long since_settled = now_ms_() - snapshot_settled_ms_; const long long since_settled = now_ms_() - settled_ms;
if (since_settled < settle_wait_ms_) return; if (since_settled < settle_wait_ms_) return Shot::Waiting;
if (camera_.trigger()) return Shot::Captured;
if (since_settled > settle_wait_ms_ + frame_timeout_ms_) return Shot::TimedOut;
return Shot::Waiting; // no fresh frame yet; retry next tick
}
if (camera_.trigger()) { void CaptureScheduler::serviceSnapshot(const MotorTelemetry& t) {
LOG_INFO << "snapshot captured"; switch (shootAtRest(t, snapshot_settled_ms_)) {
snapshot_pending_ = false; case Shot::Waiting:
return; return;
case Shot::Captured:
LOG_INFO << "snapshot captured";
break;
case Shot::TimedOut:
LOG_WARN << "snapshot: no frame within " << frame_timeout_ms_
<< " ms of settling; send 'snapshot' (Space) again";
break;
} }
// No fresh frame yet. Give up on the same budget a waypoint gets - measured snapshot_pending_ = false;
// from settling, as the message says, not from the keypress: the operator may }
// have pressed Space mid-slew, and counting the slew against the camera would
// abandon a snapshot the camera never had a chance at. // The scan's cycle without the move: wait the interval, then shoot at rest.
if (since_settled > settle_wait_ms_ + frame_timeout_ms_) { // The timer restarts after each frame kept or given up, exactly as a scan's
LOG_WARN << "snapshot: no frame within " << frame_timeout_ms_ // does, so the cadence matches what a scan achieves rather than the bare
<< " ms of settling; press Space again"; // interval.
snapshot_pending_ = false; void CaptureScheduler::serviceContinuous(const MotorTelemetry& t) {
const double interval_ms = image_rate_ > 0.0 ? 1000.0 / image_rate_ : 0.0;
if (interval_ms <= 0.0 || elapsedMs() <= interval_ms) return;
switch (shootAtRest(t, continuous_settled_ms_)) {
case Shot::Waiting:
return;
case Shot::Captured:
LOG_INFO << "continuous capture: captured";
break;
case Shot::TimedOut:
LOG_WARN << "continuous capture: no frame within " << frame_timeout_ms_
<< " ms of settling; trying again next interval";
break;
} }
continuous_settled_ms_ = 0;
resetTimer();
} }
void CaptureScheduler::tick() { void CaptureScheduler::tick() {
@ -121,10 +160,13 @@ void CaptureScheduler::tick() {
MotorTelemetry t = motor_.telemetry(); MotorTelemetry t = motor_.telemetry();
pitch_present_ = t.pitch_present; pitch_present_ = t.pitch_present;
// 2. Manual snapshot. Only in manual, so a keypress can never inject a frame // 2. Manual: a snapshot, else continuous capture. Only in manual, so a
// into a running sweep or fight it for the settle window. // request can never inject a frame into a running sweep or fight it for
// the settle window. A snapshot goes first; continuous capture resumes
// on its own clock afterwards.
if (mode_ == Mode::Manual) { if (mode_ == Mode::Manual) {
if (snapshot_pending_) serviceSnapshot(t); if (snapshot_pending_) serviceSnapshot(t);
else if (continuous_) serviceContinuous(t);
return; return;
} }

View File

@ -24,217 +24,102 @@ std::string verbOf(const std::string& syntax) {
const std::vector<HelpSection>& helpCatalog() { const std::vector<HelpSection>& helpCatalog() {
// clang-format off // clang-format off
static const std::vector<HelpSection> catalog = { static const std::vector<HelpSection> catalog = {
{"Modes", {"Modes", "What the tower is doing. MQTT StatusCode: 0 auto, 1 pan, 2 manual.", {
"What the tower is doing: auto, pan or manual. StatusCode on MQTT says which (0/1/2).", { {"mode", "Report the current mode.", {}},
{"mode", {"mode auto", "Sweep the scan grid, one image per waypoint (key a).", {
"Say which mode the tower is in (and, in pan, where it is looking).", {}}, "The unattended state; --start boots into it. In auto and pan only",
{"mode auto", "status, logging, 'mode', 'cancel' and 'exit' are accepted."}},
"Sweep the scan grid, a picture at every waypoint. Key: a.", { {"mode pan <heading> <span> [<pitch>]", "Sweep a span centred on a bearing, repeatedly (key p).", {
"The normal, unattended state; the boot service starts here (--start).", "One image every pan_step_deg (hardware.ini), both edges included.",
"In auto and pan only reading commands, logging, 'mode', 'cancel' and", "Pitch defaults to 0 (level). Key p pans 30 deg around the current aim.",
"'exit' are accepted: nothing that moves the gimbal or changes a setting."}}, "Example: mode pan 90 30"}},
{"mode pan <heading> <span> [<pitch>]", {"mode manual", "Stop scanning; aim by hand (key m). Cancels a running procedure.", {}},
"Watch one direction: sweep a span centred on a bearing, repeatedly.", {
"Left to right across heading +/- span/2, one picture every pan_step_deg",
"(hardware.ini) or less, both edges included; then back to the left edge.",
"A span under one picture is the one heading. Pitch defaults to 0 (level).",
"Example: mode pan 90 30 (75, 81, ..., 105: six pictures). Key p pans",
"30 deg around where the gimbal points now."}},
{"mode manual",
"Stop capturing and take the gimbal by hand. Key: m.", {
"Every command is accepted. With a test, homing or calibration running,",
"cancels it instead (as Esc and 'cancel' do)."}},
}}, }},
{"Positioning", {"Capture", "Images and cameras. Manual mode, homed gimbal.", {
"Aim the gimbal. Yaw is a TRUE BEARING (0 = north); pitch is elevation (0 = level).", { {"snapshot", "Take one image now (key Space).", {
{"gimbal move <yaw>,<pitch>", "Waits for standstill and the profile's settle time, as a scan waypoint",
"Point at an absolute bearing/elevation in DEGREES.", { "does, so the file and CamEvent are the same as a scan frame's."}},
"Converts to encoder counts with the [Motor] calibration and moves both", {"capture [on|off]", "Continuous capture where the gimbal is aimed (key r).", {
"axes together. Yaw travel is under a full turn, so bearings in the", "One image per scan cycle: [General] image_interval, then standstill and",
"unreachable arc are refused rather than clamped to the nearest limit.", "the profile's settle time. The gimbal can still be nudged; images are",
"Manual mode only - refused while a scan is running.", "kept only while it stands still. Bare 'capture' reports on/off. Ends on",
"Example: gimbal move 30,-10 (bearing 30 deg, 10 deg below level)"}}, "any mode change, homing, heading setup or test."}},
{"gimbal steps <yaw>,<pitch>", {"set camera profile [<camera>] <name>", "Switch a camera's VimbaX settings profile.", {
"Move both axes to absolute encoder COUNTS (no degree conversion).", { "<name> is an XML stem in that camera's profile_dir. The camera may be",
"omitted when only one is configured.",
"Example: set camera profile night"}},
{"camera reset", "Reboot the cameras and restore their profiles.", {
"For a stream that has stopped and will not restart. Takes seconds."}},
}},
{"Positioning", "Yaw is a true bearing (0 = north); pitch is elevation (0 = level).", {
{"gimbal move <yaw>,<pitch>", "Point at a bearing/elevation in degrees.", {
"Bearings in the unreachable yaw arc are refused, not clamped.",
"Example: gimbal move 30,-10"}},
{"gimbal steps <yaw>,<pitch>", "Move to absolute encoder counts.", {
"Example: gimbal steps 100000,250000"}}, "Example: gimbal steps 100000,250000"}},
{"gimbal nudge <yaw|pitch> <+/-pct>", {"gimbal nudge <yaw|pitch> <+/-pct>", "Relative move, percent of travel (arrow keys).", {
"Relative move by a percent of the homed axis travel.", {
"The arrow keys do this when no overlay is open; inside an overlay they",
"scroll instead. Manual mode only.",
"Example: gimbal nudge yaw -5"}}, "Example: gimbal nudge yaw -5"}},
{"gimbal jog <yaw|pitch> <+/-counts>", {"gimbal jog <yaw|pitch> <+/-counts>", "Relative move in encoder counts.", {
"Relative move in raw encoder counts.", { "Steps below ~13 counts are finer than the encoder resolves.",
"Finer than 'nudge' (a percent of travel) and relative unlike 'steps'.",
"Below ~13 counts the encoder cannot resolve the difference, so a smaller",
"step moves the motor to a position it cannot reproduce.",
"Example: gimbal jog yaw -25"}}, "Example: gimbal jog yaw -25"}},
{"gimbal heading setup", {"gimbal home [y|p]", "Home both axes, or one.", {}},
"Tie encoder counts to true north by sighting known landmarks.", { {"gimbal stop [y|p]", "Stop motion now (bare: every axis).", {}},
"Aim at a landmark, then 'gimbal heading mark <its true bearing>'; repeat,", {"gimbal heading setup|mark <deg>|fit|cancel", "Tie yaw to true north by sighting landmarks.", {
"then 'gimbal heading fit'. Two landmarks minimum, the further apart the", "setup, then aim at a landmark and 'mark <its true bearing>'; repeat for",
"better. On the dashboard it runs in the activity strip: arrows aim, Tab", "at least two, far apart; then 'fit'. 'fit <counts>:<deg> ...' fits",
"step size, Enter mark, Space image, f fit, Esc cancel. Needs no IMU."}}, "known pairs without a setup.",
{"gimbal heading mark <deg>", "Example: gimbal heading mark 137.4"}},
"Record where the gimbal points now as the landmark's true bearing.", { {"gimbal heading offset <deg>", "Shift the whole yaw map by a fixed amount.", {
"Only during 'gimbal heading setup'. Example: gimbal heading mark 137.4"}},
{"gimbal heading fit [<counts>:<deg> ...]",
"Fit the yaw map from the marks, or from (encoder position, bearing) pairs.", {
"Bare, it fits this heading setup's marks. With pairs, it needs no setup -",
"for a headless tower whose pairs are known. Two fit exactly; more are",
"least-squares fitted and report R^2. Pairs closer than 10 deg apart are",
"refused - the fit would look perfect and be wrong.",
"Example: gimbal heading fit 480000:45.0 520000:115.0"}},
{"gimbal heading cancel",
"Stop heading setup and drop its marks (so do 'cancel' and Esc).", {}},
{"gimbal heading offset <deg>",
"Shift the whole yaw map by a known amount and save it.", {
"For when the tower is found to be off by a fixed bearing and re-sighting",
"landmarks is not worth it.",
"Example: gimbal heading offset -2.5"}}, "Example: gimbal heading offset -2.5"}},
{"gimbal home [y|p]", {"gimbal pitch setup", "Calibrate pitch against gravity (needs the IMU).", {
"Run the endstop-finding home sequence (both axes, or one).", { "Homes if needed, sweeps pitch, fits, parks level and offers to save.",
"Example: gimbal home / gimbal home y"}}, "Takes minutes; Esc or 'cancel' stops it."}},
{"gimbal stop [y|p|all]",
"Stop motion immediately; bare, every axis. The axis holds where it stops.", {
"To cancel a running procedure use 'cancel' (or Esc, or m):",
"the whole 'gimbal' family is ignored while one is in progress."}},
{"gimbal speed <y|p> <vel>",
"Set the max slew speed (counts/s) for an axis.", {}},
{"gimbal current <y|p> <scaler> <irun> <irun_home>",
"Set motor current, as raw TMC register fields.",
{"Normally sent automatically from hardware.ini [Motor.<axis>], which states",
"the current in milliamps and lets the host convert it using this board's",
"sense resistor. Setting it by hand does not stay secret: the next home",
"checks the firmware's reported current against the configured one."}},
{"gimbal reset [y|p] / gimbal enable|disable <y|p> / gimbal setpos <y|p> <v>",
"Other firmware motor controls.", {}},
}}, }},
{"IMU", "Read and configure the Xsens MTi orientation sensor.", { {"Motor", "Firmware-level motor controls.", {
{"imu status", {"gimbal speed <y|p> <vel>", "Max slew speed, counts/s.", {}},
"Print the latest orientation reading.", { {"gimbal current <y|p> <scaler> <irun> <irun_home>", "Motor current as raw TMC fields.", {
"Yaw/pitch/roll, device temperature and sample counter. The Sensors panel", "Normally set from hardware.ini [Motor.<axis>] in mA."}},
"and the 's' view show the same data continuously in the TUI."}}, {"gimbal reset|enable|disable <y|p>", "Reset, enable or disable an axis driver.", {}},
{"imu config", {"gimbal setpos <y|p> <counts>", "Redefine an axis's current position.", {}},
"Re-read the device configuration and print it.", { {"gimbal status", "Emit one telemetry line now.", {}},
"Identity, firmware, sample rate, active XKF profile and the available", {"gimbal dump", "Fetch the firmware state; shown in the gimbal view (g).", {}},
"list. Re-reads first, so it never reports a stale profile."}}, {"gimbal raw \"<command>\"", "Send text to the firmware verbatim.", {
{"imu profile [<type|label>]", "Example: gimbal raw \"MOVE 100000,250000\""}},
"Select the XKF filter profile (persists on the device).", {
"The profile decides whether heading uses the magnetometer. Nothing here",
"affects the gimbal's own heading: that comes from the encoder, tied to",
"north by 'gimbal heading setup'. Typical MTi profiles are General, High_mag_dep,",
"Dynamic and VRU_general; run with no argument for this device's real list.",
"Example: imu profile General / imu profile 39"}},
{"imu zero",
"Redefine the current pose as yaw 0 (heading reset).", {
"Run 'imu norotation' first so the gyro bias is corrected. Mid-scan this",
"re-references every later CamEvent heading."}},
{"imu norotation [<seconds>]",
"Run the gyro-bias update; hold the unit still (default 3 s).", {
"Cuts heading drift, which matters most on a no-mag profile. The command",
"returns immediately - keeping the unit still is up to you.",
"Example: imu norotation 5"}},
}}, }},
{"Diagnostics", "Inspect, self-test and calibrate the gimbal.", { {"IMU", "Xsens MTi orientation sensor.", {
{"gimbal dump", {"imu status", "Latest orientation reading (also in the sensors view, s).", {}},
"Request a full firmware state dump and show it here.", { {"imu config", "Re-read and print the device configuration.", {}},
"Build, uptime, reset cause and the per-axis TMC5160 registers; also shown", {"imu profile [<type|label>]", "Select the XKF filter profile (bare: list them).", {
"in the gimbal 'g' view. Offline equivalent: ./firmware/dump.sh"}}, "Persists on the device. Example: imu profile General"}},
{"test [<subsystem> [<leaf>]] [<profile>]", {"imu zero", "Make the current pose yaw 0. Run 'imu norotation' first.", {}},
"Run the hardware self-test suite; a report lands in logs/test_*.log.", { {"imu norotation [<seconds>]", "Gyro-bias update; hold still (default 3 s).", {}},
"'test' runs everything; a subsystem or leaf narrows it, and a trailing", }},
"token names a config profile. Each run is compared to a pinned baseline", {"Tests", "Self-tests. Reports land in logs/test_*.log.", {
"and the previous run. Esc cancels.", {"test [<subsystem> [<leaf>]] [<profile>]", "Run the self-test suite, or part of it.", {
"Compared with the pinned baseline and the previous run. Esc cancels.",
"Example: test gimbal homing / test list / test baseline"}}, "Example: test gimbal homing / test list / test baseline"}},
{"test hardware [axes|motor|imu|env|camera]", {"test hardware [axes|motor|imu|env|camera]", "Check the rig matches hardware.ini.", {}},
"Check the rig against what hardware.ini declares.", { {"test camera [frames|dark|bright]", "Check the camera delivers and responds.", {
"Preconditions, not measurements: the declared axes appear in telemetry and", "Never judges the picture itself. Capture pauses meanwhile."}},
"no others; the firmware echoes the drivetrain current hardware.ini asked for;",
"a declared IMU streams; a declared ambient sensor sits on a REAL I2C bus (not",
"SMBus-only) and its CRC checks out; the cameras opened and the USB",
"preconditions hold. Nothing declared reports 'not checked', never PASS."}},
{"test camera [frames|dark|bright]",
"Check the camera works, whatever it is pointed at.", {
"frames: whole frames arrive in time. dark: minimum exposure and gain take",
"every pixel to black. bright: maximum gain and a long exposure make the",
"frame measurably different - the sensor is alive and took the settings.",
"The picture itself is never judged, so fog, night or a lens cap cannot",
"decide it. The profile is put back after; capture pauses meanwhile."}},
{"camera reset",
"Reboot the camera and put it back on its profile.", {
"Sends the camera its own DeviceReset, waits for it to come back on USB, and",
"re-applies the profile (manual mode). For a stream that has",
"stopped delivering frames and will not restart. The program also does this",
"by itself when restarting the stream stops helping."}},
{"gimbal pitch setup",
"Calibrate PITCH steps<->degrees against gravity, using the IMU.", {
"Auto-homes if needed, sweeps pitch dwelling at each step, fits, then",
"parks level. Writes logs/pitch_setup_*.log and offers to save. Takes",
"minutes; 'cancel' or Esc cancels. Requires the IMU - level is defined by",
"gravity and nothing else on the tower can measure it. Yaw is NOT touched:",
"use 'gimbal heading setup'."}},
{"gimbal status",
"Ask the firmware to emit one telemetry (ST) line now.", {}},
{"gimbal raw \"<command>\"",
"Send the quoted text to the firmware verbatim.", {
"Bypasses all unit conversion and wrappers - use with care; sends exactly",
"what you type, plus a newline.",
"Example: gimbal raw \"MOVE 100000,250000\" / gimbal raw \"DUMP\""}},
}}, }},
{"Capture", "Control image capture and encoding.", { {"Keys", "Dashboard shortcuts. Each sends the command shown.", {
{"set fps <rate>", "Set capture rate in images/second.", {}}, {"a / p / m", "mode auto / mode pan (30 deg around the aim) / mode manual.", {}},
{"set camera profile [<camera>] <name>", {"h", "gimbal home.", {}},
"Switch a camera's profile (rate, exposure, all imaging settings).", { {"Space / r", "snapshot / capture on|off.", {}},
"A profile is a settings XML exported from the VimbaX Viewer, in that", {"Arrows", "gimbal nudge: yaw 5%, pitch 10% (manual).", {}},
"camera's [Camera.<name>] profile_dir; the name is the filename stem. The", {"Esc", "Close the view, or cancel a running procedure.", {}},
"camera may be omitted when only one is configured.", {"? / g / s / c / n", "Views: help, gimbal, sensors, cameras, connectivity.", {}},
"Example: set camera profile night / set camera profile rgb night"}}, {"PgUp / PgDn", "Scroll the log or the open view.", {}},
{"set camera jxlq <dist>", "Set JPEG-XL distance (lower = higher quality).", {}}, {"f", "Freeze the screen, to select text or read back.", {}},
{"set camera jxle <effort>", "Set JPEG-XL encode effort.", {}}, {"q", "Quit, or leave the dashboard running in the background.", {}},
{"set camera display <0|1>", "Toggle the local display window.", {}},
}}, }},
{"Logging", "Control console/log verbosity.", { {"Session", "Logging and exit.", {
{"debug", "Toggle debug-level logging on/off.", {}}, {"help [topic]", "This list; 'help <topic>' adds detail.", {
{"trace <serial|mqtt|camera|control|all|off> [on|off]", "Example: help capture / help positioning"}},
"Toggle verbatim wire-trace categories.", { {"cancel", "Cancel the running test, homing or calibration.", {}},
"Example: trace serial on / trace off"}}, {"debug", "Toggle debug-level logging.", {}},
}}, {"trace <serial|mqtt|camera|control|all|off> [on|off]", "Toggle wire-trace categories.", {
{"Display", "Terminal dashboard keys (TUI only).", { "Example: trace serial on"}},
{"a / p / m",
"Auto mode / pan around where the gimbal points now / manual mode.", {
"p is 'mode pan <current heading> 30 <current pitch>'. m also cancels a",
"running test, homing or calibration, as Esc does."}},
{"? / g / s / c / n",
"Toggle the Help, Gimbal, Sensors, Cameras and Connectivity views.", {
"A view takes the full terminal height; press its key again, or Esc, to",
"close it. With none open, Esc cancels a running procedure. 'n' shows",
"broker state, per-topic traffic and the recent MQTT messages."}},
{"Arrows",
"Nudge the gimbal: left/right yaw 5%, up/down pitch 10% (manual mode).", {
"In every view. During heading setup they aim in the step Tab selects."}},
{"PageUp/PageDown",
"Scroll the log, or the open view. The only scroll keys.", {
"The log and the message history jump back to the newest line when one",
"arrives; pause (f) first to read further back. In Help, j/k change",
"section."}},
{"Space",
"Take one image now and publish its CamEvent (manual mode only).", {
"Goes through the same settle-and-trigger path a scan waypoint uses, so",
"the file and the CamEvent are indistinguishable from a scan frame."}},
{"f",
"Freeze the screen: to select and copy text, or to scroll back.", {
"While frozen nothing new is drawn, so the log stays where you page it.",
"The header says when it froze. f again resumes."}},
{"q",
"Ask whether to quit or leave the dashboard running in the background.", {
"Quitting stops capture and closes the camera; backgrounding detaches the",
"tmux client (same as Ctrl-b d) and leaves everything running. Ctrl-C still",
"shuts down immediately."}},
}},
{"Session", "Help and exit.", {
{"help [topic]",
"Show this reference; 'help <topic>' expands one section.", {
"Example: help positioning / help imu"}},
{"exit", "Shut down and quit.", {}}, {"exit", "Shut down and quit.", {}},
}}, }},
}; };
@ -247,12 +132,22 @@ std::vector<std::string> renderHelp(const std::string& topic) {
const std::string q = lower(topic); const std::string q = lower(topic);
if (q.empty()) { if (q.empty()) {
out.emplace_back("Available commands (type 'help <topic>' for detail, e.g. 'help imu'):"); // Summaries line up in one column; a syntax longer than the cap keeps its
// own width rather than pushing every other line right.
size_t width = 0;
for (const auto& sec : helpCatalog())
for (const auto& e : sec.entries) width = std::max(width, e.syntax.size());
width = std::min<size_t>(width, 40);
out.emplace_back("Commands ('help <topic>' for detail, e.g. 'help capture'):");
for (const auto& sec : helpCatalog()) { for (const auto& sec : helpCatalog()) {
out.emplace_back(""); out.emplace_back("");
out.emplace_back("== " + sec.title + " == " + sec.blurb); out.emplace_back(sec.title + " - " + sec.blurb);
for (const auto& e : sec.entries) for (const auto& e : sec.entries) {
out.emplace_back(" " + e.syntax + " - " + e.summary); std::string l = " " + e.syntax;
l.append(e.syntax.size() < width ? width - e.syntax.size() : 0, ' ');
out.emplace_back(l + " " + e.summary);
}
} }
return out; return out;
} }

View File

@ -260,14 +260,11 @@ Element keyBar(Overlay overlay, bool manual, bool heading_setup) {
e.push_back(keyHint("f", "Pause")); e.push_back(keyHint("f", "Pause"));
e.push_back(keyHint("PgUp/Dn", "Log")); e.push_back(keyHint("PgUp/Dn", "Log"));
break; break;
case Overlay::Help:
e.push_back(keyHint("j/k", "Section"));
e.push_back(keyHint("PgUp/Dn", "Scroll"));
break;
case Overlay::Gimbal: case Overlay::Gimbal:
e.push_back(keyHint("d", "Dump")); e.push_back(keyHint("d", "Dump"));
e.push_back(keyHint("PgUp/Dn", "Scroll")); e.push_back(keyHint("PgUp/Dn", "Scroll"));
break; break;
case Overlay::Help:
case Overlay::Sensors: case Overlay::Sensors:
case Overlay::Cameras: case Overlay::Cameras:
case Overlay::Conn: case Overlay::Conn:
@ -279,6 +276,7 @@ Element keyBar(Overlay overlay, bool manual, bool heading_setup) {
if (manual) { if (manual) {
e.push_back(keyHint(kArrows4, "Nudge")); e.push_back(keyHint(kArrows4, "Nudge"));
e.push_back(keyHint("Spc", "Snap")); e.push_back(keyHint("Spc", "Snap"));
e.push_back(keyHint("r", "Capture"));
} }
if (overlay != Overlay::None) e.push_back(keyHint("Esc", "Close")); if (overlay != Overlay::None) e.push_back(keyHint("Esc", "Close"));
} }
@ -727,45 +725,30 @@ Element activityPanel(const ActivityView& a, const std::string& step) {
return window(text(" ACTIVITY ") | bold | color(Color::Cyan), vbox(std::move(rows))); return window(text(" ACTIVITY ") | bold | color(Color::Cyan), vbox(std::move(rows)));
} }
// Inline help pane (toggled with '?'). Lists every command section; the // Help view ('?'): every section on one page, one aligned line per command,
// `sel`-th section is expanded to show each entry's detail. The Diagnostics // scrolled with PgUp/PgDn like every other view. The detail lines stay in the
// section additionally renders the last captured firmware DUMP block. // catalog for ':help <topic>', which keeps this page a quick reference.
Element helpPanel(int sel, const DumpView& dump, ScrollState& st) { Element helpPanel(ScrollState& st) {
const auto& cat = helpCatalog(); const auto& cat = helpCatalog();
std::vector<Element> rows; size_t width = 0;
for (int i = 0; i < static_cast<int>(cat.size()); ++i) { for (const auto& sec : cat)
const HelpSection& sec = cat[i]; for (const auto& e : sec.entries) width = std::max(width, e.syntax.size());
const bool open = (i == sel); width = std::min<size_t>(width, 40); // a rare long syntax wraps rather than
Element title = hbox({ // pushing every summary off screen
text(open ? " v " : " > "),
text(sec.title) | bold,
text(" " + sec.blurb) | dim,
});
rows.push_back(open ? (title | inverted) : title);
if (!open) continue;
for (const auto& e : sec.entries) { std::vector<Element> rows;
rows.push_back(hbox({text(" "), text(e.syntax) | color(Color::Cyan) | bold})); for (const auto& sec : cat) {
rows.push_back(hbox({text(" "), text(e.summary) | dim})); if (!rows.empty()) rows.push_back(text(""));
for (const auto& d : e.detail) rows.push_back(hbox({text(sec.title) | bold | color(Color::Cyan),
rows.push_back(hbox({text(" "), text(d) | dim})); text(" " + sec.blurb) | dim}));
} for (const auto& e : sec.entries)
// Diagnostics: show the most recent firmware dump inline. rows.push_back(hbox({text(" "),
if (sec.title == "Diagnostics") { text(e.syntax) | bold | size(WIDTH, EQUAL, static_cast<int>(width)),
rows.push_back(text(" --- last firmware dump ---") | bold); text(" "), paragraph(e.summary) | dim | flex}));
if (dump.has) {
std::istringstream iss(dump.text);
std::string l;
while (std::getline(iss, l))
rows.push_back(hbox({text(" "), text(l) | color(Color::Green)}));
} else {
rows.push_back(hbox({text(" "),
text("(none captured yet - run 'gimbal dump')") | dim}));
}
}
rows.push_back(text(""));
} }
return window(overlayTitle("HELP", "?:close j/k:section", st, Color::Cyan), rows.push_back(text(""));
rows.push_back(text("Type ':help <topic>' for detail and examples, e.g. ':help capture'.") | dim);
return window(overlayTitle("HELP", "?:close PgUp/Dn:scroll", st, Color::Cyan),
scrollableBody(vbox(std::move(rows)), st)); scrollableBody(vbox(std::move(rows)), st));
} }
@ -1259,12 +1242,12 @@ void TuiUi::uiLoop() {
int cmd_cursor = 0; int cmd_cursor = 0;
bool command_mode = false; bool command_mode = false;
Overlay overlay = Overlay::None; // which takeover panel owns the main area Overlay overlay = Overlay::None; // which takeover panel owns the main area
int help_sel = 0;
bool gimbal_dump_requested = false; // auto-pull a dump the first time bool gimbal_dump_requested = false; // auto-pull a dump the first time
bool calib_prompt = false; // a yes/no calib-save question is showing bool calib_prompt = false; // a yes/no calib-save question is showing
// Mirrored out of the snapshot each render: CatchEvent runs on the same // Mirrored out of the snapshot each render: CatchEvent runs on the same
// thread but has no access to the renderer's local snapshot copy. // thread but has no access to the renderer's local snapshot copy.
bool manual_mode = true; bool manual_mode = true;
bool capture_on = false; // continuous capture, so r knows which way to toggle
bool heading_setup = false; bool heading_setup = false;
double yaw_deg = 0.0, pitch_deg = 0.0; // where the gimbal points, for 'p' double yaw_deg = 0.0, pitch_deg = 0.0; // where the gimbal points, for 'p'
bool pitch_present = false; bool pitch_present = false;
@ -1325,6 +1308,7 @@ void TuiUi::uiLoop() {
calib_prompt = !s.activity.prompt.empty(); calib_prompt = !s.activity.prompt.empty();
manual_mode = s.capture.manual; manual_mode = s.capture.manual;
capture_on = s.capture.continuous;
heading_setup = s.activity.heading_setup; heading_setup = s.activity.heading_setup;
yaw_deg = s.gimbal.yaw.deg; yaw_deg = s.gimbal.yaw.deg;
pitch_deg = s.gimbal.pitch.deg; pitch_deg = s.gimbal.pitch.deg;
@ -1375,7 +1359,7 @@ void TuiUi::uiLoop() {
// gimbal register dump overflows the small log-sized area and clips. // gimbal register dump overflows the small log-sized area and clips.
switch (overlay) { switch (overlay) {
case Overlay::Help: case Overlay::Help:
return vbox({header, separator(), helpPanel(help_sel, s.dump, help_scroll) | flex, bottom}); return vbox({header, separator(), helpPanel(help_scroll) | flex, bottom});
case Overlay::Gimbal: case Overlay::Gimbal:
return vbox({header, separator(), return vbox({header, separator(),
gimbalDetailPanel(s.gimbal, s.dump, s.calib, s.diag, gimbal_scroll) | gimbalDetailPanel(s.gimbal, s.dump, s.calib, s.diag, gimbal_scroll) |
@ -1497,7 +1481,6 @@ void TuiUi::uiLoop() {
if (e == Event::Character("f")) { sink_("gimbal heading fit"); return true; } if (e == Event::Character("f")) { sink_("gimbal heading fit"); return true; }
} }
const int n = static_cast<int>(helpCatalog().size());
// Scroll the view that owns the body, or the log. PgUp/PgDn only. // Scroll the view that owns the body, or the log. PgUp/PgDn only.
switch (overlay) { switch (overlay) {
case Overlay::None: if (scrollKeys(log_scroll, e)) { case Overlay::None: if (scrollKeys(log_scroll, e)) {
@ -1571,10 +1554,6 @@ void TuiUi::uiLoop() {
if (paused_) paused_at_hms_ = nowHms(); if (paused_) paused_at_hms_ = nowHms();
return true; // this keypress redraws once so the indicator updates return true; // this keypress redraws once so the indicator updates
} }
if (overlay == Overlay::Help) { // section nav while help is open
if (c == "j") { help_sel = (help_sel + 1) % n; help_scroll = {}; return true; }
if (c == "k") { help_sel = (help_sel - 1 + n) % n; help_scroll = {}; return true; }
}
if (c == "q") { exit_prompt = true; return true; } if (c == "q") { exit_prompt = true; return true; }
if (c == "a") { if (sink_) sink_("mode auto"); return true; } if (c == "a") { if (sink_) sink_("mode auto"); return true; }
if (c == "m") { if (sink_) sink_("mode manual"); return true; } if (c == "m") { if (sink_) sink_("mode manual"); return true; }
@ -1592,6 +1571,7 @@ void TuiUi::uiLoop() {
// Spacebar: one image now, through the same pipeline a scan waypoint uses. // Spacebar: one image now, through the same pipeline a scan waypoint uses.
// The app refuses it outside manual; the bar just stops advertising it. // The app refuses it outside manual; the bar just stops advertising it.
if (c == " ") { if (sink_) sink_("snapshot"); return true; } if (c == " ") { if (sink_) sink_("snapshot"); return true; }
if (c == "r") { if (sink_) sink_(capture_on ? "capture off" : "capture on"); return true; }
return false; return false;
}); });

View File

@ -5,32 +5,33 @@
using namespace fgc; using namespace fgc;
TEST_CASE("parseCommand handles single-verb commands") { TEST_CASE("parseCommand handles single-verb commands") {
auto c = parseCommand("start"); auto c = parseCommand("snapshot");
CHECK(c.verb == "start"); CHECK(c.verb == "snapshot");
CHECK(c.device.empty()); CHECK(c.device.empty());
CHECK_FALSE(c.has_value); CHECK_FALSE(c.has_value);
} }
TEST_CASE("parseCommand splits device and trailing numeric value") { TEST_CASE("parseCommand takes the second word as the device") {
auto c = parseCommand("set fps 5"); auto c = parseCommand("capture on");
CHECK(c.verb == "set"); CHECK(c.verb == "capture");
CHECK(c.device == "fps"); CHECK(c.device == "on");
CHECK(c.option.empty()); CHECK(c.option.empty());
CHECK_FALSE(c.has_value);
}
TEST_CASE("parseCommand keeps option plus trailing numeric value") {
auto c = parseCommand("gimbal nudge yaw 5");
CHECK(c.verb == "gimbal");
CHECK(c.device == "nudge");
CHECK(c.option == "yaw");
CHECK(c.has_value); CHECK(c.has_value);
CHECK(c.value == doctest::Approx(5.0)); CHECK(c.value == doctest::Approx(5.0));
} }
TEST_CASE("parseCommand keeps option plus value") { TEST_CASE("parseCommand joins non-numeric trailing words into the option") {
auto c = parseCommand("set camera jxlq 2"); auto c = parseCommand("set camera profile night");
CHECK(c.device == "camera"); CHECK(c.device == "camera");
CHECK(c.option == "jxlq"); CHECK(c.option == "profile night");
CHECK(c.value == doctest::Approx(2.0));
}
TEST_CASE("parseCommand keeps non-numeric option (raw motor command)") {
auto c = parseCommand("set motorctl kd180");
CHECK(c.device == "motorctl");
CHECK(c.option == "kd180");
CHECK_FALSE(c.has_value); CHECK_FALSE(c.has_value);
} }

View File

@ -106,7 +106,7 @@ TEST_CASE("auto and pan accept only what cannot move the gimbal or change a sett
} }
for (const char* no : {"gimbal nudge yaw 5", "gimbal move 10,0", "gimbal raw \"HOME\"", for (const char* no : {"gimbal nudge yaw 5", "gimbal move 10,0", "gimbal raw \"HOME\"",
"gimbal home", "gimbal stop", "gimbal heading offset 2", "gimbal home", "gimbal stop", "gimbal heading offset 2",
"gimbal speed y 1000", "set fps 1", "set camera profile night", "gimbal speed y 1000", "capture on", "capture off", "set camera profile night",
"imu zero", "imu profile general", "test", "test hardware", "imu zero", "imu profile general", "test", "test hardware",
"camera reset", "snapshot", "calib save", "gimbal"}) { "camera reset", "snapshot", "calib save", "gimbal"}) {
CAPTURE(no); CAPTURE(no);
@ -116,7 +116,7 @@ TEST_CASE("auto and pan accept only what cannot move the gimbal or change a sett
} }
TEST_CASE("manual accepts everything") { TEST_CASE("manual accepts everything") {
for (const char* any : {"gimbal nudge yaw 5", "gimbal raw \"HOME\"", "set fps 1", "test", for (const char* any : {"gimbal nudge yaw 5", "gimbal raw \"HOME\"", "capture on", "test",
"camera reset", "exit", "anything at all"}) "camera reset", "exit", "anything at all"})
CHECK(allowedIn(Mode::Manual, any)); CHECK(allowedIn(Mode::Manual, any));
} }

View File

@ -450,3 +450,128 @@ TEST_CASE("a snapshot that times out can simply be asked for again") {
CHECK(cam.triggers == attempts + 1); CHECK(cam.triggers == attempts + 1);
CHECK_FALSE(sch.snapshotPending()); CHECK_FALSE(sch.snapshotPending());
} }
// --- continuous capture (`capture on`) -------------------------------------
//
// The scan's cycle without the move: interval, then standstill + settle, then
// one frame, and the timer restarts from there - so the cadence is what a scan
// achieves, and the gimbal stays free to be aimed in between.
TEST_CASE("continuous capture keeps one frame per interval + settle, in manual") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0 /*1000 ms*/, testGeometry(), grid,
[&] { return clock; });
sch.setSettleWait(500, 2000);
settleAt(motor, 0, 0);
sch.setContinuous(true);
CHECK(sch.continuous());
clock = 900; sch.tick(); // inside the interval
CHECK(cam.triggers == 0);
clock = 1100; sch.tick(); // interval over: standstill seen, settle starts
CHECK(cam.triggers == 0);
clock = 1500; sch.tick(); // still settling
CHECK(cam.triggers == 0);
clock = 1700; sch.tick(); // settled: shoot
CHECK(cam.triggers == 1);
CHECK(motor.cmds.empty()); // never moves the gimbal
// The timer restarted at the capture, so the next frame is a whole cycle on.
clock = 2600; sch.tick();
CHECK(cam.triggers == 1);
clock = 2800; sch.tick(); // interval over, standstill seen
clock = 3400; sch.tick(); // + settle
CHECK(cam.triggers == 2);
}
TEST_CASE("continuous capture waits for the gimbal to stop before keeping a frame") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(500, 60000);
settleAt(motor, 0, 0);
motor.tel.yaw.standstill = false; // the operator is nudging
sch.setContinuous(true);
for (clock = 1100; clock < 5000; clock += 250) sch.tick();
CHECK(cam.triggers == 0);
motor.tel.yaw.standstill = true;
clock = 5100; sch.tick(); // standstill: settle clock starts here
CHECK(cam.triggers == 0);
clock = 5700; sch.tick();
CHECK(cam.triggers == 1);
}
TEST_CASE("continuous capture gives up on a cycle without a frame and carries on") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
cam.ok = false;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(0, 1000);
settleAt(motor, 0, 0);
sch.setContinuous(true);
clock = 1100; sch.tick(); // standstill seen
clock = 1200; sch.tick(); // tries, no frame
CHECK(cam.triggers == 1);
clock = 2200; sch.tick(); // past the budget: give up, timer restarts
const int after_giving_up = cam.triggers;
clock = 2500; sch.tick(); // new cycle not due yet
CHECK(cam.triggers == after_giving_up);
CHECK(sch.continuous()); // still on
cam.ok = true;
clock = 3300; sch.tick(); // next cycle: standstill seen
clock = 3400; sch.tick();
CHECK(cam.triggers == after_giving_up + 1);
}
TEST_CASE("any mode change ends continuous capture") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
settleAt(motor, 0, 0);
sch.setContinuous(true);
sch.setMode(Mode::Auto);
CHECK_FALSE(sch.continuous());
sch.setMode(Mode::Manual); // coming back does not resume it
CHECK_FALSE(sch.continuous());
sch.setContinuous(true);
sch.setMode(Mode::Manual);
CHECK_FALSE(sch.continuous());
}
TEST_CASE("a snapshot during continuous capture fires once, then capture carries on") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(0, 1000);
settleAt(motor, 0, 0);
sch.setContinuous(true);
sch.requestSnapshot();
clock = 10; sch.tick(); // snapshot: standstill seen
clock = 20; sch.tick(); // snapshot fires, well before the interval
CHECK(cam.triggers == 1);
CHECK_FALSE(sch.snapshotPending());
CHECK(sch.continuous());
clock = 1100; sch.tick(); // continuous: standstill seen
clock = 1200; sch.tick();
CHECK(cam.triggers == 2);
}