fwt_software/docs/configuration.md

46 KiB
Raw Blame History

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):

  1. --config <path> CLI flag
  2. $FGC_CONFIG environment variable
  3. ./config.ini (current directory)
  4. <executable dir>/config.ini
  5. $XDG_CONFIG_HOME/fire_gimbal_control/config.ini (else ~/.config/...)

If none exist, the program prints every location it searched and exits. Start from the template:

cp config/config.example.ini config.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 int > 0 5 Seconds between captures (→ image_rate = 1/interval)
General debug bool false Start with debug-level logging
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 — USB DEV_ id or GigE IP. Required when enabled
Camera.<name> driver vimba vimba Backend. Only vimba is implemented
Camera.<name> label string the block name Output subdirectory and CamEvent cam field
Camera.<name> profile_dir string — 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 Abandon the waypoint after this
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
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 Use the Xsens MTi orientation/IMU
Features mock_camera bool false Use the simulated camera
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 Use the ambient temp/humidity sensor (SHT41)
Features mock_env bool false Use the simulated env sensor instead of the SHT41
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 Linear log level (--log-level overrides)
Logging trace csv — Wire-trace categories, off by default (--trace overrides)
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
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)
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
Scan grid_file string — CSV of yaw_deg,pitch_deg waypoints; empty → generate
Scan yaw_intervals int 56 Generated yaw positions across [yaw_min_deg, yaw_max_deg]
Scan yaw_min_deg/yaw_max_deg float -90/90 Generated yaw arc
Scan pitch_levels csv 0 Generated pitch elevations (deg)

[Motor] calibration & [Scan] grid

The firmware reports only encoder counts; [Motor] maps them to the bearing/elevation degrees used by MQTT (target_HDG) 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_count silently 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.

In the TUI press H (manual mode only):

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 Freeze the position and type that landmark's true bearing
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. stop 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. Set [Scan] grid_file to an editable CSV (config/scan.csv) to define exact coordinates, or leave it blank to generate yaw_intervals × pitch_levels points.

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. Generated grids interpolate yaw_min_deg → yaw_max_deg linearly and wrap each point, so an arc crossing north is written monotonically (340 → 380).

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 i). 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 refresh it: press r (or type refresh). That re-queries the device (briefly pausing the stream) and also requests a fresh firmware dump for the gimbal g view.

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 (i), 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 kMtiReference to ImuReference::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 (i) 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.

[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:

  • i2cdetect false-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, then i2ctransfer -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 N reports I2C = 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.

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, CaptureScheduler sends the single-axis MOVE Y form. 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.ini is never reconciled, whatever strict_presence says. Gaining that file must not be what changes how a tower behaves; nothing is compared until something is declared.
  • An empty axes states nothing, matching hasAxis() 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 camera is 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.

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
-i, --init Run the endstop-finding init sequence before the loop
-s, --start Start capture automatically
-d, --demo Demo mode: copy the placeholder image instead of encoding
--no-mqtt Disable MQTT (use the null channel)
--mock-camera Use the simulated 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> trace/debug/info/warn/error/off
--trace <cats> Verbatim wire trace; comma list serial,mqtt,camera,control,all,none

Typical headless dev run: scripts/run.sh --mock-serial --mock-camera --no-mqtt --start.

Init sequence (--init)

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
start Start camera acquisition + capture
stop Stop acquisition
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)
set camera jxlq <v> JPEG XL distance (0 = lossless)
set camera jxle <v> JPEG XL effort
set camera display <0|1> Toggle OpenCV preview window
set camera fps <v> Camera acquisition frame rate (real camera only)
set fps <v> Capture interval rate (images/second)
set motorctl <cmd> Forward a raw command to the motor controller (e.g. set motorctl MOVE Y 20000)
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
refresh Re-read the IMU configuration and request a fresh firmware dump
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. 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), Camera (count, capture state, rate, last capture), Connectivity (MQTT state, broker, tower, control mode, target heading). 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 (d requests a fresh one), the homing limits, and the last calibration result (per-axis counts_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, the profile_dir and the exact XML applied, the per-profile settle_wait_ms, rotation_deg and the JPEG XL settings, plus live per-device telemetry (dimensions, streaming, frames, drops) and the scan grid.
  • Connectivity (m) — broker, tower, client id, connection state and control 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 the m badge that opens it.
  • Sensors (i) — 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, capture scan) 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. s start and x stop lead everywhere — a scan can be started and stopped from any view — and : command, ? help and q quit close every set. The overview adds h home, r refresh, g i c m views, p pause and the arrow keys (nudge the gimbal, yaw ±5 % / pitch ±10 %); an expanded view instead offers its own (d dump in the gimbal view, r refresh in sensors, the scroll keys, Esc close). Every binding still works everywhere; only the advertising changes. p freezes the live refresh so text can be selected and copied.

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, console debug. Filters ordinary messages (trace<debug<info<warn<error<off). Default info; an active capture is nearly silent at info.
  • Wire-trace categories — [Logging] trace, --trace, console trace. 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 serial prints all firmware serial traffic even at info. Only level off silences them. The camera category 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/target_HDG 180
[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 drive both axes to absolute counts (combined form)
MOVE Y|P <pos> — single-axis absolute move (set motorctl)
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.

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 50000 and SPEED 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. ControlCode 0 walks the scan grid (front to back, repeating); ControlCode 1 drives yaw to target_HDG (pitch held). Telemetry arrives as firmware ST lines (per-axis state + encoder counts), parsed by TelemetryParser.