diff --git a/docs/configuration.md b/docs/configuration.md index 3750084..f9dafa7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -217,9 +217,9 @@ away in the log. It only appears once something has run. When a `gimbal calib` completes it is applied to the live session and the strip shows a highlighted yes/no prompt — **`Save this calibration to config as the new default? (y / n)`**. Press **`y`** to -write the fitted `[Motor]` `*_counts_per_deg` / `*_zero_count` back into the `config.ini` the program -was launched with (replacing those keys in place, preserving everything else) so it persists across -restarts; press **`n`** to keep it for this session only. The keys are active only while the prompt is +write the full `[Motor]` per-axis map — `*_counts_per_deg`, `*_zero_count`, and `*_min_deg`/`*_max_deg` +— back into the `config.ini` the program was launched with (replacing those keys in place, preserving +everything else) so it persists across restarts; press **`n`** to keep it for this session only. The keys are active only while the prompt is showing. (Until you answer `y`, calibration remains session-only — see [known-issues.md](known-issues.md).) diff --git a/docs/known-issues.md b/docs/known-issues.md index 9e29cf6..df38fd6 100644 --- a/docs/known-issues.md +++ b/docs/known-issues.md @@ -53,7 +53,13 @@ doctest unit-test suite (`ctest`). - **`gimbal calib` persistence**: the fitted `counts_per_deg`/`zero_count` are always applied to the live session (display, manual moves, MQTT heading, **and** the capture scheduler) and written to `logs/calib_*.log`. In the **TUI**, the activity strip then prompts `Save … as the new default? (y/n)` - — `y` writes them into `[Motor]` in `config.ini` (persists across restarts), `n` keeps them + — `y` writes the full per-axis `[Motor]` map (`*_counts_per_deg`, `*_zero_count`, + `*_min_deg`/`*_max_deg`) into `config.ini` (persists across restarts), `n` keeps them session-only. In the **headless** console there is no prompt, so calibration stays session-only there; copy the logged values into `[Motor]` by hand to keep them. +- **`gimbal nudge`** moves a fixed fraction of the **homed** endstop-to-endstop travel (from the + firmware dump), so it is independent of the degrees↔counts calibration. If no dump has been captured + yet (not homed / no `gimbal dump`), nudge requests one and does nothing that press — it never falls + back to the configured degree clamps (which, if `*_min_deg`/`*_max_deg` were unset, produced absurd + ±100000° steps). - DHT11 temperature/humidity is still a Sensors-panel placeholder (the IMU half is integrated). diff --git a/src/core/Application.cpp b/src/core/Application.cpp index c1adda1..8926224 100644 --- a/src/core/Application.cpp +++ b/src/core/Application.cpp @@ -530,15 +530,17 @@ struct Application::Impl { return static_cast(iss >> a >> b); } - // Soft-limit travel (counts) for an axis from the last dump, else the - // configured degree range converted to counts. Returns 0 if unknown. - long axisRangeCounts(char axis) { + // 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); - const AxisMap& m = (axis == 'Y') ? cfg.geometry.yaw : cfg.geometry.pitch; - return std::labs(m.toCounts(m.max_deg) - m.toCounts(m.min_deg)); + return 0; } // `gimbal ` — unified, lowercase motor control. `move` is degrees @@ -579,8 +581,16 @@ struct Application::Impl { 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; } - long range = axisRangeCounts(axis); - if (range <= 0) { LOG_WARN << "gimbal nudge: travel unknown; run gimbal home/dump first"; motor->sendCommand("DUMP"); 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); diff --git a/src/core/Config.cpp b/src/core/Config.cpp index ec1b435..fed59cd 100644 --- a/src/core/Config.cpp +++ b/src/core/Config.cpp @@ -219,11 +219,19 @@ bool saveMotorCalibration(const std::string& path, const Geometry& geo) { std::snprintf(b, sizeof(b), "%.10g", v); return std::string(b); }; + // Persist the full per-axis map, not just the fitted counts_per_deg/zero_count: + // min_deg/max_deg are part of the geometry and consumers (e.g. travel-range + // estimates) rely on them, so writing them keeps the [Motor] block complete and + // self-consistent rather than depending on pre-existing lines. const std::vector> kv = { {"yaw_counts_per_deg", num(geo.yaw.counts_per_deg)}, {"yaw_zero_count", std::to_string(geo.yaw.zero_count)}, + {"yaw_min_deg", num(geo.yaw.min_deg)}, + {"yaw_max_deg", num(geo.yaw.max_deg)}, {"pitch_counts_per_deg", num(geo.pitch.counts_per_deg)}, {"pitch_zero_count", std::to_string(geo.pitch.zero_count)}, + {"pitch_min_deg", num(geo.pitch.min_deg)}, + {"pitch_max_deg", num(geo.pitch.max_deg)}, }; const std::string updated = updateIniSectionKeys(buf.str(), "Motor", kv); diff --git a/src/ui/TuiUi.cpp b/src/ui/TuiUi.cpp index f816155..17be263 100644 --- a/src/ui/TuiUi.cpp +++ b/src/ui/TuiUi.cpp @@ -615,12 +615,12 @@ void TuiUi::uiLoop() { } if (overlay != Overlay::None && e == Event::Escape) { overlay = Overlay::None; return true; } // Arrow keys nudge the gimbal in steps (only when no overlay is open): - // Left/Right = yaw -/+5%, Up/Down = pitch +/-10% of travel. + // Left/Right = yaw -/+5%, Up/Down = pitch -/+10% of travel. if (overlay == Overlay::None && sink_) { if (e == Event::ArrowLeft) { sink_("gimbal nudge yaw -5"); return true; } if (e == Event::ArrowRight) { sink_("gimbal nudge yaw 5"); return true; } - if (e == Event::ArrowUp) { sink_("gimbal nudge pitch 10"); return true; } - if (e == Event::ArrowDown) { sink_("gimbal nudge pitch -10"); return true; } + if (e == Event::ArrowUp) { sink_("gimbal nudge pitch -10"); return true; } + if (e == Event::ArrowDown) { sink_("gimbal nudge pitch 10"); return true; } } if (!e.is_character()) return false; const std::string& c = e.character(); diff --git a/tests/test_config.cpp b/tests/test_config.cpp index 019e4ce..ebe90b4 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -3,7 +3,9 @@ #include "fgc/Config.h" #include +#include #include +#include using namespace fgc; @@ -78,3 +80,41 @@ TEST_CASE("updateIniSectionKeys appends keys missing from the section") { // re-parsing must see the appended key under [Motor] CHECK(out.find("pitch_zero_count = 7") != std::string::npos); } + +TEST_CASE("saveMotorCalibration round-trips the full per-axis map") { + // Write a starting config, save a calibrated Geometry into it, reload, and + // confirm every [Motor] parameter (incl. min/max_deg) survives, while another + // section is left untouched. + const std::string path = "test_calib_roundtrip.ini"; + { + std::ofstream f(path, std::ios::trunc); + f << "[General]\ntower_name = rig7\n\n" + "[Motor]\n" + "yaw_counts_per_deg = 1.0\n" + "yaw_zero_count = 0\n" + "yaw_min_deg = -90\n" + "yaw_max_deg = 90\n" + "pitch_counts_per_deg = 1.0\n" + "pitch_zero_count = 0\n" + "pitch_min_deg = 0\n" + "pitch_max_deg = 60\n"; + } + + Geometry geo; + geo.yaw = {983.33, 500123, -88.0, 92.0}; + geo.pitch = {8333.33, -4200, -2.0, 61.5}; + REQUIRE(saveMotorCalibration(path, geo)); + + AppConfig cfg = ConfigLoader::loadFromFile(path); + CHECK(cfg.general.tower_name == "rig7"); // unrelated section preserved + CHECK(cfg.geometry.yaw.counts_per_deg == doctest::Approx(983.33)); + CHECK(cfg.geometry.yaw.zero_count == 500123); + CHECK(cfg.geometry.yaw.min_deg == doctest::Approx(-88.0)); + CHECK(cfg.geometry.yaw.max_deg == doctest::Approx(92.0)); + CHECK(cfg.geometry.pitch.counts_per_deg == doctest::Approx(8333.33)); + CHECK(cfg.geometry.pitch.zero_count == -4200); + CHECK(cfg.geometry.pitch.min_deg == doctest::Approx(-2.0)); + CHECK(cfg.geometry.pitch.max_deg == doctest::Approx(61.5)); + + std::remove(path.c_str()); +}