diff --git a/CHANGELOG.md b/CHANGELOG.md index 34a9ec1..fba6ab7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project are documented in this file. ## Unreleased +## 0.3.5 - 2026-10-27 + +### Fixed + +- Allow legacy firmware to omit the display-only `prodname` field during sensor + discovery. Use `ATI Net F/T` as a generic name while preserving the reported + calibration scales and units. Empty, duplicate or overlong names and missing or + invalid calibration fields remain errors. Based on the compatibility fix by + Inigo Moreno in [ros-netft #20](https://github.com/netft/ros-netft/pull/20). + ## 0.3.4 - 2026-10-08 - Reject durations and deadlines outside the steady-clock/HTTP timer range before conversion; document synchronous resolver shutdown limits. diff --git a/CMakeLists.txt b/CMakeLists.txt index e7a6ce4..b32d726 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.16) -project(netft VERSION 0.3.4 LANGUAGES CXX) +project(netft VERSION 0.3.5 LANGUAGES CXX) include(CTest) include(GNUInstallDirs) diff --git a/README.md b/README.md index 13191ac..ff09fc6 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,12 @@ recovery. For an end-user terminal application, see - Supports reconnect and fail-stop recovery policies. - Installs shared or static libraries as the `netft::netft` CMake target. +Legacy firmware may omit the display-only product name during configuration +discovery. SDK 0.3.5 and later use `ATI Net F/T` as a generic label in that case; +the label does not identify the connected sensor model. Calibration scales and +units must still be complete and valid. A present but empty, duplicate or +overlong product name is rejected. + ## Installation | Platform | Architectures | Support | diff --git a/pixi.toml b/pixi.toml index e511ddf..5b677c0 100644 --- a/pixi.toml +++ b/pixi.toml @@ -1,6 +1,6 @@ [workspace] name = "netft-cpp" -version = "0.3.4" +version = "0.3.5" channels = ["conda-forge"] platforms = ["linux-64", "linux-aarch64"] diff --git a/src/detail/xml_config.cpp b/src/detail/xml_config.cpp index dd40fef..48a3d0a 100644 --- a/src/detail/xml_config.cpp +++ b/src/detail/xml_config.cpp @@ -208,6 +208,11 @@ RequiredFields extract_required_fields(std::string_view xml) { } for (std::size_t index = 0; index < kRequiredTags.size(); ++index) { if (!seen[index]) { + // Legacy firmware may omit the display name; calibration fields remain mandatory. + if (index == 0) { + fields[index] = "ATI Net F/T"; + continue; + } throw DiscoveryError("sensor configuration field '" + std::string{kRequiredTags[index]} + "' must appear exactly once"); } diff --git a/test/test_client_stream.cpp b/test/test_client_stream.cpp index ab146ef..ee19d73 100644 --- a/test/test_client_stream.cpp +++ b/test/test_client_stream.cpp @@ -162,6 +162,36 @@ TEST(ClientStream, CompleteCalibrationOverrideSkipsHttpDiscovery) { client.stop(); } +TEST(ClientStream, MissingProductNameDoesNotBlockCalibratedStreaming) { + netft::test::FakeSensor sensor; + sensor.pause(); + sensor.set_xml_configuration("10010" + "NNmm"); + sensor.queue_record(1, 0, 100, {100, -200, 300, 10, -20, 30}); + netft::Client client{config_for(sensor)}; + client.start([&](const netft::Sample &) { sensor.pause(); }); + + ASSERT_TRUE(sensor.wait_for_command(netft::detail::Command::StartRealtime)); + sensor.resume(); + ASSERT_TRUE(client.wait_for_first_sample(500ms)); + client.stop(); + + const auto sample = client.latest_sample(); + ASSERT_TRUE(sample); + EXPECT_EQ(sample->force, (std::array{1.0, -2.0, 3.0})); + EXPECT_EQ(sample->torque, (std::array{1.0, -2.0, 3.0})); + EXPECT_EQ(sample->force_unit, netft::ForceUnit::Newton); + EXPECT_EQ(sample->torque_unit, netft::TorqueUnit::NewtonMillimeter); + const auto health = client.health(); + ASSERT_TRUE(health.sensor_configuration); + EXPECT_EQ(health.sensor_configuration->product_name, "ATI Net F/T"); + EXPECT_EQ(health.sensor_configuration->source, netft::CalibrationSource::Sensor); + EXPECT_EQ(sensor.http_request_count(), 1U); + const auto commands = sensor.commands(); + EXPECT_EQ(std::count(commands.begin(), commands.end(), netft::detail::Command::SetSoftwareBias), + 0); +} + TEST(ClientStream, DeliversFiniteAxesForExtremeRawCounts) { netft::test::FakeSensor sensor; sensor.pause(); diff --git a/test/test_discovery.cpp b/test/test_discovery.cpp index 16d5799..c757812 100644 --- a/test/test_discovery.cpp +++ b/test/test_discovery.cpp @@ -185,31 +185,56 @@ TEST(XmlConfiguration, ParsesDecimalAndScientificCountsExactly) { EXPECT_DOUBLE_EQ(result.calibration.counts_per_torque_unit, 2'500'000.0); } -class RequiredXmlField : public ::testing::TestWithParam {}; - -TEST_P(RequiredXmlField, RejectsMissingFields) { - EXPECT_THROW(netft::detail::parse_sensor_configuration(remove_element(kValidXml, GetParam())), +TEST(XmlConfiguration, DefaultsMissingProductNameWithoutChangingCalibration) { + const auto named = netft::detail::parse_sensor_configuration(kValidXml); + const auto unnamed = + netft::detail::parse_sensor_configuration(remove_element(kValidXml, "prodname")); + + EXPECT_EQ(unnamed.product_name, "ATI Net F/T"); + EXPECT_DOUBLE_EQ(unnamed.calibration.counts_per_force_unit, + named.calibration.counts_per_force_unit); + EXPECT_DOUBLE_EQ(unnamed.calibration.counts_per_torque_unit, + named.calibration.counts_per_torque_unit); + EXPECT_EQ(unnamed.calibration.force_unit, named.calibration.force_unit); + EXPECT_EQ(unnamed.calibration.torque_unit, named.calibration.torque_unit); + EXPECT_EQ(unnamed.source, named.source); + EXPECT_EQ(unnamed.revision, named.revision); +} + +class RequiredCalibrationField : public ::testing::TestWithParam {}; + +TEST_P(RequiredCalibrationField, RejectsMissingFields) { + const auto missing_calibration = remove_element(kValidXml, GetParam()); + EXPECT_THROW(netft::detail::parse_sensor_configuration(missing_calibration), netft::DiscoveryError); + EXPECT_THROW( + netft::detail::parse_sensor_configuration(remove_element(missing_calibration, "prodname")), + netft::DiscoveryError); } -TEST_P(RequiredXmlField, RejectsDuplicateFields) { +INSTANTIATE_TEST_SUITE_P(EveryCalibrationField, RequiredCalibrationField, + ::testing::Values("cfgcpf", "cfgcpt", "scfgfu", "scfgtu")); + +class XmlField : public ::testing::TestWithParam {}; + +TEST_P(XmlField, RejectsDuplicateFields) { EXPECT_THROW(netft::detail::parse_sensor_configuration(duplicate_element(kValidXml, GetParam())), netft::DiscoveryError); } -TEST_P(RequiredXmlField, RejectsEmptyFields) { +TEST_P(XmlField, RejectsEmptyFields) { EXPECT_THROW( netft::detail::parse_sensor_configuration(replace_value(kValidXml, GetParam(), " \t\r\n")), netft::DiscoveryError); } -TEST_P(RequiredXmlField, RejectsFieldsLongerThan128Bytes) { +TEST_P(XmlField, RejectsFieldsLongerThan128Bytes) { EXPECT_THROW(netft::detail::parse_sensor_configuration( replace_value(kValidXml, GetParam(), std::string(129, '1'))), netft::DiscoveryError); } -INSTANTIATE_TEST_SUITE_P(EveryField, RequiredXmlField, +INSTANTIATE_TEST_SUITE_P(EveryField, XmlField, ::testing::Values("prodname", "cfgcpf", "cfgcpt", "scfgfu", "scfgtu")); struct InvalidCountCase { @@ -424,8 +449,8 @@ TEST(XmlConfiguration, IgnoresRequiredElementTextInsideComments) { } TEST(XmlConfiguration, RejectsAFieldThatAppearsOnlyInsideAComment) { - const auto xml = insert_after_root_open(remove_element(kValidXml, "prodname"), - ""); + const auto xml = insert_after_root_open(remove_element(kValidXml, "cfgcpf"), + ""); EXPECT_THROW(netft::detail::parse_sensor_configuration(xml), netft::DiscoveryError); } @@ -440,12 +465,21 @@ TEST(XmlConfiguration, IgnoresRequiredElementTextInsideCdata) { } TEST(XmlConfiguration, RejectsAFieldThatAppearsOnlyInsideCdata) { - const auto xml = insert_after_root_open(remove_element(kValidXml, "prodname"), - "Fake Sensor]]>"); + const auto xml = insert_after_root_open(remove_element(kValidXml, "cfgcpf"), + "1000000]]>"); EXPECT_THROW(netft::detail::parse_sensor_configuration(xml), netft::DiscoveryError); } +TEST(XmlConfiguration, IgnoresProductNamesThatAppearOnlyInsideCommentsOrCdata) { + for (const auto markup : {"", + "Fake Sensor]]>"}) { + const auto xml = insert_after_root_open(remove_element(kValidXml, "prodname"), markup); + const auto result = netft::detail::parse_sensor_configuration(xml); + EXPECT_EQ(result.product_name, "ATI Net F/T"); + } +} + TEST(XmlConfiguration, RejectsUnterminatedCommentsAndCdata) { EXPECT_THROW( netft::detail::parse_sensor_configuration(std::string{kValidXml} + "