Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion pixi.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[workspace]
name = "netft-cpp"
version = "0.3.4"
version = "0.3.5"
channels = ["conda-forge"]
platforms = ["linux-64", "linux-aarch64"]

Expand Down
5 changes: 5 additions & 0 deletions src/detail/xml_config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand Down
30 changes: 30 additions & 0 deletions test/test_client_stream.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,36 @@ TEST(ClientStream, CompleteCalibrationOverrideSkipsHttpDiscovery) {
client.stop();
}

TEST(ClientStream, MissingProductNameDoesNotBlockCalibratedStreaming) {
netft::test::FakeSensor sensor;
sensor.pause();
sensor.set_xml_configuration("<netft><cfgcpf>100</cfgcpf><cfgcpt>10</cfgcpt>"
"<scfgfu>N</scfgfu><scfgtu>Nmm</scfgtu></netft>");
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<double, 3>{1.0, -2.0, 3.0}));
EXPECT_EQ(sample->torque, (std::array<double, 3>{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();
Expand Down
58 changes: 46 additions & 12 deletions test/test_discovery.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<const char *> {};

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<const char *> {};

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<const char *> {};

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 {
Expand Down Expand Up @@ -424,8 +449,8 @@ TEST(XmlConfiguration, IgnoresRequiredElementTextInsideComments) {
}

TEST(XmlConfiguration, RejectsAFieldThatAppearsOnlyInsideAComment) {
const auto xml = insert_after_root_open(remove_element(kValidXml, "prodname"),
"<!-- <prodname>Fake Sensor</prodname> -->");
const auto xml = insert_after_root_open(remove_element(kValidXml, "cfgcpf"),
"<!-- <cfgcpf>1000000</cfgcpf> -->");

EXPECT_THROW(netft::detail::parse_sensor_configuration(xml), netft::DiscoveryError);
}
Expand All @@ -440,12 +465,21 @@ TEST(XmlConfiguration, IgnoresRequiredElementTextInsideCdata) {
}

TEST(XmlConfiguration, RejectsAFieldThatAppearsOnlyInsideCdata) {
const auto xml = insert_after_root_open(remove_element(kValidXml, "prodname"),
"<![CDATA[<prodname>Fake Sensor</prodname>]]>");
const auto xml = insert_after_root_open(remove_element(kValidXml, "cfgcpf"),
"<![CDATA[<cfgcpf>1000000</cfgcpf>]]>");

EXPECT_THROW(netft::detail::parse_sensor_configuration(xml), netft::DiscoveryError);
}

TEST(XmlConfiguration, IgnoresProductNamesThatAppearOnlyInsideCommentsOrCdata) {
for (const auto markup : {"<!-- <prodname>Fake Sensor</prodname> -->",
"<![CDATA[<prodname>Fake Sensor</prodname>]]>"}) {
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} + "<!-- unterminated"),
Expand Down
Loading