fwt_software/docs/configuration.md

647 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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](../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:
```bash
cp config/config.example.ini config.ini
```
## `config.ini` keys
Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../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 |
| `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` |
| `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](../include/fgc/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](../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](../include/fgc/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](../include/fgc/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](../include/fgc/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](../include/fgc/sensors/Sht41Protocol.h)); 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](../include/fgc/IEnvSensor.h)), so swapping in a different ambient sensor
later means adding a new backend, not touching `Application`, the UI, or MQTT.
### 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](../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`) |
| `--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](../src/core/Application.cpp), `runInitSequence`).
## Interactive console commands
Lines on stdin are parsed by `parseCommand` ([src/core/CommandParser.cpp](../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](../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](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](https://github.com/ArthurSonzogni/FTXUI) (fetched via
[cmake/Ftxui.cmake](../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](../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](../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.
### 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](../src/core/TelemetryParser.cpp).