fwt_software/src/core/Application.cpp

1572 lines
70 KiB
C++
Raw 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.

#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