diff --git a/CMakeLists.txt b/CMakeLists.txt index 84d7478..a35aa12 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -62,6 +62,7 @@ add_library(fgc_core STATIC src/core/IniEdit.cpp src/core/CameraStall.cpp src/core/RemoteCommands.cpp + src/core/OperatingMode.cpp src/core/Paths.cpp src/core/SingleInstance.cpp src/core/Logger.cpp diff --git a/README.md b/README.md index 985eff0..a8af117 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,7 @@ press a key that acts. | Key | Does | |---|---| -| `s` / `x` | start / stop the capture sweep | +| `s` / `x` | auto mode (sweep and capture) / manual mode (stop, take it by hand) | | `h` | home the gimbal | | `r` | refresh: ask the firmware for a full status dump, re-read the sensor setup | | `g` `i` `c` `m` | open the full-screen gimbal, sensors, camera or comms view; press it again to close | @@ -295,6 +295,14 @@ echo $? # 0 = PASS, 4 = FAIL, 5 = could not start (the log says why) ## Everyday commands +**A tower is always in one of three modes.** In **auto** it sweeps its scan +grid by itself - the normal state, and where it starts at boot. In **pan** it +watches one direction: `mode pan 90 30` sweeps 75° to 105°, a picture every +6°, over and over. In **manual** it does nothing by itself and a person drives +it. Auto and pan refuse anything that would move the gimbal or change a +setting, so `mode manual` comes first; the tower publishes its mode on MQTT +(`StatusCode`: 0 auto, 1 pan, 2 manual). + The same commands work at the `:` prompt in the dashboard, in the plain console, and **over MQTT**: a web maintenance interface can send any of them on `GGS/FWT//Command` and read the answer on `CommandReply` - the lines the @@ -304,11 +312,12 @@ job. Details, payloads and safety rules: [docs/mqtt-api.md](docs/mqtt-api.md). | Command | Does | |---|---| -| `start` / `stop` | start / stop the capture sweep (the gimbal stays homed and powered) | +| `mode auto` / `mode manual` | sweep the scan grid / stop and take the gimbal by hand (it stays homed and powered) | +| `mode pan []` | watch one direction: `mode pan 90 30` covers 75°-105°, a picture every 6° | | `gimbal home` | find the endstops and centre - needed after every power-up | | `gimbal move ,` | point at a **true bearing** (0 = north) and an elevation, in degrees | | `gimbal nudge <+/-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 [ []]` | run self-tests - see [below](#is-the-tower-healthy) | | `gimbal heading setup` | tie the yaw axis to true north by sighting landmarks | | `set camera profile ` | switch the camera profile, e.g. `night` | diff --git a/config/config.example.ini b/config/config.example.ini index ca660b7..f33fac4 100644 --- a/config/config.example.ini +++ b/config/config.example.ini @@ -156,7 +156,7 @@ period_ms = 2000 [Motor] ; Degrees<->encoder-counts maps, anchored on the mechanical centre found by ; homing. The firmware speaks only in absolute encoder counts; these map them to -; the heading/elevation degrees used by MQTT (target_HDG) and CamEvent. +; the heading/elevation degrees used by mode pan, gimbal move and CamEvent. ; ; Homing finds both endstops, moves to the middle of that range and DEFINES the ; midpoint as _center_count. Anchoring on the midpoint rather than one diff --git a/config/hardware.example.ini b/config/hardware.example.ini index 2fac607..51b59b8 100644 --- a/config/hardware.example.ini +++ b/config/hardware.example.ini @@ -49,6 +49,9 @@ imu = none env = sht41 ; The [Camera.] blocks expected to be present and found. cameras = rgb +; How much bearing one picture covers, in degrees. Pan mode ('mode pan') cuts +; its span into as many pictures as this takes. 6 until the lens is measured. +pan_step_deg = 6 ; ; What happens when this does not match the rig: ; declared but absent -> warned about by name, and with [Features] diff --git a/docs/architecture.md b/docs/architecture.md index 31daf78..0aa82a6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -69,13 +69,16 @@ corrupted. 2. **Telemetry** — the real motor controller streams firmware `ST Y:...[ P:...]` lines; `parseTelemetryLine` turns each into a per-axis `MotorTelemetry` snapshot (state + encoder counts + flags). The mock synthesizes one. Encoder counts are mapped to/from degrees by `Geometry` (`[Motor]` calibration). -3. **Control input** — `IControlChannel::poll()` returns the latest `ControlCommand` (control code + target - heading), clearing its "available" flags so each update is acted on once. +3. **Mode** — auto, pan or manual ([OperatingMode.h](../include/fgc/OperatingMode.h)), changed only by + the `mode` command (console, dashboard keys, or MQTT `Command`) through `Application::setMode`, which + publishes it on StatusCode. The same header's `allowedIn()` gates every command by mode. 4. **Capture cycle** (`CaptureScheduler::tick`, per 10 ms) — a move → settle → wait → keep machine: - - ControlCode 0: `MOVE ,` to the next `ScanGrid` waypoint (front to back, then wrap). - - ControlCode 1: `MOVE` yaw to `target_HDG` (pitch held), converted to counts via `Geometry`. + - auto: `MOVE ,` to the next `ScanGrid` waypoint (front to back, then wrap). + - pan: `MOVE` to the next of `panHeadings()` (left to right, then back to the left edge) at the + pan's pitch, converted to counts via `Geometry`. + - manual: nothing, bar a requested snapshot. - Once **both axes report standstill at the target**, wait `settle_wait_ms`, then keep the newest - frame and advance the grid. + frame and advance the grid or pan position. 5. **Acquisition** — the camera free-runs at the active mode's `AcquisitionFrameRate` with its own auto-exposure tracking the light; `trigger()` hands over the freshest complete frame. See *Image capture* below. @@ -88,11 +91,11 @@ corrupted. ## Capture state machine ``` - interval elapsed AND capture active AND not already moving + interval elapsed AND mode auto or pan AND not already moving │ ▼ - ┌──────────────────────┐ ControlCode 0 → next ScanGrid (yaw,pitch) - │ MOVE , │ ControlCode 1 → target_HDG (pitch held) + ┌──────────────────────┐ auto → next ScanGrid (yaw,pitch) + │ MOVE , │ pan → next pan heading, at the pan's pitch └──────────┬───────────┘ deg→counts via Geometry; set moving, reset timer │ (both axes standstill AND |xenc − target| ≤ tol) ▼ @@ -100,7 +103,7 @@ corrupted. │ wait settle_wait_ms │ what makes the kept frame one exposed at rest └──────────┬───────────┘ ▼ - ┌──────────────────────┐ on success: clear moving, advance grid (ControlCode 0) + ┌──────────────────────┐ on success: clear moving, advance the grid / pan position │ camera.trigger() │ no fresh frame: retry next tick └──────────────────────┘ still none after frame_timeout_ms: warn, skip, advance ``` diff --git a/docs/configuration.md b/docs/configuration.md index a46743f..22c709e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -93,7 +93,7 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config ### `[Motor]` calibration & `[Scan]` grid The firmware reports only **encoder counts**; `[Motor]` maps them to the bearing/elevation degrees -used by MQTT (`target_HDG`) and `CamEvent`. **Yaw degrees are true bearings: 0 is north.** Pitch +used by `mode pan`, `gimbal move` and `CamEvent`. **Yaw degrees are true bearings: 0 is north.** Pitch degrees are elevation above level, so 0 is horizontal. #### The counts are anchored by homing @@ -158,7 +158,7 @@ gimbal heading offset -2.5 # shift the whole map by a known amount, and sav `gimbal pitch setup` (**requires the IMU**) fits `pitch_counts_per_deg` and `pitch_angle_at_center` by sweeping pitch and correlating encoder counts with the MTi's gravity-referenced pitch. It auto-homes if needed, dwells at each step, fits, then parks level. Writes `logs/pitch_setup_*.log`, reports R², -and offers the same save prompt. `stop` or Esc cancels. Timings are the `CalibParams` defaults in +and offers the same save prompt. `cancel` or Esc cancels. Timings are the `CalibParams` defaults in [PitchSetup.h](../include/fgc/PitchSetup.h). This is the one axis that genuinely needs the IMU — level is defined by gravity and nothing else on @@ -411,6 +411,14 @@ generic `IEnvSensor` interface ([include/fgc/IEnvSensor.h](../include/fgc/IEnvSensor.h)), so swapping in a different ambient sensor later means adding a new backend, not touching `Application`, the UI, or MQTT. +### How much one picture covers: `[Hardware] pan_step_deg` + +`mode pan ` cuts its span into as many pictures as it takes at +`pan_step_deg` degrees of bearing each (default **6**, until the lens is +measured), both edges included - so `mode pan 90 30` takes six pictures, 75° +to 105°. It is a property of the camera and lens, so it lives in +`hardware.ini`. Must be above 0. See [mqtt-api.md](mqtt-api.md#modes-auto-pan-manual). + ### One key, not two: declaring a sensor is what enables it `[Hardware] imu` and `[Hardware] env` name what is fitted. `[Features] enable_imu` @@ -545,8 +553,10 @@ Handled in `Application::Impl::handleCommand`. | Type this | Meaning | |-----------|---------| -| `start` | Start camera acquisition + capture | -| `stop` | Stop acquisition | +| `mode` | Say which mode the tower is in: auto, pan or manual | +| `mode auto` | Sweep the scan grid (camera acquisition + capture) | +| `mode pan []` | Watch one direction: sweep `heading ± span/2` in `pan_step_deg` steps, pitch default 0 | +| `mode manual` | Stop capturing; every command accepted. In auto and pan, only reading commands, `debug`/`trace`, `mode`, `cancel` and `exit` are | | `debug` | Toggle debug logging | | `trace [on\|off]` | Toggle a wire-trace category (`serial`/`mqtt`/`camera`/`control`) | | `trace all` / `trace off` | Enable every category / silence all | @@ -644,7 +654,7 @@ the original, so an interrupted save cannot truncate your config. `gimbal heading offset` saves immediately without asking — it is a one-line correction, not a fit. **The key bar is contextual**: it advertises the keys that are useful in the current view, not every -binding. `s` start and `x` stop lead everywhere — a scan can be started and stopped from any view — +binding. `s` auto and `x` manual lead everywhere — the mode can be changed from any view — and `:` command, `?` help and `q` quit close every set. The overview adds `h` home, `r` refresh, `g i c m` views, `p` pause and the arrow keys (nudge the gimbal, yaw ±5 % / pitch ±10 %); an expanded view instead offers its own (`d` dump in the gimbal view, `r` refresh in sensors, the @@ -681,7 +691,7 @@ Trace lines carry a category tag and a `TX`/`RX` direction so one subsystem is e ``` [SERIAL] TX MOVE -90,0 (command to firmware: yaw,pitch counts) [SERIAL] RX ST Y:A,982,969,80084000,0,8,8,Se P:A,... (status from firmware) -[MQTT] PUB GGS/FWT/Tower/StatusCode 0 / [MQTT] RX GGS/FWT/Tower/target_HDG 180 +[MQTT] PUB GGS/FWT/Tower/StatusCode 0 / [MQTT] RX GGS/FWT/Tower/Command mode pan 180 30 [CAMERA] TX trigger cam0 / [CAMERA] RX frame cam0 1936x1216 7064576B [CONTROL] sweep -> grid yaw=-90 pitch=0 (scheduler decisions + inbound console commands) ``` @@ -840,7 +850,8 @@ quietly overwrite it. error in the angle is a 1 % error in every bearing. `gimbal heading setup` supersedes it. Capture is a **move → settle → trigger** cycle: the scheduler issues a `MOVE`, waits until both -axes report standstill at the target, then triggers the cameras. **ControlCode 0** walks the scan -grid (front to back, repeating); **ControlCode 1** drives yaw to `target_HDG` (pitch held). Telemetry arrives as +axes report standstill at the target, then triggers the cameras. In **auto** mode it walks the scan +grid (front to back, repeating); in **pan** mode it walks the pan's headings left to right at the pan's +pitch, then starts again from the left edge (see [mqtt-api.md](mqtt-api.md#modes-auto-pan-manual)). Telemetry arrives as firmware `ST` lines (per-axis state + encoder counts), parsed by [TelemetryParser](../src/core/TelemetryParser.cpp). diff --git a/docs/deployment.md b/docs/deployment.md index f751743..f0a374f 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -291,7 +291,7 @@ list below and says which. | Camera opened, but no images and `no frame yet (stream warming up)` for minutes | the stream is stuck; the program restarts it and then resets the camera by itself (known-issues #15), or type `camera reset` | | `No camera found` / Vimba startup error | SDK installed, GenTL path set (`/etc/profile.d/*Vimba*GenTL*.sh` or `VIMBA_CTI_PATH`), `[Camera.rgb] id` in `hardware.ini` | | `MQTT connect failed` (degraded mode) | broker reachable, `credentials.env` readable by `ggs`, firewall | -| Runs but no images | capture started (`--start`, or the `start` command) and the gimbal homed; `output_dir` writable | +| Runs but no images | in auto mode (`--start`, or `mode auto`) and the gimbal homed; `output_dir` writable | | `another instance is already running` (exit 2) | `scripts/fgc status`; the service and a hand-started run both up (section 3) | | `fgc status` says not running, but the program is | the service started it before anyone logged in, without lingering (section 4) | | Program dies when ssh disconnects | started with `run.sh` / `deploy.sh --foreground` instead of `scripts/fgc start` | diff --git a/docs/modules-reference.md b/docs/modules-reference.md index e8999e6..bb5fc75 100644 --- a/docs/modules-reference.md +++ b/docs/modules-reference.md @@ -97,9 +97,10 @@ on this rig and is what the TUI's STALL badge shows. `stall` (flag `s`) is Stall because production runs StealthChop; it is still parsed in case that changes. The latch is cleared by homing or `gimbal reset`, so a lit badge means "position untrustworthy since the last home". -### `ControlCommand` ([IControlChannel.h](../include/fgc/IControlChannel.h)) -`control_code` (0 = scan-grid sweep, 1 = directed to `target_HDG`) + `target_heading`, each with an -`*_available` flag. +### `Mode`, `PanSpec` ([OperatingMode.h](../include/fgc/OperatingMode.h)) +`Mode` is auto (0), pan (1) or manual (2) - the values StatusCode carries. `PanSpec` is a pan's +`heading`, `span` and `pitch` in degrees; `panHeadings()` turns it into the bearings visited, and +`allowedIn()` says which commands a mode accepts. ### `CamEvent` ([IControlChannel.h](../include/fgc/IControlChannel.h)) `tower`, `camera` (RGB/ACR/NIR), `heading_decideg` (yaw×10), `pitch_decideg` (pitch×10) and diff --git a/docs/mqtt-api.md b/docs/mqtt-api.md index 9c1f52a..db77a15 100644 --- a/docs/mqtt-api.md +++ b/docs/mqtt-api.md @@ -1,9 +1,10 @@ # MQTT API -The program is both an MQTT **subscriber** (remote control) and **publisher** (status + capture events). The +The program is both an MQTT **subscriber** (commands) and **publisher** (its mode, capture events, and +command replies). The MQTT channel is the `MqttControlChannel` implementation of `IControlChannel` ([src/mqtt/MqttControlChannel.cpp](../src/mqtt/MqttControlChannel.cpp), Eclipse Paho C++). It is used only when -MQTT is enabled; otherwise a `NullControlChannel` runs (publishes dropped, auto-sweep mode). All topics are +MQTT is enabled; otherwise a `NullControlChannel` runs (publishes dropped and recorded). All topics are namespaced by tower name: ``` @@ -26,42 +27,62 @@ are **retained**, except command replies. - **The program no longer exits if MQTT is unavailable** — it logs a warning and continues in degraded mode. Use `--no-mqtt` to disable MQTT entirely (null channel). -## Subscribed topics (inbound — remote control) +## Modes: auto, pan, manual -Subscribed on (re)connect; handled in `MqttControlChannel::message_arrived()` -([src/mqtt/MqttControlChannel.cpp](../src/mqtt/MqttControlChannel.cpp)). +The tower is always in one of three modes. It changes mode **only by command** - +`mode ...`, sent on the `Command` topic below, typed at the console, or the +dashboard's `s` / `x` keys - and reports it on `StatusCode`. -| Topic | Payload | Parsed as | Effect | -|-------|---------|-----------|--------| -| `GGS/FWT//target_HDG` | integer bearing as string, e.g. `"137"` | validated with `stoi`, stored as **string** | Sets the yaw target used in ControlCode 1 (a **true bearing**, 0 = north, converted to encoder counts via `[Motor]`) | -| `GGS/FWT//ControlCode` | integer as string, `"0"` or `"1"` | `stoi` → int | Selects the capture mode (see below) | +| Mode | StatusCode | What the tower does | Command | +|---|---|---|---| +| auto | `0` | Sweeps the scan grid front to back, a picture at every waypoint, then starts over. The normal state: the boot service starts here (`--start`). | `mode auto` | +| pan | `1` | Watches one direction: walks left to right across a span centred on a bearing, a picture at each position, then back to the left edge. | `mode pan []` | +| manual | `2` | Nothing by itself; a person drives it. The state without `--start`. | `mode manual` | -Invalid (non-integer) payloads are caught and logged; the previous value is kept. +**Pan positions.** `mode pan 90 30` covers 75°..105°. One picture covers +`pan_step_deg` of bearing (hardware.ini `[Hardware]`, 6 until the lens is +measured), so the span is cut into `ceil(span / step)` equal intervals, both +edges included: 75, 81, 87, 93, 99, 105 - six pictures. A span that does not +divide evenly gets one more picture, never a gap (32° → seven, 5.33° apart). A +span under one picture is the one heading, photographed once per loop. Pitch is +optional and defaults to 0 (level). Pictures are taken at the same pace as auto +(`image_interval`), and each publishes a CamEvent exactly as a sweep picture +does. -The main loop consumes these via `get_sub_data()`, which returns a snapshot and **clears the "available" -flags** so each update is acted on once (`MqttControlChannel::poll()`). +**Headings are true bearings and can be out of reach.** Yaw travel is under a +full turn, so there is always an arc the gimbal cannot reach. Pan positions in +it are skipped - not clamped to the nearest limit, which would image a +direction nobody asked for and label it with the one they did. A pan with no +reachable position, or a pitch outside the homed pitch travel, is refused. The +reachable arc is logged after every home. -### ControlCode semantics +**What each mode accepts.** Manual accepts every command. Auto and pan accept +only what cannot move the gimbal or change a setting: -| ControlCode | Behaviour ([CaptureScheduler](../src/core/CaptureScheduler.cpp)) | -|-------------|---------------------------------------------------| -| `0` | **Automatic sweep.** Walk the scan grid front to back, then start over: `MOVE` to the next `(yaw,pitch)` waypoint, wait for standstill, trigger cameras. | -| `1` | **Directed.** `MOVE` yaw to `target_HDG` (pitch held), wait for standstill, trigger. | +- reading: `help`, `gimbal status`, `gimbal dump`, `imu status`, `imu config`, `refresh` +- logging: `debug`, `trace` +- `mode ...`, `cancel`, `exit` -> **`target_HDG` is a true bearing and can be refused.** Yaw travel is under a full turn, so there is -> always an unreachable arc. A bearing falling in it is logged once and the gimbal **holds position**; -> it is not clamped to the nearest limit, which would image a direction up to 180° away and publish it -> under the bearing that was asked for. The reachable arc is logged after every home. +Anything else is refused - `ignored '': the tower is in pan mode +('mode manual' first)` - whether it came over MQTT or from the console. So a +web page switches to manual before driving the gimbal, and back to auto (or +pan) when done. -When a ControlCode message arrives, the program echoes the current code back on the StatusCode topic. +**A refused mode change changes nothing**, StatusCode included. `mode auto` +and `mode pan` are refused while the gimbal is not homed, during heading +setup, or while a test or calibration runs. ## Published topics (outbound) | Topic | When | Payload | QoS / Retain | |-------|------|---------|--------------| -| `GGS/FWT//StatusCode` | At startup (`"0"`); whenever a ControlCode message is received (echoes the code) | integer as string | 1 / retained | +| `GGS/FWT//StatusCode` | At startup, on every mode change, and again on every reconnect | the mode: `"0"` auto, `"1"` pan, `"2"` manual | 1 / retained | | `GGS/FWT//CamEvent` | After each image is saved | JSON object (below) | 1 / retained | +StatusCode says what the tower **is doing**, not what it was last asked to do: +it is published by the mode change itself, whatever caused it. Being retained, +a page that subscribes late still learns the mode at once. + ### CamEvent payload Built by `MqttControlChannel::publishCamEvent` from a `CamEvent`: @@ -173,36 +194,42 @@ The tower subscribes on **its own broker** (RabbitMQ with the MQTT plugin, in `~/zkms-client` on the tower). Today that broker has one path to the ground station, and it runs outwards only: `GGS.FWT.*.CamEvent` is bound into a `CamEvents` queue that a shovel (`rabbitmq/shovel-declaration.sh`) carries to -10.11.12.13. **Nothing carries messages from the ground station to a tower** - -which is also true of `ControlCode` and `target_HDG`. +10.11.12.13. **Nothing carries messages from the ground station to a tower.** So a web interface either: - connects to each tower's broker directly (over the VPN) - `Command` and `CommandReply` both live there, and nothing else is needed; or - goes through the ground station, which needs, in zkms-client: a `CommandReply` queue, binding and shovel out (mirroring `CamEvents`), and an - inbound shovel from the ground station to the tower for `Command` (and - `ControlCode` / `target_HDG`). + inbound shovel from the ground station to the tower for `Command` (and a + binding and shovel out for `StatusCode`, so the ground station sees the mode). -### Example +### Example: watch a fire, then look around by hand ```text # to GGS/FWT/dev/Command -{"id":"maint-17","cmd":"gimbal status"} +{"id":"w1","cmd":"mode pan 90 30"} +# from GGS/FWT/dev/CommandReply, and StatusCode becomes "1" +{"id":"w1","cmd":"mode pan 90 30","level":"info", + "lines":["16:40:02.114 [INFO ] mode pan: heading 90, span 30, pitch 0 deg - 6 positions (75..105 deg)"], + "truncated":false} -# from GGS/FWT/dev/CommandReply -{"id":"maint-17","cmd":"gimbal status","level":"info", - "lines":["16:40:02.114 [INFO ] remote ..."],"truncated":false} +{"id":"w2","cmd":"gimbal nudge yaw 5"} +# -> level "warn": ignored 'gimbal nudge yaw 5': the tower is in pan mode ('mode manual' first) + +{"id":"w3","cmd":"mode manual"} # StatusCode "2"; now anything goes +{"id":"w4","cmd":"gimbal move 95,5"} +{"id":"w5","cmd":"snapshot"} +{"id":"w6","cmd":"mode auto"} # back to the sweep; StatusCode "0" ``` ## Topic summary ``` -subscribe: GGS/FWT//target_HDG (int heading) - GGS/FWT//ControlCode (0 = auto sweep, 1 = directed) - GGS/FWT//Command (any console command: JSON {id, cmd} or a bare line) +subscribe: GGS/FWT//Command (any console command: JSON {id, cmd} or a bare line; + modes change here: mode auto|pan|manual) -publish: GGS/FWT//StatusCode (echoed control code) +publish: GGS/FWT//StatusCode (the mode: 0 auto, 1 pan, 2 manual) GGS/FWT//CamEvent (JSON: fwt, cam, hdg×10, pit×10, time-ms) GGS/FWT//CommandReply (JSON: id, cmd, level, lines, truncated - not retained) ``` @@ -213,8 +240,7 @@ Point `zkms_server_ip` at a local broker and exercise the topics with Mosquitto: ```bash mosquitto_sub -t 'GGS/FWT/ExampleTower/#' -v # watch everything for the tower -mosquitto_pub -t 'GGS/FWT/ExampleTower/ControlCode' -m '0' -mosquitto_pub -t 'GGS/FWT/ExampleTower/target_HDG' -m '180' +mosquitto_pub -q 1 -t 'GGS/FWT/ExampleTower/Command' -m 'mode pan 180 30' mosquitto_pub -q 1 -t 'GGS/FWT/ExampleTower/Command' -m '{"id":"t1","cmd":"gimbal status"}' mosquitto_pub -q 1 -t 'GGS/FWT/ExampleTower/Command' -m 'help' # a bare line works too ``` diff --git a/docs/test-command.md b/docs/test-command.md index 4810372..0762f6a 100644 --- a/docs/test-command.md +++ b/docs/test-command.md @@ -34,7 +34,7 @@ token that names a configured profile selects the profile at any position. | `test strict` | everything, `strict` profile | While a test runs, interfering commands (moves, homing, a second test, capture, -`refresh`) are ignored; **Esc** (or `stop`) cancels. Pure-UI keys (`p`, `i`, `c`, +`refresh`) are ignored; **Esc** (or `cancel`, or `x`) cancels. Pure-UI keys (`p`, `i`, `c`, `g`, `?`) keep working. ## Subsystems and leaves diff --git a/docs/tower-installation.md b/docs/tower-installation.md index ce39a29..4d8d730 100644 --- a/docs/tower-installation.md +++ b/docs/tower-installation.md @@ -806,7 +806,7 @@ Add the camera, still no broker: [INFO ] camera profile 'standard' applied from .../standard.xml (17206 settings) ``` -Then `start`, and confirm `.jxl` files appear under the configured output dir. +Then `mode auto`, and confirm `.jxl` files appear under the configured output dir. > **A camera-free phase needs no profile XMLs** - but this only became true on > 2026-09-16. Before that, `[Features] enable_camera` was parsed and never used, diff --git a/include/fgc/CaptureScheduler.h b/include/fgc/CaptureScheduler.h index 1e14273..3468fdd 100644 --- a/include/fgc/CaptureScheduler.h +++ b/include/fgc/CaptureScheduler.h @@ -2,12 +2,13 @@ #include "fgc/Geometry.h" #include "fgc/ICameraSource.h" -#include "fgc/IControlChannel.h" #include "fgc/IMotorController.h" +#include "fgc/OperatingMode.h" #include "fgc/ScanGrid.h" #include #include +#include namespace fgc { @@ -15,29 +16,39 @@ namespace fgc { // unit-tested with mocks. // // Each tick(): -// 1. polls the control channel (updates control code / target heading, -// echoes the code back as status), -// 2. reads motor telemetry (per-axis encoder counts + state), -// 3. runs the capture cycle as a move -> settle -> wait -> keep machine: when +// 1. reads motor telemetry (per-axis encoder counts + state), +// 2. runs the capture cycle as a move -> settle -> wait -> keep machine: when // the interval elapses it issues an absolute `MOVE ,` to the next // target, waits until both axes report standstill at that target, waits a // further settle_wait_ms so the free-running camera has exposed a frame with // the gimbal at rest, then keeps the newest frame. If none is ready it // retries each tick, and gives up on the waypoint after frame_timeout_ms. // -// ControlCode 0 = automatic sweep through the ScanGrid (front to back, repeating); -// ControlCode 1 = drive yaw to the MQTT-supplied target heading (pitch held). +// What it captures is the mode (OperatingMode.h): auto sweeps the ScanGrid front +// to back, repeating; pan walks panHeadings() left to right at the pan's pitch, +// then jumps back to the left edge; manual captures nothing (bar a snapshot). // Degrees are converted to encoder counts via Geometry. class CaptureScheduler { public: // now_ms: monotonic millisecond clock; defaults to steady_clock. Injectable // for deterministic tests. - CaptureScheduler(IMotorController& motor, ICameraSource& camera, IControlChannel& channel, - double image_rate, Geometry geometry, ScanGrid& grid, - std::function now_ms = {}); + CaptureScheduler(IMotorController& motor, ICameraSource& camera, double image_rate, + Geometry geometry, ScanGrid& grid, std::function now_ms = {}); - void setCaptureActive(bool active); - bool captureActive() const { return capture_active_; } + // Switch mode. The caller (Application::setMode) has already checked that + // the gimbal can do it; this restarts the move/settle cycle from the first + // waypoint or pan position. + void setMode(Mode mode, const PanSpec& pan = {}); + Mode mode() const { return mode_; } + PanSpec pan() const { return pan_; } + bool captureActive() const { return mode_ != Mode::Manual; } + + // hardware.ini [Hardware] pan_step_deg: how much bearing one picture covers. + void setPanStep(double deg) { pan_step_deg_ = deg; } + double panStep() const { return pan_step_deg_; } + // The current pan's bearings, left to right, and the one being visited. + const std::vector& panHeadingsNow() const { return pan_headings_; } + size_t panIndex() const { return pan_index_; } // Take one image now, outside any scan (the TUI's spacebar / `snapshot`). // Serviced on the next tick once the gimbal is standing still and the settle @@ -64,8 +75,6 @@ public: void tick(); // Inspection (mainly for tests). - int controlCode() const { return control_code_; } - std::string targetHeading() const { return target_heading_; } long yawTargetCounts() const { return yaw_target_; } long pitchTargetCounts() const { return pitch_target_; } bool moving() const { return moving_; } @@ -79,7 +88,6 @@ private: IMotorController& motor_; ICameraSource& camera_; - IControlChannel& channel_; Geometry geometry_; ScanGrid& grid_; @@ -87,9 +95,11 @@ private: double image_rate_; long long timer_start_ = 0; - bool capture_active_ = false; - int control_code_ = 0; - std::string target_heading_ = "0"; + Mode mode_ = Mode::Manual; + PanSpec pan_; + double pan_step_deg_ = 6.0; + std::vector pan_headings_; + size_t pan_index_ = 0; bool moving_ = false; // a MOVE has been issued, awaiting settle long long settled_at_ms_ = 0; // when standstill was first seen (0 = not yet) @@ -98,8 +108,8 @@ private: bool pitch_present_ = false; // a P: segment was seen in telemetry (2-axis rig) // One warning per run for each unreachable-heading case: a grid with a few // out-of-arc waypoints would otherwise log on every pass, forever. - bool unreachable_warned_ = false; - bool directed_unreachable_warned_ = false; + bool unreachable_warned_ = false; + bool pan_unreachable_warned_ = false; bool snapshot_pending_ = false; // a manual snapshot is armed long long snapshot_settled_ms_ = 0; // when standstill was first seen (0 = not yet) diff --git a/include/fgc/ChannelTopics.h b/include/fgc/ChannelTopics.h index b3ee543..01891c9 100644 --- a/include/fgc/ChannelTopics.h +++ b/include/fgc/ChannelTopics.h @@ -14,8 +14,7 @@ namespace fgc { // Scheme: GGS/FWT// — see docs/mqtt-api.md. // Leaf names, so the two channels and the UI agree on spelling. -inline constexpr const char* kTopicTargetHdg = "target_HDG"; -inline constexpr const char* kTopicControlCode = "ControlCode"; +// The tower's mode, out (0 auto, 1 pan, 2 manual - see OperatingMode.h). inline constexpr const char* kTopicStatusCode = "StatusCode"; inline constexpr const char* kTopicCamEvent = "CamEvent"; // Any console command, in; its answer, out. See docs/mqtt-api.md. diff --git a/include/fgc/Config.h b/include/fgc/Config.h index 0e6d60c..74791e2 100644 --- a/include/fgc/Config.h +++ b/include/fgc/Config.h @@ -41,6 +41,9 @@ struct HardwareConfig { std::string imu; // sensor model, or "none" std::string env; // sensor model, or "none" std::vector cameras; // the [Camera.] blocks expected to exist + // How much bearing one picture covers, in degrees. Pan mode steps by it: + // a span is cut into as many pictures as it takes (OperatingMode.h). + double pan_step_deg = 6.0; // Was a hardware.ini actually read? False on every tower that has not been // migrated yet, and the switch that keeps this change invisible on rollout. diff --git a/include/fgc/IControlChannel.h b/include/fgc/IControlChannel.h index 7310600..53734ce 100644 --- a/include/fgc/IControlChannel.h +++ b/include/fgc/IControlChannel.h @@ -5,15 +5,6 @@ namespace fgc { -// Remote control input (was struct mqtt_sub_data). The *_available flags mark -// whether a fresh value arrived since the last poll(). -struct ControlCommand { - bool control_code_available = false; - int control_code = 0; // 0 = automatic sweep, 1 = directed to target heading - bool heading_available = false; - std::string target_heading; // kept as string; the yaw target in degrees -}; - // Notification published after an image is captured and saved. struct CamEvent { std::string tower; // tower / FWT name @@ -70,7 +61,7 @@ struct ChannelStats { // Abstraction over the remote control/telemetry channel. Implemented by // MqttControlChannel (Eclipse Paho) and NullControlChannel (no broker; used -// for development - publishes are dropped and poll() yields a default). +// for development - publishes are dropped and recorded). class IControlChannel { public: virtual ~IControlChannel() = default; @@ -80,13 +71,11 @@ public: virtual void disconnect() = 0; virtual bool connected() const = 0; + // The tower's mode (OperatingMode.h: 0 auto, 1 pan, 2 manual), on + // StatusCode - on every change, and again on every reconnect. virtual void publishStatus(int code) = 0; virtual void publishCamEvent(const CamEvent& event) = 0; - // Latest control input; clears the *_available flags so each update is - // acted on once. - virtual ControlCommand poll() = 0; - // Commands received since the last call, oldest first; clears them. And the // answer to one. Defaults: none / dropped, for a channel that takes no commands. virtual std::vector takeCommands() { return {}; } diff --git a/include/fgc/MqttControlChannel.h b/include/fgc/MqttControlChannel.h index 9eab51b..811cb78 100644 --- a/include/fgc/MqttControlChannel.h +++ b/include/fgc/MqttControlChannel.h @@ -14,8 +14,7 @@ namespace fgc { // Real control channel over MQTT (Eclipse Paho C++). Subscribes to the tower's -// target_HDG / ControlCode / Command topics and publishes StatusCode / CamEvent / -// CommandReply. Topic scheme: GGS/FWT//... (see docs/mqtt-api.md). +// Command topic and publishes StatusCode / CamEvent / CommandReply. Topic scheme: GGS/FWT//... (see docs/mqtt-api.md). // Publishes are QoS 1; retained, except command replies. // // Unlike the old MQTTClient it does NOT spin a busy-wait thread; Paho's async @@ -36,7 +35,6 @@ public: void publishStatus(int code) override; void publishCamEvent(const CamEvent& event) override; - ControlCommand poll() override; std::vector takeCommands() override; void publishCommandReply(const CommandReply& reply) override; @@ -57,8 +55,6 @@ private: ChannelHistory history_; std::string broker_uri_; std::string tower_; - std::string topic_target_hdg_; - std::string topic_control_code_; std::string topic_status_; std::string topic_cam_event_; std::string topic_command_; @@ -69,7 +65,7 @@ private: std::atomic connected_{false}; std::mutex mutex_; - ControlCommand sub_data_; + std::string last_status_; // republished on reconnect (under mutex_) std::vector commands_; // received, not yet taken (under mutex_) }; diff --git a/include/fgc/OperatingMode.h b/include/fgc/OperatingMode.h new file mode 100644 index 0000000..46ee7b6 --- /dev/null +++ b/include/fgc/OperatingMode.h @@ -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 +#include + +namespace fgc { + +enum class Mode { Auto = 0, Pan = 1, Manual = 2 }; // values = StatusCode + +const char* modeName(Mode m); // "auto" / "pan" / "manual" + +// `mode pan []`, 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 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& 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 diff --git a/include/fgc/mock/NullControlChannel.h b/include/fgc/mock/NullControlChannel.h index 831d034..f4ef6ee 100644 --- a/include/fgc/mock/NullControlChannel.h +++ b/include/fgc/mock/NullControlChannel.h @@ -12,8 +12,7 @@ namespace fgc { // No-op control channel for running without an MQTT broker. Publishes go -// nowhere and poll() never supplies a control command, so the scheduler stays -// in automatic-sweep mode (ControlCode 0). +// nowhere, and no commands arrive unless a test inject()s one. // // It still records what it was asked to send, using the same topic and payload // builders as the real channel, so the connectivity view works under --no-mqtt @@ -39,7 +38,6 @@ public: << " (null channel)"; drop(kTopicCamEvent, camEventJson(e)); } - ControlCommand poll() override { return {}; } // Commands: none arrive by themselves; inject() stands in for the broker, // so --no-mqtt runs and tests can drive the remote-command path. diff --git a/include/fgc/ui/UiSnapshot.h b/include/fgc/ui/UiSnapshot.h index fdc7a90..00a92f0 100644 --- a/include/fgc/ui/UiSnapshot.h +++ b/include/fgc/ui/UiSnapshot.h @@ -151,7 +151,7 @@ struct CaptureView { CameraConfigView config; std::vector devices; long long images_saved = 0; - // Defined scan grid (ControlCode 0 auto-sweep waypoints) with the live cursor + // Defined scan grid (the auto-mode waypoints) with the live cursor // flagged, surfaced in the expanded camera view. std::vector scan_grid; bool scan_from_file = false; // grid_file CSV vs generated @@ -171,9 +171,8 @@ struct ConnView { bool mqtt_connected = false; std::string broker; std::string tower; - int control_code = 0; // 0 = auto-sweep, 1 = directed - std::string target_heading; - int last_status_code = 0; + std::string mode = "manual"; // auto / pan / manual + int status_code = 2; // what StatusCode carries for it // Recent traffic + counters for the expanded 'm' view. std::vector recent; @@ -185,6 +184,7 @@ struct HeaderView { std::string build; long long uptime_ms = 0; bool live = true; // LIVE vs MOCK + std::string mode = "manual"; // auto / pan / manual (OperatingMode.h) }; struct LogLine { diff --git a/main.cpp b/main.cpp index a6c73af..8cc03d5 100644 --- a/main.cpp +++ b/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, " "or one beside config.ini)") ("init,i", po::bool_switch(), "home the gimbal on startup (required: nothing is homed across a power cycle)") - ("start,s", po::bool_switch(), "start capture automatically") + ("start,s", po::bool_switch(), "start in auto mode (sweep and capture); without it, manual") ("demo,d", po::bool_switch(), "demo mode (copy placeholder image instead of encoding)") ("no-mqtt", po::bool_switch(), "disable MQTT (use a null control channel)") ("mock-camera", po::bool_switch(), "use a simulated camera (no hardware)") diff --git a/src/core/Application.cpp b/src/core/Application.cpp index a002941..faa2551 100644 --- a/src/core/Application.cpp +++ b/src/core/Application.cpp @@ -26,6 +26,7 @@ #include "fgc/ScanGrid.h" #include "fgc/SerialMotorController.h" #include "fgc/TestReport.h" +#include "fgc/OperatingMode.h" #include "fgc/RemoteCommands.h" #include "fgc/TestRunner.h" #include "fgc/mock/MockCameraSource.h" @@ -287,7 +288,8 @@ struct Application::Impl { std::deque remote_queue_; // taken from the channel, not yet run // --test: what run() returns, and where the one-shot test has got to. int exit_code_ = 0; - bool resume_capture_after_test_ = false; // was capturing when a test began + Mode resume_mode_after_test_ = Mode::Manual; // the mode a test paused + PanSpec resume_pan_after_test_; bool oneshot_started_ = false; std::chrono::steady_clock::time_point oneshot_since_{}; std::chrono::steady_clock::time_point oneshot_link_ok_{}; @@ -685,6 +687,7 @@ struct Application::Impl { s.header.uptime_ms = std::chrono::duration_cast( std::chrono::steady_clock::now() - start_time).count(); s.header.live = !mock_serial; + s.header.mode = scheduler ? modeName(scheduler->mode()) : "manual"; // --- Gimbal --- MotorTelemetry t = motor->telemetry(); @@ -917,9 +920,8 @@ struct Application::Impl { s.conn.mqtt_connected = channel && channel->connected(); s.conn.broker = cfg.network.brokerUri(); s.conn.tower = cfg.general.tower_name; - s.conn.control_code = scheduler ? scheduler->controlCode() : 0; - s.conn.target_heading = scheduler ? scheduler->targetHeading() : "0"; - s.conn.last_status_code = s.conn.control_code; // echoed back as status + s.conn.mode = s.header.mode; + s.conn.status_code = static_cast(scheduler ? scheduler->mode() : Mode::Manual); if (channel) { s.conn.recent = channel->recentMessages(); s.conn.stats = channel->channelStats(); @@ -973,18 +975,23 @@ struct Application::Impl { a.status = "finding endstops…"; } else if (scheduler && scheduler->captureActive()) { a.active = true; - a.title = "CAPTURE"; const char* phase = scheduler->moving() ? "moving" : "dwell"; - if (scheduler->controlCode() == 1) { - a.status = "directed → heading " + scheduler->targetHeading() + "° · " + - (scheduler->moving() ? "moving" : "holding"); + if (scheduler->mode() == Mode::Pan) { + a.title = "PAN"; + const auto& h = scheduler->panHeadingsNow(); + const size_t i = scheduler->panIndex(); + a.status = "position " + std::to_string(i + 1) + "/" + std::to_string(h.size()) + + " · yaw " + formatDegrees(i < h.size() ? h[i] : 0.0) + " / pitch " + + formatDegrees(scheduler->pan().pitch) + " · " + phase; } else if (!grid.empty()) { + a.title = "AUTO"; const ScanPoint& p = grid.current(); std::string coord = "yaw " + formatDegrees(p.yaw_deg); if (s.capture.scan_pitch) coord += " / pitch " + formatDegrees(p.pitch_deg); a.status = "waypoint " + std::to_string(grid.index() + 1) + "/" + std::to_string(grid.size()) + " · " + coord + " · " + phase; } else { + a.title = "AUTO"; a.status = "no scan grid — auto-sweep disabled"; } } @@ -1642,42 +1649,119 @@ struct Application::Impl { // `camera reset`: reboot every camera (DeviceReset) and put it back on its // profile. For a stream that has stopped delivering and will not restart - // the state tower 111's camera was in on 2026-09-24, which only this cleared. + // Manual mode only (allowedIn), so there is no scan to pause around it. void resetCameras() { - const bool was = scheduler->captureActive(); - stopCapture(); + camera->stop(); for (int i = 0; i < camera->cameraCount(); ++i) LOG_INFO << "camera " << i << (camera->resetCamera(i) ? ": reset, back on its profile" : ": reset FAILED (see above)"); - if (was) startCapture(); } - void startCapture() { - // Heading setup owns the gimbal by hand. Starting a scan under it would - // move the axis between a sighting and the bearing being typed for it, - // silently poisoning the fit with a position nobody looked through. - if (heading_setup_active_) { - LOG_WARN << "Capture not started: heading setup in progress " - "(finish it, or 'gimbal heading cancel')"; - return; + // The one way the mode changes - `mode ...`, the s / x keys, --start, and a + // test pausing the scan all come here. Returns false, having said why and + // leaving the mode as it was, when the gimbal cannot do what was asked. + // StatusCode is published on every change, so it always says what the tower + // is doing, not what someone last asked for. + bool setMode(Mode m, const PanSpec& pan = {}) { + if (m != Mode::Manual) { + // Heading setup owns the gimbal by hand. Starting a scan under it would + // move the axis between a sighting and the bearing being typed for it, + // silently poisoning the fit with a position nobody looked through. + if (heading_setup_active_) { + LOG_WARN << "mode " << modeName(m) << " refused: heading setup in progress " + "(finish it, or 'gimbal heading cancel')"; + return false; + } + // Before READY the firmware leaves coils disabled and clamps MOVE + // targets to a coarse bench guard, so no position is ever reached and + // the move/settle cycle stalls silently. Refuse with a clear message + // instead. (Single-axis builds report no P: segment, so skip pitch.) + MotorTelemetry t = motor->telemetry(); + if (!t.yaw.ready() || (t.pitch_present && !t.pitch.ready())) { + LOG_WARN << "mode " << modeName(m) << " refused: gimbal not homed " + "(run 'gimbal home' first)"; + return false; + } } - // The scheduler arms regardless, but a scan can't progress until the axes - // are homed: before READY the firmware leaves coils disabled and clamps - // MOVE targets to a coarse bench guard, so no waypoint is ever reached and - // the move/settle cycle stalls silently. Refuse with a clear message - // instead. (Single-axis builds report no P: segment, so skip pitch.) - MotorTelemetry t = motor->telemetry(); - if (!t.yaw.ready() || (t.pitch_present && !t.pitch.ready())) { - LOG_WARN << "Capture not started: gimbal not homed (run init/home first)"; - return; + 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)" : ""); } - camera->start(); - scheduler->setCaptureActive(true); - LOG_INFO << "Capture started"; + channel->publishStatus(static_cast(m)); + return true; } - void stopCapture() { - camera->stop(); - scheduler->setCaptureActive(false); - LOG_INFO << "Capture stopped"; + + // 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& 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 []` - switch. + void handleMode(const std::string& line) { + std::istringstream in(line); + std::vector tok; + for (std::string w; in >> w;) tok.push_back(w); + const ModeRequest r = parseModeCommand(tok); + if (!r.valid) { + LOG_WARN << r.error; + return; + } + if (!r.query) { + setMode(r.mode, r.pan); + return; + } + const Mode m = scheduler->mode(); + if (m == Mode::Pan) { + const PanSpec p = scheduler->pan(); + const auto& h = scheduler->panHeadingsNow(); + LOG_INFO << "mode pan: heading " << p.heading << ", span " << p.span << ", pitch " + << p.pitch << " deg - " << h.size() + << (h.size() == 1 ? " position" : " positions") << " (" << panRangeText(h) + << "), at " << (scheduler->panIndex() + 1) << "/" << h.size(); + } else { + LOG_INFO << "mode " << modeName(m); + } } // `gimbal heading fit : ...` / `gimbal heading offset `. @@ -1780,24 +1864,25 @@ struct Application::Impl { return; } // The camera is stopped outside a scan; leave it running afterwards so a - // burst of snapshots does not pay the start-up cost each time. stopCapture() + // burst of snapshots does not pay the start-up cost each time. setMode() // and shutdown stop it as before. camera->start(); scheduler->requestSnapshot(); LOG_INFO << "snapshot requested"; } - // Manual mode is simply "no scan running" - derived, never a separate flag an - // operator could strand. Hand aiming and a scan are mutually exclusive: both - // drive the same axes, and a nudge mid-sweep would silently corrupt the - // waypoint the next frame is published under. - bool manualMode() const { return !scheduler || !scheduler->captureActive(); } + // Hand aiming and a scan are mutually exclusive: both drive the same axes, + // and a nudge mid-sweep would silently corrupt the waypoint the next frame is + // published under. The mode gate in handleCommand() already refuses these + // outside manual; the guard stays so each command is safe on its own. + bool manualMode() const { return !scheduler || scheduler->mode() == Mode::Manual; } // Guard for the hand-aiming commands. Returns false (having said why) when a // scan owns the gimbal. bool requireManual(const char* what) { if (manualMode()) return true; - LOG_WARN << "ignored '" << what << "': a scan is running (press x / type 'stop' first)"; + LOG_WARN << "ignored '" << what << "': the tower is in " << modeName(scheduler->mode()) + << " mode (press x / type 'mode manual' first)"; return false; } @@ -2043,14 +2128,14 @@ struct Application::Impl { return; } if (calib && calib->running()) { - LOG_WARN << "pitch setup already running (press Esc, or type 'stop', to cancel)"; + LOG_WARN << "pitch setup already running (press Esc, or type 'cancel', to cancel)"; return; } if (heading_setup_active_) { LOG_WARN << "pitch setup: heading setup in progress ('gimbal heading cancel' first)"; return; } - stopCapture(); // free the serial link + don't fight the routine's moves + setMode(Mode::Manual); // free the serial link + don't fight the routine's moves calib = std::make_unique(*motor, *imu, cfg.geometry); calib->start(); } @@ -2132,7 +2217,7 @@ struct Application::Impl { while (!local.empty()) { const std::string& line = local.front(); Command c = parseCommand(line); - if (c.verb == "cancel" || c.verb == "stop") + if (c.verb == "cancel" || (c.verb == "mode" && c.device == "manual")) cancel_procedure_ = true; else if (!c.empty()) LOG_WARN << "ignored '" << c.verb << "': homing in progress (Esc to cancel)"; @@ -2207,8 +2292,12 @@ struct Application::Impl { // Paused, not ended: a test run on a tower in service (install-tower // --verify does exactly that) must not leave it idle afterwards. - resume_capture_after_test_ = scheduler->captureActive(); - stopCapture(); // free the serial link; don't fight the routine's moves + // Typed tests only run in manual (allowedIn); a --test at startup may find + // a scan running, and gets it back afterwards. + resume_mode_after_test_ = scheduler->mode(); + resume_pan_after_test_ = scheduler->pan(); + if (resume_mode_after_test_ != Mode::Manual) + setMode(Mode::Manual); // free the serial link; don't fight the routine's moves LOG_INFO << "starting hardware test (profile '" << profile.name << "')"; // What `test hardware` needs beyond the motor and IMU: the declaration to // check against, and the subsystems that answer for themselves. @@ -2358,10 +2447,11 @@ struct Application::Impl { exit_code_ = r.all_pass ? 0 : kExitTestFail; running = false; } - if (resume_capture_after_test_) { - resume_capture_after_test_ = false; - LOG_INFO << "resuming capture, paused for the test"; - startCapture(); + if (resume_mode_after_test_ != Mode::Manual) { + LOG_INFO << "resuming " << modeName(resume_mode_after_test_) + << " mode, paused for the test"; + setMode(resume_mode_after_test_, resume_pan_after_test_); + resume_mode_after_test_ = Mode::Manual; } } } @@ -2415,7 +2505,7 @@ struct Application::Impl { // Commands that would disturb a running procedure (motion, capture, a second // long op, or a DUMP over the same serial link). Ignored while busy. static bool interferingVerb(const std::string& verb) { - return verb == "gimbal" || verb == "start" || verb == "test" || + return verb == "gimbal" || verb == "mode" || verb == "test" || verb == "refresh" || verb == "snapshot" || verb == "camera"; } @@ -2425,13 +2515,27 @@ struct Application::Impl { LOG_TRACE_CAT(LogCat::Control) << "cmd " << line; // Lock-out: while a test/calibration/homing runs, ignore interfering - // commands (Esc, or `stop`/`cancel`, cancels the procedure instead). + // commands (Esc, `cancel`, or `mode manual` - the x key - cancels the + // procedure instead: procedures run in manual, so that is what x means). + if (procedureBusy() && c.verb == "mode" && c.device == "manual") { + cancelActiveProcedure(); + return; + } if (procedureBusy() && interferingVerb(c.verb)) { LOG_WARN << "ignored '" << c.verb << "': " << procedureName() << " in progress (Esc to cancel)"; return; } + // Mode gate: in auto and pan only what cannot move the gimbal or change + // a setting runs (OperatingMode.h). The same rule for every source - the + // console, the dashboard's keys and MQTT all arrive here. + if (scheduler && !allowedIn(scheduler->mode(), line)) { + LOG_WARN << "ignored '" << line << "': the tower is in " + << modeName(scheduler->mode()) << " mode ('mode manual' first)"; + return; + } + if (c.verb == "help") { // c.device holds the optional topic (first token after "help"). for (const auto& l : renderHelp(c.device)) LOG_INFO << l; @@ -2459,12 +2563,8 @@ struct Application::Impl { else LOG_WARN << "usage: camera reset"; } else if (c.verb == "snapshot") { takeSnapshot(); - } else if (c.verb == "start") { - startCapture(); - } else if (c.verb == "stop") { - // `stop`/`x` cancels any running procedure, else stops capture. - if (procedureBusy()) cancelActiveProcedure(); - else stopCapture(); + } else if (c.verb == "mode") { + handleMode(line); } else if (c.verb == "debug") { bool on = Logger::level() != LogLevel::Debug; Logger::setLevel(on ? LogLevel::Debug : LogLevel::Info); @@ -2727,8 +2827,9 @@ struct Application::Impl { LOG_WARN << "scan grid load failed (" << e.what() << "); auto-sweep disabled"; } LOG_INFO << "scan grid: " << grid.size() << " waypoints"; - scheduler = std::make_unique(*motor, *camera, *channel, cfg.image_rate(), + scheduler = std::make_unique(*motor, *camera, cfg.image_rate(), cfg.geometry, grid); + scheduler->setPanStep(cfg.hardware.pan_step_deg); // Every camera has to have finished by the time the gimbal moves on, so // the wait is the longest across them, not the first one's. scheduler->setSettleWait(cfg.camera.maxSettleWaitMs(), cfg.camera.maxFrameTimeoutMs()); @@ -2754,7 +2855,7 @@ struct Application::Impl { } pipeline->start(); - channel->publishStatus(0); + channel->publishStatus(static_cast(Mode::Manual)); // until --start says otherwise // Seed an initial snapshot so the UI's first frame is populated, then // start the interface (headless console or TUI) on its own thread. The @@ -2784,7 +2885,7 @@ struct Application::Impl { sendAnchorConfig(); applyHomedTravel(); } - if (opts.start) startCapture(); + if (opts.start) setMode(Mode::Auto); // Clean up on Ctrl-C / kill / systemd stop, not just on "exit"/Ctrl-D - so the // camera is always stopped and closed (an unclean exit wedges it for next time). diff --git a/src/core/CaptureScheduler.cpp b/src/core/CaptureScheduler.cpp index 2f14c73..6276d72 100644 --- a/src/core/CaptureScheduler.cpp +++ b/src/core/CaptureScheduler.cpp @@ -4,6 +4,7 @@ #include #include +#include namespace fgc { @@ -22,11 +23,10 @@ constexpr long kSettleTolCounts = 600; } // namespace CaptureScheduler::CaptureScheduler(IMotorController& motor, ICameraSource& camera, - IControlChannel& channel, double image_rate, Geometry geometry, - ScanGrid& grid, std::function now_ms) + double image_rate, Geometry geometry, ScanGrid& grid, + std::function now_ms) : motor_(motor), camera_(camera), - channel_(channel), geometry_(geometry), grid_(grid), now_ms_(now_ms ? std::move(now_ms) : steadyNowMs), @@ -34,8 +34,12 @@ CaptureScheduler::CaptureScheduler(IMotorController& motor, ICameraSource& camer timer_start_ = now_ms_(); } -void CaptureScheduler::setCaptureActive(bool active) { - capture_active_ = active; +void CaptureScheduler::setMode(Mode mode, const PanSpec& pan) { + mode_ = mode; + pan_ = pan; + pan_headings_ = mode == Mode::Pan ? panHeadings(pan, pan_step_deg_) : std::vector{}; + pan_index_ = 0; + pan_unreachable_warned_ = false; moving_ = false; // restart the move/settle cycle cleanly settled_at_ms_ = 0; // Drop any armed snapshot: a scan starting means the operator moved on, and @@ -113,39 +117,25 @@ void CaptureScheduler::serviceSnapshot(const MotorTelemetry& t) { } void CaptureScheduler::tick() { - // 1. Remote control input. - ControlCommand cmd = channel_.poll(); - if (cmd.control_code_available) { - control_code_ = cmd.control_code; - LOG_TRACE_CAT(LogCat::Control) << "control_code -> " << control_code_; - channel_.publishStatus(control_code_); - } - if (cmd.heading_available) { - target_heading_ = cmd.target_heading; - LOG_TRACE_CAT(LogCat::Control) << "target_heading -> " << target_heading_; - } - - // 2. Telemetry. + // 1. Telemetry. MotorTelemetry t = motor_.telemetry(); pitch_present_ = t.pitch_present; - // 3. Manual snapshot. Only outside a scan, so a keypress can never inject a - // frame into a running sweep or fight it for the settle window. - if (!capture_active_) { + // 2. Manual snapshot. Only in manual, so a keypress can never inject a frame + // into a running sweep or fight it for the settle window. + if (mode_ == Mode::Manual) { if (snapshot_pending_) serviceSnapshot(t); return; } - // 4. Capture cycle. Only ControlCode 0 (sweep) and 1 (directed) act. - if (control_code_ != 0 && control_code_ != 1) return; - + // 3. Capture cycle: the next scan waypoint (auto) or pan position (pan). const double interval_ms = image_rate_ > 0.0 ? 1000.0 / image_rate_ : 0.0; if (!moving_) { // Issue the next move once the interval has elapsed. if (interval_ms <= 0.0 || elapsedMs() <= interval_ms) return; - if (control_code_ == 0) { + if (mode_ == Mode::Auto) { if (grid_.empty()) return; const ScanPoint& p = grid_.current(); // Yaw travel is under a full turn, so some bearings are unreachable. @@ -171,28 +161,29 @@ void CaptureScheduler::tick() { // log without enabling the control wire-trace. LOG_INFO << "scan move " << (grid_.index() + 1) << "/" << grid_.size() << ": yaw " << p.yaw_deg << " pitch " << p.pitch_deg << " (deg)"; - } else { // ControlCode 1: directed yaw, pitch held at current position. - double heading = 0.0; - try { - heading = std::stod(target_heading_); - } catch (const std::exception&) { - return; // bad heading; wait for a valid one - } - auto yc = geometry_.yaw.headingToCounts(heading); - if (!yc) { - // Hold position and say so: silently pointing at the nearest limit - // would publish CamEvents claiming a bearing we cannot reach. - if (!directed_unreachable_warned_) { - directed_unreachable_warned_ = true; - LOG_WARN << "target heading " << heading - << " deg is outside the reachable yaw arc; holding position"; + } else { // pan: the next position, left to right, at the pan's pitch + if (pan_headings_.empty()) return; + // Skip positions outside the reachable arc, as the sweep does; try + // each at most once per tick so a pan with none reachable just idles. + std::optional yc; + for (size_t tries = 0; tries < pan_headings_.size() && !yc; ++tries) { + yc = geometry_.yaw.headingToCounts(pan_headings_[pan_index_]); + if (yc) break; + if (!pan_unreachable_warned_) { + pan_unreachable_warned_ = true; + LOG_WARN << "pan position " << (pan_index_ + 1) << "/" << pan_headings_.size() + << " heading " << pan_headings_[pan_index_] + << " deg is outside the reachable yaw arc; skipping it (further " + "unreachable positions are skipped silently)"; } - return; + pan_index_ = (pan_index_ + 1) % pan_headings_.size(); } - directed_unreachable_warned_ = false; + if (!yc) return; yaw_target_ = *yc; - pitch_target_ = t.pitch.xenc; - LOG_INFO << "directed move: heading " << target_heading_ << " (deg)"; + pitch_target_ = geometry_.pitch.toCounts(pan_.pitch); + LOG_INFO << "pan move " << (pan_index_ + 1) << "/" << pan_headings_.size() + << ": yaw " << pan_headings_[pan_index_] << " pitch " << pan_.pitch + << " (deg)"; } sendMove(); moving_ = true; @@ -219,24 +210,26 @@ void CaptureScheduler::tick() { if (since_settled < settle_wait_ms_) return; if (camera_.trigger()) { - if (control_code_ == 0) + if (mode_ == Mode::Auto) LOG_INFO << "captured waypoint " << (grid_.index() + 1) << "/" << grid_.size(); else - LOG_INFO << "captured (directed)"; + LOG_INFO << "captured pan position " << (pan_index_ + 1) << "/" + << pan_headings_.size(); } else if (since_settled < settle_wait_ms_ + frame_timeout_ms_) { return; // no fresh frame yet; retry next tick } else { // Never park on one waypoint: a stalled camera would otherwise stop the // whole sweep rather than costing it a single image. LOG_WARN << "no frame within " << frame_timeout_ms_ << " ms of settling; skipping " - << (control_code_ == 0 ? "waypoint " + std::to_string(grid_.index() + 1) + - "/" + std::to_string(grid_.size()) - : std::string("directed capture")); + << (mode_ == Mode::Auto ? "waypoint " : "pan position ") + << (mode_ == Mode::Auto ? grid_.index() + 1 : pan_index_ + 1) << "/" + << (mode_ == Mode::Auto ? grid_.size() : pan_headings_.size()); } moving_ = false; settled_at_ms_ = 0; - if (control_code_ == 0) grid_.advance(); + if (mode_ == Mode::Auto) grid_.advance(); + else if (!pan_headings_.empty()) pan_index_ = (pan_index_ + 1) % pan_headings_.size(); resetTimer(); } diff --git a/src/core/Config.cpp b/src/core/Config.cpp index c93a2de..fb6bf21 100644 --- a/src/core/Config.cpp +++ b/src/core/Config.cpp @@ -256,6 +256,12 @@ AppConfig ConfigLoader::fromMap(const std::map& kv) { p.spi_mosi = getInt(kv, "Pins.spi_mosi", -1); p.spi_miso = getInt(kv, "Pins.spi_miso", -1); } + if (kv.count("Hardware.pan_step_deg")) { + cfg.hardware.pan_step_deg = getDouble(kv, "Hardware.pan_step_deg", 0.0); + if (!(cfg.hardware.pan_step_deg > 0.0)) + throw std::runtime_error("Hardware.pan_step_deg must be > 0 (degrees of bearing " + "one picture covers)"); + } for (const auto& a : cfg.hardware.axes) if (a != "yaw" && a != "pitch") throw std::runtime_error("Hardware.axes: unknown axis '" + a + diff --git a/src/core/HelpText.cpp b/src/core/HelpText.cpp index bcf4267..b99f55a 100644 --- a/src/core/HelpText.cpp +++ b/src/core/HelpText.cpp @@ -24,6 +24,26 @@ std::string verbOf(const std::string& syntax) { const std::vector& helpCatalog() { // clang-format off static const std::vector 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 []", + "Watch one direction: sweep a span centred on a bearing, repeatedly.", { + "Left to right across heading +/- span/2, one picture every pan_step_deg", + "(hardware.ini) or less, both edges included; then back to the left edge.", + "A span under one picture is the one heading. Pitch defaults to 0 (level).", + "Example: mode pan 90 30 (75, 81, ..., 105: six pictures)"}}, + {"mode manual", + "Stop capturing and take the gimbal by hand. Key: x.", { + "Every command is accepted. With a test, homing or calibration running,", + "cancels it instead (as Esc and 'cancel' do)."}}, + }}, {"Positioning", "Aim the gimbal. Yaw is a TRUE BEARING (0 = north); pitch is elevation (0 = level).", { {"gimbal move ,", @@ -70,7 +90,7 @@ const std::vector& helpCatalog() { "Example: gimbal home / gimbal home y"}}, {"gimbal stop [y|p|all]", "Stop motion immediately.", { - "To cancel a running procedure use the bare 'stop' (or 'cancel', or Esc):", + "To cancel a running procedure use 'cancel' (or Esc, or x):", "the whole 'gimbal' family is ignored while one is in progress."}}, {"gimbal speed ", "Set the max slew speed (counts/s) for an axis.", {}}, @@ -137,14 +157,14 @@ const std::vector& helpCatalog() { {"camera reset", "Reboot the camera and put it back on its profile.", { "Sends the camera its own DeviceReset, waits for it to come back on USB, and", - "re-applies the profile; capture pauses meanwhile. For a stream that has", + "re-applies the profile (manual mode). For a stream that has", "stopped delivering frames and will not restart. The program also does this", "by itself when restarting the stream stops helping."}}, {"gimbal pitch setup", "Calibrate PITCH steps<->degrees against gravity, using the IMU.", { "Auto-homes if needed, sweeps pitch dwelling at each step, fits, then", "parks level. Writes logs/pitch_setup_*.log and offers to save. Takes", - "minutes; 'stop' or Esc cancels. Requires the IMU - level is defined by", + "minutes; 'cancel' or Esc cancels. Requires the IMU - level is defined by", "gravity and nothing else on the tower can measure it. Yaw is NOT touched:", "use 'gimbal heading setup'."}}, {"gimbal status", @@ -156,8 +176,6 @@ const std::vector& helpCatalog() { "Example: gimbal raw \"MOVE 100000,250000\" / gimbal raw \"DUMP\""}}, }}, {"Capture", "Control image capture and encoding.", { - {"start", "Begin the capture scan.", {}}, - {"stop", "Halt the capture scan.", {}}, {"set fps ", "Set capture rate in images/second.", {}}, {"set camera profile [] ", "Switch a camera's profile (rate, exposure, all imaging settings).", { @@ -185,7 +203,7 @@ const std::vector& helpCatalog() { "Take one image now and publish its CamEvent (manual mode only).", { "Goes through the same settle-and-trigger path a scan waypoint uses, so", "the file and the CamEvent are indistinguishable from a scan frame.", - "Refused while a scan is running."}}, + "Refused in auto and pan."}}, {"H", "Open the heading-setup wizard (see 'gimbal heading setup').", { "Manual mode only. Blocks a scan from starting until it is finished or", diff --git a/src/core/OperatingMode.cpp b/src/core/OperatingMode.cpp new file mode 100644 index 0000000..928185a --- /dev/null +++ b/src/core/OperatingMode.cpp @@ -0,0 +1,116 @@ +#include "fgc/OperatingMode.h" + +#include "fgc/Geometry.h" + +#include +#include +#include + +namespace fgc { + +namespace { + +std::string lower(std::string s) { + for (char& c : s) c = static_cast(std::tolower(static_cast(c))); + return s; +} + +std::vector words(const std::string& line) { + std::istringstream in(line); + std::vector 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 panHeadings(const PanSpec& pan, double step) { + if (step <= 0.0 || pan.span < step) return {wrap360(pan.heading)}; + const int intervals = static_cast(std::ceil(pan.span / step - 1e-9)); + const double spacing = pan.span / intervals; + const double left = pan.heading - pan.span / 2.0; + std::vector out; + for (int i = 0; i <= intervals; ++i) out.push_back(wrap360(left + i * spacing)); + return out; +} + +ModeRequest parseModeCommand(const std::vector& 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 []"; + 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 diff --git a/src/mqtt/MqttControlChannel.cpp b/src/mqtt/MqttControlChannel.cpp index 43ef59a..a6b1241 100644 --- a/src/mqtt/MqttControlChannel.cpp +++ b/src/mqtt/MqttControlChannel.cpp @@ -18,8 +18,6 @@ MqttControlChannel::MqttControlChannel(const std::string& broker_uri, const std: const std::string& user, const std::string& password) : broker_uri_(broker_uri), tower_(tower), - topic_target_hdg_(towerTopic(tower, kTopicTargetHdg)), - topic_control_code_(towerTopic(tower, kTopicControlCode)), topic_status_(towerTopic(tower, kTopicStatusCode)), topic_cam_event_(towerTopic(tower, kTopicCamEvent)), topic_command_(towerTopic(tower, kTopicCommand)), @@ -63,10 +61,27 @@ bool MqttControlChannel::connected() const { return connected_; } void MqttControlChannel::connected(const std::string& /*cause*/) { LOG_INFO << "MQTT (re)connected; subscribing"; history_.noteConnect(); - client_.subscribe(topic_target_hdg_, kQos); - client_.subscribe(topic_control_code_, kQos); client_.subscribe(topic_command_, kQos); connected_ = true; + // Say the mode again: whoever watches StatusCode may have missed changes + // while the link was down. Not waited on - this runs on Paho's callback + // thread, which is the one that would deliver the acknowledgement. + std::string status; + { + std::lock_guard lock(mutex_); + status = last_status_; + } + if (!status.empty()) { + try { + auto msg = mqtt::make_message(topic_status_, status); + msg->set_qos(kQos); + msg->set_retained(true); + client_.publish(msg); + history_.record(/*outbound=*/true, Delivery::Ok, topic_status_, status); + } catch (const mqtt::exception& e) { + LOG_WARN << "MQTT status republish failed: " << e.what(); + } + } } void MqttControlChannel::connection_lost(const std::string& cause) { @@ -97,28 +112,7 @@ void MqttControlChannel::message_arrived(mqtt::const_message_ptr msg) { // A flood - a stuck publisher - must not grow without bound. if (commands_.size() < 256) commands_.push_back(std::move(rc)); else LOG_WARN << "MQTT command queue full; dropped: " << payload; - return; } - try { - if (topic == topic_target_hdg_) { - (void)std::stoi(payload); // validate it is an integer - sub_data_.target_heading = payload; - sub_data_.heading_available = true; - } else if (topic == topic_control_code_) { - sub_data_.control_code = std::stoi(payload); - sub_data_.control_code_available = true; - } - } catch (const std::exception& e) { - LOG_WARN << "MQTT payload conversion error on " << topic << ": " << e.what(); - } -} - -ControlCommand MqttControlChannel::poll() { - std::lock_guard lock(mutex_); - ControlCommand out = sub_data_; - sub_data_.control_code_available = false; - sub_data_.heading_available = false; - return out; } std::vector MqttControlChannel::takeCommands() { @@ -157,6 +151,10 @@ void MqttControlChannel::publish(const std::string& topic, const std::string& pa void MqttControlChannel::publishStatus(int code) { const std::string payload = std::to_string(code); + { + std::lock_guard lock(mutex_); + last_status_ = payload; + } history_.noteStatus(payload); publish(topic_status_, payload); } diff --git a/src/ui/TuiUi.cpp b/src/ui/TuiUi.cpp index 148e475..2ac5295 100644 --- a/src/ui/TuiUi.cpp +++ b/src/ui/TuiUi.cpp @@ -10,6 +10,7 @@ #include "fgc/ui/Scroll.h" #include +#include #include #include #include @@ -35,6 +36,11 @@ using namespace ftxui; namespace { +std::string upperStr(std::string s) { + for (char& c : s) c = static_cast(std::toupper(static_cast(c))); + return s; +} + Color toColor(UiColor c) { switch (c) { case UiColor::Green: return Color::Green; @@ -284,7 +290,7 @@ Element headingWizardPanel(const UiSnapshot& s, const HeadingWizard& w, Componen // the overlay keys, and each overlay advertises what its own body responds to. // // This changes only what is ADVERTISED. Every binding still works everywhere - -// 's' starts a scan from inside the gimbal view exactly as before - so the bar +// 's' switches to auto from inside the gimbal view exactly as before - so the bar // is a hint about what is useful here, not a restriction. // // flexbox (Wrap::Wrap by default) rather than hbox: the overview set is wider @@ -293,8 +299,8 @@ Element keyBar(Overlay overlay, bool manual) { static const char* kArrowsV = "\xE2\x86\x91\xE2\x86\x93"; static const char* kArrows4 = "\xE2\x86\x90\xE2\x86\x92\xE2\x86\x91\xE2\x86\x93"; - // Start/stop are wanted from every view, so they lead everywhere. - Elements e{keyHint("s", "Start"), keyHint("x", "Stop")}; + // Auto/manual are wanted from every view, so they lead everywhere. + Elements e{keyHint("s", "Auto"), keyHint("x", "Manual")}; switch (overlay) { case Overlay::None: @@ -623,17 +629,15 @@ Element connPanel(const ConnView& v) { Element mqtt = !v.mqtt_enabled ? (text(" disabled ") | dim) : v.mqtt_connected ? (text(" connected ") | color(Color::Green) | bold) : (text(" disconnected ") | color(Color::Red) | bold); - std::string mode = v.control_code == 0 ? "0 auto-sweep" : "1 directed"; return panel("[m] COMMS", Color::Green, vbox({ labelRow("MQTT", hbox({mqtt, filler(), text(v.broker) | dim})), labelRow("tower", text(v.tower)), - labelRow("control", hbox({text(mode), filler(), - text("hdg " + v.target_heading) | dim})), - labelRow("status", text(std::to_string(v.last_status_code))), + labelRow("mode", hbox({text(v.mode), filler(), + text("StatusCode " + std::to_string(v.status_code)) | dim})), })); } -// Expanded comms view ('m'): broker state, the six topics with their last +// Expanded comms view ('m'): broker state, the four topics with their last // traffic, the counters, and a scrollable history of recent messages. // // The history is the point of the view. `dropped` in particular has no other @@ -685,8 +689,8 @@ Element connDetailPanel(const ConnView& v, ScrollState& st) { rows.push_back(text("TOPICS") | bold | color(Color::Green)); struct TopicRow { const char* dir; const char* leaf; }; static const TopicRow kTopics[] = { - {"sub", kTopicTargetHdg}, {"sub", kTopicControlCode}, {"sub", kTopicCommand}, - {"pub", kTopicStatusCode}, {"pub", kTopicCamEvent}, {"pub", kTopicCommandReply}, + {"sub", kTopicCommand}, {"pub", kTopicStatusCode}, + {"pub", kTopicCamEvent}, {"pub", kTopicCommandReply}, }; for (const auto& t : kTopics) { const ChannelMessage* last = nullptr; @@ -1373,6 +1377,7 @@ void TuiUi::uiLoop() { (paused_ ? text(" PAUSED @" + paused_at_hms_ + " ") | color(Color::Black) | bgcolor(Color::Yellow) | bold : text("")), + text(" " + upperStr(s.header.mode) + " ") | inverted | bold, text(" " + mode + " ") | (s.header.live ? color(Color::Green) : color(Color::Yellow)) | bold, text(" up " + std::to_string(s.header.uptime_ms / 1000) + "s ") | dim, }); @@ -1696,8 +1701,8 @@ void TuiUi::uiLoop() { if (c == "k") { help_sel = (help_sel - 1 + n) % n; help_scroll = {}; return true; } } if (c == "q") { exit_prompt = true; return true; } - if (c == "s") { if (sink_) sink_("start"); return true; } - if (c == "x") { if (sink_) sink_("stop"); return true; } + if (c == "s") { if (sink_) sink_("mode auto"); return true; } + if (c == "x") { if (sink_) sink_("mode manual"); return true; } if (c == "h") { if (sink_) sink_("gimbal home"); return true; } if (c == "r") { if (sink_) sink_("refresh"); return true; } // IMU config + dump if (c == ":") { command_mode = true; return true; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 63fbd1f..80a9ba4 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -25,6 +25,7 @@ add_executable(fgc_tests test_iniedit.cpp test_camerastall.cpp test_remotecommands.cpp + test_operatingmode.cpp test_tmccurrent.cpp test_hardwarepresence.cpp test_hardwarechecks.cpp diff --git a/tests/test_operatingmode.cpp b/tests/test_operatingmode.cpp new file mode 100644 index 0000000..876d464 --- /dev/null +++ b/tests/test_operatingmode.cpp @@ -0,0 +1,122 @@ +#include + +#include "fgc/OperatingMode.h" + +#include +#include + +using namespace fgc; + +namespace { + +std::vector tok(const std::string& line) { + std::vector 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{90.0}); + CHECK(panHeadings({90.0, 0.0, 0.0}, 6.0) == std::vector{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)); +} diff --git a/tests/test_remotecommands.cpp b/tests/test_remotecommands.cpp index e707c87..382ade4 100644 --- a/tests/test_remotecommands.cpp +++ b/tests/test_remotecommands.cpp @@ -92,7 +92,7 @@ TEST_CASE("the level is the most severe line: a refusal reads as warn") { TEST_CASE("a retained or unreadable command is answered but never run") { RemoteCommandRunner run; int ran = 0; - RemoteCommand retained; retained.cmd = "start"; retained.retained = true; + RemoteCommand retained; retained.cmd = "mode auto"; retained.retained = true; auto r1 = run.run(retained, [&](const std::string&) { ++ran; }); CHECK(ran == 0); CHECK(r1->level == "error"); diff --git a/tests/test_scheduler.cpp b/tests/test_scheduler.cpp index 320df09..6732222 100644 --- a/tests/test_scheduler.cpp +++ b/tests/test_scheduler.cpp @@ -36,21 +36,6 @@ struct FakeCamera : ICameraSource { int cameraCount() const override { return 1; } }; -struct FakeChannel : IControlChannel { - ControlCommand next; - int last_status = -1; - bool connect() override { return true; } - void disconnect() override {} - bool connected() const override { return true; } - void publishStatus(int c) override { last_status = c; } - void publishCamEvent(const CamEvent&) override {} - ControlCommand poll() override { - ControlCommand c = next; - next = {}; - return c; - } -}; - // Simple linear map: 10 counts/deg, centre at count 0 pointing at 0 deg. // Reachable yaw travel -90..90, pitch 0..60. // Fields: counts_per_deg, center_count, deg_at_center, min_deg, max_deg. @@ -74,22 +59,18 @@ TEST_CASE("CaptureScheduler sweeps the scan grid with MOVE + settle + trigger") long long clock = 0; FakeMotor motor; FakeCamera cam; - FakeChannel chan; ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}}); // -> yaw counts 0, then 900 - CaptureScheduler sch(motor, cam, chan, 1.0 /*img/s -> 1000ms*/, testGeometry(), grid, + CaptureScheduler sch(motor, cam, 1.0 /*img/s -> 1000ms*/, testGeometry(), grid, [&] { return clock; }); sch.setSettleWait(0, 0); // these predate the settle wait; timing has its own test - sch.setCaptureActive(true); + sch.setMode(Mode::Auto); - chan.next.control_code_available = true; - chan.next.control_code = 0; - // Before the interval elapses: nothing moves, but status is echoed. + // Before the interval elapses: nothing moves. clock = 500; sch.tick(); CHECK(motor.cmds.empty()); - CHECK(chan.last_status == 0); // After the interval: MOVE to the first grid point (0,0) is issued. clock = 1600; @@ -119,14 +100,11 @@ TEST_CASE("CaptureScheduler scans a 1-axis (yaw-only) rig with yaw-only MOVE/set FakeMotor motor; motor.tel.pitch_present = false; // yaw-only gimbal: no P: segment in telemetry FakeCamera cam; - FakeChannel chan; ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}}); - CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; }); + CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; }); sch.setSettleWait(0, 0); // these predate the settle wait; timing has its own test - sch.setCaptureActive(true); - chan.next.control_code_available = true; - chan.next.control_code = 0; + sch.setMode(Mode::Auto); // The MOVE must address yaw alone, not the two-axis form a 1-axis firmware rejects. clock = 1600; @@ -149,101 +127,117 @@ TEST_CASE("CaptureScheduler scans a 1-axis (yaw-only) rig with yaw-only MOVE/set CHECK(motor.cmds.back() == "MOVE Y 900"); } -TEST_CASE("CaptureScheduler ControlCode 1 drives yaw to the target heading") { - long long clock = 0; - FakeMotor motor; - FakeCamera cam; - FakeChannel chan; - ScanGrid grid({{0.0, 0.0}}); - CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; }); - sch.setSettleWait(0, 0); // these predate the settle wait; timing has its own test - sch.setCaptureActive(true); - - chan.next.control_code_available = true; - chan.next.control_code = 1; - chan.next.heading_available = true; - chan.next.target_heading = "45"; // 45 deg * 10 = 450 counts - - clock = 1600; +// Walk one capture: the interval elapses and a MOVE goes out, then the gimbal +// settles on it and the frame is kept. Returns the MOVE. +std::string captureOnce(CaptureScheduler& sch, FakeMotor& motor, long long& clock) { + clock += 1100; sch.tick(); REQUIRE(!motor.cmds.empty()); - CHECK(motor.cmds.back() == "MOVE 450,0"); - CHECK(sch.controlCode() == 1); + const std::string move = motor.cmds.back(); + settleAt(motor, sch.yawTargetCounts(), sch.pitchTargetCounts()); + clock += 10; + sch.tick(); // records standstill + clock += 10; + sch.tick(); // keeps the frame + return move; } -TEST_CASE("CaptureScheduler refuses an unreachable target heading instead of clamping") { +TEST_CASE("pan walks its positions left to right, then jumps back to the left edge") { long long clock = 0; FakeMotor motor; FakeCamera cam; - FakeChannel chan; ScanGrid grid({{0.0, 0.0}}); - CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; }); - sch.setSettleWait(0, 0); // these predate the settle wait; timing has its own test - sch.setCaptureActive(true); + CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; }); + sch.setSettleWait(0, 0); + sch.setPanStep(6.0); + // 0 +/- 15 at 6 deg: 345, 351, 357, 3, 9, 15 - six pictures, across north. + sch.setMode(Mode::Pan, PanSpec{0.0, 30.0, 20.0}); + REQUIRE(sch.panHeadingsNow().size() == 6); - chan.next.control_code_available = true; - chan.next.control_code = 1; - chan.next.heading_available = true; - // 200 deg is 160 deg the short way round, past the 90 deg limit: in the arc - // this gimbal physically cannot reach. - chan.next.target_heading = "200"; + std::vector moves; + for (int i = 0; i < 7; ++i) moves.push_back(captureOnce(sch, motor, clock)); + // 10 counts/deg; pitch 20 deg -> 200 counts, whatever pitch the gimbal had. + CHECK(moves[0] == "MOVE -150,200"); + CHECK(moves[1] == "MOVE -90,200"); + CHECK(moves[2] == "MOVE -30,200"); + CHECK(moves[3] == "MOVE 30,200"); + CHECK(moves[4] == "MOVE 90,200"); + CHECK(moves[5] == "MOVE 150,200"); + CHECK(moves[6] == "MOVE -150,200"); // back to the left edge + CHECK(cam.triggers == 7); +} +TEST_CASE("a pan narrower than one picture stays on its heading") { + long long clock = 0; + FakeMotor motor; + FakeCamera cam; + ScanGrid grid({{0.0, 0.0}}); + CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; }); + sch.setSettleWait(0, 0); + sch.setMode(Mode::Pan, PanSpec{45.0, 5.0, 0.0}); + CHECK(captureOnce(sch, motor, clock) == "MOVE 450,0"); + CHECK(captureOnce(sch, motor, clock) == "MOVE 450,0"); + CHECK(cam.triggers == 2); +} + +TEST_CASE("a pan skips positions outside the reachable arc instead of clamping them") { + long long clock = 0; + FakeMotor motor; + FakeCamera cam; + ScanGrid grid({{0.0, 0.0}}); + CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; }); + sch.setSettleWait(0, 0); + // 84, 90, 96: the last is past the 90 deg limit. Clamping it to 90 would + // image 90 twice and label one of them 96. + sch.setMode(Mode::Pan, PanSpec{90.0, 12.0, 0.0}); + CHECK(captureOnce(sch, motor, clock) == "MOVE 840,0"); + CHECK(captureOnce(sch, motor, clock) == "MOVE 900,0"); + CHECK(captureOnce(sch, motor, clock) == "MOVE 840,0"); +} + +TEST_CASE("a pan with nothing reachable moves nothing") { + long long clock = 0; + FakeMotor motor; + FakeCamera cam; + ScanGrid grid({{0.0, 0.0}}); + CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; }); + sch.setSettleWait(0, 0); + // 200 deg is 160 deg the short way round, past the 90 deg limit. + sch.setMode(Mode::Pan, PanSpec{200.0, 0.0, 0.0}); clock = 1600; sch.tick(); - // No move at all. Clamping to the 90 deg limit would have imaged a direction - // 110 deg away from the one requested and labelled it "200". CHECK(motor.cmds.empty()); CHECK_FALSE(sch.moving()); } TEST_CASE("CaptureScheduler::setGeometry re-targets with the recalibrated conversion") { // A `gimbal heading fit` updates the live geometry; the scheduler must adopt it so - // the scan/directed targets use the calibrated counts/deg (not the stale copy). + // the pan targets use the calibrated counts/deg (not the stale copy). long long clock = 0; FakeMotor motor; FakeCamera cam; - FakeChannel chan; ScanGrid grid({{0.0, 0.0}}); - CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; }); - sch.setSettleWait(0, 0); // these predate the settle wait; timing has its own test - sch.setCaptureActive(true); + CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; }); + sch.setSettleWait(0, 0); + sch.setMode(Mode::Pan, PanSpec{45.0, 0.0, 0.0}); + CHECK(captureOnce(sch, motor, clock) == "MOVE 450,0"); // 45 * 10 counts/deg - chan.next.control_code_available = true; - chan.next.control_code = 1; - chan.next.heading_available = true; - chan.next.target_heading = "45"; // 45 deg - - clock = 1600; - sch.tick(); - CHECK(motor.cmds.back() == "MOVE 450,0"); // 45 * 10 counts/deg - - // Settle + trigger so the scheduler is ready to move again. - settleAt(motor, 450, 0); - clock = 1700; - sch.tick(); // records standstill - clock = 1710; - sch.tick(); // keeps the frame - - // Recalibrate to 20 counts/deg; the same persisted heading must now resolve - // to twice the counts — no new heading sent. + // Recalibrate to 20 counts/deg; the same heading must now resolve to twice + // the counts. Geometry g2; g2.yaw = {20.0, 0, 0.0, -90.0, 90.0}; g2.pitch = {10.0, 0, 0.0, 0.0, 60.0}; sch.setGeometry(g2); - - clock = 2800; - sch.tick(); - CHECK(motor.cmds.back() == "MOVE 900,0"); // 45 * 20 counts/deg + CHECK(captureOnce(sch, motor, clock) == "MOVE 900,0"); // 45 * 20 counts/deg } -TEST_CASE("CaptureScheduler stays idle when capture inactive") { +TEST_CASE("CaptureScheduler stays idle in manual mode") { long long clock = 0; FakeMotor motor; FakeCamera cam; - FakeChannel chan; ScanGrid grid({{0.0, 0.0}}); - CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; }); - // capture not active + CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; }); + // manual: the mode it starts in clock = 5000; sch.tick(); @@ -255,14 +249,11 @@ TEST_CASE("CaptureScheduler holds still for settle_wait_ms before keeping a fram long long clock = 0; FakeMotor motor; FakeCamera cam; - FakeChannel chan; ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}}); - CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; }); + CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; }); sch.setSettleWait(1000, 2000); - sch.setCaptureActive(true); - chan.next.control_code_available = true; - chan.next.control_code = 0; + sch.setMode(Mode::Auto); clock = 1600; sch.tick(); @@ -289,14 +280,11 @@ TEST_CASE("CaptureScheduler abandons a waypoint after frame_timeout_ms") { FakeMotor motor; FakeCamera cam; cam.ok = false; // camera never has a fresh frame - FakeChannel chan; ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}}); - CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; }); + CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; }); sch.setSettleWait(1000, 2000); - sch.setCaptureActive(true); - chan.next.control_code_available = true; - chan.next.control_code = 0; + sch.setMode(Mode::Auto); clock = 1600; sch.tick(); @@ -327,9 +315,8 @@ TEST_CASE("requestSnapshot captures one frame once the gimbal has settled") { long long clock = 0; FakeMotor motor; FakeCamera cam; - FakeChannel chan; ScanGrid grid({{0.0, 0.0}}); - CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; }); + CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; }); sch.setSettleWait(1000, 2000); // Capture inactive: this is manual mode. @@ -360,9 +347,8 @@ TEST_CASE("a snapshot waits out a slew rather than shooting mid-move") { long long clock = 0; FakeMotor motor; FakeCamera cam; - FakeChannel chan; ScanGrid grid({{0.0, 0.0}}); - CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; }); + CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; }); sch.setSettleWait(1000, 60000); // long frame budget: the point here is the settle settleAt(motor, 0, 0); @@ -385,14 +371,13 @@ TEST_CASE("a scan owns the camera: snapshots are refused and never queue up") { long long clock = 0; FakeMotor motor; FakeCamera cam; - FakeChannel chan; ScanGrid grid({{0.0, 0.0}}); - CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; }); + CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; }); sch.setSettleWait(0, 0); settleAt(motor, 0, 0); sch.requestSnapshot(); - sch.setCaptureActive(true); + sch.setMode(Mode::Auto); // Arming is dropped, not deferred: firing it whenever the scan happens to end // would attribute an image to whatever the gimbal was pointing at then. CHECK_FALSE(sch.snapshotPending()); @@ -400,7 +385,7 @@ TEST_CASE("a scan owns the camera: snapshots are refused and never queue up") { sch.requestSnapshot(); // asked for again mid-scan clock = 1600; sch.tick(); // the scan's own move, not a snapshot - sch.setCaptureActive(false); + sch.setMode(Mode::Manual); clock = 3000; sch.tick(); CHECK_FALSE(sch.snapshotPending()); @@ -414,9 +399,8 @@ TEST_CASE("the snapshot frame budget is measured from settling, not from the key long long clock = 0; FakeMotor motor; FakeCamera cam; - FakeChannel chan; ScanGrid grid({{0.0, 0.0}}); - CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; }); + CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; }); sch.setSettleWait(1000, 2000); settleAt(motor, 0, 0); @@ -440,9 +424,8 @@ TEST_CASE("a snapshot that times out can simply be asked for again") { long long clock = 0; FakeMotor motor; FakeCamera cam; - FakeChannel chan; ScanGrid grid({{0.0, 0.0}}); - CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; }); + CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; }); sch.setSettleWait(1000, 2000); settleAt(motor, 0, 0);