3080 lines
147 KiB
C++
3080 lines
147 KiB
C++
#include "fgc/Application.h"
|
||
|
||
#include "fgc/PitchSetup.h"
|
||
#include "fgc/HeadingFit.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/CameraProfile.h"
|
||
#include "fgc/AxisSpeed.h"
|
||
#include "fgc/FirmwareConfig.h"
|
||
#include "fgc/HardwareChecks.h"
|
||
#include "fgc/HardwarePresence.h"
|
||
#include "fgc/TmcCurrent.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/MountedImuSource.h"
|
||
#include "fgc/MtiImuSource.h"
|
||
#include "fgc/ScanGrid.h"
|
||
#include "fgc/SerialMotorController.h"
|
||
#include "fgc/TestReport.h"
|
||
#include "fgc/OperatingMode.h"
|
||
#include "fgc/RemoteCommands.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/McuEnvSensor.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 <deque>
|
||
#include <ctime>
|
||
#include <filesystem>
|
||
#include <fstream>
|
||
#include <memory>
|
||
#include <mutex>
|
||
#include <optional>
|
||
#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 {
|
||
|
||
// One encoder count in microsteps, rounded up. ENC_CONST is set so X_ENC advances
|
||
// in 12.8-microstep quanta, which is the finest position the gimbal can actually
|
||
// reproduce - the tolerance for any "is the count where we asked for it" check.
|
||
constexpr long kEncoderQuantumCounts = 13;
|
||
|
||
// 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();
|
||
}
|
||
|
||
// "2026-09-11T14:32Z" - UTC, minute resolution. Stamped into the `;prev` comments
|
||
// saveMotorGeometry leaves behind, where the point is telling two calibrations
|
||
// apart later, not sub-second precision. UTC because towers are read remotely and
|
||
// a local-time stamp says nothing without also knowing the host's zone.
|
||
std::string utcStampNow() {
|
||
const std::time_t t = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now());
|
||
std::tm tm{};
|
||
#if defined(_WIN32)
|
||
gmtime_s(&tm, &t);
|
||
#else
|
||
gmtime_r(&t, &tm);
|
||
#endif
|
||
// 64, not 32, purely to keep the compiler quiet. The real output is always 17
|
||
// characters, but the tm fields are plain ints, so GCC cannot rule out a
|
||
// nonsense clock and computes a worst case of 61 bytes - warning on every
|
||
// build. snprintf truncates rather than overflows either way, so this was
|
||
// never a safety problem; a recurring warning that everyone learns to ignore
|
||
// is the actual cost.
|
||
char b[64];
|
||
std::snprintf(b, sizeof(b), "%04d-%02d-%02dT%02d:%02dZ", tm.tm_year + 1900, tm.tm_mon + 1,
|
||
tm.tm_mday, tm.tm_hour, tm.tm_min);
|
||
return std::string(b);
|
||
}
|
||
|
||
// 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. Keyed by camera name, then profile name; `active_profile` tracks
|
||
// which profile each camera is currently on.
|
||
std::map<std::string, std::map<std::string, CameraProfile>> camera_profiles;
|
||
std::map<std::string, std::string> active_profile;
|
||
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<PitchSetupRoutine> 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
|
||
// Motor-current registers computed once from hardware.ini, sent to the
|
||
// firmware before every home, and checked against the DUMP echo afterwards.
|
||
TmcCurrentPlan yaw_current_, pitch_current_;
|
||
bool calib_save_pending_ = false; // awaiting y/n to persist to config
|
||
// Which command produced the pending values, recorded into the `;prev`
|
||
// comment when they are saved so the file says what superseded what.
|
||
std::string calib_save_source_;
|
||
|
||
// 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;
|
||
// Latches the "no IMU sample, publishing encoder pitch" warning so an outage
|
||
// costs one line, not one per frame. Written from the camera delivery
|
||
// thread(s) via the orientation supplier, hence atomic.
|
||
std::atomic<bool> imu_pitch_warned_{false};
|
||
// The TUI heading wizard is aiming by hand; blocks a scan from starting.
|
||
bool heading_setup_active_ = false;
|
||
long long fan_seen_ms_ = 0; // last ENV reading checkFan() judged
|
||
int fan_stall_count_ = 0; // consecutive stalled readings
|
||
bool fan_stalled_ = false;
|
||
std::vector<HeadingSighting> heading_marks_; // `gimbal heading mark`s this setup
|
||
|
||
std::atomic<bool> running{true};
|
||
RemoteCommandRunner remote_; // commands over MQTT (GGS/FWT/<tower>/Command)
|
||
std::deque<RemoteCommand> remote_queue_; // taken from the channel, not yet run
|
||
// --test: what run() returns, and where the one-shot test has got to.
|
||
int exit_code_ = 0;
|
||
Mode resume_mode_after_test_ = Mode::Manual; // the mode a test paused
|
||
PanSpec resume_pan_after_test_;
|
||
bool oneshot_started_ = false;
|
||
std::chrono::steady_clock::time_point oneshot_since_{};
|
||
std::chrono::steady_clock::time_point oneshot_link_ok_{};
|
||
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>(cfg.general.tower_name);
|
||
}
|
||
|
||
std::unique_ptr<IMotorController> makeMotor() {
|
||
bool mock = opts.mock_serial.value_or(cfg.features.mock_serial);
|
||
if (mock) {
|
||
auto m = std::make_unique<MockMotorController>(cfg.features.mock_needcfg);
|
||
m->setBuiltPins(cfg.hardware.pins);
|
||
return m;
|
||
}
|
||
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"
|
||
|
||
std::unique_ptr<IImuSource> src;
|
||
if (cfg.features.mock_imu) {
|
||
src = std::make_unique<MockImuSource>();
|
||
} else if (cfg.imu.device.empty()) {
|
||
LOG_WARN << "IMU enabled but [IMU] device is empty; disabling IMU";
|
||
return nullptr;
|
||
} else {
|
||
src = std::make_unique<MtiImuSource>(cfg.imu.device, cfg.imu.baud);
|
||
}
|
||
|
||
// Wrapping here is what makes the mount universal: every consumer reads
|
||
// through IImuSource::sample(), so none of them can see the sensor frame.
|
||
// An unmounted rig skips the wrapper entirely rather than rotating by
|
||
// zero on every sample.
|
||
// The mock fabricates gimbal-frame readings directly, so only the real
|
||
// device carries an earth reference that has to be corrected.
|
||
const ImuReference ref = cfg.features.mock_imu ? ImuReference::Ned : kMtiReference;
|
||
if (ref == ImuReference::Ned && cfg.imu.mount.identity()) return src;
|
||
LOG_INFO << "IMU frames: reference " << referenceName(ref) << " -> NED; mount x "
|
||
<< cfg.imu.mount.x_deg << " y " << cfg.imu.mount.y_deg << " z "
|
||
<< cfg.imu.mount.z_deg << " (deg, Z-X-Y); readings are in the gimbal frame";
|
||
return std::make_unique<MountedImuSource>(std::move(src), ref, cfg.imu.mount);
|
||
}
|
||
|
||
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>(std::chrono::milliseconds(cfg.env.period_ms));
|
||
// The legacy towers' DHT11 hangs off the motor MCU, which reads it and
|
||
// reports each reading on the serial link - there is no bus here to open.
|
||
if (cfg.hardware.env == "dht11") return std::make_unique<McuEnvSensor>(*motor);
|
||
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));
|
||
}
|
||
|
||
// Does this run drive a real camera? Asked in two places - makeCamera() and
|
||
// the startup sequence, which must agree: profiles are meaningful only to
|
||
// VimbaCameraSource, so loading them for a mock run demands settings XMLs
|
||
// that a tower being brought up one subsystem at a time does not have yet.
|
||
//
|
||
// enable_camera substitutes the mock source rather than leaving `camera`
|
||
// null, because the capture path dereferences it in a dozen places. The
|
||
// result is a run with no camera hardware and no Vimba dependency, which is
|
||
// what the flag is for; it is not a silent no-op, and says so at startup.
|
||
bool realCameraWanted() const {
|
||
#if !FGC_WITH_VIMBA
|
||
return false;
|
||
#else
|
||
if (!cfg.features.enable_camera) return false;
|
||
return !opts.mock_camera.value_or(cfg.features.mock_camera);
|
||
#endif
|
||
}
|
||
|
||
std::unique_ptr<ICameraSource> makeCamera() {
|
||
bool mock = !realCameraWanted();
|
||
if (mock && !cfg.features.enable_camera) {
|
||
LOG_WARN << "camera disabled ([Features] enable_camera = false); using a mock source "
|
||
"- no images come from hardware in this run";
|
||
}
|
||
#if !FGC_WITH_VIMBA
|
||
if (!opts.mock_camera.value_or(cfg.features.mock_camera) && cfg.features.enable_camera) {
|
||
LOG_WARN << "Camera requested but binary built without Vimba X; using mock camera";
|
||
}
|
||
#endif
|
||
const auto active = cfg.camera.active();
|
||
if (mock) {
|
||
return std::make_unique<MockCameraSource>(
|
||
active.empty() ? 1 : static_cast<int>(active.size()));
|
||
}
|
||
#if FGC_WITH_VIMBA
|
||
std::vector<CameraSpec> specs;
|
||
std::vector<CameraProfile> profiles;
|
||
for (const auto* c : active) {
|
||
specs.push_back(*c);
|
||
profiles.push_back(camera_profiles.at(c->name).at(active_profile.at(c->name)));
|
||
}
|
||
return std::make_unique<VimbaCameraSource>(std::move(specs), std::move(profiles));
|
||
#else
|
||
return std::make_unique<MockCameraSource>(1);
|
||
#endif
|
||
}
|
||
|
||
// Load every profile of every camera up front. Two reasons: a profile nobody
|
||
// is using today still fails at startup rather than the first time someone
|
||
// switches to it, and a settings XML edited in the Viewer is detected and
|
||
// reported here rather than silently taking effect.
|
||
void loadProfiles() {
|
||
if (cfg.camera.cameras.empty())
|
||
throw std::runtime_error("no cameras configured (expected at least one "
|
||
"[Camera.<name>] block)");
|
||
|
||
for (const auto& c : cfg.camera.cameras) {
|
||
if (!c.enabled) continue;
|
||
std::map<std::string, fgc::ProfileLoadResult> results;
|
||
auto profiles = fgc::loadCameraProfiles(c.profile_dir, results);
|
||
|
||
if (profiles.find(c.profile) == profiles.end()) {
|
||
std::string known;
|
||
for (const auto& [name, p] : profiles) known += (known.empty() ? "" : ", ") + name;
|
||
throw std::runtime_error("Camera." + c.name + ".profile '" + c.profile +
|
||
"' has no " + c.profile + ".xml in " + c.profile_dir +
|
||
" (available: " + known + ")");
|
||
}
|
||
|
||
LOG_INFO << "camera '" << c.name << "': " << profiles.size() << " profile(s) from "
|
||
<< c.profile_dir << "; active '" << c.profile << "'";
|
||
for (const auto& [pname, r] : results) {
|
||
if (r.first_run) {
|
||
// Nothing changed - there was simply no record yet.
|
||
LOG_INFO << "camera '" << c.name << "' profile '" << pname
|
||
<< "': writing " << pname << ".txt (no previous record)";
|
||
continue;
|
||
}
|
||
// A changed XML is a warning, not an info line: the camera is about
|
||
// to behave differently from the last run and nobody edited this
|
||
// program.
|
||
LOG_WARN << "camera '" << c.name << "' profile '" << pname
|
||
<< "': settings XML changed, regenerating " << pname << ".txt";
|
||
for (const auto& line : r.changes) LOG_WARN << " " << line;
|
||
}
|
||
|
||
camera_profiles[c.name] = std::move(profiles);
|
||
active_profile[c.name] = c.profile;
|
||
}
|
||
}
|
||
|
||
// Switch one camera's profile by name. With no camera named, applies to the
|
||
// single configured camera. Returns false (and warns) if unknown.
|
||
bool applyProfile(const std::string& cam_name, const std::string& name) {
|
||
auto ci = camera_profiles.find(cam_name);
|
||
if (ci == camera_profiles.end()) {
|
||
std::string known;
|
||
for (const auto& [n, p] : camera_profiles) known += (known.empty() ? "" : ", ") + n;
|
||
LOG_WARN << "unknown camera '" << cam_name << "' (configured: " << known << ')';
|
||
return false;
|
||
}
|
||
auto it = ci->second.find(name);
|
||
if (it == ci->second.end()) {
|
||
std::string known;
|
||
for (const auto& [n, p] : ci->second) known += (known.empty() ? "" : ", ") + n;
|
||
LOG_WARN << "unknown profile '" << name << "' for camera '" << cam_name
|
||
<< "' (available: " << known << ')';
|
||
return false;
|
||
}
|
||
// The index is the camera's position among the ACTIVE cameras, which is the
|
||
// order the source opened them in and the order Frame::cam_id follows.
|
||
int index = -1, i = 0;
|
||
for (const auto* c : cfg.camera.active()) {
|
||
if (c->name == cam_name) { index = i; break; }
|
||
++i;
|
||
}
|
||
if (index < 0) {
|
||
LOG_WARN << "camera '" << cam_name << "' is configured but not enabled";
|
||
return false;
|
||
}
|
||
if (camera) camera->setProfile(index, it->second);
|
||
active_profile[cam_name] = name;
|
||
|
||
// The gimbal must stand still long enough for every camera, so recompute
|
||
// from all of them rather than from the one that just changed.
|
||
if (scheduler) {
|
||
int settle = 0, timeout = 0;
|
||
for (const auto* c : cfg.camera.active()) {
|
||
const auto t = c->timingFor(active_profile.count(c->name) ? active_profile[c->name]
|
||
: c->profile);
|
||
settle = std::max(settle, t.settle_wait_ms);
|
||
timeout = std::max(timeout, t.frame_timeout_ms);
|
||
}
|
||
scheduler->setSettleWait(settle, timeout);
|
||
}
|
||
LOG_INFO << "camera '" << cam_name << "' profile -> " << name;
|
||
return true;
|
||
}
|
||
|
||
// Comma-joined "label (type N)" for every XKF profile the device reports, so a
|
||
// bad argument can say what IS available instead of just refusing.
|
||
std::string imuProfileList() const {
|
||
auto cfg_opt = imu ? imu->config() : std::nullopt;
|
||
if (!cfg_opt || cfg_opt->available_profiles.empty()) return "(none reported)";
|
||
std::string out;
|
||
for (const auto& p : cfg_opt->available_profiles) {
|
||
if (!out.empty()) out += ", ";
|
||
out += (p.label.empty() ? std::string("?") : p.label) + " (type " +
|
||
std::to_string(static_cast<int>(p.type)) + ")";
|
||
if (cfg_opt->has_scenario && p.type == cfg_opt->scenario_type) out += " *";
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// `imu profile [<type|label>]`. The MTi's XKF profile decides whether its
|
||
// heading uses the magnetometer. Note this does not affect the gimbal's own
|
||
// heading, which comes from the encoder via `gimbal heading setup`.
|
||
void imuProfile(const std::string& arg) {
|
||
if (arg.empty()) {
|
||
LOG_INFO << "XKF profiles (* = active): " << imuProfileList();
|
||
LOG_INFO << "select one with: imu profile <type|label>";
|
||
return;
|
||
}
|
||
|
||
int type = -1;
|
||
auto cfg_opt = imu->config();
|
||
|
||
// All-digits => a raw type number; anything else is a label, matched
|
||
// case-insensitively against what the device reported.
|
||
const bool numeric = arg.find_first_not_of("0123456789") == std::string::npos;
|
||
if (numeric) {
|
||
type = std::stoi(arg);
|
||
} else if (cfg_opt) {
|
||
auto lower = [](std::string v) {
|
||
std::transform(v.begin(), v.end(), v.begin(),
|
||
[](unsigned char ch) { return std::tolower(ch); });
|
||
return v;
|
||
};
|
||
const std::string want = lower(arg);
|
||
for (const auto& p : cfg_opt->available_profiles)
|
||
if (lower(p.label) == want) { type = p.type; break; }
|
||
}
|
||
|
||
if (type < 0) {
|
||
LOG_WARN << "unknown XKF profile '" << arg << "'; available: " << imuProfileList();
|
||
return;
|
||
}
|
||
|
||
LOG_INFO << "switching IMU to XKF profile type " << type
|
||
<< " (persists on the device; briefly pauses the IMU stream)";
|
||
const bool ok = imu->setFilterProfile(type);
|
||
// Either way the device state may have moved, so make the 'i' view re-read it
|
||
// rather than keep showing the profile we asked to leave.
|
||
imu_config_done_ = false;
|
||
if (ok)
|
||
LOG_INFO << "IMU XKF profile is now type " << type;
|
||
else
|
||
LOG_WARN << "IMU did not confirm XKF profile type " << type
|
||
<< " (older firmware may not report it; check the 'i' view)";
|
||
}
|
||
|
||
void imuStatus() {
|
||
if (!imu->connected()) {
|
||
LOG_WARN << "IMU: no recent sample (check [IMU] device and wiring)";
|
||
return;
|
||
}
|
||
auto sm = imu->sample();
|
||
if (!sm) { LOG_WARN << "IMU: no sample available"; return; }
|
||
LOG_INFO << "IMU: yaw " << formatDegrees(sm->yaw_deg) << " pitch "
|
||
<< formatDegrees(sm->pitch_deg) << " roll " << formatDegrees(sm->roll_deg);
|
||
char temp[32];
|
||
std::snprintf(temp, sizeof(temp), "%.1f", sm->temp_c);
|
||
LOG_INFO << " device temp " << temp << " C, sample #" << sm->sample_counter;
|
||
}
|
||
|
||
// Re-read before printing: the device is the authority, and a profile switched
|
||
// from anywhere else would otherwise be reported wrong.
|
||
void imuConfig() {
|
||
LOG_INFO << "re-reading IMU configuration (briefly pauses the stream)...";
|
||
imu->refreshConfig();
|
||
imu_config_done_ = false; // let the sensors view pick the new values up too
|
||
auto c = imu->config();
|
||
if (!c) {
|
||
LOG_WARN << "IMU reported no configuration (older firmware may not answer the queries)";
|
||
return;
|
||
}
|
||
LOG_INFO << "IMU: " << (c->product_code.empty() ? "(unknown product)" : c->product_code)
|
||
<< " device id " << c->device_id << " firmware " << c->firmware;
|
||
if (c->has_period)
|
||
LOG_INFO << " sample rate " << c->sample_rate_hz << " Hz";
|
||
LOG_INFO << " XKF profile: "
|
||
<< (c->scenario_label.empty() ? std::string("(unnamed)") : c->scenario_label)
|
||
<< " (type " << static_cast<int>(c->scenario_type) << ')';
|
||
LOG_INFO << " available (* = active): " << imuProfileList();
|
||
const auto problems = imu->configProblems();
|
||
if (problems.empty()) {
|
||
LOG_INFO << " device matches the configuration the host asked for";
|
||
} else {
|
||
for (const auto& p : problems) LOG_ERROR << " " << p;
|
||
}
|
||
}
|
||
|
||
// `imu` command family, mirroring handleGimbal: the operator-facing surface of
|
||
// IImuSource. `imu norotation` + `imu zero` correct and re-zero the MTi's own
|
||
// heading; the gimbal's heading is independent of both.
|
||
void handleImu(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] == "imu"
|
||
if (tok.size() < 2) {
|
||
LOG_WARN << "usage: imu <status|config|profile|zero|norotation>";
|
||
return;
|
||
}
|
||
if (!imu) {
|
||
LOG_WARN << "no IMU (declare [Hardware] imu, or use --mock-imu)";
|
||
return;
|
||
}
|
||
|
||
std::string sub = tok[1];
|
||
for (char& ch : sub) ch = static_cast<char>(std::tolower((unsigned char)ch));
|
||
|
||
if (sub == "status") {
|
||
imuStatus();
|
||
} else if (sub == "config") {
|
||
imuConfig();
|
||
} else if (sub == "profile") {
|
||
imuProfile(tok.size() > 2 ? tok[2] : "");
|
||
} else if (sub == "zero") {
|
||
// Yaw becomes relative to wherever the unit is pointing NOW, so say so:
|
||
// run mid-scan this silently re-references every later CamEvent heading.
|
||
LOG_INFO << "IMU heading reset: this pose is now yaw 0";
|
||
imu->headingReset();
|
||
} else if (sub == "norotation") {
|
||
int secs = 3; // matches CalibParams::norotation_s
|
||
if (tok.size() > 2) {
|
||
try { secs = std::stoi(tok[2]); } catch (...) {
|
||
LOG_WARN << "usage: imu norotation [<seconds>]";
|
||
return;
|
||
}
|
||
}
|
||
if (secs < 1) secs = 1;
|
||
// Non-blocking: the device runs the update on its own clock, so the
|
||
// operator - not this command - is what keeps the unit still.
|
||
LOG_INFO << "IMU gyro-bias update started: hold the unit still for " << secs << " s";
|
||
imu->noRotation(secs);
|
||
} else {
|
||
LOG_WARN << "unknown imu command '" << sub
|
||
<< "'; usage: imu <status|config|profile|zero|norotation>";
|
||
}
|
||
}
|
||
|
||
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;
|
||
// Set by CMake from $GIT_REV, which deploy.sh passes in; "unknown" for a
|
||
// plain local build. This is the TUI header's build id.
|
||
s.header.build = FGC_GIT_REV;
|
||
s.header.uptime_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||
std::chrono::steady_clock::now() - start_time).count();
|
||
s.header.live = !mock_serial;
|
||
s.header.mode = scheduler ? modeName(scheduler->mode()) : "manual";
|
||
s.log_file = opts.log_file ? opts.log_file() : "";
|
||
|
||
// --- Gimbal ---
|
||
MotorTelemetry t = motor->telemetry();
|
||
s.gimbal.present = motor->connected();
|
||
s.gimbal.pitch_present = t.pitch_present;
|
||
// Yaw degrees are true compass bearings in [0,360); pitch is a signed
|
||
// elevation about level, so it must not wrap.
|
||
auto fillAxis = [](AxisView& v, const char* label, const AxisTelemetry& a,
|
||
const AxisMap& map, long target_counts, bool wrap) {
|
||
auto deg = [&](long counts) { return wrap ? map.headingDeg(counts) : map.toDeg(counts); };
|
||
v.label = label;
|
||
v.state = a.state;
|
||
v.deg = deg(a.xenc);
|
||
v.xactual = a.xactual;
|
||
v.xenc = a.xenc;
|
||
v.enc_err = a.xactual - a.xenc; // live tracking error (counts)
|
||
v.target_deg = deg(target_counts);
|
||
v.counts_per_deg = map.counts_per_deg;
|
||
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.deviation = a.deviation;
|
||
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, true);
|
||
fillAxis(s.gimbal.pitch, "PITCH", t.pitch, cfg.geometry.pitch, pitch_tgt, false);
|
||
|
||
// 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, bool wrap) {
|
||
v.has_limits = true;
|
||
v.lim_neg = a.lim_neg;
|
||
v.lim_pos = a.lim_pos;
|
||
v.lim_neg_deg = wrap ? map.headingDeg(a.lim_neg) : map.toDeg(a.lim_neg);
|
||
v.lim_pos_deg = wrap ? map.headingDeg(a.lim_pos) : 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, true);
|
||
else if (a.axis == 'P') fillLimits(s.gimbal.pitch, a, cfg.geometry.pitch, false);
|
||
}
|
||
}
|
||
|
||
// --- 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_;
|
||
// From config.ini, not from the device, so it is available even when
|
||
// the MTi never answered the configuration queries.
|
||
s.imu.config.mount = formatImuMount(cfg.imu.mount);
|
||
// Also outside the cache above, and for a sharper reason: the handshake
|
||
// that produces these runs before the TUI installs its log sink, so the
|
||
// errors it logs are wiped by the alt-screen. This is the only place the
|
||
// operator can still see them.
|
||
s.imu.config.problems = imu->configProblems();
|
||
s.sensors.imu_config_alert = !s.imu.config.problems.empty();
|
||
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 (cfg.hardware.env == "dht11") {
|
||
s.env.on_mcu = true;
|
||
s.env.period_ms = 10000; // the firmware's own read period
|
||
}
|
||
s.env.fan = cfg.hardware.cooling == "fan";
|
||
s.env.fan_tach = cfg.hardware.pins.fan_tach >= 0;
|
||
if (auto r = motor ? motor->latestEnv() : std::nullopt) {
|
||
s.env.fan_duty = r->fan_duty;
|
||
s.env.fan_rpm = r->fan_rpm;
|
||
}
|
||
s.env.fan_stalled = fan_stalled_;
|
||
s.sensors.env.title = env->name() + (s.env.on_mcu ? " (ambient, via MCU)" : " (ambient)");
|
||
s.sensors.fan_alert = fan_stalled_;
|
||
if (s.env.fan) {
|
||
// The duty as a percentage, and the rpm where a tach says what it is.
|
||
std::string v = std::to_string(s.env.fan_duty * 100 / 255) + "%";
|
||
if (s.env.fan_tach) v += " " + std::to_string(s.env.fan_rpm) + " rpm";
|
||
s.sensors.env.fields.push_back({"Fan", v, "", true});
|
||
}
|
||
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));
|
||
}
|
||
} else {
|
||
s.sensors.env.status = "no fix";
|
||
}
|
||
}
|
||
|
||
// --- Camera / capture ---
|
||
s.capture.present = true;
|
||
s.capture.active = scheduler && scheduler->captureActive();
|
||
// Manual = no scan running. Drives what the key bar advertises; the
|
||
// enforcement is in requireManual(), not in the UI.
|
||
s.capture.manual = manualMode();
|
||
s.capture.snapshot_armed = scheduler && scheduler->snapshotPending();
|
||
s.capture.image_rate = scheduler ? scheduler->imageRate() : 0.0;
|
||
s.capture.camera_count = camera ? camera->cameraCount() : 0;
|
||
s.capture.labels.clear();
|
||
for (const auto* c : cfg.camera.active()) s.capture.labels.push_back(c->label);
|
||
// Per-camera configuration, for the expanded camera view.
|
||
{
|
||
auto& cc = s.capture.config;
|
||
cc.mock = opts.mock_camera.value_or(cfg.features.mock_camera);
|
||
cc.output_dir = cfg.paths.output_dir;
|
||
cc.cameras.clear();
|
||
for (const auto& c : cfg.camera.cameras) {
|
||
CameraView v;
|
||
v.name = c.name;
|
||
v.label = c.label;
|
||
v.id = c.id;
|
||
v.driver = c.driver;
|
||
v.enabled = c.enabled;
|
||
v.profile_dir = c.profile_dir;
|
||
v.rotation_deg = c.rotation_deg;
|
||
v.jxl_distance = c.jxl_distance;
|
||
v.jxl_effort = c.jxl_effort;
|
||
auto ap = active_profile.find(c.name);
|
||
v.profile = ap != active_profile.end() ? ap->second : c.profile;
|
||
v.settle_wait_ms = c.timingFor(v.profile).settle_wait_ms;
|
||
if (auto ci = camera_profiles.find(c.name); ci != camera_profiles.end()) {
|
||
for (const auto& [pname, prof] : ci->second)
|
||
v.profiles_available.push_back(pname);
|
||
if (auto pi = ci->second.find(v.profile); pi != ci->second.end())
|
||
v.profile_xml = pi->second.xml_path;
|
||
}
|
||
cc.cameras.push_back(std::move(v));
|
||
}
|
||
}
|
||
// 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.mode = s.header.mode;
|
||
s.conn.status_code = static_cast<int>(scheduler ? scheduler->mode() : Mode::Manual);
|
||
if (channel) {
|
||
s.conn.recent = channel->recentMessages();
|
||
s.conn.stats = channel->channelStats();
|
||
}
|
||
|
||
// --- 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;
|
||
// The map in force right now, whether or not anything was fitted today.
|
||
s.calib.live_yaw = {cfg.geometry.yaw.counts_per_deg, cfg.geometry.yaw.deg_at_center};
|
||
s.calib.live_pitch = {cfg.geometry.pitch.counts_per_deg, cfg.geometry.pitch.deg_at_center};
|
||
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 (heading_setup_active_) {
|
||
a.active = true;
|
||
a.heading_setup = true;
|
||
a.title = "HEADING SETUP";
|
||
a.status = "at " + std::to_string(s.gimbal.yaw.xenc) + " counts (reads " +
|
||
formatDegrees(s.gimbal.yaw.deg) + ")";
|
||
std::string marks;
|
||
for (const auto& m : heading_marks_)
|
||
marks += (marks.empty() ? "" : " ") + std::to_string(m.counts) + "\xE2\x86\x92" +
|
||
formatDegrees(m.heading_deg);
|
||
a.detail.push_back(marks.empty() ? "no marks yet - two landmarks minimum"
|
||
: "marks: " + marks);
|
||
} else 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;
|
||
const char* phase = scheduler->moving() ? "moving" : "dwell";
|
||
if (scheduler->mode() == Mode::Pan) {
|
||
a.title = "PAN";
|
||
const auto& h = scheduler->panHeadingsNow();
|
||
const size_t i = scheduler->panIndex();
|
||
a.status = "position " + std::to_string(i + 1) + "/" + std::to_string(h.size()) +
|
||
" · yaw " + formatDegrees(i < h.size() ? h[i] : 0.0) + " / pitch " +
|
||
formatDegrees(scheduler->pan().pitch) + " · " + phase;
|
||
} else if (!grid.empty()) {
|
||
a.title = "AUTO";
|
||
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.title = "AUTO";
|
||
a.status = "no scan grid — auto-sweep disabled";
|
||
}
|
||
}
|
||
|
||
// A running procedure is cancelable with Esc.
|
||
a.cancelable = procedureBusy() || heading_setup_active_;
|
||
|
||
// 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_)
|
||
// Name the source: a rough derivation and a landmark fit both arrive
|
||
// here, and they do not deserve the same confidence.
|
||
a.prompt = "Save the new [Motor] calibration (" +
|
||
(calib_save_source_.empty() ? std::string("calibration")
|
||
: calib_save_source_) +
|
||
") to config as the 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.
|
||
// A legacy tower's enclosure fan, driven hard but not turning, on two
|
||
// readings in a row (20 s): said once, and again when it recovers. Nothing
|
||
// on a tower without a fan, or without a tach to tell.
|
||
void checkFan() {
|
||
if (cfg.hardware.cooling != "fan" || cfg.hardware.pins.fan_tach < 0) return;
|
||
const auto r = motor->latestEnv();
|
||
if (!r || r->epoch_ms == fan_seen_ms_) return;
|
||
fan_seen_ms_ = r->epoch_ms;
|
||
fan_stall_count_ = fanLooksStalled(*r) ? fan_stall_count_ + 1 : 0;
|
||
const bool stalled = fan_stall_count_ >= 2;
|
||
if (stalled && !fan_stalled_)
|
||
LOG_WARN << "enclosure fan driven at " << r->fan_duty
|
||
<< "/255 but its tach reads 0 rpm - dead, jammed or unplugged; the box "
|
||
"is not being cooled (" << r->temp_c << " C inside)";
|
||
else if (!stalled && fan_stalled_)
|
||
LOG_INFO << "enclosure fan turning again (" << r->fan_rpm << " rpm)";
|
||
fan_stalled_ = stalled;
|
||
}
|
||
|
||
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_};
|
||
|
||
// Tell the firmware where to anchor the homed midpoint and how much
|
||
// clearance to keep from each endstop, BEFORE homing: finishHoming() uses
|
||
// the centre count to re-origin, so a late send would apply to the next
|
||
// home, not this one. And before ENABLE: this also carries the drivetrain,
|
||
// and a board that has just lost its settings (power-up, fresh flash)
|
||
// refuses ENABLE with "ERR need config" until it has them.
|
||
sendAnchorConfig();
|
||
|
||
motor->sendCommand("ENABLE Y");
|
||
// Only address axes this tower actually has. A yaw-only rig answers
|
||
// "ERR axis not present" to every pitch command, which arrives in the log
|
||
// as a row of warnings at every startup - noise that trains an operator to
|
||
// ignore exactly the channel that reports real faults.
|
||
if (cfg.hardware.hasAxis("pitch")) motor->sendCommand("ENABLE P");
|
||
|
||
// Confirm the current landed BEFORE driving into anything. This is the
|
||
// only check standing between a stale firmware and an endstop that cannot
|
||
// survive full torque.
|
||
// Only when a reduction is actually depended on. An axis running at one
|
||
// current for everything has nothing to lose if the command went astray,
|
||
// and the check costs a DUMP round trip whose output floods the log.
|
||
if (cfg.drivetrain.yaw.reducesForHoming() || cfg.drivetrain.pitch.reducesForHoming()) {
|
||
if (auto dd = freshDump(std::chrono::seconds(3))) {
|
||
if (!verifyCurrentAdoption(*dd)) return;
|
||
} else {
|
||
LOG_WARN << "no firmware dump before homing; cannot confirm the motor "
|
||
"current was adopted";
|
||
}
|
||
}
|
||
|
||
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());
|
||
};
|
||
|
||
// An axis re-homed from READY is still READY for a moment, 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 having waited for nothing.
|
||
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;
|
||
// How long a full two-end search may take is a property of the drivetrain,
|
||
// not of this program: a high gear ratio homing at a deliberately gentle
|
||
// speed takes minutes, and a fixed 65 s ceiling reported those towers as
|
||
// failed while the firmware was still searching perfectly happily.
|
||
const auto deadline =
|
||
std::chrono::steady_clock::now() + std::chrono::seconds(cfg.homing_timeout_s);
|
||
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,
|
||
// and take the chance to check the degree scale against the travel we just
|
||
// measured. Derivation first: applyHomedTravel() converts the soft limits
|
||
// through the map, so it wants the corrected scale.
|
||
if (homed) {
|
||
using namespace std::chrono_literals;
|
||
if (auto dd = freshDump(3s)) {
|
||
deriveCountsPerDeg(*dd);
|
||
applyHomedTravel(&*dd);
|
||
} else {
|
||
LOG_WARN << "no firmware dump after homing; degree limits and the derived "
|
||
"counts_per_deg both skipped";
|
||
}
|
||
}
|
||
// Production move speeds for subsequent MOVE commands, converted from the
|
||
// declared deg/s. Sent here rather than earlier because deriveCountsPerDeg()
|
||
// above has just refreshed the scale this conversion depends on.
|
||
sendProductionSpeeds();
|
||
LOG_INFO << "Gimbal init finished";
|
||
}
|
||
|
||
// Translate each axis's declared deg/s into the VMAX this drivetrain needs,
|
||
// and tell the firmware.
|
||
//
|
||
// The speed is declared as a physical quantity because a register value is
|
||
// not portable: the same VMAX is 21.8 deg/s on the Delta fleet and 6.0 deg/s
|
||
// on the IOTA, and both were carrying the same literal. What is logged is
|
||
// what the axis will actually do, not what was asked for - the two differ by
|
||
// the integer quantisation, and by more than that when a request is clamped.
|
||
void sendProductionSpeeds() {
|
||
auto one = [&](const char* name, char code, const AxisMap& map,
|
||
const AxisDrivetrainConfig& d) {
|
||
if (!cfg.hardware.hasAxis(name)) return;
|
||
const auto p = planAxisSpeed(d.scan_speed_deg_s, map.counts_per_deg);
|
||
if (!p.ok) {
|
||
// Leave the firmware on its compile-time default rather than
|
||
// commanding a speed derived from a scale we do not trust.
|
||
LOG_WARN << name << " speed: not set -- " << p.error
|
||
<< "; the firmware keeps its built-in default";
|
||
return;
|
||
}
|
||
motor->sendCommand(std::string("SPEED ") + code + " " + std::to_string(p.vmax));
|
||
if (p.clamped)
|
||
LOG_WARN << name << " speed: " << d.scan_speed_deg_s
|
||
<< " deg/s exceeds what VMAX can express on this drivetrain; clamped to "
|
||
<< p.actual_deg_s << " deg/s";
|
||
LOG_INFO << name << " speed: " << p.actual_deg_s << " deg/s (VMAX " << p.vmax
|
||
<< ") -- " << map.counts_per_deg << " counts/deg";
|
||
};
|
||
one("yaw", 'Y', cfg.geometry.yaw, cfg.drivetrain.yaw);
|
||
one("pitch", 'P', cfg.geometry.pitch, cfg.drivetrain.pitch);
|
||
}
|
||
|
||
// Build what the host can answer for one axis, for the config handshake.
|
||
//
|
||
// Speeds are converted here rather than declared as VMAX, for the reason in
|
||
// AxisSpeed.h. Homing and centring happen BEFORE the first home, so they
|
||
// cannot use the counts_per_deg that homing derives - they use the scale the
|
||
// declaration implies (steps_per_rev / travel_deg), which on the IOTA agrees
|
||
// with the measured one to 0.25%.
|
||
AxisConfigSource axisConfigSource(const char* axis, const AxisMap& map,
|
||
const AxisDrivetrainConfig& d, const TmcCurrentPlan& cur,
|
||
long soft_margin) {
|
||
AxisConfigSource s;
|
||
s.have_current = cur.ok;
|
||
s.global_scaler = cur.global_scaler;
|
||
s.run_irun = cur.run_irun;
|
||
s.homing_irun = cur.homing_irun;
|
||
|
||
s.center_count = map.center_count;
|
||
s.soft_margin = soft_margin;
|
||
|
||
s.have_travel = d.steps_per_rev > 0;
|
||
s.steps_per_rev = d.steps_per_rev;
|
||
s.have_deviation = d.enc_deviation > 0;
|
||
s.enc_deviation = d.enc_deviation;
|
||
|
||
s.have_wiring = d.wiring_declared;
|
||
s.sw_mode = d.sw_mode;
|
||
s.has_encoder = d.has_encoder;
|
||
|
||
// The scale to convert through. The firmware asks for all three speeds at
|
||
// BOOT, before anything has been homed, so the live calibration is often
|
||
// still AxisMap's uncalibrated default - fall back to the one the
|
||
// declaration implies. Homing supersedes it: sendProductionSpeeds() re-sends
|
||
// the production speed against the measured scale once it exists.
|
||
const double live = std::fabs(map.counts_per_deg);
|
||
const double cpd = live >= kMinPlausibleCountsPerDeg ? map.counts_per_deg
|
||
: d.preHomeCountsPerDeg();
|
||
auto vmax = [&](double deg_s) -> long {
|
||
if (deg_s <= 0.0) return 0; // undeclared: firmware default stands
|
||
const auto p = planAxisSpeed(deg_s, cpd);
|
||
if (!p.ok) {
|
||
LOG_WARN << axis << " " << deg_s << " deg/s could not be converted: " << p.error;
|
||
return 0;
|
||
}
|
||
return p.vmax;
|
||
};
|
||
s.normal_vmax = vmax(d.scan_speed_deg_s);
|
||
s.homing_vmax = vmax(d.homing_speed_deg_s);
|
||
s.center_vmax = vmax(d.center_speed_deg_s);
|
||
return s;
|
||
}
|
||
|
||
// Answer any NEED lines the firmware has emitted.
|
||
//
|
||
// The firmware stays in NEEDCFG and moves nothing until its required set is
|
||
// complete, so an unanswered key is a startup failure rather than a warning:
|
||
// it is named here, loudly, because the fix is in hardware.ini and nothing
|
||
// else will make the gimbal move.
|
||
void answerFirmwareNeeds() {
|
||
for (const auto& line : motor->takeNeedLines()) {
|
||
const auto need = parseNeedLine(line);
|
||
if (!need) {
|
||
LOG_WARN << "unparseable NEED line from the firmware: " << line;
|
||
continue;
|
||
}
|
||
answerNeed(*need, /*asked=*/true);
|
||
}
|
||
}
|
||
|
||
// Every key the host can answer, by its canonical name. Used to re-send the
|
||
// whole declared set on connect - see resendDeclaredConfig().
|
||
static NeedRequest fullRequest(char axis) {
|
||
NeedRequest r;
|
||
r.axis = axis;
|
||
r.keys = {"steps_per_rev", "global_scaler", "run_irun", "homing_irun",
|
||
"center_count", "soft_margin", "enc_deviation", "normal_speed",
|
||
"homing_speed", "center_speed", "sw_mode", "has_encoder"};
|
||
return r;
|
||
}
|
||
|
||
// Push the declared drivetrain at the firmware whether or not it asked.
|
||
//
|
||
// The firmware asks exactly once, at boot, and only while it is in NEEDCFG.
|
||
// Without this, editing hardware.ini and restarting the HOST changes nothing
|
||
// - the board is already configured, so it says nothing, and the host has
|
||
// nothing to reply to. The tower keeps running the old values silently.
|
||
// Re-tuning would need a re-flash, which is seven seconds on a bench and a
|
||
// trip up a mast in the field.
|
||
//
|
||
// CENTER and MARGIN are sent again as SET here, having just gone out as
|
||
// their own commands. That is idempotent and costs two lines; keeping the
|
||
// set uniform is worth more than saving them.
|
||
//
|
||
// It also makes the handshake robust to a NEED line lost on the wire: an
|
||
// axis still in NEEDCFG is completed by this re-send rather than sitting
|
||
// there refusing to move.
|
||
void resendDeclaredConfig() {
|
||
for (const char* name : {"yaw", "pitch"}) {
|
||
if (!cfg.hardware.hasAxis(name)) continue;
|
||
answerNeed(fullRequest(name[0] == 'y' ? 'Y' : 'P'), /*asked=*/false);
|
||
}
|
||
}
|
||
|
||
// `asked` distinguishes the two callers. When the firmware ASKED, a key the
|
||
// host cannot supply is a startup failure - the axis stays in NEEDCFG and
|
||
// will not move, so it is an error. On a proactive re-send it means only
|
||
// "hardware.ini does not declare this one", and the firmware simply keeps
|
||
// what it already had; saying ERROR there would cry wolf on every start.
|
||
void answerNeed(const NeedRequest& need, bool asked) {
|
||
const bool is_yaw = need.axis == 'Y';
|
||
|
||
// An axis the tower says it does not have. That is not a missing
|
||
// declaration - it is a firmware built for more axes than this rig,
|
||
// and answering would configure a driver that is not there. Same
|
||
// class of fault as presence reconciliation catches, so it is named
|
||
// that way rather than as a hole in hardware.ini.
|
||
const char* axis_name = is_yaw ? "yaw" : "pitch";
|
||
if (cfg.hardware.declared && !cfg.hardware.hasAxis(axis_name)) {
|
||
LOG_ERROR << "firmware asked for axis " << need.axis << " (" << axis_name
|
||
<< "), which this tower does not declare (axes "
|
||
<< cfg.hardware.axesLabel()
|
||
<< ") -- the firmware was built for more axes than the rig has; "
|
||
"rebuild it for this board rather than declaring an axis that is "
|
||
"not fitted";
|
||
return;
|
||
}
|
||
const auto src = is_yaw
|
||
? axisConfigSource("yaw", cfg.geometry.yaw, cfg.drivetrain.yaw,
|
||
yaw_current_, cfg.yaw_soft_margin_counts)
|
||
: axisConfigSource("pitch", cfg.geometry.pitch,
|
||
cfg.drivetrain.pitch, pitch_current_,
|
||
cfg.pitch_soft_margin_counts);
|
||
|
||
const auto plan = planFirmwareConfig(need, src);
|
||
for (const auto& cmd : plan.sets) motor->sendCommand(cmd);
|
||
|
||
if (!plan.unanswered.empty() && asked) {
|
||
std::string keys;
|
||
for (const auto& k : plan.unanswered) keys += (keys.empty() ? "" : ", ") + k;
|
||
LOG_ERROR << "firmware asked axis " << need.axis << " for " << keys
|
||
<< " and hardware.ini does not declare "
|
||
<< (plan.unanswered.size() == 1 ? "it" : "them")
|
||
<< "; the axis stays in NEEDCFG and will not move";
|
||
}
|
||
if (!plan.unknown.empty() && asked) {
|
||
std::string keys;
|
||
for (const auto& k : plan.unknown) keys += (keys.empty() ? "" : ", ") + k;
|
||
LOG_ERROR << "firmware asked axis " << need.axis << " for " << keys
|
||
<< ", which this host does not know -- the firmware is newer than the "
|
||
"host; upgrade the host rather than editing hardware.ini";
|
||
}
|
||
if (asked && plan.complete())
|
||
LOG_INFO << "firmware config: answered " << plan.sets.size() << " keys for axis "
|
||
<< need.axis;
|
||
if (!asked && !plan.sets.empty())
|
||
LOG_INFO << "drivetrain re-sent to axis " << need.axis << ": " << plan.sets.size()
|
||
<< " values from hardware.ini";
|
||
}
|
||
|
||
// Convert this tower's declared milliamps into TMC register fields, once.
|
||
// The host owns this because hardware.ini is where the sense resistor lives;
|
||
// the firmware is told registers and keeps working compile-time defaults.
|
||
void planMotorCurrents() {
|
||
auto plan = [&](const char* name, const AxisDrivetrainConfig& d, TmcCurrentPlan& out) {
|
||
if (!d.declared || d.run_current_ma <= 0.0) return;
|
||
out = planTmcCurrent(d.r_sense_ohm, d.run_current_ma, d.homing_current_ma);
|
||
for (const auto& w : out.warnings) LOG_WARN << name << " motor current: " << w;
|
||
if (!out.ok) {
|
||
LOG_ERROR << name << " motor current could not be computed; the firmware "
|
||
"will keep its built-in defaults";
|
||
return;
|
||
}
|
||
// Log what the registers actually deliver, not what was asked for.
|
||
LOG_INFO << name << " motor current: run " << std::lround(out.run_ma)
|
||
<< " mA (GLOBALSCALER " << out.global_scaler << ", IRUN " << out.run_irun
|
||
<< "), homing " << std::lround(out.homing_ma) << " mA (IRUN "
|
||
<< out.homing_irun << ") -- R_sense " << d.r_sense_ohm
|
||
<< " ohm from hardware.ini [Motor." << name << ']';
|
||
};
|
||
plan("yaw", cfg.drivetrain.yaw, yaw_current_);
|
||
plan("pitch", cfg.drivetrain.pitch, pitch_current_);
|
||
}
|
||
|
||
// Did the firmware actually adopt the current we sent? GLOBALSCALER and
|
||
// IHOLD_IRUN are write-only on the TMC5160, so the DUMP echo is the only
|
||
// channel - and sendCommand() is fire-and-forget, so an 'ERR unknown command'
|
||
// from an older firmware arrives as an uncorrelated warning we cannot pin to
|
||
// this command. Returns false only when a reduction was REQUIRED and is
|
||
// demonstrably absent.
|
||
bool verifyCurrentAdoption(const DumpData& dd) {
|
||
bool safe = true;
|
||
auto check = [&](const char* name, char axis, const AxisDrivetrainConfig& d,
|
||
const TmcCurrentPlan& want) {
|
||
if (!want.ok) return;
|
||
const DumpAxis* ax = nullptr;
|
||
for (const auto& a : dd.axes)
|
||
if (a.axis == axis) ax = &a;
|
||
if (!ax) return;
|
||
|
||
const bool absent = (ax->run_irun < 0);
|
||
const bool wrong = !absent && (ax->global_scaler != want.global_scaler ||
|
||
ax->run_irun != want.run_irun ||
|
||
ax->homing_irun != want.homing_irun);
|
||
if (!absent && !wrong) return;
|
||
|
||
std::ostringstream detail;
|
||
if (absent)
|
||
detail << "the firmware does not report a motor current at all, so it "
|
||
"predates the CURRENT command";
|
||
else
|
||
detail << "the firmware reports GLOBALSCALER " << ax->global_scaler << ", IRUN "
|
||
<< ax->run_irun << '/' << ax->homing_irun << " but was sent "
|
||
<< want.global_scaler << ", IRUN " << want.run_irun << '/'
|
||
<< want.homing_irun;
|
||
|
||
if (d.reducesForHoming()) {
|
||
// The reduction is load-bearing: this tower's endstop yields to
|
||
// full torque. Homing anyway is how the stop gets destroyed.
|
||
LOG_ERROR << name << " motor current was NOT adopted -- " << detail.str()
|
||
<< ". Refusing to home: this axis declares a reduced homing "
|
||
"current because its endstop cannot survive full torque. "
|
||
"Flash the firmware, or remove homing_current_ma from "
|
||
"hardware.ini to accept the risk deliberately.";
|
||
safe = false;
|
||
} else {
|
||
LOG_WARN << name << " motor current was not adopted -- " << detail.str()
|
||
<< ". Continuing: this axis asks for no reduction, so homing is "
|
||
"unaffected.";
|
||
}
|
||
};
|
||
check("yaw", 'Y', cfg.drivetrain.yaw, yaw_current_);
|
||
check("pitch", 'P', cfg.drivetrain.pitch, pitch_current_);
|
||
return safe;
|
||
}
|
||
|
||
// A homed axis that is not standing still: a move nobody is watching. Homing
|
||
// is left alone - it ends itself, and a STOP would end it in ERROR instead.
|
||
static bool leftoverMove(const AxisTelemetry& a) {
|
||
return a.state == AxisState::Ready && !a.standstill;
|
||
}
|
||
bool anyLeftoverMove() {
|
||
const MotorTelemetry t = motor->telemetry();
|
||
return leftoverMove(t.yaw) || (t.pitch_present && leftoverMove(t.pitch));
|
||
}
|
||
|
||
// A program stopped mid-sweep leaves the firmware finishing its last MOVE,
|
||
// and the firmware refuses to re-scale a moving motor: the CURRENT sent
|
||
// below would be refused and this start would run on whatever current the
|
||
// board last had. So stop that move and let it come to rest first.
|
||
void stopLeftoverMotion() {
|
||
using namespace std::chrono_literals;
|
||
if (!anyLeftoverMove()) return;
|
||
LOG_INFO << "gimbal still moving from before this start; stopping it before "
|
||
"configuring the motors";
|
||
motor->sendCommand("STOP ALL");
|
||
const auto deadline = std::chrono::steady_clock::now() + 10s;
|
||
while (std::chrono::steady_clock::now() < deadline) {
|
||
std::this_thread::sleep_for(100ms);
|
||
if (!anyLeftoverMove()) return;
|
||
}
|
||
LOG_WARN << "gimbal did not come to a standstill within 10 s; configuring it anyway "
|
||
"(a motor current sent while it moves is refused)";
|
||
}
|
||
|
||
// Tell the firmware the anchor point and endstop clearance for each axis.
|
||
// The centre count is what finishHoming() re-origins onto, so all counts stay
|
||
// positive and reproducible across power cycles; the margin is what
|
||
// clampTarget() keeps clear of each hard limit.
|
||
void sendAnchorConfig() {
|
||
stopLeftoverMotion();
|
||
motor->sendCommand("CENTER Y " + std::to_string(cfg.geometry.yaw.center_count));
|
||
motor->sendCommand("MARGIN Y " + std::to_string(cfg.yaw_soft_margin_counts));
|
||
if (cfg.hardware.hasAxis("pitch")) {
|
||
motor->sendCommand("CENTER P " + std::to_string(cfg.geometry.pitch.center_count));
|
||
motor->sendCommand("MARGIN P " + std::to_string(cfg.pitch_soft_margin_counts));
|
||
}
|
||
// Motor current belongs here too: it governs homing torque, so it has to
|
||
// land before the search, not after it.
|
||
auto sendCurrent = [&](char axis, const TmcCurrentPlan& p) {
|
||
if (!p.ok) return;
|
||
motor->sendCommand(std::string("CURRENT ") + axis + ' ' +
|
||
std::to_string(p.global_scaler) + ' ' +
|
||
std::to_string(p.run_irun) + ' ' +
|
||
std::to_string(p.homing_irun));
|
||
};
|
||
sendCurrent('Y', yaw_current_);
|
||
sendCurrent('P', pitch_current_);
|
||
|
||
// And the rest of the declared drivetrain. CENTER/MARGIN/CURRENT above
|
||
// have always been re-sent on every start; the keys the config handshake
|
||
// added were not, so half the tuning took effect on a restart and half
|
||
// silently did not. This makes all of it behave the same way.
|
||
resendDeclaredConfig();
|
||
}
|
||
|
||
// Derive each axis's reachable degree window from the freshly homed soft
|
||
// limits. These are deliberately NOT configurable: the firmware's limits are
|
||
// the only place they can be correct, and a stale configured range would
|
||
// either advertise travel that does not exist or hide travel that does.
|
||
//
|
||
// Also verifies that the firmware actually adopted the anchor and margin we
|
||
// sent. With no EEPROM the limits can only come from a home in this power
|
||
// cycle, so a midpoint that is not center_count means the CENTER command did
|
||
// not take effect - firmware predating it answers "ERR unknown command" and
|
||
// homes to its own compiled default, which would leave every published
|
||
// bearing silently wrong.
|
||
// Request a DUMP and wait briefly for a parseable one. Several callers need
|
||
// the firmware's own view of itself - the homed limits, and whether it adopted
|
||
// what we sent - so the round trip lives in one place.
|
||
// What this run actually has, for reconciliation against hardware.ini.
|
||
// Called once at startup, after every subsystem has been constructed and
|
||
// started, because "found" can only mean "came up", not "was configured".
|
||
FoundHardware observeHardware(std::chrono::milliseconds wait) {
|
||
using namespace std::chrono_literals;
|
||
FoundHardware f;
|
||
|
||
// Axes come from telemetry, which takes a moment to arrive. Yaw reaching
|
||
// any real state is the signal the link is alive; until then a
|
||
// default-constructed snapshot would report every axis missing.
|
||
MotorTelemetry t = motor->telemetry();
|
||
const auto deadline = std::chrono::steady_clock::now() + wait;
|
||
while (running && t.yaw.state == AxisState::Unknown &&
|
||
std::chrono::steady_clock::now() < deadline) {
|
||
std::this_thread::sleep_for(100ms);
|
||
t = motor->telemetry();
|
||
}
|
||
f.axes_known = t.yaw.state != AxisState::Unknown;
|
||
if (f.axes_known) {
|
||
f.axes.push_back("yaw"); // any well-formed ST line carries Y:
|
||
if (t.pitch_present) f.axes.push_back("pitch");
|
||
}
|
||
|
||
// A null source is the "not fitted / not enabled" case; makeImu() and
|
||
// makeEnv() already log why.
|
||
f.imu = imu != nullptr;
|
||
f.env = env != nullptr;
|
||
|
||
for (const auto* c : cfg.camera.active()) f.cameras.push_back(c->name);
|
||
f.cameras_opened = camera ? camera->cameraCount() : -1;
|
||
return f;
|
||
}
|
||
|
||
// Compare the rig against its declaration and act on the difference.
|
||
// Returns false when the run must not continue.
|
||
bool reconcileHardware() {
|
||
using namespace std::chrono_literals;
|
||
if (!cfg.hardware.declared) return true; // nothing declared, nothing to check
|
||
|
||
const PresenceReport report = reconcilePresence(cfg.hardware, observeHardware(3s));
|
||
for (const auto& line : formatPresence(report)) LOG_WARN << "hardware: " << line;
|
||
|
||
const PresenceVerdict v =
|
||
presenceVerdict(report, cfg.features.strict_presence, opts.degraded);
|
||
switch (v.action) {
|
||
case PresenceAction::Proceed:
|
||
LOG_INFO << "hardware: declared assembly confirmed (" << cfg.hardware.axesLabel()
|
||
<< ")";
|
||
return true;
|
||
case PresenceAction::WarnOnly:
|
||
LOG_WARN << "hardware: " << v.reason;
|
||
return true;
|
||
case PresenceAction::Refuse:
|
||
LOG_ERROR << "hardware: " << v.reason;
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
std::optional<DumpData> freshDump(std::chrono::milliseconds wait) {
|
||
using namespace std::chrono_literals;
|
||
motor->sendCommand("DUMP");
|
||
const auto deadline = std::chrono::steady_clock::now() + wait;
|
||
while (running && std::chrono::steady_clock::now() < deadline) {
|
||
std::this_thread::sleep_for(100ms);
|
||
publishSnapshot();
|
||
DumpData dd = parseDump(motor->lastDump());
|
||
if (dd.valid) return dd;
|
||
}
|
||
return std::nullopt;
|
||
}
|
||
|
||
void applyHomedTravel(const DumpData* pre = nullptr) {
|
||
using namespace std::chrono_literals;
|
||
|
||
std::optional<DumpData> got;
|
||
if (!pre) {
|
||
got = freshDump(3s);
|
||
if (!got) {
|
||
LOG_WARN << "homed travel: no firmware dump; keeping the previous degree limits";
|
||
return;
|
||
}
|
||
}
|
||
const DumpData& dd = pre ? *pre : *got;
|
||
|
||
bool any = false;
|
||
for (const auto& a : dd.axes) {
|
||
if (a.lim_pos == a.lim_neg) continue; // that axis has not homed
|
||
const bool is_yaw = (a.axis == 'Y');
|
||
AxisMap& map = is_yaw ? cfg.geometry.yaw : cfg.geometry.pitch;
|
||
const char* name = is_yaw ? "yaw" : "pitch";
|
||
const long want_margin =
|
||
is_yaw ? cfg.yaw_soft_margin_counts : cfg.pitch_soft_margin_counts;
|
||
|
||
// The centre is arbitrary, but it has to be big enough that the whole
|
||
// travel stays positive: the range is centre +/- half the travel, so
|
||
// a centre below half-travel puts the negative end under zero. The
|
||
// 500000 default was sized for the Delta's ~89k half-travel and does
|
||
// not survive a gear ratio 7x higher.
|
||
const auto fits = checkTravelFitsPositive(name, map.center_count,
|
||
(a.lim_pos - a.lim_neg) / 2);
|
||
if (fits.ran && !fits.pass) LOG_WARN << fits.detail;
|
||
|
||
// One encoder quantum of slack: ENC_CONST makes X_ENC advance in 12.8
|
||
// microstep steps, so the midpoint can land a count or so off.
|
||
const long mid = a.lim_neg + (a.lim_pos - a.lim_neg) / 2;
|
||
if (std::labs(mid - map.center_count) > kEncoderQuantumCounts) {
|
||
LOG_ERROR << "homed " << name << " midpoint is " << mid << " but ["
|
||
<< "Motor] " << name << "_center_count is " << map.center_count
|
||
<< " -- the firmware did not adopt CENTER (is it older than this "
|
||
"host?). Refusing to derive travel limits; headings would be "
|
||
"wrong.";
|
||
continue;
|
||
}
|
||
|
||
long margin = a.soft_margin;
|
||
if (margin <= 0) {
|
||
LOG_WARN << "homed " << name
|
||
<< ": firmware reported no soft_margin (update firmware); "
|
||
"anchoring at the hard homing limit instead";
|
||
margin = 0;
|
||
} else if (margin != want_margin) {
|
||
// Trust the firmware: it is what is actually clamping the axis, so
|
||
// deriving from our value would advertise travel it will not allow.
|
||
LOG_WARN << "homed " << name << ": firmware soft_margin " << margin
|
||
<< " != configured " << want_margin
|
||
<< "; using the firmware's value";
|
||
}
|
||
|
||
const long lo_soft = a.lim_neg + margin;
|
||
const long hi_soft = a.lim_pos - margin;
|
||
const double d_lo = map.toDeg(lo_soft);
|
||
const double d_hi = map.toDeg(hi_soft);
|
||
map.min_deg = std::min(d_lo, d_hi);
|
||
map.max_deg = std::max(d_lo, d_hi);
|
||
any = true;
|
||
|
||
if (is_yaw)
|
||
LOG_INFO << "homed yaw travel: " << map.min_deg << ".." << map.max_deg
|
||
<< " deg (headings " << wrap360(map.min_deg) << ".."
|
||
<< wrap360(map.max_deg) << " true), centre " << map.center_count
|
||
<< " = " << map.deg_at_center << " deg";
|
||
else
|
||
LOG_INFO << "homed pitch travel: " << map.min_deg << ".." << map.max_deg
|
||
<< " deg, centre " << map.center_count << " = " << map.deg_at_center
|
||
<< " deg";
|
||
}
|
||
if (!any) return;
|
||
if (scheduler) scheduler->setGeometry(cfg.geometry); // re-target the scan with it
|
||
auditScanReachability();
|
||
}
|
||
|
||
// Report scan waypoints that fall in the yaw arc the gimbal cannot reach, once
|
||
// per homing rather than once per pass. The scheduler skips them at run time;
|
||
// without this the only symptom would be a grid quietly shorter than the CSV.
|
||
void auditScanReachability() {
|
||
if (grid.empty()) return;
|
||
std::size_t bad = 0;
|
||
for (const auto& p : grid.points())
|
||
if (!cfg.geometry.yaw.headingToCounts(p.yaw_deg)) ++bad;
|
||
if (bad == 0) return;
|
||
LOG_WARN << bad << " of " << grid.size()
|
||
<< " scan waypoints lie outside the reachable yaw arc ("
|
||
<< wrap360(cfg.geometry.yaw.min_deg) << ".."
|
||
<< wrap360(cfg.geometry.yaw.max_deg) << " true) and will be skipped";
|
||
}
|
||
|
||
// `camera reset`: reboot every camera (DeviceReset) and put it back on its
|
||
// profile. For a stream that has stopped delivering and will not restart -
|
||
// the state tower 111's camera was in on 2026-09-24, which only this cleared.
|
||
// Manual mode only (allowedIn), so there is no scan to pause around it.
|
||
void resetCameras() {
|
||
camera->stop();
|
||
for (int i = 0; i < camera->cameraCount(); ++i)
|
||
LOG_INFO << "camera " << i << (camera->resetCamera(i) ? ": reset, back on its profile"
|
||
: ": reset FAILED (see above)");
|
||
}
|
||
|
||
// The one way the mode changes - `mode ...`, the a / p / m keys, --start, and a
|
||
// test pausing the scan all come here. Returns false, having said why and
|
||
// leaving the mode as it was, when the gimbal cannot do what was asked.
|
||
// StatusCode is published on every change, so it always says what the tower
|
||
// is doing, not what someone last asked for.
|
||
bool setMode(Mode m, const PanSpec& pan = {}) {
|
||
if (m != Mode::Manual) {
|
||
// Heading setup owns the gimbal by hand. Starting a scan under it would
|
||
// move the axis between a sighting and the bearing being typed for it,
|
||
// silently poisoning the fit with a position nobody looked through.
|
||
if (heading_setup_active_) {
|
||
LOG_WARN << "mode " << modeName(m) << " refused: heading setup in progress "
|
||
"(finish it, or 'gimbal heading cancel')";
|
||
return false;
|
||
}
|
||
// Before READY the firmware leaves coils disabled and clamps MOVE
|
||
// targets to a coarse bench guard, so no position 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 << "mode " << modeName(m) << " refused: gimbal not homed "
|
||
"(run 'gimbal home' first)";
|
||
return false;
|
||
}
|
||
}
|
||
if (m == Mode::Pan && !checkPan(pan)) return false;
|
||
|
||
const Mode was = scheduler->mode();
|
||
if (m == Mode::Manual) camera->stop();
|
||
else camera->start();
|
||
scheduler->setMode(m, pan);
|
||
if (m == Mode::Pan) {
|
||
const auto& h = scheduler->panHeadingsNow();
|
||
LOG_INFO << "mode pan: heading " << pan.heading << ", span " << pan.span
|
||
<< ", pitch " << pan.pitch << " deg - " << h.size()
|
||
<< (h.size() == 1 ? " position" : " positions") << " ("
|
||
<< panRangeText(h) << ")";
|
||
} else {
|
||
LOG_INFO << "mode " << modeName(m) << (was == m ? " (again)" : "");
|
||
}
|
||
channel->publishStatus(static_cast<int>(m));
|
||
return true;
|
||
}
|
||
|
||
// A pan the gimbal can actually do: its pitch inside the homed window (not
|
||
// clamped to it - that would image an elevation nobody asked for), and at
|
||
// least one of its headings inside the reachable yaw arc.
|
||
bool checkPan(const PanSpec& pan) {
|
||
const AxisMap& p = cfg.geometry.pitch;
|
||
if (cfg.hardware.hasAxis("pitch") && (pan.pitch < p.min_deg || pan.pitch > p.max_deg)) {
|
||
LOG_WARN << "mode pan refused: pitch " << pan.pitch << " deg is outside the homed "
|
||
<< "pitch travel (" << p.min_deg << ".." << p.max_deg << " deg)";
|
||
return false;
|
||
}
|
||
const auto headings = panHeadings(pan, cfg.hardware.pan_step_deg);
|
||
std::size_t reachable = 0;
|
||
for (double h : headings)
|
||
if (cfg.geometry.yaw.headingToCounts(h)) ++reachable;
|
||
if (reachable == 0) {
|
||
LOG_WARN << "mode pan refused: no heading in " << panRangeText(headings)
|
||
<< " is inside the reachable yaw arc (" << wrap360(cfg.geometry.yaw.min_deg)
|
||
<< ".." << wrap360(cfg.geometry.yaw.max_deg) << " true)";
|
||
return false;
|
||
}
|
||
if (reachable < headings.size())
|
||
LOG_WARN << "mode pan: " << headings.size() - reachable << " of " << headings.size()
|
||
<< " headings lie outside the reachable yaw arc and will be skipped";
|
||
return true;
|
||
}
|
||
|
||
static std::string panRangeText(const std::vector<double>& h) {
|
||
if (h.empty()) return "none";
|
||
std::ostringstream o;
|
||
o << h.front();
|
||
if (h.size() > 1) o << ".." << h.back();
|
||
o << " deg";
|
||
return o.str();
|
||
}
|
||
|
||
// `mode` - report; `mode auto|manual|pan <heading> <span> [<pitch>]` - switch.
|
||
void handleMode(const std::string& line) {
|
||
std::istringstream in(line);
|
||
std::vector<std::string> tok;
|
||
for (std::string w; in >> w;) tok.push_back(w);
|
||
const ModeRequest r = parseModeCommand(tok);
|
||
if (!r.valid) {
|
||
LOG_WARN << r.error;
|
||
return;
|
||
}
|
||
if (!r.query) {
|
||
setMode(r.mode, r.pan);
|
||
return;
|
||
}
|
||
const Mode m = scheduler->mode();
|
||
if (m == Mode::Pan) {
|
||
const PanSpec p = scheduler->pan();
|
||
const auto& h = scheduler->panHeadingsNow();
|
||
LOG_INFO << "mode pan: heading " << p.heading << ", span " << p.span << ", pitch "
|
||
<< p.pitch << " deg - " << h.size()
|
||
<< (h.size() == 1 ? " position" : " positions") << " (" << panRangeText(h)
|
||
<< "), at " << (scheduler->panIndex() + 1) << "/" << h.size();
|
||
} else {
|
||
LOG_INFO << "mode " << modeName(m);
|
||
}
|
||
}
|
||
|
||
// `gimbal heading fit <counts>:<deg> ...` / `gimbal heading offset <deg>`.
|
||
//
|
||
// Heading setup is four commands, so it works the same from the dashboard,
|
||
// the console and MQTT: `setup` claims the gimbal (no scan can start under
|
||
// it), `mark <bearing>` records where the gimbal points now against the true
|
||
// bearing of the landmark it is aimed at, `fit` fits the marks, `cancel`
|
||
// drops them. `fit <counts>:<deg> ...` skips the marking, for an operator who
|
||
// already knows the pairs.
|
||
void handleHeading(const std::vector<std::string>& tok) {
|
||
const std::string what = tok.size() > 2 ? lowerStr(tok[2]) : "";
|
||
if (what == "setup") {
|
||
if (procedureBusy()) {
|
||
LOG_WARN << "gimbal heading setup: " << procedureName() << " in progress";
|
||
return;
|
||
}
|
||
if (!requireManual("gimbal heading setup")) return;
|
||
heading_setup_active_ = true;
|
||
heading_marks_.clear();
|
||
LOG_INFO << "heading setup: aim at a landmark whose true bearing you know, then "
|
||
"'gimbal heading mark <bearing>'. Two landmarks minimum, further "
|
||
"apart is better; 'gimbal heading fit' when done.";
|
||
return;
|
||
}
|
||
if (what == "cancel") {
|
||
if (heading_setup_active_) LOG_INFO << "heading setup cancelled";
|
||
heading_setup_active_ = false;
|
||
heading_marks_.clear();
|
||
return;
|
||
}
|
||
if (what == "mark") {
|
||
if (!heading_setup_active_) {
|
||
LOG_WARN << "gimbal heading mark: no heading setup running "
|
||
"('gimbal heading setup' first)";
|
||
return;
|
||
}
|
||
double bearing = 0;
|
||
size_t used = 0;
|
||
try {
|
||
if (tok.size() != 4) throw std::invalid_argument("count");
|
||
bearing = std::stod(tok[3], &used);
|
||
if (used != tok[3].size() || !std::isfinite(bearing))
|
||
throw std::invalid_argument("trailing");
|
||
} catch (...) {
|
||
LOG_WARN << "usage: gimbal heading mark <true bearing of the landmark, deg>";
|
||
return;
|
||
}
|
||
// Where the gimbal points now: the operator aimed, then typed.
|
||
const long counts = motor->telemetry().yaw.xenc;
|
||
heading_marks_.push_back({counts, wrap360(bearing)});
|
||
LOG_INFO << "heading mark " << heading_marks_.size() << ": " << counts
|
||
<< " counts -> " << wrap360(bearing) << " deg true";
|
||
return;
|
||
}
|
||
if (what == "offset") {
|
||
double off = 0;
|
||
if (tok.size() < 4) { LOG_WARN << "usage: gimbal heading offset <deg>"; return; }
|
||
try { off = std::stod(tok[3]); } catch (...) {
|
||
LOG_WARN << "gimbal heading offset: bad number '" << tok[3] << "'";
|
||
return;
|
||
}
|
||
AxisMap& y = cfg.geometry.yaw;
|
||
const double old = y.deg_at_center;
|
||
y.deg_at_center = wrap360(old + off);
|
||
applyYawMapChange();
|
||
LOG_INFO << "yaw heading at centre " << old << " -> " << y.deg_at_center
|
||
<< " deg true (offset " << off << ")";
|
||
return;
|
||
}
|
||
if (what != "fit") {
|
||
LOG_WARN << "usage: gimbal heading <setup|mark <deg>|fit [<counts>:<deg> ...]|"
|
||
"cancel|offset <deg>>";
|
||
return;
|
||
}
|
||
|
||
std::vector<HeadingSighting> sightings;
|
||
if (tok.size() == 3) { // bare `fit`: the marks of this setup
|
||
if (!heading_setup_active_) {
|
||
LOG_WARN << "usage: gimbal heading fit <counts>:<deg> <counts>:<deg> [...] "
|
||
"(or mark landmarks in 'gimbal heading setup' first)";
|
||
return;
|
||
}
|
||
if (heading_marks_.size() < 2) {
|
||
LOG_WARN << "gimbal heading fit: " << heading_marks_.size()
|
||
<< " mark(s); at least two landmarks are needed";
|
||
return;
|
||
}
|
||
sightings = heading_marks_;
|
||
} else {
|
||
std::string err;
|
||
if (!parseHeadingSightings({tok.begin() + 3, tok.end()}, sightings, err)) {
|
||
LOG_WARN << "gimbal heading fit: " << err;
|
||
return;
|
||
}
|
||
}
|
||
const HeadingFitResult r = fitHeading(sightings, cfg.geometry.yaw.center_count);
|
||
if (!r.ok) {
|
||
LOG_WARN << "gimbal heading fit: " << r.error;
|
||
return;
|
||
}
|
||
|
||
AxisMap& y = cfg.geometry.yaw;
|
||
LOG_INFO << "yaw heading fit over " << r.n << " sightings spanning " << r.span_deg
|
||
<< " deg: counts_per_deg " << y.counts_per_deg << " -> " << r.counts_per_deg
|
||
<< ", heading at centre " << y.deg_at_center << " -> " << r.deg_at_center
|
||
<< " deg true (R^2=" << r.r2 << ")";
|
||
y.counts_per_deg = r.counts_per_deg;
|
||
y.deg_at_center = r.deg_at_center;
|
||
applyYawMapChange();
|
||
|
||
// The landmark wizard and a bare `gimbal heading fit` land here alike; name
|
||
// whichever it was before the flag is cleared.
|
||
calib_save_source_ = heading_setup_active_ ? "gimbal heading setup"
|
||
: "gimbal heading fit";
|
||
heading_setup_active_ = false;
|
||
heading_marks_.clear();
|
||
// Offer to persist, through the same activity-strip prompt the pitch setup
|
||
// uses - the operator answers y/n without leaving the dashboard.
|
||
if (!opts.config_path.empty()) calib_save_pending_ = true;
|
||
}
|
||
|
||
// Re-derive everything that depends on the yaw map after it changes: the
|
||
// reachable degree window (which is in degrees, so it moves with the map) and
|
||
// the scheduler's copy used to target scan waypoints.
|
||
void applyYawMapChange() {
|
||
applyHomedTravel();
|
||
if (scheduler) scheduler->setGeometry(cfg.geometry);
|
||
}
|
||
|
||
// Capture one image now, outside any scan: the operator's reference shot while
|
||
// aiming by hand. It goes through the scheduler rather than poking the camera
|
||
// directly so it produces exactly what a scan waypoint produces - same file,
|
||
// same CamEvent on MQTT.
|
||
void takeSnapshot() {
|
||
if (!requireManual("snapshot")) return;
|
||
if (!camera || !scheduler) { LOG_WARN << "snapshot: no camera configured"; return; }
|
||
MotorTelemetry t = motor->telemetry();
|
||
if (!t.yaw.ready() || (t.pitch_present && !t.pitch.ready())) {
|
||
LOG_WARN << "snapshot: gimbal not homed (press h / run 'gimbal home' first)";
|
||
return;
|
||
}
|
||
// The camera is stopped outside a scan; leave it running afterwards so a
|
||
// burst of snapshots does not pay the start-up cost each time. setMode()
|
||
// and shutdown stop it as before.
|
||
camera->start();
|
||
scheduler->requestSnapshot();
|
||
LOG_INFO << "snapshot requested";
|
||
}
|
||
|
||
// Hand aiming and a scan are mutually exclusive: both drive the same axes,
|
||
// and a nudge mid-sweep would silently corrupt the waypoint the next frame is
|
||
// published under. The mode gate in handleCommand() already refuses these
|
||
// outside manual; the guard stays so each command is safe on its own.
|
||
bool manualMode() const { return !scheduler || scheduler->mode() == Mode::Manual; }
|
||
|
||
// Guard for the hand-aiming commands. Returns false (having said why) when a
|
||
// scan owns the gimbal.
|
||
bool requireManual(const char* what) {
|
||
if (manualMode()) return true;
|
||
LOG_WARN << "ignored '" << what << "': the tower is in " << modeName(scheduler->mode())
|
||
<< " mode (press m / type 'mode manual' first)";
|
||
return false;
|
||
}
|
||
|
||
// `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);
|
||
}
|
||
|
||
// Cross-check the degree scale against the travel homing just measured, and
|
||
// bootstrap it when it is obviously wrong.
|
||
//
|
||
// Called ONLY from the homing path. applyHomedTravel() runs after every
|
||
// heading fit and offset too, and deriving there would overwrite a landmark
|
||
// calibration with this rougher figure on every tweak.
|
||
void deriveCountsPerDeg(const DumpData& dd) {
|
||
struct AxisRef {
|
||
const char* name;
|
||
char axis;
|
||
const AxisDrivetrainConfig& drive;
|
||
AxisMap& map;
|
||
};
|
||
AxisRef axes[] = {
|
||
{"yaw", 'Y', cfg.drivetrain.yaw, cfg.geometry.yaw},
|
||
{"pitch", 'P', cfg.drivetrain.pitch, cfg.geometry.pitch},
|
||
};
|
||
|
||
bool changed = false;
|
||
for (auto& a : axes) {
|
||
if (a.drive.travel_deg <= 0.0) continue; // nothing declared to divide by
|
||
// Measured travel from THIS dump. Taking it from motor->lastDump()
|
||
// instead would read whatever dump happened to be cached - which,
|
||
// before applyHomedTravel() fetches a fresh one, is the pre-homing
|
||
// dump whose limits are still zero, so the derivation would skip in
|
||
// silence on every single home.
|
||
long counts = 0;
|
||
for (const auto& ax : dd.axes)
|
||
if (ax.axis == a.axis) counts = std::labs(ax.lim_pos - ax.lim_neg);
|
||
if (counts <= 0) continue; // that axis did not home
|
||
|
||
// The angle is unsigned; which way the axis counts is a property of
|
||
// the map, so carry the existing sign rather than inventing one.
|
||
const double derived =
|
||
std::copysign(counts / a.drive.travel_deg, a.map.counts_per_deg);
|
||
const double current = a.map.counts_per_deg;
|
||
const double ratio =
|
||
std::fabs(current) > 1e-9 ? std::fabs(derived / current) : 1e9;
|
||
|
||
// Only bootstrap a scale that is obviously wrong. Within a factor of
|
||
// two this is a health check, not a correction: a landmark fit is a
|
||
// better measurement than this one and must not be clobbered by a
|
||
// routine re-home.
|
||
if (ratio > 2.0 || ratio < 0.5) {
|
||
LOG_WARN << a.name << " counts_per_deg derived from homing: " << derived
|
||
<< " (was " << current << ") = " << counts << " counts / "
|
||
<< a.drive.travel_deg << " deg of travel declared in hardware.ini ["
|
||
<< "Motor." << a.name << "] travel_deg. That angle is a NAMEPLATE "
|
||
"figure: every bearing carries its error until it is measured. "
|
||
"Run 'gimbal heading setup' to supersede this with a landmark fit.";
|
||
a.map.counts_per_deg = derived;
|
||
changed = true;
|
||
} else {
|
||
LOG_INFO << a.name << " counts_per_deg " << current << " agrees with the homed "
|
||
<< "travel (" << counts << " counts / " << a.drive.travel_deg
|
||
<< " deg = " << derived << "); leaving the calibration alone";
|
||
}
|
||
}
|
||
|
||
if (changed && !opts.config_path.empty()) {
|
||
calib_save_source_ = "homed travel / hardware.ini travel_deg (nameplate)";
|
||
calib_save_pending_ = true;
|
||
}
|
||
}
|
||
|
||
// 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|jog|heading|pitch|home|stop|reset|"
|
||
"enable|disable|speed|current|setpos|status|dump|raw> "
|
||
"(self-test: 'test')";
|
||
return;
|
||
}
|
||
std::string sub = tok[1];
|
||
for (char& ch : sub) ch = static_cast<char>(std::tolower((unsigned char)ch));
|
||
|
||
// Hand aiming is manual-mode only: these fight a running scan for the
|
||
// same axes, and a move mid-sweep mislabels the next published frame.
|
||
if (sub == "move" || sub == "steps" || sub == "nudge" || sub == "jog") {
|
||
if (!requireManual(("gimbal " + sub).c_str())) return;
|
||
}
|
||
|
||
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 == "jog") {
|
||
// Relative move in raw counts. `nudge` is a percent of travel and
|
||
// `steps` is absolute; neither can express the sub-degree steps the
|
||
// heading wizard needs, and counts are what the encoder resolves.
|
||
if (tok.size() < 4) { LOG_WARN << "usage: gimbal jog <yaw|pitch> <+/-counts>"; return; }
|
||
char axis = (tok[2][0] == 'p' || tok[2][0] == 'P') ? 'P' : 'Y';
|
||
long delta = 0;
|
||
try { delta = std::stol(tok[3]); } catch (...) { LOG_WARN << "gimbal jog: bad count"; return; }
|
||
MotorTelemetry tel = motor->telemetry();
|
||
long cur = (axis == 'Y') ? tel.yaw.xenc : tel.pitch.xenc;
|
||
long target = cur + delta;
|
||
LOG_INFO << "gimbal jog " << axis << " " << delta << " counts -> MOVE " << axis
|
||
<< " " << target;
|
||
motor->sendCommand(std::string("MOVE ") + axis + " " + std::to_string(target));
|
||
} else if (sub == "heading") {
|
||
handleHeading(tok);
|
||
} else if (sub == "pitch") {
|
||
if (tok.size() > 2 && lowerStr(tok[2]) == "setup") startPitchSetup();
|
||
else LOG_WARN << "usage: gimbal pitch setup";
|
||
} 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" || sub == "current") {
|
||
// 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];
|
||
// A bare `gimbal stop` means every axis. The firmware wants the axis
|
||
// named, and answered a bare STOP with "ERR missing argument" - so the
|
||
// one command that has to work, did nothing.
|
||
if (sub == "stop" && tok.size() == 2) fw = "STOP ALL";
|
||
motor->sendCommand(fw);
|
||
} else {
|
||
LOG_WARN << "unknown gimbal subcommand: " << sub;
|
||
}
|
||
}
|
||
|
||
void startPitchSetup() {
|
||
// Pitch is the one axis that genuinely needs the IMU: level is defined by
|
||
// gravity, and nothing else on the tower can measure it. Yaw does not -
|
||
// `gimbal heading setup` uses landmarks instead.
|
||
if (!imu) {
|
||
LOG_WARN << "gimbal pitch setup requires the IMU (declare [Hardware] imu); "
|
||
"yaw does not - use 'gimbal heading setup'";
|
||
return;
|
||
}
|
||
if (calib && calib->running()) {
|
||
LOG_WARN << "pitch setup already running (press Esc, or type 'cancel', to cancel)";
|
||
return;
|
||
}
|
||
if (heading_setup_active_) {
|
||
LOG_WARN << "pitch setup: heading setup in progress ('gimbal heading cancel' first)";
|
||
return;
|
||
}
|
||
setMode(Mode::Manual); // free the serial link + don't fight the routine's moves
|
||
calib = std::make_unique<PitchSetupRoutine>(*motor, *imu, cfg.geometry);
|
||
calib->start();
|
||
}
|
||
|
||
// Persist the live (just-calibrated) [Motor] geometry back into config.ini.
|
||
// Superseded values are kept above their key as `;prev` comments, stamped with
|
||
// when and what replaced them, so a calibration that turns out worse than the
|
||
// one it replaced can be reverted by uncommenting.
|
||
void saveCalibration() {
|
||
if (!calib_save_pending_) return;
|
||
calib_save_pending_ = false;
|
||
const std::string provenance =
|
||
utcStampNow() + ", " +
|
||
(calib_save_source_.empty() ? std::string("calibration") : calib_save_source_);
|
||
if (saveMotorGeometry(opts.config_path, cfg.geometry, cfg.yaw_soft_margin_counts,
|
||
cfg.pitch_soft_margin_counts, provenance))
|
||
LOG_INFO << "[Motor] calibration saved as the new default in " << opts.config_path
|
||
<< " (previous values kept as ';prev' comments)";
|
||
else
|
||
LOG_WARN << "could not write [Motor] calibration to " << opts.config_path;
|
||
}
|
||
|
||
void cancelPitchSetup() {
|
||
if (calib && calib->running()) {
|
||
LOG_INFO << "cancelling pitch setup";
|
||
calib->cancel();
|
||
motor->sendCommand("STOP ALL");
|
||
}
|
||
}
|
||
|
||
// ---- Procedure lock-out (test / pitch setup / homing are mutually exclusive) ----
|
||
enum class Procedure { None, Homing, PitchSetup, Test };
|
||
Procedure activeProcedure() const {
|
||
if (tester && tester->running()) return Procedure::Test;
|
||
if (calib && calib->running()) return Procedure::PitchSetup;
|
||
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::PitchSetup: return "pitch setup";
|
||
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::PitchSetup:
|
||
cancelPitchSetup();
|
||
break;
|
||
case Procedure::Homing:
|
||
LOG_INFO << "cancelling homing";
|
||
cancel_procedure_ = true;
|
||
motor->sendCommand("STOP ALL");
|
||
break;
|
||
case Procedure::None:
|
||
if (heading_setup_active_) {
|
||
LOG_INFO << "heading setup cancelled";
|
||
heading_setup_active_ = false;
|
||
heading_marks_.clear();
|
||
} else {
|
||
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 == "mode" && c.device == "manual"))
|
||
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 (declare [Hardware] 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);
|
||
|
||
// Paused, not ended: a test run on a tower in service (install-tower
|
||
// --verify does exactly that) must not leave it idle afterwards.
|
||
// Typed tests only run in manual (allowedIn); a --test at startup may find
|
||
// a scan running, and gets it back afterwards.
|
||
resume_mode_after_test_ = scheduler->mode();
|
||
resume_pan_after_test_ = scheduler->pan();
|
||
if (resume_mode_after_test_ != Mode::Manual)
|
||
setMode(Mode::Manual); // free the serial link; don't fight the routine's moves
|
||
// Manual mode stops the camera's stream, and `test camera` counts the
|
||
// frames it delivers: without this, every test run in manual - which is
|
||
// every typed one - failed "the camera delivers whole frames" on a camera
|
||
// that was fine. Left running afterwards, as a snapshot leaves it.
|
||
if (camera) camera->start();
|
||
LOG_INFO << "starting hardware test (profile '" << profile.name << "')";
|
||
// What `test hardware` needs beyond the motor and IMU: the declaration to
|
||
// check against, and the subsystems that answer for themselves.
|
||
HardwareTestContext hw;
|
||
hw.declared = &cfg.hardware;
|
||
hw.drive = &cfg.drivetrain;
|
||
hw.env = env.get();
|
||
hw.camera = camera.get();
|
||
for (const auto* c : cfg.camera.active()) {
|
||
hw.configured_cameras.push_back(c->name);
|
||
hw.frame_timeout_ms.push_back(c->timingFor(c->profile).frame_timeout_ms);
|
||
}
|
||
hw.i2c_device = cfg.env.i2c_device;
|
||
hw.geo_yaw = cfg.geometry.yaw;
|
||
hw.geo_pitch = cfg.geometry.pitch;
|
||
hw.usbfs_min_mb = cfg.test.usbfs_min_mb;
|
||
hw.lpm_path = cfg.test.usb3_lpm_path;
|
||
|
||
tester = std::make_unique<TestRunner>(*motor, imu.get(), cfg.geometry, mask, profile,
|
||
cfg.paths.output_dir, std::move(hw));
|
||
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->deg_at_center = ax.deg_at_center;
|
||
dst->r2 = ax.r2;
|
||
dst->n = ax.n;
|
||
char b[96];
|
||
std::snprintf(b, sizeof(b), "%s cpd=%.3f deg@ctr=%.3f R2=%.4f (n=%d)%s",
|
||
ax.axis == 'P' ? "PITCH" : "YAW", ax.counts_per_deg,
|
||
ax.deg_at_center, 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).
|
||
calib_save_source_ = "gimbal pitch setup";
|
||
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;
|
||
if (!opts.run_test.empty()) {
|
||
exit_code_ = r.all_pass ? 0 : kExitTestFail;
|
||
running = false;
|
||
}
|
||
if (resume_mode_after_test_ != Mode::Manual) {
|
||
LOG_INFO << "resuming " << modeName(resume_mode_after_test_)
|
||
<< " mode, paused for the test";
|
||
setMode(resume_mode_after_test_, resume_pan_after_test_);
|
||
resume_mode_after_test_ = Mode::Manual;
|
||
}
|
||
}
|
||
}
|
||
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 == "mode" || verb == "test" ||
|
||
verb == "snapshot" || verb == "camera";
|
||
}
|
||
|
||
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, `cancel`, or `mode manual` - the m key - cancels the
|
||
// procedure instead: procedures run in manual, so that is what m means).
|
||
if (procedureBusy() && c.verb == "mode" && c.device == "manual") {
|
||
cancelActiveProcedure();
|
||
return;
|
||
}
|
||
if (procedureBusy() && interferingVerb(c.verb)) {
|
||
LOG_WARN << "ignored '" << c.verb << "': " << procedureName()
|
||
<< " in progress (Esc to cancel)";
|
||
return;
|
||
}
|
||
|
||
// Mode gate: in auto and pan only what cannot move the gimbal or change
|
||
// a setting runs (OperatingMode.h). The same rule for every source - the
|
||
// console, the dashboard's keys and MQTT all arrive here.
|
||
if (scheduler && !allowedIn(scheduler->mode(), line)) {
|
||
LOG_WARN << "ignored '" << line << "': the tower is in "
|
||
<< modeName(scheduler->mode()) << " mode ('mode manual' first)";
|
||
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 == "imu") {
|
||
handleImu(line);
|
||
} else if (c.verb == "calib") {
|
||
// Answer to the "save to config?" prompt (activity-strip y/n keys).
|
||
// Shared by `gimbal pitch setup` and `gimbal heading fit`: both change
|
||
// the [Motor] map and both offer the same choice.
|
||
if (c.device == "save") saveCalibration();
|
||
else if (c.device == "discard") {
|
||
calib_save_pending_ = false;
|
||
LOG_INFO << "kept for this session only (not saved to config)";
|
||
}
|
||
} else if (c.verb == "exit") {
|
||
running = false;
|
||
} else if (c.verb == "camera") {
|
||
if (c.device == "reset") resetCameras();
|
||
else LOG_WARN << "usage: camera reset";
|
||
} else if (c.verb == "snapshot") {
|
||
takeSnapshot();
|
||
} else if (c.verb == "mode") {
|
||
handleMode(line);
|
||
} 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 == "trace") {
|
||
handleTrace(c);
|
||
} else if (c.verb == "set") {
|
||
if (c.device == "camera") {
|
||
// "set camera profile night" arrives as option="profile night":
|
||
// the parser joins non-numeric trailing tokens into `option`. With
|
||
// two words after "profile" the first names the camera.
|
||
if (c.option.rfind("profile ", 0) == 0) {
|
||
const std::string arg = c.option.substr(8);
|
||
const auto sp = arg.find(' ');
|
||
if (sp != std::string::npos)
|
||
applyProfile(arg.substr(0, sp), arg.substr(sp + 1));
|
||
else if (camera_profiles.size() == 1)
|
||
applyProfile(camera_profiles.begin()->first, arg);
|
||
else
|
||
LOG_WARN << "more than one camera configured: "
|
||
"usage: set camera profile <camera> <name>";
|
||
} else if (c.option == "profile") {
|
||
LOG_WARN << "usage: set camera profile [<camera>] <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") {
|
||
const bool on = c.value != 0.0;
|
||
pipeline->setDisplay(on);
|
||
// Confirm it: the preview only repaints when a frame goes
|
||
// through the pipeline, so with no capture running there is
|
||
// nothing on screen and silence is indistinguishable from the
|
||
// command having been rejected.
|
||
LOG_INFO << "local display " << (on ? "on" : "off")
|
||
<< " (updates on the next captured frame)";
|
||
}
|
||
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;
|
||
}
|
||
}
|
||
|
||
// --test: start the requested test once the firmware is talking AND has been
|
||
// told its drivetrain, then let pollBackgroundResults() end the run with the
|
||
// verdict.
|
||
//
|
||
// Not immediately at startup: the firmware's NEED is answered from inside the
|
||
// control loop, so a DUMP taken on the first tick can catch an axis still in
|
||
// NEEDCFG and fail `test hardware motor` on values nobody had sent yet - a
|
||
// false alarm that would teach people to ignore the test. A deadline stops a
|
||
// dead link from waiting forever; the test then runs anyway and reports the
|
||
// real failure rather than this function inventing one.
|
||
void driveOneShotTest() {
|
||
if (opts.run_test.empty() || oneshot_started_) return;
|
||
using namespace std::chrono;
|
||
const auto now = steady_clock::now();
|
||
const MotorTelemetry tel = motor->telemetry();
|
||
auto axisSettled = [](AxisState a) {
|
||
return a == AxisState::Reset || a == AxisState::Ready || a == AxisState::Error;
|
||
};
|
||
// Pitch counts only where it is both expected and reporting: a yaw-only
|
||
// firmware has no P segment to wait for.
|
||
const AxisState st = tel.yaw.state;
|
||
const bool settled =
|
||
axisSettled(st) && (!cfg.hardware.hasAxis("pitch") || !tel.pitch_present ||
|
||
axisSettled(tel.pitch.state));
|
||
if (!settled) oneshot_link_ok_ = {};
|
||
else if (oneshot_link_ok_ == steady_clock::time_point{}) oneshot_link_ok_ = now;
|
||
|
||
const bool grace_over = settled && now - oneshot_link_ok_ >= seconds(1);
|
||
const bool timed_out = now - oneshot_since_ >= seconds(20);
|
||
if (!grace_over && !timed_out) return;
|
||
if (!grace_over)
|
||
LOG_WARN << "--test: the motor link did not settle within 20 s (an axis is still "
|
||
"booting, homing or waiting for its settings); running the test anyway";
|
||
|
||
oneshot_started_ = true;
|
||
handleTest("test " + opts.run_test);
|
||
if (!tester) {
|
||
LOG_ERROR << "--test: '" << opts.run_test << "' did not start (see above)";
|
||
exit_code_ = kExitTestNotRun;
|
||
running = false;
|
||
}
|
||
}
|
||
|
||
// Commands that arrived over MQTT: run exactly as typed ones, answered on
|
||
// CommandReply. After the typed ones in the same tick, one at a time and each
|
||
// answered before the next runs - so an `exit` is still answered. A command
|
||
// whose answer the firmware sends later holds the queue until it arrives.
|
||
void drainRemoteCommands() {
|
||
const long long now = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||
std::chrono::steady_clock::now().time_since_epoch()).count();
|
||
if (remote_.waiting()) {
|
||
auto reply = remote_.poll(now);
|
||
if (!reply) return;
|
||
channel->publishCommandReply(*reply);
|
||
}
|
||
for (auto& rc : channel->takeCommands()) remote_queue_.push_back(std::move(rc));
|
||
while (!remote_queue_.empty() && running) {
|
||
const RemoteCommand rc = std::move(remote_queue_.front());
|
||
remote_queue_.pop_front();
|
||
const std::optional<Await> await = remoteAwait(rc.cmd);
|
||
auto reply = remote_.run(
|
||
rc, [this](const std::string& line) { handleCommand(line); },
|
||
await ? &*await : nullptr, now);
|
||
if (!reply) return; // waiting; poll() answers it on a later tick
|
||
channel->publishCommandReply(*reply);
|
||
}
|
||
}
|
||
|
||
// The firmware answers these on the serial thread after the command has
|
||
// returned; the console shows that answer when it lands, so the remote reply
|
||
// waits for it.
|
||
static std::optional<Await> remoteAwait(const std::string& line) {
|
||
std::istringstream in(line);
|
||
std::string a, b;
|
||
in >> a >> b;
|
||
if (lowerStr(a) == "gimbal" && lowerStr(b) == "dump")
|
||
return Await{{"firmware dump", "Motor controller:"},
|
||
{"firmware dump:", "firmware dump still incomplete"},
|
||
5000};
|
||
return std::nullopt;
|
||
}
|
||
|
||
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());
|
||
|
||
// Before makeCamera(): the profile is applied at start(). Skipped entirely
|
||
// for a mock or disabled camera - a settings XML configures a physical
|
||
// sensor and nothing else, so requiring one would block bringing a tower
|
||
// up in phases (docs/tower-installation.md section 10).
|
||
if (realCameraWanted()) {
|
||
loadProfiles();
|
||
} else {
|
||
LOG_INFO << "camera profiles not loaded: no real camera in this run";
|
||
}
|
||
|
||
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.tower = cfg.general.tower_name;
|
||
pp.demo = opts.demo;
|
||
// In open order, matching Frame::cam_id: the source opens the active
|
||
// cameras in this order too.
|
||
for (const auto* c : cfg.camera.active())
|
||
pp.cameras.push_back({c->label, c->rotation_deg, c->jxl_distance, c->jxl_effort});
|
||
// Orientation supplier, called by ImagePipeline::submit() as each frame
|
||
// arrives. The two axes come from different sources on purpose:
|
||
//
|
||
// yaw - the encoder, as a true compass bearing in [0,360). IMU yaw
|
||
// drifts and depends on the heading reset and the XKF profile,
|
||
// whereas encoder yaw is anchored to the homed centre and tied to
|
||
// north by `gimbal heading setup`, and then stays put.
|
||
// pitch - the IMU. The encoder only measures the axis relative to the
|
||
// gimbal's own base, so a leaning mast is invisible to it; the
|
||
// IMU measures against gravity, which is the true elevation the
|
||
// imagery needs to be georeferenced against.
|
||
pipeline = std::make_unique<ImagePipeline>(
|
||
*channel,
|
||
[this] {
|
||
MotorTelemetry t = motor->telemetry();
|
||
Orientation o{static_cast<float>(cfg.geometry.yaw.headingDeg(t.yaw.xenc)),
|
||
static_cast<float>(cfg.geometry.pitch.toDeg(t.pitch.xenc))};
|
||
if (!imu) return o; // IMU disabled in config: encoder pitch, as before
|
||
if (auto s = imu->sample()) {
|
||
o.pitch_deg = s->pitch_deg;
|
||
imu_pitch_warned_ = false;
|
||
} else if (!imu_pitch_warned_) {
|
||
// Falling back silently would put two different measurements -
|
||
// true elevation and an axis angle - into one archive field
|
||
// with nothing recording which is which. Say it once per outage.
|
||
imu_pitch_warned_ = true;
|
||
LOG_WARN << "IMU has no fresh sample; publishing encoder pitch instead "
|
||
"(CamEvent 'pit' is the axis angle, not true elevation)";
|
||
}
|
||
return o;
|
||
},
|
||
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, cfg.image_rate(),
|
||
cfg.geometry, grid);
|
||
scheduler->setPanStep(cfg.hardware.pan_step_deg);
|
||
// Every camera has to have finished by the time the gimbal moves on, so
|
||
// the wait is the longest across them, not the first one's.
|
||
scheduler->setSettleWait(cfg.camera.maxSettleWaitMs(), cfg.camera.maxFrameTimeoutMs());
|
||
|
||
// Before the firmware is talked to: the registers are wanted by the very
|
||
// first sendAnchorConfig(), on both the --init and the no---init path.
|
||
planMotorCurrents();
|
||
motor->start();
|
||
if (imu) imu->start();
|
||
if (env) env->start();
|
||
camera->open();
|
||
|
||
// Everything is up, so "found" now means what it says. A tower that
|
||
// declares hardware it does not have stops here rather than quietly
|
||
// capturing a narrower panorama and reporting itself healthy.
|
||
if (!reconcileHardware()) {
|
||
camera->close();
|
||
if (imu) imu->stop();
|
||
if (env) env->stop();
|
||
motor->stop();
|
||
channel->disconnect();
|
||
return 3; // distinct from the config (1) and lock (2) exits
|
||
}
|
||
|
||
pipeline->start();
|
||
channel->publishStatus(static_cast<int>(Mode::Manual)); // until --start says otherwise
|
||
|
||
// 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 derives the travel window
|
||
} else {
|
||
// No homing this launch. The firmware keeps nothing across a power
|
||
// cycle, so this only finds limits when the gimbal was already homed
|
||
// earlier in this session (e.g. the host restarted, the board did not).
|
||
// Self-guards to a warning when there is no dump to read.
|
||
sendAnchorConfig();
|
||
applyHomedTravel();
|
||
}
|
||
if (opts.start) setMode(Mode::Auto);
|
||
|
||
// 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)";
|
||
oneshot_since_ = std::chrono::steady_clock::now();
|
||
while (running && !g_shutdown_requested.load()) {
|
||
drainCommands();
|
||
drainRemoteCommands();
|
||
driveOneShotTest();
|
||
answerFirmwareNeeds(); // the firmware asks; hardware.ini answers
|
||
pollBackgroundResults(); // apply calibration result / emit DIAG summary+log
|
||
scheduler->tick();
|
||
checkTrackingError(); // warn on live encoder-error excursions
|
||
checkFan(); // a legacy tower's fan, driven but not turning
|
||
publishSnapshot();
|
||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||
}
|
||
|
||
LOG_INFO << "Shutting down";
|
||
// First: leave the gimbal at rest, not finishing a move nobody will
|
||
// watch - and that the next start would find still under way. Closing
|
||
// the camera below takes seconds; the gimbal should not wait for it.
|
||
if (anyLeftoverMove()) {
|
||
LOG_INFO << "stopping the gimbal's move before exit";
|
||
motor->sendCommand("STOP ALL");
|
||
}
|
||
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(); // sends what is queued (the STOP above) before closing
|
||
channel->disconnect();
|
||
// A --test run interrupted before its verdict has no verdict to report.
|
||
if (!opts.run_test.empty() && running && exit_code_ == 0) exit_code_ = kExitTestNotRun;
|
||
return exit_code_;
|
||
}
|
||
};
|
||
|
||
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
|