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/IniEdit.cpp
src/core/CameraStall.cpp src/core/CameraStall.cpp
src/core/RemoteCommands.cpp src/core/RemoteCommands.cpp
src/core/OperatingMode.cpp
src/core/Paths.cpp src/core/Paths.cpp
src/core/SingleInstance.cpp src/core/SingleInstance.cpp
src/core/Logger.cpp src/core/Logger.cpp

View File

@ -218,7 +218,7 @@ press a key that acts.
| Key | Does | | 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 | | `h` | home the gimbal |
| `r` | refresh: ask the firmware for a full status dump, re-read the sensor setup | | `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 | | `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 ## 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 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 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 `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 | | 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 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 move <yaw>,<pitch>` | point at a **true bearing** (0 = north) and an elevation, in degrees |
| `gimbal nudge <yaw\|pitch> <+/-percent>` | a small move by hand | | `gimbal nudge <yaw\|pitch> <+/-percent>` | a small move by hand |
| `snapshot` | take one picture now (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) | | `test [<subsystem> [<test>]]` | run self-tests - see [below](#is-the-tower-healthy) |
| `gimbal heading setup` | tie the yaw axis to true north by sighting landmarks | | `gimbal heading setup` | tie the yaw axis to true north by sighting landmarks |
| `set camera profile <name>` | switch the camera profile, e.g. `night` | | `set camera profile <name>` | switch the camera profile, e.g. `night` |

View File

@ -156,7 +156,7 @@ period_ms = 2000
[Motor] [Motor]
; Degrees<->encoder-counts maps, anchored on the mechanical centre found by ; Degrees<->encoder-counts maps, anchored on the mechanical centre found by
; homing. The firmware speaks only in absolute encoder counts; these map them to ; 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 ; 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 ; midpoint as <axis>_center_count. Anchoring on the midpoint rather than one

View File

@ -49,6 +49,9 @@ imu = none
env = sht41 env = sht41
; The [Camera.<name>] blocks expected to be present and found. ; The [Camera.<name>] blocks expected to be present and found.
cameras = rgb 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: ; What happens when this does not match the rig:
; declared but absent -> warned about by name, and with [Features] ; 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` 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 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). one. Encoder counts are mapped to/from degrees by `Geometry` (`[Motor]` calibration).
3. **Control input** — `IControlChannel::poll()` returns the latest `ControlCommand` (control code + target 3. **Mode** — auto, pan or manual ([OperatingMode.h](../include/fgc/OperatingMode.h)), changed only by
heading), clearing its "available" flags so each update is acted on once. 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: 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). - auto: `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`. - 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 - 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 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 auto-exposure tracking the light; `trigger()` hands over the freshest complete frame. See *Image
capture* below. capture* below.
@ -88,11 +91,11 @@ corrupted.
## Capture state machine ## 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) ┌──────────────────────┐ auto → next ScanGrid (yaw,pitch)
│ MOVE <yaw>,<pitch> │ ControlCode 1 → target_HDG (pitch held) │ MOVE <yaw>,<pitch> │ pan → next pan heading, at the pan's pitch
└──────────┬───────────┘ deg→counts via Geometry; set moving, reset timer └──────────┬───────────┘ deg→counts via Geometry; set moving, reset timer
│ (both axes standstill AND |xenc − target| ≤ tol) │ (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 │ 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 │ camera.trigger() │ no fresh frame: retry next tick
└──────────────────────┘ still none after frame_timeout_ms: warn, skip, advance └──────────────────────┘ 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 ### `[Motor]` calibration & `[Scan]` grid
The firmware reports only **encoder counts**; `[Motor]` maps them to the bearing/elevation degrees 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. degrees are elevation above level, so 0 is horizontal.
#### The counts are anchored by homing #### 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 `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 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², 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). [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 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 ([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. 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 ### One key, not two: declaring a sensor is what enables it
`[Hardware] imu` and `[Hardware] env` name what is fitted. `[Features] enable_imu` `[Hardware] imu` and `[Hardware] env` name what is fitted. `[Features] enable_imu`
@ -545,8 +553,10 @@ Handled in `Application::Impl::handleCommand`.
| Type this | Meaning | | Type this | Meaning |
|-----------|---------| |-----------|---------|
| `start` | Start camera acquisition + capture | | `mode` | Say which mode the tower is in: auto, pan or manual |
| `stop` | Stop acquisition | | `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 | | `debug` | Toggle debug logging |
| `trace <cat> [on\|off]` | Toggle a wire-trace category (`serial`/`mqtt`/`camera`/`control`) | | `trace <cat> [on\|off]` | Toggle a wire-trace category (`serial`/`mqtt`/`camera`/`control`) |
| `trace all` / `trace off` | Enable every category / silence all | | `trace all` / `trace off` | Enable every category / silence all |
@ -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. `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 **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, 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 `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 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] 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) [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 [CAMERA] TX trigger cam0 / [CAMERA] RX frame cam0 1936x1216 7064576B
[CONTROL] sweep -> grid yaw=-90 pitch=0 (scheduler decisions + inbound console commands) [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. 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 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 axes report standstill at the target, then triggers the cameras. In **auto** mode it walks the scan
grid (front to back, repeating); **ControlCode 1** drives yaw to `target_HDG` (pitch held). Telemetry arrives as 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 firmware `ST` lines (per-axis state + encoder counts), parsed by
[TelemetryParser](../src/core/TelemetryParser.cpp). [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` | | 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` | | `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 | | `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) | | `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) | | `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` | | 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 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". homing or `gimbal reset`, so a lit badge means "position untrustworthy since the last home".
### `ControlCommand` ([IControlChannel.h](../include/fgc/IControlChannel.h)) ### `Mode`, `PanSpec` ([OperatingMode.h](../include/fgc/OperatingMode.h))
`control_code` (0 = scan-grid sweep, 1 = directed to `target_HDG`) + `target_heading`, each with an `Mode` is auto (0), pan (1) or manual (2) - the values StatusCode carries. `PanSpec` is a pan's
`*_available` flag. `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)) ### `CamEvent` ([IControlChannel.h](../include/fgc/IControlChannel.h))
`tower`, `camera` (RGB/ACR/NIR), `heading_decideg` (yaw×10), `pitch_decideg` (pitch×10) and `tower`, `camera` (RGB/ACR/NIR), `heading_decideg` (yaw×10), `pitch_decideg` (pitch×10) and

View File

@ -1,9 +1,10 @@
# MQTT API # 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` MQTT channel is the `MqttControlChannel` implementation of `IControlChannel`
([src/mqtt/MqttControlChannel.cpp](../src/mqtt/MqttControlChannel.cpp), Eclipse Paho C++). It is used only when ([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: 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. - **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). 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()` The tower is always in one of three modes. It changes mode **only by command** -
([src/mqtt/MqttControlChannel.cpp](../src/mqtt/MqttControlChannel.cpp)). `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 | | Mode | StatusCode | What the tower does | Command |
|-------|---------|-----------|--------| |---|---|---|---|
| `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]`) | | 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` |
| `GGS/FWT/<tower>/ControlCode` | integer as string, `"0"` or `"1"` | `stoi` → int | Selects the capture mode (see below) | | 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" **Headings are true bearings and can be out of reach.** Yaw travel is under a
flags** so each update is acted on once (`MqttControlChannel::poll()`). 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)) | - reading: `help`, `gimbal status`, `gimbal dump`, `imu status`, `imu config`, `refresh`
|-------------|---------------------------------------------------| - logging: `debug`, `trace`
| `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. | - `mode ...`, `cancel`, `exit`
| `1` | **Directed.** `MOVE` yaw to `target_HDG` (pitch held), wait for standstill, trigger. |
> **`target_HDG` is a true bearing and can be refused.** Yaw travel is under a full turn, so there is Anything else is refused - `ignored '<command>': the tower is in pan mode
> always an unreachable arc. A bearing falling in it is logged once and the gimbal **holds position**; ('mode manual' first)` - whether it came over MQTT or from the console. So a
> it is not clamped to the nearest limit, which would image a direction up to 180° away and publish it web page switches to manual before driving the gimbal, and back to auto (or
> under the bearing that was asked for. The reachable arc is logged after every home. 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) ## Published topics (outbound)
| Topic | When | Payload | QoS / Retain | | 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 | | `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 ### CamEvent payload
Built by `MqttControlChannel::publishCamEvent` from a `CamEvent`: 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 `~/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 station, and it runs outwards only: `GGS.FWT.*.CamEvent` is bound into a
`CamEvents` queue that a shovel (`rabbitmq/shovel-declaration.sh`) carries to `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** - 10.11.12.13. **Nothing carries messages from the ground station to a tower.**
which is also true of `ControlCode` and `target_HDG`.
So a web interface either: So a web interface either:
- connects to each tower's broker directly (over the VPN) - `Command` and - connects to each tower's broker directly (over the VPN) - `Command` and
`CommandReply` both live there, and nothing else is needed; or `CommandReply` both live there, and nothing else is needed; or
- goes through the ground station, which needs, in zkms-client: a - goes through the ground station, which needs, in zkms-client: a
`CommandReply` queue, binding and shovel out (mirroring `CamEvents`), and an `CommandReply` queue, binding and shovel out (mirroring `CamEvents`), and an
inbound shovel from the ground station to the tower for `Command` (and inbound shovel from the ground station to the tower for `Command` (and a
`ControlCode` / `target_HDG`). binding and shovel out for `StatusCode`, so the ground station sees the mode).
### Example ### Example: watch a fire, then look around by hand
```text ```text
# to GGS/FWT/dev/Command # 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":"w2","cmd":"gimbal nudge yaw 5"}
{"id":"maint-17","cmd":"gimbal status","level":"info", # -> level "warn": ignored 'gimbal nudge yaw 5': the tower is in pan mode ('mode manual' first)
"lines":["16:40:02.114 [INFO ] remote ..."],"truncated":false}
{"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 ## Topic summary
``` ```
subscribe: GGS/FWT/<tower>/target_HDG (int heading) subscribe: GGS/FWT/<tower>/Command (any console command: JSON {id, cmd} or a bare line;
GGS/FWT/<tower>/ControlCode (0 = auto sweep, 1 = directed) modes change here: mode auto|pan|manual)
GGS/FWT/<tower>/Command (any console command: JSON {id, cmd} or a bare line)
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>/CamEvent (JSON: fwt, cam, hdg×10, pit×10, time-ms)
GGS/FWT/<tower>/CommandReply (JSON: id, cmd, level, lines, truncated - not retained) 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 ```bash
mosquitto_sub -t 'GGS/FWT/ExampleTower/#' -v # watch everything for the tower mosquitto_sub -t 'GGS/FWT/ExampleTower/#' -v # watch everything for the tower
mosquitto_pub -t 'GGS/FWT/ExampleTower/ControlCode' -m '0' mosquitto_pub -q 1 -t 'GGS/FWT/ExampleTower/Command' -m 'mode pan 180 30'
mosquitto_pub -t 'GGS/FWT/ExampleTower/target_HDG' -m '180'
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 '{"id":"t1","cmd":"gimbal status"}'
mosquitto_pub -q 1 -t 'GGS/FWT/ExampleTower/Command' -m 'help' # a bare line works too 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 | | `test strict` | everything, `strict` profile |
While a test runs, interfering commands (moves, homing, a second test, capture, 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. `g`, `?`) keep working.
## Subsystems and leaves ## 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) [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 > **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, > 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/Geometry.h"
#include "fgc/ICameraSource.h" #include "fgc/ICameraSource.h"
#include "fgc/IControlChannel.h"
#include "fgc/IMotorController.h" #include "fgc/IMotorController.h"
#include "fgc/OperatingMode.h"
#include "fgc/ScanGrid.h" #include "fgc/ScanGrid.h"
#include <functional> #include <functional>
#include <string> #include <string>
#include <vector>
namespace fgc { namespace fgc {
@ -15,29 +16,39 @@ namespace fgc {
// unit-tested with mocks. // unit-tested with mocks.
// //
// Each tick(): // Each tick():
// 1. polls the control channel (updates control code / target heading, // 1. reads motor telemetry (per-axis encoder counts + state),
// echoes the code back as status), // 2. runs the capture cycle as a move -> settle -> wait -> keep machine: when
// 2. reads motor telemetry (per-axis encoder counts + state),
// 3. 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 // 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 // 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 // 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 // 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. // retries each tick, and gives up on the waypoint after frame_timeout_ms.
// //
// ControlCode 0 = automatic sweep through the ScanGrid (front to back, repeating); // What it captures is the mode (OperatingMode.h): auto sweeps the ScanGrid front
// ControlCode 1 = drive yaw to the MQTT-supplied target heading (pitch held). // 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. // Degrees are converted to encoder counts via Geometry.
class CaptureScheduler { class CaptureScheduler {
public: public:
// now_ms: monotonic millisecond clock; defaults to steady_clock. Injectable // now_ms: monotonic millisecond clock; defaults to steady_clock. Injectable
// for deterministic tests. // for deterministic tests.
CaptureScheduler(IMotorController& motor, ICameraSource& camera, IControlChannel& channel, CaptureScheduler(IMotorController& motor, ICameraSource& camera, double image_rate,
double image_rate, Geometry geometry, ScanGrid& grid, Geometry geometry, ScanGrid& grid, std::function<long long()> now_ms = {});
std::function<long long()> now_ms = {});
void setCaptureActive(bool active); // Switch mode. The caller (Application::setMode) has already checked that
bool captureActive() const { return capture_active_; } // 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`). // 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 // Serviced on the next tick once the gimbal is standing still and the settle
@ -64,8 +75,6 @@ public:
void tick(); void tick();
// Inspection (mainly for tests). // Inspection (mainly for tests).
int controlCode() const { return control_code_; }
std::string targetHeading() const { return target_heading_; }
long yawTargetCounts() const { return yaw_target_; } long yawTargetCounts() const { return yaw_target_; }
long pitchTargetCounts() const { return pitch_target_; } long pitchTargetCounts() const { return pitch_target_; }
bool moving() const { return moving_; } bool moving() const { return moving_; }
@ -79,7 +88,6 @@ private:
IMotorController& motor_; IMotorController& motor_;
ICameraSource& camera_; ICameraSource& camera_;
IControlChannel& channel_;
Geometry geometry_; Geometry geometry_;
ScanGrid& grid_; ScanGrid& grid_;
@ -87,9 +95,11 @@ private:
double image_rate_; double image_rate_;
long long timer_start_ = 0; long long timer_start_ = 0;
bool capture_active_ = false; Mode mode_ = Mode::Manual;
int control_code_ = 0; PanSpec pan_;
std::string target_heading_ = "0"; 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 bool moving_ = false; // a MOVE has been issued, awaiting settle
long long settled_at_ms_ = 0; // when standstill was first seen (0 = not yet) 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 // 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. // out-of-arc waypoints would otherwise log on every pass, forever.
bool unreachable_warned_ = false; bool unreachable_warned_ = false;
bool directed_unreachable_warned_ = false; bool pan_unreachable_warned_ = false;
bool snapshot_pending_ = false; // a manual snapshot is armed bool snapshot_pending_ = false; // a manual snapshot is armed
long long snapshot_settled_ms_ = 0; // when standstill was first seen (0 = not yet) long long snapshot_settled_ms_ = 0; // when standstill was first seen (0 = not yet)

View File

@ -14,8 +14,7 @@ namespace fgc {
// Scheme: GGS/FWT/<tower>/<leaf> — see docs/mqtt-api.md. // Scheme: GGS/FWT/<tower>/<leaf> — see docs/mqtt-api.md.
// Leaf names, so the two channels and the UI agree on spelling. // Leaf names, so the two channels and the UI agree on spelling.
inline constexpr const char* kTopicTargetHdg = "target_HDG"; // The tower's mode, out (0 auto, 1 pan, 2 manual - see OperatingMode.h).
inline constexpr const char* kTopicControlCode = "ControlCode";
inline constexpr const char* kTopicStatusCode = "StatusCode"; inline constexpr const char* kTopicStatusCode = "StatusCode";
inline constexpr const char* kTopicCamEvent = "CamEvent"; inline constexpr const char* kTopicCamEvent = "CamEvent";
// Any console command, in; its answer, out. See docs/mqtt-api.md. // 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 imu; // sensor model, or "none"
std::string env; // sensor model, or "none" std::string env; // sensor model, or "none"
std::vector<std::string> cameras; // the [Camera.<name>] blocks expected to exist 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 // 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. // migrated yet, and the switch that keeps this change invisible on rollout.

View File

@ -5,15 +5,6 @@
namespace fgc { 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. // Notification published after an image is captured and saved.
struct CamEvent { struct CamEvent {
std::string tower; // tower / FWT name std::string tower; // tower / FWT name
@ -70,7 +61,7 @@ struct ChannelStats {
// Abstraction over the remote control/telemetry channel. Implemented by // Abstraction over the remote control/telemetry channel. Implemented by
// MqttControlChannel (Eclipse Paho) and NullControlChannel (no broker; used // 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 { class IControlChannel {
public: public:
virtual ~IControlChannel() = default; virtual ~IControlChannel() = default;
@ -80,13 +71,11 @@ public:
virtual void disconnect() = 0; virtual void disconnect() = 0;
virtual bool connected() const = 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 publishStatus(int code) = 0;
virtual void publishCamEvent(const CamEvent& event) = 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 // 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. // answer to one. Defaults: none / dropped, for a channel that takes no commands.
virtual std::vector<RemoteCommand> takeCommands() { return {}; } virtual std::vector<RemoteCommand> takeCommands() { return {}; }

View File

@ -14,8 +14,7 @@
namespace fgc { namespace fgc {
// Real control channel over MQTT (Eclipse Paho C++). Subscribes to the tower's // Real control channel over MQTT (Eclipse Paho C++). Subscribes to the tower's
// target_HDG / ControlCode / Command topics and publishes StatusCode / CamEvent / // Command topic and publishes StatusCode / CamEvent / CommandReply. Topic scheme: GGS/FWT/<tower>/... (see docs/mqtt-api.md).
// CommandReply. Topic scheme: GGS/FWT/<tower>/... (see docs/mqtt-api.md).
// Publishes are QoS 1; retained, except command replies. // Publishes are QoS 1; retained, except command replies.
// //
// Unlike the old MQTTClient it does NOT spin a busy-wait thread; Paho's async // 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 publishStatus(int code) override;
void publishCamEvent(const CamEvent& event) override; void publishCamEvent(const CamEvent& event) override;
ControlCommand poll() override;
std::vector<RemoteCommand> takeCommands() override; std::vector<RemoteCommand> takeCommands() override;
void publishCommandReply(const CommandReply& reply) override; void publishCommandReply(const CommandReply& reply) override;
@ -57,8 +55,6 @@ private:
ChannelHistory history_; ChannelHistory history_;
std::string broker_uri_; std::string broker_uri_;
std::string tower_; std::string tower_;
std::string topic_target_hdg_;
std::string topic_control_code_;
std::string topic_status_; std::string topic_status_;
std::string topic_cam_event_; std::string topic_cam_event_;
std::string topic_command_; std::string topic_command_;
@ -69,7 +65,7 @@ private:
std::atomic<bool> connected_{false}; std::atomic<bool> connected_{false};
std::mutex mutex_; 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_) 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 { namespace fgc {
// No-op control channel for running without an MQTT broker. Publishes go // No-op control channel for running without an MQTT broker. Publishes go
// nowhere and poll() never supplies a control command, so the scheduler stays // nowhere, and no commands arrive unless a test inject()s one.
// in automatic-sweep mode (ControlCode 0).
// //
// It still records what it was asked to send, using the same topic and payload // 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 // builders as the real channel, so the connectivity view works under --no-mqtt
@ -39,7 +38,6 @@ public:
<< " (null channel)"; << " (null channel)";
drop(kTopicCamEvent, camEventJson(e)); drop(kTopicCamEvent, camEventJson(e));
} }
ControlCommand poll() override { return {}; }
// Commands: none arrive by themselves; inject() stands in for the broker, // Commands: none arrive by themselves; inject() stands in for the broker,
// so --no-mqtt runs and tests can drive the remote-command path. // so --no-mqtt runs and tests can drive the remote-command path.

View File

@ -151,7 +151,7 @@ struct CaptureView {
CameraConfigView config; CameraConfigView config;
std::vector<CameraDeviceView> devices; std::vector<CameraDeviceView> devices;
long long images_saved = 0; 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. // flagged, surfaced in the expanded camera view.
std::vector<ScanWaypointView> scan_grid; std::vector<ScanWaypointView> scan_grid;
bool scan_from_file = false; // grid_file CSV vs generated bool scan_from_file = false; // grid_file CSV vs generated
@ -171,9 +171,8 @@ struct ConnView {
bool mqtt_connected = false; bool mqtt_connected = false;
std::string broker; std::string broker;
std::string tower; std::string tower;
int control_code = 0; // 0 = auto-sweep, 1 = directed std::string mode = "manual"; // auto / pan / manual
std::string target_heading; int status_code = 2; // what StatusCode carries for it
int last_status_code = 0;
// Recent traffic + counters for the expanded 'm' view. // Recent traffic + counters for the expanded 'm' view.
std::vector<ChannelMessage> recent; std::vector<ChannelMessage> recent;
@ -185,6 +184,7 @@ struct HeaderView {
std::string build; std::string build;
long long uptime_ms = 0; long long uptime_ms = 0;
bool live = true; // LIVE vs MOCK bool live = true; // LIVE vs MOCK
std::string mode = "manual"; // auto / pan / manual (OperatingMode.h)
}; };
struct LogLine { 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, " "path to hardware.ini (default: $FGC_HARDWARE, /etc/fire_gimbal_control/hardware.ini, "
"or one beside config.ini)") "or one beside config.ini)")
("init,i", po::bool_switch(), "home the gimbal on startup (required: nothing is homed across a power cycle)") ("init,i", po::bool_switch(), "home the gimbal on startup (required: nothing is homed across a power cycle)")
("start,s", po::bool_switch(), "start 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)") ("demo,d", po::bool_switch(), "demo mode (copy placeholder image instead of encoding)")
("no-mqtt", po::bool_switch(), "disable MQTT (use a null control channel)") ("no-mqtt", po::bool_switch(), "disable MQTT (use a null control channel)")
("mock-camera", po::bool_switch(), "use a simulated camera (no hardware)") ("mock-camera", po::bool_switch(), "use a simulated camera (no hardware)")

View File

@ -26,6 +26,7 @@
#include "fgc/ScanGrid.h" #include "fgc/ScanGrid.h"
#include "fgc/SerialMotorController.h" #include "fgc/SerialMotorController.h"
#include "fgc/TestReport.h" #include "fgc/TestReport.h"
#include "fgc/OperatingMode.h"
#include "fgc/RemoteCommands.h" #include "fgc/RemoteCommands.h"
#include "fgc/TestRunner.h" #include "fgc/TestRunner.h"
#include "fgc/mock/MockCameraSource.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 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. // --test: what run() returns, and where the one-shot test has got to.
int exit_code_ = 0; 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; bool oneshot_started_ = false;
std::chrono::steady_clock::time_point oneshot_since_{}; std::chrono::steady_clock::time_point oneshot_since_{};
std::chrono::steady_clock::time_point oneshot_link_ok_{}; 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>( s.header.uptime_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start_time).count(); std::chrono::steady_clock::now() - start_time).count();
s.header.live = !mock_serial; s.header.live = !mock_serial;
s.header.mode = scheduler ? modeName(scheduler->mode()) : "manual";
// --- Gimbal --- // --- Gimbal ---
MotorTelemetry t = motor->telemetry(); MotorTelemetry t = motor->telemetry();
@ -917,9 +920,8 @@ struct Application::Impl {
s.conn.mqtt_connected = channel && channel->connected(); s.conn.mqtt_connected = channel && channel->connected();
s.conn.broker = cfg.network.brokerUri(); s.conn.broker = cfg.network.brokerUri();
s.conn.tower = cfg.general.tower_name; s.conn.tower = cfg.general.tower_name;
s.conn.control_code = scheduler ? scheduler->controlCode() : 0; s.conn.mode = s.header.mode;
s.conn.target_heading = scheduler ? scheduler->targetHeading() : "0"; s.conn.status_code = static_cast<int>(scheduler ? scheduler->mode() : Mode::Manual);
s.conn.last_status_code = s.conn.control_code; // echoed back as status
if (channel) { if (channel) {
s.conn.recent = channel->recentMessages(); s.conn.recent = channel->recentMessages();
s.conn.stats = channel->channelStats(); s.conn.stats = channel->channelStats();
@ -973,18 +975,23 @@ struct Application::Impl {
a.status = "finding endstops…"; a.status = "finding endstops…";
} else if (scheduler && scheduler->captureActive()) { } else if (scheduler && scheduler->captureActive()) {
a.active = true; a.active = true;
a.title = "CAPTURE";
const char* phase = scheduler->moving() ? "moving" : "dwell"; const char* phase = scheduler->moving() ? "moving" : "dwell";
if (scheduler->controlCode() == 1) { if (scheduler->mode() == Mode::Pan) {
a.status = "directed → heading " + scheduler->targetHeading() + "° · " + a.title = "PAN";
(scheduler->moving() ? "moving" : "holding"); 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()) { } else if (!grid.empty()) {
a.title = "AUTO";
const ScanPoint& p = grid.current(); const ScanPoint& p = grid.current();
std::string coord = "yaw " + formatDegrees(p.yaw_deg); std::string coord = "yaw " + formatDegrees(p.yaw_deg);
if (s.capture.scan_pitch) coord += " / pitch " + formatDegrees(p.pitch_deg); if (s.capture.scan_pitch) coord += " / pitch " + formatDegrees(p.pitch_deg);
a.status = "waypoint " + std::to_string(grid.index() + 1) + "/" + a.status = "waypoint " + std::to_string(grid.index() + 1) + "/" +
std::to_string(grid.size()) + " · " + coord + " · " + phase; std::to_string(grid.size()) + " · " + coord + " · " + phase;
} else { } else {
a.title = "AUTO";
a.status = "no scan grid — auto-sweep disabled"; 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 // `camera reset`: reboot every camera (DeviceReset) and put it back on its
// profile. For a stream that has stopped delivering and will not restart - // 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. // 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() { void resetCameras() {
const bool was = scheduler->captureActive(); camera->stop();
stopCapture();
for (int i = 0; i < camera->cameraCount(); ++i) for (int i = 0; i < camera->cameraCount(); ++i)
LOG_INFO << "camera " << i << (camera->resetCamera(i) ? ": reset, back on its profile" LOG_INFO << "camera " << i << (camera->resetCamera(i) ? ": reset, back on its profile"
: ": reset FAILED (see above)"); : ": 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 // 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, // move the axis between a sighting and the bearing being typed for it,
// silently poisoning the fit with a position nobody looked through. // silently poisoning the fit with a position nobody looked through.
if (heading_setup_active_) { 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')"; "(finish it, or 'gimbal heading cancel')";
return; return false;
} }
// The scheduler arms regardless, but a scan can't progress until the axes // Before READY the firmware leaves coils disabled and clamps MOVE
// are homed: before READY the firmware leaves coils disabled and clamps // targets to a coarse bench guard, so no position is ever reached and
// MOVE targets to a coarse bench guard, so no waypoint is ever reached and
// the move/settle cycle stalls silently. Refuse with a clear message // the move/settle cycle stalls silently. Refuse with a clear message
// instead. (Single-axis builds report no P: segment, so skip pitch.) // instead. (Single-axis builds report no P: segment, so skip pitch.)
MotorTelemetry t = motor->telemetry(); MotorTelemetry t = motor->telemetry();
if (!t.yaw.ready() || (t.pitch_present && !t.pitch.ready())) { 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; return;
} }
camera->start(); if (!r.query) {
scheduler->setCaptureActive(true); setMode(r.mode, r.pan);
LOG_INFO << "Capture started"; 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>`. // `gimbal heading fit <counts>:<deg> ...` / `gimbal heading offset <deg>`.
@ -1780,24 +1864,25 @@ struct Application::Impl {
return; return;
} }
// The camera is stopped outside a scan; leave it running afterwards so a // 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. // and shutdown stop it as before.
camera->start(); camera->start();
scheduler->requestSnapshot(); scheduler->requestSnapshot();
LOG_INFO << "snapshot requested"; LOG_INFO << "snapshot requested";
} }
// Manual mode is simply "no scan running" - derived, never a separate flag an // Hand aiming and a scan are mutually exclusive: both drive the same axes,
// operator could strand. Hand aiming and a scan are mutually exclusive: both // and a nudge mid-sweep would silently corrupt the waypoint the next frame is
// drive the same axes, and a nudge mid-sweep would silently corrupt the // published under. The mode gate in handleCommand() already refuses these
// waypoint the next frame is published under. // outside manual; the guard stays so each command is safe on its own.
bool manualMode() const { return !scheduler || !scheduler->captureActive(); } bool manualMode() const { return !scheduler || scheduler->mode() == Mode::Manual; }
// Guard for the hand-aiming commands. Returns false (having said why) when a // Guard for the hand-aiming commands. Returns false (having said why) when a
// scan owns the gimbal. // scan owns the gimbal.
bool requireManual(const char* what) { bool requireManual(const char* what) {
if (manualMode()) return true; 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; return false;
} }
@ -2043,14 +2128,14 @@ struct Application::Impl {
return; return;
} }
if (calib && calib->running()) { 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; return;
} }
if (heading_setup_active_) { if (heading_setup_active_) {
LOG_WARN << "pitch setup: heading setup in progress ('gimbal heading cancel' first)"; LOG_WARN << "pitch setup: heading setup in progress ('gimbal heading cancel' first)";
return; 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 = std::make_unique<PitchSetupRoutine>(*motor, *imu, cfg.geometry);
calib->start(); calib->start();
} }
@ -2132,7 +2217,7 @@ struct Application::Impl {
while (!local.empty()) { while (!local.empty()) {
const std::string& line = local.front(); const std::string& line = local.front();
Command c = parseCommand(line); Command c = parseCommand(line);
if (c.verb == "cancel" || c.verb == "stop") if (c.verb == "cancel" || (c.verb == "mode" && c.device == "manual"))
cancel_procedure_ = true; cancel_procedure_ = true;
else if (!c.empty()) else if (!c.empty())
LOG_WARN << "ignored '" << c.verb << "': homing in progress (Esc to cancel)"; 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 // Paused, not ended: a test run on a tower in service (install-tower
// --verify does exactly that) must not leave it idle afterwards. // --verify does exactly that) must not leave it idle afterwards.
resume_capture_after_test_ = scheduler->captureActive(); // Typed tests only run in manual (allowedIn); a --test at startup may find
stopCapture(); // free the serial link; don't fight the routine's moves // 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 << "')"; LOG_INFO << "starting hardware test (profile '" << profile.name << "')";
// What `test hardware` needs beyond the motor and IMU: the declaration to // What `test hardware` needs beyond the motor and IMU: the declaration to
// check against, and the subsystems that answer for themselves. // check against, and the subsystems that answer for themselves.
@ -2358,10 +2447,11 @@ struct Application::Impl {
exit_code_ = r.all_pass ? 0 : kExitTestFail; exit_code_ = r.all_pass ? 0 : kExitTestFail;
running = false; running = false;
} }
if (resume_capture_after_test_) { if (resume_mode_after_test_ != Mode::Manual) {
resume_capture_after_test_ = false; LOG_INFO << "resuming " << modeName(resume_mode_after_test_)
LOG_INFO << "resuming capture, paused for the test"; << " mode, paused for the test";
startCapture(); 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 // Commands that would disturb a running procedure (motion, capture, a second
// long op, or a DUMP over the same serial link). Ignored while busy. // long op, or a DUMP over the same serial link). Ignored while busy.
static bool interferingVerb(const std::string& verb) { static bool interferingVerb(const std::string& verb) {
return verb == "gimbal" || verb == "start" || verb == "test" || return verb == "gimbal" || verb == "mode" || verb == "test" ||
verb == "refresh" || verb == "snapshot" || verb == "camera"; verb == "refresh" || verb == "snapshot" || verb == "camera";
} }
@ -2425,13 +2515,27 @@ struct Application::Impl {
LOG_TRACE_CAT(LogCat::Control) << "cmd " << line; LOG_TRACE_CAT(LogCat::Control) << "cmd " << line;
// Lock-out: while a test/calibration/homing runs, ignore interfering // 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)) { if (procedureBusy() && interferingVerb(c.verb)) {
LOG_WARN << "ignored '" << c.verb << "': " << procedureName() LOG_WARN << "ignored '" << c.verb << "': " << procedureName()
<< " in progress (Esc to cancel)"; << " in progress (Esc to cancel)";
return; 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") { if (c.verb == "help") {
// c.device holds the optional topic (first token after "help"). // c.device holds the optional topic (first token after "help").
for (const auto& l : renderHelp(c.device)) LOG_INFO << l; for (const auto& l : renderHelp(c.device)) LOG_INFO << l;
@ -2459,12 +2563,8 @@ struct Application::Impl {
else LOG_WARN << "usage: camera reset"; else LOG_WARN << "usage: camera reset";
} else if (c.verb == "snapshot") { } else if (c.verb == "snapshot") {
takeSnapshot(); takeSnapshot();
} else if (c.verb == "start") { } else if (c.verb == "mode") {
startCapture(); handleMode(line);
} else if (c.verb == "stop") {
// `stop`/`x` cancels any running procedure, else stops capture.
if (procedureBusy()) cancelActiveProcedure();
else stopCapture();
} else if (c.verb == "debug") { } else if (c.verb == "debug") {
bool on = Logger::level() != LogLevel::Debug; bool on = Logger::level() != LogLevel::Debug;
Logger::setLevel(on ? LogLevel::Debug : LogLevel::Info); 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_WARN << "scan grid load failed (" << e.what() << "); auto-sweep disabled";
} }
LOG_INFO << "scan grid: " << grid.size() << " waypoints"; 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); cfg.geometry, grid);
scheduler->setPanStep(cfg.hardware.pan_step_deg);
// Every camera has to have finished by the time the gimbal moves on, so // 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. // the wait is the longest across them, not the first one's.
scheduler->setSettleWait(cfg.camera.maxSettleWaitMs(), cfg.camera.maxFrameTimeoutMs()); scheduler->setSettleWait(cfg.camera.maxSettleWaitMs(), cfg.camera.maxFrameTimeoutMs());
@ -2754,7 +2855,7 @@ struct Application::Impl {
} }
pipeline->start(); 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 // 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 // start the interface (headless console or TUI) on its own thread. The
@ -2784,7 +2885,7 @@ struct Application::Impl {
sendAnchorConfig(); sendAnchorConfig();
applyHomedTravel(); 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 // 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). // camera is always stopped and closed (an unclean exit wedges it for next time).

View File

@ -4,6 +4,7 @@
#include <chrono> #include <chrono>
#include <cstdlib> #include <cstdlib>
#include <optional>
namespace fgc { namespace fgc {
@ -22,11 +23,10 @@ constexpr long kSettleTolCounts = 600;
} // namespace } // namespace
CaptureScheduler::CaptureScheduler(IMotorController& motor, ICameraSource& camera, CaptureScheduler::CaptureScheduler(IMotorController& motor, ICameraSource& camera,
IControlChannel& channel, double image_rate, Geometry geometry, double image_rate, Geometry geometry, ScanGrid& grid,
ScanGrid& grid, std::function<long long()> now_ms) std::function<long long()> now_ms)
: motor_(motor), : motor_(motor),
camera_(camera), camera_(camera),
channel_(channel),
geometry_(geometry), geometry_(geometry),
grid_(grid), grid_(grid),
now_ms_(now_ms ? std::move(now_ms) : steadyNowMs), now_ms_(now_ms ? std::move(now_ms) : steadyNowMs),
@ -34,8 +34,12 @@ CaptureScheduler::CaptureScheduler(IMotorController& motor, ICameraSource& camer
timer_start_ = now_ms_(); timer_start_ = now_ms_();
} }
void CaptureScheduler::setCaptureActive(bool active) { void CaptureScheduler::setMode(Mode mode, const PanSpec& pan) {
capture_active_ = active; 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 moving_ = false; // restart the move/settle cycle cleanly
settled_at_ms_ = 0; settled_at_ms_ = 0;
// Drop any armed snapshot: a scan starting means the operator moved on, and // 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() { void CaptureScheduler::tick() {
// 1. Remote control input. // 1. Telemetry.
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.
MotorTelemetry t = motor_.telemetry(); MotorTelemetry t = motor_.telemetry();
pitch_present_ = t.pitch_present; pitch_present_ = t.pitch_present;
// 3. Manual snapshot. Only outside a scan, so a keypress can never inject a // 2. Manual snapshot. Only in manual, so a keypress can never inject a frame
// frame into a running sweep or fight it for the settle window. // into a running sweep or fight it for the settle window.
if (!capture_active_) { if (mode_ == Mode::Manual) {
if (snapshot_pending_) serviceSnapshot(t); if (snapshot_pending_) serviceSnapshot(t);
return; return;
} }
// 4. Capture cycle. Only ControlCode 0 (sweep) and 1 (directed) act. // 3. Capture cycle: the next scan waypoint (auto) or pan position (pan).
if (control_code_ != 0 && control_code_ != 1) return;
const double interval_ms = image_rate_ > 0.0 ? 1000.0 / image_rate_ : 0.0; const double interval_ms = image_rate_ > 0.0 ? 1000.0 / image_rate_ : 0.0;
if (!moving_) { if (!moving_) {
// Issue the next move once the interval has elapsed. // Issue the next move once the interval has elapsed.
if (interval_ms <= 0.0 || elapsedMs() <= interval_ms) return; if (interval_ms <= 0.0 || elapsedMs() <= interval_ms) return;
if (control_code_ == 0) { if (mode_ == Mode::Auto) {
if (grid_.empty()) return; if (grid_.empty()) return;
const ScanPoint& p = grid_.current(); const ScanPoint& p = grid_.current();
// Yaw travel is under a full turn, so some bearings are unreachable. // 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 without enabling the control wire-trace.
LOG_INFO << "scan move " << (grid_.index() + 1) << "/" << grid_.size() LOG_INFO << "scan move " << (grid_.index() + 1) << "/" << grid_.size()
<< ": yaw " << p.yaw_deg << " pitch " << p.pitch_deg << " (deg)"; << ": yaw " << p.yaw_deg << " pitch " << p.pitch_deg << " (deg)";
} else { // ControlCode 1: directed yaw, pitch held at current position. } else { // pan: the next position, left to right, at the pan's pitch
double heading = 0.0; if (pan_headings_.empty()) return;
try { // Skip positions outside the reachable arc, as the sweep does; try
heading = std::stod(target_heading_); // each at most once per tick so a pan with none reachable just idles.
} catch (const std::exception&) { std::optional<long> yc;
return; // bad heading; wait for a valid one 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); pan_index_ = (pan_index_ + 1) % pan_headings_.size();
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";
} }
return; if (!yc) return;
}
directed_unreachable_warned_ = false;
yaw_target_ = *yc; yaw_target_ = *yc;
pitch_target_ = t.pitch.xenc; pitch_target_ = geometry_.pitch.toCounts(pan_.pitch);
LOG_INFO << "directed move: heading " << target_heading_ << " (deg)"; LOG_INFO << "pan move " << (pan_index_ + 1) << "/" << pan_headings_.size()
<< ": yaw " << pan_headings_[pan_index_] << " pitch " << pan_.pitch
<< " (deg)";
} }
sendMove(); sendMove();
moving_ = true; moving_ = true;
@ -219,24 +210,26 @@ void CaptureScheduler::tick() {
if (since_settled < settle_wait_ms_) return; if (since_settled < settle_wait_ms_) return;
if (camera_.trigger()) { if (camera_.trigger()) {
if (control_code_ == 0) if (mode_ == Mode::Auto)
LOG_INFO << "captured waypoint " << (grid_.index() + 1) << "/" << grid_.size(); LOG_INFO << "captured waypoint " << (grid_.index() + 1) << "/" << grid_.size();
else 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_) { } else if (since_settled < settle_wait_ms_ + frame_timeout_ms_) {
return; // no fresh frame yet; retry next tick return; // no fresh frame yet; retry next tick
} else { } else {
// Never park on one waypoint: a stalled camera would otherwise stop the // Never park on one waypoint: a stalled camera would otherwise stop the
// whole sweep rather than costing it a single image. // whole sweep rather than costing it a single image.
LOG_WARN << "no frame within " << frame_timeout_ms_ << " ms of settling; skipping " LOG_WARN << "no frame within " << frame_timeout_ms_ << " ms of settling; skipping "
<< (control_code_ == 0 ? "waypoint " + std::to_string(grid_.index() + 1) + << (mode_ == Mode::Auto ? "waypoint " : "pan position ")
"/" + std::to_string(grid_.size()) << (mode_ == Mode::Auto ? grid_.index() + 1 : pan_index_ + 1) << "/"
: std::string("directed capture")); << (mode_ == Mode::Auto ? grid_.size() : pan_headings_.size());
} }
moving_ = false; moving_ = false;
settled_at_ms_ = 0; 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(); 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_mosi = getInt(kv, "Pins.spi_mosi", -1);
p.spi_miso = getInt(kv, "Pins.spi_miso", -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) for (const auto& a : cfg.hardware.axes)
if (a != "yaw" && a != "pitch") if (a != "yaw" && a != "pitch")
throw std::runtime_error("Hardware.axes: unknown axis '" + a + 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() { const std::vector<HelpSection>& helpCatalog() {
// clang-format off // clang-format off
static const std::vector<HelpSection> catalog = { 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", {"Positioning",
"Aim the gimbal. Yaw is a TRUE BEARING (0 = north); pitch is elevation (0 = level).", { "Aim the gimbal. Yaw is a TRUE BEARING (0 = north); pitch is elevation (0 = level).", {
{"gimbal move <yaw>,<pitch>", {"gimbal move <yaw>,<pitch>",
@ -70,7 +90,7 @@ const std::vector<HelpSection>& helpCatalog() {
"Example: gimbal home / gimbal home y"}}, "Example: gimbal home / gimbal home y"}},
{"gimbal stop [y|p|all]", {"gimbal stop [y|p|all]",
"Stop motion immediately.", { "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."}}, "the whole 'gimbal' family is ignored while one is in progress."}},
{"gimbal speed <y|p> <vel>", {"gimbal speed <y|p> <vel>",
"Set the max slew speed (counts/s) for an axis.", {}}, "Set the max slew speed (counts/s) for an axis.", {}},
@ -137,14 +157,14 @@ const std::vector<HelpSection>& helpCatalog() {
{"camera reset", {"camera reset",
"Reboot the camera and put it back on its profile.", { "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", "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", "stopped delivering frames and will not restart. The program also does this",
"by itself when restarting the stream stops helping."}}, "by itself when restarting the stream stops helping."}},
{"gimbal pitch setup", {"gimbal pitch setup",
"Calibrate PITCH steps<->degrees against gravity, using the IMU.", { "Calibrate PITCH steps<->degrees against gravity, using the IMU.", {
"Auto-homes if needed, sweeps pitch dwelling at each step, fits, then", "Auto-homes if needed, sweeps pitch dwelling at each step, fits, then",
"parks level. Writes logs/pitch_setup_*.log and offers to save. Takes", "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:", "gravity and nothing else on the tower can measure it. Yaw is NOT touched:",
"use 'gimbal heading setup'."}}, "use 'gimbal heading setup'."}},
{"gimbal status", {"gimbal status",
@ -156,8 +176,6 @@ const std::vector<HelpSection>& helpCatalog() {
"Example: gimbal raw \"MOVE 100000,250000\" / gimbal raw \"DUMP\""}}, "Example: gimbal raw \"MOVE 100000,250000\" / gimbal raw \"DUMP\""}},
}}, }},
{"Capture", "Control image capture and encoding.", { {"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 fps <rate>", "Set capture rate in images/second.", {}},
{"set camera profile [<camera>] <name>", {"set camera profile [<camera>] <name>",
"Switch a camera's profile (rate, exposure, all imaging settings).", { "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).", { "Take one image now and publish its CamEvent (manual mode only).", {
"Goes through the same settle-and-trigger path a scan waypoint uses, so", "Goes through the same settle-and-trigger path a scan waypoint uses, so",
"the file and the CamEvent are indistinguishable from a scan frame.", "the file and the CamEvent are indistinguishable from a scan frame.",
"Refused while a scan is running."}}, "Refused in auto and pan."}},
{"H", {"H",
"Open the heading-setup wizard (see 'gimbal heading setup').", { "Open the heading-setup wizard (see 'gimbal heading setup').", {
"Manual mode only. Blocks a scan from starting until it is finished or", "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) const std::string& user, const std::string& password)
: broker_uri_(broker_uri), : broker_uri_(broker_uri),
tower_(tower), tower_(tower),
topic_target_hdg_(towerTopic(tower, kTopicTargetHdg)),
topic_control_code_(towerTopic(tower, kTopicControlCode)),
topic_status_(towerTopic(tower, kTopicStatusCode)), topic_status_(towerTopic(tower, kTopicStatusCode)),
topic_cam_event_(towerTopic(tower, kTopicCamEvent)), topic_cam_event_(towerTopic(tower, kTopicCamEvent)),
topic_command_(towerTopic(tower, kTopicCommand)), topic_command_(towerTopic(tower, kTopicCommand)),
@ -63,10 +61,27 @@ bool MqttControlChannel::connected() const { return connected_; }
void MqttControlChannel::connected(const std::string& /*cause*/) { void MqttControlChannel::connected(const std::string& /*cause*/) {
LOG_INFO << "MQTT (re)connected; subscribing"; LOG_INFO << "MQTT (re)connected; subscribing";
history_.noteConnect(); history_.noteConnect();
client_.subscribe(topic_target_hdg_, kQos);
client_.subscribe(topic_control_code_, kQos);
client_.subscribe(topic_command_, kQos); client_.subscribe(topic_command_, kQos);
connected_ = true; 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) { 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. // A flood - a stuck publisher - must not grow without bound.
if (commands_.size() < 256) commands_.push_back(std::move(rc)); if (commands_.size() < 256) commands_.push_back(std::move(rc));
else LOG_WARN << "MQTT command queue full; dropped: " << payload; 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() { 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) { void MqttControlChannel::publishStatus(int code) {
const std::string payload = std::to_string(code); const std::string payload = std::to_string(code);
{
std::lock_guard<std::mutex> lock(mutex_);
last_status_ = payload;
}
history_.noteStatus(payload); history_.noteStatus(payload);
publish(topic_status_, payload); publish(topic_status_, payload);
} }

View File

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

View File

@ -25,6 +25,7 @@ add_executable(fgc_tests
test_iniedit.cpp test_iniedit.cpp
test_camerastall.cpp test_camerastall.cpp
test_remotecommands.cpp test_remotecommands.cpp
test_operatingmode.cpp
test_tmccurrent.cpp test_tmccurrent.cpp
test_hardwarepresence.cpp test_hardwarepresence.cpp
test_hardwarechecks.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") { TEST_CASE("a retained or unreadable command is answered but never run") {
RemoteCommandRunner run; RemoteCommandRunner run;
int ran = 0; 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; }); auto r1 = run.run(retained, [&](const std::string&) { ++ran; });
CHECK(ran == 0); CHECK(ran == 0);
CHECK(r1->level == "error"); CHECK(r1->level == "error");

View File

@ -36,21 +36,6 @@ struct FakeCamera : ICameraSource {
int cameraCount() const override { return 1; } 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. // Simple linear map: 10 counts/deg, centre at count 0 pointing at 0 deg.
// Reachable yaw travel -90..90, pitch 0..60. // Reachable yaw travel -90..90, pitch 0..60.
// Fields: counts_per_deg, center_count, deg_at_center, min_deg, max_deg. // 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; long long clock = 0;
FakeMotor motor; FakeMotor motor;
FakeCamera cam; FakeCamera cam;
FakeChannel chan;
ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}}); // -> yaw counts 0, then 900 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; }); [&] { return clock; });
sch.setSettleWait(0, 0); // these predate the settle wait; timing has its own test 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; clock = 500;
sch.tick(); sch.tick();
CHECK(motor.cmds.empty()); CHECK(motor.cmds.empty());
CHECK(chan.last_status == 0);
// After the interval: MOVE to the first grid point (0,0) is issued. // After the interval: MOVE to the first grid point (0,0) is issued.
clock = 1600; clock = 1600;
@ -119,14 +100,11 @@ TEST_CASE("CaptureScheduler scans a 1-axis (yaw-only) rig with yaw-only MOVE/set
FakeMotor motor; FakeMotor motor;
motor.tel.pitch_present = false; // yaw-only gimbal: no P: segment in telemetry motor.tel.pitch_present = false; // yaw-only gimbal: no P: segment in telemetry
FakeCamera cam; FakeCamera cam;
FakeChannel chan;
ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}}); 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.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;
// The MOVE must address yaw alone, not the two-axis form a 1-axis firmware rejects. // The MOVE must address yaw alone, not the two-axis form a 1-axis firmware rejects.
clock = 1600; 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"); CHECK(motor.cmds.back() == "MOVE Y 900");
} }
TEST_CASE("CaptureScheduler ControlCode 1 drives yaw to the target heading") { // Walk one capture: the interval elapses and a MOVE goes out, then the gimbal
long long clock = 0; // settles on it and the frame is kept. Returns the MOVE.
FakeMotor motor; std::string captureOnce(CaptureScheduler& sch, FakeMotor& motor, long long& clock) {
FakeCamera cam; clock += 1100;
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;
sch.tick(); sch.tick();
REQUIRE(!motor.cmds.empty()); REQUIRE(!motor.cmds.empty());
CHECK(motor.cmds.back() == "MOVE 450,0"); const std::string move = motor.cmds.back();
CHECK(sch.controlCode() == 1); 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; long long clock = 0;
FakeMotor motor; FakeMotor motor;
FakeCamera cam; FakeCamera cam;
FakeChannel chan;
ScanGrid grid({{0.0, 0.0}}); 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); // these predate the settle wait; timing has its own test sch.setSettleWait(0, 0);
sch.setCaptureActive(true); 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; std::vector<std::string> moves;
chan.next.control_code = 1; for (int i = 0; i < 7; ++i) moves.push_back(captureOnce(sch, motor, clock));
chan.next.heading_available = true; // 10 counts/deg; pitch 20 deg -> 200 counts, whatever pitch the gimbal had.
// 200 deg is 160 deg the short way round, past the 90 deg limit: in the arc CHECK(moves[0] == "MOVE -150,200");
// this gimbal physically cannot reach. CHECK(moves[1] == "MOVE -90,200");
chan.next.target_heading = "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; clock = 1600;
sch.tick(); 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(motor.cmds.empty());
CHECK_FALSE(sch.moving()); CHECK_FALSE(sch.moving());
} }
TEST_CASE("CaptureScheduler::setGeometry re-targets with the recalibrated conversion") { TEST_CASE("CaptureScheduler::setGeometry re-targets with the recalibrated conversion") {
// A `gimbal heading fit` updates the live geometry; the scheduler must adopt it so // 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; long long clock = 0;
FakeMotor motor; FakeMotor motor;
FakeCamera cam; FakeCamera cam;
FakeChannel chan;
ScanGrid grid({{0.0, 0.0}}); 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); // these predate the settle wait; timing has its own test sch.setSettleWait(0, 0);
sch.setCaptureActive(true); 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; // Recalibrate to 20 counts/deg; the same heading must now resolve to twice
chan.next.control_code = 1; // the counts.
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.
Geometry g2; Geometry g2;
g2.yaw = {20.0, 0, 0.0, -90.0, 90.0}; g2.yaw = {20.0, 0, 0.0, -90.0, 90.0};
g2.pitch = {10.0, 0, 0.0, 0.0, 60.0}; g2.pitch = {10.0, 0, 0.0, 0.0, 60.0};
sch.setGeometry(g2); sch.setGeometry(g2);
CHECK(captureOnce(sch, motor, clock) == "MOVE 900,0"); // 45 * 20 counts/deg
clock = 2800;
sch.tick();
CHECK(motor.cmds.back() == "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; long long clock = 0;
FakeMotor motor; FakeMotor motor;
FakeCamera cam; FakeCamera cam;
FakeChannel chan;
ScanGrid grid({{0.0, 0.0}}); 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; });
// capture not active // manual: the mode it starts in
clock = 5000; clock = 5000;
sch.tick(); sch.tick();
@ -255,14 +249,11 @@ TEST_CASE("CaptureScheduler holds still for settle_wait_ms before keeping a fram
long long clock = 0; long long clock = 0;
FakeMotor motor; FakeMotor motor;
FakeCamera cam; FakeCamera cam;
FakeChannel chan;
ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}}); 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.setSettleWait(1000, 2000);
sch.setCaptureActive(true); sch.setMode(Mode::Auto);
chan.next.control_code_available = true;
chan.next.control_code = 0;
clock = 1600; clock = 1600;
sch.tick(); sch.tick();
@ -289,14 +280,11 @@ TEST_CASE("CaptureScheduler abandons a waypoint after frame_timeout_ms") {
FakeMotor motor; FakeMotor motor;
FakeCamera cam; FakeCamera cam;
cam.ok = false; // camera never has a fresh frame cam.ok = false; // camera never has a fresh frame
FakeChannel chan;
ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}}); 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.setSettleWait(1000, 2000);
sch.setCaptureActive(true); sch.setMode(Mode::Auto);
chan.next.control_code_available = true;
chan.next.control_code = 0;
clock = 1600; clock = 1600;
sch.tick(); sch.tick();
@ -327,9 +315,8 @@ TEST_CASE("requestSnapshot captures one frame once the gimbal has settled") {
long long clock = 0; long long clock = 0;
FakeMotor motor; FakeMotor motor;
FakeCamera cam; FakeCamera cam;
FakeChannel chan;
ScanGrid grid({{0.0, 0.0}}); 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); sch.setSettleWait(1000, 2000);
// Capture inactive: this is manual mode. // 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; long long clock = 0;
FakeMotor motor; FakeMotor motor;
FakeCamera cam; FakeCamera cam;
FakeChannel chan;
ScanGrid grid({{0.0, 0.0}}); 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 sch.setSettleWait(1000, 60000); // long frame budget: the point here is the settle
settleAt(motor, 0, 0); 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; long long clock = 0;
FakeMotor motor; FakeMotor motor;
FakeCamera cam; FakeCamera cam;
FakeChannel chan;
ScanGrid grid({{0.0, 0.0}}); 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); sch.setSettleWait(0, 0);
settleAt(motor, 0, 0); settleAt(motor, 0, 0);
sch.requestSnapshot(); sch.requestSnapshot();
sch.setCaptureActive(true); sch.setMode(Mode::Auto);
// Arming is dropped, not deferred: firing it whenever the scan happens to end // 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. // would attribute an image to whatever the gimbal was pointing at then.
CHECK_FALSE(sch.snapshotPending()); 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 sch.requestSnapshot(); // asked for again mid-scan
clock = 1600; clock = 1600;
sch.tick(); // the scan's own move, not a snapshot sch.tick(); // the scan's own move, not a snapshot
sch.setCaptureActive(false); sch.setMode(Mode::Manual);
clock = 3000; clock = 3000;
sch.tick(); sch.tick();
CHECK_FALSE(sch.snapshotPending()); 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; long long clock = 0;
FakeMotor motor; FakeMotor motor;
FakeCamera cam; FakeCamera cam;
FakeChannel chan;
ScanGrid grid({{0.0, 0.0}}); 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); sch.setSettleWait(1000, 2000);
settleAt(motor, 0, 0); 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; long long clock = 0;
FakeMotor motor; FakeMotor motor;
FakeCamera cam; FakeCamera cam;
FakeChannel chan;
ScanGrid grid({{0.0, 0.0}}); 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); sch.setSettleWait(1000, 2000);
settleAt(motor, 0, 0); settleAt(motor, 0, 0);