TUI improvements

This commit is contained in:
pgdalmeida 2026-06-23 23:56:44 +02:00
parent df42a4fc69
commit 5fb1d3103b
Signed by: pedro.almeida
GPG Key ID: D4A6C394DF13F1D7
4 changed files with 192 additions and 55 deletions

View File

@ -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 {

View File

@ -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)) {

View File

@ -1,9 +1,11 @@
#include "fgc/SerialMotorController.h"
#include "fgc/DumpParser.h"
#include "fgc/Logger.h"
#include "fgc/TelemetryParser.h"
#include <atomic>
#include <cctype>
#include <istream>
#include <mutex>
#include <thread>
@ -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<unsigned char>(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<std::string>(cmd + "\n");
auto trace = std::make_shared<std::string>(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<std::mutex> 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<std::mutex> 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<unsigned char>(cmd[n - 1]))) --n;
while (i < n && std::isspace(static_cast<unsigned char>(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<std::string>(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() {

View File

@ -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<Element> 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<Element> col;
col.push_back(axisLiveDetail(live));
col.push_back(separator());
// Register column (decoded firmware dump).
std::vector<Element> 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<Element> 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) {