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