IMU readings fixed; IMU mounting is part of config.ini; device config verified at startup

This commit is contained in:
pgdalmeida 2026-09-09 12:19:53 +02:00
parent 9023f79c26
commit 5fce32aee7
Signed by: pedro.almeida
GPG Key ID: D4A6C394DF13F1D7
26 changed files with 1616 additions and 46 deletions

View File

@ -60,6 +60,7 @@ add_library(fgc_core STATIC
src/core/Calibration.cpp
src/core/CalibrationRoutine.cpp
src/core/MtiProtocol.cpp
src/core/ImuMount.cpp
src/core/CameraProfile.cpp
src/core/CameraSettingsXml.cpp
src/core/Hash.cpp

View File

@ -79,6 +79,63 @@ baud = 115200
device =
baud = 115200
; The host puts the MTi into a fixed output configuration at every startup
; (Euler + calibrated at 100 Hz) and then READS IT BACK to check the device
; actually took it. There is deliberately no setting for that configuration: it
; is a wire-format contract the parser depends on, not a preference. A mismatch
; is logged as an error and shown as a red MISCONFIGURED row in the Sensors
; view (press i) - the program keeps running so you can diagnose it.
;
; The MTi measures orientation against its OWN earth frame, and the classic
; unit's is NWU (X North, Y West, Z UP) - which reports pitch FALLING as the nose
; rises. The host re-references it to NED. That correction is not settable here:
; it is a fact about the device, stated once in kMtiReference (MtiProtocol.h),
; and shown as the Reference row in the Sensors view (press i).
;
; It is also NOT the same thing as the mount below, and one cannot substitute for
; the other: the earth reference multiplies from the left, the mount from the
; right. No x/y/z value can correct an inverted pitch.
;
; --- Mounting orientation --------------------------------------------------
; How the IMU is bolted to the gimbal. Both the gimbal and the IMU use a NED
; body frame:
;
; X = forward Y = right Z = down
; roll about X pitch about Y yaw about Z
; (positive follows the right-hand rule about that axis)
;
; 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. So an IMU that physically sits turned 90
; deg to the right is corrected with z = -90.
;
; Applied in Z-X-Y order, each turn about the axes as they stand after the
; previous one: first z about down, then x about the new forward, then y about
; the new right.
;
; Worked examples:
; dead level, facing the same way as the gimbal ..... x=0 y=0 z=0
; upside down, still facing forward ................. x=180 y=0 z=0
; turned 90 deg clockwise seen from above ........... x=0 y=0 z=-90
; lying on its right side, facing forward ........... x=-90 y=0 z=0
; upside down AND turned to face backwards .......... x=180 y=0 z=180
;
; Every reading the program produces - orientation, acceleration, rate of turn
; and magnetic field - is rotated into the gimbal frame by this. Leaving all
; three at 0 changes nothing.
;
; Check it on the rig: press `i` for the Sensors view. Every reading there is
; already corrected, and IMU CONFIG shows the mount being applied. 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.
;
; NOTE: `gimbal calib` fits IMU yaw against encoder counts, so a calibration
; made before this was set has the mount's error baked in. Re-run it after
; changing these.
x = 0
y = 0
z = 0
[Env]
; Ambient temperature/humidity sensor (Adafruit SHT41), wired to the
; LattePanda's OWN native I2C bus - NOT the Arduino/motor serial link. Bus

View File

@ -57,6 +57,7 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config
| `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 |
@ -124,7 +125,7 @@ LattePanda the MTi is wired to the **RS-232 header**, i.e. an onboard hardware U
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. Yaw is reported as a **0..360 heading** (not the MTi's native −180..180). Set
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`).
@ -144,6 +145,135 @@ The config is read **once at startup** and cached, so if you change the XKF prof
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 calib` after changing the mount.** Calibration fits IMU yaw against encoder counts, so
a calibration made under a different (or absent) mount has that error baked into `yaw_zero_count`.
`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:

View File

@ -107,17 +107,43 @@ scripts for this are worth keeping; note that wrapping them in `timeout` SIGTERM
## Possible follow-ups (not done)
- **NWU vs ENU is decided by a constant, not by the device.** `kMtiReference`
([MtiProtocol.h](../include/fgc/MtiProtocol.h)) says the MTi reports against NWU, and the
host re-references it to NED. The two candidates give *identical* roll and pitch and
differ only in yaw, by exactly 90°, so nothing in the software can tell them apart —
only a compass check on the rig can. If corrected yaw is 90° off, change that constant to
`ImuReference::Enu`. Reading the device's actual coordinate-system bits would settle it,
but their position in `OutputSettings` is not established for this firmware.
- **`[IMU] x` should be re-confirmed now that the reference is corrected.** `x = 180` was
set while pitch was inverted. Level the gimbal and confirm roll and pitch ≈ 0, tip the
nose up and confirm pitch goes **positive**, roll right and confirm roll goes positive.
Then re-run `gimbal calib` — its yaw reference moved with the earth-frame correction.
- **The handshake still does not await acks for its state transitions.**
`GoToConfig`/`GoToMeasurement` are blind writes separated by 60 ms sleeps
([MtiImuSource.cpp](../src/serial/MtiImuSource.cpp)). The readback verification makes
this harmless for correctness — if `GoToConfig` is missed the Sets do nothing and the
mismatch is reported — but a missed transition is diagnosed one step later than it
happened.
- **A saved calibration silently predates the IMU mount.** `gimbal calib` fits IMU yaw against encoder
counts, so a calibration made before `[IMU] x/y/z` were set — or under different values — has that
mount error baked into `yaw_zero_count`. Nothing detects this; re-run `gimbal calib` after changing
the mount. Stamping the mount into the saved `[Motor]` block and warning on a mismatch would close it.
- **The mount is host-side only.** The rotation is applied to the MTi's output, so the XKF filter itself
still runs in the sensor frame. Xsens `SetAlignmentRotation` (MID 0xEC) would push the alignment into
the device so the filter's own gravity/magnetic corrections are made in the gimbal frame — better
filtering, at the price of writing to the unit's non-volatile memory, which `config.ini` cannot undo.
- **A profile cannot be reloaded without a restart.** `set camera profile <name>` re-applies a profile from
the copy loaded at start-up, so re-exporting the XML while the program runs has no effect. Tuning on the
rig therefore costs a restart per export. Re-reading on switch would fix it, at the price of a failure
mode (a mid-session unreadable export) that start-up loading currently rules out.
- **Nothing checks that a mode actually took.** Features are written and warned about individually, but the
camera is never read back to confirm the resulting state, and a mode whose every write failed still logs
as applied. A post-apply readback of a handful of key features (`AcquisitionFrameRate`, `Width`,
`PixelFormat`) would turn a silent misconfiguration into an error.
- **Nothing checks that a profile actually took.** `LoadSettings` reports success for the file as a whole,
but the camera is never read back to confirm the resulting state. A post-apply readback of a handful of
key features (`AcquisitionFrameRate`, `Width`, `PixelFormat`) would turn a silent misconfiguration into
an error.
- Graceful shutdown on SIGINT (currently exit via `exit`/Ctrl-D; a pending `getline` can delay shutdown).
- Make the camera index→label map fully config-driven. (JPEG XL distance/effort live under `[Camera]`, and
every on-camera setting lives in a mode file — see [configuration.md](configuration.md).)
- Support a second camera backend. `driver` is parsed and validated but only `vimba` is accepted, so a
thermal or NIR unit needs a new `ICameraSource` implementation behind that key.
- Reintroduce optional image upload to the ground station, config-driven (the old hardcoded NFS/SMB upload was
removed).
- **`gimbal calib` persistence**: the fitted `counts_per_deg`/`zero_count` are always applied to the live

View File

@ -18,7 +18,9 @@ Per-file reference for the refactored tree, plus the shared data structures.
| [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/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/CalibrationRoutine.h](../include/fgc/CalibrationRoutine.h), [src/core/CalibrationRoutine.cpp](../src/core/CalibrationRoutine.cpp) | `gimbal calib` worker thread: home-if-needed → pitch sweep at the first yaw position → pitch 0° → switch IMU to a no-mag XKF profile → IMU no-rotation + heading reset → yaw sweep, fitting degrees↔counts; tunable via `CalibParams`; exposes `progress()`/`report()`/`takeResult()` |
| [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) |
| [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/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 |
@ -38,7 +40,7 @@ Per-file reference for the refactored tree, plus the shared data structures.
| [include/fgc/IMotorController.h](../include/fgc/IMotorController.h) | `IMotorController` | `MotorTelemetry` |
| [include/fgc/IControlChannel.h](../include/fgc/IControlChannel.h) | `IControlChannel` | `ControlCommand`, `CamEvent`, `EnvEvent` |
| [include/fgc/ICameraSource.h](../include/fgc/ICameraSource.h) | `ICameraSource` | `Frame` |
| [include/fgc/IImuSource.h](../include/fgc/IImuSource.h) | `IImuSource` (incl. `config()`, `refreshConfig()`, `noRotation()`, `headingReset()`, `setFilterProfile()`) | `ImuSample` (from `MtiProtocol.h`) |
| [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` |
@ -112,7 +114,7 @@ Owned pixel buffer + `width`, `height`, `channels` (1 or 3), `timestamp_ms`, `ca
### `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`
(**0..360 heading** — the MTi's native −180..180 is shifted by `parseMTData`), `sample_counter`.
(**−180..180**, exactly as the device reports it), `sample_counter`. `parseMTData` applies no sign flip and no re-ranging to any axis.
`gimbal calib` phase-unwraps the swept yaw (`unwrapNear`) so a sweep crossing 0/360 still fits a clean
line.

View File

@ -1,5 +1,6 @@
#pragma once
#include "fgc/ImuMount.h"
#include "fgc/Geometry.h"
#include <map>
@ -109,6 +110,12 @@ struct FeaturesConfig {
struct ImuConfig {
std::string device = ""; // e.g. /dev/ttyS4; empty => required when enabled
unsigned int baud = 115200;
// How the unit is bolted to the gimbal, as [IMU] x/y/z. Every reading the
// program produces is rotated into the gimbal's NED body frame by this, so
// nothing downstream ever sees the sensor frame. See ImuMount.h for the
// convention; the default is the identity, which changes nothing.
ImuMount mount;
};
// [Env]: ambient temperature/humidity sensor (SHT41) on the LattePanda's own

View File

@ -3,6 +3,8 @@
#include "fgc/MtiProtocol.h" // ImuSample
#include <optional>
#include <string>
#include <vector>
namespace fgc {
@ -27,6 +29,15 @@ public:
// backend cannot report it.
virtual std::optional<ImuDeviceConfig> config() const { return std::nullopt; }
// How the device disagreed with what the host asked it for during the last
// handshake, already formatted one finding per entry. Empty means it agreed -
// or that the backend does not configure a device at all.
//
// This exists because the handshake runs before the TUI installs its log sink,
// so an error logged there scrolls away with the alt-screen. The operator has
// to be able to see it later, from the Sensors view and from `imu config`.
virtual std::vector<std::string> configProblems() const { return {}; }
// Re-read the device configuration (e.g. after the XKF profile was changed
// externally). May briefly pause the measurement stream. Blocking; after it
// returns config() reflects the device's current settings.

85
include/fgc/ImuMount.h Normal file
View File

@ -0,0 +1,85 @@
#pragma once
#include "fgc/MtiProtocol.h" // ImuSample
#include <array>
namespace fgc {
// How the IMU is physically bolted to the gimbal.
//
// 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/z are degrees, and they are the rotation applied TO THE IMU to bring it
// into alignment with the gimbal - the direction the operator reads off the
// installation "to get from the IMU to the gimbal". An IMU that physically sits
// yawed +90 deg is therefore corrected with z = -90.
//
// Composition is intrinsic Z-X-Y: turn about Z, then about the X axis as it now
// stands, then the new Y.
struct ImuMount {
double x_deg = 0.0; // roll - about forward
double y_deg = 0.0; // pitch - about right
double z_deg = 0.0; // yaw - about down
// No rotation at all. The common case, and worth testing for: an unmounted
// rig then pays nothing and its samples pass through bit for bit.
bool identity() const;
};
// Row-major 3x3. m[r][c].
using Mat3 = std::array<std::array<double, 3>, 3>;
// The rotation carrying the device's local earth frame onto NED. Identity for a
// device already reporting NED.
Mat3 nedFromReference(ImuReference r);
// Short name for logs and the Sensors view ("NED", "NWU", "ENU").
const char* referenceName(ImuReference r);
// The mount rotation: R = Rz(z) * Rx(x) * Ry(y).
//
// R carries the IMU frame onto the gimbal frame. Two transforms follow from it,
// and they are NOT the same matrix:
//
// - a sensor-frame vector's components in the gimbal frame: v_gimbal = R^T v_sensor
// - the gimbal's orientation: R_earth<-gimbal = R_earth<-sensor * R
Mat3 mountRotationZXY(const ImuMount& m);
// The MTi's own orientation output is the ordinary aerospace Z-Y-X sequence
// (yaw, then pitch, then roll), which is a DIFFERENT order from the mount above.
// These two helpers exist as a named pair so the two conventions cannot quietly
// swap places: R = Rz(yaw) * Ry(pitch) * Rx(roll).
Mat3 rotationFromEulerZYX(double roll_deg, double pitch_deg, double yaw_deg);
// Inverse of the above. Angles come back in the ranges atan2/asin produce, which
// are the MTi's own: roll and yaw in -180..180, pitch in -90..90. Degenerate at
// pitch = +/-90, where roll and yaw are no longer separable: roll is pinned to 0
// and the whole rotation is carried in yaw.
void eulerZYXFromRotation(const Mat3& R, double& roll_deg, double& pitch_deg, double& yaw_deg);
// The whole reading moved into the gimbal frame: orientation, acceleration,
// rate of turn and magnetic field. Temperature, sample counter and the valid
// flag are properties of the device rather than of a frame, and pass through.
//
// Returns the sample unchanged when the mount is the identity.
ImuSample applyMount(const ImuMount& m, const ImuSample& s);
// The full frame correction: re-reference the orientation from the device's earth
// frame to NED, then move the whole reading into the gimbal's body frame.
//
// R_ned<-gimbal = nedFromReference(ref) * R_local<-sensor * mountRotationZXY(m)
// \_____ earth _____/ \____ body ____/
//
// Only the orientation is re-referenced. acc, gyr and mag are BODY-frame
// quantities - they describe directions in the sensor, not relative to the earth
// - so the earth frame does not touch them and only the mount rotates them.
//
// Returns the sample unchanged when there is nothing to do (NED already, and an
// identity mount).
ImuSample applyFrames(ImuReference ref, const ImuMount& m, const ImuSample& s);
} // namespace fgc

View File

@ -0,0 +1,56 @@
#pragma once
#include "fgc/IImuSource.h"
#include "fgc/ImuMount.h"
#include <memory>
#include <utility>
namespace fgc {
// Wraps any IImuSource and puts its readings into the gimbal's NED body frame:
// the device's earth reference is re-referenced to NED, and the [IMU] x/y/z mount
// carries the body frame onto the gimbal's. Both are needed and they are not
// interchangeable - see applyFrames().
//
// This sits directly on the factory's output, which is the whole point: every
// consumer - the calibration routine, the self-test suite, the TUI, `imu status`,
// the MQTT path - already reads through IImuSource::sample(), so one wrap covers
// all of them and no consumer needs to know a mount exists. It wraps the mock as
// readily as the MTi, which is what makes the feature exercisable with
// --mock-imu on a machine with no hardware.
//
// Application skips the wrapper entirely when there is nothing to do (the device
// already reports NED and the mount is the identity), so such a rig carries
// neither the indirection nor the arithmetic.
class MountedImuSource : public IImuSource {
public:
MountedImuSource(std::unique_ptr<IImuSource> inner, ImuReference reference, ImuMount mount)
: inner_(std::move(inner)), reference_(reference), mount_(mount) {}
void start() override { inner_->start(); }
void stop() override { inner_->stop(); }
bool connected() const override { return inner_->connected(); }
std::optional<ImuSample> sample() override {
auto s = inner_->sample();
if (!s) return s;
return applyFrames(reference_, mount_, *s);
}
// Everything below describes the device, not a frame, so it passes straight
// through. The mount is a host-side correction and never reaches the MTi.
std::optional<ImuDeviceConfig> config() const override { return inner_->config(); }
std::vector<std::string> configProblems() const override { return inner_->configProblems(); }
void refreshConfig() override { inner_->refreshConfig(); }
void noRotation(int seconds) override { inner_->noRotation(seconds); }
void headingReset() override { inner_->headingReset(); }
bool setFilterProfile(int type) override { return inner_->setFilterProfile(type); }
private:
std::unique_ptr<IImuSource> inner_;
ImuReference reference_;
ImuMount mount_;
};
} // namespace fgc

View File

@ -21,6 +21,7 @@ public:
bool connected() const override;
std::optional<ImuSample> sample() override;
std::optional<ImuDeviceConfig> config() const override;
std::vector<std::string> configProblems() const override;
void refreshConfig() override;
bool setFilterProfile(int type) override;
void noRotation(int seconds) override;

View File

@ -57,14 +57,135 @@ inline constexpr uint8_t kMidResetOrientationAck = 0xA5;
inline constexpr uint16_t kResetHeading = 0x0001; // current heading becomes yaw 0
inline constexpr uint16_t kResetStore = 0x0000; // persist current reset (Config state)
// OutputMode = Temperature(0x01) | Calibrated(0x02) | Orientation(0x04).
inline constexpr uint16_t kOutputMode = 0x0007;
// OutputSettings: orientation mode Euler (bits3:2=01 => 0x04) + timestamp
// SampleCounter (bits1:0=01 => 0x01), float, all calibrated channels enabled.
inline constexpr uint32_t kOutputSettings = 0x00000005;
// Expected MTData payload with the above config: Temp(4) + Acc(12) + Gyr(12) +
// Mag(12) + Euler(12) + SampleCounter(2).
inline constexpr uint8_t kMTDataLen = 54;
// Which EARTH frame a device measures its orientation against.
//
// A different axis entirely from the [IMU] x/y/z mount, and the distinction is
// load-bearing: the mount says how the sensor is bolted to the gimbal (a BODY
// frame), while this says what the reported angles are measured relative to.
//
// NED X North Y East Z Down - what the gimbal frame assumes
// NWU X North Y West Z Up - the classic MTi's factory local frame
// ENU X East Y North Z Up - the newer MTi series' default
//
// A Z-up reference is what makes reported pitch FALL as the nose rises: with the
// body Y axis pointing left, a right-hand rotation about it tips the nose DOWN,
// so "nose up" is reported negative.
//
// This CANNOT be corrected by any mount value. Re-referencing the earth frame
// left-multiplies the reported orientation, whereas a mount right-multiplies it,
// and no fixed right-multiplication reproduces a left-multiplication across all
// attitudes. Both are applied, by applyFrames() in ImuMount.h.
enum class ImuReference { Ned, Nwu, Enu };
// --- What the host asks the device to stream -------------------------------
//
// These are a wire-format contract, not a preference: parseMTData below decodes
// a fixed field order that follows from exactly these bits, so the two must be
// changed together. That is what mtDataLen() and the static_assert enforce.
// Deliberately not configurable from config.ini for the same reason.
// OutputMode bits: which blocks the device streams, in this fixed order.
namespace mtmode {
inline constexpr uint16_t kTemperature = 0x0001;
inline constexpr uint16_t kCalibrated = 0x0002; // acc + gyr + mag
inline constexpr uint16_t kOrientation = 0x0004;
inline constexpr uint16_t kAuxiliary = 0x0008; // AIN1/AIN2, inserted before orientation
inline constexpr uint16_t kStatus = 0x0800; // one byte, before the sample counter
} // namespace mtmode
inline constexpr uint16_t kOutputMode =
mtmode::kTemperature | mtmode::kCalibrated | mtmode::kOrientation;
// OutputSettings bits: how those blocks are formatted.
namespace mtsettings {
inline constexpr uint32_t kTimestampMask = 0x00000003u; // bits 1:0
inline constexpr uint32_t kTimestampNone = 0x00000000u;
inline constexpr uint32_t kTimestampSampleCounter = 0x00000001u;
inline constexpr uint32_t kOrientationMask = 0x0000000Cu; // bits 3:2
inline constexpr uint32_t kOrientationQuaternion = 0x00000000u; // 16 bytes
inline constexpr uint32_t kOrientationEuler = 0x00000004u; // 12 bytes
inline constexpr uint32_t kOrientationMatrix = 0x00000008u; // 36 bytes
// Per-channel disables: a SET bit turns the channel OFF.
inline constexpr uint32_t kAccDisabled = 0x00000010u;
inline constexpr uint32_t kGyrDisabled = 0x00000020u;
inline constexpr uint32_t kMagDisabled = 0x00000040u;
inline constexpr uint32_t kFormatMask = 0x00000300u; // bits 9:8
inline constexpr uint32_t kFormatFloat = 0x00000000u;
inline constexpr uint32_t kFormatFixed1220 = 0x00000100u; // same length as float:
// the length gate cannot
// catch this one
// Coordinate-system field: which earth reference the device reports against.
//
// Left at 0 - the device is NOT asked to change its reference. The correction is
// made on the host instead (kMtiReference below + applyFrames), which is
// deterministic, reversible, and unit-testable without hardware, where writing an
// unverified bit into the unit's non-volatile memory would be none of those. The
// field's position in OutputSettings is in any case not established for this
// firmware.
//
// Keeping it 0 also keeps the two ends consistent: the host rewrites this whole
// word every startup, so the device's reference is pinned to what 0x00000005
// selects and kMtiReference can describe it with certainty. If you ever do set
// the coordinate system on the device, the readback check will flag the changed
// word - at which point kMtiReference must be updated to match.
inline constexpr uint32_t kCoordSysNed = 0x00000000u;
} // namespace mtsettings
inline constexpr uint32_t kOutputSettings =
mtsettings::kTimestampSampleCounter | mtsettings::kOrientationEuler |
mtsettings::kFormatFloat | mtsettings::kCoordSysNed;
// Which earth frame this device measures orientation against, under the
// OutputSettings above.
//
// The classic MTi's factory local frame is X North, Y West, Z Up. Because the
// host rewrites OutputSettings on every startup, the device's reference is
// whatever that word selects - i.e. deterministic, not "whatever was left in the
// unit" - so this is a fact about kOutputSettings and belongs beside it.
//
// The consequence, and the reason this constant exists: a Z-up reference reports
// pitch FALLING as the nose rises. ImuReference / applyFrames re-reference it to
// NED on the host (see ImuMount.h); no [IMU] x/y/z value can, because the earth
// frame is on the other side of the multiplication.
//
// If the yaw the Sensors view reports is 90 deg off a compass, this is ENU rather
// than NWU - roll and pitch come out identical either way, so yaw is the only
// thing that distinguishes them. Changing it here is the whole fix.
inline constexpr ImuReference kMtiReference = ImuReference::Nwu;
// The MTData payload length implied by a (mode, settings) pair, in the device's
// fixed field order. Deriving it is what keeps kMTDataLen from drifting away
// from the settings above: change a bit and the length follows, so the
// static_assert fires instead of the parser silently mismatching at runtime.
constexpr unsigned mtDataLen(uint16_t mode, uint32_t settings) {
unsigned n = 0;
if (mode & mtmode::kTemperature) n += 4;
if (mode & mtmode::kCalibrated) {
if (!(settings & mtsettings::kAccDisabled)) n += 12;
if (!(settings & mtsettings::kGyrDisabled)) n += 12;
if (!(settings & mtsettings::kMagDisabled)) n += 12;
}
if (mode & mtmode::kOrientation) {
switch (settings & mtsettings::kOrientationMask) {
case mtsettings::kOrientationQuaternion: n += 16; break;
case mtsettings::kOrientationEuler: n += 12; break;
case mtsettings::kOrientationMatrix: n += 36; break;
default: break; // reserved encoding: length unknown
}
}
if (mode & mtmode::kStatus) n += 1;
if ((settings & mtsettings::kTimestampMask) == mtsettings::kTimestampSampleCounter) n += 2;
return n;
}
// Expected payload with the config above: Temp(4) + Acc(12) + Gyr(12) + Mag(12)
// + Euler(12) + SampleCounter(2).
inline constexpr uint8_t kMTDataLen = static_cast<uint8_t>(mtDataLen(kOutputMode, kOutputSettings));
static_assert(kMTDataLen == 54, "MTData layout changed; parseMTData must change with it");
// One fully decoded IMU reading.
struct ImuSample {
@ -75,7 +196,7 @@ struct ImuSample {
float mag[3] = {0, 0, 0}; // a.u. (normalized to earth field)
float roll_deg = 0.f; // -180..180
float pitch_deg = 0.f; // -90..90
float yaw_deg = 0.f; // 0..360 heading (MTi native -180..180 is shifted)
float yaw_deg = 0.f; // -180..180, exactly as the device reports it
uint16_t sample_counter = 0;
};
@ -164,6 +285,41 @@ bool applyImuConfigAck(ImuDeviceConfig& c, uint8_t mid, const uint8_t* d, std::s
// against available_profiles by type) and sample_rate_hz from the period.
void finalizeImuConfig(ImuDeviceConfig& c);
// One way the device disagrees with what the host asked it for.
struct ImuConfigMismatch {
std::string field; // "output mode" / "output settings"
uint32_t expected = 0;
uint32_t actual = 0;
bool answered = true; // false: the device never reported this field
std::string detail; // decoded difference, e.g. "orientation is Quaternion, asked for Euler"
// One line for a log or the UI, e.g.
// output settings: asked for 0x00000005, device reports 0x00000105 (data
// format is Fixed 12.20, asked for Float)
std::string describe() const;
};
// Compare what the device reports against what configure() asked it for. Empty
// means the device is doing exactly what the host configured.
//
// This is the only thing standing between a device that ignored or rejected the
// Set messages and a session that silently streams the wrong thing: the payload
// length gate in parseMTData cannot see a wrong data format or a wrong
// coordinate system, since neither changes the length.
//
// A field the device did not answer is reported as unverifiable (`answered =
// false`) rather than as a match - older firmware that does not respond to the
// Req* queries must not be mistaken for a device that agreed. Identity fields
// (product code, device ID, firmware) and anything the host does not set (period,
// XKF profile) are deliberately not compared.
std::vector<ImuConfigMismatch> checkImuDeviceConfig(const ImuDeviceConfig& c);
// Human text for an Error message (MID 0x42) payload byte - how the device says
// it rejected a message outright, as opposed to quietly not applying it. Codes
// are from the MT Low-Level Protocol error table; anything unrecognized comes
// back as "unknown error 0xNN" rather than being swallowed.
std::string mtiErrorText(uint8_t code);
// Lower byte of the sum of all bytes from BID through the end of DATA. The CS
// byte that makes the running total ≡ 0 (mod 256) is (256 - mtiChecksum) & 0xFF.
uint8_t mtiChecksum(const uint8_t* from_bid, std::size_t len);

View File

@ -27,7 +27,9 @@ public:
s.valid = true;
s.roll_deg = static_cast<float>(15.0 * std::sin(t * 0.5));
s.pitch_deg = static_cast<float>(10.0 * std::sin(t * 0.3 + 1.0));
s.yaw_deg = static_cast<float>(std::fmod(t * 8.0, 360.0)); // slow spin
// Slow spin through the MTi's own -180..180, wrap included, so anything
// consuming yaw meets the same discontinuity it will meet on the rig.
s.yaw_deg = static_cast<float>(std::fmod(t * 8.0 + 180.0, 360.0) - 180.0);
// Gravity tilts with roll/pitch; small free accel noise.
const double r = s.roll_deg * M_PI / 180.0, p = s.pitch_deg * M_PI / 180.0;
s.acc[0] = static_cast<float>(9.81 * -std::sin(p));
@ -57,6 +59,18 @@ public:
return true;
}
// Runs the same check the real handshake runs, over the synthetic config
// below. It reports nothing as long as the mock mirrors the constants - which
// is the point: perturb config()'s output_settings and the error surfaces all
// the way to the Sensors view, so that path is exercisable without hardware.
std::vector<std::string> configProblems() const override {
std::vector<std::string> out;
auto c = config();
if (!c) return out;
for (const auto& m : checkImuDeviceConfig(*c)) out.push_back(m.describe());
return out;
}
// Synthetic config mirroring the real handshake (Euler + calibrated, 100 Hz),
// so the expanded view's IMU CONFIG section renders without hardware. The
// available list includes a no-mag profile so the calibration's profile switch

View File

@ -1,6 +1,7 @@
#pragma once
#include "fgc/IControlChannel.h"
#include "fgc/ImuMount.h"
#include "fgc/IMotorController.h" // AxisState
#include "fgc/Logger.h" // LogLevel
@ -88,6 +89,11 @@ struct SensorGroup {
struct SensorsView {
SensorGroup imu; // Xsens MTi: Roll/Pitch/Yaw
SensorGroup env; // ambient temperature + humidity (SHT41)
// The MTi is not streaming what the host configured (the detail is in
// ImuConfigView::problems). Flagged on the compact panel too, because the
// readings above are wrong in a way that reading them cannot reveal.
bool imu_config_alert = false;
};
// One auto-sweep scan-grid waypoint, for the expanded camera view.
@ -210,6 +216,22 @@ struct ImuConfigView {
// Available XKF profiles with the active one flagged. Empty if the device
// did not report them.
std::vector<ImuProfileView> xkf_profiles;
// The [IMU] x/y/z mounting rotation being applied to every reading, already
// formatted (e.g. "x 180.0 y 0.0 z -90.0"). Empty when no mount is
// configured. Unlike everything above it comes from config.ini rather than
// from the device, so the view labels it as such - the MTi has its own
// internal alignment setting and the two must not be confused.
std::string mount;
// How the device disagreed with what the host asked it for at startup, one
// finding per line, already formatted. Empty when it agreed.
//
// Like `mount` this must survive the config cache, and for a sharper reason:
// the handshake runs before the TUI installs its log sink, so the LOG_ERROR
// it emits is wiped by the alt-screen. Without this field the operator would
// have no way of learning that the IMU is streaming the wrong thing.
std::vector<std::string> problems;
};
// Full Xsens MTi reading for the expanded Sensors view (units: acc m/s^2,
@ -324,6 +346,11 @@ UiColor axisStateColor(AxisState s);
// "12.3°" (one decimal, sign preserved).
std::string formatDegrees(double deg);
// The [IMU] mount as one line: "x 180.0 y 0.0 z -90.0", or "" for no mount.
// Empty rather than "none" so a caller can tell "not configured" from a value
// and decide for itself how to say so.
std::string formatImuMount(const ImuMount& m);
// Human "Ns ago" / "Nm ago" for an absolute ms timestamp relative to now_ms.
// Returns "—" when then_ms is 0 (never).
std::string formatTimeAgo(long long now_ms, long long then_ms);

View File

@ -15,6 +15,7 @@
#include "fgc/IMotorController.h"
#include "fgc/ImagePipeline.h"
#include "fgc/Logger.h"
#include "fgc/MountedImuSource.h"
#include "fgc/MtiImuSource.h"
#include "fgc/ScanGrid.h"
#include "fgc/SerialMotorController.h"
@ -264,12 +265,29 @@ struct Application::Impl {
std::unique_ptr<IImuSource> makeImu() {
if (!cfg.features.enable_imu) return nullptr; // sensors panel stays "pending"
if (cfg.features.mock_imu) return std::make_unique<MockImuSource>();
if (cfg.imu.device.empty()) {
std::unique_ptr<IImuSource> src;
if (cfg.features.mock_imu) {
src = std::make_unique<MockImuSource>();
} else if (cfg.imu.device.empty()) {
LOG_WARN << "IMU enabled but [IMU] device is empty; disabling IMU";
return nullptr;
} else {
src = std::make_unique<MtiImuSource>(cfg.imu.device, cfg.imu.baud);
}
return std::make_unique<MtiImuSource>(cfg.imu.device, cfg.imu.baud);
// Wrapping here is what makes the mount universal: every consumer reads
// through IImuSource::sample(), so none of them can see the sensor frame.
// An unmounted rig skips the wrapper entirely rather than rotating by
// zero on every sample.
// The mock fabricates gimbal-frame readings directly, so only the real
// device carries an earth reference that has to be corrected.
const ImuReference ref = cfg.features.mock_imu ? ImuReference::Ned : kMtiReference;
if (ref == ImuReference::Ned && cfg.imu.mount.identity()) return src;
LOG_INFO << "IMU frames: reference " << referenceName(ref) << " -> NED; mount x "
<< cfg.imu.mount.x_deg << " y " << cfg.imu.mount.y_deg << " z "
<< cfg.imu.mount.z_deg << " (deg, Z-X-Y); readings are in the gimbal frame";
return std::make_unique<MountedImuSource>(std::move(src), ref, cfg.imu.mount);
}
std::unique_ptr<IEnvSensor> makeEnv() {
@ -502,6 +520,12 @@ struct Application::Impl {
<< (c->scenario_label.empty() ? std::string("(unnamed)") : c->scenario_label)
<< " (type " << static_cast<int>(c->scenario_type) << ')';
LOG_INFO << " available (* = active): " << imuProfileList();
const auto problems = imu->configProblems();
if (problems.empty()) {
LOG_INFO << " device matches the configuration the host asked for";
} else {
for (const auto& p : problems) LOG_ERROR << " " << p;
}
}
// `imu` command family, mirroring handleGimbal: the operator-facing surface of
@ -648,6 +672,15 @@ struct Application::Impl {
}
}
s.imu.config = imu_config_view_;
// From config.ini, not from the device, so it is available even when
// the MTi never answered the configuration queries.
s.imu.config.mount = formatImuMount(cfg.imu.mount);
// Also outside the cache above, and for a sharper reason: the handshake
// that produces these runs before the TUI installs its log sink, so the
// errors it logs are wiped by the alt-screen. This is the only place the
// operator can still see them.
s.imu.config.problems = imu->configProblems();
s.sensors.imu_config_alert = !s.imu.config.problems.empty();
if (auto m = imu->sample()) {
// Full reading for the expanded view.
s.imu.present = true;

View File

@ -121,9 +121,10 @@ double dwellAndMeasure(IImuSource& imu, char axis, int dwell_ms, int sample_ms,
}
if (vals.empty()) return 0.0;
if (axis == 'Y') {
double m = circularMeanDeg(vals); // robust to wrap within the dwell
if (m < 0.0) m += 360.0; // report a 0..360 heading (matches IMU)
return m;
// Robust to a wrap within the dwell, and already in the device's own
// -180..180. The sweep's per-sample unwrapNear below is what keeps the
// fit continuous, so no range shift is needed - or wanted - here.
return circularMeanDeg(vals);
}
double m = 0;
for (double v : vals) m += v;

View File

@ -1,6 +1,7 @@
#include "fgc/Config.h"
#include "fgc/Paths.h"
#include <cmath>
#include <algorithm>
#include <cctype>
#include <cstdio>
@ -309,6 +310,21 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
cfg.imu.device = get(kv, "IMU.device", cfg.imu.device);
cfg.imu.baud = static_cast<unsigned>(getInt(kv, "IMU.baud", cfg.imu.baud));
// [IMU] x/y/z: how the unit is bolted on, in degrees about forward/right/down.
// A value outside +/-360 is a typo far more often than an intent, and one that
// is accepted silently is invisible afterwards - the readings just quietly
// describe the wrong axes.
cfg.imu.mount.x_deg = getDouble(kv, "IMU.x", cfg.imu.mount.x_deg);
cfg.imu.mount.y_deg = getDouble(kv, "IMU.y", cfg.imu.mount.y_deg);
cfg.imu.mount.z_deg = getDouble(kv, "IMU.z", cfg.imu.mount.z_deg);
for (const auto& [axis, value] : {std::pair{"x", cfg.imu.mount.x_deg},
std::pair{"y", cfg.imu.mount.y_deg},
std::pair{"z", cfg.imu.mount.z_deg}}) {
if (!std::isfinite(value) || std::abs(value) > 360.0)
throw std::runtime_error(std::string("IMU.") + axis +
" must be a rotation in degrees within +/-360");
}
cfg.env.i2c_device = get(kv, "Env.i2c_device", cfg.env.i2c_device);
cfg.env.i2c_addr = static_cast<unsigned>(
getIntAutoBase(kv, "Env.i2c_addr", static_cast<int>(cfg.env.i2c_addr)));

148
src/core/ImuMount.cpp Normal file
View File

@ -0,0 +1,148 @@
#include "fgc/ImuMount.h"
#include <algorithm>
#include <cmath>
namespace fgc {
namespace {
constexpr double kDeg2Rad = M_PI / 180.0;
constexpr double kRad2Deg = 180.0 / M_PI;
// Below this the mount is not worth applying: 1e-9 deg is far under any angle an
// operator can measure, and treating it as zero keeps the untouched-sample
// guarantee exact rather than approximate.
constexpr double kAngleEpsilon = 1e-9;
Mat3 rotX(double rad) {
const double c = std::cos(rad), s = std::sin(rad);
return {{{1, 0, 0}, {0, c, -s}, {0, s, c}}};
}
Mat3 rotY(double rad) {
const double c = std::cos(rad), s = std::sin(rad);
return {{{c, 0, s}, {0, 1, 0}, {-s, 0, c}}};
}
Mat3 rotZ(double rad) {
const double c = std::cos(rad), s = std::sin(rad);
return {{{c, -s, 0}, {s, c, 0}, {0, 0, 1}}};
}
Mat3 mul(const Mat3& a, const Mat3& b) {
Mat3 r{};
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j)
r[i][j] = a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j];
return r;
}
// v' = R^T v, i.e. re-express a vector's components in the frame R carries the
// original onto. Written out rather than transposing then multiplying, since the
// transpose only ever appears here.
void applyTranspose(const Mat3& R, const float in[3], float out[3]) {
double v[3] = {in[0], in[1], in[2]};
for (int i = 0; i < 3; ++i)
out[i] = static_cast<float>(R[0][i] * v[0] + R[1][i] * v[1] + R[2][i] * v[2]);
}
} // namespace
bool ImuMount::identity() const {
return std::abs(x_deg) < kAngleEpsilon && std::abs(y_deg) < kAngleEpsilon &&
std::abs(z_deg) < kAngleEpsilon;
}
Mat3 mountRotationZXY(const ImuMount& m) {
return mul(mul(rotZ(m.z_deg * kDeg2Rad), rotX(m.x_deg * kDeg2Rad)), rotY(m.y_deg * kDeg2Rad));
}
Mat3 rotationFromEulerZYX(double roll_deg, double pitch_deg, double yaw_deg) {
return mul(mul(rotZ(yaw_deg * kDeg2Rad), rotY(pitch_deg * kDeg2Rad)), rotX(roll_deg * kDeg2Rad));
}
void eulerZYXFromRotation(const Mat3& R, double& roll_deg, double& pitch_deg, double& yaw_deg) {
// For R = Rz(yaw) Ry(pitch) Rx(roll), R[2][0] is exactly -sin(pitch).
// Repeated multiplication can push it a hair past +/-1, and asin() of that is
// NaN - which would then propagate silently through every consumer.
const double sin_pitch = std::clamp(-R[2][0], -1.0, 1.0);
const double pitch = std::asin(sin_pitch);
const double cos_pitch = std::cos(pitch);
double roll, yaw;
if (std::abs(cos_pitch) < 1e-9) {
// Gimbal lock: pointing straight up or straight down, roll and yaw are the
// same rotation and only their sum is observable. Attribute all of it to
// yaw, which is the axis the operator is steering.
roll = 0.0;
yaw = std::atan2(-R[0][1], R[1][1]);
} else {
roll = std::atan2(R[2][1], R[2][2]);
yaw = std::atan2(R[1][0], R[0][0]);
}
// atan2/asin already land in -180..180 and -90..90, which is exactly the
// range the MTi reports, so nothing is re-ranged on the way out.
roll_deg = roll * kRad2Deg;
pitch_deg = pitch * kRad2Deg;
yaw_deg = yaw * kRad2Deg;
}
Mat3 nedFromReference(ImuReference r) {
switch (r) {
// Already NED: nothing to do.
case ImuReference::Ned: return {{{1, 0, 0}, {0, 1, 0}, {0, 0, 1}}};
// NWU -> NED: North stays, West becomes East, Up becomes Down. That is a
// 180 deg turn about North, i.e. Rx(180).
case ImuReference::Nwu: return {{{1, 0, 0}, {0, -1, 0}, {0, 0, -1}}};
// ENU -> NED: North is ENU's Y, East is ENU's X, Down is -Up. A 180 deg
// turn about the horizontal axis halfway between North and East.
case ImuReference::Enu: return {{{0, 1, 0}, {1, 0, 0}, {0, 0, -1}}};
}
return {{{1, 0, 0}, {0, 1, 0}, {0, 0, 1}}};
}
const char* referenceName(ImuReference r) {
switch (r) {
case ImuReference::Ned: return "NED";
case ImuReference::Nwu: return "NWU";
case ImuReference::Enu: return "ENU";
}
return "?";
}
ImuSample applyFrames(ImuReference ref, const ImuMount& m, const ImuSample& s) {
if (ref == ImuReference::Ned && m.identity()) return s;
const Mat3 R = mountRotationZXY(m);
ImuSample out = s; // temp, counter and valid are frame-independent
// acc/gyr/mag are body-frame directions, so only the mount touches them. The
// earth reference is about what the ORIENTATION is measured against and has
// no bearing on which way the sensor felt an acceleration.
applyTranspose(R, s.acc, out.acc);
applyTranspose(R, s.gyr, out.gyr);
applyTranspose(R, s.mag, out.mag);
// The device reports local_earth<-sensor. Left-multiplying re-references the
// earth frame to NED; right-multiplying by the mount swaps the body frame for
// the gimbal's. They are independent and both are needed - which is exactly
// why a mount alone could never fix a Z-up reference.
const Mat3 local_from_sensor = rotationFromEulerZYX(s.roll_deg, s.pitch_deg, s.yaw_deg);
const Mat3 ned_from_gimbal =
mul(mul(nedFromReference(ref), local_from_sensor), R);
double roll = 0, pitch = 0, yaw = 0;
eulerZYXFromRotation(ned_from_gimbal, roll, pitch, yaw);
out.roll_deg = static_cast<float>(roll);
out.pitch_deg = static_cast<float>(pitch);
out.yaw_deg = static_cast<float>(yaw);
return out;
}
ImuSample applyMount(const ImuMount& m, const ImuSample& s) {
return applyFrames(ImuReference::Ned, m, s);
}
} // namespace fgc

View File

@ -239,6 +239,141 @@ void finalizeImuConfig(ImuDeviceConfig& c) {
}
}
namespace {
const char* orientationName(uint32_t s) {
switch (s & mtsettings::kOrientationMask) {
case mtsettings::kOrientationQuaternion: return "Quaternion";
case mtsettings::kOrientationEuler: return "Euler";
case mtsettings::kOrientationMatrix: return "Matrix";
default: return "reserved";
}
}
const char* formatName(uint32_t s) {
switch (s & mtsettings::kFormatMask) {
case mtsettings::kFormatFloat: return "Float";
case mtsettings::kFormatFixed1220: return "Fixed 12.20";
default: return "Fixed 16.32";
}
}
const char* timestampName(uint32_t s) {
return (s & mtsettings::kTimestampMask) == mtsettings::kTimestampSampleCounter
? "Sample counter"
: "none";
}
std::string join(const std::vector<std::string>& parts) {
std::string out;
for (const auto& p : parts) {
if (!out.empty()) out += "; ";
out += p;
}
return out;
}
std::string modeDetail(uint16_t expected, uint16_t actual) {
const std::pair<uint16_t, const char*> blocks[] = {
{mtmode::kTemperature, "temperature"}, {mtmode::kCalibrated, "calibrated data"},
{mtmode::kOrientation, "orientation"}, {mtmode::kAuxiliary, "auxiliary"},
{mtmode::kStatus, "status"},
};
std::vector<std::string> parts;
for (const auto& [bit, name] : blocks) {
if ((expected & bit) == (actual & bit)) continue;
parts.push_back(std::string(name) + ((actual & bit) ? " is on, was not asked for"
: " is off, but was asked for"));
}
return join(parts);
}
std::string settingsDetail(uint32_t expected, uint32_t actual) {
std::vector<std::string> parts;
if ((expected & mtsettings::kOrientationMask) != (actual & mtsettings::kOrientationMask))
parts.push_back(std::string("orientation is ") + orientationName(actual) +
", asked for " + orientationName(expected));
if ((expected & mtsettings::kFormatMask) != (actual & mtsettings::kFormatMask))
parts.push_back(std::string("data format is ") + formatName(actual) + ", asked for " +
formatName(expected));
if ((expected & mtsettings::kTimestampMask) != (actual & mtsettings::kTimestampMask))
parts.push_back(std::string("timestamp is ") + timestampName(actual) + ", asked for " +
timestampName(expected));
const std::pair<uint32_t, const char*> channels[] = {
{mtsettings::kAccDisabled, "acceleration"},
{mtsettings::kGyrDisabled, "rate of turn"},
{mtsettings::kMagDisabled, "magnetic field"},
};
for (const auto& [bit, name] : channels) {
if ((expected & bit) == (actual & bit)) continue;
parts.push_back(std::string(name) + ((actual & bit) ? " is disabled on the device"
: " is enabled but was not asked for"));
}
// Anything outside the fields decoded above. The coordinate system lives in
// here, so while mtsettings::kCoordSysNed is still 0 this is the branch that
// reports a device whose earth reference is not what the gimbal frame assumes.
const uint32_t known = mtsettings::kOrientationMask | mtsettings::kFormatMask |
mtsettings::kTimestampMask | mtsettings::kAccDisabled |
mtsettings::kGyrDisabled | mtsettings::kMagDisabled;
if ((expected ^ actual) & ~known) {
char buf[96];
std::snprintf(buf, sizeof(buf),
"other bits differ (0x%08X); the coordinate system is one of these",
(expected ^ actual) & ~known);
parts.push_back(buf);
}
return join(parts);
}
} // namespace
std::string ImuConfigMismatch::describe() const {
if (!answered) return field + ": the device did not report it, so it could not be verified";
char buf[80];
std::snprintf(buf, sizeof(buf), ": asked for 0x%08X, device reports 0x%08X", expected, actual);
std::string out = field + buf;
if (!detail.empty()) out += " (" + detail + ")";
return out;
}
std::vector<ImuConfigMismatch> checkImuDeviceConfig(const ImuDeviceConfig& c) {
std::vector<ImuConfigMismatch> out;
if (!c.has_output_mode) {
out.push_back({"output mode", kOutputMode, 0, false, ""});
} else if (c.output_mode != kOutputMode) {
out.push_back({"output mode", kOutputMode, c.output_mode, true,
modeDetail(kOutputMode, c.output_mode)});
}
if (!c.has_output_settings) {
out.push_back({"output settings", kOutputSettings, 0, false, ""});
} else if (c.output_settings != kOutputSettings) {
out.push_back({"output settings", kOutputSettings, c.output_settings, true,
settingsDetail(kOutputSettings, c.output_settings)});
}
return out;
}
std::string mtiErrorText(uint8_t code) {
switch (code) {
case 0x03: return "invalid period";
case 0x04: return "invalid message";
case 0x1E: return "timer overflow";
case 0x20: return "invalid baud rate";
case 0x21: return "invalid parameter";
case 0x28: return "device error";
default: {
char buf[32];
std::snprintf(buf, sizeof(buf), "unknown error 0x%02X", code);
return buf;
}
}
}
std::optional<ImuSample> parseMTData(uint8_t mid, const uint8_t* data, std::size_t len) {
if (mid != kMidMTData || len != kMTDataLen) return std::nullopt;
ImuSample s;
@ -250,8 +385,10 @@ std::optional<ImuSample> parseMTData(uint8_t mid, const uint8_t* data, std::size
s.roll_deg = beFloat(data + o); o += 4;
s.pitch_deg = beFloat(data + o); o += 4;
s.yaw_deg = beFloat(data + o); o += 4;
// Report yaw as a 0..360 heading rather than the MTi's native -180..180.
if (s.yaw_deg < 0.f) s.yaw_deg += 360.f;
// Reported exactly as the device sends it. Roll, pitch and yaw are all raw:
// the only transformation applied anywhere is the [IMU] x/y/z mount rotation,
// so what reaches a consumer is the sensor's own numbers in the frame the
// operator declared - nothing else.
s.sample_counter = beU16(data + o); o += 2;
s.valid = true;
return s;

View File

@ -1,5 +1,7 @@
#include "fgc/TestRunner.h"
#include "fgc/Calibration.h" // unwrapNear
#include "fgc/DiagParser.h"
#include "fgc/DumpParser.h"
#include "fgc/HostMetrics.h"
@ -672,14 +674,25 @@ void TestRunner::testImuDrift(TestReport& r) {
const auto t0 = clock_t_::now();
const auto deadline = t0 + window;
double peak_min = 1e9, peak_max = -1e9;
bool have_prev = false;
double prev_deg = 0.0;
while (clock_t_::now() < deadline && !cancelled()) {
auto smp = imu_->sample();
if (smp && smp->valid) {
double tmin = std::chrono::duration<double>(clock_t_::now() - t0).count() / 60.0;
// Yaw is reported raw, so it wraps at ±180. A rig parked on that
// boundary would otherwise show a ~360° "drift" between consecutive
// still samples; unwrapping makes the fit and the spread independent
// of which heading the tower happens to face.
double deg = smp->yaw_deg;
if (have_prev) deg = unwrapNear(prev_deg, deg);
prev_deg = deg;
have_prev = true;
ts.push_back(tmin);
yaw.push_back(smp->yaw_deg);
peak_min = std::min(peak_min, (double)smp->yaw_deg);
peak_max = std::max(peak_max, (double)smp->yaw_deg);
yaw.push_back(deg);
peak_min = std::min(peak_min, deg);
peak_max = std::max(peak_max, deg);
}
std::this_thread::sleep_for(20ms);
}

View File

@ -7,8 +7,11 @@
#include <atomic>
#include <chrono>
#include <functional>
#include <cstdio>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include <boost/asio.hpp>
#include <termios.h>
@ -37,6 +40,9 @@ struct MtiImuSource::Impl {
ImuDeviceConfig cfg; // device config read back at startup
bool have_cfg = false;
// Formatted findings from the last handshake: ways the device disagreed with
// what we asked it for, plus any Error frame it sent back. Empty = it agreed.
std::vector<std::string> cfg_problems;
MtiFramer framer;
unsigned bad_len_warned = 0;
@ -82,6 +88,11 @@ struct MtiImuSource::Impl {
if (ec) LOG_WARN << "MTi config write failed: " << ec.message();
};
write(msgGoToConfig()); std::this_thread::sleep_for(60ms);
// The device stops streaming on entering Config state, so drop the buffered
// MTData here - before the Sets rather than after them. From this point the
// only bytes arriving are answers to what we send, which is what lets
// queryConfig see an Error frame refusing a Set instead of flushing it away.
::tcflush(serial.native_handle(), TCIFLUSH);
if (set_profile >= 0) {
write(msgSetFilterProfile(static_cast<uint16_t>(set_profile)));
std::this_thread::sleep_for(60ms);
@ -121,14 +132,25 @@ struct MtiImuSource::Impl {
void queryConfig(WriteFn&& write) {
using namespace std::chrono_literals;
ImuDeviceConfig local;
std::vector<std::string> problems;
MtiFramer cf([&](uint8_t mid, const uint8_t* d, std::size_t n) {
// An Error frame is the device rejecting a message outright, as
// opposed to quietly not applying it. It was dropped on the floor
// before, which made a refused SetOutputSettings invisible.
if (mid == kMidError) {
const std::string what =
n >= 1 ? mtiErrorText(d[0]) : std::string("error with no code");
LOG_ERROR << "MTi rejected a configuration message: " << what;
problems.push_back("device rejected a configuration message: " + what);
return;
}
applyImuConfigAck(local, mid, d, n);
});
// Drop any buffered pre-config stream (e.g. MTData from before GoToConfig
// took effect) so the read window below is dominated by the query acks.
::tcflush(serial.native_handle(), TCIFLUSH);
// Deliberately NOT flushing here: configure() already dropped the buffered
// pre-config stream on entering Config state, and anything waiting now is
// an ack or an Error from the Sets that ran in between - which is exactly
// what this window is for.
write(msgReqProductCode());
write(msgReqDID());
write(msgReqFWRev());
@ -160,17 +182,42 @@ struct MtiImuSource::Impl {
finalizeImuConfig(local);
if (local.valid) {
std::lock_guard<std::mutex> lock(mutex);
cfg = local;
have_cfg = true;
// The raw settings word is printed because it is the ground truth for
// what the device is actually doing - and, while
// mtsettings::kCoordSysNed is still unconfirmed, it is how the
// coordinate-system bits get identified: set NED once in MT Manager
// and read off what changed here.
char words[48];
std::snprintf(words, sizeof(words), "mode 0x%04X settings 0x%08X", local.output_mode,
local.output_settings);
LOG_INFO << "MTi config: " << (local.product_code.empty() ? "?" : local.product_code)
<< " fw " << (local.firmware.empty() ? "?" : local.firmware)
<< " scenario " << int(local.scenario_type)
<< (local.scenario_label.empty() ? "" : " (" + local.scenario_label + ")")
<< " @ " << local.sample_rate_hz << " Hz";
<< " @ " << local.sample_rate_hz << " Hz, " << words;
// Did the device actually take what configure() just asked for? Nothing
// else catches a refused or ignored Set: a wrong data format or a wrong
// coordinate system leaves the payload length unchanged, so the length
// gate in parseMTData sees nothing wrong.
for (const auto& m : checkImuDeviceConfig(local)) {
LOG_ERROR << "MTi " << m.describe();
problems.push_back(m.describe());
}
} else {
LOG_WARN << "MTi: no configuration acks received (device may not answer Req* in this firmware)";
}
{
std::lock_guard<std::mutex> lock(mutex);
if (local.valid) {
cfg = local;
have_cfg = true;
}
// Replaced wholesale, not appended: these describe the handshake that
// just ran, so a refresh that now succeeds must clear the old findings.
cfg_problems = std::move(problems);
}
}
};
@ -232,6 +279,11 @@ std::optional<ImuDeviceConfig> MtiImuSource::config() const {
return impl_->cfg;
}
std::vector<std::string> MtiImuSource::configProblems() const {
std::lock_guard<std::mutex> lock(impl_->mutex);
return impl_->cfg_problems;
}
// Re-read the device configuration: briefly pause the streaming io_thread,
// re-run the Config-state handshake (which re-queries product/firmware/output/
// XKF profile and re-applies our output settings), then resume streaming. Mirrors

View File

@ -271,12 +271,12 @@ Element sensorsPanel(const SensorsView& s) {
// One labelled subsection per physical sensor: a title + status header, then
// its readings. Keeps MTi (orientation/device temp) and the ambient env
// sensor (SHT41) clearly separated.
auto group = [](const SensorGroup& g, const std::string& hint) {
auto group = [](const SensorGroup& g, Element trailing) {
std::vector<Element> rows;
Element status = g.present ? (text(g.status) | color(Color::Green))
: (text(g.status) | dim);
rows.push_back(hbox({text(g.title) | bold, text(" "), status, filler(),
text(hint) | dim}));
std::move(trailing)}));
for (const auto& f : g.fields) {
Element val = text(f.value + (f.unit.empty() ? "" : " " + f.unit));
val = f.present ? (val | bold) : (val | dim);
@ -285,8 +285,14 @@ Element sensorsPanel(const SensorsView& s) {
return vbox(std::move(rows));
};
// The title already says "[i]"; repeating "i to expand" in the body was noise.
// A misconfigured MTi reports plausible-looking numbers about the wrong axes,
// so the readings themselves can never reveal it - it has to be said here.
Element imu_badge = s.imu_config_alert
? (text(" IMU CFG ") | color(Color::Red) | bold)
: text("");
return panel("[i] SENSORS", Color::Magenta,
vbox({group(s.imu, ""), separator(), group(s.env, "")}));
vbox({group(s.imu, std::move(imu_badge)), separator(),
group(s.env, text(""))}));
}
Element cameraPanel(const CaptureView& c) {
@ -977,7 +983,10 @@ Element sensorsDetailPanel(const ImuView& v, const EnvView& e, ScrollState& st)
// IMU CONFIG section (device configuration read back at startup). Shown
// whenever it is known, even if the live stream is offline.
const auto& c = v.config;
if (c.present) {
// A configured mount is worth showing even when the device never answered the
// configuration queries - it is the one line here that comes from config.ini
// and is what explains why the readings above are not the sensor's own.
if (c.present || !c.mount.empty() || !c.problems.empty()) {
auto cfgRow = [](const std::string& k, const std::string& val,
Color vc = Color::Default) {
return hbox({text(k) | dim | size(WIDTH, EQUAL, 16),
@ -985,6 +994,34 @@ Element sensorsDetailPanel(const ImuView& v, const EnvView& e, ScrollState& st)
};
body.push_back(separator());
body.push_back(text("IMU CONFIG") | bold | color(Color::Magenta));
// Before anything else: the device is not doing what it was told to do, so
// every orientation number above is suspect. Red rather than the Mount
// row's yellow, which is a normal state and would blunt this one.
for (const auto& p : c.problems)
body.push_back(hbox({text("MISCONFIGURED") | color(Color::Red) | bold
| size(WIDTH, EQUAL, 16),
paragraph(p) | color(Color::Red)}));
// Mounting rotation first: it changes the meaning of every number above,
// and it is the one row here the operator sets rather than the device.
// (The earth-frame correction is deliberately not shown: it is a fixed
// property of the device, not something the operator sets or acts on. It
// is stated once on the startup log line.)
if (c.mount.empty())
body.push_back(hbox({text("Mount (cfg)") | dim | size(WIDTH, EQUAL, 16),
text("none") | dim,
text(" \xE2\x80\x94 readings are as the sensor reports them")
| dim}));
else
body.push_back(hbox({text("Mount (cfg)") | dim | size(WIDTH, EQUAL, 16),
text(c.mount) | color(Color::Yellow) | bold,
text(" deg, rotations from IMU frame to gimbal frame")
| dim}));
if (!c.present) {
// Everything below this point is device-reported and unavailable.
body.push_back(hbox({text("") | size(WIDTH, EQUAL, 16),
text("(device configuration not reported)") | dim}));
}
if (c.present) {
body.push_back(cfgRow("Product", c.product_code));
body.push_back(cfgRow("Firmware", c.firmware));
body.push_back(cfgRow("Device ID", c.device_id));
@ -1008,6 +1045,7 @@ Element sensorsDetailPanel(const ImuView& v, const EnvView& e, ScrollState& st)
text(p.name) | dim}));
}
}
}
}
Element imu_status = v.present ? (text(" MTi live ") | color(Color::Green) | bold)

View File

@ -30,6 +30,13 @@ UiColor axisStateColor(AxisState s) {
}
}
std::string formatImuMount(const ImuMount& m) {
if (m.identity()) return "";
char b[64];
std::snprintf(b, sizeof(b), "x %.1f y %.1f z %.1f", m.x_deg, m.y_deg, m.z_deg);
return b;
}
std::string formatDegrees(double deg) {
char buf[32];
std::snprintf(buf, sizeof(buf), "%.1f\xC2\xB0", deg); // UTF-8 degree sign

View File

@ -35,6 +35,7 @@ add_executable(fgc_tests
test_uisnapshot.cpp
test_scroll.cpp
test_mtiprotocol.cpp
test_imumount.cpp
test_sht41.cpp
test_dumpparser.cpp
test_helptext.cpp

View File

@ -344,3 +344,36 @@ TEST_CASE("loadFromFile anchors a relative profile_dir to the config file's dire
fs::remove_all(dir);
}
TEST_CASE("[IMU] x/y/z describe the mount and default to no rotation") {
// Absent keys must leave the identity, so an existing config.ini keeps
// behaving exactly as it did before the mount existed.
AppConfig none = ConfigLoader::fromMap({{"Camera.rgb.id", "D"},
{"Camera.rgb.profile_dir", "/tmp/p"}});
CHECK(none.imu.mount.identity());
AppConfig c = ConfigLoader::fromMap({{"Camera.rgb.id", "D"},
{"Camera.rgb.profile_dir", "/tmp/p"},
{"IMU.x", "180"},
{"IMU.y", "-0.5"},
{"IMU.z", "-90"}});
CHECK(c.imu.mount.x_deg == doctest::Approx(180.0));
CHECK(c.imu.mount.y_deg == doctest::Approx(-0.5));
CHECK(c.imu.mount.z_deg == doctest::Approx(-90.0));
CHECK_FALSE(c.imu.mount.identity());
}
TEST_CASE("A mount angle that cannot be a rotation is rejected") {
auto with = [](const std::string& k, const std::string& v) {
return ConfigLoader::fromMap({{"Camera.rgb.id", "D"},
{"Camera.rgb.profile_dir", "/tmp/p"},
{k, v}});
};
// Out of range is a typo far more often than an intent, and one accepted
// silently is invisible afterwards - the readings just describe wrong axes.
CHECK_THROWS(with("IMU.z", "900"));
CHECK_THROWS(with("IMU.x", "-361"));
CHECK_THROWS(with("IMU.y", "nan"));
CHECK_NOTHROW(with("IMU.z", "-360"));
CHECK_NOTHROW(with("IMU.z", "360"));
}

347
tests/test_imumount.cpp Normal file
View File

@ -0,0 +1,347 @@
#include <doctest/doctest.h>
#include "fgc/ImuMount.h"
#include <cmath>
using namespace fgc;
namespace {
// A sample with distinguishable values in every slot, so a transform that
// touches the wrong field is visible.
ImuSample makeSample() {
ImuSample s;
s.valid = true;
s.roll_deg = 0.f;
s.pitch_deg = 0.f;
s.yaw_deg = 0.f;
s.acc[0] = 1.f;
s.acc[1] = 2.f;
s.acc[2] = 3.f;
s.gyr[0] = 0.1f;
s.gyr[1] = 0.2f;
s.gyr[2] = 0.3f;
s.mag[0] = 0.4f;
s.mag[1] = 0.5f;
s.mag[2] = 0.6f;
s.temp_c = 24.5f;
s.sample_counter = 4321;
return s;
}
double norm(const float v[3]) {
return std::sqrt(double(v[0]) * v[0] + double(v[1]) * v[1] + double(v[2]) * v[2]);
}
// Heading difference on the circle, so 359.9 and 0.1 are 0.2 apart, not 359.8.
double headingDiff(double a, double b) {
double d = std::fmod(std::abs(a - b), 360.0);
return d > 180.0 ? 360.0 - d : d;
}
} // namespace
TEST_CASE("an identity mount changes nothing at all") {
const ImuSample in = makeSample();
const ImuSample out = applyMount(ImuMount{}, in);
// Exact, not approximate: a rig with no mount configured must be bit-for-bit
// what it was before this feature existed.
CHECK(out.roll_deg == in.roll_deg);
CHECK(out.pitch_deg == in.pitch_deg);
CHECK(out.yaw_deg == in.yaw_deg);
for (int i = 0; i < 3; ++i) {
CHECK(out.acc[i] == in.acc[i]);
CHECK(out.gyr[i] == in.gyr[i]);
CHECK(out.mag[i] == in.mag[i]);
}
CHECK(ImuMount{}.identity());
CHECK_FALSE(ImuMount{0.0, 0.0, 90.0}.identity());
}
TEST_CASE("the mount rotation is a rotation: orthonormal, determinant +1") {
for (const ImuMount& m : {ImuMount{0, 0, 0}, ImuMount{180, 0, 0}, ImuMount{0, 0, -90},
ImuMount{30, -45, 120}, ImuMount{-90, 17, 240}}) {
const Mat3 R = mountRotationZXY(m);
// R^T R = I
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j) {
const double dot = R[0][i] * R[0][j] + R[1][i] * R[1][j] + R[2][i] * R[2][j];
CHECK(dot == doctest::Approx(i == j ? 1.0 : 0.0).epsilon(1e-12));
}
const double det = R[0][0] * (R[1][1] * R[2][2] - R[1][2] * R[2][1]) -
R[0][1] * (R[1][0] * R[2][2] - R[1][2] * R[2][0]) +
R[0][2] * (R[1][0] * R[2][1] - R[1][1] * R[2][0]);
CHECK(det == doctest::Approx(1.0)); // +1, not -1: a rotation, never a reflection
}
}
TEST_CASE("z = 90: the IMU's forward axis reads as the gimbal's left") {
// The angles are what you apply TO THE IMU to bring it onto the gimbal. If it
// takes a +90 turn about down to align it, the IMU is currently pointing 90
// deg to the LEFT of the gimbal's nose - so what the sensor calls forward is
// what the gimbal calls left (-Y).
ImuSample s = makeSample();
s.acc[0] = 1.f; s.acc[1] = 0.f; s.acc[2] = 0.f; // sensor-frame forward
const ImuSample out = applyMount(ImuMount{0, 0, 90}, s);
CHECK(out.acc[0] == doctest::Approx(0.0).scale(1));
CHECK(out.acc[1] == doctest::Approx(-1.0));
CHECK(out.acc[2] == doctest::Approx(0.0).scale(1));
}
TEST_CASE("x = 180: an upside-down IMU has its gravity put back the right way up") {
// Mounted inverted, the sensor's own Z points at the sky, so a stationary unit
// reports gravity along -Z. In the gimbal frame it must come back as +Z (down).
ImuSample s = makeSample();
s.acc[0] = 0.f; s.acc[1] = 0.f; s.acc[2] = -9.81f;
const ImuSample out = applyMount(ImuMount{180, 0, 0}, s);
CHECK(out.acc[0] == doctest::Approx(0.0).scale(1));
CHECK(out.acc[1] == doctest::Approx(0.0).scale(1));
CHECK(out.acc[2] == doctest::Approx(9.81));
}
TEST_CASE("a pure yaw mount shifts the reported heading by exactly that much") {
for (double sensor_yaw : {0.0, 45.0, 170.0, 300.0, 359.0}) {
ImuSample s = makeSample();
s.yaw_deg = static_cast<float>(sensor_yaw);
const ImuSample out = applyMount(ImuMount{0, 0, 90}, s);
CHECK(headingDiff(out.yaw_deg, sensor_yaw + 90.0) == doctest::Approx(0.0).scale(1));
CHECK(out.roll_deg == doctest::Approx(0.0).scale(1));
CHECK(out.pitch_deg == doctest::Approx(0.0).scale(1));
// Stays in the device's own range: the mount rotates, it does not re-range.
CHECK(out.yaw_deg >= -180.f);
CHECK(out.yaw_deg <= 180.f);
}
}
TEST_CASE("the composition order is Z-X-Y, not any other permutation") {
// z=90 and x=180 together are what separate the orders: intrinsic Z-X-Y rolls
// about the X axis the yaw has ALREADY carried around, which lands sensor-
// forward somewhere a Y-X-Z composition never would. Without this, the order
// could be silently swapped and every single-axis test above would still pass.
ImuSample s = makeSample();
s.acc[0] = 1.f; s.acc[1] = 0.f; s.acc[2] = 0.f;
// R = Rz(90)Rx(180) = [[0,1,0],[1,0,0],[0,0,-1]], so R^T sends sensor-forward
// to gimbal +Y (right).
const ImuSample zxy = applyMount(ImuMount{180, 0, 90}, s);
CHECK(zxy.acc[0] == doctest::Approx(0.0).scale(1));
CHECK(zxy.acc[1] == doctest::Approx(1.0));
CHECK(zxy.acc[2] == doctest::Approx(0.0).scale(1));
// Feeding the same two angles to the other axes gives a different answer -
// R = Rz(180)Rx(90) sends sensor-forward to gimbal -X instead - which is what
// proves the order is load-bearing rather than incidental.
const ImuSample swapped = applyMount(ImuMount{90, 0, 180}, s);
CHECK(swapped.acc[0] == doctest::Approx(-1.0));
CHECK(swapped.acc[1] == doctest::Approx(0.0).scale(1));
}
TEST_CASE("rotating a vector never changes its length") {
// The cheapest guard against a malformed matrix: a rotation preserves
// magnitude, a typo in a sine almost never does.
const ImuSample in = makeSample();
for (const ImuMount& m : {ImuMount{30, -45, 120}, ImuMount{180, 0, -90}, ImuMount{-12, 88, 7}}) {
const ImuSample out = applyMount(m, in);
CHECK(norm(out.acc) == doctest::Approx(norm(in.acc)));
CHECK(norm(out.gyr) == doctest::Approx(norm(in.gyr)));
CHECK(norm(out.mag) == doctest::Approx(norm(in.mag)));
}
}
TEST_CASE("everything that is not a frame passes through untouched") {
const ImuSample in = makeSample();
const ImuSample out = applyMount(ImuMount{30, -45, 120}, in);
CHECK(out.temp_c == in.temp_c);
CHECK(out.sample_counter == in.sample_counter);
CHECK(out.valid == in.valid);
}
TEST_CASE("Euler round-trips through the rotation matrix") {
struct { double roll, pitch, yaw; } cases[] = {
{0, 0, 0}, {10, 20, 30}, {-45, 12, 200}, {179, -80, 359}, {-170, 0, 45},
};
for (const auto& c : cases) {
const Mat3 R = rotationFromEulerZYX(c.roll, c.pitch, c.yaw);
double roll = 0, pitch = 0, yaw = 0;
eulerZYXFromRotation(R, roll, pitch, yaw);
CHECK(roll == doctest::Approx(c.roll).epsilon(1e-9));
CHECK(pitch == doctest::Approx(c.pitch).epsilon(1e-9));
CHECK(headingDiff(yaw, c.yaw) == doctest::Approx(0.0).scale(1));
}
}
TEST_CASE("applying a mount and then its inverse gives back the original") {
const ImuMount m{30, -45, 120};
// The inverse of Rz(z)Rx(x)Ry(y) is Ry(-y)Rx(-x)Rz(-z), which is NOT another
// Z-X-Y mount - so undo it by rotating the matrix back rather than by
// negating the angles, and check against the composed identity.
const Mat3 R = mountRotationZXY(m);
Mat3 Rt{};
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j) Rt[i][j] = R[j][i];
// R^T R must be the identity, i.e. the round trip is exact by construction.
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j) {
const double v = Rt[i][0] * R[0][j] + Rt[i][1] * R[1][j] + Rt[i][2] * R[2][j];
CHECK(v == doctest::Approx(i == j ? 1.0 : 0.0).epsilon(1e-12));
}
// And a yaw mount really is undone by the opposite yaw mount.
const ImuSample in = makeSample();
const ImuSample there_and_back = applyMount(ImuMount{0, 0, -35}, applyMount(ImuMount{0, 0, 35}, in));
for (int i = 0; i < 3; ++i) CHECK(there_and_back.acc[i] == doctest::Approx(in.acc[i]));
}
TEST_CASE("pitch at the poles produces angles, not NaN") {
// Straight up and straight down are gimbal lock: roll and yaw stop being
// separable. The result must still be finite - a NaN here would propagate
// into the calibration fit and the drift test with no warning.
for (double pitch : {90.0, -90.0, 89.9999999}) {
ImuSample s = makeSample();
s.pitch_deg = static_cast<float>(pitch);
s.yaw_deg = 137.f;
const ImuSample out = applyMount(ImuMount{0, 0, 25}, s);
CHECK(std::isfinite(out.roll_deg));
CHECK(std::isfinite(out.pitch_deg));
CHECK(std::isfinite(out.yaw_deg));
CHECK(out.yaw_deg >= -180.f);
CHECK(out.yaw_deg <= 180.f);
}
}
// --- The earth reference ----------------------------------------------------
namespace {
Mat3 matMul(const Mat3& a, const Mat3& b) {
Mat3 r{};
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j)
r[i][j] = a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j];
return r;
}
// What a device with earth frame `ref` and body mount `m` would report, given the
// gimbal's true attitude in NED. The inverse of what applyFrames has to undo.
ImuSample deviceReports(ImuReference ref, const ImuMount& m, double roll, double pitch,
double yaw) {
const Mat3 ned_from_local = nedFromReference(ref);
Mat3 local_from_ned{};
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j) local_from_ned[i][j] = ned_from_local[j][i];
const Mat3 ned_from_gimbal = rotationFromEulerZYX(roll, pitch, yaw);
// R_gimbal<-sensor is the transpose of the mount (which is R_sensor<-gimbal).
const Mat3 R = mountRotationZXY(m);
Mat3 gimbal_from_sensor{};
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j) gimbal_from_sensor[i][j] = R[j][i];
const Mat3 local_from_sensor =
matMul(local_from_ned, matMul(ned_from_gimbal, gimbal_from_sensor));
double r = 0, p = 0, y = 0;
eulerZYXFromRotation(local_from_sensor, r, p, y);
ImuSample s = makeSample();
s.roll_deg = static_cast<float>(r);
s.pitch_deg = static_cast<float>(p);
s.yaw_deg = static_cast<float>(y);
return s;
}
} // namespace
TEST_CASE("a Z-up earth reference is what makes pitch fall as the nose rises") {
// The bug this whole path exists for. An upside-down IMU on a NWU-referenced
// device reports pitch with the wrong sign, and - the crucial part - the mount
// alone cannot fix it, because a mount only ever shifts roll here.
const ImuMount upside_down{180, 0, 0};
for (double nose_up : {5.0, 10.0, 20.0}) {
const ImuSample raw = deviceReports(ImuReference::Nwu, upside_down, 0.0, nose_up, 0.0);
CHECK(raw.pitch_deg == doctest::Approx(-nose_up)); // falls, as observed
CHECK(raw.roll_deg == doctest::Approx(0.0).scale(1)); // and reads level
// Mount only: pitch is untouched, roll merely picks up 180.
const ImuSample mount_only = applyMount(upside_down, raw);
CHECK(mount_only.pitch_deg == doctest::Approx(-nose_up));
CHECK(std::abs(mount_only.roll_deg) == doctest::Approx(180.0));
// Earth reference + mount: correct.
const ImuSample fixed = applyFrames(ImuReference::Nwu, upside_down, raw);
CHECK(fixed.pitch_deg == doctest::Approx(nose_up));
CHECK(fixed.roll_deg == doctest::Approx(0.0).scale(1));
CHECK(fixed.yaw_deg == doctest::Approx(0.0).scale(1));
}
}
TEST_CASE("applyFrames recovers the true attitude for every reference and mount") {
struct { double roll, pitch, yaw; } poses[] = {
{0, 0, 0}, {0, 10, 0}, {0, -25, 45}, {15, 0, 0}, {-30, 12, -120}, {0, 0, 179},
};
for (ImuReference ref : {ImuReference::Ned, ImuReference::Nwu, ImuReference::Enu}) {
for (const ImuMount& m : {ImuMount{}, ImuMount{180, 0, 0}, ImuMount{0, 0, -90},
ImuMount{90, 0, 180}}) {
for (const auto& p : poses) {
const ImuSample raw = deviceReports(ref, m, p.roll, p.pitch, p.yaw);
const ImuSample fixed = applyFrames(ref, m, raw);
CHECK(fixed.roll_deg == doctest::Approx(p.roll).epsilon(1e-4));
CHECK(fixed.pitch_deg == doctest::Approx(p.pitch).epsilon(1e-4));
CHECK(headingDiff(fixed.yaw_deg, p.yaw) == doctest::Approx(0.0).scale(1));
}
}
}
}
TEST_CASE("the earth reference leaves body-frame vectors alone") {
// acc/gyr/mag say which way the sensor felt something, which has nothing to do
// with what the orientation is measured against. Only the mount may touch them.
const ImuSample in = makeSample();
for (ImuReference ref : {ImuReference::Ned, ImuReference::Nwu, ImuReference::Enu}) {
const ImuSample out = applyFrames(ref, ImuMount{}, in);
for (int i = 0; i < 3; ++i) {
CHECK(out.acc[i] == doctest::Approx(in.acc[i]));
CHECK(out.gyr[i] == doctest::Approx(in.gyr[i]));
CHECK(out.mag[i] == doctest::Approx(in.mag[i]));
}
}
}
TEST_CASE("NED plus an identity mount is an exact no-op, and the matrices are rotations") {
const ImuSample in = makeSample();
const ImuSample out = applyFrames(ImuReference::Ned, ImuMount{}, in);
CHECK(out.roll_deg == in.roll_deg);
CHECK(out.pitch_deg == in.pitch_deg);
CHECK(out.yaw_deg == in.yaw_deg);
for (ImuReference ref : {ImuReference::Ned, ImuReference::Nwu, ImuReference::Enu}) {
const Mat3 R = nedFromReference(ref);
const double det = R[0][0] * (R[1][1] * R[2][2] - R[1][2] * R[2][1]) -
R[0][1] * (R[1][0] * R[2][2] - R[1][2] * R[2][0]) +
R[0][2] * (R[1][0] * R[2][1] - R[1][1] * R[2][0]);
CHECK(det == doctest::Approx(1.0)); // a rotation, never a reflection
}
CHECK(std::string(referenceName(ImuReference::Nwu)) == "NWU");
}
TEST_CASE("NWU and ENU differ only in yaw, by exactly 90 degrees") {
// Which of the two the device uses cannot be told from roll or pitch - only a
// compass check on yaw distinguishes them. That is worth pinning, because it
// is the whole risk of picking the wrong one.
for (double pitch : {0.0, 15.0, -20.0}) {
const ImuSample raw = deviceReports(ImuReference::Nwu, ImuMount{180, 0, 0}, 0, pitch, 30);
const ImuSample as_nwu = applyFrames(ImuReference::Nwu, ImuMount{180, 0, 0}, raw);
const ImuSample as_enu = applyFrames(ImuReference::Enu, ImuMount{180, 0, 0}, raw);
CHECK(as_enu.roll_deg == doctest::Approx(as_nwu.roll_deg));
CHECK(as_enu.pitch_deg == doctest::Approx(as_nwu.pitch_deg));
CHECK(headingDiff(as_enu.yaw_deg, as_nwu.yaw_deg + 90.0) == doctest::Approx(0.0).scale(1));
}
}

View File

@ -121,7 +121,7 @@ TEST_CASE("framer reassembles frames split across feeds and back-to-back frames"
CHECK(count == 2);
}
TEST_CASE("parseMTData reports yaw as a 0..360 heading") {
TEST_CASE("parseMTData passes roll, pitch and yaw through untouched") {
auto frameWithYaw = [](float yaw) {
std::vector<uint8_t> d;
for (int i = 0; i < 10; ++i) putBEFloat(d, 0.f); // temp + acc3 + gyr3 + mag3
@ -136,12 +136,46 @@ TEST_CASE("parseMTData reports yaw as a 0..360 heading") {
if (auto s = parseMTData(mid, p, n)) got = *s;
});
// No range shift, no sign flip: the device's own -180..180 reaches the
// consumer verbatim. The only thing that ever alters an IMU angle is the
// [IMU] x/y/z mount rotation, and that is applied later and only if set.
auto fn = frameWithYaw(-90.f); fr.feed(fn.data(), fn.size());
CHECK(got.yaw_deg == doctest::Approx(270.0f));
CHECK(got.yaw_deg == doctest::Approx(-90.0f));
auto fp = frameWithYaw(45.f); fr.feed(fp.data(), fp.size());
CHECK(got.yaw_deg == doctest::Approx(45.0f));
auto fb = frameWithYaw(-179.f); fr.feed(fb.data(), fb.size());
CHECK(got.yaw_deg == doctest::Approx(181.0f));
CHECK(got.yaw_deg == doctest::Approx(-179.0f));
}
TEST_CASE("no axis is negated or re-ranged on the way out of the parser") {
// The rig's own convention is X forward / Y right / Z down with positive
// pitch = nose up. Whether the device agrees is the device's business: the
// parser's job is to not have an opinion, so a negative angle stays negative
// and a positive one stays positive on every axis.
std::vector<uint8_t> d;
putBEFloat(d, 21.5f); // temp
for (float v : {1.f, -2.f, 3.f}) putBEFloat(d, v); // acc
for (float v : {-4.f, 5.f, -6.f}) putBEFloat(d, v); // gyr
for (float v : {7.f, -8.f, 9.f}) putBEFloat(d, v); // mag
putBEFloat(d, -30.f); // roll
putBEFloat(d, -12.5f); // pitch (nose down)
putBEFloat(d, -95.f); // yaw
d.push_back(0x12); d.push_back(0x34);
auto msg = mtiMessage(kMidMTData, d);
ImuSample got;
MtiFramer fr([&](uint8_t mid, const uint8_t* p, size_t n) {
if (auto s = parseMTData(mid, p, n)) got = *s;
});
fr.feed(msg.data(), msg.size());
CHECK(got.roll_deg == doctest::Approx(-30.0f));
CHECK(got.pitch_deg == doctest::Approx(-12.5f));
CHECK(got.yaw_deg == doctest::Approx(-95.0f));
CHECK(got.acc[1] == doctest::Approx(-2.0f)); // vectors too: no axis flipped
CHECK(got.gyr[0] == doctest::Approx(-4.0f));
CHECK(got.mag[1] == doctest::Approx(-8.0f));
CHECK(got.temp_c == doctest::Approx(21.5f));
}
TEST_CASE("orientation-control command builders are well-formed") {
@ -364,3 +398,140 @@ TEST_CASE("framer rejects a bad checksum and a wrong-length payload") {
CHECK_FALSE(parseMTData(kMidMTData, shortData.data(), shortData.size()).has_value());
}
}
// --- What the host asks for, and proving the device took it -----------------
TEST_CASE("the MTData length is derived from the output settings, not asserted") {
// The constant the parser gates on must follow the bits, so that changing a
// setting can never silently leave parseMTData decoding the old layout.
CHECK(mtDataLen(kOutputMode, kOutputSettings) == 54);
CHECK(kMTDataLen == 54);
using namespace mtsettings;
const uint32_t base = kOutputSettings & ~kOrientationMask;
// Orientation mode dominates the length: Temp(4) + acc/gyr/mag(36) + N + counter(2).
CHECK(mtDataLen(kOutputMode, base | kOrientationQuaternion) == 58);
CHECK(mtDataLen(kOutputMode, base | kOrientationEuler) == 54);
CHECK(mtDataLen(kOutputMode, base | kOrientationMatrix) == 78);
// A disabled calibrated channel drops its 12 bytes.
CHECK(mtDataLen(kOutputMode, kOutputSettings | kMagDisabled) == 42);
CHECK(mtDataLen(kOutputMode, kOutputSettings | kAccDisabled | kGyrDisabled) == 30);
// No timestamp drops the trailing sample counter.
CHECK(mtDataLen(kOutputMode, kOutputSettings & ~kTimestampMask) == 52);
// Mode bits drop whole blocks.
CHECK(mtDataLen(kOutputMode & ~mtmode::kTemperature, kOutputSettings) == 50);
CHECK(mtDataLen(kOutputMode & ~mtmode::kOrientation, kOutputSettings) == 42);
CHECK(mtDataLen(kOutputMode | mtmode::kStatus, kOutputSettings) == 55);
}
TEST_CASE("the settings word the host writes still decodes as Euler + counter + float") {
// kOutputSettings gained named fields (and a slot for the coordinate system);
// it must still describe the stream parseMTData actually decodes.
ImuDeviceConfig c;
std::vector<uint8_t> d = {uint8_t(kOutputSettings >> 24), uint8_t(kOutputSettings >> 16),
uint8_t(kOutputSettings >> 8), uint8_t(kOutputSettings)};
CHECK(applyImuConfigAck(c, kMidSetOutputSettingsAck, d.data(), d.size()));
CHECK(c.orientation_mode == "Euler");
CHECK(c.timestamp_mode == "Sample counter");
CHECK(c.data_format == "Float");
CHECK(c.acc_enabled);
CHECK(c.gyr_enabled);
CHECK(c.mag_enabled);
}
namespace {
// A device config reporting exactly what the host asked for.
ImuDeviceConfig agreeingConfig() {
ImuDeviceConfig c;
c.valid = true;
c.has_output_mode = true;
c.output_mode = kOutputMode;
c.has_output_settings = true;
c.output_settings = kOutputSettings;
return c;
}
bool mentions(const std::vector<ImuConfigMismatch>& v, const std::string& field) {
for (const auto& m : v)
if (m.field == field) return true;
return false;
}
} // namespace
TEST_CASE("checkImuDeviceConfig passes a device that took the configuration") {
CHECK(checkImuDeviceConfig(agreeingConfig()).empty());
}
TEST_CASE("checkImuDeviceConfig catches what the payload length cannot") {
// These are the dangerous ones: neither changes the MTData length, so the
// length gate in parseMTData sees nothing wrong and the readings are silently
// decoded from the wrong frame or the wrong number format.
SUBCASE("a coordinate system that is not what was asked for") {
auto c = agreeingConfig();
c.output_settings = kOutputSettings ^ 0x80000000u; // a high bit, whatever it means
const auto found = checkImuDeviceConfig(c);
REQUIRE(found.size() == 1);
CHECK(found[0].field == "output settings");
CHECK(found[0].answered);
CHECK(found[0].expected == kOutputSettings);
CHECK(found[0].actual == c.output_settings);
// The detail must point at the coordinate system, since that is where an
// unrecognized high bit lives.
CHECK(found[0].detail.find("coordinate system") != std::string::npos);
CHECK(found[0].describe().find("output settings") != std::string::npos);
}
SUBCASE("fixed-point instead of float, which keeps the length at 54") {
auto c = agreeingConfig();
c.output_settings = (kOutputSettings & ~mtsettings::kFormatMask) |
mtsettings::kFormatFixed1220;
CHECK(mtDataLen(kOutputMode, c.output_settings) == kMTDataLen); // invisible to the gate
const auto found = checkImuDeviceConfig(c);
REQUIRE(found.size() == 1);
CHECK(found[0].detail.find("Fixed 12.20") != std::string::npos);
CHECK(found[0].detail.find("Float") != std::string::npos);
}
}
TEST_CASE("an unanswered field is unverifiable, not a match") {
// Older firmware that ignores the Req* queries must never be mistaken for a
// device that agreed - that is the whole difference between "checked" and
// "could not check".
auto c = agreeingConfig();
c.has_output_settings = false;
c.output_settings = 0;
const auto found = checkImuDeviceConfig(c);
REQUIRE(found.size() == 1);
CHECK(found[0].field == "output settings");
CHECK_FALSE(found[0].answered);
CHECK(found[0].describe().find("could not be verified") != std::string::npos);
}
TEST_CASE("both fields are reported when both disagree") {
ImuDeviceConfig c;
c.valid = true;
c.has_output_mode = true;
c.output_mode = mtmode::kTemperature; // orientation is off
c.has_output_settings = true;
c.output_settings = (kOutputSettings & ~mtsettings::kOrientationMask) |
mtsettings::kOrientationQuaternion;
const auto found = checkImuDeviceConfig(c);
CHECK(found.size() == 2);
CHECK(mentions(found, "output mode"));
CHECK(mentions(found, "output settings"));
for (const auto& m : found) CHECK_FALSE(m.detail.empty());
}
TEST_CASE("mtiErrorText names known codes and does not swallow unknown ones") {
CHECK(mtiErrorText(0x04) == "invalid message");
CHECK(mtiErrorText(0x21) == "invalid parameter");
// An unrecognized code must still carry the number through to the log.
CHECK(mtiErrorText(0x7B).find("0x7B") != std::string::npos);
}