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:
parent
a21be0d4e8
commit
895c2f22fd
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
2
main.cpp
2
main.cpp
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,17 +108,10 @@ 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) {
|
||||
LOG_ERROR << "Failed to encode " << file.string();
|
||||
return;
|
||||
}
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
return;
|
||||
void CaptureScheduler::serviceSnapshot(const MotorTelemetry& t) {
|
||||
switch (shootAtRest(t, snapshot_settled_ms_)) {
|
||||
case Shot::Waiting:
|
||||
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
|
||||
// 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_) {
|
||||
LOG_WARN << "snapshot: no frame within " << frame_timeout_ms_
|
||||
<< " ms of settling; press Space again";
|
||||
snapshot_pending_ = false;
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
rows.push_back(hbox({text(" "),
|
||||
text("(none captured yet - run 'gimbal dump')") | dim}));
|
||||
}
|
||||
}
|
||||
rows.push_back(text(""));
|
||||
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(e.syntax) | bold | size(WIDTH, EQUAL, static_cast<int>(width)),
|
||||
text(" "), paragraph(e.summary) | dim | flex}));
|
||||
}
|
||||
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));
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue