71 KiB
Configuration & Commands
Behaviour is controlled by three layers: the config.ini file, command-line flags (which override the
config), and interactive console commands typed while running.
Config file resolution
There are no hardcoded paths. The file is resolved in this order (src/core/Paths.cpp):
--config <path>CLI flag$FGC_CONFIGenvironment variable$XDG_CONFIG_HOME/fire_gimbal_control/config.ini(else~/.config/...) - the tower folder./config.ini(current directory)<executable dir>/config.ini
If none exist, the program prints every location it searched and exits.
The tower folder
Everything that belongs to one tower and is changed on it lives in one folder,
~/.config/fire_gimbal_control/:
| File | What |
|---|---|
config.ini |
what the tower does, and its calibration |
scan.csv (or whatever [Scan] grid_file names) |
the scan grid chosen for this tower's view |
camera_profiles/rgb/*.xml, *.txt |
the camera profiles exported on this tower |
The folder is outside the project folder, which is what a deploy (rsync --delete) and an update
replace, so neither can overwrite or delete anything in it. hardware.ini, which describes the
assembly and is not changed, stays in /etc/fire_gimbal_control/. Relative paths in config.ini
(grid_file, profile_dir) resolve from the folder config.ini is in.
scripts/tower-config manages it:
scripts/tower-config seed # a new tower: config.ini, scan.csv and camera_profiles/rgb from the examples
scripts/tower-config where # the config.ini the program will load
scripts/tower-config move --dry-run # a tower set up before the folder existed: what would move
scripts/tower-config move # ... and move it (stop the program first)
Places 4 and 5 are the old ones: a tower not moved yet still has config.ini in the project
folder, and the program loads it from there and warns at every start. A development checkout may
keep its own ./config.ini there too; the program only warns on a machine with
/etc/fire_gimbal_control/hardware.ini.
config.ini keys
Parsed and validated by ConfigLoader (src/core/Config.cpp) into a typed
AppConfig. Invalid types/values fail fast with a clear message.
| Section | Key | Type | Default | Meaning |
|---|---|---|---|---|
General |
tower_name |
string | Unnamed |
Tower identity; used in all MQTT topics/payloads |
General |
image_interval |
number > 0 | 5 |
Desired seconds from one picture to the next, decimals allowed (→ image_rate = 1/interval). Counted from the last frame kept; the move and the exposure wait come out of it. A waypoint whose move + wait outlasts it warns (not the return to the first waypoint, nor the first move after a mode starts) |
General |
debug |
bool | false |
Start with debug-level logging |
Startup |
home |
bool | false |
Home the gimbal at every start (see Startup). A tower in service needs true, and then homes unattended at every boot |
Startup |
mode |
manual/auto |
manual |
Mode the tower starts in. auto is refused, leaving it in manual, if the gimbal is not homed by then. pan is by command only |
Network |
zkms_server_ip |
string | 127.0.0.1 |
MQTT broker host/IP, or a full tcp://… URI |
Network |
mqtt_port |
int 1..65535 | 1883 |
Broker port (8883 for TLS). Ignored when zkms_server_ip carries its own port or a scheme |
Network |
mqtt_user / mqtt_pw |
string | — | MQTT credentials (see secrets below) |
Serial |
device |
string | /dev/ttyACM0 |
Motor-controller serial device |
Serial |
baud |
int | 115200 |
Serial baud rate |
Camera.<name> |
id |
string | — | vimba: USB DEV_ id or GigE IP; taris: the camera's serial number. Required when enabled |
Camera.<name> |
driver |
vimba | taris |
vimba |
Backend: Allied Vision Vimba X (colour), or InfraTec TarisIR (thermal, below). Colour cameras always come first in the camera order, whatever the block names |
Camera.<name> |
label |
string | the block name | Output subdirectory and CamEvent cam field |
Camera.<name> |
profile_dir |
string | — | vimba only. Directory of profile XMLs; a profile named X is <profile_dir>/X.xml. A relative path resolves against the directory holding the config.ini in use |
Camera.<name> |
profile |
string | standard |
Profile to start in. Switch live with set camera profile [<camera>] <name> |
Camera.<name> |
enabled |
bool | true |
A disabled camera is not opened and does not affect the scan timing |
Camera.<name> |
rotation_deg |
0/90/180/270 |
0 |
Clockwise rotation applied to that camera's saved images |
Camera.<name>.<profile> |
settle_wait_ms |
int ≥ 0 | 1000 |
Hold after standstill before keeping a frame |
Camera.<name>.<profile> |
frame_timeout_ms |
int ≥ 0 | 2000 |
With settle_rule = brightness, the longest wait for the brightness after settle_wait_ms; then keep the newest frame anyway. Also how long to wait for a frame once one is due before skipping the waypoint |
Camera.<name>.<profile> |
settle_rule |
fixed | brightness |
fixed |
fixed: keep the newest frame once settle_wait_ms has passed. brightness: keep the first frame whose brightness is in the camera's target band and steady for 3 frames (keep rule) |
Camera |
jxl_distance / jxl_effort |
double ≥ 0 / int 1..9 | 0.8 / 4 |
Encoder defaults inherited by every camera block |
Camera.<name> |
jxl_distance / jxl_effort |
double ≥ 0 / int 1..9 | the [Camera] value |
Per-camera override of the encoder settings. Distance 0 is truly lossless |
Camera / Camera.<name> |
demosaic |
edge_aware | bilinear |
edge_aware |
How raw profiles (BayerRG8, BayerRG12p) are converted to colour on the tower |
Camera.<name> (taris) |
calib_profile |
int ≥ 0 | 0 |
Calibration profile set on connect: 0 = -40..120 °C at 0.01 K/count, 1 = 0..600 °C at 0.02 K/count. Switch live with camera calib <n> |
Camera.<name> (taris) |
frame_rate_hz |
0 < x ≤ 50 | 5 |
The rate the camera free-runs at |
Camera.<name> (taris) |
max_frame_age_ms |
int > 0 | 400 |
A frame older than this when the picture is taken is not kept: that picture has no thermal image. Keep it below the scan's settle wait, or the frame can predate standstill (warned at startup) |
Camera.<name> (taris) |
library |
string | libirbgrab_x86_64-linux.so |
The InfraTec SDK, found through the ld cache |
Camera.<name> (taris, hardware.ini) |
interface |
string | — | The network port the camera is cabled to; only named in the message when it is not found |
Hardware (hardware.ini) |
save_workers |
int 1..4 | 1 |
Frames saved at once. 1 on the IOTA, 2 on the 3 Delta (measurements) |
Hardware (hardware.ini) |
jxl_threads |
int 0..8 | 0 = 4 / save_workers |
libjxl threads per saved frame |
Hardware (hardware.ini) |
save_queue_max |
int 1..32 | 4 |
Frames waiting or being saved before the scan holds at its waypoint (a raw 16 MP frame is ~24 MB) |
Paths |
output_dir |
string | $XDG_DATA_HOME/fire_gimbal_control/images |
Image output dir; supports ~/$ENV |
Features |
enable_mqtt |
bool | true |
Use MQTT (vs null channel) |
Features |
enable_camera |
bool | true |
(reserved) |
Features |
enable_serial |
bool | true |
(reserved) |
Features |
enable_imu |
bool | false |
Legacy. Derived from [Hardware] imu wherever that is declared; kept only for towers without a hardware.ini. A flag contradicting the declaration is warned about and ignored |
Features |
mock_camera |
bool | false |
Use the simulated camera |
Features |
mock_camera_size |
small/full |
small |
The simulated camera's frames: small = 640×480 colour; full = the real camera's 5328×3040 12-bit raw, so saving costs the CPU what it does on a tower (throttling simulations) |
Features |
mock_serial |
bool | false |
Use the simulated motor controller |
Features |
mock_imu |
bool | false |
Use the simulated IMU instead of the MTi |
Features |
enable_env |
bool | false |
Legacy. Derived from [Hardware] env wherever that is declared; same rule as enable_imu |
Features |
mock_env |
bool | false |
Use the simulated env sensor instead of the SHT41 |
Features |
mock_needcfg |
bool | false |
Make the simulated motor controller run the config handshake (emit NEED, refuse to move until answered) |
Features |
strict_presence |
bool | false |
Refuse to start when hardware.ini declares a subsystem this rig does not have. Off during the fleet rollout; turn it on per tower once that file is known good. --degraded overrides it |
IMU |
device |
string | — | MTi serial device (see [IMU] note); required when enable_imu |
IMU |
baud |
int | 115200 |
MTi serial baud rate |
IMU |
x / y / z |
float ±360 | 0 |
Mounting rotation in degrees about forward/right/down, applied to every IMU reading (see below). All zero = the IMU is aligned with the gimbal |
Env |
i2c_device |
string | /dev/i2c-1 |
SHT41 I2C bus device (see [Env] note); required when enable_env |
Env |
i2c_addr |
int | 68 (0x44) |
SHT41 I2C slave address; decimal or 0x-prefixed hex |
Env |
period_ms |
int | 2000 |
Sample interval |
Logging |
level |
enum | info |
Console log level (--log-level overrides). Does not silence the log file, which has its own |
Logging |
trace |
csv | — | Wire-trace categories, off by default (--trace overrides) |
Logging |
file |
bool | true |
Write a persistent log file (--no-log-file overrides) |
Logging |
file_level |
enum | debug |
File log level, independent of level (--log-file-level overrides) |
Logging |
file_max_mb |
int | 20 |
Rotate past this size. Clamped to 1..1024 |
Logging |
file_keep |
int | 10 |
How many fgc_*.log to retain, oldest deleted. Clamped to 1..100 |
Logging |
file_dir |
path | — | Defaults to the data log dir alongside the test and diag reports |
UI |
enable_tui |
bool | false |
Full-screen terminal dashboard (--tui/--no-tui override; needs WITH_TUI=ON) |
Motor |
yaw_counts_per_deg / pitch_counts_per_deg |
float | 983.33 / — |
Encoder counts per degree (calibrate; may be negative to flip) |
Motor |
yaw_center_count / pitch_center_count |
int | 500000 |
Count homing defines as the middle of the found travel. Must exceed half the travel, or the negative end falls below zero — the 500000 default suits the Delta's ~89k half-travel, not a geared-up rig (the IOTA needs 1000000). A warning names it after each home |
Motor |
yaw_heading_at_center |
float | 0 |
True bearing (0 = north) at that centre (calibrate) |
Motor |
pitch_angle_at_center |
float | 0 |
Elevation above level at that centre (calibrate; not 0 on a real rig). On a yaw-only tower, the camera's fixed tilt, published as CamEvent pit when no IMU is fitted |
Motor |
yaw_soft_margin_counts / pitch_soft_margin_counts |
int | 5000 |
Clearance kept from each hard endstop; sent to the firmware, which enforces it |
Motor |
homing_timeout_s |
int | 65 |
How long a full two-end homing may take before the host calls it failed. A property of the drivetrain — a high gear ratio searching gently takes minutes |
Motor.<axis> |
r_sense_ohm |
float | — | Sense resistor on that axis's TMC5160 board. hardware.ini. Makes the milliamp figures below mean something |
Motor.<axis> |
run_current_ma / homing_current_ma |
float | — | Coil current (mA RMS) for normal motion, and while hunting an endstop. hardware.ini |
Motor.<axis> |
travel_deg |
float | — | Mechanical angle between the hard endstops. hardware.ini. Homing divides the measured travel by it to derive <axis>_counts_per_deg |
Motor.<axis> |
scan_speed_deg_s |
float | 8.0 |
Production move speed in degrees per second. hardware.ini. The host converts it to VMAX through <axis>_counts_per_deg and sends SPEED; a register value would not mean the same thing on two drivetrains |
Motor.<axis> |
homing_speed_deg_s / center_speed_deg_s |
float | — | Endstop-search and park speeds, also in °/s. hardware.ini. Both are needed before the first home, so they convert through the declared scale (steps_per_rev / travel_deg), not the homed one |
Motor.<axis> |
steps_per_rev |
int | — | Expected full mechanical travel, in counts. hardware.ini. The firmware's only guard against a phantom endstop |
Motor.<axis> |
enc_deviation |
int | — | |XACTUAL−XENC| that means "position untrustworthy". hardware.ini |
Motor.<axis> |
enc_const |
float | 12.8 | Microsteps per encoder count (the TMC's ENC_CONST), up to 4 decimals. hardware.ini, optional: only a drivetrain whose encoder differs declares it. Wrong by a few percent, the step counter and encoder drift apart as the axis turns, and homing takes the drift for a stop (staeffelsberg: 12.5, from its old firmware) |
Motor.<axis> |
sw_mode / has_encoder |
int / bool | — | TMC SW_MODE (physical endstop switches on REFL/REFR) and whether this axis's encoder is aligned and usable. hardware.ini |
Scan |
grid_file |
string | — | CSV of yaw_deg,pitch_deg waypoints (yaw_deg alone means pitch 0). The only source of a scan grid; empty → auto-sweep disabled |
[Startup] — homing and the start mode
What the tower does as it comes up. It is in config.ini, not in flags, so the
boot service, deploy.sh --run and a run by hand all do the same thing, and the
boot service starts the program with no arguments.
[Startup]
home = true ; home the gimbal at every start
mode = manual ; manual or auto
homeruns the endstop-finding home sequence (see Init sequence) before anything else. The firmware keeps no position across a power cycle, so without it the axes stay inRESETand refuse every move; the one case forfalseis a development tower, where nothing should move by itself. To home once without changing the file, typegimbal home(keyh).modeis the mode entered once startup (and homing) is done.autoneeds the gimbal homed; if it is not - homing off, or homing failed - the program logsmode auto refused: gimbal not homedand stays inmanual.panneeds a heading and a span, so it is chosen by command only (mode pan …).- Both absent is the idle start: no homing, manual. The example config ships
home = true,mode = manual- what a new tower gets frominstall-tower: it homes at every boot, then waits for someone to choose auto. - A mode changed at runtime (
mode autoover MQTT, keya) is not written back: after a reboot the tower is in its[Startup]mode again. - A
--testrun ignores[Startup]entirely: it neither homes nor leaves manual.
These replaced the --init / --start flags, which the program now refuses with
a message pointing here. scripts/install-tower moves a boot service's old
flags into this section (--init → home = true, --start → mode = auto).
[Motor] calibration & [Scan] grid
The firmware reports only encoder counts; [Motor] maps them to the bearing/elevation degrees
used by mode pan, gimbal move and CamEvent. Yaw degrees are true bearings: 0 is north. Pitch
degrees are elevation above level, so 0 is horizontal.
The counts are anchored by homing
Homing finds both hard endstops, moves to the middle of that range, and defines the midpoint as
<axis>_center_count (default 500000). Two consequences worth understanding:
- The origin is reproducible. Power-cycle the gimbal, re-home, and the limits land on the same
numbers. Before this change the count origin was whatever the free-running encoder register
happened to hold at boot, so a saved
zero_countsilently pointed somewhere else after a restart. - Both endstops contribute. Anchoring on the midpoint averages the error of the two searches instead of inheriting whichever one was used as the reference.
A large positive anchor also keeps the whole working range positive, which the driver wants.
*_min_deg/*_max_deg are no longer configured. They are derived from the firmware's homed soft
limits after every home — the only place they can be correct. The host verifies on each home that the
reported midpoint really is <axis>_center_count; a mismatch means the firmware did not adopt the
CENTER command (it predates this host) and is reported as an error rather than papered over.
*_soft_margin_counts is the clearance kept from each hard endstop. It is sent to the firmware, which
enforces it, and takes effect immediately without re-homing. It is in counts, not degrees, because
it must apply at homing time — before any degrees↔counts map is trusted — and must stay correct even
when the heading calibration is absent or wrong.
gimbal heading setup — tying yaw to true north
Aim at landmarks whose true bearing you know (a mast, a peak, a road junction on the map) and record the encoder position against each bearing. No IMU is required, which is the point: not every tower has one, and MTi yaw drifts too much to be a heading reference.
It is four commands, so it works the same at the console, on the dashboard and over MQTT (manual mode only):
| Command | Does |
|---|---|
gimbal heading setup |
start: claims the gimbal, so no sweep can start under it |
gimbal heading mark <bearing> |
record where the gimbal points now as that landmark's true bearing |
gimbal heading fit |
fit the marks and apply the result |
gimbal heading cancel |
stop and drop the marks (so do cancel and Esc) |
On the dashboard, type :gimbal heading setup; it then runs in the activity strip, which shows the
position, the step size and the marks so far, and these keys apply:
| Key | Action |
|---|---|
← → |
Aim yaw |
↑ ↓ |
Aim pitch (centre the landmark vertically) |
Tab |
Cycle step size: coarse (5% of travel) → fine (0.5%) → very fine (25 counts, ≈0.05°) |
Space |
Record an image + CamEvent, exactly as a scan waypoint would |
Enter |
Open the command line with gimbal heading mark filled in: type the bearing, Enter |
f |
Fit and apply |
Esc |
Cancel |
Two landmarks are the minimum and fit the line exactly; more are least-squares fitted and report an R². Sightings closer than 10° apart are refused — two points always fit perfectly, so a short baseline produces a healthy-looking R² and a wildly wrong scale, and refusing is the only way you find out. A set spanning north is handled (bearings are unwrapped before fitting).
The very-fine step is 25 counts rather than 1 because ENC_CONST makes the encoder advance in
12.8-microstep quanta: a smaller step moves the motor to a position it cannot reproduce.
The result is applied to the live session immediately, and in the TUI the activity strip offers to
save it to [Motor] (y/n). On a headless tower the same fit is available directly:
gimbal heading fit 480000:45.0 520000:115.0
gimbal heading offset -2.5 # shift the whole map by a known amount, and save
gimbal pitch setup — levelling pitch against gravity
gimbal pitch setup (requires the IMU) fits pitch_counts_per_deg and pitch_angle_at_center by
sweeping pitch and correlating encoder counts with the MTi's gravity-referenced pitch. It auto-homes
if needed, dwells at each step, fits, then parks level. Writes logs/pitch_setup_*.log, reports R²,
and offers the same save prompt. cancel or Esc cancels. Timings are the CalibParams defaults in
PitchSetup.h.
This is the one axis that genuinely needs the IMU — level is defined by gravity and nothing else on the tower can measure it. Yaw is untouched by it.
Scan grid
The capture scan grid is the ordered (yaw,pitch) waypoints auto-sweep visits, always front to
back, then round to the first again — never in reverse. [Scan] grid_file points at an editable CSV
in the tower folder (scan.csv, first copied from the example config/scan.csv)
and is the only source of waypoints.
Nothing is generated. Leave grid_file blank (or name a file that is missing, malformed or
empty) and the program logs why and leaves auto-sweep off, showing the reason in the TUI's scan-grid
panel. A tower that invented its own arc would archive images under bearings nobody chose, with no
file to read back to find out what was photographed. The pre-2026-09 keys yaw_intervals,
yaw_min_deg, yaw_max_deg and pitch_levels are gone; a config still carrying them gets a
startup warning naming them.
Grid yaw values are true bearings. Yaw travel is under a full turn, so there is always an
unreachable arc; waypoints falling in it are skipped with a warning rather than clamped to the
nearest limit — clamping would image a direction nobody asked for and publish it under the bearing
they did. A one-line audit after each home reports how many waypoints are affected. Write an arc
crossing north monotonically (340 → 380) rather than letting it wrap back through zero.
A line may be a yaw alone (45), meaning pitch 0. A yaw-only tower ignores the pitch column
and moves yaw alone (MOVE Y), so one grid serves either kind of tower. Rows that differ only in pitch
photograph the same bearing again there; the audit after each home warns how many do.
Each camera's output subfolder is its [Camera.<name>] label (defaulting to the block name); nothing is derived from the order of the blocks.
[IMU] — Xsens MTi orientation sensor
Enable with [Features] enable_imu = true and point [IMU] device at the MTi's serial node. On the
LattePanda the MTi is wired to the RS-232 header, i.e. an onboard hardware UART — a stable
/dev/ttyS* node (e.g. /dev/ttyS4), not a USB device and not /dev/ttyUSB0-3 (those are the
modem). Find it with ls /dev/ttyS* / dmesg | grep -iE 'ttyS|LPSS', or probe for the 0xFA-framed
stream. At startup the host reconfigures the MTi (GoToConfig → SetOutputMode → SetOutputSettings → GoToMeasurement) to a 100 Hz Euler + calibrated stream — so the device will start streaming even if it
was left in Config state. Set
[Features] mock_imu = true to use a synthetic IMU on dev machines (no hardware). Protocol/units are
documented in the modules reference (MtiProtocol).
During that same Config-state handshake the host also reads back the device configuration
(ReqProductCode, ReqDID, ReqFWRev, ReqPeriod, ReqOutputMode, ReqOutputSettings, and the
filter-profile / XKF profile via ReqFilterProfile + ReqAvailableFilterProfiles). The decoded
values — product code, firmware, device ID, output mode/format, calibration channels, sample rate, and
the list of supported Xsens Kalman Filter (XKF) profiles with the active one marked — are surfaced
in the IMU CONFIG section of the expanded Sensors view (press s). This is read-only: it reports
what the device is actually configured to do, which is the place to confirm the active XKF profile
(e.g. General vs VRU_general) when diagnosing yaw drift. On the legacy MTi the same concept is
called a "scenario" in the device manual (e.g. Machine_nomagfield); it is the same setting and shares
the wire MIDs. Older MTi firmware that does not answer the Req* queries simply leaves the section
absent (the host logs no configuration acks received).
The config is read once at startup and cached, so if you change the XKF profile externally the view
stays stale until you re-read it with imu config, which re-queries the device (briefly pausing the
stream).
The device configuration is verified, not assumed
What the host asks the MTi for — OutputMode and OutputSettings — is a wire-format
contract, not a preference, and is deliberately not exposed in config.ini.
parseMTData decodes a fixed field order that follows from exactly those bits, so a
settable value would only give you ways to break the parser. The payload length is
therefore derived from the settings at compile time (mtDataLen), guarded by a
static_assert: change a bit and the length follows, so the parser must be updated with
it rather than silently mismatching at runtime.
Since the handshake's writes were previously blind, the host now compares the readback
against what it asked for (checkImuDeviceConfig) and reports every difference at
ERROR. This matters because the length gate cannot see the two most damaging
misconfigurations — a fixed-point instead of float data format, and the wrong coordinate
system — since neither changes the payload length. A device that never answered the
Req* queries is reported as unverifiable, never as a match. An Error message
(MID 0x42), i.e. the device rejecting a message outright, is also decoded and logged
instead of being dropped.
A failed check never stops the program: it logs at ERROR, shows a red MISCONFIGURED
row in the IMU CONFIG section of the expanded Sensors view (s), a red IMU CFG
badge on the compact Sensors panel, and repeats in imu config. The persistent surfaces
matter because the handshake runs before the TUI installs its log sink, so the log line
alone is wiped by the alt-screen.
Earth reference — why pitch fell as the nose rose
The MTi measures orientation against its own local earth frame, and the classic unit's is NWU (X North, Y West, Z Up). The gimbal frame is NED. A Z-up reference reports pitch with the opposite sign: with the body Y axis pointing left, a right-hand rotation about it tips the nose down, so "nose up" comes back negative.
No [IMU] x/y/z value can fix this. Re-referencing the earth frame
left-multiplies the reported orientation; a mount right-multiplies it, and no fixed
right-multiplication reproduces a left-multiplication across all attitudes. With an
upside-down IMU the mount alone does nothing but add 180° to roll — pitch passes straight
through, which is exactly the symptom.
Both corrections are applied, in applyFrames
(ImuMount.h):
R_ned←gimbal = nedFromReference(ref) · R_local←sensor · mountRotationZXY(mount)
\______ earth ______/ \______ body ______/
Only the orientation is re-referenced. acc, gyr and mag are body-frame
directions — which way the sensor felt something, not where it points on the earth — so
the earth frame leaves them alone and only the mount rotates them.
The device is deliberately not asked to change its own coordinate system
(mtsettings::kCoordSysNed stays 0). Correcting on the host is deterministic,
reversible and unit-testable without hardware; writing an unverified bit to the unit's
non-volatile memory would be none of those. And because the host rewrites OutputSettings
on every startup, the device's reference is pinned to what that word selects rather than
to whatever was left in the unit — which is what lets kMtiReference
(MtiProtocol.h) state it with certainty.
The active reference is stated on the startup log line (IMU frames: reference NWU -> NED; mount ...). It is not shown in the Sensors view: unlike the mount it is a fixed property of
the device rather than something the operator sets, so it would be a permanent row nobody
can act on.
One thing to check on the rig. NWU and ENU produce identical roll and pitch and differ only in yaw, by exactly 90°. If the corrected yaw is 90° off a compass, the device is ENU: change
kMtiReferencetoImuReference::Enu. Nothing else needs to move.
Raw readings — nothing is flipped
Roll, pitch, yaw, acceleration, rate of turn and magnetic field are reported exactly as the device sends them, including yaw's native −180..180 (it used to be shifted to a 0..360 heading; that shift is gone). No axis is negated and no range is rewritten anywhere between the wire and the screen.
The parser applies no transformation at all: no axis is negated and no range is rewritten
between the wire and ImuSample. The two corrections that do apply are declared above and
applied together in one place (applyFrames, via MountedImuSource) — the earth reference
NWU → NED, and the [IMU] x/y/z mount. With a NED-referenced device and x/y/z all zero
there is no transformation at all and you see the sensor's own numbers.
Consumers that do arithmetic across yaw handle the ±180 wrap themselves with unwrapNear
(Calibration.h): the calibration sweep unwraps per waypoint before
fitting, and the IMU drift test unwraps before its slope and spread. That makes both independent of
which heading the tower happens to face, rather than merely moving the problem to a different bearing.
Mounting orientation (x / y / z)
The MTi reports in its own sensor frame. [IMU] x, y and z say how it is bolted on, and every
reading the program produces — orientation, acceleration, rate of turn and magnetic field — is rotated
into the gimbal's frame accordingly. Nothing downstream sees the sensor frame: the rotation is applied
at the source (MountedImuSource), so the calibration routine, the self-test suite, the Sensors view
and imu status all speak gimbal coordinates.
Both frames are NED body frames:
X = forward Y = right Z = down
roll about X pitch about Y yaw about Z (right-hand rule)
x, y and z are degrees, and they are the rotation you apply to the IMU to bring it into
alignment with the gimbal — the turns needed to get from the IMU's orientation to the gimbal's.
An IMU that physically sits turned 90° to the right is therefore corrected with z = -90.
They compose in Z-X-Y order, intrinsically: first z about down, then x about the forward axis
as it now stands, then y about the new right — i.e. R = Rz(z)·Rx(x)·Ry(y). Order matters as soon
as two of them are non-zero.
| The IMU is… | x |
y |
z |
|---|---|---|---|
| level, facing the same way as the gimbal | 0 | 0 | 0 |
| upside down, still facing forward | 180 | 0 | 0 |
| turned 90° clockwise seen from above | 0 | 0 | -90 |
| lying on its right side, facing forward | -90 | 0 | 0 |
| upside down and facing backwards | 180 | 0 | 180 |
Values outside ±360° are rejected at startup, and a non-zero mount is logged at INFO so the session's
log records which frame its readings are in. The expanded Sensors view (s) shows the mount in force
on the Mount (cfg) row of IMU CONFIG — labelled (cfg) because, unlike everything else in that
section, it comes from config.ini rather than from the device.
To check it on the rig: level the gimbal — roll and pitch should read ≈0. Tip the nose up — pitch goes positive. Roll to the right — roll goes positive. Slew right — yaw increases.
Re-run gimbal pitch setup after changing the mount. It fits IMU pitch against encoder counts, so
a calibration made under a different (or absent) mount has that error baked into
pitch_angle_at_center. Yaw is unaffected — gimbal heading setup uses landmarks, not the IMU.
imu zero is unaffected: it acts on the device, before this rotation, and the two compose correctly —
the mount is what makes "zero" mean zero in the gimbal frame.
[Camera] — cameras and profiles
Each camera gets its own [Camera.<name>] block. Nothing is implied by position:
the block names the camera, label names its output, and driver picks the
backend. A camera's settings are not here at all.
The settings live in a profile XML exported from the VimbaX Viewer. A profile
named X is <profile_dir>/X.xml, and the program hands that file straight to
the Vimba X SDK. There is no other path by which settings reach the camera, and a
missing XML is a startup error rather than a fallback.
That indirection exists because GenICam validity is stateful:
AcquisitionFrameRate is only writable once AcquisitionFrameRateEnable is set,
Width's maximum depends on binning, and IntensityAutoPrecedence accepts
Minimizenoise but not MinimizeNoise. The Viewer greys out invalid
combinations as you configure; a hand-written list has no such guardrail, and a
line that silently fails leaves the camera on its previous value.
To change a camera's settings: open the Viewer, configure, save the settings
XML over <profile>.xml, restart.
The .txt beside each XML is generated and configures nothing. It is a
human-readable record of the same settings so a profile can be read and diffed
without opening the Viewer. On startup the program hashes the XML and compares it
with the hash recorded in the .txt; if they differ it regenerates the record
and logs a warning naming every setting that moved:
[WARN ] camera 'rgb' profile 'standard': settings XML changed, regenerating standard.txt
[WARN ] AcquisitionFrameRate 3.399707 -> 10
[WARN ] ExposureAutoMax 50000 -> 15000
Timing is per camera and per profile, in [Camera.<name>.<profile>]. These
are not camera settings — they are how long the program waits around them. With
several cameras the scan uses the longest settle_wait_ms across the enabled
ones, since the gimbal must stand still for all of them.
Every *.xml in profile_dir is loaded at start-up, so a profile nobody is
using today still fails at launch rather than the first time someone switches to
it.
Raw profiles
A profile whose XML sets PixelFormat = BayerRG12p (or BayerRG8) sends the
camera's raw sensor mosaic. The tower converts it to colour ([Camera] demosaic)
and saves it at the frame's own bit depth: a 12-bit raw frame becomes a 12-bit
JPEG XL file. RGB8 profiles are saved exactly as before. At the 250 MB/s link
cap the 1800 U-1620c delivers 9.86 fps in BayerRG12p against 4.95 in RGB8, so
auto-exposure settles in about half the time; the tower-side cost is the same as
saving RGB8 (measurements).
scripts/export_camera_profile.py builds such profiles; it also turns on the
per-frame data (chunk mode) the keep rule reads.
Which frame a waypoint keeps: settle_rule
With settle_rule = brightness, after settle_wait_ms (now the mechanical
minimum) the scan watches every frame the camera delivers and keeps the first one
once the last three are all inside the camera's own auto-exposure target band
(IntensityControllerTarget ± IntensityControllerTolerance, read from the camera)
and their brightness changed by at most 2% from frame to frame. A steady frame
outside the band also counts when exposure and gain sit at the limits that would
move it (waiting cannot help). Past frame_timeout_ms the newest frame is kept
anyway, with a warning. On the 24 recorded exposure steps this picked a good frame
21 times; "exposure and gain unchanged" never did, because auto-exposure pauses
mid-way. A camera that reports no frame data falls back to the fixed wait. The
rule applies to scan and pan waypoints; a snapshot or continuous capture keeps
the fixed wait. Its time counts towards image_interval, like the move's.
When saving falls behind
Saving runs in background workers ([Hardware] save_workers, jxl_threads). If
more than save_queue_max frames are waiting or being saved, the scan holds at
its waypoint until there is room (logged once), so no picture is lost and memory
stays bounded. That waypoint does not also warn about running past
image_interval. Continuous capture holds the same way; a requested snapshot is
never held. Measured: the IOTA keeps up
with a frame every 0.7 s on one worker; the 3 Delta needs two.
[Env] — SHT41 ambient temperature/humidity sensor
Enable with [Features] enable_env = true and point [Env] i2c_device at the LattePanda's own
native I2C bus — this is a separate physical link from the motor Leonardo's serial connection, not
the Arduino's D2 pin (the original DHT11 plan). i2c_addr accepts either form — 0x44 hex or
68 decimal.
Finding the bus. The number varies by board, and on this LattePanda the sensor answers on
/dev/i2c-2, not the /dev/i2c-1 default. Two traps make this harder than it looks:
i2cdetectfalse-negatives on the SHT4x. Its probe (quick-write, or a plain read) is not a command the sensor answers, so the address can show empty on the very bus the sensor is on. Probe with a real measurement instead —i2ctransfer -y N w1@0x44 0xfd, wait ~10 ms, theni2ctransfer -y N r6@0x44; six bytes that pass the CRC is the bus.- Not every
/dev/i2c-*is a full I2C bus. An SMBus-only adapter (i2cdetect -F NreportsI2C = no) cannot carry the SHT4x's transfers at all, so that bus can never work regardless of wiring.
A failed read is logged — once at the start of a failure streak (naming the bus, the address and
an i2ctransfer probe to run), and again when the sensor recovers, so a wrong bus announces itself
instead of leaving a silently empty panel, without reprinting the same line every period_ms for
the life of the process. A sample that fails its CRC is logged separately each time. The backend (Sht41EnvSensor)
polls at period_ms, discarding any sample that fails
the SHT4x CRC-8 check (decode lives in the pure, unit-tested
Sht41Protocol); once nothing valid arrives within 2× the
period, sample() reports absence rather than a frozen value, which is what turns the panel from
live to no fix. Set [Features] mock_env = true (or pass --mock-env, which also implies
enable_env) to use a synthetic reading on dev machines with no hardware. The sensor sits behind the
generic IEnvSensor interface
(include/fgc/IEnvSensor.h), so swapping in a different ambient sensor
later means adding a new backend, not touching Application, the UI, or MQTT.
How much one picture covers: [Hardware] pan_step_deg
mode pan <heading> <span> cuts its span into as many pictures as it takes at
pan_step_deg degrees of bearing each (default 6, until the lens is
measured), both edges included - so mode pan 90 30 takes six pictures, 75°
to 105°. It is a property of the camera and lens, so it lives in
hardware.ini. Must be above 0. See mqtt-api.md.
When the computer runs hot: [Thermal]
The CPU guard (ThermalGuard.h) reads the CPU's
temperature, clock, throttle counters and busy time every sample_ms and paces
the scan to them. It never stops the program, never changes the mode and never
touches picture quality. Per computer, so normally in hardware.ini
(config.ini works too). Every key is optional.
| Key | Default | Meaning |
|---|---|---|
enabled |
true |
false: full pace however hot (readings and throttling log go on) |
slow_c |
85 | Slowed from this CPU temperature (°C) |
hysteresis_c |
5 | Back to normal needs slow_c - hysteresis_c |
slow_factor |
2 | Slowed = pictures at least this many times further apart (1..10); further still when saving needs it (1.5 × the save time per worker) |
calm_s |
120 | Slowed → normal once all clear this long, and the normal pace fits what saving takes |
held_share_pct |
25 | Slowed when the scan spent this share of the last minute waiting for saving |
held_slow_s |
30 | Slowed when saving has kept the scan waiting this long in one go |
pause_c |
92 | Paused once at this temperature for pause_after_s |
pause_after_s |
20 | |
held_pause_s |
120 | Paused when saving has kept the scan waiting this long in one go, or is still the bottleneck this long after slowing down |
resume_c |
75 | A pause ends at or below this, after min_pause_s, with nothing left to save |
min_pause_s |
300 | |
clock_low_pct / busy_high_pct |
50 / 50 | Throttling judged from the clock: below this share of the top clock while at least this busy |
sample_ms |
2000 | 500..60000 |
sysfs_root |
/ |
A fake /sys + /proc tree for a simulation (tools/thermal-sim) |
Must hold: resume_c < slow_c < pause_c, held_slow_s <= held_pause_s. The
whole story, and how to simulate it, is in thermal-throttling.md.
The legacy towers: a DHT11 and a fan on the motor MCU
Four towers carry an ambient DHT11 and an enclosure fan wired to the Leonardo itself, not to the host. They are declared, not branched:
[Hardware]
axes = yaw
env = dht11 ; read by the motor MCU
cooling = fan ; the MCU runs the fan from it
[Pins] ; hardware/lattepanda-3-delta/pins-legacy.ini
yaw_cs = 8
yaw_en = 7
dht = 9
fan_pwm = 10
fan_tach = 11
spi_idle_cs = 2
- The firmware is built with them only where they are declared
(
firmware/tools/gen_wiring.py); a tower without them gets byte-for-byte the firmware it had. - The fan is the MCU's job, and keeps running with the host down. The law is the legacy firmware's: off up to 25 °C, +20/255 per degree, full from 37.75 °C. The fan runs at full speed until the first good reading, and after three failed reads in a row.
- What the host does: the MCU reports every 10 s as
ENV t=<C> h=<%> ok=<0|1> fan=<duty> rpm=<n>. The host shows it as the ambient sensor ("DHT11 (ambient, via MCU)"), with the fan's duty and rpm. - A fan driven at half speed or more that reads 0 rpm on two readings is
logged as a warning, marked
FANon the Sensors panel, and failstest hardware env. This needsfan_tach. spi_idle_csholds a second SPI device's chip select high from power-up, so it never answers on the drivers' bus.
One key, not two: declaring a sensor is what enables it
[Hardware] imu and [Hardware] env name what is fitted. [Features] enable_imu
and enable_env used to say the same thing a second time, and the two could
disagree — declare imu = mti-630, forget the flag, and the program would report
missing hardware for what was a missing config key.
The declaration now wins wherever it exists:
[Hardware] imu |
[Features] enable_imu |
Result |
|---|---|---|
| (absent) | true / false |
the flag governs, exactly as before — every un-migrated tower |
mti-630 |
(absent) | enabled. No second key is needed |
mti-630 |
false |
enabled, and the clash is warned about so the stale key can be deleted |
none |
true |
disabled. A rig that says it has no IMU cannot be argued into having one |
mock_imu / mock_env / mock_camera / mock_serial stay in config.ini and are
untouched by this: substituting a simulated device is an operational choice about
this run, not a statement about what is bolted to the tower.
Declared vs found — what hardware.ini is checked against
Everything in this program degrades gracefully: TestRunner skips the pitch and IMU leaves, CamEvent
falls back to encoder pitch (or, with no pitch axis, the fixed pitch_angle_at_center), CaptureScheduler
and gimbal move send the single-axis MOVE Y form, and pitch-only commands are refused. That is correct for a
rig that genuinely lacks them, and indistinguishable from a failure on a rig that does not. A two-axis
tower whose pitch stops enumerating becomes a yaw-only tower, captures one elevation band, and reports
itself healthy.
Declaring the assembly is what separates those two. At startup — once the motor, sensors and camera have
all been brought up, so "found" means came up rather than was configured — the program compares
[Hardware] against what it actually has:
| Declared | Found | Result |
|---|---|---|
| present | present | one INFO line confirming the assembly |
| absent | present | warn: hardware.ini is stale. Never fatal — more hardware than declared is a documentation fault |
| present | absent | warn, or refuse to start under strict_presence, naming each missing subsystem |
Two properties are deliberate:
- A tower with no
hardware.iniis never reconciled, whateverstrict_presencesays. Gaining that file must not be what changes how a tower behaves; nothing is compared until something is declared. - An empty
axesstates nothing, matchinghasAxis()answering true for everything when nothing is said. Reconciling silence would turn a missing key into a fault.
strict_presence defaults to off so the fleet rollout cannot change behaviour on upgrade. Turn it on
per tower once that tower's file is known good; the fleet-wide flip is then a config change rather than a
release. When it refuses, the program exits 3 (distinct from a config error's 1 and the
single-instance lock's 2) before the control loop is entered.
--degraded overrides a refusal for field triage, and logs RUNNING DEGRADED BY REQUEST with the list of
what is missing — worded so it cannot be mistaken for a normal startup line.
Which camera is missing is reported as a count (
2 configured, only 1 opened), because the camera source reports how many it opened, not which.test hardware camerais what names the device.
Secrets
mqtt_user / mqtt_pw are read from the environment variables FGC_MQTT_USER / FGC_MQTT_PW first,
falling back to the config file. Keep credentials out of config.ini (which is gitignored anyway) by exporting
them or using a systemd EnvironmentFile.
The log file
A tower runs detached for days inside a tmux session, and in TUI mode every log line goes into a 500-line on-screen ring buffer and nowhere else — so the record of what it did dies with the session. The one moment you want it, "why is this axis not moving?", is exactly when it is gone.
So the program also writes ~/.local/share/fire_gimbal_control/logs/fgc_<stamp>.<NNN>.log,
alongside the existing test_*, diag_* and pitch_setup_* reports. On by
default.
The file is deliberately more verbose than the console. level governs the
dashboard, file_level the file, and the two are independent — the default pair
is a readable info on screen and a detailed debug on disk. Consequently
--log-level off quietens the console and the tower keeps recording; use
--no-log-file to stop writing.
Wire traces reach the file only when trace turns them on, for both outputs
at once. That gating is not cosmetic: the serial trace alone is about 4 ST
lines per second, so steady-state logging goes from roughly 5 MB/day to
41 MB/day — enough to churn through the whole retained window in a couple of
days and age out the startup record you may also have wanted.
A new file is started per run and rotated by size within it, oldest pruned by
count, so the footprint is bounded at file_keep × file_max_mb (200 MB by
default). Each file opens with a header naming the build, tower, PID, config and
hardware paths and the full command line, re-emitted after every rotation so an
archive found on its own is still self-describing. Lines carry a YYYY-MM-DD
prefix the console ones do not, because a file may span days.
Failures before the config is read — bad arguments, no
config.inifound — happen before the log's own path is knowable and stay console-only.
Command-line flags
Parsed by Boost.Program_options (main.cpp). Flags override [Features].
| Flag | Effect |
|---|---|
-h, --help |
Show help and exit |
-c, --config <path> |
Explicit config file path |
--no-mqtt |
Disable MQTT (use the null channel) |
--mock-camera |
Use the simulated camera |
--mock-camera-full |
Simulated camera with tower-size 12-bit frames (implies --mock-camera) |
--mock-serial |
Use the simulated motor controller |
--mock-env |
Use the simulated ambient temp/humidity sensor (implies [Features] enable_env) |
--degraded |
Start even though declared hardware is missing (field triage; overrides strict_presence and says so loudly in the log) |
--tui |
Show the full-screen terminal dashboard (overrides [UI] enable_tui) |
--no-tui |
Force the headless line console (overrides config; wins over --tui) |
--log-level <lvl> |
Console level: trace/debug/info/warn/error/off. Does not silence the log file |
--no-log-file |
Do not write a persistent log file this run |
--log-file-level <lvl> |
File level, independent of --log-level (default debug) |
--trace <cats> |
Verbatim wire trace; comma list serial,mqtt,camera,control,all,none |
Homing and the start mode are not flags: they are [Startup]
home / mode. The old -i, --init and -s, --start are refused with a message saying so.
--test ignores [Startup].
Typical headless dev run: scripts/run.sh --mock-serial --mock-camera --no-mqtt, with
[Startup] mode = auto in the dev config.ini to start sweeping at once.
Init sequence ([Startup] home = true)
Sends ENABLE Y, ENABLE P, HOME, then polls telemetry until both axes report READY
(firmware ST state A), bounded by the firmware's 60 s homing timeout, then sets SPEED Y/P
(src/core/Application.cpp, runInitSequence).
Interactive console commands
Lines on stdin are parsed by parseCommand (src/core/CommandParser.cpp) — a
whitespace tokenizer (<verb> [device] [option] [value]) that replaced the old fragile Boost.Spirit grammar.
Handled in Application::Impl::handleCommand.
| Type this | Meaning |
|---|---|
mode |
Say which mode the tower is in: auto, pan or manual |
mode auto |
Sweep the scan grid (camera acquisition + capture) |
mode pan <heading> <span> [<pitch>] |
Watch one direction: sweep heading ± span/2 in pan_step_deg steps, pitch default 0 |
mode manual |
Stop capturing; every command accepted. In auto and pan, only reading commands, debug/trace, mode, cancel and exit are |
debug |
Toggle debug logging |
trace <cat> [on|off] |
Toggle a wire-trace category (serial/mqtt/camera/control) |
trace all / trace off |
Enable every category / silence all |
set camera profile [<camera>] <name> |
Switch a camera to another profile from its profile_dir (the camera may be omitted when only one is configured) |
snapshot |
One image now, through the scan's settle-and-trigger path (manual mode, homed) |
capture [on|off] |
Continuous capture where the gimbal is aimed: one image per image_interval since the last, each once the gimbal has stood still for settle_wait_ms. Manual mode, homed; ends on any mode change, homing, heading setup or test. Bare: report |
camera reset |
Reboot the cameras and put them back on their profiles |
thermal |
The CPU's temperature, clock, load and throttling, and whether the scan is slowed or paused for it. Any mode |
thermal resume / thermal off / thermal on |
Back to full pace now (the rules then apply afresh) / turn the CPU guard off or on until restart. Any mode |
test camera [frames|dark|bright] |
Check the camera delivers frames and responds to exposure/gain |
test [<subsystem> [<leaf>]] [<profile>] |
Run the hardware self-test suite (see below) |
test list / test baseline |
List profiles / pin the last report as the comparison baseline |
imu status |
One-line orientation + sample age |
imu config |
The configuration read back from the device (product, firmware, output mode, sample rate, XKF profile) |
imu profile [<type|label>] |
Show the available XKF profiles, or switch to one (by numeric type or by name, e.g. VRU_general) |
imu zero |
Heading reset — make the current yaw the zero reference |
imu norotation [<seconds>] |
Gyro-bias update; the sensor must be still for the whole window |
help [topic] |
The command catalog, or one section/verb expanded |
exit |
Quit (Ctrl-D also works) |
The full catalog with per-command detail lives in helpCatalog
(src/core/HelpText.cpp) and is what help prints — the table above is a
summary, and the gimbal … positioning/diagnostic verbs are documented there and in the motor
vocabulary section below.
test — hardware self-test suite
test validates the gimbal/IMU/host on real hardware, writes a text report to
logs/test_*.log, and compares each run to a pinned baseline and the previous run.
It is modular (test gimbal, test imu drift, test host, …) and driven by
profiles configured under [Test] / [TestProfile.<name>] in config.ini
(repetition counts, sampling windows, pass thresholds; default standard). While a
test (or calibration, or homing) runs, interfering commands are ignored and Esc
cancels. A stop is not ignored: exit, Ctrl-C or SIGTERM (scripts/fgc stop) during homing
cancels the homing, as Esc does, and shuts the program down. Full reference: test-command.md. Replaces the former
gimbal diag (now folded into test gimbal encoder).
Terminal dashboard (TUI)
An optional full-screen interface (--tui, or [UI] enable_tui = true) renders the tower
status as sectioned, colored panels updated in place, with a scrolling log pane and a nano-style
key bar. It is built on FTXUI (fetched via
cmake/Ftxui.cmake when WITH_TUI=ON, the default) and is fully decoupled
from application logic: the control loop publishes a plain UiSnapshot
(include/fgc/ui/UiSnapshot.h) that the UI renders, and the UI
forwards keystrokes/typed commands back through the same command queue the console uses. Headless
operation is unchanged and remains the default — the same binary runs under systemd/ssh/pipes with
logs on stdout.
Dashboard panels: Gimbal (per-axis state, heading, encoder counts, flag badges, target),
Sensors (the Xsens MTi shows live roll/pitch/yaw once enable_imu; the ambient
SHT41 shows live temp/humidity once enable_env. The SHT41's is the only temperature on the main window: the MTi's
device-internal temp is diagnostic detail and appears solely in the expanded view), Cameras (count, capture state, rate, last capture), Connectivity (MQTT state,
broker, tower, the mode as published on StatusCode). The LOG title names the file the log is being
written to, and follows the newest line. Adding a panel later is a struct in UiSnapshot.h plus
one node in src/ui/TuiUi.cpp.
Expanded takeover views replace the dashboard body full-screen (Esc or the same key closes):
- Gimbal (
g) — both axes side by side: live telemetry, the decoded firmware register dump (drequests a fresh one), the homing limits, and the last calibration result (per-axiscounts_per_deg/deg@ctr/ R² / age). - Cameras (
c) — a block per configured camera: identity (name,label,id,driver, enabled), the active profile and the others available, theprofile_dirand the exact XML applied, the per-profilesettle_wait_ms,rotation_degand the JPEG XL settings, plus live per-device telemetry (dimensions, streaming, frames, drops) and the scan grid. - Connectivity (
n) — broker, tower, client id, connection state and mode, the delivery counters (sent / failed / connects / losses), and a history of the last MQTT messages — time, topic leaf and payload — so a publish can be confirmed without a broker-side subscriber. The compact Connectivity panel carries thenbadge that opens it. - Sensors (
s) — both sensors, in headed sections so every reading is attributable. MTi: every channel with units — orientation (°), acceleration (m/s²), rate-of-turn (rad/s), magnetic field (a.u.),TEMP (internal), sample counter, plus an IMU CONFIG section (read back from the device at startup): product code, firmware, device ID, output mode/format, calibration channels, sample rate, and the Xsens Kalman Filter (XKF) profile list — every profile the device supports, by name, with the active one marked●(selected). SHT41:TEMP (ambient), humidity, the age of the last sample, and the configured I2C bus (device, address, poll period — hidden for the mock). Empty states name what to enable (enable_env/--mock-env) or, when enabled but silent, the staleness window that was missed.
Activity strip — a compact section between the log and the key bar that shows the
currently-running special operation with live progress (test, gimbal pitch setup, homing, the
auto sweep, a pan, heading setup) and persists the last test/calibration result (PASS/FAIL, age) so it doesn't scroll
away in the log. It only appears once something has run. While a procedure is running, Esc
cancels it.
When a gimbal pitch setup or gimbal heading fit completes it is applied to the live session and the
strip shows a highlighted yes/no prompt — Save the new [Motor] calibration to config as the default? (y / n). Press y to write the [Motor] map — *_counts_per_deg, *_center_count,
yaw_heading_at_center/pitch_angle_at_center and *_soft_margin_counts — back into the
config.ini the program was launched with (replacing those keys in place, preserving comments and
everything else) so it persists across restarts; press n to keep it for this session only. The
keys are active only while the prompt is showing. The file is written to a temp file and renamed over
the original, so an interrupted save cannot truncate your config.
gimbal heading offset saves immediately without asking — it is a one-line correction, not a fit.
The key bar is contextual: it advertises the keys that are useful in the current view, not every
binding. a auto, p pan (30° around where the gimbal points now) and m manual lead everywhere —
the mode can be changed from any view — and : command, ? help and q quit close every set. The
overview adds h home, g s c n views and f pause; an expanded view offers its own (d dump in
the gimbal view, Esc close). In manual mode the arrow keys nudge the gimbal
(yaw ±5 % / pitch ±10 %; a yaw-only tower has only ←→, and the bar shows just those) from any view, Space takes a picture (snapshot) and r toggles continuous
capture (capture on|off). Every key that acts sends a command, so nothing is dashboard-only. Every binding still works
everywhere; only the advertising changes.
Scrolling is PgUp/PgDn only, the arrows being the gimbal's. The log, the message history and a
freshly pulled gimbal dump jump to their newest line whenever one arrives, so to read further back,
pause first: f freezes the whole frame (log included) until f again, and the header says when it
froze. It is also how to select and copy text.
q asks rather than quitting. It opens a prompt: q quit (stop capture, close the camera,
end the program), b background (detach and leave it running — the same as Ctrl-b d, shown
dimmed and unavailable when the program is not inside a scripts/fgc tmux session), Esc
cancel. Plain letters are used rather than Ctrl chords so terminal flow-control
(Ctrl-S/Ctrl-Q XON/XOFF) can't swallow them. In TUI mode all log output is diverted
from stdout into the on-screen log pane via a Logger sink, so the screen is never corrupted.
Build without it (-DWITH_TUI=OFF) for a smaller, dependency-free binary; --tui then warns and
runs headless.
Logging: level vs. wire-trace categories
Two independent controls, each settable via config ([Logging]), CLI, and a console command:
- Linear level —
[Logging] level,--log-level, consoledebug. Filters ordinary messages (trace<debug<info<warn<error<off). Defaultinfo; an active capture is nearly silent atinfo. - Wire-trace categories —
[Logging] trace,--trace, consoletrace. Each enabled category (serial,mqtt,camera,control) logs every message exchanged with that subsystem, verbatim. They are off by default and independent of the level:--trace serialprints all firmware serial traffic even atinfo. Only leveloffsilences them. Thecameracategory is high-rate (one line per frame, metadata only — no pixel data).
Precedence at startup: config applies first, then CLI overrides (--trace replaces the config set,
it does not merge). At runtime the trace console command edits categories incrementally.
Trace lines carry a category tag and a TX/RX direction so one subsystem is easy to follow/grep:
[SERIAL] TX MOVE -90,0 (command to firmware: yaw,pitch counts)
[SERIAL] RX ST Y:A,982,969,80084000,0,8,8,Se P:A,... (status from firmware)
[MQTT] PUB GGS/FWT/Tower/StatusCode 0 / [MQTT] RX GGS/FWT/Tower/Command mode pan 180 30
[CAMERA] TX trigger cam0 / [CAMERA] RX frame cam0 1936x1216 7064576B
[CONTROL] sweep -> grid yaw=-90 pitch=0 (scheduler decisions + inbound console commands)
Motor command vocabulary (emitted by the software)
The firmware speaks full-word, newline-terminated commands in absolute encoder counts
(see ../firmware/docs/protocol.md). The host converts degrees↔counts via the [Motor] calibration.
| Command | When | Meaning |
|---|---|---|
ENABLE Y / ENABLE P |
init | energize the axis coils |
HOME |
init | home all axes (firmware runs it non-blocking; watch ST state R→H→A) |
SPEED Y|P <vel> |
init | set the production move speed (VMAX, counts/s) |
CURRENT Y|P <scaler> <irun> <irun_home> |
init, before HOME |
motor current as TMC register fields, converted by the host from [Motor.<axis>] milliamps |
CENTER Y|P <count> |
init, before HOME |
the count homing anchors the travel midpoint to |
MARGIN Y|P <counts> |
init, before HOME |
clearance kept from each hard endstop |
MOVE <yaw>,<pitch> |
each capture point; gimbal move/steps with a pitch |
drive both axes to absolute counts (combined form) |
MOVE Y|P <pos> |
each capture point on a yaw-only tower; gimbal move/steps without a pitch; nudge, jog |
single-axis absolute move. A yaw-only firmware refuses the combined form, so the host sends MOVE Y there |
STOP Y|P|ALL |
— | ramp to a controlled stop |
Motor current, and why the host computes it
The TMC5160 expresses coil current as two dimensionless registers, and what a given pair
delivers depends on the sense resistor soldered to the board. Only hardware.ini knows that
resistor, so the host converts milliamps to registers and sends the result; the firmware
keeps compile-time defaults so a bench board still behaves.
homing_current_ma exists for one reason: an endstop the motor can out-push is not an
endstop. Homing detects a stop by driving into it until the shaft falls behind the
commanded position. On the IOTA tower, whose gear ratio was raised for wind stability, the
motor simply sheared one screw-head stop past the other — it ran 1.8 turns past the stop with
the encoder never falling behind. At 189 mA it homes against the same screw cleanly.
The startup log names what the registers actually deliver, which is not always what was asked for:
yaw motor current: run 756 mA (GLOBALSCALER 80, IRUN 31), homing 189 mA (IRUN 7)
-- R_sense 0.075 ohm from hardware.ini [Motor.yaw]
Because both registers are write-only on the chip, the firmware echoes them in DUMP and the
host checks them before homing. If an axis declares a reduced homing current and the
firmware has not adopted it, the host refuses to home rather than driving into a stop at
full torque — the fire it is there to prevent.
The config handshake — the firmware asks, hardware.ini answers
The firmware used to carry a full parameter table and guess when the host said nothing, so every drivetrain value existed in two places and the two could disagree. It no longer guesses: it declares what it needs and refuses to move until it has it.
firmware -> BOOT build=abc123
firmware -> NEED Y steps_per_rev global_scaler sw_mode enc_deviation normal_speed ...
host -> SET Y steps_per_rev 1210778
host -> SET Y global_scaler 80
host -> SET Y normal_speed 39830
...
SPEED, CENTER, MARGIN and CURRENT remain as aliases, so an old firmware
and a hand-typed session both keep working; center and margin are accepted as
key names alongside center_count and soft_margin for the same reason.
Three rules, each protecting against a different silent failure:
- A value the host does not have is left unanswered, never sent as zero. A
zero here is a real setting — a zeroed current is a motor that cannot hold, a
zeroed VMAX is a stopped axis. The axis stays in
NEEDCFGand the missing keys are named atERROR, because nothing but editinghardware.iniwill make the gimbal move. - A key the host has never heard of is reported separately from one it simply
lacks. The first means the firmware is newer than the host and the fix is to
upgrade the host; the second means
hardware.iniis incomplete. Conflating them sends someone to edit the wrong file. - A partial answer does not unlock the axis. Most keys landing is the common case when a declaration is nearly complete, and it must not look like success.
An axis waiting on its config reports
NEEDCFGin the dashboard andCin theSTline — deliberately not red, and deliberately not "no telemetry": the link is fine and the axis is talking, it just has not been told what it is driving.Retuning needs only a restart. The firmware asks exactly once, at boot, and only while it is in
NEEDCFG— so the handshake alone would mean that editinghardware.iniand restarting the host changed nothing, because the board is already configured and says nothing to reply to. The host therefore re-sends the whole declared block on every connect, whether or not it was asked. Edit, restart, done; no re-flash.It also makes the handshake robust to a
NEEDline lost on the wire: an axis still sitting inNEEDCFGis completed by that re-send rather than refusing to move until someone notices.Both halves are live, and validated on tower 121: the firmware emits
NEEDat boot and refusesHOME/MOVE/ENABLEuntil every required key is answered, and the host answers it. On a dev box with no board attached, set[Features] mock_needcfg = 1to make the simulated controller run the same handshake, or0to have it stand in for a board that is already configured — which is what exercises the unprompted re-send.
Why speed is declared in degrees per second
VMAX is a register value, and what it means depends on the gear ratio:
| Tower 111 (Delta) | Tower 121 (IOTA) | |
|---|---|---|
counts_per_deg |
983.33 | 3552.87 |
VMAX 30000 is |
21.8 °/s | 6.0 °/s |
Both towers were carrying the same literal 30000, running at speeds differing by
3.6× — exactly their gear-ratio difference. A register value copied between towers
produces a silently different machine, which is why the speed is declared as the
physical quantity and converted per tower.
The conversion is v[counts/s] = VMAX × f_CLK / 2²⁴ with f_CLK = 12 MHz, divided
by counts_per_deg. That single factor suffices because the firmware keeps
XACTUAL (step space) and XENC (encoder space) on the same scale — see
firmware/docs/hardware-notes.md, "Encoders track 1:1 but must be aligned to
XACTUAL". The 0.7153 counts/s per VMAX unit this predicts is within 2% of the 0.73
measured across three homing runs on tower 121.
The speed is sent at the end of the init sequence, after counts_per_deg has
been re-derived from the travel just homed, so it always uses the current scale.
What is logged is what the axis will actually do, which differs from the request by
the integer quantisation:
yaw speed: 8.00012 deg/s (VMAX 32897) -- 2941.18 counts/deg
An axis whose scale is not trustworthy is not commanded at all — the firmware
keeps its compile-time default and the reason is logged. The floor is physical:
the AS5047P emits 4000 steps/rev in ABI mode, so even an ungeared axis reads 11.1
counts/deg, and anything below that is an uncalibrated axis still carrying
AxisMap's default of 1.0 rather than a real drivetrain.
This replaced two hardcoded literals —
SPEED Y 50000andSPEED P 150000— which contradicted the firmware's own tuned-down defaults and won. On the Delta fleet that was 36.4 °/s against an intended 21.8 °/s.
Deriving counts_per_deg from the homed travel
travel_deg is a nameplate fact: the mechanical angle between the stops. Homing measures the
same span in counts, so dividing gives the degree scale. On a successful home the host
compares the two and, when they disagree by more than a factor of two, adopts the derived
value and offers it at the usual y/n prompt, saving to config.ini with a ;prev line.
Within a factor of two it only logs the comparison. That guard matters: a gimbal heading setup landmark fit is a better measurement than this one, and a routine re-home must not
quietly overwrite it.
The derived scale is only as good as travel_deg, which is usually a rough figure — a 1 %
error in the angle is a 1 % error in every bearing. gimbal heading setup supersedes it.
Capture is a move → settle → trigger cycle: the scheduler issues a MOVE, waits until both
axes report standstill at the target, then triggers the cameras. In auto mode it walks the scan
grid (front to back, repeating); in pan mode it walks the pan's headings left to right at the pan's
pitch, then starts again from the left edge (see mqtt-api.md). Telemetry arrives as
firmware ST lines (per-axis state + encoder counts), parsed by
TelemetryParser.
Thermal camera (driver = taris)
A TarisIR mini beside the colour camera takes its picture at the same waypoints
and intervals: the colour camera is asked first, and only once it has delivered
is the thermal one asked for its newest frame. Both frames carry the colour
frame's timestamp, so RGB/<t>.jxl and TIR/<t>.jxl are a pair (and so are
their CamEvents, see mqtt-api.md).
- Best effort. A thermal camera that is missing, unlicensed or has no fresh
frame costs that picture its thermal image, nothing more: the scan goes on, the
camera reconnects by itself, and Health
cams_offlinenames it meanwhile. - Always lossless. The counts are measurements;
jxl_distancemust be 0 (the default for this driver) and the loader refuses anything else. - No settings XML. The camera is set up from its keys;
profileonly picks a[Camera.<name>.<profile>]timing block, which it does not need: without one its wait is 0 and the colour camera's settle wait alone sets the pace. The frame it hands over is at mostmax_frame_age_msold, so that must stay below the colour camera'ssettle_wait_ms. - Kept grabbing: from start to exit, also in manual mode. A microbolometer that is restarted begins with zero frames and a fresh NUC.
- Split between the files like any camera:
driver,idandinterfacein hardware.ini (withtirin[Hardware] cameras), the rest in config.ini.save_queue_maxcounts frames, so raise it by 2. - The binary needs
cap_net_raw(the SDK uses a raw socket): tower-installation.md.