From f1005f3e312688678e5be12c00eca4ea0c207482 Mon Sep 17 00:00:00 2001 From: pgdalmeida Date: Mon, 22 Jun 2026 13:07:03 +0200 Subject: [PATCH] Add unit tests, deployment scripts, and refreshed docs --- README.md | 133 ++++++++++------ bin/x64/Release/startup_gimbal.sh | 1 - bin/x64/Release/startup_gimbal_with_init.sh | 1 - docs/architecture.md | 168 ++++++++------------ docs/build-and-setup.md | 159 +++++++++--------- docs/configuration.md | 167 +++++++++---------- docs/known-issues.md | 143 +++++------------ docs/modules-reference.md | 148 ++++++----------- docs/mqtt-api.md | 24 +-- scripts/fire-gimbal-control.service | 25 +++ scripts/run.sh | 20 +++ tests/CMakeLists.txt | 31 ++++ tests/doctest_main.cpp | 2 + tests/test_command.cpp | 40 +++++ tests/test_config.cpp | 49 ++++++ tests/test_paths.cpp | 25 +++ tests/test_scheduler.cpp | 115 ++++++++++++++ tests/test_telemetry.cpp | 30 ++++ 18 files changed, 739 insertions(+), 542 deletions(-) delete mode 100644 bin/x64/Release/startup_gimbal.sh delete mode 100644 bin/x64/Release/startup_gimbal_with_init.sh create mode 100644 scripts/fire-gimbal-control.service create mode 100755 scripts/run.sh create mode 100644 tests/CMakeLists.txt create mode 100644 tests/doctest_main.cpp create mode 100644 tests/test_command.cpp create mode 100644 tests/test_config.cpp create mode 100644 tests/test_paths.cpp create mode 100644 tests/test_scheduler.cpp create mode 100644 tests/test_telemetry.cpp diff --git a/README.md b/README.md index 6b9e19b..cc55b21 100644 --- a/README.md +++ b/README.md @@ -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/.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 `, 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. diff --git a/bin/x64/Release/startup_gimbal.sh b/bin/x64/Release/startup_gimbal.sh deleted file mode 100644 index a3c16a7..0000000 --- a/bin/x64/Release/startup_gimbal.sh +++ /dev/null @@ -1 +0,0 @@ -~/projects/Fire_Gimbal_Control/bin/x64/Release/Fire_Gimbal_Control.out -s 1 diff --git a/bin/x64/Release/startup_gimbal_with_init.sh b/bin/x64/Release/startup_gimbal_with_init.sh deleted file mode 100644 index 78d1b03..0000000 --- a/bin/x64/Release/startup_gimbal_with_init.sh +++ /dev/null @@ -1 +0,0 @@ -~/projects/Fire_Gimbal_Control/bin/x64/Release/Fire_Gimbal_Control.out -i 1 -s 1 diff --git a/docs/architecture.md b/docs/architecture.md index 970909d..fd8ab28 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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` 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 + `/