Expanded IMU commands; help text shortened

This commit is contained in:
pgdalmeida 2026-09-02 11:50:32 +02:00
parent ef96513b58
commit d3514f7d2b
Signed by: pedro.almeida
GPG Key ID: D4A6C394DF13F1D7
3 changed files with 225 additions and 39 deletions

View File

@ -341,6 +341,153 @@ struct Application::Impl {
return true; return true;
} }
// Comma-joined "label (type N)" for every XKF profile the device reports, so a
// bad argument can say what IS available instead of just refusing.
std::string imuProfileList() const {
auto cfg_opt = imu ? imu->config() : std::nullopt;
if (!cfg_opt || cfg_opt->available_profiles.empty()) return "(none reported)";
std::string out;
for (const auto& p : cfg_opt->available_profiles) {
if (!out.empty()) out += ", ";
out += (p.label.empty() ? std::string("?") : p.label) + " (type " +
std::to_string(static_cast<int>(p.type)) + ")";
if (cfg_opt->has_scenario && p.type == cfg_opt->scenario_type) out += " *";
}
return out;
}
// `imu profile [<type|label>]`. The MTi's XKF profile decides whether heading
// uses the magnetometer, so switching it is how you move between a compass-
// referenced heading and the no-mag profile `gimbal calib` leaves behind.
void imuProfile(const std::string& arg) {
if (arg.empty()) {
LOG_INFO << "XKF profiles (* = active): " << imuProfileList();
LOG_INFO << "select one with: imu profile <type|label>";
return;
}
int type = -1;
auto cfg_opt = imu->config();
// All-digits => a raw type number; anything else is a label, matched
// case-insensitively against what the device reported.
const bool numeric = arg.find_first_not_of("0123456789") == std::string::npos;
if (numeric) {
type = std::stoi(arg);
} else if (cfg_opt) {
auto lower = [](std::string v) {
std::transform(v.begin(), v.end(), v.begin(),
[](unsigned char ch) { return std::tolower(ch); });
return v;
};
const std::string want = lower(arg);
for (const auto& p : cfg_opt->available_profiles)
if (lower(p.label) == want) { type = p.type; break; }
}
if (type < 0) {
LOG_WARN << "unknown XKF profile '" << arg << "'; available: " << imuProfileList();
return;
}
LOG_INFO << "switching IMU to XKF profile type " << type
<< " (persists on the device; briefly pauses the IMU stream)";
const bool ok = imu->setFilterProfile(type);
// Either way the device state may have moved, so make the 'i' view re-read it
// rather than keep showing the profile we asked to leave.
imu_config_done_ = false;
if (ok)
LOG_INFO << "IMU XKF profile is now type " << type;
else
LOG_WARN << "IMU did not confirm XKF profile type " << type
<< " (older firmware may not report it; check the 'i' view)";
}
void imuStatus() {
if (!imu->connected()) {
LOG_WARN << "IMU: no recent sample (check [IMU] device and wiring)";
return;
}
auto sm = imu->sample();
if (!sm) { LOG_WARN << "IMU: no sample available"; return; }
LOG_INFO << "IMU: yaw " << formatDegrees(sm->yaw_deg) << " pitch "
<< formatDegrees(sm->pitch_deg) << " roll " << formatDegrees(sm->roll_deg);
char temp[32];
std::snprintf(temp, sizeof(temp), "%.1f", sm->temp_c);
LOG_INFO << " device temp " << temp << " C, sample #" << sm->sample_counter;
}
// Re-read before printing: the device is the authority, and a profile switched
// from anywhere else would otherwise be reported wrong.
void imuConfig() {
LOG_INFO << "re-reading IMU configuration (briefly pauses the stream)...";
imu->refreshConfig();
imu_config_done_ = false; // let the 'i' view pick the new values up too
auto c = imu->config();
if (!c) {
LOG_WARN << "IMU reported no configuration (older firmware may not answer the queries)";
return;
}
LOG_INFO << "IMU: " << (c->product_code.empty() ? "(unknown product)" : c->product_code)
<< " device id " << c->device_id << " firmware " << c->firmware;
if (c->has_period)
LOG_INFO << " sample rate " << c->sample_rate_hz << " Hz";
LOG_INFO << " XKF profile: "
<< (c->scenario_label.empty() ? std::string("(unnamed)") : c->scenario_label)
<< " (type " << static_cast<int>(c->scenario_type) << ')';
LOG_INFO << " available (* = active): " << imuProfileList();
}
// `imu` command family, mirroring handleGimbal: the operator-facing surface of
// IImuSource. `imu norotation` + `imu zero` are the quick heading calibration
// that gimbal calib runs internally, without the multi-minute sweep.
void handleImu(const std::string& line) {
std::istringstream iss(line);
std::vector<std::string> tok;
std::string t;
while (iss >> t) tok.push_back(t); // tok[0] == "imu"
if (tok.size() < 2) {
LOG_WARN << "usage: imu <status|config|profile|zero|norotation>";
return;
}
if (!imu) {
LOG_WARN << "no IMU (needs [Features] enable_imu, or mock_imu)";
return;
}
std::string sub = tok[1];
for (char& ch : sub) ch = static_cast<char>(std::tolower((unsigned char)ch));
if (sub == "status") {
imuStatus();
} else if (sub == "config") {
imuConfig();
} else if (sub == "profile") {
imuProfile(tok.size() > 2 ? tok[2] : "");
} else if (sub == "zero") {
// Yaw becomes relative to wherever the unit is pointing NOW, so say so:
// run mid-scan this silently re-references every later CamEvent heading.
LOG_INFO << "IMU heading reset: this pose is now yaw 0";
imu->headingReset();
} else if (sub == "norotation") {
int secs = 3; // matches CalibParams::norotation_s
if (tok.size() > 2) {
try { secs = std::stoi(tok[2]); } catch (...) {
LOG_WARN << "usage: imu norotation [<seconds>]";
return;
}
}
if (secs < 1) secs = 1;
// Non-blocking: the device runs the update on its own clock, so the
// operator - not this command - is what keeps the unit still.
LOG_INFO << "IMU gyro-bias update started: hold the unit still for " << secs << " s";
imu->noRotation(secs);
} else {
LOG_WARN << "unknown imu command '" << sub
<< "'; usage: imu <status|config|profile|zero|norotation>";
}
}
std::unique_ptr<IUserInterface> makeUi() { std::unique_ptr<IUserInterface> makeUi() {
bool want = opts.use_tui.value_or(cfg.ui.enable_tui); bool want = opts.use_tui.value_or(cfg.ui.enable_tui);
#if FGC_WITH_TUI #if FGC_WITH_TUI
@ -1345,6 +1492,8 @@ struct Application::Impl {
cancelActiveProcedure(); cancelActiveProcedure();
} else if (c.verb == "gimbal") { } else if (c.verb == "gimbal") {
handleGimbal(line); handleGimbal(line);
} else if (c.verb == "imu") {
handleImu(line);
} else if (c.verb == "calib") { } else if (c.verb == "calib") {
// Answer to the "save calibration?" prompt (activity-strip y/n keys). // Answer to the "save calibration?" prompt (activity-strip y/n keys).
if (c.device == "save") saveCalibration(); if (c.device == "save") saveCalibration();

View File

@ -27,16 +27,16 @@ const std::vector<HelpSection>& helpCatalog() {
{"Positioning", "Aim the gimbal. 'move' is degrees; 'steps' is raw encoder counts.", { {"Positioning", "Aim the gimbal. 'move' is degrees; 'steps' is raw encoder counts.", {
{"gimbal move <yaw>,<pitch>", {"gimbal move <yaw>,<pitch>",
"Point the gimbal at an absolute heading/elevation in DEGREES.", { "Point the gimbal at an absolute heading/elevation in DEGREES.", {
"Converts degrees to encoder counts (operator-calibrated) and sends a", "Converts to encoder counts with the operator calibration and moves both",
"two-axis move so both axes start together; soft-clamped to travel limits.", "axes together, soft-clamped to the travel limits.",
"Example: gimbal move 30,-10 (yaw 30 deg, pitch -10 deg)"}}, "Example: gimbal move 30,-10 (yaw 30 deg, pitch -10 deg)"}},
{"gimbal steps <yaw>,<pitch>", {"gimbal steps <yaw>,<pitch>",
"Move both axes to absolute encoder COUNTS (no degree conversion).", { "Move both axes to absolute encoder COUNTS (no degree conversion).", {
"Example: gimbal steps 100000,250000"}}, "Example: gimbal steps 100000,250000"}},
{"gimbal nudge <yaw|pitch> <+/-pct>", {"gimbal nudge <yaw|pitch> <+/-pct>",
"Relative step move by a percent of the axis travel.", { "Relative step move by a percent of the axis travel.", {
"Arrow keys do this when NO overlay is open: Left/Right = yaw -/+5%,", "The arrow keys do this when no overlay is open; inside an overlay they",
"Up/Down = pitch +/-10%. Inside an overlay the arrows scroll instead.", "scroll instead.",
"Example: gimbal nudge yaw -5"}}, "Example: gimbal nudge yaw -5"}},
{"gimbal home [y|p]", {"gimbal home [y|p]",
"Run the endstop-finding home sequence (both axes, or one).", { "Run the endstop-finding home sequence (both axes, or one).", {
@ -48,44 +48,65 @@ const std::vector<HelpSection>& helpCatalog() {
{"gimbal reset [y|p] / gimbal enable|disable <y|p> / gimbal setpos <y|p> <v>", {"gimbal reset [y|p] / gimbal enable|disable <y|p> / gimbal setpos <y|p> <v>",
"Other firmware motor controls.", {}}, "Other firmware motor controls.", {}},
}}, }},
{"IMU", "Read and configure the Xsens MTi orientation sensor.", {
{"imu status",
"Print the latest orientation reading.", {
"Yaw/pitch/roll, device temperature and sample counter. The Sensors panel",
"and the 'i' view show the same data continuously in the TUI."}},
{"imu config",
"Re-read the device configuration and print it.", {
"Identity, firmware, sample rate, active XKF profile and the available",
"list. Re-reads first, so it never reports a stale profile."}},
{"imu profile [<type|label>]",
"Select the XKF filter profile (persists on the device).", {
"The profile decides whether heading uses the magnetometer: 'gimbal calib'",
"leaves the device on a no-mag one, where yaw is drift-corrected but not",
"compass-referenced. Typical MTi profiles are General, High_mag_dep,",
"Dynamic and VRU_general; run with no argument for this device's real list.",
"Example: imu profile General / imu profile 39"}},
{"imu zero",
"Redefine the current pose as yaw 0 (heading reset).", {
"Run 'imu norotation' first so the gyro bias is corrected. Mid-scan this",
"re-references every later CamEvent heading."}},
{"imu norotation [<seconds>]",
"Run the gyro-bias update; hold the unit still (default 3 s).", {
"Cuts heading drift, which matters most on a no-mag profile. The command",
"returns immediately - keeping the unit still is up to you.",
"Example: imu norotation 5"}},
}},
{"Diagnostics", "Inspect, self-test and calibrate the gimbal.", { {"Diagnostics", "Inspect, self-test and calibrate the gimbal.", {
{"gimbal dump", {"gimbal dump",
"Request a full firmware state dump and show it here.", { "Request a full firmware state dump and show it here.", {
"Captured DUMP BEGIN..END block (build, uptime, reset cause, per-axis", "Build, uptime, reset cause and the per-axis TMC5160 registers; also shown",
"TMC5160 registers); shown in the gimbal 'g' expanded view.", "in the gimbal 'g' view. Offline equivalent: ./firmware/dump.sh"}},
"Equivalent offline tool: ./firmware/dump.sh"}},
{"test [<subsystem> [<leaf>]] [<profile>]", {"test [<subsystem> [<leaf>]] [<profile>]",
"Run the hardware self-test suite; a text report is saved to logs/test_*.log.", { "Run the hardware self-test suite; a report lands in logs/test_*.log.", {
"Modular: 'test' runs everything; 'test gimbal' / 'test imu' / 'test host'", "'test' runs everything; a subsystem or leaf narrows it, and a trailing",
"narrow it; 'test gimbal homing' runs one leaf. A trailing token names a", "token names a config profile. Each run is compared to a pinned baseline",
"config profile (repetition counts + thresholds). Each run is compared to a", "and the previous run. Esc cancels.",
"pinned baseline and the previous run (drift flagged). Gimbal/IMU leaves need", "Example: test gimbal homing / test list / test baseline"}},
"a homed gimbal / the IMU; 'test host' (thermal/disk/memory) needs neither.",
"Esc cancels a running test. 'test baseline' pins the last report; 'test list'",
"shows profiles."}},
{"gimbal calib", {"gimbal calib",
"Calibrate steps<->degrees using the IMU (auto-homes; ~minutes).", { "Calibrate steps<->degrees using the IMU (auto-homes; ~minutes).", {
"Needs the IMU. Homes first if needed, calibrates PITCH at the first yaw", "Fits pitch then yaw, applies the result to the session and writes",
"position, moves pitch to 0, switches the IMU to a no-mag XKF profile", "logs/calib_*.log. Leaves the IMU on a no-mag XKF profile. 'gimbal stop'",
"(persists on the device), runs no-rotation + heading reset to zero yaw", "cancels."}},
"drift, then calibrates YAW. Applies the fit to the session and writes",
"logs/calib_*.log. 'gimbal stop' cancels."}},
{"gimbal status", {"gimbal status",
"Ask the firmware to emit one telemetry (ST) line now.", {}}, "Ask the firmware to emit one telemetry (ST) line now.", {}},
{"gimbal raw \"<command>\"", {"gimbal raw \"<command>\"",
"Low-level: send the quoted text to the firmware verbatim (a newline is", { "Send the quoted text to the firmware verbatim.", {
"added automatically). Bypasses all wrappers/unit conversion for direct", "Bypasses all unit conversion and wrappers - use with care; sends exactly",
"firmware control — use with care; sends exactly what you type.", "what you type, plus a newline.",
"Example: gimbal raw \"MOVE 100000,250000\" / gimbal raw \"DUMP\""}}, "Example: gimbal raw \"MOVE 100000,250000\" / gimbal raw \"DUMP\""}},
}}, }},
{"Capture", "Control image capture and encoding.", { {"Capture", "Control image capture and encoding.", {
{"start", "Begin the capture scan.", {}}, {"start", "Begin the capture scan.", {}},
{"stop", "Halt the capture scan.", {}}, {"stop", "Halt the capture scan.", {}},
{"set fps <rate>", "Set capture rate in images/second.", {}}, {"set fps <rate>", "Set capture rate in images/second.", {}},
{"set camera mode <name>", "Switch the camera mode (acquisition rate, exposure " {"set camera mode <name>",
"limits, imaging settings).", { "Switch the camera mode (rate, exposure limits, imaging settings).", {
"Modes are files in [Camera] mode_dir - one .ini per mode; the name is the", "Modes are files in [Camera] mode_dir, one .ini each; the name is the",
"filename stem. Example: set camera mode night"}}, "filename stem.",
"Example: set camera mode night"}},
{"set camera jxlq <dist>", "Set JPEG-XL distance (lower = higher quality).", {}}, {"set camera jxlq <dist>", "Set JPEG-XL distance (lower = higher quality).", {}},
{"set camera jxle <effort>", "Set JPEG-XL encode effort.", {}}, {"set camera jxle <effort>", "Set JPEG-XL encode effort.", {}},
{"set camera display <0|1>", "Toggle the local display window.", {}}, {"set camera display <0|1>", "Toggle the local display window.", {}},
@ -99,32 +120,30 @@ const std::vector<HelpSection>& helpCatalog() {
{"Display", "Terminal dashboard keys (TUI only).", { {"Display", "Terminal dashboard keys (TUI only).", {
{"? / g / i / c", {"? / g / i / c",
"Toggle the Help, Gimbal, Sensors and Camera overlays.", { "Toggle the Help, Gimbal, Sensors and Camera overlays.", {
"An overlay takes over the body, so it gets the full terminal height.", "An overlay takes the full terminal height. Esc closes it; with none open,",
"Esc closes it; with no overlay open, Esc cancels a running procedure."}}, "Esc cancels a running procedure."}},
{"Up/Down, j/k", {"Up/Down, j/k",
"Scroll the open overlay one line (Help: change section).", { "Scroll the open overlay one line (Help: change section).", {
"The scrollbar on the right shows how much is off-screen."}}, "The scrollbar on the right shows how much is off-screen."}},
{"PageUp/PageDown", {"PageUp/PageDown",
"Scroll the open overlay a screenful; on the dashboard, the log.", { "Scroll the open overlay a screenful; on the dashboard, the log.", {
"Paging the log freezes it: new lines keep arriving but the view", "Paging the log freezes it: new lines keep arriving but the view holds",
"holds still. The LOG title shows 'scrollback' while it is frozen."}}, "still, and the LOG title shows 'scrollback'."}},
{"Home/End", {"Home/End",
"Jump to the top/bottom; on the log, End resumes following the tail.", {}}, "Jump to the top/bottom; on the log, End resumes following the tail.", {}},
{"p", {"p",
"Freeze the 10 Hz repaint so terminal text selection survives.", { "Freeze the 10 Hz repaint so terminal text selection survives.", {
"Any keypress still repaints, so scrolling while paused drops a", "Any keypress repaints, so scrolling while paused drops the selection -",
"selection - pause is for copying, not for reading scrollback."}}, "pause is for copying, not for reading scrollback."}},
}}, }},
{"Session", "Help and exit.", { {"Session", "Help and exit.", {
{"refresh", {"refresh",
"Re-read live device state (TUI: press 'r').", { "Re-read live device state (TUI: press 'r').", {
"Re-queries the IMU configuration (XKF profile, output settings) and", "Re-queries the IMU configuration and requests a firmware dump, updating",
"requests a fresh firmware dump, updating the 'i' and 'g' expanded views.", "the 'i' and 'g' views. Briefly pauses the IMU stream."}},
"Use after changing the XKF profile externally. Briefly pauses the IMU",
"stream while it re-enters the device's Config state."}},
{"help [topic]", {"help [topic]",
"Show this reference; 'help <topic>' expands one section.", { "Show this reference; 'help <topic>' expands one section.", {
"Example: help positioning / help dump"}}, "Example: help positioning / help imu"}},
{"exit", "Shut down and quit.", {}}, {"exit", "Shut down and quit.", {}},
}}, }},
}; };
@ -137,7 +156,7 @@ std::vector<std::string> renderHelp(const std::string& topic) {
const std::string q = lower(topic); const std::string q = lower(topic);
if (q.empty()) { if (q.empty()) {
out.emplace_back("Available commands (type 'help <topic>' for detail, e.g. 'help goto'):"); out.emplace_back("Available commands (type 'help <topic>' for detail, e.g. 'help imu'):");
for (const auto& sec : helpCatalog()) { for (const auto& sec : helpCatalog()) {
out.emplace_back(""); out.emplace_back("");
out.emplace_back("== " + sec.title + " == " + sec.blurb); out.emplace_back("== " + sec.title + " == " + sec.blurb);

View File

@ -56,6 +56,24 @@ TEST_CASE("renderHelp(<verb>) matches the gimbal commands, case-insensitively")
CHECK(lower == upper); CHECK(lower == upper);
} }
TEST_CASE("renderHelp(imu) matches the whole imu family") {
// `imu` is both a section title and the verb of every entry in it, so the
// topic lookup must find the family either way.
std::string out = join(renderHelp("imu"));
for (const char* sub : {"imu status", "imu config", "imu profile", "imu zero",
"imu norotation"})
CHECK(out.find(sub) != std::string::npos);
CHECK(join(renderHelp("imu")) == join(renderHelp("IMU")));
}
TEST_CASE("help detail stays a short paragraph") {
// The catalog is operator-facing reference, not prose: cap it so entries do
// not creep back to half-screen blocks.
for (const auto& sec : helpCatalog())
for (const auto& e : sec.entries)
CHECK(e.detail.size() <= 5);
}
TEST_CASE("renderHelp(unknown) reports no match") { TEST_CASE("renderHelp(unknown) reports no match") {
std::string out = join(renderHelp("definitely-not-a-command")); std::string out = join(renderHelp("definitely-not-a-command"));
CHECK(out.find("No help topic") != std::string::npos); CHECK(out.find("No help topic") != std::string::npos);