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} + "