Compare commits
6 Commits
bc4d2ec002
...
8eda0b4a36
| Author | SHA1 | Date |
|---|---|---|
|
|
8eda0b4a36 | |
|
|
5fce32aee7 | |
|
|
9023f79c26 | |
|
|
a5ad814260 | |
|
|
7db8d2092a | |
|
|
c887a6c9b8 |
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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>]`.
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
18
deploy.sh
18
deploy.sh
|
|
@ -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/"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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};
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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_;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 '/'.
|
||||||
|
|
|
||||||
10
scripts/fgc
10
scripts/fgc
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 -",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
331
src/ui/TuiUi.cpp
331
src/ui/TuiUi.cpp
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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");
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
|
@ -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"));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()));
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue