added help info

This commit is contained in:
pgdalmeida 2026-06-23 15:22:31 +02:00
parent 09b032b998
commit 0b62fef9c7
Signed by: pedro.almeida
GPG Key ID: D4A6C394DF13F1D7
11 changed files with 317 additions and 3 deletions

View File

@ -50,6 +50,7 @@ add_library(fgc_core STATIC
src/core/TelemetryParser.cpp
src/core/CaptureScheduler.cpp
src/core/CommandParser.cpp
src/core/HelpText.cpp
src/ui/UiSnapshot.cpp
src/ui/HeadlessUi.cpp
ini.c

35
include/fgc/HelpText.h Normal file
View File

@ -0,0 +1,35 @@
#pragma once
#include <string>
#include <vector>
namespace fgc {
// Operator command reference, modelled as data so the headless console and the
// TUI render from one source of truth (avoids the two drifting apart). The
// console prints renderHelp(); the TUI walks helpCatalog() to draw its inline
// help pane.
struct HelpEntry {
std::string syntax; // e.g. "goto <yaw_deg> <pitch_deg>"
std::string summary; // one-line description
std::vector<std::string> detail; // expanded lines: examples, units, notes
};
struct HelpSection {
std::string title; // e.g. "Positioning"
std::string blurb; // one line under the title
std::vector<HelpEntry> entries;
};
// The full catalog (static, built once).
const std::vector<HelpSection>& helpCatalog();
// Flatten the catalog to printable lines for the console.
// topic == "" -> every section's title/blurb + one summary line per entry.
// topic == "<x>" -> the matching section(s)/entry(ies) with their detail lines.
// Matching is case-insensitive against section titles and the first token of
// each entry's syntax (the command verb).
std::vector<std::string> renderHelp(const std::string& topic = "");
} // namespace fgc

View File

@ -53,6 +53,11 @@ public:
// Latest telemetry snapshot (thread-safe in implementations).
virtual MotorTelemetry telemetry() = 0;
// The most recently completed firmware DUMP block (the text between
// "DUMP BEGIN" and "DUMP END", inclusive), or "" if none has been captured.
// Thread-safe in implementations.
virtual std::string lastDump() = 0;
// Whether the underlying link is usable.
virtual bool connected() const = 0;
};

View File

@ -20,6 +20,7 @@ public:
void stop() override;
void sendCommand(const std::string& cmd) override;
MotorTelemetry telemetry() override;
std::string lastDump() override;
bool connected() const override;
private:

View File

@ -46,6 +46,20 @@ public:
} else if (verb == "STOP") {
yaw_target_ = yaw_.xenc;
pitch_target_ = pitch_.xenc;
} else if (verb == "DUMP") {
// Mirror the firmware DUMP block so the `dump` path is demoable
// without hardware. Values are illustrative, not simulated.
std::ostringstream d;
d << "DUMP BEGIN build=mock uptime_ms=0 reset=POR\n"
<< "Y state=" << (homed_ ? "READY" : "RESET")
<< " xactual=" << yaw_.xenc << " xenc=" << yaw_.xenc
<< " DRV_STATUS=80084000 GSTAT=00\n"
<< "P state=" << (homed_ ? "READY" : "RESET")
<< " xactual=" << pitch_.xenc << " xenc=" << pitch_.xenc
<< " DRV_STATUS=80084000 GSTAT=00\n"
<< "DUMP END\n";
dump_ = d.str();
LOG_INFO << "firmware dump:\n" << dump_;
}
// ENABLE/DISABLE/SPEED/SETPOS/RESET: accepted, no simulation effect.
}
@ -61,6 +75,11 @@ public:
return t;
}
std::string lastDump() override {
std::lock_guard<std::mutex> lock(mutex_);
return dump_;
}
bool connected() const override { return true; }
private:
@ -91,6 +110,7 @@ private:
long yaw_target_ = 0;
long pitch_target_ = 0;
bool homed_ = false;
std::string dump_;
};
} // namespace fgc

View File

@ -92,6 +92,12 @@ struct LogLine {
std::string text; // formatted line (no trailing newline)
};
// Most recent firmware DUMP block (raw text), surfaced in the TUI help pane.
struct DumpView {
bool has = false;
std::string text;
};
struct UiSnapshot {
HeaderView header;
GimbalView gimbal;
@ -99,6 +105,7 @@ struct UiSnapshot {
CaptureView capture;
ConnView conn;
std::vector<LogLine> log;
DumpView dump;
};
// ---- Pure formatting helpers (unit-tested in tests/test_uisnapshot.cpp) ----

View File

@ -2,6 +2,7 @@
#include "fgc/CaptureScheduler.h"
#include "fgc/CommandParser.h"
#include "fgc/HelpText.h"
#include "fgc/ICameraSource.h"
#include "fgc/IControlChannel.h"
#include "fgc/IMotorController.h"
@ -21,6 +22,7 @@
#include <memory>
#include <mutex>
#include <queue>
#include <sstream>
#include <string>
#include <thread>
@ -195,6 +197,10 @@ struct Application::Impl {
s.conn.control_code = scheduler ? scheduler->controlCode() : 0;
s.conn.target_heading = scheduler ? scheduler->targetHeading() : "0";
s.conn.last_status_code = s.conn.control_code; // echoed back as status
// --- Diagnostics (last firmware DUMP) ---
s.dump.text = motor->lastDump();
s.dump.has = !s.dump.text.empty();
return s;
}
@ -277,12 +283,39 @@ struct Application::Impl {
LOG_INFO << "trace categories: " << traceNames(Logger::categories());
}
// `goto <yaw_deg> <pitch_deg>` — aim the gimbal at an absolute heading and
// elevation in degrees. Converts to encoder counts via the operator-calibrated
// Geometry maps (which soft-clamp to the travel limits) and issues a two-axis
// MOVE so both axes start together.
void handleGoto(const std::string& line) {
std::istringstream iss(line);
std::string verb;
double yaw_deg = 0.0, pitch_deg = 0.0;
if (!(iss >> verb >> yaw_deg >> pitch_deg)) {
LOG_WARN << "usage: goto <yaw_deg> <pitch_deg> (e.g. goto 30 -10)";
return;
}
long yc = cfg.geometry.yaw.toCounts(yaw_deg);
long pc = cfg.geometry.pitch.toCounts(pitch_deg);
LOG_INFO << "goto yaw=" << yaw_deg << "deg pitch=" << pitch_deg
<< "deg -> MOVE " << yc << "," << pc;
motor->sendCommand("MOVE " + std::to_string(yc) + "," + std::to_string(pc));
}
void handleCommand(const std::string& line) {
Command c = parseCommand(line);
if (c.empty()) return;
LOG_TRACE_CAT(LogCat::Control) << "cmd " << line;
if (c.verb == "exit") {
if (c.verb == "help") {
// c.device holds the optional topic (first token after "help").
for (const auto& l : renderHelp(c.device)) LOG_INFO << l;
} else if (c.verb == "goto") {
handleGoto(line);
} else if (c.verb == "dump") {
LOG_INFO << "requesting firmware dump...";
motor->sendCommand("DUMP");
} else if (c.verb == "exit") {
running = false;
} else if (c.verb == "start") {
startCapture();

118
src/core/HelpText.cpp Normal file
View File

@ -0,0 +1,118 @@
#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 = {
{"Positioning", "Aim the gimbal. 'goto' is degrees; raw MOVE is encoder counts.", {
{"goto <yaw_deg> <pitch_deg>",
"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. Degrees are soft-clamped to",
"the configured travel limits.",
"Example: goto 30 -10 (yaw 30 deg, pitch -10 deg)"}},
{"set motorctl MOVE <yaw>,<pitch>",
"Move both axes to absolute encoder counts (no degree conversion).", {
"Example: set motorctl MOVE 100000,250000",
"Single axis: set motorctl MOVE Y 100000 / set motorctl MOVE P 250000"}},
{"set motorctl HOME [Y|P]",
"Run the endstop-finding home sequence (both axes, or one).", {
"Example: set motorctl HOME / set motorctl HOME Y"}},
{"set motorctl STOP <Y|P|ALL>",
"Stop motion immediately on an axis or both.", {}},
{"set motorctl SPEED <Y|P> <vel>",
"Set the max slew speed (counts/s) for an axis.", {}},
}},
{"Diagnostics", "Inspect the firmware/driver state for debugging.", {
{"dump",
"Request a full firmware state dump and show it here.", {
"Sends DUMP to the firmware; the captured DUMP BEGIN..END block (build,",
"uptime, reset cause, and per-axis TMC5160 registers) is logged and, in",
"the TUI, shown in the Diagnostics help section.",
"Equivalent offline tool: ./firmware/dump.sh"}},
{"set motorctl DIAG [Y|P|ALL]",
"Run the motor self-test (emits DG lines, ends with DG DONE).", {
"Each axis is swept at several speeds/directions; results stream as DG",
"lines in the log. Axis must be homed first."}},
{"set motorctl STATUS",
"Ask the firmware to emit one telemetry (ST) line now.", {}},
}},
{"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 fps <rate>", "Set the camera sensor frame rate.", {}},
{"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"}},
}},
{"Session", "Help and exit.", {
{"help [topic]",
"Show this reference; 'help <topic>' expands one section.", {
"Example: help positioning / help dump"}},
{"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 goto'):");
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

View File

@ -25,6 +25,13 @@ struct SerialMotorController::Impl {
MotorTelemetry latest;
std::atomic<bool> connected{false};
// DUMP capture: the firmware emits a "DUMP BEGIN" ... "DUMP END" block in
// response to a DUMP command. We accumulate it line-by-line and publish the
// completed block (latest_dump) for the UI / `dump` command.
bool dumping = false;
std::string dump_buf;
std::string latest_dump;
void doRead() {
boost::asio::async_read_until(
serial, buffer, '\n',
@ -60,6 +67,28 @@ struct SerialMotorController::Impl {
} else if (!line.empty()) {
// OK acks and other async output (DG/DUMP/BOOT/...).
LOG_TRACE_CAT(LogCat::Serial) << "RX " << line;
captureDump(line);
}
}
// Assemble the multi-line DUMP block. On completion, store it and emit it
// once at INFO so it's visible regardless of the SERIAL trace toggle.
void captureDump(const std::string& line) {
if (line.rfind("DUMP BEGIN", 0) == 0) {
dumping = true;
dump_buf = line + "\n";
return;
}
if (!dumping) return;
dump_buf += line + "\n";
if (line.rfind("DUMP END", 0) == 0) {
dumping = false;
{
std::lock_guard<std::mutex> lock(mutex);
latest_dump = dump_buf;
}
LOG_INFO << "firmware dump:\n" << dump_buf;
dump_buf.clear();
}
}
};
@ -118,6 +147,11 @@ MotorTelemetry SerialMotorController::telemetry() {
return impl_->latest;
}
std::string SerialMotorController::lastDump() {
std::lock_guard<std::mutex> lock(impl_->mutex);
return impl_->latest_dump;
}
bool SerialMotorController::connected() const { return impl_->connected; }
} // namespace fgc

View File

@ -1,8 +1,10 @@
#include "fgc/ui/TuiUi.h"
#include "fgc/HelpText.h"
#include "fgc/Logger.h"
#include <chrono>
#include <sstream>
#include <vector>
#include <ftxui/component/component.hpp>
@ -146,6 +148,48 @@ Element logPanel(const std::vector<LogLine>& lines) {
vbox(std::move(rows)) | focusPositionRelative(0, 1) | yframe);
}
// Inline help pane (toggled with '?'). Lists every command section; the
// `sel`-th section is expanded to show each entry's detail. The Diagnostics
// section additionally renders the last captured firmware DUMP block.
Element helpPanel(int sel, const DumpView& dump) {
const auto& cat = helpCatalog();
std::vector<Element> rows;
for (int i = 0; i < static_cast<int>(cat.size()); ++i) {
const HelpSection& sec = cat[i];
const bool open = (i == sel);
Element title = hbox({
text(open ? " v " : " > "),
text(sec.title) | bold,
text(" " + sec.blurb) | dim,
});
rows.push_back(open ? (title | inverted) : title);
if (!open) continue;
for (const auto& e : sec.entries) {
rows.push_back(hbox({text(" "), text(e.syntax) | color(Color::Cyan) | bold}));
rows.push_back(hbox({text(" "), text(e.summary) | dim}));
for (const auto& d : e.detail)
rows.push_back(hbox({text(" "), text(d) | dim}));
}
// Diagnostics: show the most recent firmware dump inline.
if (sec.title == "Diagnostics") {
rows.push_back(text(" --- last firmware dump ---") | bold);
if (dump.has) {
std::istringstream iss(dump.text);
std::string l;
while (std::getline(iss, l))
rows.push_back(hbox({text(" "), text(l) | color(Color::Green)}));
} else {
rows.push_back(hbox({text(" "),
text("(none captured yet - run 'dump')") | dim}));
}
}
rows.push_back(text(""));
}
return window(text(" HELP (?:close Up/Down:section) ") | bold | color(Color::Cyan),
vbox(std::move(rows)) | yframe);
}
} // namespace
TuiUi::TuiUi() = default;
@ -190,6 +234,8 @@ void TuiUi::refreshLoop() {
void TuiUi::uiLoop() {
std::string cmd_buffer;
bool command_mode = false;
bool help_open = false;
int help_sel = 0;
auto input = Input(&cmd_buffer, "type a command, Enter to run, Esc to cancel");
@ -219,11 +265,13 @@ void TuiUi::uiLoop() {
} else {
bottom = hbox({
keyHint("s", "Start"), keyHint("x", "Stop"), keyHint("h", "Home"),
keyHint("r", "Reset"), keyHint(":", "Cmd"), filler(), keyHint("q", "Quit"),
keyHint("r", "Reset"), keyHint(":", "Cmd"), keyHint("?", "Help"),
filler(), keyHint("q", "Quit"),
});
}
return vbox({header, separator(), top, middle, logPanel(s.log) | flex, bottom});
Element main = help_open ? helpPanel(help_sel, s.dump) : logPanel(s.log);
return vbox({header, separator(), top, middle, main | flex, bottom});
});
auto root = CatchEvent(renderer, [&](Event e) {
@ -241,8 +289,19 @@ void TuiUi::uiLoop() {
}
return false; // let the Input edit the buffer
}
const int n = static_cast<int>(helpCatalog().size());
if (help_open) { // help pane navigation
if (e == Event::ArrowDown) { help_sel = (help_sel + 1) % n; return true; }
if (e == Event::ArrowUp) { help_sel = (help_sel - 1 + n) % n; return true; }
if (e == Event::Escape) { help_open = false; return true; }
}
if (!e.is_character()) return false;
const std::string& c = e.character();
if (c == "?") { help_open = !help_open; return true; }
if (help_open) { // vim-style section nav while help is open
if (c == "j") { help_sel = (help_sel + 1) % n; return true; }
if (c == "k") { help_sel = (help_sel - 1 + n) % n; return true; }
}
if (c == "q") { if (sink_) sink_("exit"); return true; }
if (c == "s") { if (sink_) sink_("start"); return true; }
if (c == "x") { if (sink_) sink_("stop"); return true; }

View File

@ -16,6 +16,7 @@ struct FakeMotor : IMotorController {
void stop() override {}
void sendCommand(const std::string& c) override { cmds.push_back(c); }
MotorTelemetry telemetry() override { return tel; }
std::string lastDump() override { return ""; }
bool connected() const override { return true; }
};