Camera settings now come from VimbaX profile XMLs; explicit per-camera config

This commit is contained in:
pgdalmeida 2026-09-04 14:12:05 +02:00
parent 7db8d2092a
commit a5ad814260
Signed by: pedro.almeida
GPG Key ID: D4A6C394DF13F1D7
46 changed files with 1727 additions and 925 deletions

View File

@ -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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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>]`.

View File

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

View File

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

View File

@ -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

View File

@ -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

View File

@ -29,7 +29,7 @@ doctest unit-test suite (`ctest`).
|---|-------|--------| |---|-------|--------|
| 13 | `[Motor]` degrees↔counts calibration | The `config.example.ini` values are **placeholders**. Calibrate `*_counts_per_deg` / `*_zero_count` against real `xenc` readings after homing on the rig. | | 13 | `[Motor]` degrees↔counts calibration | The `config.example.ini` values are **placeholders**. Calibrate `*_counts_per_deg` / `*_zero_count` against real `xenc` readings after homing on the rig. |
| 14 | Capture sweep on hardware | **Mechanically verified on the rig (2026-08-07).** A full `--init --start` run homed both axes and completed `MOVE → settle → trigger` for 15/15 waypoints with 0 dropped frames, 0 stalls and 0 acquisition failures. `kSettleTolCounts` (600) and the per-interval timing in [CaptureScheduler.cpp](../src/core/CaptureScheduler.cpp) have **not** been tuned against observed `ST` behaviour yet, and the settle tolerance has not been checked against a sharp image (see #16). | | 14 | Capture sweep on hardware | **Mechanically verified on the rig (2026-08-07).** A full `--init --start` run homed both axes and completed `MOVE → settle → trigger` for 15/15 waypoints with 0 dropped frames, 0 stalls and 0 acquisition failures. `kSettleTolCounts` (600) and the per-interval timing in [CaptureScheduler.cpp](../src/core/CaptureScheduler.cpp) have **not** been tuned against observed `ST` behaviour yet, and the settle tolerance has not been checked against a sharp image (see #16). |
| 15 | **Camera modes unverified on a lit scene** | The `standard`/`night` mode files were derived from a VimbaX export plus the bandwidth arithmetic, not measured against real light. Confirm on the rig that every feature applies without a `camera: no such feature` / `could not set` warning, that `standard` sustains 10 fps with `frames_dropped` at 0, and that the auto-exposure lands on a sensible `ExposureTime` outdoors. The only previous rig test ran with the lens covered. | | 15 | **Camera profiles unverified on a lit scene** | The `standard`/`night` profiles must be exported from the VimbaX Viewer (see `config/camera_profiles/rgb/README.md`) and have not been measured against real light. Confirm on the rig that `LoadSettings` succeeds, that `standard` sustains 10 fps with `frames_dropped` at 0, and that the auto-exposure lands on a sensible `ExposureTime` outdoors. The only previous rig test ran with the lens covered. |
| 16 | **`settle_wait_ms` untuned** | `standard` waits 1 s and `night` 2.5 s after standstill before keeping a frame. Both are reasoned (≫ one frame period; long enough for the auto-exposure to see the new scene) but not measured. Check on a lit scene that the kept frame shows no slew blur and that its exposure has actually converged for that heading, and shorten the wait if it has. | | 16 | **`settle_wait_ms` untuned** | `standard` waits 1 s and `night` 2.5 s after standstill before keeping a frame. Both are reasoned (≫ one frame period; long enough for the auto-exposure to see the new scene) but not measured. Check on a lit scene that the kept frame shows no slew blur and that its exposure has actually converged for that heading, and shorten the wait if it has. |
| 19 | **USB preconditions do not survive a reboot** | `usbcore.usbfs_memory_mb` reverts to 16 (needs 1000) and USB3 LPM re-enables on the camera's port. **Both were wrong on the LattePanda when tested on 2026-08-07**, and frame delivery stalls without them regardless of application code. Needs the udev rule + kernel cmdline persistence described under *Camera acquisition* below. | | 19 | **USB preconditions do not survive a reboot** | `usbcore.usbfs_memory_mb` reverts to 16 (needs 1000) and USB3 LPM re-enables on the camera's port. **Both were wrong on the LattePanda when tested on 2026-08-07**, and frame delivery stalls without them regardless of application code. Needs the udev rule + kernel cmdline persistence described under *Camera acquisition* below. |
@ -107,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`,

View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 {

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

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

View File

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

View File

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

View File

@ -28,16 +28,23 @@ 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 bool demo = false; // copy demo_image instead of encoding
int jxl_effort = 3; std::string demo_image = "test_smoke.jxl";
int rotation_deg = 0; // clockwise; 0/90/180/270 bool display = false; // show an OpenCV preview window
bool demo = false; // copy demo_image instead of encoding
std::string demo_image = "test_smoke.jxl";
bool display = false; // show an OpenCV preview window
}; };
// orientation: returns the current gimbal yaw+pitch (degrees) for CamEvent. // orientation: returns the current gimbal yaw+pitch (degrees) for CamEvent.
@ -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_;

View File

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

View File

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

View File

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

View File

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

View File

@ -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,16 +98,28 @@ struct ScanWaypointView {
}; };
// Static imaging + encoding config (from CameraConfig), for the expanded view. // Static imaging + encoding config (from CameraConfig), for the expanded view.
struct CameraConfigView { // One configured camera, as the expanded view shows it. Everything here is
bool mock = false; // explicit per camera - identity is never implied by position.
std::vector<std::string> ids; struct CameraView {
// Active camera mode (see CameraMode) and the ones available to switch to. std::string name; // the [Camera.<name>] block
std::string mode, mode_desc; 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 settle_wait_ms = 0;
std::vector<std::string> modes_available; int rotation_deg = 0;
double jxl_distance = 0.0; int jxl_effort = 0; double jxl_distance = 0.0;
int rotation_deg = 0; // clockwise; 0 = none int jxl_effort = 0;
std::string output_dir; };
struct CameraConfigView {
bool mock = false;
std::vector<CameraView> cameras;
std::string output_dir;
}; };
// Live per-camera identity/telemetry/stats (from ICameraSource::deviceInfo()). // Live per-camera identity/telemetry/stats (from ICameraSource::deviceInfo()).

View File

@ -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;

View File

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

View File

@ -7,7 +7,7 @@
#include "fgc/DumpParser.h" #include "fgc/DumpParser.h"
#include "fgc/HelpText.h" #include "fgc/HelpText.h"
#include "fgc/Paths.h" #include "fgc/Paths.h"
#include "fgc/CameraMode.h" #include "fgc/CameraProfile.h"
#include "fgc/ICameraSource.h" #include "fgc/ICameraSource.h"
#include "fgc/IControlChannel.h" #include "fgc/IControlChannel.h"
#include "fgc/IEnvSensor.h" #include "fgc/IEnvSensor.h"
@ -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() {
std::string known; if (cfg.camera.cameras.empty())
for (const auto& [name, m] : camera_modes) known += (known.empty() ? "" : ", ") + name; throw std::runtime_error("no cameras configured (expected at least one "
throw std::runtime_error("Camera.mode '" + cfg.camera.mode + "' has no " + "[Camera.<name>] block)");
cfg.camera.mode + ".ini in " + cfg.camera.mode_dir +
" (available: " + known + ")"); 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;
for (const auto& [name, p] : profiles) known += (known.empty() ? "" : ", ") + name;
throw std::runtime_error("Camera." + c.name + ".profile '" + c.profile +
"' has no " + c.profile + ".xml in " + c.profile_dir +
" (available: " + known + ")");
}
LOG_INFO << "camera '" << c.name << "': " << profiles.size() << " profile(s) from "
<< 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;
}
camera_profiles[c.name] = std::move(profiles);
active_profile[c.name] = c.profile;
} }
camera_mode = cfg.camera.mode;
LOG_INFO << "camera modes: " << camera_modes.size() << " from " << cfg.camera.mode_dir
<< "; active '" << camera_mode << "'";
} }
// Switch the live camera mode by name. Returns false (and warns) if unknown. // Switch one camera's profile by name. With no camera named, applies to the
bool applyMode(const std::string& name) { // single configured camera. Returns false (and warns) if unknown.
auto it = camera_modes.find(name); bool applyProfile(const std::string& cam_name, const std::string& name) {
if (it == camera_modes.end()) { 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.output_dir = cfg.paths.output_dir;
cc.mode = camera_mode; cc.cameras.clear();
if (auto it = camera_modes.find(camera_mode); it != camera_modes.end()) { for (const auto& c : cfg.camera.cameras) {
cc.mode_desc = it->second.description; CameraView v;
cc.settle_wait_ms = it->second.settle_wait_ms; 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));
} }
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;
} }
// 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();

View File

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

View File

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

View File

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

View File

@ -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"}) { cfg.camera.jxl_distance = getDouble(kv, "Camera.jxl_distance", cfg.camera.jxl_distance);
std::string id = get(kv, key); cfg.camera.jxl_effort = getInt(kv, "Camera.jxl_effort", cfg.camera.jxl_effort);
if (!id.empty()) cfg.camera.ids.push_back(id);
// Cameras are discovered from the key names rather than a list, so there is
// no registry to keep in sync: "Camera.rgb.id" declares a camera called rgb.
// A third dotted segment ("Camera.rgb.standard.settle_wait_ms") is per-profile
// timing, so only two-segment suffixes name a camera.
std::vector<std::string> names; // in first-seen order
for (const auto& [key, unused] : kv) {
(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);
} }
// On-camera imaging + encoding. See CameraConfig for why the defaults are conservative. cfg.camera.cameras.clear();
cfg.camera.mode_dir = get(kv, "Camera.mode_dir", cfg.camera.mode_dir); for (const auto& name : names) {
cfg.camera.mode = get(kv, "Camera.mode", cfg.camera.mode); const std::string pre = "Camera." + name + ".";
cfg.camera.jxl_distance = getDouble(kv, "Camera.jxl_distance", cfg.camera.jxl_distance); CameraSpec c;
cfg.camera.jxl_effort = getInt(kv, "Camera.jxl_effort", cfg.camera.jxl_effort); c.name = name;
cfg.camera.rotation_deg = getInt(kv, "Camera.rotation_deg", cfg.camera.rotation_deg); c.enabled = getBool(kv, pre + "enabled", true);
if (cfg.camera.jxl_distance < 0.0) c.driver = get(kv, pre + "driver", c.driver);
throw std::runtime_error("Camera.jxl_distance must be >= 0 (0 = lossless)"); c.id = get(kv, pre + "id");
if (cfg.camera.jxl_effort < 1 || cfg.camera.jxl_effort > 9) c.label = get(kv, pre + "label", name);
throw std::runtime_error("Camera.jxl_effort must be in 1..9"); c.profile_dir = get(kv, pre + "profile_dir");
if (cfg.camera.rotation_deg != 0 && cfg.camera.rotation_deg != 90 && c.profile = get(kv, pre + "profile", c.profile);
cfg.camera.rotation_deg != 180 && cfg.camera.rotation_deg != 270) c.rotation_deg = getInt(kv, pre + "rotation_deg", c.rotation_deg);
throw std::runtime_error("Camera.rotation_deg must be 0, 90, 180 or 270"); 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;
} }

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

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

View File

@ -102,11 +102,12 @@ const std::vector<HelpSection>& helpCatalog() {
{"start", "Begin the capture scan.", {}}, {"start", "Begin the capture scan.", {}},
{"stop", "Halt the capture scan.", {}}, {"stop", "Halt the capture scan.", {}},
{"set fps <rate>", "Set capture rate in images/second.", {}}, {"set fps <rate>", "Set capture rate in images/second.", {}},
{"set camera mode <name>", {"set camera profile [<camera>] <name>",
"Switch the camera mode (rate, exposure limits, imaging settings).", { "Switch a camera's profile (rate, exposure, all imaging settings).", {
"Modes are files in [Camera] mode_dir, one .ini each; the name is the", "A profile is a settings XML exported from the VimbaX Viewer, in that",
"filename stem.", "camera's [Camera.<name>] profile_dir; the name is the filename stem. The",
"Example: set camera mode night"}}, "camera may be omitted when only one is configured.",
"Example: set camera profile night / set camera profile rgb night"}},
{"set camera jxlq <dist>", "Set JPEG-XL distance (lower = higher quality).", {}}, {"set camera jxlq <dist>", "Set JPEG-XL distance (lower = higher quality).", {}},
{"set camera jxle <effort>", "Set JPEG-XL encode effort.", {}}, {"set camera jxle <effort>", "Set JPEG-XL encode effort.", {}},
{"set camera display <0|1>", "Toggle the local display window.", {}}, {"set camera display <0|1>", "Toggle the local display window.", {}},

View File

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

View File

@ -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) | {
color(Color::Cyan))); // 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)));
}
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,
std::string others; text(" " + v.id) | dim,
for (const auto& m : cf.modes_available) others += (others.empty() ? "" : ", ") + m; filler(),
rows.push_back(row("available", text(others.empty() ? "—" : others) | dim)); 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;
for (const auto& p : v.profiles_available) others += (others.empty() ? "" : ", ") + p;
rows.push_back(row(" available", text(others.empty() ? "\xE2\x80\x94" : others) | dim));
}
rows.push_back(row(" settle", text(std::to_string(v.settle_wait_ms) +
" ms after standstill before keeping a frame")));
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::to_string(v.rotation_deg) + " deg CW")));
} }
rows.push_back(row("encoding", text("JPEG XL distance " + num(cf.jxl_distance, 2) + rows.push_back(row("output", text(cf.output_dir.empty() ? "\xE2\x80\x94" : cf.output_dir) | dim));
" effort " + std::to_string(cf.jxl_effort))));
rows.push_back(row("rotation", text(cf.rotation_deg == 0
? std::string("none")
: std::to_string(cf.rotation_deg) + " deg CW")));
// --- 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) {

View File

@ -20,8 +20,8 @@ const char* axisStateLabel(AxisState s) {
UiColor axisStateColor(AxisState s) { UiColor axisStateColor(AxisState s) {
switch (s) { switch (s) {
case AxisState::Ready: return UiColor::Green; case AxisState::Ready:
case AxisState::Homing: return UiColor::Yellow; case AxisState::Homing: return UiColor::Cyan;
case AxisState::Error: return UiColor::Red; case AxisState::Error: return UiColor::Red;
case AxisState::Boot: case AxisState::Boot:
case AxisState::Reset: return UiColor::Dim; case AxisState::Reset: return UiColor::Dim;

View File

@ -22,7 +22,9 @@ add_executable(fgc_tests
test_paths.cpp test_paths.cpp
test_singleinstance.cpp test_singleinstance.cpp
test_config.cpp test_config.cpp
test_cameramode.cpp test_cameraprofile.cpp
test_camerasettingsxml.cpp
test_hash.cpp
test_channelhistory.cpp test_channelhistory.cpp
test_telemetry.cpp test_telemetry.cpp
test_command.cpp test_command.cpp

View File

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

View File

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

View File

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

View File

@ -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") {

47
tests/test_hash.cpp Normal file
View File

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

View File

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

View File

@ -14,9 +14,11 @@ TEST_CASE("axisStateLabel maps every state") {
CHECK(std::string(axisStateLabel(AxisState::Unknown)) == "----"); CHECK(std::string(axisStateLabel(AxisState::Unknown)) == "----");
} }
TEST_CASE("axisStateColor signals health") { TEST_CASE("axisStateColor uses one hue, reserving red for a fault") {
CHECK(axisStateColor(AxisState::Ready) == UiColor::Green); // Healthy states share the panel accent so nothing but a fault changes
CHECK(axisStateColor(AxisState::Homing) == UiColor::Yellow); // colour; Boot/Reset stay dim, which is brightness rather than a second hue.
CHECK(axisStateColor(AxisState::Ready) == UiColor::Cyan);
CHECK(axisStateColor(AxisState::Homing) == UiColor::Cyan);
CHECK(axisStateColor(AxisState::Error) == UiColor::Red); CHECK(axisStateColor(AxisState::Error) == UiColor::Red);
CHECK(axisStateColor(AxisState::Boot) == UiColor::Dim); CHECK(axisStateColor(AxisState::Boot) == UiColor::Dim);
CHECK(axisStateColor(AxisState::Reset) == UiColor::Dim); CHECK(axisStateColor(AxisState::Reset) == UiColor::Dim);