#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/SettleRule.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/HostMetrics.h" #include "fgc/ThermalGuard.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 #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "fgc/CompositeCameraSource.h" #include "fgc/TarisCameraSource.h" #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). // // Installed as the very first thing run() does, so a stop that arrives while the // camera opens or the gimbal homes is not the default action - which kills the // process on the spot and wedges the camera. A second signal of the same kind // goes back to that default: Ctrl-C twice still gets out of a start that hangs. std::atomic g_shutdown_requested{false}; void handleShutdownSignal(int sig) { if (g_shutdown_requested.exchange(true)) { std::signal(sig, SIG_DFL); std::raise(sig); } } 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 // 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 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 profiles, 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 // 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 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; long long fan_seen_ms_ = 0; // last ENV reading checkFan() judged int fan_stall_count_ = 0; // consecutive stalled readings bool fan_stalled_ = false; // The CPU guard (ThermalGuard.h): sampled every [Thermal] sample_ms from the // control loop; it slows or pauses the scan when the computer runs hot. ThermalGuard thermal_; long long thermal_next_ms_ = 0; // steady ms of the next sample hostmetrics::CpuTimes cpu_prev_; // for busy % between samples hostmetrics::CpuFreqInfo cpu_freq_; // last sample, for the UI hostmetrics::LoadInfo cpu_load_; bool thermal_camera_stopped_ = false; // the guard stopped the stream long long health_sent_ms_ = 0; // steady ms of the last Health std::string health_sent_key_; // state+throttling last published std::vector heading_marks_; // `gimbal heading mark`s this setup std::atomic running{true}; RemoteCommandRunner remote_; // commands over MQTT (GGS/FWT//Command) std::deque 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 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) { auto m = std::make_unique(cfg.features.mock_needcfg); m->setBuiltPins(cfg.hardware.pins); return m; } 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)); // 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(*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(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 } // One source for the colour cameras (Vimba, or the mock), one per thermal // camera, behind a CompositeCameraSource when there is more than one. The // colour source leads (CameraConfig::cameras puts its cameras first). 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 std::vector colour, thermal; for (const auto* c : cfg.camera.active()) (c->thermal() ? thermal : colour).push_back(c); // With the camera count each source is configured with: Vimba reports // none until open(), and the thermal camera's index must not move. std::vector> sources; std::vector counts; if (!colour.empty() || thermal.empty()) { sources.push_back(makeColourCamera(colour, mock)); counts.push_back(colour.empty() ? 1 : static_cast(colour.size())); } for (const auto* c : thermal) { sources.push_back(makeThermalCamera(*c)); counts.push_back(1); } if (sources.size() == 1) return std::move(sources.front()); return std::make_unique(std::move(sources), std::move(counts)); } std::unique_ptr makeColourCamera(const std::vector& active, bool mock) { if (mock) { const int n = active.empty() ? 1 : static_cast(active.size()); if (opts.mock_camera_full.value_or(cfg.features.mock_camera_full)) { LOG_INFO << "[mock] camera frames are tower-size: 5328x3040 12-bit raw"; return std::make_unique(n, 5328, 3040, PixelLayout::BayerRG12p); } return std::make_unique(n); } #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 } // A thermal camera needs no build option: its SDK is loaded at run time, and // a tower without it just has that camera offline. Only a mock or disabled // camera run gets synthetic counts instead. std::unique_ptr makeThermalCamera(const CameraSpec& c) { if (!cfg.features.enable_camera || opts.mock_camera.value_or(cfg.features.mock_camera)) { LOG_INFO << "[mock] thermal camera '" << c.name << "': synthetic 640x480 counts"; return std::make_unique(1, 640, 480, PixelLayout::Mono16); } LOG_INFO << "thermal camera '" << c.name << "': TarisIR " << c.id << ", profile " << c.calib_profile << ", " << c.frame_rate_hz << " Hz"; return std::make_unique(TarisCameraSource::fromSpec(c)); } // 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; if (c.thermal()) { // No settings file: the thermal camera is set up from its config // keys, and its profile only names a timing block. active_profile[c.name] = c.profile; LOG_INFO << "camera '" << c.name << "': thermal, no settings XML; timing '" << c.profile << "'"; 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) { for (const auto& c : cfg.camera.cameras) { if (c.name != cam_name || !c.thermal()) continue; // A thermal camera's profile is its timing block and nothing else. if (!c.enabled) { LOG_WARN << "camera '" << cam_name << "' is configured but not enabled"; return false; } if (name != c.profile && c.timing.find(name) == c.timing.end()) { std::string known = c.profile; for (const auto& [n, t] : c.timing) if (n != c.profile) known += ", " + n; LOG_WARN << "unknown profile '" << name << "' for camera '" << cam_name << "' (available: " << known << ')'; return false; } active_profile[cam_name] = name; configureSettle(); LOG_INFO << "camera '" << cam_name << "' profile -> " << name << " (timing only)"; return true; } 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; configureSettle(); LOG_INFO << "camera '" << cam_name << "' profile -> " << name; return true; } // How long the scan waits after standstill, and how it decides the frame is // good to keep, from the active profiles. The gimbal must stand still long // enough for every camera, so the waits are the longest across them; the // keep rule is on if any active profile asks for it, judged on camera 0's // frames against the camera's own auto-exposure target and limits. void configureSettle() { if (!scheduler) return; int settle = 0, timeout = 0; bool rule = false; 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); rule = rule || t.settle_rule == "brightness"; } scheduler->setSettleWait(settle, timeout); // The thermal camera hands over the newest frame it has, up to // max_frame_age_ms old: older than the settle wait, and it may have been // taken while the gimbal still moved. for (const auto* c : cfg.camera.active()) if (c->thermal() && c->max_frame_age_ms > settle) LOG_WARN << "camera '" << c->name << "': a frame may be up to " << c->max_frame_age_ms << " ms old, but the scan waits only " << settle << " ms after standstill - lower max_frame_age_ms, or the thermal " "image may be blurred by the last move"; SettleRule::Params p; if (rule && camera) { double v = 0; if (camera->readFeature(0, "IntensityControllerTarget", v)) p.target_pct = v; if (camera->readFeature(0, "IntensityControllerTolerance", v)) p.tolerance_pct = v; if (camera->readFeature(0, "ExposureAutoMin", v)) p.exposure_min_us = v; if (camera->readFeature(0, "ExposureAutoMax", v)) p.exposure_max_us = v; if (camera->readFeature(0, "GainAutoMin", v)) p.gain_min_db = v; if (camera->readFeature(0, "GainAutoMax", v)) p.gain_max_db = v; } scheduler->setSettleRule(rule, p); if (rule) LOG_INFO << "keep rule: brightness in " << p.target_pct << " +- " << p.tolerance_pct << " % and steady for " << p.frames << " frames, after " << settle << " ms, within " << timeout << " ms"; else LOG_INFO << "keep rule: fixed wait of " << settle << " ms after standstill"; } // 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 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(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 (declare [Hardware] imu, or use --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; 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"; } } // --- The computer's CPU (the guard's last sample; no /sys reads here) --- if (thermal_.haveReading()) { const HostReading& r = thermal_.last(); const ThermalParams& p = thermal_.params(); HostView& h = s.host; h.present = true; h.temp_ok = r.temp_ok; h.cpu_c = r.cpu_c; h.zone = r.zone; h.clock_ok = cpu_freq_.ok; h.mean_mhz = cpu_freq_.mean_mhz; h.core_mhz = cpu_freq_.core_mhz; h.hw_max_mhz = cpu_freq_.hw_max_mhz; h.cap_mhz = cpu_freq_.cap_mhz; h.busy_pct = r.busy_pct; h.load1 = cpu_load_.load1; h.load5 = cpu_load_.load5; h.load15 = cpu_load_.load15; h.cpus = cpu_load_.cpus; h.throttling = thermal_.throttle().throttling(); h.throttle_cause = thermal_.throttle().cause(); h.counters_ok = r.count_ok; h.throttle_total = thermal_.throttle().eventsTotal(); h.throttle_last_min = thermal_.throttle().eventsLastMinute(); h.guard_on = thermal_.enabled(); h.state = guardStateName(thermal_.state()); h.reason = thermal_.reason(); h.slow_c = p.slow_c; h.pause_c = p.pause_c; h.resume_c = p.resume_c; h.interval_s = scheduler ? scheduler->intervalMs() / 1000.0 : 0.0; h.held_s = thermal_.heldForSaveMs() / 1000; h.paused_s = thermal_.pausedForMs() / 1000; if (pipeline) { const auto t = pipeline->saveTiming(); h.save_count = t.count; h.save_mean_ms = t.mean_ms; h.save_max_ms = t.max_ms; } auto& g = s.sensors.host; g.present = true; g.status = "live"; auto set = [](SensorField& f, double v, int decimals) { char b[32]; std::snprintf(b, sizeof(b), "%.*f", decimals, v); f.value = b; f.present = true; }; if (g.fields.size() >= 4) { if (r.temp_ok) set(g.fields[0], r.cpu_c, 1); if (cpu_freq_.ok) set(g.fields[1], cpu_freq_.mean_mhz / 1000.0, 1); if (r.busy_pct >= 0.0) set(g.fields[2], r.busy_pct, 0); const long long n = thermal_.throttle().eventsLastMinute(); g.fields[3].value = !h.throttling ? "no" : n > 0 ? "yes, " + std::to_string(n) + "x last min" : "yes (clock)"; g.fields[3].present = true; } // The loudest thing that is true: paused, slowed, throttling, hot. if (thermal_.state() == GuardState::Paused) { s.sensors.cpu_badge = "PAUSED"; s.sensors.cpu_badge_red = true; } else if (thermal_.state() == GuardState::Slowed) { s.sensors.cpu_badge = "SLOWED"; } else if (h.throttling) { s.sensors.cpu_badge = "THROTTLED"; } else if (r.temp_ok && r.cpu_c >= p.slow_c) { s.sensors.cpu_badge = "HOT"; } if (!thermal_.enabled() && !s.sensors.cpu_badge.empty()) s.sensors.cpu_badge += " (guard off)"; } // --- Camera / capture --- s.capture.present = true; s.capture.continuous = scheduler && scheduler->continuous(); s.capture.active = scheduler && (scheduler->captureActive() || s.capture.continuous); // 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) { int index = 0; for (const auto& d : camera->deviceInfo()) { CameraDeviceView v; v.offline = camera->offline(index++); 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_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; } if (auto ev = pipeline->lastThermalEvent(); ev && ev->thermal) { s.capture.has_thermal = true; s.capture.thermal_cal = ev->thermal->cal; s.capture.thermal_stats_ok = ev->thermal->stats_ok; s.capture.thermal_max_c = ev->thermal->tmax_decic / 10.0; s.capture.thermal_med_c = ev->thermal->tmed_decic / 10.0; s.capture.thermal_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(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->phase(); 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"; } } else if (scheduler && scheduler->continuous()) { a.active = true; a.title = "CAPTURE"; a.status = "continuous · one image per cycle while the gimbal stands still · " "'capture off' (r) to stop"; } // 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 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; } static long long steadyNowMs() { using namespace std::chrono; return duration_cast(steady_clock::now().time_since_epoch()).count(); } static void logGuard(const std::vector& lines) { for (const auto& l : lines) { if (l.first == GuardLevel::Warn) LOG_WARN << l.second; else LOG_INFO << l.second; } } // Every [Thermal] sample_ms: read the CPU's temperature, clock, throttle // counters and busy time, hand them to the guard, log what it says, and // publish Health when anything changed (or once a minute). void watchHost() { const long long now = steadyNowMs(); if (now < thermal_next_ms_) return; thermal_next_ms_ = now + thermal_.params().sample_ms; const std::string& root = thermal_.params().sysfs_root; const auto th = hostmetrics::readThermal(root); cpu_freq_ = hostmetrics::readCpuFreq(root); const auto count = hostmetrics::readThrottleCount(root); const auto times = hostmetrics::readCpuTimes(root); cpu_load_ = hostmetrics::readLoad(); HostReading r; r.now_ms = now; r.temp_ok = th.ok; r.cpu_c = th.cpu_temp_c; r.zone = th.cpu_zone; r.clock_ok = cpu_freq_.ok; r.mhz = cpu_freq_.mean_mhz; r.hw_max_mhz = cpu_freq_.hw_max_mhz; r.count_ok = count.ok; r.throttle_count = count.events; r.busy_pct = hostmetrics::busyPercent(cpu_prev_, times); cpu_prev_ = times; // While paused the scan is not waiting for saving - it is parked. r.held_for_save = scheduler && scheduler->holdingForSave() && !scheduler->paused(); r.held_total_ms = scheduler ? scheduler->heldForSaveTotalMs() : 0; r.save_pending = pipeline ? static_cast(pipeline->pending()) : 0; r.base_interval_ms = cfg.image_rate() > 0.0 ? 1000.0 / cfg.image_rate() : 0.0; r.save_ms_mean = pipeline ? pipeline->saveTiming().mean_ms : 0.0; r.save_workers = cfg.hardware.save_workers; logGuard(thermal_.update(r)); applyGuard(); publishHealth(false); } // The guard's verdict, applied to the scan - every control-loop pass, since // the camera may only stop once the waypoint under way has finished. void applyGuard() { if (!scheduler) return; scheduler->setIntervalScale(thermal_.intervalScale()); scheduler->setPaused(thermal_.pauseCapture()); if (!camera) return; // Paused and parked: stop the camera stream, so the CPU all but idles (it // copies every frame it streams). Never under a running test or homing, // which may need the camera. const bool shooting = scheduler->mode() != Mode::Manual || scheduler->continuous(); if (scheduler->pausedAtRest() && shooting && !thermal_camera_stopped_ && !procedureBusy()) { camera->stop(); thermal_camera_stopped_ = true; LOG_INFO << "camera stream stopped while pictures are paused"; } else if (!scheduler->paused() && thermal_camera_stopped_) { thermal_camera_stopped_ = false; // Back on only where something will take pictures: a scan, or // continuous capture. In plain manual it stays off, as setMode leaves it. if (scheduler->mode() != Mode::Manual || scheduler->continuous()) { camera->start(); LOG_INFO << "camera stream started again"; } } } HealthReport healthReport() const { HealthReport h; const HostReading& r = thermal_.last(); h.tower = cfg.general.tower_name; h.state = guardStateName(thermal_.state()); h.reason = thermal_.reason(); h.guard_on = thermal_.enabled(); h.cpu_c_ok = r.temp_ok; h.cpu_c = r.cpu_c; h.cpu_mhz_ok = r.clock_ok; h.cpu_mhz = r.mhz; h.cpu_busy_pct = r.busy_pct; h.throttling = thermal_.throttle().throttling(); h.throttle_events_last_min = thermal_.throttle().eventsLastMinute(); if (env) { if (auto m = env->sample()) { h.enclosure_c_ok = true; h.enclosure_c = m->temp_c; } } if (pipeline) { const auto t = pipeline->saveTiming(); h.save_count_last_min = t.count; h.save_ms_mean = t.mean_ms; h.save_ms_max = t.max_ms; } if (camera) { // Only a camera the scan can do without is ever offline (the // thermal one); the colour camera failing stops the run instead. const auto active = cfg.camera.active(); for (int i = 0; i < camera->cameraCount() && i < static_cast(active.size()); ++i) if (!camera->offline(i).empty()) h.cams_offline += (h.cams_offline.empty() ? "" : ",") + active[i]->label; } h.held_s = thermal_.heldForSaveMs() / 1000; h.interval_s = scheduler ? scheduler->intervalMs() / 1000.0 : 0.0; h.timestamp_ms = nowEpochMs(); return h; } // On a change of state or throttling, or once a minute; `force` for a command. void publishHealth(bool force) { if (!channel || !thermal_.haveReading()) return; const long long now = steadyNowMs(); const std::string key = std::string(guardStateName(thermal_.state())) + (thermal_.throttle().throttling() ? "+t" : "") + (thermal_.enabled() ? "" : "+off"); if (!force && key == health_sent_key_ && now - health_sent_ms_ < 60000) return; health_sent_key_ = key; health_sent_ms_ = now; channel->publishHealth(healthReport()); } // `thermal [resume|off|on]`: the CPU's heat and the scan's pace for it. void handleThermal(const std::string& what) { const long long now = steadyNowMs(); if (what == "resume") { logGuard(thermal_.forceResume(now)); } else if (what == "off" || what == "on") { logGuard(thermal_.setEnabled(what == "on", now)); } else if (!what.empty()) { LOG_WARN << "usage: thermal [resume|off|on]"; return; } else { const HostReading& r = thermal_.last(); const ThermalParams& p = thermal_.params(); if (!thermal_.haveReading()) { LOG_INFO << "CPU: no reading yet"; } else { LOG_INFO << "CPU: " << (r.temp_ok ? celsiusText(r.cpu_c) + " (" + r.zone + ")" : std::string("temperature unknown")) << ", " << (r.clock_ok ? ghzText(r.mhz) : std::string("clock unknown")) << (r.hw_max_mhz > 0.0 ? " of " + ghzText(r.hw_max_mhz) : std::string()) << ", " << (r.busy_pct >= 0.0 ? std::to_string(static_cast(r.busy_pct + 0.5)) + "% busy" : std::string("busy unknown")); LOG_INFO << "throttling: " << (thermal_.throttle().throttling() ? "yes (" + thermal_.throttle().cause() + ")" : std::string("no")) << ", " << thermal_.throttle().eventsLastMinute() << " in the last minute, " << thermal_.throttle().eventsTotal() << " since start"; } char every[48] = ""; if (scheduler) std::snprintf(every, sizeof(every), ", one picture every %.1f s", scheduler->intervalMs() / 1000.0); LOG_INFO << "scan pace: " << (thermal_.enabled() ? guardStateName(thermal_.state()) : "guard off") << (thermal_.reason().empty() ? "" : " - " + thermal_.reason()) << every; if (pipeline) { const auto t = pipeline->saveTiming(); if (t.count > 0) LOG_INFO << "saving: " << t.count << " in the last minute, " << static_cast(t.mean_ms + 0.5) << " ms each (longest " << static_cast(t.max_ms + 0.5) << " ms)"; } LOG_INFO << "limits: slows at " << celsiusText(p.slow_c) << ", pauses at " << celsiusText(p.pause_c) << " for " << p.pause_after_s << " s, resumes at " << celsiusText(p.resume_c) << " after at least " << p.min_pause_s << " s"; return; } applyGuard(); publishHealth(true); } 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)"; stopContinuous("homing"); // 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_}; // 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"; } } // A stop that came in while the program was starting up: do not begin a // home that would only be cancelled. if (shutdownRequested()) { LOG_INFO << "stop requested: not homing"; return; } 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. // // Only after a successful home. A failed homing leaves the driver in // velocity mode, where firmware before 2026-09-28 wrote SPEED straight to // VMAX: on staeffelsberg that turned the axis on its own until it jammed on // its stop and ground there. The firmware now refuses that too; this keeps // towers still running the old build safe. if (homed) sendProductionSpeeds(); else LOG_WARN << "production speed not sent: the gimbal is not homed"; 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; if (const auto r = encConstRegister(d.enc_const)) { s.have_enc_const = true; s.enc_const = *r; } 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 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 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; // A yaw-only tower ignores the pitch column, so rows that differ only in // pitch photograph the same bearing again. if (noPitchAxis()) if (const std::size_t repeats = repeatedBearings(grid.points())) LOG_WARN << repeats << " scan waypoints repeat an earlier bearing: this tower " "has no pitch axis, so the pitch column is ignored"; 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. // With a camera named (block name or label), only that one: the thermal // camera reconnects without rebooting the colour one. void resetCameras(const std::string& which = "") { const auto active = cfg.camera.active(); int only = -1; auto lowered = [](std::string v) { std::transform(v.begin(), v.end(), v.begin(), [](unsigned char ch) { return std::tolower(ch); }); return v; }; if (!which.empty()) { for (size_t i = 0; i < active.size(); ++i) if (lowered(active[i]->name) == lowered(which) || lowered(active[i]->label) == lowered(which)) only = static_cast(i); if (only < 0) { LOG_WARN << "camera reset: no active camera '" << which << "'"; return; } } camera->stop(); for (int i = 0; i < camera->cameraCount(); ++i) { if (only >= 0 && i != only) continue; const std::string name = i < static_cast(active.size()) ? active[i]->name : std::to_string(i); LOG_INFO << "camera " << name << (camera->resetCamera(i) ? ": reset, back on its profile" : ": reset FAILED (see above)"); } } // `camera calib []`: the thermal camera's calibration profile, // which sets the scale of its counts. Every frame says which it was taken // on, so switching is safe for the archive; it is manual-mode only because // the camera delivers nothing for the ~3 s it takes. void handleCalib(const Command& c) { int index = -1, i = 0; for (const auto* s : cfg.camera.active()) { if (s->thermal()) { index = i; break; } ++i; } if (index < 0) { LOG_WARN << "camera calib: no thermal camera configured"; return; } const std::string why = camera->offline(index); if (!why.empty()) { LOG_WARN << "camera calib: the thermal camera is offline (" << why << ")"; return; } if (!c.has_value) { double v = 0, k = 0; if (camera->readFeature(index, "CalibProfile", v) && camera->readFeature(index, "KelvinPerCount", k)) LOG_INFO << "thermal calibration profile " << static_cast(v) << ", " << k << " K per count"; else LOG_INFO << "thermal calibration profile: no frame yet"; return; } const int profile = static_cast(c.value); LOG_INFO << "thermal camera: switching to calibration profile " << profile << " ..."; if (camera->setFeature(index, "CalibProfile", std::to_string(profile))) LOG_INFO << "thermal camera on calibration profile " << profile << " (for this run; [Camera." << cfg.camera.active()[index]->name << "] calib_profile sets it at startup)"; else LOG_WARN << "thermal camera: switching to calibration profile " << profile << " FAILED (see above)"; } // The one way the mode changes - `mode ...`, the a / p / m keys, [Startup] mode, 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 (scheduler->continuous()) LOG_INFO << "continuous capture off (mode change)"; if (m == Mode::Manual) camera->stop(); else camera->start(); // The stream is as the mode left it; the CPU guard stops it again if a // pause still holds (applyGuard). thermal_camera_stopped_ = false; scheduler->setMode(m, pan); if (m == Mode::Manual && was != Mode::Manual) stopScanMove(); if (m == Mode::Pan) { const auto& h = scheduler->panHeadingsNow(); LOG_INFO << "mode pan: " << panText(pan) << " - " << h.size() << (h.size() == 1 ? " position" : " positions") << " (" << panRangeText(h) << ")"; } else { LOG_INFO << "mode " << modeName(m) << (was == m ? " (again)" : ""); } channel->publishStatus(static_cast(m)); return true; } // Leaving a scan: stop the move it last sent, which the firmware would // otherwise finish - a slow return to the first waypoint can take many // seconds the operator spends waiting for the gimbal to answer. The firmware // ramps the axis down and holds where it comes to rest (it does not resume // the move), and the next MOVE or jog supersedes a stop still ramping down. // Sent whether or not telemetry shows motion, since a MOVE sent this tick // is not in it yet. Never during a homing, which a STOP ends in ERROR. void stopScanMove() { const MotorTelemetry t = motor->telemetry(); if (t.yaw.state == AxisState::Homing || (t.pitch_present && t.pitch.state == AxisState::Homing)) return; if (anyLeftoverMove()) LOG_INFO << "stopping the gimbal where it is"; motor->sendCommand("STOP ALL"); } // 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) { if (pan.pitch != 0.0 && noPitchAxis()) LOG_WARN << "mode pan: this tower has no pitch axis; pitch " << pan.pitch << " deg ignored"; 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; } // "heading 90, span 30, pitch 5 deg" - no pitch on a yaw-only tower. std::string panText(const PanSpec& p) const { std::ostringstream o; o << "heading " << p.heading << ", span " << p.span; if (!noPitchAxis()) o << ", pitch " << p.pitch; o << " deg"; return o.str(); } static std::string panRangeText(const std::vector& 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 []` - switch. void handleMode(const std::string& line) { std::istringstream in(line); std::vector 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: " << panText(p) << " - " << h.size() << (h.size() == 1 ? " position" : " positions") << " (" << panRangeText(h) << "), at " << (scheduler->panIndex() + 1) << "/" << h.size(); } else { LOG_INFO << "mode " << modeName(m) << (scheduler->continuous() ? " (continuous capture on)" : ""); } } // `gimbal heading fit : ...` / `gimbal heading offset `. // // 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 ` 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 : ...` skips the marking, for an operator who // already knows the pairs. void handleHeading(const std::vector& 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; stopContinuous("heading setup"); heading_setup_active_ = true; heading_marks_.clear(); LOG_INFO << "heading setup: aim at a landmark whose true bearing you know, then " "'gimbal heading mark '. 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 "; 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 "; 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 |fit [: ...]|" "cancel|offset >"; return; } std::vector sightings; if (tok.size() == 3) { // bare `fit`: the marks of this setup if (!heading_setup_active_) { LOG_WARN << "usage: gimbal heading fit : : [...] " "(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"; } // `capture on|off` - keep frames on the scan's own cycle wherever the gimbal // is aimed, while leaving it free to nudge (manual mode). Bare: report. void handleCapture(const std::string& what) { if (!scheduler || !camera) { LOG_WARN << "capture: no camera configured"; return; } if (what.empty()) { LOG_INFO << "continuous capture " << (scheduler->continuous() ? "on" : "off"); return; } if (what == "off") { if (!scheduler->continuous()) { LOG_INFO << "continuous capture already off"; return; } scheduler->setContinuous(false); camera->stop(); LOG_INFO << "continuous capture off"; return; } if (what != "on") { LOG_WARN << "usage: capture [on|off]"; return; } if (!requireManual("capture on")) return; if (heading_setup_active_) { LOG_WARN << "capture on: heading setup in progress ('gimbal heading cancel' first)"; return; } MotorTelemetry t = motor->telemetry(); if (!t.yaw.ready() || (t.pitch_present && !t.pitch.ready())) { LOG_WARN << "capture on: gimbal not homed (press h / run 'gimbal home' first)"; return; } camera->start(); scheduler->setContinuous(true); const double interval_s = scheduler->imageRate() > 0.0 ? 1.0 / scheduler->imageRate() : 0.0; LOG_INFO << "continuous capture on: one image every " << interval_s << " s, each once the gimbal has stood still for " << scheduler->settleWaitMs() << " ms"; } // End continuous capture because something else is taking the gimbal or the // camera. Says so, so the operator is not left thinking it is still running. void stopContinuous(const char* why) { if (!scheduler || !scheduler->continuous()) return; scheduler->setContinuous(false); if (camera) camera->stop(); LOG_INFO << "continuous capture off (" << why << ")"; } // 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; } // Before the first status line nothing is known, so nothing is refused - the // firmware answers for itself. bool noPitchAxis() const { return motor->telemetry().pitchAbsent(); } bool requirePitch(const char* what) { if (!noPitchAxis()) return true; LOG_WARN << "ignored '" << what << "': this tower has no pitch axis"; return false; } // Aim at absolute counts. Without a pitch, or on a yaw-only tower (whose // firmware refuses the two-axis MOVE), yaw moves alone and pitch stays put. void sendAim(long yaw_counts, std::optional pitch_counts) { if (pitch_counts && noPitchAxis()) { LOG_WARN << "this tower has no pitch axis; moving yaw only"; pitch_counts.reset(); } const std::string fw = moveCommand(yaw_counts, pitch_counts); LOG_INFO << "gimbal -> " << fw; motor->sendCommand(fw); } // `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()); } // 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 ` — 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") { auto aim = parseAim(tok, 2); if (!aim) { LOG_WARN << "usage: gimbal move [,]"; return; } // Yaw is a true bearing: one in the arc the gimbal cannot reach is // refused, as a pan's are - clamping would image the wrong direction. auto yc = cfg.geometry.yaw.headingToCounts(aim->yaw); if (!yc) { LOG_WARN << "gimbal move refused: bearing " << wrap360(aim->yaw) << " deg is outside the reachable yaw arc (" << wrap360(cfg.geometry.yaw.min_deg) << ".." << wrap360(cfg.geometry.yaw.max_deg) << " true)"; return; } std::optional pc; if (aim->pitch) pc = cfg.geometry.pitch.toCounts(*aim->pitch); sendAim(*yc, pc); } else if (sub == "steps") { auto aim = parseAim(tok, 2); if (!aim) { LOG_WARN << "usage: gimbal steps [,]"; return; } std::optional pc; if (aim->pitch) pc = static_cast(*aim->pitch); sendAim(static_cast(aim->yaw), 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'; if (axis == 'P' && !requirePitch("gimbal nudge pitch")) return; 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'; if (axis == 'P' && !requirePitch("gimbal jog pitch")) return; 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 the startup home, [Startup] home = true), 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. if (tok.size() > 2 && lowerStr(tok[2]) == "p" && !requirePitch(("gimbal " + sub + " p").c_str())) return; 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]; // 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 (!requirePitch("gimbal pitch setup")) return; 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(*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 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 == "mode" && c.device == "manual")) cancel_procedure_ = true; else if (c.verb == "exit") running = false; // not an interfering command: a stop else if (!c.empty()) LOG_WARN << "ignored '" << c.verb << "': homing in progress (Esc to cancel)"; local.pop(); } // A stop cancels homing, as Esc does, rather than waiting for it to finish: // a full home takes minutes on some towers - longer than `fgc stop` (120 s) // and systemd (150 s) wait before giving up or killing the program outright, // which wedges the camera. Dropping the stop instead, as this once did, left // the program running with its dashboard already closed. if (shutdownRequested() && !cancel_procedure_) { LOG_INFO << "stop requested: cancelling homing to shut down"; cancel_procedure_ = true; } } // A stop asked for in any of the ways there are: the `exit` command - which is // also what the dashboard sends when it closes, on Ctrl-C and SIGTERM too - or // SIGINT/SIGTERM caught by handleShutdownSignal() when there is no dashboard. bool shutdownRequested() const { return !running || g_shutdown_requested.load(); } // ---- `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|camera|hardware []] [] (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. stopContinuous("test"); 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(*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 == "capture" || 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(c.option); else if (c.device == "calib") handleCalib(c); else LOG_WARN << "usage: camera reset [] | camera calib []"; } else if (c.verb == "snapshot") { takeSnapshot(); } else if (c.verb == "capture") { handleCapture(c.device); } else if (c.verb == "thermal") { handleThermal(c.device); } 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 "; } else { LOG_WARN << "usage: set camera profile [] "; } } 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::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 = 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 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 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(); // 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). // First, before anything slow: homing alone takes minutes on some towers. // The TUI's own handler takes over while the dashboard runs (it turns the // signal into "exit") and puts this one back when the dashboard closes. std::signal(SIGINT, handleShutdownSignal); std::signal(SIGTERM, handleShutdownSignal); // 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; // 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, c->demosaic == "edge_aware"}); pp.workers = cfg.hardware.save_workers; pp.jxl_threads = cfg.hardware.jxl_threads; pp.queue_max = cfg.hardware.save_queue_max; LOG_INFO << "image saving: " << pp.workers << " worker(s) x " << pp.jxl_threads << " thread(s), up to " << pp.queue_max << " frame(s) waiting"; // 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. A yaw-only tower // has no pitch encoder: its camera sits at a fixed elevation, // [Motor] pitch_angle_at_center (0 unless set). pipeline = std::make_unique( *channel, [this] { MotorTelemetry t = motor->telemetry(); const double pitch = encoderPitchDeg( cfg.geometry.pitch, t.pitch_present ? std::optional(t.pitch.xenc) : std::nullopt); Orientation o{static_cast(cfg.geometry.yaw.headingDeg(t.yaw.xenc)), static_cast(pitch)}; 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, cfg.image_rate(), cfg.geometry, grid); scheduler->setPanStep(cfg.hardware.pan_step_deg); // A scan that outruns saving holds at its waypoint instead of piling // frames up in memory (hardware.ini save_queue_max). scheduler->setSaveGate([pipe_ptr] { return pipe_ptr->canAccept(); }); // 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. configureSettle(); thermal_ = ThermalGuard(cfg.thermal); if (cfg.thermal.enabled) LOG_INFO << "CPU guard: pictures slow down at " << celsiusText(cfg.thermal.slow_c) << " or when the CPU throttles, and pause at " << celsiusText(cfg.thermal.pause_c) << " (resume at " << celsiusText(cfg.thermal.resume_c) << ")" << (cfg.thermal.sysfs_root != "/" ? "; readings from the simulated tree " + cfg.thermal.sysfs_root : std::string()); else LOG_WARN << "CPU guard off ([Thermal] enabled = false): the scan keeps full pace " "however hot the CPU runs"; // Before the firmware is talked to: the registers are wanted by the very // first sendAnchorConfig(), on both the homing and the no-homing startup 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(Mode::Manual)); // until [Startup] mode 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 lk(snapshot_mutex); return latest_snapshot; }, [this](const std::string& line) { std::lock_guard lk(cmd_mutex); cmd_queue.push(line); }); // [Startup] decides; a --test run ignores it, so a test never homes the // gimbal or starts a sweep that nobody asked the test for. const bool test_run = !opts.run_test.empty(); const bool home = cfg.startup.home && !test_run; const Mode start_in = test_run ? Mode::Manual : cfg.startup.mode; if (test_run && (cfg.startup.home || cfg.startup.mode != Mode::Manual)) LOG_INFO << "--test: ignoring [Startup] (no homing, staying in manual)"; if (home) { 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(); } // A stop that came in during homing: do not start sweeping on the way out. if (start_in != Mode::Manual && !shutdownRequested()) setMode(start_in); 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 watchHost(); // CPU heat and load: slow or pause the scan for it applyGuard(); 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(std::move(config), std::move(options))) {} Application::~Application() = default; int Application::run() { return impl_->run(); } } // namespace fgc