954 lines
61 KiB
Markdown
954 lines
61 KiB
Markdown
# 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 |
|
||
| `Startup` | `home` | bool | `false` | Home the gimbal at every start (see [Startup](#startup--homing-and-the-start-mode)). 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 | — | 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` | **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_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.
|
||
|
||
```ini
|
||
[Startup]
|
||
home = true ; home the gimbal at every start
|
||
mode = manual ; manual or auto
|
||
```
|
||
|
||
- **`home`** runs the endstop-finding home sequence (see [Init sequence](#init-sequence-startup-home--true))
|
||
before anything else. The firmware keeps no position across a power cycle, so
|
||
without it the axes stay in `RESET` and refuse every move; the one case for
|
||
`false` is a development tower, where nothing should move by itself. To home
|
||
once without changing the file, type `gimbal home` (key `h`).
|
||
- **`mode`** is the mode entered once startup (and homing) is done. `auto` needs
|
||
the gimbal homed; if it is not - homing off, or homing failed - the program
|
||
logs `mode auto refused: gimbal not homed` and stays in `manual`. `pan` needs 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 from `install-tower`:
|
||
it homes at every boot, then waits for someone to choose auto.
|
||
- A mode changed at runtime (`mode auto` over MQTT, key `a`) is **not** written
|
||
back: after a reboot the tower is in its `[Startup]` mode again.
|
||
- A `--test` run 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_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.
|
||
|
||
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](../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, then round to the first again — never in reverse. `[Scan] grid_file` points at an editable CSV
|
||
([config/scan.csv](../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](../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 (`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.
|
||
|
||
### `[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.
|
||
|
||
### 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](mqtt-api.md#modes-auto-pan-manual).
|
||
|
||
### 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:
|
||
|
||
```ini
|
||
[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 `FAN` on the Sensors panel, and fails
|
||
`test hardware env`. This needs `fan_tach`.
|
||
- **`spi_idle_cs`** holds 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.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`.
|
||
|
||
### 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.ini` found —
|
||
> happen before the log's own path is knowable and stay console-only.
|
||
|
||
## 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 |
|
||
| `--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>` | **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]`](#startup--homing-and-the-start-mode)
|
||
`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](../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 |
|
||
|-----------|---------|
|
||
| `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` + standstill + `settle_wait_ms`, kept only at rest. 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 |
|
||
| `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](../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), **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](../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** (`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 the `n` badge 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`, 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/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 `NEEDCFG` and the missing keys
|
||
are named at `ERROR`, because nothing but editing `hardware.ini` will 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.ini` is 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 `NEEDCFG` in the dashboard and `C` in the
|
||
> `ST` line — 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
|
||
> editing `hardware.ini` and 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 `NEED` line lost on the wire: an axis
|
||
> still sitting in `NEEDCFG` is 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 `NEED`
|
||
> at boot and refuses `HOME` / `MOVE` / `ENABLE` until every required key is
|
||
> answered, and the host answers it. On a dev box with no board attached, set
|
||
> `[Features] mock_needcfg = 1` to make the simulated controller run the same
|
||
> handshake, or `0` to 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 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. 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](mqtt-api.md#modes-auto-pan-manual)). Telemetry arrives as
|
||
firmware `ST` lines (per-axis state + encoder counts), parsed by
|
||
[TelemetryParser](../src/core/TelemetryParser.cpp).
|