247 lines
13 KiB
Markdown
247 lines
13 KiB
Markdown
# 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). 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 |
|
||
| `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.
|
||
|
||
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 `stop` 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"}
|
||
{"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
|
||
```
|