fwt_software/docs/architecture.md

99 lines
6.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 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.