#include #include "fgc/DumpParser.h" #include #include using namespace fgc; namespace { // A realistic two-axis firmware DUMP block (the on-wire format from // firmware/src/motor.cpp printDump): header, then per axis a state line and a // TMC register line, bracketed by DUMP BEGIN / DUMP END. const char* kDump = "DUMP BEGIN build=11dd3ce-dirty uptime=11740067 mcusr=0x01 free_ram=1852\n" "DUMP Y state=3 hsub=10 enabled=1 lim_neg=411500 lim_pos=588500 hold_target=499910 " "speed=50000 center=500000 soft_margin=5000 hold_corr=7 hold_peak=430 hold_cumul=2810 " "has_encoder=1\n" "DUMP Y TMC GCONF=0x0000000C GSTAT=0x00000000 IOIN=0x30000008 TSTEP=0x000FFFFF " "RAMPMODE=0x00000000 XACTUAL=0xFFFFFFA6 VACTUAL=0x00000000 XTARGET=0xFFFFFFA6 " "SW_MODE=0x000008A0 RAMP_STAT=0x00001680 X_ENC=0xFFFFFF7F ENC_STATUS=0x00000002 " "CHOPCONF=0x00410043 DRV_STATUS=0x80084000 PWM_SCALE=0x00000011 PWM_AUTO=0x003F0039\n" "DUMP P state=3 hsub=10 enabled=1 lim_neg=213070 lim_pos=786930 hold_target=500183 " "speed=150000 center=500000 has_encoder=1\n" "DUMP P TMC GCONF=0x0000000C GSTAT=0x00000000 IOIN=0x30000008 TSTEP=0x000FFFFF " "RAMPMODE=0x00000000 XACTUAL=0x0007A1C8 VACTUAL=0x00000000 XTARGET=0x0007A1C8 " "SW_MODE=0x000008A3 RAMP_STAT=0x00001688 X_ENC=0x0007A231 ENC_STATUS=0x00000002 " "CHOPCONF=0x00410043 DRV_STATUS=0x80084000 PWM_SCALE=0x00010011 PWM_AUTO=0x0021003C\n" "DUMP END\n"; bool has(const std::vector& v, const std::string& s) { return std::find(v.begin(), v.end(), s) != v.end(); } const DumpAxis* axis(const DumpData& d, char a) { for (const auto& ax : d.axes) if (ax.axis == a) return &ax; return nullptr; } } // namespace TEST_CASE("parseDump decodes the header") { DumpData d = parseDump(kDump); REQUIRE(d.valid); CHECK(d.build == "11dd3ce-dirty"); CHECK(d.uptime_ms == 11740067); CHECK(d.mcusr == 0x01u); CHECK(d.free_ram == 1852); CHECK(has(d.reset_flags, "PORF (power-on)")); REQUIRE(d.axes.size() == 2); } TEST_CASE("parseDump decodes the per-axis state line") { DumpData d = parseDump(kDump); const DumpAxis* y = axis(d, 'Y'); REQUIRE(y != nullptr); CHECK(y->state_name == "READY"); // state=3 CHECK(y->enabled); CHECK(y->has_encoder); CHECK(y->lim_neg == 411500); CHECK(y->lim_pos == 588500); CHECK(y->hold_target == 499910); // The anchor the firmware homed onto; limits straddle it symmetrically. CHECK(y->center_count == 500000); CHECK((y->lim_neg + y->lim_pos) / 2 == y->center_count); CHECK(y->speed == 50000); CHECK(y->hsub == 10); CHECK(y->soft_margin == 5000); CHECK(y->hold_corrections == 7); CHECK(y->hold_peak_dev == 430); CHECK(y->hold_cumulative_dev == 2810); const DumpAxis* p = axis(d, 'P'); REQUIRE(p != nullptr); CHECK(p->lim_neg == 213070); CHECK(p->lim_pos == 786930); } TEST_CASE("parseDump keeps all 16 TMC registers (regression: TMC-line whitespace)") { // The "DUMP Y TMC ..." line was once misclassified due to a leading-space // slice, dropping every register. Guard that all of them survive. DumpData d = parseDump(kDump); const DumpAxis* y = axis(d, 'Y'); REQUIRE(y != nullptr); for (const char* r : {"GCONF", "GSTAT", "IOIN", "TSTEP", "RAMPMODE", "XACTUAL", "VACTUAL", "XTARGET", "SW_MODE", "RAMP_STAT", "X_ENC", "ENC_STATUS", "CHOPCONF", "DRV_STATUS", "PWM_SCALE", "PWM_AUTO"}) { CHECK_MESSAGE(y->regs.count(r) == 1, "missing register ", r); } CHECK(y->regs.at("RAMP_STAT") == "0x00001680"); CHECK(y->regs.at("DRV_STATUS") == "0x80084000"); } TEST_CASE("parseDump decodes status-register bit flags") { DumpData d = parseDump(kDump); const DumpAxis* y = axis(d, 'Y'); REQUIRE(y != nullptr); // DRV_STATUS = 0x80084000 -> bit31 stst, bit14 stealth; CS_ACTUAL=(>>16)&0x1F=8. CHECK(y->drv_status == 0x80084000u); CHECK(has(y->drv_flags, "stst")); CHECK(has(y->drv_flags, "stealth")); CHECK(y->cs_actual == 8); CHECK(y->sg_result == 0); // GSTAT = 0 -> no flags (placeholder "-"). CHECK(has(y->gstat_flags, "-")); // RAMP_STAT = 0x1680 -> bit7 event_pos_reached, bit9 position_reached, bit10 vzero. CHECK(y->ramp_stat == 0x00001680u); CHECK(has(y->ramp_flags, "event_pos_reached")); CHECK(has(y->ramp_flags, "position_reached")); CHECK(has(y->ramp_flags, "vzero")); } TEST_CASE("parseDump tolerates a corrupted/merged DUMP END (best-effort)") { // The host detects DUMP END anywhere in a line; a clean header still parses // even when later content is mangled — but a block with no BEGIN/END is invalid. CHECK_FALSE(parseDump("").valid); CHECK_FALSE(parseDump("garbage with no markers\n").valid); CHECK_FALSE(parseDump("DUMP Y state=3\nno end marker\n").valid); } TEST_CASE("formatDump renders valid and invalid dumps") { auto bad = formatDump(DumpData{}); REQUIRE(bad.size() == 1); CHECK(bad[0].find("no firmware dump") != std::string::npos); auto good = formatDump(parseDump(kDump)); std::string joined; for (const auto& l : good) joined += l + "\n"; CHECK(joined.find("build=11dd3ce-dirty") != std::string::npos); CHECK(joined.find("[Y]") != std::string::npos); CHECK(joined.find("[P]") != std::string::npos); CHECK(joined.find("RAMP_STAT") != std::string::npos); } TEST_CASE("a DUMP axis line carries the motor current the firmware applied") { auto d = parseDump( "DUMP BEGIN build=abc uptime=1 mcusr=0x3 free_ram=100\n" "DUMP Y state=3 hsub=0 enabled=1 lim_neg=-105389 lim_pos=1105389 hold_target=0 " "speed=3000 center=500000 soft_margin=5000 gs=80 irun=31 irun_home=7 " "hold_corr=0 hold_peak=0 hold_cumul=0 has_encoder=1\n" "DUMP END\n"); REQUIRE(d.valid); REQUIRE(d.axes.size() == 1); CHECK(d.axes[0].global_scaler == 80); CHECK(d.axes[0].run_irun == 31); CHECK(d.axes[0].homing_irun == 7); } TEST_CASE("a DUMP from firmware without the current fields reports -1, not zero") { // Zero is a legitimate IRUN; absent is a version skew. Conflating them would // let an old firmware look like one configured to zero current. auto d = parseDump( "DUMP BEGIN build=abc uptime=1 mcusr=0x3 free_ram=100\n" "DUMP Y state=3 hsub=0 enabled=1 lim_neg=0 lim_pos=0 hold_target=0 speed=3000 " "center=500000 soft_margin=5000 hold_corr=0 hold_peak=0 hold_cumul=0 has_encoder=1\n" "DUMP END\n"); REQUIRE(d.valid); REQUIRE(d.axes.size() == 1); CHECK(d.axes[0].global_scaler == -1); CHECK(d.axes[0].run_irun == -1); CHECK(d.axes[0].homing_irun == -1); } TEST_CASE("the host-owned drivetrain values are read back from DUMP") { const std::string text = "DUMP BEGIN build=abc uptime=1 mcusr=0x01 free_ram=4096\n" "DUMP Y state=2 hsub=0 enabled=1 lim_neg=0 lim_pos=1000000 hold_target=0 speed=39738 " "center=500000 soft_margin=5000 gs=80 irun=31 irun_home=7 steps_per_rev=1210778 " "enc_dev=1500 homing_speed=9958 center_speed=29873 sw_mode=0 cfg_rx=0xFFE " "hold_corr=0 hold_peak=0 hold_cumul=0 has_encoder=1\n" "DUMP END\n"; auto d = fgc::parseDump(text); REQUIRE(d.valid); REQUIRE(d.axes.size() == 1); const auto& a = d.axes[0]; CHECK(a.steps_per_rev == 1210778); CHECK(a.enc_deviation == 1500); CHECK(a.homing_speed == 9958); CHECK(a.center_speed == 29873); CHECK(a.sw_mode == 0); CHECK(a.cfg_received == 0xFFE); } TEST_CASE("a firmware predating the handshake leaves those fields absent, not zero") { // -1 keeps "did not say" distinguishable from "said zero" - sw_mode 0 is a // legitimate setting on the yaw axis, so zero-as-absent would be a real bug. const std::string text = "DUMP BEGIN build=old uptime=1 mcusr=0x01 free_ram=4096\n" "DUMP Y state=2 hsub=0 enabled=1 lim_neg=0 lim_pos=1000000 hold_target=0 speed=200000 " "center=500000 soft_margin=5000 hold_corr=0 hold_peak=0 hold_cumul=0 has_encoder=1\n" "DUMP END\n"; auto d = fgc::parseDump(text); REQUIRE(d.valid); REQUIRE(d.axes.size() == 1); CHECK(d.axes[0].steps_per_rev == -1); CHECK(d.axes[0].sw_mode == -1); CHECK(d.axes[0].cfg_received == -1); } TEST_CASE("the numeric state codes did not shift when NEEDCFG was added") { // NEEDCFG is appended (5), not inserted in lifecycle order, because these // numbers are on the wire. Had it gone in at 1, a READY axis (which would // have become 4) would read back as ERROR - a healthy tower reporting a // fault in every dump, and an old firmware misread by a new host. auto one = [](int n) { const std::string text = "DUMP BEGIN build=x uptime=1 mcusr=0x01 free_ram=1\n" "DUMP Y state=" + std::to_string(n) + " hsub=0 enabled=1 lim_neg=0 lim_pos=1 hold_target=0 speed=1 center=0 " "soft_margin=0 hold_corr=0 hold_peak=0 hold_cumul=0 has_encoder=1\n" "DUMP END\n"; return fgc::parseDump(text).axes.at(0).state_name; }; CHECK(one(0) == "BOOT"); CHECK(one(1) == "RESET"); CHECK(one(2) == "HOMING"); CHECK(one(3) == "READY"); CHECK(one(4) == "ERROR"); CHECK(one(5) == "NEEDCFG"); } TEST_CASE("the firmware's own name for the reset cause wins over any decode") { // mcusr means different things per board: an AVR MCUSR bitmask, or the // RP2040's getResetReason() enum. 0x3 is "two bits set" on one and a single // enum value on the other, so a host-side table cannot be right for both. const std::string text = "DUMP BEGIN build=x uptime=1 mcusr=0x3 reset=SOFT_RESET free_ram=4096\n" "DUMP Y state=1 hsub=0 enabled=0 lim_neg=0 lim_pos=0 hold_target=0 speed=1 center=0 " "soft_margin=0 hold_corr=0 hold_peak=0 hold_cumul=0 has_encoder=1\n" "DUMP END\n"; auto d = fgc::parseDump(text); REQUIRE(d.valid); CHECK(d.reset_text == "SOFT_RESET"); std::string out; for (const auto& l : fgc::formatDump(d)) out += l + "\n"; CHECK(out.find("SOFT_RESET") != std::string::npos); // The AVR names must not appear when the firmware said what it was. CHECK(out.find("PORF") == std::string::npos); CHECK(out.find("EXTRF") == std::string::npos); } TEST_CASE("without that field the AVR decode is labelled as an assumption") { const std::string text = "DUMP BEGIN build=old uptime=1 mcusr=0x3 free_ram=4096\n" "DUMP Y state=1 hsub=0 enabled=0 lim_neg=0 lim_pos=0 hold_target=0 speed=1 center=0 " "soft_margin=0 hold_corr=0 hold_peak=0 hold_cumul=0 has_encoder=1\n" "DUMP END\n"; auto d = fgc::parseDump(text); REQUIRE(d.valid); CHECK(d.reset_text.empty()); std::string out; for (const auto& l : fgc::formatDump(d)) out += l + "\n"; CHECK(out.find("PORF") != std::string::npos); // still decoded CHECK(out.find("assumed AVR") != std::string::npos); // but not stated as fact }