fwt_software/docs/modules-reference.md

160 lines
23 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.

# Module Reference
Per-file reference for the refactored tree, plus the shared data structures.
## Core (`fgc_core` — SDK-independent, unit-tested)
| File | Contents |
|------|----------|
| [include/fgc/Config.h](../include/fgc/Config.h), [src/core/Config.cpp](../src/core/Config.cpp) | Typed `AppConfig` (General/Network/Serial/Camera/Paths/Features/Logging/UI/Motor/Scan/**IMU**) + `ConfigLoader` (INI parse, env overrides, validation) |
| [include/fgc/Paths.h](../include/fgc/Paths.h), [src/core/Paths.cpp](../src/core/Paths.cpp) | `~`/`$ENV` expansion, executable dir, config search order, default output dir |
| [include/fgc/IniEdit.h](../include/fgc/IniEdit.h), [src/core/IniEdit.cpp](../src/core/IniEdit.cpp) | Editing `config.ini` and `hardware.ini` without breaking them, as pure functions on file contents: set keys keeping comments, order and one `;prev` generation, comment out keys `hardware.ini` owns (without their value, so `deploy.sh`'s placeholder guard stays quiet), and refuse lines too long for inih to read whole. `tools/fgc_config.cpp` does the file IO |
| [include/fgc/SingleInstance.h](../include/fgc/SingleInstance.h), [src/core/SingleInstance.cpp](../src/core/SingleInstance.cpp) | Refuses a second running copy of the program, which would fight the first over the motor's serial port and the camera: an advisory `flock` on a lockfile holding the owner's PID, taken in the binary so every way of starting it is covered |
| [include/fgc/Logger.h](../include/fgc/Logger.h), [src/core/Logger.cpp](../src/core/Logger.cpp) | Leveled, thread-safe logger + per-category wire trace; `LOG_TRACE..LOG_ERROR`, `LOG_TRACE_CAT` |
| [include/fgc/FileLogSink.h](../include/fgc/FileLogSink.h), [src/core/FileLogSink.cpp](../src/core/FileLogSink.cpp) | The persistent log file: append, size rotation, count-based pruning, a self-describing header per file. Runs inside `Logger`'s non-recursive lock, so it never logs - failures latch into `failed()`/`lastError()` for the caller to report |
| [include/fgc/Geometry.h](../include/fgc/Geometry.h), [src/core/Geometry.cpp](../src/core/Geometry.cpp) | Per-axis degrees↔encoder-counts affine map (`[Motor]` calibration); `encoderPitchDeg` (the CamEvent pitch when there is no IMU — the fixed `deg_at_center` on a yaw-only tower) |
| [include/fgc/ScanGrid.h](../include/fgc/ScanGrid.h), [src/core/ScanGrid.cpp](../src/core/ScanGrid.cpp) | Capture waypoints (CSV only, never generated; a bare `yaw` line is pitch 0) + one-way wrapping cursor (`[Scan]`); `repeatedBearings` for the yaw-only audit |
| [include/fgc/TelemetryParser.h](../include/fgc/TelemetryParser.h), [src/core/TelemetryParser.cpp](../src/core/TelemetryParser.cpp) | `parseTelemetryLine` (firmware `ST` line) → `std::optional<MotorTelemetry>` |
| [include/fgc/CommandParser.h](../include/fgc/CommandParser.h), [src/core/CommandParser.cpp](../src/core/CommandParser.cpp) | `parseCommand` whitespace tokenizer → `Command`; `parseAim` (`<yaw>[,<pitch>]` for `gimbal move/steps`) and `moveCommand` (the firmware `MOVE`: the pair, or `MOVE Y` without a pitch) |
| [include/fgc/HelpText.h](../include/fgc/HelpText.h), [src/core/HelpText.cpp](../src/core/HelpText.cpp) | Operator command catalog (`helpCatalog`) + `renderHelp` (console & TUI help) |
| [include/fgc/TmcCurrent.h](../include/fgc/TmcCurrent.h), [src/core/TmcCurrent.cpp](../src/core/TmcCurrent.cpp) | Milliamps → TMC5160 `GLOBALSCALER`/`IRUN` register fields, using the sense resistor from `[Motor.<axis>]`. Pure arithmetic, so it is unit-tested; the host owns the conversion because only it knows the resistor |
| [include/fgc/HardwarePresence.h](../include/fgc/HardwarePresence.h), [src/core/HardwarePresence.cpp](../src/core/HardwarePresence.cpp) | `reconcilePresence` (what `hardware.ini` declares vs what actually came up) + `presenceVerdict` (proceed / warn / refuse, per `strict_presence` and `--degraded`). Pure comparison, so the policy is unit-tested without a rig |
| [include/fgc/HardwareChecks.h](../include/fgc/HardwareChecks.h), [src/core/HardwareChecks.cpp](../src/core/HardwareChecks.cpp) | The verdicts behind `test hardware`: `checkUsbfsMemory`, `checkUsb3Lpm`, `checkI2cFunctionality`, `checkDrivetrainAdopted`. Pure, so the thresholds that encode known-issues #2 and #19 cannot drift unnoticed |
| [include/fgc/AxisSpeed.h](../include/fgc/AxisSpeed.h), [src/core/AxisSpeed.cpp](../src/core/AxisSpeed.cpp) | `planAxisSpeed` (deg/s -> TMC `VMAX` through `counts_per_deg`) + `vmaxToDegPerSec`. Pure arithmetic; refuses an uncalibrated scale rather than commanding a near-zero speed |
| [include/fgc/FirmwareConfig.h](../include/fgc/FirmwareConfig.h), [src/core/FirmwareConfig.cpp](../src/core/FirmwareConfig.cpp) | `parseNeedLine` (firmware `NEED` line -> axis + keys) + `planFirmwareConfig` (-> the `SET` lines that answer it). Reports a key it cannot answer separately from one it does not recognise: incomplete `hardware.ini` vs. a firmware newer than the host |
| [include/fgc/DumpParser.h](../include/fgc/DumpParser.h), [src/core/DumpParser.cpp](../src/core/DumpParser.cpp) | `parseDump` firmware `DUMP` block → `DumpData` (per-axis state + decoded TMC registers/flags); `formatDump`. Prefers the firmware's own `reset=` name over any host-side decode - `mcusr` is an AVR bitmask on one board and a plain enum on the other, so a single table cannot be right for both |
| [include/fgc/DiagParser.h](../include/fgc/DiagParser.h), [src/core/DiagParser.cpp](../src/core/DiagParser.cpp) | `parseDiag` firmware `DG` self-test stream → `DiagResult` (per-axis tests, PASS/FAIL); `formatDiag` |
| [include/fgc/TestRunner.h](../include/fgc/TestRunner.h), [src/core/TestRunner.cpp](../src/core/TestRunner.cpp) | The `test` command: resolves `test [<subsystem> [<leaf>]]` to a set of leaf tests, knows which need a homed gimbal (the `test hardware` leaves deliberately do not), and runs them on a worker thread with live progress, handing back a `TestReport` |
| [include/fgc/TestReport.h](../include/fgc/TestReport.h), [src/core/TestReport.cpp](../src/core/TestReport.cpp) | A self-test run's result as plain data: formats a human-readable text report that `parseTestReport` reads back, so a run can be compared with the baseline or the previous one |
| [include/fgc/HostMetrics.h](../include/fgc/HostMetrics.h), [src/core/HostMetrics.cpp](../src/core/HostMetrics.cpp) | Tower computer health for `test host`: CPU temperature, disk, memory and load. Pure parsers split from the live `/sys` and `/proc` readers so they unit-test anywhere; readers report `ok = false` rather than throw |
| [include/fgc/Calibration.h](../include/fgc/Calibration.h), [src/core/Calibration.cpp](../src/core/Calibration.cpp) | `linearFit` (least-squares, R²) + `circularMeanDeg` for the IMU-referenced calibration |
| [include/fgc/PitchSetup.h](../include/fgc/PitchSetup.h), [src/core/PitchSetup.cpp](../src/core/PitchSetup.cpp) | `gimbal pitch setup` worker thread: home-if-needed → pitch sweep dwelling on the gravity-referenced IMU pitch → least-squares fit → park level; tunable via `CalibParams`; exposes `progress()`/`report()`/`takeResult()`. Yaw is not touched |
| [include/fgc/HeadingFit.h](../include/fgc/HeadingFit.h), [src/core/HeadingFit.cpp](../src/core/HeadingFit.cpp) | `gimbal heading fit`: least-squares fit of the yaw map from operator sightings of landmarks with known true bearings. Sorts by encoder position, unwraps bearings so a set spanning north fits one line, and refuses a baseline under 10° (two points always fit exactly, so a short baseline looks perfect and is wrong). No IMU involved |
| [include/fgc/MtiProtocol.h](../include/fgc/MtiProtocol.h), [src/core/MtiProtocol.cpp](../src/core/MtiProtocol.cpp) | Xsens MTi binary protocol: `MtiFramer` (checksum framing), config-message builders, `parseMTData` → `ImuSample` (temp/acc/gyr/mag/euler), config-readback query builders + `applyImuConfigAck`/`finalizeImuConfig` → `ImuDeviceConfig` (product/firmware/device-id/output mode+settings/sample rate/**XKF scenario**); orientation-control builders `msgSetNoRotation` (gyro-bias update) + `msgResetOrientation` (heading reset / store) + `msgSetFilterProfile` (select XKF profile) + `pickNoMagProfile` (choose a magnetometer-free profile from the available list); named `mtmode`/`mtsettings` bitfields with `mtDataLen` deriving the payload length from them (so `kMTDataLen` cannot drift from what the parser decodes), plus `checkImuDeviceConfig` → `ImuConfigMismatch` (does the device report what the host asked for?) and `mtiErrorText` for MID `0x42` rejections |
| [include/fgc/ImuMount.h](../include/fgc/ImuMount.h), [src/core/ImuMount.cpp](../src/core/ImuMount.cpp) | `ImuMount` (the `[IMU] x/y/z` mounting rotation) and the pure math around it: `mountRotationZXY` (intrinsic Z-X-Y), the `rotationFromEulerZYX`/`eulerZYXFromRotation` pair for the MTi's own yaw-pitch-roll output, `applyFrames`, which moves a whole `ImuSample` into the gimbal's NED frame by re-referencing the earth frame (`ImuReference`/`nedFromReference`, left-multiplied) and then applying the mount (right-multiplied) — the two are not interchangeable, which is why a mount alone cannot correct a Z-up device; `applyMount` is the NED-already shorthand. Body-frame vectors are rotated by the mount only. Returns the sample untouched when there is nothing to do |
| [include/fgc/MountedImuSource.h](../include/fgc/MountedImuSource.h) | Decorator over any `IImuSource` applying that rotation in `sample()`. Wrapped around the factory's output in `Application::makeImu()`, so every consumer reads gimbal-frame values without knowing a mount exists; skipped entirely when the mount is the identity |
| [include/fgc/sensors/Sht41Protocol.h](../include/fgc/sensors/Sht41Protocol.h), [src/sensors/Sht41Protocol.cpp](../src/sensors/Sht41Protocol.cpp) | Sensirion SHT4x wire protocol, pure: command bytes, `sht41Crc8` (poly 0x31/init 0xFF), `decodeSht41Measurement` (6-byte response → `Sht41Reading`, CRC-checked, raw→°C/%RH with RH clamped 0..100) |
| [include/fgc/sensors/McuEnvSensor.h](../include/fgc/sensors/McuEnvSensor.h), [src/sensors/McuEnvSensor.cpp](../src/sensors/McuEnvSensor.cpp) | `IEnvSensor` for the older towers' DHT11, wired to the motor board (`[Hardware] env = dht11`): serves the firmware's latest `ENV` line, and no reading after a failed checksum or 2.5 read periods of silence |
| [include/fgc/CameraProfile.h](../include/fgc/CameraProfile.h), [src/core/CameraProfile.cpp](../src/core/CameraProfile.cpp) | `loadCameraProfile` / `loadCameraProfiles`: one VimbaX settings XML per profile. Hashes the XML, regenerates the `.txt` record when it changes, and reports which settings moved. A missing XML throws — the record is never a fallback |
| [include/fgc/CameraSettingsXml.h](../include/fgc/CameraSettingsXml.h), [src/core/CameraSettingsXml.cpp](../src/core/CameraSettingsXml.cpp) | Parses the Viewer export's `<RemoteDevice>` features (ordered, selector-aware), renders the human-readable `.txt` record, reads one back, and diffs two settings lists. Used for display and change detection only — never to configure a camera |
| [include/fgc/ChannelTopics.h](../include/fgc/ChannelTopics.h), [src/core/ChannelTopics.cpp](../src/core/ChannelTopics.cpp) | The MQTT wire format in one place: topic builders for the `GGS/FWT/<tower>/…` tree and the `CamEvent`/status payload builders. Pure, so the documented format is unit-tested rather than assembled inline at each publish site |
| [include/fgc/ChannelHistory.h](../include/fgc/ChannelHistory.h), [src/core/ChannelHistory.cpp](../src/core/ChannelHistory.cpp) | Ring buffer of recently published messages (time, topic, payload, outcome) plus the delivery counters — sent, failed, connects, losses, last status. Feeds the expanded Connectivity view so a publish can be confirmed without a broker-side subscriber |
| [include/fgc/RemoteCommands.h](../include/fgc/RemoteCommands.h), [src/core/RemoteCommands.cpp](../src/core/RemoteCommands.cpp) | Console commands that arrive over MQTT: run through the same handler as typed ones, on the control thread, and answered on `CommandReply` with every line they logged. Retained messages are never run, a repeated id is answered again rather than re-run, each is audited in the log, and a reply that depends on the firmware (`gimbal dump`) waits for its answer |
| [include/fgc/Hash.h](../include/fgc/Hash.h), [src/core/Hash.cpp](../src/core/Hash.cpp) | FNV-1a 64 over a string or a file, with an algorithm-prefixed label. Change detection for the profile records, deliberately not a crypto hash |
| [include/fgc/CaptureScheduler.h](../include/fgc/CaptureScheduler.h), [src/core/CaptureScheduler.cpp](../src/core/CaptureScheduler.cpp) | Capture state machine over the interfaces: move, standstill, settle wait (+ `SettleRule`), the rest of the interval since the last frame, keep; holds while saving is behind; injectable clock; `setGeometry` adopts a recalibration |
| [include/fgc/SettleRule.h](../include/fgc/SettleRule.h), [src/core/SettleRule.cpp](../src/core/SettleRule.cpp) | The `settle_rule = brightness` keep rule, pure: fed one frame's brightness, exposure and gain at a time, says when the image has settled (in the camera's target band and steady for 3 frames) or is pinned at the auto-exposure limits |
| [include/fgc/RawImage.h](../include/fgc/RawImage.h), [src/core/RawImage.cpp](../src/core/RawImage.cpp) | Pixel layouts the program accepts (`Mono8`, `RGB8`, `BayerRG8`, `BayerRG12p`), their sizes and bit depths, `unpack12p`, and `sampleBrightness` - the cheap per-frame sample the keep rule reads |
| [include/fgc/CameraStall.h](../include/fgc/CameraStall.h), [src/core/CameraStall.cpp](../src/core/CameraStall.cpp) | When a camera stream delivers nothing new: wait, restart the stream, or reset the camera. Pure policy, scaled by the exposure and frame period so a slow frame is not taken for a stall |
| [include/fgc/Application.h](../include/fgc/Application.h), [src/core/Application.cpp](../src/core/Application.cpp) | Factory (real vs mock, headless vs TUI), wiring, control loop, `gimbal …` commands, background-result polling, `buildSnapshot()` |
| [include/fgc/ui/UiSnapshot.h](../include/fgc/ui/UiSnapshot.h), [src/ui/UiSnapshot.cpp](../src/ui/UiSnapshot.cpp) | Plain-data view model (incl. `ImuView`, `EnvView`, `ActivityView`, `CalibResultView`, `DumpView`) + pure formatting helpers (state label/colour, degrees, time-ago, pending-sensors) |
| [include/fgc/ui/Scroll.h](../include/fgc/ui/Scroll.h), [src/ui/Scroll.cpp](../src/ui/Scroll.cpp) | Viewport arithmetic for the scrollable TUI panes, line-anchored. Pure integer maths with no FTXUI types, so it is in `fgc_core` and unit-tested without a terminal |
| [include/fgc/ui/HeadlessUi.h](../include/fgc/ui/HeadlessUi.h), [src/ui/HeadlessUi.cpp](../src/ui/HeadlessUi.cpp) | Default line console: stdin → command sink; logs via the default stdout/stderr writer |
| [ini.c](../ini.c), [ini.h](../ini.h) | Bundled third-party inih INI parser |
## Interfaces
| File | Interface | Shared structs |
|------|-----------|----------------|
| [include/fgc/IMotorController.h](../include/fgc/IMotorController.h) | `IMotorController` | `MotorTelemetry` |
| [include/fgc/IControlChannel.h](../include/fgc/IControlChannel.h) | `IControlChannel` | `RemoteCommand`, `CommandReply`, `CamEvent` |
| [include/fgc/ICameraSource.h](../include/fgc/ICameraSource.h) | `ICameraSource` | `Frame` |
| [include/fgc/IImuSource.h](../include/fgc/IImuSource.h) | `IImuSource` (incl. `config()`, `configProblems()`, `refreshConfig()`, `noRotation()`, `headingReset()`, `setFilterProfile()`) | `ImuSample` (from `MtiProtocol.h`) |
| [include/fgc/IEnvSensor.h](../include/fgc/IEnvSensor.h) | `IEnvSensor` (`connected()`, `sample()`, `name()`) | `EnvSample` |
| [include/fgc/ui/IUserInterface.h](../include/fgc/ui/IUserInterface.h) | `IUserInterface` | `UiSnapshot` |
## Real implementations (SDK-gated)
| File | Implements | Built when |
|------|-----------|-----------|
| [src/serial/SerialMotorController.cpp](../src/serial/SerialMotorController.cpp) | `IMotorController` over Boost.Asio serial (pImpl) | always |
| [src/serial/MtiImuSource.cpp](../src/serial/MtiImuSource.cpp) | `IImuSource` over Boost.Asio serial: configures the MTi to Euler+calibrated, reads back its config (`config()` → `ImuDeviceConfig`), then frames the MTData stream | always |
| [src/sensors/I2cBus.cpp](../src/sensors/I2cBus.cpp) | Thin `/dev/i2c-N` wrapper (open/close, `write`, `writeRead` via `I2C_SLAVE` ioctl) | always |
| [src/sensors/Sht41EnvSensor.cpp](../src/sensors/Sht41EnvSensor.cpp) | `IEnvSensor` over I2C (pImpl): soft-reset, poll thread at `[Env] period_ms`, decode via `Sht41Protocol`, staleness = no valid sample within 2× the period | always |
| [src/mqtt/MqttControlChannel.cpp](../src/mqtt/MqttControlChannel.cpp) | `IControlChannel` over Eclipse Paho | `WITH_MQTT` |
| [src/ui/TuiUi.cpp](../src/ui/TuiUi.cpp) | `IUserInterface` over FTXUI: panels, key bar, log pane | `WITH_TUI` |
| [src/camera/VimbaCameraSource.cpp](../src/camera/VimbaCameraSource.cpp) | `ICameraSource` over Vimba X (pImpl): free-running stream + keep-latest, restart-on-stall, and per-camera profiles applied with the SDK's `LoadSettings` on the Viewer's settings XML | `WITH_VIMBA` |
| [src/camera/JpegXlEncoder.cpp](../src/camera/JpegXlEncoder.cpp) | libjxl encode-to-file, 8-bit or up to 16-bit values (a 12-bit frame keeps its depth); distance 0 is lossless | always |
| [src/camera/ImagePipeline.cpp](../src/camera/ImagePipeline.cpp) | frame → de-Bayer (raw profiles) → rotate → encode → write → CamEvent (`save_workers` background workers; `canAccept()` is the scan's save gate). The pose is sampled in `submit()` and carried with the frame, so it belongs to the pixels rather than to whenever the encode finished | always |
## Mock / null implementations
| File | Implements |
|------|-----------|
| [include/fgc/mock/MockMotorController.h](../include/fgc/mock/MockMotorController.h) | Simulated sweeping gimbal (incl. canned `DUMP`) |
| [include/fgc/mock/NullControlChannel.h](../include/fgc/mock/NullControlChannel.h) | No-op channel; auto-sweep |
| [include/fgc/mock/MockCameraSource.h](../include/fgc/mock/MockCameraSource.h) | Synthetic gradient frames |
| [include/fgc/mock/MockImuSource.h](../include/fgc/mock/MockImuSource.h) | Synthetic IMU (sinusoidal orientation, gravity on accZ) |
| [include/fgc/mock/MockEnvSensor.h](../include/fgc/mock/MockEnvSensor.h) | Synthetic ambient sensor (slow sinusoidal temp/humidity, never stale); `--mock-env` |
## Entry point & scripts
| File | Role |
|------|------|
| [main.cpp](../main.cpp) | CLI parsing → `AppConfig` + `RuntimeOptions` → `Application::run()` |
| [tools/fgc_config.cpp](../tools/fgc_config.cpp) | `fgc-config`: writes and edits a tower's `config.ini` and `hardware.ini` (`seed`, `set`, `get`, `check`) so what lands on disk is always a file the program accepts. Used by `scripts/install-tower`; the rules are in `IniEdit` |
| [tools/camera-bench/](../tools/camera-bench/README.md) | Camera and CPU measurements behind the capture design (frame rates, losses, settle time, encode cost). Not part of the build; results in [camera-capture-measurements.md](camera-capture-measurements.md) |
| [scripts/run.sh](../scripts/run.sh) | Path-independent launcher |
| [scripts/fire-gimbal-control.service](../scripts/fire-gimbal-control.service) | systemd unit template |
## Data structures
### `MotorTelemetry` / `AxisTelemetry` ([IMotorController.h](../include/fgc/IMotorController.h))
Per-axis `yaw` and `pitch` segments (`pitch_present` flag), each an `AxisTelemetry`: `state`
(`AxisState` B/R/H/A/E/C — the last being NEEDCFG, a firmware waiting to be told its drivetrain), `xactual`, `xenc` (encoder counts), `drv_status`, `sg`/`cs`/`pwm`, and flags
`standstill`/`stall`/`deviation`/`overtemp`/`endstop_l`/`endstop_r`, with `moving()`/`ready()` helpers.
`MotorTelemetry::pitchAbsent()` is true for a yaw-only tower — a status line has arrived without a `P:`
segment — and is what the host checks before refusing a pitch command.
Parsed from the firmware `ST Y:...[ P:...]` line; degrees are derived via `Geometry`.
`deviation` (flag `d`) is the TMC5160's hardware encoder-deviation latch — set when `|xactual − xenc|`
exceeds `ENC_DEVIATION`, i.e. the shaft is not where it was commanded to be. It is the usable stall signal
on this rig and is what the TUI's STALL badge shows. `stall` (flag `s`) is StallGuard2, which is inert
because production runs StealthChop; it is still parsed in case that changes. The latch is cleared by
homing or `gimbal reset`, so a lit badge means "position untrustworthy since the last home".
### `Mode`, `PanSpec` ([OperatingMode.h](../include/fgc/OperatingMode.h))
`Mode` is auto (0), pan (1) or manual (2) - the values StatusCode carries. `PanSpec` is a pan's
`heading`, `span` and `pitch` in degrees; `panHeadings()` turns it into the bearings visited, and
`allowedIn()` says which commands a mode accepts.
### `CamEvent` ([IControlChannel.h](../include/fgc/IControlChannel.h))
`tower`, `camera` (RGB/ACR/NIR), `heading_decideg` (yaw×10), `pitch_decideg` (pitch×10) and
`timestamp_ms` (which matches the image filename). Serialized to the CamEvent JSON payload (see [mqtt-api.md](mqtt-api.md)).
### `EnvSample` ([IEnvSensor.h](../include/fgc/IEnvSensor.h))
One ambient reading: `valid`, `temp_c` (°C), `humidity_pct` (0..100), `timestamp_ms` (epoch ms).
`IEnvSensor::sample()` returns `nullopt` rather than a stale sample once nothing valid has arrived
within 2× the configured poll period, so a silently dead sensor reads as absent, not as a frozen
value. Surfaced as `EnvView` in the TUI (`s` view) and as `SensorsView::env` in the compact panel.
### `Frame` ([ICameraSource.h](../include/fgc/ICameraSource.h))
Owned pixel buffer + `width`, `height`, `channels` (1 or 3), `timestamp_ms`, `cam_id`.
### `ImuSample` ([MtiProtocol.h](../include/fgc/MtiProtocol.h))
One decoded Xsens MTi reading: `temp_c` (°C), `acc[3]` (m/s², incl. gravity), `gyr[3]` (rad/s),
`mag[3]` (a.u., earth-normalized), `roll_deg` (−180..180), `pitch_deg` (−90..90), `yaw_deg`
(**−180..180**, exactly as the device reports it), `sample_counter`. `parseMTData` applies no sign flip and no re-ranging to any axis.
`gimbal heading fit` phase-unwraps the sighted bearings (`unwrapNear`) so a set crossing 0/360 still fits a clean
line.
### `ImuDeviceConfig` ([MtiProtocol.h](../include/fgc/MtiProtocol.h))
The device configuration read back during the Config-state handshake (each `has_*` flag marks whether
the device actually answered): `product_code`, `device_id`, `firmware`, output mode flags
(temp/calibrated/orientation/aux/status), output settings (`orientation_mode`, `timestamp_mode`,
per-channel `acc/gyr/mag_enabled`, `data_format`), `period`/`sample_rate_hz`, and the **XKF scenario**
(`scenario_type`/`version`, resolved to `scenario_label` against `available_profiles`). Built by
`applyImuConfigAck` (one ack frame at a time) + `finalizeImuConfig` (derive rate, resolve label).
Surfaced read-only in the TUI's `s` view (IMU CONFIG section); the legacy "scenario" is the modern
"filter profile" — same MIDs (`0x62/0x64/0x65`).
## On-disk artifacts
| Artifact | Path | Format |
|----------|------|--------|
| Captured images | `<output_dir>/<label>/<unix_ms>.jxl` | JPEG XL, rotated per that camera's `rotation_deg`; `<label>` is `[Camera.<name>] label` |
| Diagnostics log | `<data_dir>/fire_gimbal_control/logs/diag_<ts>.log` | raw `DG` stream + parsed summary |
| Calibration log | `<data_dir>/fire_gimbal_control/logs/calib_<ts>.log` | per-axis samples + the least-squares fit |
Diagnostics/calibration results are also surfaced live in the TUI (activity strip + gimbal `g` view)
and applied to the running session's geometry; other state lives in memory.