fwt_software/docs/architecture.md

92 lines
5.5 KiB
Markdown

# 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.
- **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
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`.
## 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](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
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.