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
# ---------------------------------------------------------------------------
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.
if(POLICY CMP0167)
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) |
| `h` | home the gimbal |
| `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) |
| `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 |
| 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) |
| `q` | quit or background the program - it asks which |
| `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 nudge <yaw\|pitch> <+/-percent>` | a small move by hand |
| `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) |
| `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 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 |
| `gimbal dump` | show everything the firmware is holding |
| `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 |
| `--no-mqtt` | no broker |
| `--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-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` |

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) |
| 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
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
@ -76,16 +76,17 @@ corrupted.
- 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'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
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
capture* below.
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 —
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.
## 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
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.
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 |
|------------|-----------|-------------------|
| **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 |
| **Boost** (`program_options`; header-only Asio) | CLI parsing, serial I/O | `find_package(Boost CONFIG)` — system package |
| **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 |
| `-i, --init` | Run the endstop-finding init sequence before the loop |
| `-s, --start` | Start capture automatically |
| `-d, --demo` | Demo mode: copy the placeholder image instead of encoding |
| `--no-mqtt` | Disable MQTT (use the null channel) |
| `--mock-camera` | Use the simulated camera |
| `--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 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 jxlq <v>` | JPEG XL distance (0 = lossless) |
| `set camera jxle <v>` | JPEG XL effort |
| `set camera display <0\|1>` | Toggle OpenCV preview window |
| `set camera fps <v>` | Camera acquisition frame rate (real camera only) |
| `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`) |
| `snapshot` | One image now, through the scan's settle-and-trigger path (manual mode, homed) |
| `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 |
| `camera reset` | Reboot the cameras and put them back on their profiles |
| `test camera [frames\|dark\|bright]` | Check the camera delivers frames and responds to exposure/gain |
| `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 |
| `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 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 |
| `refresh` | Re-read the IMU configuration and request a fresh firmware dump |
| `help [topic]` | The command catalog, or one section/verb expanded |
| `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 —
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
the gimbal view, `j`/`k` sections in help, `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*
the gimbal view, `Esc` close). In manual mode the arrow keys nudge the gimbal
(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.
**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 |
| `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 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 |
### 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
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
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
@ -114,8 +114,6 @@ scripts for this are worth keeping; note that wrapping them in `timeout` SIGTERM
`include/fgc/HardwareChecks.h`.
- **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.
- **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)

View File

@ -45,7 +45,7 @@ Per-file reference for the refactored tree, plus the shared data structures.
| File | Interface | Shared structs |
|------|-----------|----------------|
| [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/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` |
@ -137,7 +137,6 @@ Surfaced read-only in the TUI's `s` view (IMU CONFIG section); the legacy "scena
| 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` |
| 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 |
| 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
(`Application::handleCommand()`), on the same control thread, with the same
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
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":"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"
```

View File

@ -13,7 +13,6 @@ namespace fgc {
struct RuntimeOptions {
bool init = false; // run the endstop-finding init sequence
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.
// 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.

View File

@ -26,7 +26,8 @@ namespace fgc {
//
// 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,
// 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.
class CaptureScheduler {
public:
@ -57,8 +58,14 @@ public:
void requestSnapshot();
bool snapshotPending() const { return snapshot_pending_; }
void setImageRate(double rate); // images per second
double imageRate() const { return image_rate_; }
// Continuous capture (`capture on|off`): in manual, keep a frame on the scan's
// 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
// 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_)
bool settledAt(const MotorTelemetry& t) const;
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_;
ICameraSource& camera_;
@ -113,6 +127,8 @@ private:
bool snapshot_pending_ = false; // a manual snapshot is armed
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 pitch_target_ = 0;
};

View File

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

View File

@ -42,9 +42,6 @@ public:
std::string output_dir;
std::vector<CameraOutput> cameras; // by camera index, in open order
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.
@ -67,16 +64,6 @@ public:
// different pose than the pixels.
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
// UI's camera panel. Thread-safe; nullopt until the first capture is saved.
std::optional<CamEvent> lastEvent() const;
@ -108,10 +95,6 @@ private:
std::atomic<bool> running_{false};
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_;
std::optional<CamEvent> last_event_;
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.
bool manual = true;
bool snapshot_armed = false; // a snapshot is waiting to fire
bool continuous = false; // `capture on` (manual mode)
double image_rate = 0.0; // img/s
int camera_count = 0;
std::vector<std::string> labels;

View File

@ -50,7 +50,6 @@ int main(int argc, char* argv[]) {
"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")
("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)")
("mock-camera", po::bool_switch(), "use a simulated camera (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;
opts.init = vm["init"].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["mock-camera"].as<bool>()) opts.mock_camera = true;
if (vm["mock-serial"].as<bool>()) opts.mock_serial = true;

View File

@ -5,7 +5,8 @@
#include <filesystem>
#include <opencv2/opencv.hpp>
#include <opencv2/core.hpp>
#include <opencv2/imgproc.hpp>
namespace fs = std::filesystem;
@ -28,9 +29,7 @@ bool rotateCode(int deg, int& code) {
ImagePipeline::ImagePipeline(IControlChannel& channel, std::function<Orientation()> orientation,
Params params)
: channel_(channel), orientation_(std::move(orientation)), params_(std::move(params)) {
display_.store(params_.display);
}
: channel_(channel), orientation_(std::move(orientation)), params_(std::move(params)) {}
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.
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))
: out.label;
const fs::path dir = fs::path(params_.output_dir) / label;
@ -132,18 +108,11 @@ void ImagePipeline::process(const QueuedFrame& queued) {
}
const fs::path file = dir / (std::to_string(frame.timestamp_ms) + ".jxl");
if (params_.demo) {
fs::copy_file(params_.demo_image, file, fs::copy_options::overwrite_existing, ec);
if (ec) LOG_ERROR << "Demo copy failed: " << ec.message();
} else {
bool ok = JpegXlEncoder::encodeToFile(file.string(), img.data, img.cols, img.rows,
frame.channels, out.jxl_distance,
out.jxl_effort);
if (!ok) {
if (!JpegXlEncoder::encodeToFile(file.string(), img.data, img.cols, img.rows,
frame.channels, out.jxl_distance, out.jxl_effort)) {
LOG_ERROR << "Failed to encode " << file.string();
return;
}
}
// Sampled at submit(), not here: by this point the encode above has run and
// the gimbal may already be slewing to the next waypoint.

View File

@ -224,7 +224,7 @@ struct Application::Impl {
std::unique_ptr<IControlChannel> channel;
std::unique_ptr<IMotorController> motor;
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
// which profile each camera is currently on.
std::map<std::string, std::map<std::string, CameraProfile>> camera_profiles;
@ -869,7 +869,8 @@ struct Application::Impl {
// --- Camera / capture ---
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
// enforcement is in requireManual(), not in the UI.
s.capture.manual = manualMode();
@ -1033,6 +1034,11 @@ struct Application::Impl {
a.title = "AUTO";
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.
@ -1123,6 +1129,7 @@ struct Application::Impl {
void runInitSequence() {
using namespace std::chrono_literals;
LOG_INFO << "Running gimbal init sequence (enable + home)";
stopContinuous("homing");
// Mark homing active so interfering commands are ignored and Esc cancels.
// This blocks the control thread, so commands are drained+discarded here.
homing_active_ = true;
@ -1790,6 +1797,7 @@ struct Application::Impl {
if (m == Mode::Pan && !checkPan(pan)) return false;
const Mode was = scheduler->mode();
if (scheduler->continuous()) LOG_INFO << "continuous capture off (mode change)";
if (m == Mode::Manual) camera->stop();
else camera->start();
scheduler->setMode(m, pan);
@ -1864,7 +1872,8 @@ struct Application::Impl {
<< (h.size() == 1 ? " position" : " positions") << " (" << panRangeText(h)
<< "), at " << (scheduler->panIndex() + 1) << "/" << h.size();
} else {
LOG_INFO << "mode " << modeName(m);
LOG_INFO << "mode " << modeName(m)
<< (scheduler->continuous() ? " (continuous capture on)" : "");
}
}
@ -1884,6 +1893,7 @@ struct Application::Impl {
return;
}
if (!requireManual("gimbal heading setup")) return;
stopContinuous("heading setup");
heading_setup_active_ = true;
heading_marks_.clear();
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";
}
// `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,
// and a nudge mid-sweep would silently corrupt the waypoint the next frame is
// published under. The mode gate in handleCommand() already refuses these
@ -2413,7 +2468,7 @@ struct Application::Impl {
std::string err;
if (!resolveTestSelection(subsystem, leaf, mask, 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;
}
startTest(mask, profileName);
@ -2449,6 +2504,7 @@ struct Application::Impl {
// --verify does exactly that) must not leave it idle afterwards.
// Typed tests only run in manual (allowedIn); a --test at startup may find
// a scan running, and gets it back afterwards.
stopContinuous("test");
resume_mode_after_test_ = scheduler->mode();
resume_pan_after_test_ = scheduler->pan();
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.
static bool interferingVerb(const std::string& verb) {
return verb == "gimbal" || verb == "mode" || verb == "test" ||
verb == "snapshot" || verb == "camera";
verb == "snapshot" || verb == "capture" || verb == "camera";
}
void handleCommand(const std::string& line) {
@ -2723,6 +2779,8 @@ struct Application::Impl {
else LOG_WARN << "usage: camera reset";
} else if (c.verb == "snapshot") {
takeSnapshot();
} else if (c.verb == "capture") {
handleCapture(c.device);
} else if (c.verb == "mode") {
handleMode(line);
} else if (c.verb == "debug") {
@ -2746,25 +2804,9 @@ struct Application::Impl {
else
LOG_WARN << "more than one camera configured: "
"usage: set camera profile <camera> <name>";
} else if (c.option == "profile") {
} else {
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 {
LOG_WARN << "unknown 'set' target: " << c.device;
}
@ -2929,7 +2971,6 @@ struct Application::Impl {
ImagePipeline::Params pp;
pp.output_dir = cfg.paths.output_dir;
pp.tower = cfg.general.tower_name;
pp.demo = opts.demo;
// In open order, matching Frame::cam_id: the source opens the active
// cameras in this order too.
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
// stops - attributed to whatever the gimbal happened to be pointing at then.
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();
}
void CaptureScheduler::setImageRate(double rate) { image_rate_ = rate; }
void CaptureScheduler::setSettleWait(int settle_ms, int timeout_ms) {
settle_wait_ms_ = settle_ms < 0 ? 0 : settle_ms;
@ -82,38 +93,66 @@ void CaptureScheduler::requestSnapshot() {
snapshot_settled_ms_ = 0;
}
// One frame, on request, through exactly the path a scan waypoint takes: the
// same standstill + settle wait, the same camera trigger, and therefore the same
// .jxl on disk and the same CamEvent on MQTT. Reusing the path rather than
// triggering the camera directly is the point - a snapshot that differed from a
// scan frame would be useless as a reference for aiming.
void CaptureScheduler::serviceSnapshot(const MotorTelemetry& t) {
// Wait for rest, settle, then shoot - the tail of a scan waypoint without the
// move. A snapshot and a continuous-capture frame both come through here, so
// either is exactly what a scan waypoint produces: same file, same CamEvent.
//
// Standstill only, no target check: the operator aims, so there is no target.
// 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);
if (!standstill) {
snapshot_settled_ms_ = 0; // still slewing (e.g. a jog is in flight)
return;
settled_ms = 0; // still slewing (e.g. a jog is in flight)
return Shot::Waiting;
}
if (snapshot_settled_ms_ == 0) {
snapshot_settled_ms_ = now_ms_();
return;
if (settled_ms == 0) {
settled_ms = now_ms_();
return Shot::Waiting;
}
const long long since_settled = now_ms_() - snapshot_settled_ms_;
if (since_settled < settle_wait_ms_) return;
const long long since_settled = now_ms_() - settled_ms;
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()) {
LOG_INFO << "snapshot captured";
snapshot_pending_ = false;
void CaptureScheduler::serviceSnapshot(const MotorTelemetry& t) {
switch (shootAtRest(t, snapshot_settled_ms_)) {
case Shot::Waiting:
return;
}
// No fresh frame yet. Give up on the same budget a waypoint gets - measured
// 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.
if (since_settled > settle_wait_ms_ + frame_timeout_ms_) {
case Shot::Captured:
LOG_INFO << "snapshot captured";
break;
case Shot::TimedOut:
LOG_WARN << "snapshot: no frame within " << frame_timeout_ms_
<< " ms of settling; press Space again";
snapshot_pending_ = false;
<< " ms of settling; send 'snapshot' (Space) again";
break;
}
snapshot_pending_ = false;
}
// The scan's cycle without the move: wait the interval, then shoot at rest.
// The timer restarts after each frame kept or given up, exactly as a scan's
// does, so the cadence matches what a scan achieves rather than the bare
// interval.
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() {
@ -121,10 +160,13 @@ void CaptureScheduler::tick() {
MotorTelemetry t = motor_.telemetry();
pitch_present_ = t.pitch_present;
// 2. Manual snapshot. Only in manual, so a keypress can never inject a frame
// into a running sweep or fight it for the settle window.
// 2. Manual: a snapshot, else continuous capture. Only in manual, so a
// 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 (snapshot_pending_) serviceSnapshot(t);
else if (continuous_) serviceContinuous(t);
return;
}

View File

@ -24,217 +24,102 @@ std::string verbOf(const std::string& syntax) {
const std::vector<HelpSection>& helpCatalog() {
// clang-format off
static const std::vector<HelpSection> catalog = {
{"Modes",
"What the tower is doing: auto, pan or manual. StatusCode on MQTT says which (0/1/2).", {
{"mode",
"Say which mode the tower is in (and, in pan, where it is looking).", {}},
{"mode auto",
"Sweep the scan grid, a picture at every waypoint. Key: a.", {
"The normal, unattended state; the boot service starts here (--start).",
"In auto and pan only reading commands, logging, 'mode', 'cancel' and",
"'exit' are accepted: nothing that moves the gimbal or changes a setting."}},
{"mode pan <heading> <span> [<pitch>]",
"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)."}},
{"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."}},
{"mode pan <heading> <span> [<pitch>]", "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). Key p pans 30 deg around the current aim.",
"Example: mode pan 90 30"}},
{"mode manual", "Stop scanning; aim by hand (key m). Cancels a running procedure.", {}},
}},
{"Positioning",
"Aim the gimbal. Yaw is a TRUE BEARING (0 = north); pitch is elevation (0 = level).", {
{"gimbal move <yaw>,<pitch>",
"Point at an absolute bearing/elevation in DEGREES.", {
"Converts to encoder counts with the [Motor] calibration and moves both",
"axes together. Yaw travel is under a full turn, so bearings in the",
"unreachable arc are refused rather than clamped to the nearest limit.",
"Manual mode only - refused while a scan is running.",
"Example: gimbal move 30,-10 (bearing 30 deg, 10 deg below level)"}},
{"gimbal steps <yaw>,<pitch>",
"Move both axes to absolute encoder COUNTS (no degree conversion).", {
{"Capture", "Images and cameras. Manual mode, homed gimbal.", {
{"snapshot", "Take one image now (key Space).", {
"Waits for standstill and the profile's settle time, as a scan waypoint",
"does, so the file and CamEvent are the same as a scan frame's."}},
{"capture [on|off]", "Continuous capture where the gimbal is aimed (key r).", {
"One image per scan cycle: [General] image_interval, then standstill and",
"the profile's settle time. The gimbal can still be nudged; images are",
"kept only while it stands still. Bare 'capture' reports on/off. Ends on",
"any mode change, homing, heading setup or test."}},
{"set camera profile [<camera>] <name>", "Switch a camera's VimbaX settings profile.", {
"<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"}},
{"gimbal nudge <yaw|pitch> <+/-pct>",
"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.",
{"gimbal nudge <yaw|pitch> <+/-pct>", "Relative move, percent of travel (arrow keys).", {
"Example: gimbal nudge yaw -5"}},
{"gimbal jog <yaw|pitch> <+/-counts>",
"Relative move in raw encoder counts.", {
"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.",
{"gimbal jog <yaw|pitch> <+/-counts>", "Relative move in encoder counts.", {
"Steps below ~13 counts are finer than the encoder resolves.",
"Example: gimbal jog yaw -25"}},
{"gimbal heading setup",
"Tie encoder counts to true north by sighting known landmarks.", {
"Aim at a landmark, then 'gimbal heading mark <its true bearing>'; repeat,",
"then 'gimbal heading fit'. Two landmarks minimum, the further apart the",
"better. On the dashboard it runs in the activity strip: arrows aim, Tab",
"step size, Enter mark, Space image, f fit, Esc cancel. Needs no IMU."}},
{"gimbal heading mark <deg>",
"Record where the gimbal points now as the landmark's true bearing.", {
"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.",
{"gimbal home [y|p]", "Home both axes, or one.", {}},
{"gimbal stop [y|p]", "Stop motion now (bare: every axis).", {}},
{"gimbal heading setup|mark <deg>|fit|cancel", "Tie yaw to true north by sighting landmarks.", {
"setup, then aim at a landmark and 'mark <its true bearing>'; repeat for",
"at least two, far apart; then 'fit'. 'fit <counts>:<deg> ...' fits",
"known pairs without a setup.",
"Example: gimbal heading mark 137.4"}},
{"gimbal heading offset <deg>", "Shift the whole yaw map by a fixed amount.", {
"Example: gimbal heading offset -2.5"}},
{"gimbal home [y|p]",
"Run the endstop-finding home sequence (both axes, or one).", {
"Example: gimbal home / gimbal home y"}},
{"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.", {}},
{"gimbal pitch setup", "Calibrate pitch against gravity (needs the IMU).", {
"Homes if needed, sweeps pitch, fits, parks level and offers to save.",
"Takes minutes; Esc or 'cancel' stops it."}},
}},
{"IMU", "Read and configure the Xsens MTi orientation sensor.", {
{"imu status",
"Print the latest orientation reading.", {
"Yaw/pitch/roll, device temperature and sample counter. The Sensors panel",
"and the 's' view show the same data continuously in the TUI."}},
{"imu config",
"Re-read the device configuration and print it.", {
"Identity, firmware, sample rate, active XKF profile and the available",
"list. Re-reads first, so it never reports a stale profile."}},
{"imu profile [<type|label>]",
"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"}},
{"Motor", "Firmware-level motor controls.", {
{"gimbal speed <y|p> <vel>", "Max slew speed, counts/s.", {}},
{"gimbal current <y|p> <scaler> <irun> <irun_home>", "Motor current as raw TMC fields.", {
"Normally set from hardware.ini [Motor.<axis>] in mA."}},
{"gimbal reset|enable|disable <y|p>", "Reset, enable or disable an axis driver.", {}},
{"gimbal setpos <y|p> <counts>", "Redefine an axis's current position.", {}},
{"gimbal status", "Emit one telemetry line now.", {}},
{"gimbal dump", "Fetch the firmware state; shown in the gimbal view (g).", {}},
{"gimbal raw \"<command>\"", "Send text to the firmware verbatim.", {
"Example: gimbal raw \"MOVE 100000,250000\""}},
}},
{"Diagnostics", "Inspect, self-test and calibrate the gimbal.", {
{"gimbal dump",
"Request a full firmware state dump and show it here.", {
"Build, uptime, reset cause and the per-axis TMC5160 registers; also shown",
"in the gimbal 'g' view. Offline equivalent: ./firmware/dump.sh"}},
{"test [<subsystem> [<leaf>]] [<profile>]",
"Run the hardware self-test suite; a report lands in logs/test_*.log.", {
"'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",
"and the previous run. Esc cancels.",
{"IMU", "Xsens MTi orientation sensor.", {
{"imu status", "Latest orientation reading (also in the sensors view, s).", {}},
{"imu config", "Re-read and print the device configuration.", {}},
{"imu profile [<type|label>]", "Select the XKF filter profile (bare: list them).", {
"Persists on the device. Example: imu profile General"}},
{"imu zero", "Make the current pose yaw 0. Run 'imu norotation' first.", {}},
{"imu norotation [<seconds>]", "Gyro-bias update; hold still (default 3 s).", {}},
}},
{"Tests", "Self-tests. Reports land in logs/test_*.log.", {
{"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"}},
{"test hardware [axes|motor|imu|env|camera]",
"Check the rig against what hardware.ini declares.", {
"Preconditions, not measurements: the declared axes appear in telemetry and",
"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\""}},
{"test hardware [axes|motor|imu|env|camera]", "Check the rig matches hardware.ini.", {}},
{"test camera [frames|dark|bright]", "Check the camera delivers and responds.", {
"Never judges the picture itself. Capture pauses meanwhile."}},
}},
{"Capture", "Control image capture and encoding.", {
{"set fps <rate>", "Set capture rate in images/second.", {}},
{"set camera profile [<camera>] <name>",
"Switch a camera's profile (rate, exposure, all imaging settings).", {
"A profile is a settings XML exported from the VimbaX Viewer, in that",
"camera's [Camera.<name>] profile_dir; the name is the filename stem. The",
"camera may be omitted when only one is configured.",
"Example: set camera profile night / set camera profile rgb night"}},
{"set camera jxlq <dist>", "Set JPEG-XL distance (lower = higher quality).", {}},
{"set camera jxle <effort>", "Set JPEG-XL encode effort.", {}},
{"set camera display <0|1>", "Toggle the local display window.", {}},
{"Keys", "Dashboard shortcuts. Each sends the command shown.", {
{"a / p / m", "mode auto / mode pan (30 deg around the aim) / mode manual.", {}},
{"h", "gimbal home.", {}},
{"Space / r", "snapshot / capture on|off.", {}},
{"Arrows", "gimbal nudge: yaw 5%, pitch 10% (manual).", {}},
{"Esc", "Close the view, or cancel a running procedure.", {}},
{"? / g / s / c / n", "Views: help, gimbal, sensors, cameras, connectivity.", {}},
{"PgUp / PgDn", "Scroll the log or the open view.", {}},
{"f", "Freeze the screen, to select text or read back.", {}},
{"q", "Quit, or leave the dashboard running in the background.", {}},
}},
{"Logging", "Control console/log verbosity.", {
{"debug", "Toggle debug-level logging on/off.", {}},
{"trace <serial|mqtt|camera|control|all|off> [on|off]",
"Toggle verbatim wire-trace categories.", {
"Example: trace serial on / trace off"}},
}},
{"Display", "Terminal dashboard keys (TUI only).", {
{"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"}},
{"Session", "Logging and exit.", {
{"help [topic]", "This list; 'help <topic>' adds detail.", {
"Example: help capture / help positioning"}},
{"cancel", "Cancel the running test, homing or calibration.", {}},
{"debug", "Toggle debug-level logging.", {}},
{"trace <serial|mqtt|camera|control|all|off> [on|off]", "Toggle wire-trace categories.", {
"Example: trace serial on"}},
{"exit", "Shut down and quit.", {}},
}},
};
@ -247,12 +132,22 @@ std::vector<std::string> renderHelp(const std::string& topic) {
const std::string q = lower(topic);
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()) {
out.emplace_back("");
out.emplace_back("== " + sec.title + " == " + sec.blurb);
for (const auto& e : sec.entries)
out.emplace_back(" " + e.syntax + " - " + e.summary);
out.emplace_back(sec.title + " - " + sec.blurb);
for (const auto& e : sec.entries) {
std::string l = " " + e.syntax;
l.append(e.syntax.size() < width ? width - e.syntax.size() : 0, ' ');
out.emplace_back(l + " " + e.summary);
}
}
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("PgUp/Dn", "Log"));
break;
case Overlay::Help:
e.push_back(keyHint("j/k", "Section"));
e.push_back(keyHint("PgUp/Dn", "Scroll"));
break;
case Overlay::Gimbal:
e.push_back(keyHint("d", "Dump"));
e.push_back(keyHint("PgUp/Dn", "Scroll"));
break;
case Overlay::Help:
case Overlay::Sensors:
case Overlay::Cameras:
case Overlay::Conn:
@ -279,6 +276,7 @@ Element keyBar(Overlay overlay, bool manual, bool heading_setup) {
if (manual) {
e.push_back(keyHint(kArrows4, "Nudge"));
e.push_back(keyHint("Spc", "Snap"));
e.push_back(keyHint("r", "Capture"));
}
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)));
}
// Inline help pane (toggled with '?'). Lists every command section; the
// `sel`-th section is expanded to show each entry's detail. The Diagnostics
// section additionally renders the last captured firmware DUMP block.
Element helpPanel(int sel, const DumpView& dump, ScrollState& st) {
// Help view ('?'): every section on one page, one aligned line per command,
// scrolled with PgUp/PgDn like every other view. The detail lines stay in the
// catalog for ':help <topic>', which keeps this page a quick reference.
Element helpPanel(ScrollState& st) {
const auto& cat = helpCatalog();
std::vector<Element> rows;
for (int i = 0; i < static_cast<int>(cat.size()); ++i) {
const HelpSection& sec = cat[i];
const bool open = (i == sel);
Element title = hbox({
text(open ? " v " : " > "),
text(sec.title) | bold,
text(" " + sec.blurb) | dim,
});
rows.push_back(open ? (title | inverted) : title);
if (!open) continue;
size_t width = 0;
for (const auto& sec : cat)
for (const auto& e : sec.entries) width = std::max(width, e.syntax.size());
width = std::min<size_t>(width, 40); // a rare long syntax wraps rather than
// pushing every summary off screen
for (const auto& e : sec.entries) {
rows.push_back(hbox({text(" "), text(e.syntax) | color(Color::Cyan) | bold}));
rows.push_back(hbox({text(" "), text(e.summary) | dim}));
for (const auto& d : e.detail)
rows.push_back(hbox({text(" "), text(d) | dim}));
}
// Diagnostics: show the most recent firmware dump inline.
if (sec.title == "Diagnostics") {
rows.push_back(text(" --- last firmware dump ---") | bold);
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 {
std::vector<Element> rows;
for (const auto& sec : cat) {
if (!rows.empty()) rows.push_back(text(""));
rows.push_back(hbox({text(sec.title) | bold | color(Color::Cyan),
text(" " + sec.blurb) | dim}));
for (const auto& e : sec.entries)
rows.push_back(hbox({text(" "),
text("(none captured yet - run 'gimbal dump')") | dim}));
}
text(e.syntax) | bold | size(WIDTH, EQUAL, static_cast<int>(width)),
text(" "), paragraph(e.summary) | dim | flex}));
}
rows.push_back(text(""));
}
return window(overlayTitle("HELP", "?:close j/k:section", st, Color::Cyan),
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));
}
@ -1259,12 +1242,12 @@ void TuiUi::uiLoop() {
int cmd_cursor = 0;
bool command_mode = false;
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 calib_prompt = false; // a yes/no calib-save question is showing
// Mirrored out of the snapshot each render: CatchEvent runs on the same
// thread but has no access to the renderer's local snapshot copy.
bool manual_mode = true;
bool capture_on = false; // continuous capture, so r knows which way to toggle
bool heading_setup = false;
double yaw_deg = 0.0, pitch_deg = 0.0; // where the gimbal points, for 'p'
bool pitch_present = false;
@ -1325,6 +1308,7 @@ void TuiUi::uiLoop() {
calib_prompt = !s.activity.prompt.empty();
manual_mode = s.capture.manual;
capture_on = s.capture.continuous;
heading_setup = s.activity.heading_setup;
yaw_deg = s.gimbal.yaw.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.
switch (overlay) {
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:
return vbox({header, separator(),
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; }
}
const int n = static_cast<int>(helpCatalog().size());
// Scroll the view that owns the body, or the log. PgUp/PgDn only.
switch (overlay) {
case Overlay::None: if (scrollKeys(log_scroll, e)) {
@ -1571,10 +1554,6 @@ void TuiUi::uiLoop() {
if (paused_) paused_at_hms_ = nowHms();
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 == "a") { if (sink_) sink_("mode auto"); 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.
// The app refuses it outside manual; the bar just stops advertising it.
if (c == " ") { if (sink_) sink_("snapshot"); return true; }
if (c == "r") { if (sink_) sink_(capture_on ? "capture off" : "capture on"); return true; }
return false;
});

View File

@ -5,32 +5,33 @@
using namespace fgc;
TEST_CASE("parseCommand handles single-verb commands") {
auto c = parseCommand("start");
CHECK(c.verb == "start");
auto c = parseCommand("snapshot");
CHECK(c.verb == "snapshot");
CHECK(c.device.empty());
CHECK_FALSE(c.has_value);
}
TEST_CASE("parseCommand splits device and trailing numeric value") {
auto c = parseCommand("set fps 5");
CHECK(c.verb == "set");
CHECK(c.device == "fps");
TEST_CASE("parseCommand takes the second word as the device") {
auto c = parseCommand("capture on");
CHECK(c.verb == "capture");
CHECK(c.device == "on");
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.value == doctest::Approx(5.0));
}
TEST_CASE("parseCommand keeps option plus value") {
auto c = parseCommand("set camera jxlq 2");
TEST_CASE("parseCommand joins non-numeric trailing words into the option") {
auto c = parseCommand("set camera profile night");
CHECK(c.device == "camera");
CHECK(c.option == "jxlq");
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(c.option == "profile night");
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\"",
"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",
"camera reset", "snapshot", "calib save", "gimbal"}) {
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") {
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"})
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_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);
}