221 lines
11 KiB
Markdown
221 lines
11 KiB
Markdown
# MQTT API
|
||
|
||
The program is both an MQTT **subscriber** (remote control) and **publisher** (status + capture events). 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
|
||
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).
|
||
|
||
## Subscribed topics (inbound — remote control)
|
||
|
||
Subscribed on (re)connect; handled in `MqttControlChannel::message_arrived()`
|
||
([src/mqtt/MqttControlChannel.cpp](../src/mqtt/MqttControlChannel.cpp)).
|
||
|
||
| Topic | Payload | Parsed as | Effect |
|
||
|-------|---------|-----------|--------|
|
||
| `GGS/FWT/<tower>/target_HDG` | integer bearing as string, e.g. `"137"` | validated with `stoi`, stored as **string** | Sets the yaw target used in ControlCode 1 (a **true bearing**, 0 = north, converted to encoder counts via `[Motor]`) |
|
||
| `GGS/FWT/<tower>/ControlCode` | integer as string, `"0"` or `"1"` | `stoi` → int | Selects the capture mode (see below) |
|
||
|
||
Invalid (non-integer) payloads are caught and logged; the previous value is kept.
|
||
|
||
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()`).
|
||
|
||
### ControlCode semantics
|
||
|
||
| 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. |
|
||
|
||
> **`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.
|
||
|
||
When a ControlCode message arrives, the program echoes the current code back on the StatusCode topic.
|
||
|
||
## Published topics (outbound)
|
||
|
||
| Topic | When | Payload | QoS / Retain |
|
||
|-------|------|---------|--------------|
|
||
| `GGS/FWT/<tower>/StatusCode` | At startup (`"0"`); whenever a ControlCode message is received (echoes the code) | integer as string | 1 / retained |
|
||
| `GGS/FWT/<tower>/CamEvent` | After each image is saved | JSON object (below) | 1 / retained |
|
||
|
||
### 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** -
|
||
which is also true of `ControlCode` and `target_HDG`.
|
||
|
||
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`).
|
||
|
||
### Example
|
||
|
||
```text
|
||
# to GGS/FWT/dev/Command
|
||
{"id":"maint-17","cmd":"gimbal status"}
|
||
|
||
# from GGS/FWT/dev/CommandReply
|
||
{"id":"maint-17","cmd":"gimbal status","level":"info",
|
||
"lines":["16:40:02.114 [INFO ] remote ..."],"truncated":false}
|
||
```
|
||
|
||
## Topic summary
|
||
|
||
```
|
||
subscribe: GGS/FWT/<tower>/target_HDG (int heading)
|
||
GGS/FWT/<tower>/ControlCode (0 = auto sweep, 1 = directed)
|
||
GGS/FWT/<tower>/Command (any console command: JSON {id, cmd} or a bare line)
|
||
|
||
publish: GGS/FWT/<tower>/StatusCode (echoed control code)
|
||
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 -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 '{"id":"t1","cmd":"gimbal status"}'
|
||
mosquitto_pub -q 1 -t 'GGS/FWT/ExampleTower/Command' -m 'help' # a bare line works too
|
||
```
|