1572 lines
70 KiB
C++
1572 lines
70 KiB
C++
#include "fgc/Application.h"
|
||
|
||
#include "fgc/CalibrationRoutine.h"
|
||
#include "fgc/CaptureScheduler.h"
|
||
#include "fgc/CommandParser.h"
|
||
#include "fgc/DiagParser.h"
|
||
#include "fgc/DumpParser.h"
|
||
#include "fgc/HelpText.h"
|
||
#include "fgc/Paths.h"
|
||
#include "fgc/CameraMode.h"
|
||
#include "fgc/ICameraSource.h"
|
||
#include "fgc/IControlChannel.h"
|
||
#include "fgc/IEnvSensor.h"
|
||
#include "fgc/IImuSource.h"
|
||
#include "fgc/IMotorController.h"
|
||
#include "fgc/ImagePipeline.h"
|
||
#include "fgc/Logger.h"
|
||
#include "fgc/MtiImuSource.h"
|
||
#include "fgc/ScanGrid.h"
|
||
#include "fgc/SerialMotorController.h"
|
||
#include "fgc/TestReport.h"
|
||
#include "fgc/TestRunner.h"
|
||
#include "fgc/mock/MockCameraSource.h"
|
||
#include "fgc/mock/MockEnvSensor.h"
|
||
#include "fgc/mock/MockImuSource.h"
|
||
#include "fgc/mock/MockMotorController.h"
|
||
#include "fgc/mock/NullControlChannel.h"
|
||
#include "fgc/sensors/Sht41EnvSensor.h"
|
||
#include "fgc/ui/HeadlessUi.h"
|
||
#include "fgc/ui/IUserInterface.h"
|
||
#include "fgc/ui/UiSnapshot.h"
|
||
|
||
#include <algorithm>
|
||
#include <atomic>
|
||
#include <cctype>
|
||
#include <chrono>
|
||
#include <cmath>
|
||
#include <csignal>
|
||
#include <cstdio>
|
||
#include <cstdlib>
|
||
#include <filesystem>
|
||
#include <fstream>
|
||
#include <memory>
|
||
#include <mutex>
|
||
#include <queue>
|
||
#include <sstream>
|
||
#include <string>
|
||
#include <thread>
|
||
|
||
#if FGC_WITH_MQTT
|
||
#include "fgc/MqttControlChannel.h"
|
||
#endif
|
||
#if FGC_WITH_VIMBA
|
||
#include "fgc/VimbaCameraSource.h"
|
||
#endif
|
||
#if FGC_WITH_TUI
|
||
#include "fgc/ui/TuiUi.h"
|
||
#endif
|
||
|
||
namespace fgc {
|
||
|
||
namespace {
|
||
|
||
// Set by SIGINT/SIGTERM so the control loop exits cleanly and tears the camera down
|
||
// (StopContinuousImageAcquisition + Close). Without this, a Ctrl-C / kill / systemd
|
||
// stop skips teardown and leaves the camera acquiring - which wedges it so the NEXT
|
||
// run gets no frames. Only an atomic flag is set here (async-signal-safe).
|
||
std::atomic<bool> g_shutdown_requested{false};
|
||
void handleShutdownSignal(int) { g_shutdown_requested.store(true); }
|
||
|
||
long long nowEpochMs() {
|
||
return std::chrono::duration_cast<std::chrono::milliseconds>(
|
||
std::chrono::system_clock::now().time_since_epoch())
|
||
.count();
|
||
}
|
||
|
||
// True if `dir` can be created and a probe file written there. A successful
|
||
// create_directories alone isn't enough (the dir may exist read-only), so we
|
||
// actually write+remove a probe file.
|
||
bool outputDirWritable(const std::string& dir) {
|
||
namespace fs = std::filesystem;
|
||
std::error_code ec;
|
||
fs::create_directories(dir, ec);
|
||
if (ec) return false;
|
||
const fs::path probe = fs::path(dir) / ".fgc_write_test";
|
||
std::ofstream f(probe);
|
||
if (!f.good()) return false;
|
||
f.close();
|
||
fs::remove(probe, ec);
|
||
return true;
|
||
}
|
||
|
||
// Human-readable list of the enabled wire-trace categories, for echoing back.
|
||
std::string traceNames(unsigned mask) {
|
||
if (mask == 0) return "none";
|
||
std::string out;
|
||
const std::pair<LogCat, const char*> all[] = {
|
||
{LogCat::Serial, "serial"}, {LogCat::Mqtt, "mqtt"},
|
||
{LogCat::Camera, "camera"}, {LogCat::Control, "control"},
|
||
};
|
||
for (const auto& [cat, name] : all) {
|
||
if (mask & static_cast<unsigned>(cat)) {
|
||
if (!out.empty()) out += ',';
|
||
out += name;
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// Build the expanded-view ImuConfigView from the device's decoded config.
|
||
ImuConfigView formatImuConfig(const ImuDeviceConfig& c) {
|
||
ImuConfigView v;
|
||
v.present = true;
|
||
v.product_code = c.product_code.empty() ? "—" : c.product_code;
|
||
if (c.has_device_id) {
|
||
char b[16];
|
||
std::snprintf(b, sizeof(b), "0x%08X", c.device_id);
|
||
v.device_id = b;
|
||
} else {
|
||
v.device_id = "—";
|
||
}
|
||
v.firmware = c.firmware.empty() ? "—" : c.firmware;
|
||
|
||
if (c.has_output_mode) {
|
||
std::string m;
|
||
auto add = [&](bool on, const char* name) {
|
||
if (on) { if (!m.empty()) m += " · "; m += name; }
|
||
};
|
||
add(c.out_temperature, "Temp");
|
||
add(c.out_calibrated, "Calibrated");
|
||
add(c.out_orientation, "Orientation");
|
||
add(c.out_auxiliary, "Auxiliary");
|
||
add(c.out_status, "Status");
|
||
v.output_mode = m.empty() ? "—" : m;
|
||
} else {
|
||
v.output_mode = "—";
|
||
}
|
||
|
||
if (c.has_output_settings) {
|
||
v.output_settings = c.orientation_mode + " · " + c.timestamp_mode + " · " + c.data_format;
|
||
std::string ch;
|
||
if (c.acc_enabled) ch += "acc ";
|
||
if (c.gyr_enabled) ch += "gyr ";
|
||
if (c.mag_enabled) ch += "mag";
|
||
v.channels = ch.empty() ? "none" : ch;
|
||
} else {
|
||
v.output_settings = "—";
|
||
v.channels = "—";
|
||
}
|
||
|
||
if (c.has_period && c.sample_rate_hz > 0) {
|
||
char b[24];
|
||
std::snprintf(b, sizeof(b), "%.0f Hz", c.sample_rate_hz);
|
||
v.sample_rate = b;
|
||
} else {
|
||
v.sample_rate = "—";
|
||
}
|
||
|
||
// XKF profiles: list every available profile by name (no numeric IDs) and
|
||
// flag the active one (matched by profile type).
|
||
for (const auto& p : c.available_profiles) {
|
||
ImuProfileView pv;
|
||
pv.name = p.label.empty() ? "(unnamed)" : p.label;
|
||
pv.selected = c.has_scenario && p.type == c.scenario_type;
|
||
v.xkf_profiles.push_back(std::move(pv));
|
||
}
|
||
// If the device reported the active profile but not the full list, still show
|
||
// the one in use (by its resolved label, when known).
|
||
if (v.xkf_profiles.empty() && c.has_scenario && !c.scenario_label.empty())
|
||
v.xkf_profiles.push_back({c.scenario_label, true});
|
||
return v;
|
||
}
|
||
|
||
} // namespace
|
||
|
||
struct Application::Impl {
|
||
Impl(AppConfig c, RuntimeOptions o) : cfg(std::move(c)), opts(std::move(o)) {}
|
||
|
||
AppConfig cfg;
|
||
RuntimeOptions opts;
|
||
|
||
std::unique_ptr<IControlChannel> channel;
|
||
std::unique_ptr<IMotorController> motor;
|
||
std::unique_ptr<ICameraSource> camera;
|
||
// Camera modes, loaded once at startup and kept so a live switch needs no disk
|
||
// access. `camera_mode` is the active one.
|
||
std::map<std::string, CameraMode> camera_modes;
|
||
std::string camera_mode;
|
||
std::unique_ptr<IImuSource> imu;
|
||
std::unique_ptr<IEnvSensor> env;
|
||
std::unique_ptr<ImagePipeline> pipeline;
|
||
std::unique_ptr<CaptureScheduler> scheduler;
|
||
std::unique_ptr<IUserInterface> ui;
|
||
std::unique_ptr<CalibrationRoutine> calib;
|
||
std::unique_ptr<TestRunner> tester;
|
||
unsigned last_diag_seq = 0;
|
||
ScanGrid grid; // outlives scheduler (holds a reference to it)
|
||
std::string scan_load_error_; // non-empty if grid_file failed to load
|
||
|
||
// Persisted last-results for the TUI activity strip (survive in the log churn).
|
||
bool diag_running_ = false;
|
||
std::chrono::steady_clock::time_point diag_started_;
|
||
std::vector<std::string> last_diag_summary;
|
||
long long last_diag_ts = 0; // epoch ms
|
||
bool last_diag_pass = false;
|
||
DiagResultView last_diag_view; // per-axis worst tracking error
|
||
// Rising-edge latches for the live encoder-error WARN (avoid 100Hz spam).
|
||
bool yaw_track_over_ = false;
|
||
bool pitch_track_over_ = false;
|
||
CalibResultView last_calib_view;
|
||
std::vector<std::string> last_calib_summary;
|
||
long long last_calib_ts = 0; // epoch ms
|
||
bool calib_save_pending_ = false; // awaiting y/n to persist to config
|
||
|
||
// Hardware self-test (`test` command) results, for the activity strip.
|
||
TestResultView last_test_view;
|
||
std::vector<std::string> last_test_summary;
|
||
long long last_test_ts = 0; // epoch ms
|
||
bool last_test_pass = false;
|
||
std::string last_test_report_path; // promoted by `test baseline`
|
||
|
||
// Procedure lock-out: while a long op runs, interfering commands are ignored
|
||
// and Esc cancels. Homing runs on the control thread (blocking), so it uses
|
||
// these flags directly; calibration/test run on their own threads.
|
||
std::atomic<bool> homing_active_{false};
|
||
std::atomic<bool> cancel_procedure_{false};
|
||
double test_drift_warn_ = 15.0; // % (from the running profile)
|
||
bool test_drift_gate_ = false;
|
||
mutable ImuConfigView imu_config_view_; // formatted MTi config (read once)
|
||
mutable bool imu_config_done_ = false;
|
||
mutable long long last_env_published_ts_ = 0; // gate MQTT publish to one per fresh sample
|
||
|
||
std::atomic<bool> running{true};
|
||
std::mutex cmd_mutex;
|
||
std::queue<std::string> cmd_queue;
|
||
|
||
std::chrono::steady_clock::time_point start_time;
|
||
std::mutex snapshot_mutex;
|
||
UiSnapshot latest_snapshot;
|
||
|
||
std::string statusTopicTower() const { return cfg.general.tower_name; }
|
||
|
||
std::unique_ptr<IControlChannel> makeChannel() {
|
||
bool want = opts.use_mqtt.value_or(cfg.features.enable_mqtt);
|
||
#if FGC_WITH_MQTT
|
||
if (want) {
|
||
return std::make_unique<MqttControlChannel>(cfg.network.brokerUri(),
|
||
cfg.general.tower_name,
|
||
cfg.network.mqtt_user,
|
||
cfg.network.mqtt_pw);
|
||
}
|
||
#else
|
||
if (want) LOG_WARN << "MQTT requested but binary built without MQTT; using null channel";
|
||
#endif
|
||
return std::make_unique<NullControlChannel>();
|
||
}
|
||
|
||
std::unique_ptr<IMotorController> makeMotor() {
|
||
bool mock = opts.mock_serial.value_or(cfg.features.mock_serial);
|
||
if (mock) return std::make_unique<MockMotorController>();
|
||
return std::make_unique<SerialMotorController>(cfg.serial.device, cfg.serial.baud);
|
||
}
|
||
|
||
std::unique_ptr<IImuSource> makeImu() {
|
||
if (!cfg.features.enable_imu) return nullptr; // sensors panel stays "pending"
|
||
if (cfg.features.mock_imu) return std::make_unique<MockImuSource>();
|
||
if (cfg.imu.device.empty()) {
|
||
LOG_WARN << "IMU enabled but [IMU] device is empty; disabling IMU";
|
||
return nullptr;
|
||
}
|
||
return std::make_unique<MtiImuSource>(cfg.imu.device, cfg.imu.baud);
|
||
}
|
||
|
||
std::unique_ptr<IEnvSensor> makeEnv() {
|
||
// --mock-env implies enable_env: the sensor is off by default, so without
|
||
// that the flag would silently do nothing.
|
||
const bool force_mock = opts.mock_env.value_or(false);
|
||
if (!cfg.features.enable_env && !force_mock)
|
||
return nullptr; // sensors panel stays "pending"
|
||
if (force_mock || cfg.features.mock_env) return std::make_unique<MockEnvSensor>();
|
||
if (cfg.env.i2c_device.empty()) {
|
||
LOG_WARN << "Env sensor enabled but [Env] i2c_device is empty; disabling";
|
||
return nullptr;
|
||
}
|
||
return std::make_unique<Sht41EnvSensor>(cfg.env.i2c_device,
|
||
static_cast<uint8_t>(cfg.env.i2c_addr),
|
||
std::chrono::milliseconds(cfg.env.period_ms));
|
||
}
|
||
|
||
std::unique_ptr<ICameraSource> makeCamera() {
|
||
bool mock = opts.mock_camera.value_or(cfg.features.mock_camera);
|
||
#if !FGC_WITH_VIMBA
|
||
if (!mock) {
|
||
LOG_WARN << "Camera requested but binary built without Vimba X; using mock camera";
|
||
mock = true;
|
||
}
|
||
#endif
|
||
if (mock) {
|
||
int count = cfg.camera.ids.empty() ? 1 : static_cast<int>(cfg.camera.ids.size());
|
||
return std::make_unique<MockCameraSource>(count);
|
||
}
|
||
#if FGC_WITH_VIMBA
|
||
auto cam = std::make_unique<VimbaCameraSource>(cfg.camera);
|
||
cam->setInitialMode(camera_modes.at(camera_mode));
|
||
return cam;
|
||
#else
|
||
return std::make_unique<MockCameraSource>(1);
|
||
#endif
|
||
}
|
||
|
||
// Load every mode file up front, so a typo in a mode nobody is using today
|
||
// still fails at startup rather than the first time someone switches to it.
|
||
void loadModes() {
|
||
camera_modes = fgc::loadCameraModes(cfg.camera.mode_dir);
|
||
if (camera_modes.find(cfg.camera.mode) == camera_modes.end()) {
|
||
std::string known;
|
||
for (const auto& [name, m] : camera_modes) known += (known.empty() ? "" : ", ") + name;
|
||
throw std::runtime_error("Camera.mode '" + cfg.camera.mode + "' has no " +
|
||
cfg.camera.mode + ".ini in " + cfg.camera.mode_dir +
|
||
" (available: " + known + ")");
|
||
}
|
||
camera_mode = cfg.camera.mode;
|
||
LOG_INFO << "camera modes: " << camera_modes.size() << " from " << cfg.camera.mode_dir
|
||
<< "; active '" << camera_mode << "'";
|
||
}
|
||
|
||
// Switch the live camera mode by name. Returns false (and warns) if unknown.
|
||
bool applyMode(const std::string& name) {
|
||
auto it = camera_modes.find(name);
|
||
if (it == camera_modes.end()) {
|
||
std::string known;
|
||
for (const auto& [n, m] : camera_modes) known += (known.empty() ? "" : ", ") + n;
|
||
LOG_WARN << "unknown camera mode '" << name << "' (available: " << known << ')';
|
||
return false;
|
||
}
|
||
if (camera) camera->setMode(it->second);
|
||
camera_mode = name;
|
||
if (scheduler)
|
||
scheduler->setSettleWait(it->second.settle_wait_ms, it->second.frame_timeout_ms);
|
||
LOG_INFO << "camera mode -> " << name << " (" << it->second.description << ')';
|
||
return true;
|
||
}
|
||
|
||
std::unique_ptr<IUserInterface> makeUi() {
|
||
bool want = opts.use_tui.value_or(cfg.ui.enable_tui);
|
||
#if FGC_WITH_TUI
|
||
if (want) return std::make_unique<TuiUi>();
|
||
#else
|
||
if (want) LOG_WARN << "TUI requested but binary built without TUI support; using headless console";
|
||
#endif
|
||
return std::make_unique<HeadlessUi>();
|
||
}
|
||
|
||
// Read live state (on the control thread) into a plain snapshot the UI can
|
||
// copy and render without touching the logic objects. The log pane is filled
|
||
// by the UI from its own ring buffer, so it is left empty here.
|
||
UiSnapshot buildSnapshot() const {
|
||
UiSnapshot s;
|
||
|
||
const bool mock_serial = opts.mock_serial.value_or(cfg.features.mock_serial);
|
||
s.header.tower = cfg.general.tower_name;
|
||
s.header.build = "dev";
|
||
s.header.uptime_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||
std::chrono::steady_clock::now() - start_time).count();
|
||
s.header.live = !mock_serial;
|
||
|
||
// --- Gimbal ---
|
||
MotorTelemetry t = motor->telemetry();
|
||
s.gimbal.present = motor->connected();
|
||
s.gimbal.pitch_present = t.pitch_present;
|
||
auto fillAxis = [](AxisView& v, const char* label, const AxisTelemetry& a,
|
||
const AxisMap& map, long target_counts) {
|
||
v.label = label;
|
||
v.state = a.state;
|
||
v.deg = map.toDeg(a.xenc);
|
||
v.xactual = a.xactual;
|
||
v.xenc = a.xenc;
|
||
v.enc_err = a.xactual - a.xenc; // live tracking error (counts)
|
||
v.target_deg = map.toDeg(target_counts);
|
||
v.sg = a.sg;
|
||
v.cs = a.cs;
|
||
v.pwm = a.pwm;
|
||
v.drv_status = a.drv_status;
|
||
v.moving = a.moving();
|
||
v.standstill = a.standstill;
|
||
v.stall = a.stall;
|
||
v.overtemp = a.overtemp;
|
||
v.endstop_l = a.endstop_l;
|
||
v.endstop_r = a.endstop_r;
|
||
};
|
||
long yaw_tgt = scheduler ? scheduler->yawTargetCounts() : 0;
|
||
long pitch_tgt = scheduler ? scheduler->pitchTargetCounts() : 0;
|
||
fillAxis(s.gimbal.yaw, "YAW", t.yaw, cfg.geometry.yaw, yaw_tgt);
|
||
fillAxis(s.gimbal.pitch, "PITCH", t.pitch, cfg.geometry.pitch, pitch_tgt);
|
||
|
||
// Homing travel limits come from the last firmware dump (lim_neg/lim_pos);
|
||
// convert the endstop counts to degrees via the per-axis geometry.
|
||
DumpData dd = parseDump(motor->lastDump());
|
||
if (dd.valid) {
|
||
auto fillLimits = [](AxisView& v, const DumpAxis& a, const AxisMap& map) {
|
||
v.has_limits = true;
|
||
v.lim_neg = a.lim_neg;
|
||
v.lim_pos = a.lim_pos;
|
||
v.lim_neg_deg = map.toDeg(a.lim_neg);
|
||
v.lim_pos_deg = map.toDeg(a.lim_pos);
|
||
v.has_track = true;
|
||
v.hold_corrections = a.hold_corrections;
|
||
v.hold_peak_dev = a.hold_peak_dev;
|
||
v.hold_cumulative_dev = a.hold_cumulative_dev;
|
||
};
|
||
for (const auto& a : dd.axes) {
|
||
if (a.axis == 'Y') fillLimits(s.gimbal.yaw, a, cfg.geometry.yaw);
|
||
else if (a.axis == 'P') fillLimits(s.gimbal.pitch, a, cfg.geometry.pitch);
|
||
}
|
||
}
|
||
|
||
// --- Sensors: MTi (orientation) + SHT41 (ambient), each live if present ---
|
||
s.sensors = pendingSensorsView();
|
||
if (imu) {
|
||
auto fmt1 = [](float v) {
|
||
char b[24];
|
||
std::snprintf(b, sizeof(b), "%.1f", v);
|
||
return std::string(b);
|
||
};
|
||
auto setField = [&](SensorField& f, const std::string& v) {
|
||
f.value = v;
|
||
f.present = true;
|
||
};
|
||
// Device configuration (read once at startup): format and cache.
|
||
if (!imu_config_done_) {
|
||
if (auto c = imu->config()) {
|
||
imu_config_view_ = formatImuConfig(*c);
|
||
imu_config_done_ = true;
|
||
}
|
||
}
|
||
s.imu.config = imu_config_view_;
|
||
if (auto m = imu->sample()) {
|
||
// Full reading for the expanded view.
|
||
s.imu.present = true;
|
||
s.imu.roll_deg = m->roll_deg;
|
||
s.imu.pitch_deg = m->pitch_deg;
|
||
s.imu.yaw_deg = m->yaw_deg;
|
||
for (int i = 0; i < 3; ++i) {
|
||
s.imu.acc[i] = m->acc[i];
|
||
s.imu.gyr[i] = m->gyr[i];
|
||
s.imu.mag[i] = m->mag[i];
|
||
}
|
||
s.imu.temp_c = m->temp_c;
|
||
s.imu.sample_counter = m->sample_counter;
|
||
// Compact MTi subsection: Roll/Pitch/Yaw only. The internal temp
|
||
// stays in s.imu (expanded view) so the main window's single
|
||
// temperature is unambiguously the SHT41's ambient one.
|
||
auto& g = s.sensors.imu;
|
||
g.present = true;
|
||
g.status = "live";
|
||
if (g.fields.size() >= 3) {
|
||
setField(g.fields[0], fmt1(m->roll_deg));
|
||
setField(g.fields[1], fmt1(m->pitch_deg));
|
||
setField(g.fields[2], fmt1(m->yaw_deg));
|
||
}
|
||
} else {
|
||
s.sensors.imu.status = "no fix";
|
||
}
|
||
}
|
||
|
||
if (env) {
|
||
auto fmt1 = [](float v) {
|
||
char b[24];
|
||
std::snprintf(b, sizeof(b), "%.1f", v);
|
||
return std::string(b);
|
||
};
|
||
auto setField = [&](SensorField& f, const std::string& v) {
|
||
f.value = v;
|
||
f.present = true;
|
||
};
|
||
// Identity and bus config are set whether or not a sample is available,
|
||
// so the expanded view can still say what the sensor is and where it
|
||
// should be when it goes quiet.
|
||
s.env.enabled = true;
|
||
s.env.mock = opts.mock_env.value_or(cfg.features.mock_env);
|
||
s.env.name = env->name();
|
||
s.env.i2c_device = cfg.env.i2c_device;
|
||
s.env.i2c_addr = cfg.env.i2c_addr;
|
||
s.env.period_ms = cfg.env.period_ms;
|
||
if (auto m = env->sample()) {
|
||
s.env.present = true;
|
||
s.env.temp_c = m->temp_c;
|
||
s.env.humidity_pct = m->humidity_pct;
|
||
s.env.timestamp_ms = m->timestamp_ms;
|
||
|
||
auto& g = s.sensors.env;
|
||
g.present = true;
|
||
g.status = "live";
|
||
if (g.fields.size() >= 2) {
|
||
setField(g.fields[0], fmt1(m->temp_c));
|
||
setField(g.fields[1], fmt1(m->humidity_pct));
|
||
}
|
||
// Publish once per fresh sample, not every ~10ms control-loop tick.
|
||
if (channel && m->timestamp_ms != last_env_published_ts_) {
|
||
last_env_published_ts_ = m->timestamp_ms;
|
||
EnvEvent ev;
|
||
ev.tower = cfg.general.tower_name;
|
||
ev.temp_c = m->temp_c;
|
||
ev.humidity_pct = m->humidity_pct;
|
||
ev.timestamp_ms = m->timestamp_ms;
|
||
channel->publishEnv(ev);
|
||
}
|
||
} else {
|
||
s.sensors.env.status = "no fix";
|
||
}
|
||
}
|
||
|
||
// --- Camera / capture ---
|
||
s.capture.present = true;
|
||
s.capture.active = scheduler && scheduler->captureActive();
|
||
s.capture.image_rate = scheduler ? scheduler->imageRate() : 0.0;
|
||
s.capture.camera_count = camera ? camera->cameraCount() : 0;
|
||
s.capture.labels = cfg.camera.labels;
|
||
// Static imaging + encoding config, for the expanded camera view.
|
||
{
|
||
auto& cc = s.capture.config;
|
||
cc.mock = opts.mock_camera.value_or(cfg.features.mock_camera);
|
||
cc.ids = cfg.camera.ids;
|
||
cc.mode = camera_mode;
|
||
if (auto it = camera_modes.find(camera_mode); it != camera_modes.end()) {
|
||
cc.mode_desc = it->second.description;
|
||
cc.settle_wait_ms = it->second.settle_wait_ms;
|
||
}
|
||
cc.modes_available.clear();
|
||
for (const auto& [name, m] : camera_modes) cc.modes_available.push_back(name);
|
||
cc.jxl_distance = cfg.camera.jxl_distance;
|
||
cc.jxl_effort = cfg.camera.jxl_effort;
|
||
cc.rotation_deg = cfg.camera.rotation_deg;
|
||
cc.output_dir = cfg.paths.output_dir;
|
||
}
|
||
// Live per-camera identity/telemetry/stats.
|
||
s.capture.devices.clear();
|
||
if (camera) {
|
||
for (const auto& d : camera->deviceInfo()) {
|
||
CameraDeviceView v;
|
||
v.id = d.id; v.model = d.model; v.serial = d.serial; v.streaming = d.streaming;
|
||
v.width = d.width; v.height = d.height; v.payload_bytes = d.payload_bytes;
|
||
v.exposure_us = d.exposure_us; v.gain_db = d.gain_db;
|
||
v.temperature_c = d.temperature_c; v.actual_fps = d.actual_fps;
|
||
v.frames_delivered = d.frames_delivered; v.frames_dropped = d.frames_dropped;
|
||
v.restarts = d.restarts;
|
||
s.capture.devices.push_back(std::move(v));
|
||
}
|
||
}
|
||
s.capture.images_saved = pipeline ? pipeline->imagesSaved() : 0;
|
||
|
||
// Defined scan grid + live cursor (read on the control thread, same as the
|
||
// scheduler that mutates it, so no locking is needed).
|
||
s.capture.scan_from_file = !cfg.scan.grid_file.empty();
|
||
s.capture.scan_pitch = t.pitch_present;
|
||
s.capture.scan_error = scan_load_error_;
|
||
s.capture.scan_grid.clear();
|
||
const auto& pts = grid.points();
|
||
s.capture.scan_grid.reserve(pts.size());
|
||
for (std::size_t i = 0; i < pts.size(); ++i)
|
||
s.capture.scan_grid.push_back({pts[i].yaw_deg, pts[i].pitch_deg, i == grid.index()});
|
||
if (pipeline) {
|
||
if (auto ev = pipeline->lastEvent()) {
|
||
s.capture.has_last = true;
|
||
s.capture.last_label = ev->camera;
|
||
s.capture.last_heading_deg = ev->heading_decideg / 10.0;
|
||
s.capture.last_pitch_deg = ev->pitch_decideg / 10.0;
|
||
s.capture.last_ts_ms = ev->timestamp_ms;
|
||
}
|
||
}
|
||
|
||
// --- Connectivity ---
|
||
s.conn.mqtt_enabled = opts.use_mqtt.value_or(cfg.features.enable_mqtt);
|
||
s.conn.mqtt_connected = channel && channel->connected();
|
||
s.conn.broker = cfg.network.brokerUri();
|
||
s.conn.tower = cfg.general.tower_name;
|
||
s.conn.control_code = scheduler ? scheduler->controlCode() : 0;
|
||
s.conn.target_heading = scheduler ? scheduler->targetHeading() : "0";
|
||
s.conn.last_status_code = s.conn.control_code; // echoed back as status
|
||
|
||
// --- Diagnostics (last firmware DUMP) ---
|
||
s.dump.text = motor->lastDump();
|
||
s.dump.has = !s.dump.text.empty();
|
||
|
||
// --- Activity strip + last calibration ---
|
||
s.calib = last_calib_view;
|
||
s.calib.pitch_present = s.gimbal.pitch_present || s.calib.pitch_present;
|
||
s.diag = last_diag_view;
|
||
s.test = last_test_view;
|
||
fillActivity(s);
|
||
return s;
|
||
}
|
||
|
||
// The currently-running special op (priority: calib > diag > homing > capture)
|
||
// plus the most recent persisted result, for the activity strip.
|
||
void fillActivity(UiSnapshot& s) const {
|
||
ActivityView& a = s.activity;
|
||
|
||
if (tester && tester->running()) {
|
||
TestProgress p = tester->progress();
|
||
a.active = true;
|
||
a.title = "TESTING";
|
||
a.status = p.section.empty()
|
||
? "running hardware self-test…"
|
||
: p.section + " " + std::to_string(p.step) + "/" +
|
||
std::to_string(p.total) + " — " + p.phase;
|
||
} else if (calib && calib->running()) {
|
||
CalibProgress p = calib->progress();
|
||
a.active = true;
|
||
a.title = "CALIBRATING";
|
||
std::string axis = (p.axis == 'P') ? "PITCH" : (p.axis == 'Y') ? "YAW" : "";
|
||
a.status = axis.empty() ? p.phase
|
||
: axis + " step " + std::to_string(p.step) + "/" +
|
||
std::to_string(p.total) + " — " + p.phase;
|
||
} else if (diag_running_) {
|
||
a.active = true;
|
||
a.title = "DIAGNOSTICS";
|
||
a.status = "running motor self-test…";
|
||
} else if (s.gimbal.yaw.state == AxisState::Homing ||
|
||
(s.gimbal.pitch_present && s.gimbal.pitch.state == AxisState::Homing)) {
|
||
a.active = true;
|
||
a.title = "HOMING";
|
||
a.status = "finding endstops…";
|
||
} else if (scheduler && scheduler->captureActive()) {
|
||
a.active = true;
|
||
a.title = "CAPTURE";
|
||
const char* phase = scheduler->moving() ? "moving" : "dwell";
|
||
if (scheduler->controlCode() == 1) {
|
||
a.status = "directed → heading " + scheduler->targetHeading() + "° · " +
|
||
(scheduler->moving() ? "moving" : "holding");
|
||
} else if (!grid.empty()) {
|
||
const ScanPoint& p = grid.current();
|
||
std::string coord = "yaw " + formatDegrees(p.yaw_deg);
|
||
if (s.capture.scan_pitch) coord += " / pitch " + formatDegrees(p.pitch_deg);
|
||
a.status = "waypoint " + std::to_string(grid.index() + 1) + "/" +
|
||
std::to_string(grid.size()) + " · " + coord + " · " + phase;
|
||
} else {
|
||
a.status = "no scan grid — auto-sweep disabled";
|
||
}
|
||
}
|
||
|
||
// A running procedure is cancelable with Esc.
|
||
a.cancelable = procedureBusy();
|
||
|
||
// Persisted result: the most recent of last test / calibration / diagnostics.
|
||
const long long now = nowEpochMs();
|
||
if (last_test_ts >= last_calib_ts && last_test_ts >= last_diag_ts && last_test_ts) {
|
||
a.has_result = true;
|
||
a.result_title = std::string("Test ") + (last_test_pass ? "PASS" : "FAIL") +
|
||
" · " + formatTimeAgo(now, last_test_ts);
|
||
a.result = last_test_summary;
|
||
a.result_color = last_test_pass ? UiColor::Green : UiColor::Red;
|
||
} else if (last_calib_ts || last_diag_ts) {
|
||
const bool calib_newer = last_calib_ts >= last_diag_ts;
|
||
a.has_result = true;
|
||
if (calib_newer) {
|
||
a.result_title = "Calibration · " + formatTimeAgo(now, last_calib_ts);
|
||
a.result = last_calib_summary;
|
||
a.result_color = last_calib_view.has &&
|
||
last_calib_view.yaw.ok ? UiColor::Green : UiColor::Yellow;
|
||
} else {
|
||
a.result_title = std::string("Diagnostics ") +
|
||
(last_diag_pass ? "PASS" : "FAIL") + " · " +
|
||
formatTimeAgo(now, last_diag_ts);
|
||
a.result = last_diag_summary;
|
||
a.result_color = last_diag_pass ? UiColor::Green : UiColor::Red;
|
||
}
|
||
}
|
||
|
||
// Pending yes/no: persist the just-applied calibration as the new default?
|
||
if (calib_save_pending_)
|
||
a.prompt = "Save this calibration to config as the new default? (y / n)";
|
||
}
|
||
|
||
void publishSnapshot() {
|
||
UiSnapshot s = buildSnapshot();
|
||
std::lock_guard<std::mutex> lock(snapshot_mutex);
|
||
latest_snapshot = std::move(s);
|
||
}
|
||
|
||
// Warn (once per excursion) when an axis's live encoder error |xactual-xenc|
|
||
// crosses the configured limit, so positional slip is visible in the log
|
||
// without watching the gimbal view. Hysteresis re-arms at 75% of the limit.
|
||
void checkTrackingError() {
|
||
const long limit = cfg.enc_error_warn_counts;
|
||
if (limit <= 0) return; // disabled
|
||
MotorTelemetry t = motor->telemetry();
|
||
auto check = [&](const char* axis, const AxisTelemetry& a, bool present, bool& over) {
|
||
if (!present) return;
|
||
const long err = std::labs(a.xactual - a.xenc);
|
||
if (!over && err > limit) {
|
||
over = true;
|
||
LOG_WARN << "encoder tracking error on " << axis << ": " << (a.xactual - a.xenc)
|
||
<< " counts (limit " << limit << ")";
|
||
} else if (over && err < limit * 3 / 4) {
|
||
over = false;
|
||
}
|
||
};
|
||
check("yaw", t.yaw, true, yaw_track_over_);
|
||
check("pitch", t.pitch, t.pitch_present, pitch_track_over_);
|
||
}
|
||
|
||
void runInitSequence() {
|
||
using namespace std::chrono_literals;
|
||
LOG_INFO << "Running gimbal init sequence (enable + home)";
|
||
// Mark homing active so interfering commands are ignored and Esc cancels.
|
||
// This blocks the control thread, so commands are drained+discarded here.
|
||
homing_active_ = true;
|
||
cancel_procedure_ = false;
|
||
struct ClearHoming {
|
||
std::atomic<bool>& flag;
|
||
~ClearHoming() { flag = false; }
|
||
} clear_homing{homing_active_};
|
||
|
||
motor->sendCommand("ENABLE Y");
|
||
motor->sendCommand("ENABLE P");
|
||
motor->sendCommand("HOME"); // homes all axes; firmware runs it non-blocking
|
||
|
||
auto allReady = [](const MotorTelemetry& t) {
|
||
return t.yaw.ready() && (!t.pitch_present || t.pitch.ready());
|
||
};
|
||
|
||
// The axes may already be READY (EEPROM fast-path), so first wait briefly
|
||
// for homing to actually begin (an axis leaves READY) before waiting for
|
||
// it to finish - otherwise we'd see the residual READY and return early.
|
||
const auto t0 = std::chrono::steady_clock::now();
|
||
while (running && !cancel_procedure_ && std::chrono::steady_clock::now() < t0 + 3s) {
|
||
std::this_thread::sleep_for(100ms);
|
||
drainAndDiscardCommands(); // ignore moves/etc queued during homing
|
||
publishSnapshot(); // keep the TUI's gimbal panel live during homing
|
||
if (!allReady(motor->telemetry())) break; // homing started
|
||
}
|
||
|
||
// Wait for completion (both axes READY again) or the homing timeout.
|
||
bool homed = false;
|
||
const auto deadline = std::chrono::steady_clock::now() + 65s;
|
||
while (running && !cancel_procedure_ && std::chrono::steady_clock::now() < deadline) {
|
||
std::this_thread::sleep_for(250ms);
|
||
drainAndDiscardCommands();
|
||
publishSnapshot(); // keep the TUI's gimbal panel live during homing
|
||
MotorTelemetry t = motor->telemetry();
|
||
if (t.yaw.state == AxisState::Error ||
|
||
(t.pitch_present && t.pitch.state == AxisState::Error)) {
|
||
LOG_WARN << "Homing reported ERROR state; check the gimbal";
|
||
break;
|
||
}
|
||
if (allReady(t)) {
|
||
LOG_INFO << "Gimbal homed (both axes READY)";
|
||
homed = true;
|
||
break;
|
||
}
|
||
}
|
||
if (cancel_procedure_) {
|
||
LOG_WARN << "Homing cancelled";
|
||
motor->sendCommand("STOP ALL");
|
||
return;
|
||
}
|
||
// Re-anchor the yaw zero to an endstop now that the homed limits are known.
|
||
if (homed) applyYawHomeZero();
|
||
// Production move speeds for subsequent MOVE commands.
|
||
motor->sendCommand("SPEED Y 50000");
|
||
motor->sendCommand("SPEED P 150000");
|
||
LOG_INFO << "Gimbal init finished";
|
||
}
|
||
|
||
// With [Motor] yaw_home_zero = low|high, redefine the yaw degree origin from
|
||
// the freshly homed endstops: the chosen endstop becomes 0 deg and degrees
|
||
// rise toward the other limit (forced positive, so it lands near +360). The
|
||
// counts_per_deg magnitude is kept from config/calibration; only the zero and
|
||
// sign change, so the yaw zero is fixed by homing and never needs a separate
|
||
// calibration. Pitch is left untouched.
|
||
void applyYawHomeZero() {
|
||
using namespace std::chrono_literals;
|
||
if (cfg.yaw_home_zero == YawHomeZero::Off) return;
|
||
|
||
// The homed limits live in the firmware DUMP; pull a fresh one and wait
|
||
// briefly for it to arrive with yaw endstops populated.
|
||
motor->sendCommand("DUMP");
|
||
long lo = 0, hi = 0, margin = 0;
|
||
bool got = false;
|
||
const auto deadline = std::chrono::steady_clock::now() + 3s;
|
||
while (running && std::chrono::steady_clock::now() < deadline) {
|
||
std::this_thread::sleep_for(100ms);
|
||
publishSnapshot();
|
||
DumpData dd = parseDump(motor->lastDump());
|
||
if (dd.valid)
|
||
for (const auto& a : dd.axes)
|
||
if (a.axis == 'Y' && a.lim_pos != a.lim_neg) {
|
||
lo = a.lim_neg; hi = a.lim_pos; margin = a.soft_margin; got = true;
|
||
}
|
||
if (got) break;
|
||
}
|
||
if (!got) {
|
||
LOG_WARN << "yaw home-zero: no homed yaw limits in dump; keeping configured zero";
|
||
return;
|
||
}
|
||
if (margin <= 0)
|
||
LOG_WARN << "yaw home-zero: firmware reported no soft_margin (update firmware); "
|
||
"anchoring at the hard homing limit instead of the soft limit";
|
||
|
||
const double mag = std::fabs(cfg.geometry.yaw.counts_per_deg);
|
||
if (mag < 1e-9) {
|
||
LOG_WARN << "yaw home-zero: yaw_counts_per_deg ~0; calibrate first; keeping zero";
|
||
return;
|
||
}
|
||
// Anchor at the soft limits (the actual reachable travel), not the hard
|
||
// homing endstops, so the rightmost *reachable* position reads exactly 0.
|
||
const long lo_soft = lo + margin;
|
||
const long hi_soft = hi - margin;
|
||
const bool zero_high = (cfg.yaw_home_zero == YawHomeZero::High);
|
||
const long zero_cnt = zero_high ? hi_soft : lo_soft;
|
||
const long other_cnt = zero_high ? lo_soft : hi_soft;
|
||
// Sign so the other endstop sits at positive degrees (rises toward ~+360).
|
||
const double cpd = (other_cnt > zero_cnt) ? mag : -mag;
|
||
const double range_deg = std::fabs(static_cast<double>(other_cnt - zero_cnt)) / mag;
|
||
|
||
AxisMap& y = cfg.geometry.yaw;
|
||
y.zero_count = zero_cnt;
|
||
y.counts_per_deg = cpd;
|
||
y.min_deg = 0.0;
|
||
y.max_deg = range_deg;
|
||
if (scheduler) scheduler->setGeometry(cfg.geometry); // re-target the scan with it
|
||
|
||
LOG_INFO << "yaw zero anchored at " << (zero_high ? "high" : "low")
|
||
<< "-count endstop: 0.0.." << range_deg << " deg (zero_count=" << zero_cnt
|
||
<< ", counts_per_deg=" << cpd << ")";
|
||
}
|
||
|
||
void startCapture() {
|
||
// The scheduler arms regardless, but a scan can't progress until the axes
|
||
// are homed: before READY the firmware leaves coils disabled and clamps
|
||
// MOVE targets to a coarse bench guard, so no waypoint is ever reached and
|
||
// the move/settle cycle stalls silently. Refuse with a clear message
|
||
// instead. (Single-axis builds report no P: segment, so skip pitch.)
|
||
MotorTelemetry t = motor->telemetry();
|
||
if (!t.yaw.ready() || (t.pitch_present && !t.pitch.ready())) {
|
||
LOG_WARN << "Capture not started: gimbal not homed (run init/home first)";
|
||
return;
|
||
}
|
||
camera->start();
|
||
scheduler->setCaptureActive(true);
|
||
LOG_INFO << "Capture started";
|
||
}
|
||
void stopCapture() {
|
||
camera->stop();
|
||
scheduler->setCaptureActive(false);
|
||
LOG_INFO << "Capture stopped";
|
||
}
|
||
|
||
// `trace off|none`, `trace all`, or `trace <cat> [on|off]` — toggle the
|
||
// verbatim wire-trace categories (serial/mqtt/camera/control).
|
||
void handleTrace(const Command& c) {
|
||
if (c.device.empty() || c.device == "off" || c.device == "none") {
|
||
Logger::setCategories(0);
|
||
} else if (c.device == "all") {
|
||
Logger::setCategories(static_cast<unsigned>(LogCat::All));
|
||
} else {
|
||
bool ok = true;
|
||
LogCat cat = Logger::catFromString(c.device, &ok);
|
||
if (!ok) {
|
||
LOG_WARN << "unknown trace category: " << c.device;
|
||
return;
|
||
}
|
||
bool on = (c.option != "off"); // "trace serial" / "trace serial on" => enable
|
||
if (on) Logger::enableCategory(cat);
|
||
else Logger::disableCategory(cat);
|
||
}
|
||
LOG_INFO << "trace categories: " << traceNames(Logger::categories());
|
||
}
|
||
|
||
// `goto <yaw_deg> <pitch_deg>` — aim the gimbal at an absolute heading and
|
||
// elevation in degrees. Converts to encoder counts via the operator-calibrated
|
||
// Geometry maps (which soft-clamp to the travel limits) and issues a two-axis
|
||
// MOVE so both axes start together.
|
||
// Parse "<a>,<b>" or "<a> <b>" (tokens from `from` onward) into two numbers.
|
||
static bool parsePair(const std::vector<std::string>& tok, size_t from,
|
||
double& a, double& b) {
|
||
std::string s;
|
||
for (size_t i = from; i < tok.size(); ++i) s += tok[i] + " ";
|
||
for (char& ch : s) if (ch == ',') ch = ' ';
|
||
std::istringstream iss(s);
|
||
return static_cast<bool>(iss >> a >> b);
|
||
}
|
||
|
||
// Homed physical travel (counts) for an axis: the endstop-to-endstop span from
|
||
// the last firmware dump. Returns 0 if no dump has been captured yet (caller
|
||
// should request one). Deliberately does NOT fall back to the configured degree
|
||
// range × counts_per_deg — that conflates calibration with physical travel and,
|
||
// when min/max are unset, yields an absurd span.
|
||
long homedTravelCounts(char axis) {
|
||
DumpData d = parseDump(motor->lastDump());
|
||
if (d.valid)
|
||
for (const auto& ax : d.axes)
|
||
if (ax.axis == axis) return std::labs(ax.lim_pos - ax.lim_neg);
|
||
return 0;
|
||
}
|
||
|
||
// `gimbal <subcommand>` — unified, lowercase motor control. `move` is degrees
|
||
// (default), `steps` is raw encoder counts; the rest map to firmware verbs.
|
||
void handleGimbal(const std::string& line) {
|
||
std::istringstream iss(line);
|
||
std::vector<std::string> tok;
|
||
std::string t;
|
||
while (iss >> t) tok.push_back(t); // tok[0] == "gimbal"
|
||
if (tok.size() < 2) {
|
||
LOG_WARN << "usage: gimbal <move|steps|nudge|home|stop|reset|enable|disable|"
|
||
"speed|setpos|status|dump|calib|raw> (self-test: 'test')";
|
||
return;
|
||
}
|
||
std::string sub = tok[1];
|
||
for (char& ch : sub) ch = static_cast<char>(std::tolower((unsigned char)ch));
|
||
|
||
if (sub == "move") {
|
||
double yaw_deg = 0, pitch_deg = 0;
|
||
if (!parsePair(tok, 2, yaw_deg, pitch_deg)) {
|
||
LOG_WARN << "usage: gimbal move <yaw_deg>,<pitch_deg>";
|
||
return;
|
||
}
|
||
long yc = cfg.geometry.yaw.toCounts(yaw_deg);
|
||
long pc = cfg.geometry.pitch.toCounts(pitch_deg);
|
||
LOG_INFO << "gimbal move yaw=" << yaw_deg << " pitch=" << pitch_deg
|
||
<< " deg -> MOVE " << yc << "," << pc;
|
||
motor->sendCommand("MOVE " + std::to_string(yc) + "," + std::to_string(pc));
|
||
} else if (sub == "steps") {
|
||
double yc = 0, pc = 0;
|
||
if (!parsePair(tok, 2, yc, pc)) {
|
||
LOG_WARN << "usage: gimbal steps <yaw_counts>,<pitch_counts>";
|
||
return;
|
||
}
|
||
motor->sendCommand("MOVE " + std::to_string((long)yc) + "," + std::to_string((long)pc));
|
||
} else if (sub == "nudge") {
|
||
if (tok.size() < 4) { LOG_WARN << "usage: gimbal nudge <yaw|pitch> <+/-pct>"; return; }
|
||
char axis = (tok[2][0] == 'p' || tok[2][0] == 'P') ? 'P' : 'Y';
|
||
double pct = 0;
|
||
try { pct = std::stod(tok[3]); } catch (...) { LOG_WARN << "gimbal nudge: bad percent"; return; }
|
||
// Nudge is a fraction of the *homed* physical travel (endstop-to-endstop
|
||
// from the firmware dump), so it is independent of calibration and never
|
||
// a fraction of the configured degree clamps. If no dump is available
|
||
// yet, request one and bail rather than guessing.
|
||
long range = homedTravelCounts(axis);
|
||
if (range <= 0) {
|
||
LOG_WARN << "gimbal nudge: homed travel unknown; home or 'gimbal dump' first";
|
||
motor->sendCommand("DUMP");
|
||
return;
|
||
}
|
||
MotorTelemetry tel = motor->telemetry();
|
||
long cur = (axis == 'Y') ? tel.yaw.xenc : tel.pitch.xenc;
|
||
long target = cur + static_cast<long>(pct / 100.0 * range);
|
||
LOG_INFO << "gimbal nudge " << axis << " " << pct << "% -> MOVE " << axis << " " << target;
|
||
motor->sendCommand(std::string("MOVE ") + axis + " " + std::to_string(target));
|
||
} else if (sub == "calib") {
|
||
startCalibration();
|
||
} else if (sub == "dump") {
|
||
LOG_INFO << "requesting firmware dump...";
|
||
motor->sendCommand("DUMP");
|
||
} else if (sub == "raw") {
|
||
// Low-level escape hatch: send the text inside the quotation marks to
|
||
// the serial port verbatim (the transport appends the newline). This
|
||
// exposes any firmware verb directly, including ones with no wrapper.
|
||
std::string raw;
|
||
auto q1 = line.find('"');
|
||
auto q2 = line.rfind('"');
|
||
if (q1 != std::string::npos && q2 != std::string::npos && q2 > q1) {
|
||
raw = line.substr(q1 + 1, q2 - q1 - 1); // exact text between quotes
|
||
} else {
|
||
for (size_t i = 2; i < tok.size(); ++i) // unquoted: join remaining tokens
|
||
raw += (raw.empty() ? "" : " ") + tok[i];
|
||
}
|
||
if (raw.empty()) {
|
||
LOG_WARN << "usage: gimbal raw \"<verbatim firmware command>\"";
|
||
return;
|
||
}
|
||
LOG_INFO << "gimbal raw -> " << raw;
|
||
motor->sendCommand(raw);
|
||
} else if (sub == "home" && tok.size() <= 2) {
|
||
// Full re-home: enable + HOME + wait for READY + re-anchor the yaw zero
|
||
// (same path as startup --init), so the homing key behaves like a boot
|
||
// home rather than a fire-and-forget HOME that skips the re-anchor.
|
||
runInitSequence();
|
||
} else if (sub == "home" || sub == "stop" || sub == "reset" || sub == "enable" ||
|
||
sub == "disable" || sub == "speed" || sub == "setpos" || sub == "status") {
|
||
// Passthrough to the (case-insensitive) firmware verb + args.
|
||
std::string fw = sub;
|
||
for (char& ch : fw) ch = static_cast<char>(std::toupper((unsigned char)ch));
|
||
for (size_t i = 2; i < tok.size(); ++i) fw += " " + tok[i];
|
||
motor->sendCommand(fw);
|
||
} else {
|
||
LOG_WARN << "unknown gimbal subcommand: " << sub;
|
||
}
|
||
}
|
||
|
||
void startCalibration() {
|
||
if (!imu) { LOG_WARN << "gimbal calib requires the IMU (set [Features] enable_imu)"; return; }
|
||
if (calib && calib->running()) { LOG_WARN << "calibration already running (gimbal stop to cancel)"; return; }
|
||
stopCapture(); // free the serial link + don't fight the routine's moves
|
||
calib = std::make_unique<CalibrationRoutine>(*motor, *imu, cfg.geometry);
|
||
calib->start();
|
||
}
|
||
|
||
// Persist the live (just-calibrated) [Motor] geometry back into config.ini.
|
||
void saveCalibration() {
|
||
if (!calib_save_pending_) return;
|
||
calib_save_pending_ = false;
|
||
if (saveMotorCalibration(opts.config_path, cfg.geometry))
|
||
LOG_INFO << "calibration saved as the new default in " << opts.config_path;
|
||
else
|
||
LOG_WARN << "could not write calibration to " << opts.config_path;
|
||
}
|
||
|
||
void cancelCalibration() {
|
||
if (calib && calib->running()) {
|
||
LOG_INFO << "cancelling calibration";
|
||
calib->cancel();
|
||
motor->sendCommand("STOP ALL");
|
||
}
|
||
}
|
||
|
||
// ---- Procedure lock-out (test / calibration / homing are mutually exclusive) ----
|
||
enum class Procedure { None, Homing, Calibration, Test };
|
||
Procedure activeProcedure() const {
|
||
if (tester && tester->running()) return Procedure::Test;
|
||
if (calib && calib->running()) return Procedure::Calibration;
|
||
if (homing_active_.load()) return Procedure::Homing;
|
||
return Procedure::None;
|
||
}
|
||
bool procedureBusy() const { return activeProcedure() != Procedure::None; }
|
||
const char* procedureName() const {
|
||
switch (activeProcedure()) {
|
||
case Procedure::Test: return "test";
|
||
case Procedure::Calibration: return "calibration";
|
||
case Procedure::Homing: return "homing";
|
||
default: return "none";
|
||
}
|
||
}
|
||
|
||
void cancelActiveProcedure() {
|
||
switch (activeProcedure()) {
|
||
case Procedure::Test:
|
||
LOG_INFO << "cancelling test";
|
||
tester->cancel();
|
||
motor->sendCommand("STOP ALL");
|
||
break;
|
||
case Procedure::Calibration:
|
||
cancelCalibration();
|
||
break;
|
||
case Procedure::Homing:
|
||
LOG_INFO << "cancelling homing";
|
||
cancel_procedure_ = true;
|
||
motor->sendCommand("STOP ALL");
|
||
break;
|
||
case Procedure::None:
|
||
LOG_INFO << "nothing to cancel";
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Pop the command queue and drop any interfering command (homing runs on the
|
||
// control thread, so handleCommand() is not reached during it). Used inside
|
||
// runInitSequence()'s wait loops so queued moves are *ignored*, not deferred.
|
||
void drainAndDiscardCommands() {
|
||
std::queue<std::string> local;
|
||
{
|
||
std::lock_guard<std::mutex> lock(cmd_mutex);
|
||
std::swap(local, cmd_queue);
|
||
}
|
||
while (!local.empty()) {
|
||
const std::string& line = local.front();
|
||
Command c = parseCommand(line);
|
||
if (c.verb == "cancel" || c.verb == "stop")
|
||
cancel_procedure_ = true;
|
||
else if (!c.empty())
|
||
LOG_WARN << "ignored '" << c.verb << "': homing in progress (Esc to cancel)";
|
||
local.pop();
|
||
}
|
||
}
|
||
|
||
// ---- `test` command ----
|
||
static std::string lowerStr(std::string s) {
|
||
for (char& ch : s) ch = static_cast<char>(std::tolower((unsigned char)ch));
|
||
return s;
|
||
}
|
||
|
||
void handleTest(const std::string& line) {
|
||
std::istringstream iss(line);
|
||
std::vector<std::string> tok;
|
||
std::string t;
|
||
while (iss >> t) tok.push_back(t); // tok[0] == "test"
|
||
|
||
if (tok.size() >= 2) {
|
||
std::string sub = lowerStr(tok[1]);
|
||
if (sub == "baseline") { promoteBaseline(); return; }
|
||
if (sub == "list") { listTestProfiles(); return; }
|
||
}
|
||
|
||
// Split [subsystem] [test] [profile]: a token naming a configured profile
|
||
// is the profile; the remaining (ordered) tokens are subsystem then test.
|
||
std::string subsystem, leaf, profileName;
|
||
for (size_t i = 1; i < tok.size(); ++i) {
|
||
std::string w = lowerStr(tok[i]);
|
||
if (profileName.empty() && cfg.test.profiles.count(w)) { profileName = w; continue; }
|
||
if (subsystem.empty()) subsystem = w;
|
||
else if (leaf.empty()) leaf = w;
|
||
}
|
||
if (profileName.empty()) profileName = cfg.test.default_profile;
|
||
|
||
uint32_t mask = 0;
|
||
std::string err;
|
||
if (!resolveTestSelection(subsystem, leaf, mask, err)) {
|
||
LOG_WARN << "test: " << err;
|
||
LOG_INFO << "usage: test [gimbal|imu|host [<leaf>]] [<profile>] (also: test list, test baseline)";
|
||
return;
|
||
}
|
||
startTest(mask, profileName);
|
||
}
|
||
|
||
void startTest(uint32_t mask, const std::string& profileName) {
|
||
if (procedureBusy()) {
|
||
LOG_WARN << "test: " << procedureName() << " already in progress (Esc to cancel)";
|
||
return;
|
||
}
|
||
// IMU leaves need an IMU; drop them (with a warning) if absent.
|
||
if ((mask & T_NEEDS_IMU) && !imu) {
|
||
LOG_WARN << "test: IMU tests skipped (set [Features] enable_imu)";
|
||
mask &= ~T_NEEDS_IMU;
|
||
}
|
||
// Motor leaves need a homed gimbal.
|
||
if (mask & T_NEEDS_MOTOR) {
|
||
MotorTelemetry tel = motor->telemetry();
|
||
if (!tel.yaw.ready() && !(tel.pitch_present && tel.pitch.ready())) {
|
||
LOG_WARN << "test: gimbal not READY (home first)";
|
||
return;
|
||
}
|
||
}
|
||
if (mask == 0) { LOG_WARN << "test: nothing to run"; return; }
|
||
|
||
TestProfile profile;
|
||
if (const TestProfile* p = cfg.test.find(profileName)) profile = *p;
|
||
else profile.name = profileName; // unknown name => built-in defaults
|
||
test_drift_warn_ = profile.get("drift_warn_pct", 15.0);
|
||
test_drift_gate_ = profile.getBool("drift_gates_pass", false);
|
||
|
||
stopCapture(); // free the serial link; don't fight the routine's moves
|
||
LOG_INFO << "starting hardware test (profile '" << profile.name << "')";
|
||
tester = std::make_unique<TestRunner>(*motor, imu.get(), cfg.geometry, mask, profile,
|
||
cfg.paths.output_dir);
|
||
tester->start();
|
||
}
|
||
|
||
void listTestProfiles() {
|
||
LOG_INFO << "test profiles (default: " << cfg.test.default_profile << "):";
|
||
if (cfg.test.profiles.empty()) LOG_INFO << " (none configured; built-in defaults used)";
|
||
for (const auto& [name, p] : cfg.test.profiles)
|
||
LOG_INFO << " " << name << " (" << p.params.size() << " params)";
|
||
}
|
||
|
||
std::string baselinePath() const {
|
||
return cfg.test.baseline_file.empty()
|
||
? (paths::defaultLogDir() + "/test_baseline.txt")
|
||
: cfg.test.baseline_file;
|
||
}
|
||
|
||
void promoteBaseline() {
|
||
if (last_test_report_path.empty()) {
|
||
LOG_WARN << "test baseline: no test report from this session to promote";
|
||
return;
|
||
}
|
||
std::error_code ec;
|
||
std::filesystem::copy_file(last_test_report_path, baselinePath(),
|
||
std::filesystem::copy_options::overwrite_existing, ec);
|
||
if (ec) LOG_WARN << "test baseline: copy failed: " << ec.message();
|
||
else LOG_INFO << "test baseline set from " << last_test_report_path << " -> " << baselinePath();
|
||
}
|
||
|
||
// Read+parse a report file; returns valid()==false-ish empty report on failure.
|
||
static bool loadReport(const std::string& path, TestReport& out) {
|
||
std::ifstream f(path);
|
||
if (!f) return false;
|
||
std::ostringstream ss;
|
||
ss << f.rdbuf();
|
||
out = parseTestReport(ss.str());
|
||
return !out.sections.empty();
|
||
}
|
||
|
||
// Most recent prior "test_*.log" in the log dir (excluding `exclude`).
|
||
std::string latestPriorTestReport(const std::string& exclude) const {
|
||
namespace fs = std::filesystem;
|
||
std::string best;
|
||
std::error_code ec;
|
||
const std::string dir = paths::defaultLogDir();
|
||
if (!fs::exists(dir, ec)) return best;
|
||
for (auto& e : fs::directory_iterator(dir, ec)) {
|
||
const std::string name = e.path().filename().string();
|
||
if (name.rfind("test_", 0) != 0 || e.path().extension() != ".log") continue;
|
||
if (e.path().string() == exclude) continue;
|
||
if (best.empty() || name > fs::path(best).filename().string())
|
||
best = e.path().string();
|
||
}
|
||
return best;
|
||
}
|
||
|
||
// Pick up results produced by background work (calibration thread, DIAG
|
||
// capture) — runs on the control thread each tick so all geometry/state
|
||
// mutation stays single-threaded.
|
||
void pollBackgroundResults() {
|
||
if (calib) {
|
||
if (auto g = calib->takeResult()) {
|
||
cfg.geometry = *g;
|
||
if (scheduler) scheduler->setGeometry(cfg.geometry); // also fix scan targeting
|
||
LOG_INFO << "calibration applied to live geometry";
|
||
// Capture the structured report for the activity strip + gimbal view.
|
||
CalibReport rep = calib->report();
|
||
CalibResultView v;
|
||
v.has = rep.valid;
|
||
v.ts_ms = rep.ts_ms;
|
||
last_calib_summary.clear();
|
||
for (const auto& ax : rep.axes) {
|
||
CalibAxisView* dst = (ax.axis == 'P') ? &v.pitch : &v.yaw;
|
||
if (ax.axis == 'P') v.pitch_present = true;
|
||
dst->ok = ax.ok;
|
||
dst->counts_per_deg = ax.counts_per_deg;
|
||
dst->zero_count = ax.zero_count;
|
||
dst->r2 = ax.r2;
|
||
dst->n = ax.n;
|
||
char b[96];
|
||
std::snprintf(b, sizeof(b), "%s cpd=%.3f zero=%ld R2=%.4f (n=%d)%s",
|
||
ax.axis == 'P' ? "PITCH" : "YAW", ax.counts_per_deg,
|
||
ax.zero_count, ax.r2, ax.n, ax.ok ? "" : " FIT FAILED");
|
||
last_calib_summary.emplace_back(b);
|
||
}
|
||
last_calib_view = v;
|
||
last_calib_ts = nowEpochMs();
|
||
// Offer to persist it as the new default (answered via the activity strip).
|
||
if (rep.valid && !opts.config_path.empty()) calib_save_pending_ = true;
|
||
}
|
||
}
|
||
if (tester) {
|
||
if (auto rep = tester->takeReport()) {
|
||
TestReport r = std::move(*rep);
|
||
// Compare against the pinned baseline and the most recent prior run.
|
||
TestReport baseline, prev;
|
||
bool have_baseline = loadReport(baselinePath(), baseline);
|
||
bool have_prev = loadReport(latestPriorTestReport(""), prev);
|
||
applyComparison(r, have_baseline ? &baseline : nullptr,
|
||
have_prev ? &prev : nullptr, test_drift_warn_, test_drift_gate_);
|
||
|
||
std::string text = formatTestReport(r);
|
||
std::string path = paths::writeLogFile(paths::timestampedLogName("test"), text);
|
||
if (!path.empty()) {
|
||
last_test_report_path = path;
|
||
LOG_INFO << "test report written: " << path;
|
||
}
|
||
// Surface a concise per-section summary in the log + activity strip.
|
||
last_test_summary.clear();
|
||
for (const auto& s : r.sections) {
|
||
if (!s.ran) continue;
|
||
std::string l = s.id + ": " + (s.pass ? "PASS" : "FAIL");
|
||
last_test_summary.push_back(l);
|
||
LOG_INFO << "test " << l;
|
||
}
|
||
LOG_INFO << "test overall: " << (r.all_pass ? "PASS" : "FAIL")
|
||
<< " (profile " << r.profile << ")";
|
||
last_test_pass = r.all_pass;
|
||
last_test_ts = nowEpochMs();
|
||
last_test_view = TestResultView{};
|
||
last_test_view.has = true;
|
||
last_test_view.ts_ms = last_test_ts;
|
||
last_test_view.pass = r.all_pass;
|
||
last_test_view.profile = r.profile;
|
||
last_test_view.summary = last_test_summary;
|
||
}
|
||
}
|
||
unsigned seq = motor->diagSeq();
|
||
if (seq != last_diag_seq) {
|
||
last_diag_seq = seq;
|
||
diag_running_ = false;
|
||
DiagResult dr = parseDiag(motor->lastDiag());
|
||
std::ostringstream summary;
|
||
for (const auto& l : formatDiag(dr)) { LOG_INFO << l; summary << l << "\n"; }
|
||
// Concise per-axis lines for the activity strip + the per-axis worst
|
||
// tracking error for the gimbal view. Full detail is logged above.
|
||
last_diag_summary.clear();
|
||
last_diag_view = DiagResultView{};
|
||
last_diag_view.has = dr.valid;
|
||
for (const auto& ax : dr.axes) {
|
||
DiagAxisView dv;
|
||
dv.axis = ax.axis;
|
||
dv.has = !ax.tests.empty();
|
||
dv.pass = ax.pass;
|
||
dv.err_peak = dv.err_rms = dv.err_still = -1; // -1 stays if no encoder
|
||
for (const auto& tst : ax.tests) { // worst (max) across the tests
|
||
if (tst.err_peak < 0) continue; // no-encoder test: skip
|
||
dv.err_peak = std::max(dv.err_peak, tst.err_peak);
|
||
dv.err_rms = std::max(dv.err_rms, tst.err_rms);
|
||
dv.err_still = std::max(dv.err_still, tst.err_still);
|
||
}
|
||
last_diag_view.axes.push_back(dv);
|
||
std::string line = std::string(1, ax.axis) + ": " +
|
||
(ax.pass ? "PASS" : "FAIL");
|
||
if (dv.has && dv.err_peak >= 0) // -1 == no encoder
|
||
line += " peak " + std::to_string(dv.err_peak) +
|
||
" still " + std::to_string(dv.err_still);
|
||
line += " (" + std::to_string(ax.tests.size()) + " tests)";
|
||
last_diag_summary.push_back(line);
|
||
}
|
||
last_diag_pass = dr.allPass();
|
||
last_diag_ts = nowEpochMs();
|
||
last_diag_view.ts_ms = last_diag_ts;
|
||
std::string path = paths::writeLogFile(
|
||
paths::timestampedLogName("diag"),
|
||
motor->lastDiag() + "\n--- parsed ---\n" + summary.str());
|
||
if (!path.empty()) LOG_INFO << "diagnostics log written: " << path;
|
||
}
|
||
// Safety: clear a stuck diag flag if the firmware never replied.
|
||
if (diag_running_ &&
|
||
std::chrono::steady_clock::now() - diag_started_ > std::chrono::seconds(30))
|
||
diag_running_ = false;
|
||
}
|
||
|
||
// Commands that would disturb a running procedure (motion, capture, a second
|
||
// long op, or a DUMP over the same serial link). Ignored while busy.
|
||
static bool interferingVerb(const std::string& verb) {
|
||
return verb == "gimbal" || verb == "start" || verb == "calib" ||
|
||
verb == "test" || verb == "refresh";
|
||
}
|
||
|
||
void handleCommand(const std::string& line) {
|
||
Command c = parseCommand(line);
|
||
if (c.empty()) return;
|
||
LOG_TRACE_CAT(LogCat::Control) << "cmd " << line;
|
||
|
||
// Lock-out: while a test/calibration/homing runs, ignore interfering
|
||
// commands (Esc, or `stop`/`cancel`, cancels the procedure instead).
|
||
if (procedureBusy() && interferingVerb(c.verb)) {
|
||
LOG_WARN << "ignored '" << c.verb << "': " << procedureName()
|
||
<< " in progress (Esc to cancel)";
|
||
return;
|
||
}
|
||
|
||
if (c.verb == "help") {
|
||
// c.device holds the optional topic (first token after "help").
|
||
for (const auto& l : renderHelp(c.device)) LOG_INFO << l;
|
||
} else if (c.verb == "test") {
|
||
handleTest(line);
|
||
} else if (c.verb == "cancel") {
|
||
cancelActiveProcedure();
|
||
} else if (c.verb == "gimbal") {
|
||
handleGimbal(line);
|
||
} else if (c.verb == "calib") {
|
||
// Answer to the "save calibration?" prompt (activity-strip y/n keys).
|
||
if (c.device == "save") saveCalibration();
|
||
else if (c.device == "discard") {
|
||
calib_save_pending_ = false;
|
||
LOG_INFO << "calibration kept for this session only (not saved to config)";
|
||
}
|
||
} else if (c.verb == "exit") {
|
||
running = false;
|
||
} else if (c.verb == "start") {
|
||
startCapture();
|
||
} else if (c.verb == "stop") {
|
||
// `stop`/`x` cancels any running procedure, else stops capture.
|
||
if (procedureBusy()) cancelActiveProcedure();
|
||
else stopCapture();
|
||
} else if (c.verb == "debug") {
|
||
bool on = Logger::level() != LogLevel::Debug;
|
||
Logger::setLevel(on ? LogLevel::Debug : LogLevel::Info);
|
||
LOG_INFO << "debug logging " << (on ? "on" : "off");
|
||
} else if (c.verb == "refresh") {
|
||
// Re-read live device state surfaced in the expanded views: the IMU
|
||
// configuration (e.g. after the XKF profile was changed externally)
|
||
// and the firmware register dump shown in the gimbal 'g' view.
|
||
LOG_INFO << "refresh: re-reading IMU configuration + firmware dump";
|
||
if (imu) {
|
||
imu->refreshConfig();
|
||
imu_config_done_ = false; // force buildSnapshot to re-read config()
|
||
}
|
||
motor->sendCommand("DUMP");
|
||
} else if (c.verb == "trace") {
|
||
handleTrace(c);
|
||
} else if (c.verb == "set") {
|
||
if (c.device == "camera") {
|
||
// "set camera mode night" arrives as option="mode night": the parser
|
||
// joins non-numeric trailing tokens into `option`.
|
||
if (c.option.rfind("mode ", 0) == 0) applyMode(c.option.substr(5));
|
||
else if (c.option == "mode") LOG_WARN << "usage: set camera mode <name>";
|
||
else if (c.option == "jxlq" && c.has_value) pipeline->setDistance(c.value);
|
||
else if (c.option == "jxle" && c.has_value) pipeline->setEffort(static_cast<int>(c.value));
|
||
else if (c.option == "display") pipeline->setDisplay(c.value != 0.0);
|
||
else LOG_WARN << "unknown 'set camera' option: " << c.option;
|
||
} else if (c.device == "fps" && c.has_value) {
|
||
scheduler->setImageRate(c.value);
|
||
LOG_INFO << "capture rate set to " << c.value << " img/s";
|
||
} else {
|
||
LOG_WARN << "unknown 'set' target: " << c.device;
|
||
}
|
||
} else {
|
||
LOG_WARN << "unknown command: " << c.verb;
|
||
}
|
||
}
|
||
|
||
void drainCommands() {
|
||
std::queue<std::string> local;
|
||
{
|
||
std::lock_guard<std::mutex> lock(cmd_mutex);
|
||
std::swap(local, cmd_queue);
|
||
}
|
||
while (!local.empty()) {
|
||
handleCommand(local.front());
|
||
local.pop();
|
||
}
|
||
}
|
||
|
||
int run() {
|
||
start_time = std::chrono::steady_clock::now();
|
||
|
||
// Log level: config first, CLI overrides.
|
||
if (!cfg.logging.level.empty() && !Logger::setLevelFromString(cfg.logging.level))
|
||
LOG_WARN << "unknown Logging.level '" << cfg.logging.level << "', keeping default";
|
||
if (!opts.log_level.empty() && !Logger::setLevelFromString(opts.log_level))
|
||
LOG_WARN << "unknown log level '" << opts.log_level << "', keeping default";
|
||
|
||
// Wire-trace categories: config first, CLI overrides (replaces, not merges).
|
||
if (!cfg.logging.trace.empty()) {
|
||
bool ok = true;
|
||
unsigned m = Logger::parseCategories(cfg.logging.trace, &ok);
|
||
if (!ok) LOG_WARN << "unknown trace category in Logging.trace '" << cfg.logging.trace << "'";
|
||
Logger::setCategories(m);
|
||
}
|
||
if (!opts.trace_categories.empty()) {
|
||
bool ok = true;
|
||
unsigned m = Logger::parseCategories(opts.trace_categories, &ok);
|
||
if (!ok) LOG_WARN << "unknown trace category in '" << opts.trace_categories << "'";
|
||
Logger::setCategories(m);
|
||
}
|
||
if (Logger::categories())
|
||
LOG_INFO << "wire trace enabled: " << traceNames(Logger::categories());
|
||
|
||
loadModes(); // before makeCamera(): the mode is applied at start()
|
||
|
||
channel = makeChannel();
|
||
motor = makeMotor();
|
||
camera = makeCamera();
|
||
imu = makeImu();
|
||
env = makeEnv();
|
||
|
||
if (!channel->connect())
|
||
LOG_WARN << "Control channel not connected; continuing in degraded mode";
|
||
|
||
// Verify the capture output dir is writable up front; if not (e.g. a
|
||
// misconfigured absolute path), fall back to the per-user default so
|
||
// captures land on disk instead of failing once per frame. Resolved once
|
||
// here, then used everywhere via cfg.paths.output_dir.
|
||
if (!outputDirWritable(cfg.paths.output_dir)) {
|
||
const std::string fb = paths::defaultOutputDir();
|
||
LOG_ERROR << "capture output_dir '" << cfg.paths.output_dir
|
||
<< "' is not writable; falling back to '" << fb << "'";
|
||
cfg.paths.output_dir = fb;
|
||
if (!outputDirWritable(cfg.paths.output_dir))
|
||
LOG_ERROR << "fallback output_dir '" << fb
|
||
<< "' is also not writable; captures will fail to save";
|
||
}
|
||
LOG_INFO << "capture output dir: " << cfg.paths.output_dir;
|
||
|
||
ImagePipeline::Params pp;
|
||
pp.output_dir = cfg.paths.output_dir;
|
||
pp.labels = cfg.camera.labels;
|
||
pp.tower = cfg.general.tower_name;
|
||
pp.demo = opts.demo;
|
||
pp.jxl_distance = cfg.camera.jxl_distance;
|
||
pp.jxl_effort = cfg.camera.jxl_effort;
|
||
pp.rotation_deg = cfg.camera.rotation_deg;
|
||
// Orientation supplier: convert the per-axis encoder counts to degrees.
|
||
pipeline = std::make_unique<ImagePipeline>(
|
||
*channel,
|
||
[this] {
|
||
MotorTelemetry t = motor->telemetry();
|
||
return Orientation{static_cast<float>(cfg.geometry.yaw.toDeg(t.yaw.xenc)),
|
||
static_cast<float>(cfg.geometry.pitch.toDeg(t.pitch.xenc))};
|
||
},
|
||
pp);
|
||
|
||
ImagePipeline* pipe_ptr = pipeline.get();
|
||
camera->setFrameCallback([pipe_ptr](const Frame& f) { pipe_ptr->submit(f); });
|
||
|
||
try {
|
||
grid = ScanGrid::fromConfig(cfg.scan);
|
||
} catch (const std::exception& e) {
|
||
scan_load_error_ = e.what();
|
||
LOG_WARN << "scan grid load failed (" << e.what() << "); auto-sweep disabled";
|
||
}
|
||
LOG_INFO << "scan grid: " << grid.size() << " waypoints";
|
||
scheduler = std::make_unique<CaptureScheduler>(*motor, *camera, *channel, cfg.image_rate(),
|
||
cfg.geometry, grid);
|
||
{
|
||
const CameraMode& m = camera_modes.at(camera_mode);
|
||
scheduler->setSettleWait(m.settle_wait_ms, m.frame_timeout_ms);
|
||
}
|
||
|
||
motor->start();
|
||
if (imu) imu->start();
|
||
if (env) env->start();
|
||
camera->open();
|
||
pipeline->start();
|
||
channel->publishStatus(0);
|
||
|
||
// Seed an initial snapshot so the UI's first frame is populated, then
|
||
// start the interface (headless console or TUI) on its own thread. The
|
||
// UI is a pure observer: it pulls snapshots and pushes command strings
|
||
// into the same queue the console always used.
|
||
//
|
||
// The UI is brought up BEFORE the init/home sequence so the dashboard is
|
||
// visible during homing (which can take many seconds) and so the serial
|
||
// wire traces render in the TUI's log pane instead of scrolling raw —
|
||
// makeUi()/start() installs the TUI log sink, so anything logged after
|
||
// this point is captured by it.
|
||
publishSnapshot();
|
||
ui = makeUi();
|
||
ui->start([this] { std::lock_guard<std::mutex> lk(snapshot_mutex); return latest_snapshot; },
|
||
[this](const std::string& line) {
|
||
std::lock_guard<std::mutex> lk(cmd_mutex);
|
||
cmd_queue.push(line);
|
||
});
|
||
|
||
if (opts.init) {
|
||
runInitSequence(); // homes, then re-anchors the yaw zero
|
||
} else if (cfg.yaw_home_zero != YawHomeZero::Off) {
|
||
// No homing this launch: the firmware restores its homed limits from
|
||
// EEPROM to READY on boot, so re-derive the same yaw zero from them.
|
||
// This makes the homing-anchored zero persist across software restarts
|
||
// without re-homing. Self-guards (no-op + note) if not yet homed.
|
||
applyYawHomeZero();
|
||
}
|
||
if (opts.start) startCapture();
|
||
|
||
// Clean up on Ctrl-C / kill / systemd stop, not just on "exit"/Ctrl-D - so the
|
||
// camera is always stopped and closed (an unclean exit wedges it for next time).
|
||
std::signal(SIGINT, handleShutdownSignal);
|
||
std::signal(SIGTERM, handleShutdownSignal);
|
||
|
||
LOG_INFO << "Entering control loop (type 'exit' to quit, or Ctrl-C)";
|
||
while (running && !g_shutdown_requested.load()) {
|
||
drainCommands();
|
||
pollBackgroundResults(); // apply calibration result / emit DIAG summary+log
|
||
scheduler->tick();
|
||
checkTrackingError(); // warn on live encoder-error excursions
|
||
publishSnapshot();
|
||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||
}
|
||
|
||
LOG_INFO << "Shutting down";
|
||
if (tester) tester->cancel(); // stop any in-flight self-test before teardown
|
||
if (calib) calib->cancel(); // stop any in-flight calibration before teardown
|
||
if (ui) ui->stop();
|
||
pipeline->stop();
|
||
camera->stop();
|
||
camera->close();
|
||
if (imu) imu->stop();
|
||
if (env) env->stop();
|
||
motor->stop();
|
||
channel->disconnect();
|
||
return 0;
|
||
}
|
||
};
|
||
|
||
Application::Application(AppConfig config, RuntimeOptions options)
|
||
: impl_(std::make_unique<Impl>(std::move(config), std::move(options))) {}
|
||
|
||
Application::~Application() = default;
|
||
|
||
int Application::run() { return impl_->run(); }
|
||
|
||
} // namespace fgc
|