#include "fgc/HelpText.h" #include #include namespace fgc { namespace { std::string lower(std::string s) { std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return static_cast(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& helpCatalog() { // clang-format off static const std::vector 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 []", "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 ,", "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 ,", "Move both axes to absolute encoder COUNTS (no degree conversion).", { "Example: gimbal steps 100000,250000"}}, {"gimbal nudge <+/-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 <+/-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 '; 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 ", "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 [: ...]", "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 ", "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; bare, every axis. The axis holds where it stops.", { "To cancel a running procedure use 'cancel' (or Esc, or m):", "the whole 'gimbal' family is ignored while one is in progress."}}, {"gimbal speed ", "Set the max slew speed (counts/s) for an axis.", {}}, {"gimbal current ", "Set motor current, as raw TMC register fields.", {"Normally sent automatically from hardware.ini [Motor.], 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 / gimbal setpos ", "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 []", "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 []", "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 [ []] []", "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 \"\"", "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 ", "Set capture rate in images/second.", {}}, {"set camera profile [] ", "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.] 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 ", "Set JPEG-XL distance (lower = higher quality).", {}}, {"set camera jxle ", "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 [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 30 '. 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 ' expands one section.", { "Example: help positioning / help imu"}}, {"exit", "Shut down and quit.", {}}, }}, }; // clang-format on return catalog; } std::vector renderHelp(const std::string& topic) { std::vector out; const std::string q = lower(topic); if (q.empty()) { out.emplace_back("Available commands (type 'help ' 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