fwt_software/docs/mqtt-api.md

255 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# MQTT API
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 and recorded). All topics are
namespaced by tower name:
```
GGS/FWT/<tower_name>/...
```
`<tower_name>` comes from `config.ini` (`General.tower_name`). All messages use **QoS 1**. Published messages
are **retained**, except command replies.
## Connection
- Broker URI is assembled from `Network.zkms_server_ip` + `Network.mqtt_port` as `tcp://<host>:<port>`
(`NetworkConfig::brokerUri`), and logged on every connect and connect failure. Two escape hatches, so
existing configs keep working: a `zkms_server_ip` containing `://` is passed through verbatim, and one
already of the form `host:port` keeps that port. In both cases `mqtt_port` is ignored. Client ID = the
tower name.
- Auth: `mqtt_user` / `mqtt_pw` (preferring `$FGC_MQTT_USER`/`$FGC_MQTT_PW`); `clean_session = true`,
keep-alive 20 s, `set_automatic_reconnect(true)`.
- Connect timeout 5 s. On connection loss Paho auto-reconnects; the channel re-subscribes on reconnect.
- **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).
## Modes: auto, pan, manual
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 `a` / `p` / `m` keys - and reports it on `StatusCode`.
| Mode | StatusCode | What the tower does | Command |
|---|---|---|---|
| auto | `0` | Sweeps the scan grid front to back, a picture at every waypoint, then starts over. The normal state: the boot service starts here (`--start`). | `mode auto` |
| pan | `1` | Watches one direction: walks left to right across a span centred on a bearing, a picture at each position, then back to the left edge. | `mode pan <heading> <span> [<pitch>]` |
| manual | `2` | Nothing by itself; a person drives it. The state without `--start`. | `mode manual` |
**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); a yaw-only tower ignores it, with a warning. Pictures are taken at the same pace as auto
(`image_interval`), and each publishes a CamEvent exactly as a sweep picture
does.
**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.
**What each mode accepts.** Manual accepts every command. Auto and pan accept
only what cannot move the gimbal or change a setting:
- reading: `help`, `gimbal status`, `gimbal dump`, `imu status`, `imu config`
- logging: `debug`, `trace`
- `mode ...`, `cancel`, `exit`
Anything else is refused - `ignored '<command>': the tower is in pan mode
('mode manual' first)` - whether it came over MQTT or from the console. So a
web page switches to manual before driving the gimbal, and back to auto (or
pan) when done.
**A refused mode change changes nothing**, StatusCode included. `mode auto`
and `mode pan` are refused while the gimbal is not homed, during heading
setup, or while a test or calibration runs.
## Published topics (outbound)
| Topic | When | Payload | QoS / Retain |
|-------|------|---------|--------------|
| `GGS/FWT/<tower>/StatusCode` | At startup, on every mode change, and again on every reconnect | the mode: `"0"` auto, `"1"` pan, `"2"` manual | 1 / retained |
| `GGS/FWT/<tower>/CamEvent` | After each image is saved | JSON object (below) | 1 / retained |
StatusCode says what the tower **is doing**, not what it was last asked to do:
it is published by the mode change itself, whatever caused it. Being retained,
a page that subscribes late still learns the mode at once.
### CamEvent payload
Built by `MqttControlChannel::publishCamEvent` from a `CamEvent`:
```json
{ "fwt":"ExampleTower", "cam":"RGB", "hdg":1373, "pit":300, "time":1719312345678 }
```
| Field | Type | Meaning |
|-------|------|---------|
| `fwt` | string | Tower name (`config.ini` `tower_name`) |
| `cam` | string | Camera label: `RGB`, `ACR`, or `NIR` (by camera index) |
| `hdg` | int | Gimbal **true bearing** × 10 (0 = north) at capture time, from the **yaw encoder** |
| `pit` | int | Gimbal pitch elevation **× 10** at capture time, from the **IMU** — elevation relative to gravity, i.e. true pointing angle (fallbacks below) |
| `time` | int | Capture timestamp, Unix epoch **milliseconds** (matches the `.jxl` filename) |
> The `time` value is the same Unix-ms timestamp used as the image filename, so a consumer can locate the file
> for a given event: `<RGB|ACR|NIR>/<time>.jxl`.
**`pit` is measured against gravity, `hdg` against the encoder.** The two axes come from
different sources deliberately. The pitch encoder only measures the axis relative to the
gimbal's own base, so a leaning mast is invisible to it; the IMU measures against gravity
and therefore reports the elevation the imagery is actually pointing at. Yaw stays on the
encoder because IMU yaw drifts and depends on the heading reset and XKF profile, whereas
encoder yaw is tied to true north by `gimbal heading setup` (sighting known
landmarks, no IMU involved) and then holds.
Two consequences for consumers:
- **`pit` no longer agrees with the commanded waypoint by construction.** Commanding `+10.0`
and receiving `pit: 104` is correct — the residual is real tilt, not error. Before this
change both came off the same encoder scale and agreed trivially.
- **Archives spanning the change contain two different measurements** in this field: an axis
angle before it, a true elevation after. If the mast is level the two agree; if it leans,
they differ by the lean.
If the IMU is disabled (`[Features] enable_imu = false`), `pit` falls back to the encoder as
before. If the IMU is enabled but has no fresh sample, it also falls back — and logs a
warning saying so, once per outage, since that mixes the two measurements in one field.
A **yaw-only tower** has no pitch encoder. With an IMU, `pit` is still the IMU's elevation;
without one it is the camera's fixed elevation, `[Motor] pitch_angle_at_center` (0 unless
set). Commands keep one syntax on every tower: `gimbal move <yaw>[,<pitch>]` and `mode pan`
accept a pitch and ignore it there (with a warning), and pitch-only commands
(`gimbal nudge|jog pitch`, `gimbal pitch setup`, `gimbal <verb> p`) are refused with
"this tower has no pitch axis".
Both `hdg` and `pit` are sampled **when the frame arrives**, not when its JPEG XL encode
finishes, so a slow encode or a backed-up queue cannot attribute a later pose to earlier
pixels.
## Remote commands: `Command` / `CommandReply`
For development and maintenance: **any console command** - everything that can
be typed at the dashboard's `:` prompt or the plain console - can be sent on
`Command`, and the tower answers each one on `CommandReply`. It is meant for a
web maintenance interface; a tower in normal service simply never receives any.
| Topic | Direction | Payload |
|---|---|---|
| `GGS/FWT/<tower>/Command` | to the tower | `{"id":"<your id>","cmd":"<command line>"}`, or a bare command line |
| `GGS/FWT/<tower>/CommandReply` | from the tower | `{"id":"...","cmd":"...","level":"info\|warn\|error","lines":[...],"truncated":false}` - **not retained** |
**How a command runs.** Exactly as if typed: the same handler
(`Application::handleCommand()`), on the same control thread, with the same
refusals. `gimbal move` during a sweep is refused as it is at the console
(send `mode manual` first); a command sent while a test or homing runs gets "ignored
... in progress". Commands run in the order they arrive, one at a time, each
answered before the next starts.
**The reply.**
- `id` is echoed from the request, so a page can match answers to questions.
Choose a fresh one per request.
- `lines` are what the command logged while it ran - what the console would
have shown in response, timestamps and all. `help` returns the help text,
`gimbal dump` the dump, a refusal the reason.
- `level` is the most severe of those lines: `info` (done), `warn` (refused or
questionable), `error` (failed, or not run - see below).
- `gimbal dump`'s answer comes back from the firmware a moment later; its reply
waits for it (up to 5 s) and carries the dump itself. Commands behind it wait
their turn.
- Long jobs - `gimbal home`, `test ...`, `camera reset` - are answered when they
have **started**. Their outcome is not published here; ask afterwards
(`gimbal dump`), or read the tower's log - a self-test writes its report to
`logs/test_*.log`.
- Some commands answer with nothing (`lines: []`): `gimbal status` only asks the
firmware to refresh the dashboard's telemetry, and `exit` is answered just
before the program stops.
- At most 200 lines / 16 KB; `truncated` says when an answer was cut.
**What is refused, and never run** (answered with `level: error`):
- a **retained** message - the broker re-delivers retained messages on every
reconnect, so acting on one would re-run it each time the tower restarts.
Publish commands without the retain flag;
- an unreadable payload (bad JSON, no `cmd`, more than one line).
**Repeats.** QoS 1 can deliver a message twice. The last 64 ids are
remembered: a repeated id is answered again but not run again. A bare command
has no id, so it cannot be recognised as a repeat.
**Every remote command is logged** before it runs, as
`remote command [<id>]: <command line>`, so the tower's log records who drove
it, and how.
> **Careful - this topic is always live, and nothing is withheld.** Anyone who
> can publish to `GGS/FWT/<tower>/Command` can do anything the console can:
> move the gimbal, change settings, send raw text to the firmware
> (`gimbal raw`), and stop the program (`exit`). **After `exit`, a tower with the
> boot service does not come back by itself** - someone has to restart the
> service or reboot it. Who may command a tower is decided by who may publish
> to its broker: set that with the broker's users and permissions.
### Reaching a tower
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.**
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 a
binding and shovel out for `StatusCode`, so the ground station sees the mode).
### Example: watch a fire, then look around by hand
```text
# to GGS/FWT/dev/Command
{"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}
{"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"} # starts the camera itself; one image
{"id":"w5b","cmd":"capture on"} # or: keep one image per cycle here until 'capture off'
{"id":"w6","cmd":"mode auto"} # back to the sweep; StatusCode "0"
```
## Topic summary
```
subscribe: GGS/FWT/<tower>/Command (any console command: JSON {id, cmd} or a bare line;
modes change here: mode auto|pan|manual)
publish: GGS/FWT/<tower>/StatusCode (the mode: 0 auto, 1 pan, 2 manual)
GGS/FWT/<tower>/CamEvent (JSON: fwt, cam, hdg×10, pit×10, time-ms)
GGS/FWT/<tower>/CommandReply (JSON: id, cmd, level, lines, truncated - not retained)
```
## Local testing
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 -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
```