Add unit tests, deployment scripts, and refreshed docs
This commit is contained in:
parent
11a2384c53
commit
f1005f3e31
133
README.md
133
README.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
~/projects/Fire_Gimbal_Control/bin/x64/Release/Fire_Gimbal_Control.out -s 1
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
~/projects/Fire_Gimbal_Control/bin/x64/Release/Fire_Gimbal_Control.out -i 1 -s 1
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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" "$@"
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
|
||||||
|
#include <doctest/doctest.h>
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
|
@ -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"}}));
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue