fwt_software/src/core/HelpText.cpp

278 lines
16 KiB
C++

#include "fgc/HelpText.h"
#include <algorithm>
#include <cctype>
namespace fgc {
namespace {
std::string lower(std::string s) {
std::transform(s.begin(), s.end(), s.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return s;
}
// First whitespace-delimited token of a syntax string (the command verb), lowercased.
std::string verbOf(const std::string& syntax) {
auto end = syntax.find_first_of(" \t");
return lower(syntax.substr(0, end == std::string::npos ? syntax.size() : end));
}
} // namespace
const std::vector<HelpSection>& helpCatalog() {
// clang-format off
static const std::vector<HelpSection> catalog = {
{"Modes",
"What the tower is doing: auto, pan or manual. StatusCode on MQTT says which (0/1/2).", {
{"mode",
"Say which mode the tower is in (and, in pan, where it is looking).", {}},
{"mode auto",
"Sweep the scan grid, a picture at every waypoint. Key: a.", {
"The normal, unattended state; the boot service starts here (--start).",
"In auto and pan only reading commands, logging, 'mode', 'cancel' and",
"'exit' are accepted: nothing that moves the gimbal or changes a setting."}},
{"mode pan <heading> <span> [<pitch>]",
"Watch one direction: sweep a span centred on a bearing, repeatedly.", {
"Left to right across heading +/- span/2, one picture every pan_step_deg",
"(hardware.ini) or less, both edges included; then back to the left edge.",
"A span under one picture is the one heading. Pitch defaults to 0 (level).",
"Example: mode pan 90 30 (75, 81, ..., 105: six pictures). Key p pans",
"30 deg around where the gimbal points now."}},
{"mode manual",
"Stop capturing and take the gimbal by hand. Key: m.", {
"Every command is accepted. With a test, homing or calibration running,",
"cancels it instead (as Esc and 'cancel' do)."}},
}},
{"Positioning",
"Aim the gimbal. Yaw is a TRUE BEARING (0 = north); pitch is elevation (0 = level).", {
{"gimbal move <yaw>,<pitch>",
"Point at an absolute bearing/elevation in DEGREES.", {
"Converts to encoder counts with the [Motor] calibration and moves both",
"axes together. Yaw travel is under a full turn, so bearings in the",
"unreachable arc are refused rather than clamped to the nearest limit.",
"Manual mode only - refused while a scan is running.",
"Example: gimbal move 30,-10 (bearing 30 deg, 10 deg below level)"}},
{"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 move by a percent of the homed axis travel.", {
"The arrow keys do this when no overlay is open; inside an overlay they",
"scroll instead. Manual mode only.",
"Example: gimbal nudge yaw -5"}},
{"gimbal jog <yaw|pitch> <+/-counts>",
"Relative move in raw encoder counts.", {
"Finer than 'nudge' (a percent of travel) and relative unlike 'steps'.",
"Below ~13 counts the encoder cannot resolve the difference, so a smaller",
"step moves the motor to a position it cannot reproduce.",
"Example: gimbal jog yaw -25"}},
{"gimbal heading setup",
"Tie encoder counts to true north by sighting known landmarks.", {
"Aim at a landmark, then 'gimbal heading mark <its true bearing>'; repeat,",
"then 'gimbal heading fit'. Two landmarks minimum, the further apart the",
"better. On the dashboard it runs in the activity strip: arrows aim, Tab",
"step size, Enter mark, Space image, f fit, Esc cancel. Needs no IMU."}},
{"gimbal heading mark <deg>",
"Record where the gimbal points now as the landmark's true bearing.", {
"Only during 'gimbal heading setup'. Example: gimbal heading mark 137.4"}},
{"gimbal heading fit [<counts>:<deg> ...]",
"Fit the yaw map from the marks, or from (encoder position, bearing) pairs.", {
"Bare, it fits this heading setup's marks. With pairs, it needs no setup -",
"for a headless tower whose pairs are known. Two fit exactly; more are",
"least-squares fitted and report R^2. Pairs closer than 10 deg apart are",
"refused - the fit would look perfect and be wrong.",
"Example: gimbal heading fit 480000:45.0 520000:115.0"}},
{"gimbal heading cancel",
"Stop heading setup and drop its marks (so do 'cancel' and Esc).", {}},
{"gimbal heading offset <deg>",
"Shift the whole yaw map by a known amount and save it.", {
"For when the tower is found to be off by a fixed bearing and re-sighting",
"landmarks is not worth it.",
"Example: gimbal heading offset -2.5"}},
{"gimbal home [y|p]",
"Run the endstop-finding home sequence (both axes, or one).", {
"Example: gimbal home / gimbal home y"}},
{"gimbal stop [y|p|all]",
"Stop motion immediately.", {
"To cancel a running procedure use 'cancel' (or Esc, or x):",
"the whole 'gimbal' family is ignored while one is in progress."}},
{"gimbal speed <y|p> <vel>",
"Set the max slew speed (counts/s) for an axis.", {}},
{"gimbal current <y|p> <scaler> <irun> <irun_home>",
"Set motor current, as raw TMC register fields.",
{"Normally sent automatically from hardware.ini [Motor.<axis>], which states",
"the current in milliamps and lets the host convert it using this board's",
"sense resistor. Setting it by hand does not stay secret: the next home",
"checks the firmware's reported current against the configured one."}},
{"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 's' 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. Nothing here",
"affects the gimbal's own heading: that comes from the encoder, tied to",
"north by 'gimbal heading setup'. 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.", {
"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 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"}},
{"test hardware [axes|motor|imu|env|camera]",
"Check the rig against what hardware.ini declares.", {
"Preconditions, not measurements: the declared axes appear in telemetry and",
"no others; the firmware echoes the drivetrain current hardware.ini asked for;",
"a declared IMU streams; a declared ambient sensor sits on a REAL I2C bus (not",
"SMBus-only) and its CRC checks out; the cameras opened and the USB",
"preconditions hold. Nothing declared reports 'not checked', never PASS."}},
{"test camera [frames|dark|bright]",
"Check the camera works, whatever it is pointed at.", {
"frames: whole frames arrive in time. dark: minimum exposure and gain take",
"every pixel to black. bright: maximum gain and a long exposure make the",
"frame measurably different - the sensor is alive and took the settings.",
"The picture itself is never judged, so fog, night or a lens cap cannot",
"decide it. The profile is put back after; capture pauses meanwhile."}},
{"camera reset",
"Reboot the camera and put it back on its profile.", {
"Sends the camera its own DeviceReset, waits for it to come back on USB, and",
"re-applies the profile (manual mode). For a stream that has",
"stopped delivering frames and will not restart. The program also does this",
"by itself when restarting the stream stops helping."}},
{"gimbal pitch setup",
"Calibrate PITCH steps<->degrees against gravity, using the IMU.", {
"Auto-homes if needed, sweeps pitch dwelling at each step, fits, then",
"parks level. Writes logs/pitch_setup_*.log and offers to save. Takes",
"minutes; 'cancel' or Esc cancels. Requires the IMU - level is defined by",
"gravity and nothing else on the tower can measure it. Yaw is NOT touched:",
"use 'gimbal heading setup'."}},
{"gimbal status",
"Ask the firmware to emit one telemetry (ST) line now.", {}},
{"gimbal raw \"<command>\"",
"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.", {
{"set fps <rate>", "Set capture rate in images/second.", {}},
{"set camera profile [<camera>] <name>",
"Switch a camera's profile (rate, exposure, all imaging settings).", {
"A profile is a settings XML exported from the VimbaX Viewer, in that",
"camera's [Camera.<name>] profile_dir; the name is the filename stem. The",
"camera may be omitted when only one is configured.",
"Example: set camera profile night / set camera profile rgb 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.", {}},
}},
{"Logging", "Control console/log verbosity.", {
{"debug", "Toggle debug-level logging on/off.", {}},
{"trace <serial|mqtt|camera|control|all|off> [on|off]",
"Toggle verbatim wire-trace categories.", {
"Example: trace serial on / trace off"}},
}},
{"Display", "Terminal dashboard keys (TUI only).", {
{"a / p / m",
"Auto mode / pan around where the gimbal points now / manual mode.", {
"p is 'mode pan <current heading> 30 <current pitch>'. m also cancels a",
"running test, homing or calibration, as Esc does."}},
{"? / g / s / c / n",
"Toggle the Help, Gimbal, Sensors, Cameras and Connectivity views.", {
"A view takes the full terminal height; press its key again, or Esc, to",
"close it. With none open, Esc cancels a running procedure. 'n' shows",
"broker state, per-topic traffic and the recent MQTT messages."}},
{"Arrows",
"Nudge the gimbal: left/right yaw 5%, up/down pitch 10% (manual mode).", {
"In every view. During heading setup they aim in the step Tab selects."}},
{"PageUp/PageDown",
"Scroll the log, or the open view. The only scroll keys.", {
"The log and the message history jump back to the newest line when one",
"arrives; pause (f) first to read further back. In Help, j/k change",
"section."}},
{"Space",
"Take one image now and publish its CamEvent (manual mode only).", {
"Goes through the same settle-and-trigger path a scan waypoint uses, so",
"the file and the CamEvent are indistinguishable from a scan frame."}},
{"f",
"Freeze the screen: to select and copy text, or to scroll back.", {
"While frozen nothing new is drawn, so the log stays where you page it.",
"The header says when it froze. f again resumes."}},
{"q",
"Ask whether to quit or leave the dashboard running in the background.", {
"Quitting stops capture and closes the camera; backgrounding detaches the",
"tmux client (same as Ctrl-b d) and leaves everything running. Ctrl-C still",
"shuts down immediately."}},
}},
{"Session", "Help and exit.", {
{"help [topic]",
"Show this reference; 'help <topic>' expands one section.", {
"Example: help positioning / help imu"}},
{"exit", "Shut down and quit.", {}},
}},
};
// clang-format on
return catalog;
}
std::vector<std::string> renderHelp(const std::string& topic) {
std::vector<std::string> out;
const std::string q = lower(topic);
if (q.empty()) {
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);
for (const auto& e : sec.entries)
out.emplace_back(" " + e.syntax + " - " + e.summary);
}
return out;
}
// Topic mode: match a section by title, or an entry by command verb.
bool matched = false;
for (const auto& sec : helpCatalog()) {
const bool sec_match = lower(sec.title).find(q) != std::string::npos;
for (const auto& e : sec.entries) {
if (!sec_match && verbOf(e.syntax) != q) continue;
matched = true;
out.emplace_back(e.syntax);
out.emplace_back(" " + e.summary);
for (const auto& d : e.detail) out.emplace_back(" " + d);
out.emplace_back("");
}
}
if (!matched) out.emplace_back("No help topic matching '" + topic + "'. Try 'help'.");
return out;
}
} // namespace fgc