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;
}
// 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() {
bool want = opts.use_tui.value_or(cfg.ui.enable_tui);
#if FGC_WITH_TUI
@ -1345,6 +1492,8 @@ struct Application::Impl {
cancelActiveProcedure();
} else if (c.verb == "gimbal") {
handleGimbal(line);
} else if (c.verb == "imu") {
handleImu(line);
} else if (c.verb == "calib") {
// Answer to the "save calibration?" prompt (activity-strip y/n keys).
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.", {
{"gimbal move <yaw>,<pitch>",
"Point the gimbal at an absolute heading/elevation in DEGREES.", {
"Converts degrees to encoder counts (operator-calibrated) and sends a",
"two-axis move so both axes start together; soft-clamped to travel limits.",
"Converts to encoder counts with the operator calibration and moves both",
"axes together, soft-clamped to the travel limits.",
"Example: gimbal move 30,-10 (yaw 30 deg, pitch -10 deg)"}},
{"gimbal steps <yaw>,<pitch>",
"Move both axes to absolute encoder COUNTS (no degree conversion).", {
"Example: gimbal steps 100000,250000"}},
{"gimbal nudge <yaw|pitch> <+/-pct>",
"Relative step move by a percent of the axis travel.", {
"Arrow keys do this when NO overlay is open: Left/Right = yaw -/+5%,",
"Up/Down = pitch +/-10%. Inside an overlay the arrows scroll instead.",
"The arrow keys do this when no overlay is open; inside an overlay they",
"scroll instead.",
"Example: gimbal nudge yaw -5"}},
{"gimbal home [y|p]",
"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>",
"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.", {
{"gimbal dump",
"Request a full firmware state dump and show it here.", {
"Captured DUMP BEGIN..END block (build, uptime, reset cause, per-axis",
"TMC5160 registers); shown in the gimbal 'g' expanded view.",
"Equivalent offline tool: ./firmware/dump.sh"}},
"Build, uptime, reset cause and the per-axis TMC5160 registers; also shown",
"in the gimbal 'g' view. Offline equivalent: ./firmware/dump.sh"}},
{"test [<subsystem> [<leaf>]] [<profile>]",
"Run the hardware self-test suite; a text report is saved to logs/test_*.log.", {
"Modular: 'test' runs everything; 'test gimbal' / 'test imu' / 'test host'",
"narrow it; 'test gimbal homing' runs one leaf. A trailing token names a",
"config profile (repetition counts + thresholds). Each run is compared to a",
"pinned baseline and the previous run (drift flagged). Gimbal/IMU leaves need",
"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."}},
"Run the hardware self-test suite; a report lands in logs/test_*.log.", {
"'test' runs everything; a subsystem or leaf narrows it, and a trailing",
"token names a config profile. Each run is compared to a pinned baseline",
"and the previous run. Esc cancels.",
"Example: test gimbal homing / test list / test baseline"}},
{"gimbal calib",
"Calibrate steps<->degrees using the IMU (auto-homes; ~minutes).", {
"Needs the IMU. Homes first if needed, calibrates PITCH at the first yaw",
"position, moves pitch to 0, switches the IMU to a no-mag XKF profile",
"(persists on the device), runs no-rotation + heading reset to zero yaw",
"drift, then calibrates YAW. Applies the fit to the session and writes",
"logs/calib_*.log. 'gimbal stop' cancels."}},
"Fits pitch then yaw, applies the result to the session and writes",
"logs/calib_*.log. Leaves the IMU on a no-mag XKF profile. 'gimbal stop'",
"cancels."}},
{"gimbal status",
"Ask the firmware to emit one telemetry (ST) line now.", {}},
{"gimbal raw \"<command>\"",
"Low-level: send the quoted text to the firmware verbatim (a newline is", {
"added automatically). Bypasses all wrappers/unit conversion for direct",
"firmware control — use with care; sends exactly what you type.",
"Send the quoted text to the firmware verbatim.", {
"Bypasses all unit conversion and wrappers - use with care; sends exactly",
"what you type, plus a newline.",
"Example: gimbal raw \"MOVE 100000,250000\" / gimbal raw \"DUMP\""}},
}},
{"Capture", "Control image capture and encoding.", {
{"start", "Begin the capture scan.", {}},
{"stop", "Halt the capture scan.", {}},
{"set fps <rate>", "Set capture rate in images/second.", {}},
{"set camera mode <name>", "Switch the camera mode (acquisition rate, exposure "
"limits, imaging settings).", {
"Modes are files in [Camera] mode_dir - one .ini per mode; the name is the",
"filename stem. Example: set camera mode night"}},
{"set camera mode <name>",
"Switch the camera mode (rate, exposure limits, imaging settings).", {
"Modes are files in [Camera] mode_dir, one .ini each; the name is the",
"filename stem.",
"Example: set camera mode night"}},
{"set camera jxlq <dist>", "Set JPEG-XL distance (lower = higher quality).", {}},
{"set camera jxle <effort>", "Set JPEG-XL encode effort.", {}},
{"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).", {
{"? / g / i / c",
"Toggle the Help, Gimbal, Sensors and Camera overlays.", {
"An overlay takes over the body, so it gets the full terminal height.",
"Esc closes it; with no overlay open, Esc cancels a running procedure."}},
"An overlay takes the full terminal height. Esc closes it; with none open,",
"Esc cancels a running procedure."}},
{"Up/Down, j/k",
"Scroll the open overlay one line (Help: change section).", {
"The scrollbar on the right shows how much is off-screen."}},
{"PageUp/PageDown",
"Scroll the open overlay a screenful; on the dashboard, the log.", {
"Paging the log freezes it: new lines keep arriving but the view",
"holds still. The LOG title shows 'scrollback' while it is frozen."}},
"Paging the log freezes it: new lines keep arriving but the view holds",
"still, and the LOG title shows 'scrollback'."}},
{"Home/End",
"Jump to the top/bottom; on the log, End resumes following the tail.", {}},
{"p",
"Freeze the 10 Hz repaint so terminal text selection survives.", {
"Any keypress still repaints, so scrolling while paused drops a",
"selection - pause is for copying, not for reading scrollback."}},
"Any keypress repaints, so scrolling while paused drops the selection -",
"pause is for copying, not for reading scrollback."}},
}},
{"Session", "Help and exit.", {
{"refresh",
"Re-read live device state (TUI: press 'r').", {
"Re-queries the IMU configuration (XKF profile, output settings) and",
"requests a fresh firmware dump, updating the 'i' and 'g' expanded views.",
"Use after changing the XKF profile externally. Briefly pauses the IMU",
"stream while it re-enters the device's Config state."}},
"Re-queries the IMU configuration and requests a firmware dump, updating",
"the 'i' and 'g' views. Briefly pauses the IMU stream."}},
{"help [topic]",
"Show this reference; 'help <topic>' expands one section.", {
"Example: help positioning / help dump"}},
"Example: help positioning / help imu"}},
{"exit", "Shut down and quit.", {}},
}},
};
@ -137,7 +156,7 @@ std::vector<std::string> renderHelp(const std::string& topic) {
const std::string q = lower(topic);
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()) {
out.emplace_back("");
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);
}
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") {
std::string out = join(renderHelp("definitely-not-a-command"));
CHECK(out.find("No help topic") != std::string::npos);