11 KiB
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, 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_portastcp://<host>:<port>(NetworkConfig::brokerUri), and logged on every connect and connect failure. Two escape hatches, so existing configs keep working: azkms_server_ipcontaining://is passed through verbatim, and one already of the formhost:portkeeps that port. In both casesmqtt_portis 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-mqttto disable MQTT entirely (null channel).
Subscribed topics (inbound — remote control)
Subscribed on (re)connect; handled in MqttControlChannel::message_arrived()
(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) |
|---|---|
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_HDGis 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:
{ "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
timevalue 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:
pitno longer agrees with the commanded waypoint by construction. Commanding+10.0and receivingpit: 104is 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.
idis echoed from the request, so a page can match answers to questions. Choose a fresh one per request.linesare what the command logged while it ran - what the console would have shown in response, timestamps and all.helpreturns the help text,gimbal dumpthe dump, a refusal the reason.levelis 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 tologs/test_*.log. - Some commands answer with nothing (
lines: []):gimbal statusonly asks the firmware to refresh the dashboard's telemetry, andexitis answered just before the program stops. - At most 200 lines / 16 KB;
truncatedsays 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>/Commandcan do anything the console can: move the gimbal, change settings, send raw text to the firmware (gimbal raw), and stop the program (exit). Afterexit, 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) -
CommandandCommandReplyboth live there, and nothing else is needed; or - goes through the ground station, which needs, in zkms-client: a
CommandReplyqueue, binding and shovel out (mirroringCamEvents), and an inbound shovel from the ground station to the tower forCommand(andControlCode/target_HDG).
Example
# 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:
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