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)
|
||||
|
||||
Real-time control software for an automated **fire-watch gimbal**. A single C++17 binary runs on a
|
||||
tower-mounted PC, rotates a pan gimbal carrying up to four industrial cameras, captures a 360° panorama
|
||||
for wildfire detection, compresses each frame to JPEG XL, and reports to a ground station over MQTT.
|
||||
Real-time control software for an automated **fire-watch gimbal**. A C++17 program runs on a tower-mounted
|
||||
PC, rotates a pan gimbal carrying up to four industrial cameras, captures a 360° panorama for wildfire
|
||||
detection, compresses each frame to JPEG XL, and reports to a ground station over MQTT.
|
||||
|
||||
This is the deployment for the **Staeffelsberg** fire-watch tower (FWT). The same codebase is used across
|
||||
towers; the tower identity comes from `config.ini`.
|
||||
This is the deployment for the **Staeffelsberg** fire-watch tower (FWT). The same codebase serves all towers;
|
||||
the tower identity comes from `config.ini`.
|
||||
|
||||
State is held in memory (mutex-guarded structs), configuration is read once from an INI file, images are
|
||||
written to the filesystem as `.jxl`, and telemetry flows over MQTT. See [docs/architecture.md](docs/architecture.md)
|
||||
for the full picture.
|
||||
The code is organized around an **SDK-independent core** (`fgc_core`: config, logging, capture scheduler,
|
||||
parsers) and **swappable I/O implementations** behind three interfaces — motor controller, control channel,
|
||||
and camera source — each with a real and a mock/null version. This makes the system deployable on any machine
|
||||
and runnable **without hardware or an MQTT broker** for development.
|
||||
|
||||
## What it does (at a glance)
|
||||
State is held in memory (mutex-guarded), configuration is read once from an INI file, images are written to
|
||||
the filesystem as `.jxl`, and telemetry flows over MQTT. There is no database.
|
||||
|
||||
## Architecture at a glance
|
||||
|
||||
```
|
||||
motor controller (MCU) this program (Fire_Gimbal_Control.out) ground station
|
||||
┌───────────────────┐ serial ┌──────────────────────────────────────┐ MQTT ┌──────────────┐
|
||||
│ gimbal + sensors │ ─────────▶ │ read telemetry → decide when to stop │ ──────▶ │ broker / ZKMS │
|
||||
│ /dev/ttyACM0 │ ◀───────── │ send move/stop commands │ ◀────── │ control UI │
|
||||
└───────────────────┘ │ trigger cameras → encode JXL → save │ └──────────────┘
|
||||
└──────────────┬───────────────────────┘
|
||||
Vimba X (GigE/USB) │ files
|
||||
cameras ───────────────────▶ ▼ RGB/ACR/NIR/<unix_ms>.jxl
|
||||
┌──────────────────────── fgc_core (no SDKs) ─────────────────────────┐
|
||||
│ Config · Logger · CaptureScheduler · TelemetryParser · CommandParser │
|
||||
└───────▲─────────────────▲──────────────────▲────────────────────────┘
|
||||
│ IMotorController │ IControlChannel │ ICameraSource
|
||||
┌───────────┴──────┐ ┌────────┴─────────┐ ┌──────┴────────────────┐
|
||||
real │ SerialMotorCtrl │ │ MqttControlChannel│ │ VimbaCameraSource │ (WITH_VIMBA/WITH_MQTT)
|
||||
mock │ MockMotorCtrl │ │ NullControlChannel│ │ MockCameraSource │
|
||||
└──────────────────┘ └──────────────────┘ └──────────┬────────────┘
|
||||
frames
|
||||
▼
|
||||
ImagePipeline → .jxl + CamEvent
|
||||
```
|
||||
|
||||
## Quick start
|
||||
See [docs/architecture.md](docs/architecture.md) for the full design.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
# 1. Install dependencies (see docs/build-and-setup.md for details + Vimba X SDK)
|
||||
# 2. Build
|
||||
make # produces bin/Fire_Gimbal_Control.out
|
||||
# 3. Run (requires camera(s), motor MCU on /dev/ttyACM0, and a reachable MQTT broker)
|
||||
./bin/Fire_Gimbal_Control.out --start 1 # auto-start capture
|
||||
./bin/Fire_Gimbal_Control.out --init 1 --start 1 # also find endstops first
|
||||
cmake -B build # configure (needs the Vimba X SDK + Paho for a full build)
|
||||
cmake --build build # -> build/fire_gimbal_control
|
||||
|
||||
# Development build with NO proprietary SDKs (mocks only):
|
||||
cmake -B build -DWITH_VIMBA=OFF -DWITH_MQTT=OFF
|
||||
cmake --build build
|
||||
```
|
||||
|
||||
> **Before it will run on this machine**, several paths are hardcoded to `/home/ggs/projects/Fire_Gimbal_Control/...`
|
||||
> and the program exits if MQTT can't connect. Read **[docs/known-issues.md](docs/known-issues.md)** first — it
|
||||
> lists every reproduction blocker and the deployed layout the binary expects.
|
||||
Dependencies and the Vimba X SDK setup are in [docs/build-and-setup.md](docs/build-and-setup.md).
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
# Real deployment (needs cameras, motor MCU on the configured serial port, MQTT broker):
|
||||
scripts/run.sh --start
|
||||
scripts/run.sh --init --start # find endstops first
|
||||
|
||||
# Development, no hardware or broker:
|
||||
scripts/run.sh --mock-serial --mock-camera --no-mqtt --start --log-level debug
|
||||
```
|
||||
|
||||
Config is resolved from `--config <path>`, then `$FGC_CONFIG`, `./config.ini`, the executable's directory, and
|
||||
`$XDG_CONFIG_HOME/fire_gimbal_control/config.ini`. Copy the template to get started:
|
||||
|
||||
```bash
|
||||
cp config/config.example.ini config.ini # then edit
|
||||
```
|
||||
|
||||
Type `exit` (or Ctrl-D) to stop. See [docs/configuration.md](docs/configuration.md) for all options.
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
cmake -B build -DWITH_VIMBA=OFF -DWITH_MQTT=OFF -DBUILD_TESTING=ON
|
||||
cmake --build build
|
||||
ctest --test-dir build --output-on-failure
|
||||
```
|
||||
|
||||
The unit tests cover the core (config, paths, telemetry/command parsers, capture scheduler) and need no SDKs.
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Contents |
|
||||
|----------|----------|
|
||||
| [docs/architecture.md](docs/architecture.md) | System overview, threading model, end-to-end data flow, capture state machine |
|
||||
| [docs/build-and-setup.md](docs/build-and-setup.md) | Toolchain, dependencies, build, serial/MQTT setup, directory layout |
|
||||
| [docs/configuration.md](docs/configuration.md) | `config.ini` keys, CLI flags, console command grammar |
|
||||
| [docs/mqtt-api.md](docs/mqtt-api.md) | MQTT topic catalog, payloads, QoS/retain, ControlCode semantics |
|
||||
| [docs/modules-reference.md](docs/modules-reference.md) | Per-file reference and key data structures |
|
||||
| [docs/known-issues.md](docs/known-issues.md) | Reproduction blockers and recommended follow-ups |
|
||||
| [docs/architecture.md](docs/architecture.md) | Components, interfaces, threading, data flow, capture state machine |
|
||||
| [docs/build-and-setup.md](docs/build-and-setup.md) | CMake, dependencies, options, Vimba X / Paho, host setup |
|
||||
| [docs/configuration.md](docs/configuration.md) | `config.ini` keys, CLI flags, console commands |
|
||||
| [docs/mqtt-api.md](docs/mqtt-api.md) | MQTT topic catalog and payloads |
|
||||
| [docs/modules-reference.md](docs/modules-reference.md) | Per-file reference and data structures |
|
||||
| [docs/known-issues.md](docs/known-issues.md) | Status of past issues + remaining caveats |
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
.
|
||||
├── main.cpp Entry point: config, CLI args, threads, main control loop
|
||||
├── Serial.h SerialPort + motor_info telemetry parser (Boost.Asio)
|
||||
├── MQTT.h / MQTT.cpp MQTTClient + callbacks (Eclipse Paho C++)
|
||||
├── Camera.h / Camera.cpp VimbaHandler: acquisition, queue, JXL save (Vimba X + OpenCV)
|
||||
├── JPEG_XL.h JPEG XL encoder wrapper (libjxl)
|
||||
├── Parser.h Console command parser (Boost.Spirit Qi) + command evaluator
|
||||
├── timing.h Timer / timestamp helpers
|
||||
├── ini.c / ini.h inih INI parser (third-party)
|
||||
├── cxxopts.hpp Third-party CLI parser (legacy/unused — Boost is used instead)
|
||||
├── Log.h Empty stub
|
||||
├── config.ini Configuration (also a separate copy under bin/x64/Release/)
|
||||
├── Makefile Build definition
|
||||
└── bin/x64/Release/ Deployed/runtime directory (binary, config, startup scripts, image folders)
|
||||
CMakeLists.txt Build (options: WITH_VIMBA, WITH_MQTT, BUILD_TESTING)
|
||||
cmake/ FindVmb.cmake (Vimba X), Paho.cmake (FetchContent)
|
||||
config/ config.example.ini (real config.ini is gitignored)
|
||||
include/fgc/ Public headers: interfaces, Config, Logger, scheduler, impls
|
||||
mock/ Mock/null implementations
|
||||
src/
|
||||
core/ Config, Logger, Paths, parsers, CaptureScheduler, Application
|
||||
serial/ SerialMotorController
|
||||
mqtt/ MqttControlChannel
|
||||
camera/ VimbaCameraSource, ImagePipeline, JpegXlEncoder
|
||||
main.cpp Thin entry point (CLI -> Application)
|
||||
tests/ doctest unit tests
|
||||
scripts/ run.sh + systemd unit template
|
||||
ini.c / ini.h Bundled inih INI parser
|
||||
```
|
||||
|
||||
## License / ownership
|
||||
## Ownership
|
||||
|
||||
Internal tooling for the GGS fire-watch tower network. No license file is present in the repository.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
## Purpose
|
||||
## Overview
|
||||
|
||||
The program automates a **pan gimbal** carrying up to four cameras. It periodically rotates the gimbal to a
|
||||
series of headings, stops, triggers the cameras, compresses each captured frame to JPEG XL, writes it to disk,
|
||||
and announces the capture over MQTT so a ground station can ingest the images for wildfire detection. Remote
|
||||
operators can override the behaviour over MQTT (e.g. point at a specific heading).
|
||||
The program rotates a pan gimbal carrying up to four cameras, periodically stops/points it, triggers the
|
||||
cameras, compresses frames to JPEG XL, writes them to disk, and announces each capture over MQTT. Remote
|
||||
operators can override behaviour over MQTT.
|
||||
|
||||
There is no persistent state beyond image files. Everything is one of:
|
||||
- **Configuration** — read once at startup from `config.ini`.
|
||||
- **Live state** — in-memory C++ structs, guarded by mutexes, overwritten each cycle.
|
||||
- **Artifacts** — `.jxl` image files on disk.
|
||||
- **Messages** — MQTT publish/subscribe (no local persistence).
|
||||
The design separates **policy** (the control logic) from **mechanism** (the I/O to hardware/broker):
|
||||
|
||||
## Component overview
|
||||
- **`fgc_core`** — an SDK-independent static library: typed configuration, path resolution, logging, the
|
||||
telemetry/command parsers, and the `CaptureScheduler` (control state machine). Depends on nothing
|
||||
proprietary, so it builds and unit-tests anywhere.
|
||||
- **Three interfaces** abstract the outside world, each with a real and a mock/null implementation:
|
||||
|
||||
| Interface | Real | Mock / Null |
|
||||
|-----------|------|-------------|
|
||||
| `IMotorController` | `SerialMotorController` (Boost.Asio) | `MockMotorController` (simulated sweep) |
|
||||
| `IControlChannel` | `MqttControlChannel` (Paho) | `NullControlChannel` (no broker) |
|
||||
| `ICameraSource` | `VimbaCameraSource` (Vimba X) | `MockCameraSource` (synthetic frames) |
|
||||
|
||||
`Application` picks real vs mock from config + CLI, wires everything to the `ImagePipeline` and
|
||||
`CaptureScheduler`, and runs the loop. Selecting mocks lets the whole system run with **no hardware or broker**.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ main.cpp │
|
||||
│ - load config.ini (ini.c) │
|
||||
│ - parse CLI args (Boost.Program_options) │
|
||||
│ - own the 10 ms control loop │
|
||||
└───┬───────────────┬───────────────┬──────────────┬─────┘
|
||||
│ │ │ │
|
||||
reads/writes │ reads │ reads │ drives │
|
||||
▼ ▼ ▼ ▼
|
||||
┌────────────────┐ ┌───────────┐ ┌──────────────┐ ┌──────────┐
|
||||
│ SerialPort │ │ MQTTClient│ │ Parser / │ │VimbaHandler│
|
||||
│ (Serial.h) │ │ (MQTT.*) │ │ CMD_eval │ │ (Camera.*) │
|
||||
│ Boost.Asio │ │ Paho C++ │ │ Boost.Spirit │ │ Vimba X │
|
||||
└──────┬─────────┘ └─────┬─────┘ └──────────────┘ └─────┬──────┘
|
||||
│ │ │
|
||||
serial │ /dev/ttyACM0 │ TCP GigE/USB │
|
||||
▼ ▼ ▼
|
||||
motor controller MQTT broker cameras
|
||||
(MCU + sensors) (ground station) (RGB/ACR/NIR)
|
||||
┌──────────────── fgc_core (no SDKs) ───────────────┐
|
||||
main.cpp ──► Application ──► CaptureScheduler ──► (interfaces below) │
|
||||
│ Config · Logger · Paths · TelemetryParser · CommandParser│
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
│ builds + owns
|
||||
┌────────────────┼───────────────────────────┬──────────────────────┐
|
||||
IMotorController IControlChannel ICameraSource ImagePipeline
|
||||
Serial/Mock Mqtt/Null Vimba/Mock ◄── frames ──┘ encode→.jxl→CamEvent
|
||||
│ │ │
|
||||
serial MQTT broker cameras
|
||||
```
|
||||
|
||||
| Component | File(s) | Responsibility |
|
||||
|-----------|---------|----------------|
|
||||
| Entry / control loop | [main.cpp](../main.cpp) | Config, CLI, thread orchestration, capture cycle logic |
|
||||
| Serial telemetry & commands | [Serial.h](../Serial.h) | Open `/dev/ttyACM0`, parse `motor_info` telemetry, send motor commands |
|
||||
| MQTT client | [MQTT.h](../MQTT.h), [MQTT.cpp](../MQTT.cpp) | Connect to broker, subscribe to control topics, publish status & cam events |
|
||||
| Camera acquisition | [Camera.h](../Camera.h), [Camera.cpp](../Camera.cpp) | Vimba X cameras, frame queue, JXL encode + save, cam event publish |
|
||||
| JPEG XL encoder | [JPEG_XL.h](../JPEG_XL.h) | Wrap libjxl to encode an 8-bit image buffer to a `.jxl` file |
|
||||
| Console parser | [Parser.h](../Parser.h) | Parse stdin command lines into actions |
|
||||
| Timing helpers | [timing.h](../timing.h) | Stopwatch + Unix-ms timestamps used for filenames and loop pacing |
|
||||
| INI parser | [ini.c](../ini.c), [ini.h](../ini.h) | Third-party `inih` used to read `config.ini` |
|
||||
|
||||
## Threading model
|
||||
|
||||
The process runs five concurrent contexts:
|
||||
| Thread | Where | Role |
|
||||
|--------|-------|------|
|
||||
| Main / control loop | `Application::run` | 10 ms tick: drain console commands, `scheduler.tick()` |
|
||||
| stdin reader | `Application` | reads command lines into a queue |
|
||||
| Serial I/O | `SerialMotorController` | Boost.Asio `io_context`; async read-until parses telemetry |
|
||||
| Image worker | `ImagePipeline` | drains the frame queue: rotate → encode → write → publish |
|
||||
| MQTT client | Paho (internal) | delivers callbacks; auto-reconnects (no busy-wait loop) |
|
||||
| Camera acquisition | Vimba X (internal) | delivers frames via the observer (real source) |
|
||||
|
||||
| Thread | Created in | Loop / blocking behaviour |
|
||||
|--------|-----------|---------------------------|
|
||||
| **Main / control loop** | `main()` | `while(running)`; 10 ms sleep per tick ([main.cpp](../main.cpp) lines 243-336) |
|
||||
| **Serial I/O** | `main()` → `io_thread` | Runs `boost::asio::io_service::run()`; async read-until `\n` re-arms itself ([Serial.h](../Serial.h) lines 123-139) |
|
||||
| **stdin reader** | `main()` → `inputThread` | `readInput()` blocks on `std::getline`, feeds the `Parser` ([main.cpp](../main.cpp) lines 42-59) |
|
||||
| **MQTT client** | `MQTTClient::connect_client()` → `mqtt_thread` | Connects, then **busy-waits** `while(running);` ([MQTT.cpp](../MQTT.cpp) lines 82-99). Paho's own threads deliver callbacks |
|
||||
| **Image saver** | `VimbaHandler::Start()` → `image_saver_thread` | Waits on a condition variable, drains the per-camera queues, encodes + writes JXL ([Camera.cpp](../Camera.cpp) lines 303-394) |
|
||||
Shared state is mutex-guarded: latest `MotorTelemetry` (serial), `ControlCommand` (channel), the frame queue
|
||||
(pipeline), and the console command queue. The `CaptureScheduler` runs only on the main thread.
|
||||
|
||||
Vimba X additionally delivers frames on its own internal acquisition threads via the `FrameObserver` callback.
|
||||
## Data flow
|
||||
|
||||
### Shared state and synchronization
|
||||
1. **Startup** — `main()` parses CLI, resolves + loads config, constructs `Application`, which builds the
|
||||
motor/channel/camera (real or mock), the `ImagePipeline`, and the `CaptureScheduler`.
|
||||
2. **Telemetry** — the real motor controller streams `$;...;` lines; `parseTelemetryLine` turns each into a
|
||||
`MotorTelemetry` snapshot. The mock synthesizes one.
|
||||
3. **Control input** — `IControlChannel::poll()` returns the latest `ControlCommand` (control code + target
|
||||
heading), clearing its "available" flags so each update is acted on once.
|
||||
4. **Capture cycle** (`CaptureScheduler::tick`, per 10 ms):
|
||||
- ControlCode 0: when the interval elapses, send `p` to advance/stop, then trigger the cameras.
|
||||
- ControlCode 1: send `kd<target_heading>` to drive to the requested heading, then trigger.
|
||||
5. **Frame handling** — a triggered camera delivers a `Frame` to the callback, which `submit()`s it to the
|
||||
`ImagePipeline`. The worker rotates it 90° CCW, encodes JPEG XL (or copies the demo image), writes
|
||||
`<output_dir>/<label>/<unix_ms>.jxl`, and publishes a `CamEvent`.
|
||||
|
||||
| Shared data | Type | Guard | Producer → Consumer |
|
||||
|-------------|------|-------|---------------------|
|
||||
| `motor_info` telemetry | struct | `SerialPort::mut` | Serial I/O thread → main loop (`get_controller_info()`) |
|
||||
| `mqtt_sub_data` (target heading, control code) | struct | `MQTTCallback::mqtt_mut` | Paho callback thread → main loop (`get_sub_data()`, which clears the "available" flags) |
|
||||
| Per-camera frame queues | `std::queue<ImageStore8Ptr>` (max 100) | `VimbaHandler::queue_mut` | FrameObserver/enqueue → saver thread; saver woken by `cv_proc` |
|
||||
| `parser_data` (console command) | struct | `Parser::mut` | stdin thread → main loop |
|
||||
|
||||
## End-to-end data flow
|
||||
|
||||
1. **Startup** — `main()` loads `config.ini`, parses CLI flags, constructs `SerialPort`, `MQTTClient`,
|
||||
and `VimbaHandler`. If `--init` is set, it sends an endstop-finding command sequence to the motor controller
|
||||
(`r`, `e`, `q`, wait 60 s, `y`, `ud56`).
|
||||
2. **Telemetry ingest** — the motor controller streams lines like
|
||||
`$;<Xenc>;<Xerr>;<sgt_val>;<sgt_stat>;<is_moving>;<control_status>;<hdg>;<deviation_warn>;<humid>;<temp>;<fan_pwm>`.
|
||||
`SerialPort::parser()` splits on `;`, validates 12 fields, and stores a fresh `motor_info`.
|
||||
3. **Control decision** (per 10 ms tick, [main.cpp](../main.cpp) lines 293-334):
|
||||
- Read current `motor_info` and current MQTT `ControlCode`/`target_HDG`.
|
||||
- **ControlCode 0 (automatic sweep):** when the image interval has elapsed and the camera is started, send
|
||||
`p` (stop/advance), then once stopped, trigger the cameras.
|
||||
- **ControlCode 1 (directed):** same timing, but send `kd<heading>` to drive to the MQTT-supplied target
|
||||
heading before triggering.
|
||||
4. **Trigger & settle** — `TriggerCamera()` resets each observer's `settle` counter to 3 and fires the
|
||||
`TriggerSoftware` command 4 times (~400 ms apart). The first 3 frames are intentionally dumped (sensor
|
||||
settling); the 4th is the real image.
|
||||
5. **Enqueue** — for each completed frame, `EnqueueToStoreStruct()` deep-copies the pixel buffer into an
|
||||
`image_store_8bit` with a Unix-ms timestamp and pushes it onto that camera's bounded queue (oldest reused if
|
||||
the queue is at 100). Enqueuing camera 0 notifies the saver via `cv_proc`.
|
||||
6. **Save** — `SaveImage()` pops frames, builds an OpenCV `Mat`, rotates 90° CCW, optionally displays it, then
|
||||
(unless in demo mode) encodes with `JPEGXL` and writes to
|
||||
`$HOME/projects/Fire_Gimbal_Control/bin/x64/Release/<RGB|ACR|NIR>/<unix_ms>.jxl`. In demo mode it copies
|
||||
`test_smoke.jxl` instead.
|
||||
7. **Announce** — after each saved frame, publish a `CamEvent` JSON message to
|
||||
`GGS/FWT/<tower>/CamEvent` with the tower, camera name, heading (×10, integer), and timestamp.
|
||||
|
||||
## Capture state machine (per camera trigger cycle)
|
||||
## Capture state machine
|
||||
|
||||
```
|
||||
interval elapsed AND cam_started
|
||||
interval elapsed AND capture active AND is_moving==1
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐ ControlCode 0 → send "p"
|
||||
│ request gimbal stop │ ControlCode 1 → send "kd<target_HDG>"
|
||||
└──────────┬───────────┘ set trigger_after_stopping = true, reset loop_timer
|
||||
│
|
||||
▼ (after >100 ms, when is_moving == 1)
|
||||
┌──────────────────────┐
|
||||
│ TriggerCamera() │ reset settle=3; fire TriggerSoftware ×4 (400 ms apart)
|
||||
└──────────┬───────────┘
|
||||
│ frames 1-3 dumped (settling), frame 4 enqueued
|
||||
┌──────────────────────┐ ControlCode 0 → "p"
|
||||
│ stop / point gimbal │ ControlCode 1 → "kd<target_heading>"
|
||||
└──────────┬───────────┘ arm trigger_after_stopping, reset timer
|
||||
│ (>100 ms later, is_moving==1)
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ enqueue → save → MQTT │ trigger_after_stopping = false
|
||||
│ camera.trigger() │ on success: disarm
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
> The settle/trigger timing and the `is_moving == 1` conditions are documented exactly as implemented; see
|
||||
> [docs/known-issues.md](known-issues.md) for behaviour that looks surprising (e.g. triggering is gated on
|
||||
> `is_moving == 1` rather than `== 0`).
|
||||
The trigger predicate (`is_moving == 1`) is preserved from the original; see
|
||||
[known-issues.md](known-issues.md) for the open question about its semantics. The scheduler is unit-tested with
|
||||
mock doubles and an injected clock ([tests/test_scheduler.cpp](../tests/test_scheduler.cpp)).
|
||||
|
||||
## Why this shape
|
||||
|
||||
The design favours **low-latency real-time control** over durability: a tight polling loop reacts to motor
|
||||
feedback in milliseconds, the camera pipeline is decoupled by an in-memory queue so encoding never blocks
|
||||
acquisition, and all coordination with the outside world is asynchronous (serial async I/O + MQTT). Persistence
|
||||
is deliberately limited to image files plus fire-and-forget MQTT messages.
|
||||
Decoupling the control logic from the SDKs makes the core testable and the binary buildable/runnable without
|
||||
proprietary dependencies, while preserving the original real-time behaviour. Persistence remains deliberately
|
||||
limited to image files plus fire-and-forget MQTT.
|
||||
|
|
|
|||
|
|
@ -1,118 +1,103 @@
|
|||
# Build & Setup
|
||||
|
||||
This guide covers building the binary and the host setup needed to run it. For the things that will stop a
|
||||
clean reproduction on a fresh machine, read [known-issues.md](known-issues.md) alongside this page.
|
||||
The project builds with **CMake** (≥ 3.20) and a C++17 compiler. The build is split so the SDK-independent
|
||||
core and the development (mock) build work on any machine; the proprietary/heavy dependencies are optional.
|
||||
|
||||
## Toolchain
|
||||
## Build options
|
||||
|
||||
- **g++** with **C++17** (`-std=c++17`), GNU Make. The C file `ini.c` is compiled with **gcc**.
|
||||
- Linux. Developed/deployed on a tower PC (LattePanda Sigma) running Linux; serial device is `/dev/ttyACM0`.
|
||||
| Option | Default | Effect |
|
||||
|--------|---------|--------|
|
||||
| `WITH_VIMBA` | `ON` | Build the Allied Vision Vimba X camera source. `OFF` → mock cameras only (no SDK needed). |
|
||||
| `WITH_MQTT` | `ON` | Build the Eclipse Paho MQTT channel (fetched via FetchContent). `OFF` → null channel only. |
|
||||
| `BUILD_TESTING` | `OFF` | Build the doctest unit tests (fetches doctest). |
|
||||
|
||||
See [Makefile](../Makefile) — `CXXFLAGS := -std=c++17 -g -Wall -I.`
|
||||
```bash
|
||||
# Full build (requires Vimba X SDK installed + network for Paho)
|
||||
cmake -B build
|
||||
cmake --build build # -> build/fire_gimbal_control
|
||||
|
||||
# Development build: no proprietary SDKs, no broker
|
||||
cmake -B build -DWITH_VIMBA=OFF -DWITH_MQTT=OFF
|
||||
cmake --build build
|
||||
|
||||
# With tests
|
||||
cmake -B build -DWITH_VIMBA=OFF -DWITH_MQTT=OFF -DBUILD_TESTING=ON
|
||||
cmake --build build && ctest --test-dir build --output-on-failure
|
||||
```
|
||||
|
||||
CMake exports `compile_commands.json` into the build dir for clangd/IDEs.
|
||||
|
||||
## Dependencies
|
||||
|
||||
The link line in the [Makefile](../Makefile) is:
|
||||
| Dependency | Needed for | How it's provided |
|
||||
|------------|-----------|-------------------|
|
||||
| **CMake ≥ 3.20**, g++/clang with C++17 | always | system package (`cmake`, official repo) |
|
||||
| **OpenCV** (`core`, `highgui`, `imgproc`) | image rotate/display | `find_package(OpenCV)` — system package |
|
||||
| **libjxl** (`libjxl`, `libjxl_threads`) | JPEG XL encoding | pkg-config — system package |
|
||||
| **Boost** (`program_options`; header-only Asio) | CLI parsing, serial I/O | `find_package(Boost CONFIG)` — system package |
|
||||
| **Threads** | concurrency | system |
|
||||
| **Eclipse Paho MQTT C/C++** | MQTT (`WITH_MQTT=ON`) | **FetchContent** from official upstream (no AUR / system install) |
|
||||
| **Allied Vision Vimba X SDK** | cameras (`WITH_VIMBA=ON`) | **proprietary**, install manually (see below) |
|
||||
|
||||
```
|
||||
-lpaho-mqttpp3 -lpaho-mqtt3a -lopencv_core -lopencv_highgui -ljxl -ljxl_threads -lboost_program_options -lVmbC -lVmbCPP
|
||||
```
|
||||
|
||||
| Dependency | Libraries linked | Headers used | Typical source |
|
||||
|------------|------------------|--------------|----------------|
|
||||
| **Eclipse Paho MQTT C++** | `paho-mqttpp3`, `paho-mqtt3a` | `mqtt/async_client.h` | `libpaho-mqttpp-dev` / build from source (also needs Paho MQTT C) |
|
||||
| **OpenCV** | `opencv_core`, `opencv_highgui` | `opencv2/opencv.hpp` | `libopencv-dev` |
|
||||
| **libjxl (JPEG XL)** | `jxl`, `jxl_threads` | `jxl/encode.h`, `jxl/encode_cxx.h`, `jxl/thread_parallel_runner*.h` | `libjxl-dev` / build from source |
|
||||
| **Boost** | `boost_program_options` | `boost/asio.hpp`, `boost/program_options.hpp`, `boost/spirit/include/qi.hpp`, `phoenix.hpp` | `libboost-all-dev` (or at least program_options, system, and the header-only Asio/Spirit) |
|
||||
| **Allied Vision Vimba X SDK** | `VmbC`, `VmbCPP` | `VmbC/VmbC.h`, `VmbCPP/VmbCPP.h` | **Proprietary** — download from Allied Vision (see below) |
|
||||
|
||||
`cxxopts.hpp` is bundled but **not used** (the active CLI parser is Boost.Program_options); it does not need to
|
||||
be installed.
|
||||
|
||||
### Debian/Ubuntu package hint
|
||||
|
||||
The non-proprietary dependencies are roughly:
|
||||
On Arch/Manjaro the system deps are roughly:
|
||||
|
||||
```bash
|
||||
sudo apt install build-essential libboost-all-dev libopencv-dev libjxl-dev \
|
||||
libpaho-mqttpp-dev libpaho-mqtt-dev
|
||||
sudo pacman -S cmake opencv libjxl boost
|
||||
```
|
||||
|
||||
Package names vary by distro/version; `libjxl-dev` and the Paho C++ packages may not exist on older releases and
|
||||
then have to be built from source. This machine is **Manjaro/Arch** — use `pacman`/AUR equivalents
|
||||
(`boost`, `opencv`, `libjxl`, `paho-mqtt-c`, `paho-mqtt-cpp`).
|
||||
(Debian/Ubuntu equivalents: `cmake build-essential libopencv-dev libjxl-dev libboost-all-dev`.)
|
||||
|
||||
### Allied Vision Vimba X SDK (proprietary — required)
|
||||
### Eclipse Paho MQTT (FetchContent, no AUR)
|
||||
|
||||
`VmbC`/`VmbCPP` are **not** available through any package manager. Download and install the Vimba X SDK from
|
||||
Allied Vision (<https://www.alliedvision.com/en/products/software/vimba-x-sdk/>). After install you typically
|
||||
need to:
|
||||
When `WITH_MQTT=ON`, CMake downloads and builds Paho MQTT C and C++ from the **official Eclipse repos**
|
||||
([cmake/Paho.cmake](../cmake/Paho.cmake)), pinned by tag. This keeps the dependency off the AUR. For maximum
|
||||
integrity, pin `PAHO_C_TAG`/`PAHO_CPP_TAG` to a commit SHA or switch to a hash-verified release tarball. The
|
||||
first configure needs network access.
|
||||
|
||||
- Make the SDK headers visible to the compiler (add `-I<vimbax>/api/include`) and the libs to the linker
|
||||
(`-L<vimbax>/api/lib`), and/or add the lib dir to `LD_LIBRARY_PATH` / an `ld.so.conf.d` entry at runtime.
|
||||
- Run the SDK's transport-layer setup so cameras are discoverable (GenICam GenTL producers).
|
||||
### Allied Vision Vimba X SDK (proprietary)
|
||||
|
||||
The current [Makefile](../Makefile) assumes the headers/libs are already on the default include/link paths; on
|
||||
a fresh machine you will likely need to extend `CXXFLAGS`/`LDFLAGS` with the SDK paths.
|
||||
|
||||
## Build
|
||||
`VmbC`/`VmbCPP` are not in any package manager. Download the Vimba X SDK from Allied Vision
|
||||
(<https://www.alliedvision.com/en/products/software/vimba-x-sdk/>) and point CMake at it:
|
||||
|
||||
```bash
|
||||
make # compiles ini.c, main.cpp, MQTT.cpp, Camera.cpp → bin/Fire_Gimbal_Control.out
|
||||
make clean # removes object files and the binary
|
||||
cmake -B build -DVMB_HOME=/opt/VimbaX_2024-1
|
||||
# or: export VIMBA_X_HOME=/opt/VimbaX_2024-1
|
||||
```
|
||||
|
||||
- Object files land in `obj/` (the Makefile uses `OBJDIR := obj`).
|
||||
- The output binary is `bin/Fire_Gimbal_Control.out`.
|
||||
[cmake/FindVmb.cmake](../cmake/FindVmb.cmake) locates the headers/libs and creates `Vmb::VmbC` / `Vmb::VmbCPP`.
|
||||
If the SDK is absent, build with `-DWITH_VIMBA=OFF` to get a mock-only binary.
|
||||
|
||||
> Note: the committed build artifacts live under `bin/x64/Release/` and `obj/x64/Release/`, but the current
|
||||
> Makefile writes to `bin/` and `obj/`. The `x64/Release/` layout is the *deployed* layout the running binary
|
||||
> expects for config and image output (see below and [known-issues.md](known-issues.md)).
|
||||
## Host setup (for a real run)
|
||||
|
||||
## Directory layout (build vs. deployed)
|
||||
|
||||
```
|
||||
<repo>/
|
||||
├── obj/ ← Makefile object output
|
||||
├── bin/Fire_Gimbal_Control.out ← Makefile binary output
|
||||
└── bin/x64/Release/ ← deployed/runtime directory
|
||||
├── Fire_Gimbal_Control.out ← deployed binary
|
||||
├── config.ini ← runtime config (differs from repo-root config.ini)
|
||||
├── startup_gimbal.sh ← launches with --start 1
|
||||
├── startup_gimbal_with_init.sh ← launches with --init 1 --start 1
|
||||
├── test_smoke.jxl ← demo-mode placeholder image
|
||||
├── RGB/ (ACR/ NIR/) ← image output folders (created on demand)
|
||||
└── crash_genicam.txt ← captured GenICam trigger error log (reference)
|
||||
```
|
||||
|
||||
`compile_commands.json` is present for editor/clangd integration.
|
||||
|
||||
## Host setup to run
|
||||
|
||||
1. **Serial device** — the motor controller must enumerate as `/dev/ttyACM0` at 115200 8N1. Add your user to
|
||||
the `dialout` group (or set a udev rule) for non-root access:
|
||||
1. **Serial device** — the motor controller must appear at the configured `[Serial] device` (default
|
||||
`/dev/ttyACM0`) at the configured baud. Add your user to `dialout` for non-root access:
|
||||
```bash
|
||||
sudo usermod -aG dialout "$USER" # log out/in afterward
|
||||
sudo usermod -aG dialout "$USER" # re-login afterwards
|
||||
```
|
||||
2. **Cameras** — connect the Allied Vision cameras and confirm Vimba X can see them (vendor `VimbaXViewer` /
|
||||
`ListCameras`). The camera IDs in `config.ini` must match (GigE IPs like `192.168.11.101` or USB
|
||||
`DEV_...` IDs). For GigE, configure the host NIC on the `192.168.11.x` subnet.
|
||||
3. **MQTT broker** — a broker must be reachable at the `zkms_server_ip` from `config.ini` with the configured
|
||||
username/password. **The program exits immediately if it cannot connect** ([main.cpp](../main.cpp) lines
|
||||
162-165). For local testing run e.g. Mosquitto and point `zkms_server_ip` at `127.0.0.1`.
|
||||
4. **Paths** — the binary reads config from a hardcoded absolute path and writes images under
|
||||
`$HOME/projects/Fire_Gimbal_Control/...`. See [known-issues.md](known-issues.md) for the exact paths and how
|
||||
to satisfy them.
|
||||
If the port can't be opened the program logs an error and continues (degraded, no telemetry).
|
||||
2. **Cameras** — connect the Allied Vision cameras and confirm Vimba X sees them. Camera IDs in `config.ini`
|
||||
must match (GigE IP like `192.168.11.101`, host NIC on that subnet; or USB `DEV_...`).
|
||||
3. **MQTT broker** — reachable at `[Network] zkms_server_ip` with the credentials (from `$FGC_MQTT_USER`/
|
||||
`$FGC_MQTT_PW` or the config). Unlike the old version, the program **no longer exits** if MQTT is
|
||||
unavailable — it logs a warning and continues. Use `--no-mqtt` to skip MQTT entirely.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
# from the deployed directory
|
||||
./Fire_Gimbal_Control.out --help # show options
|
||||
./Fire_Gimbal_Control.out --start 1 # connect, start camera capture immediately
|
||||
./Fire_Gimbal_Control.out --init 1 --start 1 # find endstops, then start
|
||||
./Fire_Gimbal_Control.out --demo 1 # demo mode (copies test_smoke.jxl instead of encoding)
|
||||
scripts/run.sh --start # real deployment
|
||||
scripts/run.sh --init --start # find endstops, then start
|
||||
scripts/run.sh --mock-serial --mock-camera --no-mqtt --start # dev, no hardware
|
||||
```
|
||||
|
||||
Type `exit` on the console (or send EOF) to stop. See [configuration.md](configuration.md) for all CLI flags
|
||||
and the interactive console commands.
|
||||
[scripts/run.sh](../scripts/run.sh) locates the binary relative to itself, and
|
||||
[scripts/fire-gimbal-control.service](../scripts/fire-gimbal-control.service) is a systemd unit template
|
||||
(uses `WorkingDirectory` + `FGC_CONFIG`, so no paths are hardcoded in the binary).
|
||||
|
||||
## Directory layout
|
||||
|
||||
```
|
||||
build/ CMake build output (gitignored)
|
||||
include/fgc/, src/ headers and implementations (see modules-reference.md)
|
||||
tests/ unit tests
|
||||
config/config.example.ini template (copy to config.ini)
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,115 +1,102 @@
|
|||
# Configuration & Commands
|
||||
|
||||
Three layers control behaviour: the **`config.ini`** file (read once at startup), **command-line flags**, and
|
||||
**interactive console commands** typed on stdin while running.
|
||||
Behaviour is controlled by three layers: the **`config.ini`** file, **command-line flags** (which override the
|
||||
config), and **interactive console commands** typed while running.
|
||||
|
||||
## `config.ini`
|
||||
## Config file resolution
|
||||
|
||||
Parsed by the `inih` library ([ini.c](../ini.c)). Keys are flattened to `Section.name` and stored in a
|
||||
`std::map<std::string,std::string>` ([main.cpp](../main.cpp) lines 35-40, 64-95).
|
||||
There are no hardcoded paths. The file is resolved in this order ([src/core/Paths.cpp](../src/core/Paths.cpp)):
|
||||
|
||||
> **Important:** the binary reads from a **hardcoded absolute path**, not the file next to the binary:
|
||||
> `/home/ggs/projects/Fire_Gimbal_Control/bin/x64/Release/config.ini` ([main.cpp](../main.cpp) line 66).
|
||||
> See [known-issues.md](known-issues.md).
|
||||
1. `--config <path>` CLI flag
|
||||
2. `$FGC_CONFIG` environment variable
|
||||
3. `./config.ini` (current directory)
|
||||
4. `<executable dir>/config.ini`
|
||||
5. `$XDG_CONFIG_HOME/fire_gimbal_control/config.ini` (else `~/.config/...`)
|
||||
|
||||
### Keys
|
||||
If none exist, the program prints every location it searched and exits. Start from the template:
|
||||
|
||||
| Section | Key | Type | Used as | Notes |
|
||||
|---------|-----|------|---------|-------|
|
||||
| `General` | `tower_name` | string | Tower identity; substituted into all MQTT topics and CamEvent payloads | e.g. `Rietschen`, `Staeffelsberg` |
|
||||
| `General` | `image_interval` | int | Seconds between captures; converted to `imagerate = 1/interval` | Required (parsed with `stoi`) |
|
||||
| `General` | `debug` | int (0/1) | Sets `motorctl_info_out`; when on, prints full telemetry each tick | |
|
||||
| `Network` | `zkms_server_ip` | string | MQTT broker address the client connects to | "ZKMS" = the ground-station server |
|
||||
| `Network` | `mqtt_user` | string | MQTT username | Plaintext |
|
||||
| `Network` | `mqtt_pw` | string | MQTT password | Plaintext |
|
||||
| `Camera` | `id_Cam1`..`id_Cam4` | string | Camera IDs; non-empty ones are added in order | GigE IP (`192.168.11.101`) or USB ID (`DEV_1AB22C0AADED`) |
|
||||
|
||||
Camera index → output folder mapping is by **position** (first configured camera = index 0):
|
||||
`0 → RGB`, `1 → ACR`, `2 → NIR` ([Camera.cpp](../Camera.cpp) lines 342-347).
|
||||
|
||||
### Two config files exist (and differ)
|
||||
|
||||
| File | tower_name | interval | broker | cameras |
|
||||
|------|-----------|----------|--------|---------|
|
||||
| [config.ini](../config.ini) (repo root) | `Rietschen` | 5 | `10.11.12.13` | 3× GigE IPs `192.168.11.101-103` |
|
||||
| [bin/x64/Release/config.ini](../bin/x64/Release/config.ini) | `Staeffelsberg` | 3 | `127.0.0.1` | 1× USB `DEV_1AB22C0AADED` |
|
||||
|
||||
The Release copy is the Staeffelsberg deployment. Neither sits at the hardcoded path the binary actually reads —
|
||||
see [known-issues.md](known-issues.md).
|
||||
|
||||
### Example
|
||||
|
||||
```ini
|
||||
[General]
|
||||
tower_name = Staeffelsberg
|
||||
image_interval = 3
|
||||
debug = 0
|
||||
|
||||
[Network]
|
||||
zkms_server_ip = 127.0.0.1
|
||||
mqtt_user = fwt_gimbal
|
||||
mqtt_pw = aeroaero
|
||||
|
||||
[Camera]
|
||||
id_Cam1 = DEV_1AB22C0AADED
|
||||
```bash
|
||||
cp config/config.example.ini config.ini
|
||||
```
|
||||
|
||||
## `config.ini` keys
|
||||
|
||||
Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config.cpp)) into a typed
|
||||
`AppConfig`. Invalid types/values fail fast with a clear message.
|
||||
|
||||
| Section | Key | Type | Default | Meaning |
|
||||
|---------|-----|------|---------|---------|
|
||||
| `General` | `tower_name` | string | `Unnamed` | Tower identity; used in all MQTT topics/payloads |
|
||||
| `General` | `image_interval` | int > 0 | `5` | Seconds between captures (→ `image_rate = 1/interval`) |
|
||||
| `General` | `debug` | bool | `false` | Start with debug-level logging |
|
||||
| `Network` | `zkms_server_ip` | string | `127.0.0.1` | MQTT broker address |
|
||||
| `Network` | `mqtt_user` / `mqtt_pw` | string | — | MQTT credentials (see secrets below) |
|
||||
| `Serial` | `device` | string | `/dev/ttyACM0` | Motor-controller serial device |
|
||||
| `Serial` | `baud` | int | `115200` | Serial baud rate |
|
||||
| `Camera` | `id_Cam1`..`id_Cam4` | string | — | Camera IDs (GigE IP or USB `DEV_...`); non-empty ones used in order |
|
||||
| `Paths` | `output_dir` | string | `$XDG_DATA_HOME/fire_gimbal_control/images` | Image output dir; supports `~`/`$ENV` |
|
||||
| `Features` | `enable_mqtt` | bool | `true` | Use MQTT (vs null channel) |
|
||||
| `Features` | `enable_camera` | bool | `true` | (reserved) |
|
||||
| `Features` | `enable_serial` | bool | `true` | (reserved) |
|
||||
| `Features` | `mock_camera` | bool | `false` | Use the simulated camera |
|
||||
| `Features` | `mock_serial` | bool | `false` | Use the simulated motor controller |
|
||||
|
||||
Camera index → output subfolder defaults to `RGB`, `ACR`, `NIR` (`CameraConfig::labels`).
|
||||
|
||||
### Secrets
|
||||
|
||||
`mqtt_user` / `mqtt_pw` are read from the environment variables **`FGC_MQTT_USER` / `FGC_MQTT_PW`** first,
|
||||
falling back to the config file. Keep credentials out of `config.ini` (which is gitignored anyway) by exporting
|
||||
them or using a systemd `EnvironmentFile`.
|
||||
|
||||
## Command-line flags
|
||||
|
||||
Parsed by Boost.Program_options ([main.cpp](../main.cpp) lines 104-153). All take a `bool` value
|
||||
(presence + value); the code treats *presence* of the option as "on" regardless of the value.
|
||||
Parsed by Boost.Program_options ([main.cpp](../main.cpp)). Flags override `[Features]`.
|
||||
|
||||
| Flag | Short | Value | Effect |
|
||||
|------|-------|-------|--------|
|
||||
| `--help` | `-h` | — | Print options and exit |
|
||||
| `--init` | `-i` | bool | Run the endstop-finding init sequence before the main loop |
|
||||
| `--start` | `-s` | bool | Start camera capture automatically (no `start` console command needed) |
|
||||
| `--demo` | `-d` | bool | Demo/simulation mode: copy `test_smoke.jxl` instead of encoding real frames |
|
||||
| Flag | Effect |
|
||||
|------|--------|
|
||||
| `-h, --help` | Show help and exit |
|
||||
| `-c, --config <path>` | Explicit config file path |
|
||||
| `-i, --init` | Run the endstop-finding init sequence before the loop |
|
||||
| `-s, --start` | Start capture automatically |
|
||||
| `-d, --demo` | Demo mode: copy the placeholder image instead of encoding |
|
||||
| `--no-mqtt` | Disable MQTT (use the null channel) |
|
||||
| `--mock-camera` | Use the simulated camera |
|
||||
| `--mock-serial` | Use the simulated motor controller |
|
||||
| `--log-level <lvl>` | `trace`/`debug`/`info`/`warn`/`error`/`off` |
|
||||
|
||||
Usage: `./Fire_Gimbal_Control.out --init 1 --start 1`
|
||||
Typical headless dev run: `scripts/run.sh --mock-serial --mock-camera --no-mqtt --start`.
|
||||
|
||||
### Init sequence (`--init`)
|
||||
|
||||
When `--init` is set, before entering the loop the program sends to the motor controller, with delays
|
||||
([main.cpp](../main.cpp) lines 220-241): `r` → `e` → `q` → *(wait 60 s)* → `y` → `ud56` ("set number of
|
||||
intervals per turn" = 56). This finds endstops and configures the sweep resolution.
|
||||
Sends to the motor controller, with delays: `r` → `e` → `q` → *(wait 60 s)* → `y` → `ud56`
|
||||
([src/core/Application.cpp](../src/core/Application.cpp), `runInitSequence`).
|
||||
|
||||
## Interactive console commands
|
||||
|
||||
While running, lines typed on stdin are parsed by [Parser.h](../Parser.h) (Boost.Spirit Qi) into up to four
|
||||
tokens: `command device option value`. The grammar expects alphabetic tokens separated by spaces and an
|
||||
optional trailing number. `CMD_eval` then maps them to actions ([main.cpp](../main.cpp) lines 244-273).
|
||||
Lines on stdin are parsed by `parseCommand` ([src/core/CommandParser.cpp](../src/core/CommandParser.cpp)) — a
|
||||
whitespace tokenizer (`<verb> [device] [option] [value]`) that replaced the old fragile Boost.Spirit grammar.
|
||||
Handled in `Application::Impl::handleCommand`.
|
||||
|
||||
| Type this | Meaning |
|
||||
|-----------|---------|
|
||||
| `start` | Start camera acquisition + saver thread |
|
||||
| `stop` | Stop camera acquisition |
|
||||
| `debug` | Toggle telemetry printout (`motorctl_info_out`) |
|
||||
| `set camera fps <value>` | Change camera acquisition frame rate (`AcquisitionFrameRate`) |
|
||||
| `set camera jxlq <value>` | Set JPEG XL quality/distance (0 = lossless; higher = lossier) |
|
||||
| `set camera jxle <value>` | Set JPEG XL effort (libjxl effort level) |
|
||||
| `set camera display <0\|1>` | Toggle live OpenCV preview window |
|
||||
| `set fps <value>` | Set the **capture interval rate** `imagerate` (images per second logic), not camera fps |
|
||||
| `set motorctl <cmd>` | Forward a raw command string to the motor controller over serial |
|
||||
| `exit` | Stop the program (sets `running = false`) |
|
||||
| `start` | Start camera acquisition + capture |
|
||||
| `stop` | Stop acquisition |
|
||||
| `debug` | Toggle debug logging |
|
||||
| `set camera jxlq <v>` | JPEG XL distance (0 = lossless) |
|
||||
| `set camera jxle <v>` | JPEG XL effort |
|
||||
| `set camera display <0\|1>` | Toggle OpenCV preview window |
|
||||
| `set camera fps <v>` | Camera acquisition frame rate (real camera only) |
|
||||
| `set fps <v>` | Capture interval rate (images/second) |
|
||||
| `set motorctl <cmd>` | Forward a raw command to the motor controller (e.g. `set motorctl kd180`) |
|
||||
| `exit` | Quit (Ctrl-D also works) |
|
||||
|
||||
Notes:
|
||||
- `set camera ...` options are handled in `VimbaHandler::evaluateCommand` ([Camera.cpp](../Camera.cpp) lines
|
||||
241-251); only `fps`, `jxlq`, `jxle`, `display` are recognized.
|
||||
- Because the grammar tokenizes on spaces with a trailing number, commands like `set motorctl <cmd>` rely on the
|
||||
`<cmd>` landing in the `option` field. Numeric-only motor commands and multi-token strings may not parse as
|
||||
expected — see [known-issues.md](known-issues.md).
|
||||
## Motor command vocabulary (emitted by the software)
|
||||
|
||||
## Raw motor-controller command reference (observed)
|
||||
|
||||
These short strings are sent over serial (`sendCommand`) by the program or via `set motorctl`. They are
|
||||
interpreted by the **motor controller firmware** (not in this repo); listed here for reference of what the
|
||||
software emits:
|
||||
|
||||
| Command | Sent when | Apparent meaning |
|
||||
|---------|-----------|------------------|
|
||||
| `r`, `e`, `q` | init sequence | endstop/homing steps |
|
||||
| `y` | init sequence | (post-home step) |
|
||||
| `ud56` | init sequence | set intervals-per-turn = 56 |
|
||||
| Command | When | Meaning |
|
||||
|---------|------|---------|
|
||||
| `r`,`e`,`q`,`y`,`ud56` | init sequence | homing / set intervals-per-turn |
|
||||
| `p` | ControlCode 0 capture | stop / advance to next interval |
|
||||
| `kd<heading>` | ControlCode 1 capture | drive to target heading `<heading>` |
|
||||
| `kd<heading>` | ControlCode 1 capture | drive to target heading |
|
||||
|
||||
These are interpreted by the motor-controller firmware (not in this repo).
|
||||
|
|
|
|||
|
|
@ -1,116 +1,47 @@
|
|||
# Known Issues & Reproduction Blockers
|
||||
# Known Issues — Status
|
||||
|
||||
This page lists everything that stands between a freshly-copied checkout and a running system, plus a few
|
||||
correctness/robustness notes. These are **documentation only** — no code has been changed. Each entry notes a
|
||||
recommended fix if you choose to act on it later.
|
||||
This tracks the reproduction blockers and robustness issues identified in the original code and what the
|
||||
refactor did about them.
|
||||
|
||||
## Reproduction blockers
|
||||
## Resolved
|
||||
|
||||
### 1. Hardcoded config path (will fail on this machine)
|
||||
[main.cpp](../main.cpp) line 66 reads the config from an absolute path owned by user `ggs`:
|
||||
| # | Original issue | Resolution |
|
||||
|---|----------------|------------|
|
||||
| 1 | Hardcoded config path (`/home/ggs/...`) | Config search order: `--config` → `$FGC_CONFIG` → `./config.ini` → exe dir → XDG ([src/core/Paths.cpp](../src/core/Paths.cpp)) |
|
||||
| 2 | Hardcoded image output path | `[Paths] output_dir` with `~`/`$ENV` expansion + sensible default |
|
||||
| 3 | Startup scripts with `~/projects/...` | Replaced by path-independent [scripts/run.sh](../scripts/run.sh) + [systemd unit](../scripts/fire-gimbal-control.service) |
|
||||
| 4 | Vimba X required to build | `WITH_VIMBA` CMake option (default ON); `OFF` builds a mock-only binary |
|
||||
| 5 | No hardware path; exits if MQTT down | Mock implementations + runtime toggles; MQTT failure now logs and continues |
|
||||
| 8 | Plaintext MQTT credentials | `$FGC_MQTT_USER`/`$FGC_MQTT_PW` env override; `config.ini` gitignored |
|
||||
| 9 | MQTT busy-wait (`while(running);`) | Gone — Paho async client + `set_automatic_reconnect`; no spin thread |
|
||||
| 10 | `parser()` missing return | Old parser removed; `parseTelemetryLine` returns `std::optional` cleanly |
|
||||
| 11 | Fragile Boost.Spirit command grammar | Replaced by `parseCommand` whitespace tokenizer ([src/core/CommandParser.cpp](../src/core/CommandParser.cpp)) |
|
||||
| 12 | Two divergent `config.ini` files | Single committed `config/config.example.ini`; real configs gitignored |
|
||||
|
||||
```cpp
|
||||
ini_parse("/home/ggs/projects/Fire_Gimbal_Control/bin/x64/Release/config.ini", handler, &config)
|
||||
```
|
||||
Also added along the way: a leveled logger, typed/validated config, an SDK-independent core library, and a
|
||||
doctest unit-test suite (`ctest`).
|
||||
|
||||
On this machine (user `pedro`, repo at `~/code/Fire_Gimbal_Control_staeffelsberg`) that file does not exist, so
|
||||
the program prints `Can't load config.ini file!` and exits.
|
||||
## Open / needs hardware confirmation
|
||||
|
||||
**To run as-is:** create the expected tree and place a config there:
|
||||
```bash
|
||||
mkdir -p /home/ggs/projects/Fire_Gimbal_Control/bin/x64/Release
|
||||
cp bin/x64/Release/config.ini /home/ggs/projects/Fire_Gimbal_Control/bin/x64/Release/
|
||||
```
|
||||
(Or run under a `ggs` user / adjust the path.)
|
||||
**Recommended fix:** make the config path a CLI argument or resolve it relative to the executable / `$HOME`.
|
||||
| # | Issue | Status |
|
||||
|---|-------|--------|
|
||||
| 6 | Telemetry field order: humidity (field 9) before temperature (field 10) | **Preserved as-is** with a clear comment in [TelemetryParser.cpp](../src/core/TelemetryParser.cpp). Confirm against the motor-controller firmware before changing. |
|
||||
| 7 | Trigger fires while `is_moving == 1` (rather than when stopped) | **Preserved** behind a named predicate in [CaptureScheduler.cpp](../src/core/CaptureScheduler.cpp). Looks like a bug; confirm the firmware's `is_moving` semantics, then flip if needed. |
|
||||
|
||||
### 2. Hardcoded image output path
|
||||
[Camera.cpp](../Camera.cpp) line 348 writes images under:
|
||||
## Verification caveats
|
||||
|
||||
```
|
||||
$HOME/projects/Fire_Gimbal_Control/bin/x64/Release/<RGB|ACR|NIR>/<unix_ms>.jxl
|
||||
```
|
||||
- **Real Serial/MQTT/Vimba wrappers** were verified by code review, not compilation, on the development
|
||||
machine (no Paho/Vimba installed there). They are faithful adaptations of the original code. First compile
|
||||
happens on a machine with the SDKs, or via `WITH_MQTT=ON` (Paho is fetched) + the Vimba X SDK.
|
||||
- **Makefile parity** was never re-checked because the original Makefile build also can't run without the SDKs.
|
||||
The Makefile has been removed in favour of CMake; if you need to confirm byte-for-byte behaviour, do a full
|
||||
`WITH_VIMBA=ON WITH_MQTT=ON` build on a tower PC.
|
||||
- **Demo mode** copies `bin/x64/Release/test_smoke.jxl`, resolved relative to the working directory. Run from a
|
||||
directory where that path exists, or extend `ImagePipeline::Params::demo_image`.
|
||||
|
||||
This uses `$HOME` (so it follows the running user) but a **fixed `projects/Fire_Gimbal_Control/...` subtree**,
|
||||
not the repo location. The directories are created on demand, so this mainly matters for knowing where images
|
||||
land and for free-space planning.
|
||||
**Recommended fix:** derive the output root from config.
|
||||
## Possible follow-ups (not done)
|
||||
|
||||
### 3. Startup scripts point at the deployed tree
|
||||
[bin/x64/Release/startup_gimbal.sh](../bin/x64/Release/startup_gimbal.sh) and
|
||||
[startup_gimbal_with_init.sh](../bin/x64/Release/startup_gimbal_with_init.sh) invoke:
|
||||
|
||||
```
|
||||
~/projects/Fire_Gimbal_Control/bin/x64/Release/Fire_Gimbal_Control.out ...
|
||||
```
|
||||
|
||||
i.e. the deployed `~/projects/...` layout, **not** `~/code/Fire_Gimbal_Control_staeffelsberg`. To use the
|
||||
scripts unchanged, deploy the built binary + config into that tree; otherwise edit the scripts to point at your
|
||||
build output.
|
||||
|
||||
### 4. Proprietary SDK required — Allied Vision Vimba X
|
||||
`VmbC`/`VmbCPP` are not installable via any package manager. Without the Vimba X SDK the project **will not
|
||||
compile or link** (`-lVmbC -lVmbCPP`, headers `VmbC/VmbC.h`, `VmbCPP/VmbCPP.h`). Download from Allied Vision and
|
||||
add the SDK include/lib paths — see [build-and-setup.md](build-and-setup.md). Camera IDs in `config.ini` must
|
||||
match the transport: GigE IPs (`192.168.11.10x`, host NIC on that subnet) or USB IDs (`DEV_...`).
|
||||
|
||||
### 5. Hardware + broker needed for a full run
|
||||
A complete run needs all of:
|
||||
- the **motor controller MCU** enumerated at `/dev/ttyACM0` (115200 8N1), user in `dialout`;
|
||||
- the **cameras** reachable by Vimba X;
|
||||
- a **reachable MQTT broker** at `zkms_server_ip` with the configured credentials.
|
||||
|
||||
If serial open fails it is caught and logged (the program continues but has no telemetry). If MQTT does not
|
||||
connect, the program **exits** ([main.cpp](../main.cpp) lines 162-165).
|
||||
|
||||
**Demo mode is not a no-hardware path.** `--demo 1` skips JXL encoding (copies `test_smoke.jxl` instead), but
|
||||
`VimbaHandler`'s constructor still starts the Vimba system and calls `Open()` on each configured camera, so
|
||||
cameras must still be present. There is no flag to bypass cameras entirely.
|
||||
|
||||
## Correctness / robustness notes
|
||||
|
||||
### 6. Telemetry field order (humid before temp)
|
||||
The serial parser maps `values[9] → humid` and `values[10] → temp` ([Serial.h](../Serial.h) lines 99-100). The
|
||||
motor-controller firmware must emit fields in that order. If temperature/humidity look swapped, this is why.
|
||||
|
||||
### 7. Trigger gated on `is_moving == 1`
|
||||
In the capture state machine the actual `TriggerCamera()` call happens only while `act_info.is_moving == 1`
|
||||
([main.cpp](../main.cpp) lines 305-306 and 328-329), i.e. it triggers *while the gimbal still reports moving*
|
||||
rather than after it has stopped. Documented as-is; verify against the firmware's `is_moving` semantics if
|
||||
captured frames look motion-blurred.
|
||||
|
||||
### 8. Plaintext MQTT credentials
|
||||
`mqtt_user` / `mqtt_pw` are stored in plaintext in `config.ini` and printed paths/values go to stdout.
|
||||
**Recommended:** restrict file permissions and/or source credentials from the environment or a secrets store.
|
||||
|
||||
### 9. `MQTTClient::run()` busy-waits
|
||||
After connecting, the MQTT thread spins on `while (running) ;` ([MQTT.cpp](../MQTT.cpp) lines 97-98), pegging a
|
||||
CPU core. Functionally harmless but wasteful. **Recommended:** replace with a condition variable / sleep, or
|
||||
simply let the thread exit since Paho's own threads handle delivery.
|
||||
|
||||
### 10. `SerialPort::parser()` missing return on non-`$` lines
|
||||
`parser()` returns nothing when the first character isn't `$` ([Serial.h](../Serial.h) lines 74-109) — undefined
|
||||
behaviour for a non-void function. In practice the result is ignored, but compile with `-Wall -Wreturn-type`
|
||||
(already `-Wall`) and consider adding an explicit `return false;`.
|
||||
|
||||
### 11. Console grammar limits on `set motorctl`
|
||||
The Boost.Spirit grammar expects `command device option <double>` with alphabetic tokens
|
||||
([Parser.h](../Parser.h) lines 52-62). Raw motor commands that are numeric or multi-token may not land in the
|
||||
`option` field as intended. Verify any `set motorctl <cmd>` you rely on actually parses (watch the echoed
|
||||
`command/device/option/value` line).
|
||||
|
||||
### 12. Two divergent `config.ini` files
|
||||
[config.ini](../config.ini) (root: `Rietschen`, 3 GigE cameras) and
|
||||
[bin/x64/Release/config.ini](../bin/x64/Release/config.ini) (`Staeffelsberg`, 1 USB camera) differ, and
|
||||
**neither is at the path the binary reads** (issue #1). Decide which is authoritative for this deployment and
|
||||
place it at the expected path.
|
||||
|
||||
## Minimal local bring-up checklist
|
||||
|
||||
1. Install non-proprietary deps + Vimba X SDK; `make` succeeds. ([build-and-setup.md](build-and-setup.md))
|
||||
2. Put a valid `config.ini` at `/home/ggs/projects/Fire_Gimbal_Control/bin/x64/Release/config.ini` (issue #1),
|
||||
with `zkms_server_ip = 127.0.0.1` and a local broker running (e.g. Mosquitto).
|
||||
3. Ensure at least one configured camera is visible to Vimba X and on the right subnet/USB.
|
||||
4. Connect the motor MCU on `/dev/ttyACM0` (or expect telemetry-less operation).
|
||||
5. Run `./Fire_Gimbal_Control.out --start 1` and watch `GGS/FWT/<tower>/CamEvent` over MQTT and the
|
||||
`<RGB|ACR|NIR>/` image folders.
|
||||
- Graceful shutdown on SIGINT (currently exit via `exit`/Ctrl-D; a pending `getline` can delay shutdown).
|
||||
- Make the camera index→label map and JPEG XL defaults fully config-driven.
|
||||
- Reintroduce optional image upload to the ground station, config-driven (the old hardcoded NFS/SMB upload was
|
||||
removed).
|
||||
|
|
|
|||
|
|
@ -1,118 +1,76 @@
|
|||
# Module Reference
|
||||
|
||||
Per-file reference for the source tree, plus the key in-memory data structures (this project's "data model").
|
||||
Per-file reference for the refactored tree, plus the shared data structures.
|
||||
|
||||
## Source files
|
||||
## Core (`fgc_core` — SDK-independent, unit-tested)
|
||||
|
||||
### [main.cpp](../main.cpp) — entry point & control loop
|
||||
- `handler()` — inih callback flattening INI keys to `Section.name`.
|
||||
- `readInput()` — stdin thread; reads lines, forwards to the `Parser`, handles `exit`.
|
||||
- `main()` — loads config, parses CLI flags, constructs `SerialPort`, `MQTTClient`, `VimbaHandler`; starts the
|
||||
serial I/O thread and stdin thread; optionally runs the init sequence; runs the 10 ms control loop
|
||||
(command eval → telemetry poll → MQTT poll → capture state machine); cleans up on exit.
|
||||
- Global state lives at file scope (`running`, `imagerate`, `ctl_code`, `mqtt_hdg`, `mqtt_client`,
|
||||
`camera_id_vec`, …).
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| [include/fgc/Config.h](../include/fgc/Config.h), [src/core/Config.cpp](../src/core/Config.cpp) | Typed `AppConfig` (General/Network/Serial/Camera/Paths/Features) + `ConfigLoader` (INI parse, env overrides, validation) |
|
||||
| [include/fgc/Paths.h](../include/fgc/Paths.h), [src/core/Paths.cpp](../src/core/Paths.cpp) | `~`/`$ENV` expansion, executable dir, config search order, default output dir |
|
||||
| [include/fgc/Logger.h](../include/fgc/Logger.h), [src/core/Logger.cpp](../src/core/Logger.cpp) | Leveled, thread-safe logger; `LOG_TRACE..LOG_ERROR` macros |
|
||||
| [include/fgc/TelemetryParser.h](../include/fgc/TelemetryParser.h), [src/core/TelemetryParser.cpp](../src/core/TelemetryParser.cpp) | `parseTelemetryLine` → `std::optional<MotorTelemetry>` |
|
||||
| [include/fgc/CommandParser.h](../include/fgc/CommandParser.h), [src/core/CommandParser.cpp](../src/core/CommandParser.cpp) | `parseCommand` whitespace tokenizer → `Command` |
|
||||
| [include/fgc/CaptureScheduler.h](../include/fgc/CaptureScheduler.h), [src/core/CaptureScheduler.cpp](../src/core/CaptureScheduler.cpp) | Capture state machine over the interfaces; injectable clock |
|
||||
| [include/fgc/Application.h](../include/fgc/Application.h), [src/core/Application.cpp](../src/core/Application.cpp) | Factory (real vs mock), wiring, control loop, console commands |
|
||||
| [ini.c](../ini.c), [ini.h](../ini.h) | Bundled third-party inih INI parser |
|
||||
|
||||
### [Serial.h](../Serial.h) — motor controller link
|
||||
- `struct motor_info` — telemetry snapshot (see below).
|
||||
- `class SerialPort` — opens the port (115200 8N1, no parity/flow), async read-until `\n`, async write.
|
||||
- `parser()` — splits a `$`-prefixed `;`-delimited line into 12 fields and stores a `motor_info`.
|
||||
- `set_controller_info()` / `get_controller_info()` — mutex-guarded accessors.
|
||||
- `sendCommand()` — async-write a command string.
|
||||
- `run()` / `stop()` — drive the Boost.Asio `io_service`.
|
||||
## Interfaces
|
||||
|
||||
### [MQTT.h](../MQTT.h) / [MQTT.cpp](../MQTT.cpp) — MQTT client
|
||||
- `struct mqtt_sub_data` — latest remote control state + "available" flags (see below).
|
||||
- `class MQTTCallback` — Paho callback/listener: subscribes on connect, parses inbound messages, auto-reconnects
|
||||
on loss; `get_sub_data()` returns + clears the available flags.
|
||||
- `class MQTTClient` — owns the `async_client`, connect options (auth, keep-alive, clean session); `publish()`
|
||||
sends QoS 1 retained messages. See [mqtt-api.md](mqtt-api.md) for the topic catalog.
|
||||
| File | Interface | Shared structs |
|
||||
|------|-----------|----------------|
|
||||
| [include/fgc/IMotorController.h](../include/fgc/IMotorController.h) | `IMotorController` | `MotorTelemetry` |
|
||||
| [include/fgc/IControlChannel.h](../include/fgc/IControlChannel.h) | `IControlChannel` | `ControlCommand`, `CamEvent` |
|
||||
| [include/fgc/ICameraSource.h](../include/fgc/ICameraSource.h) | `ICameraSource` | `Frame` |
|
||||
|
||||
### [Camera.h](../Camera.h) / [Camera.cpp](../Camera.cpp) — camera pipeline (Vimba X)
|
||||
- `struct image_store_8bit` — owns a deep copy of one frame's pixel buffer + metadata (see below).
|
||||
- `class FrameObserver` (in .cpp) — Vimba `IFrameObserver`; dumps the first 3 frames after each trigger
|
||||
(`settle`), enqueues complete frames, re-queues buffers to the camera.
|
||||
- `class VimbaHandler` — starts up the Vimba system, opens cameras by ID, manages the per-camera bounded
|
||||
queues and the saver thread.
|
||||
- `Start()`/`Stop()` — begin/end continuous acquisition + saver thread.
|
||||
- `EnqueueToStoreStruct()` — copy a received frame into the queue (reuse oldest if at 100).
|
||||
- `SaveImage()` — saver loop: rotate 90° CCW, optionally display, encode JXL (or copy `test_smoke.jxl` in
|
||||
demo), write file, publish CamEvent.
|
||||
- `TriggerCamera()` / `TriggerSettle()` — fire `TriggerSoftware` ×4 with settle reset.
|
||||
- `ChangeFramerate()`, `evaluateCommand()`, `SetTowerName()`.
|
||||
## Real implementations (SDK-gated)
|
||||
|
||||
### [JPEG_XL.h](../JPEG_XL.h) — `class JPEGXL`
|
||||
Wraps libjxl. Constructor encodes an 8-bit interleaved buffer (1 or 3 channels) into an in-memory JXL
|
||||
codestream using a thread-parallel runner; `q == 0` → lossless, otherwise `q` is the frame distance; `e` is the
|
||||
effort level. `WriteFile()` flushes the codestream to disk.
|
||||
| File | Implements | Built when |
|
||||
|------|-----------|-----------|
|
||||
| [src/serial/SerialMotorController.cpp](../src/serial/SerialMotorController.cpp) | `IMotorController` over Boost.Asio serial (pImpl) | always |
|
||||
| [src/mqtt/MqttControlChannel.cpp](../src/mqtt/MqttControlChannel.cpp) | `IControlChannel` over Eclipse Paho | `WITH_MQTT` |
|
||||
| [src/camera/VimbaCameraSource.cpp](../src/camera/VimbaCameraSource.cpp) | `ICameraSource` over Vimba X (pImpl) | `WITH_VIMBA` |
|
||||
| [src/camera/JpegXlEncoder.cpp](../src/camera/JpegXlEncoder.cpp) | libjxl encode-to-file | always |
|
||||
| [src/camera/ImagePipeline.cpp](../src/camera/ImagePipeline.cpp) | frame → rotate → encode → write → CamEvent (worker thread) | always |
|
||||
|
||||
### [Parser.h](../Parser.h) — console command parsing
|
||||
- `enum InputCommands` — command kinds (`startcamera`, `stopcamera`, `setcamera`, `setimagerate`,
|
||||
`setmotorcontrol`, `setdebug`, `no_cmd`, `setgimbal`).
|
||||
- `struct parser_data` — parsed tokens (see below).
|
||||
- `struct Parser` — Boost.Spirit Qi grammar splitting input into `command device option value`; mutex-guarded.
|
||||
- `struct CMD_eval` — maps `parser_data` to an `InputCommands` value. See [configuration.md](configuration.md).
|
||||
## Mock / null implementations
|
||||
|
||||
### [timing.h](../timing.h) — time helpers
|
||||
- `class Timer` — high-resolution stopwatch (`Reset`, `Elapsed`, `ElapsedMillis`) plus string/file timestamp
|
||||
helpers; used for loop pacing and profiling.
|
||||
- `class NanoUnixTimer` — Unix-epoch **millisecond** timestamps (`Stamp_longlong`, `Stamp_string`) used for
|
||||
image filenames and CamEvent `time`.
|
||||
- `class ScopedTimer` — RAII timer that logs elapsed time on destruction.
|
||||
| File | Implements |
|
||||
|------|-----------|
|
||||
| [include/fgc/mock/MockMotorController.h](../include/fgc/mock/MockMotorController.h) | Simulated sweeping gimbal |
|
||||
| [include/fgc/mock/NullControlChannel.h](../include/fgc/mock/NullControlChannel.h) | No-op channel; auto-sweep |
|
||||
| [include/fgc/mock/MockCameraSource.h](../include/fgc/mock/MockCameraSource.h) | Synthetic gradient frames |
|
||||
|
||||
### [ini.c](../ini.c) / [ini.h](../ini.h) — third-party `inih`
|
||||
Minimal INI parser (`ini_parse`). Unmodified vendored library.
|
||||
## Entry point & scripts
|
||||
|
||||
### Other files
|
||||
- [cxxopts.hpp](../cxxopts.hpp) — third-party CLI parser, **included but unused** (the live CLI parsing uses
|
||||
Boost.Program_options; the cxxopts block in `main.cpp` is commented out).
|
||||
- [Log.h](../Log.h) — empty stub (`#pragma once` only).
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| [main.cpp](../main.cpp) | CLI parsing → `AppConfig` + `RuntimeOptions` → `Application::run()` |
|
||||
| [scripts/run.sh](../scripts/run.sh) | Path-independent launcher |
|
||||
| [scripts/fire-gimbal-control.service](../scripts/fire-gimbal-control.service) | systemd unit template |
|
||||
|
||||
## Data structures (the in-memory "data model")
|
||||
## Data structures
|
||||
|
||||
### `motor_info` ([Serial.h](../Serial.h) lines 6-19)
|
||||
Telemetry from the motor controller, parsed from a 12-field `$`-line.
|
||||
### `MotorTelemetry` ([IMotorController.h](../include/fgc/IMotorController.h))
|
||||
`encoder`, `encoder_err`, `sgt_val`, `sgt_stat`, `is_moving`, `control_status`, `heading` (float),
|
||||
`deviation_warn`, `humidity`, `temperature`, `fan_pwm`. Parsed from the `$;...;` line (humidity is field 9,
|
||||
temperature field 10 — see [known-issues.md](known-issues.md)).
|
||||
|
||||
| Field | Type | Meaning | Source field index |
|
||||
|-------|------|---------|---------------------|
|
||||
| `Xenc` | int | Encoder position | 1 |
|
||||
| `Xerr` | int | Encoder error | 2 |
|
||||
| `sgt_val` | int | StallGuard value | 3 |
|
||||
| `sgt_stat` | int | StallGuard status | 4 |
|
||||
| `is_moving` | int | Movement flag | 5 |
|
||||
| `control_status` | int | Driver/controller status | 6 |
|
||||
| `hdg` | float | Heading (degrees) | 7 |
|
||||
| `deviation_warn` | int | Deviation warning | 8 |
|
||||
| `humid` | int | Humidity | **9** |
|
||||
| `temp` | int | Temperature | **10** |
|
||||
| `fan_pwm` | int | Fan PWM (0-255) | 11 |
|
||||
### `ControlCommand` ([IControlChannel.h](../include/fgc/IControlChannel.h))
|
||||
`control_code` (0 = auto sweep, 1 = directed) + `target_heading`, each with an `*_available` flag.
|
||||
|
||||
> Field 0 is the literal `$` marker. Note the order: index 9 → `humid`, index 10 → `temp` (see
|
||||
> [known-issues.md](known-issues.md) so the firmware emits them in this order).
|
||||
### `CamEvent` ([IControlChannel.h](../include/fgc/IControlChannel.h))
|
||||
`tower`, `camera` (RGB/ACR/NIR), `heading_decideg` (heading×10), `timestamp_ms`. Serialized to the CamEvent
|
||||
JSON payload (see [mqtt-api.md](mqtt-api.md)).
|
||||
|
||||
### `mqtt_sub_data` ([MQTT.h](../MQTT.h) lines 7-21)
|
||||
Latest remote-control state.
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|-------|------|---------|
|
||||
| `ctl_avail` | bool | A new ControlCode arrived since last read |
|
||||
| `hdg_avail` | bool | A new target heading arrived since last read |
|
||||
| `target_heading` | string | Target heading (kept as string, forwarded as `kd<heading>`) |
|
||||
| `control_code` | int | 0 = auto sweep, 1 = directed |
|
||||
|
||||
### `parser_data` ([Parser.h](../Parser.h) lines 21-26)
|
||||
One parsed console command: `command`, `device`, `option` (strings) and `command_val` (double).
|
||||
|
||||
### `image_store_8bit` ([Camera.h](../Camera.h) lines 14-63)
|
||||
One captured frame held in the queue: a `std::vector<VmbUchar_t>` pixel buffer plus `width`, `height`,
|
||||
`pixelFormat`, a Unix-ms `timestamp`, and `cam_id`. Provides `equal()`/`setData()` so the queue can reuse the
|
||||
oldest buffer in place when full.
|
||||
### `Frame` ([ICameraSource.h](../include/fgc/ICameraSource.h))
|
||||
Owned pixel buffer + `width`, `height`, `channels` (1 or 3), `timestamp_ms`, `cam_id`.
|
||||
|
||||
## On-disk artifacts
|
||||
|
||||
| Artifact | Path | Format |
|
||||
|----------|------|--------|
|
||||
| Captured images | `$HOME/projects/Fire_Gimbal_Control/bin/x64/Release/<RGB\|ACR\|NIR>/<unix_ms>.jxl` | JPEG XL, rotated 90° CCW |
|
||||
| Demo placeholder | `bin/x64/Release/test_smoke.jxl` | JPEG XL (copied verbatim in demo mode) |
|
||||
| Captured images | `<output_dir>/<RGB\|ACR\|NIR>/<unix_ms>.jxl` | JPEG XL, rotated 90° CCW |
|
||||
| Demo placeholder | `bin/x64/Release/test_smoke.jxl` | copied verbatim in demo mode |
|
||||
|
||||
There is no other persistence: state lives in memory and diagnostics go to stdout/stderr only (no log files).
|
||||
State otherwise lives in memory; diagnostics go to stdout/stderr (no log files, no database).
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
# MQTT API
|
||||
|
||||
The program is both an MQTT **subscriber** (remote control) and **publisher** (status + capture events). It
|
||||
uses the Eclipse Paho C++ async client ([MQTT.cpp](../MQTT.cpp)). All topics are namespaced by tower name:
|
||||
The program is both an MQTT **subscriber** (remote control) and **publisher** (status + capture events). The
|
||||
MQTT channel is the `MqttControlChannel` implementation of `IControlChannel`
|
||||
([src/mqtt/MqttControlChannel.cpp](../src/mqtt/MqttControlChannel.cpp), Eclipse Paho C++). It is used only when
|
||||
MQTT is enabled; otherwise a `NullControlChannel` runs (publishes dropped, auto-sweep mode). All topics are
|
||||
namespaced by tower name:
|
||||
|
||||
```
|
||||
GGS/FWT/<tower_name>/...
|
||||
|
|
@ -13,15 +16,16 @@ are **retained**.
|
|||
## Connection
|
||||
|
||||
- Broker URI = `Network.zkms_server_ip` from `config.ini`; client ID = the tower name.
|
||||
- Auth: `mqtt_user` / `mqtt_pw` from config; `clean_session = true`, keep-alive 20 s.
|
||||
- Connect timeout 5 s; on connection loss the client auto-reconnects (`reconnect()` with a 2.5 s backoff,
|
||||
re-subscribing on success).
|
||||
- **The program exits at startup if the initial connect fails** ([main.cpp](../main.cpp) lines 162-165).
|
||||
- Auth: `mqtt_user` / `mqtt_pw` (preferring `$FGC_MQTT_USER`/`$FGC_MQTT_PW`); `clean_session = true`,
|
||||
keep-alive 20 s, `set_automatic_reconnect(true)`.
|
||||
- Connect timeout 5 s. On connection loss Paho auto-reconnects; the channel re-subscribes on reconnect.
|
||||
- **The program no longer exits if MQTT is unavailable** — it logs a warning and continues in degraded mode.
|
||||
Use `--no-mqtt` to disable MQTT entirely (null channel).
|
||||
|
||||
## Subscribed topics (inbound — remote control)
|
||||
|
||||
Subscribed in `MQTTCallback::connected()` ([MQTT.cpp](../MQTT.cpp) lines 17-23). Handled in
|
||||
`message_arrived()` ([MQTT.cpp](../MQTT.cpp) lines 25-54).
|
||||
Subscribed on (re)connect; handled in `MqttControlChannel::message_arrived()`
|
||||
([src/mqtt/MqttControlChannel.cpp](../src/mqtt/MqttControlChannel.cpp)).
|
||||
|
||||
| Topic | Payload | Parsed as | Effect |
|
||||
|-------|---------|-----------|--------|
|
||||
|
|
@ -31,7 +35,7 @@ Subscribed in `MQTTCallback::connected()` ([MQTT.cpp](../MQTT.cpp) lines 17-23).
|
|||
Invalid (non-integer) payloads are caught and logged; the previous value is kept.
|
||||
|
||||
The main loop consumes these via `get_sub_data()`, which returns a snapshot and **clears the "available"
|
||||
flags** so each update is acted on once ([MQTT.h](../MQTT.h) lines 125-131).
|
||||
flags** so each update is acted on once (`MqttControlChannel::poll()`).
|
||||
|
||||
### ControlCode semantics
|
||||
|
||||
|
|
@ -51,7 +55,7 @@ When a ControlCode message arrives, the program echoes the current code back on
|
|||
|
||||
### CamEvent payload
|
||||
|
||||
Built in [Camera.cpp](../Camera.cpp) line 389:
|
||||
Built by `MqttControlChannel::publishCamEvent` from a `CamEvent`:
|
||||
|
||||
```json
|
||||
{ "fwt":"Staeffelsberg", "cam":"RGB", "hdg":1373, "time":1719312345678 }
|
||||
|
|
|
|||
|
|
@ -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