fwt_software/docs/architecture.md

5.5 KiB

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. Startupmain() 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 inputIControlChannel::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 for the open question about its semantics. The scheduler is unit-tested with mock doubles and an injected clock (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.