Give the tower three modes, auto, pan and manual, switched by command
This commit is contained in:
parent
4a69d65afb
commit
f4e67666d8
|
|
@ -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
|
||||||
|
|
|
||||||
15
README.md
15
README.md
|
|
@ -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` |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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` |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
102
docs/mqtt-api.md
102
docs/mqtt-api.md
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 {}; }
|
||||||
|
|
|
||||||
|
|
@ -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_)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
2
main.cpp
2
main.cpp
|
|
@ -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)")
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 +
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue