# 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 the `CaptureScheduler` (control state machine). Depends on nothing proprietary, so it builds and unit-tests anywhere. - **Five 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) | | `IImuSource` | `MtiImuSource` (Xsens MTi, Boost.Asio) | `MockImuSource` (synthetic orientation) | | `IEnvSensor` | `Sht41EnvSensor` (SHT41 over I2C) | `MockEnvSensor` (synthetic temp/humidity) | Both sensors are optional (`[Features] enable_imu` / `enable_env`) and feed the Sensors panel: the IMU supplies orientation and the IMU-referenced `gimbal pitch setup`, the SHT41 the ambient temperature and humidity. Long-running operations (`gimbal pitch setup` on a worker thread, `gimbal diag` captured from the firmware) publish progress + results into the `UiSnapshot` activity strip, polled on the control thread so all geometry/state mutation stays single-threaded. `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 UI commands, `scheduler.tick()`, publish a `UiSnapshot` | | UI input (headless) | `HeadlessUi` | reads stdin lines into the command sink | | UI render + input (TUI) | `TuiUi` | FTXUI event loop + 10 Hz refresher; pulls snapshots, pushes commands | | 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) | free-runs at the mode's frame rate and delivers frames via the observer, which keeps the latest complete one | Shared state is mutex-guarded: latest `MotorTelemetry` (serial), `RemoteCommand` queue (channel), the frame queue (pipeline), the console command queue, and the latest `UiSnapshot`. The `CaptureScheduler` runs only on the main thread; the UI is a pure observer + command source (it copies a snapshot to render and pushes command strings back through the same queue the console uses, so it never touches live logic objects). In TUI mode all `Logger` output is diverted to an on-screen pane via a `Logger::setSink` callback so the screen is never corrupted. ## Data flow 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 firmware `ST Y:...[ P:...]` lines; `parseTelemetryLine` turns each into a per-axis `MotorTelemetry` snapshot (state + encoder counts + flags). The mock synthesizes one. Encoder counts are mapped to/from degrees by `Geometry` (`[Motor]` calibration). 3. **Mode** — auto, pan or manual ([OperatingMode.h](../include/fgc/OperatingMode.h)), changed only by the `mode` command (console, dashboard keys, or MQTT `Command`) through `Application::setMode`, which publishes it on StatusCode. The same header's `allowedIn()` gates every command by mode. 4. **Capture cycle** (`CaptureScheduler::tick`, per 10 ms) — a move → settle → wait → keep machine: - auto: `MOVE ,` to the next `ScanGrid` waypoint (front to back, then wrap). A yaw-only tower (no `P:` segment in `ST`) gets `MOVE Y ` instead, as its firmware refuses the pair. - pan: `MOVE` to the next of `panHeadings()` (left to right, then back to the left edge) at the pan's pitch, converted to counts via `Geometry`. - manual: nothing, bar a requested snapshot or continuous capture (`capture on`), which skip the move and wait only for standstill. - Once **both axes report standstill at the target**, wait `settle_wait_ms` (and, with `settle_rule = brightness`, until the brightness has settled), then whatever is left of `image_interval` since the last frame kept; keep the newest frame and move straight on to the next grid or pan position - unless saving is behind, when the scan holds until it catches up. 5. **Acquisition** — the camera free-runs at the active profile's `AcquisitionFrameRate` with its own auto-exposure tracking the light; `trigger()` hands over the freshest complete frame. See *Image capture* below. 6. **Frame handling** — the kept `Frame` goes to the callback, which `submit()`s it to the `ImagePipeline`. `submit()` samples the gimbal pose there and then — yaw from the encoder, pitch from the IMU (no IMU: the pitch encoder; no pitch axis either: the fixed `pitch_angle_at_center`) — and the pose travels through the queue with the frame, so encode latency cannot attribute a later pose to earlier pixels. The worker rotates it by `Camera.rotation_deg`, encodes JPEG XL, writes `/