#include "fgc/SerialMotorController.h" #include "fgc/DumpParser.h" #include "fgc/Logger.h" #include "fgc/TelemetryParser.h" #include #include #include #include #include #include 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) {} std::string device; unsigned int baud; boost::asio::io_context io; boost::asio::serial_port serial; boost::asio::streambuf buffer; std::thread io_thread; std::mutex mutex; 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. 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; // DIAG capture: the firmware streams "DG ..." lines (interleaved with ST) // from "DG BEGIN" to "DG DONE". We accumulate the DG lines, log each one live // (so it appears in the LOG pane), and publish the completed block. bool diagging = false; std::string diag_buf; std::string latest_diag; std::atomic diag_seq{0}; // 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', [this](const boost::system::error_code& ec, std::size_t) { if (ec) { LOG_WARN << "Serial read failed: " << ec.message(); return; } std::istream is(&buffer); std::string line; std::getline(is, line); // Strip a trailing CR (firmware may send CRLF). if (!line.empty() && line.back() == '\r') line.pop_back(); dispatchLine(line); doRead(); }); } // Route an inbound line: ST -> telemetry; OK/ERR -> command ack; everything // 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); latest = *t; } else { LOG_TRACE_CAT(LogCat::Serial) << "RX(unparsed ST) " << line; } } else if (line.rfind("ERR", 0) == 0) { LOG_TRACE_CAT(LogCat::Serial) << "RX " << line; LOG_WARN << "Motor controller: " << line; } else if (!line.empty()) { // OK acks and other async output (DG/DUMP/BOOT/...). LOG_TRACE_CAT(LogCat::Serial) << "RX " << line; captureDump(line); captureDiag(line); } } // Assemble the DIAG stream. DG lines arrive interleaved with ST over many // seconds, so we accumulate only "DG " lines and log each live; on "DG DONE" // we publish the block and bump diag_seq so the app can pick up the result. void captureDiag(const std::string& line) { const bool begin = line.find("DG BEGIN") != std::string::npos; // Start on the FIRST BEGIN only; a per-axis "DG BEGIN P" mid-run must not // wipe the already-captured first axis. if (begin && !diagging) { diagging = true; diag_buf.clear(); } if (!diagging) return; if (line.rfind("DG ", 0) != 0 && !begin) return; // ignore non-DG lines diag_buf += line + "\n"; LOG_INFO << line; // stream to the LOG pane if (line.find("DG DONE") != std::string::npos) { { std::lock_guard lock(mutex); latest_diag = diag_buf; } ++diag_seq; diagging = false; diag_buf.clear(); } } // 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) { size_t begin = line.find("DUMP BEGIN"); if (begin != std::string::npos) { dumping = true; 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.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; } 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(); } }; SerialMotorController::SerialMotorController(std::string device, unsigned int baud) : impl_(std::make_unique(std::move(device), baud)) {} SerialMotorController::~SerialMotorController() { stop(); } void SerialMotorController::start() { namespace asio = boost::asio; boost::system::error_code ec; impl_->serial.open(impl_->device, ec); if (ec) { LOG_ERROR << "Failed to open serial port " << impl_->device << ": " << ec.message(); impl_->connected = false; return; } impl_->serial.set_option(asio::serial_port_base::baud_rate(impl_->baud)); impl_->serial.set_option(asio::serial_port_base::character_size(8)); impl_->serial.set_option( asio::serial_port_base::parity(asio::serial_port_base::parity::none)); impl_->serial.set_option( asio::serial_port_base::stop_bits(asio::serial_port_base::stop_bits::one)); impl_->serial.set_option( asio::serial_port_base::flow_control(asio::serial_port_base::flow_control::none)); impl_->connected = true; impl_->doRead(); impl_->io_thread = std::thread([this] { impl_->io.run(); }); LOG_INFO << "Serial controller started on " << impl_->device << " @ " << impl_->baud; } void SerialMotorController::stop() { if (!impl_) return; impl_->io.stop(); if (impl_->io_thread.joinable()) impl_->io_thread.join(); boost::system::error_code ec; if (impl_->serial.is_open()) impl_->serial.close(ec); 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; // 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() { std::lock_guard lock(impl_->mutex); return impl_->latest; } std::string SerialMotorController::lastDump() { std::lock_guard lock(impl_->mutex); return impl_->latest_dump; } std::string SerialMotorController::lastDiag() { std::lock_guard lock(impl_->mutex); return impl_->latest_diag; } unsigned SerialMotorController::diagSeq() const { return impl_->diag_seq.load(); } bool SerialMotorController::connected() const { return impl_->connected; } } // namespace fgc