diff --git a/CMakeLists.txt b/CMakeLists.txt index 041855a..0b045be 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -94,6 +94,10 @@ add_library(fgc_core STATIC src/core/ChannelHistory.cpp src/core/ChannelTopics.cpp src/core/RawImage.cpp + src/core/ThermalStats.cpp + src/camera/IrbgrabApi.cpp + src/camera/TarisCameraSource.cpp + src/camera/CompositeCameraSource.cpp src/core/SettleRule.cpp src/core/ThermalGuard.cpp src/sensors/I2cBus.cpp @@ -108,7 +112,7 @@ target_include_directories(fgc_core PUBLIC ${CMAKE_SOURCE_DIR}/include ${CMAKE_SOURCE_DIR} # for the bundled ini.h ) -target_link_libraries(fgc_core PUBLIC Threads::Threads) +target_link_libraries(fgc_core PUBLIC Threads::Threads ${CMAKE_DL_LIBS}) # PUBLIC so the application and the tests both see the same build identity. target_compile_definitions(fgc_core PUBLIC FGC_GIT_REV="${FGC_GIT_REV}") diff --git a/config/config.example.ini b/config/config.example.ini index fd1bf5d..c76489a 100644 --- a/config/config.example.ini +++ b/config/config.example.ini @@ -70,7 +70,7 @@ demosaic = edge_aware ; generated human-readable record of the same settings; it configures nothing. [Camera.rgb] enabled = true -driver = vimba ; only vimba is implemented +driver = vimba ; vimba (colour) or taris (thermal, below) id = DEV_0000000000 ; USB DEV_ id, or a GigE IP label = RGB ; output subdir + CamEvent "cam" profile_dir = camera_profiles/rgb @@ -98,6 +98,29 @@ settle_rule = brightness settle_wait_ms = 2500 frame_timeout_ms = 5000 +; --- A thermal camera (InfraTec TarisIR mini), beside the colour one --------- +; It takes its picture at the same waypoints and intervals, right after the +; colour camera. Each file and CamEvent has the time its own frame arrived; the +; two of one waypoint share hdg/pit and are at most max_frame_age_ms apart. +; The counts are saved 16-bit and always lossless (jxl_distance must stay 0); +; T[K] = count x 0.01 under profile 0, x 0.02 under profile 1, emissivity 1. +; Its driver, id (the serial) and interface belong in hardware.ini; [Hardware] +; cameras lists it. Needs the InfraTec SDK and licence on the tower, and +; cap_net_raw on the binary (scripts/fgc-setcap, which deploy.sh runs) - see +; docs/tower-installation.md. No settings XML: profile only names the timing. +;[Camera.tir] +;label = TIR +;calib_profile = 0 ; 0: -40..120 C (forest), 1: 0..600 C +;frame_rate_hz = 5 ; the camera free-runs at this, throttled +;max_frame_age_ms = 400 ; older is not kept; below the scan's settle wait +;nuc_interval_s = 30 ; NUC at the start of a move, not mid-picture; 0 = camera's timer +;rotation_deg = 0 +;jxl_effort = 3 +; +;[Camera.tir.standard] ; optional: without it 0 / 0, as it should be +;settle_wait_ms = 0 ; it takes the frame it already has +;frame_timeout_ms = 0 + [Serial] ; Motor-controller serial device and baud rate. device = /dev/ttyACM0 diff --git a/config/hardware.example.ini b/config/hardware.example.ini index fc75b3d..f72be14 100644 --- a/config/hardware.example.ini +++ b/config/hardware.example.ini @@ -53,7 +53,8 @@ env = sht41 ; The legacy towers: off to 25 C, +20/255 per degree, full from 37.75 C, and full ; whenever the sensor cannot be read. Needs env = dht11 and [Pins] fan_pwm. ;cooling = fan -; The [Camera.] blocks expected to be present and found. +; The [Camera.] blocks expected to be present and found ("rgb, tir" with +; the thermal camera). cameras = rgb ; How much bearing one picture covers, in degrees. Pan mode ('mode pan') cuts ; its span into as many pictures as this takes. 6 until the lens is measured. @@ -64,6 +65,7 @@ pan_step_deg = 6 ; (N5105) needs 2 workers for that. jxl_threads is libjxl's threads per frame; ; 0 or absent = 4 / save_workers. save_queue_max is how many frames may wait ; or be saving before the scan holds at its waypoint (each raw frame ~24 MB). +; A thermal camera adds a frame per picture (0.6 MB): raise it by 2. save_workers = 1 ;jxl_threads = 0 save_queue_max = 4 @@ -247,3 +249,11 @@ has_encoder = true ; The serial of the physical camera fitted to this tower. A USB DEV_ id, or a ; GigE IP address. id = DEV_1AB22C049CD1 + +; A thermal camera (TarisIR mini): its serial number, as the SDK's search finds +; it, and the network port it is cabled to (static address, no DHCP, MTU 1500; +; only used to say what is wrong when the camera is not found). +;[Camera.tir] +;driver = taris +;id = 2017744 +;interface = enp1s0 diff --git a/deploy.sh b/deploy.sh index e74f241..55d7b30 100755 --- a/deploy.sh +++ b/deploy.sh @@ -308,6 +308,10 @@ ssh "$REMOTE_HOST" "$RESOLVE_CMAKE $CHECK_DEPS_CMD cd '$REMOTE_DIR' && \ GIT_REV='$GIT_REV' \"\$CMAKE\" -S . -B build $CMAKE_ARGS && \ \"\$CMAKE\" --build build $JOBS_FLAG" echo ">> build complete: $REMOTE_DIR/build/fire_gimbal_control" +# The rebuilt binary has lost its capabilities; a thermal camera needs +# cap_net_raw back (scripts/fgc-setcap). Without a thermal camera: nothing. +# shellcheck disable=SC2029 +ssh "$REMOTE_HOST" "cd '$REMOTE_DIR' && scripts/fgc-setcap --if-thermal" || true # A pushed config.ini was written on another machine: check that it loads on # this one, with this tower's hardware.ini layered over it, before anyone runs it. diff --git a/docs/architecture.md b/docs/architecture.md index 4079304..5b8d451 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,7 +17,7 @@ The design separates **policy** (the control logic) from **mechanism** (the I/O |-----------|------|-------------| | `IMotorController` | `SerialMotorController` (Boost.Asio) | `MockMotorController` (simulated sweep) | | `IControlChannel` | `MqttControlChannel` (Paho) | `NullControlChannel` (no broker) | -| `ICameraSource` | `VimbaCameraSource` (Vimba X) | `MockCameraSource` (synthetic frames) | +| `ICameraSource` | `VimbaCameraSource` (Vimba X), `TarisCameraSource` (InfraTec TarisIR, thermal), both behind `CompositeCameraSource` | `MockCameraSource` (synthetic frames, also thermal counts) | | `IImuSource` | `MtiImuSource` (Xsens MTi, Boost.Asio) | `MockImuSource` (synthetic orientation) | | `IEnvSensor` | `Sht41EnvSensor` (SHT41 over I2C) | `MockEnvSensor` (synthetic temp/humidity) | @@ -54,6 +54,7 @@ thread so all geometry/state mutation stays single-threaded. | Image worker | `ImagePipeline` | drains the frame queue: rotate → encode → write → publish | | MQTT client | Paho (internal) | delivers callbacks; auto-reconnects (no busy-wait loop) | | Camera acquisition | Vimba X (internal) | free-runs at the mode's frame rate and delivers frames via the observer, which keeps the latest complete one | +| Thermal acquisition | `TarisCameraSource` worker | owns every SDK call: connects (and reconnects), polls the newest frame of counts, switches the calibration profile; `trigger()` only takes the kept frame | Shared state is mutex-guarded: latest `MotorTelemetry` (serial), `RemoteCommand` queue (channel), the frame queue (pipeline), the console command queue, and the latest `UiSnapshot`. The `CaptureScheduler` runs only on the @@ -132,6 +133,24 @@ everything below is an implementation detail of the camera source. That is what ## Image capture +### Two cameras, one array: `CompositeCameraSource` + +With a thermal camera the scheduler and the pipeline still see one +`ICameraSource`. The composite numbers the colour cameras first (camera 0 is +the one the keep rule reads) and the thermal one after them. Its `trigger()` +asks the colour source first; only if that delivered does it ask the thermal +source, which hands over its newest frame if it is no older than +`max_frame_age_ms` and otherwise says no at once. Each frame keeps the time it +reached the host, so the two of one waypoint differ by up to that age. The +colour camera failing stops the run as +before; the thermal one failing never does (`offline()` says why, Health +`cams_offline` names it). Thermal frames are `Mono16` counts: the pipeline +rotates them, takes `tmax`/`tmed`/hot spot on the rotated image +(`ThermalStats.h`), and saves them 16-bit lossless whatever `jxl_distance` says. +The SDK is declared in `IrbgrabApi.h` (not copied: its licence forbids that) and +loaded with `dlopen`, so every build has the thermal code and its tests run +against a fake SDK. + ### The model: free-run, then hold still The camera **runs continuously** at the mode's frame rate with its own auto-exposure and auto-gain in diff --git a/docs/configuration.md b/docs/configuration.md index 63a921b..1f85a1b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -62,10 +62,10 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config | `Network` | `mqtt_user` / `mqtt_pw` | string | — | MQTT credentials (see secrets below) | | `Serial` | `device` | string | `/dev/ttyACM0` | Motor-controller serial device | | `Serial` | `baud` | int | `115200` | Serial baud rate | -| `Camera.` | `id` | string | — | USB `DEV_` id or GigE IP. Required when enabled | -| `Camera.` | `driver` | `vimba` | `vimba` | Backend. Only `vimba` is implemented | +| `Camera.` | `id` | string | — | `vimba`: USB `DEV_` id or GigE IP; `taris`: the camera's serial number. Required when enabled | +| `Camera.` | `driver` | `vimba` \| `taris` | `vimba` | Backend: Allied Vision Vimba X (colour), or InfraTec TarisIR (thermal, [below](#thermal-camera-driver--taris)). Colour cameras always come first in the camera order, whatever the block names | | `Camera.` | `label` | string | the block name | Output subdirectory and `CamEvent` `cam` field | -| `Camera.` | `profile_dir` | string | — | Directory of profile XMLs; a profile named X is `/X.xml`. A relative path resolves against the directory holding the `config.ini` in use | +| `Camera.` | `profile_dir` | string | — | `vimba` only. Directory of profile XMLs; a profile named X is `/X.xml`. A relative path resolves against the directory holding the `config.ini` in use | | `Camera.` | `profile` | string | `standard` | Profile to start in. Switch live with `set camera profile [] ` | | `Camera.` | `enabled` | bool | `true` | A disabled camera is not opened and does not affect the scan timing | | `Camera.` | `rotation_deg` | `0`/`90`/`180`/`270` | `0` | Clockwise rotation applied to that camera's saved images | @@ -75,6 +75,12 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config | `Camera` | `jxl_distance` / `jxl_effort` | double ≥ 0 / int 1..9 | `0.8` / `4` | Encoder defaults inherited by every camera block | | `Camera.` | `jxl_distance` / `jxl_effort` | double ≥ 0 / int 1..9 | the `[Camera]` value | Per-camera override of the encoder settings. Distance 0 is truly lossless | | `Camera` / `Camera.` | `demosaic` | `edge_aware` \| `bilinear` | `edge_aware` | How raw profiles (`BayerRG8`, `BayerRG12p`) are converted to colour on the tower | +| `Camera.` (`taris`) | `calib_profile` | int ≥ 0 | `0` | Calibration profile set on connect: 0 = -40..120 °C at 0.01 K/count, 1 = 0..600 °C at 0.02 K/count. Switch live with `camera calib ` | +| `Camera.` (`taris`) | `frame_rate_hz` | 0 < x ≤ 50 | `5` | The rate the camera free-runs at | +| `Camera.` (`taris`) | `max_frame_age_ms` | int > 0 | `400` | A frame older than this when the picture is taken is not kept: that picture has no thermal image. Keep it below the scan's settle wait, or the frame can predate standstill (warned at startup) | +| `Camera.` (`taris`) | `nuc_interval_s` | int 0..3600 | `30` | Seconds between NUCs, run by the program at the start of a move so none blanks a picture (after twice this with no move, one runs anyway). `0` leaves the camera's own timer, which now and then takes a picture's frame | +| `Camera.` (`taris`) | `library` | string | `libirbgrab_x86_64-linux.so` | The InfraTec SDK, found through the ld cache | +| `Camera.` (`taris`, hardware.ini) | `interface` | string | — | The network port the camera is cabled to; only named in the message when it is not found | | `Hardware` (hardware.ini) | `save_workers` | int 1..4 | `1` | Frames saved at once. 1 on the IOTA, 2 on the 3 Delta ([measurements](raw12-pipeline-measurements.md)) | | `Hardware` (hardware.ini) | `jxl_threads` | int 0..8 | `0` = 4 / `save_workers` | libjxl threads per saved frame | | `Hardware` (hardware.ini) | `save_queue_max` | int 1..32 | `4` | Frames waiting or being saved before the scan holds at its waypoint (a raw 16 MP frame is ~24 MB) | @@ -1054,3 +1060,37 @@ grid (front to back, repeating); in **pan** mode it walks the pan's headings lef pitch, then starts again from the left edge (see [mqtt-api.md](mqtt-api.md#modes-auto-pan-manual)). Telemetry arrives as firmware `ST` lines (per-axis state + encoder counts), parsed by [TelemetryParser](../src/core/TelemetryParser.cpp). + +## Thermal camera (`driver = taris`) + +A TarisIR mini beside the colour camera takes its picture at the same waypoints +and intervals: the colour camera is asked first, and only once it has delivered +is the thermal one asked for its newest frame. Each file and CamEvent has the +time its own frame arrived: the two of one waypoint share `hdg`/`pit` and are at +most `max_frame_age_ms` apart (see [mqtt-api.md](mqtt-api.md#thermal-camera-cam-tir)). + +- **Best effort.** A thermal camera that is missing, unlicensed or has no fresh + frame costs that picture its thermal image, nothing more: the scan goes on, the + camera reconnects by itself, and Health `cams_offline` names it meanwhile. +- **Always lossless.** The counts are measurements; `jxl_distance` must be 0 + (the default for this driver) and the loader refuses anything else. +- **No settings XML.** The camera is set up from its keys; `profile` only picks a + `[Camera..]` timing block, which it does not need: without one + its wait is 0 and the colour camera's settle wait alone sets the pace. The + frame it hands over is at most `max_frame_age_ms` old, so that must stay below + the colour camera's `settle_wait_ms`. +- **Kept grabbing**: from start to exit, also in manual mode. A microbolometer + that is restarted begins with zero frames and a fresh NUC. +- **NUC while the gimbal moves.** The camera recalibrates against its shutter + every 30 s and sends no frame for about 0.7 s meanwhile; a picture falling + into that had no thermal image (0.8 % of them on tower 111). The program + switches the camera's timer off and runs the NUC itself at the start of a + move once `nuc_interval_s` has passed; the move and the settle wait leave + seconds before the next picture. A picture that still finds the camera + between frames waits up to 1 s for the next one. On exit the camera's timer + is put back. +- **Split between the files** like any camera: `driver`, `id` and `interface` + in hardware.ini (with `tir` in `[Hardware] cameras`), the rest in config.ini. + `save_queue_max` counts frames, so raise it by 2. +- The binary needs `cap_net_raw` (the SDK uses a raw socket): + [tower-installation.md](tower-installation.md#phase-2b---thermal-camera). diff --git a/docs/known-issues.md b/docs/known-issues.md index efff870..592b020 100644 --- a/docs/known-issues.md +++ b/docs/known-issues.md @@ -166,8 +166,25 @@ scripts for this are worth keeping; note that wrapping them in `timeout` SIGTERM key features (`AcquisitionFrameRate`, `Width`, `PixelFormat`) would turn a silent misconfiguration into an error. - Graceful shutdown on SIGINT (currently exit via `exit`/Ctrl-D; a pending `getline` can delay shutdown). -- Support a second camera backend. `driver` is parsed and validated but only `vimba` is accepted, so a - thermal or NIR unit needs a new `ICameraSource` implementation behind that key. +- ~~Support a second camera backend.~~ Done for the thermal camera (`driver = taris`, + `TarisCameraSource`, behind `CompositeCameraSource`), tested with the TarisIR mini on tower 111 on + 2026-10-14 (docs/tower-installation.md, Phase 2b). Not yet tested: a pulled camera cable during a scan + (the reconnect is only unit-tested). The 0.8 % of pictures without a thermal frame in the first long run + were the camera's own NUC every 30 s (a 0.7 s pause in its stream); since 2026-10-15 the program runs the + NUC at the start of a move instead. A NIR unit would be a third driver + behind the same key. +- The InfraTec SDK keeps the CPU awake: about 15 of its threads sleep 1 ms at a time, some 15 000 wake-ups + a second, so the cores never settle and the CPU clocks them at turbo (2.8 GHz on the N5105) whenever they + run. Until 2026-10-15 the camera also sent 50 fps (31 MB/s) because the frame rate was not taken; at the + intended 5 fps the system load fell from about 29 % to 18 %, but the wake-ups remain. In the first minutes after deploying, the + CPU guard slowed the scan once (86 °C, back to normal after 2 min). Watch Health `cpu_c` over a warm + day before a tower without air flow gets a thermal camera. +- Tower 111's colour profile does not apply: every start since 2026-10-07 logs `applying profile 'standard' + from .../rgb/standard.xml failed (error -39)`, and the camera runs on whatever it had. Unrelated to the + thermal camera; seen again while testing it. +- Per-channel boresight: `[Motor]` heading and tilt calibration belong to the tower, but the thermal camera + sits on the gimbal with its own small offset against the colour one. `hotx`/`hoty` are therefore in the + thermal image's own frame; projecting them onto terrain needs a per-camera offset nobody has measured yet. - Reintroduce optional image upload to the ground station, config-driven (the old hardcoded NFS/SMB upload was removed). - **Calibration persistence needs the TUI.** `gimbal pitch setup` and `gimbal heading fit` are always diff --git a/docs/mqtt-api.md b/docs/mqtt-api.md index d50fd6c..598e080 100644 --- a/docs/mqtt-api.md +++ b/docs/mqtt-api.md @@ -95,7 +95,7 @@ Built by `MqttControlChannel::publishCamEvent` from a `CamEvent`: | Field | Type | Meaning | |-------|------|---------| | `fwt` | string | Tower name (`config.ini` `tower_name`) | -| `cam` | string | Camera label: `RGB`, `ACR`, or `NIR` (by camera index) | +| `cam` | string | Camera label (`[Camera.] label`): `RGB`, `TIR`, `ACR` or `NIR` | | `hdg` | int | Gimbal **true bearing** × 10 (0 = north) at capture time, from the **yaw encoder** | | `pit` | int | Gimbal pitch elevation **× 10** at capture time, from the **IMU** — elevation relative to gravity, i.e. true pointing angle (fallbacks below) | | `time` | int | Capture timestamp, Unix epoch **milliseconds** (matches the `.jxl` filename) | @@ -135,6 +135,43 @@ Both `hdg` and `pit` are sampled **when the frame arrives**, not when its JPEG X finishes, so a slow encode or a backed-up queue cannot attribute a later pose to earlier pixels. +#### Thermal camera (`cam: "TIR"`) + +A thermal camera's events carry six more fields; a colour camera's never do, and +their payload is unchanged. + +```json +{ "fwt":"ExampleTower", "cam":"TIR", "hdg":1373, "pit":300, "time":1719312345678, + "cal":0, "missed":0, "tmax":3015, "tmed":187, "hotx":0.7359, "hoty":0.5833 } +``` + +| Field | Type | Meaning | +|-------|------|---------| +| `cal` | int | Calibration profile the image was taken on. **Required to read the file**: it sets the counts' scale | +| `missed` | int | GigE packets the SDK lost for this frame (transfer quality) | +| `tmax` | int/null | Hottest pixel, **apparent** temperature in tenths of a °C | +| `tmed` | int/null | Median of the image, tenths of a °C (the anchor for a display window) | +| `hotx`, `hoty` | number/null | Where the hottest pixel is: its centre, 0..1 across and down the image **as stored** (after `rotation_deg`), as for detection boxes | + +- **Its own `time`.** The thermal camera is asked right after the colour one, at + the same waypoint, and hands over the newest frame it has. `time` (and the file + name) is when that frame reached the tower computer, so it is up to + `max_frame_age_ms` (400) off the colour picture's, earlier or later. The `RGB` + and `TIR` event of one waypoint share `hdg` and `pit` (sampled together) and + follow each other in the stream; pair them by that, not by an equal `time`. A + picture the thermal camera had no fresh frame for has no `TIR` event. +- **The file holds counts**, 16-bit single channel, lossless: + `T[K] = count × 0.01` under `cal` 0 (`FF (-40-120C)`), `× 0.02` under `cal` 1 + (`FF (0-600C)`), valid for `count ≤ 65280` (above it the camera's table wraps; + 0 is never a temperature). +- **Apparent temperature**: emissivity and path transmission are taken as 1. + Vegetation (ε ≈ 0.97) over kilometres of air reads a few kelvin off its true + temperature; the counts keep every correction possible later. `tmax`/`tmed` + use the same convention, so they are for triage and display, not for a + reported fire temperature. +- `null` in the four statistics means the frame had no valid pixel; `cal` and + `missed` are still set. + CamEvent and Health leave through a small queue on their own thread, so a slow link never holds an image save or the control loop. If 64 are waiting, the oldest is dropped (and shows as dropped in the connectivity view). @@ -149,8 +186,8 @@ The tower computer's heat and load, and whether the scan is slowed or paused for {"fwt":"t121","state":"paused","reason":"CPU at 93 °C for 20 s","guard":true, "cpu_c":93.0,"cpu_mhz":1188,"cpu_busy_pct":41,"throttling":true, "throttle_events_last_min":14,"enclosure_c":48.2,"saved_last_min":0, - "save_ms_mean":null,"save_ms_max":null,"held_s":0,"interval_s":10.0, - "time":1759917600000} + "save_ms_mean":null,"save_ms_max":null,"cams_offline":"","held_s":0, + "interval_s":10.0,"time":1759917600000} ``` | Field | Type | Meaning | @@ -164,7 +201,8 @@ The tower computer's heat and load, and whether the scan is slowed or paused for | `throttling` | bool | The CPU is slowing itself (kernel counters, or a low clock while busy) | | `throttle_events_last_min` | int | Kernel throttle events counted in the last minute | | `enclosure_c` | number/null | The ambient sensor (SHT41/DHT11), when there is one | -| `saved_last_min`, `save_ms_mean`, `save_ms_max` | int, number/null | Pictures saved in the last minute and how long each took | +| `saved_last_min`, `save_ms_mean`, `save_ms_max` | int, number/null | Pictures saved in the last minute and how long each took (every camera's: with a thermal camera, two per waypoint) | +| `cams_offline` | string | Labels of cameras not delivering, comma-separated (`"TIR"`); `""` when all are. Only a camera the scan can do without - the thermal one - is ever listed: it reconnects by itself while the scan goes on | | `held_s` | int | How long the scan has been waiting for saving to catch up | | `interval_s` | number | Time between pictures now | | `time` | int | Unix epoch ms | diff --git a/docs/tower-installation.md b/docs/tower-installation.md index f7f5e47..5f48024 100644 --- a/docs/tower-installation.md +++ b/docs/tower-installation.md @@ -884,6 +884,89 @@ Then `mode auto`, and confirm `.jxl` files appear under the configured output di > running an older binary, export the profiles (step 8) first or it will refuse > with `no camera profiles (*.xml)`. +### Phase 2b - thermal camera + +Only for a tower with an InfraTec TarisIR mini (tower 111 since 2026-10). The +program runs as the tower's user; the SDK needs three things from the system. + +1. **SDK and licence.** Packages `infratec-sdk-irbgrab` and + `infratec-itcamtarisirmini` (amd64), then the licence for the camera's serial + via the vendor's `copycustomerdata.sh` into `/var/lib/infratec/.license/` + (`root:infratec`, `rw-rw----`). **Repeat after every OS update.** Keep the + source files on the tower (`~/InfraTec/LINUX/` on 111) so it can be re-run. + The tower's user must be in group `infratec` (`id` shows it; `sudo usermod -aG + infratec ggs` and log in again otherwise). The library must be in the ld + cache: `ldconfig -p | grep irbgrab`. +2. **Network.** Camera and port directly cabled, static address, **no DHCP** + (`dhcp4: false` in netplan: DHCP on a link without a server keeps failing and + takes the static address with it), **MTU 1500** - the camera does no jumbo + frames. The SDK finds the camera by serial and MAC on raw Ethernet; the IP + only matters to ordinary GigE Vision tools. On 111: `enp1s0`, 192.168.0.111, + camera 192.168.0.200. +3. **`cap_net_raw` on the binary.** The SDK opens a raw `AF_PACKET` socket. + Rather than running the program as root, the binary gets this one + capability. A rebuild drops it, so `deploy.sh` and `install-tower` run + `scripts/fgc-setcap --if-thermal` after every build. Once per tower, let + deploy do that without a password: + + ```bash + scripts/fgc-setcap --install-sudoers # /etc/sudoers.d/fire-gimbal-control-setcap + scripts/fgc-setcap && getcap build/fire_gimbal_control + ``` + + A capability binary runs in the loader's secure mode (`LD_*` ignored, no core + dumps): check once that Vimba X still opens the colour camera, and that + `scripts/fgc status` and `stop` still find the process. + +Then hardware.ini (`driver = taris`, `id = `, `interface = enp1s0`, +`cameras = rgb, tir`) and config.ini (`label = TIR`, see +[configuration.md](configuration.md#thermal-camera-driver--taris)), and a run +with the real cameras but no motors: + +```bash +./build/fire_gimbal_control --mock-serial --mock-env --no-mqtt --no-tui +``` + +``` +[INFO ] IRBGRAB SDK 4.3.44 +[INFO ] thermal camera connected via SN:2017744|...|192.168.0.200#0 +[INFO ] thermal camera: profile 0, 0.01 K per count +``` + +`snapshot` then writes `RGB/.jxl` and `TIR/.jxl` with the same ``. +`camera calib` reports the profile. If the camera stays offline the log says +why, once per change - `no cap_net_raw`, `not found (found: ...)`, the port's +state and MTU. A `0x80000017` that keeps coming is the licence before it is the +NUC. + +Measured on tower 111 (2026-10-14, SDK 4.3.44, SN 2017744): + +- `cap_net_raw` alone is enough; no root, no `cap_net_admin`. Vimba X still + opens the colour camera from the capability binary. +- Connecting takes about 15 s (SDK init and search); the scan does not wait. +- `Framerate_Hz` is settable. The LUT is empty for the first frame after + connecting and right from then on: 0.0099998 K/count (profile 0), 0.0199995 + (profile 1). +- A profile switch takes about 3 s; frames keep the old scale until + `Calib_ChangeInProgress` reaches 0, and the program takes none meanwhile. +- The camera's frame counter (`ImageNum`) is always 0; `MissedPackets` was + always 0 on the direct link. +- Frame rate (2026-10-15): from power-up the camera sends 50 fps, 31 MB/s and + 22 000 packets a second. `Framerate_Hz` set right after connecting answers ok + and changes nothing; set while grabbing it only thins out what the SDK hands + over; set while the grab is stopped, after the stream has run, it holds on the + wire. The program does that once the first frame is in and checks the packet + rate of `[Camera.tir] interface` (5 Hz: about 2 200 packets a second); the log + says `5.3 fps on enp1s0 (2224 packets/s), set on attempt 2`. +- NUC (2026-10-15): the camera runs one every 30 s (`NUC_Interval`) and sends no + frame for 650-760 ms meanwhile. `NUC_Interval = 0` stops that; `NUC_Perform` + (1-point) runs one and returns when it is done, after about 0.67 s. + `NUC_INPROGRESS` stays 0 throughout, also for those. The program now runs the + NUC itself at the start of a move (`nuc_interval_s`). +- The SDK switches the process's C locale to `LC_NUMERIC=de_DE` when it is + loaded. The program puts it back after every SDK call and writes its JSON + without the C library's number formatting. + ### Phase 3 - the broker Set `[Features] enable_mqtt = true` and point `zkms_server_ip` at the broker. diff --git a/include/fgc/CompositeCameraSource.h b/include/fgc/CompositeCameraSource.h new file mode 100644 index 0000000..17436a0 --- /dev/null +++ b/include/fgc/CompositeCameraSource.h @@ -0,0 +1,79 @@ +#pragma once + +#include "fgc/ICameraSource.h" + +#include +#include + +namespace fgc { + +// Several camera sources behind one, so the scheduler and the pipeline keep +// seeing a single camera array: the colour cameras through Vimba and the +// thermal one through its own SDK take their pictures at the same waypoints +// and intervals without either knowing about the other. +// +// The first child leads. Its cameras come first in the index order (camera 0 +// is its first camera, which the keep rule reads), and its trigger() decides +// whether a picture was taken: only once it has delivered are the others +// asked, so a retry while the leader waits never yields a second thermal +// frame for the same waypoint. The others are best effort - a follower with +// no fresh frame costs that picture its thermal image, nothing more. +// +// Every frame keeps its own time, when it reached the host: the thermal frame +// is the newest the camera had, so its time is up to max_frame_age_ms off the +// colour one, and that is what the file name and the CamEvent say. +// +// A follower that is briefly without a fresh frame (frameReady() false: the +// thermal camera's NUC) holds the picture back for up to `follower_wait_ms`, +// so the pair stays whole; past that the leader goes on alone. +// +// Indices are global: child k's camera i is offset(k) + i. Calls about one +// camera go to the child that has it. +// +// The offsets come from `counts`, how many cameras each child is configured +// with, not from cameraCount(): VimbaCameraSource counts what it has opened, +// which is 0 before open(), and a thermal frame numbered from that landed as +// camera 0 - the colour camera's label and file (seen on tower 111). Without +// counts, each child's cameraCount() at construction is taken. +class CompositeCameraSource : public ICameraSource { +public: + explicit CompositeCameraSource(std::vector> children, + std::vector counts = {}, int follower_wait_ms = 1000); + + void open() override; + void close() override; + void start() override; + void stop() override; + bool trigger() override; + void moving() override; + bool frameReady() override; + bool setProfile(int camera_index, const CameraProfile& profile) override; + void setFrameCallback(FrameCallback cb) override; + uint64_t frameSeq() override; + std::vector framesSince(uint64_t after) override; + bool readFeature(int camera_index, const std::string& name, double& value) override; + int cameraCount() const override; + std::vector deviceInfo() override; + std::string offline(int camera_index) override; + bool grabFrame(int camera_index, Frame& out, int timeout_ms) override; + bool setFeature(int camera_index, const std::string& name, const std::string& value) override; + bool featureRange(int camera_index, const std::string& name, double& min, double& max) override; + bool restoreProfile(int camera_index) override; + bool resetCamera(int camera_index) override; + + size_t childCount() const { return children_.size(); } + ICameraSource& child(size_t k) { return *children_[k]; } + +private: + // The child holding a global index and its index there; nullptr if none. + ICameraSource* route(int camera_index, int& local) const; + + std::vector> children_; + std::vector counts_; // cameras each child is configured with + std::vector offsets_; // global index of each child's camera 0 + FrameCallback callback_; + int follower_wait_ms_; + long long waiting_since_ = 0; // steady ms a picture began waiting for a follower +}; + +} // namespace fgc diff --git a/include/fgc/Config.h b/include/fgc/Config.h index e3d4ed4..3f106a8 100644 --- a/include/fgc/Config.h +++ b/include/fgc/Config.h @@ -199,11 +199,14 @@ struct CameraProfileTiming { 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 + // "vimba" (Allied Vision, colour) or "taris" (InfraTec TarisIR, thermal). + std::string driver = "vimba"; + std::string id; // vimba: USB DEV_ id or GigE IP; taris: serial number 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 + std::string profile_dir; // vimba only; relative => resolved next to config.ini + // Which profile to start in. For vimba a settings XML in profile_dir; for + // taris only the name of a timing block, the camera having no settings file. + std::string profile = "standard"; int rotation_deg = 0; // clockwise; 0/90/180/270 // Encoding, defaulted from [Camera] and overridable per camera. @@ -212,6 +215,23 @@ struct CameraSpec { // De-Bayering for raw profiles (BayerRG8/12p): "edge_aware" or "bilinear". std::string demosaic = "edge_aware"; + // ---- driver = taris only (TarisCameraSource.h) ---- + // Operation (config.ini): + int calib_profile = 0; // camera calibration profile; sets the counts' scale + double frame_rate_hz = 5.0; // the camera free-runs at this rate, throttled + // A frame older than this at capture is not kept. Must stay below the scan's + // settle wait, or the frame may have been taken while the gimbal still moved. + int max_frame_age_ms = 400; + std::string library = "libirbgrab_x86_64-linux.so"; // the SDK, found through the ld cache + // Seconds between NUCs the program runs itself, each at the start of a move + // so it never blanks a picture; 0 leaves the camera's own timer, which fires + // mid-picture now and then (TarisCameraSource.h). + int nuc_interval_s = 30; + // Mounting (hardware.ini): the camera's network port, for diagnostics only. + std::string interface; + + bool thermal() const { return driver == "taris"; } + std::map timing; // by profile name // Timing for `profile`, or the defaults when the block does not name it. @@ -219,7 +239,10 @@ struct CameraSpec { }; struct CameraConfig { - std::vector cameras; // in config order; empty is a config error + // Colour (vimba) cameras first, then thermal ones, each group in block-name + // order: camera 0 is the one the keep rule and the scan pace follow, and + // that must be a colour camera. Empty is a config error. + std::vector cameras; // [Camera] defaults, inherited by each block unless it overrides them. double jxl_distance = 0.8; int jxl_effort = 4; diff --git a/include/fgc/ICameraSource.h b/include/fgc/ICameraSource.h index 04cc4f5..b7192a3 100644 --- a/include/fgc/ICameraSource.h +++ b/include/fgc/ICameraSource.h @@ -3,9 +3,11 @@ #include "fgc/CameraProfile.h" #include "fgc/RawImage.h" #include "fgc/SettleRule.h" +#include "fgc/ThermalStats.h" #include #include +#include #include #include @@ -32,6 +34,8 @@ struct Frame { // Sampled from `data` on arrival (see RawImage.h); invalid when the layout // is Unknown. BrightnessSample brightness; + // Set by a thermal camera only: how to read its Mono16 counts. + std::optional thermal; }; // Live per-camera identity, sensor telemetry, and acquisition stats, for the @@ -82,6 +86,16 @@ public: // yet, and the caller simply retries. virtual bool trigger() = 0; + // The gimbal has started moving: nothing will be captured until it stands + // still again and has settled. A camera with housekeeping that blanks its + // stream - the thermal camera's NUC - does it now. Default: nothing. + virtual void moving() {} + + // Would trigger() hand over a fresh frame right now? False while a camera is + // briefly between frames (a NUC), so a picture can wait a moment for it + // rather than go without. Default: always. + virtual bool frameReady() { return true; } + // Apply a profile to one camera, identified by its index in the order the // source opened them (the same index Frame::cam_id carries). The backend // loads the profile's settings XML through its SDK - see CameraProfile.h. @@ -113,6 +127,12 @@ public: // Default: none. Implementations may refresh a throttled cache, so non-const. virtual std::vector deviceInfo() { return {}; } + // Why one camera delivers nothing right now ("not found on the network"), + // or empty while it works. For a camera the scan can do without (the + // thermal one), which reconnects by itself rather than stopping the run. + // Default: always working. + virtual std::string offline(int /*camera_index*/) { return {}; } + // ---- For `test camera` only ------------------------------------------------ // The camera's configuration otherwise changes through setProfile() and // nothing else; these let the self-test drive a camera to known extremes and diff --git a/include/fgc/IControlChannel.h b/include/fgc/IControlChannel.h index 0a5d2c1..36ee77f 100644 --- a/include/fgc/IControlChannel.h +++ b/include/fgc/IControlChannel.h @@ -1,17 +1,32 @@ #pragma once +#include #include #include namespace fgc { +// What a thermal camera's CamEvent carries beyond the common fields (see +// docs/mqtt-api.md). Temperatures are apparent (emissivity 1), in tenths of a +// degree Celsius; the hot spot is normalised 0..1 across the stored image. +struct ThermalEventFields { + int cal = 0; // calibration profile: says how to read the counts + int missed = 0; // GigE packets lost for this frame + bool stats_ok = false; // false: no valid pixel, the four below go out as null + int tmax_decic = 0; + int tmed_decic = 0; + double hotx = 0.0; + double hoty = 0.0; +}; + // Notification published after an image is captured and saved. struct CamEvent { std::string tower; // tower / FWT name - std::string camera; // "RGB" / "ACR" / "NIR" + std::string camera; // "RGB" / "TIR" / "ACR" / "NIR" int heading_decideg = 0; // yaw heading * 10 (one decimal as integer) int pitch_decideg = 0; // pitch elevation * 10 (one decimal as integer) long long timestamp_ms = 0; // Unix epoch ms; matches the image filename + std::optional thermal; // thermal cameras only }; // The tower computer's health, on GGS/FWT//Health (retained): the CPU @@ -34,6 +49,7 @@ struct HealthReport { int save_count_last_min = 0; double save_ms_mean = 0.0; double save_ms_max = 0.0; + std::string cams_offline; // labels of cameras not delivering, comma-separated long long held_s = 0; // the scan waiting for saving, seconds double interval_s = 0.0; // the time between pictures now long long timestamp_ms = 0; // Unix epoch ms diff --git a/include/fgc/ImagePipeline.h b/include/fgc/ImagePipeline.h index b39cf25..c3ed01c 100644 --- a/include/fgc/ImagePipeline.h +++ b/include/fgc/ImagePipeline.h @@ -26,7 +26,9 @@ struct Orientation { // Camera.rotation_deg, encode to JPEG XL, write to // /