#include #include "fgc/MtiProtocol.h" #include #include using namespace fgc; namespace { void putBEFloat(std::vector& v, float f) { uint32_t u; std::memcpy(&u, &f, 4); v.push_back(static_cast(u >> 24)); v.push_back(static_cast(u >> 16)); v.push_back(static_cast(u >> 8)); v.push_back(static_cast(u)); } // Sum of all bytes from BID through CS must be ≡ 0 (mod 256) for a valid frame. bool frameChecksumOk(const std::vector& m) { unsigned s = 0; for (size_t i = 1; i < m.size(); ++i) s += m[i]; return (s & 0xFF) == 0; } } // namespace TEST_CASE("config messages are well-formed with correct payloads") { auto cfg = msgGoToConfig(); auto mode = msgSetOutputMode(); auto set = msgSetOutputSettings(); auto meas = msgGoToMeasurement(); for (const auto& m : {cfg, mode, set, meas}) { CHECK(m[0] == kMtiPreamble); CHECK(m[1] == kMtiBid); CHECK(frameChecksumOk(m)); } // GoToConfig / GoToMeasurement: no data. CHECK(cfg[2] == kMidGoToConfig); CHECK(cfg[3] == 0); CHECK(meas[2] == kMidGoToMeasurement); CHECK(meas[3] == 0); // SetOutputMode = 0x0007 (Temp|Calibrated|Orientation), 2-byte big-endian. CHECK(mode[2] == kMidSetOutputMode); CHECK(mode[3] == 2); CHECK(mode[4] == 0x00); CHECK(mode[5] == 0x07); // SetOutputSettings = 0x00000005 (Euler + sample counter), 4-byte big-endian. CHECK(set[2] == kMidSetOutputSettings); CHECK(set[3] == 4); CHECK(set[4] == 0x00); CHECK(set[5] == 0x00); CHECK(set[6] == 0x00); CHECK(set[7] == 0x05); } TEST_CASE("framer decodes a combined MTData frame into a full sample") { std::vector d; putBEFloat(d, 24.5f); // temp putBEFloat(d, 0.10f); putBEFloat(d, -0.20f); putBEFloat(d, 9.81f); // acc putBEFloat(d, 0.01f); putBEFloat(d, 0.02f); putBEFloat(d, -0.03f); // gyr putBEFloat(d, 0.45f); putBEFloat(d, -0.88f); putBEFloat(d, 0.21f); // mag putBEFloat(d, -1.5f); putBEFloat(d, 3.25f); putBEFloat(d, 187.0f); // roll/pitch/yaw d.push_back(0x12); d.push_back(0x34); // sample counter REQUIRE(d.size() == kMTDataLen); auto frame = mtiMessage(kMidMTData, d); ImuSample got; bool fired = false; MtiFramer fr([&](uint8_t mid, const uint8_t* p, size_t n) { if (auto s = parseMTData(mid, p, n)) { got = *s; fired = true; } }); // Leading noise must not break resync. const uint8_t noise[] = {0x00, 0xAB, 0xFA, 0x01}; fr.feed(noise, sizeof(noise)); fr.feed(frame.data(), frame.size()); REQUIRE(fired); CHECK(got.valid); CHECK(got.temp_c == doctest::Approx(24.5f)); CHECK(got.acc[2] == doctest::Approx(9.81f)); CHECK(got.gyr[0] == doctest::Approx(0.01f)); CHECK(got.mag[1] == doctest::Approx(-0.88f)); CHECK(got.roll_deg == doctest::Approx(-1.5f)); CHECK(got.pitch_deg == doctest::Approx(3.25f)); CHECK(got.yaw_deg == doctest::Approx(187.0f)); CHECK(got.sample_counter == 0x1234); } TEST_CASE("framer reassembles frames split across feeds and back-to-back frames") { // Real serial reads arrive in arbitrary chunks; the framer must not depend // on frame boundaries aligning with feed() calls. std::vector d; putBEFloat(d, 7.5f); // temp d.resize(kMTDataLen, 0); // rest of the 54-byte payload = 0 auto frame = mtiMessage(kMidMTData, d); int count = 0; float last_temp = 0; MtiFramer fr([&](uint8_t mid, const uint8_t* p, size_t n) { if (auto s = parseMTData(mid, p, n)) { ++count; last_temp = s->temp_c; } }); // Two frames fed one byte at a time (worst-case fragmentation). for (uint8_t b : frame) fr.feed(&b, 1); for (uint8_t b : frame) fr.feed(&b, 1); CHECK(count == 2); CHECK(last_temp == doctest::Approx(7.5f)); // Two frames concatenated in a single feed. std::vector two = frame; two.insert(two.end(), frame.begin(), frame.end()); count = 0; MtiFramer fr2([&](uint8_t mid, const uint8_t* p, size_t n) { if (parseMTData(mid, p, n)) ++count; }); fr2.feed(two.data(), two.size()); CHECK(count == 2); } TEST_CASE("parseMTData passes roll, pitch and yaw through untouched") { auto frameWithYaw = [](float yaw) { std::vector d; for (int i = 0; i < 10; ++i) putBEFloat(d, 0.f); // temp + acc3 + gyr3 + mag3 putBEFloat(d, 0.f); // roll putBEFloat(d, 0.f); // pitch putBEFloat(d, yaw); // yaw d.push_back(0); d.push_back(0); // counter return mtiMessage(kMidMTData, d); }; ImuSample got; MtiFramer fr([&](uint8_t mid, const uint8_t* p, size_t n) { if (auto s = parseMTData(mid, p, n)) got = *s; }); // No range shift, no sign flip: the device's own -180..180 reaches the // consumer verbatim. The only thing that ever alters an IMU angle is the // [IMU] x/y/z mount rotation, and that is applied later and only if set. auto fn = frameWithYaw(-90.f); fr.feed(fn.data(), fn.size()); CHECK(got.yaw_deg == doctest::Approx(-90.0f)); auto fp = frameWithYaw(45.f); fr.feed(fp.data(), fp.size()); CHECK(got.yaw_deg == doctest::Approx(45.0f)); auto fb = frameWithYaw(-179.f); fr.feed(fb.data(), fb.size()); CHECK(got.yaw_deg == doctest::Approx(-179.0f)); } TEST_CASE("no axis is negated or re-ranged on the way out of the parser") { // The rig's own convention is X forward / Y right / Z down with positive // pitch = nose up. Whether the device agrees is the device's business: the // parser's job is to not have an opinion, so a negative angle stays negative // and a positive one stays positive on every axis. std::vector d; putBEFloat(d, 21.5f); // temp for (float v : {1.f, -2.f, 3.f}) putBEFloat(d, v); // acc for (float v : {-4.f, 5.f, -6.f}) putBEFloat(d, v); // gyr for (float v : {7.f, -8.f, 9.f}) putBEFloat(d, v); // mag putBEFloat(d, -30.f); // roll putBEFloat(d, -12.5f); // pitch (nose down) putBEFloat(d, -95.f); // yaw d.push_back(0x12); d.push_back(0x34); auto msg = mtiMessage(kMidMTData, d); ImuSample got; MtiFramer fr([&](uint8_t mid, const uint8_t* p, size_t n) { if (auto s = parseMTData(mid, p, n)) got = *s; }); fr.feed(msg.data(), msg.size()); CHECK(got.roll_deg == doctest::Approx(-30.0f)); CHECK(got.pitch_deg == doctest::Approx(-12.5f)); CHECK(got.yaw_deg == doctest::Approx(-95.0f)); CHECK(got.acc[1] == doctest::Approx(-2.0f)); // vectors too: no axis flipped CHECK(got.gyr[0] == doctest::Approx(-4.0f)); CHECK(got.mag[1] == doctest::Approx(-8.0f)); CHECK(got.temp_c == doctest::Approx(21.5f)); } TEST_CASE("orientation-control command builders are well-formed") { SUBCASE("SetNoRotation carries the duration big-endian") { auto m = msgSetNoRotation(3); CHECK(m[0] == kMtiPreamble); CHECK(m[1] == kMtiBid); CHECK(m[2] == kMidSetNoRotation); CHECK(m[3] == 2); CHECK(m[4] == 0x00); CHECK(m[5] == 0x03); CHECK(frameChecksumOk(m)); } SUBCASE("ResetOrientation heading reset = CODE 0x0001") { auto m = msgResetOrientation(kResetHeading); CHECK(m[2] == kMidResetOrientation); CHECK(m[3] == 2); CHECK(m[4] == 0x00); CHECK(m[5] == 0x01); CHECK(frameChecksumOk(m)); } SUBCASE("ResetOrientation store = CODE 0x0000") { auto m = msgResetOrientation(kResetStore); CHECK(m[4] == 0x00); CHECK(m[5] == 0x00); CHECK(frameChecksumOk(m)); } SUBCASE("SetFilterProfile carries the profile number (set = non-empty data)") { auto m = msgSetFilterProfile(53); CHECK(m[2] == kMidReqFilterProfile); // same MID as Req; data makes it a Set CHECK(m[3] == 2); CHECK(m[4] == 0x00); CHECK(m[5] == 53); CHECK(frameChecksumOk(m)); } } TEST_CASE("pickNoMagProfile chooses a magnetometer-free profile") { SUBCASE("prefers an explicit no-mag label") { std::vector ps = { {39, 11, "General"}, {40, 11, "High_mag_dep"}, {7, 1, "Machine_nomagfield"}}; CHECK(pickNoMagProfile(ps) == 7); } SUBCASE("falls back to a VRU profile") { std::vector ps = { {39, 11, "General"}, {53, 11, "VRU_general"}, {40, 11, "High_mag_dep"}}; CHECK(pickNoMagProfile(ps) == 53); } SUBCASE("returns -1 when every profile uses the magnetometer") { std::vector ps = { {39, 11, "General"}, {40, 11, "High_mag_dep"}, {41, 11, "Dynamic"}}; CHECK(pickNoMagProfile(ps) == -1); } } TEST_CASE("config-readback query builders are well-formed requests (empty data)") { struct Q { std::vector m; uint8_t mid; }; Q qs[] = { {msgReqProductCode(), kMidReqProductCode}, {msgReqDID(), kMidReqDID}, {msgReqFWRev(), kMidReqFWRev}, {msgReqPeriod(), kMidReqPeriod}, {msgReqOutputMode(), kMidSetOutputMode}, {msgReqOutputSettings(), kMidSetOutputSettings}, {msgReqFilterProfile(), kMidReqFilterProfile}, {msgReqAvailFilterProfiles(), kMidReqAvailFilterProf}, }; for (const auto& q : qs) { CHECK(q.m[0] == kMtiPreamble); CHECK(q.m[1] == kMtiBid); CHECK(q.m[2] == q.mid); CHECK(q.m[3] == 0); // request => empty data field CHECK(frameChecksumOk(q.m)); } } TEST_CASE("applyImuConfigAck decodes each ack type") { ImuDeviceConfig c; SUBCASE("DeviceID is a 32-bit big-endian serial") { uint8_t d[] = {0x00, 0x99, 0x0A, 0xBC}; CHECK(applyImuConfigAck(c, kMidDeviceID, d, sizeof(d))); CHECK(c.has_device_id); CHECK(c.device_id == 0x00990ABCu); } SUBCASE("ProductCode trims trailing spaces") { const char* s = "MTi-28A53G35 "; CHECK(applyImuConfigAck(c, kMidProductCode, reinterpret_cast(s), 15)); CHECK(c.product_code == "MTi-28A53G35"); } SUBCASE("FirmwareRev with build number") { uint8_t d[] = {2, 8, 1, 0, 0, 0, 25}; // 2.8.1 build 25 CHECK(applyImuConfigAck(c, kMidFirmwareRev, d, sizeof(d))); CHECK(c.firmware == "2.8.1 build 25"); } SUBCASE("FirmwareRev short form (major.minor.rev only)") { uint8_t d[] = {1, 2, 3}; CHECK(applyImuConfigAck(c, kMidFirmwareRev, d, sizeof(d))); CHECK(c.firmware == "1.2.3"); } SUBCASE("Period yields 100 Hz from 0x0480") { uint8_t d[] = {0x04, 0x80}; // 1152 => 115200/1152 = 100 Hz CHECK(applyImuConfigAck(c, kMidReqPeriodAck, d, sizeof(d))); finalizeImuConfig(c); CHECK(c.has_period); CHECK(c.sample_rate_hz == doctest::Approx(100.0f)); } SUBCASE("OutputMode 0x0007 = Temp + Calibrated + Orientation") { uint8_t d[] = {0x00, 0x07}; CHECK(applyImuConfigAck(c, kMidSetOutputModeAck, d, sizeof(d))); CHECK(c.out_temperature); CHECK(c.out_calibrated); CHECK(c.out_orientation); CHECK_FALSE(c.out_auxiliary); } SUBCASE("OutputSettings 0x00000005 = Euler + sample counter + float, all channels") { uint8_t d[] = {0x00, 0x00, 0x00, 0x05}; CHECK(applyImuConfigAck(c, kMidSetOutputSettingsAck, d, sizeof(d))); CHECK(c.orientation_mode == "Euler"); CHECK(c.timestamp_mode == "Sample counter"); CHECK(c.data_format == "Float"); CHECK(c.acc_enabled); CHECK(c.gyr_enabled); CHECK(c.mag_enabled); } SUBCASE("OutputSettings with disabled mag and fixed-point format") { // bit6 set (disable mag), output format bits 9:8 = 01 (Fixed 12.20). uint32_t s = 0x05 | 0x40 | 0x100; uint8_t d[] = {uint8_t(s >> 24), uint8_t(s >> 16), uint8_t(s >> 8), uint8_t(s)}; CHECK(applyImuConfigAck(c, kMidSetOutputSettingsAck, d, sizeof(d))); CHECK(c.acc_enabled); CHECK(c.gyr_enabled); CHECK_FALSE(c.mag_enabled); CHECK(c.data_format == "Fixed 12.20"); } SUBCASE("unknown MID is ignored") { uint8_t d[] = {0}; CHECK_FALSE(applyImuConfigAck(c, 0x99, d, sizeof(d))); CHECK_FALSE(c.valid); } } TEST_CASE("scenario label resolves against the available-profiles list") { ImuDeviceConfig c; // AvailableFilterProfiles: two 22-byte records (type, version, 20-byte label). std::vector d; auto addProfile = [&](uint8_t type, uint8_t ver, const std::string& label) { d.push_back(type); d.push_back(ver); std::string padded = label; padded.resize(20, ' '); d.insert(d.end(), padded.begin(), padded.end()); }; addProfile(39, 11, "General"); addProfile(40, 11, "High_mag_dep"); CHECK(applyImuConfigAck(c, kMidAvailFilterProf, d.data(), d.size())); REQUIRE(c.available_profiles.size() == 2); CHECK(c.available_profiles[0].label == "General"); CHECK(c.available_profiles[1].label == "High_mag_dep"); // Current filter profile ack: VERSION, FILTERPROFILE(type). uint8_t fp[] = {11, 40}; CHECK(applyImuConfigAck(c, kMidReqFilterProfileAck, fp, sizeof(fp))); finalizeImuConfig(c); CHECK(c.has_scenario); CHECK(c.scenario_type == 40); CHECK(c.scenario_label == "High_mag_dep"); } TEST_CASE("a full ack stream decodes through the framer") { // Concatenate realistic acks and run them through MtiFramer, mirroring how // the live handshake collects them. ImuDeviceConfig c; MtiFramer fr([&](uint8_t mid, const uint8_t* p, size_t n) { applyImuConfigAck(c, mid, p, n); }); auto feed = [&](const std::vector& m) { fr.feed(m.data(), m.size()); }; feed(mtiMessage(kMidProductCode, {'M','T','i','-','2','8'})); feed(mtiMessage(kMidDeviceID, {0x00, 0x99, 0x0A, 0xBC})); feed(mtiMessage(kMidSetOutputModeAck, {0x00, 0x07})); feed(mtiMessage(kMidSetOutputSettingsAck, {0x00, 0x00, 0x00, 0x05})); feed(mtiMessage(kMidReqPeriodAck, {0x04, 0x80})); feed(mtiMessage(kMidReqFilterProfileAck, {11, 39})); finalizeImuConfig(c); CHECK(c.valid); CHECK(c.product_code == "MTi-28"); CHECK(c.device_id == 0x00990ABCu); CHECK(c.out_orientation); CHECK(c.orientation_mode == "Euler"); CHECK(c.sample_rate_hz == doctest::Approx(100.0f)); CHECK(c.scenario_type == 39); } TEST_CASE("framer rejects a bad checksum and a wrong-length payload") { std::vector d(kMTDataLen, 0); auto frame = mtiMessage(kMidMTData, d); SUBCASE("corrupt checksum") { auto bad = frame; bad.back() ^= 0xFF; bool fired = false; MtiFramer fr([&](uint8_t, const uint8_t*, size_t) { fired = true; }); fr.feed(bad.data(), bad.size()); CHECK_FALSE(fired); } SUBCASE("wrong-length MTData parses to nullopt") { std::vector shortData(10, 0); CHECK_FALSE(parseMTData(kMidMTData, shortData.data(), shortData.size()).has_value()); } } // --- What the host asks for, and proving the device took it ----------------- TEST_CASE("the MTData length is derived from the output settings, not asserted") { // The constant the parser gates on must follow the bits, so that changing a // setting can never silently leave parseMTData decoding the old layout. CHECK(mtDataLen(kOutputMode, kOutputSettings) == 54); CHECK(kMTDataLen == 54); using namespace mtsettings; const uint32_t base = kOutputSettings & ~kOrientationMask; // Orientation mode dominates the length: Temp(4) + acc/gyr/mag(36) + N + counter(2). CHECK(mtDataLen(kOutputMode, base | kOrientationQuaternion) == 58); CHECK(mtDataLen(kOutputMode, base | kOrientationEuler) == 54); CHECK(mtDataLen(kOutputMode, base | kOrientationMatrix) == 78); // A disabled calibrated channel drops its 12 bytes. CHECK(mtDataLen(kOutputMode, kOutputSettings | kMagDisabled) == 42); CHECK(mtDataLen(kOutputMode, kOutputSettings | kAccDisabled | kGyrDisabled) == 30); // No timestamp drops the trailing sample counter. CHECK(mtDataLen(kOutputMode, kOutputSettings & ~kTimestampMask) == 52); // Mode bits drop whole blocks. CHECK(mtDataLen(kOutputMode & ~mtmode::kTemperature, kOutputSettings) == 50); CHECK(mtDataLen(kOutputMode & ~mtmode::kOrientation, kOutputSettings) == 42); CHECK(mtDataLen(kOutputMode | mtmode::kStatus, kOutputSettings) == 55); } TEST_CASE("the settings word the host writes still decodes as Euler + counter + float") { // kOutputSettings gained named fields (and a slot for the coordinate system); // it must still describe the stream parseMTData actually decodes. ImuDeviceConfig c; std::vector d = {uint8_t(kOutputSettings >> 24), uint8_t(kOutputSettings >> 16), uint8_t(kOutputSettings >> 8), uint8_t(kOutputSettings)}; CHECK(applyImuConfigAck(c, kMidSetOutputSettingsAck, d.data(), d.size())); CHECK(c.orientation_mode == "Euler"); CHECK(c.timestamp_mode == "Sample counter"); CHECK(c.data_format == "Float"); CHECK(c.acc_enabled); CHECK(c.gyr_enabled); CHECK(c.mag_enabled); } namespace { // A device config reporting exactly what the host asked for. ImuDeviceConfig agreeingConfig() { ImuDeviceConfig c; c.valid = true; c.has_output_mode = true; c.output_mode = kOutputMode; c.has_output_settings = true; c.output_settings = kOutputSettings; return c; } bool mentions(const std::vector& v, const std::string& field) { for (const auto& m : v) if (m.field == field) return true; return false; } } // namespace TEST_CASE("checkImuDeviceConfig passes a device that took the configuration") { CHECK(checkImuDeviceConfig(agreeingConfig()).empty()); } TEST_CASE("checkImuDeviceConfig catches what the payload length cannot") { // These are the dangerous ones: neither changes the MTData length, so the // length gate in parseMTData sees nothing wrong and the readings are silently // decoded from the wrong frame or the wrong number format. SUBCASE("a coordinate system that is not what was asked for") { auto c = agreeingConfig(); c.output_settings = kOutputSettings ^ 0x80000000u; // a high bit, whatever it means const auto found = checkImuDeviceConfig(c); REQUIRE(found.size() == 1); CHECK(found[0].field == "output settings"); CHECK(found[0].answered); CHECK(found[0].expected == kOutputSettings); CHECK(found[0].actual == c.output_settings); // The detail must point at the coordinate system, since that is where an // unrecognized high bit lives. CHECK(found[0].detail.find("coordinate system") != std::string::npos); CHECK(found[0].describe().find("output settings") != std::string::npos); } SUBCASE("fixed-point instead of float, which keeps the length at 54") { auto c = agreeingConfig(); c.output_settings = (kOutputSettings & ~mtsettings::kFormatMask) | mtsettings::kFormatFixed1220; CHECK(mtDataLen(kOutputMode, c.output_settings) == kMTDataLen); // invisible to the gate const auto found = checkImuDeviceConfig(c); REQUIRE(found.size() == 1); CHECK(found[0].detail.find("Fixed 12.20") != std::string::npos); CHECK(found[0].detail.find("Float") != std::string::npos); } } TEST_CASE("an unanswered field is unverifiable, not a match") { // Older firmware that ignores the Req* queries must never be mistaken for a // device that agreed - that is the whole difference between "checked" and // "could not check". auto c = agreeingConfig(); c.has_output_settings = false; c.output_settings = 0; const auto found = checkImuDeviceConfig(c); REQUIRE(found.size() == 1); CHECK(found[0].field == "output settings"); CHECK_FALSE(found[0].answered); CHECK(found[0].describe().find("could not be verified") != std::string::npos); } TEST_CASE("both fields are reported when both disagree") { ImuDeviceConfig c; c.valid = true; c.has_output_mode = true; c.output_mode = mtmode::kTemperature; // orientation is off c.has_output_settings = true; c.output_settings = (kOutputSettings & ~mtsettings::kOrientationMask) | mtsettings::kOrientationQuaternion; const auto found = checkImuDeviceConfig(c); CHECK(found.size() == 2); CHECK(mentions(found, "output mode")); CHECK(mentions(found, "output settings")); for (const auto& m : found) CHECK_FALSE(m.detail.empty()); } TEST_CASE("mtiErrorText names known codes and does not swallow unknown ones") { CHECK(mtiErrorText(0x04) == "invalid message"); CHECK(mtiErrorText(0x21) == "invalid parameter"); // An unrecognized code must still carry the number through to the log. CHECK(mtiErrorText(0x7B).find("0x7B") != std::string::npos); }