diff --git a/CMakeLists.txt b/CMakeLists.txt index 3efe459..44e6bab 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/include/fgc/HelpText.h b/include/fgc/HelpText.h new file mode 100644 index 0000000..61d366a --- /dev/null +++ b/include/fgc/HelpText.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include + +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 " + std::string summary; // one-line description + std::vector detail; // expanded lines: examples, units, notes +}; + +struct HelpSection { + std::string title; // e.g. "Positioning" + std::string blurb; // one line under the title + std::vector entries; +}; + +// The full catalog (static, built once). +const std::vector& helpCatalog(); + +// Flatten the catalog to printable lines for the console. +// topic == "" -> every section's title/blurb + one summary line per entry. +// topic == "" -> 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 renderHelp(const std::string& topic = ""); + +} // namespace fgc diff --git a/include/fgc/IMotorController.h b/include/fgc/IMotorController.h index 8ee5148..dd36433 100644 --- a/include/fgc/IMotorController.h +++ b/include/fgc/IMotorController.h @@ -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; }; diff --git a/include/fgc/SerialMotorController.h b/include/fgc/SerialMotorController.h index f57ecdd..997e50a 100644 --- a/include/fgc/SerialMotorController.h +++ b/include/fgc/SerialMotorController.h @@ -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: diff --git a/include/fgc/mock/MockMotorController.h b/include/fgc/mock/MockMotorController.h index 8f4c447..26bd1fe 100644 --- a/include/fgc/mock/MockMotorController.h +++ b/include/fgc/mock/MockMotorController.h @@ -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 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 diff --git a/include/fgc/ui/UiSnapshot.h b/include/fgc/ui/UiSnapshot.h index c0b275f..7cc786c 100644 --- a/include/fgc/ui/UiSnapshot.h +++ b/include/fgc/ui/UiSnapshot.h @@ -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 log; + DumpView dump; }; // ---- Pure formatting helpers (unit-tested in tests/test_uisnapshot.cpp) ---- diff --git a/src/core/Application.cpp b/src/core/Application.cpp index 6abe128..1586361 100644 --- a/src/core/Application.cpp +++ b/src/core/Application.cpp @@ -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 #include #include +#include #include #include @@ -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 ` — 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 (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(); diff --git a/src/core/HelpText.cpp b/src/core/HelpText.cpp new file mode 100644 index 0000000..6d85432 --- /dev/null +++ b/src/core/HelpText.cpp @@ -0,0 +1,118 @@ +#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 = { + {"Positioning", "Aim the gimbal. 'goto' is degrees; raw MOVE is encoder counts.", { + {"goto ", + "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 ,", + "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 ", + "Stop motion immediately on an axis or both.", {}}, + {"set motorctl SPEED ", + "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 ", "Set capture rate in images/second.", {}}, + {"set camera fps ", "Set the camera sensor frame rate.", {}}, + {"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"}}, + }}, + {"Session", "Help and exit.", { + {"help [topic]", + "Show this reference; 'help ' expands one section.", { + "Example: help positioning / help dump"}}, + {"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 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 diff --git a/src/serial/SerialMotorController.cpp b/src/serial/SerialMotorController.cpp index eb77aff..55567f9 100644 --- a/src/serial/SerialMotorController.cpp +++ b/src/serial/SerialMotorController.cpp @@ -25,6 +25,13 @@ struct SerialMotorController::Impl { MotorTelemetry latest; std::atomic 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 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 lock(impl_->mutex); + return impl_->latest_dump; +} + bool SerialMotorController::connected() const { return impl_->connected; } } // namespace fgc diff --git a/src/ui/TuiUi.cpp b/src/ui/TuiUi.cpp index 11eefd6..f91e0b4 100644 --- a/src/ui/TuiUi.cpp +++ b/src/ui/TuiUi.cpp @@ -1,8 +1,10 @@ #include "fgc/ui/TuiUi.h" +#include "fgc/HelpText.h" #include "fgc/Logger.h" #include +#include #include #include @@ -146,6 +148,48 @@ Element logPanel(const std::vector& 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 rows; + for (int i = 0; i < static_cast(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(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; } diff --git a/tests/test_scheduler.cpp b/tests/test_scheduler.cpp index 8b08076..cad4822 100644 --- a/tests/test_scheduler.cpp +++ b/tests/test_scheduler.cpp @@ -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; } };