# 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.
- **Four 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) |

The IMU is optional (`[Features] enable_imu`); it feeds the Sensors panel and the IMU-referenced
`gimbal calib`. Long-running operations (`gimbal calib` 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) | delivers frames via the observer (real source) |

Shared state is mutex-guarded: latest `MotorTelemetry` (serial), `ControlCommand` (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. **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) — a move → settle → trigger machine:
   - ControlCode 0: `MOVE <yaw>,<pitch>` to the next `ScanGrid` waypoint (ping-pong).
   - ControlCode 1: `MOVE` yaw to `target_HDG` (pitch held), converted to counts via `Geometry`.
   - Trigger the cameras once **both axes report standstill at the target**, then advance the grid.
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` (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](known-issues.md) #7). The scheduler is unit-tested with mock doubles and an injected clock
([tests/test_scheduler.cpp](../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.
