From 5fb1d3103bf93215410919c3530e1c8f1fa84350 Mon Sep 17 00:00:00 2001 From: pgdalmeida Date: Tue, 23 Jun 2026 23:56:44 +0200 Subject: [PATCH] TUI improvements --- include/fgc/ui/UiSnapshot.h | 7 ++ src/core/Application.cpp | 20 +++++ src/serial/SerialMotorController.cpp | 122 +++++++++++++++++++++++---- src/ui/TuiUi.cpp | 98 ++++++++++++--------- 4 files changed, 192 insertions(+), 55 deletions(-) diff --git a/include/fgc/ui/UiSnapshot.h b/include/fgc/ui/UiSnapshot.h index 9691877..4b472e9 100644 --- a/include/fgc/ui/UiSnapshot.h +++ b/include/fgc/ui/UiSnapshot.h @@ -34,6 +34,13 @@ struct AxisView { bool overtemp = false; bool endstop_l = false; bool endstop_r = false; + // Homing travel limits (from the last firmware dump): the endstop positions + // found during homing, in encoder counts and converted to degrees. + bool has_limits = false; + long lim_neg = 0; + long lim_pos = 0; + double lim_neg_deg = 0.0; + double lim_pos_deg = 0.0; }; struct GimbalView { diff --git a/src/core/Application.cpp b/src/core/Application.cpp index c79af49..a2613d1 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/DumpParser.h" #include "fgc/HelpText.h" #include "fgc/ICameraSource.h" #include "fgc/IControlChannel.h" @@ -174,6 +175,23 @@ struct Application::Impl { fillAxis(s.gimbal.yaw, "YAW", t.yaw, cfg.geometry.yaw, yaw_tgt); fillAxis(s.gimbal.pitch, "PITCH", t.pitch, cfg.geometry.pitch, pitch_tgt); + // Homing travel limits come from the last firmware dump (lim_neg/lim_pos); + // convert the endstop counts to degrees via the per-axis geometry. + DumpData dd = parseDump(motor->lastDump()); + if (dd.valid) { + auto fillLimits = [](AxisView& v, const DumpAxis& a, const AxisMap& map) { + v.has_limits = true; + v.lim_neg = a.lim_neg; + v.lim_pos = a.lim_pos; + v.lim_neg_deg = map.toDeg(a.lim_neg); + v.lim_pos_deg = map.toDeg(a.lim_pos); + }; + for (const auto& a : dd.axes) { + if (a.axis == 'Y') fillLimits(s.gimbal.yaw, a, cfg.geometry.yaw); + else if (a.axis == 'P') fillLimits(s.gimbal.pitch, a, cfg.geometry.pitch); + } + } + // --- Sensors (DHT11 + MTi not integrated yet) --- s.sensors = pendingSensorsView(); @@ -231,6 +249,7 @@ struct Application::Impl { const auto t0 = std::chrono::steady_clock::now(); while (running && std::chrono::steady_clock::now() < t0 + 3s) { std::this_thread::sleep_for(100ms); + publishSnapshot(); // keep the TUI's gimbal panel live during homing if (!allReady(motor->telemetry())) break; // homing started } @@ -238,6 +257,7 @@ struct Application::Impl { const auto deadline = std::chrono::steady_clock::now() + 65s; while (running && std::chrono::steady_clock::now() < deadline) { std::this_thread::sleep_for(250ms); + publishSnapshot(); // keep the TUI's gimbal panel live during homing MotorTelemetry t = motor->telemetry(); if (t.yaw.state == AxisState::Error || (t.pitch_present && t.pitch.state == AxisState::Error)) { diff --git a/src/serial/SerialMotorController.cpp b/src/serial/SerialMotorController.cpp index 55567f9..0b9cdf6 100644 --- a/src/serial/SerialMotorController.cpp +++ b/src/serial/SerialMotorController.cpp @@ -1,9 +1,11 @@ #include "fgc/SerialMotorController.h" +#include "fgc/DumpParser.h" #include "fgc/Logger.h" #include "fgc/TelemetryParser.h" #include +#include #include #include #include @@ -12,6 +14,38 @@ namespace fgc { +namespace { + +// A firmware register value is always "0x" + exactly 8 hex digits (printHex8). +bool isHex8(const std::string& v) { + if (v.size() != 10 || v[0] != '0' || (v[1] != 'x' && v[1] != 'X')) return false; + for (size_t i = 2; i < 10; ++i) + if (!std::isxdigit(static_cast(v[i]))) return false; + return true; +} + +// True only if a captured DUMP block decodes cleanly: each axis present must +// carry all 16 TMC registers with well-formed 8-hex values. The USB read is +// occasionally lossy, so this gates whether a block is trustworthy or must be +// re-requested (see captureDump). +bool dumpComplete(const std::string& block) { + static const char* kRegs[] = { + "GCONF", "GSTAT", "IOIN", "TSTEP", "RAMPMODE", "XACTUAL", "VACTUAL", "XTARGET", + "SW_MODE", "RAMP_STAT", "X_ENC", "ENC_STATUS", "CHOPCONF", "DRV_STATUS", + "PWM_SCALE", "PWM_AUTO"}; + DumpData d = parseDump(block); + if (!d.valid || d.axes.empty()) return false; + for (const auto& ax : d.axes) { + for (const char* r : kRegs) { + auto it = ax.regs.find(r); + if (it == ax.regs.end() || !isHex8(it->second)) return false; + } + } + return true; +} + +} // namespace + struct SerialMotorController::Impl { Impl(std::string dev, unsigned int b) : device(std::move(dev)), baud(b), serial(io) {} @@ -27,11 +61,36 @@ struct SerialMotorController::Impl { // 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. + // completed block (latest_dump) for the UI / `dump` command. The USB read is + // occasionally lossy on the big dump burst, so an incomplete block is + // auto-re-requested up to dump_attempts times before we give up. + static constexpr int kMaxDumpAttempts = 8; bool dumping = false; + int dump_attempts = 0; // remaining tries for the in-flight dump std::string dump_buf; std::string latest_dump; + // Write one command line (newline-terminated) to the controller. Used by + // sendCommand and by the internal DUMP re-request. + // + // The write is initiated on the io_context thread (via post), NOT the + // caller's thread. asio I/O objects are not thread-safe for concurrent + // operations: issuing async_write from the main thread (e.g. the capture + // scheduler's MOVEs, or a TUI command) while the io_thread runs async_read + // on the same serial_port races the reader and corrupts inbound data — this + // was shredding the DUMP burst whenever the link was busy. + void sendLine(const std::string& cmd) { + auto data = std::make_shared(cmd + "\n"); + auto trace = std::make_shared(cmd); + boost::asio::post(io, [this, data, trace] { + LOG_TRACE_CAT(LogCat::Serial) << "TX " << *trace; + boost::asio::async_write(serial, boost::asio::buffer(*data), + [data](const boost::system::error_code& ec, std::size_t) { + if (ec) LOG_WARN << "Serial write failed: " << ec.message(); + }); + }); + } + void doRead() { boost::asio::async_read_until( serial, buffer, '\n', @@ -54,6 +113,11 @@ struct SerialMotorController::Impl { // else (DG/DUMP/BOOT/IOIN/GSTAT...) is async output we just trace. void dispatchLine(const std::string& line) { if (line.rfind("ST ", 0) == 0 || line == "ST") { + // A status line means the firmware has finished any dump in progress. + // If we were still assembling one, its DUMP END was lost on the wire — + // finalize now so the block is validated and (if incomplete) retried, + // rather than hanging forever waiting for an END that never arrives. + if (dumping) finalizeDump(); if (auto t = parseTelemetryLine(line)) { LOG_TRACE_CAT(LogCat::Serial) << "RX " << line; std::lock_guard lock(mutex); @@ -71,25 +135,43 @@ struct SerialMotorController::Impl { } } - // 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. + // Assemble the multi-line DUMP block. Detection of BEGIN/END must tolerate + // corruption: on a lossy read a dropped newline merges "DUMP END" onto the + // tail of the previous line (e.g. "...PWM_AUTO=0x...DUMP END"), so we search + // for the markers anywhere in the line, not just at the start. A merged/short + // block then fails dumpComplete() and is re-requested (see finalizeDump). void captureDump(const std::string& line) { - if (line.rfind("DUMP BEGIN", 0) == 0) { + size_t begin = line.find("DUMP BEGIN"); + if (begin != std::string::npos) { dumping = true; - dump_buf = line + "\n"; + dump_buf = line.substr(begin) + "\n"; // drop any junk before BEGIN + if (line.find("DUMP END", begin) != std::string::npos) finalizeDump(); return; } if (!dumping) return; dump_buf += line + "\n"; - if (line.rfind("DUMP END", 0) == 0) { - dumping = false; + if (line.find("DUMP END") != std::string::npos) finalizeDump(); + } + + void finalizeDump() { + dumping = false; + if (!dumpComplete(dump_buf) && dump_attempts > 1) { + // Lossy read — re-request rather than publish a corrupt block. + --dump_attempts; + LOG_WARN << "firmware dump incomplete (lossy read); re-requesting (" + << dump_attempts << " attempt(s) left)"; + sendLine("DUMP"); + } else { { std::lock_guard lock(mutex); latest_dump = dump_buf; } - LOG_INFO << "firmware dump:\n" << dump_buf; - dump_buf.clear(); + if (dumpComplete(dump_buf)) LOG_INFO << "firmware dump:\n" << dump_buf; + else LOG_WARN << "firmware dump still incomplete after retries; showing best effort:\n" + << dump_buf; + dump_attempts = 0; } + dump_buf.clear(); } }; @@ -131,15 +213,23 @@ void SerialMotorController::stop() { impl_->connected = false; } +// Case-insensitive check for a bare "DUMP" command (optionally trailing ws). +static bool isDumpCommand(const std::string& cmd) { + size_t i = 0, n = cmd.size(); + while (n > 0 && std::isspace(static_cast(cmd[n - 1]))) --n; + while (i < n && std::isspace(static_cast(cmd[i]))) ++i; + std::string t = cmd.substr(i, n - i); + if (t.size() != 4) return false; + return (t[0] == 'D' || t[0] == 'd') && (t[1] == 'U' || t[1] == 'u') && + (t[2] == 'M' || t[2] == 'm') && (t[3] == 'P' || t[3] == 'p'); +} + void SerialMotorController::sendCommand(const std::string& cmd) { if (!impl_->connected) return; - LOG_TRACE_CAT(LogCat::Serial) << "TX " << cmd; - // The firmware requires a newline terminator on every command. - auto data = std::make_shared(cmd + "\n"); - boost::asio::async_write(impl_->serial, boost::asio::buffer(*data), - [data](const boost::system::error_code& ec, std::size_t) { - if (ec) LOG_WARN << "Serial write failed: " << ec.message(); - }); + // Arm dump-integrity retries when the operator requests a dump (the internal + // re-request goes through Impl::sendLine, which does not re-arm). + if (isDumpCommand(cmd)) impl_->dump_attempts = Impl::kMaxDumpAttempts; + impl_->sendLine(cmd); } MotorTelemetry SerialMotorController::telemetry() { diff --git a/src/ui/TuiUi.cpp b/src/ui/TuiUi.cpp index c9f1491..e22af54 100644 --- a/src/ui/TuiUi.cpp +++ b/src/ui/TuiUi.cpp @@ -230,6 +230,14 @@ Element axisLiveDetail(const AxisView& a) { badge("L", a.endstop_l, Color::Yellow), badge("R", a.endstop_r, Color::Yellow)}), }; + // Homing limits (endstops found at homing), degrees then raw counts. + if (a.has_limits) { + rows.push_back(hbox({ + text("homing lim") | dim | size(WIDTH, EQUAL, 12), + text(formatDegrees(a.lim_neg_deg) + " .. " + formatDegrees(a.lim_pos_deg)) | bold, + text(" (" + std::to_string(a.lim_neg) + ".." + std::to_string(a.lim_pos) + ")") | dim, + })); + } return vbox(std::move(rows)); } @@ -269,44 +277,51 @@ Element axisDumpDetail(const DumpAxis& ax) { }); } -// Full-screen gimbal view (the takeover panel toggled with 'g'): live telemetry -// for both axes on the left, the decoded firmware register dump on the right. -Element gimbalDetailPanel(const GimbalView& g, const DumpView& dump) { - // Live column. - std::vector live; - if (!g.present) live.push_back(text("link down") | color(Color::Red) | bold); - live.push_back(axisLiveDetail(g.yaw)); - if (g.pitch_present) { - live.push_back(separator()); - live.push_back(axisLiveDetail(g.pitch)); - } - Element live_col = panel("GIMBAL · live", Color::Cyan, vbox(std::move(live))); +// One axis's full panel: live telemetry on top, then its decoded firmware +// registers. Rendered per axis so the two axes sit side by side and every row +// (incl. RAMP_STAT, the last one) is visible without scrolling. +Element axisColumn(const std::string& title, const AxisView& live, const DumpData& d, + char letter) { + std::vector col; + col.push_back(axisLiveDetail(live)); + col.push_back(separator()); - // Register column (decoded firmware dump). - std::vector regs; + const DumpAxis* ax = nullptr; + if (d.valid) + for (const auto& a : d.axes) + if (a.axis == letter) ax = &a; + if (ax) + col.push_back(axisDumpDetail(*ax)); + else + col.push_back(text(d.valid ? "(axis absent from dump)" + : "(awaiting dump - press 'd')") | dim); + + return panel(title, Color::Cyan, vbox(std::move(col)) | yframe); +} + +// Full-screen gimbal view (toggled with 'g'): one column per axis, each with +// live telemetry above its decoded firmware register dump. +Element gimbalDetailPanel(const GimbalView& g, const DumpView& dump) { DumpData d = parseDump(dump.text); - if (!d.valid) { - regs.push_back(text("(requesting firmware dump...)") | dim); - regs.push_back(text("press 'd' to refresh") | dim); - } else { - regs.push_back(hbox({text("build " + d.build) | dim, filler(), - text("up " + std::to_string(d.uptime_ms) + "ms") | dim})); - { - std::string rc; - for (size_t i = 0; i < d.reset_flags.size(); ++i) - rc += (i ? " " : "") + d.reset_flags[i]; - regs.push_back(text("reset: " + rc) | dim); - } - for (const auto& ax : d.axes) { - regs.push_back(separator()); - regs.push_back(axisDumpDetail(ax)); - } - } - Element reg_col = panel("GIMBAL · firmware registers", Color::Cyan, - vbox(std::move(regs)) | yframe); + + std::string reset; + for (size_t i = 0; i < d.reset_flags.size(); ++i) + reset += (i ? " " : "") + d.reset_flags[i]; + Element header = hbox({ + (g.present ? text(" link up ") | color(Color::Green) + : text(" link down ") | color(Color::Red) | bold), + filler(), + text(d.valid ? ("build " + d.build + " up " + std::to_string(d.uptime_ms) + + "ms reset:" + reset) + : std::string("no dump yet")) | dim, + }); + + std::vector cols; + cols.push_back(axisColumn("YAW", g.yaw, d, 'Y')); + if (g.pitch_present) cols.push_back(axisColumn("PITCH", g.pitch, d, 'P')); return window(text(" GIMBAL (g/Esc:close d:refresh dump) ") | bold | color(Color::Cyan), - hbox({live_col, reg_col})); + vbox({header, separator(), hbox(std::move(cols)) | flex})); } } // namespace @@ -391,13 +406,18 @@ void TuiUi::uiLoop() { }); } - Element main; + // An open overlay takes over the whole body (the dashboard panels are + // hidden) so it has the full height — otherwise tall content like the + // gimbal register dump overflows the small log-sized area and clips. switch (overlay) { - case Overlay::Help: main = helpPanel(help_sel, s.dump); break; - case Overlay::Gimbal: main = gimbalDetailPanel(s.gimbal, s.dump); break; - default: main = logPanel(s.log); break; + case Overlay::Help: + return vbox({header, separator(), helpPanel(help_sel, s.dump) | flex, bottom}); + case Overlay::Gimbal: + return vbox({header, separator(), + gimbalDetailPanel(s.gimbal, s.dump) | flex, bottom}); + default: + return vbox({header, separator(), top, middle, logPanel(s.log) | flex, bottom}); } - return vbox({header, separator(), top, middle, main | flex, bottom}); }); auto root = CatchEvent(renderer, [&](Event e) {