fwt_software/docs/mqtt-api.md

18 KiB
Raw Blame History

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, 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: a tower in service starts here (config.ini [Startup] mode = auto). 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 start mode unless [Startup] mode = auto; also where auto falls back if the gimbal is not homed. 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 (one per image_interval, counted from the last picture), 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
GGS/FWT/<tower>/Health When the CPU guard changes state or throttling starts/ends, once a minute, after a thermal command, and on every reconnect 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:

{ "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 ([Camera.<name>] label): RGB, TIR, ACR or NIR
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.

Thermal camera (cam: "TIR")

A thermal camera's events carry six more fields; a colour camera's never do, and their payload is unchanged.

{ "fwt":"ExampleTower", "cam":"TIR", "hdg":1373, "pit":300, "time":1719312345678,
  "cal":0, "missed":0, "tmax":3015, "tmed":187, "hotx":0.7359, "hoty":0.5833 }
Field Type Meaning
cal int Calibration profile the image was taken on. Required to read the file: it sets the counts' scale
missed int GigE packets the SDK lost for this frame (transfer quality)
tmax int/null Hottest pixel, apparent temperature in tenths of a °C
tmed int/null Median of the image, tenths of a °C (the anchor for a display window)
hotx, hoty number/null Where the hottest pixel is: its centre, 0..1 across and down the image as stored (after rotation_deg), as for detection boxes
  • Same time as the colour picture. The thermal camera is asked right after the colour one, at the same waypoint, and its event and file take the colour frame's timestamp: RGB/<time>.jxl and TIR/<time>.jxl are one pair. A picture the thermal camera had no fresh frame for has no TIR event.
  • The file holds counts, 16-bit single channel, lossless: T[K] = count × 0.01 under cal 0 (FF (-40-120C)), × 0.02 under cal 1 (FF (0-600C)), valid for count ≤ 65280 (above it the camera's table wraps; 0 is never a temperature).
  • Apparent temperature: emissivity and path transmission are taken as 1. Vegetation (ε ≈ 0.97) over kilometres of air reads a few kelvin off its true temperature; the counts keep every correction possible later. tmax/tmed use the same convention, so they are for triage and display, not for a reported fire temperature.
  • null in the four statistics means the frame had no valid pixel; cal and missed are still set.

CamEvent and Health leave through a small queue on their own thread, so a slow link never holds an image save or the control loop. If 64 are waiting, the oldest is dropped (and shows as dropped in the connectivity view).

Health payload

The tower computer's heat and load, and whether the scan is slowed or paused for it (see thermal-throttling.md). The mode in StatusCode does not change: a paused auto scan is still "0", and carries on by itself once cool.

{"fwt":"t121","state":"paused","reason":"CPU at 93 °C for 20 s","guard":true,
 "cpu_c":93.0,"cpu_mhz":1188,"cpu_busy_pct":41,"throttling":true,
 "throttle_events_last_min":14,"enclosure_c":48.2,"saved_last_min":0,
 "save_ms_mean":null,"save_ms_max":null,"cams_offline":"","held_s":0,
 "interval_s":10.0,"time":1759917600000}
Field Type Meaning
state string normal, slowed (pictures slow_factor× further apart) or paused (gimbal parked, camera stream stopped)
reason string Why not normal, in words; "" when normal
guard bool false after thermal off: full pace whatever the heat
cpu_c number/null Hottest CPU thermal zone, °C
cpu_mhz number/null Mean clock over the cores
cpu_busy_pct number/null How busy the CPU was since the last sample
throttling bool The CPU is slowing itself (kernel counters, or a low clock while busy)
throttle_events_last_min int Kernel throttle events counted in the last minute
enclosure_c number/null The ambient sensor (SHT41/DHT11), when there is one
saved_last_min, save_ms_mean, save_ms_max int, number/null Pictures saved in the last minute and how long each took (every camera's: with a thermal camera, two per waypoint)
cams_offline string Labels of cameras not delivering, comma-separated ("TIR"); "" when all are. Only a camera the scan can do without - the thermal one - is ever listed: it reconnects by itself while the scan goes on
held_s int How long the scan has been waiting for saving to catch up
interval_s number Time between pictures now
time int Unix epoch ms

A missing reading is null, never 0.

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

# 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>/Health          (JSON: CPU guard state, CPU °C/MHz/busy, throttling, saving)
            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 -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