5.5 KiB
Architecture
Overview
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.
The design separates policy (the control logic) from mechanism (the I/O to hardware/broker):
fgc_core— an SDK-independent static library: typed configuration, path resolution, logging, the telemetry/command parsers, and theCaptureScheduler(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 ──► 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
Threading model
| 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) |
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.
Data flow
- Startup —
main()parses CLI, resolves + loads config, constructsApplication, which builds the motor/channel/camera (real or mock), theImagePipeline, and theCaptureScheduler. - Telemetry — the real motor controller streams
$;...;lines;parseTelemetryLineturns each into aMotorTelemetrysnapshot. The mock synthesizes one. - Control input —
IControlChannel::poll()returns the latestControlCommand(control code + target heading), clearing its "available" flags so each update is acted on once. - Capture cycle (
CaptureScheduler::tick, per 10 ms):- ControlCode 0: when the interval elapses, send
pto advance/stop, then trigger the cameras. - ControlCode 1: send
kd<target_heading>to drive to the requested heading, then trigger.
- ControlCode 0: when the interval elapses, send
- Frame handling — a triggered camera delivers a
Frameto the callback, whichsubmit()s it to theImagePipeline. The worker rotates it 90° CCW, encodes JPEG XL (or copies the demo image), writes<output_dir>/<label>/<unix_ms>.jxl, and publishes aCamEvent.
Capture state machine
interval elapsed AND capture active AND is_moving==1
│
▼
┌──────────────────────┐ ControlCode 0 → "p"
│ stop / point gimbal │ ControlCode 1 → "kd<target_heading>"
└──────────┬───────────┘ arm trigger_after_stopping, reset timer
│ (>100 ms later, is_moving==1)
▼
┌──────────────────────┐
│ camera.trigger() │ on success: disarm
└──────────────────────┘
The trigger predicate (is_moving == 1) is preserved from the original; see
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).
Why this shape
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.