diff --git a/CMakeLists.txt b/CMakeLists.txt index 44e6bab..a5027b2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -51,6 +51,7 @@ add_library(fgc_core STATIC src/core/CaptureScheduler.cpp src/core/CommandParser.cpp src/core/HelpText.cpp + src/core/DumpParser.cpp src/ui/UiSnapshot.cpp src/ui/HeadlessUi.cpp ini.c diff --git a/include/fgc/DumpParser.h b/include/fgc/DumpParser.h new file mode 100644 index 0000000..957dc0b --- /dev/null +++ b/include/fgc/DumpParser.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include +#include + +namespace fgc { + +// Structured decode of a firmware DUMP block (the text between "DUMP BEGIN" and +// "DUMP END"). Mirrors firmware/tools/decode_dump.py, which is authoritative for +// the on-wire field names and the TMC status-register bit tables. + +struct DumpAxis { + char axis = '?'; // 'Y' (yaw) / 'P' (pitch) + std::string state_name = "?"; // BOOT/RESET/HOMING/READY/ERROR + bool enabled = false; + bool eeprom_restored = false; + bool has_encoder = false; + long lim_neg = 0; + long lim_pos = 0; + long hold_target = 0; + long speed = 0; + int hsub = 0; + + // Raw register hex strings as received, keyed by name (GCONF, DRV_STATUS...). + std::map regs; + + // Decoded status registers + their set-bit names. + unsigned drv_status = 0; + unsigned gstat = 0; + unsigned ramp_stat = 0; + int cs_actual = 0; // (DRV_STATUS >> 16) & 0x1F + int sg_result = 0; // DRV_STATUS & 0x3FF + std::vector drv_flags; + std::vector gstat_flags; + std::vector ramp_flags; +}; + +struct DumpData { + bool valid = false; + std::string build; + long uptime_ms = 0; + unsigned mcusr = 0; + long free_ram = 0; + std::vector reset_flags; + std::vector axes; +}; + +// Parse a raw DUMP block. Returns {valid=false} for empty/malformed input (no +// "DUMP BEGIN .. DUMP END" pair). +DumpData parseDump(const std::string& block); + +// Human-readable lines for a decoded dump (console-reusable pretty print). +std::vector formatDump(const DumpData& d); + +} // namespace fgc diff --git a/include/fgc/mock/MockMotorController.h b/include/fgc/mock/MockMotorController.h index 26bd1fe..49d7e0a 100644 --- a/include/fgc/mock/MockMotorController.h +++ b/include/fgc/mock/MockMotorController.h @@ -47,17 +47,30 @@ public: 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. + // Mirror the real firmware DUMP wire format (see firmware/src/motor.cpp + // and main.cpp) so the host-side DumpParser is exercised without + // hardware. Register values are illustrative, not simulated; the + // encoder positions track the mock's live state. + const int st = homed_ ? 3 : 1; // READY : RESET 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"; + d << "DUMP BEGIN build=mock uptime=0 mcusr=0x01 free_ram=4096\n"; + auto axis = [&](char L, const AxisTelemetry& a) { + d << "DUMP " << L << " state=" << st + << " hsub=0 enabled=" << (homed_ ? 1 : 0) + << " lim_neg=0 lim_pos=1000000 hold_target=" << a.xenc + << " speed=200000 eeprom_restored=1 has_encoder=1\n"; + d << "DUMP " << L << " TMC GCONF=0x00000004 GSTAT=0x00000000" + << " IOIN=0x30000008 TSTEP=0x000fffff RAMPMODE=0x00000000" + << " XACTUAL=0x" << std::hex << (a.xactual & 0xffffffff) + << " VACTUAL=0x00000000 XTARGET=0x" << (a.xenc & 0xffffffff) << std::dec + << " SW_MODE=0x00000000 RAMP_STAT=0x00000300 X_ENC=0x" << std::hex + << (a.xenc & 0xffffffff) << std::dec + << " ENC_STATUS=0x00000000 CHOPCONF=0x10410150" + << " DRV_STATUS=0x80084000 PWM_SCALE=0x00000008 PWM_AUTO=0x00000000\n"; + }; + axis('Y', yaw_); + axis('P', pitch_); + d << "DUMP END\n"; dump_ = d.str(); LOG_INFO << "firmware dump:\n" << dump_; } diff --git a/include/fgc/ui/UiSnapshot.h b/include/fgc/ui/UiSnapshot.h index 7cc786c..9691877 100644 --- a/include/fgc/ui/UiSnapshot.h +++ b/include/fgc/ui/UiSnapshot.h @@ -24,6 +24,10 @@ struct AxisView { long xactual = 0; long xenc = 0; double target_deg = 0.0; // current scheduler target + int sg = 0; // SG_RESULT (live, from ST line) + int cs = 0; // CS_ACTUAL + int pwm = 0; // PWM_SCALE_SUM + unsigned drv_status = 0; // DRV_STATUS register bool moving = false; bool standstill = false; bool stall = false; diff --git a/src/core/Application.cpp b/src/core/Application.cpp index 1586361..c79af49 100644 --- a/src/core/Application.cpp +++ b/src/core/Application.cpp @@ -158,6 +158,10 @@ struct Application::Impl { v.xactual = a.xactual; v.xenc = a.xenc; v.target_deg = map.toDeg(target_counts); + v.sg = a.sg; + v.cs = a.cs; + v.pwm = a.pwm; + v.drv_status = a.drv_status; v.moving = a.moving(); v.standstill = a.standstill; v.stall = a.stall; diff --git a/src/core/DumpParser.cpp b/src/core/DumpParser.cpp new file mode 100644 index 0000000..f9e7a20 --- /dev/null +++ b/src/core/DumpParser.cpp @@ -0,0 +1,216 @@ +#include "fgc/DumpParser.h" + +#include +#include +#include + +namespace fgc { + +namespace { + +// Bit tables, copied verbatim from firmware/tools/decode_dump.py (keep in sync). +const char* stateName(int s) { + switch (s) { + case 0: return "BOOT"; + case 1: return "RESET"; + case 2: return "HOMING"; + case 3: return "READY"; + case 4: return "ERROR"; + default: return "?"; + } +} + +struct Bit { int shift; const char* name; }; + +const std::array kMcusr = {{ + {0, "PORF (power-on)"}, {1, "EXTRF (external)"}, {2, "BORF (brown-out)"}, + {3, "WDRF (watchdog)"}, {4, "JTRF (jtag)"}, +}}; +const std::array kDrv = {{ + {31, "stst"}, {30, "olb"}, {29, "ola"}, {28, "s2gb"}, {27, "s2ga"}, + {26, "otpw"}, {25, "ot"}, {24, "stallguard"}, {15, "fsactive"}, + {14, "stealth"}, {12, "s2vsb"}, {11, "s2vsa"}, +}}; +const std::array kGstat = {{ + {0, "reset"}, {1, "drv_err"}, {2, "uv_cp"}, +}}; +const std::array kRamp = {{ + {0, "stop_l"}, {1, "stop_r"}, {4, "event_stop_l"}, {5, "event_stop_r"}, + {6, "event_stop_sg"}, {7, "event_pos_reached"}, {8, "velocity_reached"}, + {9, "position_reached"}, {10, "vzero"}, {13, "status_sg"}, +}}; + +template +std::vector bitsSet(unsigned val, const std::array& table) { + std::vector out; + for (const auto& b : table) + if (val & (1u << b.shift)) out.emplace_back(b.name); + if (out.empty()) out.emplace_back("-"); + return out; +} + +// Parse "key=value key=value ..." into a map. +std::map kvPairs(const std::string& s) { + std::map m; + std::istringstream iss(s); + std::string tok; + while (iss >> tok) { + auto eq = tok.find('='); + if (eq != std::string::npos) m[tok.substr(0, eq)] = tok.substr(eq + 1); + } + return m; +} + +// Integer from a decimal or "0x"-prefixed hex string; `fallback` on failure. +long asInt(const std::string& s, long fallback = 0) { + if (s.empty()) return fallback; + try { + size_t pos = 0; + bool hex = s.size() > 1 && s[0] == '0' && (s[1] == 'x' || s[1] == 'X'); + long v = std::stol(s, &pos, hex ? 16 : 10); + return v; + } catch (const std::exception&) { + return fallback; + } +} + +std::string get(const std::map& m, const std::string& k, + const std::string& fallback = "") { + auto it = m.find(k); + return it == m.end() ? fallback : it->second; +} + +std::string join(const std::vector& v) { + std::string out; + for (size_t i = 0; i < v.size(); ++i) out += (i ? " " : "") + v[i]; + return out; +} + +} // namespace + +DumpData parseDump(const std::string& block) { + DumpData d; + if (block.empty()) return d; + + // Split into lines, locate the BEGIN..END envelope. + std::vector lines; + { + std::istringstream iss(block); + std::string l; + while (std::getline(iss, l)) { + if (!l.empty() && l.back() == '\r') l.pop_back(); + lines.push_back(l); + } + } + size_t begin = lines.size(), end = lines.size(); + for (size_t i = 0; i < lines.size(); ++i) { + if (begin == lines.size() && lines[i].rfind("DUMP BEGIN", 0) == 0) begin = i; + if (lines[i].rfind("DUMP END", 0) == 0) { end = i; break; } + } + if (begin == lines.size() || end <= begin) return d; + + // Header. + auto hdr = kvPairs(lines[begin].substr(std::string("DUMP BEGIN").size())); + d.build = get(hdr, "build"); + d.uptime_ms = asInt(get(hdr, "uptime", "0")); + d.mcusr = static_cast(asInt(get(hdr, "mcusr", "0"))); + d.free_ram = asInt(get(hdr, "free_ram", "0")); + d.reset_flags = bitsSet(d.mcusr, kMcusr); + + // Per-axis lines: "DUMP state=.." and "DUMP TMC ..". + auto axisFor = [&d](char a) -> DumpAxis& { + for (auto& ax : d.axes) + if (ax.axis == a) return ax; + d.axes.push_back(DumpAxis{}); + d.axes.back().axis = a; + return d.axes.back(); + }; + + for (size_t i = begin + 1; i < end; ++i) { + const std::string& l = lines[i]; + if (l.rfind("DUMP ", 0) != 0 || l.size() < 7) continue; + char a = l[5]; + if (a != 'Y' && a != 'P') continue; + // Skip past "DUMP X" and any whitespace; the payload is either + // "state=.." or "TMC GCONF=..". (substr(6) alone leaves a leading space, + // which would make the "TMC" prefix check below miss.) + std::string rest = l.substr(6); + size_t nb = rest.find_first_not_of(" \t"); + rest = (nb == std::string::npos) ? "" : rest.substr(nb); + DumpAxis& ax = axisFor(a); + + if (rest.rfind("TMC", 0) == 0) { + auto m = kvPairs(rest.substr(3)); + ax.regs = m; + ax.drv_status = static_cast(asInt(get(m, "DRV_STATUS", "0"))); + ax.gstat = static_cast(asInt(get(m, "GSTAT", "0"))); + ax.ramp_stat = static_cast(asInt(get(m, "RAMP_STAT", "0"))); + ax.cs_actual = static_cast((ax.drv_status >> 16) & 0x1F); + ax.sg_result = static_cast(ax.drv_status & 0x3FF); + ax.drv_flags = bitsSet(ax.drv_status, kDrv); + ax.gstat_flags = bitsSet(ax.gstat, kGstat); + ax.ramp_flags = bitsSet(ax.ramp_stat, kRamp); + } else { + auto m = kvPairs(rest); + ax.state_name = stateName(static_cast(asInt(get(m, "state", "-1")))); + ax.hsub = static_cast(asInt(get(m, "hsub", "0"))); + ax.enabled = asInt(get(m, "enabled", "0")) != 0; + ax.eeprom_restored = asInt(get(m, "eeprom_restored", "0")) != 0; + ax.has_encoder = asInt(get(m, "has_encoder", "0")) != 0; + ax.lim_neg = asInt(get(m, "lim_neg", "0")); + ax.lim_pos = asInt(get(m, "lim_pos", "0")); + ax.hold_target = asInt(get(m, "hold_target", "0")); + ax.speed = asInt(get(m, "speed", "0")); + } + } + + d.valid = true; + return d; +} + +std::vector formatDump(const DumpData& d) { + std::vector out; + if (!d.valid) { + out.emplace_back("(no firmware dump captured)"); + return out; + } + { + std::ostringstream os; + os << "build=" << d.build << " uptime=" << d.uptime_ms << " ms free_ram=" + << d.free_ram << " bytes"; + out.push_back(os.str()); + } + { + std::ostringstream os; + os << "reset cause (mcusr=0x" << std::hex << d.mcusr << std::dec << "): " + << join(d.reset_flags); + out.push_back(os.str()); + } + for (const auto& ax : d.axes) { + out.emplace_back(""); + { + std::ostringstream os; + os << "[" << ax.axis << "] state=" << ax.state_name + << " enabled=" << (ax.enabled ? 1 : 0) + << " limits=[" << ax.lim_neg << ".." << ax.lim_pos << "]" + << " hold_target=" << ax.hold_target + << " eeprom_restored=" << (ax.eeprom_restored ? 1 : 0) + << " has_encoder=" << (ax.has_encoder ? 1 : 0); + out.push_back(os.str()); + } + for (const char* k : {"GCONF", "CHOPCONF", "XACTUAL", "X_ENC", "XTARGET", "VACTUAL"}) { + auto it = ax.regs.find(k); + if (it != ax.regs.end()) out.push_back(std::string(" ") + k + " = " + it->second); + } + out.push_back(" DRV_STATUS = " + get(ax.regs, "DRV_STATUS", "?") + " [" + + join(ax.drv_flags) + "] CS_ACTUAL=" + std::to_string(ax.cs_actual) + + " SG_RESULT=" + std::to_string(ax.sg_result)); + out.push_back(" GSTAT = " + get(ax.regs, "GSTAT", "?") + " [" + + join(ax.gstat_flags) + "]"); + out.push_back(" RAMP_STAT = " + get(ax.regs, "RAMP_STAT", "?") + " [" + + join(ax.ramp_flags) + "]"); + } + return out; +} + +} // namespace fgc diff --git a/src/ui/TuiUi.cpp b/src/ui/TuiUi.cpp index f91e0b4..c9f1491 100644 --- a/src/ui/TuiUi.cpp +++ b/src/ui/TuiUi.cpp @@ -1,9 +1,12 @@ #include "fgc/ui/TuiUi.h" +#include "fgc/DumpParser.h" #include "fgc/HelpText.h" #include "fgc/Logger.h" #include +#include +#include #include #include @@ -190,6 +193,122 @@ Element helpPanel(int sel, const DumpView& dump) { vbox(std::move(rows)) | yframe); } +// Aligned "label: value" row for the detail view. +Element kvRow(const std::string& k, const std::string& v, Color vc = Color::Default) { + return hbox({text(k) | dim | size(WIDTH, EQUAL, 12), text(v) | color(vc)}); +} + +std::string hex8(unsigned v) { + char buf[11]; + std::snprintf(buf, sizeof(buf), "0x%08X", v); + return buf; +} + +std::string get_or(const std::map& m, const std::string& k) { + auto it = m.find(k); + return it == m.end() ? "?" : it->second; +} + +// Live per-axis block: everything we get from the ST telemetry line. +Element axisLiveDetail(const AxisView& a) { + Element state = text(std::string(" ") + axisStateLabel(a.state) + " ") + | color(toColor(axisStateColor(a.state))) | bold; + std::vector rows = { + hbox({text(a.label + " ") | bold, state, filler(), + text(formatDegrees(a.deg) + " -> " + formatDegrees(a.target_deg)) | bold}), + kvRow("xactual", std::to_string(a.xactual)), + kvRow("xenc", std::to_string(a.xenc)), + kvRow("SG_RESULT", std::to_string(a.sg)), + kvRow("CS_ACTUAL", std::to_string(a.cs)), + kvRow("PWM", std::to_string(a.pwm)), + kvRow("DRV_STATUS", hex8(a.drv_status), Color::Cyan), + hbox({text("flags") | dim | size(WIDTH, EQUAL, 12), + badge("STILL", a.standstill, Color::GrayLight), + badge("MOVE", a.moving, Color::Cyan), + badge("STALL", a.stall, Color::Red), + badge("OT", a.overtemp, Color::Red), + badge("L", a.endstop_l, Color::Yellow), + badge("R", a.endstop_r, Color::Yellow)}), + }; + return vbox(std::move(rows)); +} + +// Decoded register block for one axis from a parsed firmware dump. +Element axisDumpDetail(const DumpAxis& ax) { + auto reg = [&](const char* k) -> Element { + auto it = ax.regs.find(k); + return kvRow(k, it == ax.regs.end() ? "-" : it->second); + }; + auto flags = [](const std::vector& f) { + std::string s; + for (size_t i = 0; i < f.size(); ++i) s += (i ? " " : "") + f[i]; + return s; + }; + auto statusRow = [&](const char* name, const std::string& raw, + const std::vector& f) { + return hbox({text(name) | dim | size(WIDTH, EQUAL, 12), + text(raw + " ") | color(Color::Cyan), + text("[" + flags(f) + "]") | dim}); + }; + return vbox({ + hbox({text(std::string(1, ax.axis) + " ") | bold, + text(ax.state_name) | color(Color::Green) | bold, + text(" enabled=" + std::to_string(ax.enabled ? 1 : 0)) | dim, + text(" enc=" + std::to_string(ax.has_encoder ? 1 : 0)) | dim, + text(" eeprom=" + std::to_string(ax.eeprom_restored ? 1 : 0)) | dim}), + kvRow("limits", "[" + std::to_string(ax.lim_neg) + ".." + std::to_string(ax.lim_pos) + "]"), + kvRow("hold_tgt", std::to_string(ax.hold_target)), + reg("GCONF"), reg("CHOPCONF"), reg("XACTUAL"), reg("X_ENC"), + reg("XTARGET"), reg("VACTUAL"), + statusRow("DRV_STATUS", get_or(ax.regs, "DRV_STATUS"), ax.drv_flags), + hbox({text("") | size(WIDTH, EQUAL, 12), + text("CS_ACTUAL=" + std::to_string(ax.cs_actual) + + " SG_RESULT=" + std::to_string(ax.sg_result)) | dim}), + statusRow("GSTAT", get_or(ax.regs, "GSTAT"), ax.gstat_flags), + statusRow("RAMP_STAT", get_or(ax.regs, "RAMP_STAT"), ax.ramp_flags), + }); +} + +// 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))); + + // Register column (decoded firmware dump). + std::vector regs; + 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); + + return window(text(" GIMBAL (g/Esc:close d:refresh dump) ") | bold | color(Color::Cyan), + hbox({live_col, reg_col})); +} + } // namespace TuiUi::TuiUi() = default; @@ -234,8 +353,10 @@ void TuiUi::refreshLoop() { void TuiUi::uiLoop() { std::string cmd_buffer; bool command_mode = false; - bool help_open = false; + enum class Overlay { None, Help, Gimbal }; + Overlay overlay = Overlay::None; // which takeover panel owns the main area int help_sel = 0; + bool gimbal_dump_requested = false; // auto-pull a dump the first time auto input = Input(&cmd_buffer, "type a command, Enter to run, Esc to cancel"); @@ -265,12 +386,17 @@ void TuiUi::uiLoop() { } else { bottom = hbox({ keyHint("s", "Start"), keyHint("x", "Stop"), keyHint("h", "Home"), - keyHint("r", "Reset"), keyHint(":", "Cmd"), keyHint("?", "Help"), - filler(), keyHint("q", "Quit"), + keyHint("r", "Reset"), keyHint("g", "Gimbal"), keyHint(":", "Cmd"), + keyHint("?", "Help"), filler(), keyHint("q", "Quit"), }); } - Element main = help_open ? helpPanel(help_sel, s.dump) : logPanel(s.log); + Element main; + 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; + } return vbox({header, separator(), top, middle, main | flex, bottom}); }); @@ -290,15 +416,34 @@ 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 (overlay == Overlay::Help) { // 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 (overlay != Overlay::None && e == Event::Escape) { overlay = Overlay::None; 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 == "?") { + overlay = (overlay == Overlay::Help) ? Overlay::None : Overlay::Help; + return true; + } + if (c == "g") { + if (overlay == Overlay::Gimbal) { + overlay = Overlay::None; + } else { + overlay = Overlay::Gimbal; + if (!gimbal_dump_requested) { // auto-pull a dump the first time + if (sink_) sink_("dump"); + gimbal_dump_requested = true; + } + } + return true; + } + if (overlay == Overlay::Gimbal && c == "d") { // manual dump refresh + if (sink_) sink_("dump"); + return true; + } + if (overlay == Overlay::Help) { // 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; } }