Give the tower three modes, auto, pan and manual, switched by command

This commit is contained in:
pgdalmeida 2026-09-25 17:50:48 +02:00
parent 4a69d65afb
commit f4e67666d8
Signed by: pedro.almeida
GPG Key ID: D4A6C394DF13F1D7
31 changed files with 832 additions and 382 deletions

View File

@ -62,6 +62,7 @@ add_library(fgc_core STATIC
src/core/IniEdit.cpp
src/core/CameraStall.cpp
src/core/RemoteCommands.cpp
src/core/OperatingMode.cpp
src/core/Paths.cpp
src/core/SingleInstance.cpp
src/core/Logger.cpp

View File

@ -218,7 +218,7 @@ press a key that acts.
| Key | Does |
|---|---|
| `s` / `x` | start / stop the capture sweep |
| `s` / `x` | auto mode (sweep and capture) / manual mode (stop, take it by hand) |
| `h` | home the gimbal |
| `r` | refresh: ask the firmware for a full status dump, re-read the sensor setup |
| `g` `i` `c` `m` | open the full-screen gimbal, sensors, camera or comms view; press it again to close |
@ -295,6 +295,14 @@ echo $? # 0 = PASS, 4 = FAIL, 5 = could not start (the log says why)
## Everyday commands
**A tower is always in one of three modes.** In **auto** it sweeps its scan
grid by itself - the normal state, and where it starts at boot. In **pan** it
watches one direction: `mode pan 90 30` sweeps 75° to 105°, a picture every
6°, over and over. In **manual** it does nothing by itself and a person drives
it. Auto and pan refuse anything that would move the gimbal or change a
setting, so `mode manual` comes first; the tower publishes its mode on MQTT
(`StatusCode`: 0 auto, 1 pan, 2 manual).
The same commands work at the `:` prompt in the dashboard, in the plain
console, and **over MQTT**: a web maintenance interface can send any of them on
`GGS/FWT/<tower>/Command` and read the answer on `CommandReply` - the lines the
@ -304,11 +312,12 @@ job. Details, payloads and safety rules: [docs/mqtt-api.md](docs/mqtt-api.md).
| Command | Does |
|---|---|
| `start` / `stop` | start / stop the capture sweep (the gimbal stays homed and powered) |
| `mode auto` / `mode manual` | sweep the scan grid / stop and take the gimbal by hand (it stays homed and powered) |
| `mode pan <heading> <span> [<pitch>]` | watch one direction: `mode pan 90 30` covers 75°-105°, a picture every 6° |
| `gimbal home` | find the endstops and centre - needed after every power-up |
| `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 (only when no sweep is running) |
| `snapshot` | take one picture now (manual mode) |
| `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` |

View File

@ -156,7 +156,7 @@ period_ms = 2000
[Motor]
; Degrees<->encoder-counts maps, anchored on the mechanical centre found by
; homing. The firmware speaks only in absolute encoder counts; these map them to
; the heading/elevation degrees used by MQTT (target_HDG) and CamEvent.
; the heading/elevation degrees used by mode pan, gimbal move and CamEvent.
;
; Homing finds both endstops, moves to the middle of that range and DEFINES the
; midpoint as <axis>_center_count. Anchoring on the midpoint rather than one

View File

@ -49,6 +49,9 @@ imu = none
env = sht41
; The [Camera.<name>] blocks expected to be present and found.
cameras = rgb
; How much bearing one picture covers, in degrees. Pan mode ('mode pan') cuts
; its span into as many pictures as this takes. 6 until the lens is measured.
pan_step_deg = 6
;
; What happens when this does not match the rig:
; declared but absent -> warned about by name, and with [Features]

View File

@ -69,13 +69,16 @@ corrupted.
2. **Telemetry** — the real motor controller streams firmware `ST Y:...[ P:...]` lines; `parseTelemetryLine`
turns each into a per-axis `MotorTelemetry` snapshot (state + encoder counts + flags). The mock synthesizes
one. Encoder counts are mapped to/from degrees by `Geometry` (`[Motor]` calibration).
3. **Control input** — `IControlChannel::poll()` returns the latest `ControlCommand` (control code + target
heading), clearing its "available" flags so each update is acted on once.
3. **Mode** — auto, pan or manual ([OperatingMode.h](../include/fgc/OperatingMode.h)), changed only by
the `mode` command (console, dashboard keys, or MQTT `Command`) through `Application::setMode`, which
publishes it on StatusCode. The same header's `allowedIn()` gates every command by mode.
4. **Capture cycle** (`CaptureScheduler::tick`, per 10 ms) — a move → settle → wait → keep machine:
- ControlCode 0: `MOVE <yaw>,<pitch>` to the next `ScanGrid` waypoint (front to back, then wrap).
- ControlCode 1: `MOVE` yaw to `target_HDG` (pitch held), converted to counts via `Geometry`.
- 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.
- Once **both axes report standstill at the target**, wait `settle_wait_ms`, then keep the newest
frame and advance the grid.
frame and advance the grid or pan position.
5. **Acquisition** — the camera free-runs at the active mode's `AcquisitionFrameRate` with its own
auto-exposure tracking the light; `trigger()` hands over the freshest complete frame. See *Image
capture* below.
@ -88,11 +91,11 @@ corrupted.
## Capture state machine
```
interval elapsed AND capture active AND not already moving
interval elapsed AND mode auto or pan AND not already moving
│
▼
┌──────────────────────┐ ControlCode 0 → next ScanGrid (yaw,pitch)
│ MOVE <yaw>,<pitch> │ ControlCode 1 → target_HDG (pitch held)
┌──────────────────────┐ auto → next ScanGrid (yaw,pitch)
│ MOVE <yaw>,<pitch> │ pan → next pan heading, at the pan's pitch
└──────────┬───────────┘ deg→counts via Geometry; set moving, reset timer
│ (both axes standstill AND |xenc − target| ≤ tol)
▼
@ -100,7 +103,7 @@ corrupted.
│ wait settle_wait_ms │ what makes the kept frame one exposed at rest
└──────────┬───────────┘
▼
┌──────────────────────┐ on success: clear moving, advance grid (ControlCode 0)
┌──────────────────────┐ on success: clear moving, advance the grid / pan position
│ camera.trigger() │ no fresh frame: retry next tick
└──────────────────────┘ still none after frame_timeout_ms: warn, skip, advance
```

View File

@ -93,7 +93,7 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config
### `[Motor]` calibration & `[Scan]` grid
The firmware reports only **encoder counts**; `[Motor]` maps them to the bearing/elevation degrees
used by MQTT (`target_HDG`) and `CamEvent`. **Yaw degrees are true bearings: 0 is north.** Pitch
used by `mode pan`, `gimbal move` and `CamEvent`. **Yaw degrees are true bearings: 0 is north.** Pitch
degrees are elevation above level, so 0 is horizontal.
#### The counts are anchored by homing
@ -158,7 +158,7 @@ gimbal heading offset -2.5 # shift the whole map by a known amount, and sav
`gimbal pitch setup` (**requires the IMU**) fits `pitch_counts_per_deg` and `pitch_angle_at_center` by
sweeping pitch and correlating encoder counts with the MTi's gravity-referenced pitch. It auto-homes
if needed, dwells at each step, fits, then parks level. Writes `logs/pitch_setup_*.log`, reports R²,
and offers the same save prompt. `stop` or Esc cancels. Timings are the `CalibParams` defaults in
and offers the same save prompt. `cancel` or Esc cancels. Timings are the `CalibParams` defaults in
[PitchSetup.h](../include/fgc/PitchSetup.h).
This is the one axis that genuinely needs the IMU — level is defined by gravity and nothing else on
@ -411,6 +411,14 @@ generic `IEnvSensor` interface
([include/fgc/IEnvSensor.h](../include/fgc/IEnvSensor.h)), so swapping in a different ambient sensor
later means adding a new backend, not touching `Application`, the UI, or MQTT.
### How much one picture covers: `[Hardware] pan_step_deg`
`mode pan <heading> <span>` cuts its span into as many pictures as it takes at
`pan_step_deg` degrees of bearing each (default **6**, until the lens is
measured), both edges included - so `mode pan 90 30` takes six pictures, 75°
to 105°. It is a property of the camera and lens, so it lives in
`hardware.ini`. Must be above 0. See [mqtt-api.md](mqtt-api.md#modes-auto-pan-manual).
### One key, not two: declaring a sensor is what enables it
`[Hardware] imu` and `[Hardware] env` name what is fitted. `[Features] enable_imu`
@ -545,8 +553,10 @@ Handled in `Application::Impl::handleCommand`.
| Type this | Meaning |
|-----------|---------|
| `start` | Start camera acquisition + capture |
| `stop` | Stop acquisition |
| `mode` | Say which mode the tower is in: auto, pan or manual |
| `mode auto` | Sweep the scan grid (camera acquisition + capture) |
| `mode pan <heading> <span> [<pitch>]` | Watch one direction: sweep `heading ± span/2` in `pan_step_deg` steps, pitch default 0 |
| `mode manual` | Stop capturing; every command accepted. In auto and pan, only reading commands, `debug`/`trace`, `mode`, `cancel` and `exit` are |
| `debug` | Toggle debug logging |
| `trace <cat> [on\|off]` | Toggle a wire-trace category (`serial`/`mqtt`/`camera`/`control`) |
| `trace all` / `trace off` | Enable every category / silence all |
@ -644,7 +654,7 @@ the original, so an interrupted save cannot truncate your config.
`gimbal heading offset` saves immediately without asking — it is a one-line correction, not a fit.
**The key bar is contextual**: it advertises the keys that are useful in the current view, not every
binding. `s` start and `x` stop lead everywhere — a scan can be started and stopped from any view —
binding. `s` auto and `x` 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, `r` refresh,
`g i c m` views, `p` pause and the arrow keys (nudge the gimbal, yaw ±5 % / pitch ±10 %); an
expanded view instead offers its own (`d` dump in the gimbal view, `r` refresh in sensors, the
@ -681,7 +691,7 @@ Trace lines carry a category tag and a `TX`/`RX` direction so one subsystem is e
```
[SERIAL] TX MOVE -90,0 (command to firmware: yaw,pitch counts)
[SERIAL] RX ST Y:A,982,969,80084000,0,8,8,Se P:A,... (status from firmware)
[MQTT] PUB GGS/FWT/Tower/StatusCode 0 / [MQTT] RX GGS/FWT/Tower/target_HDG 180
[MQTT] PUB GGS/FWT/Tower/StatusCode 0 / [MQTT] RX GGS/FWT/Tower/Command mode pan 180 30
[CAMERA] TX trigger cam0 / [CAMERA] RX frame cam0 1936x1216 7064576B
[CONTROL] sweep -> grid yaw=-90 pitch=0 (scheduler decisions + inbound console commands)
```
@ -840,7 +850,8 @@ quietly overwrite it.
error in the angle is a 1 % error in every bearing. `gimbal heading setup` supersedes it.
Capture is a **move → settle → trigger** cycle: the scheduler issues a `MOVE`, waits until both
axes report standstill at the target, then triggers the cameras. **ControlCode 0** walks the scan
grid (front to back, repeating); **ControlCode 1** drives yaw to `target_HDG` (pitch held). Telemetry arrives as
axes report standstill at the target, then triggers the cameras. In **auto** mode it walks the scan
grid (front to back, repeating); in **pan** mode it walks the pan's headings left to right at the pan's
pitch, then starts again from the left edge (see [mqtt-api.md](mqtt-api.md#modes-auto-pan-manual)). Telemetry arrives as
firmware `ST` lines (per-axis state + encoder counts), parsed by
[TelemetryParser](../src/core/TelemetryParser.cpp).

View File

@ -291,7 +291,7 @@ list below and says which.
| Camera opened, but no images and `no frame yet (stream warming up)` for minutes | the stream is stuck; the program restarts it and then resets the camera by itself (known-issues #15), or type `camera reset` |
| `No camera found` / Vimba startup error | SDK installed, GenTL path set (`/etc/profile.d/*Vimba*GenTL*.sh` or `VIMBA_CTI_PATH`), `[Camera.rgb] id` in `hardware.ini` |
| `MQTT connect failed` (degraded mode) | broker reachable, `credentials.env` readable by `ggs`, firewall |
| Runs but no images | capture started (`--start`, or the `start` command) and the gimbal homed; `output_dir` writable |
| Runs but no images | in auto mode (`--start`, or `mode auto`) and the gimbal homed; `output_dir` writable |
| `another instance is already running` (exit 2) | `scripts/fgc status`; the service and a hand-started run both up (section 3) |
| `fgc status` says not running, but the program is | the service started it before anyone logged in, without lingering (section 4) |
| Program dies when ssh disconnects | started with `run.sh` / `deploy.sh --foreground` instead of `scripts/fgc start` |

View File

@ -97,9 +97,10 @@ on this rig and is what the TUI's STALL badge shows. `stall` (flag `s`) is Stall
because production runs StealthChop; it is still parsed in case that changes. The latch is cleared by
homing or `gimbal reset`, so a lit badge means "position untrustworthy since the last home".
### `ControlCommand` ([IControlChannel.h](../include/fgc/IControlChannel.h))
`control_code` (0 = scan-grid sweep, 1 = directed to `target_HDG`) + `target_heading`, each with an
`*_available` flag.
### `Mode`, `PanSpec` ([OperatingMode.h](../include/fgc/OperatingMode.h))
`Mode` is auto (0), pan (1) or manual (2) - the values StatusCode carries. `PanSpec` is a pan's
`heading`, `span` and `pitch` in degrees; `panHeadings()` turns it into the bearings visited, and
`allowedIn()` says which commands a mode accepts.
### `CamEvent` ([IControlChannel.h](../include/fgc/IControlChannel.h))
`tower`, `camera` (RGB/ACR/NIR), `heading_decideg` (yaw×10), `pitch_decideg` (pitch×10) and

View File

@ -1,9 +1,10 @@
# MQTT API
The program is both an MQTT **subscriber** (remote control) and **publisher** (status + capture events). The
The program is both an MQTT **subscriber** (commands) and **publisher** (its mode, capture events, and
command replies). The
MQTT channel is the `MqttControlChannel` implementation of `IControlChannel`
([src/mqtt/MqttControlChannel.cpp](../src/mqtt/MqttControlChannel.cpp), Eclipse Paho C++). It is used only when
MQTT is enabled; otherwise a `NullControlChannel` runs (publishes dropped, auto-sweep mode). All topics are
MQTT is enabled; otherwise a `NullControlChannel` runs (publishes dropped and recorded). All topics are
namespaced by tower name:
```
@ -26,42 +27,62 @@ are **retained**, except command replies.
- **The program no longer exits if MQTT is unavailable** — it logs a warning and continues in degraded mode.
Use `--no-mqtt` to disable MQTT entirely (null channel).
## Subscribed topics (inbound — remote control)
## Modes: auto, pan, manual
Subscribed on (re)connect; handled in `MqttControlChannel::message_arrived()`
([src/mqtt/MqttControlChannel.cpp](../src/mqtt/MqttControlChannel.cpp)).
The tower is always in one of three modes. It changes mode **only by command** -
`mode ...`, sent on the `Command` topic below, typed at the console, or the
dashboard's `s` / `x` keys - and reports it on `StatusCode`.
| Topic | Payload | Parsed as | Effect |
|-------|---------|-----------|--------|
| `GGS/FWT/<tower>/target_HDG` | integer bearing as string, e.g. `"137"` | validated with `stoi`, stored as **string** | Sets the yaw target used in ControlCode 1 (a **true bearing**, 0 = north, converted to encoder counts via `[Motor]`) |
| `GGS/FWT/<tower>/ControlCode` | integer as string, `"0"` or `"1"` | `stoi` → int | Selects the capture mode (see below) |
| Mode | StatusCode | What the tower does | Command |
|---|---|---|---|
| auto | `0` | Sweeps the scan grid front to back, a picture at every waypoint, then starts over. The normal state: the boot service starts here (`--start`). | `mode auto` |
| pan | `1` | Watches one direction: walks left to right across a span centred on a bearing, a picture at each position, then back to the left edge. | `mode pan <heading> <span> [<pitch>]` |
| manual | `2` | Nothing by itself; a person drives it. The state without `--start`. | `mode manual` |
Invalid (non-integer) payloads are caught and logged; the previous value is kept.
**Pan positions.** `mode pan 90 30` covers 75°..105°. One picture covers
`pan_step_deg` of bearing (hardware.ini `[Hardware]`, 6 until the lens is
measured), so the span is cut into `ceil(span / step)` equal intervals, both
edges included: 75, 81, 87, 93, 99, 105 - six pictures. A span that does not
divide evenly gets one more picture, never a gap (32° → seven, 5.33° apart). A
span under one picture is the one heading, photographed once per loop. Pitch is
optional and defaults to 0 (level). Pictures are taken at the same pace as auto
(`image_interval`), and each publishes a CamEvent exactly as a sweep picture
does.
The main loop consumes these via `get_sub_data()`, which returns a snapshot and **clears the "available"
flags** so each update is acted on once (`MqttControlChannel::poll()`).
**Headings are true bearings and can be out of reach.** Yaw travel is under a
full turn, so there is always an arc the gimbal cannot reach. Pan positions in
it are skipped - not clamped to the nearest limit, which would image a
direction nobody asked for and label it with the one they did. A pan with no
reachable position, or a pitch outside the homed pitch travel, is refused. The
reachable arc is logged after every home.
### ControlCode semantics
**What each mode accepts.** Manual accepts every command. Auto and pan accept
only what cannot move the gimbal or change a setting:
| ControlCode | Behaviour ([CaptureScheduler](../src/core/CaptureScheduler.cpp)) |
|-------------|---------------------------------------------------|
| `0` | **Automatic sweep.** Walk the scan grid front to back, then start over: `MOVE` to the next `(yaw,pitch)` waypoint, wait for standstill, trigger cameras. |
| `1` | **Directed.** `MOVE` yaw to `target_HDG` (pitch held), wait for standstill, trigger. |
- reading: `help`, `gimbal status`, `gimbal dump`, `imu status`, `imu config`, `refresh`
- logging: `debug`, `trace`
- `mode ...`, `cancel`, `exit`
> **`target_HDG` is a true bearing and can be refused.** Yaw travel is under a full turn, so there is
> always an unreachable arc. A bearing falling in it is logged once and the gimbal **holds position**;
> it is not clamped to the nearest limit, which would image a direction up to 180° away and publish it
> under the bearing that was asked for. The reachable arc is logged after every home.
Anything else is refused - `ignored '<command>': the tower is in pan mode
('mode manual' first)` - whether it came over MQTT or from the console. So a
web page switches to manual before driving the gimbal, and back to auto (or
pan) when done.
When a ControlCode message arrives, the program echoes the current code back on the StatusCode topic.
**A refused mode change changes nothing**, StatusCode included. `mode auto`
and `mode pan` are refused while the gimbal is not homed, during heading
setup, or while a test or calibration runs.
## Published topics (outbound)
| Topic | When | Payload | QoS / Retain |
|-------|------|---------|--------------|
| `GGS/FWT/<tower>/StatusCode` | At startup (`"0"`); whenever a ControlCode message is received (echoes the code) | integer as string | 1 / retained |
| `GGS/FWT/<tower>/StatusCode` | At startup, on every mode change, and again on every reconnect | the mode: `"0"` auto, `"1"` pan, `"2"` manual | 1 / retained |
| `GGS/FWT/<tower>/CamEvent` | After each image is saved | JSON object (below) | 1 / retained |
StatusCode says what the tower **is doing**, not what it was last asked to do:
it is published by the mode change itself, whatever caused it. Being retained,
a page that subscribes late still learns the mode at once.
### CamEvent payload
Built by `MqttControlChannel::publishCamEvent` from a `CamEvent`:
@ -173,36 +194,42 @@ The tower subscribes on **its own broker** (RabbitMQ with the MQTT plugin, in
`~/zkms-client` on the tower). Today that broker has one path to the ground
station, and it runs outwards only: `GGS.FWT.*.CamEvent` is bound into a
`CamEvents` queue that a shovel (`rabbitmq/shovel-declaration.sh`) carries to
10.11.12.13. **Nothing carries messages from the ground station to a tower** -
which is also true of `ControlCode` and `target_HDG`.
10.11.12.13. **Nothing carries messages from the ground station to a tower.**
So a web interface either:
- connects to each tower's broker directly (over the VPN) - `Command` and
`CommandReply` both live there, and nothing else is needed; or
- goes through the ground station, which needs, in zkms-client: a
`CommandReply` queue, binding and shovel out (mirroring `CamEvents`), and an
inbound shovel from the ground station to the tower for `Command` (and
`ControlCode` / `target_HDG`).
inbound shovel from the ground station to the tower for `Command` (and a
binding and shovel out for `StatusCode`, so the ground station sees the mode).
### Example
### Example: watch a fire, then look around by hand
```text
# to GGS/FWT/dev/Command
{"id":"maint-17","cmd":"gimbal status"}
{"id":"w1","cmd":"mode pan 90 30"}
# from GGS/FWT/dev/CommandReply, and StatusCode becomes "1"
{"id":"w1","cmd":"mode pan 90 30","level":"info",
"lines":["16:40:02.114 [INFO ] mode pan: heading 90, span 30, pitch 0 deg - 6 positions (75..105 deg)"],
"truncated":false}
# from GGS/FWT/dev/CommandReply
{"id":"maint-17","cmd":"gimbal status","level":"info",
"lines":["16:40:02.114 [INFO ] remote ..."],"truncated":false}
{"id":"w2","cmd":"gimbal nudge yaw 5"}
# -> level "warn": ignored 'gimbal nudge yaw 5': the tower is in pan mode ('mode manual' first)
{"id":"w3","cmd":"mode manual"} # StatusCode "2"; now anything goes
{"id":"w4","cmd":"gimbal move 95,5"}
{"id":"w5","cmd":"snapshot"}
{"id":"w6","cmd":"mode auto"} # back to the sweep; StatusCode "0"
```
## Topic summary
```
subscribe: GGS/FWT/<tower>/target_HDG (int heading)
GGS/FWT/<tower>/ControlCode (0 = auto sweep, 1 = directed)
GGS/FWT/<tower>/Command (any console command: JSON {id, cmd} or a bare line)
subscribe: GGS/FWT/<tower>/Command (any console command: JSON {id, cmd} or a bare line;
modes change here: mode auto|pan|manual)
publish: GGS/FWT/<tower>/StatusCode (echoed control code)
publish: GGS/FWT/<tower>/StatusCode (the mode: 0 auto, 1 pan, 2 manual)
GGS/FWT/<tower>/CamEvent (JSON: fwt, cam, hdg×10, pit×10, time-ms)
GGS/FWT/<tower>/CommandReply (JSON: id, cmd, level, lines, truncated - not retained)
```
@ -213,8 +240,7 @@ Point `zkms_server_ip` at a local broker and exercise the topics with Mosquitto:
```bash
mosquitto_sub -t 'GGS/FWT/ExampleTower/#' -v # watch everything for the tower
mosquitto_pub -t 'GGS/FWT/ExampleTower/ControlCode' -m '0'
mosquitto_pub -t 'GGS/FWT/ExampleTower/target_HDG' -m '180'
mosquitto_pub -q 1 -t 'GGS/FWT/ExampleTower/Command' -m 'mode pan 180 30'
mosquitto_pub -q 1 -t 'GGS/FWT/ExampleTower/Command' -m '{"id":"t1","cmd":"gimbal status"}'
mosquitto_pub -q 1 -t 'GGS/FWT/ExampleTower/Command' -m 'help' # a bare line works too
```

View File

@ -34,7 +34,7 @@ token that names a configured profile selects the profile at any position.
| `test strict` | everything, `strict` profile |
While a test runs, interfering commands (moves, homing, a second test, capture,
`refresh`) are ignored; **Esc** (or `stop`) cancels. Pure-UI keys (`p`, `i`, `c`,
`refresh`) are ignored; **Esc** (or `cancel`, or `x`) cancels. Pure-UI keys (`p`, `i`, `c`,
`g`, `?`) keep working.
## Subsystems and leaves

View File

@ -806,7 +806,7 @@ Add the camera, still no broker:
[INFO ] camera profile 'standard' applied from .../standard.xml (17206 settings)
```
Then `start`, and confirm `.jxl` files appear under the configured output dir.
Then `mode auto`, and confirm `.jxl` files appear under the configured output dir.
> **A camera-free phase needs no profile XMLs** - but this only became true on
> 2026-09-16. Before that, `[Features] enable_camera` was parsed and never used,

View File

@ -2,12 +2,13 @@
#include "fgc/Geometry.h"
#include "fgc/ICameraSource.h"
#include "fgc/IControlChannel.h"
#include "fgc/IMotorController.h"
#include "fgc/OperatingMode.h"
#include "fgc/ScanGrid.h"
#include <functional>
#include <string>
#include <vector>
namespace fgc {
@ -15,29 +16,39 @@ namespace fgc {
// unit-tested with mocks.
//
// Each tick():
// 1. polls the control channel (updates control code / target heading,
// echoes the code back as status),
// 2. reads motor telemetry (per-axis encoder counts + state),
// 3. runs the capture cycle as a move -> settle -> wait -> keep machine: when
// 1. reads motor telemetry (per-axis encoder counts + state),
// 2. runs the capture cycle as a move -> settle -> wait -> keep machine: when
// the interval elapses it issues an absolute `MOVE <yaw>,<pitch>` to the next
// target, waits until both axes report standstill at that target, waits a
// further settle_wait_ms so the free-running camera has exposed a frame with
// the gimbal at rest, then keeps the newest frame. If none is ready it
// retries each tick, and gives up on the waypoint after frame_timeout_ms.
//
// ControlCode 0 = automatic sweep through the ScanGrid (front to back, repeating);
// ControlCode 1 = drive yaw to the MQTT-supplied target heading (pitch held).
// 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).
// Degrees are converted to encoder counts via Geometry.
class CaptureScheduler {
public:
// now_ms: monotonic millisecond clock; defaults to steady_clock. Injectable
// for deterministic tests.
CaptureScheduler(IMotorController& motor, ICameraSource& camera, IControlChannel& channel,
double image_rate, Geometry geometry, ScanGrid& grid,
std::function<long long()> now_ms = {});
CaptureScheduler(IMotorController& motor, ICameraSource& camera, double image_rate,
Geometry geometry, ScanGrid& grid, std::function<long long()> now_ms = {});
void setCaptureActive(bool active);
bool captureActive() const { return capture_active_; }
// Switch mode. The caller (Application::setMode) has already checked that
// the gimbal can do it; this restarts the move/settle cycle from the first
// waypoint or pan position.
void setMode(Mode mode, const PanSpec& pan = {});
Mode mode() const { return mode_; }
PanSpec pan() const { return pan_; }
bool captureActive() const { return mode_ != Mode::Manual; }
// hardware.ini [Hardware] pan_step_deg: how much bearing one picture covers.
void setPanStep(double deg) { pan_step_deg_ = deg; }
double panStep() const { return pan_step_deg_; }
// The current pan's bearings, left to right, and the one being visited.
const std::vector<double>& panHeadingsNow() const { return pan_headings_; }
size_t panIndex() const { return pan_index_; }
// Take one image now, outside any scan (the TUI's spacebar / `snapshot`).
// Serviced on the next tick once the gimbal is standing still and the settle
@ -64,8 +75,6 @@ public:
void tick();
// Inspection (mainly for tests).
int controlCode() const { return control_code_; }
std::string targetHeading() const { return target_heading_; }
long yawTargetCounts() const { return yaw_target_; }
long pitchTargetCounts() const { return pitch_target_; }
bool moving() const { return moving_; }
@ -79,7 +88,6 @@ private:
IMotorController& motor_;
ICameraSource& camera_;
IControlChannel& channel_;
Geometry geometry_;
ScanGrid& grid_;
@ -87,9 +95,11 @@ private:
double image_rate_;
long long timer_start_ = 0;
bool capture_active_ = false;
int control_code_ = 0;
std::string target_heading_ = "0";
Mode mode_ = Mode::Manual;
PanSpec pan_;
double pan_step_deg_ = 6.0;
std::vector<double> pan_headings_;
size_t pan_index_ = 0;
bool moving_ = false; // a MOVE has been issued, awaiting settle
long long settled_at_ms_ = 0; // when standstill was first seen (0 = not yet)
@ -99,7 +109,7 @@ private:
// One warning per run for each unreachable-heading case: a grid with a few
// out-of-arc waypoints would otherwise log on every pass, forever.
bool unreachable_warned_ = false;
bool directed_unreachable_warned_ = false;
bool pan_unreachable_warned_ = false;
bool snapshot_pending_ = false; // a manual snapshot is armed
long long snapshot_settled_ms_ = 0; // when standstill was first seen (0 = not yet)

View File

@ -14,8 +14,7 @@ namespace fgc {
// Scheme: GGS/FWT/<tower>/<leaf> — see docs/mqtt-api.md.
// Leaf names, so the two channels and the UI agree on spelling.
inline constexpr const char* kTopicTargetHdg = "target_HDG";
inline constexpr const char* kTopicControlCode = "ControlCode";
// The tower's mode, out (0 auto, 1 pan, 2 manual - see OperatingMode.h).
inline constexpr const char* kTopicStatusCode = "StatusCode";
inline constexpr const char* kTopicCamEvent = "CamEvent";
// Any console command, in; its answer, out. See docs/mqtt-api.md.

View File

@ -41,6 +41,9 @@ struct HardwareConfig {
std::string imu; // sensor model, or "none"
std::string env; // sensor model, or "none"
std::vector<std::string> cameras; // the [Camera.<name>] blocks expected to exist
// How much bearing one picture covers, in degrees. Pan mode steps by it:
// a span is cut into as many pictures as it takes (OperatingMode.h).
double pan_step_deg = 6.0;
// Was a hardware.ini actually read? False on every tower that has not been
// migrated yet, and the switch that keeps this change invisible on rollout.

View File

@ -5,15 +5,6 @@
namespace fgc {
// Remote control input (was struct mqtt_sub_data). The *_available flags mark
// whether a fresh value arrived since the last poll().
struct ControlCommand {
bool control_code_available = false;
int control_code = 0; // 0 = automatic sweep, 1 = directed to target heading
bool heading_available = false;
std::string target_heading; // kept as string; the yaw target in degrees
};
// Notification published after an image is captured and saved.
struct CamEvent {
std::string tower; // tower / FWT name
@ -70,7 +61,7 @@ struct ChannelStats {
// Abstraction over the remote control/telemetry channel. Implemented by
// MqttControlChannel (Eclipse Paho) and NullControlChannel (no broker; used
// for development - publishes are dropped and poll() yields a default).
// for development - publishes are dropped and recorded).
class IControlChannel {
public:
virtual ~IControlChannel() = default;
@ -80,13 +71,11 @@ public:
virtual void disconnect() = 0;
virtual bool connected() const = 0;
// The tower's mode (OperatingMode.h: 0 auto, 1 pan, 2 manual), on
// StatusCode - on every change, and again on every reconnect.
virtual void publishStatus(int code) = 0;
virtual void publishCamEvent(const CamEvent& event) = 0;
// Latest control input; clears the *_available flags so each update is
// acted on once.
virtual ControlCommand poll() = 0;
// Commands received since the last call, oldest first; clears them. And the
// answer to one. Defaults: none / dropped, for a channel that takes no commands.
virtual std::vector<RemoteCommand> takeCommands() { return {}; }

View File

@ -14,8 +14,7 @@
namespace fgc {
// Real control channel over MQTT (Eclipse Paho C++). Subscribes to the tower's
// target_HDG / ControlCode / Command topics and publishes StatusCode / CamEvent /
// CommandReply. Topic scheme: GGS/FWT/<tower>/... (see docs/mqtt-api.md).
// Command topic and publishes StatusCode / CamEvent / CommandReply. Topic scheme: GGS/FWT/<tower>/... (see docs/mqtt-api.md).
// Publishes are QoS 1; retained, except command replies.
//
// Unlike the old MQTTClient it does NOT spin a busy-wait thread; Paho's async
@ -36,7 +35,6 @@ public:
void publishStatus(int code) override;
void publishCamEvent(const CamEvent& event) override;
ControlCommand poll() override;
std::vector<RemoteCommand> takeCommands() override;
void publishCommandReply(const CommandReply& reply) override;
@ -57,8 +55,6 @@ private:
ChannelHistory history_;
std::string broker_uri_;
std::string tower_;
std::string topic_target_hdg_;
std::string topic_control_code_;
std::string topic_status_;
std::string topic_cam_event_;
std::string topic_command_;
@ -69,7 +65,7 @@ private:
std::atomic<bool> connected_{false};
std::mutex mutex_;
ControlCommand sub_data_;
std::string last_status_; // republished on reconnect (under mutex_)
std::vector<RemoteCommand> commands_; // received, not yet taken (under mutex_)
};

View File

@ -0,0 +1,58 @@
#pragma once
// The tower's three modes of operation, and what each one accepts.
//
// auto - sweep the scan grid, a picture at every waypoint (the normal,
// unattended state; the boot service starts here with --start);
// pan - watch one direction: centred on a heading, walk left to right
// across a span, a picture at each position, then back to the left
// edge and again;
// manual - the gimbal does nothing by itself; a person drives it, over the
// MQTT Command topic or at the console.
//
// Modes change only by command (`mode auto|pan|manual`), and the tower reports
// its mode on StatusCode, as the numeric value of Mode.
#include <string>
#include <vector>
namespace fgc {
enum class Mode { Auto = 0, Pan = 1, Manual = 2 }; // values = StatusCode
const char* modeName(Mode m); // "auto" / "pan" / "manual"
// `mode pan <heading> <span> [<pitch>]`, all in degrees. Heading is a true
// bearing; the span is the full width covered, centred on it.
struct PanSpec {
double heading = 0.0;
double span = 0.0;
double pitch = 0.0;
};
// The bearings a pan visits, left to right. A span narrower than one picture
// (`step`, hardware.ini [Hardware] pan_step_deg) is a single heading, the
// centre. Otherwise the span is cut into ceil(span / step) equal intervals and
// both edges are included: 90 +/- 15 at 6 degrees is 75, 81, ..., 105 - six
// pictures, never more than `step` apart. Bearings are wrapped to [0, 360).
std::vector<double> panHeadings(const PanSpec& pan, double step);
// `mode ...` from its whitespace-separated tokens (tokens[0] == "mode").
// `valid` false with `error` set on a bad command; a bare `mode` is valid with
// `query` set - it asks for the mode rather than changing it.
struct ModeRequest {
bool valid = false;
bool query = false;
Mode mode = Mode::Manual;
PanSpec pan;
std::string error;
};
ModeRequest parseModeCommand(const std::vector<std::string>& tokens);
// May this console command line run in `mode`? Manual accepts everything. Auto
// and pan accept only what cannot move the gimbal or change the tower's
// settings: reading its state, the log's verbosity, changing mode, cancelling,
// and exit. The one list, used for every source - MQTT, console, dashboard keys.
bool allowedIn(Mode mode, const std::string& line);
} // namespace fgc

View File

@ -12,8 +12,7 @@
namespace fgc {
// No-op control channel for running without an MQTT broker. Publishes go
// nowhere and poll() never supplies a control command, so the scheduler stays
// in automatic-sweep mode (ControlCode 0).
// nowhere, and no commands arrive unless a test inject()s one.
//
// It still records what it was asked to send, using the same topic and payload
// builders as the real channel, so the connectivity view works under --no-mqtt
@ -39,7 +38,6 @@ public:
<< " (null channel)";
drop(kTopicCamEvent, camEventJson(e));
}
ControlCommand poll() override { return {}; }
// Commands: none arrive by themselves; inject() stands in for the broker,
// so --no-mqtt runs and tests can drive the remote-command path.

View File

@ -151,7 +151,7 @@ struct CaptureView {
CameraConfigView config;
std::vector<CameraDeviceView> devices;
long long images_saved = 0;
// Defined scan grid (ControlCode 0 auto-sweep waypoints) with the live cursor
// Defined scan grid (the auto-mode waypoints) with the live cursor
// flagged, surfaced in the expanded camera view.
std::vector<ScanWaypointView> scan_grid;
bool scan_from_file = false; // grid_file CSV vs generated
@ -171,9 +171,8 @@ struct ConnView {
bool mqtt_connected = false;
std::string broker;
std::string tower;
int control_code = 0; // 0 = auto-sweep, 1 = directed
std::string target_heading;
int last_status_code = 0;
std::string mode = "manual"; // auto / pan / manual
int status_code = 2; // what StatusCode carries for it
// Recent traffic + counters for the expanded 'm' view.
std::vector<ChannelMessage> recent;
@ -185,6 +184,7 @@ struct HeaderView {
std::string build;
long long uptime_ms = 0;
bool live = true; // LIVE vs MOCK
std::string mode = "manual"; // auto / pan / manual (OperatingMode.h)
};
struct LogLine {

View File

@ -49,7 +49,7 @@ int main(int argc, char* argv[]) {
"path to hardware.ini (default: $FGC_HARDWARE, /etc/fire_gimbal_control/hardware.ini, "
"or one beside config.ini)")
("init,i", po::bool_switch(), "home the gimbal on startup (required: nothing is homed across a power cycle)")
("start,s", po::bool_switch(), "start capture automatically")
("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)")

View File

@ -26,6 +26,7 @@
#include "fgc/ScanGrid.h"
#include "fgc/SerialMotorController.h"
#include "fgc/TestReport.h"
#include "fgc/OperatingMode.h"
#include "fgc/RemoteCommands.h"
#include "fgc/TestRunner.h"
#include "fgc/mock/MockCameraSource.h"
@ -287,7 +288,8 @@ struct Application::Impl {
std::deque<RemoteCommand> remote_queue_; // taken from the channel, not yet run
// --test: what run() returns, and where the one-shot test has got to.
int exit_code_ = 0;
bool resume_capture_after_test_ = false; // was capturing when a test began
Mode resume_mode_after_test_ = Mode::Manual; // the mode a test paused
PanSpec resume_pan_after_test_;
bool oneshot_started_ = false;
std::chrono::steady_clock::time_point oneshot_since_{};
std::chrono::steady_clock::time_point oneshot_link_ok_{};
@ -685,6 +687,7 @@ struct Application::Impl {
s.header.uptime_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start_time).count();
s.header.live = !mock_serial;
s.header.mode = scheduler ? modeName(scheduler->mode()) : "manual";
// --- Gimbal ---
MotorTelemetry t = motor->telemetry();
@ -917,9 +920,8 @@ struct Application::Impl {
s.conn.mqtt_connected = channel && channel->connected();
s.conn.broker = cfg.network.brokerUri();
s.conn.tower = cfg.general.tower_name;
s.conn.control_code = scheduler ? scheduler->controlCode() : 0;
s.conn.target_heading = scheduler ? scheduler->targetHeading() : "0";
s.conn.last_status_code = s.conn.control_code; // echoed back as status
s.conn.mode = s.header.mode;
s.conn.status_code = static_cast<int>(scheduler ? scheduler->mode() : Mode::Manual);
if (channel) {
s.conn.recent = channel->recentMessages();
s.conn.stats = channel->channelStats();
@ -973,18 +975,23 @@ struct Application::Impl {
a.status = "finding endstops…";
} else if (scheduler && scheduler->captureActive()) {
a.active = true;
a.title = "CAPTURE";
const char* phase = scheduler->moving() ? "moving" : "dwell";
if (scheduler->controlCode() == 1) {
a.status = "directed → heading " + scheduler->targetHeading() + "° · " +
(scheduler->moving() ? "moving" : "holding");
if (scheduler->mode() == Mode::Pan) {
a.title = "PAN";
const auto& h = scheduler->panHeadingsNow();
const size_t i = scheduler->panIndex();
a.status = "position " + std::to_string(i + 1) + "/" + std::to_string(h.size()) +
" · yaw " + formatDegrees(i < h.size() ? h[i] : 0.0) + " / pitch " +
formatDegrees(scheduler->pan().pitch) + " · " + phase;
} else if (!grid.empty()) {
a.title = "AUTO";
const ScanPoint& p = grid.current();
std::string coord = "yaw " + formatDegrees(p.yaw_deg);
if (s.capture.scan_pitch) coord += " / pitch " + formatDegrees(p.pitch_deg);
a.status = "waypoint " + std::to_string(grid.index() + 1) + "/" +
std::to_string(grid.size()) + " · " + coord + " · " + phase;
} else {
a.title = "AUTO";
a.status = "no scan grid — auto-sweep disabled";
}
}
@ -1642,42 +1649,119 @@ struct Application::Impl {
// `camera reset`: reboot every camera (DeviceReset) and put it back on its
// profile. For a stream that has stopped delivering and will not restart -
// the state tower 111's camera was in on 2026-09-24, which only this cleared.
// Manual mode only (allowedIn), so there is no scan to pause around it.
void resetCameras() {
const bool was = scheduler->captureActive();
stopCapture();
camera->stop();
for (int i = 0; i < camera->cameraCount(); ++i)
LOG_INFO << "camera " << i << (camera->resetCamera(i) ? ": reset, back on its profile"
: ": reset FAILED (see above)");
if (was) startCapture();
}
void startCapture() {
// The one way the mode changes - `mode ...`, the s / x keys, --start, and a
// test pausing the scan all come here. Returns false, having said why and
// leaving the mode as it was, when the gimbal cannot do what was asked.
// StatusCode is published on every change, so it always says what the tower
// is doing, not what someone last asked for.
bool setMode(Mode m, const PanSpec& pan = {}) {
if (m != Mode::Manual) {
// Heading setup owns the gimbal by hand. Starting a scan under it would
// move the axis between a sighting and the bearing being typed for it,
// silently poisoning the fit with a position nobody looked through.
if (heading_setup_active_) {
LOG_WARN << "Capture not started: heading setup in progress "
LOG_WARN << "mode " << modeName(m) << " refused: heading setup in progress "
"(finish it, or 'gimbal heading cancel')";
return;
return false;
}
// The scheduler arms regardless, but a scan can't progress until the axes
// are homed: before READY the firmware leaves coils disabled and clamps
// MOVE targets to a coarse bench guard, so no waypoint is ever reached and
// Before READY the firmware leaves coils disabled and clamps MOVE
// targets to a coarse bench guard, so no position is ever reached and
// the move/settle cycle stalls silently. Refuse with a clear message
// instead. (Single-axis builds report no P: segment, so skip pitch.)
MotorTelemetry t = motor->telemetry();
if (!t.yaw.ready() || (t.pitch_present && !t.pitch.ready())) {
LOG_WARN << "Capture not started: gimbal not homed (run init/home first)";
LOG_WARN << "mode " << modeName(m) << " refused: gimbal not homed "
"(run 'gimbal home' first)";
return false;
}
}
if (m == Mode::Pan && !checkPan(pan)) return false;
const Mode was = scheduler->mode();
if (m == Mode::Manual) camera->stop();
else camera->start();
scheduler->setMode(m, pan);
if (m == Mode::Pan) {
const auto& h = scheduler->panHeadingsNow();
LOG_INFO << "mode pan: heading " << pan.heading << ", span " << pan.span
<< ", pitch " << pan.pitch << " deg - " << h.size()
<< (h.size() == 1 ? " position" : " positions") << " ("
<< panRangeText(h) << ")";
} else {
LOG_INFO << "mode " << modeName(m) << (was == m ? " (again)" : "");
}
channel->publishStatus(static_cast<int>(m));
return true;
}
// A pan the gimbal can actually do: its pitch inside the homed window (not
// clamped to it - that would image an elevation nobody asked for), and at
// least one of its headings inside the reachable yaw arc.
bool checkPan(const PanSpec& pan) {
const AxisMap& p = cfg.geometry.pitch;
if (cfg.hardware.hasAxis("pitch") && (pan.pitch < p.min_deg || pan.pitch > p.max_deg)) {
LOG_WARN << "mode pan refused: pitch " << pan.pitch << " deg is outside the homed "
<< "pitch travel (" << p.min_deg << ".." << p.max_deg << " deg)";
return false;
}
const auto headings = panHeadings(pan, cfg.hardware.pan_step_deg);
std::size_t reachable = 0;
for (double h : headings)
if (cfg.geometry.yaw.headingToCounts(h)) ++reachable;
if (reachable == 0) {
LOG_WARN << "mode pan refused: no heading in " << panRangeText(headings)
<< " is inside the reachable yaw arc (" << wrap360(cfg.geometry.yaw.min_deg)
<< ".." << wrap360(cfg.geometry.yaw.max_deg) << " true)";
return false;
}
if (reachable < headings.size())
LOG_WARN << "mode pan: " << headings.size() - reachable << " of " << headings.size()
<< " headings lie outside the reachable yaw arc and will be skipped";
return true;
}
static std::string panRangeText(const std::vector<double>& h) {
if (h.empty()) return "none";
std::ostringstream o;
o << h.front();
if (h.size() > 1) o << ".." << h.back();
o << " deg";
return o.str();
}
// `mode` - report; `mode auto|manual|pan <heading> <span> [<pitch>]` - switch.
void handleMode(const std::string& line) {
std::istringstream in(line);
std::vector<std::string> tok;
for (std::string w; in >> w;) tok.push_back(w);
const ModeRequest r = parseModeCommand(tok);
if (!r.valid) {
LOG_WARN << r.error;
return;
}
camera->start();
scheduler->setCaptureActive(true);
LOG_INFO << "Capture started";
if (!r.query) {
setMode(r.mode, r.pan);
return;
}
const Mode m = scheduler->mode();
if (m == Mode::Pan) {
const PanSpec p = scheduler->pan();
const auto& h = scheduler->panHeadingsNow();
LOG_INFO << "mode pan: heading " << p.heading << ", span " << p.span << ", pitch "
<< p.pitch << " deg - " << h.size()
<< (h.size() == 1 ? " position" : " positions") << " (" << panRangeText(h)
<< "), at " << (scheduler->panIndex() + 1) << "/" << h.size();
} else {
LOG_INFO << "mode " << modeName(m);
}
void stopCapture() {
camera->stop();
scheduler->setCaptureActive(false);
LOG_INFO << "Capture stopped";
}
// `gimbal heading fit <counts>:<deg> ...` / `gimbal heading offset <deg>`.
@ -1780,24 +1864,25 @@ struct Application::Impl {
return;
}
// The camera is stopped outside a scan; leave it running afterwards so a
// burst of snapshots does not pay the start-up cost each time. stopCapture()
// burst of snapshots does not pay the start-up cost each time. setMode()
// and shutdown stop it as before.
camera->start();
scheduler->requestSnapshot();
LOG_INFO << "snapshot requested";
}
// Manual mode is simply "no scan running" - derived, never a separate flag an
// operator could strand. 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.
bool manualMode() const { return !scheduler || !scheduler->captureActive(); }
// 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
// outside manual; the guard stays so each command is safe on its own.
bool manualMode() const { return !scheduler || scheduler->mode() == Mode::Manual; }
// Guard for the hand-aiming commands. Returns false (having said why) when a
// scan owns the gimbal.
bool requireManual(const char* what) {
if (manualMode()) return true;
LOG_WARN << "ignored '" << what << "': a scan is running (press x / type 'stop' first)";
LOG_WARN << "ignored '" << what << "': the tower is in " << modeName(scheduler->mode())
<< " mode (press x / type 'mode manual' first)";
return false;
}
@ -2043,14 +2128,14 @@ struct Application::Impl {
return;
}
if (calib && calib->running()) {
LOG_WARN << "pitch setup already running (press Esc, or type 'stop', to cancel)";
LOG_WARN << "pitch setup already running (press Esc, or type 'cancel', to cancel)";
return;
}
if (heading_setup_active_) {
LOG_WARN << "pitch setup: heading setup in progress ('gimbal heading cancel' first)";
return;
}
stopCapture(); // free the serial link + don't fight the routine's moves
setMode(Mode::Manual); // free the serial link + don't fight the routine's moves
calib = std::make_unique<PitchSetupRoutine>(*motor, *imu, cfg.geometry);
calib->start();
}
@ -2132,7 +2217,7 @@ struct Application::Impl {
while (!local.empty()) {
const std::string& line = local.front();
Command c = parseCommand(line);
if (c.verb == "cancel" || c.verb == "stop")
if (c.verb == "cancel" || (c.verb == "mode" && c.device == "manual"))
cancel_procedure_ = true;
else if (!c.empty())
LOG_WARN << "ignored '" << c.verb << "': homing in progress (Esc to cancel)";
@ -2207,8 +2292,12 @@ struct Application::Impl {
// Paused, not ended: a test run on a tower in service (install-tower
// --verify does exactly that) must not leave it idle afterwards.
resume_capture_after_test_ = scheduler->captureActive();
stopCapture(); // free the serial link; don't fight the routine's moves
// Typed tests only run in manual (allowedIn); a --test at startup may find
// a scan running, and gets it back afterwards.
resume_mode_after_test_ = scheduler->mode();
resume_pan_after_test_ = scheduler->pan();
if (resume_mode_after_test_ != Mode::Manual)
setMode(Mode::Manual); // free the serial link; don't fight the routine's moves
LOG_INFO << "starting hardware test (profile '" << profile.name << "')";
// What `test hardware` needs beyond the motor and IMU: the declaration to
// check against, and the subsystems that answer for themselves.
@ -2358,10 +2447,11 @@ struct Application::Impl {
exit_code_ = r.all_pass ? 0 : kExitTestFail;
running = false;
}
if (resume_capture_after_test_) {
resume_capture_after_test_ = false;
LOG_INFO << "resuming capture, paused for the test";
startCapture();
if (resume_mode_after_test_ != Mode::Manual) {
LOG_INFO << "resuming " << modeName(resume_mode_after_test_)
<< " mode, paused for the test";
setMode(resume_mode_after_test_, resume_pan_after_test_);
resume_mode_after_test_ = Mode::Manual;
}
}
}
@ -2415,7 +2505,7 @@ struct Application::Impl {
// Commands that would disturb a running procedure (motion, capture, a second
// long op, or a DUMP over the same serial link). Ignored while busy.
static bool interferingVerb(const std::string& verb) {
return verb == "gimbal" || verb == "start" || verb == "test" ||
return verb == "gimbal" || verb == "mode" || verb == "test" ||
verb == "refresh" || verb == "snapshot" || verb == "camera";
}
@ -2425,13 +2515,27 @@ struct Application::Impl {
LOG_TRACE_CAT(LogCat::Control) << "cmd " << line;
// Lock-out: while a test/calibration/homing runs, ignore interfering
// commands (Esc, or `stop`/`cancel`, cancels the procedure instead).
// commands (Esc, `cancel`, or `mode manual` - the x key - cancels the
// procedure instead: procedures run in manual, so that is what x means).
if (procedureBusy() && c.verb == "mode" && c.device == "manual") {
cancelActiveProcedure();
return;
}
if (procedureBusy() && interferingVerb(c.verb)) {
LOG_WARN << "ignored '" << c.verb << "': " << procedureName()
<< " in progress (Esc to cancel)";
return;
}
// Mode gate: in auto and pan only what cannot move the gimbal or change
// a setting runs (OperatingMode.h). The same rule for every source - the
// console, the dashboard's keys and MQTT all arrive here.
if (scheduler && !allowedIn(scheduler->mode(), line)) {
LOG_WARN << "ignored '" << line << "': the tower is in "
<< modeName(scheduler->mode()) << " mode ('mode manual' first)";
return;
}
if (c.verb == "help") {
// c.device holds the optional topic (first token after "help").
for (const auto& l : renderHelp(c.device)) LOG_INFO << l;
@ -2459,12 +2563,8 @@ struct Application::Impl {
else LOG_WARN << "usage: camera reset";
} else if (c.verb == "snapshot") {
takeSnapshot();
} else if (c.verb == "start") {
startCapture();
} else if (c.verb == "stop") {
// `stop`/`x` cancels any running procedure, else stops capture.
if (procedureBusy()) cancelActiveProcedure();
else stopCapture();
} else if (c.verb == "mode") {
handleMode(line);
} else if (c.verb == "debug") {
bool on = Logger::level() != LogLevel::Debug;
Logger::setLevel(on ? LogLevel::Debug : LogLevel::Info);
@ -2727,8 +2827,9 @@ struct Application::Impl {
LOG_WARN << "scan grid load failed (" << e.what() << "); auto-sweep disabled";
}
LOG_INFO << "scan grid: " << grid.size() << " waypoints";
scheduler = std::make_unique<CaptureScheduler>(*motor, *camera, *channel, cfg.image_rate(),
scheduler = std::make_unique<CaptureScheduler>(*motor, *camera, cfg.image_rate(),
cfg.geometry, grid);
scheduler->setPanStep(cfg.hardware.pan_step_deg);
// Every camera has to have finished by the time the gimbal moves on, so
// the wait is the longest across them, not the first one's.
scheduler->setSettleWait(cfg.camera.maxSettleWaitMs(), cfg.camera.maxFrameTimeoutMs());
@ -2754,7 +2855,7 @@ struct Application::Impl {
}
pipeline->start();
channel->publishStatus(0);
channel->publishStatus(static_cast<int>(Mode::Manual)); // until --start says otherwise
// Seed an initial snapshot so the UI's first frame is populated, then
// start the interface (headless console or TUI) on its own thread. The
@ -2784,7 +2885,7 @@ struct Application::Impl {
sendAnchorConfig();
applyHomedTravel();
}
if (opts.start) startCapture();
if (opts.start) setMode(Mode::Auto);
// Clean up on Ctrl-C / kill / systemd stop, not just on "exit"/Ctrl-D - so the
// camera is always stopped and closed (an unclean exit wedges it for next time).

View File

@ -4,6 +4,7 @@
#include <chrono>
#include <cstdlib>
#include <optional>
namespace fgc {
@ -22,11 +23,10 @@ constexpr long kSettleTolCounts = 600;
} // namespace
CaptureScheduler::CaptureScheduler(IMotorController& motor, ICameraSource& camera,
IControlChannel& channel, double image_rate, Geometry geometry,
ScanGrid& grid, std::function<long long()> now_ms)
double image_rate, Geometry geometry, ScanGrid& grid,
std::function<long long()> now_ms)
: motor_(motor),
camera_(camera),
channel_(channel),
geometry_(geometry),
grid_(grid),
now_ms_(now_ms ? std::move(now_ms) : steadyNowMs),
@ -34,8 +34,12 @@ CaptureScheduler::CaptureScheduler(IMotorController& motor, ICameraSource& camer
timer_start_ = now_ms_();
}
void CaptureScheduler::setCaptureActive(bool active) {
capture_active_ = active;
void CaptureScheduler::setMode(Mode mode, const PanSpec& pan) {
mode_ = mode;
pan_ = pan;
pan_headings_ = mode == Mode::Pan ? panHeadings(pan, pan_step_deg_) : std::vector<double>{};
pan_index_ = 0;
pan_unreachable_warned_ = false;
moving_ = false; // restart the move/settle cycle cleanly
settled_at_ms_ = 0;
// Drop any armed snapshot: a scan starting means the operator moved on, and
@ -113,39 +117,25 @@ void CaptureScheduler::serviceSnapshot(const MotorTelemetry& t) {
}
void CaptureScheduler::tick() {
// 1. Remote control input.
ControlCommand cmd = channel_.poll();
if (cmd.control_code_available) {
control_code_ = cmd.control_code;
LOG_TRACE_CAT(LogCat::Control) << "control_code -> " << control_code_;
channel_.publishStatus(control_code_);
}
if (cmd.heading_available) {
target_heading_ = cmd.target_heading;
LOG_TRACE_CAT(LogCat::Control) << "target_heading -> " << target_heading_;
}
// 2. Telemetry.
// 1. Telemetry.
MotorTelemetry t = motor_.telemetry();
pitch_present_ = t.pitch_present;
// 3. Manual snapshot. Only outside a scan, so a keypress can never inject a
// frame into a running sweep or fight it for the settle window.
if (!capture_active_) {
// 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.
if (mode_ == Mode::Manual) {
if (snapshot_pending_) serviceSnapshot(t);
return;
}
// 4. Capture cycle. Only ControlCode 0 (sweep) and 1 (directed) act.
if (control_code_ != 0 && control_code_ != 1) return;
// 3. Capture cycle: the next scan waypoint (auto) or pan position (pan).
const double interval_ms = image_rate_ > 0.0 ? 1000.0 / image_rate_ : 0.0;
if (!moving_) {
// Issue the next move once the interval has elapsed.
if (interval_ms <= 0.0 || elapsedMs() <= interval_ms) return;
if (control_code_ == 0) {
if (mode_ == Mode::Auto) {
if (grid_.empty()) return;
const ScanPoint& p = grid_.current();
// Yaw travel is under a full turn, so some bearings are unreachable.
@ -171,28 +161,29 @@ void CaptureScheduler::tick() {
// log without enabling the control wire-trace.
LOG_INFO << "scan move " << (grid_.index() + 1) << "/" << grid_.size()
<< ": yaw " << p.yaw_deg << " pitch " << p.pitch_deg << " (deg)";
} else { // ControlCode 1: directed yaw, pitch held at current position.
double heading = 0.0;
try {
heading = std::stod(target_heading_);
} catch (const std::exception&) {
return; // bad heading; wait for a valid one
} else { // pan: the next position, left to right, at the pan's pitch
if (pan_headings_.empty()) return;
// Skip positions outside the reachable arc, as the sweep does; try
// each at most once per tick so a pan with none reachable just idles.
std::optional<long> yc;
for (size_t tries = 0; tries < pan_headings_.size() && !yc; ++tries) {
yc = geometry_.yaw.headingToCounts(pan_headings_[pan_index_]);
if (yc) break;
if (!pan_unreachable_warned_) {
pan_unreachable_warned_ = true;
LOG_WARN << "pan position " << (pan_index_ + 1) << "/" << pan_headings_.size()
<< " heading " << pan_headings_[pan_index_]
<< " deg is outside the reachable yaw arc; skipping it (further "
"unreachable positions are skipped silently)";
}
auto yc = geometry_.yaw.headingToCounts(heading);
if (!yc) {
// Hold position and say so: silently pointing at the nearest limit
// would publish CamEvents claiming a bearing we cannot reach.
if (!directed_unreachable_warned_) {
directed_unreachable_warned_ = true;
LOG_WARN << "target heading " << heading
<< " deg is outside the reachable yaw arc; holding position";
pan_index_ = (pan_index_ + 1) % pan_headings_.size();
}
return;
}
directed_unreachable_warned_ = false;
if (!yc) return;
yaw_target_ = *yc;
pitch_target_ = t.pitch.xenc;
LOG_INFO << "directed move: heading " << target_heading_ << " (deg)";
pitch_target_ = geometry_.pitch.toCounts(pan_.pitch);
LOG_INFO << "pan move " << (pan_index_ + 1) << "/" << pan_headings_.size()
<< ": yaw " << pan_headings_[pan_index_] << " pitch " << pan_.pitch
<< " (deg)";
}
sendMove();
moving_ = true;
@ -219,24 +210,26 @@ void CaptureScheduler::tick() {
if (since_settled < settle_wait_ms_) return;
if (camera_.trigger()) {
if (control_code_ == 0)
if (mode_ == Mode::Auto)
LOG_INFO << "captured waypoint " << (grid_.index() + 1) << "/" << grid_.size();
else
LOG_INFO << "captured (directed)";
LOG_INFO << "captured pan position " << (pan_index_ + 1) << "/"
<< pan_headings_.size();
} else if (since_settled < settle_wait_ms_ + frame_timeout_ms_) {
return; // no fresh frame yet; retry next tick
} else {
// Never park on one waypoint: a stalled camera would otherwise stop the
// whole sweep rather than costing it a single image.
LOG_WARN << "no frame within " << frame_timeout_ms_ << " ms of settling; skipping "
<< (control_code_ == 0 ? "waypoint " + std::to_string(grid_.index() + 1) +
"/" + std::to_string(grid_.size())
: std::string("directed capture"));
<< (mode_ == Mode::Auto ? "waypoint " : "pan position ")
<< (mode_ == Mode::Auto ? grid_.index() + 1 : pan_index_ + 1) << "/"
<< (mode_ == Mode::Auto ? grid_.size() : pan_headings_.size());
}
moving_ = false;
settled_at_ms_ = 0;
if (control_code_ == 0) grid_.advance();
if (mode_ == Mode::Auto) grid_.advance();
else if (!pan_headings_.empty()) pan_index_ = (pan_index_ + 1) % pan_headings_.size();
resetTimer();
}

View File

@ -256,6 +256,12 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
p.spi_mosi = getInt(kv, "Pins.spi_mosi", -1);
p.spi_miso = getInt(kv, "Pins.spi_miso", -1);
}
if (kv.count("Hardware.pan_step_deg")) {
cfg.hardware.pan_step_deg = getDouble(kv, "Hardware.pan_step_deg", 0.0);
if (!(cfg.hardware.pan_step_deg > 0.0))
throw std::runtime_error("Hardware.pan_step_deg must be > 0 (degrees of bearing "
"one picture covers)");
}
for (const auto& a : cfg.hardware.axes)
if (a != "yaw" && a != "pitch")
throw std::runtime_error("Hardware.axes: unknown axis '" + a +

View File

@ -24,6 +24,26 @@ 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: s.", {
"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)"}},
{"mode manual",
"Stop capturing and take the gimbal by hand. Key: x.", {
"Every command is accepted. With a test, homing or calibration running,",
"cancels it instead (as Esc and 'cancel' do)."}},
}},
{"Positioning",
"Aim the gimbal. Yaw is a TRUE BEARING (0 = north); pitch is elevation (0 = level).", {
{"gimbal move <yaw>,<pitch>",
@ -70,7 +90,7 @@ const std::vector<HelpSection>& helpCatalog() {
"Example: gimbal home / gimbal home y"}},
{"gimbal stop [y|p|all]",
"Stop motion immediately.", {
"To cancel a running procedure use the bare 'stop' (or 'cancel', or Esc):",
"To cancel a running procedure use 'cancel' (or Esc, or x):",
"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.", {}},
@ -137,14 +157,14 @@ const std::vector<HelpSection>& helpCatalog() {
{"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; capture pauses meanwhile. For a stream that has",
"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; 'stop' or Esc cancels. Requires the IMU - level is defined by",
"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",
@ -156,8 +176,6 @@ const std::vector<HelpSection>& helpCatalog() {
"Example: gimbal raw \"MOVE 100000,250000\" / gimbal raw \"DUMP\""}},
}},
{"Capture", "Control image capture and encoding.", {
{"start", "Begin the capture scan.", {}},
{"stop", "Halt the capture scan.", {}},
{"set fps <rate>", "Set capture rate in images/second.", {}},
{"set camera profile [<camera>] <name>",
"Switch a camera's profile (rate, exposure, all imaging settings).", {
@ -185,7 +203,7 @@ const std::vector<HelpSection>& helpCatalog() {
"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.",
"Refused while a scan is running."}},
"Refused in auto and pan."}},
{"H",
"Open the heading-setup wizard (see 'gimbal heading setup').", {
"Manual mode only. Blocks a scan from starting until it is finished or",

116
src/core/OperatingMode.cpp Normal file
View File

@ -0,0 +1,116 @@
#include "fgc/OperatingMode.h"
#include "fgc/Geometry.h"
#include <cctype>
#include <cmath>
#include <sstream>
namespace fgc {
namespace {
std::string lower(std::string s) {
for (char& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
return s;
}
std::vector<std::string> words(const std::string& line) {
std::istringstream in(line);
std::vector<std::string> out;
for (std::string w; in >> w;) out.push_back(lower(w));
return out;
}
// A whole-token number: "30" and "-4.5" yes, "30x" and "" no.
bool number(const std::string& s, double& out) {
try {
size_t used = 0;
out = std::stod(s, &used);
return used == s.size() && std::isfinite(out);
} catch (...) {
return false;
}
}
} // namespace
const char* modeName(Mode m) {
switch (m) {
case Mode::Auto: return "auto";
case Mode::Pan: return "pan";
case Mode::Manual: return "manual";
}
return "?";
}
std::vector<double> panHeadings(const PanSpec& pan, double step) {
if (step <= 0.0 || pan.span < step) return {wrap360(pan.heading)};
const int intervals = static_cast<int>(std::ceil(pan.span / step - 1e-9));
const double spacing = pan.span / intervals;
const double left = pan.heading - pan.span / 2.0;
std::vector<double> out;
for (int i = 0; i <= intervals; ++i) out.push_back(wrap360(left + i * spacing));
return out;
}
ModeRequest parseModeCommand(const std::vector<std::string>& tokens) {
ModeRequest r;
if (tokens.size() < 2) {
r.valid = true;
r.query = true;
return r;
}
const std::string which = lower(tokens[1]);
if (which == "auto" || which == "manual") {
if (tokens.size() > 2) {
r.error = "'mode " + which + "' takes no arguments";
return r;
}
r.mode = which == "auto" ? Mode::Auto : Mode::Manual;
r.valid = true;
return r;
}
if (which != "pan") {
r.error = "unknown mode '" + tokens[1] + "' (auto, pan or manual)";
return r;
}
if (tokens.size() < 4 || tokens.size() > 5) {
r.error = "usage: mode pan <heading> <span> [<pitch>]";
return r;
}
PanSpec p;
if (!number(tokens[2], p.heading)) {
r.error = "mode pan: bad heading '" + tokens[2] + "'";
return r;
}
if (!number(tokens[3], p.span) || p.span < 0.0 || p.span > 360.0) {
r.error = "mode pan: the span must be 0 to 360 degrees (got '" + tokens[3] + "')";
return r;
}
if (tokens.size() == 5 && !number(tokens[4], p.pitch)) {
r.error = "mode pan: bad pitch '" + tokens[4] + "'";
return r;
}
p.heading = wrap360(p.heading);
r.mode = Mode::Pan;
r.pan = p;
r.valid = true;
return r;
}
bool allowedIn(Mode mode, const std::string& line) {
if (mode == Mode::Manual) return true;
const auto w = words(line);
if (w.empty()) return true;
const std::string& verb = w[0];
const std::string sub = w.size() > 1 ? w[1] : "";
if (verb == "help" || verb == "mode" || verb == "cancel" || verb == "exit" ||
verb == "debug" || verb == "trace" || verb == "refresh")
return true;
if (verb == "gimbal") return sub == "status" || sub == "dump";
if (verb == "imu") return sub == "status" || sub == "config";
return false;
}
} // namespace fgc

View File

@ -18,8 +18,6 @@ MqttControlChannel::MqttControlChannel(const std::string& broker_uri, const std:
const std::string& user, const std::string& password)
: broker_uri_(broker_uri),
tower_(tower),
topic_target_hdg_(towerTopic(tower, kTopicTargetHdg)),
topic_control_code_(towerTopic(tower, kTopicControlCode)),
topic_status_(towerTopic(tower, kTopicStatusCode)),
topic_cam_event_(towerTopic(tower, kTopicCamEvent)),
topic_command_(towerTopic(tower, kTopicCommand)),
@ -63,10 +61,27 @@ bool MqttControlChannel::connected() const { return connected_; }
void MqttControlChannel::connected(const std::string& /*cause*/) {
LOG_INFO << "MQTT (re)connected; subscribing";
history_.noteConnect();
client_.subscribe(topic_target_hdg_, kQos);
client_.subscribe(topic_control_code_, kQos);
client_.subscribe(topic_command_, kQos);
connected_ = true;
// Say the mode again: whoever watches StatusCode may have missed changes
// while the link was down. Not waited on - this runs on Paho's callback
// thread, which is the one that would deliver the acknowledgement.
std::string status;
{
std::lock_guard<std::mutex> lock(mutex_);
status = last_status_;
}
if (!status.empty()) {
try {
auto msg = mqtt::make_message(topic_status_, status);
msg->set_qos(kQos);
msg->set_retained(true);
client_.publish(msg);
history_.record(/*outbound=*/true, Delivery::Ok, topic_status_, status);
} catch (const mqtt::exception& e) {
LOG_WARN << "MQTT status republish failed: " << e.what();
}
}
}
void MqttControlChannel::connection_lost(const std::string& cause) {
@ -97,28 +112,7 @@ void MqttControlChannel::message_arrived(mqtt::const_message_ptr msg) {
// A flood - a stuck publisher - must not grow without bound.
if (commands_.size() < 256) commands_.push_back(std::move(rc));
else LOG_WARN << "MQTT command queue full; dropped: " << payload;
return;
}
try {
if (topic == topic_target_hdg_) {
(void)std::stoi(payload); // validate it is an integer
sub_data_.target_heading = payload;
sub_data_.heading_available = true;
} else if (topic == topic_control_code_) {
sub_data_.control_code = std::stoi(payload);
sub_data_.control_code_available = true;
}
} catch (const std::exception& e) {
LOG_WARN << "MQTT payload conversion error on " << topic << ": " << e.what();
}
}
ControlCommand MqttControlChannel::poll() {
std::lock_guard<std::mutex> lock(mutex_);
ControlCommand out = sub_data_;
sub_data_.control_code_available = false;
sub_data_.heading_available = false;
return out;
}
std::vector<RemoteCommand> MqttControlChannel::takeCommands() {
@ -157,6 +151,10 @@ void MqttControlChannel::publish(const std::string& topic, const std::string& pa
void MqttControlChannel::publishStatus(int code) {
const std::string payload = std::to_string(code);
{
std::lock_guard<std::mutex> lock(mutex_);
last_status_ = payload;
}
history_.noteStatus(payload);
publish(topic_status_, payload);
}

View File

@ -10,6 +10,7 @@
#include "fgc/ui/Scroll.h"
#include <algorithm>
#include <cctype>
#include <array>
#include <chrono>
#include <cmath>
@ -35,6 +36,11 @@ using namespace ftxui;
namespace {
std::string upperStr(std::string s) {
for (char& c : s) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
return s;
}
Color toColor(UiColor c) {
switch (c) {
case UiColor::Green: return Color::Green;
@ -284,7 +290,7 @@ Element headingWizardPanel(const UiSnapshot& s, const HeadingWizard& w, Componen
// the overlay keys, and each overlay advertises what its own body responds to.
//
// This changes only what is ADVERTISED. Every binding still works everywhere -
// 's' starts a scan from inside the gimbal view exactly as before - so the bar
// 's' switches to auto from inside the gimbal view exactly as before - so the bar
// is a hint about what is useful here, not a restriction.
//
// flexbox (Wrap::Wrap by default) rather than hbox: the overview set is wider
@ -293,8 +299,8 @@ Element keyBar(Overlay overlay, bool manual) {
static const char* kArrowsV = "\xE2\x86\x91\xE2\x86\x93";
static const char* kArrows4 = "\xE2\x86\x90\xE2\x86\x92\xE2\x86\x91\xE2\x86\x93";
// Start/stop are wanted from every view, so they lead everywhere.
Elements e{keyHint("s", "Start"), keyHint("x", "Stop")};
// Auto/manual are wanted from every view, so they lead everywhere.
Elements e{keyHint("s", "Auto"), keyHint("x", "Manual")};
switch (overlay) {
case Overlay::None:
@ -623,17 +629,15 @@ Element connPanel(const ConnView& v) {
Element mqtt = !v.mqtt_enabled ? (text(" disabled ") | dim)
: v.mqtt_connected ? (text(" connected ") | color(Color::Green) | bold)
: (text(" disconnected ") | color(Color::Red) | bold);
std::string mode = v.control_code == 0 ? "0 auto-sweep" : "1 directed";
return panel("[m] COMMS", Color::Green, vbox({
labelRow("MQTT", hbox({mqtt, filler(), text(v.broker) | dim})),
labelRow("tower", text(v.tower)),
labelRow("control", hbox({text(mode), filler(),
text("hdg " + v.target_heading) | dim})),
labelRow("status", text(std::to_string(v.last_status_code))),
labelRow("mode", hbox({text(v.mode), filler(),
text("StatusCode " + std::to_string(v.status_code)) | dim})),
}));
}
// Expanded comms view ('m'): broker state, the six topics with their last
// Expanded comms view ('m'): broker state, the four topics with their last
// traffic, the counters, and a scrollable history of recent messages.
//
// The history is the point of the view. `dropped` in particular has no other
@ -685,8 +689,8 @@ Element connDetailPanel(const ConnView& v, ScrollState& st) {
rows.push_back(text("TOPICS") | bold | color(Color::Green));
struct TopicRow { const char* dir; const char* leaf; };
static const TopicRow kTopics[] = {
{"sub", kTopicTargetHdg}, {"sub", kTopicControlCode}, {"sub", kTopicCommand},
{"pub", kTopicStatusCode}, {"pub", kTopicCamEvent}, {"pub", kTopicCommandReply},
{"sub", kTopicCommand}, {"pub", kTopicStatusCode},
{"pub", kTopicCamEvent}, {"pub", kTopicCommandReply},
};
for (const auto& t : kTopics) {
const ChannelMessage* last = nullptr;
@ -1373,6 +1377,7 @@ void TuiUi::uiLoop() {
(paused_ ? text(" PAUSED @" + paused_at_hms_ + " ")
| color(Color::Black) | bgcolor(Color::Yellow) | bold
: text("")),
text(" " + upperStr(s.header.mode) + " ") | inverted | bold,
text(" " + mode + " ") | (s.header.live ? color(Color::Green) : color(Color::Yellow)) | bold,
text(" up " + std::to_string(s.header.uptime_ms / 1000) + "s ") | dim,
});
@ -1696,8 +1701,8 @@ void TuiUi::uiLoop() {
if (c == "k") { help_sel = (help_sel - 1 + n) % n; help_scroll = {}; return true; }
}
if (c == "q") { exit_prompt = true; return true; }
if (c == "s") { if (sink_) sink_("start"); return true; }
if (c == "x") { if (sink_) sink_("stop"); return true; }
if (c == "s") { if (sink_) sink_("mode auto"); return true; }
if (c == "x") { if (sink_) sink_("mode manual"); return true; }
if (c == "h") { if (sink_) sink_("gimbal home"); return true; }
if (c == "r") { if (sink_) sink_("refresh"); return true; } // IMU config + dump
if (c == ":") { command_mode = true; return true; }

View File

@ -25,6 +25,7 @@ add_executable(fgc_tests
test_iniedit.cpp
test_camerastall.cpp
test_remotecommands.cpp
test_operatingmode.cpp
test_tmccurrent.cpp
test_hardwarepresence.cpp
test_hardwarechecks.cpp

View File

@ -0,0 +1,122 @@
#include <doctest/doctest.h>
#include "fgc/OperatingMode.h"
#include <string>
#include <vector>
using namespace fgc;
namespace {
std::vector<std::string> tok(const std::string& line) {
std::vector<std::string> out;
std::string w;
for (char c : line) {
if (c == ' ') { if (!w.empty()) out.push_back(w); w.clear(); }
else w += c;
}
if (!w.empty()) out.push_back(w);
return out;
}
} // namespace
// --- pan positions --------------------------------------------------------------
TEST_CASE("the user's example: 90 +/- 15 at 6 degrees is six pictures, 75 to 105") {
const auto h = panHeadings({90.0, 30.0, 0.0}, 6.0);
REQUIRE(h.size() == 6);
const double want[] = {75, 81, 87, 93, 99, 105};
for (size_t i = 0; i < h.size(); ++i) CHECK(h[i] == doctest::Approx(want[i]));
}
TEST_CASE("a span that does not divide evenly gets one more picture, never a gap") {
const auto h = panHeadings({90.0, 32.0, 0.0}, 6.0);
REQUIRE(h.size() == 7);
CHECK(h.front() == doctest::Approx(74.0));
CHECK(h.back() == doctest::Approx(106.0));
for (size_t i = 1; i < h.size(); ++i) CHECK(h[i] - h[i - 1] <= 6.0 + 1e-9);
}
TEST_CASE("a span narrower than one picture is the one heading") {
CHECK(panHeadings({90.0, 5.9, 0.0}, 6.0) == std::vector<double>{90.0});
CHECK(panHeadings({90.0, 0.0, 0.0}, 6.0) == std::vector<double>{90.0});
}
TEST_CASE("exactly one picture wide is its two edges") {
const auto h = panHeadings({90.0, 6.0, 0.0}, 6.0);
REQUIRE(h.size() == 2);
CHECK(h[0] == doctest::Approx(87.0));
CHECK(h[1] == doctest::Approx(93.0));
}
TEST_CASE("a pan across north wraps its bearings") {
const auto h = panHeadings({5.0, 30.0, 0.0}, 6.0);
REQUIRE(h.size() == 6);
CHECK(h.front() == doctest::Approx(350.0));
CHECK(h[2] == doctest::Approx(2.0));
CHECK(h.back() == doctest::Approx(20.0));
}
// --- the command ----------------------------------------------------------------
TEST_CASE("mode pan: pitch is optional and defaults to level") {
auto r = parseModeCommand(tok("mode pan 90 30"));
REQUIRE(r.valid);
CHECK(r.mode == Mode::Pan);
CHECK(r.pan.heading == 90.0);
CHECK(r.pan.span == 30.0);
CHECK(r.pan.pitch == 0.0);
r = parseModeCommand(tok("mode pan 450 30 10"));
REQUIRE(r.valid);
CHECK(r.pan.heading == doctest::Approx(90.0)); // bearings wrap
CHECK(r.pan.pitch == 10.0);
}
TEST_CASE("mode auto, mode manual, and a bare mode that only asks") {
CHECK(parseModeCommand(tok("mode auto")).mode == Mode::Auto);
CHECK(parseModeCommand(tok("mode MANUAL")).mode == Mode::Manual);
const auto q = parseModeCommand(tok("mode"));
CHECK(q.valid);
CHECK(q.query);
}
TEST_CASE("a bad mode command is refused with the reason, never half-applied") {
for (const char* bad : {"mode pan", "mode pan 90", "mode pan x 30", "mode pan 90 30x",
"mode pan 90 -1", "mode pan 90 361", "mode pan 90 30 up",
"mode pan 90 30 10 4", "mode sweep", "mode auto now"}) {
CAPTURE(bad);
const auto r = parseModeCommand(tok(bad));
CHECK_FALSE(r.valid);
CHECK_FALSE(r.error.empty());
}
}
// --- what each mode accepts -------------------------------------------------------
TEST_CASE("auto and pan accept only what cannot move the gimbal or change a setting") {
for (Mode m : {Mode::Auto, Mode::Pan}) {
CAPTURE(modeName(m));
for (const char* ok : {"help", "help imu", "gimbal status", "gimbal dump", "imu status",
"imu config", "refresh", "debug", "trace serial on", "mode",
"mode manual", "mode pan 90 30", "cancel", "exit", "GIMBAL DUMP"}) {
CAPTURE(ok);
CHECK(allowedIn(m, ok));
}
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",
"imu zero", "imu profile general", "test", "test hardware",
"camera reset", "snapshot", "calib save", "gimbal"}) {
CAPTURE(no);
CHECK_FALSE(allowedIn(m, no));
}
}
}
TEST_CASE("manual accepts everything") {
for (const char* any : {"gimbal nudge yaw 5", "gimbal raw \"HOME\"", "set fps 1", "test",
"camera reset", "exit", "anything at all"})
CHECK(allowedIn(Mode::Manual, any));
}

View File

@ -92,7 +92,7 @@ TEST_CASE("the level is the most severe line: a refusal reads as warn") {
TEST_CASE("a retained or unreadable command is answered but never run") {
RemoteCommandRunner run;
int ran = 0;
RemoteCommand retained; retained.cmd = "start"; retained.retained = true;
RemoteCommand retained; retained.cmd = "mode auto"; retained.retained = true;
auto r1 = run.run(retained, [&](const std::string&) { ++ran; });
CHECK(ran == 0);
CHECK(r1->level == "error");

View File

@ -36,21 +36,6 @@ struct FakeCamera : ICameraSource {
int cameraCount() const override { return 1; }
};
struct FakeChannel : IControlChannel {
ControlCommand next;
int last_status = -1;
bool connect() override { return true; }
void disconnect() override {}
bool connected() const override { return true; }
void publishStatus(int c) override { last_status = c; }
void publishCamEvent(const CamEvent&) override {}
ControlCommand poll() override {
ControlCommand c = next;
next = {};
return c;
}
};
// Simple linear map: 10 counts/deg, centre at count 0 pointing at 0 deg.
// Reachable yaw travel -90..90, pitch 0..60.
// Fields: counts_per_deg, center_count, deg_at_center, min_deg, max_deg.
@ -74,22 +59,18 @@ TEST_CASE("CaptureScheduler sweeps the scan grid with MOVE + settle + trigger")
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
FakeChannel chan;
ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}}); // -> yaw counts 0, then 900
CaptureScheduler sch(motor, cam, chan, 1.0 /*img/s -> 1000ms*/, testGeometry(), grid,
CaptureScheduler sch(motor, cam, 1.0 /*img/s -> 1000ms*/, testGeometry(), grid,
[&] { return clock; });
sch.setSettleWait(0, 0); // these predate the settle wait; timing has its own test
sch.setCaptureActive(true);
sch.setMode(Mode::Auto);
chan.next.control_code_available = true;
chan.next.control_code = 0;
// Before the interval elapses: nothing moves, but status is echoed.
// Before the interval elapses: nothing moves.
clock = 500;
sch.tick();
CHECK(motor.cmds.empty());
CHECK(chan.last_status == 0);
// After the interval: MOVE to the first grid point (0,0) is issued.
clock = 1600;
@ -119,14 +100,11 @@ TEST_CASE("CaptureScheduler scans a 1-axis (yaw-only) rig with yaw-only MOVE/set
FakeMotor motor;
motor.tel.pitch_present = false; // yaw-only gimbal: no P: segment in telemetry
FakeCamera cam;
FakeChannel chan;
ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}});
CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; });
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(0, 0); // these predate the settle wait; timing has its own test
sch.setCaptureActive(true);
chan.next.control_code_available = true;
chan.next.control_code = 0;
sch.setMode(Mode::Auto);
// The MOVE must address yaw alone, not the two-axis form a 1-axis firmware rejects.
clock = 1600;
@ -149,101 +127,117 @@ TEST_CASE("CaptureScheduler scans a 1-axis (yaw-only) rig with yaw-only MOVE/set
CHECK(motor.cmds.back() == "MOVE Y 900");
}
TEST_CASE("CaptureScheduler ControlCode 1 drives yaw to the target heading") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
FakeChannel chan;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(0, 0); // these predate the settle wait; timing has its own test
sch.setCaptureActive(true);
chan.next.control_code_available = true;
chan.next.control_code = 1;
chan.next.heading_available = true;
chan.next.target_heading = "45"; // 45 deg * 10 = 450 counts
clock = 1600;
// Walk one capture: the interval elapses and a MOVE goes out, then the gimbal
// settles on it and the frame is kept. Returns the MOVE.
std::string captureOnce(CaptureScheduler& sch, FakeMotor& motor, long long& clock) {
clock += 1100;
sch.tick();
REQUIRE(!motor.cmds.empty());
CHECK(motor.cmds.back() == "MOVE 450,0");
CHECK(sch.controlCode() == 1);
const std::string move = motor.cmds.back();
settleAt(motor, sch.yawTargetCounts(), sch.pitchTargetCounts());
clock += 10;
sch.tick(); // records standstill
clock += 10;
sch.tick(); // keeps the frame
return move;
}
TEST_CASE("CaptureScheduler refuses an unreachable target heading instead of clamping") {
TEST_CASE("pan walks its positions left to right, then jumps back to the left edge") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
FakeChannel chan;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(0, 0); // these predate the settle wait; timing has its own test
sch.setCaptureActive(true);
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(0, 0);
sch.setPanStep(6.0);
// 0 +/- 15 at 6 deg: 345, 351, 357, 3, 9, 15 - six pictures, across north.
sch.setMode(Mode::Pan, PanSpec{0.0, 30.0, 20.0});
REQUIRE(sch.panHeadingsNow().size() == 6);
chan.next.control_code_available = true;
chan.next.control_code = 1;
chan.next.heading_available = true;
// 200 deg is 160 deg the short way round, past the 90 deg limit: in the arc
// this gimbal physically cannot reach.
chan.next.target_heading = "200";
std::vector<std::string> moves;
for (int i = 0; i < 7; ++i) moves.push_back(captureOnce(sch, motor, clock));
// 10 counts/deg; pitch 20 deg -> 200 counts, whatever pitch the gimbal had.
CHECK(moves[0] == "MOVE -150,200");
CHECK(moves[1] == "MOVE -90,200");
CHECK(moves[2] == "MOVE -30,200");
CHECK(moves[3] == "MOVE 30,200");
CHECK(moves[4] == "MOVE 90,200");
CHECK(moves[5] == "MOVE 150,200");
CHECK(moves[6] == "MOVE -150,200"); // back to the left edge
CHECK(cam.triggers == 7);
}
TEST_CASE("a pan narrower than one picture stays on its heading") {
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, 0);
sch.setMode(Mode::Pan, PanSpec{45.0, 5.0, 0.0});
CHECK(captureOnce(sch, motor, clock) == "MOVE 450,0");
CHECK(captureOnce(sch, motor, clock) == "MOVE 450,0");
CHECK(cam.triggers == 2);
}
TEST_CASE("a pan skips positions outside the reachable arc instead of clamping them") {
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, 0);
// 84, 90, 96: the last is past the 90 deg limit. Clamping it to 90 would
// image 90 twice and label one of them 96.
sch.setMode(Mode::Pan, PanSpec{90.0, 12.0, 0.0});
CHECK(captureOnce(sch, motor, clock) == "MOVE 840,0");
CHECK(captureOnce(sch, motor, clock) == "MOVE 900,0");
CHECK(captureOnce(sch, motor, clock) == "MOVE 840,0");
}
TEST_CASE("a pan with nothing reachable moves nothing") {
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, 0);
// 200 deg is 160 deg the short way round, past the 90 deg limit.
sch.setMode(Mode::Pan, PanSpec{200.0, 0.0, 0.0});
clock = 1600;
sch.tick();
// No move at all. Clamping to the 90 deg limit would have imaged a direction
// 110 deg away from the one requested and labelled it "200".
CHECK(motor.cmds.empty());
CHECK_FALSE(sch.moving());
}
TEST_CASE("CaptureScheduler::setGeometry re-targets with the recalibrated conversion") {
// A `gimbal heading fit` updates the live geometry; the scheduler must adopt it so
// the scan/directed targets use the calibrated counts/deg (not the stale copy).
// the pan targets use the calibrated counts/deg (not the stale copy).
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
FakeChannel chan;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(0, 0); // these predate the settle wait; timing has its own test
sch.setCaptureActive(true);
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(0, 0);
sch.setMode(Mode::Pan, PanSpec{45.0, 0.0, 0.0});
CHECK(captureOnce(sch, motor, clock) == "MOVE 450,0"); // 45 * 10 counts/deg
chan.next.control_code_available = true;
chan.next.control_code = 1;
chan.next.heading_available = true;
chan.next.target_heading = "45"; // 45 deg
clock = 1600;
sch.tick();
CHECK(motor.cmds.back() == "MOVE 450,0"); // 45 * 10 counts/deg
// Settle + trigger so the scheduler is ready to move again.
settleAt(motor, 450, 0);
clock = 1700;
sch.tick(); // records standstill
clock = 1710;
sch.tick(); // keeps the frame
// Recalibrate to 20 counts/deg; the same persisted heading must now resolve
// to twice the counts — no new heading sent.
// Recalibrate to 20 counts/deg; the same heading must now resolve to twice
// the counts.
Geometry g2;
g2.yaw = {20.0, 0, 0.0, -90.0, 90.0};
g2.pitch = {10.0, 0, 0.0, 0.0, 60.0};
sch.setGeometry(g2);
clock = 2800;
sch.tick();
CHECK(motor.cmds.back() == "MOVE 900,0"); // 45 * 20 counts/deg
CHECK(captureOnce(sch, motor, clock) == "MOVE 900,0"); // 45 * 20 counts/deg
}
TEST_CASE("CaptureScheduler stays idle when capture inactive") {
TEST_CASE("CaptureScheduler stays idle in manual mode") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
FakeChannel chan;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; });
// capture not active
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
// manual: the mode it starts in
clock = 5000;
sch.tick();
@ -255,14 +249,11 @@ TEST_CASE("CaptureScheduler holds still for settle_wait_ms before keeping a fram
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
FakeChannel chan;
ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}});
CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; });
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(1000, 2000);
sch.setCaptureActive(true);
chan.next.control_code_available = true;
chan.next.control_code = 0;
sch.setMode(Mode::Auto);
clock = 1600;
sch.tick();
@ -289,14 +280,11 @@ TEST_CASE("CaptureScheduler abandons a waypoint after frame_timeout_ms") {
FakeMotor motor;
FakeCamera cam;
cam.ok = false; // camera never has a fresh frame
FakeChannel chan;
ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}});
CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; });
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(1000, 2000);
sch.setCaptureActive(true);
chan.next.control_code_available = true;
chan.next.control_code = 0;
sch.setMode(Mode::Auto);
clock = 1600;
sch.tick();
@ -327,9 +315,8 @@ TEST_CASE("requestSnapshot captures one frame once the gimbal has settled") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
FakeChannel chan;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; });
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(1000, 2000);
// Capture inactive: this is manual mode.
@ -360,9 +347,8 @@ TEST_CASE("a snapshot waits out a slew rather than shooting mid-move") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
FakeChannel chan;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; });
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(1000, 60000); // long frame budget: the point here is the settle
settleAt(motor, 0, 0);
@ -385,14 +371,13 @@ TEST_CASE("a scan owns the camera: snapshots are refused and never queue up") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
FakeChannel chan;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; });
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(0, 0);
settleAt(motor, 0, 0);
sch.requestSnapshot();
sch.setCaptureActive(true);
sch.setMode(Mode::Auto);
// Arming is dropped, not deferred: firing it whenever the scan happens to end
// would attribute an image to whatever the gimbal was pointing at then.
CHECK_FALSE(sch.snapshotPending());
@ -400,7 +385,7 @@ TEST_CASE("a scan owns the camera: snapshots are refused and never queue up") {
sch.requestSnapshot(); // asked for again mid-scan
clock = 1600;
sch.tick(); // the scan's own move, not a snapshot
sch.setCaptureActive(false);
sch.setMode(Mode::Manual);
clock = 3000;
sch.tick();
CHECK_FALSE(sch.snapshotPending());
@ -414,9 +399,8 @@ TEST_CASE("the snapshot frame budget is measured from settling, not from the key
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
FakeChannel chan;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; });
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(1000, 2000);
settleAt(motor, 0, 0);
@ -440,9 +424,8 @@ TEST_CASE("a snapshot that times out can simply be asked for again") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
FakeChannel chan;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; });
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(1000, 2000);
settleAt(motor, 0, 0);