Add unit tests, deployment scripts, and refreshed docs

This commit is contained in:
pgdalmeida 2026-06-22 13:07:03 +02:00
parent 11a2384c53
commit f1005f3e31
Signed by: pedro.almeida
GPG Key ID: D4A6C394DF13F1D7
18 changed files with 739 additions and 542 deletions

133
README.md
View File

@ -1,74 +1,111 @@
# Fire Gimbal Control (Staeffelsberg)
Real-time control software for an automated **fire-watch gimbal**. A single C++17 binary runs on a
tower-mounted PC, rotates a pan gimbal carrying up to four industrial cameras, captures a 360° panorama
for wildfire detection, compresses each frame to JPEG XL, and reports to a ground station over MQTT.
Real-time control software for an automated **fire-watch gimbal**. A C++17 program runs on a tower-mounted
PC, rotates a pan gimbal carrying up to four industrial cameras, captures a 360° panorama for wildfire
detection, compresses each frame to JPEG XL, and reports to a ground station over MQTT.
This is the deployment for the **Staeffelsberg** fire-watch tower (FWT). The same codebase is used across
towers; the tower identity comes from `config.ini`.
This is the deployment for the **Staeffelsberg** fire-watch tower (FWT). The same codebase serves all towers;
the tower identity comes from `config.ini`.
State is held in memory (mutex-guarded structs), configuration is read once from an INI file, images are
written to the filesystem as `.jxl`, and telemetry flows over MQTT. See [docs/architecture.md](docs/architecture.md)
for the full picture.
The code is organized around an **SDK-independent core** (`fgc_core`: config, logging, capture scheduler,
parsers) and **swappable I/O implementations** behind three interfaces — motor controller, control channel,
and camera source — each with a real and a mock/null version. This makes the system deployable on any machine
and runnable **without hardware or an MQTT broker** for development.
## What it does (at a glance)
State is held in memory (mutex-guarded), configuration is read once from an INI file, images are written to
the filesystem as `.jxl`, and telemetry flows over MQTT. There is no database.
## Architecture at a glance
```
motor controller (MCU) this program (Fire_Gimbal_Control.out) ground station
┌───────────────────┐ serial ┌──────────────────────────────────────┐ MQTT ┌──────────────┐
│ gimbal + sensors │ ─────────▶ │ read telemetry → decide when to stop │ ──────▶ │ broker / ZKMS │
│ /dev/ttyACM0 │ ◀───────── │ send move/stop commands │ ◀────── │ control UI │
└───────────────────┘ │ trigger cameras → encode JXL → save │ └──────────────┘
└──────────────┬───────────────────────┘
Vimba X (GigE/USB) │ files
cameras ───────────────────▶ ▼ RGB/ACR/NIR/<unix_ms>.jxl
┌──────────────────────── fgc_core (no SDKs) ─────────────────────────┐
│ Config · Logger · CaptureScheduler · TelemetryParser · CommandParser │
└───────▲─────────────────▲──────────────────▲────────────────────────┘
│ IMotorController │ IControlChannel │ ICameraSource
┌───────────┴──────┐ ┌────────┴─────────┐ ┌──────┴────────────────┐
real │ SerialMotorCtrl │ │ MqttControlChannel│ │ VimbaCameraSource │ (WITH_VIMBA/WITH_MQTT)
mock │ MockMotorCtrl │ │ NullControlChannel│ │ MockCameraSource │
└──────────────────┘ └──────────────────┘ └──────────┬────────────┘
frames
ImagePipeline → .jxl + CamEvent
```
## Quick start
See [docs/architecture.md](docs/architecture.md) for the full design.
## Build
```bash
# 1. Install dependencies (see docs/build-and-setup.md for details + Vimba X SDK)
# 2. Build
make # produces bin/Fire_Gimbal_Control.out
# 3. Run (requires camera(s), motor MCU on /dev/ttyACM0, and a reachable MQTT broker)
./bin/Fire_Gimbal_Control.out --start 1 # auto-start capture
./bin/Fire_Gimbal_Control.out --init 1 --start 1 # also find endstops first
cmake -B build # configure (needs the Vimba X SDK + Paho for a full build)
cmake --build build # -> build/fire_gimbal_control
# Development build with NO proprietary SDKs (mocks only):
cmake -B build -DWITH_VIMBA=OFF -DWITH_MQTT=OFF
cmake --build build
```
> **Before it will run on this machine**, several paths are hardcoded to `/home/ggs/projects/Fire_Gimbal_Control/...`
> and the program exits if MQTT can't connect. Read **[docs/known-issues.md](docs/known-issues.md)** first — it
> lists every reproduction blocker and the deployed layout the binary expects.
Dependencies and the Vimba X SDK setup are in [docs/build-and-setup.md](docs/build-and-setup.md).
## Run
```bash
# Real deployment (needs cameras, motor MCU on the configured serial port, MQTT broker):
scripts/run.sh --start
scripts/run.sh --init --start # find endstops first
# Development, no hardware or broker:
scripts/run.sh --mock-serial --mock-camera --no-mqtt --start --log-level debug
```
Config is resolved from `--config <path>`, then `$FGC_CONFIG`, `./config.ini`, the executable's directory, and
`$XDG_CONFIG_HOME/fire_gimbal_control/config.ini`. Copy the template to get started:
```bash
cp config/config.example.ini config.ini # then edit
```
Type `exit` (or Ctrl-D) to stop. See [docs/configuration.md](docs/configuration.md) for all options.
## Test
```bash
cmake -B build -DWITH_VIMBA=OFF -DWITH_MQTT=OFF -DBUILD_TESTING=ON
cmake --build build
ctest --test-dir build --output-on-failure
```
The unit tests cover the core (config, paths, telemetry/command parsers, capture scheduler) and need no SDKs.
## Documentation
| Document | Contents |
|----------|----------|
| [docs/architecture.md](docs/architecture.md) | System overview, threading model, end-to-end data flow, capture state machine |
| [docs/build-and-setup.md](docs/build-and-setup.md) | Toolchain, dependencies, build, serial/MQTT setup, directory layout |
| [docs/configuration.md](docs/configuration.md) | `config.ini` keys, CLI flags, console command grammar |
| [docs/mqtt-api.md](docs/mqtt-api.md) | MQTT topic catalog, payloads, QoS/retain, ControlCode semantics |
| [docs/modules-reference.md](docs/modules-reference.md) | Per-file reference and key data structures |
| [docs/known-issues.md](docs/known-issues.md) | Reproduction blockers and recommended follow-ups |
| [docs/architecture.md](docs/architecture.md) | Components, interfaces, threading, data flow, capture state machine |
| [docs/build-and-setup.md](docs/build-and-setup.md) | CMake, dependencies, options, Vimba X / Paho, host setup |
| [docs/configuration.md](docs/configuration.md) | `config.ini` keys, CLI flags, console commands |
| [docs/mqtt-api.md](docs/mqtt-api.md) | MQTT topic catalog and payloads |
| [docs/modules-reference.md](docs/modules-reference.md) | Per-file reference and data structures |
| [docs/known-issues.md](docs/known-issues.md) | Status of past issues + remaining caveats |
## Repository layout
```
.
├── main.cpp Entry point: config, CLI args, threads, main control loop
├── Serial.h SerialPort + motor_info telemetry parser (Boost.Asio)
├── MQTT.h / MQTT.cpp MQTTClient + callbacks (Eclipse Paho C++)
├── Camera.h / Camera.cpp VimbaHandler: acquisition, queue, JXL save (Vimba X + OpenCV)
├── JPEG_XL.h JPEG XL encoder wrapper (libjxl)
├── Parser.h Console command parser (Boost.Spirit Qi) + command evaluator
├── timing.h Timer / timestamp helpers
├── ini.c / ini.h inih INI parser (third-party)
├── cxxopts.hpp Third-party CLI parser (legacy/unused — Boost is used instead)
├── Log.h Empty stub
├── config.ini Configuration (also a separate copy under bin/x64/Release/)
├── Makefile Build definition
└── bin/x64/Release/ Deployed/runtime directory (binary, config, startup scripts, image folders)
CMakeLists.txt Build (options: WITH_VIMBA, WITH_MQTT, BUILD_TESTING)
cmake/ FindVmb.cmake (Vimba X), Paho.cmake (FetchContent)
config/ config.example.ini (real config.ini is gitignored)
include/fgc/ Public headers: interfaces, Config, Logger, scheduler, impls
mock/ Mock/null implementations
src/
core/ Config, Logger, Paths, parsers, CaptureScheduler, Application
serial/ SerialMotorController
mqtt/ MqttControlChannel
camera/ VimbaCameraSource, ImagePipeline, JpegXlEncoder
main.cpp Thin entry point (CLI -> Application)
tests/ doctest unit tests
scripts/ run.sh + systemd unit template
ini.c / ini.h Bundled inih INI parser
```
## License / ownership
## Ownership
Internal tooling for the GGS fire-watch tower network. No license file is present in the repository.

View File

@ -1 +0,0 @@
~/projects/Fire_Gimbal_Control/bin/x64/Release/Fire_Gimbal_Control.out -s 1

View File

@ -1 +0,0 @@
~/projects/Fire_Gimbal_Control/bin/x64/Release/Fire_Gimbal_Control.out -i 1 -s 1

View File

@ -1,131 +1,91 @@
# Architecture
## Purpose
## Overview
The program automates a **pan gimbal** carrying up to four cameras. It periodically rotates the gimbal to a
series of headings, stops, triggers the cameras, compresses each captured frame to JPEG XL, writes it to disk,
and announces the capture over MQTT so a ground station can ingest the images for wildfire detection. Remote
operators can override the behaviour over MQTT (e.g. point at a specific heading).
The program rotates a pan gimbal carrying up to four cameras, periodically stops/points it, triggers the
cameras, compresses frames to JPEG XL, writes them to disk, and announces each capture over MQTT. Remote
operators can override behaviour over MQTT.
There is no persistent state beyond image files. Everything is one of:
- **Configuration** — read once at startup from `config.ini`.
- **Live state** — in-memory C++ structs, guarded by mutexes, overwritten each cycle.
- **Artifacts**`.jxl` image files on disk.
- **Messages** — MQTT publish/subscribe (no local persistence).
The design separates **policy** (the control logic) from **mechanism** (the I/O to hardware/broker):
## Component overview
- **`fgc_core`** — an SDK-independent static library: typed configuration, path resolution, logging, the
telemetry/command parsers, and the `CaptureScheduler` (control state machine). Depends on nothing
proprietary, so it builds and unit-tests anywhere.
- **Three interfaces** abstract the outside world, each with a real and a mock/null implementation:
| Interface | Real | Mock / Null |
|-----------|------|-------------|
| `IMotorController` | `SerialMotorController` (Boost.Asio) | `MockMotorController` (simulated sweep) |
| `IControlChannel` | `MqttControlChannel` (Paho) | `NullControlChannel` (no broker) |
| `ICameraSource` | `VimbaCameraSource` (Vimba X) | `MockCameraSource` (synthetic frames) |
`Application` picks real vs mock from config + CLI, wires everything to the `ImagePipeline` and
`CaptureScheduler`, and runs the loop. Selecting mocks lets the whole system run with **no hardware or broker**.
```
┌──────────────────────────────────────────────────────┐
│ main.cpp │
│ - load config.ini (ini.c) │
│ - parse CLI args (Boost.Program_options) │
│ - own the 10 ms control loop │
└───┬───────────────┬───────────────┬──────────────┬─────┘
│ │ │ │
reads/writes │ reads │ reads │ drives │
▼ ▼ ▼ ▼
┌────────────────┐ ┌───────────┐ ┌──────────────┐ ┌──────────┐
│ SerialPort │ │ MQTTClient│ │ Parser / │ │VimbaHandler│
│ (Serial.h) │ │ (MQTT.*) │ │ CMD_eval │ │ (Camera.*) │
│ Boost.Asio │ │ Paho C++ │ │ Boost.Spirit │ │ Vimba X │
└──────┬─────────┘ └─────┬─────┘ └──────────────┘ └─────┬──────┘
│ │ │
serial │ /dev/ttyACM0 │ TCP GigE/USB │
▼ ▼ ▼
motor controller MQTT broker cameras
(MCU + sensors) (ground station) (RGB/ACR/NIR)
┌──────────────── fgc_core (no SDKs) ───────────────┐
main.cpp ──► Application ──► CaptureScheduler ──► (interfaces below) │
│ Config · Logger · Paths · TelemetryParser · CommandParser│
└───────────────────────────────────────────────────────────┘
│ builds + owns
┌────────────────┼───────────────────────────┬──────────────────────┐
IMotorController IControlChannel ICameraSource ImagePipeline
Serial/Mock Mqtt/Null Vimba/Mock ◄── frames ──┘ encode→.jxl→CamEvent
│ │ │
serial MQTT broker cameras
```
| Component | File(s) | Responsibility |
|-----------|---------|----------------|
| Entry / control loop | [main.cpp](../main.cpp) | Config, CLI, thread orchestration, capture cycle logic |
| Serial telemetry & commands | [Serial.h](../Serial.h) | Open `/dev/ttyACM0`, parse `motor_info` telemetry, send motor commands |
| MQTT client | [MQTT.h](../MQTT.h), [MQTT.cpp](../MQTT.cpp) | Connect to broker, subscribe to control topics, publish status & cam events |
| Camera acquisition | [Camera.h](../Camera.h), [Camera.cpp](../Camera.cpp) | Vimba X cameras, frame queue, JXL encode + save, cam event publish |
| JPEG XL encoder | [JPEG_XL.h](../JPEG_XL.h) | Wrap libjxl to encode an 8-bit image buffer to a `.jxl` file |
| Console parser | [Parser.h](../Parser.h) | Parse stdin command lines into actions |
| Timing helpers | [timing.h](../timing.h) | Stopwatch + Unix-ms timestamps used for filenames and loop pacing |
| INI parser | [ini.c](../ini.c), [ini.h](../ini.h) | Third-party `inih` used to read `config.ini` |
## Threading model
The process runs five concurrent contexts:
| Thread | Where | Role |
|--------|-------|------|
| Main / control loop | `Application::run` | 10 ms tick: drain console commands, `scheduler.tick()` |
| stdin reader | `Application` | reads command lines into a queue |
| Serial I/O | `SerialMotorController` | Boost.Asio `io_context`; async read-until parses telemetry |
| 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) | delivers frames via the observer (real source) |
| Thread | Created in | Loop / blocking behaviour |
|--------|-----------|---------------------------|
| **Main / control loop** | `main()` | `while(running)`; 10 ms sleep per tick ([main.cpp](../main.cpp) lines 243-336) |
| **Serial I/O** | `main()``io_thread` | Runs `boost::asio::io_service::run()`; async read-until `\n` re-arms itself ([Serial.h](../Serial.h) lines 123-139) |
| **stdin reader** | `main()``inputThread` | `readInput()` blocks on `std::getline`, feeds the `Parser` ([main.cpp](../main.cpp) lines 42-59) |
| **MQTT client** | `MQTTClient::connect_client()``mqtt_thread` | Connects, then **busy-waits** `while(running);` ([MQTT.cpp](../MQTT.cpp) lines 82-99). Paho's own threads deliver callbacks |
| **Image saver** | `VimbaHandler::Start()``image_saver_thread` | Waits on a condition variable, drains the per-camera queues, encodes + writes JXL ([Camera.cpp](../Camera.cpp) lines 303-394) |
Shared state is mutex-guarded: latest `MotorTelemetry` (serial), `ControlCommand` (channel), the frame queue
(pipeline), and the console command queue. The `CaptureScheduler` runs only on the main thread.
Vimba X additionally delivers frames on its own internal acquisition threads via the `FrameObserver` callback.
## Data flow
### Shared state and synchronization
1. **Startup**`main()` parses CLI, resolves + loads config, constructs `Application`, which builds the
motor/channel/camera (real or mock), the `ImagePipeline`, and the `CaptureScheduler`.
2. **Telemetry** — the real motor controller streams `$;...;` lines; `parseTelemetryLine` turns each into a
`MotorTelemetry` snapshot. The mock synthesizes one.
3. **Control input**`IControlChannel::poll()` returns the latest `ControlCommand` (control code + target
heading), clearing its "available" flags so each update is acted on once.
4. **Capture cycle** (`CaptureScheduler::tick`, per 10 ms):
- ControlCode 0: when the interval elapses, send `p` to advance/stop, then trigger the cameras.
- ControlCode 1: send `kd<target_heading>` to drive to the requested heading, then trigger.
5. **Frame handling** — a triggered camera delivers a `Frame` to the callback, which `submit()`s it to the
`ImagePipeline`. The worker rotates it 90° CCW, encodes JPEG XL (or copies the demo image), writes
`<output_dir>/<label>/<unix_ms>.jxl`, and publishes a `CamEvent`.
| Shared data | Type | Guard | Producer → Consumer |
|-------------|------|-------|---------------------|
| `motor_info` telemetry | struct | `SerialPort::mut` | Serial I/O thread → main loop (`get_controller_info()`) |
| `mqtt_sub_data` (target heading, control code) | struct | `MQTTCallback::mqtt_mut` | Paho callback thread → main loop (`get_sub_data()`, which clears the "available" flags) |
| Per-camera frame queues | `std::queue<ImageStore8Ptr>` (max 100) | `VimbaHandler::queue_mut` | FrameObserver/enqueue → saver thread; saver woken by `cv_proc` |
| `parser_data` (console command) | struct | `Parser::mut` | stdin thread → main loop |
## End-to-end data flow
1. **Startup**`main()` loads `config.ini`, parses CLI flags, constructs `SerialPort`, `MQTTClient`,
and `VimbaHandler`. If `--init` is set, it sends an endstop-finding command sequence to the motor controller
(`r`, `e`, `q`, wait 60 s, `y`, `ud56`).
2. **Telemetry ingest** — the motor controller streams lines like
`$;<Xenc>;<Xerr>;<sgt_val>;<sgt_stat>;<is_moving>;<control_status>;<hdg>;<deviation_warn>;<humid>;<temp>;<fan_pwm>`.
`SerialPort::parser()` splits on `;`, validates 12 fields, and stores a fresh `motor_info`.
3. **Control decision** (per 10 ms tick, [main.cpp](../main.cpp) lines 293-334):
- Read current `motor_info` and current MQTT `ControlCode`/`target_HDG`.
- **ControlCode 0 (automatic sweep):** when the image interval has elapsed and the camera is started, send
`p` (stop/advance), then once stopped, trigger the cameras.
- **ControlCode 1 (directed):** same timing, but send `kd<heading>` to drive to the MQTT-supplied target
heading before triggering.
4. **Trigger & settle**`TriggerCamera()` resets each observer's `settle` counter to 3 and fires the
`TriggerSoftware` command 4 times (~400 ms apart). The first 3 frames are intentionally dumped (sensor
settling); the 4th is the real image.
5. **Enqueue** — for each completed frame, `EnqueueToStoreStruct()` deep-copies the pixel buffer into an
`image_store_8bit` with a Unix-ms timestamp and pushes it onto that camera's bounded queue (oldest reused if
the queue is at 100). Enqueuing camera 0 notifies the saver via `cv_proc`.
6. **Save**`SaveImage()` pops frames, builds an OpenCV `Mat`, rotates 90° CCW, optionally displays it, then
(unless in demo mode) encodes with `JPEGXL` and writes to
`$HOME/projects/Fire_Gimbal_Control/bin/x64/Release/<RGB|ACR|NIR>/<unix_ms>.jxl`. In demo mode it copies
`test_smoke.jxl` instead.
7. **Announce** — after each saved frame, publish a `CamEvent` JSON message to
`GGS/FWT/<tower>/CamEvent` with the tower, camera name, heading (×10, integer), and timestamp.
## Capture state machine (per camera trigger cycle)
## Capture state machine
```
interval elapsed AND cam_started
interval elapsed AND capture active AND is_moving==1
┌──────────────────────┐ ControlCode 0 → send "p"
│ request gimbal stop │ ControlCode 1 → send "kd<target_HDG>"
└──────────┬───────────┘ set trigger_after_stopping = true, reset loop_timer
▼ (after >100 ms, when is_moving == 1)
┌──────────────────────┐
│ TriggerCamera() │ reset settle=3; fire TriggerSoftware ×4 (400 ms apart)
└──────────┬───────────┘
│ frames 1-3 dumped (settling), frame 4 enqueued
┌──────────────────────┐ ControlCode 0 → "p"
│ stop / point gimbal │ ControlCode 1 → "kd<target_heading>"
└──────────┬───────────┘ arm trigger_after_stopping, reset timer
│ (>100 ms later, is_moving==1)
┌──────────────────────┐
enqueue → save → MQTT │ trigger_after_stopping = false
│ camera.trigger() │ on success: disarm
└──────────────────────┘
```
> The settle/trigger timing and the `is_moving == 1` conditions are documented exactly as implemented; see
> [docs/known-issues.md](known-issues.md) for behaviour that looks surprising (e.g. triggering is gated on
> `is_moving == 1` rather than `== 0`).
The trigger predicate (`is_moving == 1`) is preserved from the original; see
[known-issues.md](known-issues.md) for the open question about its semantics. The scheduler is unit-tested with
mock doubles and an injected clock ([tests/test_scheduler.cpp](../tests/test_scheduler.cpp)).
## Why this shape
The design favours **low-latency real-time control** over durability: a tight polling loop reacts to motor
feedback in milliseconds, the camera pipeline is decoupled by an in-memory queue so encoding never blocks
acquisition, and all coordination with the outside world is asynchronous (serial async I/O + MQTT). Persistence
is deliberately limited to image files plus fire-and-forget MQTT messages.
Decoupling the control logic from the SDKs makes the core testable and the binary buildable/runnable without
proprietary dependencies, while preserving the original real-time behaviour. Persistence remains deliberately
limited to image files plus fire-and-forget MQTT.

View File

@ -1,118 +1,103 @@
# Build & Setup
This guide covers building the binary and the host setup needed to run it. For the things that will stop a
clean reproduction on a fresh machine, read [known-issues.md](known-issues.md) alongside this page.
The project builds with **CMake** (≥ 3.20) and a C++17 compiler. The build is split so the SDK-independent
core and the development (mock) build work on any machine; the proprietary/heavy dependencies are optional.
## Toolchain
## Build options
- **g++** with **C++17** (`-std=c++17`), GNU Make. The C file `ini.c` is compiled with **gcc**.
- Linux. Developed/deployed on a tower PC (LattePanda Sigma) running Linux; serial device is `/dev/ttyACM0`.
| Option | Default | Effect |
|--------|---------|--------|
| `WITH_VIMBA` | `ON` | Build the Allied Vision Vimba X camera source. `OFF` → mock cameras only (no SDK needed). |
| `WITH_MQTT` | `ON` | Build the Eclipse Paho MQTT channel (fetched via FetchContent). `OFF` → null channel only. |
| `BUILD_TESTING` | `OFF` | Build the doctest unit tests (fetches doctest). |
See [Makefile](../Makefile) — `CXXFLAGS := -std=c++17 -g -Wall -I.`
```bash
# Full build (requires Vimba X SDK installed + network for Paho)
cmake -B build
cmake --build build # -> build/fire_gimbal_control
# Development build: no proprietary SDKs, no broker
cmake -B build -DWITH_VIMBA=OFF -DWITH_MQTT=OFF
cmake --build build
# With tests
cmake -B build -DWITH_VIMBA=OFF -DWITH_MQTT=OFF -DBUILD_TESTING=ON
cmake --build build && ctest --test-dir build --output-on-failure
```
CMake exports `compile_commands.json` into the build dir for clangd/IDEs.
## Dependencies
The link line in the [Makefile](../Makefile) is:
| Dependency | Needed for | How it's provided |
|------------|-----------|-------------------|
| **CMake ≥ 3.20**, g++/clang with C++17 | always | system package (`cmake`, official repo) |
| **OpenCV** (`core`, `highgui`, `imgproc`) | image rotate/display | `find_package(OpenCV)` — system package |
| **libjxl** (`libjxl`, `libjxl_threads`) | JPEG XL encoding | pkg-config — system package |
| **Boost** (`program_options`; header-only Asio) | CLI parsing, serial I/O | `find_package(Boost CONFIG)` — system package |
| **Threads** | concurrency | system |
| **Eclipse Paho MQTT C/C++** | MQTT (`WITH_MQTT=ON`) | **FetchContent** from official upstream (no AUR / system install) |
| **Allied Vision Vimba X SDK** | cameras (`WITH_VIMBA=ON`) | **proprietary**, install manually (see below) |
```
-lpaho-mqttpp3 -lpaho-mqtt3a -lopencv_core -lopencv_highgui -ljxl -ljxl_threads -lboost_program_options -lVmbC -lVmbCPP
```
| Dependency | Libraries linked | Headers used | Typical source |
|------------|------------------|--------------|----------------|
| **Eclipse Paho MQTT C++** | `paho-mqttpp3`, `paho-mqtt3a` | `mqtt/async_client.h` | `libpaho-mqttpp-dev` / build from source (also needs Paho MQTT C) |
| **OpenCV** | `opencv_core`, `opencv_highgui` | `opencv2/opencv.hpp` | `libopencv-dev` |
| **libjxl (JPEG XL)** | `jxl`, `jxl_threads` | `jxl/encode.h`, `jxl/encode_cxx.h`, `jxl/thread_parallel_runner*.h` | `libjxl-dev` / build from source |
| **Boost** | `boost_program_options` | `boost/asio.hpp`, `boost/program_options.hpp`, `boost/spirit/include/qi.hpp`, `phoenix.hpp` | `libboost-all-dev` (or at least program_options, system, and the header-only Asio/Spirit) |
| **Allied Vision Vimba X SDK** | `VmbC`, `VmbCPP` | `VmbC/VmbC.h`, `VmbCPP/VmbCPP.h` | **Proprietary** — download from Allied Vision (see below) |
`cxxopts.hpp` is bundled but **not used** (the active CLI parser is Boost.Program_options); it does not need to
be installed.
### Debian/Ubuntu package hint
The non-proprietary dependencies are roughly:
On Arch/Manjaro the system deps are roughly:
```bash
sudo apt install build-essential libboost-all-dev libopencv-dev libjxl-dev \
libpaho-mqttpp-dev libpaho-mqtt-dev
sudo pacman -S cmake opencv libjxl boost
```
Package names vary by distro/version; `libjxl-dev` and the Paho C++ packages may not exist on older releases and
then have to be built from source. This machine is **Manjaro/Arch** — use `pacman`/AUR equivalents
(`boost`, `opencv`, `libjxl`, `paho-mqtt-c`, `paho-mqtt-cpp`).
(Debian/Ubuntu equivalents: `cmake build-essential libopencv-dev libjxl-dev libboost-all-dev`.)
### Allied Vision Vimba X SDK (proprietary — required)
### Eclipse Paho MQTT (FetchContent, no AUR)
`VmbC`/`VmbCPP` are **not** available through any package manager. Download and install the Vimba X SDK from
Allied Vision (<https://www.alliedvision.com/en/products/software/vimba-x-sdk/>). After install you typically
need to:
When `WITH_MQTT=ON`, CMake downloads and builds Paho MQTT C and C++ from the **official Eclipse repos**
([cmake/Paho.cmake](../cmake/Paho.cmake)), pinned by tag. This keeps the dependency off the AUR. For maximum
integrity, pin `PAHO_C_TAG`/`PAHO_CPP_TAG` to a commit SHA or switch to a hash-verified release tarball. The
first configure needs network access.
- Make the SDK headers visible to the compiler (add `-I<vimbax>/api/include`) and the libs to the linker
(`-L<vimbax>/api/lib`), and/or add the lib dir to `LD_LIBRARY_PATH` / an `ld.so.conf.d` entry at runtime.
- Run the SDK's transport-layer setup so cameras are discoverable (GenICam GenTL producers).
### Allied Vision Vimba X SDK (proprietary)
The current [Makefile](../Makefile) assumes the headers/libs are already on the default include/link paths; on
a fresh machine you will likely need to extend `CXXFLAGS`/`LDFLAGS` with the SDK paths.
## Build
`VmbC`/`VmbCPP` are not in any package manager. Download the Vimba X SDK from Allied Vision
(<https://www.alliedvision.com/en/products/software/vimba-x-sdk/>) and point CMake at it:
```bash
make # compiles ini.c, main.cpp, MQTT.cpp, Camera.cpp → bin/Fire_Gimbal_Control.out
make clean # removes object files and the binary
cmake -B build -DVMB_HOME=/opt/VimbaX_2024-1
# or: export VIMBA_X_HOME=/opt/VimbaX_2024-1
```
- Object files land in `obj/` (the Makefile uses `OBJDIR := obj`).
- The output binary is `bin/Fire_Gimbal_Control.out`.
[cmake/FindVmb.cmake](../cmake/FindVmb.cmake) locates the headers/libs and creates `Vmb::VmbC` / `Vmb::VmbCPP`.
If the SDK is absent, build with `-DWITH_VIMBA=OFF` to get a mock-only binary.
> Note: the committed build artifacts live under `bin/x64/Release/` and `obj/x64/Release/`, but the current
> Makefile writes to `bin/` and `obj/`. The `x64/Release/` layout is the *deployed* layout the running binary
> expects for config and image output (see below and [known-issues.md](known-issues.md)).
## Host setup (for a real run)
## Directory layout (build vs. deployed)
```
<repo>/
├── obj/ ← Makefile object output
├── bin/Fire_Gimbal_Control.out ← Makefile binary output
└── bin/x64/Release/ ← deployed/runtime directory
├── Fire_Gimbal_Control.out ← deployed binary
├── config.ini ← runtime config (differs from repo-root config.ini)
├── startup_gimbal.sh ← launches with --start 1
├── startup_gimbal_with_init.sh ← launches with --init 1 --start 1
├── test_smoke.jxl ← demo-mode placeholder image
├── RGB/ (ACR/ NIR/) ← image output folders (created on demand)
└── crash_genicam.txt ← captured GenICam trigger error log (reference)
```
`compile_commands.json` is present for editor/clangd integration.
## Host setup to run
1. **Serial device** — the motor controller must enumerate as `/dev/ttyACM0` at 115200 8N1. Add your user to
the `dialout` group (or set a udev rule) for non-root access:
1. **Serial device** — the motor controller must appear at the configured `[Serial] device` (default
`/dev/ttyACM0`) at the configured baud. Add your user to `dialout` for non-root access:
```bash
sudo usermod -aG dialout "$USER" # log out/in afterward
sudo usermod -aG dialout "$USER" # re-login afterwards
```
2. **Cameras** — connect the Allied Vision cameras and confirm Vimba X can see them (vendor `VimbaXViewer` /
`ListCameras`). The camera IDs in `config.ini` must match (GigE IPs like `192.168.11.101` or USB
`DEV_...` IDs). For GigE, configure the host NIC on the `192.168.11.x` subnet.
3. **MQTT broker** — a broker must be reachable at the `zkms_server_ip` from `config.ini` with the configured
username/password. **The program exits immediately if it cannot connect** ([main.cpp](../main.cpp) lines
162-165). For local testing run e.g. Mosquitto and point `zkms_server_ip` at `127.0.0.1`.
4. **Paths** — the binary reads config from a hardcoded absolute path and writes images under
`$HOME/projects/Fire_Gimbal_Control/...`. See [known-issues.md](known-issues.md) for the exact paths and how
to satisfy them.
If the port can't be opened the program logs an error and continues (degraded, no telemetry).
2. **Cameras** — connect the Allied Vision cameras and confirm Vimba X sees them. Camera IDs in `config.ini`
must match (GigE IP like `192.168.11.101`, host NIC on that subnet; or USB `DEV_...`).
3. **MQTT broker** — reachable at `[Network] zkms_server_ip` with the credentials (from `$FGC_MQTT_USER`/
`$FGC_MQTT_PW` or the config). Unlike the old version, the program **no longer exits** if MQTT is
unavailable — it logs a warning and continues. Use `--no-mqtt` to skip MQTT entirely.
## Run
```bash
# from the deployed directory
./Fire_Gimbal_Control.out --help # show options
./Fire_Gimbal_Control.out --start 1 # connect, start camera capture immediately
./Fire_Gimbal_Control.out --init 1 --start 1 # find endstops, then start
./Fire_Gimbal_Control.out --demo 1 # demo mode (copies test_smoke.jxl instead of encoding)
scripts/run.sh --start # real deployment
scripts/run.sh --init --start # find endstops, then start
scripts/run.sh --mock-serial --mock-camera --no-mqtt --start # dev, no hardware
```
Type `exit` on the console (or send EOF) to stop. See [configuration.md](configuration.md) for all CLI flags
and the interactive console commands.
[scripts/run.sh](../scripts/run.sh) locates the binary relative to itself, and
[scripts/fire-gimbal-control.service](../scripts/fire-gimbal-control.service) is a systemd unit template
(uses `WorkingDirectory` + `FGC_CONFIG`, so no paths are hardcoded in the binary).
## Directory layout
```
build/ CMake build output (gitignored)
include/fgc/, src/ headers and implementations (see modules-reference.md)
tests/ unit tests
config/config.example.ini template (copy to config.ini)
```

View File

@ -1,115 +1,102 @@
# Configuration & Commands
Three layers control behaviour: the **`config.ini`** file (read once at startup), **command-line flags**, and
**interactive console commands** typed on stdin while running.
Behaviour is controlled by three layers: the **`config.ini`** file, **command-line flags** (which override the
config), and **interactive console commands** typed while running.
## `config.ini`
## Config file resolution
Parsed by the `inih` library ([ini.c](../ini.c)). Keys are flattened to `Section.name` and stored in a
`std::map<std::string,std::string>` ([main.cpp](../main.cpp) lines 35-40, 64-95).
There are no hardcoded paths. The file is resolved in this order ([src/core/Paths.cpp](../src/core/Paths.cpp)):
> **Important:** the binary reads from a **hardcoded absolute path**, not the file next to the binary:
> `/home/ggs/projects/Fire_Gimbal_Control/bin/x64/Release/config.ini` ([main.cpp](../main.cpp) line 66).
> See [known-issues.md](known-issues.md).
1. `--config <path>` CLI flag
2. `$FGC_CONFIG` environment variable
3. `./config.ini` (current directory)
4. `<executable dir>/config.ini`
5. `$XDG_CONFIG_HOME/fire_gimbal_control/config.ini` (else `~/.config/...`)
### Keys
If none exist, the program prints every location it searched and exits. Start from the template:
| Section | Key | Type | Used as | Notes |
|---------|-----|------|---------|-------|
| `General` | `tower_name` | string | Tower identity; substituted into all MQTT topics and CamEvent payloads | e.g. `Rietschen`, `Staeffelsberg` |
| `General` | `image_interval` | int | Seconds between captures; converted to `imagerate = 1/interval` | Required (parsed with `stoi`) |
| `General` | `debug` | int (0/1) | Sets `motorctl_info_out`; when on, prints full telemetry each tick | |
| `Network` | `zkms_server_ip` | string | MQTT broker address the client connects to | "ZKMS" = the ground-station server |
| `Network` | `mqtt_user` | string | MQTT username | Plaintext |
| `Network` | `mqtt_pw` | string | MQTT password | Plaintext |
| `Camera` | `id_Cam1`..`id_Cam4` | string | Camera IDs; non-empty ones are added in order | GigE IP (`192.168.11.101`) or USB ID (`DEV_1AB22C0AADED`) |
Camera index → output folder mapping is by **position** (first configured camera = index 0):
`0 → RGB`, `1 → ACR`, `2 → NIR` ([Camera.cpp](../Camera.cpp) lines 342-347).
### Two config files exist (and differ)
| File | tower_name | interval | broker | cameras |
|------|-----------|----------|--------|---------|
| [config.ini](../config.ini) (repo root) | `Rietschen` | 5 | `10.11.12.13` | 3× GigE IPs `192.168.11.101-103` |
| [bin/x64/Release/config.ini](../bin/x64/Release/config.ini) | `Staeffelsberg` | 3 | `127.0.0.1` | 1× USB `DEV_1AB22C0AADED` |
The Release copy is the Staeffelsberg deployment. Neither sits at the hardcoded path the binary actually reads —
see [known-issues.md](known-issues.md).
### Example
```ini
[General]
tower_name = Staeffelsberg
image_interval = 3
debug = 0
[Network]
zkms_server_ip = 127.0.0.1
mqtt_user = fwt_gimbal
mqtt_pw = aeroaero
[Camera]
id_Cam1 = DEV_1AB22C0AADED
```bash
cp config/config.example.ini config.ini
```
## `config.ini` keys
Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config.cpp)) into a typed
`AppConfig`. Invalid types/values fail fast with a clear message.
| Section | Key | Type | Default | Meaning |
|---------|-----|------|---------|---------|
| `General` | `tower_name` | string | `Unnamed` | Tower identity; used in all MQTT topics/payloads |
| `General` | `image_interval` | int > 0 | `5` | Seconds between captures (→ `image_rate = 1/interval`) |
| `General` | `debug` | bool | `false` | Start with debug-level logging |
| `Network` | `zkms_server_ip` | string | `127.0.0.1` | MQTT broker address |
| `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_Cam1`..`id_Cam4` | string | — | Camera IDs (GigE IP or USB `DEV_...`); non-empty ones used in order |
| `Paths` | `output_dir` | string | `$XDG_DATA_HOME/fire_gimbal_control/images` | Image output dir; supports `~`/`$ENV` |
| `Features` | `enable_mqtt` | bool | `true` | Use MQTT (vs null channel) |
| `Features` | `enable_camera` | bool | `true` | (reserved) |
| `Features` | `enable_serial` | bool | `true` | (reserved) |
| `Features` | `mock_camera` | bool | `false` | Use the simulated camera |
| `Features` | `mock_serial` | bool | `false` | Use the simulated motor controller |
Camera index → output subfolder defaults to `RGB`, `ACR`, `NIR` (`CameraConfig::labels`).
### Secrets
`mqtt_user` / `mqtt_pw` are read from the environment variables **`FGC_MQTT_USER` / `FGC_MQTT_PW`** first,
falling back to the config file. Keep credentials out of `config.ini` (which is gitignored anyway) by exporting
them or using a systemd `EnvironmentFile`.
## Command-line flags
Parsed by Boost.Program_options ([main.cpp](../main.cpp) lines 104-153). All take a `bool` value
(presence + value); the code treats *presence* of the option as "on" regardless of the value.
Parsed by Boost.Program_options ([main.cpp](../main.cpp)). Flags override `[Features]`.
| Flag | Short | Value | Effect |
|------|-------|-------|--------|
| `--help` | `-h` | — | Print options and exit |
| `--init` | `-i` | bool | Run the endstop-finding init sequence before the main loop |
| `--start` | `-s` | bool | Start camera capture automatically (no `start` console command needed) |
| `--demo` | `-d` | bool | Demo/simulation mode: copy `test_smoke.jxl` instead of encoding real frames |
| Flag | Effect |
|------|--------|
| `-h, --help` | Show help and exit |
| `-c, --config <path>` | Explicit config file path |
| `-i, --init` | Run the endstop-finding init sequence before the loop |
| `-s, --start` | Start capture automatically |
| `-d, --demo` | Demo mode: copy the placeholder image instead of encoding |
| `--no-mqtt` | Disable MQTT (use the null channel) |
| `--mock-camera` | Use the simulated camera |
| `--mock-serial` | Use the simulated motor controller |
| `--log-level <lvl>` | `trace`/`debug`/`info`/`warn`/`error`/`off` |
Usage: `./Fire_Gimbal_Control.out --init 1 --start 1`
Typical headless dev run: `scripts/run.sh --mock-serial --mock-camera --no-mqtt --start`.
### Init sequence (`--init`)
When `--init` is set, before entering the loop the program sends to the motor controller, with delays
([main.cpp](../main.cpp) lines 220-241): `r``e``q`*(wait 60 s)*`y``ud56` ("set number of
intervals per turn" = 56). This finds endstops and configures the sweep resolution.
Sends to the motor controller, with delays: `r``e``q`*(wait 60 s)*`y``ud56`
([src/core/Application.cpp](../src/core/Application.cpp), `runInitSequence`).
## Interactive console commands
While running, lines typed on stdin are parsed by [Parser.h](../Parser.h) (Boost.Spirit Qi) into up to four
tokens: `command device option value`. The grammar expects alphabetic tokens separated by spaces and an
optional trailing number. `CMD_eval` then maps them to actions ([main.cpp](../main.cpp) lines 244-273).
Lines on stdin are parsed by `parseCommand` ([src/core/CommandParser.cpp](../src/core/CommandParser.cpp)) — a
whitespace tokenizer (`<verb> [device] [option] [value]`) that replaced the old fragile Boost.Spirit grammar.
Handled in `Application::Impl::handleCommand`.
| Type this | Meaning |
|-----------|---------|
| `start` | Start camera acquisition + saver thread |
| `stop` | Stop camera acquisition |
| `debug` | Toggle telemetry printout (`motorctl_info_out`) |
| `set camera fps <value>` | Change camera acquisition frame rate (`AcquisitionFrameRate`) |
| `set camera jxlq <value>` | Set JPEG XL quality/distance (0 = lossless; higher = lossier) |
| `set camera jxle <value>` | Set JPEG XL effort (libjxl effort level) |
| `set camera display <0\|1>` | Toggle live OpenCV preview window |
| `set fps <value>` | Set the **capture interval rate** `imagerate` (images per second logic), not camera fps |
| `set motorctl <cmd>` | Forward a raw command string to the motor controller over serial |
| `exit` | Stop the program (sets `running = false`) |
| `start` | Start camera acquisition + capture |
| `stop` | Stop acquisition |
| `debug` | Toggle debug logging |
| `set camera jxlq <v>` | JPEG XL distance (0 = lossless) |
| `set camera jxle <v>` | JPEG XL effort |
| `set camera display <0\|1>` | Toggle OpenCV preview window |
| `set camera fps <v>` | Camera acquisition frame rate (real camera only) |
| `set fps <v>` | Capture interval rate (images/second) |
| `set motorctl <cmd>` | Forward a raw command to the motor controller (e.g. `set motorctl kd180`) |
| `exit` | Quit (Ctrl-D also works) |
Notes:
- `set camera ...` options are handled in `VimbaHandler::evaluateCommand` ([Camera.cpp](../Camera.cpp) lines
241-251); only `fps`, `jxlq`, `jxle`, `display` are recognized.
- Because the grammar tokenizes on spaces with a trailing number, commands like `set motorctl <cmd>` rely on the
`<cmd>` landing in the `option` field. Numeric-only motor commands and multi-token strings may not parse as
expected — see [known-issues.md](known-issues.md).
## Motor command vocabulary (emitted by the software)
## Raw motor-controller command reference (observed)
These short strings are sent over serial (`sendCommand`) by the program or via `set motorctl`. They are
interpreted by the **motor controller firmware** (not in this repo); listed here for reference of what the
software emits:
| Command | Sent when | Apparent meaning |
|---------|-----------|------------------|
| `r`, `e`, `q` | init sequence | endstop/homing steps |
| `y` | init sequence | (post-home step) |
| `ud56` | init sequence | set intervals-per-turn = 56 |
| Command | When | Meaning |
|---------|------|---------|
| `r`,`e`,`q`,`y`,`ud56` | init sequence | homing / set intervals-per-turn |
| `p` | ControlCode 0 capture | stop / advance to next interval |
| `kd<heading>` | ControlCode 1 capture | drive to target heading `<heading>` |
| `kd<heading>` | ControlCode 1 capture | drive to target heading |
These are interpreted by the motor-controller firmware (not in this repo).

View File

@ -1,116 +1,47 @@
# Known Issues & Reproduction Blockers
# Known Issues — Status
This page lists everything that stands between a freshly-copied checkout and a running system, plus a few
correctness/robustness notes. These are **documentation only** — no code has been changed. Each entry notes a
recommended fix if you choose to act on it later.
This tracks the reproduction blockers and robustness issues identified in the original code and what the
refactor did about them.
## Reproduction blockers
## Resolved
### 1. Hardcoded config path (will fail on this machine)
[main.cpp](../main.cpp) line 66 reads the config from an absolute path owned by user `ggs`:
| # | Original issue | Resolution |
|---|----------------|------------|
| 1 | Hardcoded config path (`/home/ggs/...`) | Config search order: `--config``$FGC_CONFIG``./config.ini` → exe dir → XDG ([src/core/Paths.cpp](../src/core/Paths.cpp)) |
| 2 | Hardcoded image output path | `[Paths] output_dir` with `~`/`$ENV` expansion + sensible default |
| 3 | Startup scripts with `~/projects/...` | Replaced by path-independent [scripts/run.sh](../scripts/run.sh) + [systemd unit](../scripts/fire-gimbal-control.service) |
| 4 | Vimba X required to build | `WITH_VIMBA` CMake option (default ON); `OFF` builds a mock-only binary |
| 5 | No hardware path; exits if MQTT down | Mock implementations + runtime toggles; MQTT failure now logs and continues |
| 8 | Plaintext MQTT credentials | `$FGC_MQTT_USER`/`$FGC_MQTT_PW` env override; `config.ini` gitignored |
| 9 | MQTT busy-wait (`while(running);`) | Gone — Paho async client + `set_automatic_reconnect`; no spin thread |
| 10 | `parser()` missing return | Old parser removed; `parseTelemetryLine` returns `std::optional` cleanly |
| 11 | Fragile Boost.Spirit command grammar | Replaced by `parseCommand` whitespace tokenizer ([src/core/CommandParser.cpp](../src/core/CommandParser.cpp)) |
| 12 | Two divergent `config.ini` files | Single committed `config/config.example.ini`; real configs gitignored |
```cpp
ini_parse("/home/ggs/projects/Fire_Gimbal_Control/bin/x64/Release/config.ini", handler, &config)
```
Also added along the way: a leveled logger, typed/validated config, an SDK-independent core library, and a
doctest unit-test suite (`ctest`).
On this machine (user `pedro`, repo at `~/code/Fire_Gimbal_Control_staeffelsberg`) that file does not exist, so
the program prints `Can't load config.ini file!` and exits.
## Open / needs hardware confirmation
**To run as-is:** create the expected tree and place a config there:
```bash
mkdir -p /home/ggs/projects/Fire_Gimbal_Control/bin/x64/Release
cp bin/x64/Release/config.ini /home/ggs/projects/Fire_Gimbal_Control/bin/x64/Release/
```
(Or run under a `ggs` user / adjust the path.)
**Recommended fix:** make the config path a CLI argument or resolve it relative to the executable / `$HOME`.
| # | Issue | Status |
|---|-------|--------|
| 6 | Telemetry field order: humidity (field 9) before temperature (field 10) | **Preserved as-is** with a clear comment in [TelemetryParser.cpp](../src/core/TelemetryParser.cpp). Confirm against the motor-controller firmware before changing. |
| 7 | Trigger fires while `is_moving == 1` (rather than when stopped) | **Preserved** behind a named predicate in [CaptureScheduler.cpp](../src/core/CaptureScheduler.cpp). Looks like a bug; confirm the firmware's `is_moving` semantics, then flip if needed. |
### 2. Hardcoded image output path
[Camera.cpp](../Camera.cpp) line 348 writes images under:
## Verification caveats
```
$HOME/projects/Fire_Gimbal_Control/bin/x64/Release/<RGB|ACR|NIR>/<unix_ms>.jxl
```
- **Real Serial/MQTT/Vimba wrappers** were verified by code review, not compilation, on the development
machine (no Paho/Vimba installed there). They are faithful adaptations of the original code. First compile
happens on a machine with the SDKs, or via `WITH_MQTT=ON` (Paho is fetched) + the Vimba X SDK.
- **Makefile parity** was never re-checked because the original Makefile build also can't run without the SDKs.
The Makefile has been removed in favour of CMake; if you need to confirm byte-for-byte behaviour, do a full
`WITH_VIMBA=ON WITH_MQTT=ON` build on a tower PC.
- **Demo mode** copies `bin/x64/Release/test_smoke.jxl`, resolved relative to the working directory. Run from a
directory where that path exists, or extend `ImagePipeline::Params::demo_image`.
This uses `$HOME` (so it follows the running user) but a **fixed `projects/Fire_Gimbal_Control/...` subtree**,
not the repo location. The directories are created on demand, so this mainly matters for knowing where images
land and for free-space planning.
**Recommended fix:** derive the output root from config.
## Possible follow-ups (not done)
### 3. Startup scripts point at the deployed tree
[bin/x64/Release/startup_gimbal.sh](../bin/x64/Release/startup_gimbal.sh) and
[startup_gimbal_with_init.sh](../bin/x64/Release/startup_gimbal_with_init.sh) invoke:
```
~/projects/Fire_Gimbal_Control/bin/x64/Release/Fire_Gimbal_Control.out ...
```
i.e. the deployed `~/projects/...` layout, **not** `~/code/Fire_Gimbal_Control_staeffelsberg`. To use the
scripts unchanged, deploy the built binary + config into that tree; otherwise edit the scripts to point at your
build output.
### 4. Proprietary SDK required — Allied Vision Vimba X
`VmbC`/`VmbCPP` are not installable via any package manager. Without the Vimba X SDK the project **will not
compile or link** (`-lVmbC -lVmbCPP`, headers `VmbC/VmbC.h`, `VmbCPP/VmbCPP.h`). Download from Allied Vision and
add the SDK include/lib paths — see [build-and-setup.md](build-and-setup.md). Camera IDs in `config.ini` must
match the transport: GigE IPs (`192.168.11.10x`, host NIC on that subnet) or USB IDs (`DEV_...`).
### 5. Hardware + broker needed for a full run
A complete run needs all of:
- the **motor controller MCU** enumerated at `/dev/ttyACM0` (115200 8N1), user in `dialout`;
- the **cameras** reachable by Vimba X;
- a **reachable MQTT broker** at `zkms_server_ip` with the configured credentials.
If serial open fails it is caught and logged (the program continues but has no telemetry). If MQTT does not
connect, the program **exits** ([main.cpp](../main.cpp) lines 162-165).
**Demo mode is not a no-hardware path.** `--demo 1` skips JXL encoding (copies `test_smoke.jxl` instead), but
`VimbaHandler`'s constructor still starts the Vimba system and calls `Open()` on each configured camera, so
cameras must still be present. There is no flag to bypass cameras entirely.
## Correctness / robustness notes
### 6. Telemetry field order (humid before temp)
The serial parser maps `values[9] → humid` and `values[10] → temp` ([Serial.h](../Serial.h) lines 99-100). The
motor-controller firmware must emit fields in that order. If temperature/humidity look swapped, this is why.
### 7. Trigger gated on `is_moving == 1`
In the capture state machine the actual `TriggerCamera()` call happens only while `act_info.is_moving == 1`
([main.cpp](../main.cpp) lines 305-306 and 328-329), i.e. it triggers *while the gimbal still reports moving*
rather than after it has stopped. Documented as-is; verify against the firmware's `is_moving` semantics if
captured frames look motion-blurred.
### 8. Plaintext MQTT credentials
`mqtt_user` / `mqtt_pw` are stored in plaintext in `config.ini` and printed paths/values go to stdout.
**Recommended:** restrict file permissions and/or source credentials from the environment or a secrets store.
### 9. `MQTTClient::run()` busy-waits
After connecting, the MQTT thread spins on `while (running) ;` ([MQTT.cpp](../MQTT.cpp) lines 97-98), pegging a
CPU core. Functionally harmless but wasteful. **Recommended:** replace with a condition variable / sleep, or
simply let the thread exit since Paho's own threads handle delivery.
### 10. `SerialPort::parser()` missing return on non-`$` lines
`parser()` returns nothing when the first character isn't `$` ([Serial.h](../Serial.h) lines 74-109) — undefined
behaviour for a non-void function. In practice the result is ignored, but compile with `-Wall -Wreturn-type`
(already `-Wall`) and consider adding an explicit `return false;`.
### 11. Console grammar limits on `set motorctl`
The Boost.Spirit grammar expects `command device option <double>` with alphabetic tokens
([Parser.h](../Parser.h) lines 52-62). Raw motor commands that are numeric or multi-token may not land in the
`option` field as intended. Verify any `set motorctl <cmd>` you rely on actually parses (watch the echoed
`command/device/option/value` line).
### 12. Two divergent `config.ini` files
[config.ini](../config.ini) (root: `Rietschen`, 3 GigE cameras) and
[bin/x64/Release/config.ini](../bin/x64/Release/config.ini) (`Staeffelsberg`, 1 USB camera) differ, and
**neither is at the path the binary reads** (issue #1). Decide which is authoritative for this deployment and
place it at the expected path.
## Minimal local bring-up checklist
1. Install non-proprietary deps + Vimba X SDK; `make` succeeds. ([build-and-setup.md](build-and-setup.md))
2. Put a valid `config.ini` at `/home/ggs/projects/Fire_Gimbal_Control/bin/x64/Release/config.ini` (issue #1),
with `zkms_server_ip = 127.0.0.1` and a local broker running (e.g. Mosquitto).
3. Ensure at least one configured camera is visible to Vimba X and on the right subnet/USB.
4. Connect the motor MCU on `/dev/ttyACM0` (or expect telemetry-less operation).
5. Run `./Fire_Gimbal_Control.out --start 1` and watch `GGS/FWT/<tower>/CamEvent` over MQTT and the
`<RGB|ACR|NIR>/` image folders.
- Graceful shutdown on SIGINT (currently exit via `exit`/Ctrl-D; a pending `getline` can delay shutdown).
- Make the camera index→label map and JPEG XL defaults fully config-driven.
- Reintroduce optional image upload to the ground station, config-driven (the old hardcoded NFS/SMB upload was
removed).

View File

@ -1,118 +1,76 @@
# Module Reference
Per-file reference for the source tree, plus the key in-memory data structures (this project's "data model").
Per-file reference for the refactored tree, plus the shared data structures.
## Source files
## Core (`fgc_core` — SDK-independent, unit-tested)
### [main.cpp](../main.cpp) — entry point & control loop
- `handler()` — inih callback flattening INI keys to `Section.name`.
- `readInput()` — stdin thread; reads lines, forwards to the `Parser`, handles `exit`.
- `main()` — loads config, parses CLI flags, constructs `SerialPort`, `MQTTClient`, `VimbaHandler`; starts the
serial I/O thread and stdin thread; optionally runs the init sequence; runs the 10 ms control loop
(command eval → telemetry poll → MQTT poll → capture state machine); cleans up on exit.
- Global state lives at file scope (`running`, `imagerate`, `ctl_code`, `mqtt_hdg`, `mqtt_client`,
`camera_id_vec`, …).
| File | Contents |
|------|----------|
| [include/fgc/Config.h](../include/fgc/Config.h), [src/core/Config.cpp](../src/core/Config.cpp) | Typed `AppConfig` (General/Network/Serial/Camera/Paths/Features) + `ConfigLoader` (INI parse, env overrides, validation) |
| [include/fgc/Paths.h](../include/fgc/Paths.h), [src/core/Paths.cpp](../src/core/Paths.cpp) | `~`/`$ENV` expansion, executable dir, config search order, default output dir |
| [include/fgc/Logger.h](../include/fgc/Logger.h), [src/core/Logger.cpp](../src/core/Logger.cpp) | Leveled, thread-safe logger; `LOG_TRACE..LOG_ERROR` macros |
| [include/fgc/TelemetryParser.h](../include/fgc/TelemetryParser.h), [src/core/TelemetryParser.cpp](../src/core/TelemetryParser.cpp) | `parseTelemetryLine``std::optional<MotorTelemetry>` |
| [include/fgc/CommandParser.h](../include/fgc/CommandParser.h), [src/core/CommandParser.cpp](../src/core/CommandParser.cpp) | `parseCommand` whitespace tokenizer → `Command` |
| [include/fgc/CaptureScheduler.h](../include/fgc/CaptureScheduler.h), [src/core/CaptureScheduler.cpp](../src/core/CaptureScheduler.cpp) | Capture state machine over the interfaces; injectable clock |
| [include/fgc/Application.h](../include/fgc/Application.h), [src/core/Application.cpp](../src/core/Application.cpp) | Factory (real vs mock), wiring, control loop, console commands |
| [ini.c](../ini.c), [ini.h](../ini.h) | Bundled third-party inih INI parser |
### [Serial.h](../Serial.h) — motor controller link
- `struct motor_info` — telemetry snapshot (see below).
- `class SerialPort` — opens the port (115200 8N1, no parity/flow), async read-until `\n`, async write.
- `parser()` — splits a `$`-prefixed `;`-delimited line into 12 fields and stores a `motor_info`.
- `set_controller_info()` / `get_controller_info()` — mutex-guarded accessors.
- `sendCommand()` — async-write a command string.
- `run()` / `stop()` — drive the Boost.Asio `io_service`.
## Interfaces
### [MQTT.h](../MQTT.h) / [MQTT.cpp](../MQTT.cpp) — MQTT client
- `struct mqtt_sub_data` — latest remote control state + "available" flags (see below).
- `class MQTTCallback` — Paho callback/listener: subscribes on connect, parses inbound messages, auto-reconnects
on loss; `get_sub_data()` returns + clears the available flags.
- `class MQTTClient` — owns the `async_client`, connect options (auth, keep-alive, clean session); `publish()`
sends QoS 1 retained messages. See [mqtt-api.md](mqtt-api.md) for the topic catalog.
| File | Interface | Shared structs |
|------|-----------|----------------|
| [include/fgc/IMotorController.h](../include/fgc/IMotorController.h) | `IMotorController` | `MotorTelemetry` |
| [include/fgc/IControlChannel.h](../include/fgc/IControlChannel.h) | `IControlChannel` | `ControlCommand`, `CamEvent` |
| [include/fgc/ICameraSource.h](../include/fgc/ICameraSource.h) | `ICameraSource` | `Frame` |
### [Camera.h](../Camera.h) / [Camera.cpp](../Camera.cpp) — camera pipeline (Vimba X)
- `struct image_store_8bit` — owns a deep copy of one frame's pixel buffer + metadata (see below).
- `class FrameObserver` (in .cpp) — Vimba `IFrameObserver`; dumps the first 3 frames after each trigger
(`settle`), enqueues complete frames, re-queues buffers to the camera.
- `class VimbaHandler` — starts up the Vimba system, opens cameras by ID, manages the per-camera bounded
queues and the saver thread.
- `Start()`/`Stop()` — begin/end continuous acquisition + saver thread.
- `EnqueueToStoreStruct()` — copy a received frame into the queue (reuse oldest if at 100).
- `SaveImage()` — saver loop: rotate 90° CCW, optionally display, encode JXL (or copy `test_smoke.jxl` in
demo), write file, publish CamEvent.
- `TriggerCamera()` / `TriggerSettle()` — fire `TriggerSoftware` ×4 with settle reset.
- `ChangeFramerate()`, `evaluateCommand()`, `SetTowerName()`.
## Real implementations (SDK-gated)
### [JPEG_XL.h](../JPEG_XL.h) — `class JPEGXL`
Wraps libjxl. Constructor encodes an 8-bit interleaved buffer (1 or 3 channels) into an in-memory JXL
codestream using a thread-parallel runner; `q == 0` → lossless, otherwise `q` is the frame distance; `e` is the
effort level. `WriteFile()` flushes the codestream to disk.
| File | Implements | Built when |
|------|-----------|-----------|
| [src/serial/SerialMotorController.cpp](../src/serial/SerialMotorController.cpp) | `IMotorController` over Boost.Asio serial (pImpl) | always |
| [src/mqtt/MqttControlChannel.cpp](../src/mqtt/MqttControlChannel.cpp) | `IControlChannel` over Eclipse Paho | `WITH_MQTT` |
| [src/camera/VimbaCameraSource.cpp](../src/camera/VimbaCameraSource.cpp) | `ICameraSource` over Vimba X (pImpl) | `WITH_VIMBA` |
| [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 |
### [Parser.h](../Parser.h) — console command parsing
- `enum InputCommands` — command kinds (`startcamera`, `stopcamera`, `setcamera`, `setimagerate`,
`setmotorcontrol`, `setdebug`, `no_cmd`, `setgimbal`).
- `struct parser_data` — parsed tokens (see below).
- `struct Parser` — Boost.Spirit Qi grammar splitting input into `command device option value`; mutex-guarded.
- `struct CMD_eval` — maps `parser_data` to an `InputCommands` value. See [configuration.md](configuration.md).
## Mock / null implementations
### [timing.h](../timing.h) — time helpers
- `class Timer` — high-resolution stopwatch (`Reset`, `Elapsed`, `ElapsedMillis`) plus string/file timestamp
helpers; used for loop pacing and profiling.
- `class NanoUnixTimer` — Unix-epoch **millisecond** timestamps (`Stamp_longlong`, `Stamp_string`) used for
image filenames and CamEvent `time`.
- `class ScopedTimer` — RAII timer that logs elapsed time on destruction.
| File | Implements |
|------|-----------|
| [include/fgc/mock/MockMotorController.h](../include/fgc/mock/MockMotorController.h) | Simulated sweeping gimbal |
| [include/fgc/mock/NullControlChannel.h](../include/fgc/mock/NullControlChannel.h) | No-op channel; auto-sweep |
| [include/fgc/mock/MockCameraSource.h](../include/fgc/mock/MockCameraSource.h) | Synthetic gradient frames |
### [ini.c](../ini.c) / [ini.h](../ini.h) — third-party `inih`
Minimal INI parser (`ini_parse`). Unmodified vendored library.
## Entry point & scripts
### Other files
- [cxxopts.hpp](../cxxopts.hpp) — third-party CLI parser, **included but unused** (the live CLI parsing uses
Boost.Program_options; the cxxopts block in `main.cpp` is commented out).
- [Log.h](../Log.h) — empty stub (`#pragma once` only).
| File | Role |
|------|------|
| [main.cpp](../main.cpp) | CLI parsing → `AppConfig` + `RuntimeOptions``Application::run()` |
| [scripts/run.sh](../scripts/run.sh) | Path-independent launcher |
| [scripts/fire-gimbal-control.service](../scripts/fire-gimbal-control.service) | systemd unit template |
## Data structures (the in-memory "data model")
## Data structures
### `motor_info` ([Serial.h](../Serial.h) lines 6-19)
Telemetry from the motor controller, parsed from a 12-field `$`-line.
### `MotorTelemetry` ([IMotorController.h](../include/fgc/IMotorController.h))
`encoder`, `encoder_err`, `sgt_val`, `sgt_stat`, `is_moving`, `control_status`, `heading` (float),
`deviation_warn`, `humidity`, `temperature`, `fan_pwm`. Parsed from the `$;...;` line (humidity is field 9,
temperature field 10 — see [known-issues.md](known-issues.md)).
| Field | Type | Meaning | Source field index |
|-------|------|---------|---------------------|
| `Xenc` | int | Encoder position | 1 |
| `Xerr` | int | Encoder error | 2 |
| `sgt_val` | int | StallGuard value | 3 |
| `sgt_stat` | int | StallGuard status | 4 |
| `is_moving` | int | Movement flag | 5 |
| `control_status` | int | Driver/controller status | 6 |
| `hdg` | float | Heading (degrees) | 7 |
| `deviation_warn` | int | Deviation warning | 8 |
| `humid` | int | Humidity | **9** |
| `temp` | int | Temperature | **10** |
| `fan_pwm` | int | Fan PWM (0-255) | 11 |
### `ControlCommand` ([IControlChannel.h](../include/fgc/IControlChannel.h))
`control_code` (0 = auto sweep, 1 = directed) + `target_heading`, each with an `*_available` flag.
> Field 0 is the literal `$` marker. Note the order: index 9 → `humid`, index 10 → `temp` (see
> [known-issues.md](known-issues.md) so the firmware emits them in this order).
### `CamEvent` ([IControlChannel.h](../include/fgc/IControlChannel.h))
`tower`, `camera` (RGB/ACR/NIR), `heading_decideg` (heading×10), `timestamp_ms`. Serialized to the CamEvent
JSON payload (see [mqtt-api.md](mqtt-api.md)).
### `mqtt_sub_data` ([MQTT.h](../MQTT.h) lines 7-21)
Latest remote-control state.
| Field | Type | Meaning |
|-------|------|---------|
| `ctl_avail` | bool | A new ControlCode arrived since last read |
| `hdg_avail` | bool | A new target heading arrived since last read |
| `target_heading` | string | Target heading (kept as string, forwarded as `kd<heading>`) |
| `control_code` | int | 0 = auto sweep, 1 = directed |
### `parser_data` ([Parser.h](../Parser.h) lines 21-26)
One parsed console command: `command`, `device`, `option` (strings) and `command_val` (double).
### `image_store_8bit` ([Camera.h](../Camera.h) lines 14-63)
One captured frame held in the queue: a `std::vector<VmbUchar_t>` pixel buffer plus `width`, `height`,
`pixelFormat`, a Unix-ms `timestamp`, and `cam_id`. Provides `equal()`/`setData()` so the queue can reuse the
oldest buffer in place when full.
### `Frame` ([ICameraSource.h](../include/fgc/ICameraSource.h))
Owned pixel buffer + `width`, `height`, `channels` (1 or 3), `timestamp_ms`, `cam_id`.
## On-disk artifacts
| Artifact | Path | Format |
|----------|------|--------|
| Captured images | `$HOME/projects/Fire_Gimbal_Control/bin/x64/Release/<RGB\|ACR\|NIR>/<unix_ms>.jxl` | JPEG XL, rotated 90° CCW |
| Demo placeholder | `bin/x64/Release/test_smoke.jxl` | JPEG XL (copied verbatim in demo mode) |
| Captured images | `<output_dir>/<RGB\|ACR\|NIR>/<unix_ms>.jxl` | JPEG XL, rotated 90° CCW |
| Demo placeholder | `bin/x64/Release/test_smoke.jxl` | copied verbatim in demo mode |
There is no other persistence: state lives in memory and diagnostics go to stdout/stderr only (no log files).
State otherwise lives in memory; diagnostics go to stdout/stderr (no log files, no database).

View File

@ -1,7 +1,10 @@
# MQTT API
The program is both an MQTT **subscriber** (remote control) and **publisher** (status + capture events). It
uses the Eclipse Paho C++ async client ([MQTT.cpp](../MQTT.cpp)). All topics are namespaced by tower name:
The program is both an MQTT **subscriber** (remote control) and **publisher** (status + capture events). The
MQTT channel is the `MqttControlChannel` implementation of `IControlChannel`
([src/mqtt/MqttControlChannel.cpp](../src/mqtt/MqttControlChannel.cpp), Eclipse Paho C++). It is used only when
MQTT is enabled; otherwise a `NullControlChannel` runs (publishes dropped, auto-sweep mode). All topics are
namespaced by tower name:
```
GGS/FWT/<tower_name>/...
@ -13,15 +16,16 @@ are **retained**.
## Connection
- Broker URI = `Network.zkms_server_ip` from `config.ini`; client ID = the tower name.
- Auth: `mqtt_user` / `mqtt_pw` from config; `clean_session = true`, keep-alive 20 s.
- Connect timeout 5 s; on connection loss the client auto-reconnects (`reconnect()` with a 2.5 s backoff,
re-subscribing on success).
- **The program exits at startup if the initial connect fails** ([main.cpp](../main.cpp) lines 162-165).
- Auth: `mqtt_user` / `mqtt_pw` (preferring `$FGC_MQTT_USER`/`$FGC_MQTT_PW`); `clean_session = true`,
keep-alive 20 s, `set_automatic_reconnect(true)`.
- Connect timeout 5 s. On connection loss Paho auto-reconnects; the channel re-subscribes on reconnect.
- **The program no longer exits if MQTT is unavailable** — it logs a warning and continues in degraded mode.
Use `--no-mqtt` to disable MQTT entirely (null channel).
## Subscribed topics (inbound — remote control)
Subscribed in `MQTTCallback::connected()` ([MQTT.cpp](../MQTT.cpp) lines 17-23). Handled in
`message_arrived()` ([MQTT.cpp](../MQTT.cpp) lines 25-54).
Subscribed on (re)connect; handled in `MqttControlChannel::message_arrived()`
([src/mqtt/MqttControlChannel.cpp](../src/mqtt/MqttControlChannel.cpp)).
| Topic | Payload | Parsed as | Effect |
|-------|---------|-----------|--------|
@ -31,7 +35,7 @@ Subscribed in `MQTTCallback::connected()` ([MQTT.cpp](../MQTT.cpp) lines 17-23).
Invalid (non-integer) payloads are caught and logged; the previous value is kept.
The main loop consumes these via `get_sub_data()`, which returns a snapshot and **clears the "available"
flags** so each update is acted on once ([MQTT.h](../MQTT.h) lines 125-131).
flags** so each update is acted on once (`MqttControlChannel::poll()`).
### ControlCode semantics
@ -51,7 +55,7 @@ When a ControlCode message arrives, the program echoes the current code back on
### CamEvent payload
Built in [Camera.cpp](../Camera.cpp) line 389:
Built by `MqttControlChannel::publishCamEvent` from a `CamEvent`:
```json
{ "fwt":"Staeffelsberg", "cam":"RGB", "hdg":1373, "time":1719312345678 }

View File

@ -0,0 +1,25 @@
# systemd unit template for Fire Gimbal Control.
# Copy to /etc/systemd/system/, adjust the paths/user, then:
# sudo systemctl daemon-reload
# sudo systemctl enable --now fire-gimbal-control
#
# Credentials are passed via the environment (not the config file). Prefer an
# EnvironmentFile with 0600 perms over inline Environment= lines.
[Unit]
Description=Fire Gimbal Control
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=ggs
WorkingDirectory=/opt/fire_gimbal_control
ExecStart=/opt/fire_gimbal_control/fire_gimbal_control --start
Environment=FGC_CONFIG=/opt/fire_gimbal_control/config.ini
# EnvironmentFile=/etc/fire_gimbal_control/credentials.env # FGC_MQTT_USER / FGC_MQTT_PW
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target

20
scripts/run.sh Executable file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Launch fire_gimbal_control, resolving the binary relative to this script so it
# works regardless of where the repo is checked out. Extra args are forwarded.
#
# scripts/run.sh --start
# scripts/run.sh --mock-serial --mock-camera --no-mqtt --start # dev, no hardware
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
bin=""
for cand in "$here/../build/fire_gimbal_control" "$here/fire_gimbal_control" \
"$here/../fire_gimbal_control"; do
if [[ -x "$cand" ]]; then bin="$cand"; break; fi
done
if [[ -z "$bin" ]]; then
echo "fire_gimbal_control binary not found. Build it first:" >&2
echo " cmake -B build && cmake --build build" >&2
exit 1
fi
exec "$bin" "$@"

31
tests/CMakeLists.txt Normal file
View File

@ -0,0 +1,31 @@
# Unit tests for the SDK-independent core (fgc_core). No Vimba X / Paho needed,
# so these run anywhere - including CI with -DWITH_VIMBA=OFF -DWITH_MQTT=OFF.
include(FetchContent)
# doctest from the official upstream, pinned. For maximum integrity pin to a
# commit SHA or add URL_HASH of a release tarball.
set(DOCTEST_TAG "v2.4.11" CACHE STRING "doctest git tag/commit")
FetchContent_Declare(doctest
GIT_REPOSITORY https://github.com/doctest/doctest.git
GIT_TAG ${DOCTEST_TAG}
GIT_SHALLOW TRUE
)
# doctest's bundled CMakeLists uses a cmake_minimum_required below CMake 4's
# floor; allow it to configure under modern CMake.
set(CMAKE_POLICY_VERSION_MINIMUM 3.5)
FetchContent_MakeAvailable(doctest)
unset(CMAKE_POLICY_VERSION_MINIMUM)
add_executable(fgc_tests
doctest_main.cpp
test_paths.cpp
test_config.cpp
test_telemetry.cpp
test_command.cpp
test_scheduler.cpp
)
target_link_libraries(fgc_tests PRIVATE fgc_core doctest::doctest)
include(${doctest_SOURCE_DIR}/scripts/cmake/doctest.cmake)
doctest_discover_tests(fgc_tests)

2
tests/doctest_main.cpp Normal file
View File

@ -0,0 +1,2 @@
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include <doctest/doctest.h>

40
tests/test_command.cpp Normal file
View File

@ -0,0 +1,40 @@
#include <doctest/doctest.h>
#include "fgc/CommandParser.h"
using namespace fgc;
TEST_CASE("parseCommand handles single-verb commands") {
auto c = parseCommand("start");
CHECK(c.verb == "start");
CHECK(c.device.empty());
CHECK_FALSE(c.has_value);
}
TEST_CASE("parseCommand splits device and trailing numeric value") {
auto c = parseCommand("set fps 5");
CHECK(c.verb == "set");
CHECK(c.device == "fps");
CHECK(c.option.empty());
CHECK(c.has_value);
CHECK(c.value == doctest::Approx(5.0));
}
TEST_CASE("parseCommand keeps option plus value") {
auto c = parseCommand("set camera jxlq 2");
CHECK(c.device == "camera");
CHECK(c.option == "jxlq");
CHECK(c.value == doctest::Approx(2.0));
}
TEST_CASE("parseCommand keeps non-numeric option (raw motor command)") {
auto c = parseCommand("set motorctl kd180");
CHECK(c.device == "motorctl");
CHECK(c.option == "kd180");
CHECK_FALSE(c.has_value);
}
TEST_CASE("parseCommand on empty input") {
CHECK(parseCommand("").empty());
CHECK(parseCommand(" ").empty());
}

49
tests/test_config.cpp Normal file
View File

@ -0,0 +1,49 @@
#include <doctest/doctest.h>
#include "fgc/Config.h"
#include <cmath>
#include <cstdlib>
using namespace fgc;
TEST_CASE("ConfigLoader maps and defaults typed values") {
unsetenv("FGC_MQTT_USER");
unsetenv("FGC_MQTT_PW");
std::map<std::string, std::string> kv = {
{"General.tower_name", "Staeffelsberg"},
{"General.image_interval", "3"},
{"General.debug", "1"},
{"Network.zkms_server_ip", "10.0.0.5"},
{"Network.mqtt_user", "fileuser"},
{"Camera.id_Cam1", "DEV_1"},
{"Camera.id_Cam3", "DEV_3"},
{"Features.enable_mqtt", "false"},
};
AppConfig c = ConfigLoader::fromMap(kv);
CHECK(c.general.tower_name == "Staeffelsberg");
CHECK(c.general.image_interval == 3);
CHECK(c.general.debug == true);
CHECK(c.network.broker_ip == "10.0.0.5");
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.paths.output_dir.empty()); // defaulted
CHECK(std::abs(c.image_rate() - 1.0 / 3.0) < 1e-9);
}
TEST_CASE("environment overrides file credentials") {
setenv("FGC_MQTT_USER", "envuser", 1);
AppConfig c = ConfigLoader::fromMap({{"Network.mqtt_user", "fileuser"}});
CHECK(c.network.mqtt_user == "envuser");
unsetenv("FGC_MQTT_USER");
}
TEST_CASE("ConfigLoader validates input") {
CHECK_THROWS(ConfigLoader::fromMap({{"General.image_interval", "0"}}));
CHECK_THROWS(ConfigLoader::fromMap({{"General.image_interval", "abc"}}));
CHECK_THROWS(ConfigLoader::fromMap({{"General.debug", "maybe"}}));
}

25
tests/test_paths.cpp Normal file
View File

@ -0,0 +1,25 @@
#include <doctest/doctest.h>
#include "fgc/Paths.h"
#include <cstdlib>
using namespace fgc;
TEST_CASE("expandUser expands ~ and environment variables") {
setenv("HOME", "/home/tester", 1);
setenv("FOO", "bar", 1);
CHECK(paths::expandUser("~/x") == "/home/tester/x");
CHECK(paths::expandUser("~") == "/home/tester");
CHECK(paths::expandUser("$FOO/y") == "bar/y");
CHECK(paths::expandUser("${FOO}z") == "barz");
CHECK(paths::expandUser("/abs/path") == "/abs/path");
CHECK(paths::expandUser("") == "");
}
TEST_CASE("configSearchPaths honours the CLI argument first") {
auto p = paths::configSearchPaths("/explicit/config.ini");
REQUIRE(!p.empty());
CHECK(p.front() == "/explicit/config.ini");
}

115
tests/test_scheduler.cpp Normal file
View File

@ -0,0 +1,115 @@
#include <doctest/doctest.h>
#include "fgc/CaptureScheduler.h"
#include <string>
#include <vector>
using namespace fgc;
namespace {
struct FakeMotor : IMotorController {
MotorTelemetry tel;
std::vector<std::string> cmds;
void start() override {}
void stop() override {}
void sendCommand(const std::string& c) override { cmds.push_back(c); }
MotorTelemetry telemetry() override { return tel; }
bool connected() const override { return true; }
};
struct FakeCamera : ICameraSource {
int triggers = 0;
bool ok = true;
void open() override {}
void close() override {}
void start() override {}
void stop() override {}
bool trigger() override { ++triggers; return ok; }
void setFrameCallback(FrameCallback) override {}
int cameraCount() const override { return 1; }
};
struct FakeChannel : IControlChannel {
ControlCommand next;
int last_status = -1;
bool connect() override { return true; }
void disconnect() override {}
bool connected() const override { return true; }
void publishStatus(int c) override { last_status = c; }
void publishCamEvent(const CamEvent&) override {}
ControlCommand poll() override {
ControlCommand c = next;
next = {};
return c;
}
};
} // namespace
TEST_CASE("CaptureScheduler drives the auto-sweep capture cycle") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
FakeChannel chan;
CaptureScheduler sch(motor, cam, chan, 1.0 /*img/s -> 1000ms*/, [&] { return clock; });
sch.setCaptureActive(true);
chan.next.control_code_available = true;
chan.next.control_code = 0;
motor.tel.is_moving = 1;
// Before the interval elapses: nothing happens, but status is echoed.
clock = 500;
sch.tick();
CHECK(motor.cmds.empty());
CHECK(chan.last_status == 0);
// After the interval, while moving: stop command issued, timer reset.
clock = 1600;
sch.tick();
REQUIRE(motor.cmds.size() == 1);
CHECK(motor.cmds[0] == "p");
// >100ms later, still moving: camera fires.
clock = 1750;
sch.tick();
CHECK(cam.triggers == 1);
}
TEST_CASE("CaptureScheduler ControlCode 1 drives to target heading") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
FakeChannel chan;
CaptureScheduler sch(motor, cam, chan, 1.0, [&] { return clock; });
sch.setCaptureActive(true);
motor.tel.is_moving = 1;
chan.next.control_code_available = true;
chan.next.control_code = 1;
chan.next.heading_available = true;
chan.next.target_heading = "180";
clock = 1600;
sch.tick();
REQUIRE(!motor.cmds.empty());
CHECK(motor.cmds.back() == "kd180");
CHECK(sch.controlCode() == 1);
}
TEST_CASE("CaptureScheduler stays idle when capture inactive") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
FakeChannel chan;
CaptureScheduler sch(motor, cam, chan, 1.0, [&] { return clock; });
motor.tel.is_moving = 1; // moving, but capture not active
clock = 5000;
sch.tick();
CHECK(motor.cmds.empty());
CHECK(cam.triggers == 0);
}

30
tests/test_telemetry.cpp Normal file
View File

@ -0,0 +1,30 @@
#include <doctest/doctest.h>
#include "fgc/TelemetryParser.h"
using namespace fgc;
TEST_CASE("parseTelemetryLine reads a valid record") {
auto t = parseTelemetryLine("$;100;2;3;4;1;5;123.5;0;55;21;200;");
REQUIRE(t.has_value());
CHECK(t->encoder == 100);
CHECK(t->is_moving == 1);
CHECK(t->heading == doctest::Approx(123.5f));
// field order: humidity (index 9) before temperature (index 10)
CHECK(t->humidity == 55);
CHECK(t->temperature == 21);
CHECK(t->fan_pwm == 200);
}
TEST_CASE("parseTelemetryLine rejects malformed lines") {
CHECK_FALSE(parseTelemetryLine("garbage").has_value());
CHECK_FALSE(parseTelemetryLine("").has_value());
CHECK_FALSE(parseTelemetryLine("$;1;2;3").has_value()); // too few
CHECK_FALSE(parseTelemetryLine("$;a;2;3;4;5;6;7;8;9;10;11;").has_value()); // bad int
}
TEST_CASE("parseTelemetryLine tolerates trailing CR/LF") {
auto t = parseTelemetryLine("$;1;2;3;4;0;6;7.0;8;9;10;11;\r\n");
REQUIRE(t.has_value());
CHECK(t->fan_pwm == 11);
}