Add logging, I/O interfaces, and extracted capture scheduler

This commit is contained in:
pgdalmeida 2026-06-22 12:26:05 +02:00
parent c4b24c0fbf
commit 16ab54484d
Signed by: pedro.almeida
GPG Key ID: D4A6C394DF13F1D7
11 changed files with 484 additions and 1 deletions

View File

@ -43,6 +43,9 @@ pkg_check_modules(JXL REQUIRED IMPORTED_TARGET libjxl libjxl_threads)
add_library(fgc_core STATIC
src/core/Config.cpp
src/core/Paths.cpp
src/core/Logger.cpp
src/core/TelemetryParser.cpp
src/core/CaptureScheduler.cpp
ini.c
)
target_include_directories(fgc_core PUBLIC

2
Log.h
View File

@ -1 +1,3 @@
#pragma once
// Legacy shim: the project's logging now lives in fgc/Logger.h.
#include "fgc/Logger.h"

View File

@ -0,0 +1,63 @@
#pragma once
#include "fgc/ICameraSource.h"
#include "fgc/IControlChannel.h"
#include "fgc/IMotorController.h"
#include <functional>
#include <string>
namespace fgc {
// The capture control loop, extracted from the old main() while-loop and
// expressed against the I/O interfaces so it can be unit-tested with mocks.
//
// Each tick():
// 1. polls the control channel (updates control code / target heading,
// echoes the code back as status),
// 2. reads motor telemetry,
// 3. runs the capture cycle: when the configured interval elapses, stops /
// points the gimbal, then software-triggers the cameras.
//
// ControlCode 0 = automatic sweep ("p"); ControlCode 1 = drive to the
// MQTT-supplied target heading ("kd<heading>").
class CaptureScheduler {
public:
// now_ms: monotonic millisecond clock; defaults to steady_clock. Injectable
// for deterministic tests.
CaptureScheduler(IMotorController& motor, ICameraSource& camera,
IControlChannel& channel, double image_rate,
std::function<long long()> now_ms = {});
void setCaptureActive(bool active); // mirrors the old cam_started flag
bool captureActive() const { return capture_active_; }
void setImageRate(double rate); // images per second
double imageRate() const { return image_rate_; }
// Run one iteration of the control logic.
void tick();
// Inspection (mainly for tests).
int controlCode() const { return control_code_; }
std::string targetHeading() const { return target_heading_; }
private:
long long elapsedMs() const;
void resetTimer();
IMotorController& motor_;
ICameraSource& camera_;
IControlChannel& channel_;
std::function<long long()> now_ms_;
double image_rate_;
long long timer_start_ = 0;
bool capture_active_ = false;
int control_code_ = 0;
std::string target_heading_ = "0";
bool trigger_after_stopping_ = false;
};
} // namespace fgc

View File

@ -0,0 +1,44 @@
#pragma once
#include <cstdint>
#include <functional>
#include <vector>
namespace fgc {
// One captured frame, owning a copy of its pixel buffer (was image_store_8bit).
struct Frame {
std::vector<uint8_t> data;
uint32_t width = 0;
uint32_t height = 0;
int channels = 0; // 1 (mono) or 3 (RGB)
long long timestamp_ms = 0; // Unix epoch ms
int cam_id = 0; // index into the configured camera list
};
// Abstraction over the camera array. Implemented by VimbaCameraSource (Allied
// Vision Vimba X) and MockCameraSource (synthetic frames, no hardware).
//
// Completed frames are delivered to the callback set via setFrameCallback();
// the consumer (ImagePipeline) encodes and stores them.
class ICameraSource {
public:
using FrameCallback = std::function<void(const Frame&)>;
virtual ~ICameraSource() = default;
virtual void open() = 0; // acquire/open cameras
virtual void close() = 0;
virtual void start() = 0; // begin acquisition
virtual void stop() = 0;
// Software-trigger a capture. Returns true on success.
virtual bool trigger() = 0;
virtual void setFrameCallback(FrameCallback cb) = 0;
// Number of cameras this source manages.
virtual int cameraCount() const = 0;
};
} // namespace fgc

View File

@ -0,0 +1,44 @@
#pragma once
#include <string>
namespace fgc {
// Remote control input (was struct mqtt_sub_data). The *_available flags mark
// whether a fresh value arrived since the last poll().
struct ControlCommand {
bool control_code_available = false;
int control_code = 0; // 0 = automatic sweep, 1 = directed to target heading
bool heading_available = false;
std::string target_heading; // kept as string; forwarded as "kd<heading>"
};
// Notification published after an image is captured and saved.
struct CamEvent {
std::string tower; // tower / FWT name
std::string camera; // "RGB" / "ACR" / "NIR"
int heading_decideg = 0; // heading * 10 (one decimal as integer)
long long timestamp_ms = 0; // Unix epoch ms; matches the image filename
};
// Abstraction over the remote control/telemetry channel. Implemented by
// MqttControlChannel (Eclipse Paho) and NullControlChannel (no broker; used
// for development - publishes are dropped and poll() yields a default).
class IControlChannel {
public:
virtual ~IControlChannel() = default;
// Returns true once usable. NullControlChannel always succeeds.
virtual bool connect() = 0;
virtual void disconnect() = 0;
virtual bool connected() const = 0;
virtual void publishStatus(int code) = 0;
virtual void publishCamEvent(const CamEvent& event) = 0;
// Latest control input; clears the *_available flags so each update is
// acted on once.
virtual ControlCommand poll() = 0;
};
} // namespace fgc

View File

@ -0,0 +1,43 @@
#pragma once
#include <string>
namespace fgc {
// Telemetry snapshot from the motor controller (was struct motor_info).
struct MotorTelemetry {
int encoder = 0; // Xenc
int encoder_err = 0; // Xerr
int sgt_val = 0; // StallGuard value
int sgt_stat = 0; // StallGuard status
int is_moving = 0; // movement flag (kept as int to preserve firmware semantics)
int control_status = 0; // driver/controller status
float heading = 0.f; // degrees
int deviation_warn = 0;
int humidity = 0;
int temperature = 0;
int fan_pwm = 0; // 0-255
};
// Abstraction over the gimbal's motor controller. Implemented by
// SerialMotorController (Boost.Asio serial link) and MockMotorController
// (simulated, for development without hardware).
class IMotorController {
public:
virtual ~IMotorController() = default;
// Begin/*end* background telemetry reading.
virtual void start() = 0;
virtual void stop() = 0;
// Send a raw command string to the controller (e.g. "p", "kd180").
virtual void sendCommand(const std::string& cmd) = 0;
// Latest telemetry snapshot (thread-safe in implementations).
virtual MotorTelemetry telemetry() = 0;
// Whether the underlying link is usable.
virtual bool connected() const = 0;
};
} // namespace fgc

57
include/fgc/Logger.h Normal file
View File

@ -0,0 +1,57 @@
#pragma once
#include <ostream>
#include <sstream>
#include <string>
namespace fgc {
enum class LogLevel { Trace = 0, Debug, Info, Warn, Error, Off };
// Minimal leveled, thread-safe logger. Each log line is assembled in a
// per-statement buffer and written atomically under a shared mutex, so lines
// from different threads never interleave. Level filtering is global.
//
// Usage: LOG_INFO << "starting, rate=" << rate;
// Lines at Warn/Error go to stderr; everything else to stdout.
class Logger {
public:
static void setLevel(LogLevel level);
static LogLevel level();
static bool enabled(LogLevel level);
// Parse "trace"|"debug"|"info"|"warn"|"error"|"off" (case-insensitive).
// Returns false (and leaves the level unchanged) on an unknown string.
static bool setLevelFromString(const std::string& s);
};
// RAII helper that buffers one log line and flushes it on commit().
// Designed to be driven by the LOG_* macros' for-loop guard.
class LogStream {
public:
explicit LogStream(LogLevel level);
bool pending() const { return enabled_ && !done_; }
void commit();
std::ostream& stream() { return buffer_; }
private:
LogLevel level_;
bool enabled_;
bool done_ = false;
std::ostringstream buffer_;
};
} // namespace fgc
// Brace-safe: the whole macro is a single for-statement, so it composes
// correctly inside if/else without dangling-else hazards, and the message
// expression is never evaluated when the level is disabled.
#define FGC_LOG_AT(level) \
for (::fgc::LogStream _fgc_ls(level); _fgc_ls.pending(); _fgc_ls.commit()) \
_fgc_ls.stream()
#define LOG_TRACE FGC_LOG_AT(::fgc::LogLevel::Trace)
#define LOG_DEBUG FGC_LOG_AT(::fgc::LogLevel::Debug)
#define LOG_INFO FGC_LOG_AT(::fgc::LogLevel::Info)
#define LOG_WARN FGC_LOG_AT(::fgc::LogLevel::Warn)
#define LOG_ERROR FGC_LOG_AT(::fgc::LogLevel::Error)

View File

@ -0,0 +1,19 @@
#pragma once
#include "fgc/IMotorController.h"
#include <optional>
#include <string>
namespace fgc {
// Parse a motor-controller telemetry line of the form:
//
// $;Xenc;Xerr;sgt_val;sgt_stat;is_moving;control_status;hdg;deviation_warn;humid;temp;fan_pwm;
//
// i.e. a leading '$' marker followed by 11 ';'-separated numeric fields
// (a trailing ';' is tolerated). Returns nullopt if the line does not start
// with '$', has too few fields, or a field fails to convert.
std::optional<MotorTelemetry> parseTelemetryLine(const std::string& line);
} // namespace fgc

View File

@ -0,0 +1,80 @@
#include "fgc/CaptureScheduler.h"
#include <chrono>
namespace fgc {
namespace {
long long steadyNowMs() {
using namespace std::chrono;
return duration_cast<milliseconds>(steady_clock::now().time_since_epoch()).count();
}
// Whether a software trigger should fire given the current telemetry.
//
// NOTE: this preserves the original behaviour of triggering while the gimbal
// reports is_moving == 1. That looks counter-intuitive (one might expect to
// trigger once stopped). See docs/known-issues.md #7 - kept as-is pending
// confirmation of the firmware's is_moving semantics.
bool shouldTrigger(const MotorTelemetry& t) { return t.is_moving == 1; }
} // namespace
CaptureScheduler::CaptureScheduler(IMotorController& motor, ICameraSource& camera,
IControlChannel& channel, double image_rate,
std::function<long long()> now_ms)
: motor_(motor),
camera_(camera),
channel_(channel),
now_ms_(now_ms ? std::move(now_ms) : steadyNowMs),
image_rate_(image_rate) {
timer_start_ = now_ms_();
}
void CaptureScheduler::setCaptureActive(bool active) { capture_active_ = active; }
void CaptureScheduler::setImageRate(double rate) { image_rate_ = rate; }
long long CaptureScheduler::elapsedMs() const { return now_ms_() - timer_start_; }
void CaptureScheduler::resetTimer() { timer_start_ = now_ms_(); }
void CaptureScheduler::tick() {
// 1. Remote control input.
ControlCommand cmd = channel_.poll();
if (cmd.control_code_available) {
control_code_ = cmd.control_code;
channel_.publishStatus(control_code_);
}
if (cmd.heading_available) {
target_heading_ = cmd.target_heading;
}
// 2. Telemetry.
MotorTelemetry t = motor_.telemetry();
// 3. Capture cycle. The interval gate is 1000/rate milliseconds.
if (control_code_ != 0 && control_code_ != 1) return;
const double interval_ms = image_rate_ > 0.0 ? 1000.0 / image_rate_ : 0.0;
if (capture_active_ && interval_ms > 0.0 && elapsedMs() > interval_ms) {
if (t.is_moving == 1) {
resetTimer();
trigger_after_stopping_ = true;
if (control_code_ == 0)
motor_.sendCommand("p");
else // control_code_ == 1
motor_.sendCommand("kd" + target_heading_);
}
}
if (trigger_after_stopping_ && elapsedMs() > 100) {
if (shouldTrigger(t)) {
if (camera_.trigger()) {
trigger_after_stopping_ = false;
}
}
}
}
} // namespace fgc

73
src/core/Logger.cpp Normal file
View File

@ -0,0 +1,73 @@
#include "fgc/Logger.h"
#include <atomic>
#include <chrono>
#include <cstdio>
#include <ctime>
#include <iostream>
#include <mutex>
namespace fgc {
namespace {
std::atomic<LogLevel> g_level{LogLevel::Info};
std::mutex g_mutex;
const char* tag(LogLevel l) {
switch (l) {
case LogLevel::Trace: return "TRACE";
case LogLevel::Debug: return "DEBUG";
case LogLevel::Info: return "INFO ";
case LogLevel::Warn: return "WARN ";
case LogLevel::Error: return "ERROR";
case LogLevel::Off: return "OFF ";
}
return "?????";
}
std::string timestamp() {
using namespace std::chrono;
auto now = system_clock::now();
auto t = system_clock::to_time_t(now);
auto ms = duration_cast<milliseconds>(now.time_since_epoch()) % 1000;
std::tm tm{};
localtime_r(&t, &tm);
char buf[32];
std::snprintf(buf, sizeof(buf), "%02d:%02d:%02d.%03d", tm.tm_hour, tm.tm_min,
tm.tm_sec, static_cast<int>(ms.count()));
return buf;
}
} // namespace
void Logger::setLevel(LogLevel level) { g_level.store(level); }
LogLevel Logger::level() { return g_level.load(); }
bool Logger::enabled(LogLevel level) { return level >= g_level.load(); }
bool Logger::setLevelFromString(const std::string& s) {
std::string v;
for (char c : s) v += static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
if (v == "trace") { setLevel(LogLevel::Trace); return true; }
if (v == "debug") { setLevel(LogLevel::Debug); return true; }
if (v == "info") { setLevel(LogLevel::Info); return true; }
if (v == "warn" || v == "warning") { setLevel(LogLevel::Warn); return true; }
if (v == "error") { setLevel(LogLevel::Error); return true; }
if (v == "off" || v == "none") { setLevel(LogLevel::Off); return true; }
return false;
}
LogStream::LogStream(LogLevel level) : level_(level), enabled_(Logger::enabled(level)) {}
void LogStream::commit() {
done_ = true;
if (!enabled_) return;
std::ostream& sink = (level_ >= LogLevel::Warn) ? std::cerr : std::cout;
std::lock_guard<std::mutex> lock(g_mutex);
sink << timestamp() << " [" << tag(level_) << "] " << buffer_.str() << '\n';
sink.flush();
}
} // namespace fgc

View File

@ -0,0 +1,55 @@
#include "fgc/TelemetryParser.h"
#include <vector>
namespace fgc {
namespace {
std::vector<std::string> split(const std::string& s, char delim) {
std::vector<std::string> out;
std::string cur;
for (char c : s) {
if (c == delim) {
out.push_back(cur);
cur.clear();
} else if (c != '\r' && c != '\n') {
cur += c;
}
}
out.push_back(cur);
return out;
}
} // namespace
std::optional<MotorTelemetry> parseTelemetryLine(const std::string& line) {
if (line.empty() || line[0] != '$') return std::nullopt;
std::vector<std::string> f = split(line, ';');
// Need indices 0..11: '$' marker + 11 data fields.
if (f.size() < 12) return std::nullopt;
try {
MotorTelemetry t;
t.encoder = std::stoi(f[1]);
t.encoder_err = std::stoi(f[2]);
t.sgt_val = std::stoi(f[3]);
t.sgt_stat = std::stoi(f[4]);
t.is_moving = std::stoi(f[5]);
t.control_status = std::stoi(f[6]);
t.heading = std::stof(f[7]);
t.deviation_warn = std::stoi(f[8]);
// NOTE: field order is humidity (index 9) BEFORE temperature (index 10),
// matching the controller firmware. See docs/known-issues.md #6 - do not
// swap without confirming the firmware output.
t.humidity = std::stoi(f[9]);
t.temperature = std::stoi(f[10]);
t.fan_pwm = std::stoi(f[11]);
return t;
} catch (const std::exception&) {
return std::nullopt;
}
}
} // namespace fgc