5.9 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 firmware
ST Y:...[ P:...]lines;parseTelemetryLineturns each into a per-axisMotorTelemetrysnapshot (state + encoder counts + flags). The mock synthesizes one. Encoder counts are mapped to/from degrees byGeometry([Motor]calibration). - 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) — a move → settle → trigger machine:- ControlCode 0:
MOVE <yaw>,<pitch>to the nextScanGridwaypoint (ping-pong). - ControlCode 1:
MOVEyaw totarget_HDG(pitch held), converted to counts viaGeometry. - Trigger the cameras once both axes report standstill at the target, then advance the grid.
- ControlCode 0:
- 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(yaw + pitch from the encoders).
Capture state machine
interval elapsed AND capture active AND not already moving
│
▼
┌──────────────────────┐ ControlCode 0 → next ScanGrid (yaw,pitch)
│ MOVE <yaw>,<pitch> │ ControlCode 1 → target_HDG (pitch held)
└──────────┬───────────┘ deg→counts via Geometry; set moving, reset timer
│ (both axes standstill AND |xenc − target| ≤ tol)
▼
┌──────────────────────┐
│ camera.trigger() │ on success: clear moving, advance grid (ControlCode 0)
└──────────────────────┘
Triggering only at the settled target replaces the original's trigger-while-moving behaviour (see known-issues.md #7). 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.