Compare commits

...

6 Commits

63 changed files with 3745 additions and 1069 deletions

View File

@ -60,7 +60,10 @@ add_library(fgc_core STATIC
src/core/Calibration.cpp src/core/Calibration.cpp
src/core/CalibrationRoutine.cpp src/core/CalibrationRoutine.cpp
src/core/MtiProtocol.cpp src/core/MtiProtocol.cpp
src/core/CameraMode.cpp src/core/ImuMount.cpp
src/core/CameraProfile.cpp
src/core/CameraSettingsXml.cpp
src/core/Hash.cpp
src/core/ChannelHistory.cpp src/core/ChannelHistory.cpp
src/core/ChannelTopics.cpp src/core/ChannelTopics.cpp
src/sensors/Sht41Protocol.cpp src/sensors/Sht41Protocol.cpp

View File

@ -135,7 +135,7 @@ While running, the program reads commands from stdin (one per line):
| `debug` | Toggle debug-level logging on/off. | | `debug` | Toggle debug-level logging on/off. |
| `set fps <n>` | Capture rate, in images per second. | | `set fps <n>` | Capture rate, in images per second. |
| `set camera fps <n>` | Camera sensor frame rate. | | `set camera fps <n>` | Camera sensor frame rate. |
| `set camera mode <name>` | Switch the camera mode (`standard`, `night`, or any other file in `[Camera] mode_dir`). | | `set camera profile [<cam>] <name>` | Switch a camera's profile (any `<name>.xml` in its `profile_dir`). |
| `set camera jxlq <d>` | JPEG XL quality as butteraugli **distance** (lower = higher quality / larger files). | | `set camera jxlq <d>` | JPEG XL quality as butteraugli **distance** (lower = higher quality / larger files). |
| `set camera jxle <n>` | JPEG XL encoder effort (higher = slower, smaller). | | `set camera jxle <n>` | JPEG XL encoder effort (higher = slower, smaller). |
| `set camera display <0\|1>` | Toggle the local preview window. | | `set camera display <0\|1>` | Toggle the local preview window. |
@ -150,9 +150,10 @@ discarded, and their job is simply to keep that exposure controller converged. A
gimbal settles, holds still for the mode's `settle_wait_ms`, and the newest frame is kept — so the saved gimbal settles, holds still for the mode's `settle_wait_ms`, and the newest frame is kept — so the saved
image was exposed at rest and already correctly metered, with nothing waiting on a convergence loop. image was exposed at rest and already correctly metered, with nothing waiting on a convergence loop.
Which rate and which exposure limits apply comes from a **camera mode**: one INI file per mode, listing Which rate and which exposure limits apply comes from a **camera profile**: a settings XML exported from
Vimba X features applied verbatim. `standard` runs 10 Hz for daylight, `night` runs 1 Hz with long the VimbaX Viewer and handed straight to the Vimba X SDK, so the camera gets exactly the combination the
exposures; switch live with `set camera mode <name>`. Details in GUI validated. `standard` runs 10 Hz for daylight, `night` runs 1 Hz with long exposures; switch live with
`set camera profile <name>`. Details in
[docs/architecture.md](docs/architecture.md#image-capture); the mode-file format is `[Camera]` in [docs/architecture.md](docs/architecture.md#image-capture); the mode-file format is `[Camera]` in
[docs/configuration.md](docs/configuration.md). [docs/configuration.md](docs/configuration.md).

View File

@ -1,110 +0,0 @@
; Low-light camera mode for the RGB Alvium 1800 U-2040c: continuous 1 Hz
; acquisition with long exposures and a high gain ceiling.
;
; Standalone: mode files do not inherit from one another, so this repeats
; standard.ini except where the two deliberately differ (marked NIGHT below).
;
; [Camera] is a list of Vimba X features written to the camera IN FILE ORDER
; when this mode is applied. Names and enum values must be spelled exactly as
; VimbaX spells them (Minimizenoise, not MinimizeNoise) - see a VimbaX settings
; export for the full feature list. A name this camera does not expose is logged
; as a warning and skipped, never fatal.
;
; Selector features are stateful: set the selector, then the value it selects.
; Repeating a key is intentional and supported.
[Mode]
description = Low light: 1 Hz, long exposures, high gain ceiling
; Wait this long after the gimbal reports standstill, then keep the newest frame.
; NIGHT: at 1 fps a 1 s wait need not contain a whole fresh frame; allow two periods.
settle_wait_ms = 2500
; Give up on the waypoint if no fresh frame arrives within this long after that.
frame_timeout_ms = 5000
[Camera]
; --- Image format: binning first, it changes the ROI maxima ----------------
; Same geometry as standard so both modes produce comparable images; 2x2 binning
; also sums four photosites, which is worth having in the dark.
BinningSelector = Digital
BinningHorizontalMode = Sum
BinningVerticalMode = Sum
BinningHorizontal = 2
BinningVertical = 2
OffsetX = 0
OffsetY = 0
Width = 2256
Height = 2256
ReverseX = 0
ReverseY = 0
PixelFormat = RGB8
SensorBitDepth = Bpp12 ; NIGHT: more tonal range where signal is scarce
ColorInterpolation = HighQualityLinear5X5
; --- Bandwidth: 1 Hz x 15.3 MB = ~15 MB/s ----------------------------------
; Do NOT raise to 450: that produced incomplete frames on this host.
DeviceLinkThroughputLimitMode = On
DeviceLinkThroughputLimit = 250000000
; --- Acquisition: free-run, no trigger -------------------------------------
AcquisitionMode = Continuous
TriggerSelector = FrameStart
TriggerMode = Off
AcquisitionFrameRateMode = Basic
AcquisitionFrameRateEnable = 1
AcquisitionFrameRate = 1 ; NIGHT
; --- Exposure and gain: camera-managed, bounded ----------------------------
ExposureMode = Timed
ExposureAuto = Continuous
ExposureAutoMin = 1000
ExposureAutoMax = 500000 ; NIGHT: 0.5 s, inside the 1 Hz frame period
GainSelector = All
GainAuto = Continuous
GainAutoMin = 0
GainAutoMax = 48 ; NIGHT
; --- Auto-exposure controller ----------------------------------------------
IntensityControllerSelector = IntensityController1
IntensityControllerAlgorithm = Mean
IntensityControllerTarget = 50 ; percent of full scale
IntensityControllerTolerance = 5
IntensityControllerRate = 100
IntensityControllerRegion = FullImage
IntensityControllerOutliersDark = 0
IntensityControllerOutliersBright = 0
IntensityAutoPrecedence = Minimizenoise ; NIGHT: expose longer before adding gain
AutoModeRegionSelector = AutoModeRegion1
AutoModeRegionOffsetX = 0
AutoModeRegionOffsetY = 0
AutoModeRegionWidth = 2256
AutoModeRegionHeight = 2256
; --- White balance: fixed ratios, measured in VimbaX ------------------------
BalanceWhiteAuto = Off
BalanceRatioSelector = Red
BalanceRatio = 2.870117
BalanceRatioSelector = Blue
BalanceRatio = 1.999023
; --- Image processing -------------------------------------------------------
BlackLevelSelector = All
BlackLevel = 81
Gamma = 1.0
Hue = 0
Saturation = 1.0
Sharpness = 0
ContrastEnable = 0
ContrastBrightLimit = 255
ContrastDarkLimit = 0
ContrastShape = 4
ColorTransformationEnable = 0
AdaptiveNoiseSuppressionFactor = 1 ; raise if frames look noisy
CorrectionSelector = DefectPixelCorrection
CorrectionMode = On
CorrectionSelector = FixedPatternNoiseCorrection
CorrectionMode = On
; --- Device -----------------------------------------------------------------
DeviceIndicatorMode = Off ; NIGHT: no status LED glow at a dark site
DeviceIndicatorLuminance = 0
DevicePowerSavingMode = Disabled

View File

@ -1,106 +0,0 @@
; Daytime camera mode for the RGB Alvium 1800 U-2040c: continuous 10 Hz
; acquisition with camera-managed exposure.
;
; [Camera] is a list of Vimba X features written to the camera IN FILE ORDER
; when this mode is applied. Names and enum values must be spelled exactly as
; VimbaX spells them (Minimizenoise, not MinimizeNoise) - see a VimbaX settings
; export for the full feature list. A name this camera does not expose is logged
; as a warning and skipped, never fatal.
;
; Selector features are stateful: set the selector, then the value it selects.
; Repeating a key is intentional and supported.
[Mode]
description = Daytime sweep: continuous 10 Hz, camera-managed exposure
; Wait this long after the gimbal reports standstill, then keep the newest frame.
settle_wait_ms = 1000
; Give up on the waypoint if no fresh frame arrives within this long after that.
frame_timeout_ms = 2000
[Camera]
; --- Image format: binning first, it changes the ROI maxima ----------------
; 2x2 binning is not optional at 10 Hz: a full-resolution RGB8 frame is 61 MB,
; which caps out at ~4 fps under the 250 MB/s link limit. Binned it is 15.3 MB.
BinningSelector = Digital
BinningHorizontalMode = Sum
BinningVerticalMode = Sum
BinningHorizontal = 2
BinningVertical = 2
OffsetX = 0
OffsetY = 0
Width = 2256
Height = 2256
ReverseX = 0
ReverseY = 0
PixelFormat = RGB8
SensorBitDepth = Bpp8
ColorInterpolation = HighQualityLinear5X5
; --- Bandwidth: 10 Hz x 15.3 MB = ~153 MB/s --------------------------------
; Do NOT raise to 450: that produced incomplete frames on this host.
DeviceLinkThroughputLimitMode = On
DeviceLinkThroughputLimit = 250000000
; --- Acquisition: free-run, no trigger -------------------------------------
AcquisitionMode = Continuous
TriggerSelector = FrameStart
TriggerMode = Off
AcquisitionFrameRateMode = Basic
AcquisitionFrameRateEnable = 1
AcquisitionFrameRate = 10
; --- Exposure and gain: camera-managed, bounded ----------------------------
ExposureMode = Timed
ExposureAuto = Continuous
ExposureAutoMin = 200
ExposureAutoMax = 20000 ; < 1/fps and short enough to limit slew blur
GainSelector = All
GainAuto = Continuous
GainAutoMin = 0
GainAutoMax = 24
; --- Auto-exposure controller ----------------------------------------------
IntensityControllerSelector = IntensityController1
IntensityControllerAlgorithm = Mean
IntensityControllerTarget = 50 ; percent of full scale
IntensityControllerTolerance = 5
IntensityControllerRate = 100
IntensityControllerRegion = FullImage
IntensityControllerOutliersDark = 0
IntensityControllerOutliersBright = 0
IntensityAutoPrecedence = Minimizeexposuretime
AutoModeRegionSelector = AutoModeRegion1
AutoModeRegionOffsetX = 0
AutoModeRegionOffsetY = 0
AutoModeRegionWidth = 2256
AutoModeRegionHeight = 2256
; --- White balance: fixed ratios, measured in VimbaX ------------------------
BalanceWhiteAuto = Off
BalanceRatioSelector = Red
BalanceRatio = 2.870117
BalanceRatioSelector = Blue
BalanceRatio = 1.999023
; --- Image processing -------------------------------------------------------
BlackLevelSelector = All
BlackLevel = 81
Gamma = 1.0
Hue = 0
Saturation = 1.0
Sharpness = 0
ContrastEnable = 0
ContrastBrightLimit = 255
ContrastDarkLimit = 0
ContrastShape = 4
ColorTransformationEnable = 0
AdaptiveNoiseSuppressionFactor = 1 ; raise if frames look noisy
CorrectionSelector = DefectPixelCorrection
CorrectionMode = On
CorrectionSelector = FixedPatternNoiseCorrection
CorrectionMode = On
; --- Device -----------------------------------------------------------------
DeviceIndicatorMode = Active
DeviceIndicatorLuminance = 10
DevicePowerSavingMode = Disabled

View File

@ -0,0 +1,67 @@
# RGB camera profiles
A profile is a **settings XML exported from the VimbaX Viewer**. A profile named
`X` is `X.xml` in this directory, and `[Camera.rgb] profile = X` in `config.ini`
selects it.
The program hands the XML straight to the Vimba X SDK (`LoadSettings`). Nothing
else writes camera settings, and there is no fallback — a missing `X.xml` is a
startup error. That is deliberate: GenICam validity is *stateful*
(`AcquisitionFrameRate` is only writable once `AcquisitionFrameRateEnable` is
set, `Width`'s maximum depends on binning, `IntensityAutoPrecedence` takes
`Minimizenoise` but not `MinimizeNoise`), so a hand-written list can be
individually plausible and collectively wrong. The Viewer greys out invalid
combinations as you configure; that is the guardrail this design relies on.
## These files live on the device only
The XMLs are exported on the LattePanda, because that is where the camera is, so
they exist nowhere in the repo. `deploy.sh` therefore excludes `*.xml` and
`*.txt` under this directory from its `rsync --delete` — without that, the next
deploy erases them. This README does sync; it is repo documentation.
Keep a copy somewhere durable if a profile represents real tuning effort: a
reflash or a rebuilt device starts with an empty directory.
## To create or change a profile
1. Open the VimbaX Viewer and connect to the camera.
2. Configure it. The Viewer will not let you pick an invalid combination.
3. Save the settings XML over `<profile>.xml` here.
4. Restart. The program notices the file changed, regenerates `<profile>.txt`,
and logs every setting that moved.
## The `.txt` files
Beside each `X.xml` the program writes `X.txt`: a generated, human-readable
record of the same settings, so a profile can be read and diffed without opening
the Viewer. **It configures nothing.** Editing it changes nothing; it is
overwritten whenever the XML changes.
## Profiles this rig expects
`config.ini` ships referencing `standard` and `night`. They were previously
hand-written; these are the values they used, as a starting point when
recreating them in the Viewer. Everything not listed was identical.
| Setting | `standard` | `night` |
|---|---|---|
| `AcquisitionFrameRate` | 10 | 1 |
| `SensorBitDepth` | Bpp8 | Bpp12 |
| `ExposureAutoMin` | 200 | 1000 |
| `ExposureAutoMax` | 20000 | 500000 |
| `GainAutoMax` | 24 | 48 |
| `IntensityAutoPrecedence` | Minimizeexposuretime | Minimizenoise |
| `DeviceIndicatorMode` | Active | Off |
| `DeviceIndicatorLuminance` | 10 | 0 |
Both used 2×2 binning at `RGB8` (2256×2256, 15.3 MB/frame) and a 250 MB/s
`DeviceLinkThroughputLimit`. **That binning is not optional at 10 Hz**: a
full-resolution RGB8 frame is 61 MB, which caps out near 4 fps under that limit.
The full previous definitions are in git history at
the hand-written `config/camera_modes/rgb/{standard,night}.ini`, removed in the
same change that made the XML the only thing applied.
`settle_wait_ms` / `frame_timeout_ms` are *not* camera settings and are not in
the XML — they live in `config.ini` under `[Camera.rgb.<profile>]`.

View File

@ -27,32 +27,43 @@ mqtt_user = CHANGE_ME
mqtt_pw = CHANGE_ME mqtt_pw = CHANGE_ME
[Camera] [Camera]
; Camera IDs: GigE IP (e.g. 192.168.11.101) or USB device ID (e.g. DEV_1AB22C0AADED). ; Defaults inherited by every camera block below; each may override them.
; Leave entries blank/absent for cameras you do not have. Order maps to RGB, ACR, NIR. ; JPEG XL encoding. distance: 0 = lossless, ~0.8 = near-lossless; effort 1..9.
id_Cam1 = DEV_0000000000
id_Cam2 =
id_Cam3 =
id_Cam4 =
; --- Camera modes ---
; Every on-camera setting (acquisition rate, exposure limits, binning, ROI, image
; processing) lives in a mode file: a mode named X is <mode_dir>/X.ini. Nothing
; about the sensor is configured here, so the two can never disagree.
; A relative path resolves against the directory holding the config.ini IN USE -
; which is the repo root on the device (deploy.sh copies this file there), not
; this config/ directory.
mode_dir = config/camera_modes/rgb
; Which mode to start in. Switch live with `set camera mode <name>`.
; Ships with: standard (10 Hz daytime), night (1 Hz, long exposures).
mode = standard
; JPEG XL encoding. distance: 0 = lossless, ~0.8 = near-lossless (default); effort 1..9.
jxl_distance = 0.8 jxl_distance = 0.8
jxl_effort = 4 jxl_effort = 4
; Clockwise rotation applied to saved images: 0, 90, 180 or 270. Depends on how the
; camera is mounted. 0 = store frames exactly as the sensor delivers them. ; --- One [Camera.<name>] block per camera -----------------------------------
; (Builds before this key existed always rotated 90 CCW, i.e. 270.) ; Nothing is implied by position: the block names the camera, `label` names its
; output folder and its CamEvent "cam" field, and `driver` picks the backend.
;
; The camera's own settings are NOT here. They live in a settings XML exported
; from the VimbaX Viewer - the Viewer validates combinations as you configure,
; which a text file cannot - and the program hands that XML to the SDK. A
; profile named X is <profile_dir>/X.xml. Beside it the program writes X.txt, a
; generated human-readable record of the same settings; it configures nothing.
[Camera.rgb]
enabled = true
driver = vimba ; only vimba is implemented
id = DEV_0000000000 ; USB DEV_ id, or a GigE IP
label = RGB ; output subdir + CamEvent "cam"
profile_dir = config/camera_profiles/rgb
profile = standard ; <profile_dir>/standard.xml
; Clockwise rotation applied to saved images: 0, 90, 180 or 270.
rotation_deg = 0 rotation_deg = 0
; Per-profile timing. Not camera settings - how long the program waits around
; them. After the gimbal reports standstill it holds still this long before
; keeping a frame, so the image is exposed at rest with the auto-exposure
; settled; frame_timeout_ms then abandons the waypoint rather than parking on it.
; With several cameras the scan uses the LONGEST wait across them.
[Camera.rgb.standard]
settle_wait_ms = 1000
frame_timeout_ms = 2000
[Camera.rgb.night]
settle_wait_ms = 2500
frame_timeout_ms = 5000
[Serial] [Serial]
; Motor-controller serial device and baud rate. ; Motor-controller serial device and baud rate.
device = /dev/ttyACM0 device = /dev/ttyACM0
@ -68,6 +79,63 @@ baud = 115200
device = device =
baud = 115200 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] [Env]
; Ambient temperature/humidity sensor (Adafruit SHT41), wired to the ; Ambient temperature/humidity sensor (Adafruit SHT41), wired to the
; LattePanda's OWN native I2C bus - NOT the Arduino/motor serial link. Bus ; LattePanda's OWN native I2C bus - NOT the Arduino/motor serial link. Bus

View File

@ -15,6 +15,9 @@
# ./deploy.sh --stop shut the running app down cleanly # ./deploy.sh --stop shut the running app down cleanly
# ./deploy.sh --clean wipe the remote build dir first (fresh configure) # ./deploy.sh --clean wipe the remote build dir first (fresh configure)
# ./deploy.sh --push-config ALSO overwrite the device's config.ini with the local one # ./deploy.sh --push-config ALSO overwrite the device's config.ini with the local one
#
# NOTE: camera profile XMLs under config/camera_profiles/ are device-local and
# are never synced or deleted - export them on the device with the VimbaX Viewer.
# ./deploy.sh --check-deps only check the remote build dependencies # ./deploy.sh --check-deps only check the remote build dependencies
# ./deploy.sh --run --force skip the placeholder-config guard before running # ./deploy.sh --run --force skip the placeholder-config guard before running
# ./deploy.sh --run --foreground run attached to this terminal instead (debugging) # ./deploy.sh --run --foreground run attached to this terminal instead (debugging)
@ -176,10 +179,15 @@ echo ">> build id: $GIT_REV"
echo ">> ensuring remote dir exists" echo ">> ensuring remote dir exists"
ssh "$REMOTE_HOST" "mkdir -p '$REMOTE_DIR'" ssh "$REMOTE_HOST" "mkdir -p '$REMOTE_DIR'"
# config.ini and images/ are device-local (runtime config + captured output) and # config.ini, the camera profile files and images/ are device-local (runtime
# are deliberately NOT mirrored, so a deploy never clobbers them. Note this # config, operator-exported camera settings, captured output) and are
# excludes only the config.ini at the repo root: config/ itself (the example, # deliberately NOT mirrored, so a deploy never clobbers them.
# camera-mode files, scan grids) always syncs. #
# The profile XMLs matter especially: they are exported on the DEVICE from the
# VimbaX Viewer, which is where the camera is, so they exist nowhere in this
# repo. Without these excludes `--delete` erases them on the next deploy - which
# it did once, taking a freshly exported standard.xml with it. The README.md in
# that directory IS repo documentation and still syncs.
# #
# build*/ covers every local build tree, not just build/. They are machine-specific # build*/ covers every local build tree, not just build/. They are machine-specific
# and the device regenerates its own build/ remotely, so shipping build-tui/ etc. # and the device regenerates its own build/ remotely, so shipping build-tui/ etc.
@ -208,6 +216,8 @@ rsync -avz --delete \
--exclude '.deploy.env' \ --exclude '.deploy.env' \
"${CONFIG_EXCLUDES[@]}" \ "${CONFIG_EXCLUDES[@]}" \
--exclude 'config.ini.bak-*' \ --exclude 'config.ini.bak-*' \
--exclude 'config/camera_profiles/**/*.xml' \
--exclude 'config/camera_profiles/**/*.txt' \
--exclude 'images/' \ --exclude 'images/' \
./ "$REMOTE_HOST:$REMOTE_DIR/" ./ "$REMOTE_HOST:$REMOTE_DIR/"

View File

@ -80,8 +80,10 @@ corrupted.
auto-exposure tracking the light; `trigger()` hands over the freshest complete frame. See *Image auto-exposure tracking the light; `trigger()` hands over the freshest complete frame. See *Image
capture* below. capture* below.
6. **Frame handling** — the kept `Frame` goes to the callback, which `submit()`s it to the 6. **Frame handling** — the kept `Frame` goes to the callback, which `submit()`s it to the
`ImagePipeline`. The worker rotates it by `Camera.rotation_deg`, encodes JPEG XL (or copies the demo image), writes `ImagePipeline`. `submit()` samples the gimbal pose there and then — yaw from the encoder, pitch from the IMU —
`<output_dir>/<label>/<unix_ms>.jxl`, and publishes a `CamEvent` (yaw + pitch from the encoders). and the pose travels through the queue with the frame, so encode latency cannot attribute a later pose to earlier
pixels. The worker rotates it by `Camera.rotation_deg`, encodes JPEG XL (or copies the demo image), writes
`<output_dir>/<label>/<unix_ms>.jxl`, and publishes a `CamEvent` carrying that pose.
## Capture state machine ## Capture state machine
@ -133,29 +135,30 @@ which is why `night` at 1 Hz waits 2.5 s where `standard` at 10 Hz waits 1 s.
If no fresh frame has arrived by `frame_timeout_ms` past the wait, the waypoint is logged and skipped. A If no fresh frame has arrived by `frame_timeout_ms` past the wait, the waypoint is logged and skipped. A
stalled camera costs single images, never the whole sweep. stalled camera costs single images, never the whole sweep.
### Camera modes ### Camera profiles
Everything on the camera comes from a **mode**: one INI file per mode, `<mode_dir>/<name>.ini`, holding an Everything on the camera comes from a **profile**: a settings XML exported from the VimbaX Viewer, at
ordered list of Vimba X features written verbatim at start-up and on every switch. There is no C++ struct of `<profile_dir>/<name>.xml`. The program hands that file to the Vimba X SDK (`LoadSettings`, restricted to
camera settings and no translation layer — a feature the camera exposes can be set by adding a line, and the export's `RemoteDevice` block) and nothing else writes camera settings.
`Feature::GetDataType()` tells the applier whether to write it as an int, float, enum, string or bool.
Two properties of the file are load-bearing, and both are why the mode loader does not reuse the ordinary The reason is validity, not convenience. GenICam writability is **stateful** — `AcquisitionFrameRate` is
config parser (which flattens into a `std::map`): only writable once `AcquisitionFrameRateEnable` is set, `Width`'s maximum depends on binning,
`IntensityAutoPrecedence` takes `Minimizenoise` but not `MinimizeNoise` — so a list of features applied
line by line can be individually plausible and collectively wrong, and a line that fails leaves the camera
quietly on its old value. The Viewer greys out invalid combinations as the operator configures; replaying
its export is how that guarantee is inherited. An earlier design applied a hand-written INI feature list;
it was removed rather than repaired, because the failure mode was silent.
- **Order is preserved.** GenICam selectors are stateful: `BinningHorizontal` means nothing until Beside each XML the program writes `<name>.txt`, a generated human-readable record of the same settings.
`BinningSelector` is set, and binning must precede `Width`/`Height` because it changes their maxima. **It configures nothing** — it exists so a profile can be read and diffed without opening the Viewer. At
- **Repeated keys are kept.** Writing both white-balance ratios *requires* `BalanceRatioSelector = Red`, start-up the XML is hashed and compared with the hash stored in the record; a mismatch regenerates the
`BalanceRatio = …`, `BalanceRatioSelector = Blue`, `BalanceRatio = …`. record and logs every setting that changed, so a camera behaving differently from the last run says so
rather than being discovered later. Every `*.xml` in the directory is loaded at start-up, so a profile
nobody uses today still fails at launch rather than the first night someone switches to it.
A feature this camera does not expose is a warning, not a fatal error — that warning is the feedback loop Each camera has its own `[Camera.<name>]` block and its own profile directory, so profiles are per camera
for a hand-edited mode file. A *structural* error (unknown section, unknown `[Mode]` key, a mode with no rather than shared. Switch live with `set camera profile [<camera>] <name>`; that stops acquisition,
features) is fatal, and every mode file in the directory is parsed at start-up, so a typo in a mode nobody reloads the XML and restarts, because binning, ROI and pixel format cannot be changed on a running stream.
uses today still fails at launch rather than the first night someone switches to it.
Ships with `standard` (10 Hz daytime) and `night` (1 Hz, long exposures, high gain ceiling). Switch live
with `set camera mode <name>`; that stops acquisition, rewrites the features and restarts, because binning,
ROI and pixel format cannot be changed on a running stream.
### Why not judge each frame instead ### Why not judge each frame instead

View File

@ -34,12 +34,17 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config
| `Network` | `mqtt_user` / `mqtt_pw` | string | — | MQTT credentials (see secrets below) | | `Network` | `mqtt_user` / `mqtt_pw` | string | — | MQTT credentials (see secrets below) |
| `Serial` | `device` | string | `/dev/ttyACM0` | Motor-controller serial device | | `Serial` | `device` | string | `/dev/ttyACM0` | Motor-controller serial device |
| `Serial` | `baud` | int | `115200` | Serial baud rate | | `Serial` | `baud` | int | `115200` | Serial baud rate |
| `Camera` | `id_Cam1`..`id_Cam4` | string | — | Camera IDs (GigE IP or USB `DEV_...`); non-empty ones used in order | | `Camera.<name>` | `id` | string | — | USB `DEV_` id or GigE IP. Required when enabled |
| `Camera` | `mode_dir` | string | `config/camera_modes/rgb` | Directory of camera-mode files; a mode named X is `<mode_dir>/X.ini`. A relative path resolves against the directory holding the `config.ini` in use | | `Camera.<name>` | `driver` | `vimba` | `vimba` | Backend. Only `vimba` is implemented |
| `Camera` | `mode` | string | `standard` | Mode to start in. Ships with `standard` (10 Hz day) and `night` (1 Hz). Switch live with `set camera mode <name>` | | `Camera.<name>` | `label` | string | the block name | Output subdirectory and `CamEvent` `cam` field |
| `Camera` | `jxl_distance` | double ≥ 0 | `0.8` | JPEG XL distance (`0` = lossless, `~0.8` = near-lossless) | | `Camera.<name>` | `profile_dir` | string | — | Directory of profile XMLs; a profile named X is `<profile_dir>/X.xml`. A relative path resolves against the directory holding the `config.ini` in use |
| `Camera` | `jxl_effort` | int 1..9 | `4` | JPEG XL effort (higher = slower/smaller) | | `Camera.<name>` | `profile` | string | `standard` | Profile to start in. Switch live with `set camera profile [<camera>] <name>` |
| `Camera` | `rotation_deg` | `0`/`90`/`180`/`270` | `0` | Clockwise rotation applied to saved images (`0` = as the sensor delivers them) | | `Camera.<name>` | `enabled` | bool | `true` | A disabled camera is not opened and does not affect the scan timing |
| `Camera.<name>` | `rotation_deg` | `0`/`90`/`180`/`270` | `0` | Clockwise rotation applied to that camera's saved images |
| `Camera.<name>.<profile>` | `settle_wait_ms` | int ≥ 0 | `1000` | Hold after standstill before keeping a frame |
| `Camera.<name>.<profile>` | `frame_timeout_ms` | int ≥ 0 | `2000` | Abandon the waypoint after this |
| `Camera` | `jxl_distance` / `jxl_effort` | double ≥ 0 / int 1..9 | `0.8` / `4` | Encoder defaults inherited by every camera block |
| `Camera.<name>` | `jxl_distance` / `jxl_effort` | double ≥ 0 / int 1..9 | the `[Camera]` value | Per-camera override of the encoder settings |
| `Paths` | `output_dir` | string | `$XDG_DATA_HOME/fire_gimbal_control/images` | Image output dir; supports `~`/`$ENV` | | `Paths` | `output_dir` | string | `$XDG_DATA_HOME/fire_gimbal_control/images` | Image output dir; supports `~`/`$ENV` |
| `Features` | `enable_mqtt` | bool | `true` | Use MQTT (vs null channel) | | `Features` | `enable_mqtt` | bool | `true` | Use MQTT (vs null channel) |
| `Features` | `enable_camera` | bool | `true` | (reserved) | | `Features` | `enable_camera` | bool | `true` | (reserved) |
@ -52,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 | | `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` | `device` | string | — | MTi serial device (see `[IMU]` note); required when `enable_imu` |
| `IMU` | `baud` | int | `115200` | MTi serial baud rate | | `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_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` | `i2c_addr` | int | `68` (`0x44`) | SHT41 I2C slave address; decimal or `0x`-prefixed hex |
| `Env` | `period_ms` | int | `2000` | Sample interval | | `Env` | `period_ms` | int | `2000` | Sample interval |
@ -109,7 +115,7 @@ back. Set
`[Scan] grid_file` to an editable CSV ([config/scan.csv](../config/scan.csv)) to define exact `[Scan] grid_file` to an editable CSV ([config/scan.csv](../config/scan.csv)) to define exact
coordinates, or leave it blank to generate `yaw_intervals × pitch_levels` points. coordinates, or leave it blank to generate `yaw_intervals × pitch_levels` points.
Camera index → output subfolder defaults to `RGB`, `ACR`, `NIR` (`CameraConfig::labels`). Each camera's output subfolder is its `[Camera.<name>] label` (defaulting to the block name); nothing is derived from the order of the blocks.
### `[IMU]` — Xsens MTi orientation sensor ### `[IMU]` — Xsens MTi orientation sensor
@ -119,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 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 → 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 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 `[Features] mock_imu = true` to use a synthetic IMU on dev machines (no hardware). Protocol/units are
documented in the modules reference (`MtiProtocol`). documented in the modules reference (`MtiProtocol`).
@ -139,64 +145,200 @@ 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 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. pausing the stream) and also requests a fresh firmware dump for the gimbal `g` view.
### `[Camera]` — camera modes #### The device configuration is verified, not assumed
Every on-camera setting lives in a **mode file**, one per mode, at `<mode_dir>/<name>.ini`. The mode's What the host asks the MTi for — `OutputMode` and `OutputSettings` — is a **wire-format
name is the filename stem, so adding a mode is dropping in a file — there is no registry to keep in contract, not a preference**, and is deliberately **not exposed in `config.ini`**.
sync. `config.ini` only picks which one to start in; nothing about the sensor is configured in two `parseMTData` decodes a fixed field order that follows from exactly those bits, so a
places. 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.
Two modes ship: 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.
| Mode | Rate | Intent | 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 `
| `standard` | 10 Hz | Daytime sweep. Short exposure ceiling (20 ms) and a modest gain cap; `IntensityAutoPrecedence = Minimizeexposuretime` so the camera buys brightness with gain rather than time | badge on the compact Sensors panel, and repeats in `imu config`. The persistent surfaces
| `night` | 1 Hz | Long exposures (up to 0.5 s), gain to 48 dB, `Minimizenoise` so it spends time before noise, 12-bit readout, status LED off | matter because the handshake runs *before* the TUI installs its log sink, so the log line
alone is wiped by the alt-screen.
**How a mode is applied.** A `[Camera]` block is a list of Vimba X features written verbatim, in file #### Earth reference — why pitch fell as the nose rose
order, at start-up and on every switch. Names and enum values must be spelled exactly as VimbaX spells
them (`Minimizenoise`, not `MinimizeNoise`); the easiest source of truth is a settings export from
VimbaX itself. There is no fixed vocabulary — any feature the camera exposes can be set by adding a
line, and the type is read off the camera.
Order and repetition are deliberate and preserved: 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.
- **Order.** GenICam selectors are stateful, so a selector must precede what it selects, and binning **No `[IMU] x/y/z` value can fix this.** Re-referencing the earth frame
must precede `Width`/`Height` because it changes their maxima. *left*-multiplies the reported orientation; a mount *right*-multiplies it, and no fixed
- **Repeats.** Writing both white-balance ratios requires `BalanceRatioSelector = Red`, right-multiplication reproduces a left-multiplication across all attitudes. With an
`BalanceRatio = …`, `BalanceRatioSelector = Blue`, `BalanceRatio = …`. upside-down IMU the mount alone does nothing but add 180° to roll — pitch passes straight
through, which is exactly the symptom.
**Error handling.** A feature this camera does not expose logs `camera: no such feature '<name>'` and Both corrections are applied, in `applyFrames`
is skipped — that warning is how you find a typo in a hand-edited file, so a clean start-up log is the ([ImuMount.h](../include/fgc/ImuMount.h)):
acceptance test for a mode. Structural mistakes are fatal instead: an unknown section, an unknown
`[Mode]` key, or a mode that sets no features. Every `*.ini` in `mode_dir` is parsed at start-up, so a
broken mode fails at launch rather than the first night someone switches to it.
**The `[Mode]` header** holds the three values the program itself uses: ```
R_ned←gimbal = nedFromReference(ref) · R_local←sensor · mountRotationZXY(mount)
\______ earth ______/ \______ body ______/
```
| Key | Default | Meaning | 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
| `description` | — | Shown in the log and the TUI `c` view | the earth frame leaves them alone and only the mount rotates them.
| `settle_wait_ms` | `1000` | After the axes report standstill, hold this long before keeping a frame |
| `frame_timeout_ms` | `2000` | If no fresh frame arrives within this long after the wait, log and skip the waypoint |
`settle_wait_ms` is the mechanism that makes a free-running camera usable for a stop-and-stare scan: it The device is deliberately **not** asked to change its own coordinate system
guarantees the kept frame was exposed with the gimbal at rest and after the auto-exposure has seen this (`mtsettings::kCoordSysNed` stays `0`). Correcting on the host is deterministic,
waypoint's light. It must comfortably exceed one frame period, which is why `night` at 1 Hz waits 2.5 s reversible and unit-testable without hardware; writing an unverified bit to the unit's
where `standard` at 10 Hz waits 1 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.
**Frame rate is bounded by bandwidth, not the sensor.** A full-resolution RGB8 frame is 61 MB, which The active reference is stated on the startup log line (`IMU frames: reference NWU -> NED;
caps out around 4 fps under the 250 MB/s `DeviceLinkThroughputLimit`. Both shipped modes therefore set mount ...`). It is not shown in the Sensors view: unlike the mount it is a fixed property of
`BinningHorizontal/Vertical = 2` (2256×2256 ≈ 5 MP, 15.3 MB), so 10 Hz needs only ~153 MB/s. Raising the device rather than something the operator sets, so it would be a permanent row nobody
the limit to 450 is not a fix — it produced incomplete frames on this host. 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:
the block names the camera, `label` names its output, and `driver` picks the
backend. A camera's *settings* are not here at all.
**The settings live in a profile XML exported from the VimbaX Viewer.** A profile
named `X` is `<profile_dir>/X.xml`, and the program hands that file straight to
the Vimba X SDK. There is no other path by which settings reach the camera, and a
missing XML is a startup error rather than a fallback.
That indirection exists because GenICam validity is **stateful**:
`AcquisitionFrameRate` is only writable once `AcquisitionFrameRateEnable` is set,
`Width`'s maximum depends on binning, and `IntensityAutoPrecedence` accepts
`Minimizenoise` but not `MinimizeNoise`. The Viewer greys out invalid
combinations as you configure; a hand-written list has no such guardrail, and a
line that silently fails leaves the camera on its previous value.
**To change a camera's settings:** open the Viewer, configure, save the settings
XML over `<profile>.xml`, restart.
**The `.txt` beside each XML is generated and configures nothing.** It is a
human-readable record of the same settings so a profile can be read and diffed
without opening the Viewer. On startup the program hashes the XML and compares it
with the hash recorded in the `.txt`; if they differ it regenerates the record
and logs a warning naming every setting that moved:
```
[WARN ] camera 'rgb' profile 'standard': settings XML changed, regenerating standard.txt
[WARN ] AcquisitionFrameRate 3.399707 -> 10
[WARN ] ExposureAutoMax 50000 -> 15000
```
**Timing is per camera and per profile**, in `[Camera.<name>.<profile>]`. These
are not camera settings — they are how long the program waits around them. With
several cameras the scan uses the **longest** `settle_wait_ms` across the enabled
ones, since the gimbal must stand still for all of them.
Every `*.xml` in `profile_dir` is loaded at start-up, so a profile nobody is
using today still fails at launch rather than the first time someone switches to
it.
### `[Env]` — SHT41 ambient temperature/humidity sensor ### `[Env]` — SHT41 ambient temperature/humidity sensor
Enable with `[Features] enable_env = true` and point `[Env] i2c_device` at the LattePanda's **own Enable with `[Features] enable_env = true` and point `[Env] i2c_device` at the LattePanda's **own
native I2C bus** — this is a separate physical link from the motor Leonardo's serial connection, not native I2C bus** — this is a separate physical link from the motor Leonardo's serial connection, not
the Arduino's D2 pin (the original DHT11 plan). Bus number varies by board; confirm with the Arduino's D2 pin (the original DHT11 plan). `i2c_addr` accepts either form — `0x44` hex or
`i2cdetect -y N` (the SHT41 answers at `0x44`/`68`). `i2c_addr` accepts either form — `0x44` hex or `68` decimal.
`68` decimal. The backend (`Sht41EnvSensor`) polls at `period_ms`, discarding any sample that fails
**Finding the bus.** The number varies by board, and on this LattePanda the sensor answers on
`/dev/i2c-2`, not the `/dev/i2c-1` default. Two traps make this harder than it looks:
- **`i2cdetect` false-negatives on the SHT4x.** Its probe (quick-write, or a plain read) is not a
command the sensor answers, so the address can show empty on the very bus the sensor is on. Probe
with a real measurement instead — `i2ctransfer -y N w1@0x44 0xfd`, wait ~10 ms, then
`i2ctransfer -y N r6@0x44`; six bytes that pass the CRC is the bus.
- **Not every `/dev/i2c-*` is a full I2C bus.** An SMBus-only adapter (`i2cdetect -F N` reports
`I2C = no`) cannot carry the SHT4x's transfers at all, so that bus can never work regardless of
wiring.
A failed read is **logged** — once at the start of a failure streak (naming the bus, the address and
an `i2ctransfer` probe to run), and again when the sensor recovers, so a wrong bus announces itself
instead of leaving a silently empty panel, without reprinting the same line every `period_ms` for
the life of the process. A sample that fails its CRC is logged separately each time. The backend (`Sht41EnvSensor`)
polls at `period_ms`, discarding any sample that fails
the SHT4x CRC-8 check (decode lives in the pure, unit-tested the SHT4x CRC-8 check (decode lives in the pure, unit-tested
[Sht41Protocol](../include/fgc/sensors/Sht41Protocol.h)); once nothing valid arrives within **2× the [Sht41Protocol](../include/fgc/sensors/Sht41Protocol.h)); once nothing valid arrives within **2× the
period**, `sample()` reports absence rather than a frozen value, which is what turns the panel from period**, `sample()` reports absence rather than a frozen value, which is what turns the panel from
@ -253,6 +395,7 @@ Handled in `Application::Impl::handleCommand`.
| `debug` | Toggle debug logging | | `debug` | Toggle debug logging |
| `trace <cat> [on\|off]` | Toggle a wire-trace category (`serial`/`mqtt`/`camera`/`control`) | | `trace <cat> [on\|off]` | Toggle a wire-trace category (`serial`/`mqtt`/`camera`/`control`) |
| `trace all` / `trace off` | Enable every category / silence all | | `trace all` / `trace off` | Enable every category / silence all |
| `set camera profile [<camera>] <name>` | Switch a camera to another profile from its `profile_dir` (the camera may be omitted when only one is configured) |
| `set camera jxlq <v>` | JPEG XL distance (0 = lossless) | | `set camera jxlq <v>` | JPEG XL distance (0 = lossless) |
| `set camera jxle <v>` | JPEG XL effort | | `set camera jxle <v>` | JPEG XL effort |
| `set camera display <0\|1>` | Toggle OpenCV preview window | | `set camera display <0\|1>` | Toggle OpenCV preview window |
@ -261,8 +404,20 @@ Handled in `Application::Impl::handleCommand`.
| `set motorctl <cmd>` | Forward a raw command to the motor controller (e.g. `set motorctl MOVE Y 20000`) | | `set motorctl <cmd>` | Forward a raw command to the motor controller (e.g. `set motorctl MOVE Y 20000`) |
| `test [<subsystem> [<leaf>]] [<profile>]` | Run the hardware self-test suite (see below) | | `test [<subsystem> [<leaf>]] [<profile>]` | Run the hardware self-test suite (see below) |
| `test list` / `test baseline` | List profiles / pin the last report as the comparison baseline | | `test list` / `test baseline` | List profiles / pin the last report as the comparison baseline |
| `imu status` | One-line orientation + sample age |
| `imu config` | The configuration read back from the device (product, firmware, output mode, sample rate, XKF profile) |
| `imu profile [<type\|label>]` | Show the available XKF profiles, or switch to one (by numeric type or by name, e.g. `VRU_general`) |
| `imu zero` | Heading reset — make the current yaw the zero reference |
| `imu norotation [<seconds>]` | Gyro-bias update; the sensor must be still for the whole window |
| `refresh` | Re-read the IMU configuration and request a fresh firmware dump |
| `help [topic]` | The command catalog, or one section/verb expanded |
| `exit` | Quit (Ctrl-D also works) | | `exit` | Quit (Ctrl-D also works) |
The full catalog with per-command detail lives in `helpCatalog`
([src/core/HelpText.cpp](../src/core/HelpText.cpp)) and is what `help` prints — the table above is a
summary, and the `gimbal …` positioning/diagnostic verbs are documented there and in the motor
vocabulary section below.
### `test` — hardware self-test suite ### `test` — hardware self-test suite
`test` validates the gimbal/IMU/host on real hardware, writes a text report to `test` validates the gimbal/IMU/host on real hardware, writes a text report to
@ -298,6 +453,14 @@ one node in [src/ui/TuiUi.cpp](../src/ui/TuiUi.cpp).
- **Gimbal** (`g`) — both axes side by side: live telemetry, the decoded firmware register **dump** - **Gimbal** (`g`) — both axes side by side: live telemetry, the decoded firmware register **dump**
(`d` requests a fresh one), the homing limits, and the **last calibration** result (per-axis (`d` requests a fresh one), the homing limits, and the **last calibration** result (per-axis
`counts_per_deg` / `zero_count` / R² / age). `counts_per_deg` / `zero_count` / R² / age).
- **Cameras** (`c`) — a block per configured camera: identity (`name`, `label`, `id`, `driver`,
enabled), the active **profile** and the others available, the `profile_dir` and the exact XML
applied, the per-profile `settle_wait_ms`, `rotation_deg` and the JPEG XL settings, plus live
per-device telemetry (dimensions, streaming, frames, drops) and the scan grid.
- **Connectivity** (`m`) — broker, tower, client id, connection state and control mode, the
delivery counters (sent / failed / connects / losses), and a **history of the last MQTT
messages** — time, topic leaf and payload — so a publish can be confirmed without a broker-side
subscriber. The compact Connectivity panel carries the `m` badge that opens it.
- **Sensors** (`i`) — both sensors, in headed sections so every reading is attributable. - **Sensors** (`i`) — both sensors, in headed sections so every reading is attributable.
**MTi**: every channel with units — orientation (°), acceleration (m/s²), rate-of-turn **MTi**: every channel with units — orientation (°), acceleration (m/s²), rate-of-turn
(rad/s), magnetic field (a.u.), `TEMP (internal)`, sample counter, plus an **IMU CONFIG** section (rad/s), magnetic field (a.u.), `TEMP (internal)`, sample counter, plus an **IMU CONFIG** section
@ -322,11 +485,19 @@ everything else) so it persists across restarts; press **`n`** to keep it for th
showing. (Until you answer `y`, calibration remains session-only — see showing. (Until you answer `y`, calibration remains session-only — see
[known-issues.md](known-issues.md).) [known-issues.md](known-issues.md).)
Keys (bottom bar): `s` start · `x` stop · `h` home · `g` gimbal view · `i` IMU view · `r` refresh **The key bar is contextual**: it advertises the keys that are useful in the current view, not every
(re-read IMU config + firmware dump) · arrow keys nudge the gimbal (yaw ±5 % / pitch ±10 %) · `:` open binding. `s` start and `x` stop lead everywhere — a scan can be started and stopped from any view —
a command line (any console/`gimbal …` and `:` command, `?` help and `q` quit close every set. The overview adds `h` home, `r` refresh,
command) · `?` help · `q` quit. Plain letters are used rather than Ctrl chords so terminal `g i c m` views, `p` pause and the arrow keys (nudge the gimbal, yaw ±5 % / pitch ±10 %); an
flow-control (`Ctrl-S`/`Ctrl-Q` XON/XOFF) can't swallow them. In TUI mode all log output is diverted expanded view instead offers its own (`d` dump in the gimbal view, `r` refresh in sensors, the
scroll keys, `Esc` close). Every binding still *works* everywhere; only the advertising changes.
`p` freezes the live refresh so text can be selected and copied.
**`q` asks rather than quitting.** It opens a prompt: **`q`** quit (stop capture, close the camera,
end the program), **`b`** background (detach and leave it running — the same as `Ctrl-b d`, shown
dimmed and unavailable when the program is not inside a `scripts/fgc` tmux session), **`Esc`**
cancel. Plain letters are used rather than Ctrl chords so terminal flow-control
(`Ctrl-S`/`Ctrl-Q` XON/XOFF) can't swallow them. In TUI mode all log output is diverted
from stdout into the on-screen log pane via a `Logger` sink, so the screen is never corrupted. from stdout into the on-screen log pane via a `Logger` sink, so the screen is never corrupted.
Build without it (`-DWITH_TUI=OFF`) for a smaller, dependency-free binary; `--tui` then warns and Build without it (`-DWITH_TUI=OFF`) for a smaller, dependency-free binary; `--tui` then warns and

View File

@ -90,8 +90,11 @@ zkms_server_ip = 10.11.12.13 ; the broker address
device = /dev/ttyACM0 ; the motor-controller MCU device = /dev/ttyACM0 ; the motor-controller MCU
baud = 115200 baud = 115200
[Camera] [Camera.rgb]
id_Cam1 = DEV_1AB22C0AADED ; or GigE IPs like 192.168.11.101 id = DEV_1AB22C0AADED ; or a GigE IP like 192.168.11.101
label = RGB
profile_dir = config/camera_profiles/rgb
profile = standard
``` ```
**Credentials** go in the environment, not the config file. Create a root-only env file for the service: **Credentials** go in the environment, not the config file. Create a root-only env file for the service:
@ -158,8 +161,10 @@ scripts/fgc stop # SIGTERM -> the same clean teardown as `exit`
scripts/fgc peek # snapshot of the pane without attaching scripts/fgc peek # snapshot of the pane without attaching
``` ```
**`Ctrl-b d` detaches. The dashboard's own `q` key exits the program.** That is the one thing to get **`Ctrl-b d` detaches. The dashboard's own `q` key opens an exit prompt** offering `q` (quit the
right while attached. program) or `b` (background — detach and leave it running, the same as `Ctrl-b d`), with `Esc` to
cancel. `b` is offered only inside a `scripts/fgc` session; outside one there is nothing to detach
into and it is shown dimmed.
`scripts/fgc stop` sends SIGTERM and waits up to 30s; the program handles it with the same shutdown path `scripts/fgc stop` sends SIGTERM and waits up to 30s; the program handles it with the same shutdown path
as the `exit` command (stop capture, close the camera, flush the image queue). Do **not** `kill -9` — as the `exit` command (stop capture, close the camera, flush the image queue). Do **not** `kill -9` —
@ -168,7 +173,7 @@ an unclean exit leaves the camera wedged for the next run. If it will not stop,
`scripts/fgc start` refuses if a run is already up, and prints the previous run's last output if it died `scripts/fgc start` refuses if a run is already up, and prints the previous run's last output if it died
rather than silently discarding it. rather than silently discarding it.
Quitting the dashboard with `q` while attached ends the tmux session too, so you land straight back in Choosing **quit** at that prompt while attached ends the tmux session too, so you land straight back in
your shell — `exit` then closes ssh. A run that *fails* is the exception: its pane is kept so you can read your shell — `exit` then closes ssh. A run that *fails* is the exception: its pane is kept so you can read
why (`remain-on-exit failed`), and `fgc status` shows it until `fgc stop` or the next `fgc start` clears it. why (`remain-on-exit failed`), and `fgc status` shows it until `fgc stop` or the next `fgc start` clears it.

View File

@ -29,7 +29,7 @@ doctest unit-test suite (`ctest`).
|---|-------|--------| |---|-------|--------|
| 13 | `[Motor]` degrees↔counts calibration | The `config.example.ini` values are **placeholders**. Calibrate `*_counts_per_deg` / `*_zero_count` against real `xenc` readings after homing on the rig. | | 13 | `[Motor]` degrees↔counts calibration | The `config.example.ini` values are **placeholders**. Calibrate `*_counts_per_deg` / `*_zero_count` against real `xenc` readings after homing on the rig. |
| 14 | Capture sweep on hardware | **Mechanically verified on the rig (2026-08-07).** A full `--init --start` run homed both axes and completed `MOVE → settle → trigger` for 15/15 waypoints with 0 dropped frames, 0 stalls and 0 acquisition failures. `kSettleTolCounts` (600) and the per-interval timing in [CaptureScheduler.cpp](../src/core/CaptureScheduler.cpp) have **not** been tuned against observed `ST` behaviour yet, and the settle tolerance has not been checked against a sharp image (see #16). | | 14 | Capture sweep on hardware | **Mechanically verified on the rig (2026-08-07).** A full `--init --start` run homed both axes and completed `MOVE → settle → trigger` for 15/15 waypoints with 0 dropped frames, 0 stalls and 0 acquisition failures. `kSettleTolCounts` (600) and the per-interval timing in [CaptureScheduler.cpp](../src/core/CaptureScheduler.cpp) have **not** been tuned against observed `ST` behaviour yet, and the settle tolerance has not been checked against a sharp image (see #16). |
| 15 | **Camera modes unverified on a lit scene** | The `standard`/`night` mode files were derived from a VimbaX export plus the bandwidth arithmetic, not measured against real light. Confirm on the rig that every feature applies without a `camera: no such feature` / `could not set` warning, that `standard` sustains 10 fps with `frames_dropped` at 0, and that the auto-exposure lands on a sensible `ExposureTime` outdoors. The only previous rig test ran with the lens covered. | | 15 | **Camera profiles unverified on a lit scene** | The `standard`/`night` profiles must be exported from the VimbaX Viewer (see `config/camera_profiles/rgb/README.md`) and have not been measured against real light. Confirm on the rig that `LoadSettings` succeeds, that `standard` sustains 10 fps with `frames_dropped` at 0, and that the auto-exposure lands on a sensible `ExposureTime` outdoors. The only previous rig test ran with the lens covered. |
| 16 | **`settle_wait_ms` untuned** | `standard` waits 1 s and `night` 2.5 s after standstill before keeping a frame. Both are reasoned (≫ one frame period; long enough for the auto-exposure to see the new scene) but not measured. Check on a lit scene that the kept frame shows no slew blur and that its exposure has actually converged for that heading, and shorten the wait if it has. | | 16 | **`settle_wait_ms` untuned** | `standard` waits 1 s and `night` 2.5 s after standstill before keeping a frame. Both are reasoned (≫ one frame period; long enough for the auto-exposure to see the new scene) but not measured. Check on a lit scene that the kept frame shows no slew blur and that its exposure has actually converged for that heading, and shorten the wait if it has. |
| 19 | **USB preconditions do not survive a reboot** | `usbcore.usbfs_memory_mb` reverts to 16 (needs 1000) and USB3 LPM re-enables on the camera's port. **Both were wrong on the LattePanda when tested on 2026-08-07**, and frame delivery stalls without them regardless of application code. Needs the udev rule + kernel cmdline persistence described under *Camera acquisition* below. | | 19 | **USB preconditions do not survive a reboot** | `usbcore.usbfs_memory_mb` reverts to 16 (needs 1000) and USB3 LPM re-enables on the camera's port. **Both were wrong on the LattePanda when tested on 2026-08-07**, and frame delivery stalls without them regardless of application code. Needs the udev rule + kernel cmdline persistence described under *Camera acquisition* below. |
@ -107,17 +107,43 @@ scripts for this are worth keeping; note that wrapping them in `timeout` SIGTERM
## Possible follow-ups (not done) ## Possible follow-ups (not done)
- **A mode file cannot be reloaded without a restart.** `set camera mode <name>` re-applies a mode from the - **NWU vs ENU is decided by a constant, not by the device.** `kMtiReference`
copy loaded at start-up, so editing a `.ini` while the program runs has no effect. Tuning on the rig ([MtiProtocol.h](../include/fgc/MtiProtocol.h)) says the MTi reports against NWU, and the
therefore costs a restart per edit. Re-reading the file on switch would fix it, at the price of a failure host re-references it to NED. The two candidates give *identical* roll and pitch and
mode (a mid-session syntax error) that start-up loading currently rules out. differ only in yaw, by exactly 90°, so nothing in the software can tell them apart —
- **Nothing checks that a mode actually took.** Features are written and warned about individually, but the only a compass check on the rig can. If corrected yaw is 90° off, change that constant to
camera is never read back to confirm the resulting state, and a mode whose every write failed still logs `ImuReference::Enu`. Reading the device's actual coordinate-system bits would settle it,
as applied. A post-apply readback of a handful of key features (`AcquisitionFrameRate`, `Width`, but their position in `OutputSettings` is not established for this firmware.
`PixelFormat`) would turn a silent misconfiguration into an error. - **`[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 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). - 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 - Support a second camera backend. `driver` is parsed and validated but only `vimba` is accepted, so a
every on-camera setting lives in a mode file — see [configuration.md](configuration.md).) 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 - Reintroduce optional image upload to the ground station, config-driven (the old hardcoded NFS/SMB upload was
removed). removed).
- **`gimbal calib` persistence**: the fitted `counts_per_deg`/`zero_count` are always applied to the live - **`gimbal calib` persistence**: the fitted `counts_per_deg`/`zero_count` are always applied to the live

View File

@ -18,9 +18,15 @@ 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/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/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/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/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/CameraMode.h](../include/fgc/CameraMode.h), [src/core/CameraMode.cpp](../src/core/CameraMode.cpp) | `loadCameraMode` / `loadCameraModes`: one INI per camera mode → `CameraMode` (settle timing + an **ordered, duplicate-preserving** list of Vimba X features). Order and repeats are load-bearing (GenICam selectors are stateful), so it uses its own appending `inih` handler rather than the flattening one in `Config.cpp` | | [include/fgc/CameraProfile.h](../include/fgc/CameraProfile.h), [src/core/CameraProfile.cpp](../src/core/CameraProfile.cpp) | `loadCameraProfile` / `loadCameraProfiles`: one VimbaX settings XML per profile. Hashes the XML, regenerates the `.txt` record when it changes, and reports which settings moved. A missing XML throws — the record is never a fallback |
| [include/fgc/CameraSettingsXml.h](../include/fgc/CameraSettingsXml.h), [src/core/CameraSettingsXml.cpp](../src/core/CameraSettingsXml.cpp) | Parses the Viewer export's `<RemoteDevice>` features (ordered, selector-aware), renders the human-readable `.txt` record, reads one back, and diffs two settings lists. Used for display and change detection only — never to configure a camera |
| [include/fgc/ChannelTopics.h](../include/fgc/ChannelTopics.h), [src/core/ChannelTopics.cpp](../src/core/ChannelTopics.cpp) | The MQTT wire format in one place: topic builders for the `GGS/FWT/<tower>/…` tree and the `CamEvent`/`EnvEvent`/status payload builders. Pure, so the documented format is unit-tested rather than assembled inline at each publish site |
| [include/fgc/ChannelHistory.h](../include/fgc/ChannelHistory.h), [src/core/ChannelHistory.cpp](../src/core/ChannelHistory.cpp) | Ring buffer of recently published messages (time, topic, payload, outcome) plus the delivery counters — sent, failed, connects, losses, last status. Feeds the expanded Connectivity view so a publish can be confirmed without a broker-side subscriber |
| [include/fgc/Hash.h](../include/fgc/Hash.h), [src/core/Hash.cpp](../src/core/Hash.cpp) | FNV-1a 64 over a string or a file, with an algorithm-prefixed label. Change detection for the profile records, deliberately not a crypto hash |
| [include/fgc/CaptureScheduler.h](../include/fgc/CaptureScheduler.h), [src/core/CaptureScheduler.cpp](../src/core/CaptureScheduler.cpp) | Capture state machine over the interfaces; injectable clock; `setGeometry` adopts a recalibration | | [include/fgc/CaptureScheduler.h](../include/fgc/CaptureScheduler.h), [src/core/CaptureScheduler.cpp](../src/core/CaptureScheduler.cpp) | Capture state machine over the interfaces; injectable clock; `setGeometry` adopts a recalibration |
| [include/fgc/Application.h](../include/fgc/Application.h), [src/core/Application.cpp](../src/core/Application.cpp) | Factory (real vs mock, headless vs TUI), wiring, control loop, `gimbal …` commands, background-result polling, `buildSnapshot()` | | [include/fgc/Application.h](../include/fgc/Application.h), [src/core/Application.cpp](../src/core/Application.cpp) | Factory (real vs mock, headless vs TUI), wiring, control loop, `gimbal …` commands, background-result polling, `buildSnapshot()` |
| [include/fgc/ui/UiSnapshot.h](../include/fgc/ui/UiSnapshot.h), [src/ui/UiSnapshot.cpp](../src/ui/UiSnapshot.cpp) | Plain-data view model (incl. `ImuView`, `EnvView`, `ActivityView`, `CalibResultView`, `DumpView`) + pure formatting helpers (state label/colour, degrees, time-ago, pending-sensors) | | [include/fgc/ui/UiSnapshot.h](../include/fgc/ui/UiSnapshot.h), [src/ui/UiSnapshot.cpp](../src/ui/UiSnapshot.cpp) | Plain-data view model (incl. `ImuView`, `EnvView`, `ActivityView`, `CalibResultView`, `DumpView`) + pure formatting helpers (state label/colour, degrees, time-ago, pending-sensors) |
@ -34,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/IMotorController.h](../include/fgc/IMotorController.h) | `IMotorController` | `MotorTelemetry` |
| [include/fgc/IControlChannel.h](../include/fgc/IControlChannel.h) | `IControlChannel` | `ControlCommand`, `CamEvent`, `EnvEvent` | | [include/fgc/IControlChannel.h](../include/fgc/IControlChannel.h) | `IControlChannel` | `ControlCommand`, `CamEvent`, `EnvEvent` |
| [include/fgc/ICameraSource.h](../include/fgc/ICameraSource.h) | `ICameraSource` | `Frame` | | [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/IEnvSensor.h](../include/fgc/IEnvSensor.h) | `IEnvSensor` (`connected()`, `sample()`, `name()`) | `EnvSample` |
| [include/fgc/ui/IUserInterface.h](../include/fgc/ui/IUserInterface.h) | `IUserInterface` | `UiSnapshot` | | [include/fgc/ui/IUserInterface.h](../include/fgc/ui/IUserInterface.h) | `IUserInterface` | `UiSnapshot` |
@ -48,9 +54,9 @@ Per-file reference for the refactored tree, plus the shared data structures.
| [src/sensors/Sht41EnvSensor.cpp](../src/sensors/Sht41EnvSensor.cpp) | `IEnvSensor` over I2C (pImpl): soft-reset, poll thread at `[Env] period_ms`, decode via `Sht41Protocol`, staleness = no valid sample within 2× the period | always | | [src/sensors/Sht41EnvSensor.cpp](../src/sensors/Sht41EnvSensor.cpp) | `IEnvSensor` over I2C (pImpl): soft-reset, poll thread at `[Env] period_ms`, decode via `Sht41Protocol`, staleness = no valid sample within 2× the period | always |
| [src/mqtt/MqttControlChannel.cpp](../src/mqtt/MqttControlChannel.cpp) | `IControlChannel` over Eclipse Paho | `WITH_MQTT` | | [src/mqtt/MqttControlChannel.cpp](../src/mqtt/MqttControlChannel.cpp) | `IControlChannel` over Eclipse Paho | `WITH_MQTT` |
| [src/ui/TuiUi.cpp](../src/ui/TuiUi.cpp) | `IUserInterface` over FTXUI: panels, key bar, log pane | `WITH_TUI` | | [src/ui/TuiUi.cpp](../src/ui/TuiUi.cpp) | `IUserInterface` over FTXUI: panels, key bar, log pane | `WITH_TUI` |
| [src/camera/VimbaCameraSource.cpp](../src/camera/VimbaCameraSource.cpp) | `ICameraSource` over Vimba X (pImpl): free-running stream + keep-latest, restart-on-stall, and a type-dispatched feature applier that writes a `CameraMode` verbatim | `WITH_VIMBA` | | [src/camera/VimbaCameraSource.cpp](../src/camera/VimbaCameraSource.cpp) | `ICameraSource` over Vimba X (pImpl): free-running stream + keep-latest, restart-on-stall, and per-camera profiles applied with the SDK's `LoadSettings` on the Viewer's settings XML | `WITH_VIMBA` |
| [src/camera/JpegXlEncoder.cpp](../src/camera/JpegXlEncoder.cpp) | libjxl encode-to-file | always | | [src/camera/JpegXlEncoder.cpp](../src/camera/JpegXlEncoder.cpp) | libjxl encode-to-file | always |
| [src/camera/ImagePipeline.cpp](../src/camera/ImagePipeline.cpp) | frame → rotate → encode → write → CamEvent (worker thread) | always | | [src/camera/ImagePipeline.cpp](../src/camera/ImagePipeline.cpp) | frame → rotate → encode → write → CamEvent (worker thread). The pose is sampled in `submit()` and carried with the frame, so it belongs to the pixels rather than to whenever the encode finished | always |
## Mock / null implementations ## Mock / null implementations
@ -75,8 +81,14 @@ Per-file reference for the refactored tree, plus the shared data structures.
### `MotorTelemetry` / `AxisTelemetry` ([IMotorController.h](../include/fgc/IMotorController.h)) ### `MotorTelemetry` / `AxisTelemetry` ([IMotorController.h](../include/fgc/IMotorController.h))
Per-axis `yaw` and `pitch` segments (`pitch_present` flag), each an `AxisTelemetry`: `state` Per-axis `yaw` and `pitch` segments (`pitch_present` flag), each an `AxisTelemetry`: `state`
(`AxisState` B/R/H/A/E), `xactual`, `xenc` (encoder counts), `drv_status`, `sg`/`cs`/`pwm`, and flags (`AxisState` B/R/H/A/E), `xactual`, `xenc` (encoder counts), `drv_status`, `sg`/`cs`/`pwm`, and flags
`standstill`/`stall`/`overtemp`/`endstop_l`/`endstop_r`, with `moving()`/`ready()` helpers. Parsed from the `standstill`/`stall`/`deviation`/`overtemp`/`endstop_l`/`endstop_r`, with `moving()`/`ready()` helpers.
firmware `ST Y:...[ P:...]` line; degrees are derived via `Geometry`. Parsed from the firmware `ST Y:...[ P:...]` line; degrees are derived via `Geometry`.
`deviation` (flag `d`) is the TMC5160's hardware encoder-deviation latch — set when `|xactual − xenc|`
exceeds `ENC_DEVIATION`, i.e. the shaft is not where it was commanded to be. It is the usable stall signal
on this rig and is what the TUI's STALL badge shows. `stall` (flag `s`) is StallGuard2, which is inert
because production runs StealthChop; it is still parsed in case that changes. The latch is cleared by
homing or `gimbal reset`, so a lit badge means "position untrustworthy since the last home".
### `ControlCommand` ([IControlChannel.h](../include/fgc/IControlChannel.h)) ### `ControlCommand` ([IControlChannel.h](../include/fgc/IControlChannel.h))
`control_code` (0 = scan-grid sweep, 1 = directed to `target_HDG`) + `target_heading`, each with an `control_code` (0 = scan-grid sweep, 1 = directed to `target_HDG`) + `target_heading`, each with an
@ -102,7 +114,7 @@ Owned pixel buffer + `width`, `height`, `channels` (1 or 3), `timestamp_ms`, `ca
### `ImuSample` ([MtiProtocol.h](../include/fgc/MtiProtocol.h)) ### `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), 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` `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 `gimbal calib` phase-unwraps the swept yaw (`unwrapNear`) so a sweep crossing 0/360 still fits a clean
line. line.
@ -120,7 +132,7 @@ Surfaced read-only in the TUI's `i` view (IMU CONFIG section); the legacy "scena
| Artifact | Path | Format | | Artifact | Path | Format |
|----------|------|--------| |----------|------|--------|
| Captured images | `<output_dir>/<RGB\|ACR\|NIR>/<unix_ms>.jxl` | JPEG XL, rotated per `Camera.rotation_deg` | | Captured images | `<output_dir>/<label>/<unix_ms>.jxl` | JPEG XL, rotated per that camera's `rotation_deg`; `<label>` is `[Camera.<name>] label` |
| Demo placeholder | `bin/x64/Release/test_smoke.jxl` | copied verbatim in demo mode | | Demo placeholder | `bin/x64/Release/test_smoke.jxl` | copied verbatim in demo mode |
| Diagnostics log | `<data_dir>/fire_gimbal_control/logs/diag_<ts>.log` | raw `DG` stream + parsed summary | | Diagnostics log | `<data_dir>/fire_gimbal_control/logs/diag_<ts>.log` | raw `DG` stream + parsed summary |
| Calibration log | `<data_dir>/fire_gimbal_control/logs/calib_<ts>.log` | per-axis samples + the least-squares fit | | Calibration log | `<data_dir>/fire_gimbal_control/logs/calib_<ts>.log` | per-axis samples + the least-squares fit |

View File

@ -70,13 +70,37 @@ Built by `MqttControlChannel::publishCamEvent` from a `CamEvent`:
|-------|------|---------| |-------|------|---------|
| `fwt` | string | Tower name (`config.ini` `tower_name`) | | `fwt` | string | Tower name (`config.ini` `tower_name`) |
| `cam` | string | Camera label: `RGB`, `ACR`, or `NIR` (by camera index) | | `cam` | string | Camera label: `RGB`, `ACR`, or `NIR` (by camera index) |
| `hdg` | int | Gimbal yaw heading **× 10** (one decimal place encoded as integer) at capture time | | `hdg` | int | Gimbal yaw heading **× 10** (one decimal place encoded as integer) at capture time, from the **yaw encoder** |
| `pit` | int | Gimbal pitch elevation **× 10** at capture time (derived from the pitch encoder) | | `pit` | int | Gimbal pitch elevation **× 10** at capture time, from the **IMU** — elevation relative to gravity, i.e. true pointing angle |
| `time` | int | Capture timestamp, Unix epoch **milliseconds** (matches the `.jxl` filename) | | `time` | int | Capture timestamp, Unix epoch **milliseconds** (matches the `.jxl` filename) |
> The `time` value is the same Unix-ms timestamp used as the image filename, so a consumer can locate the file > The `time` value is the same Unix-ms timestamp used as the image filename, so a consumer can locate the file
> for a given event: `<RGB|ACR|NIR>/<time>.jxl`. > for a given event: `<RGB|ACR|NIR>/<time>.jxl`.
**`pit` is measured against gravity, `hdg` against the encoder.** The two axes come from
different sources deliberately. The pitch encoder only measures the axis relative to the
gimbal's own base, so a leaning mast is invisible to it; the IMU measures against gravity
and therefore reports the elevation the imagery is actually pointing at. Yaw stays on the
encoder because IMU yaw drifts and depends on the heading reset and XKF profile, whereas
encoder yaw is calibrated against the IMU by `gimbal calib` and then holds.
Two consequences for consumers:
- **`pit` no longer agrees with the commanded waypoint by construction.** Commanding `+10.0`
and receiving `pit: 104` is correct — the residual is real tilt, not error. Before this
change both came off the same encoder scale and agreed trivially.
- **Archives spanning the change contain two different measurements** in this field: an axis
angle before it, a true elevation after. If the mast is level the two agree; if it leans,
they differ by the lean.
If the IMU is disabled (`[Features] enable_imu = false`), `pit` falls back to the encoder as
before. If the IMU is enabled but has no fresh sample, it also falls back — and logs a
warning saying so, once per outage, since that mixes the two measurements in one field.
Both `hdg` and `pit` are sampled **when the frame arrives**, not when its JPEG XL encode
finishes, so a slow encode or a backed-up queue cannot attribute a later pose to earlier
pixels.
### Env payload ### Env payload
Built by `MqttControlChannel::publishEnv` from an `EnvEvent`, sourced from whichever `IEnvSensor` backend is Built by `MqttControlChannel::publishEnv` from an `EnvEvent`, sourced from whichever `IEnvSensor` backend is

View File

@ -35,7 +35,7 @@ Keep entries dated; move shipped items into the reference docs.
described in [architecture.md](architecture.md#image-capture) and the modes in described in [architecture.md](architecture.md#image-capture) and the modes in
[configuration.md](configuration.md). [configuration.md](configuration.md).
- **A dusk/dawn mode, and automatic switching.** `standard` and `night` are - **A dusk/dawn mode, and automatic switching.** `standard` and `night` are
chosen by hand (`set camera mode <name>`). A third mode for the transition, and chosen by hand (`set camera profile <name>`). A third profile for the transition, and
a rule to pick between them — by clock, sun angle, or measured scene brightness a rule to pick between them — by clock, sun angle, or measured scene brightness
— is the obvious follow-on. _(TODO: decide the trigger.)_ — is the obvious follow-on. _(TODO: decide the trigger.)_
- **Thermal camera.** Add the thermal sensor with radiometric handling (NUC, - **Thermal camera.** Add the thermal sensor with radiometric handling (NUC,

View File

@ -60,7 +60,7 @@ monotonicity and encode-throughput timing.
The RGB camera itself is integrated and verified on the rig (see The RGB camera itself is integrated and verified on the rig (see
[architecture.md](architecture.md#image-capture)), so this leaf is an implementation task rather than a [architecture.md](architecture.md#image-capture)), so this leaf is an implementation task rather than a
blocked one. Note that a self-test asserting exposure health needs the lens uncovered and the shipped blocked one. Note that a self-test asserting exposure health needs the lens uncovered and the shipped
camera modes tuned first — [known-issues.md](known-issues.md) #15–#16. camera profiles tuned first — [known-issues.md](known-issues.md) #15–#16.
- **`camera / rgb`** — _Healthy:_ enumerates and connects; delivers frames at ~the - **`camera / rgb`** — _Healthy:_ enumerates and connects; delivers frames at ~the
configured rate; exposure/gain give a usable histogram (not clipped); few configured rate; exposure/gain give a usable histogram (not clipped); few

View File

@ -1,42 +0,0 @@
#pragma once
#include <map>
#include <string>
#include <utility>
#include <vector>
namespace fgc {
// One named camera mode, loaded from <mode_dir>/<name>.ini.
//
// A mode is deliberately NOT a struct of typed camera settings: it is the raw
// list of Vimba X features to write, in file order, plus the few values the
// program itself needs. That means a new camera feature can be set by adding a
// line to an INI file - no C++ change, no vocabulary to keep in sync with the
// SDK. VimbaCameraSource asks the camera what type each feature is and converts
// the string accordingly.
struct CameraMode {
std::string name; // filename stem
std::string description;
int settle_wait_ms = 1000; // pause after standstill before keeping a frame
int frame_timeout_ms = 2000; // give up on the waypoint after this
// Vimba X features in FILE ORDER, duplicates preserved. Both matter:
// GenICam selectors are stateful, so "BalanceRatioSelector=Red,
// BalanceRatio=2.87, BalanceRatioSelector=Blue, BalanceRatio=2.0" is the
// only way to write both ratios. A std::map would sort and de-duplicate
// that into nonsense.
std::vector<std::pair<std::string, std::string>> features;
};
// Load one mode file. `name` comes from the filename stem.
// Throws std::runtime_error with a clear message on a missing file, an unknown
// section, or an unknown/invalid [Mode] key.
CameraMode loadCameraMode(const std::string& path);
// Load every *.ini in `dir`, keyed by mode name. Throws if the directory is
// missing or contains no modes - a malformed mode nobody is using today still
// fails at startup rather than the first time someone switches to it.
std::map<std::string, CameraMode> loadCameraModes(const std::string& dir);
} // namespace fgc

View File

@ -0,0 +1,50 @@
#pragma once
#include "fgc/CameraSettingsXml.h"
#include <map>
#include <string>
#include <vector>
namespace fgc {
// One named camera profile: a VimbaX Viewer settings export, plus the generated
// record beside it.
//
// A profile named X is <profile_dir>/X.xml, and X.txt is a human-readable record
// of what that XML contains. Only the XML is ever applied - the program hands
// the path to the Vimba X SDK, so the camera receives exactly the combination
// the Viewer validated. `settings` is parsed for display and diffing, never to
// configure anything.
struct CameraProfile {
std::string name; // filename stem
std::string xml_path; // what actually gets applied
std::string txt_path; // the generated record
CameraSettings settings; // parsed from the XML, for reading and diffing
};
// What a load did, so the caller can report it. `first_run` distinguishes "there was no record to compare against" from "the
// settings changed": on a first load every setting would otherwise be reported
// as new, which for a real export is thousands of lines and no information.
struct ProfileLoadResult {
bool regenerated = false;
bool first_run = false; // no previous record existed
std::vector<std::string> changes; // empty when first_run
};
// Load a profile: hash the XML, compare against the hash recorded in the .txt,
// and regenerate the record when they differ.
//
// Throws std::runtime_error if the XML is missing or unreadable. There is
// deliberately no fallback to the .txt: it is a record, not a configuration
// source, and applying one would defeat the point of the GUI validating the XML.
CameraProfile loadCameraProfile(const std::string& profile_dir, const std::string& name,
ProfileLoadResult& result);
// Every *.xml in `dir`, keyed by profile name. Throws if the directory is
// missing or holds no profiles, so a typo in a profile nobody is using today
// still fails at startup rather than the first time someone switches to it.
std::map<std::string, CameraProfile> loadCameraProfiles(
const std::string& dir, std::map<std::string, ProfileLoadResult>& results);
} // namespace fgc

View File

@ -0,0 +1,55 @@
#pragma once
#include <cstdint>
#include <string>
#include <utility>
#include <vector>
namespace fgc {
// One camera setting as the VimbaX Viewer exported it.
struct SettingEntry {
std::string name;
std::string value;
std::string selector; // "" unless the Viewer nested it in a <SelectorGroup>
};
// The settings a Viewer XML holds, in file order.
//
// These are NEVER applied: the program hands the XML itself to the Vimba X SDK,
// which is what guarantees the camera gets exactly the combination the GUI
// validated. This parse exists only to render a human-readable record and to
// diff one export against the previous one.
struct CameraSettings {
std::string model; // <CameraInfo Model="...">
std::string device_id;
std::vector<SettingEntry> entries; // <RemoteDevice> features, in order
};
// Parse a VimbaX Viewer settings export. Only the <RemoteDevice> block is read:
// the <LocalDevice>/stream sections describe the host's transport layer, not the
// sensor. Throws std::runtime_error naming the problem if the file is missing or
// does not look like a Viewer export.
CameraSettings parseCameraSettingsXml(const std::string& path);
// Render the human-readable record written beside the XML. `xml_name` and
// `hash_label` go in the header so the file says what it came from.
std::string renderSettingsRecord(const CameraSettings& s, const std::string& xml_name,
const std::string& hash_label);
// Read back a record written by renderSettingsRecord. Returns the recorded hash
// label (empty if the file is absent or has none) and the settings it listed, so
// a regeneration can report what actually changed.
struct SettingsRecord {
std::string hash_label;
std::vector<SettingEntry> entries;
};
SettingsRecord readSettingsRecord(const std::string& path);
// Human-readable difference between two settings lists, one line per change:
// "AcquisitionFrameRate 10 -> 8", "+ Gamma 1.2 (new)", "- Sharpness (removed)".
// Empty when they match.
std::vector<std::string> diffSettings(const std::vector<SettingEntry>& before,
const std::vector<SettingEntry>& after);
} // namespace fgc

View File

@ -42,7 +42,7 @@ public:
void setImageRate(double rate); // images per second void setImageRate(double rate); // images per second
double imageRate() const { return image_rate_; } double imageRate() const { return image_rate_; }
// Per-camera-mode timing (see CameraMode): how long to wait after standstill // Per-camera-profile timing (see CameraProfile): how long to wait after standstill
// before keeping a frame, and how long to keep retrying before abandoning the // before keeping a frame, and how long to keep retrying before abandoning the
// waypoint. Called on the control thread, same as tick(). // waypoint. Called on the control thread, same as tick().
void setSettleWait(int settle_ms, int timeout_ms); void setSettleWait(int settle_ms, int timeout_ms);

View File

@ -1,5 +1,6 @@
#pragma once #pragma once
#include "fgc/ImuMount.h"
#include "fgc/Geometry.h" #include "fgc/Geometry.h"
#include <map> #include <map>
@ -42,25 +43,48 @@ struct SerialConfig {
unsigned int baud = 115200; unsigned int baud = 115200;
}; };
// Per-profile program behaviour, from [Camera.<name>.<profile>]. These are not
// camera settings - the camera's own settings live entirely in the profile XML -
// but timing the program must observe around them.
struct CameraProfileTiming {
int settle_wait_ms = 1000; // hold after standstill before keeping a frame
int frame_timeout_ms = 2000; // give up on the waypoint after this
};
// One camera, from a [Camera.<name>] block. Nothing is implied by position: the
// block names the camera, `label` names its output, `driver` picks the backend.
struct CameraSpec {
std::string name; // the block suffix, e.g. "rgb"
bool enabled = true;
std::string driver = "vimba"; // only "vimba" is implemented
std::string id; // USB DEV_ id or GigE IP
std::string label; // output subdir + CamEvent "cam"; defaults to name
std::string profile_dir; // relative => resolved next to config.ini
std::string profile = "standard"; // which profile to start in
int rotation_deg = 0; // clockwise; 0/90/180/270
// Encoding, defaulted from [Camera] and overridable per camera.
double jxl_distance = 0.8;
int jxl_effort = 4;
std::map<std::string, CameraProfileTiming> timing; // by profile name
// Timing for `profile`, or the defaults when the block does not name it.
CameraProfileTiming timingFor(const std::string& profile_name) const;
};
struct CameraConfig { struct CameraConfig {
std::vector<std::string> ids; // GigE IP or USB DEV_ id, in order std::vector<CameraSpec> cameras; // in config order; empty is a config error
std::vector<std::string> labels = {"RGB", "ACR", "NIR"}; // index -> output subfolder // [Camera] defaults, inherited by each block unless it overrides them.
double jxl_distance = 0.8;
int jxl_effort = 4;
// On-camera imaging settings live in mode files, one per mode: a mode named X // Enabled cameras only.
// is <mode_dir>/X.ini (see CameraMode.h). Nothing about the sensor is std::vector<const CameraSpec*> active() const;
// configured here - a mode file is the whole story, so the two can never // The gimbal must stand still long enough for EVERY active camera, so the
// disagree about, say, binning. // scheduler takes the longest wait rather than the first camera's.
// Relative => resolved against the DIRECTORY HOLDING config.ini. The deployed int maxSettleWaitMs() const;
// config.ini lives at the repo root (deploy.sh copies the example there), so int maxFrameTimeoutMs() const;
// the default is written from there, not from config/.
std::string mode_dir = "config/camera_modes/rgb";
std::string mode = "standard"; // which mode to start in
// JPEG XL encoding (consumed by ImagePipeline). Near-lossless default.
double jxl_distance = 0.8; // 0 = lossless; higher = lossier
int jxl_effort = 4; // libjxl effort 1..9
// Clockwise rotation applied to saved images (0/90/180/270). 0 = as-sensed.
int rotation_deg = 0;
}; };
struct PathsConfig { struct PathsConfig {
@ -86,6 +110,12 @@ struct FeaturesConfig {
struct ImuConfig { struct ImuConfig {
std::string device = ""; // e.g. /dev/ttyS4; empty => required when enabled std::string device = ""; // e.g. /dev/ttyS4; empty => required when enabled
unsigned int baud = 115200; 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 // [Env]: ambient temperature/humidity sensor (SHT41) on the LattePanda's own

25
include/fgc/Hash.h Normal file
View File

@ -0,0 +1,25 @@
#pragma once
#include <cstdint>
#include <string>
namespace fgc {
// FNV-1a 64-bit, for detecting that a file changed.
//
// Deliberately not a cryptographic hash: the only question asked of it is "is
// this the same settings XML the record was generated from?", which is change
// detection, not tamper resistance. A crypto dependency would be a poor trade
// for that, and this is ten lines that need no third party.
//
// Digests are rendered with an algorithm prefix ("fnv1a64:<16 hex digits>") so a
// stored digest can never be silently misread if the algorithm is ever changed.
uint64_t fnv1a64(const std::string& data);
std::string hashLabel(uint64_t digest); // -> "fnv1a64:9d3c1a77b21e4f80"
// Hash a file's bytes. Throws std::runtime_error if it cannot be read - callers
// use this to decide whether to regenerate, so silently returning 0 would make a
// missing file look like an unchanged one.
uint64_t fnv1a64File(const std::string& path);
} // namespace fgc

View File

@ -1,6 +1,6 @@
#pragma once #pragma once
#include "fgc/CameraMode.h" #include "fgc/CameraProfile.h"
#include <cstdint> #include <cstdint>
#include <functional> #include <functional>
@ -67,9 +67,13 @@ public:
// yet, and the caller simply retries. // yet, and the caller simply retries.
virtual bool trigger() = 0; virtual bool trigger() = 0;
// Apply a camera mode's feature list (see CameraMode.h). Default: no-op, for // Apply a profile to one camera, identified by its index in the order the
// sources with no features to set. // source opened them (the same index Frame::cam_id carries). The backend
virtual bool setMode(const CameraMode& /*mode*/) { return false; } // loads the profile's settings XML through its SDK - see CameraProfile.h.
// Default: no-op, for sources with nothing to configure.
virtual bool setProfile(int /*camera_index*/, const CameraProfile& /*profile*/) {
return false;
}
virtual void setFrameCallback(FrameCallback cb) = 0; virtual void setFrameCallback(FrameCallback cb) = 0;

View File

@ -3,6 +3,8 @@
#include "fgc/MtiProtocol.h" // ImuSample #include "fgc/MtiProtocol.h" // ImuSample
#include <optional> #include <optional>
#include <string>
#include <vector>
namespace fgc { namespace fgc {
@ -27,6 +29,15 @@ public:
// backend cannot report it. // backend cannot report it.
virtual std::optional<ImuDeviceConfig> config() const { return std::nullopt; } 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 // Re-read the device configuration (e.g. after the XKF profile was changed
// externally). May briefly pause the measurement stream. Blocking; after it // externally). May briefly pause the measurement stream. Blocking; after it
// returns config() reflects the device's current settings. // returns config() reflects the device's current settings.

View File

@ -19,7 +19,13 @@ struct AxisTelemetry {
int cs = 0; // CS_ACTUAL int cs = 0; // CS_ACTUAL
int pwm = 0; // PWM_SCALE_SUM int pwm = 0; // PWM_SCALE_SUM
bool standstill = false; // flag 'S' (DRV_STST) bool standstill = false; // flag 'S' (DRV_STST)
bool stall = false; // flag 's' bool stall = false; // flag 's' (StallGuard) - inert in StealthChop, see below
// Flag 'd': the TMC's hardware encoder-deviation latch, set when
// |xactual - xenc| exceeded ENC_DEVIATION. This is the usable stall signal on
// this rig - StallGuard needs SpreadCycle, which production does not run.
// Latched until the axis is homed or reset, so it means "position
// untrustworthy", not "stalling right now".
bool deviation = false;
bool overtemp = false; // flag 'o'/'O' bool overtemp = false; // flag 'o'/'O'
bool endstop_l = false; // flag 'L' bool endstop_l = false; // flag 'L'
bool endstop_r = false; // flag 'R' bool endstop_r = false; // flag 'R'

View File

@ -28,19 +28,28 @@ struct Orientation {
// VimbaHandler::SaveImage so any camera source (real or mock) can feed it. // VimbaHandler::SaveImage so any camera source (real or mock) can feed it.
class ImagePipeline { class ImagePipeline {
public: public:
// Per-camera output settings. Label, rotation and encoding are all properties
// of the individual camera now, so they travel together and are indexed by
// Frame::cam_id rather than assumed uniform across the rig.
struct CameraOutput {
std::string label; // output subdir + CamEvent "cam"
int rotation_deg = 0; // clockwise; 0/90/180/270
double jxl_distance = 0.8; // 0 = lossless
int jxl_effort = 4;
};
struct Params { struct Params {
std::string output_dir; std::string output_dir;
std::vector<std::string> labels = {"RGB", "ACR", "NIR"}; // by camera index std::vector<CameraOutput> cameras; // by camera index, in open order
std::string tower; std::string tower;
double jxl_distance = 2.0; // 0 = lossless
int jxl_effort = 3;
int rotation_deg = 0; // clockwise; 0/90/180/270
bool demo = false; // copy demo_image instead of encoding bool demo = false; // copy demo_image instead of encoding
std::string demo_image = "test_smoke.jxl"; std::string demo_image = "test_smoke.jxl";
bool display = false; // show an OpenCV preview window bool display = false; // show an OpenCV preview window
}; };
// orientation: returns the current gimbal yaw+pitch (degrees) for CamEvent. // orientation: returns the current gimbal yaw+pitch (degrees) for CamEvent.
// Called from submit(), i.e. on the camera's delivery thread at the moment the
// frame arrives - see submit() for why it must not be sampled any later.
ImagePipeline(IControlChannel& channel, std::function<Orientation()> orientation, Params params); ImagePipeline(IControlChannel& channel, std::function<Orientation()> orientation, Params params);
~ImagePipeline(); ~ImagePipeline();
@ -48,11 +57,21 @@ public:
void stop(); void stop();
// Enqueue a frame for processing (thread-safe; copies the frame). // Enqueue a frame for processing (thread-safe; copies the frame).
//
// The gimbal orientation is sampled HERE, not in the worker, and travels with
// the frame. Sampling it after encoding would attribute to the image whatever
// direction the gimbal had reached by the time the encode finished - which is
// only harmless while captures happen at standstill and nothing overlaps. The
// moment encoding runs while the gimbal is already slewing to the next
// waypoint, or the queue backs up, the published heading and pitch belong to a
// different pose than the pixels.
void submit(const Frame& frame); void submit(const Frame& frame);
// Runtime-adjustable encoder settings (console "set camera ..." commands). // Runtime-adjustable encoder settings (console "set camera ..." commands).
void setDistance(double d) { params_.jxl_distance = d; } // These apply to every camera: they exist for quick experiments, and asking
void setEffort(int e) { params_.jxl_effort = e; } // which camera on every keystroke would make that worse, not better.
void setDistance(double d) { for (auto& c : params_.cameras) c.jxl_distance = d; }
void setEffort(int e) { for (auto& c : params_.cameras) c.jxl_effort = e; }
void setDisplay(bool on) { params_.display = on; } void setDisplay(bool on) { params_.display = on; }
// Last CamEvent published (camera label, heading/pitch, timestamp), for the // Last CamEvent published (camera label, heading/pitch, timestamp), for the
@ -63,15 +82,24 @@ public:
long long imagesSaved() const { return saved_.load(); } long long imagesSaved() const { return saved_.load(); }
private: private:
// A frame and the pose it was taken at, captured together at submit() time so
// encoding latency and queue depth cannot separate them.
struct QueuedFrame {
Frame frame;
Orientation orientation;
};
void run(); void run();
void process(const Frame& frame); void process(const QueuedFrame& queued);
std::string labelFor(int cam_id) const; // Output settings for a camera index, or sane defaults if the index is out of
// range (a source delivering more cameras than were configured).
const CameraOutput& outputFor(int cam_id) const;
IControlChannel& channel_; IControlChannel& channel_;
std::function<Orientation()> orientation_; std::function<Orientation()> orientation_;
Params params_; Params params_;
std::queue<Frame> queue_; std::queue<QueuedFrame> queue_;
std::mutex mutex_; std::mutex mutex_;
std::condition_variable cv_; std::condition_variable cv_;
std::atomic<bool> running_{false}; std::atomic<bool> running_{false};

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; bool connected() const override;
std::optional<ImuSample> sample() override; std::optional<ImuSample> sample() override;
std::optional<ImuDeviceConfig> config() const override; std::optional<ImuDeviceConfig> config() const override;
std::vector<std::string> configProblems() const override;
void refreshConfig() override; void refreshConfig() override;
bool setFilterProfile(int type) override; bool setFilterProfile(int type) override;
void noRotation(int seconds) 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 kResetHeading = 0x0001; // current heading becomes yaw 0
inline constexpr uint16_t kResetStore = 0x0000; // persist current reset (Config state) inline constexpr uint16_t kResetStore = 0x0000; // persist current reset (Config state)
// OutputMode = Temperature(0x01) | Calibrated(0x02) | Orientation(0x04). // Which EARTH frame a device measures its orientation against.
inline constexpr uint16_t kOutputMode = 0x0007; //
// OutputSettings: orientation mode Euler (bits3:2=01 => 0x04) + timestamp // A different axis entirely from the [IMU] x/y/z mount, and the distinction is
// SampleCounter (bits1:0=01 => 0x01), float, all calibrated channels enabled. // load-bearing: the mount says how the sensor is bolted to the gimbal (a BODY
inline constexpr uint32_t kOutputSettings = 0x00000005; // frame), while this says what the reported angles are measured relative to.
// Expected MTData payload with the above config: Temp(4) + Acc(12) + Gyr(12) + //
// Mag(12) + Euler(12) + SampleCounter(2). // NED X North Y East Z Down - what the gimbal frame assumes
inline constexpr uint8_t kMTDataLen = 54; // 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. // One fully decoded IMU reading.
struct ImuSample { struct ImuSample {
@ -75,7 +196,7 @@ struct ImuSample {
float mag[3] = {0, 0, 0}; // a.u. (normalized to earth field) float mag[3] = {0, 0, 0}; // a.u. (normalized to earth field)
float roll_deg = 0.f; // -180..180 float roll_deg = 0.f; // -180..180
float pitch_deg = 0.f; // -90..90 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; 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. // against available_profiles by type) and sample_rate_hz from the period.
void finalizeImuConfig(ImuDeviceConfig& c); 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 // 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. // 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); uint8_t mtiChecksum(const uint8_t* from_bid, std::size_t len);

View File

@ -12,7 +12,8 @@ namespace fgc {
// ST Y:<state>,<xactual>,<xenc>,<drv_hex>,<sg>,<cs>,<pwm>,<flags> [P:...] // ST Y:<state>,<xactual>,<xenc>,<drv_hex>,<sg>,<cs>,<pwm>,<flags> [P:...]
// //
// where <state> is B/R/H/A/E and <flags> is an optional run of status letters // where <state> is B/R/H/A/E and <flags> is an optional run of status letters
// (S=standstill, s=stall, o/O=overtemp, L/R=endstops, e=eeprom). Returns nullopt // (S=standstill, s=stall, d=encoder deviation, o/O=overtemp, L/R=endstops,
// e=eeprom). Returns nullopt
// for any line that is not a well-formed `ST` line (acks and other async output // for any line that is not a well-formed `ST` line (acks and other async output
// like OK/ERR/DG/DUMP are handled by the caller). A missing/garbled axis segment // like OK/ERR/DG/DUMP are handled by the caller). A missing/garbled axis segment
// yields nullopt rather than partial data. // yields nullopt rather than partial data.

View File

@ -1,35 +1,37 @@
#pragma once #pragma once
#include "fgc/CameraMode.h" #include "fgc/CameraProfile.h"
#include "fgc/Config.h" #include "fgc/Config.h"
#include "fgc/ICameraSource.h" #include "fgc/ICameraSource.h"
#include <memory> #include <memory>
#include <vector>
namespace fgc { namespace fgc {
// Real camera source backed by the Allied Vision Vimba X SDK (VmbCPP). Opens the // Real camera source backed by the Allied Vision Vimba X SDK (VmbCPP). Opens the
// cameras named in CameraConfig; every on-camera setting comes from a CameraMode, // cameras named by their CameraSpec and configures each one by handing its
// which is just a list of Vimba features written verbatim. // profile's settings XML to the SDK - the XML the VimbaX Viewer wrote, so the
// camera receives exactly the combination the GUI validated.
// //
// The camera free-runs continuously at the mode's AcquisitionFrameRate with its // The camera free-runs continuously at the rate the profile sets, with its own
// own auto-exposure tracking the light. trigger() hands over the latest complete // auto-exposure tracking the light. trigger() hands over the latest complete
// frame, restarting acquisition if the stream has stalled. Encode/save lives in // frame, restarting acquisition if the stream has stalled. Encode/save lives in
// ImagePipeline. // ImagePipeline.
class VimbaCameraSource : public ICameraSource { class VimbaCameraSource : public ICameraSource {
public: public:
explicit VimbaCameraSource(CameraConfig config); // specs and profiles are index-aligned and in open order: cameras[i] is
// configured by profiles[i], so a camera never inherits its neighbour's
// settings.
VimbaCameraSource(std::vector<CameraSpec> specs, std::vector<CameraProfile> profiles);
~VimbaCameraSource() override; ~VimbaCameraSource() override;
// Mode to apply at start(). Also the mode setMode() replaces.
void setInitialMode(CameraMode mode);
void open() override; void open() override;
void close() override; void close() override;
void start() override; void start() override;
void stop() override; void stop() override;
bool trigger() override; bool trigger() override;
bool setMode(const CameraMode& mode) override; bool setProfile(int camera_index, const CameraProfile& profile) override;
void setFrameCallback(FrameCallback cb) override; void setFrameCallback(FrameCallback cb) override;
int cameraCount() const override; int cameraCount() const override;
std::vector<CameraDeviceInfo> deviceInfo() override; std::vector<CameraDeviceInfo> deviceInfo() override;

View File

@ -10,9 +10,9 @@ namespace fgc {
// Synthetic camera for development without hardware. On trigger() it generates // Synthetic camera for development without hardware. On trigger() it generates
// a small RGB gradient frame (varying with time so successive captures differ) // a small RGB gradient frame (varying with time so successive captures differ)
// and delivers it to the frame callback. setMode() only records the mode name - // and delivers it to the frame callback. setProfile() only records the profile
// there is no sensor to configure - which is enough to exercise mode switching // name - there is no sensor to configure - which is enough to exercise profile
// end to end with --mock-camera. // switching end to end with --mock-camera.
class MockCameraSource : public ICameraSource { class MockCameraSource : public ICameraSource {
public: public:
explicit MockCameraSource(int count = 1, uint32_t width = 640, uint32_t height = 480) explicit MockCameraSource(int count = 1, uint32_t width = 640, uint32_t height = 480)
@ -37,9 +37,9 @@ public:
void setFrameCallback(FrameCallback cb) override { callback_ = std::move(cb); } void setFrameCallback(FrameCallback cb) override { callback_ = std::move(cb); }
int cameraCount() const override { return count_; } int cameraCount() const override { return count_; }
bool setMode(const CameraMode& mode) override { bool setProfile(int camera_index, const CameraProfile& profile) override {
mode_ = mode.name; profile_ = profile.name;
LOG_INFO << "[mock] camera mode -> " << mode_; LOG_INFO << "[mock] camera " << camera_index << " profile -> " << profile_;
return true; return true;
} }
@ -103,7 +103,7 @@ private:
uint32_t height_; uint32_t height_;
bool started_ = false; bool started_ = false;
long long frames_ = 0; long long frames_ = 0;
std::string mode_; std::string profile_;
FrameCallback callback_; FrameCallback callback_;
}; };

View File

@ -27,7 +27,9 @@ public:
s.valid = true; s.valid = true;
s.roll_deg = static_cast<float>(15.0 * std::sin(t * 0.5)); 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.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. // 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; 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)); s.acc[0] = static_cast<float>(9.81 * -std::sin(p));
@ -57,6 +59,18 @@ public:
return true; 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), // Synthetic config mirroring the real handshake (Euler + calibrated, 100 Hz),
// so the expanded view's IMU CONFIG section renders without hardware. The // 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 // available list includes a no-mag profile so the calibration's profile switch

View File

@ -29,6 +29,14 @@ public:
if (verb == "HOME") { if (verb == "HOME") {
homed_ = true; homed_ = true;
deviation_ = false; // homing clears the latch, as on the real driver
} else if (verb == "STALL") {
// Test hook: fake the hardware encoder-deviation latch so the STALL
// badge is reachable without jamming a real axis. Latches like the
// real one - only HOME/RESET clears it.
deviation_ = true;
} else if (verb == "RESET") {
deviation_ = false;
} else if (verb == "MOVE") { } else if (verb == "MOVE") {
std::string a; std::string a;
in >> a; in >> a;
@ -139,6 +147,7 @@ private:
} }
a.xactual = a.xenc; a.xactual = a.xenc;
a.state = homed_ ? AxisState::Ready : AxisState::Reset; a.state = homed_ ? AxisState::Ready : AxisState::Reset;
a.deviation = deviation_;
} }
static void tryParse(const std::string& s, long& out) { static void tryParse(const std::string& s, long& out) {
@ -153,6 +162,7 @@ private:
long yaw_target_ = 0; long yaw_target_ = 0;
long pitch_target_ = 0; long pitch_target_ = 0;
bool homed_ = false; bool homed_ = false;
bool deviation_ = false; // faked encoder-deviation latch (see STALL above)
std::string dump_; std::string dump_;
std::string diag_; std::string diag_;
unsigned diag_seq_ = 0; unsigned diag_seq_ = 0;

View File

@ -1,6 +1,7 @@
#pragma once #pragma once
#include "fgc/IControlChannel.h" #include "fgc/IControlChannel.h"
#include "fgc/ImuMount.h"
#include "fgc/IMotorController.h" // AxisState #include "fgc/IMotorController.h" // AxisState
#include "fgc/Logger.h" // LogLevel #include "fgc/Logger.h" // LogLevel
@ -33,7 +34,8 @@ struct AxisView {
unsigned drv_status = 0; // DRV_STATUS register unsigned drv_status = 0; // DRV_STATUS register
bool moving = false; bool moving = false;
bool standstill = false; bool standstill = false;
bool stall = false; bool stall = false; // StallGuard ('s') - inert on this rig
bool deviation = false; // encoder-deviation latch ('d') - the real stall signal
bool overtemp = false; bool overtemp = false;
bool endstop_l = false; bool endstop_l = false;
bool endstop_r = false; bool endstop_r = false;
@ -87,6 +89,11 @@ struct SensorGroup {
struct SensorsView { struct SensorsView {
SensorGroup imu; // Xsens MTi: Roll/Pitch/Yaw SensorGroup imu; // Xsens MTi: Roll/Pitch/Yaw
SensorGroup env; // ambient temperature + humidity (SHT41) 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. // One auto-sweep scan-grid waypoint, for the expanded camera view.
@ -97,15 +104,27 @@ struct ScanWaypointView {
}; };
// Static imaging + encoding config (from CameraConfig), for the expanded view. // Static imaging + encoding config (from CameraConfig), for the expanded view.
// One configured camera, as the expanded view shows it. Everything here is
// explicit per camera - identity is never implied by position.
struct CameraView {
std::string name; // the [Camera.<name>] block
std::string label; // output subdir + CamEvent "cam"
std::string id; // USB DEV_ id or GigE IP
std::string driver;
bool enabled = true;
std::string profile; // active profile
std::vector<std::string> profiles_available;
std::string profile_dir;
std::string profile_xml; // the file actually applied to the camera
int settle_wait_ms = 0;
int rotation_deg = 0;
double jxl_distance = 0.0;
int jxl_effort = 0;
};
struct CameraConfigView { struct CameraConfigView {
bool mock = false; bool mock = false;
std::vector<std::string> ids; std::vector<CameraView> cameras;
// Active camera mode (see CameraMode) and the ones available to switch to.
std::string mode, mode_desc;
int settle_wait_ms = 0;
std::vector<std::string> modes_available;
double jxl_distance = 0.0; int jxl_effort = 0;
int rotation_deg = 0; // clockwise; 0 = none
std::string output_dir; std::string output_dir;
}; };
@ -197,6 +216,22 @@ struct ImuConfigView {
// Available XKF profiles with the active one flagged. Empty if the device // Available XKF profiles with the active one flagged. Empty if the device
// did not report them. // did not report them.
std::vector<ImuProfileView> xkf_profiles; 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, // Full Xsens MTi reading for the expanded Sensors view (units: acc m/s^2,
@ -311,10 +346,23 @@ UiColor axisStateColor(AxisState s);
// "12.3°" (one decimal, sign preserved). // "12.3°" (one decimal, sign preserved).
std::string formatDegrees(double deg); 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. // Human "Ns ago" / "Nm ago" for an absolute ms timestamp relative to now_ms.
// Returns "—" when then_ms is 0 (never). // Returns "—" when then_ms is 0 (never).
std::string formatTimeAgo(long long now_ms, long long then_ms); std::string formatTimeAgo(long long now_ms, long long then_ms);
// Whether the dashboard can be sent to the background, i.e. whether it is
// running inside a tmux pane that a client can be detached from. Pass getenv
//("TMUX"): tmux sets it to the server socket path, so a set, non-empty value is
// both the test and what lets `tmux detach-client` find the right client.
// Takes the value as an argument rather than reading the environment itself so
// the rule is testable.
bool backgroundAvailable(const char* tmux_env);
// Last path segment of an MQTT topic ("GGS/FWT/Tower/CamEvent" -> "CamEvent"). // Last path segment of an MQTT topic ("GGS/FWT/Tower/CamEvent" -> "CamEvent").
// Every row in the connectivity view shares the same prefix, which only eats // Every row in the connectivity view shares the same prefix, which only eats
// width. Returns the input unchanged when there is no '/'. // width. Returns the input unchanged when there is no '/'.

View File

@ -14,8 +14,8 @@
# scripts/fgc peek print what the pane shows right now # scripts/fgc peek print what the pane shows right now
# scripts/fgc restart [args...] stop, then start # scripts/fgc restart [args...] stop, then start
# #
# Detaching is Ctrl-b d. NOTE: the dashboard's own `q` key *exits the program*, # Detaching is Ctrl-b d. The dashboard's own `q` asks first, offering both:
# it does not detach — that is the one thing to get right when attached. # quit the program, or background it (which detaches, same as Ctrl-b d).
# #
# Only one instance may run. The binary enforces that itself with a lockfile # Only one instance may run. The binary enforces that itself with a lockfile
# (see src/core/SingleInstance.cpp), so a bare ./build/fire_gimbal_control is # (see src/core/SingleInstance.cpp), so a bare ./build/fire_gimbal_control is
@ -159,7 +159,7 @@ cmd_start() {
tmux set-option -t "$pane" status-left-length 30 >/dev/null tmux set-option -t "$pane" status-left-length 30 >/dev/null
tmux set-option -t "$pane" status-right-length 80 >/dev/null tmux set-option -t "$pane" status-right-length 80 >/dev/null
tmux set-option -t "$pane" status-right \ tmux set-option -t "$pane" status-right \
' Ctrl-b d = detach, keeps running | q = quit the program ' >/dev/null ' Ctrl-b d = detach, keeps running | q = quit or background ' >/dev/null
tmux respawn-pane -k -t "$win" -c "$repo" "$launch" >/dev/null tmux respawn-pane -k -t "$win" -c "$repo" "$launch" >/dev/null
@ -182,7 +182,7 @@ cmd_start() {
fi fi
echo "fgc: started detached as PID $(pane_pid) (session '$session')" echo "fgc: started detached as PID $(pane_pid) (session '$session')"
echo " attach: scripts/fgc attach (Ctrl-b d to detach; 'q' quits the program)" echo " attach: scripts/fgc attach (Ctrl-b d to detach; 'q' asks quit/background)"
} }
# Hidden: the command tmux runs inside the pane. Not for direct use. # Hidden: the command tmux runs inside the pane. Not for direct use.
@ -228,7 +228,7 @@ cmd_attach() {
if [[ -n "${TMUX:-}" ]]; then if [[ -n "${TMUX:-}" ]]; then
die "already inside tmux; use 'tmux switch-client -t $session'" die "already inside tmux; use 'tmux switch-client -t $session'"
fi fi
echo "fgc: attaching — Ctrl-b d detaches and leaves it running ('q' would quit it)" >&2 echo "fgc: attaching — Ctrl-b d detaches and leaves it running ('q' asks first)" >&2
exec tmux attach-session -t "$target" exec tmux attach-session -t "$target"
} }

View File

@ -44,44 +44,54 @@ void ImagePipeline::stop() {
} }
void ImagePipeline::submit(const Frame& frame) { void ImagePipeline::submit(const Frame& frame) {
// Sample the pose now, while the frame is fresh, and carry it through the
// queue. Both sources behind this callback are mutex-guarded snapshot reads,
// so it costs the camera's delivery thread a lock and a copy.
QueuedFrame q{frame, orientation_ ? orientation_() : Orientation{}};
{ {
std::lock_guard<std::mutex> lock(mutex_); std::lock_guard<std::mutex> lock(mutex_);
queue_.push(frame); queue_.push(std::move(q));
} }
cv_.notify_one(); cv_.notify_one();
} }
void ImagePipeline::run() { void ImagePipeline::run() {
while (running_) { while (running_) {
Frame frame; QueuedFrame queued;
{ {
std::unique_lock<std::mutex> lock(mutex_); std::unique_lock<std::mutex> lock(mutex_);
cv_.wait(lock, [this] { return !queue_.empty() || !running_; }); cv_.wait(lock, [this] { return !queue_.empty() || !running_; });
if (!running_ && queue_.empty()) break; if (!running_ && queue_.empty()) break;
frame = std::move(queue_.front()); queued = std::move(queue_.front());
queue_.pop(); queue_.pop();
} }
process(frame); process(queued);
} }
} }
std::string ImagePipeline::labelFor(int cam_id) const { const ImagePipeline::CameraOutput& ImagePipeline::outputFor(int cam_id) const {
if (cam_id >= 0 && cam_id < static_cast<int>(params_.labels.size())) if (cam_id >= 0 && cam_id < static_cast<int>(params_.cameras.size()))
return params_.labels[cam_id]; return params_.cameras[cam_id];
return "cam" + std::to_string(cam_id); // A source delivering an index we were not told about still has to write
// somewhere identifiable rather than colliding with camera 0.
static const CameraOutput fallback{"cam?", 0, 0.8, 4};
return fallback;
} }
void ImagePipeline::process(const Frame& frame) { void ImagePipeline::process(const QueuedFrame& queued) {
const Frame& frame = queued.frame;
if (frame.channels != 1 && frame.channels != 3) { if (frame.channels != 1 && frame.channels != 3) {
LOG_WARN << "Dropping frame with unsupported channel count " << frame.channels; LOG_WARN << "Dropping frame with unsupported channel count " << frame.channels;
return; return;
} }
const CameraOutput& out = outputFor(frame.cam_id);
const int type = frame.channels == 1 ? CV_8UC1 : CV_8UC3; const int type = frame.channels == 1 ? CV_8UC1 : CV_8UC3;
cv::Mat src(frame.height, frame.width, type, const_cast<uint8_t*>(frame.data.data())); cv::Mat src(frame.height, frame.width, type, const_cast<uint8_t*>(frame.data.data()));
cv::Mat rotated; cv::Mat rotated;
int code = 0; int code = 0;
bool rotate = rotateCode(params_.rotation_deg, code); bool rotate = rotateCode(out.rotation_deg, code);
if (rotate) cv::rotate(src, rotated, code); if (rotate) cv::rotate(src, rotated, code);
// src only wraps frame.data, so an unrotated frame costs no copy at all. // src only wraps frame.data, so an unrotated frame costs no copy at all.
const cv::Mat& img = rotate ? rotated : src; const cv::Mat& img = rotate ? rotated : src;
@ -93,7 +103,8 @@ void ImagePipeline::process(const Frame& frame) {
cv::waitKey(10); cv::waitKey(10);
} }
const std::string label = labelFor(frame.cam_id); const std::string label = out.label.empty() ? ("cam" + std::to_string(frame.cam_id))
: out.label;
const fs::path dir = fs::path(params_.output_dir) / label; const fs::path dir = fs::path(params_.output_dir) / label;
std::error_code ec; std::error_code ec;
fs::create_directories(dir, ec); fs::create_directories(dir, ec);
@ -108,15 +119,17 @@ void ImagePipeline::process(const Frame& frame) {
if (ec) LOG_ERROR << "Demo copy failed: " << ec.message(); if (ec) LOG_ERROR << "Demo copy failed: " << ec.message();
} else { } else {
bool ok = JpegXlEncoder::encodeToFile(file.string(), img.data, img.cols, img.rows, bool ok = JpegXlEncoder::encodeToFile(file.string(), img.data, img.cols, img.rows,
frame.channels, params_.jxl_distance, frame.channels, out.jxl_distance,
params_.jxl_effort); out.jxl_effort);
if (!ok) { if (!ok) {
LOG_ERROR << "Failed to encode " << file.string(); LOG_ERROR << "Failed to encode " << file.string();
return; return;
} }
} }
Orientation o = orientation_ ? orientation_() : Orientation{}; // Sampled at submit(), not here: by this point the encode above has run and
// the gimbal may already be slewing to the next waypoint.
const Orientation& o = queued.orientation;
CamEvent ev; CamEvent ev;
ev.tower = params_.tower; ev.tower = params_.tower;
ev.camera = label; ev.camera = label;

View File

@ -134,80 +134,50 @@ std::string getString(const CameraPtr& cam, const char* name) {
return {}; return {};
} }
bool parseBool(const std::string& v) { // Apply a profile by handing its settings XML to the SDK.
return v == "1" || v == "true" || v == "True" || v == "yes" || v == "on"; //
} // This replaces a hand-rolled feature applier, and the reason is validity rather
// than brevity: GenICam writability is stateful (AcquisitionFrameRate is only
// writable once AcquisitionFrameRateEnable is set, Width's maximum depends on
// binning, and so on), so a text list applied line by line can be individually
// plausible and collectively wrong. The XML comes out of the VimbaX Viewer,
// which greys out invalid combinations as the operator configures, and
// LoadSettings replays exactly that. Nothing else may write settings.
bool applyProfile(const CameraPtr& cam, const CameraProfile& profile) {
VmbFeaturePersistSettings settings{};
settings.persistType = VmbFeaturePersistNoLUT;
// RemoteDevice only: the transport-layer and stream sections of the export
// describe the host that produced it, not the sensor.
settings.modulePersistFlags = VmbModulePersistFlagsRemoteDevice;
settings.maxIterations = 10; // features can need several passes to settle
settings.loggingLevel = VmbLogLevelWarn;
// Write one feature from a mode file, converting the string to whatever type the const VmbErrorType rc = SP_ACCESS(cam)->LoadSettings(profile.xml_path.c_str(), &settings);
// camera says the feature is. Mode files are hand-edited and cameras differ in if (rc != VmbErrorSuccess) {
// which features they expose (and which are locked at a given moment), so a LOG_ERROR << "camera: applying profile '" << profile.name << "' from "
// failure is a warning, never fatal - the warning IS the feedback that a mode << profile.xml_path << " failed (error " << rc << ')';
// file has a typo in it. return false;
void applyFeature(const CameraPtr& cam, const std::string& name, const std::string& value) {
FeaturePtr f;
if (SP_ACCESS(cam)->GetFeatureByName(name.c_str(), f) != VmbErrorSuccess) {
LOG_WARN << "camera: no such feature '" << name << "' (skipped)";
return;
} }
LOG_INFO << "camera profile '" << profile.name << "' applied from " << profile.xml_path
VmbFeatureDataType type = VmbFeatureDataUnknown; << " (" << profile.settings.entries.size() << " settings)";
if (f->GetDataType(type) != VmbErrorSuccess) { return true;
LOG_WARN << "camera: cannot read type of feature '" << name << "' (skipped)";
return;
}
VmbErrorType rc = VmbErrorSuccess;
try {
switch (type) {
case VmbFeatureDataInt:
rc = f->SetValue(static_cast<VmbInt64_t>(std::stoll(value)));
break;
case VmbFeatureDataFloat:
rc = f->SetValue(std::stod(value));
break;
case VmbFeatureDataBool:
rc = f->SetValue(parseBool(value));
break;
case VmbFeatureDataEnum:
case VmbFeatureDataString:
rc = f->SetValue(value.c_str());
break;
default:
LOG_WARN << "camera: feature '" << name << "' is not settable from a mode file";
return;
}
} catch (const std::exception&) {
LOG_WARN << "camera: feature '" << name << "' rejected value '" << value
<< "' (wrong number format)";
return;
}
if (rc != VmbErrorSuccess)
LOG_WARN << "camera: could not set " << name << '=' << value << " (error " << rc << ')';
else
LOG_TRACE_CAT(LogCat::Camera) << "SET " << name << '=' << value;
}
// Apply a whole mode, IN FILE ORDER - GenICam selectors are stateful, so order is
// part of the meaning (see CameraMode.h).
void applyMode(const CameraPtr& cam, const CameraMode& mode) {
for (const auto& [name, value] : mode.features) applyFeature(cam, name, value);
LOG_INFO << "camera mode '" << mode.name << "' applied (" << mode.features.size()
<< " features)";
} }
} // namespace } // namespace
struct VimbaCameraSource::Impl { struct VimbaCameraSource::Impl {
explicit Impl(CameraConfig cfg) : config(std::move(cfg)), sys(VmbSystem::GetInstance()) {} Impl(std::vector<CameraSpec> s, std::vector<CameraProfile> p)
: specs(std::move(s)), profiles(std::move(p)), sys(VmbSystem::GetInstance()) {}
struct Identity { std::string id, model, serial; }; struct Identity { std::string id, model, serial; };
struct Telemetry { struct Telemetry {
double exposure_us = 0, gain_db = 0, temperature_c = 0, actual_fps = 0; double exposure_us = 0, gain_db = 0, temperature_c = 0, actual_fps = 0;
}; };
CameraConfig config; // One spec and one profile per camera, index-aligned and in open order, so a
CameraMode mode; // applied at start() and by setMode() // camera's settings are never those of the camera next to it.
std::vector<CameraSpec> specs;
std::vector<CameraProfile> profiles;
VmbSystem& sys; VmbSystem& sys;
std::vector<CameraPtr> cameras; std::vector<CameraPtr> cameras;
std::vector<IFrameObserverPtr> observers; std::vector<IFrameObserverPtr> observers;
@ -247,8 +217,9 @@ struct VimbaCameraSource::Impl {
}; };
VimbaCameraSource::VimbaCameraSource(CameraConfig config) VimbaCameraSource::VimbaCameraSource(std::vector<CameraSpec> specs,
: impl_(std::make_unique<Impl>(std::move(config))) {} std::vector<CameraProfile> profiles)
: impl_(std::make_unique<Impl>(std::move(specs), std::move(profiles))) {}
VimbaCameraSource::~VimbaCameraSource() { VimbaCameraSource::~VimbaCameraSource() {
try { try {
@ -262,19 +233,28 @@ VimbaCameraSource::~VimbaCameraSource() {
void VimbaCameraSource::open() { void VimbaCameraSource::open() {
if (impl_->sys.Startup() != VmbErrorSuccess) if (impl_->sys.Startup() != VmbErrorSuccess)
throw std::runtime_error("Could not start Vimba X API"); throw std::runtime_error("Could not start Vimba X API");
for (const auto& id : impl_->config.ids) { for (const auto& spec : impl_->specs) {
const std::string& id = spec.id;
CameraPtr cam; CameraPtr cam;
if (impl_->sys.GetCameraByID(id.c_str(), cam) != VmbErrorSuccess) if (impl_->sys.GetCameraByID(id.c_str(), cam) != VmbErrorSuccess)
throw std::runtime_error("Camera not found: " + id); throw std::runtime_error("Camera '" + spec.name + "' not found: " + id);
if (cam->Open(VmbAccessModeFull) != VmbErrorSuccess) if (const VmbErrorType rc = cam->Open(VmbAccessModeFull); rc != VmbErrorSuccess) {
throw std::runtime_error("Could not open camera: " + id); std::string why;
if (rc == VmbErrorInvalidAccess || rc == VmbErrorBusy)
why = " - another process has it open (is the VimbaX Viewer still running?)";
else if (rc == VmbErrorNotFound)
why = " - it disappeared between discovery and open (USB reset?)";
throw std::runtime_error("Could not open camera '" + spec.name + "': " + id +
" (Vimba error " + std::to_string(rc) + ")" + why);
}
impl_->cameras.push_back(cam); impl_->cameras.push_back(cam);
// Per-camera stats (persist across restart) + identity (read once here). // Per-camera stats (persist across restart) + identity (read once here).
impl_->stats.push_back(std::make_unique<CamStats>()); impl_->stats.push_back(std::make_unique<CamStats>());
impl_->telemetry.emplace_back(); impl_->telemetry.emplace_back();
impl_->identity.push_back({id, getString(cam, "DeviceModelName"), impl_->identity.push_back({id, getString(cam, "DeviceModelName"),
getString(cam, "DeviceSerialNumber")}); getString(cam, "DeviceSerialNumber")});
LOG_INFO << "Opened camera " << id << " (" << impl_->identity.back().model << ')'; LOG_INFO << "Opened camera '" << spec.name << "' " << id << " ("
<< impl_->identity.back().model << ')';
} }
} }
@ -284,30 +264,36 @@ void VimbaCameraSource::close() {
impl_->sys.Shutdown(); impl_->sys.Shutdown();
} }
void VimbaCameraSource::setInitialMode(CameraMode mode) { impl_->mode = std::move(mode); }
void VimbaCameraSource::start() { void VimbaCameraSource::start() {
for (auto& cam : impl_->cameras) applyMode(cam, impl_->mode); for (size_t i = 0; i < impl_->cameras.size() && i < impl_->profiles.size(); ++i)
applyProfile(impl_->cameras[i], impl_->profiles[i]);
impl_->startAcquisition(); impl_->startAcquisition();
impl_->started = true; impl_->started = true;
} }
bool VimbaCameraSource::setMode(const CameraMode& mode) { bool VimbaCameraSource::setProfile(int camera_index, const CameraProfile& profile) {
impl_->mode = mode; const size_t idx = static_cast<size_t>(camera_index);
if (camera_index < 0 || idx >= impl_->cameras.size() || idx >= impl_->profiles.size()) {
LOG_WARN << "camera: setProfile for unknown camera index " << camera_index;
return false;
}
impl_->profiles[idx] = profile;
if (!impl_->started) return true; // start() will apply it if (!impl_->started) return true; // start() will apply it
// Binning, ROI and pixel format cannot be written while the stream runs, and a // Binning, ROI and pixel format cannot be written while the stream runs, and a
// mode may change any of them, so always cycle the stream. Mode switches are // profile may change any of them, so always cycle the stream. Profile switches
// rare; a per-feature "is this writable live" allow-list would be fragile. // are rare; a per-feature "is this writable live" allow-list would be fragile.
try { try {
impl_->stopAcquisition(); impl_->stopAcquisition();
for (auto& cam : impl_->cameras) applyMode(cam, impl_->mode); const bool ok = applyProfile(impl_->cameras[idx], profile);
impl_->startAcquisition(); impl_->startAcquisition();
if (!ok) return false;
} catch (const std::exception& e) { } catch (const std::exception& e) {
LOG_ERROR << "camera: applying mode '" << mode.name << "' failed: " << e.what(); LOG_ERROR << "camera: applying profile '" << profile.name << "' failed: " << e.what();
return false; return false;
} }
// The next trigger() must not hand back a frame exposed under the old mode. // The next trigger() must not hand back a frame exposed under the old profile.
impl_->last_captured_ts = nowMs(); impl_->last_captured_ts = nowMs();
return true; return true;
} }

View File

@ -7,7 +7,7 @@
#include "fgc/DumpParser.h" #include "fgc/DumpParser.h"
#include "fgc/HelpText.h" #include "fgc/HelpText.h"
#include "fgc/Paths.h" #include "fgc/Paths.h"
#include "fgc/CameraMode.h" #include "fgc/CameraProfile.h"
#include "fgc/ICameraSource.h" #include "fgc/ICameraSource.h"
#include "fgc/IControlChannel.h" #include "fgc/IControlChannel.h"
#include "fgc/IEnvSensor.h" #include "fgc/IEnvSensor.h"
@ -15,6 +15,7 @@
#include "fgc/IMotorController.h" #include "fgc/IMotorController.h"
#include "fgc/ImagePipeline.h" #include "fgc/ImagePipeline.h"
#include "fgc/Logger.h" #include "fgc/Logger.h"
#include "fgc/MountedImuSource.h"
#include "fgc/MtiImuSource.h" #include "fgc/MtiImuSource.h"
#include "fgc/ScanGrid.h" #include "fgc/ScanGrid.h"
#include "fgc/SerialMotorController.h" #include "fgc/SerialMotorController.h"
@ -183,9 +184,10 @@ struct Application::Impl {
std::unique_ptr<IMotorController> motor; std::unique_ptr<IMotorController> motor;
std::unique_ptr<ICameraSource> camera; std::unique_ptr<ICameraSource> camera;
// Camera modes, loaded once at startup and kept so a live switch needs no disk // Camera modes, loaded once at startup and kept so a live switch needs no disk
// access. `camera_mode` is the active one. // access. Keyed by camera name, then profile name; `active_profile` tracks
std::map<std::string, CameraMode> camera_modes; // which profile each camera is currently on.
std::string camera_mode; std::map<std::string, std::map<std::string, CameraProfile>> camera_profiles;
std::map<std::string, std::string> active_profile;
std::unique_ptr<IImuSource> imu; std::unique_ptr<IImuSource> imu;
std::unique_ptr<IEnvSensor> env; std::unique_ptr<IEnvSensor> env;
std::unique_ptr<ImagePipeline> pipeline; std::unique_ptr<ImagePipeline> pipeline;
@ -229,6 +231,10 @@ struct Application::Impl {
mutable ImuConfigView imu_config_view_; // formatted MTi config (read once) mutable ImuConfigView imu_config_view_; // formatted MTi config (read once)
mutable bool imu_config_done_ = false; mutable bool imu_config_done_ = false;
mutable long long last_env_published_ts_ = 0; // gate MQTT publish to one per fresh sample mutable long long last_env_published_ts_ = 0; // gate MQTT publish to one per fresh sample
// Latches the "no IMU sample, publishing encoder pitch" warning so an outage
// costs one line, not one per frame. Written from the camera delivery
// thread(s) via the orientation supplier, hence atomic.
std::atomic<bool> imu_pitch_warned_{false};
std::atomic<bool> running{true}; std::atomic<bool> running{true};
std::mutex cmd_mutex; std::mutex cmd_mutex;
@ -263,12 +269,29 @@ struct Application::Impl {
std::unique_ptr<IImuSource> makeImu() { std::unique_ptr<IImuSource> makeImu() {
if (!cfg.features.enable_imu) return nullptr; // sensors panel stays "pending" 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"; LOG_WARN << "IMU enabled but [IMU] device is empty; disabling IMU";
return nullptr; 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() { std::unique_ptr<IEnvSensor> makeEnv() {
@ -296,49 +319,113 @@ struct Application::Impl {
mock = true; mock = true;
} }
#endif #endif
const auto active = cfg.camera.active();
if (mock) { if (mock) {
int count = cfg.camera.ids.empty() ? 1 : static_cast<int>(cfg.camera.ids.size()); return std::make_unique<MockCameraSource>(
return std::make_unique<MockCameraSource>(count); active.empty() ? 1 : static_cast<int>(active.size()));
} }
#if FGC_WITH_VIMBA #if FGC_WITH_VIMBA
auto cam = std::make_unique<VimbaCameraSource>(cfg.camera); std::vector<CameraSpec> specs;
cam->setInitialMode(camera_modes.at(camera_mode)); std::vector<CameraProfile> profiles;
return cam; for (const auto* c : active) {
specs.push_back(*c);
profiles.push_back(camera_profiles.at(c->name).at(active_profile.at(c->name)));
}
return std::make_unique<VimbaCameraSource>(std::move(specs), std::move(profiles));
#else #else
return std::make_unique<MockCameraSource>(1); return std::make_unique<MockCameraSource>(1);
#endif #endif
} }
// Load every mode file up front, so a typo in a mode nobody is using today // Load every profile of every camera up front. Two reasons: a profile nobody
// still fails at startup rather than the first time someone switches to it. // is using today still fails at startup rather than the first time someone
void loadModes() { // switches to it, and a settings XML edited in the Viewer is detected and
camera_modes = fgc::loadCameraModes(cfg.camera.mode_dir); // reported here rather than silently taking effect.
if (camera_modes.find(cfg.camera.mode) == camera_modes.end()) { void loadProfiles() {
if (cfg.camera.cameras.empty())
throw std::runtime_error("no cameras configured (expected at least one "
"[Camera.<name>] block)");
for (const auto& c : cfg.camera.cameras) {
if (!c.enabled) continue;
std::map<std::string, fgc::ProfileLoadResult> results;
auto profiles = fgc::loadCameraProfiles(c.profile_dir, results);
if (profiles.find(c.profile) == profiles.end()) {
std::string known; std::string known;
for (const auto& [name, m] : camera_modes) known += (known.empty() ? "" : ", ") + name; for (const auto& [name, p] : profiles) known += (known.empty() ? "" : ", ") + name;
throw std::runtime_error("Camera.mode '" + cfg.camera.mode + "' has no " + throw std::runtime_error("Camera." + c.name + ".profile '" + c.profile +
cfg.camera.mode + ".ini in " + cfg.camera.mode_dir + "' has no " + c.profile + ".xml in " + c.profile_dir +
" (available: " + known + ")"); " (available: " + known + ")");
} }
camera_mode = cfg.camera.mode;
LOG_INFO << "camera modes: " << camera_modes.size() << " from " << cfg.camera.mode_dir LOG_INFO << "camera '" << c.name << "': " << profiles.size() << " profile(s) from "
<< "; active '" << camera_mode << "'"; << c.profile_dir << "; active '" << c.profile << "'";
for (const auto& [pname, r] : results) {
if (r.first_run) {
// Nothing changed - there was simply no record yet.
LOG_INFO << "camera '" << c.name << "' profile '" << pname
<< "': writing " << pname << ".txt (no previous record)";
continue;
}
// A changed XML is a warning, not an info line: the camera is about
// to behave differently from the last run and nobody edited this
// program.
LOG_WARN << "camera '" << c.name << "' profile '" << pname
<< "': settings XML changed, regenerating " << pname << ".txt";
for (const auto& line : r.changes) LOG_WARN << " " << line;
} }
// Switch the live camera mode by name. Returns false (and warns) if unknown. camera_profiles[c.name] = std::move(profiles);
bool applyMode(const std::string& name) { active_profile[c.name] = c.profile;
auto it = camera_modes.find(name); }
if (it == camera_modes.end()) { }
// Switch one camera's profile by name. With no camera named, applies to the
// single configured camera. Returns false (and warns) if unknown.
bool applyProfile(const std::string& cam_name, const std::string& name) {
auto ci = camera_profiles.find(cam_name);
if (ci == camera_profiles.end()) {
std::string known; std::string known;
for (const auto& [n, m] : camera_modes) known += (known.empty() ? "" : ", ") + n; for (const auto& [n, p] : camera_profiles) known += (known.empty() ? "" : ", ") + n;
LOG_WARN << "unknown camera mode '" << name << "' (available: " << known << ')'; LOG_WARN << "unknown camera '" << cam_name << "' (configured: " << known << ')';
return false; return false;
} }
if (camera) camera->setMode(it->second); auto it = ci->second.find(name);
camera_mode = name; if (it == ci->second.end()) {
if (scheduler) std::string known;
scheduler->setSettleWait(it->second.settle_wait_ms, it->second.frame_timeout_ms); for (const auto& [n, p] : ci->second) known += (known.empty() ? "" : ", ") + n;
LOG_INFO << "camera mode -> " << name << " (" << it->second.description << ')'; LOG_WARN << "unknown profile '" << name << "' for camera '" << cam_name
<< "' (available: " << known << ')';
return false;
}
// The index is the camera's position among the ACTIVE cameras, which is the
// order the source opened them in and the order Frame::cam_id follows.
int index = -1, i = 0;
for (const auto* c : cfg.camera.active()) {
if (c->name == cam_name) { index = i; break; }
++i;
}
if (index < 0) {
LOG_WARN << "camera '" << cam_name << "' is configured but not enabled";
return false;
}
if (camera) camera->setProfile(index, it->second);
active_profile[cam_name] = name;
// The gimbal must stand still long enough for every camera, so recompute
// from all of them rather than from the one that just changed.
if (scheduler) {
int settle = 0, timeout = 0;
for (const auto* c : cfg.camera.active()) {
const auto t = c->timingFor(active_profile.count(c->name) ? active_profile[c->name]
: c->profile);
settle = std::max(settle, t.settle_wait_ms);
timeout = std::max(timeout, t.frame_timeout_ms);
}
scheduler->setSettleWait(settle, timeout);
}
LOG_INFO << "camera '" << cam_name << "' profile -> " << name;
return true; return true;
} }
@ -437,6 +524,12 @@ struct Application::Impl {
<< (c->scenario_label.empty() ? std::string("(unnamed)") : c->scenario_label) << (c->scenario_label.empty() ? std::string("(unnamed)") : c->scenario_label)
<< " (type " << static_cast<int>(c->scenario_type) << ')'; << " (type " << static_cast<int>(c->scenario_type) << ')';
LOG_INFO << " available (* = active): " << imuProfileList(); 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 // `imu` command family, mirroring handleGimbal: the operator-facing surface of
@ -532,6 +625,7 @@ struct Application::Impl {
v.moving = a.moving(); v.moving = a.moving();
v.standstill = a.standstill; v.standstill = a.standstill;
v.stall = a.stall; v.stall = a.stall;
v.deviation = a.deviation;
v.overtemp = a.overtemp; v.overtemp = a.overtemp;
v.endstop_l = a.endstop_l; v.endstop_l = a.endstop_l;
v.endstop_r = a.endstop_r; v.endstop_r = a.endstop_r;
@ -582,6 +676,15 @@ struct Application::Impl {
} }
} }
s.imu.config = imu_config_view_; 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()) { if (auto m = imu->sample()) {
// Full reading for the expanded view. // Full reading for the expanded view.
s.imu.present = true; s.imu.present = true;
@ -663,23 +766,36 @@ struct Application::Impl {
s.capture.active = scheduler && scheduler->captureActive(); s.capture.active = scheduler && scheduler->captureActive();
s.capture.image_rate = scheduler ? scheduler->imageRate() : 0.0; s.capture.image_rate = scheduler ? scheduler->imageRate() : 0.0;
s.capture.camera_count = camera ? camera->cameraCount() : 0; s.capture.camera_count = camera ? camera->cameraCount() : 0;
s.capture.labels = cfg.camera.labels; s.capture.labels.clear();
// Static imaging + encoding config, for the expanded camera view. for (const auto* c : cfg.camera.active()) s.capture.labels.push_back(c->label);
// Per-camera configuration, for the expanded camera view.
{ {
auto& cc = s.capture.config; auto& cc = s.capture.config;
cc.mock = opts.mock_camera.value_or(cfg.features.mock_camera); cc.mock = opts.mock_camera.value_or(cfg.features.mock_camera);
cc.ids = cfg.camera.ids;
cc.mode = camera_mode;
if (auto it = camera_modes.find(camera_mode); it != camera_modes.end()) {
cc.mode_desc = it->second.description;
cc.settle_wait_ms = it->second.settle_wait_ms;
}
cc.modes_available.clear();
for (const auto& [name, m] : camera_modes) cc.modes_available.push_back(name);
cc.jxl_distance = cfg.camera.jxl_distance;
cc.jxl_effort = cfg.camera.jxl_effort;
cc.rotation_deg = cfg.camera.rotation_deg;
cc.output_dir = cfg.paths.output_dir; cc.output_dir = cfg.paths.output_dir;
cc.cameras.clear();
for (const auto& c : cfg.camera.cameras) {
CameraView v;
v.name = c.name;
v.label = c.label;
v.id = c.id;
v.driver = c.driver;
v.enabled = c.enabled;
v.profile_dir = c.profile_dir;
v.rotation_deg = c.rotation_deg;
v.jxl_distance = c.jxl_distance;
v.jxl_effort = c.jxl_effort;
auto ap = active_profile.find(c.name);
v.profile = ap != active_profile.end() ? ap->second : c.profile;
v.settle_wait_ms = c.timingFor(v.profile).settle_wait_ms;
if (auto ci = camera_profiles.find(c.name); ci != camera_profiles.end()) {
for (const auto& [pname, prof] : ci->second)
v.profiles_available.push_back(pname);
if (auto pi = ci->second.find(v.profile); pi != ci->second.end())
v.profile_xml = pi->second.xml_path;
}
cc.cameras.push_back(std::move(v));
}
} }
// Live per-camera identity/telemetry/stats. // Live per-camera identity/telemetry/stats.
s.capture.devices.clear(); s.capture.devices.clear();
@ -1532,10 +1648,22 @@ struct Application::Impl {
handleTrace(c); handleTrace(c);
} else if (c.verb == "set") { } else if (c.verb == "set") {
if (c.device == "camera") { if (c.device == "camera") {
// "set camera mode night" arrives as option="mode night": the parser // "set camera profile night" arrives as option="profile night":
// joins non-numeric trailing tokens into `option`. // the parser joins non-numeric trailing tokens into `option`. With
if (c.option.rfind("mode ", 0) == 0) applyMode(c.option.substr(5)); // two words after "profile" the first names the camera.
else if (c.option == "mode") LOG_WARN << "usage: set camera mode <name>"; if (c.option.rfind("profile ", 0) == 0) {
const std::string arg = c.option.substr(8);
const auto sp = arg.find(' ');
if (sp != std::string::npos)
applyProfile(arg.substr(0, sp), arg.substr(sp + 1));
else if (camera_profiles.size() == 1)
applyProfile(camera_profiles.begin()->first, arg);
else
LOG_WARN << "more than one camera configured: "
"usage: set camera profile <camera> <name>";
} else if (c.option == "profile") {
LOG_WARN << "usage: set camera profile [<camera>] <name>";
}
else if (c.option == "jxlq" && c.has_value) pipeline->setDistance(c.value); else if (c.option == "jxlq" && c.has_value) pipeline->setDistance(c.value);
else if (c.option == "jxle" && c.has_value) pipeline->setEffort(static_cast<int>(c.value)); else if (c.option == "jxle" && c.has_value) pipeline->setEffort(static_cast<int>(c.value));
else if (c.option == "display") pipeline->setDisplay(c.value != 0.0); else if (c.option == "display") pipeline->setDisplay(c.value != 0.0);
@ -1588,7 +1716,7 @@ struct Application::Impl {
if (Logger::categories()) if (Logger::categories())
LOG_INFO << "wire trace enabled: " << traceNames(Logger::categories()); LOG_INFO << "wire trace enabled: " << traceNames(Logger::categories());
loadModes(); // before makeCamera(): the mode is applied at start() loadProfiles(); // before makeCamera(): the profile is applied at start()
channel = makeChannel(); channel = makeChannel();
motor = makeMotor(); motor = makeMotor();
@ -1616,19 +1744,41 @@ struct Application::Impl {
ImagePipeline::Params pp; ImagePipeline::Params pp;
pp.output_dir = cfg.paths.output_dir; pp.output_dir = cfg.paths.output_dir;
pp.labels = cfg.camera.labels;
pp.tower = cfg.general.tower_name; pp.tower = cfg.general.tower_name;
pp.demo = opts.demo; pp.demo = opts.demo;
pp.jxl_distance = cfg.camera.jxl_distance; // In open order, matching Frame::cam_id: the source opens the active
pp.jxl_effort = cfg.camera.jxl_effort; // cameras in this order too.
pp.rotation_deg = cfg.camera.rotation_deg; for (const auto* c : cfg.camera.active())
// Orientation supplier: convert the per-axis encoder counts to degrees. pp.cameras.push_back({c->label, c->rotation_deg, c->jxl_distance, c->jxl_effort});
// Orientation supplier, called by ImagePipeline::submit() as each frame
// arrives. The two axes come from different sources on purpose:
//
// yaw - the encoder. IMU yaw drifts and depends on the heading reset
// and the XKF profile, whereas encoder yaw is calibrated against
// the IMU by `gimbal calib` and then stays put.
// pitch - the IMU. The encoder only measures the axis relative to the
// gimbal's own base, so a leaning mast is invisible to it; the
// IMU measures against gravity, which is the true elevation the
// imagery needs to be georeferenced against.
pipeline = std::make_unique<ImagePipeline>( pipeline = std::make_unique<ImagePipeline>(
*channel, *channel,
[this] { [this] {
MotorTelemetry t = motor->telemetry(); MotorTelemetry t = motor->telemetry();
return Orientation{static_cast<float>(cfg.geometry.yaw.toDeg(t.yaw.xenc)), Orientation o{static_cast<float>(cfg.geometry.yaw.toDeg(t.yaw.xenc)),
static_cast<float>(cfg.geometry.pitch.toDeg(t.pitch.xenc))}; static_cast<float>(cfg.geometry.pitch.toDeg(t.pitch.xenc))};
if (!imu) return o; // IMU disabled in config: encoder pitch, as before
if (auto s = imu->sample()) {
o.pitch_deg = s->pitch_deg;
imu_pitch_warned_ = false;
} else if (!imu_pitch_warned_) {
// Falling back silently would put two different measurements -
// true elevation and an axis angle - into one archive field
// with nothing recording which is which. Say it once per outage.
imu_pitch_warned_ = true;
LOG_WARN << "IMU has no fresh sample; publishing encoder pitch instead "
"(CamEvent 'pit' is the axis angle, not true elevation)";
}
return o;
}, },
pp); pp);
@ -1644,10 +1794,9 @@ struct Application::Impl {
LOG_INFO << "scan grid: " << grid.size() << " waypoints"; LOG_INFO << "scan grid: " << grid.size() << " waypoints";
scheduler = std::make_unique<CaptureScheduler>(*motor, *camera, *channel, cfg.image_rate(), scheduler = std::make_unique<CaptureScheduler>(*motor, *camera, *channel, cfg.image_rate(),
cfg.geometry, grid); cfg.geometry, grid);
{ // Every camera has to have finished by the time the gimbal moves on, so
const CameraMode& m = camera_modes.at(camera_mode); // the wait is the longest across them, not the first one's.
scheduler->setSettleWait(m.settle_wait_ms, m.frame_timeout_ms); scheduler->setSettleWait(cfg.camera.maxSettleWaitMs(), cfg.camera.maxFrameTimeoutMs());
}
motor->start(); motor->start();
if (imu) imu->start(); if (imu) imu->start();

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 (vals.empty()) return 0.0;
if (axis == 'Y') { if (axis == 'Y') {
double m = circularMeanDeg(vals); // robust to wrap within the dwell // Robust to a wrap within the dwell, and already in the device's own
if (m < 0.0) m += 360.0; // report a 0..360 heading (matches IMU) // -180..180. The sweep's per-sample unwrapNear below is what keeps the
return m; // fit continuous, so no range shift is needed - or wanted - here.
return circularMeanDeg(vals);
} }
double m = 0; double m = 0;
for (double v : vals) m += v; for (double v : vals) m += v;

View File

@ -1,95 +0,0 @@
#include "fgc/CameraMode.h"
#include <algorithm>
#include <filesystem>
#include <stdexcept>
#include <string>
extern "C" {
#include "ini.h"
}
namespace fs = std::filesystem;
namespace fgc {
namespace {
// inih callback. Unlike Config.cpp's handler this APPENDS rather than writing a
// map: mode files depend on file order and on repeated keys (see CameraMode).
int modeHandler(void* user, const char* section, const char* name, const char* value) {
auto* m = static_cast<CameraMode*>(user);
const std::string sec = section ? section : "";
const std::string key = name ? name : "";
const std::string val = value ? value : "";
if (sec == "Camera") {
m->features.emplace_back(key, val);
return 1;
}
if (sec != "Mode") return 0; // unknown section -> parse error
if (key == "description") {
m->description = val;
} else if (key == "settle_wait_ms" || key == "frame_timeout_ms") {
int parsed = 0;
try {
size_t consumed = 0;
parsed = std::stoi(val, &consumed);
if (consumed != val.size() || parsed < 0) return 0;
} catch (const std::exception&) {
return 0;
}
(key == "settle_wait_ms" ? m->settle_wait_ms : m->frame_timeout_ms) = parsed;
} else {
return 0; // unknown [Mode] key -> parse error, not a silent typo
}
return 1;
}
} // namespace
CameraMode loadCameraMode(const std::string& path) {
CameraMode mode;
mode.name = fs::path(path).stem().string();
const int rc = ini_parse(path.c_str(), modeHandler, &mode);
if (rc == -1)
throw std::runtime_error("Cannot open camera mode file: " + path);
if (rc == -2)
throw std::runtime_error("Out of memory reading camera mode file: " + path);
if (rc > 0)
throw std::runtime_error("Camera mode file '" + path + "' line " + std::to_string(rc) +
": unknown section, unknown [Mode] key, or invalid value "
"(expected [Mode] description/settle_wait_ms/frame_timeout_ms, "
"or a [Camera] feature)");
if (mode.features.empty())
throw std::runtime_error("Camera mode file '" + path + "' sets no [Camera] features");
return mode;
}
std::map<std::string, CameraMode> loadCameraModes(const std::string& dir) {
std::error_code ec;
if (!fs::is_directory(dir, ec))
throw std::runtime_error("Camera mode directory not found: " + dir);
// Sorted so startup logs and the UI list modes in a stable order.
std::vector<fs::path> files;
for (const auto& entry : fs::directory_iterator(dir, ec))
if (entry.is_regular_file() && entry.path().extension() == ".ini")
files.push_back(entry.path());
std::sort(files.begin(), files.end());
std::map<std::string, CameraMode> modes;
for (const auto& f : files) {
CameraMode m = loadCameraMode(f.string());
modes.emplace(m.name, std::move(m));
}
if (modes.empty())
throw std::runtime_error("No camera modes (*.ini) in " + dir);
return modes;
}
} // namespace fgc

View File

@ -0,0 +1,80 @@
#include "fgc/CameraProfile.h"
#include "fgc/Hash.h"
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <stdexcept>
namespace fs = std::filesystem;
namespace fgc {
CameraProfile loadCameraProfile(const std::string& profile_dir, const std::string& name,
ProfileLoadResult& result) {
result = {};
CameraProfile p;
p.name = name;
p.xml_path = (fs::path(profile_dir) / (name + ".xml")).string();
p.txt_path = (fs::path(profile_dir) / (name + ".txt")).string();
if (!fs::is_regular_file(p.xml_path))
throw std::runtime_error("camera profile '" + name + "': " + p.xml_path +
" not found (export it from the VimbaX Viewer)");
const std::string label = hashLabel(fnv1a64File(p.xml_path));
p.settings = parseCameraSettingsXml(p.xml_path);
// An absent or stale record is regenerated. Reading the old one first is what
// lets the caller report WHICH settings moved, rather than just that the file
// changed - that difference is the whole value of the check.
const SettingsRecord old = readSettingsRecord(p.txt_path);
if (old.hash_label == label) return p;
result.regenerated = true;
// No previous record means no baseline, not 800 changed settings.
result.first_run = old.hash_label.empty() && old.entries.empty();
if (!result.first_run) result.changes = diffSettings(old.entries, p.settings.entries);
const std::string xml_name = fs::path(p.xml_path).filename().string();
std::ofstream out(p.txt_path, std::ios::binary | std::ios::trunc);
if (!out)
throw std::runtime_error("camera profile '" + name + "': cannot write the settings "
"record " + p.txt_path);
out << renderSettingsRecord(p.settings, xml_name, label);
if (!out)
throw std::runtime_error("camera profile '" + name + "': failed writing " + p.txt_path);
return p;
}
std::map<std::string, CameraProfile> loadCameraProfiles(
const std::string& dir, std::map<std::string, ProfileLoadResult>& results) {
results.clear();
std::error_code ec;
if (!fs::is_directory(dir, ec))
throw std::runtime_error("camera profile directory not found: " + dir);
// Sorted so startup logs and the UI list profiles in a stable order.
std::vector<std::string> names;
for (const auto& entry : fs::directory_iterator(dir, ec))
if (entry.is_regular_file() && entry.path().extension() == ".xml")
names.push_back(entry.path().stem().string());
std::sort(names.begin(), names.end());
std::map<std::string, CameraProfile> out;
for (const auto& n : names) {
ProfileLoadResult r;
out.emplace(n, loadCameraProfile(dir, n, r));
if (r.regenerated) results.emplace(n, std::move(r));
}
if (out.empty())
throw std::runtime_error("no camera profiles (*.xml) in " + dir +
" (export them from the VimbaX Viewer)");
return out;
}
} // namespace fgc

View File

@ -0,0 +1,219 @@
#include "fgc/CameraSettingsXml.h"
#include <algorithm>
#include <ctime>
#include <fstream>
#include <map>
#include <sstream>
#include <stdexcept>
namespace fgc {
namespace {
// The input is machine-generated by the VimbaX Viewer and has a fixed, flat
// shape: <Feature Name=".." Value=".." Type=".." /> elements, some nested in
// <SelectorGroup Name=".." Value=".." >. A targeted scanner is enough for that
// and keeps the project free of an XML dependency - but it validates what it
// finds and throws rather than guessing, because a silently mis-parsed settings
// file would be worse than a missing one.
// Value of `attr` in `tag`, or "" when absent.
std::string attrValue(const std::string& tag, const std::string& attr) {
const std::string key = attr + "=\"";
const auto pos = tag.find(key);
if (pos == std::string::npos) return "";
const auto start = pos + key.size();
const auto end = tag.find('"', start);
if (end == std::string::npos) return "";
return tag.substr(start, end - start);
}
std::string slurp(const std::string& path) {
std::ifstream in(path, std::ios::binary);
if (!in) throw std::runtime_error("cannot read camera settings XML: " + path);
std::ostringstream ss;
ss << in.rdbuf();
return ss.str();
}
std::string nowIso8601() {
const std::time_t t = std::time(nullptr);
std::tm tm{};
localtime_r(&t, &tm);
char buf[32];
std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S", &tm);
return buf;
}
} // namespace
CameraSettings parseCameraSettingsXml(const std::string& path) {
const std::string xml = slurp(path);
CameraSettings out;
const auto info = xml.find("<CameraInfo");
if (info != std::string::npos) {
const auto close = xml.find('>', info);
const std::string tag = xml.substr(info, close - info);
out.model = attrValue(tag, "Model");
out.device_id = attrValue(tag, "Id");
}
// Only <RemoteDevice>: <LocalDevice> and the stream sections describe the
// host's transport layer, which is per-machine and must not be replayed.
const auto begin = xml.find("<RemoteDevice>");
if (begin == std::string::npos)
throw std::runtime_error("not a VimbaX settings export (no <RemoteDevice>): " + path);
const auto end = xml.find("</RemoteDevice>", begin);
if (end == std::string::npos)
throw std::runtime_error("malformed VimbaX settings export (unterminated "
"<RemoteDevice>): " + path);
const std::string body = xml.substr(begin, end - begin);
// Walk the block in order, tracking the enclosing SelectorGroup so a repeated
// feature name (BalanceRatio for Red and Blue, say) stays distinguishable.
std::string selector;
size_t i = 0;
while (i < body.size()) {
const auto lt = body.find('<', i);
if (lt == std::string::npos) break;
const auto gt = body.find('>', lt);
if (gt == std::string::npos) break;
const std::string tag = body.substr(lt, gt - lt + 1);
i = gt + 1;
if (tag.rfind("<SelectorGroup", 0) == 0) {
selector = attrValue(tag, "Name") + "=" + attrValue(tag, "Value");
} else if (tag.rfind("</SelectorGroup", 0) == 0) {
selector.clear();
} else if (tag.rfind("<Feature", 0) == 0) {
SettingEntry e;
e.name = attrValue(tag, "Name");
e.value = attrValue(tag, "Value");
e.selector = selector;
if (!e.name.empty()) out.entries.push_back(std::move(e));
}
}
if (out.entries.empty())
throw std::runtime_error("VimbaX settings export has no features: " + path);
return out;
}
std::string renderSettingsRecord(const CameraSettings& s, const std::string& xml_name,
const std::string& hash_label) {
std::ostringstream o;
o << "; ===========================================================================\n"
"; GENERATED - THIS FILE DOES NOT CONFIGURE THE CAMERA\n"
";\n"
"; A human-readable record of what " << xml_name << " contains, so the settings\n"
"; can be read and diffed without opening the Viewer. The program applies\n"
"; " << xml_name << " directly through the Vimba X SDK. Nothing here is ever\n"
"; written to the camera, and editing this file changes nothing.\n"
";\n"
"; To change the settings: open the VimbaX Viewer, configure the camera there\n"
"; (it validates combinations as you go), save the settings XML over\n"
"; " << xml_name << ", and restart. This file is then regenerated and the\n"
"; changes are listed in the log.\n"
"; ===========================================================================\n\n";
o << "[Source]\n"
<< "xml = " << xml_name << "\n"
<< "xml_hash = " << hash_label << "\n"
<< "generated = " << nowIso8601() << "\n";
if (!s.model.empty()) o << "model = " << s.model << "\n";
if (!s.device_id.empty()) o << "device_id = " << s.device_id << "\n";
o << "\n[Settings]\n";
std::string current_selector;
for (const auto& e : s.entries) {
// Reproduce the selector context as a comment: without it a repeated
// feature name reads as a duplicate rather than a per-selector value.
if (e.selector != current_selector) {
current_selector = e.selector;
o << "; --- " << (current_selector.empty() ? "(none)" : current_selector) << "\n";
}
o << e.name << " = " << e.value << "\n";
}
return o.str();
}
SettingsRecord readSettingsRecord(const std::string& path) {
SettingsRecord rec;
std::ifstream in(path);
if (!in) return rec; // absent: caller treats it as a mismatch
std::string line;
bool in_settings = false;
std::string selector;
while (std::getline(in, line)) {
// Selector context is written as a comment; recover it so a diff can tell
// BalanceRatio-for-Red from BalanceRatio-for-Blue.
if (line.rfind("; --- ", 0) == 0) {
selector = line.substr(6);
if (selector == "(none)") selector.clear();
continue;
}
if (!line.empty() && line[0] == ';') continue;
if (line == "[Settings]") { in_settings = true; continue; }
if (!line.empty() && line[0] == '[') { in_settings = false; continue; }
const auto eq = line.find('=');
if (eq == std::string::npos) continue;
auto trim = [](std::string v) {
const auto a = v.find_first_not_of(" \t\r");
if (a == std::string::npos) return std::string();
const auto b = v.find_last_not_of(" \t\r");
return v.substr(a, b - a + 1);
};
const std::string key = trim(line.substr(0, eq));
const std::string val = trim(line.substr(eq + 1));
if (in_settings) {
rec.entries.push_back({key, val, selector});
} else if (key == "xml_hash") {
rec.hash_label = val;
}
}
return rec;
}
std::vector<std::string> diffSettings(const std::vector<SettingEntry>& before,
const std::vector<SettingEntry>& after) {
// Key by selector + name + occurrence index. A real Viewer export repeats the
// same SelectorGroup name AND value (FileOperationSelector=Open appears once
// per FileSelector group), so selector+name alone is not unique - keying on it
// collapsed the duplicates and reported every one after the first as "new",
// burying a one-line change under hundreds of phantom ones.
auto keyed = [](const std::vector<SettingEntry>& v) {
std::map<std::string, const SettingEntry*> m;
std::map<std::string, int> seen;
for (const auto& e : v) {
const std::string base = e.selector + "|" + e.name;
m[base + "|" + std::to_string(seen[base]++)] = &e;
}
return m;
};
auto label = [](const SettingEntry& e) {
return e.selector.empty() ? e.name : e.name + " [" + e.selector + "]";
};
auto old_by_key = keyed(before);
auto new_by_key = keyed(after);
std::vector<std::string> out;
for (const auto& [k, e] : new_by_key) {
auto it = old_by_key.find(k);
if (it == old_by_key.end()) {
out.push_back("+ " + label(*e) + " " + e->value + " (new)");
} else if (it->second->value != e->value) {
out.push_back(label(*e) + " " + it->second->value + " -> " + e->value);
}
}
for (const auto& [k, e] : old_by_key)
if (new_by_key.find(k) == new_by_key.end())
out.push_back("- " + label(*e) + " (removed)");
return out;
}
} // namespace fgc

View File

@ -1,6 +1,7 @@
#include "fgc/Config.h" #include "fgc/Config.h"
#include "fgc/Paths.h" #include "fgc/Paths.h"
#include <cmath>
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
#include <cstdio> #include <cstdio>
@ -108,6 +109,31 @@ int iniHandler(void* user, const char* section, const char* name, const char* va
} // namespace } // namespace
CameraProfileTiming CameraSpec::timingFor(const std::string& profile_name) const {
auto it = timing.find(profile_name);
return it != timing.end() ? it->second : CameraProfileTiming{};
}
std::vector<const CameraSpec*> CameraConfig::active() const {
std::vector<const CameraSpec*> out;
for (const auto& c : cameras)
if (c.enabled) out.push_back(&c);
return out;
}
int CameraConfig::maxSettleWaitMs() const {
int worst = 0;
for (const auto* c : active()) worst = std::max(worst, c->timingFor(c->profile).settle_wait_ms);
return worst > 0 ? worst : CameraProfileTiming{}.settle_wait_ms;
}
int CameraConfig::maxFrameTimeoutMs() const {
int worst = 0;
for (const auto* c : active())
worst = std::max(worst, c->timingFor(c->profile).frame_timeout_ms);
return worst > 0 ? worst : CameraProfileTiming{}.frame_timeout_ms;
}
std::string NetworkConfig::brokerUri() const { std::string NetworkConfig::brokerUri() const {
// A scheme means the operator wrote the whole URI: hands off. Appending a port // A scheme means the operator wrote the whole URI: hands off. Appending a port
// could corrupt one that already has it, or a ws:// path. // could corrupt one that already has it, or a ws:// path.
@ -192,25 +218,81 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
cfg.serial.device = get(kv, "Serial.device", cfg.serial.device); cfg.serial.device = get(kv, "Serial.device", cfg.serial.device);
cfg.serial.baud = static_cast<unsigned>(getInt(kv, "Serial.baud", cfg.serial.baud)); cfg.serial.baud = static_cast<unsigned>(getInt(kv, "Serial.baud", cfg.serial.baud));
cfg.camera.ids.clear(); // ---- [Camera] shared defaults, then one [Camera.<name>] block per camera ----
for (const char* key : {"Camera.id_Cam1", "Camera.id_Cam2", "Camera.id_Cam3", "Camera.id_Cam4"}) {
std::string id = get(kv, key);
if (!id.empty()) cfg.camera.ids.push_back(id);
}
// On-camera imaging + encoding. See CameraConfig for why the defaults are conservative.
cfg.camera.mode_dir = get(kv, "Camera.mode_dir", cfg.camera.mode_dir);
cfg.camera.mode = get(kv, "Camera.mode", cfg.camera.mode);
cfg.camera.jxl_distance = getDouble(kv, "Camera.jxl_distance", cfg.camera.jxl_distance); cfg.camera.jxl_distance = getDouble(kv, "Camera.jxl_distance", cfg.camera.jxl_distance);
cfg.camera.jxl_effort = getInt(kv, "Camera.jxl_effort", cfg.camera.jxl_effort); cfg.camera.jxl_effort = getInt(kv, "Camera.jxl_effort", cfg.camera.jxl_effort);
cfg.camera.rotation_deg = getInt(kv, "Camera.rotation_deg", cfg.camera.rotation_deg);
if (cfg.camera.jxl_distance < 0.0) // Cameras are discovered from the key names rather than a list, so there is
throw std::runtime_error("Camera.jxl_distance must be >= 0 (0 = lossless)"); // no registry to keep in sync: "Camera.rgb.id" declares a camera called rgb.
if (cfg.camera.jxl_effort < 1 || cfg.camera.jxl_effort > 9) // A third dotted segment ("Camera.rgb.standard.settle_wait_ms") is per-profile
throw std::runtime_error("Camera.jxl_effort must be in 1..9"); // timing, so only two-segment suffixes name a camera.
if (cfg.camera.rotation_deg != 0 && cfg.camera.rotation_deg != 90 && std::vector<std::string> names; // in first-seen order
cfg.camera.rotation_deg != 180 && cfg.camera.rotation_deg != 270) for (const auto& [key, unused] : kv) {
throw std::runtime_error("Camera.rotation_deg must be 0, 90, 180 or 270"); (void)unused;
if (key.rfind("Camera.", 0) != 0) continue;
const std::string rest = key.substr(7);
const auto dot = rest.find('.');
if (dot == std::string::npos) continue; // [Camera] itself
const std::string name = rest.substr(0, dot);
if (rest.find('.', dot + 1) != std::string::npos) { // per-profile timing
if (std::find(names.begin(), names.end(), name) == names.end()) names.push_back(name);
continue;
}
if (std::find(names.begin(), names.end(), name) == names.end()) names.push_back(name);
}
cfg.camera.cameras.clear();
for (const auto& name : names) {
const std::string pre = "Camera." + name + ".";
CameraSpec c;
c.name = name;
c.enabled = getBool(kv, pre + "enabled", true);
c.driver = get(kv, pre + "driver", c.driver);
c.id = get(kv, pre + "id");
c.label = get(kv, pre + "label", name);
c.profile_dir = get(kv, pre + "profile_dir");
c.profile = get(kv, pre + "profile", c.profile);
c.rotation_deg = getInt(kv, pre + "rotation_deg", c.rotation_deg);
c.jxl_distance = getDouble(kv, pre + "jxl_distance", cfg.camera.jxl_distance);
c.jxl_effort = getInt(kv, pre + "jxl_effort", cfg.camera.jxl_effort);
// Per-profile timing: Camera.<name>.<profile>.settle_wait_ms
for (const auto& [key, val] : kv) {
(void)val;
if (key.rfind(pre, 0) != 0) continue;
const std::string rest = key.substr(pre.size());
const auto dot = rest.find('.');
if (dot == std::string::npos) continue;
const std::string profile = rest.substr(0, dot);
const std::string field = rest.substr(dot + 1);
CameraProfileTiming& t = c.timing[profile];
if (field == "settle_wait_ms") t.settle_wait_ms = getInt(kv, key, t.settle_wait_ms);
else if (field == "frame_timeout_ms") t.frame_timeout_ms = getInt(kv, key, t.frame_timeout_ms);
else throw std::runtime_error("unknown key '" + key + "' (expected " + pre +
profile + ".settle_wait_ms or .frame_timeout_ms)");
}
if (c.driver != "vimba")
throw std::runtime_error("Camera." + name + ".driver must be 'vimba' (got '" +
c.driver + "'); no other backend is implemented yet");
if (c.enabled && c.id.empty())
throw std::runtime_error("Camera." + name + ".id is required (USB DEV_ id or GigE IP)");
if (c.profile_dir.empty())
throw std::runtime_error("Camera." + name + ".profile_dir is required");
if (c.jxl_distance < 0.0)
throw std::runtime_error("Camera." + name + ".jxl_distance must be >= 0 (0 = lossless)");
if (c.jxl_effort < 1 || c.jxl_effort > 9)
throw std::runtime_error("Camera." + name + ".jxl_effort must be in 1..9");
if (c.rotation_deg != 0 && c.rotation_deg != 90 && c.rotation_deg != 180 &&
c.rotation_deg != 270)
throw std::runtime_error("Camera." + name + ".rotation_deg must be 0, 90, 180 or 270");
for (const auto& [pname, t] : c.timing) {
if (t.settle_wait_ms < 0 || t.frame_timeout_ms < 0)
throw std::runtime_error("Camera." + name + "." + pname +
" timings must be >= 0");
}
cfg.camera.cameras.push_back(std::move(c));
}
std::string out = get(kv, "Paths.output_dir"); std::string out = get(kv, "Paths.output_dir");
cfg.paths.output_dir = out.empty() ? paths::defaultOutputDir() : paths::expandUser(out); cfg.paths.output_dir = out.empty() ? paths::defaultOutputDir() : paths::expandUser(out);
@ -228,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.device = get(kv, "IMU.device", cfg.imu.device);
cfg.imu.baud = static_cast<unsigned>(getInt(kv, "IMU.baud", cfg.imu.baud)); 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_device = get(kv, "Env.i2c_device", cfg.env.i2c_device);
cfg.env.i2c_addr = static_cast<unsigned>( cfg.env.i2c_addr = static_cast<unsigned>(
getIntAutoBase(kv, "Env.i2c_addr", static_cast<int>(cfg.env.i2c_addr))); getIntAutoBase(kv, "Env.i2c_addr", static_cast<int>(cfg.env.i2c_addr)));
@ -317,11 +414,11 @@ AppConfig ConfigLoader::loadFromFile(const std::string& path) {
if (gf.is_relative()) gf = std::filesystem::path(path).parent_path() / gf; if (gf.is_relative()) gf = std::filesystem::path(path).parent_path() / gf;
cfg.scan.grid_file = gf.lexically_normal().string(); cfg.scan.grid_file = gf.lexically_normal().string();
} }
// Same for the camera mode directory. // Same for each camera's profile directory.
{ for (auto& c : cfg.camera.cameras) {
std::filesystem::path md(paths::expandUser(cfg.camera.mode_dir)); std::filesystem::path pd(paths::expandUser(c.profile_dir));
if (md.is_relative()) md = std::filesystem::path(path).parent_path() / md; if (pd.is_relative()) pd = std::filesystem::path(path).parent_path() / pd;
cfg.camera.mode_dir = md.lexically_normal().string(); c.profile_dir = pd.lexically_normal().string();
} }
return cfg; return cfg;
} }

40
src/core/Hash.cpp Normal file
View File

@ -0,0 +1,40 @@
#include "fgc/Hash.h"
#include <cstdio>
#include <fstream>
#include <sstream>
#include <stdexcept>
namespace fgc {
namespace {
constexpr uint64_t kOffsetBasis = 0xcbf29ce484222325ULL;
constexpr uint64_t kPrime = 0x00000100000001b3ULL;
} // namespace
uint64_t fnv1a64(const std::string& data) {
uint64_t h = kOffsetBasis;
for (unsigned char c : data) {
h ^= c;
h *= kPrime;
}
return h;
}
std::string hashLabel(uint64_t digest) {
char buf[32];
std::snprintf(buf, sizeof(buf), "fnv1a64:%016llx", static_cast<unsigned long long>(digest));
return buf;
}
uint64_t fnv1a64File(const std::string& path) {
// Binary: the digest must not depend on how the platform treats line endings,
// or a file copied between machines would look changed when it is not.
std::ifstream in(path, std::ios::binary);
if (!in) throw std::runtime_error("cannot read file for hashing: " + path);
std::ostringstream ss;
ss << in.rdbuf();
return fnv1a64(ss.str());
}
} // namespace fgc

View File

@ -102,11 +102,12 @@ const std::vector<HelpSection>& helpCatalog() {
{"start", "Begin the capture scan.", {}}, {"start", "Begin the capture scan.", {}},
{"stop", "Halt the capture scan.", {}}, {"stop", "Halt the capture scan.", {}},
{"set fps <rate>", "Set capture rate in images/second.", {}}, {"set fps <rate>", "Set capture rate in images/second.", {}},
{"set camera mode <name>", {"set camera profile [<camera>] <name>",
"Switch the camera mode (rate, exposure limits, imaging settings).", { "Switch a camera's profile (rate, exposure, all imaging settings).", {
"Modes are files in [Camera] mode_dir, one .ini each; the name is the", "A profile is a settings XML exported from the VimbaX Viewer, in that",
"filename stem.", "camera's [Camera.<name>] profile_dir; the name is the filename stem. The",
"Example: set camera mode night"}}, "camera may be omitted when only one is configured.",
"Example: set camera profile night / set camera profile rgb night"}},
{"set camera jxlq <dist>", "Set JPEG-XL distance (lower = higher quality).", {}}, {"set camera jxlq <dist>", "Set JPEG-XL distance (lower = higher quality).", {}},
{"set camera jxle <effort>", "Set JPEG-XL encode effort.", {}}, {"set camera jxle <effort>", "Set JPEG-XL encode effort.", {}},
{"set camera display <0|1>", "Toggle the local display window.", {}}, {"set camera display <0|1>", "Toggle the local display window.", {}},
@ -132,6 +133,11 @@ const std::vector<HelpSection>& helpCatalog() {
"still, and the LOG title shows 'scrollback'."}}, "still, and the LOG title shows 'scrollback'."}},
{"Home/End", {"Home/End",
"Jump to the top/bottom; on the log, End resumes following the tail.", {}}, "Jump to the top/bottom; on the log, End resumes following the tail.", {}},
{"q",
"Ask whether to quit or leave the dashboard running in the background.", {
"Quitting stops capture and closes the camera; backgrounding detaches the",
"tmux client (same as Ctrl-b d) and leaves everything running. Ctrl-C still",
"shuts down immediately."}},
{"p", {"p",
"Freeze the 10 Hz repaint so terminal text selection survives.", { "Freeze the 10 Hz repaint so terminal text selection survives.", {
"Any keypress repaints, so scrolling while paused drops the selection -", "Any keypress repaints, so scrolling while paused drops the selection -",

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) { std::optional<ImuSample> parseMTData(uint8_t mid, const uint8_t* data, std::size_t len) {
if (mid != kMidMTData || len != kMTDataLen) return std::nullopt; if (mid != kMidMTData || len != kMTDataLen) return std::nullopt;
ImuSample s; 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.roll_deg = beFloat(data + o); o += 4;
s.pitch_deg = beFloat(data + o); o += 4; s.pitch_deg = beFloat(data + o); o += 4;
s.yaw_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. // Reported exactly as the device sends it. Roll, pitch and yaw are all raw:
if (s.yaw_deg < 0.f) s.yaw_deg += 360.f; // 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.sample_counter = beU16(data + o); o += 2;
s.valid = true; s.valid = true;
return s; return s;

View File

@ -53,6 +53,7 @@ std::optional<AxisTelemetry> parseAxisSegment(const std::string& seg) {
switch (c) { switch (c) {
case 'S': a.standstill = true; break; case 'S': a.standstill = true; break;
case 's': a.stall = true; break; case 's': a.stall = true; break;
case 'd': a.deviation = true; break;
case 'o': case 'O': a.overtemp = true; break; case 'o': case 'O': a.overtemp = true; break;
case 'L': a.endstop_l = true; break; case 'L': a.endstop_l = true; break;
case 'R': a.endstop_r = true; break; case 'R': a.endstop_r = true; break;

View File

@ -1,5 +1,7 @@
#include "fgc/TestRunner.h" #include "fgc/TestRunner.h"
#include "fgc/Calibration.h" // unwrapNear
#include "fgc/DiagParser.h" #include "fgc/DiagParser.h"
#include "fgc/DumpParser.h" #include "fgc/DumpParser.h"
#include "fgc/HostMetrics.h" #include "fgc/HostMetrics.h"
@ -672,14 +674,25 @@ void TestRunner::testImuDrift(TestReport& r) {
const auto t0 = clock_t_::now(); const auto t0 = clock_t_::now();
const auto deadline = t0 + window; const auto deadline = t0 + window;
double peak_min = 1e9, peak_max = -1e9; double peak_min = 1e9, peak_max = -1e9;
bool have_prev = false;
double prev_deg = 0.0;
while (clock_t_::now() < deadline && !cancelled()) { while (clock_t_::now() < deadline && !cancelled()) {
auto smp = imu_->sample(); auto smp = imu_->sample();
if (smp && smp->valid) { if (smp && smp->valid) {
double tmin = std::chrono::duration<double>(clock_t_::now() - t0).count() / 60.0; 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); ts.push_back(tmin);
yaw.push_back(smp->yaw_deg); yaw.push_back(deg);
peak_min = std::min(peak_min, (double)smp->yaw_deg); peak_min = std::min(peak_min, deg);
peak_max = std::max(peak_max, (double)smp->yaw_deg); peak_max = std::max(peak_max, deg);
} }
std::this_thread::sleep_for(20ms); std::this_thread::sleep_for(20ms);
} }

View File

@ -7,8 +7,11 @@
#include <atomic> #include <atomic>
#include <chrono> #include <chrono>
#include <functional> #include <functional>
#include <cstdio>
#include <mutex> #include <mutex>
#include <string>
#include <thread> #include <thread>
#include <vector>
#include <boost/asio.hpp> #include <boost/asio.hpp>
#include <termios.h> #include <termios.h>
@ -37,6 +40,9 @@ struct MtiImuSource::Impl {
ImuDeviceConfig cfg; // device config read back at startup ImuDeviceConfig cfg; // device config read back at startup
bool have_cfg = false; 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; MtiFramer framer;
unsigned bad_len_warned = 0; unsigned bad_len_warned = 0;
@ -82,6 +88,11 @@ struct MtiImuSource::Impl {
if (ec) LOG_WARN << "MTi config write failed: " << ec.message(); if (ec) LOG_WARN << "MTi config write failed: " << ec.message();
}; };
write(msgGoToConfig()); std::this_thread::sleep_for(60ms); 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) { if (set_profile >= 0) {
write(msgSetFilterProfile(static_cast<uint16_t>(set_profile))); write(msgSetFilterProfile(static_cast<uint16_t>(set_profile)));
std::this_thread::sleep_for(60ms); std::this_thread::sleep_for(60ms);
@ -121,14 +132,25 @@ struct MtiImuSource::Impl {
void queryConfig(WriteFn&& write) { void queryConfig(WriteFn&& write) {
using namespace std::chrono_literals; using namespace std::chrono_literals;
ImuDeviceConfig local; ImuDeviceConfig local;
std::vector<std::string> problems;
MtiFramer cf([&](uint8_t mid, const uint8_t* d, std::size_t n) { 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); applyImuConfigAck(local, mid, d, n);
}); });
// Drop any buffered pre-config stream (e.g. MTData from before GoToConfig // Deliberately NOT flushing here: configure() already dropped the buffered
// took effect) so the read window below is dominated by the query acks. // pre-config stream on entering Config state, and anything waiting now is
::tcflush(serial.native_handle(), TCIFLUSH); // an ack or an Error from the Sets that ran in between - which is exactly
// what this window is for.
write(msgReqProductCode()); write(msgReqProductCode());
write(msgReqDID()); write(msgReqDID());
write(msgReqFWRev()); write(msgReqFWRev());
@ -160,17 +182,42 @@ struct MtiImuSource::Impl {
finalizeImuConfig(local); finalizeImuConfig(local);
if (local.valid) { if (local.valid) {
std::lock_guard<std::mutex> lock(mutex); // The raw settings word is printed because it is the ground truth for
cfg = local; // what the device is actually doing - and, while
have_cfg = true; // 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) LOG_INFO << "MTi config: " << (local.product_code.empty() ? "?" : local.product_code)
<< " fw " << (local.firmware.empty() ? "?" : local.firmware) << " fw " << (local.firmware.empty() ? "?" : local.firmware)
<< " scenario " << int(local.scenario_type) << " scenario " << int(local.scenario_type)
<< (local.scenario_label.empty() ? "" : " (" + local.scenario_label + ")") << (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 { } else {
LOG_WARN << "MTi: no configuration acks received (device may not answer Req* in this firmware)"; 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; 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-read the device configuration: briefly pause the streaming io_thread,
// re-run the Config-state handshake (which re-queries product/firmware/output/ // re-run the Config-state handshake (which re-queries product/firmware/output/
// XKF profile and re-applies our output settings), then resume streaming. Mirrors // XKF profile and re-applies our output settings), then resume streaming. Mirrors

View File

@ -10,6 +10,7 @@
#include <algorithm> #include <algorithm>
#include <array> #include <array>
#include <chrono> #include <chrono>
#include <cstdlib>
#include <cstdio> #include <cstdio>
#include <map> #include <map>
#include <sstream> #include <sstream>
@ -129,11 +130,107 @@ Element cameraDetailTitle(const ScrollState& st) {
return overlayTitle("CAMERA SYSTEM", "c/Esc:close", st); return overlayTitle("CAMERA SYSTEM", "c/Esc:close", st);
} }
// Fixed-decimal number. Bare std::to_string on a double gives six decimals, so
// an image rate of 0.2 rendered as "0.200000 img/s".
std::string num(double v, int decimals) {
char b[32];
std::snprintf(b, sizeof(b), "%.*f", decimals, v);
return b;
}
// "label value" with the label in a fixed-width gutter, so the value columns
// line up across every panel instead of being hand-spaced per panel.
constexpr int kLabelW = 9; // compact dashboard panels
Element labelRow(const std::string& label, Element value, int width = kLabelW) {
return hbox({text(label) | dim | size(WIDTH, EQUAL, width), std::move(value) | flex});
}
// "key" + label pair for the nano-style bottom bar. // "key" + label pair for the nano-style bottom bar.
Element keyHint(const std::string& key, const std::string& label) { Element keyHint(const std::string& key, const std::string& label) {
return hbox({text(" " + key + " ") | inverted, text(" " + label + " ")}); return hbox({text(" " + key + " ") | inverted, text(" " + label + " ")});
} }
// Which overlay owns the body. Declared here so keyBar() can switch on it; the
// render loop keeps the authoritative value.
enum class Overlay { None, Help, Gimbal, Sensors, Cameras, Conn };
// The bottom bar, contextual: the overview advertises everyday operations and
// the overlay keys, and each overlay advertises what its own body responds to.
//
// This changes only what is ADVERTISED. Every binding still works everywhere -
// 's' starts a scan from inside the gimbal view exactly as before - so the bar
// is a hint about what is useful here, not a restriction.
//
// flexbox (Wrap::Wrap by default) rather than hbox: the overview set is wider
// than an 80-column terminal, and hbox silently truncated it mid-word ("q Qui").
Element keyBar(Overlay overlay) {
static const char* kArrowsV = "\xE2\x86\x91\xE2\x86\x93";
static const char* kArrows4 = "\xE2\x86\x90\xE2\x86\x92\xE2\x86\x91\xE2\x86\x93";
// Start/stop are wanted from every view, so they lead everywhere.
Elements e{keyHint("s", "Start"), keyHint("x", "Stop")};
switch (overlay) {
case Overlay::None:
e.push_back(keyHint("h", "Home"));
e.push_back(keyHint("r", "Refresh"));
e.push_back(keyHint("g i c m", "Views"));
e.push_back(keyHint("p", "Pause"));
e.push_back(keyHint(kArrows4, "Nudge"));
e.push_back(keyHint("PgUp/Dn", "Log"));
break;
case Overlay::Help:
e.push_back(keyHint(kArrowsV, "Section"));
e.push_back(keyHint("PgUp/Dn", "Scroll"));
break;
case Overlay::Gimbal:
e.push_back(keyHint("d", "Dump"));
e.push_back(keyHint(kArrowsV, "Scroll"));
e.push_back(keyHint("PgUp/Dn", "Page"));
e.push_back(keyHint("Home/End", "Ends"));
break;
case Overlay::Sensors:
e.push_back(keyHint("r", "Refresh"));
e.push_back(keyHint(kArrowsV, "Scroll"));
e.push_back(keyHint("PgUp/Dn", "Page"));
e.push_back(keyHint("Home/End", "Ends"));
break;
case Overlay::Cameras:
case Overlay::Conn:
e.push_back(keyHint(kArrowsV, "Scroll"));
e.push_back(keyHint("PgUp/Dn", "Page"));
e.push_back(keyHint("Home/End", "Ends"));
break;
}
if (overlay != Overlay::None) e.push_back(keyHint("Esc", "Close"));
e.push_back(keyHint(":", "Cmd"));
e.push_back(keyHint("?", "Help"));
e.push_back(keyHint("q", "Quit"));
return flexbox(std::move(e));
}
// The "are you sure?" block that replaces the key bar when q is pressed.
//
// q and Ctrl-b d are one keystroke apart and mean opposite things when the tower
// is operated over ssh, and nothing on screen said so at the moment of choosing
// (scripts/fgc resorts to warning about it in its tmux status line). This asks.
Element exitPrompt(bool can_background) {
Element bg = can_background
? hbox({text(" b ") | bold,
text("Background \xE2\x80\x94 detach, leave it running "
"(same as Ctrl-b d)")})
: hbox({text(" b ") | dim,
text("Background \xE2\x80\x94 unavailable, needs scripts/fgc") | dim});
return vbox({
text(" Exit the dashboard? ") | bold | color(Color::Black) | bgcolor(Color::Yellow),
hbox({text(" q ") | bold,
text("Quit \xE2\x80\x94 stops capture, closes the camera, ends the program")}),
bg,
hbox({text(" Esc ") | bold, text("Cancel") | dim}),
}) | border | color(Color::Yellow);
}
Element badge(const std::string& s, bool on, Color on_color) { Element badge(const std::string& s, bool on, Color on_color) {
auto e = text(" " + s + " "); auto e = text(" " + s + " ");
return on ? (e | color(on_color) | bold) : (e | dim); return on ? (e | color(on_color) | bold) : (e | dim);
@ -150,12 +247,12 @@ Element axisRow(const AxisView& a) {
text(" x=" + std::to_string(a.xactual)) | dim, text(" x=" + std::to_string(a.xactual)) | dim,
text(" enc=" + std::to_string(a.xenc)) | dim}), text(" enc=" + std::to_string(a.xenc)) | dim}),
hbox({text(" "), hbox({text(" "),
badge("STILL", a.standstill, Color::GrayLight), badge("STILL", a.standstill, Color::Cyan),
badge("MOVE", a.moving, Color::Cyan), badge("MOVE", a.moving, Color::Cyan),
badge("STALL", a.stall, Color::Red), badge("STALL", a.deviation, Color::Red),
badge("OT", a.overtemp, Color::Red), badge("OT", a.overtemp, Color::Red),
badge("L", a.endstop_l, Color::Yellow), badge("L", a.endstop_l, Color::Cyan),
badge("R", a.endstop_r, Color::Yellow)}), badge("R", a.endstop_r, Color::Cyan)}),
}); });
} }
@ -174,12 +271,12 @@ Element sensorsPanel(const SensorsView& s) {
// One labelled subsection per physical sensor: a title + status header, then // One labelled subsection per physical sensor: a title + status header, then
// its readings. Keeps MTi (orientation/device temp) and the ambient env // its readings. Keeps MTi (orientation/device temp) and the ambient env
// sensor (SHT41) clearly separated. // sensor (SHT41) clearly separated.
auto group = [](const SensorGroup& g, const std::string& hint) { auto group = [](const SensorGroup& g, Element trailing) {
std::vector<Element> rows; std::vector<Element> rows;
Element status = g.present ? (text(g.status) | color(Color::Green)) Element status = g.present ? (text(g.status) | color(Color::Green))
: (text(g.status) | dim); : (text(g.status) | dim);
rows.push_back(hbox({text(g.title) | bold, text(" "), status, filler(), rows.push_back(hbox({text(g.title) | bold, text(" "), status, filler(),
text(hint) | dim})); std::move(trailing)}));
for (const auto& f : g.fields) { for (const auto& f : g.fields) {
Element val = text(f.value + (f.unit.empty() ? "" : " " + f.unit)); Element val = text(f.value + (f.unit.empty() ? "" : " " + f.unit));
val = f.present ? (val | bold) : (val | dim); val = f.present ? (val | bold) : (val | dim);
@ -187,10 +284,15 @@ Element sensorsPanel(const SensorsView& s) {
} }
return vbox(std::move(rows)); 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, return panel("[i] SENSORS", Color::Magenta,
vbox({group(s.imu, "i to expand"), vbox({group(s.imu, std::move(imu_badge)), separator(),
separator(), group(s.env, text(""))}));
group(s.env, "")}));
} }
Element cameraPanel(const CaptureView& c) { Element cameraPanel(const CaptureView& c) {
@ -201,43 +303,44 @@ Element cameraPanel(const CaptureView& c) {
Element active = c.active ? (text(" CAPTURING ") | color(Color::Green) | bold) Element active = c.active ? (text(" CAPTURING ") | color(Color::Green) | bold)
: (text(" idle ") | dim); : (text(" idle ") | dim);
std::vector<Element> rows = { std::vector<Element> rows = {
hbox({text("cameras ") | dim, text(std::to_string(c.camera_count) + " "), labelRow("cameras", hbox({text(std::to_string(c.camera_count) + " "),
text(labels) | dim}), text(labels) | dim})),
hbox({text("capture ") | dim, active, filler(), labelRow("capture", hbox({active, filler(),
text(std::to_string(c.image_rate) + " img/s") | dim}), text(num(c.image_rate, 2) + " img/s") | dim})),
}; };
if (!c.scan_error.empty()) if (!c.scan_error.empty())
rows.push_back(hbox({text("scan ") | dim, rows.push_back(labelRow("scan", hbox({text(" GRID LOAD FAILED ") | color(Color::Red) | bold,
text(" GRID LOAD FAILED ") | color(Color::Red) | bold, text(" c for details") | dim})));
text(" c for details") | dim})); {
rows.push_back(hbox({text("mode ") | dim, // One line per camera: "<label> <profile>", so which camera is on which
text(c.config.mode.empty() ? "—" : c.config.mode) | color(Color::Cyan)})); // profile is visible without opening the expanded view.
std::string on;
for (const auto& v : c.config.cameras) {
if (!v.enabled) continue;
on += (on.empty() ? "" : " ") + v.label + " " + v.profile;
}
rows.push_back(labelRow("profile", text(on.empty() ? "\xE2\x80\x94" : on) |
color(Color::Cyan)));
}
if (c.has_last) { if (c.has_last) {
long long now = std::chrono::duration_cast<std::chrono::milliseconds>( long long now = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()).count(); std::chrono::system_clock::now().time_since_epoch()).count();
rows.push_back(hbox({text("last ") | dim, rows.push_back(labelRow("last", hbox({
text(c.last_label + " "), text(c.last_label + " "),
text(formatDegrees(c.last_heading_deg) + " / " + text(formatDegrees(c.last_heading_deg) + " / " +
formatDegrees(c.last_pitch_deg) + " ") | dim, formatDegrees(c.last_pitch_deg) + " ") | dim,
text(formatTimeAgo(now, c.last_ts_ms)) | dim})); text(formatTimeAgo(now, c.last_ts_ms)) | dim})));
} else { } else {
rows.push_back(hbox({text("last ") | dim, text("—") | dim})); rows.push_back(labelRow("last", text("\xE2\x80\x94") | dim));
} }
return panel("CAMERA", Color::Blue, vbox(std::move(rows))); return panel("[c] CAMERA", Color::Blue, vbox(std::move(rows)));
} }
// Expanded camera view ('c'): the full camera system — identity, live sensor // Expanded camera view ('c'): the full camera system — identity, live sensor
// telemetry, imaging config, acquisition health, encoding/output — with the // telemetry, imaging config, acquisition health, encoding/output — with the
// auto-sweep scan grid below. The whole body scrolls (yframe) if it overflows. // auto-sweep scan grid below. The whole body scrolls (yframe) if it overflows.
Element cameraDetailPanel(const CaptureView& c, ScrollState& st) { Element cameraDetailPanel(const CaptureView& c, ScrollState& st) {
auto num = [](double v, int dec) { auto row = [](const std::string& k, Element v) { return labelRow(k, std::move(v), 14); };
char b[32];
std::snprintf(b, sizeof(b), "%.*f", dec, v);
return std::string(b);
};
auto row = [](const std::string& k, Element v) {
return hbox({text(k) | dim | size(WIDTH, EQUAL, 14), std::move(v)});
};
std::vector<Element> rows; std::vector<Element> rows;
@ -247,7 +350,6 @@ Element cameraDetailPanel(const CaptureView& c, ScrollState& st) {
rows.push_back(hbox({text("capture ") | dim, active, filler(), rows.push_back(hbox({text("capture ") | dim, active, filler(),
text(num(c.image_rate, 3) + " img/s") | dim})); text(num(c.image_rate, 3) + " img/s") | dim}));
rows.push_back(row("images", text(std::to_string(c.images_saved) + " saved this session"))); rows.push_back(row("images", text(std::to_string(c.images_saved) + " saved this session")));
rows.push_back(row("output", text(c.config.output_dir.empty() ? "—" : c.config.output_dir) | dim));
// --- Devices: identity + live telemetry --- // --- Devices: identity + live telemetry ---
rows.push_back(separator()); rows.push_back(separator());
@ -276,25 +378,37 @@ Element cameraDetailPanel(const CaptureView& c, ScrollState& st) {
}))); })));
} }
// --- Imaging config --- // --- Cameras: one block each, everything explicit ---
const CameraConfigView& cf = c.config; const CameraConfigView& cf = c.config;
rows.push_back(separator()); rows.push_back(separator());
rows.push_back(text("CAMERA MODE") | bold | color(Color::Blue)); rows.push_back(hbox({text("CAMERAS") | bold | color(Color::Blue),
rows.push_back(row("driver", text(cf.mock ? "MOCK (simulated)" : "real camera (Vimba X)"))); text(cf.mock ? " MOCK (simulated)" : " Vimba X") | dim}));
rows.push_back(row("mode", hbox({text(cf.mode.empty() ? "—" : cf.mode) | color(Color::Cyan) | bold, if (cf.cameras.empty()) rows.push_back(text("(no cameras configured)") | dim);
text(" " + cf.mode_desc) | dim}))); for (const auto& v : cf.cameras) {
rows.push_back(row("settle", text(std::to_string(cf.settle_wait_ms) + rows.push_back(hbox({
" ms after standstill before keeping a frame"))); text(v.label.empty() ? v.name : v.label) | bold,
text(" [" + v.name + "]") | dim,
text(" " + v.id) | dim,
filler(),
v.enabled ? (text("enabled") | color(Color::Green)) : (text("disabled") | dim),
}));
rows.push_back(row(" profile", hbox({
text(v.profile.empty() ? "\xE2\x80\x94" : v.profile) | color(Color::Cyan) | bold,
text(" " + v.profile_xml) | dim})));
{ {
std::string others; std::string others;
for (const auto& m : cf.modes_available) others += (others.empty() ? "" : ", ") + m; for (const auto& p : v.profiles_available) others += (others.empty() ? "" : ", ") + p;
rows.push_back(row("available", text(others.empty() ? "—" : others) | dim)); rows.push_back(row(" available", text(others.empty() ? "\xE2\x80\x94" : others) | dim));
} }
rows.push_back(row("encoding", text("JPEG XL distance " + num(cf.jxl_distance, 2) + rows.push_back(row(" settle", text(std::to_string(v.settle_wait_ms) +
" effort " + std::to_string(cf.jxl_effort)))); " ms after standstill before keeping a frame")));
rows.push_back(row("rotation", text(cf.rotation_deg == 0 rows.push_back(row(" encoding", text("JPEG XL distance " + num(v.jxl_distance, 2) +
" effort " + std::to_string(v.jxl_effort))));
rows.push_back(row(" rotation", text(v.rotation_deg == 0
? std::string("none") ? std::string("none")
: std::to_string(cf.rotation_deg) + " deg CW"))); : std::to_string(v.rotation_deg) + " deg CW")));
}
rows.push_back(row("output", text(cf.output_dir.empty() ? "\xE2\x80\x94" : cf.output_dir) | dim));
// --- Last capture --- // --- Last capture ---
if (c.has_last) { if (c.has_last) {
@ -368,12 +482,12 @@ Element connPanel(const ConnView& v) {
: v.mqtt_connected ? (text(" connected ") | color(Color::Green) | bold) : v.mqtt_connected ? (text(" connected ") | color(Color::Green) | bold)
: (text(" disconnected ") | color(Color::Red) | bold); : (text(" disconnected ") | color(Color::Red) | bold);
std::string mode = v.control_code == 0 ? "0 auto-sweep" : "1 directed"; std::string mode = v.control_code == 0 ? "0 auto-sweep" : "1 directed";
return panel("CONNECTIVITY", Color::Green, vbox({ return panel("[m] CONNECTIVITY", Color::Green, vbox({
hbox({text("MQTT ") | dim, mqtt, filler(), text(v.broker) | dim}), labelRow("MQTT", hbox({mqtt, filler(), text(v.broker) | dim})),
hbox({text("tower ") | dim, text(v.tower)}), labelRow("tower", text(v.tower)),
hbox({text("control ") | dim, text(mode), labelRow("control", hbox({text(mode), filler(),
filler(), text("hdg " + v.target_heading) | dim}), text("hdg " + v.target_heading) | dim})),
hbox({text("status ") | dim, text(std::to_string(v.last_status_code))}), labelRow("status", text(std::to_string(v.last_status_code))),
})); }));
} }
@ -385,9 +499,7 @@ Element connPanel(const ConnView& v) {
// the link is down without queueing, retrying or logging, so a lost CamEvent - // the link is down without queueing, retrying or logging, so a lost CamEvent -
// and with it an image the ground station never fetches - is otherwise invisible. // and with it an image the ground station never fetches - is otherwise invisible.
Element connDetailPanel(const ConnView& v, ScrollState& st) { Element connDetailPanel(const ConnView& v, ScrollState& st) {
auto row = [](const std::string& k, Element val) { auto row = [](const std::string& k, Element val) { return labelRow(k, std::move(val), 12); };
return hbox({text(k) | dim | size(WIDTH, EQUAL, 12), std::move(val)});
};
const long long now = std::chrono::duration_cast<std::chrono::milliseconds>( const long long now = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()).count(); std::chrono::system_clock::now().time_since_epoch()).count();
@ -599,12 +711,12 @@ Element axisLiveDetail(const AxisView& a) {
kvRow("PWM", std::to_string(a.pwm)), kvRow("PWM", std::to_string(a.pwm)),
kvRow("DRV_STATUS", hex8(a.drv_status), Color::Cyan), kvRow("DRV_STATUS", hex8(a.drv_status), Color::Cyan),
hbox({text("flags") | dim | size(WIDTH, EQUAL, 12), hbox({text("flags") | dim | size(WIDTH, EQUAL, 12),
badge("STILL", a.standstill, Color::GrayLight), badge("STILL", a.standstill, Color::Cyan),
badge("MOVE", a.moving, Color::Cyan), badge("MOVE", a.moving, Color::Cyan),
badge("STALL", a.stall, Color::Red), badge("STALL", a.deviation, Color::Red),
badge("OT", a.overtemp, Color::Red), badge("OT", a.overtemp, Color::Red),
badge("L", a.endstop_l, Color::Yellow), badge("L", a.endstop_l, Color::Cyan),
badge("R", a.endstop_r, Color::Yellow)}), badge("R", a.endstop_r, Color::Cyan)}),
}; };
// Homing limits (endstops found at homing), degrees then raw counts. // Homing limits (endstops found at homing), degrees then raw counts.
if (a.has_limits) { if (a.has_limits) {
@ -871,7 +983,10 @@ Element sensorsDetailPanel(const ImuView& v, const EnvView& e, ScrollState& st)
// IMU CONFIG section (device configuration read back at startup). Shown // IMU CONFIG section (device configuration read back at startup). Shown
// whenever it is known, even if the live stream is offline. // whenever it is known, even if the live stream is offline.
const auto& c = v.config; 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, auto cfgRow = [](const std::string& k, const std::string& val,
Color vc = Color::Default) { Color vc = Color::Default) {
return hbox({text(k) | dim | size(WIDTH, EQUAL, 16), return hbox({text(k) | dim | size(WIDTH, EQUAL, 16),
@ -879,6 +994,34 @@ Element sensorsDetailPanel(const ImuView& v, const EnvView& e, ScrollState& st)
}; };
body.push_back(separator()); body.push_back(separator());
body.push_back(text("IMU CONFIG") | bold | color(Color::Magenta)); 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("Product", c.product_code));
body.push_back(cfgRow("Firmware", c.firmware)); body.push_back(cfgRow("Firmware", c.firmware));
body.push_back(cfgRow("Device ID", c.device_id)); body.push_back(cfgRow("Device ID", c.device_id));
@ -903,6 +1046,7 @@ Element sensorsDetailPanel(const ImuView& v, const EnvView& e, ScrollState& st)
} }
} }
} }
}
Element imu_status = v.present ? (text(" MTi live ") | color(Color::Green) | bold) Element imu_status = v.present ? (text(" MTi live ") | color(Color::Green) | bold)
: (text(" MTi offline ") | color(Color::Red) | bold); : (text(" MTi offline ") | color(Color::Red) | bold);
@ -976,11 +1120,11 @@ void TuiUi::refreshLoop() {
void TuiUi::uiLoop() { void TuiUi::uiLoop() {
std::string cmd_buffer; std::string cmd_buffer;
bool command_mode = false; bool command_mode = false;
enum class Overlay { None, Help, Gimbal, Sensors, Cameras, Conn };
Overlay overlay = Overlay::None; // which takeover panel owns the main area Overlay overlay = Overlay::None; // which takeover panel owns the main area
int help_sel = 0; int help_sel = 0;
bool gimbal_dump_requested = false; // auto-pull a dump the first time bool gimbal_dump_requested = false; // auto-pull a dump the first time
bool calib_prompt = false; // a yes/no calib-save question is showing bool calib_prompt = false; // a yes/no calib-save question is showing
bool exit_prompt = false; // 'q' asked; awaiting quit/background/cancel
// Per-pane scroll positions. UI-thread-only view state (the renderer lambda // Per-pane scroll positions. UI-thread-only view state (the renderer lambda
// and CatchEvent below both run on this thread), so unlike screen_/paused_ // and CatchEvent below both run on this thread), so unlike screen_/paused_
@ -1022,32 +1166,24 @@ void TuiUi::uiLoop() {
text(" up " + std::to_string(s.header.uptime_ms / 1000) + "s ") | dim, text(" up " + std::to_string(s.header.uptime_ms / 1000) + "s ") | dim,
}); });
Element top = hbox({gimbalPanel(s.gimbal), sensorsPanel(s.sensors)}); // Pin the LEFT panel of each row to exactly half the terminal and let the
Element middle = hbox({cameraPanel(s.capture), connPanel(s.conn)}); // right one flex into the remainder, so both rows split at the same
// column. Two `| flex` panels do not: FTXUI grows each from its own
// min_size (box_helper.cpp), so differing content moved the seam.
// size(WIDTH, EQUAL, n) also zeroes flex_grow (size.cpp), which is what
// makes the left panel hold its width.
ScreenInteractive* sc = screen_.load();
const int half = sc ? sc->dimx() / 2 : 0;
auto halve = [half](Element e) {
return half > 0 ? std::move(e) | size(WIDTH, EQUAL, half) : std::move(e);
};
Element top = hbox({halve(gimbalPanel(s.gimbal)), sensorsPanel(s.sensors)});
Element middle = hbox({halve(cameraPanel(s.capture)), connPanel(s.conn)});
const char* kArrowsV = "\xE2\x86\x91\xE2\x86\x93"; Element bottom = command_mode
Element bottom; ? hbox({text(" : ") | inverted, input->Render() | flex}) | border
if (command_mode) { : exit_prompt ? exitPrompt(backgroundAvailable(std::getenv("TMUX")))
bottom = hbox({text(" : ") | inverted, input->Render() | flex}) | border; : keyBar(overlay);
} else if (overlay != Overlay::None) {
// The nudge arrows are inert while an overlay owns the body, so the bar
// advertises what the keys actually do here instead.
bottom = hbox({
(overlay == Overlay::Help ? keyHint(kArrowsV, "Section")
: keyHint(kArrowsV, "Scroll")),
keyHint("PgUp/Dn", "Page"), keyHint("Home/End", "Ends"),
keyHint("p", "Pause"), keyHint(":", "Cmd"), filler(),
keyHint("Esc", "Close"), keyHint("q", "Quit"),
});
} else {
bottom = hbox({
keyHint("s", "Start"), keyHint("x", "Stop"), keyHint("h", "Home"),
keyHint("g", "Gimbal"), keyHint("i", "Sensors"), keyHint("c", "Scan"),
keyHint("m", "Comms"), keyHint("p", "Pause"), keyHint("r", "Refresh"),
keyHint("\xE2\x86\x90\xE2\x86\x92\xE2\x86\x91\xE2\x86\x93", "Nudge"),
keyHint(":", "Cmd"), keyHint("?", "Help"), filler(), keyHint("q", "Quit"),
});
}
// An open overlay takes over the whole body (the dashboard panels are // An open overlay takes over the whole body (the dashboard panels are
// hidden) so it has the full height — otherwise tall content like the // hidden) so it has the full height — otherwise tall content like the
@ -1113,6 +1249,31 @@ void TuiUi::uiLoop() {
} }
return false; // let the Input edit the buffer return false; // let the Input edit the buffer
} }
// The exit question owns the keyboard while it is up: every key is
// consumed, so a stray press cannot reach the dashboard behind it.
if (exit_prompt) {
if (e == Event::Escape) { exit_prompt = false; return true; }
if (e == Event::Return) { exit_prompt = false; if (sink_) sink_("exit"); return true; }
if (e.is_character()) {
const std::string& k = e.character();
if (k == "q" || k == "Q") {
exit_prompt = false;
if (sink_) sink_("exit");
} else if ((k == "b" || k == "B") && backgroundAvailable(std::getenv("TMUX"))) {
exit_prompt = false;
// Detach the client viewing this pane; the program keeps
// running and drawing into the pty, exactly as Ctrl-b d does.
// A failure must never read as "quit", so on error we say so
// and stay put.
if (std::system("tmux detach-client") != 0)
LOG_WARN << "could not detach from tmux; still running "
"(detach with Ctrl-b d, or press q to quit)";
}
}
return true; // swallow everything else
}
const int n = static_cast<int>(helpCatalog().size()); const int n = static_cast<int>(helpCatalog().size());
if (overlay == Overlay::Help) { // help pane navigation if (overlay == Overlay::Help) { // help pane navigation
// Selecting a different section replaces the body wholesale, so the // Selecting a different section replaces the body wholesale, so the
@ -1233,7 +1394,7 @@ void TuiUi::uiLoop() {
if (c == "j") { help_sel = (help_sel + 1) % n; help_scroll = {}; return true; } if (c == "j") { help_sel = (help_sel + 1) % n; help_scroll = {}; return true; }
if (c == "k") { help_sel = (help_sel - 1 + n) % n; help_scroll = {}; return true; } if (c == "k") { help_sel = (help_sel - 1 + n) % n; help_scroll = {}; return true; }
} }
if (c == "q") { if (sink_) sink_("exit"); return true; } if (c == "q") { exit_prompt = true; return true; }
if (c == "s") { if (sink_) sink_("start"); return true; } if (c == "s") { if (sink_) sink_("start"); return true; }
if (c == "x") { if (sink_) sink_("stop"); return true; } if (c == "x") { if (sink_) sink_("stop"); return true; }
if (c == "h") { if (sink_) sink_("gimbal home"); return true; } if (c == "h") { if (sink_) sink_("gimbal home"); return true; }

View File

@ -20,8 +20,8 @@ const char* axisStateLabel(AxisState s) {
UiColor axisStateColor(AxisState s) { UiColor axisStateColor(AxisState s) {
switch (s) { switch (s) {
case AxisState::Ready: return UiColor::Green; case AxisState::Ready:
case AxisState::Homing: return UiColor::Yellow; case AxisState::Homing: return UiColor::Cyan;
case AxisState::Error: return UiColor::Red; case AxisState::Error: return UiColor::Red;
case AxisState::Boot: case AxisState::Boot:
case AxisState::Reset: return UiColor::Dim; case AxisState::Reset: return UiColor::Dim;
@ -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) { std::string formatDegrees(double deg) {
char buf[32]; char buf[32];
std::snprintf(buf, sizeof(buf), "%.1f\xC2\xB0", deg); // UTF-8 degree sign std::snprintf(buf, sizeof(buf), "%.1f\xC2\xB0", deg); // UTF-8 degree sign
@ -48,6 +55,12 @@ std::string formatTimeAgo(long long now_ms, long long then_ms) {
return buf; return buf;
} }
bool backgroundAvailable(const char* tmux_env) {
// An empty TMUX is not "in tmux": the variable exists but names no server,
// so detach-client would have nothing to talk to.
return tmux_env != nullptr && *tmux_env != '\0';
}
std::string topicLeaf(const std::string& topic) { std::string topicLeaf(const std::string& topic) {
const auto slash = topic.rfind('/'); const auto slash = topic.rfind('/');
if (slash == std::string::npos) return topic; if (slash == std::string::npos) return topic;

View File

@ -22,7 +22,9 @@ add_executable(fgc_tests
test_paths.cpp test_paths.cpp
test_singleinstance.cpp test_singleinstance.cpp
test_config.cpp test_config.cpp
test_cameramode.cpp test_cameraprofile.cpp
test_camerasettingsxml.cpp
test_hash.cpp
test_channelhistory.cpp test_channelhistory.cpp
test_telemetry.cpp test_telemetry.cpp
test_command.cpp test_command.cpp
@ -33,6 +35,7 @@ add_executable(fgc_tests
test_uisnapshot.cpp test_uisnapshot.cpp
test_scroll.cpp test_scroll.cpp
test_mtiprotocol.cpp test_mtiprotocol.cpp
test_imumount.cpp
test_sht41.cpp test_sht41.cpp
test_dumpparser.cpp test_dumpparser.cpp
test_helptext.cpp test_helptext.cpp

View File

@ -1,168 +0,0 @@
#include <doctest/doctest.h>
#include "fgc/CameraMode.h"
#include <filesystem>
#include <fstream>
#include <string>
#include <unistd.h>
using namespace fgc;
namespace fs = std::filesystem;
namespace {
// Each test gets its own directory so a stale file can never leak between them.
fs::path scratchDir(const std::string& tag) {
fs::path d = fs::temp_directory_path() / ("fgc_modes_" + tag + "_" + std::to_string(::getpid()));
fs::remove_all(d);
fs::create_directories(d);
return d;
}
void write(const fs::path& p, const std::string& text) {
std::ofstream f(p);
f << text;
}
const char* kMinimal = R"(
[Mode]
description = A mode
settle_wait_ms = 1500
frame_timeout_ms = 3000
[Camera]
AcquisitionFrameRate = 10
ExposureAuto = Continuous
)";
} // namespace
TEST_CASE("loadCameraMode reads the header and names the mode after the file") {
fs::path d = scratchDir("header");
write(d / "standard.ini", kMinimal);
CameraMode m = loadCameraMode((d / "standard.ini").string());
CHECK(m.name == "standard");
CHECK(m.description == "A mode");
CHECK(m.settle_wait_ms == 1500);
CHECK(m.frame_timeout_ms == 3000);
REQUIRE(m.features.size() == 2);
fs::remove_all(d);
}
TEST_CASE("loadCameraMode defaults the [Mode] header when it is absent") {
fs::path d = scratchDir("defaults");
write(d / "bare.ini", "[Camera]\nGamma = 1.0\n");
CameraMode m = loadCameraMode((d / "bare.ini").string());
CHECK(m.description.empty());
CHECK(m.settle_wait_ms == 1000);
CHECK(m.frame_timeout_ms == 2000);
fs::remove_all(d);
}
TEST_CASE("loadCameraMode preserves file order and repeated keys") {
// GenICam selectors are stateful, so both properties are load-bearing: a map
// would sort BalanceRatio ahead of BalanceRatioSelector and collapse the two
// ratios into one.
fs::path d = scratchDir("order");
write(d / "wb.ini", R"(
[Camera]
BalanceRatioSelector = Red
BalanceRatio = 2.87
BalanceRatioSelector = Blue
BalanceRatio = 2.0
)");
CameraMode m = loadCameraMode((d / "wb.ini").string());
REQUIRE(m.features.size() == 4);
CHECK(m.features[0] == std::make_pair(std::string("BalanceRatioSelector"), std::string("Red")));
CHECK(m.features[1] == std::make_pair(std::string("BalanceRatio"), std::string("2.87")));
CHECK(m.features[2] == std::make_pair(std::string("BalanceRatioSelector"), std::string("Blue")));
CHECK(m.features[3] == std::make_pair(std::string("BalanceRatio"), std::string("2.0")));
fs::remove_all(d);
}
TEST_CASE("loadCameraMode strips inline comments from feature values") {
fs::path d = scratchDir("comments");
write(d / "c.ini", "[Camera]\nExposureAutoMax = 20000 ; blur budget\n");
CameraMode m = loadCameraMode((d / "c.ini").string());
REQUIRE(m.features.size() == 1);
CHECK(m.features[0].second == "20000");
fs::remove_all(d);
}
TEST_CASE("loadCameraMode rejects files a typo would otherwise make silently wrong") {
fs::path d = scratchDir("bad");
write(d / "unknown_key.ini", "[Mode]\nsettle_wait = 500\n[Camera]\nGamma = 1\n");
CHECK_THROWS(loadCameraMode((d / "unknown_key.ini").string()));
write(d / "unknown_section.ini", "[Modes]\ndescription = x\n[Camera]\nGamma = 1\n");
CHECK_THROWS(loadCameraMode((d / "unknown_section.ini").string()));
write(d / "bad_value.ini", "[Mode]\nsettle_wait_ms = soon\n[Camera]\nGamma = 1\n");
CHECK_THROWS(loadCameraMode((d / "bad_value.ini").string()));
write(d / "negative.ini", "[Mode]\nsettle_wait_ms = -1\n[Camera]\nGamma = 1\n");
CHECK_THROWS(loadCameraMode((d / "negative.ini").string()));
// A mode that sets nothing on the camera is a mistake, not an empty mode.
write(d / "no_features.ini", "[Mode]\ndescription = nothing\n");
CHECK_THROWS(loadCameraMode((d / "no_features.ini").string()));
CHECK_THROWS(loadCameraMode((d / "does_not_exist.ini").string()));
fs::remove_all(d);
}
TEST_CASE("loadCameraModes picks up every *.ini in the directory") {
fs::path d = scratchDir("dir");
write(d / "standard.ini", kMinimal);
write(d / "night.ini", kMinimal);
write(d / "notes.txt", "not a mode");
auto modes = loadCameraModes(d.string());
CHECK(modes.size() == 2);
CHECK(modes.count("standard") == 1);
CHECK(modes.count("night") == 1);
CHECK(modes.count("notes") == 0);
fs::remove_all(d);
}
TEST_CASE("loadCameraModes fails loudly on an empty or missing directory") {
fs::path d = scratchDir("empty");
CHECK_THROWS(loadCameraModes(d.string()));
CHECK_THROWS(loadCameraModes((d / "nope").string()));
// One bad file fails the whole load, so a mode nobody uses today still cannot
// rot unnoticed until the night someone switches to it.
write(d / "good.ini", kMinimal);
write(d / "bad.ini", "[Mode]\nnonsense = 1\n");
CHECK_THROWS(loadCameraModes(d.string()));
fs::remove_all(d);
}
TEST_CASE("the shipped RGB mode files parse and define both modes") {
// Guards the real config/camera_modes/rgb files, which the rig depends on.
for (const char* rel : {"config/camera_modes/rgb", "../config/camera_modes/rgb"}) {
if (!fs::is_directory(rel)) continue;
auto modes = loadCameraModes(rel);
REQUIRE(modes.count("standard") == 1);
REQUIRE(modes.count("night") == 1);
CHECK(modes.at("standard").settle_wait_ms == 1000);
CHECK(modes.at("night").settle_wait_ms == 2500);
CHECK(modes.at("standard").features.size() > 40);
return;
}
MESSAGE("shipped mode files not reachable from the test working directory; skipped");
}

View File

@ -0,0 +1,147 @@
#include <doctest/doctest.h>
#include "fgc/CameraProfile.h"
#include "fgc/Hash.h"
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <string>
#include <unistd.h>
using namespace fgc;
namespace fs = std::filesystem;
namespace {
fs::path scratch(const std::string& tag) {
fs::path d = fs::temp_directory_path() / ("fgc_prof_" + tag + "_" + std::to_string(::getpid()));
fs::remove_all(d);
fs::create_directories(d);
return d;
}
std::string xmlWith(const std::string& rate) {
return
"<ModuleSettings><CameraInfo Id=\"DEV_1\" Model=\"1800 U-2040c\">"
"<RemoteDevice>"
"<Feature Name=\"AcquisitionFrameRate\" Value=\"" + rate + "\" Type=\"Float\" />"
"<Feature Name=\"Gamma\" Value=\"1\" Type=\"Float\" />"
"</RemoteDevice></CameraInfo></ModuleSettings>";
}
void write(const fs::path& p, const std::string& text) {
std::ofstream f(p, std::ios::binary);
f << text;
}
} // namespace
TEST_CASE("loadCameraProfile generates the record on first load") {
fs::path d = scratch("first");
write(d / "standard.xml", xmlWith("10"));
ProfileLoadResult r;
CameraProfile p = loadCameraProfile(d.string(), "standard", r);
CHECK(p.name == "standard");
CHECK(p.xml_path == (d / "standard.xml").string());
CHECK(p.txt_path == (d / "standard.txt").string());
CHECK(fs::is_regular_file(p.txt_path));
CHECK(p.settings.entries.size() == 2);
// A first load has no baseline: reporting every setting as "new" would be
// thousands of lines and no information for a real export.
CHECK(r.regenerated);
CHECK(r.first_run);
CHECK(r.changes.empty());
// The record carries the hash of the XML it came from.
SettingsRecord rec = readSettingsRecord(p.txt_path);
CHECK(rec.hash_label == hashLabel(fnv1a64File(p.xml_path)));
fs::remove_all(d);
}
TEST_CASE("an unchanged XML is not regenerated and reports no changes") {
fs::path d = scratch("stable");
write(d / "standard.xml", xmlWith("10"));
ProfileLoadResult r;
loadCameraProfile(d.string(), "standard", r);
const auto stamp = fs::last_write_time(d / "standard.txt");
loadCameraProfile(d.string(), "standard", r);
CHECK_FALSE(r.regenerated);
CHECK(r.changes.empty());
// Untouched: a rewrite every startup would churn the file and its timestamp.
CHECK(fs::last_write_time(d / "standard.txt") == stamp);
fs::remove_all(d);
}
TEST_CASE("a changed XML regenerates the record and names what moved") {
fs::path d = scratch("changed");
write(d / "standard.xml", xmlWith("10"));
ProfileLoadResult r;
loadCameraProfile(d.string(), "standard", r);
write(d / "standard.xml", xmlWith("8"));
CameraProfile p = loadCameraProfile(d.string(), "standard", r);
CHECK(r.regenerated);
CHECK_FALSE(r.first_run); // there WAS a baseline, so this is a real change
REQUIRE(r.changes.size() == 1);
CHECK(r.changes[0].find("AcquisitionFrameRate") != std::string::npos);
CHECK(r.changes[0].find("10 -> 8") != std::string::npos);
// And the record now matches the new XML.
SettingsRecord rec = readSettingsRecord(p.txt_path);
CHECK(rec.hash_label == hashLabel(fnv1a64File(p.xml_path)));
fs::remove_all(d);
}
TEST_CASE("a missing XML throws - the record is never a fallback") {
fs::path d = scratch("missing");
// A record on its own must not make the profile loadable: it is a record, not
// a configuration source, and applying one would defeat the GUI validation
// that the XML exists to guarantee.
write(d / "standard.txt", "[Source]\nxml_hash = fnv1a64:0000000000000000\n[Settings]\nGamma = 1\n");
ProfileLoadResult r;
CHECK_THROWS(loadCameraProfile(d.string(), "standard", r));
fs::remove_all(d);
}
TEST_CASE("loadCameraProfiles picks up every *.xml and fails loudly when there are none") {
fs::path d = scratch("dir");
std::map<std::string, ProfileLoadResult> results;
CHECK_THROWS(loadCameraProfiles(d.string(), results)); // empty directory
CHECK_THROWS(loadCameraProfiles((d / "nope").string(), results)); // missing directory
write(d / "standard.xml", xmlWith("10"));
write(d / "night.xml", xmlWith("1"));
write(d / "notes.md", "not a profile");
auto profiles = loadCameraProfiles(d.string(), results);
CHECK(profiles.size() == 2);
CHECK(profiles.count("standard") == 1);
CHECK(profiles.count("night") == 1);
CHECK(profiles.count("notes") == 0);
// Both were written for the first time, and neither reports a change.
REQUIRE(results.size() == 2);
for (const auto& [name, r] : results) {
CHECK(r.first_run);
CHECK(r.changes.empty());
}
// One bad profile fails the whole load, so a profile nobody uses today cannot
// rot unnoticed until the night someone switches to it.
write(d / "broken.xml", "<ModuleSettings></ModuleSettings>");
CHECK_THROWS(loadCameraProfiles(d.string(), results));
fs::remove_all(d);
}

View File

@ -0,0 +1,207 @@
#include <doctest/doctest.h>
#include "fgc/CameraSettingsXml.h"
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <string>
#include <unistd.h>
using namespace fgc;
namespace fs = std::filesystem;
namespace {
fs::path scratch(const std::string& tag) {
fs::path d = fs::temp_directory_path() / ("fgc_xml_" + tag + "_" + std::to_string(::getpid()));
fs::remove_all(d);
fs::create_directories(d);
return d;
}
void write(const fs::path& p, const std::string& text) {
std::ofstream f(p, std::ios::binary);
f << text;
}
// A miniature Viewer export: the two blocks that matter, plus a SelectorGroup.
const char* kXml = R"(<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
<ModuleSettings>
<CameraInfo Id="DEV_1AB22C02B9F4" Model="1800 U-2040c">
<LocalDevice>
<Feature Name="DeviceDisplayName" Value="Should Not Appear" Type="String" />
</LocalDevice>
<RemoteDevice>
<Feature Name="AcquisitionFrameRate" Value="10" Type="Float" />
<SelectorGroup Name="BalanceRatioSelector" Type="Enum" Value="Red">
<Feature Name="BalanceRatio" Value="2.87" Type="Float" />
</SelectorGroup>
<SelectorGroup Name="BalanceRatioSelector" Type="Enum" Value="Blue">
<Feature Name="BalanceRatio" Value="2.00" Type="Float" />
</SelectorGroup>
<Feature Name="Gamma" Value="1" Type="Float" />
</RemoteDevice>
</CameraInfo>
</ModuleSettings>
)";
const SettingEntry* find(const CameraSettings& s, const std::string& name,
const std::string& selector) {
for (const auto& e : s.entries)
if (e.name == name && e.selector == selector) return &e;
return nullptr;
}
} // namespace
TEST_CASE("parseCameraSettingsXml reads RemoteDevice only, in order") {
fs::path d = scratch("parse");
write(d / "p.xml", kXml);
CameraSettings s = parseCameraSettingsXml((d / "p.xml").string());
CHECK(s.model == "1800 U-2040c");
CHECK(s.device_id == "DEV_1AB22C02B9F4");
// LocalDevice describes the host's transport layer and must never be replayed.
CHECK(find(s, "DeviceDisplayName", "") == nullptr);
REQUIRE(s.entries.size() == 4);
CHECK(s.entries[0].name == "AcquisitionFrameRate");
CHECK(s.entries[3].name == "Gamma");
// A repeated feature name stays distinguishable by its selector, or the two
// white-balance ratios would collapse into one.
const auto* red = find(s, "BalanceRatio", "BalanceRatioSelector=Red");
const auto* blue = find(s, "BalanceRatio", "BalanceRatioSelector=Blue");
REQUIRE(red);
REQUIRE(blue);
CHECK(red->value == "2.87");
CHECK(blue->value == "2.00");
fs::remove_all(d);
}
TEST_CASE("parseCameraSettingsXml rejects anything that is not a Viewer export") {
fs::path d = scratch("bad");
write(d / "empty.xml", "<ModuleSettings></ModuleSettings>");
CHECK_THROWS(parseCameraSettingsXml((d / "empty.xml").string())); // no RemoteDevice
write(d / "trunc.xml", "<ModuleSettings><RemoteDevice><Feature Name=\"A\" Value=\"1\" />");
CHECK_THROWS(parseCameraSettingsXml((d / "trunc.xml").string())); // unterminated
write(d / "nofeat.xml", "<RemoteDevice></RemoteDevice>");
CHECK_THROWS(parseCameraSettingsXml((d / "nofeat.xml").string())); // no features
CHECK_THROWS(parseCameraSettingsXml((d / "missing.xml").string()));
fs::remove_all(d);
}
TEST_CASE("the record round-trips: render then read back gives the same settings") {
fs::path d = scratch("record");
write(d / "p.xml", kXml);
CameraSettings s = parseCameraSettingsXml((d / "p.xml").string());
const std::string text = renderSettingsRecord(s, "p.xml", "fnv1a64:0123456789abcdef");
// The header has to be unmissable: this file configures nothing.
CHECK(text.find("DOES NOT CONFIGURE THE CAMERA") != std::string::npos);
CHECK(text.find("VimbaX Viewer") != std::string::npos);
write(d / "p.txt", text);
SettingsRecord rec = readSettingsRecord((d / "p.txt").string());
CHECK(rec.hash_label == "fnv1a64:0123456789abcdef");
REQUIRE(rec.entries.size() == s.entries.size());
for (size_t i = 0; i < s.entries.size(); ++i) {
CHECK(rec.entries[i].name == s.entries[i].name);
CHECK(rec.entries[i].value == s.entries[i].value);
CHECK(rec.entries[i].selector == s.entries[i].selector);
}
fs::remove_all(d);
}
TEST_CASE("readSettingsRecord treats a missing file as no record") {
SettingsRecord rec = readSettingsRecord("/nonexistent/nope.txt");
CHECK(rec.hash_label.empty());
CHECK(rec.entries.empty());
}
TEST_CASE("diffSettings names what changed, not just that something did") {
std::vector<SettingEntry> before = {
{"AcquisitionFrameRate", "10", ""},
{"ExposureAutoMax", "20000", ""},
{"Sharpness", "0", ""},
{"BalanceRatio", "2.87", "BalanceRatioSelector=Red"},
};
std::vector<SettingEntry> after = {
{"AcquisitionFrameRate", "8", ""}, // changed
{"ExposureAutoMax", "20000", ""}, // same
{"Gamma", "1.2", ""}, // new
{"BalanceRatio", "3.10", "BalanceRatioSelector=Red"}, // changed, per selector
}; // Sharpness removed
auto d = diffSettings(before, after);
auto has = [&](const std::string& frag) {
return std::any_of(d.begin(), d.end(), [&](const std::string& l) {
return l.find(frag) != std::string::npos;
});
};
CHECK(has("AcquisitionFrameRate 10 -> 8"));
CHECK(has("+ Gamma"));
CHECK(has("- Sharpness"));
CHECK(has("BalanceRatio [BalanceRatioSelector=Red] 2.87 -> 3.10"));
// An unchanged setting must not be reported, or the diff becomes noise.
CHECK_FALSE(has("ExposureAutoMax"));
CHECK(d.size() == 4);
CHECK(diffSettings(before, before).empty());
}
TEST_CASE("diffSettings handles a name repeated under the SAME selector") {
// A real Viewer export nests SelectorGroups, so the same selector name AND
// value can appear many times (FileOperationSelector=Open, once per
// FileSelector group). Keying only on selector+name collapsed them and
// reported every duplicate after the first as new.
std::vector<SettingEntry> v = {
{"FileOperationStatus", "Success", "FileOperationSelector=Open"},
{"FileOperationStatus", "Success", "FileOperationSelector=Open"},
{"FileOperationStatus", "Success", "FileOperationSelector=Open"},
};
CHECK(diffSettings(v, v).empty());
// ...and a genuine change to one of them is still reported, exactly once.
auto changed = v;
changed[1].value = "Failure";
auto d = diffSettings(v, changed);
REQUIRE(d.size() == 1);
CHECK(d[0].find("Success -> Failure") != std::string::npos);
}
TEST_CASE("an unchanged real export produces an empty diff") {
for (const char* rel : {"../../camera_config_test.xml", "../camera_config_test.xml"}) {
if (!fs::is_regular_file(rel)) continue;
CameraSettings s = parseCameraSettingsXml(rel);
// The strongest possible statement: 800+ real settings, nothing changed,
// nothing reported. This is what caught the duplicate-key bug.
CHECK(diffSettings(s.entries, s.entries).empty());
return;
}
MESSAGE("camera_config_test.xml not reachable from the test working directory; skipped");
}
TEST_CASE("the real VimbaX export parses") {
// Guards against the scanner drifting from what the Viewer actually writes.
for (const char* rel : {"../../camera_config_test.xml", "../camera_config_test.xml"}) {
if (!fs::is_regular_file(rel)) continue;
CameraSettings s = parseCameraSettingsXml(rel);
CHECK(s.model == "1800 U-2040c");
CHECK(s.entries.size() > 100);
// RemoteDevice only: a LocalDevice-only feature must not have leaked in.
CHECK(find(s, "DeviceLocation", "") == nullptr);
return;
}
MESSAGE("camera_config_test.xml not reachable from the test working directory; skipped");
}

View File

@ -5,8 +5,11 @@
#include <cmath> #include <cmath>
#include <cstdio> #include <cstdio>
#include <cstdlib> #include <cstdlib>
#include <filesystem>
#include <fstream> #include <fstream>
#include <unistd.h>
using namespace fgc; using namespace fgc;
TEST_CASE("ConfigLoader maps and defaults typed values") { TEST_CASE("ConfigLoader maps and defaults typed values") {
@ -19,8 +22,6 @@ TEST_CASE("ConfigLoader maps and defaults typed values") {
{"General.debug", "1"}, {"General.debug", "1"},
{"Network.zkms_server_ip", "10.0.0.5"}, {"Network.zkms_server_ip", "10.0.0.5"},
{"Network.mqtt_user", "fileuser"}, {"Network.mqtt_user", "fileuser"},
{"Camera.id_Cam1", "DEV_1"},
{"Camera.id_Cam3", "DEV_3"},
{"Features.enable_mqtt", "false"}, {"Features.enable_mqtt", "false"},
}; };
AppConfig c = ConfigLoader::fromMap(kv); AppConfig c = ConfigLoader::fromMap(kv);
@ -30,8 +31,6 @@ TEST_CASE("ConfigLoader maps and defaults typed values") {
CHECK(c.general.debug == true); CHECK(c.general.debug == true);
CHECK(c.network.broker_ip == "10.0.0.5"); CHECK(c.network.broker_ip == "10.0.0.5");
CHECK(c.network.mqtt_user == "fileuser"); CHECK(c.network.mqtt_user == "fileuser");
CHECK(c.camera.ids.size() == 2); // blank id_Cam2 skipped
CHECK(c.camera.ids[1] == "DEV_3");
CHECK(c.features.enable_mqtt == false); CHECK(c.features.enable_mqtt == false);
CHECK(!c.paths.output_dir.empty()); // defaulted CHECK(!c.paths.output_dir.empty()); // defaulted
CHECK(std::abs(c.image_rate() - 1.0 / 3.0) < 1e-9); CHECK(std::abs(c.image_rate() - 1.0 / 3.0) < 1e-9);
@ -130,37 +129,104 @@ TEST_CASE("Network.mqtt_port is range-checked") {
CHECK_THROWS(ConfigLoader::fromMap({{"Network.mqtt_port", "1883x"}})); CHECK_THROWS(ConfigLoader::fromMap({{"Network.mqtt_port", "1883x"}}));
} }
TEST_CASE("Camera mode selection + encoding config: defaults and overrides") { TEST_CASE("Cameras are declared explicitly, one block per camera") {
AppConfig d = ConfigLoader::fromMap({}); // Identity is no longer positional: the block names the camera and `label`
CHECK(d.camera.mode == "standard"); // names its output, so nothing is implied by the order of the keys.
CHECK(d.camera.mode_dir.find("config/camera_modes/rgb") != std::string::npos);
CHECK(d.camera.jxl_distance == doctest::Approx(0.8));
CHECK(d.camera.jxl_effort == 4);
CHECK(d.camera.rotation_deg == 0);
AppConfig c = ConfigLoader::fromMap({ AppConfig c = ConfigLoader::fromMap({
{"Camera.mode_dir", "/tmp/modes"}, {"Camera.jxl_distance", "0.8"},
{"Camera.mode", "night"}, {"Camera.rgb.id", "DEV_1"},
{"Camera.jxl_distance", "0"}, {"Camera.rgb.label", "RGB"},
{"Camera.jxl_effort", "6"}, {"Camera.rgb.profile_dir", "/tmp/profiles/rgb"},
{"Camera.rotation_deg", "180"}, {"Camera.rgb.profile", "night"},
{"Camera.rgb.rotation_deg", "180"},
{"Camera.rgb.jxl_effort", "6"},
{"Camera.nir.id", "DEV_2"},
{"Camera.nir.profile_dir", "/tmp/profiles/nir"},
}); });
CHECK(c.camera.mode_dir == "/tmp/modes");
CHECK(c.camera.mode == "night"); REQUIRE(c.camera.cameras.size() == 2);
CHECK(c.camera.jxl_distance == doctest::Approx(0.0)); // lossless const CameraSpec* rgb = nullptr;
CHECK(c.camera.jxl_effort == 6); const CameraSpec* nir = nullptr;
CHECK(c.camera.rotation_deg == 180); for (const auto& s : c.camera.cameras) {
if (s.name == "rgb") rgb = &s;
if (s.name == "nir") nir = &s;
}
REQUIRE(rgb);
REQUIRE(nir);
CHECK(rgb->id == "DEV_1");
CHECK(rgb->label == "RGB");
CHECK(rgb->profile == "night");
CHECK(rgb->rotation_deg == 180);
CHECK(rgb->driver == "vimba");
CHECK(rgb->enabled);
// Per-camera override wins; anything not overridden falls back to [Camera].
CHECK(rgb->jxl_effort == 6);
CHECK(rgb->jxl_distance == doctest::Approx(0.8));
// label defaults to the block name rather than to a position in a list.
CHECK(nir->label == "nir");
CHECK(nir->profile == "standard");
} }
TEST_CASE("Camera config validates ranges") { TEST_CASE("Per-profile timing is per camera, and the scheduler takes the longest") {
// mode_dir / mode are not validated here: a blank value falls back to the AppConfig c = ConfigLoader::fromMap({
// default, and whether the mode file actually exists is checked at load time {"Camera.rgb.id", "DEV_1"},
// (loadCameraModes), which can say what IS available. {"Camera.rgb.profile_dir", "/tmp/p"},
CHECK_THROWS(ConfigLoader::fromMap({{"Camera.jxl_distance", "-1"}})); {"Camera.rgb.profile", "standard"},
CHECK_THROWS(ConfigLoader::fromMap({{"Camera.jxl_effort", "0"}})); // effort 1..9 {"Camera.rgb.standard.settle_wait_ms", "1000"},
CHECK_THROWS(ConfigLoader::fromMap({{"Camera.jxl_effort", "10"}})); {"Camera.rgb.standard.frame_timeout_ms", "2000"},
CHECK_THROWS(ConfigLoader::fromMap({{"Camera.rotation_deg", "45"}})); // 0/90/180/270 only {"Camera.nir.id", "DEV_2"},
CHECK_THROWS(ConfigLoader::fromMap({{"Camera.rotation_deg", "-90"}})); {"Camera.nir.profile_dir", "/tmp/p"},
{"Camera.nir.profile", "standard"},
{"Camera.nir.standard.settle_wait_ms", "2500"},
{"Camera.nir.standard.frame_timeout_ms", "5000"},
});
// The gimbal must stand still long enough for EVERY camera, so the longest
// wait wins - taking the first camera's would short-change the slower sensor.
CHECK(c.camera.maxSettleWaitMs() == 2500);
CHECK(c.camera.maxFrameTimeoutMs() == 5000);
// A profile with no timing block falls back to the defaults.
CHECK(c.camera.cameras[0].timingFor("night").settle_wait_ms == 1000);
}
TEST_CASE("A disabled camera is excluded from the active set and its timing") {
AppConfig c = ConfigLoader::fromMap({
{"Camera.rgb.id", "DEV_1"},
{"Camera.rgb.profile_dir", "/tmp/p"},
{"Camera.rgb.standard.settle_wait_ms", "1000"},
{"Camera.slow.enabled", "false"},
{"Camera.slow.profile_dir", "/tmp/p"},
{"Camera.slow.standard.settle_wait_ms", "9000"},
});
CHECK(c.camera.cameras.size() == 2);
CHECK(c.camera.active().size() == 1);
CHECK(c.camera.maxSettleWaitMs() == 1000); // the disabled camera does not slow the scan
}
TEST_CASE("Camera blocks reject what cannot work") {
auto base = [](std::map<std::string, std::string> extra) {
std::map<std::string, std::string> kv = {{"Camera.rgb.id", "DEV_1"},
{"Camera.rgb.profile_dir", "/tmp/p"}};
for (auto& [k, v] : extra) kv[k] = v;
return kv;
};
CHECK_NOTHROW(ConfigLoader::fromMap(base({})));
// No second backend exists yet; accepting the key silently would be worse.
CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.rgb.driver", "thermal"}})));
// An enabled camera with no id cannot be opened.
CHECK_THROWS(ConfigLoader::fromMap({{"Camera.rgb.profile_dir", "/tmp/p"}}));
// Nothing to load settings from.
CHECK_THROWS(ConfigLoader::fromMap({{"Camera.rgb.id", "DEV_1"}}));
CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.rgb.rotation_deg", "45"}})));
CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.rgb.jxl_distance", "-1"}})));
CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.rgb.jxl_effort", "10"}})));
// A typo'd timing key would otherwise be silently ignored.
CHECK_THROWS(ConfigLoader::fromMap(base({{"Camera.rgb.standard.settle_wait", "500"}})));
} }
TEST_CASE("updateIniSectionKeys replaces in-section keys, leaving others intact") { TEST_CASE("updateIniSectionKeys replaces in-section keys, leaving others intact") {
@ -231,3 +297,83 @@ TEST_CASE("saveMotorCalibration round-trips the full per-axis map") {
std::remove(path.c_str()); std::remove(path.c_str());
} }
TEST_CASE("loadFromFile anchors a relative profile_dir to the config file's directory") {
// The config is found through a search path that has nothing to do with the
// working directory, so a relative profile_dir must resolve next to the
// config.ini itself. Resolving it against the cwd is how a profile directory
// silently goes missing when the program is started from elsewhere.
namespace fs = std::filesystem;
const fs::path dir = fs::temp_directory_path() /
("fgc_cfgdir_" + std::to_string(::getpid()));
fs::remove_all(dir);
fs::create_directories(dir / "config" / "camera_profiles" / "rgb");
const fs::path path = dir / "config.ini";
{
std::ofstream f(path, std::ios::trunc);
f << "[Camera.rgb]\n"
"id = DEV_1\n"
"profile_dir = config/camera_profiles/rgb\n"
"[Camera.nir]\n"
"id = DEV_2\n"
"profile_dir = /opt/profiles/nir\n"
"[Camera.swir]\n"
"id = DEV_3\n"
"profile_dir = ~/profiles/swir\n";
}
AppConfig cfg = ConfigLoader::loadFromFile(path.string());
const CameraSpec* rgb = nullptr;
const CameraSpec* nir = nullptr;
const CameraSpec* swir = nullptr;
for (const auto& c : cfg.camera.cameras) {
if (c.name == "rgb") rgb = &c;
if (c.name == "nir") nir = &c;
if (c.name == "swir") swir = &c;
}
REQUIRE(rgb);
REQUIRE(nir);
REQUIRE(swir);
CHECK(rgb->profile_dir == (dir / "config" / "camera_profiles" / "rgb").string());
// An absolute path is the operator's explicit choice and is left alone.
CHECK(nir->profile_dir == "/opt/profiles/nir");
// ~ expands to the home directory, not to a literal "~" subdirectory.
CHECK(swir->profile_dir.rfind("~", 0) != 0);
CHECK(swir->profile_dir.find("/profiles/swir") != std::string::npos);
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"));
}

47
tests/test_hash.cpp Normal file
View File

@ -0,0 +1,47 @@
#include <doctest/doctest.h>
#include "fgc/Hash.h"
#include <filesystem>
#include <fstream>
#include <string>
#include <unistd.h>
using namespace fgc;
namespace fs = std::filesystem;
TEST_CASE("fnv1a64 matches the reference vectors") {
// The published FNV-1a 64 test vectors: if these drift, a stored digest from
// an older build would silently stop matching.
CHECK(fnv1a64("") == 0xcbf29ce484222325ULL);
CHECK(fnv1a64("a") == 0xaf63dc4c8601ec8cULL);
CHECK(fnv1a64("foobar") == 0x85944171f73967e8ULL);
}
TEST_CASE("fnv1a64 separates inputs that differ by one bit") {
CHECK(fnv1a64("standard") != fnv1a64("standarD"));
CHECK(fnv1a64("AcquisitionFrameRate = 10") != fnv1a64("AcquisitionFrameRate = 11"));
// Order matters: two files with the same bytes rearranged are different files.
CHECK(fnv1a64("ab") != fnv1a64("ba"));
}
TEST_CASE("hashLabel is prefixed and fixed width") {
// The prefix exists so a digest can never be misread if the algorithm changes.
CHECK(hashLabel(0) == "fnv1a64:0000000000000000");
CHECK(hashLabel(0xcbf29ce484222325ULL) == "fnv1a64:cbf29ce484222325");
CHECK(hashLabel(fnv1a64("")).size() == 8 + 16);
}
TEST_CASE("fnv1a64File hashes contents and throws on a missing file") {
const fs::path p = fs::temp_directory_path() /
("fgc_hash_" + std::to_string(::getpid()) + ".bin");
{ std::ofstream f(p, std::ios::binary); f << "foobar"; }
CHECK(fnv1a64File(p.string()) == fnv1a64("foobar"));
// A missing file must NOT read as an unchanged one - the caller uses this to
// decide whether to regenerate.
fs::remove(p);
CHECK_THROWS(fnv1a64File(p.string()));
}

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); 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) { auto frameWithYaw = [](float yaw) {
std::vector<uint8_t> d; std::vector<uint8_t> d;
for (int i = 0; i < 10; ++i) putBEFloat(d, 0.f); // temp + acc3 + gyr3 + mag3 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; 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()); 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()); auto fp = frameWithYaw(45.f); fr.feed(fp.data(), fp.size());
CHECK(got.yaw_deg == doctest::Approx(45.0f)); CHECK(got.yaw_deg == doctest::Approx(45.0f));
auto fb = frameWithYaw(-179.f); fr.feed(fb.data(), fb.size()); 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") { 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()); 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);
}

View File

@ -49,3 +49,36 @@ TEST_CASE("parseTelemetryLine tolerates trailing CR") {
REQUIRE(t.has_value()); REQUIRE(t.has_value());
CHECK(t->yaw.standstill == true); CHECK(t->yaw.standstill == true);
} }
TEST_CASE("parseAxisSegment reads the encoder-deviation flag 'd'") {
// 'd' is the TMC's hardware encoder-deviation latch and the only stall signal
// that can actually assert on this rig; 's' (StallGuard) is inert in
// StealthChop. They are separate fields so enabling StallGuard later stays
// meaningful.
auto a = parseAxisSegment("A,982,969,80084000,0,8,8,Sd");
REQUIRE(a.has_value());
CHECK(a->deviation == true);
CHECK(a->standstill == true);
CHECK(a->stall == false); // 'd' must not be mistaken for StallGuard
// Flag order carries no meaning, and unrelated flags must not be disturbed.
auto b = parseAxisSegment("A,982,969,80084000,0,8,8,dSL");
REQUIRE(b.has_value());
CHECK(b->deviation == true);
CHECK(b->standstill == true);
CHECK(b->endstop_l == true);
// Absent unless present, including when the flags field is missing entirely.
auto c = parseAxisSegment("A,982,969,80084000,0,8,8,S");
REQUIRE(c.has_value());
CHECK(c->deviation == false);
auto d = parseAxisSegment("A,982,969,80084000,0,8,8");
REQUIRE(d.has_value());
CHECK(d->deviation == false);
// Both can be set at once; they are independent signals.
auto e = parseAxisSegment("A,982,969,80084000,0,8,8,sd");
REQUIRE(e.has_value());
CHECK(e->stall == true);
CHECK(e->deviation == true);
}

View File

@ -14,9 +14,11 @@ TEST_CASE("axisStateLabel maps every state") {
CHECK(std::string(axisStateLabel(AxisState::Unknown)) == "----"); CHECK(std::string(axisStateLabel(AxisState::Unknown)) == "----");
} }
TEST_CASE("axisStateColor signals health") { TEST_CASE("axisStateColor uses one hue, reserving red for a fault") {
CHECK(axisStateColor(AxisState::Ready) == UiColor::Green); // Healthy states share the panel accent so nothing but a fault changes
CHECK(axisStateColor(AxisState::Homing) == UiColor::Yellow); // colour; Boot/Reset stay dim, which is brightness rather than a second hue.
CHECK(axisStateColor(AxisState::Ready) == UiColor::Cyan);
CHECK(axisStateColor(AxisState::Homing) == UiColor::Cyan);
CHECK(axisStateColor(AxisState::Error) == UiColor::Red); CHECK(axisStateColor(AxisState::Error) == UiColor::Red);
CHECK(axisStateColor(AxisState::Boot) == UiColor::Dim); CHECK(axisStateColor(AxisState::Boot) == UiColor::Dim);
CHECK(axisStateColor(AxisState::Reset) == UiColor::Dim); CHECK(axisStateColor(AxisState::Reset) == UiColor::Dim);
@ -118,3 +120,9 @@ TEST_CASE("formatClockMs renders wall-clock time, and never for 0") {
CHECK(t[8] == '.'); CHECK(t[8] == '.');
CHECK(t.substr(9) == "678"); // the ms field comes straight off the input CHECK(t.substr(9) == "678"); // the ms field comes straight off the input
} }
TEST_CASE("backgroundAvailable only accepts a set, non-empty TMUX") {
CHECK(backgroundAvailable("/tmp/tmux-1000/default,12345,0"));
CHECK_FALSE(backgroundAvailable(nullptr)); // not under tmux at all
CHECK_FALSE(backgroundAvailable("")); // set but names no server
}