#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/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/TestRunner.h" #include "fgc/mock/MockCameraSource.h" #include "fgc/mock/MockEnvSensor.h" #include "fgc/mock/MockImuSource.h" #include "fgc/mock/MockMotorController.h" #include "fgc/mock/NullControlChannel.h" #include "fgc/sensors/Sht41EnvSensor.h" #include "fgc/ui/HeadlessUi.h" #include "fgc/ui/IUserInterface.h" #include "fgc/ui/UiSnapshot.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #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 g_shutdown_requested{false}; void handleShutdownSignal(int) { g_shutdown_requested.store(true); } long long nowEpochMs() { return std::chrono::duration_cast( 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 char b[32]; 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 all[] = { {LogCat::Serial, "serial"}, {LogCat::Mqtt, "mqtt"}, {LogCat::Camera, "camera"}, {LogCat::Control, "control"}, }; for (const auto& [cat, name] : all) { if (mask & static_cast(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 channel; std::unique_ptr motor; std::unique_ptr 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> camera_profiles; std::map active_profile; std::unique_ptr imu; std::unique_ptr env; std::unique_ptr pipeline; std::unique_ptr scheduler; std::unique_ptr ui; std::unique_ptr calib; std::unique_ptr 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 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 last_calib_summary; long long last_calib_ts = 0; // epoch ms 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 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 homing_active_{false}; std::atomic 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 imu_pitch_warned_{false}; // The TUI heading wizard is aiming by hand; blocks a scan from starting. bool heading_setup_active_ = false; std::atomic running{true}; std::mutex cmd_mutex; std::queue 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 makeChannel() { bool want = opts.use_mqtt.value_or(cfg.features.enable_mqtt); #if FGC_WITH_MQTT if (want) { return std::make_unique(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(cfg.general.tower_name); } std::unique_ptr makeMotor() { bool mock = opts.mock_serial.value_or(cfg.features.mock_serial); if (mock) return std::make_unique(); return std::make_unique(cfg.serial.device, cfg.serial.baud); } std::unique_ptr makeImu() { if (!cfg.features.enable_imu) return nullptr; // sensors panel stays "pending" std::unique_ptr src; if (cfg.features.mock_imu) { src = std::make_unique(); } else if (cfg.imu.device.empty()) { LOG_WARN << "IMU enabled but [IMU] device is empty; disabling IMU"; return nullptr; } else { src = std::make_unique(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(std::move(src), ref, cfg.imu.mount); } std::unique_ptr 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(std::chrono::milliseconds(cfg.env.period_ms)); if (cfg.env.i2c_device.empty()) { LOG_WARN << "Env sensor enabled but [Env] i2c_device is empty; disabling"; return nullptr; } return std::make_unique(cfg.env.i2c_device, static_cast(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 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( active.empty() ? 1 : static_cast(active.size())); } #if FGC_WITH_VIMBA std::vector specs; std::vector 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(std::move(specs), std::move(profiles)); #else return std::make_unique(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.] block)"); for (const auto& c : cfg.camera.cameras) { if (!c.enabled) continue; std::map 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(p.type)) + ")"; if (cfg_opt->has_scenario && p.type == cfg_opt->scenario_type) out += " *"; } return out; } // `imu profile []`. 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 "; 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 'i' 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(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 tok; std::string t; while (iss >> t) tok.push_back(t); // tok[0] == "imu" if (tok.size() < 2) { LOG_WARN << "usage: imu "; return; } if (!imu) { LOG_WARN << "no IMU (needs [Features] enable_imu, or mock_imu)"; return; } std::string sub = tok[1]; for (char& ch : sub) ch = static_cast(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 []"; 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 "; } } std::unique_ptr makeUi() { bool want = opts.use_tui.value_or(cfg.ui.enable_tui); #if FGC_WITH_TUI if (want) return std::make_unique(); #else if (want) LOG_WARN << "TUI requested but binary built without TUI support; using headless console"; #endif return std::make_unique(); } // 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::steady_clock::now() - start_time).count(); s.header.live = !mock_serial; // --- Gimbal --- MotorTelemetry t = motor->telemetry(); s.gimbal.present = motor->connected(); s.gimbal.pitch_present = t.pitch_present; // 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 (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.control_code = scheduler ? scheduler->controlCode() : 0; s.conn.target_heading = scheduler ? scheduler->targetHeading() : "0"; s.conn.last_status_code = s.conn.control_code; // echoed back as status 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; s.diag = last_diag_view; s.test = last_test_view; fillActivity(s); return s; } // The currently-running special op (priority: calib > diag > homing > capture) // plus the most recent persisted result, for the activity strip. void fillActivity(UiSnapshot& s) const { ActivityView& a = s.activity; if (tester && tester->running()) { TestProgress p = tester->progress(); a.active = true; a.title = "TESTING"; a.status = p.section.empty() ? "running hardware self-test…" : p.section + " " + std::to_string(p.step) + "/" + std::to_string(p.total) + " — " + p.phase; } else if (calib && calib->running()) { CalibProgress p = calib->progress(); a.active = true; a.title = "CALIBRATING"; std::string axis = (p.axis == 'P') ? "PITCH" : (p.axis == 'Y') ? "YAW" : ""; a.status = axis.empty() ? p.phase : axis + " step " + std::to_string(p.step) + "/" + std::to_string(p.total) + " — " + p.phase; } else if (diag_running_) { a.active = true; a.title = "DIAGNOSTICS"; a.status = "running motor self-test…"; } else if (s.gimbal.yaw.state == AxisState::Homing || (s.gimbal.pitch_present && s.gimbal.pitch.state == AxisState::Homing)) { a.active = true; a.title = "HOMING"; a.status = "finding endstops…"; } else if (scheduler && scheduler->captureActive()) { a.active = true; a.title = "CAPTURE"; const char* phase = scheduler->moving() ? "moving" : "dwell"; if (scheduler->controlCode() == 1) { a.status = "directed → heading " + scheduler->targetHeading() + "° · " + (scheduler->moving() ? "moving" : "holding"); } else if (!grid.empty()) { const ScanPoint& p = grid.current(); std::string coord = "yaw " + formatDegrees(p.yaw_deg); if (s.capture.scan_pitch) coord += " / pitch " + formatDegrees(p.pitch_deg); a.status = "waypoint " + std::to_string(grid.index() + 1) + "/" + std::to_string(grid.size()) + " · " + coord + " · " + phase; } else { a.status = "no scan grid — auto-sweep disabled"; } } // A running procedure is cancelable with Esc. a.cancelable = procedureBusy(); // Persisted result: the most recent of last test / calibration / diagnostics. const long long now = nowEpochMs(); if (last_test_ts >= last_calib_ts && last_test_ts >= last_diag_ts && last_test_ts) { a.has_result = true; a.result_title = std::string("Test ") + (last_test_pass ? "PASS" : "FAIL") + " · " + formatTimeAgo(now, last_test_ts); a.result = last_test_summary; a.result_color = last_test_pass ? UiColor::Green : UiColor::Red; } else if (last_calib_ts || last_diag_ts) { const bool calib_newer = last_calib_ts >= last_diag_ts; a.has_result = true; if (calib_newer) { a.result_title = "Calibration · " + formatTimeAgo(now, last_calib_ts); a.result = last_calib_summary; a.result_color = last_calib_view.has && last_calib_view.yaw.ok ? UiColor::Green : UiColor::Yellow; } else { a.result_title = std::string("Diagnostics ") + (last_diag_pass ? "PASS" : "FAIL") + " · " + formatTimeAgo(now, last_diag_ts); a.result = last_diag_summary; a.result_color = last_diag_pass ? UiColor::Green : UiColor::Red; } } // Pending yes/no: persist the just-applied calibration as the new default? if (calib_save_pending_) a.prompt = "Save the new [Motor] calibration to config as the default? (y / n)"; } void publishSnapshot() { UiSnapshot s = buildSnapshot(); std::lock_guard lock(snapshot_mutex); latest_snapshot = std::move(s); } // Warn (once per excursion) when an axis's live encoder error |xactual-xenc| // crosses the configured limit, so positional slip is visible in the log // without watching the gimbal view. Hysteresis re-arms at 75% of the limit. void checkTrackingError() { const long limit = cfg.enc_error_warn_counts; if (limit <= 0) return; // disabled MotorTelemetry t = motor->telemetry(); auto check = [&](const char* axis, const AxisTelemetry& a, bool present, bool& over) { if (!present) return; const long err = std::labs(a.xactual - a.xenc); if (!over && err > limit) { over = true; LOG_WARN << "encoder tracking error on " << axis << ": " << (a.xactual - a.xenc) << " counts (limit " << limit << ")"; } else if (over && err < limit * 3 / 4) { over = false; } }; check("yaw", t.yaw, true, yaw_track_over_); check("pitch", t.pitch, t.pitch_present, pitch_track_over_); } void runInitSequence() { using namespace std::chrono_literals; LOG_INFO << "Running gimbal init sequence (enable + home)"; // Mark homing active so interfering commands are ignored and Esc cancels. // This blocks the control thread, so commands are drained+discarded here. homing_active_ = true; cancel_procedure_ = false; struct ClearHoming { std::atomic& flag; ~ClearHoming() { flag = false; } } clear_homing{homing_active_}; motor->sendCommand("ENABLE Y"); motor->sendCommand("ENABLE P"); // 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. sendAnchorConfig(); 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; const auto deadline = std::chrono::steady_clock::now() + 65s; while (running && !cancel_procedure_ && std::chrono::steady_clock::now() < deadline) { std::this_thread::sleep_for(250ms); drainAndDiscardCommands(); publishSnapshot(); // keep the TUI's gimbal panel live during homing MotorTelemetry t = motor->telemetry(); if (t.yaw.state == AxisState::Error || (t.pitch_present && t.pitch.state == AxisState::Error)) { LOG_WARN << "Homing reported ERROR state; check the gimbal"; break; } if (allReady(t)) { LOG_INFO << "Gimbal homed (both axes READY)"; homed = true; break; } } if (cancel_procedure_) { LOG_WARN << "Homing cancelled"; motor->sendCommand("STOP ALL"); return; } // Re-anchor the yaw zero to an endstop now that the homed limits are known. if (homed) applyHomedTravel(); // Production move speeds for subsequent MOVE commands. motor->sendCommand("SPEED Y 50000"); motor->sendCommand("SPEED P 150000"); LOG_INFO << "Gimbal init finished"; } // 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() { motor->sendCommand("CENTER Y " + std::to_string(cfg.geometry.yaw.center_count)); motor->sendCommand("CENTER P " + std::to_string(cfg.geometry.pitch.center_count)); motor->sendCommand("MARGIN Y " + std::to_string(cfg.yaw_soft_margin_counts)); motor->sendCommand("MARGIN P " + std::to_string(cfg.pitch_soft_margin_counts)); } // 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. void applyHomedTravel() { using namespace std::chrono_literals; // The homed limits live in the firmware DUMP; pull a fresh one and wait // briefly for it to arrive. motor->sendCommand("DUMP"); DumpData dd; const auto deadline = std::chrono::steady_clock::now() + 3s; while (running && std::chrono::steady_clock::now() < deadline) { std::this_thread::sleep_for(100ms); publishSnapshot(); dd = parseDump(motor->lastDump()); if (dd.valid) break; } if (!dd.valid) { LOG_WARN << "homed travel: no firmware dump; keeping the previous degree limits"; return; } 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; // 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"; } void startCapture() { // 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 << "Capture not started: heading setup in progress " "(finish it, or 'gimbal heading cancel')"; return; } // The scheduler arms regardless, but a scan can't progress until the axes // are homed: before READY the firmware leaves coils disabled and clamps // MOVE targets to a coarse bench guard, so no waypoint is ever reached and // the move/settle cycle stalls silently. Refuse with a clear message // instead. (Single-axis builds report no P: segment, so skip pitch.) MotorTelemetry t = motor->telemetry(); if (!t.yaw.ready() || (t.pitch_present && !t.pitch.ready())) { LOG_WARN << "Capture not started: gimbal not homed (run init/home first)"; return; } camera->start(); scheduler->setCaptureActive(true); LOG_INFO << "Capture started"; } void stopCapture() { camera->stop(); scheduler->setCaptureActive(false); LOG_INFO << "Capture stopped"; } // `gimbal heading fit : ...` / `gimbal heading offset `. // // `fit` is what the TUI wizard emits after the operator has sighted landmarks, // but it is a first-class command in its own right: on a headless tower an // operator who already knows two (encoder position, true bearing) pairs can // type it directly. void handleHeading(const std::vector& tok) { const std::string what = tok.size() > 2 ? lowerStr(tok[2]) : ""; if (what == "setup") { // The TUI drives the interaction (arrow keys and typed bearings exist // only there); this just claims the gimbal so a scan cannot start // underneath it and move the axis between a sighting and its bearing. if (procedureBusy()) { LOG_WARN << "gimbal heading setup: " << procedureName() << " in progress"; return; } if (!requireManual("gimbal heading setup")) return; heading_setup_active_ = true; LOG_INFO << "heading setup: aim at a landmark whose true bearing you know, " "Space to record an image, Enter to enter its bearing. Two " "landmarks minimum, further apart is better."; return; } if (what == "cancel") { if (heading_setup_active_) LOG_INFO << "heading setup cancelled"; heading_setup_active_ = false; return; } if (what == "offset") { double off = 0; if (tok.size() < 4) { LOG_WARN << "usage: gimbal heading offset "; 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 : ...|offset >"; return; } std::vector sightings; 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; // 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. stopCapture() // and shutdown stop it as before. camera->start(); scheduler->requestSnapshot(); LOG_INFO << "snapshot requested"; } // Manual mode is simply "no scan running" - derived, never a separate flag an // operator could strand. 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. bool manualMode() const { return !scheduler || !scheduler->captureActive(); } // 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 << "': a scan is running (press x / type 'stop' first)"; return false; } // `trace off|none`, `trace all`, or `trace [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(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 ` — 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 "," or " " (tokens from `from` onward) into two numbers. static bool parsePair(const std::vector& 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(iss >> a >> b); } // Homed physical travel (counts) for an axis: the endstop-to-endstop span from // the last firmware dump. Returns 0 if no dump has been captured yet (caller // should request one). Deliberately does NOT fall back to the configured degree // range × counts_per_deg — that conflates calibration with physical travel and, // when min/max are unset, yields an absurd span. long homedTravelCounts(char axis) { DumpData d = parseDump(motor->lastDump()); if (d.valid) for (const auto& ax : d.axes) if (ax.axis == axis) return std::labs(ax.lim_pos - ax.lim_neg); return 0; } // `gimbal ` — 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 tok; std::string t; while (iss >> t) tok.push_back(t); // tok[0] == "gimbal" if (tok.size() < 2) { LOG_WARN << "usage: gimbal (self-test: 'test')"; return; } std::string sub = tok[1]; for (char& ch : sub) ch = static_cast(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 ,"; 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 ,"; 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 <+/-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(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 <+/-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 \"\""; return; } LOG_INFO << "gimbal raw -> " << raw; motor->sendCommand(raw); } else if (sub == "home" && tok.size() <= 2) { // Full re-home: enable + HOME + wait for READY + re-anchor the yaw zero // (same path as startup --init), so the homing key behaves like a boot // home rather than a fire-and-forget HOME that skips the re-anchor. runInitSequence(); } else if (sub == "home" || sub == "stop" || sub == "reset" || sub == "enable" || sub == "disable" || sub == "speed" || sub == "setpos" || sub == "status") { // Passthrough to the (case-insensitive) firmware verb + args. std::string fw = sub; for (char& ch : fw) ch = static_cast(std::toupper((unsigned char)ch)); for (size_t i = 2; i < tok.size(); ++i) fw += " " + tok[i]; motor->sendCommand(fw); } else { LOG_WARN << "unknown gimbal subcommand: " << sub; } } void 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 (set [Features] enable_imu); " "yaw does not - use 'gimbal heading setup'"; return; } if (calib && calib->running()) { LOG_WARN << "pitch setup already running (press Esc, or type 'stop', to cancel)"; return; } if (heading_setup_active_) { LOG_WARN << "pitch setup: heading setup in progress ('gimbal heading cancel' first)"; return; } stopCapture(); // free the serial link + don't fight the routine's moves calib = std::make_unique(*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: 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 local; { std::lock_guard lock(cmd_mutex); std::swap(local, cmd_queue); } while (!local.empty()) { const std::string& line = local.front(); Command c = parseCommand(line); if (c.verb == "cancel" || c.verb == "stop") cancel_procedure_ = true; else if (!c.empty()) LOG_WARN << "ignored '" << c.verb << "': homing in progress (Esc to cancel)"; local.pop(); } } // ---- `test` command ---- static std::string lowerStr(std::string s) { for (char& ch : s) ch = static_cast(std::tolower((unsigned char)ch)); return s; } void handleTest(const std::string& line) { std::istringstream iss(line); std::vector 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 []] [] (also: test list, test baseline)"; return; } startTest(mask, profileName); } void startTest(uint32_t mask, const std::string& profileName) { if (procedureBusy()) { LOG_WARN << "test: " << procedureName() << " already in progress (Esc to cancel)"; return; } // IMU leaves need an IMU; drop them (with a warning) if absent. if ((mask & T_NEEDS_IMU) && !imu) { LOG_WARN << "test: IMU tests skipped (set [Features] enable_imu)"; mask &= ~T_NEEDS_IMU; } // Motor leaves need a homed gimbal. if (mask & T_NEEDS_MOTOR) { MotorTelemetry tel = motor->telemetry(); if (!tel.yaw.ready() && !(tel.pitch_present && tel.pitch.ready())) { LOG_WARN << "test: gimbal not READY (home first)"; return; } } if (mask == 0) { LOG_WARN << "test: nothing to run"; return; } TestProfile profile; if (const TestProfile* p = cfg.test.find(profileName)) profile = *p; else profile.name = profileName; // unknown name => built-in defaults test_drift_warn_ = profile.get("drift_warn_pct", 15.0); test_drift_gate_ = profile.getBool("drift_gates_pass", false); stopCapture(); // free the serial link; don't fight the routine's moves LOG_INFO << "starting hardware test (profile '" << profile.name << "')"; tester = std::make_unique(*motor, imu.get(), cfg.geometry, mask, profile, cfg.paths.output_dir); tester->start(); } void listTestProfiles() { LOG_INFO << "test profiles (default: " << cfg.test.default_profile << "):"; if (cfg.test.profiles.empty()) LOG_INFO << " (none configured; built-in defaults used)"; for (const auto& [name, p] : cfg.test.profiles) LOG_INFO << " " << name << " (" << p.params.size() << " params)"; } std::string baselinePath() const { return cfg.test.baseline_file.empty() ? (paths::defaultLogDir() + "/test_baseline.txt") : cfg.test.baseline_file; } void promoteBaseline() { if (last_test_report_path.empty()) { LOG_WARN << "test baseline: no test report from this session to promote"; return; } std::error_code ec; std::filesystem::copy_file(last_test_report_path, baselinePath(), std::filesystem::copy_options::overwrite_existing, ec); if (ec) LOG_WARN << "test baseline: copy failed: " << ec.message(); else LOG_INFO << "test baseline set from " << last_test_report_path << " -> " << baselinePath(); } // Read+parse a report file; returns valid()==false-ish empty report on failure. static bool loadReport(const std::string& path, TestReport& out) { std::ifstream f(path); if (!f) return false; std::ostringstream ss; ss << f.rdbuf(); out = parseTestReport(ss.str()); return !out.sections.empty(); } // Most recent prior "test_*.log" in the log dir (excluding `exclude`). std::string latestPriorTestReport(const std::string& exclude) const { namespace fs = std::filesystem; std::string best; std::error_code ec; const std::string dir = paths::defaultLogDir(); if (!fs::exists(dir, ec)) return best; for (auto& e : fs::directory_iterator(dir, ec)) { const std::string name = e.path().filename().string(); if (name.rfind("test_", 0) != 0 || e.path().extension() != ".log") continue; if (e.path().string() == exclude) continue; if (best.empty() || name > fs::path(best).filename().string()) best = e.path().string(); } return best; } // Pick up results produced by background work (calibration thread, DIAG // capture) — runs on the control thread each tick so all geometry/state // mutation stays single-threaded. void pollBackgroundResults() { if (calib) { if (auto g = calib->takeResult()) { cfg.geometry = *g; if (scheduler) scheduler->setGeometry(cfg.geometry); // also fix scan targeting LOG_INFO << "calibration applied to live geometry"; // Capture the structured report for the activity strip + gimbal view. CalibReport rep = calib->report(); CalibResultView v; v.has = rep.valid; v.ts_ms = rep.ts_ms; last_calib_summary.clear(); for (const auto& ax : rep.axes) { CalibAxisView* dst = (ax.axis == 'P') ? &v.pitch : &v.yaw; if (ax.axis == 'P') v.pitch_present = true; dst->ok = ax.ok; dst->counts_per_deg = ax.counts_per_deg; dst->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; } } unsigned seq = motor->diagSeq(); if (seq != last_diag_seq) { last_diag_seq = seq; diag_running_ = false; DiagResult dr = parseDiag(motor->lastDiag()); std::ostringstream summary; for (const auto& l : formatDiag(dr)) { LOG_INFO << l; summary << l << "\n"; } // Concise per-axis lines for the activity strip + the per-axis worst // tracking error for the gimbal view. Full detail is logged above. last_diag_summary.clear(); last_diag_view = DiagResultView{}; last_diag_view.has = dr.valid; for (const auto& ax : dr.axes) { DiagAxisView dv; dv.axis = ax.axis; dv.has = !ax.tests.empty(); dv.pass = ax.pass; dv.err_peak = dv.err_rms = dv.err_still = -1; // -1 stays if no encoder for (const auto& tst : ax.tests) { // worst (max) across the tests if (tst.err_peak < 0) continue; // no-encoder test: skip dv.err_peak = std::max(dv.err_peak, tst.err_peak); dv.err_rms = std::max(dv.err_rms, tst.err_rms); dv.err_still = std::max(dv.err_still, tst.err_still); } last_diag_view.axes.push_back(dv); std::string line = std::string(1, ax.axis) + ": " + (ax.pass ? "PASS" : "FAIL"); if (dv.has && dv.err_peak >= 0) // -1 == no encoder line += " peak " + std::to_string(dv.err_peak) + " still " + std::to_string(dv.err_still); line += " (" + std::to_string(ax.tests.size()) + " tests)"; last_diag_summary.push_back(line); } last_diag_pass = dr.allPass(); last_diag_ts = nowEpochMs(); last_diag_view.ts_ms = last_diag_ts; std::string path = paths::writeLogFile( paths::timestampedLogName("diag"), motor->lastDiag() + "\n--- parsed ---\n" + summary.str()); if (!path.empty()) LOG_INFO << "diagnostics log written: " << path; } // Safety: clear a stuck diag flag if the firmware never replied. if (diag_running_ && std::chrono::steady_clock::now() - diag_started_ > std::chrono::seconds(30)) diag_running_ = false; } // Commands that would disturb a running procedure (motion, capture, a second // long op, or a DUMP over the same serial link). Ignored while busy. static bool interferingVerb(const std::string& verb) { return verb == "gimbal" || verb == "start" || verb == "test" || verb == "refresh" || verb == "snapshot"; } void handleCommand(const std::string& line) { Command c = parseCommand(line); if (c.empty()) return; LOG_TRACE_CAT(LogCat::Control) << "cmd " << line; // Lock-out: while a test/calibration/homing runs, ignore interfering // commands (Esc, or `stop`/`cancel`, cancels the procedure instead). if (procedureBusy() && interferingVerb(c.verb)) { LOG_WARN << "ignored '" << c.verb << "': " << procedureName() << " in progress (Esc to cancel)"; return; } if (c.verb == "help") { // c.device holds the optional topic (first token after "help"). for (const auto& l : renderHelp(c.device)) LOG_INFO << l; } else if (c.verb == "test") { handleTest(line); } else if (c.verb == "cancel") { cancelActiveProcedure(); } else if (c.verb == "gimbal") { handleGimbal(line); } else if (c.verb == "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 == "snapshot") { takeSnapshot(); } else if (c.verb == "start") { startCapture(); } else if (c.verb == "stop") { // `stop`/`x` cancels any running procedure, else stops capture. if (procedureBusy()) cancelActiveProcedure(); else stopCapture(); } else if (c.verb == "debug") { bool on = Logger::level() != LogLevel::Debug; Logger::setLevel(on ? LogLevel::Debug : LogLevel::Info); LOG_INFO << "debug logging " << (on ? "on" : "off"); } else if (c.verb == "refresh") { // Re-read live device state surfaced in the expanded views: the IMU // configuration (e.g. after the XKF profile was changed externally) // and the firmware register dump shown in the gimbal 'g' view. LOG_INFO << "refresh: re-reading IMU configuration + firmware dump"; if (imu) { imu->refreshConfig(); imu_config_done_ = false; // force buildSnapshot to re-read config() } motor->sendCommand("DUMP"); } else if (c.verb == "trace") { handleTrace(c); } else if (c.verb == "set") { if (c.device == "camera") { // "set camera 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 "; } else if (c.option == "profile") { LOG_WARN << "usage: set camera profile [] "; } else if (c.option == "jxlq" && c.has_value) pipeline->setDistance(c.value); else if (c.option == "jxle" && c.has_value) pipeline->setEffort(static_cast(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; } } void drainCommands() { std::queue local; { std::lock_guard 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( *channel, [this] { MotorTelemetry t = motor->telemetry(); Orientation o{static_cast(cfg.geometry.yaw.headingDeg(t.yaw.xenc)), static_cast(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(*motor, *camera, *channel, cfg.image_rate(), cfg.geometry, grid); // 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()); motor->start(); if (imu) imu->start(); if (env) env->start(); camera->open(); pipeline->start(); channel->publishStatus(0); // Seed an initial snapshot so the UI's first frame is populated, then // start the interface (headless console or TUI) on its own thread. The // UI is a pure observer: it pulls snapshots and pushes command strings // into the same queue the console always used. // // The UI is brought up BEFORE the init/home sequence so the dashboard is // visible during homing (which can take many seconds) and so the serial // wire traces render in the TUI's log pane instead of scrolling raw — // makeUi()/start() installs the TUI log sink, so anything logged after // this point is captured by it. publishSnapshot(); ui = makeUi(); ui->start([this] { std::lock_guard lk(snapshot_mutex); return latest_snapshot; }, [this](const std::string& line) { std::lock_guard 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) startCapture(); // Clean up on Ctrl-C / kill / systemd stop, not just on "exit"/Ctrl-D - so the // camera is always stopped and closed (an unclean exit wedges it for next time). std::signal(SIGINT, handleShutdownSignal); std::signal(SIGTERM, handleShutdownSignal); LOG_INFO << "Entering control loop (type 'exit' to quit, or Ctrl-C)"; while (running && !g_shutdown_requested.load()) { drainCommands(); pollBackgroundResults(); // apply calibration result / emit DIAG summary+log scheduler->tick(); checkTrackingError(); // warn on live encoder-error excursions publishSnapshot(); std::this_thread::sleep_for(std::chrono::milliseconds(10)); } LOG_INFO << "Shutting down"; if (tester) tester->cancel(); // stop any in-flight self-test before teardown if (calib) calib->cancel(); // stop any in-flight calibration before teardown if (ui) ui->stop(); pipeline->stop(); camera->stop(); camera->close(); if (imu) imu->stop(); if (env) env->stop(); motor->stop(); channel->disconnect(); return 0; } }; Application::Application(AppConfig config, RuntimeOptions options) : impl_(std::make_unique(std::move(config), std::move(options))) {} Application::~Application() = default; int Application::run() { return impl_->run(); } } // namespace fgc