fwt_software/tests/test_firmwareconfig.cpp

306 lines
10 KiB
C++

#include "doctest/doctest.h"
#include "fgc/FirmwareConfig.h"
#include <algorithm>
using namespace fgc;
namespace {
// Tower 121's yaw, fully declared: the case the handshake exists to serve.
AxisConfigSource iotaYaw() {
AxisConfigSource s;
s.have_current = true;
s.global_scaler = 80;
s.run_irun = 31;
s.homing_irun = 7;
s.center_count = 500000;
s.soft_margin = 5000;
s.have_travel = true;
s.steps_per_rev = 1210778;
s.have_deviation = true;
s.enc_deviation = 1500;
s.normal_vmax = 39738; // 8 deg/s at 3552.87 counts/deg
s.homing_vmax = 30000;
s.center_vmax = 25000;
s.have_wiring = true;
s.sw_mode = 0; // yaw: no physical stops, StallGuard homing
s.has_encoder = true;
return s;
}
bool sent(const FirmwareConfigPlan& p, const std::string& text) {
return std::find(p.sets.begin(), p.sets.end(), text) != p.sets.end();
}
} // namespace
TEST_CASE("a NEED line parses into an axis and its keys") {
auto r = parseNeedLine("NEED Y steps_per_rev global_scaler center margin");
REQUIRE(r);
CHECK(r->axis == 'Y');
REQUIRE(r->keys.size() == 4);
CHECK(r->keys[0] == "steps_per_rev");
CHECK(r->keys[3] == "margin");
auto p = parseNeedLine("NEED P sw_mode");
REQUIRE(p);
CHECK(p->axis == 'P');
}
TEST_CASE("an axis that needs nothing is well-formed, not a parse failure") {
// How a firmware built for fewer axes than the rig has answers for the axis
// it does not drive.
auto r = parseNeedLine("NEED P");
REQUIRE(r);
CHECK(r->axis == 'P');
CHECK(r->keys.empty());
CHECK(planFirmwareConfig(*r, iotaYaw()).complete());
}
TEST_CASE("anything that is not a NEED line is ignored") {
CHECK_FALSE(parseNeedLine("ST Y:R,0,0,00000000,0,0,0,S"));
CHECK_FALSE(parseNeedLine("NEEDCFG"));
CHECK_FALSE(parseNeedLine("NEED"));
CHECK_FALSE(parseNeedLine("NEED X steps_per_rev")); // not an axis
CHECK_FALSE(parseNeedLine("NEED YY steps_per_rev"));
CHECK_FALSE(parseNeedLine(""));
}
TEST_CASE("a fully declared axis is answered completely, in the order asked") {
auto r = parseNeedLine(
"NEED Y steps_per_rev global_scaler sw_mode enc_deviation normal_speed homing_speed "
"center_speed center margin");
REQUIRE(r);
auto p = planFirmwareConfig(*r, iotaYaw());
CHECK(p.complete());
CHECK(p.unanswered.empty());
CHECK(p.unknown.empty());
REQUIRE(p.sets.size() == 9);
CHECK(p.sets.front() == "SET Y steps_per_rev 1210778");
CHECK(sent(p, "SET Y global_scaler 80"));
CHECK(sent(p, "SET Y enc_deviation 1500"));
CHECK(sent(p, "SET Y normal_speed 39738"));
CHECK(p.sets.back() == "SET Y margin 5000");
}
TEST_CASE("center/margin are accepted under both their names") {
auto legacy = parseNeedLine("NEED Y center margin");
auto full = parseNeedLine("NEED Y center_count soft_margin");
REQUIRE(legacy);
REQUIRE(full);
CHECK(planFirmwareConfig(*legacy, iotaYaw()).sets[0] == "SET Y center 500000");
CHECK(planFirmwareConfig(*full, iotaYaw()).sets[0] == "SET Y center_count 500000");
}
TEST_CASE("an undeclared current is unanswered, never sent as zero") {
// A silently zeroed current is a motor that cannot hold, so the key must go
// unanswered and leave the firmware in NEEDCFG.
auto s = iotaYaw();
s.have_current = false;
auto r = parseNeedLine("NEED Y global_scaler run_irun homing_irun");
REQUIRE(r);
auto p = planFirmwareConfig(*r, s);
CHECK_FALSE(p.complete());
CHECK(p.sets.empty());
CHECK(p.unanswered.size() == 3);
}
TEST_CASE("a speed that could not be converted is unanswered, not VMAX 0") {
// VMAX 0 is a stopped axis, not a slow one. AxisSpeed refuses an
// uncalibrated scale, and that refusal has to survive into the handshake.
auto s = iotaYaw();
s.normal_vmax = 0;
auto r = parseNeedLine("NEED Y normal_speed homing_speed");
REQUIRE(r);
auto p = planFirmwareConfig(*r, s);
CHECK_FALSE(p.complete());
REQUIRE(p.unanswered.size() == 1);
CHECK(p.unanswered[0] == "normal_speed");
CHECK(sent(p, "SET Y homing_speed 30000"));
}
TEST_CASE("a key this host has never heard of is a version skew, not a gap") {
// The two need different fixes - upgrade the host vs. fill in hardware.ini -
// so they are reported separately.
auto r = parseNeedLine("NEED Y steps_per_rev flux_capacitor");
REQUIRE(r);
auto p = planFirmwareConfig(*r, iotaYaw());
CHECK_FALSE(p.complete());
CHECK(p.unanswered.empty());
REQUIRE(p.unknown.size() == 1);
CHECK(p.unknown[0] == "flux_capacitor");
// The keys it DID understand are still answered.
CHECK(sent(p, "SET Y steps_per_rev 1210778"));
}
TEST_CASE("has_encoder is sent as 1/0, and follows the wiring declaration") {
auto s = iotaYaw();
s.has_encoder = false;
auto r = parseNeedLine("NEED Y has_encoder");
REQUIRE(r);
CHECK(planFirmwareConfig(*r, s).sets[0] == "SET Y has_encoder 0");
s.have_wiring = false;
CHECK_FALSE(planFirmwareConfig(*r, s).complete());
}
TEST_CASE("the pitch axis is addressed as P") {
auto r = parseNeedLine("NEED P center margin");
REQUIRE(r);
auto p = planFirmwareConfig(*r, iotaYaw());
CHECK(p.sets[0] == "SET P center 500000");
CHECK(p.sets[1] == "SET P margin 5000");
}
// --- End to end through the mock's NEEDCFG mode -----------------------------
#include "fgc/mock/MockMotorController.h"
TEST_CASE("the mock in NEEDCFG asks, and refuses to move until answered") {
MockMotorController m(/*needcfg=*/true);
m.start();
auto lines = m.takeNeedLines();
REQUIRE(lines.size() == 2);
CHECK(lines[0].rfind("NEED Y ", 0) == 0);
CHECK(lines[1].rfind("NEED P ", 0) == 0);
// Taking them clears the queue: they are a request, not a state to poll.
CHECK(m.takeNeedLines().empty());
// Before the answer, motion commands are rejected.
CHECK_FALSE(m.configured());
m.sendCommand("HOME");
CHECK_FALSE(m.telemetry().yaw.ready());
// Answer the way the host would.
AxisConfigSource src;
src.have_current = true;
src.global_scaler = 80;
src.center_count = 500000;
src.soft_margin = 5000;
src.have_travel = true;
src.steps_per_rev = 1210778;
src.have_deviation = true;
src.enc_deviation = 1500;
src.normal_vmax = 39738;
src.homing_vmax = 30000;
src.center_vmax = 25000;
src.have_wiring = true;
// Answer BOTH axes, as the host does - the axis unlocks only when every key
// it asked for has been answered.
for (const auto& l : lines) {
auto n = parseNeedLine(l);
REQUIRE(n);
auto plan = planFirmwareConfig(*n, src);
REQUIRE(plan.complete());
for (const auto& cmd : plan.sets) m.sendCommand(cmd);
}
// The values landed, under the names the firmware asked for.
const auto got = m.settings();
CHECK(got.at("Y.steps_per_rev") == "1210778");
CHECK(got.at("Y.global_scaler") == "80");
CHECK(got.at("Y.normal_speed") == "39738");
CHECK(got.at("Y.center") == "500000");
CHECK(got.at("Y.margin") == "5000");
// And the axis now moves.
CHECK(m.configured());
m.sendCommand("HOME");
CHECK(m.telemetry().yaw.ready());
}
TEST_CASE("the default mock is untouched by the handshake") {
// Every existing test constructs it this way; it must behave as before.
MockMotorController m;
m.start();
CHECK(m.takeNeedLines().empty());
m.sendCommand("HOME");
CHECK(m.telemetry().yaw.ready());
}
TEST_CASE("a partial answer leaves the axis in NEEDCFG") {
// The case that matters: hardware.ini declares most of what the firmware
// wants, so most SETs land - and the axis still must not move.
MockMotorController m(/*needcfg=*/true);
m.start();
auto lines = m.takeNeedLines();
REQUIRE(lines.size() == 2);
auto need = parseNeedLine(lines[0]);
REQUIRE(need);
AxisConfigSource partial; // only the always-known anchors
partial.center_count = 500000;
partial.soft_margin = 5000;
auto plan = planFirmwareConfig(*need, partial);
CHECK_FALSE(plan.complete());
for (const auto& cmd : plan.sets) m.sendCommand(cmd);
CHECK_FALSE(m.configured());
m.sendCommand("HOME");
CHECK_FALSE(m.telemetry().yaw.ready());
}
TEST_CASE("RESET is not a way out of NEEDCFG") {
// Found by reading the firmware before flashing it: Motor::reset() set
// ST_RESET unconditionally, so an axis that was never told its drivetrain
// would come back accepting HOME and MOVE on the compile-time table -
// reached by a command typed for unrelated reasons.
MockMotorController m(/*needcfg=*/true);
m.start();
m.takeNeedLines();
m.sendCommand("RESET");
CHECK_FALSE(m.configured());
m.sendCommand("HOME");
CHECK_FALSE(m.telemetry().yaw.ready());
CHECK(m.telemetry().yaw.state == AxisState::NeedConfig);
}
TEST_CASE("the mock leaves NEEDCFG per axis, as the firmware does") {
// A yaw-only tower never answers for pitch - the host refuses to configure
// an axis the tower does not declare. Yaw must still report configured, or
// anything waiting on it (--test, above all) waits for nothing.
MockMotorController m(/*needcfg=*/true);
m.start();
auto lines = m.takeNeedLines();
REQUIRE(lines.size() == 2);
AxisConfigSource src;
src.have_current = true;
src.global_scaler = 80;
src.center_count = 500000;
src.soft_margin = 5000;
src.have_travel = true;
src.steps_per_rev = 1210778;
src.have_deviation = true;
src.enc_deviation = 1500;
src.normal_vmax = 39738;
src.homing_vmax = 30000;
src.center_vmax = 25000;
src.have_wiring = true;
auto yaw = parseNeedLine(lines[0]);
REQUIRE(yaw);
for (const auto& cmd : planFirmwareConfig(*yaw, src).sets) m.sendCommand(cmd);
CHECK(m.axisConfigured('Y'));
CHECK_FALSE(m.axisConfigured('P'));
CHECK_FALSE(m.configured()); // "every axis" keeps its meaning
const auto t = m.telemetry();
CHECK(t.yaw.state == AxisState::Reset);
CHECK(t.pitch.state == AxisState::NeedConfig);
m.sendCommand("DUMP");
const std::string d = m.lastDump();
CHECK(d.find("DUMP Y state=1") != std::string::npos);
CHECK(d.find("DUMP P state=5") != std::string::npos);
}