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) # Fire Gimbal Control (Staeffelsberg)
Real-time control software for an automated **fire-watch gimbal**. A single C++17 binary runs on a Real-time control software for an automated **fire-watch gimbal**. A C++17 program runs on a tower-mounted
tower-mounted PC, rotates a pan gimbal carrying up to four industrial cameras, captures a 360° panorama PC, rotates a pan gimbal carrying up to four industrial cameras, captures a 360° panorama for wildfire
for wildfire detection, compresses each frame to JPEG XL, and reports to a ground station over MQTT. 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 This is the deployment for the **Staeffelsberg** fire-watch tower (FWT). The same codebase serves all towers;
towers; the tower identity comes from `config.ini`. 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 The code is organized around an **SDK-independent core** (`fgc_core`: config, logging, capture scheduler,
written to the filesystem as `.jxl`, and telemetry flows over MQTT. See [docs/architecture.md](docs/architecture.md) parsers) and **swappable I/O implementations** behind three interfaces — motor controller, control channel,
for the full picture. 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 ┌──────────────────────── fgc_core (no SDKs) ─────────────────────────┐
┌───────────────────┐ serial ┌──────────────────────────────────────┐ MQTT ┌──────────────┐ │ Config · Logger · CaptureScheduler · TelemetryParser · CommandParser │
│ gimbal + sensors │ ─────────▶ │ read telemetry → decide when to stop │ ──────▶ │ broker / ZKMS │ └───────▲─────────────────▲──────────────────▲────────────────────────┘
│ /dev/ttyACM0 │ ◀───────── │ send move/stop commands │ ◀────── │ control UI │ │ IMotorController │ IControlChannel │ ICameraSource
└───────────────────┘ │ trigger cameras → encode JXL → save │ └──────────────┘ ┌───────────┴──────┐ ┌────────┴─────────┐ ┌──────┴────────────────┐
└──────────────┬───────────────────────┘ real │ SerialMotorCtrl │ │ MqttControlChannel│ │ VimbaCameraSource │ (WITH_VIMBA/WITH_MQTT)
Vimba X (GigE/USB) │ files mock │ MockMotorCtrl │ │ NullControlChannel│ │ MockCameraSource │
cameras ───────────────────▶ ▼ RGB/ACR/NIR/<unix_ms>.jxl └──────────────────┘ └──────────────────┘ └──────────┬────────────┘
frames
ImagePipeline → .jxl + CamEvent
``` ```
## Quick start See [docs/architecture.md](docs/architecture.md) for the full design.
## Build
```bash ```bash
# 1. Install dependencies (see docs/build-and-setup.md for details + Vimba X SDK) cmake -B build # configure (needs the Vimba X SDK + Paho for a full build)
# 2. Build cmake --build build # -> build/fire_gimbal_control
make # produces bin/Fire_Gimbal_Control.out
# 3. Run (requires camera(s), motor MCU on /dev/ttyACM0, and a reachable MQTT broker) # Development build with NO proprietary SDKs (mocks only):
./bin/Fire_Gimbal_Control.out --start 1 # auto-start capture cmake -B build -DWITH_VIMBA=OFF -DWITH_MQTT=OFF
./bin/Fire_Gimbal_Control.out --init 1 --start 1 # also find endstops first cmake --build build
``` ```
> **Before it will run on this machine**, several paths are hardcoded to `/home/ggs/projects/Fire_Gimbal_Control/...` Dependencies and the Vimba X SDK setup are in [docs/build-and-setup.md](docs/build-and-setup.md).
> 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. ## 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 ## Documentation
| Document | Contents | | Document | Contents |
|----------|----------| |----------|----------|
| [docs/architecture.md](docs/architecture.md) | System overview, threading model, end-to-end data flow, capture state machine | | [docs/architecture.md](docs/architecture.md) | Components, interfaces, threading, data flow, capture state machine |
| [docs/build-and-setup.md](docs/build-and-setup.md) | Toolchain, dependencies, build, serial/MQTT setup, directory layout | | [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 command grammar | | [docs/configuration.md](docs/configuration.md) | `config.ini` keys, CLI flags, console commands |
| [docs/mqtt-api.md](docs/mqtt-api.md) | MQTT topic catalog, payloads, QoS/retain, ControlCode semantics | | [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 key data structures | | [docs/modules-reference.md](docs/modules-reference.md) | Per-file reference and data structures |
| [docs/known-issues.md](docs/known-issues.md) | Reproduction blockers and recommended follow-ups | | [docs/known-issues.md](docs/known-issues.md) | Status of past issues + remaining caveats |
## Repository layout ## Repository layout
``` ```
. CMakeLists.txt Build (options: WITH_VIMBA, WITH_MQTT, BUILD_TESTING)
├── main.cpp Entry point: config, CLI args, threads, main control loop cmake/ FindVmb.cmake (Vimba X), Paho.cmake (FetchContent)
├── Serial.h SerialPort + motor_info telemetry parser (Boost.Asio) config/ config.example.ini (real config.ini is gitignored)
├── MQTT.h / MQTT.cpp MQTTClient + callbacks (Eclipse Paho C++) include/fgc/ Public headers: interfaces, Config, Logger, scheduler, impls
├── Camera.h / Camera.cpp VimbaHandler: acquisition, queue, JXL save (Vimba X + OpenCV) mock/ Mock/null implementations
├── JPEG_XL.h JPEG XL encoder wrapper (libjxl) src/
├── Parser.h Console command parser (Boost.Spirit Qi) + command evaluator core/ Config, Logger, Paths, parsers, CaptureScheduler, Application
├── timing.h Timer / timestamp helpers serial/ SerialMotorController
├── ini.c / ini.h inih INI parser (third-party) mqtt/ MqttControlChannel
├── cxxopts.hpp Third-party CLI parser (legacy/unused — Boost is used instead) camera/ VimbaCameraSource, ImagePipeline, JpegXlEncoder
├── Log.h Empty stub main.cpp Thin entry point (CLI -> Application)
├── config.ini Configuration (also a separate copy under bin/x64/Release/) tests/ doctest unit tests
├── Makefile Build definition scripts/ run.sh + systemd unit template
└── bin/x64/Release/ Deployed/runtime directory (binary, config, startup scripts, image folders) 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. 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 # Architecture
## Purpose ## Overview
The program automates a **pan gimbal** carrying up to four cameras. It periodically rotates the gimbal to a The program rotates a pan gimbal carrying up to four cameras, periodically stops/points it, triggers the
series of headings, stops, triggers the cameras, compresses each captured frame to JPEG XL, writes it to disk, cameras, compresses frames to JPEG XL, writes them to disk, and announces each capture over MQTT. Remote
and announces the capture over MQTT so a ground station can ingest the images for wildfire detection. Remote operators can override behaviour over MQTT.
operators can override the behaviour over MQTT (e.g. point at a specific heading).
There is no persistent state beyond image files. Everything is one of: The design separates **policy** (the control logic) from **mechanism** (the I/O to hardware/broker):
- **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).
## 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**.
``` ```
┌──────────────────────────────────────────────────────┐ ┌──────────────── fgc_core (no SDKs) ───────────────┐
│ main.cpp │ main.cpp ──► Application ──► CaptureScheduler ──► (interfaces below) │
│ - load config.ini (ini.c) │ │ Config · Logger · Paths · TelemetryParser · CommandParser│
│ - parse CLI args (Boost.Program_options) │ └───────────────────────────────────────────────────────────┘
│ - own the 10 ms control loop │ │ builds + owns
└───┬───────────────┬───────────────┬──────────────┬─────┘ ┌────────────────┼───────────────────────────┬──────────────────────┐
│ │ │ │ IMotorController IControlChannel ICameraSource ImagePipeline
reads/writes │ reads │ reads │ drives │ Serial/Mock Mqtt/Null Vimba/Mock ◄── frames ──┘ encode→.jxl→CamEvent
▼ ▼ ▼ ▼
┌────────────────┐ ┌───────────┐ ┌──────────────┐ ┌──────────┐
│ SerialPort │ │ MQTTClient│ │ Parser / │ │VimbaHandler│
│ (Serial.h) │ │ (MQTT.*) │ │ CMD_eval │ │ (Camera.*) │
│ Boost.Asio │ │ Paho C++ │ │ Boost.Spirit │ │ Vimba X │
└──────┬─────────┘ └─────┬─────┘ └──────────────┘ └─────┬──────┘
│ │ │ │ │ │
serial │ /dev/ttyACM0 │ TCP GigE/USB │ serial MQTT broker cameras
▼ ▼ ▼
motor controller MQTT broker cameras
(MCU + sensors) (ground station) (RGB/ACR/NIR)
``` ```
| 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 ## 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 | 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.
| **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) |
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 | ## Capture state machine
|-------------|------|-------|---------------------|
| `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)
``` ```
interval elapsed AND cam_started interval elapsed AND capture active AND is_moving==1
┌──────────────────────┐ ControlCode 0 → send "p" ┌──────────────────────┐ ControlCode 0 → "p"
│ request gimbal stop │ ControlCode 1 → send "kd<target_HDG>" │ stop / point gimbal │ ControlCode 1 → "kd<target_heading>"
└──────────┬───────────┘ set trigger_after_stopping = true, reset loop_timer └──────────┬───────────┘ arm trigger_after_stopping, reset timer
│ (>100 ms later, is_moving==1)
▼ (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
┌──────────────────────┐ ┌──────────────────────┐
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 The trigger predicate (`is_moving == 1`) is preserved from the original; see
> [docs/known-issues.md](known-issues.md) for behaviour that looks surprising (e.g. triggering is gated on [known-issues.md](known-issues.md) for the open question about its semantics. The scheduler is unit-tested with
> `is_moving == 1` rather than `== 0`). mock doubles and an injected clock ([tests/test_scheduler.cpp](../tests/test_scheduler.cpp)).
## Why this shape ## Why this shape
The design favours **low-latency real-time control** over durability: a tight polling loop reacts to motor Decoupling the control logic from the SDKs makes the core testable and the binary buildable/runnable without
feedback in milliseconds, the camera pipeline is decoupled by an in-memory queue so encoding never blocks proprietary dependencies, while preserving the original real-time behaviour. Persistence remains deliberately
acquisition, and all coordination with the outside world is asynchronous (serial async I/O + MQTT). Persistence limited to image files plus fire-and-forget MQTT.
is deliberately limited to image files plus fire-and-forget MQTT messages.

View File

@ -1,118 +1,103 @@
# Build & Setup # Build & Setup
This guide covers building the binary and the host setup needed to run it. For the things that will stop a The project builds with **CMake** (≥ 3.20) and a C++17 compiler. The build is split so the SDK-independent
clean reproduction on a fresh machine, read [known-issues.md](known-issues.md) alongside this page. 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**. | Option | Default | Effect |
- Linux. Developed/deployed on a tower PC (LattePanda Sigma) running Linux; serial device is `/dev/ttyACM0`. |--------|---------|--------|
| `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 ## 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) |
``` On Arch/Manjaro the system deps are roughly:
-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:
```bash ```bash
sudo apt install build-essential libboost-all-dev libopencv-dev libjxl-dev \ sudo pacman -S cmake opencv libjxl boost
libpaho-mqttpp-dev libpaho-mqtt-dev
``` ```
Package names vary by distro/version; `libjxl-dev` and the Paho C++ packages may not exist on older releases and (Debian/Ubuntu equivalents: `cmake build-essential libopencv-dev libjxl-dev libboost-all-dev`.)
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`).
### 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 When `WITH_MQTT=ON`, CMake downloads and builds Paho MQTT C and C++ from the **official Eclipse repos**
Allied Vision (<https://www.alliedvision.com/en/products/software/vimba-x-sdk/>). After install you typically ([cmake/Paho.cmake](../cmake/Paho.cmake)), pinned by tag. This keeps the dependency off the AUR. For maximum
need to: 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 ### Allied Vision Vimba X SDK (proprietary)
(`-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).
The current [Makefile](../Makefile) assumes the headers/libs are already on the default include/link paths; on `VmbC`/`VmbCPP` are not in any package manager. Download the Vimba X SDK from Allied Vision
a fresh machine you will likely need to extend `CXXFLAGS`/`LDFLAGS` with the SDK paths. (<https://www.alliedvision.com/en/products/software/vimba-x-sdk/>) and point CMake at it:
## Build
```bash ```bash
make # compiles ini.c, main.cpp, MQTT.cpp, Camera.cpp → bin/Fire_Gimbal_Control.out cmake -B build -DVMB_HOME=/opt/VimbaX_2024-1
make clean # removes object files and the binary # or: export VIMBA_X_HOME=/opt/VimbaX_2024-1
``` ```
- Object files land in `obj/` (the Makefile uses `OBJDIR := obj`). [cmake/FindVmb.cmake](../cmake/FindVmb.cmake) locates the headers/libs and creates `Vmb::VmbC` / `Vmb::VmbCPP`.
- The output binary is `bin/Fire_Gimbal_Control.out`. 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 ## Host setup (for a real run)
> 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)).
## Directory layout (build vs. deployed) 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:
```
<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:
```bash ```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` / If the port can't be opened the program logs an error and continues (degraded, no telemetry).
`ListCameras`). The camera IDs in `config.ini` must match (GigE IPs like `192.168.11.101` or USB 2. **Cameras** — connect the Allied Vision cameras and confirm Vimba X sees them. Camera IDs in `config.ini`
`DEV_...` IDs). For GigE, configure the host NIC on the `192.168.11.x` subnet. must match (GigE IP like `192.168.11.101`, host NIC on that subnet; or USB `DEV_...`).
3. **MQTT broker** — a broker must be reachable at the `zkms_server_ip` from `config.ini` with the configured 3. **MQTT broker** — reachable at `[Network] zkms_server_ip` with the credentials (from `$FGC_MQTT_USER`/
username/password. **The program exits immediately if it cannot connect** ([main.cpp](../main.cpp) lines `$FGC_MQTT_PW` or the config). Unlike the old version, the program **no longer exits** if MQTT is
162-165). For local testing run e.g. Mosquitto and point `zkms_server_ip` at `127.0.0.1`. unavailable — it logs a warning and continues. Use `--no-mqtt` to skip MQTT entirely.
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.
## Run ## Run
```bash ```bash
# from the deployed directory scripts/run.sh --start # real deployment
./Fire_Gimbal_Control.out --help # show options scripts/run.sh --init --start # find endstops, then start
./Fire_Gimbal_Control.out --start 1 # connect, start camera capture immediately scripts/run.sh --mock-serial --mock-camera --no-mqtt --start # dev, no hardware
./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)
``` ```
Type `exit` on the console (or send EOF) to stop. See [configuration.md](configuration.md) for all CLI flags [scripts/run.sh](../scripts/run.sh) locates the binary relative to itself, and
and the interactive console commands. [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 # Configuration & Commands
Three layers control behaviour: the **`config.ini`** file (read once at startup), **command-line flags**, and Behaviour is controlled by three layers: the **`config.ini`** file, **command-line flags** (which override the
**interactive console commands** typed on stdin while running. 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 There are no hardcoded paths. The file is resolved in this order ([src/core/Paths.cpp](../src/core/Paths.cpp)):
`std::map<std::string,std::string>` ([main.cpp](../main.cpp) lines 35-40, 64-95).
> **Important:** the binary reads from a **hardcoded absolute path**, not the file next to the binary: 1. `--config <path>` CLI flag
> `/home/ggs/projects/Fire_Gimbal_Control/bin/x64/Release/config.ini` ([main.cpp](../main.cpp) line 66). 2. `$FGC_CONFIG` environment variable
> See [known-issues.md](known-issues.md). 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 | ```bash
|---------|-----|------|---------|-------| cp config/config.example.ini config.ini
| `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
``` ```
## `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 ## Command-line flags
Parsed by Boost.Program_options ([main.cpp](../main.cpp) lines 104-153). All take a `bool` value Parsed by Boost.Program_options ([main.cpp](../main.cpp)). Flags override `[Features]`.
(presence + value); the code treats *presence* of the option as "on" regardless of the value.
| Flag | Short | Value | Effect | | Flag | Effect |
|------|-------|-------|--------| |------|--------|
| `--help` | `-h` | — | Print options and exit | | `-h, --help` | Show help and exit |
| `--init` | `-i` | bool | Run the endstop-finding init sequence before the main loop | | `-c, --config <path>` | Explicit config file path |
| `--start` | `-s` | bool | Start camera capture automatically (no `start` console command needed) | | `-i, --init` | Run the endstop-finding init sequence before the loop |
| `--demo` | `-d` | bool | Demo/simulation mode: copy `test_smoke.jxl` instead of encoding real frames | | `-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`) ### Init sequence (`--init`)
When `--init` is set, before entering the loop the program sends to the motor controller, with delays Sends to the motor controller, with delays: `r``e``q`*(wait 60 s)*`y``ud56`
([main.cpp](../main.cpp) lines 220-241): `r``e``q`*(wait 60 s)*`y``ud56` ("set number of ([src/core/Application.cpp](../src/core/Application.cpp), `runInitSequence`).
intervals per turn" = 56). This finds endstops and configures the sweep resolution.
## Interactive console commands ## Interactive console commands
While running, lines typed on stdin are parsed by [Parser.h](../Parser.h) (Boost.Spirit Qi) into up to four Lines on stdin are parsed by `parseCommand` ([src/core/CommandParser.cpp](../src/core/CommandParser.cpp)) — a
tokens: `command device option value`. The grammar expects alphabetic tokens separated by spaces and an whitespace tokenizer (`<verb> [device] [option] [value]`) that replaced the old fragile Boost.Spirit grammar.
optional trailing number. `CMD_eval` then maps them to actions ([main.cpp](../main.cpp) lines 244-273). Handled in `Application::Impl::handleCommand`.
| Type this | Meaning | | Type this | Meaning |
|-----------|---------| |-----------|---------|
| `start` | Start camera acquisition + saver thread | | `start` | Start camera acquisition + capture |
| `stop` | Stop camera acquisition | | `stop` | Stop acquisition |
| `debug` | Toggle telemetry printout (`motorctl_info_out`) | | `debug` | Toggle debug logging |
| `set camera fps <value>` | Change camera acquisition frame rate (`AcquisitionFrameRate`) | | `set camera jxlq <v>` | JPEG XL distance (0 = lossless) |
| `set camera jxlq <value>` | Set JPEG XL quality/distance (0 = lossless; higher = lossier) | | `set camera jxle <v>` | JPEG XL effort |
| `set camera jxle <value>` | Set JPEG XL effort (libjxl effort level) | | `set camera display <0\|1>` | Toggle OpenCV preview window |
| `set camera display <0\|1>` | Toggle live OpenCV preview window | | `set camera fps <v>` | Camera acquisition frame rate (real camera only) |
| `set fps <value>` | Set the **capture interval rate** `imagerate` (images per second logic), not camera fps | | `set fps <v>` | Capture interval rate (images/second) |
| `set motorctl <cmd>` | Forward a raw command string to the motor controller over serial | | `set motorctl <cmd>` | Forward a raw command to the motor controller (e.g. `set motorctl kd180`) |
| `exit` | Stop the program (sets `running = false`) | | `exit` | Quit (Ctrl-D also works) |
Notes: ## Motor command vocabulary (emitted by the software)
- `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).
## Raw motor-controller command reference (observed) | Command | When | Meaning |
|---------|------|---------|
These short strings are sent over serial (`sendCommand`) by the program or via `set motorctl`. They are | `r`,`e`,`q`,`y`,`ud56` | init sequence | homing / set intervals-per-turn |
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 |
| `p` | ControlCode 0 capture | stop / advance to next interval | | `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 This tracks the reproduction blockers and robustness issues identified in the original code and what the
correctness/robustness notes. These are **documentation only** — no code has been changed. Each entry notes a refactor did about them.
recommended fix if you choose to act on it later.
## Reproduction blockers ## Resolved
### 1. Hardcoded config path (will fail on this machine) | # | Original issue | Resolution |
[main.cpp](../main.cpp) line 66 reads the config from an absolute path owned by user `ggs`: |---|----------------|------------|
| 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 Also added along the way: a leveled logger, typed/validated config, an SDK-independent core library, and a
ini_parse("/home/ggs/projects/Fire_Gimbal_Control/bin/x64/Release/config.ini", handler, &config) doctest unit-test suite (`ctest`).
```
On this machine (user `pedro`, repo at `~/code/Fire_Gimbal_Control_staeffelsberg`) that file does not exist, so ## Open / needs hardware confirmation
the program prints `Can't load config.ini file!` and exits.
**To run as-is:** create the expected tree and place a config there: | # | Issue | Status |
```bash |---|-------|--------|
mkdir -p /home/ggs/projects/Fire_Gimbal_Control/bin/x64/Release | 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. |
cp bin/x64/Release/config.ini /home/ggs/projects/Fire_Gimbal_Control/bin/x64/Release/ | 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. |
```
(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`.
### 2. Hardcoded image output path ## Verification caveats
[Camera.cpp](../Camera.cpp) line 348 writes images under:
``` - **Real Serial/MQTT/Vimba wrappers** were verified by code review, not compilation, on the development
$HOME/projects/Fire_Gimbal_Control/bin/x64/Release/<RGB|ACR|NIR>/<unix_ms>.jxl 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**, ## Possible follow-ups (not done)
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.
### 3. Startup scripts point at the deployed tree - Graceful shutdown on SIGINT (currently exit via `exit`/Ctrl-D; a pending `getline` can delay shutdown).
[bin/x64/Release/startup_gimbal.sh](../bin/x64/Release/startup_gimbal.sh) and - Make the camera index→label map and JPEG XL defaults fully config-driven.
[startup_gimbal_with_init.sh](../bin/x64/Release/startup_gimbal_with_init.sh) invoke: - Reintroduce optional image upload to the ground station, config-driven (the old hardcoded NFS/SMB upload was
removed).
```
~/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.

View File

@ -1,118 +1,76 @@
# Module Reference # 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 | File | Contents |
- `handler()` — inih callback flattening INI keys to `Section.name`. |------|----------|
- `readInput()` — stdin thread; reads lines, forwards to the `Parser`, handles `exit`. | [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) |
- `main()` — loads config, parses CLI flags, constructs `SerialPort`, `MQTTClient`, `VimbaHandler`; starts the | [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 |
serial I/O thread and stdin thread; optionally runs the init sequence; runs the 10 ms control loop | [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 |
(command eval → telemetry poll → MQTT poll → capture state machine); cleans up on exit. | [include/fgc/TelemetryParser.h](../include/fgc/TelemetryParser.h), [src/core/TelemetryParser.cpp](../src/core/TelemetryParser.cpp) | `parseTelemetryLine``std::optional<MotorTelemetry>` |
- Global state lives at file scope (`running`, `imagerate`, `ctl_code`, `mqtt_hdg`, `mqtt_client`, | [include/fgc/CommandParser.h](../include/fgc/CommandParser.h), [src/core/CommandParser.cpp](../src/core/CommandParser.cpp) | `parseCommand` whitespace tokenizer → `Command` |
`camera_id_vec`, …). | [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 ## Interfaces
- `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`.
### [MQTT.h](../MQTT.h) / [MQTT.cpp](../MQTT.cpp) — MQTT client | File | Interface | Shared structs |
- `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 | [include/fgc/IMotorController.h](../include/fgc/IMotorController.h) | `IMotorController` | `MotorTelemetry` |
on loss; `get_sub_data()` returns + clears the available flags. | [include/fgc/IControlChannel.h](../include/fgc/IControlChannel.h) | `IControlChannel` | `ControlCommand`, `CamEvent` |
- `class MQTTClient` — owns the `async_client`, connect options (auth, keep-alive, clean session); `publish()` | [include/fgc/ICameraSource.h](../include/fgc/ICameraSource.h) | `ICameraSource` | `Frame` |
sends QoS 1 retained messages. See [mqtt-api.md](mqtt-api.md) for the topic catalog.
### [Camera.h](../Camera.h) / [Camera.cpp](../Camera.cpp) — camera pipeline (Vimba X) ## Real implementations (SDK-gated)
- `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()`.
### [JPEG_XL.h](../JPEG_XL.h) — `class JPEGXL` | File | Implements | Built when |
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 | [src/serial/SerialMotorController.cpp](../src/serial/SerialMotorController.cpp) | `IMotorController` over Boost.Asio serial (pImpl) | always |
effort level. `WriteFile()` flushes the codestream to disk. | [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 ## Mock / null implementations
- `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).
### [timing.h](../timing.h) — time helpers | File | Implements |
- `class Timer` — high-resolution stopwatch (`Reset`, `Elapsed`, `ElapsedMillis`) plus string/file timestamp |------|-----------|
helpers; used for loop pacing and profiling. | [include/fgc/mock/MockMotorController.h](../include/fgc/mock/MockMotorController.h) | Simulated sweeping gimbal |
- `class NanoUnixTimer` — Unix-epoch **millisecond** timestamps (`Stamp_longlong`, `Stamp_string`) used for | [include/fgc/mock/NullControlChannel.h](../include/fgc/mock/NullControlChannel.h) | No-op channel; auto-sweep |
image filenames and CamEvent `time`. | [include/fgc/mock/MockCameraSource.h](../include/fgc/mock/MockCameraSource.h) | Synthetic gradient frames |
- `class ScopedTimer` — RAII timer that logs elapsed time on destruction.
### [ini.c](../ini.c) / [ini.h](../ini.h) — third-party `inih` ## Entry point & scripts
Minimal INI parser (`ini_parse`). Unmodified vendored library.
### Other files | File | Role |
- [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). | [main.cpp](../main.cpp) | CLI parsing → `AppConfig` + `RuntimeOptions``Application::run()` |
- [Log.h](../Log.h) — empty stub (`#pragma once` only). | [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) ### `MotorTelemetry` ([IMotorController.h](../include/fgc/IMotorController.h))
Telemetry from the motor controller, parsed from a 12-field `$`-line. `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 | ### `ControlCommand` ([IControlChannel.h](../include/fgc/IControlChannel.h))
|-------|------|---------|---------------------| `control_code` (0 = auto sweep, 1 = directed) + `target_heading`, each with an `*_available` flag.
| `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 |
> Field 0 is the literal `$` marker. Note the order: index 9 → `humid`, index 10 → `temp` (see ### `CamEvent` ([IControlChannel.h](../include/fgc/IControlChannel.h))
> [known-issues.md](known-issues.md) so the firmware emits them in this order). `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) ### `Frame` ([ICameraSource.h](../include/fgc/ICameraSource.h))
Latest remote-control state. Owned pixel buffer + `width`, `height`, `channels` (1 or 3), `timestamp_ms`, `cam_id`.
| 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.
## On-disk artifacts ## On-disk artifacts
| Artifact | Path | Format | | Artifact | Path | Format |
|----------|------|--------| |----------|------|--------|
| Captured images | `$HOME/projects/Fire_Gimbal_Control/bin/x64/Release/<RGB\|ACR\|NIR>/<unix_ms>.jxl` | JPEG XL, rotated 90° CCW | | Captured images | `<output_dir>/<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) | | 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 # MQTT API
The program is both an MQTT **subscriber** (remote control) and **publisher** (status + capture events). It The program is both an MQTT **subscriber** (remote control) and **publisher** (status + capture events). The
uses the Eclipse Paho C++ async client ([MQTT.cpp](../MQTT.cpp)). All topics are namespaced by tower name: 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>/... GGS/FWT/<tower_name>/...
@ -13,15 +16,16 @@ are **retained**.
## Connection ## Connection
- Broker URI = `Network.zkms_server_ip` from `config.ini`; client ID = the tower name. - 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. - Auth: `mqtt_user` / `mqtt_pw` (preferring `$FGC_MQTT_USER`/`$FGC_MQTT_PW`); `clean_session = true`,
- Connect timeout 5 s; on connection loss the client auto-reconnects (`reconnect()` with a 2.5 s backoff, keep-alive 20 s, `set_automatic_reconnect(true)`.
re-subscribing on success). - Connect timeout 5 s. On connection loss Paho auto-reconnects; the channel re-subscribes on reconnect.
- **The program exits at startup if the initial connect fails** ([main.cpp](../main.cpp) lines 162-165). - **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 topics (inbound — remote control)
Subscribed in `MQTTCallback::connected()` ([MQTT.cpp](../MQTT.cpp) lines 17-23). Handled in Subscribed on (re)connect; handled in `MqttControlChannel::message_arrived()`
`message_arrived()` ([MQTT.cpp](../MQTT.cpp) lines 25-54). ([src/mqtt/MqttControlChannel.cpp](../src/mqtt/MqttControlChannel.cpp)).
| Topic | Payload | Parsed as | Effect | | 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. 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" 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 ### ControlCode semantics
@ -51,7 +55,7 @@ When a ControlCode message arrives, the program echoes the current code back on
### CamEvent payload ### CamEvent payload
Built in [Camera.cpp](../Camera.cpp) line 389: Built by `MqttControlChannel::publishCamEvent` from a `CamEvent`:
```json ```json
{ "fwt":"Staeffelsberg", "cam":"RGB", "hdg":1373, "time":1719312345678 } { "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);
}