92 lines
5.5 KiB
Markdown
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.
|