Camera settings now come from VimbaX profile XMLs; explicit per-camera config
This commit is contained in:
parent
7db8d2092a
commit
a5ad814260
|
|
@ -60,7 +60,9 @@ 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/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,66 @@
|
||||||
|
# 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
|
||||||
|
`config/camera_modes/rgb/{standard,night}.ini`.
|
||||||
|
|
||||||
|
`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
|
||||||
|
|
|
||||||
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/"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -133,29 +133,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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,16 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config
|
||||||
| `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` | `id_Cam1`..`id_Cam4` | string | — | Camera IDs (GigE IP or USB `DEV_...`); non-empty ones used in order |
|
||||||
| `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` | `jxl_distance` / `jxl_effort` | | | Defaults inherited by every camera block |
|
||||||
| `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>` | `id` | string | — | USB `DEV_` id or GigE IP. Required when enabled |
|
||||||
|
| `Camera.<name>` | `driver` | `vimba` | `vimba` | Backend. Only `vimba` is implemented |
|
||||||
|
| `Camera.<name>` | `label` | string | the block name | Output subdirectory and `CamEvent` `cam` field |
|
||||||
|
| `Camera.<name>` | `profile_dir` | string | — | Directory of profile XMLs; a profile named X is `<profile_dir>/X.xml`. A relative path resolves against the directory holding the `config.ini` in use |
|
||||||
|
| `Camera.<name>` | `profile` | string | `standard` | Profile to start in. Switch live with `set camera profile [<camera>] <name>` |
|
||||||
|
| `Camera.<name>` | `enabled` | bool | `true` | A disabled camera is not opened and does not affect the scan timing |
|
||||||
|
| `Camera.<name>` | `rotation_deg` | `0`/`90`/`180`/`270` | `0` | Clockwise rotation applied to that camera's saved images |
|
||||||
|
| `Camera.<name>.<profile>` | `settle_wait_ms` | int ≥ 0 | `1000` | Hold after standstill before keeping a frame |
|
||||||
|
| `Camera.<name>.<profile>` | `frame_timeout_ms` | int ≥ 0 | `2000` | Abandon the waypoint after this |
|
||||||
| `Camera` | `jxl_distance` | double ≥ 0 | `0.8` | JPEG XL distance (`0` = lossless, `~0.8` = near-lossless) |
|
| `Camera` | `jxl_distance` | double ≥ 0 | `0.8` | JPEG XL distance (`0` = lossless, `~0.8` = near-lossless) |
|
||||||
| `Camera` | `jxl_effort` | int 1..9 | `4` | JPEG XL effort (higher = slower/smaller) |
|
| `Camera` | `jxl_effort` | int 1..9 | `4` | JPEG XL effort (higher = slower/smaller) |
|
||||||
| `Camera` | `rotation_deg` | `0`/`90`/`180`/`270` | `0` | Clockwise rotation applied to saved images (`0` = as the sensor delivers them) |
|
| `Camera` | `rotation_deg` | `0`/`90`/`180`/`270` | `0` | Clockwise rotation applied to saved images (`0` = as the sensor delivers them) |
|
||||||
|
|
@ -139,56 +147,47 @@ 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
|
### `[Camera]` — cameras and profiles
|
||||||
|
|
||||||
Every on-camera setting lives in a **mode file**, one per mode, at `<mode_dir>/<name>.ini`. The mode's
|
Each camera gets its own `[Camera.<name>]` block. Nothing is implied by position:
|
||||||
name is the filename stem, so adding a mode is dropping in a file — there is no registry to keep in
|
the block names the camera, `label` names its output, and `driver` picks the
|
||||||
sync. `config.ini` only picks which one to start in; nothing about the sensor is configured in two
|
backend. A camera's *settings* are not here at all.
|
||||||
places.
|
|
||||||
|
|
||||||
Two modes ship:
|
**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.
|
||||||
|
|
||||||
| Mode | Rate | Intent |
|
That indirection exists because GenICam validity is **stateful**:
|
||||||
|---|---|---|
|
`AcquisitionFrameRate` is only writable once `AcquisitionFrameRateEnable` is set,
|
||||||
| `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 |
|
`Width`'s maximum depends on binning, and `IntensityAutoPrecedence` accepts
|
||||||
| `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 |
|
`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.
|
||||||
|
|
||||||
**How a mode is applied.** A `[Camera]` block is a list of Vimba X features written verbatim, in file
|
**To change a camera's settings:** open the Viewer, configure, save the settings
|
||||||
order, at start-up and on every switch. Names and enum values must be spelled exactly as VimbaX spells
|
XML over `<profile>.xml`, restart.
|
||||||
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 `.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:
|
||||||
|
|
||||||
- **Order.** GenICam selectors are stateful, so a selector must precede what it selects, and binning
|
```
|
||||||
must precede `Width`/`Height` because it changes their maxima.
|
[WARN ] camera 'rgb' profile 'standard': settings XML changed, regenerating standard.txt
|
||||||
- **Repeats.** Writing both white-balance ratios requires `BalanceRatioSelector = Red`,
|
[WARN ] AcquisitionFrameRate 3.399707 -> 10
|
||||||
`BalanceRatio = …`, `BalanceRatioSelector = Blue`, `BalanceRatio = …`.
|
[WARN ] ExposureAutoMax 50000 -> 15000
|
||||||
|
```
|
||||||
|
|
||||||
**Error handling.** A feature this camera does not expose logs `camera: no such feature '<name>'` and
|
**Timing is per camera and per profile**, in `[Camera.<name>.<profile>]`. These
|
||||||
is skipped — that warning is how you find a typo in a hand-edited file, so a clean start-up log is the
|
are not camera settings — they are how long the program waits around them. With
|
||||||
acceptance test for a mode. Structural mistakes are fatal instead: an unknown section, an unknown
|
several cameras the scan uses the **longest** `settle_wait_ms` across the enabled
|
||||||
`[Mode]` key, or a mode that sets no features. Every `*.ini` in `mode_dir` is parsed at start-up, so a
|
ones, since the gimbal must stand still for all of them.
|
||||||
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:
|
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
|
||||||
| Key | Default | Meaning |
|
it.
|
||||||
|---|---|---|
|
|
||||||
| `description` | — | Shown in the log and the TUI `c` view |
|
|
||||||
| `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
|
|
||||||
guarantees the kept frame was exposed with the gimbal at rest and after the auto-exposure has seen this
|
|
||||||
waypoint's light. It must comfortably exceed one frame period, which is why `night` at 1 Hz waits 2.5 s
|
|
||||||
where `standard` at 10 Hz waits 1 s.
|
|
||||||
|
|
||||||
**Frame rate is bounded by bandwidth, not the sensor.** A full-resolution RGB8 frame is 61 MB, which
|
|
||||||
caps out around 4 fps under the 250 MB/s `DeviceLinkThroughputLimit`. Both shipped modes therefore set
|
|
||||||
`BinningHorizontal/Vertical = 2` (2256×2256 ≈ 5 MP, 15.3 MB), so 10 Hz needs only ~153 MB/s. Raising
|
|
||||||
the limit to 450 is not a fix — it produced incomplete frames on this host.
|
|
||||||
|
|
||||||
### `[Env]` — SHT41 ambient temperature/humidity sensor
|
### `[Env]` — SHT41 ambient temperature/humidity sensor
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,10 +107,10 @@ 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
|
- **A profile cannot be reloaded without a restart.** `set camera profile <name>` re-applies a profile from
|
||||||
copy loaded at start-up, so editing a `.ini` while the program runs has no effect. Tuning on the rig
|
the copy loaded at start-up, so re-exporting the XML while the program runs has no effect. Tuning on the
|
||||||
therefore costs a restart per edit. Re-reading the file on switch would fix it, at the price of a failure
|
rig therefore costs a restart per export. Re-reading on switch would fix it, at the price of a failure
|
||||||
mode (a mid-session syntax error) that start-up loading currently rules out.
|
mode (a mid-session unreadable export) that start-up loading currently rules out.
|
||||||
- **Nothing checks that a mode actually took.** Features are written and warned about individually, but the
|
- **Nothing checks that a mode actually took.** Features are written and warned about individually, but the
|
||||||
camera is never read back to confirm the resulting state, and a mode whose every write failed still logs
|
camera is never read back to confirm the resulting state, and a mode whose every write failed still logs
|
||||||
as applied. A post-apply readback of a handful of key features (`AcquisitionFrameRate`, `Width`,
|
as applied. A post-apply readback of a handful of key features (`AcquisitionFrameRate`, `Width`,
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,9 @@ Per-file reference for the refactored tree, plus the shared data structures.
|
||||||
| [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) |
|
||||||
| [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/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) |
|
||||||
|
|
@ -48,7 +50,7 @@ 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) | always |
|
||||||
|
|
||||||
|
|
@ -75,8 +77,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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -42,25 +42,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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,13 +28,20 @@ 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
|
||||||
|
|
@ -51,8 +58,10 @@ public:
|
||||||
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
|
||||||
|
|
@ -65,7 +74,9 @@ public:
|
||||||
private:
|
private:
|
||||||
void run();
|
void run();
|
||||||
void process(const Frame& frame);
|
void process(const Frame& frame);
|
||||||
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_;
|
||||||
|
|
|
||||||
|
|
@ -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_;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,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;
|
||||||
|
|
@ -97,15 +98,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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,10 +65,13 @@ void ImagePipeline::run() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 Frame& frame) {
|
||||||
|
|
@ -77,11 +80,13 @@ void ImagePipeline::process(const Frame& frame) {
|
||||||
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 +98,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,8 +114,8 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -183,9 +183,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;
|
||||||
|
|
@ -296,49 +297,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -532,6 +597,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;
|
||||||
|
|
@ -663,23 +729,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 +1611,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 +1679,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,12 +1707,12 @@ 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())
|
||||||
|
pp.cameras.push_back({c->label, c->rotation_deg, c->jxl_distance, c->jxl_effort});
|
||||||
// Orientation supplier: convert the per-axis encoder counts to degrees.
|
// Orientation supplier: convert the per-axis encoder counts to degrees.
|
||||||
pipeline = std::make_unique<ImagePipeline>(
|
pipeline = std::make_unique<ImagePipeline>(
|
||||||
*channel,
|
*channel,
|
||||||
|
|
@ -1644,10 +1735,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();
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -108,6 +108,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 +217,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);
|
||||||
|
|
@ -317,11 +398,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.", {}},
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -247,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)}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -305,8 +305,17 @@ Element cameraPanel(const CaptureView& c) {
|
||||||
if (!c.scan_error.empty())
|
if (!c.scan_error.empty())
|
||||||
rows.push_back(labelRow("scan", hbox({text(" GRID LOAD FAILED ") | color(Color::Red) | bold,
|
rows.push_back(labelRow("scan", hbox({text(" GRID LOAD FAILED ") | color(Color::Red) | bold,
|
||||||
text(" c for details") | dim})));
|
text(" c for details") | dim})));
|
||||||
rows.push_back(labelRow("mode", text(c.config.mode.empty() ? "\xE2\x80\x94" : c.config.mode) |
|
{
|
||||||
|
// One line per camera: "<label> <profile>", so which camera is on which
|
||||||
|
// 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)));
|
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();
|
||||||
|
|
@ -335,7 +344,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());
|
||||||
|
|
@ -364,25 +372,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) {
|
||||||
|
|
@ -685,12 +705,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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
|
@ -19,8 +19,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 +28,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 +126,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") {
|
||||||
|
|
|
||||||
|
|
@ -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()));
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue