From 8a3ab119dc95946d3f494b47f02805824602cc07 Mon Sep 17 00:00:00 2001 From: Xudong Han <42986190+han-xudong@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:46:23 +0800 Subject: [PATCH 01/25] Add snapshot core build contract --- CMakeLists.txt | 31 +++++++++++++++++++++++++++++-- package.xml | 1 + test/core/CMakeLists.txt | 18 ++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 test/core/CMakeLists.txt diff --git a/CMakeLists.txt b/CMakeLists.txt index 37a120a..607ce44 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.10) +cmake_minimum_required(VERSION 3.16) project(netft_driver) set(CMAKE_CXX_STANDARD 17) @@ -8,6 +8,33 @@ set(CMAKE_CXX_EXTENSIONS OFF) option(NETFT_CORE_ONLY "Build only the ROS-neutral C++ core" OFF) option(NETFT_INSTALL_TEST_TOOLS "Install private integration-test tools" OFF) +find_package(Threads REQUIRED) +find_package(CURL 7.62 REQUIRED) + +set(NETFT_SNAPSHOT_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/src/core") +set(NETFT_SNAPSHOT_SOURCES + ${NETFT_SNAPSHOT_SOURCE_DIR}/src/types.cpp + ${NETFT_SNAPSHOT_SOURCE_DIR}/src/status.cpp + ${NETFT_SNAPSHOT_SOURCE_DIR}/src/discovery.cpp + ${NETFT_SNAPSHOT_SOURCE_DIR}/src/client.cpp + ${NETFT_SNAPSHOT_SOURCE_DIR}/src/detail/client_impl.cpp + ${NETFT_SNAPSHOT_SOURCE_DIR}/src/detail/fault_latch.cpp + ${NETFT_SNAPSHOT_SOURCE_DIR}/src/detail/posix_transport.cpp + ${NETFT_SNAPSHOT_SOURCE_DIR}/src/detail/protocol.cpp + ${NETFT_SNAPSHOT_SOURCE_DIR}/src/detail/sequence.cpp + ${NETFT_SNAPSHOT_SOURCE_DIR}/src/detail/xml_config.cpp +) + +add_library(netft_sdk_core STATIC ${NETFT_SNAPSHOT_SOURCES}) +set_target_properties(netft_sdk_core PROPERTIES POSITION_INDEPENDENT_CODE ON) +target_compile_features(netft_sdk_core PUBLIC cxx_std_17) +target_compile_definitions(netft_sdk_core PRIVATE NETFT_BUILDING_LIBRARY) +target_include_directories(netft_sdk_core + PUBLIC ${NETFT_SNAPSHOT_SOURCE_DIR}/include + PRIVATE ${NETFT_SNAPSHOT_SOURCE_DIR}/src +) +target_link_libraries(netft_sdk_core PUBLIC Threads::Threads CURL::libcurl) + add_library(netft_core STATIC src/protocol.cpp src/status.cpp src/client.cpp) set_target_properties(netft_core PROPERTIES POSITION_INDEPENDENT_CODE ON) target_compile_features(netft_core PUBLIC cxx_std_17) @@ -16,12 +43,12 @@ target_include_directories(netft_core PUBLIC ) add_executable(netft_check src/check_main.cpp) -find_package(Threads REQUIRED) target_link_libraries(netft_check PRIVATE netft_core Threads::Threads) if(NETFT_CORE_ONLY) enable_testing() find_package(GTest REQUIRED) + add_subdirectory(test/core) add_subdirectory(test/cpp) return() endif() diff --git a/package.xml b/package.xml index 5a74c8e..d7ec7bc 100644 --- a/package.xml +++ b/package.xml @@ -22,6 +22,7 @@ geometry_msgs std_srvs diagnostic_msgs + libcurl-dev roslaunch roscpp diff --git a/test/core/CMakeLists.txt b/test/core/CMakeLists.txt new file mode 100644 index 0000000..6b000ce --- /dev/null +++ b/test/core/CMakeLists.txt @@ -0,0 +1,18 @@ +if(TARGET GTest::gtest_main) + set(NETFT_GTEST_MAIN_TARGET GTest::gtest_main) +elseif(TARGET GTest::Main) + set(NETFT_GTEST_MAIN_TARGET GTest::Main) +else() + message(FATAL_ERROR "GoogleTest does not provide a main target") +endif() + +function(add_netft_snapshot_test target source) + add_executable(${target} ${source} ${ARGN}) + target_include_directories(${target} PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${NETFT_SNAPSHOT_SOURCE_DIR}/src + ) + target_link_libraries(${target} PRIVATE + netft_sdk_core ${NETFT_GTEST_MAIN_TARGET}) + add_test(NAME ${target} COMMAND ${target}) +endfunction() From b0460e3b2cb103f05839e5d394913fc5b2165e10 Mon Sep 17 00:00:00 2001 From: Xudong Han <42986190+han-xudong@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:51:27 +0800 Subject: [PATCH 02/25] core: snapshot netft-cpp v0.1.2 --- package.xml | 1 + src/core/LICENSE | 201 +++++++ src/core/UPSTREAM | 5 + src/core/include/netft/client.hpp | 44 ++ src/core/include/netft/discovery.hpp | 26 + src/core/include/netft/export.hpp | 15 + src/core/include/netft/status.hpp | 15 + src/core/include/netft/types.hpp | 106 ++++ src/core/src/client.cpp | 31 + src/core/src/detail/client_impl.cpp | 727 ++++++++++++++++++++++++ src/core/src/detail/client_impl.hpp | 107 ++++ src/core/src/detail/fault_latch.cpp | 33 ++ src/core/src/detail/fault_latch.hpp | 27 + src/core/src/detail/posix_transport.cpp | 138 +++++ src/core/src/detail/posix_transport.hpp | 31 + src/core/src/detail/protocol.cpp | 43 ++ src/core/src/detail/protocol.hpp | 31 + src/core/src/detail/sequence.cpp | 67 +++ src/core/src/detail/sequence.hpp | 46 ++ src/core/src/detail/xml_config.cpp | 242 ++++++++ src/core/src/detail/xml_config.hpp | 11 + src/core/src/discovery.cpp | 183 ++++++ src/core/src/status.cpp | 68 +++ src/core/src/types.cpp | 164 ++++++ test/core/CMakeLists.txt | 54 ++ test/core/support/fake_http_server.cpp | 199 +++++++ test/core/support/fake_http_server.hpp | 27 + test/core/support/fake_sensor.cpp | 260 +++++++++ test/core/support/fake_sensor.hpp | 81 +++ test/core/test_client_lifecycle.cpp | 714 +++++++++++++++++++++++ test/core/test_client_recovery.cpp | 543 ++++++++++++++++++ test/core/test_client_stream.cpp | 240 ++++++++ test/core/test_discovery.cpp | 401 +++++++++++++ test/core/test_fault_latch.cpp | 28 + test/core/test_protocol.cpp | 65 +++ test/core/test_sequence.cpp | 59 ++ test/core/test_status.cpp | 35 ++ test/core/test_types.cpp | 287 ++++++++++ 38 files changed, 5355 insertions(+) create mode 100644 src/core/LICENSE create mode 100644 src/core/UPSTREAM create mode 100644 src/core/include/netft/client.hpp create mode 100644 src/core/include/netft/discovery.hpp create mode 100644 src/core/include/netft/export.hpp create mode 100644 src/core/include/netft/status.hpp create mode 100644 src/core/include/netft/types.hpp create mode 100644 src/core/src/client.cpp create mode 100644 src/core/src/detail/client_impl.cpp create mode 100644 src/core/src/detail/client_impl.hpp create mode 100644 src/core/src/detail/fault_latch.cpp create mode 100644 src/core/src/detail/fault_latch.hpp create mode 100644 src/core/src/detail/posix_transport.cpp create mode 100644 src/core/src/detail/posix_transport.hpp create mode 100644 src/core/src/detail/protocol.cpp create mode 100644 src/core/src/detail/protocol.hpp create mode 100644 src/core/src/detail/sequence.cpp create mode 100644 src/core/src/detail/sequence.hpp create mode 100644 src/core/src/detail/xml_config.cpp create mode 100644 src/core/src/detail/xml_config.hpp create mode 100644 src/core/src/discovery.cpp create mode 100644 src/core/src/status.cpp create mode 100644 src/core/src/types.cpp create mode 100644 test/core/support/fake_http_server.cpp create mode 100644 test/core/support/fake_http_server.hpp create mode 100644 test/core/support/fake_sensor.cpp create mode 100644 test/core/support/fake_sensor.hpp create mode 100644 test/core/test_client_lifecycle.cpp create mode 100644 test/core/test_client_recovery.cpp create mode 100644 test/core/test_client_stream.cpp create mode 100644 test/core/test_discovery.cpp create mode 100644 test/core/test_fault_latch.cpp create mode 100644 test/core/test_protocol.cpp create mode 100644 test/core/test_sequence.cpp create mode 100644 test/core/test_status.cpp create mode 100644 test/core/test_types.cpp diff --git a/package.xml b/package.xml index d7ec7bc..6ed42c2 100644 --- a/package.xml +++ b/package.xml @@ -6,6 +6,7 @@ Native ROS 1, ROS 2, and ros2_control driver for ATI Net F/T RDT sensors. Xudong Han MIT + Apache-2.0 https://github.com/netft/ros-netft https://github.com/netft/ros-netft https://github.com/netft/ros-netft/issues diff --git a/src/core/LICENSE b/src/core/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/src/core/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/src/core/UPSTREAM b/src/core/UPSTREAM new file mode 100644 index 0000000..ad56af6 --- /dev/null +++ b/src/core/UPSTREAM @@ -0,0 +1,5 @@ +repository=https://github.com/netft/netft-cpp.git +tag=v0.1.2 +commit=bb7df8d211ecc36ebc1434da3686897d7b3d73cc +license=Apache-2.0 +paths=include/netft,src diff --git a/src/core/include/netft/client.hpp b/src/core/include/netft/client.hpp new file mode 100644 index 0000000..4a24373 --- /dev/null +++ b/src/core/include/netft/client.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "netft/export.hpp" +#include "netft/types.hpp" + +namespace netft { + +class NETFT_API NotConnectedError : public std::runtime_error { +public: + using std::runtime_error::runtime_error; +}; + +class NETFT_API Client { +public: + using SampleCallback = std::function; + + explicit Client(Config config); + ~Client(); + Client(const Client &) = delete; + Client &operator=(const Client &) = delete; + Client(Client &&) = delete; + Client &operator=(Client &&) = delete; + + void start(SampleCallback callback); + void stop() noexcept; + void bias(); + bool wait_for_first_sample(std::chrono::duration timeout); + [[nodiscard]] bool faulted() const noexcept; + [[nodiscard]] FaultCode fault_code() const noexcept; + [[nodiscard]] HealthSnapshot health() const; + [[nodiscard]] std::optional latest_sample() const; + +private: + class Impl; + std::unique_ptr impl_; +}; + +} // namespace netft diff --git a/src/core/include/netft/discovery.hpp b/src/core/include/netft/discovery.hpp new file mode 100644 index 0000000..4357f93 --- /dev/null +++ b/src/core/include/netft/discovery.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include +#include +#include + +#include "netft/export.hpp" +#include "netft/types.hpp" + +namespace netft { + +struct DiscoveryOptions { + std::string sensor_host{"192.168.1.1"}; + int http_port{80}; + std::chrono::duration connect_timeout{0.5}; + std::chrono::duration total_timeout{1.0}; +}; + +class NETFT_API DiscoveryError : public std::runtime_error { +public: + using std::runtime_error::runtime_error; +}; + +NETFT_API SensorConfiguration discover_sensor(const DiscoveryOptions &options); + +} // namespace netft diff --git a/src/core/include/netft/export.hpp b/src/core/include/netft/export.hpp new file mode 100644 index 0000000..e9de400 --- /dev/null +++ b/src/core/include/netft/export.hpp @@ -0,0 +1,15 @@ +#pragma once + +#ifdef _WIN32 +#if defined(NETFT_BUILDING_LIBRARY) +#define NETFT_API __declspec(dllexport) +#elif defined(NETFT_USING_LIBRARY) +#define NETFT_API __declspec(dllimport) +#else +#define NETFT_API +#endif +#define NETFT_LOCAL +#else +#define NETFT_API __attribute__((visibility("default"))) +#define NETFT_LOCAL __attribute__((visibility("hidden"))) +#endif diff --git a/src/core/include/netft/status.hpp b/src/core/include/netft/status.hpp new file mode 100644 index 0000000..f9387dd --- /dev/null +++ b/src/core/include/netft/status.hpp @@ -0,0 +1,15 @@ +#pragma once + +#include +#include + +#include "netft/export.hpp" + +namespace netft { + +enum class StatusSeverity { Ok, Warn, Error }; + +NETFT_API StatusSeverity classify_status(std::uint32_t status) noexcept; +NETFT_API std::string decode_status(std::uint32_t status); + +} // namespace netft diff --git a/src/core/include/netft/types.hpp b/src/core/include/netft/types.hpp new file mode 100644 index 0000000..c401b8e --- /dev/null +++ b/src/core/include/netft/types.hpp @@ -0,0 +1,106 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "netft/export.hpp" + +namespace netft { + +enum class ForceUnit { Unknown, PoundForce, Newton, KiloPoundForce, KiloNewton, KilogramForce }; +enum class TorqueUnit { + Unknown, + PoundForceInch, + PoundForceFoot, + NewtonMeter, + NewtonMillimeter, + KilogramForceCentimeter, + KiloNewtonMeter +}; +enum class CalibrationSource { Sensor, Override }; +enum class RecoveryPolicy { Reconnect, FailStop }; +enum class ClientState { Stopped, Connecting, Streaming, Backoff, Faulted }; +enum class FaultCode { + None, + SensorConfiguration, + Timeout, + Socket, + SeriousStatus, + FtStall, + FtBackward, + MalformedStorm, + Callback +}; + +struct Calibration { + double counts_per_force_unit{}; + double counts_per_torque_unit{}; + ForceUnit force_unit{ForceUnit::Unknown}; + TorqueUnit torque_unit{TorqueUnit::Unknown}; +}; + +struct SensorConfiguration { + std::string product_name; + Calibration calibration; + CalibrationSource source{CalibrationSource::Sensor}; + std::uint64_t revision{1}; +}; + +struct Config { + std::string sensor_host{"192.168.1.1"}; + int rdt_port{49152}; + int http_port{80}; + std::chrono::duration receive_timeout{0.1}; + std::chrono::duration configuration_connect_timeout{0.5}; + std::chrono::duration configuration_timeout{1.0}; + std::chrono::duration reconnect_initial_delay{0.25}; + std::chrono::duration reconnect_max_delay{5.0}; + double sample_rate_limit_hz{0.0}; + bool deliver_samples_with_error_status{false}; + RecoveryPolicy recovery_policy{RecoveryPolicy::Reconnect}; + std::optional calibration_override; +}; + +struct Sample { + std::uint32_t rdt_sequence{}, ft_sequence{}, status{}; + std::array force{}, torque{}; + ForceUnit force_unit{ForceUnit::Unknown}; + TorqueUnit torque_unit{TorqueUnit::Unknown}; + std::uint64_t configuration_revision{}; + std::chrono::steady_clock::time_point received_at; +}; + +struct HealthSnapshot { + ClientState state{ClientState::Stopped}; + FaultCode fault_code{FaultCode::None}; + std::string sensor_host; + int rdt_port{}; + std::optional sensor_configuration; + std::optional last_rdt_sequence, last_ft_sequence; + std::uint32_t last_status{}; + double receive_rate_hz{}, delivery_rate_hz{}; + std::uint64_t received_count{}, delivered_count{}, rate_limited_count{}; + std::uint64_t device_error_count{}, warning_count{}, lost_count{}; + std::uint64_t duplicate_count{}, out_of_order_count{}, malformed_count{}; + std::uint64_t reconnect_count{}, timeout_count{}, callback_error_count{}; + std::uint64_t ft_stall_count{}, ft_backward_count{}, ft_restart_count{}; + std::uint64_t calibration_change_count{}; + std::optional> last_record_age; + std::string last_error; + std::string last_ft_progress{"unknown"}; +}; + +NETFT_API void validate(const Calibration &calibration); +NETFT_API void validate(const Config &config); +NETFT_API std::string_view to_string(ForceUnit unit) noexcept; +NETFT_API std::string_view to_string(TorqueUnit unit) noexcept; +NETFT_API std::optional force_unit_from_string(std::string_view value) noexcept; +NETFT_API std::optional torque_unit_from_string(std::string_view value) noexcept; +NETFT_API std::string_view to_string(ClientState state) noexcept; +NETFT_API std::string_view to_string(FaultCode code) noexcept; + +} // namespace netft diff --git a/src/core/src/client.cpp b/src/core/src/client.cpp new file mode 100644 index 0000000..e253b28 --- /dev/null +++ b/src/core/src/client.cpp @@ -0,0 +1,31 @@ +#include "netft/client.hpp" + +#include + +#include "detail/client_impl.hpp" + +namespace netft { + +Client::Client(Config config) : impl_(std::make_unique(std::move(config))) {} + +Client::~Client() { stop(); } + +void Client::start(SampleCallback callback) { impl_->start(std::move(callback)); } + +void Client::stop() noexcept { impl_->stop(); } + +void Client::bias() { impl_->bias(); } + +bool Client::wait_for_first_sample(const std::chrono::duration timeout) { + return impl_->wait_for_first_sample(timeout); +} + +bool Client::faulted() const noexcept { return impl_->faulted(); } + +FaultCode Client::fault_code() const noexcept { return impl_->fault_code(); } + +HealthSnapshot Client::health() const { return impl_->health(); } + +std::optional Client::latest_sample() const { return impl_->latest_sample(); } + +} // namespace netft diff --git a/src/core/src/detail/client_impl.cpp b/src/core/src/detail/client_impl.cpp new file mode 100644 index 0000000..8157d17 --- /dev/null +++ b/src/core/src/detail/client_impl.cpp @@ -0,0 +1,727 @@ +#include "detail/client_impl.hpp" + +#include +#include +#include +#include +#include + +#include "detail/protocol.hpp" +#include "netft/discovery.hpp" +#include "netft/status.hpp" + +namespace netft { +namespace { + +constexpr std::uint64_t kMalformedStormThreshold = 10; + +} // namespace + +FaultCode Client::Impl::fault_for(const SessionResult result) noexcept { + switch (result) { + case Client::Impl::SessionResult::SensorConfiguration: + return FaultCode::SensorConfiguration; + case Client::Impl::SessionResult::Timeout: + return FaultCode::Timeout; + case Client::Impl::SessionResult::Socket: + return FaultCode::Socket; + case Client::Impl::SessionResult::SeriousStatus: + return FaultCode::SeriousStatus; + case Client::Impl::SessionResult::FtStall: + return FaultCode::FtStall; + case Client::Impl::SessionResult::FtBackward: + return FaultCode::FtBackward; + case Client::Impl::SessionResult::MalformedStorm: + return FaultCode::MalformedStorm; + case Client::Impl::SessionResult::Callback: + return FaultCode::Callback; + case Client::Impl::SessionResult::Stopped: + return FaultCode::None; + } + return FaultCode::Socket; +} + +const char *Client::Impl::message_for(const SessionResult result) noexcept { + switch (result) { + case Client::Impl::SessionResult::SensorConfiguration: + return "sensor configuration discovery failed"; + case Client::Impl::SessionResult::Timeout: + return "no valid RDT record before timeout"; + case Client::Impl::SessionResult::Socket: + return "UDP socket failure"; + case Client::Impl::SessionResult::SeriousStatus: + return "serious device status"; + case Client::Impl::SessionResult::FtStall: + return "FT sequence stalled"; + case Client::Impl::SessionResult::FtBackward: + return "FT sequence moved backward"; + case Client::Impl::SessionResult::MalformedStorm: + return "malformed packet storm"; + case Client::Impl::SessionResult::Callback: + return "sample callback failed"; + case Client::Impl::SessionResult::Stopped: + return ""; + } + return "UDP socket failure"; +} + +namespace { + +bool same_calibration(const Calibration &left, const Calibration &right) { + return left.counts_per_force_unit == right.counts_per_force_unit && + left.counts_per_torque_unit == right.counts_per_torque_unit && + left.force_unit == right.force_unit && left.torque_unit == right.torque_unit; +} + +void prune_rate_window(std::deque ×, + const std::chrono::steady_clock::time_point now) { + while (!times.empty() && now - times.front() > std::chrono::seconds{1}) { + times.pop_front(); + } +} + +std::string ft_progress_name(const detail::FtSequenceKind kind) { + switch (kind) { + case detail::FtSequenceKind::First: + return "first"; + case detail::FtSequenceKind::Forward: + return "forward"; + case detail::FtSequenceKind::Stall: + return "stall"; + case detail::FtSequenceKind::Backward: + return "backward"; + case detail::FtSequenceKind::Restart: + return "restart"; + } + return "unknown"; +} + +} // namespace + +Client::Impl::Impl(Config config) : config_(std::move(config)) { + validate(config_); + health_.sensor_host = config_.sensor_host; + health_.rdt_port = config_.rdt_port; +} + +Client::Impl::~Impl() { stop(); } + +void Client::Impl::start(SampleCallback callback) { + for (;;) { + std::thread reaped_worker; + { + std::unique_lock lifecycle_lock(lifecycle_mutex_); + if (!worker_exited_.load(std::memory_order_acquire) && + active_worker_id_ == std::this_thread::get_id()) { + throw std::logic_error("client is already running"); + } + lifecycle_cv_.wait(lifecycle_lock, [this] { return !joining_; }); + if (worker_.joinable()) { + if (!worker_exited_.load(std::memory_order_acquire)) { + if (!faulted()) { + throw std::logic_error("client is already running"); + } + lifecycle_cv_.wait(lifecycle_lock, [this] { + return worker_exited_.load(std::memory_order_acquire) || !faulted(); + }); + continue; + } + joining_ = true; + reaped_worker = std::move(worker_); + } else { + const bool previous_stopping = stopping_.load(); + const bool previous_worker_exited = worker_exited_.load(); + const auto previous_fault = fault_code(); + auto previous_callback = std::move(callback_); + std::chrono::steady_clock::time_point previous_last_delivery; + { + std::scoped_lock record_lock(record_mutex_); + previous_last_delivery = last_delivery_at_; + last_delivery_at_ = {}; + } + + HealthSnapshot previous_health; + std::optional previous_latest; + std::chrono::steady_clock::time_point previous_last_record; + std::uint64_t previous_generation{}; + std::uint64_t previous_delivered_generation{}; + std::deque previous_receive_times; + std::deque previous_delivery_times; + std::uint64_t previous_consecutive_malformed{}; + { + std::scoped_lock data_lock(data_mutex_); + previous_health = health_; + previous_latest = latest_; + previous_last_record = last_record_at_; + previous_generation = generation_; + previous_delivered_generation = delivered_generation_; + previous_receive_times = receive_times_; + previous_delivery_times = delivery_times_; + previous_consecutive_malformed = consecutive_malformed_; + + fault_latch_.reset(); + health_.state = ClientState::Connecting; + health_.fault_code = FaultCode::None; + health_.last_error.clear(); + health_.receive_rate_hz = 0.0; + health_.delivery_rate_hz = 0.0; + latest_.reset(); + last_record_at_ = {}; + ++generation_; + delivered_generation_ = 0; + receive_times_.clear(); + delivery_times_.clear(); + consecutive_malformed_ = 0; + } + stopping_ = false; + worker_exited_ = false; + callback_ = std::move(callback); + try { + worker_ = create_worker_thread(); + } catch (...) { + stopping_ = previous_stopping; + worker_exited_ = previous_worker_exited; + callback_ = std::move(previous_callback); + { + std::scoped_lock record_lock(record_mutex_); + last_delivery_at_ = previous_last_delivery; + } + { + std::scoped_lock data_lock(data_mutex_); + health_ = previous_health; + latest_ = previous_latest; + last_record_at_ = previous_last_record; + generation_ = previous_generation; + delivered_generation_ = previous_delivered_generation; + receive_times_ = std::move(previous_receive_times); + delivery_times_ = std::move(previous_delivery_times); + consecutive_malformed_ = previous_consecutive_malformed; + } + fault_latch_.reset(); + if (previous_fault != FaultCode::None) { + static_cast(fault_latch_.publish(previous_fault, previous_health.last_error, + data_mutex_, health_)); + } + first_sample_cv_.notify_all(); + lifecycle_cv_.notify_all(); + throw; + } + active_worker_id_ = worker_.get_id(); + first_sample_cv_.notify_all(); + lifecycle_cv_.notify_all(); + return; + } + } + + reaped_worker.join(); + { + std::scoped_lock lifecycle_lock(lifecycle_mutex_); + active_worker_id_ = {}; + joining_ = false; + } + lifecycle_cv_.notify_all(); + } +} + +std::thread Client::Impl::create_worker_thread() { + if (thread_factory_ != nullptr) { + return thread_factory_(this); + } + return std::thread([this] { run(); }); +} + +void Client::Impl::stop() noexcept { + for (;;) { + std::thread joining_worker; + { + std::unique_lock lifecycle_lock(lifecycle_mutex_); + stopping_ = true; + first_sample_cv_.notify_all(); + lifecycle_cv_.notify_all(); + { + std::scoped_lock command_lock(command_mutex_); + if (session_started_) { + try { + transport_.send(detail::encode_request(detail::Command::StopStreaming)); + } catch (...) { + static_cast(0); + } + session_started_ = false; + } + transport_.shutdown(); + } + + if (!worker_exited_.load(std::memory_order_acquire) && + active_worker_id_ == std::this_thread::get_id()) { + return; + } + if (joining_) { + lifecycle_cv_.wait(lifecycle_lock, [this] { return !joining_; }); + continue; + } + if (!worker_.joinable()) { + if (!faulted()) { + std::scoped_lock data_lock(data_mutex_); + health_.state = ClientState::Stopped; + } + return; + } + joining_ = true; + joining_worker = std::move(worker_); + } + + joining_worker.join(); + { + std::scoped_lock lifecycle_lock(lifecycle_mutex_); + if (!faulted()) { + std::scoped_lock data_lock(data_mutex_); + health_.state = ClientState::Stopped; + } + active_worker_id_ = {}; + joining_ = false; + } + lifecycle_cv_.notify_all(); + first_sample_cv_.notify_all(); + return; + } +} + +void Client::Impl::bias() { + std::scoped_lock command_lock(command_mutex_); + std::scoped_lock record_lock(record_mutex_); + { + std::scoped_lock data_lock(data_mutex_); + if (stopping_ || !session_started_ || faulted() || health_.state != ClientState::Streaming) { + throw NotConnectedError("client is not streaming"); + } + } + transport_.send(detail::encode_request(detail::Command::SetSoftwareBias)); + transport_.send(detail::encode_request(detail::Command::StartRealtime)); + rdt_sequence_.reset(); +} + +bool Client::Impl::wait_for_first_sample(const std::chrono::duration timeout) { + std::unique_lock data_lock(data_mutex_); + const auto captured_generation = generation_; + if (captured_generation == 0) { + return false; + } + first_sample_cv_.wait_for(data_lock, timeout, [this, captured_generation] { + return generation_ != captured_generation || delivered_generation_ == captured_generation || + stopping_ || faulted(); + }); + if (const auto hook = wait_wake_test_hook_) { + data_lock.unlock(); + hook(this, captured_generation); + data_lock.lock(); + } + if (generation_ != captured_generation) { + return false; + } + return delivered_generation_ == captured_generation; +} + +bool Client::Impl::faulted() const noexcept { return fault_latch_.faulted(); } + +FaultCode Client::Impl::fault_code() const noexcept { return fault_latch_.code(); } + +HealthSnapshot Client::Impl::health() const { + std::scoped_lock data_lock(data_mutex_); + const auto now = std::chrono::steady_clock::now(); + prune_rate_window(receive_times_, now); + prune_rate_window(delivery_times_, now); + auto snapshot = health_; + snapshot.fault_code = fault_code(); + if (snapshot.fault_code != FaultCode::None) { + snapshot.state = ClientState::Faulted; + } + snapshot.receive_rate_hz = static_cast(receive_times_.size()); + snapshot.delivery_rate_hz = static_cast(delivery_times_.size()); + if (last_record_at_ != std::chrono::steady_clock::time_point{}) { + snapshot.last_record_age = now - last_record_at_; + } + return snapshot; +} + +std::optional Client::Impl::latest_sample() const { + std::scoped_lock data_lock(data_mutex_); + return latest_; +} + +SensorConfiguration Client::Impl::configuration_for_session() { + if (config_.calibration_override) { + return SensorConfiguration{"", *config_.calibration_override, CalibrationSource::Override, 1}; + } + DiscoveryOptions options; + options.sensor_host = config_.sensor_host; + options.http_port = config_.http_port; + options.connect_timeout = config_.configuration_connect_timeout; + options.total_timeout = config_.configuration_timeout; + return discover_sensor(options); +} + +void Client::Impl::apply_configuration(SensorConfiguration configuration) { + std::scoped_lock data_lock(data_mutex_); + if (health_.sensor_configuration) { + const auto &previous = *health_.sensor_configuration; + if (same_calibration(previous.calibration, configuration.calibration)) { + configuration.revision = previous.revision; + } else { + configuration.revision = previous.revision + 1; + ++health_.calibration_change_count; + } + } else { + configuration.revision = 1; + } + health_.sensor_configuration = std::move(configuration); +} + +void Client::Impl::run() noexcept { + auto backoff = config_.reconnect_initial_delay; + try { + while (!stopping_ && !faulted()) { + auto outcome = receive_session(); + close_session(); + + if (outcome.message.empty()) { + outcome.message = message_for(outcome.result); + } + if (outcome.result != SessionResult::Stopped && + config_.recovery_policy == RecoveryPolicy::FailStop) { + set_fault(fault_for(outcome.result), std::move(outcome.message)); + break; + } + if (outcome.result == SessionResult::Stopped || stopping_) { + break; + } + + if (outcome.received_valid_record) { + backoff = config_.reconnect_initial_delay; + } + { + std::scoped_lock data_lock(data_mutex_); + health_.last_error = std::move(outcome.message); + health_.state = ClientState::Backoff; + ++health_.reconnect_count; + } + + std::unique_lock data_lock(data_mutex_); + first_sample_cv_.wait_for(data_lock, backoff, [this] { return stopping_.load(); }); + data_lock.unlock(); + backoff = std::min(backoff * 2.0, config_.reconnect_max_delay); + } + } catch (const std::exception &error) { + if (!stopping_) { + set_fault(FaultCode::Socket, error.what()); + } + } catch (...) { + if (!stopping_) { + set_fault(FaultCode::Socket, "unknown client failure"); + } + } + finish_session(); +} + +Client::Impl::SessionOutcome Client::Impl::receive_session() { + bool received_valid_record = false; + { + std::scoped_lock data_lock(data_mutex_); + health_.state = ClientState::Connecting; + } + if (stopping_) { + return {SessionResult::Stopped, {}, false}; + } + + try { + apply_configuration(configuration_for_session()); + } catch (const DiscoveryError &error) { + return {stopping_ ? SessionResult::Stopped : SessionResult::SensorConfiguration, error.what(), + false}; + } + if (stopping_) { + return {SessionResult::Stopped, {}, false}; + } + + try { + transport_.connect(config_.sensor_host, config_.rdt_port); + std::scoped_lock command_lock(command_mutex_); + if (stopping_) { + return {SessionResult::Stopped, {}, false}; + } + transport_.send(detail::encode_request(detail::Command::StartRealtime)); + session_started_ = true; + } catch (const std::exception &error) { + return {stopping_ ? SessionResult::Stopped : SessionResult::Socket, error.what(), false}; + } + + { + std::scoped_lock record_lock(record_mutex_); + rdt_sequence_.reset(); + ft_sequence_.begin_session(); + } + + const auto timeout = config_.receive_timeout; + auto deadline = std::chrono::steady_clock::now() + timeout; + std::array buffer{}; + while (!stopping_) { + const auto now = std::chrono::steady_clock::now(); + if (now >= deadline) { + std::scoped_lock data_lock(data_mutex_); + if (stopping_) { + return {SessionResult::Stopped, {}, received_valid_record}; + } + ++health_.timeout_count; + return {SessionResult::Timeout, {}, received_valid_record}; + } + + std::size_t size{}; + try { + size = transport_.receive(buffer.data(), buffer.size(), deadline - now); + } catch (const std::exception &error) { + return {stopping_ ? SessionResult::Stopped : SessionResult::Socket, error.what(), + received_valid_record}; + } + if (stopping_) { + return {SessionResult::Stopped, {}, received_valid_record}; + } + if (size == 0) { + std::scoped_lock data_lock(data_mutex_); + if (stopping_) { + return {SessionResult::Stopped, {}, received_valid_record}; + } + ++health_.timeout_count; + return {SessionResult::Timeout, {}, received_valid_record}; + } + + detail::RawRecord record; + try { + record = detail::decode_record(buffer.data(), size); + } catch (const detail::ProtocolError &) { + std::scoped_lock data_lock(data_mutex_); + ++health_.malformed_count; + if (++consecutive_malformed_ >= kMalformedStormThreshold) { + return {SessionResult::MalformedStorm, {}, received_valid_record}; + } + continue; + } + + const auto received_at = std::chrono::steady_clock::now(); + { + std::scoped_lock data_lock(data_mutex_); + consecutive_malformed_ = 0; + } + received_valid_record = true; + deadline = received_at + timeout; + if (const auto outcome = handle_record(record, received_at)) { + return {*outcome, {}, received_valid_record}; + } + } + return {SessionResult::Stopped, {}, received_valid_record}; +} + +std::optional +Client::Impl::handle_record(const detail::RawRecord &record, + const std::chrono::steady_clock::time_point received_at) { + SensorConfiguration configuration; + bool deliver = true; + std::optional outcome; + { + std::unique_lock record_lock(record_mutex_); + const auto rdt = rdt_sequence_.observe(record.rdt_sequence); + const auto ft = ft_sequence_.observe(record.ft_sequence); + const auto severity = classify_status(record.status); + + { + std::scoped_lock data_lock(data_mutex_); + if (!health_.sensor_configuration) { + throw std::logic_error("sensor configuration is unavailable while streaming"); + } + configuration = *health_.sensor_configuration; + health_.state = ClientState::Streaming; + ++health_.received_count; + receive_times_.push_back(received_at); + prune_rate_window(receive_times_, received_at); + health_.receive_rate_hz = static_cast(receive_times_.size()); + health_.last_rdt_sequence = record.rdt_sequence; + health_.last_ft_sequence = record.ft_sequence; + health_.last_status = record.status; + last_record_at_ = received_at; + + switch (rdt.kind) { + case detail::SequenceKind::Gap: + health_.lost_count += rdt.gap; + break; + case detail::SequenceKind::Duplicate: + ++health_.duplicate_count; + deliver = false; + break; + case detail::SequenceKind::OutOfOrder: + ++health_.out_of_order_count; + deliver = false; + break; + case detail::SequenceKind::First: + case detail::SequenceKind::Contiguous: + break; + } + + health_.last_ft_progress = ft_progress_name(ft.kind); + switch (ft.kind) { + case detail::FtSequenceKind::Stall: + ++health_.ft_stall_count; + break; + case detail::FtSequenceKind::Backward: + ++health_.ft_backward_count; + break; + case detail::FtSequenceKind::Restart: + ++health_.ft_restart_count; + break; + case detail::FtSequenceKind::First: + case detail::FtSequenceKind::Forward: + break; + } + + if (severity == StatusSeverity::Warn) { + ++health_.warning_count; + } else if (severity == StatusSeverity::Error) { + ++health_.device_error_count; + deliver = config_.deliver_samples_with_error_status && deliver; + outcome = SessionResult::SeriousStatus; + } + + if (!outcome && ft.kind == detail::FtSequenceKind::Stall) { + outcome = SessionResult::FtStall; + } else if (!outcome && ft.kind == detail::FtSequenceKind::Backward) { + outcome = SessionResult::FtBackward; + } + + const bool drop_reconnect_ft_discontinuity = + config_.recovery_policy == RecoveryPolicy::Reconnect && + (ft.kind == detail::FtSequenceKind::Stall || ft.kind == detail::FtSequenceKind::Backward); + if (drop_reconnect_ft_discontinuity) { + deliver = false; + if (outcome == SessionResult::FtStall || outcome == SessionResult::FtBackward) { + outcome.reset(); + } + } + + if (deliver && config_.sample_rate_limit_hz > 0.0 && + last_delivery_at_ != std::chrono::steady_clock::time_point{} && + received_at - last_delivery_at_ < + std::chrono::duration{1.0 / config_.sample_rate_limit_hz}) { + deliver = false; + ++health_.rate_limited_count; + } + } + record_lock.unlock(); + } + + if (!deliver) { + return outcome; + } + + const auto &calibration = configuration.calibration; + Sample sample; + sample.rdt_sequence = record.rdt_sequence; + sample.ft_sequence = record.ft_sequence; + sample.status = record.status; + sample.force = {record.fx / calibration.counts_per_force_unit, + record.fy / calibration.counts_per_force_unit, + record.fz / calibration.counts_per_force_unit}; + sample.torque = {record.tx / calibration.counts_per_torque_unit, + record.ty / calibration.counts_per_torque_unit, + record.tz / calibration.counts_per_torque_unit}; + sample.force_unit = calibration.force_unit; + sample.torque_unit = calibration.torque_unit; + sample.configuration_revision = configuration.revision; + sample.received_at = received_at; + + const auto callback_failure_outcome = [&] { + if (outcome || config_.recovery_policy == RecoveryPolicy::Reconnect) { + return outcome; + } + return std::optional{SessionResult::Callback}; + }; + + try { + SampleCallback callback = callback_; + if (callback) { + callback(sample); + } + } catch (const std::exception &error) { + record_callback_error(error.what()); + return callback_failure_outcome(); + } catch (...) { + record_callback_error("sample callback failed"); + return callback_failure_outcome(); + } + + { + std::scoped_lock record_lock(record_mutex_); + last_delivery_at_ = received_at; + std::scoped_lock data_lock(data_mutex_); + latest_ = sample; + ++health_.delivered_count; + delivery_times_.push_back(received_at); + prune_rate_window(delivery_times_, received_at); + health_.delivery_rate_hz = static_cast(delivery_times_.size()); + delivered_generation_ = generation_; + } + first_sample_cv_.notify_all(); + lifecycle_cv_.notify_all(); + return outcome; +} + +void Client::Impl::set_fault(FaultCode code, std::string message) noexcept { + const bool published = fault_latch_.publish(code, std::move(message), data_mutex_, health_); + first_sample_cv_.notify_all(); + lifecycle_cv_.notify_all(); + if (published) { + if (const auto hook = fault_published_test_hook_) { + hook(this); + } + } +} + +void Client::Impl::record_callback_error(const char *message) noexcept { + try { + std::scoped_lock data_lock(data_mutex_); + ++health_.callback_error_count; + try { + health_.last_error = message; + } catch (...) { + static_cast(0); + } + } catch (...) { + static_cast(0); + } +} + +void Client::Impl::close_session() noexcept { + std::scoped_lock command_lock(command_mutex_); + if (session_started_) { + try { + transport_.send(detail::encode_request(detail::Command::StopStreaming)); + } catch (...) { + static_cast(0); + } + session_started_ = false; + } + transport_.close(); +} + +void Client::Impl::finish_session() noexcept { + close_session(); + worker_exited_.store(true, std::memory_order_release); + if (!faulted()) { + try { + std::scoped_lock data_lock(data_mutex_); + health_.state = ClientState::Stopped; + } catch (...) { + static_cast(0); + } + } + first_sample_cv_.notify_all(); + lifecycle_cv_.notify_all(); +} + +} // namespace netft diff --git a/src/core/src/detail/client_impl.hpp b/src/core/src/detail/client_impl.hpp new file mode 100644 index 0000000..9eb871d --- /dev/null +++ b/src/core/src/detail/client_impl.hpp @@ -0,0 +1,107 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "detail/fault_latch.hpp" +#include "detail/posix_transport.hpp" +#include "detail/protocol.hpp" +#include "detail/sequence.hpp" +#include "netft/client.hpp" + +namespace netft { + +class NETFT_LOCAL Client::Impl { +public: + explicit Impl(Config config); + ~Impl(); + + void start(SampleCallback callback); + void stop() noexcept; + void bias(); + bool wait_for_first_sample(std::chrono::duration timeout); + bool faulted() const noexcept; + FaultCode fault_code() const noexcept; + HealthSnapshot health() const; + std::optional latest_sample() const; + +private: + enum class SessionResult { + Stopped, + SensorConfiguration, + Timeout, + Socket, + SeriousStatus, + FtStall, + FtBackward, + MalformedStorm, + Callback, + }; + + struct SessionOutcome { + SessionResult result{SessionResult::Stopped}; + std::string message; + bool received_valid_record{false}; + }; + + void run() noexcept; + std::thread create_worker_thread(); + static FaultCode fault_for(SessionResult result) noexcept; + static const char *message_for(SessionResult result) noexcept; + SessionOutcome receive_session(); + SensorConfiguration configuration_for_session(); + void apply_configuration(SensorConfiguration configuration); + std::optional handle_record(const detail::RawRecord &record, + std::chrono::steady_clock::time_point received_at); + void close_session() noexcept; + void set_fault(FaultCode code, std::string message) noexcept; + void record_callback_error(const char *message) noexcept; + void finish_session() noexcept; + + using ThreadFactory = std::thread (*)(Impl *); + using FaultPublishedHook = void (*)(Impl *) noexcept; + using WaitWakeHook = void (*)(Impl *, std::uint64_t) noexcept; + + Config config_; + mutable std::mutex lifecycle_mutex_; + std::condition_variable lifecycle_cv_; + // Locks are acquired in command -> record -> data order. Callbacks run after + // all three have been released. + mutable std::mutex command_mutex_; + mutable std::mutex record_mutex_; + mutable std::mutex data_mutex_; + std::condition_variable first_sample_cv_; + std::thread worker_; + ThreadFactory thread_factory_{nullptr}; + // Internal synchronization hooks used only by lifecycle tests. Client's + // installed API remains opaque and does not expose these seams. + FaultPublishedHook fault_published_test_hook_{nullptr}; + WaitWakeHook wait_wake_test_hook_{nullptr}; + std::thread::id active_worker_id_; + bool joining_{false}; + SampleCallback callback_; + detail::PosixTransport transport_; + detail::RdtSequenceTracker rdt_sequence_; + detail::FtSequenceTracker ft_sequence_; + std::atomic stopping_{false}; + std::atomic worker_exited_{true}; + bool session_started_{false}; + detail::FaultLatch fault_latch_; + HealthSnapshot health_; + std::optional latest_; + std::chrono::steady_clock::time_point last_delivery_at_; + std::chrono::steady_clock::time_point last_record_at_; + std::uint64_t generation_{0}; + std::uint64_t delivered_generation_{0}; + mutable std::deque receive_times_; + mutable std::deque delivery_times_; + std::uint64_t consecutive_malformed_{}; +}; + +} // namespace netft diff --git a/src/core/src/detail/fault_latch.cpp b/src/core/src/detail/fault_latch.cpp new file mode 100644 index 0000000..d7abdbc --- /dev/null +++ b/src/core/src/detail/fault_latch.cpp @@ -0,0 +1,33 @@ +#include "detail/fault_latch.hpp" + +#include + +namespace netft::detail { + +bool FaultLatch::publish(const FaultCode code, std::string message, std::mutex &data_mutex, + HealthSnapshot &health) noexcept { + try { + std::scoped_lock data_lock(data_mutex); + FaultCode expected = FaultCode::None; + if (!code_.compare_exchange_strong(expected, code, std::memory_order_acq_rel, + std::memory_order_acquire)) { + return false; + } + health.state = ClientState::Faulted; + health.fault_code = code; + health.last_error.swap(message); + return true; + } catch (...) { + return false; + } +} + +void FaultLatch::reset() noexcept { code_.store(FaultCode::None, std::memory_order_release); } + +bool FaultLatch::faulted() const noexcept { + return code_.load(std::memory_order_acquire) != FaultCode::None; +} + +FaultCode FaultLatch::code() const noexcept { return code_.load(std::memory_order_acquire); } + +} // namespace netft::detail diff --git a/src/core/src/detail/fault_latch.hpp b/src/core/src/detail/fault_latch.hpp new file mode 100644 index 0000000..0916466 --- /dev/null +++ b/src/core/src/detail/fault_latch.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include +#include +#include + +#include "netft/types.hpp" + +namespace netft::detail { + +class FaultLatch { +public: + FaultLatch() = default; + FaultLatch(const FaultLatch &) = delete; + FaultLatch &operator=(const FaultLatch &) = delete; + + bool publish(FaultCode code, std::string message, std::mutex &data_mutex, + HealthSnapshot &health) noexcept; + void reset() noexcept; + [[nodiscard]] bool faulted() const noexcept; + [[nodiscard]] FaultCode code() const noexcept; + +private: + std::atomic code_{FaultCode::None}; +}; + +} // namespace netft::detail diff --git a/src/core/src/detail/posix_transport.cpp b/src/core/src/detail/posix_transport.cpp new file mode 100644 index 0000000..26620f7 --- /dev/null +++ b/src/core/src/detail/posix_transport.cpp @@ -0,0 +1,138 @@ +#include "detail/posix_transport.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace netft::detail { +namespace { + +std::runtime_error socket_error(const char *operation) { + return std::runtime_error(std::string{operation} + ": " + std::strerror(errno)); +} + +int timeout_milliseconds(const std::chrono::duration timeout) { + const auto milliseconds = std::ceil(timeout.count() * 1000.0); + if (milliseconds >= static_cast(std::numeric_limits::max())) { + return std::numeric_limits::max(); + } + return static_cast(milliseconds); +} + +} // namespace + +PosixTransport::~PosixTransport() { close(); } + +void PosixTransport::connect(const std::string &host, const int port) { + addrinfo hints{}; + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_DGRAM; + addrinfo *addresses = nullptr; + const auto service = std::to_string(port); + const int result = ::getaddrinfo(host.c_str(), service.c_str(), &hints, &addresses); + if (result != 0) { + throw std::runtime_error(std::string{"failed to resolve sensor: "} + ::gai_strerror(result)); + } + + int connected_socket = -1; + int last_error = 0; + for (auto *address = addresses; address != nullptr; address = address->ai_next) { + connected_socket = ::socket(address->ai_family, address->ai_socktype, address->ai_protocol); + if (connected_socket < 0) { + last_error = errno; + continue; + } + if (::connect(connected_socket, address->ai_addr, address->ai_addrlen) == 0) { + break; + } + last_error = errno; + ::close(connected_socket); + connected_socket = -1; + } + ::freeaddrinfo(addresses); + + if (connected_socket < 0) { + errno = last_error; + throw socket_error("failed to connect UDP socket"); + } + + std::scoped_lock lock(mutex_); + if (socket_ >= 0) { + ::close(socket_); + } + socket_ = connected_socket; +} + +void PosixTransport::send(const std::array &request) { + std::scoped_lock lock(mutex_); + if (socket_ < 0) { + throw std::runtime_error("UDP socket is not connected"); + } + const auto sent = ::send(socket_, request.data(), request.size(), 0); + if (sent < 0) { + throw socket_error("failed to send UDP request"); + } + if (static_cast(sent) != request.size()) { + throw std::runtime_error("short UDP request write"); + } +} + +std::size_t PosixTransport::receive(std::uint8_t *data, const std::size_t capacity, + const std::chrono::duration timeout) { + int socket = -1; + { + std::scoped_lock lock(mutex_); + socket = socket_; + } + if (socket < 0) { + throw std::runtime_error("UDP socket is not connected"); + } + + pollfd descriptor{socket, POLLIN, 0}; + int poll_result{}; + do { + poll_result = ::poll(&descriptor, 1, timeout_milliseconds(timeout)); + } while (poll_result < 0 && errno == EINTR); + if (poll_result < 0) { + throw socket_error("failed to wait for UDP record"); + } + if (poll_result == 0) { + return 0; + } + if ((descriptor.revents & POLLNVAL) != 0) { + throw std::runtime_error("UDP socket became invalid"); + } + + const auto received = ::recv(socket, data, capacity, 0); + if (received < 0) { + if (errno == EINTR) { + return 0; + } + throw socket_error("failed to receive UDP record"); + } + return static_cast(received); +} + +void PosixTransport::shutdown() noexcept { + std::scoped_lock lock(mutex_); + if (socket_ >= 0) { + static_cast(::shutdown(socket_, SHUT_RDWR)); + } +} + +void PosixTransport::close() noexcept { + std::scoped_lock lock(mutex_); + if (socket_ >= 0) { + ::close(socket_); + socket_ = -1; + } +} + +} // namespace netft::detail diff --git a/src/core/src/detail/posix_transport.hpp b/src/core/src/detail/posix_transport.hpp new file mode 100644 index 0000000..68b59ee --- /dev/null +++ b/src/core/src/detail/posix_transport.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace netft::detail { + +class PosixTransport { +public: + PosixTransport() = default; + ~PosixTransport(); + PosixTransport(const PosixTransport &) = delete; + PosixTransport &operator=(const PosixTransport &) = delete; + + void connect(const std::string &host, int port); + void send(const std::array &request); + std::size_t receive(std::uint8_t *data, std::size_t capacity, + std::chrono::duration timeout); + void shutdown() noexcept; + void close() noexcept; + +private: + mutable std::mutex mutex_; + int socket_{-1}; +}; + +} // namespace netft::detail diff --git a/src/core/src/detail/protocol.cpp b/src/core/src/detail/protocol.cpp new file mode 100644 index 0000000..301fc5c --- /dev/null +++ b/src/core/src/detail/protocol.cpp @@ -0,0 +1,43 @@ +#include "detail/protocol.hpp" + +#include + +namespace netft::detail { +namespace { + +std::uint32_t read_u32(const std::uint8_t *data) { + return (static_cast(data[0]) << 24U) | + (static_cast(data[1]) << 16U) | + (static_cast(data[2]) << 8U) | static_cast(data[3]); +} + +std::int32_t read_i32(const std::uint8_t *data) { + const std::uint32_t value = read_u32(data); + if (value <= 0x7fffffffU) { + return static_cast(value); + } + return -static_cast(~value) - 1; +} + +} // namespace + +std::array encode_request(const Command command, const std::uint32_t count) { + const auto value = static_cast(command); + return {{0x12, 0x34, static_cast(value >> 8U), static_cast(value), + static_cast(count >> 24U), static_cast(count >> 16U), + static_cast(count >> 8U), static_cast(count)}}; +} + +RawRecord decode_record(const std::uint8_t *data, const std::size_t size) { + if (size != 36) { + throw ProtocolError{"RDT record must be exactly 36 bytes, received " + std::to_string(size)}; + } + if (data == nullptr) { + throw ProtocolError{"RDT record data must not be null"}; + } + return {read_u32(data), read_u32(data + 4), read_u32(data + 8), + read_i32(data + 12), read_i32(data + 16), read_i32(data + 20), + read_i32(data + 24), read_i32(data + 28), read_i32(data + 32)}; +} + +} // namespace netft::detail diff --git a/src/core/src/detail/protocol.hpp b/src/core/src/detail/protocol.hpp new file mode 100644 index 0000000..ceeab36 --- /dev/null +++ b/src/core/src/detail/protocol.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include +#include + +namespace netft::detail { + +enum class Command : std::uint16_t { + StopStreaming = 0x0000, + StartRealtime = 0x0002, + StartBuffered = 0x0003, + ResetConditionLatch = 0x0041, + SetSoftwareBias = 0x0042, +}; + +struct RawRecord { + std::uint32_t rdt_sequence{}, ft_sequence{}, status{}; + std::int32_t fx{}, fy{}, fz{}, tx{}, ty{}, tz{}; +}; + +class ProtocolError : public std::runtime_error { +public: + using std::runtime_error::runtime_error; +}; + +std::array encode_request(Command command, std::uint32_t count = 0); +RawRecord decode_record(const std::uint8_t *data, std::size_t size); + +} // namespace netft::detail diff --git a/src/core/src/detail/sequence.cpp b/src/core/src/detail/sequence.cpp new file mode 100644 index 0000000..02c07a8 --- /dev/null +++ b/src/core/src/detail/sequence.cpp @@ -0,0 +1,67 @@ +#include "detail/sequence.hpp" + +namespace netft::detail { +namespace { + +constexpr std::uint32_t kRestartLowMax = 0x0000ffffU; +constexpr std::uint32_t kRestartMinBaseline = kRestartLowMax + 1U; + +} // namespace + +SequenceObservation RdtSequenceTracker::observe(const std::uint32_t current) { + if (!has_last_) { + has_last_ = true; + previous_ = current; + return {SequenceKind::First, 0}; + } + + const std::uint32_t delta = current - previous_; + if (delta == 0) { + return {SequenceKind::Duplicate, 0}; + } + if (delta < 0x80000000U) { + previous_ = current; + return delta == 1 ? SequenceObservation{SequenceKind::Contiguous, 0} + : SequenceObservation{SequenceKind::Gap, delta - 1}; + } + return {SequenceKind::OutOfOrder, 0}; +} + +void RdtSequenceTracker::reset() { has_last_ = false; } + +void FtSequenceTracker::begin_session() { has_restart_candidate_ = false; } + +FtSequenceObservation FtSequenceTracker::observe(const std::uint32_t current) { + if (!has_last_) { + has_last_ = true; + previous_ = current; + return {FtSequenceKind::First}; + } + if (current == previous_) { + has_restart_candidate_ = false; + return {FtSequenceKind::Stall}; + } + + const std::uint32_t delta = current - previous_; + if (delta < 0x80000000U) { + previous_ = current; + has_restart_candidate_ = false; + return {FtSequenceKind::Forward}; + } + + if (previous_ >= kRestartMinBaseline && has_restart_candidate_ && + restart_candidate_ <= kRestartLowMax && current <= kRestartLowMax) { + const std::uint32_t candidate_delta = current - restart_candidate_; + if (candidate_delta > 0 && candidate_delta < 0x80000000U) { + previous_ = current; + has_restart_candidate_ = false; + return {FtSequenceKind::Restart}; + } + } + + restart_candidate_ = current; + has_restart_candidate_ = true; + return {FtSequenceKind::Backward}; +} + +} // namespace netft::detail diff --git a/src/core/src/detail/sequence.hpp b/src/core/src/detail/sequence.hpp new file mode 100644 index 0000000..726d449 --- /dev/null +++ b/src/core/src/detail/sequence.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include + +namespace netft::detail { + +enum class SequenceKind { First, Contiguous, Gap, Duplicate, OutOfOrder }; + +struct SequenceObservation { + SequenceKind kind; + std::uint32_t gap{0}; +}; + +class RdtSequenceTracker { +public: + SequenceObservation observe(std::uint32_t current); + void reset(); + [[nodiscard]] bool has_last() const { return has_last_; } + [[nodiscard]] std::uint32_t last() const { return previous_; } + +private: + bool has_last_{false}; + std::uint32_t previous_{}; +}; + +enum class FtSequenceKind { First, Forward, Stall, Backward, Restart }; + +struct FtSequenceObservation { + FtSequenceKind kind; +}; + +class FtSequenceTracker { +public: + FtSequenceObservation observe(std::uint32_t current); + void begin_session(); + [[nodiscard]] bool has_last() const { return has_last_; } + [[nodiscard]] std::uint32_t last() const { return previous_; } + +private: + bool has_last_{false}; + std::uint32_t previous_{}; + bool has_restart_candidate_{false}; + std::uint32_t restart_candidate_{}; +}; + +} // namespace netft::detail diff --git a/src/core/src/detail/xml_config.cpp b/src/core/src/detail/xml_config.cpp new file mode 100644 index 0000000..124c241 --- /dev/null +++ b/src/core/src/detail/xml_config.cpp @@ -0,0 +1,242 @@ +#include "detail/xml_config.hpp" + +#include +#include +#include +#include +#include +#include + +#include "netft/discovery.hpp" + +namespace netft::detail { +namespace { + +constexpr std::size_t kMaximumFieldLength = 128; +constexpr std::array kRequiredTags{"prodname", "cfgcpf", "cfgcpt", "scfgfu", + "scfgtu"}; + +bool is_ascii_whitespace(char character) noexcept { + return character == ' ' || character == '\t' || character == '\n' || character == '\r' || + character == '\f' || character == '\v'; +} + +std::string_view trim_ascii_whitespace(std::string_view value) noexcept { + while (!value.empty() && is_ascii_whitespace(value.front())) { + value.remove_prefix(1); + } + while (!value.empty() && is_ascii_whitespace(value.back())) { + value.remove_suffix(1); + } + return value; +} + +int required_tag_index(std::string_view name) noexcept { + for (std::size_t index = 0; index < kRequiredTags.size(); ++index) { + if (name == kRequiredTags[index]) { + return static_cast(index); + } + } + return -1; +} + +std::size_t find_tag_end(std::string_view xml, std::size_t start) { + char quote{}; + for (std::size_t position = start; position < xml.size(); ++position) { + const char character = xml[position]; + if (quote != '\0') { + if (character == quote) { + quote = '\0'; + } + } else if (character == '\'' || character == '"') { + quote = character; + } else if (character == '>') { + return position; + } + } + throw DiscoveryError("sensor configuration contains unterminated markup"); +} + +struct OpenElement { + std::string_view name; + int required_index{-1}; +}; + +using RequiredFields = std::array; + +void append_required_text(const std::vector &elements, RequiredFields &fields, + std::string_view text) { + if (!elements.empty() && elements.back().required_index >= 0) { + fields[static_cast(elements.back().required_index)].append(text); + } +} + +RequiredFields extract_required_fields(std::string_view xml) { + RequiredFields fields; + std::array seen{}; + std::vector elements; + std::size_t position{}; + + while (position < xml.size()) { + const auto markup = xml.find('<', position); + if (markup == std::string_view::npos) { + append_required_text(elements, fields, xml.substr(position)); + position = xml.size(); + break; + } + append_required_text(elements, fields, xml.substr(position, markup - position)); + + if (xml.substr(markup, 4) == "", markup + 4); + if (end == std::string_view::npos) { + throw DiscoveryError("sensor configuration contains an unterminated comment"); + } + position = end + 3; + continue; + } + if (xml.substr(markup, 9) == "", markup + 9); + if (end == std::string_view::npos) { + throw DiscoveryError("sensor configuration contains unterminated CDATA"); + } + append_required_text(elements, fields, xml.substr(markup + 9, end - markup - 9)); + position = end + 3; + continue; + } + if (xml.substr(markup, 2) == "", markup + 2); + if (end == std::string_view::npos) { + throw DiscoveryError("sensor configuration contains unterminated markup"); + } + position = end + 2; + continue; + } + if (xml.substr(markup, 2) == "= 0) { + throw DiscoveryError("sensor configuration fields must contain text only"); + } + if (required_index >= 0) { + const auto index = static_cast(required_index); + if (seen[index]) { + throw DiscoveryError("sensor configuration field '" + std::string{kRequiredTags[index]} + + "' must appear exactly once"); + } + seen[index] = true; + } + if (!self_closing) { + elements.push_back(OpenElement{name, required_index}); + } + position = end + 1; + } + + if (!elements.empty()) { + throw DiscoveryError("sensor configuration contains malformed element nesting"); + } + for (std::size_t index = 0; index < kRequiredTags.size(); ++index) { + if (!seen[index]) { + throw DiscoveryError("sensor configuration field '" + std::string{kRequiredTags[index]} + + "' must appear exactly once"); + } + const auto value = trim_ascii_whitespace(fields[index]); + if (value.empty()) { + throw DiscoveryError("sensor configuration field '" + std::string{kRequiredTags[index]} + + "' is empty"); + } + if (value.size() > kMaximumFieldLength) { + throw DiscoveryError("sensor configuration field '" + std::string{kRequiredTags[index]} + + "' is too long"); + } + fields[index] = std::string{value}; + } + return fields; +} + +double parse_positive_count(std::string_view value, std::string_view tag) { + double result{}; + const auto parsed = std::from_chars(value.data(), value.data() + value.size(), result, + std::chars_format::general); + if (parsed.ec != std::errc{} || parsed.ptr != value.data() + value.size() || + !std::isfinite(result) || result <= 0.0) { + throw DiscoveryError("sensor configuration field '" + std::string{tag} + + "' must be a finite positive number"); + } + return result; +} + +std::string_view normalize_torque_spelling(std::string_view value) noexcept { + if (value == "Nm") { + return "N-m"; + } + if (value == "Nmm") { + return "N-mm"; + } + if (value == "kg-cm") { + return "kgf-cm"; + } + return value; +} + +ForceUnit parse_force_unit(std::string_view value) { + const auto unit = force_unit_from_string(value); + if (!unit.has_value() || *unit == ForceUnit::Unknown) { + throw DiscoveryError("sensor configuration contains an unknown force unit"); + } + return *unit; +} + +TorqueUnit parse_torque_unit(std::string_view value) { + const auto unit = torque_unit_from_string(normalize_torque_spelling(value)); + if (!unit.has_value() || *unit == TorqueUnit::Unknown) { + throw DiscoveryError("sensor configuration contains an unknown torque unit"); + } + return *unit; +} + +} // namespace + +SensorConfiguration parse_sensor_configuration(std::string_view xml) { + const auto fields = extract_required_fields(xml); + SensorConfiguration configuration; + configuration.product_name = fields[0]; + configuration.calibration.counts_per_force_unit = parse_positive_count(fields[1], "cfgcpf"); + configuration.calibration.counts_per_torque_unit = parse_positive_count(fields[2], "cfgcpt"); + configuration.calibration.force_unit = parse_force_unit(fields[3]); + configuration.calibration.torque_unit = parse_torque_unit(fields[4]); + configuration.source = CalibrationSource::Sensor; + configuration.revision = 1; + return configuration; +} + +} // namespace netft::detail diff --git a/src/core/src/detail/xml_config.hpp b/src/core/src/detail/xml_config.hpp new file mode 100644 index 0000000..c9b653f --- /dev/null +++ b/src/core/src/detail/xml_config.hpp @@ -0,0 +1,11 @@ +#pragma once + +#include + +#include "netft/types.hpp" + +namespace netft::detail { + +SensorConfiguration parse_sensor_configuration(std::string_view xml); + +} // namespace netft::detail diff --git a/src/core/src/discovery.cpp b/src/core/src/discovery.cpp new file mode 100644 index 0000000..cd507f2 --- /dev/null +++ b/src/core/src/discovery.cpp @@ -0,0 +1,183 @@ +#include "netft/discovery.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "detail/xml_config.hpp" + +namespace netft { +namespace { + +constexpr std::size_t kMaximumResponseBytes = 65'536; + +struct CurlHandleDeleter { + void operator()(CURL *handle) const noexcept { curl_easy_cleanup(handle); } +}; + +using CurlHandle = std::unique_ptr; + +struct CurlUrlDeleter { + void operator()(CURLU *url) const noexcept { curl_url_cleanup(url); } +}; + +using CurlUrl = std::unique_ptr; + +struct ResponseBuffer { + std::string body; + bool too_large{false}; + bool write_failed{false}; +}; + +bool is_ascii_blank(std::string_view value) noexcept { + if (value.empty()) { + return true; + } + return std::all_of(value.begin(), value.end(), [](const char character) { + return character == ' ' || character == '\t' || character == '\n' || character == '\r' || + character == '\f' || character == '\v'; + }); +} + +long timeout_milliseconds(std::chrono::duration timeout, std::string_view name) { + const double seconds = timeout.count(); + if (!std::isfinite(seconds) || seconds <= 0.0) { + throw DiscoveryError(std::string{name} + " must be finite, positive, and representable"); + } + + const long double rounded_milliseconds = std::ceil(static_cast(seconds) * 1000.0L); + if (!std::isfinite(rounded_milliseconds) || + rounded_milliseconds > static_cast(std::numeric_limits::max())) { + throw DiscoveryError(std::string{name} + " must be finite, positive, and representable"); + } + return static_cast(rounded_milliseconds); +} + +void validate_options(const DiscoveryOptions &options) { + if (is_ascii_blank(options.sensor_host)) { + throw DiscoveryError("sensor_host must not be blank"); + } + if (options.http_port < 1 || options.http_port > 65'535) { + throw DiscoveryError("http_port must be in the range 1..65535"); + } + static_cast(timeout_milliseconds(options.connect_timeout, "connect_timeout")); + static_cast(timeout_milliseconds(options.total_timeout, "total_timeout")); +} + +CURLcode initialize_curl_once() { + static std::once_flag initialization_flag; + static CURLcode initialization_result = CURLE_FAILED_INIT; + std::call_once(initialization_flag, + [] { initialization_result = curl_global_init(CURL_GLOBAL_DEFAULT); }); + return initialization_result; +} + +template void set_curl_option(CURL *handle, CURLoption option, Value value) { + if (curl_easy_setopt(handle, option, value) != CURLE_OK) { + throw DiscoveryError("failed to configure sensor discovery request"); + } +} + +void set_url_part(CURLU *url, CURLUPart part, const char *value) { + if (curl_url_set(url, part, value, 0) != CURLUE_OK) { + throw DiscoveryError("invalid sensor discovery URL"); + } +} + +std::size_t append_response(char *data, std::size_t size, std::size_t count, + void *user_data) noexcept { + auto &response = *static_cast(user_data); + if (size != 0 && count > std::numeric_limits::max() / size) { + response.too_large = true; + return 0; + } + const std::size_t byte_count = size * count; + if (byte_count > kMaximumResponseBytes - response.body.size()) { + response.too_large = true; + return 0; + } + try { + response.body.append(data, byte_count); + } catch (...) { + response.write_failed = true; + return 0; + } + return byte_count; +} + +} // namespace + +SensorConfiguration discover_sensor(const DiscoveryOptions &options) { + validate_options(options); + if (initialize_curl_once() != CURLE_OK) { + throw DiscoveryError("failed to initialize HTTP transport"); + } + + CurlHandle handle{curl_easy_init()}; + if (!handle) { + throw DiscoveryError("failed to create HTTP transport"); + } + + CurlUrl url{curl_url()}; + if (!url) { + throw DiscoveryError("failed to create sensor discovery URL"); + } + std::string host = options.sensor_host; + if (host.find(':') != std::string::npos && + (host.size() < 2 || host.front() != '[' || host.back() != ']')) { + host = "[" + host + "]"; + } + const std::string port = std::to_string(options.http_port); + set_url_part(url.get(), CURLUPART_SCHEME, "http"); + set_url_part(url.get(), CURLUPART_HOST, host.c_str()); + set_url_part(url.get(), CURLUPART_PORT, port.c_str()); + set_url_part(url.get(), CURLUPART_PATH, "/netftapi2.xml"); + + ResponseBuffer response; + set_curl_option(handle.get(), CURLOPT_CURLU, url.get()); +#if LIBCURL_VERSION_NUM >= 0x075500 + set_curl_option(handle.get(), CURLOPT_PROTOCOLS_STR, "http"); +#else + set_curl_option(handle.get(), CURLOPT_PROTOCOLS, static_cast(CURLPROTO_HTTP)); +#endif + set_curl_option(handle.get(), CURLOPT_FOLLOWLOCATION, 0L); + set_curl_option(handle.get(), CURLOPT_MAXREDIRS, 0L); + set_curl_option(handle.get(), CURLOPT_NOSIGNAL, 1L); + set_curl_option(handle.get(), CURLOPT_PROXY, ""); + set_curl_option(handle.get(), CURLOPT_CONNECTTIMEOUT_MS, + timeout_milliseconds(options.connect_timeout, "connect_timeout")); + set_curl_option(handle.get(), CURLOPT_TIMEOUT_MS, + timeout_milliseconds(options.total_timeout, "total_timeout")); + set_curl_option(handle.get(), CURLOPT_WRITEFUNCTION, &append_response); + set_curl_option(handle.get(), CURLOPT_WRITEDATA, &response); + + const CURLcode transfer_result = curl_easy_perform(handle.get()); + if (response.too_large) { + throw DiscoveryError("sensor configuration response exceeds 65536 bytes"); + } + if (response.write_failed) { + throw DiscoveryError("failed to store sensor configuration response"); + } + if (transfer_result != CURLE_OK) { + throw DiscoveryError(std::string{"sensor configuration request failed: "} + + curl_easy_strerror(transfer_result)); + } + + long status{}; + if (curl_easy_getinfo(handle.get(), CURLINFO_RESPONSE_CODE, &status) != CURLE_OK) { + throw DiscoveryError("failed to inspect sensor configuration response"); + } + if (status != 200) { + throw DiscoveryError("sensor configuration request returned HTTP " + std::to_string(status)); + } + + return detail::parse_sensor_configuration(response.body); +} + +} // namespace netft diff --git a/src/core/src/status.cpp b/src/core/src/status.cpp new file mode 100644 index 0000000..344f0a9 --- /dev/null +++ b/src/core/src/status.cpp @@ -0,0 +1,68 @@ +#include "netft/status.hpp" + +#include +#include + +namespace netft { +namespace { + +constexpr std::array, 30> kStatusBits{{ + {0x80000000U, "error summary"}, + {0x40000000U, "CPU or RAM error"}, + {0x20000000U, "digital board error"}, + {0x10000000U, "analog board error"}, + {0x08000000U, "serial link communication error"}, + {0x04000000U, "program memory verification error"}, + {0x02000000U, "halted due to configuration errors"}, + {0x01000000U, "settings validation error"}, + {0x00800000U, "configuration incompatible with calibration"}, + {0x00400000U, "network communication failure"}, + {0x00200000U, "CAN communication error"}, + {0x00100000U, "RDT communication error"}, + {0x00080000U, "EtherNet/IP protocol failure"}, + {0x00040000U, "DeviceNet protocol failure"}, + {0x00020000U, "transducer saturation or A/D error"}, + {0x00010000U, "monitor condition latched"}, + {0x00004000U, "watchdog timeout error"}, + {0x00002000U, "stack check error"}, + {0x00001000U, "serial EEPROM I2C failure"}, + {0x00000800U, "serial flash SPI failure"}, + {0x00000400U, "analog board watchdog timeout"}, + {0x00000200U, "excessive strain gage excitation current"}, + {0x00000100U, "insufficient strain gage excitation current"}, + {0x00000080U, "artificial analog ground out of range"}, + {0x00000040U, "analog board power supply too high"}, + {0x00000020U, "analog board power supply too low"}, + {0x00000010U, "serial link data unavailable"}, + {0x00000008U, "reference voltage or power monitoring error"}, + {0x00000004U, "internal temperature error"}, + {0x00000002U, "HTTP protocol failure"}, +}}; + +} // namespace + +StatusSeverity classify_status(const std::uint32_t status) noexcept { + if (status == 0) { + return StatusSeverity::Ok; + } + return status == 0x80010000U ? StatusSeverity::Warn : StatusSeverity::Error; +} + +std::string decode_status(const std::uint32_t status) { + if (status == 0) { + return "healthy"; + } + + std::string result; + for (const auto &[mask, name] : kStatusBits) { + if ((status & mask) != 0) { + if (!result.empty()) { + result += ", "; + } + result += name; + } + } + return result; +} + +} // namespace netft diff --git a/src/core/src/types.cpp b/src/core/src/types.cpp new file mode 100644 index 0000000..3f2824b --- /dev/null +++ b/src/core/src/types.cpp @@ -0,0 +1,164 @@ +#include "netft/types.hpp" + +#include +#include +#include +#include + +namespace netft { +namespace { + +bool is_blank(std::string_view value) { + return value.empty() || std::all_of(value.begin(), value.end(), [](unsigned char character) { + return std::isspace(character) != 0; + }); +} + +void require_positive_finite(std::string_view name, double value) { + if (!std::isfinite(value) || value <= 0.0) { + throw std::invalid_argument(std::string{name} + " must be finite and positive"); + } +} + +void require_port(std::string_view name, int value) { + if (value < 1 || value > 65535) { + throw std::invalid_argument(std::string{name} + " must be in the range 1..65535"); + } +} + +} // namespace + +void validate(const Calibration &calibration) { + require_positive_finite("counts_per_force_unit", calibration.counts_per_force_unit); + require_positive_finite("counts_per_torque_unit", calibration.counts_per_torque_unit); + if (calibration.force_unit == ForceUnit::Unknown || + calibration.torque_unit == TorqueUnit::Unknown) { + throw std::invalid_argument("calibration units must be known"); + } +} + +void validate(const Config &config) { + if (is_blank(config.sensor_host)) { + throw std::invalid_argument("sensor_host must not be blank"); + } + require_port("rdt_port", config.rdt_port); + require_port("http_port", config.http_port); + require_positive_finite("receive_timeout", config.receive_timeout.count()); + require_positive_finite("configuration_connect_timeout", + config.configuration_connect_timeout.count()); + require_positive_finite("configuration_timeout", config.configuration_timeout.count()); + require_positive_finite("reconnect_initial_delay", config.reconnect_initial_delay.count()); + require_positive_finite("reconnect_max_delay", config.reconnect_max_delay.count()); + if (config.reconnect_max_delay < config.reconnect_initial_delay) { + throw std::invalid_argument("reconnect_max_delay must not be below reconnect_initial_delay"); + } + if (!std::isfinite(config.sample_rate_limit_hz) || config.sample_rate_limit_hz < 0.0) { + throw std::invalid_argument("sample_rate_limit_hz must be finite and non-negative"); + } + if (config.calibration_override.has_value()) { + validate(*config.calibration_override); + } +} + +std::string_view to_string(ForceUnit unit) noexcept { + switch (unit) { + case ForceUnit::PoundForce: + return "lbf"; + case ForceUnit::Newton: + return "N"; + case ForceUnit::KiloPoundForce: + return "klbf"; + case ForceUnit::KiloNewton: + return "kN"; + case ForceUnit::KilogramForce: + return "kgf"; + case ForceUnit::Unknown: + return "unknown"; + } + return "unknown"; +} + +std::string_view to_string(TorqueUnit unit) noexcept { + switch (unit) { + case TorqueUnit::PoundForceInch: + return "lbf-in"; + case TorqueUnit::PoundForceFoot: + return "lbf-ft"; + case TorqueUnit::NewtonMeter: + return "N-m"; + case TorqueUnit::NewtonMillimeter: + return "N-mm"; + case TorqueUnit::KilogramForceCentimeter: + return "kgf-cm"; + case TorqueUnit::KiloNewtonMeter: + return "kN-m"; + case TorqueUnit::Unknown: + return "unknown"; + } + return "unknown"; +} + +std::optional force_unit_from_string(std::string_view value) noexcept { + for (const auto unit : + {ForceUnit::Unknown, ForceUnit::PoundForce, ForceUnit::Newton, ForceUnit::KiloPoundForce, + ForceUnit::KiloNewton, ForceUnit::KilogramForce}) { + if (to_string(unit) == value) { + return unit; + } + } + return std::nullopt; +} + +std::optional torque_unit_from_string(std::string_view value) noexcept { + for (const auto unit : + {TorqueUnit::Unknown, TorqueUnit::PoundForceInch, TorqueUnit::PoundForceFoot, + TorqueUnit::NewtonMeter, TorqueUnit::NewtonMillimeter, TorqueUnit::KilogramForceCentimeter, + TorqueUnit::KiloNewtonMeter}) { + if (to_string(unit) == value) { + return unit; + } + } + return std::nullopt; +} + +std::string_view to_string(ClientState state) noexcept { + switch (state) { + case ClientState::Stopped: + return "stopped"; + case ClientState::Connecting: + return "connecting"; + case ClientState::Streaming: + return "streaming"; + case ClientState::Backoff: + return "backoff"; + case ClientState::Faulted: + return "faulted"; + } + return "unknown"; +} + +std::string_view to_string(FaultCode code) noexcept { + switch (code) { + case FaultCode::None: + return "none"; + case FaultCode::SensorConfiguration: + return "sensor_configuration"; + case FaultCode::Timeout: + return "timeout"; + case FaultCode::Socket: + return "socket"; + case FaultCode::SeriousStatus: + return "serious_status"; + case FaultCode::FtStall: + return "ft_stall"; + case FaultCode::FtBackward: + return "ft_backward"; + case FaultCode::MalformedStorm: + return "malformed_storm"; + case FaultCode::Callback: + return "callback"; + } + return "unknown"; +} + +} // namespace netft diff --git a/test/core/CMakeLists.txt b/test/core/CMakeLists.txt index 6b000ce..948cf8a 100644 --- a/test/core/CMakeLists.txt +++ b/test/core/CMakeLists.txt @@ -16,3 +16,57 @@ function(add_netft_snapshot_test target source) netft_sdk_core ${NETFT_GTEST_MAIN_TARGET}) add_test(NAME ${target} COMMAND ${target}) endfunction() + +add_netft_snapshot_test(netft_snapshot_types test_types.cpp) +add_netft_snapshot_test(netft_snapshot_status test_status.cpp) + +add_netft_snapshot_test(netft_snapshot_protocol + test_protocol.cpp + ${NETFT_SNAPSHOT_SOURCE_DIR}/src/detail/protocol.cpp) + +add_netft_snapshot_test(netft_snapshot_sequence + test_sequence.cpp + ${NETFT_SNAPSHOT_SOURCE_DIR}/src/detail/sequence.cpp) + +add_netft_snapshot_test(netft_snapshot_fault_latch + test_fault_latch.cpp + ${NETFT_SNAPSHOT_SOURCE_DIR}/src/detail/fault_latch.cpp) + +add_netft_snapshot_test(netft_snapshot_discovery + test_discovery.cpp + support/fake_http_server.cpp + ${NETFT_SNAPSHOT_SOURCE_DIR}/src/detail/xml_config.cpp) + +add_netft_snapshot_test(netft_snapshot_client_stream + test_client_stream.cpp + support/fake_http_server.cpp + support/fake_sensor.cpp) + +add_netft_snapshot_test(netft_snapshot_client_recovery + test_client_recovery.cpp + support/fake_http_server.cpp + support/fake_sensor.cpp) + +add_library(netft_snapshot_client_impl_test STATIC + ${NETFT_SNAPSHOT_SOURCE_DIR}/src/detail/client_impl.cpp + ${NETFT_SNAPSHOT_SOURCE_DIR}/src/detail/fault_latch.cpp + ${NETFT_SNAPSHOT_SOURCE_DIR}/src/detail/posix_transport.cpp + ${NETFT_SNAPSHOT_SOURCE_DIR}/src/detail/protocol.cpp + ${NETFT_SNAPSHOT_SOURCE_DIR}/src/detail/sequence.cpp + ${NETFT_SNAPSHOT_SOURCE_DIR}/src/detail/xml_config.cpp) +target_include_directories(netft_snapshot_client_impl_test PRIVATE + ${NETFT_SNAPSHOT_SOURCE_DIR}/src) +target_link_libraries(netft_snapshot_client_impl_test PUBLIC netft_sdk_core) + +add_executable(netft_snapshot_client_lifecycle + test_client_lifecycle.cpp + support/fake_http_server.cpp + support/fake_sensor.cpp) +target_include_directories(netft_snapshot_client_lifecycle PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${NETFT_SNAPSHOT_SOURCE_DIR}/src) +target_link_libraries(netft_snapshot_client_lifecycle PRIVATE + netft_snapshot_client_impl_test + ${NETFT_GTEST_MAIN_TARGET}) +add_test(NAME netft_snapshot_client_lifecycle + COMMAND netft_snapshot_client_lifecycle) diff --git a/test/core/support/fake_http_server.cpp b/test/core/support/fake_http_server.cpp new file mode 100644 index 0000000..677f860 --- /dev/null +++ b/test/core/support/fake_http_server.cpp @@ -0,0 +1,199 @@ +#include "support/fake_http_server.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +void close_socket(int socket) noexcept { + if (socket >= 0) { + ::close(socket); + } +} + +void send_all(int socket, std::string_view data) noexcept { + while (!data.empty()) { + const auto sent = ::send(socket, data.data(), data.size(), MSG_NOSIGNAL); + if (sent <= 0) { + return; + } + data.remove_prefix(static_cast(sent)); + } +} + +} // namespace + +struct FakeHttpServer::Impl { + explicit Impl(std::string initial_body, int initial_status) + : body(std::move(initial_body)), status(initial_status) { + listener = ::socket(AF_INET, SOCK_STREAM, 0); + if (listener < 0) { + throw std::runtime_error("failed to create fake HTTP server socket"); + } + + const int reuse_address = 1; + ::setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &reuse_address, sizeof(reuse_address)); + + sockaddr_in address{}; + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + address.sin_port = 0; + if (::bind(listener, reinterpret_cast(&address), sizeof(address)) != 0 || + ::listen(listener, 8) != 0) { + const auto error = std::string{std::strerror(errno)}; + close_socket(listener); + listener = -1; + throw std::runtime_error("failed to bind fake HTTP server: " + error); + } + + socklen_t address_length = sizeof(address); + if (::getsockname(listener, reinterpret_cast(&address), &address_length) != 0) { + const auto error = std::string{std::strerror(errno)}; + close_socket(listener); + listener = -1; + throw std::runtime_error("failed to inspect fake HTTP server: " + error); + } + listening_port = ntohs(address.sin_port); + worker = std::thread([this] { serve(); }); + } + + ~Impl() { + stopping.store(true); + response_changed.notify_all(); + if (listener >= 0) { + ::shutdown(listener, SHUT_RDWR); + close_socket(listener); + } + const int client = active_client.exchange(-1); + if (client >= 0) { + ::shutdown(client, SHUT_RDWR); + } + if (worker.joinable()) { + worker.join(); + } + } + + void serve() noexcept { + while (!stopping.load()) { + const int client = ::accept(listener, nullptr, nullptr); + if (client < 0) { + if (stopping.load()) { + return; + } + continue; + } + active_client.store(client); + accepted_connections.fetch_add(1); + if (stopping.load()) { + ::shutdown(client, SHUT_RDWR); + } + handle_request(client); + int expected_client = client; + static_cast(active_client.compare_exchange_strong(expected_client, -1)); + close_socket(client); + } + } + + void handle_request(int client) noexcept { + std::string request; + char buffer[1024]; + while (request.size() < 8192 && request.find("\r\n\r\n") == std::string::npos) { + const auto received = ::recv(client, buffer, sizeof(buffer), 0); + if (received <= 0) { + return; + } + request.append(buffer, static_cast(received)); + } + requests.fetch_add(1); + + std::string response_body; + std::string response_location; + int response_status{}; + std::chrono::milliseconds response_delay{}; + { + std::lock_guard lock(response_mutex); + response_body = body; + response_location = redirect_location; + response_status = status; + response_delay = delay; + } + + if (request.rfind("GET /netftapi2.xml ", 0) != 0) { + response_body.clear(); + response_status = 404; + } + + if (response_delay.count() > 0) { + std::unique_lock lock(response_mutex); + if (response_changed.wait_for(lock, response_delay, [this] { return stopping.load(); })) { + return; + } + } + + const std::string reason = response_status == 200 ? "OK" : "Error"; + const std::string location_header = + response_location.empty() ? std::string{} : "Location: " + response_location + "\r\n"; + const std::string headers = "HTTP/1.1 " + std::to_string(response_status) + " " + reason + + "\r\n" + "Content-Type: application/xml\r\n" + location_header + + "Content-Length: " + std::to_string(response_body.size()) + "\r\n" + + "Connection: close\r\n\r\n"; + send_all(client, headers); + send_all(client, response_body); + } + + std::string body; + std::string redirect_location; + int status; + std::chrono::milliseconds delay{}; + std::mutex response_mutex; + std::condition_variable response_changed; + std::atomic stopping{false}; + std::atomic requests{0}; + std::atomic accepted_connections{0}; + std::atomic active_client{-1}; + int listener{-1}; + int listening_port{}; + std::thread worker; +}; + +FakeHttpServer::FakeHttpServer(std::string body, int status) + : impl_(std::make_unique(std::move(body), status)) {} + +FakeHttpServer::~FakeHttpServer() = default; + +std::string FakeHttpServer::host() const { return "127.0.0.1"; } + +int FakeHttpServer::port() const { return impl_->listening_port; } + +std::uint64_t FakeHttpServer::request_count() const noexcept { return impl_->requests.load(); } + +std::uint64_t FakeHttpServer::accepted_connection_count() const noexcept { + return impl_->accepted_connections.load(); +} + +void FakeHttpServer::set_response(std::string body, int status) { + std::lock_guard lock(impl_->response_mutex); + impl_->body = std::move(body); + impl_->status = status; +} + +void FakeHttpServer::set_response_delay(std::chrono::milliseconds delay) { + std::lock_guard lock(impl_->response_mutex); + impl_->delay = delay; +} + +void FakeHttpServer::set_redirect_location(std::string location) { + std::lock_guard lock(impl_->response_mutex); + impl_->redirect_location = std::move(location); +} diff --git a/test/core/support/fake_http_server.hpp b/test/core/support/fake_http_server.hpp new file mode 100644 index 0000000..bd9eea0 --- /dev/null +++ b/test/core/support/fake_http_server.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include +#include +#include +#include + +class FakeHttpServer { +public: + explicit FakeHttpServer(std::string body, int status = 200); + ~FakeHttpServer(); + + FakeHttpServer(const FakeHttpServer &) = delete; + FakeHttpServer &operator=(const FakeHttpServer &) = delete; + + std::string host() const; + int port() const; + std::uint64_t request_count() const noexcept; + std::uint64_t accepted_connection_count() const noexcept; + void set_response(std::string body, int status = 200); + void set_response_delay(std::chrono::milliseconds delay); + void set_redirect_location(std::string location); + +private: + struct Impl; + std::unique_ptr impl_; +}; diff --git a/test/core/support/fake_sensor.cpp b/test/core/support/fake_sensor.cpp new file mode 100644 index 0000000..17005d5 --- /dev/null +++ b/test/core/support/fake_sensor.cpp @@ -0,0 +1,260 @@ +#include "support/fake_sensor.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace netft::test { +namespace { + +constexpr std::string_view kDefaultXml = R"xml( +Fake Net F/T1000000 +1000000NNm)xml"; + +void put_u32(std::vector &bytes, std::size_t offset, std::uint32_t value) { + for (unsigned index = 0; index < 4; ++index) { + bytes[offset + index] = static_cast(value >> (24U - 8U * index)); + } +} + +std::vector make_record(const std::uint32_t rdt_sequence, const std::uint32_t status, + const std::uint32_t ft_sequence, + const std::array &axes) { + std::vector data(36); + put_u32(data, 0, rdt_sequence); + put_u32(data, 4, ft_sequence); + put_u32(data, 8, status); + for (std::size_t index = 0; index < axes.size(); ++index) { + put_u32(data, 12 + 4 * index, static_cast(axes[index])); + } + return data; +} + +} // namespace + +FakeSensor::FakeSensor(const double rate_hz) : http_(std::string{kDefaultXml}), rate_hz_(rate_hz) { + socket_ = ::socket(AF_INET, SOCK_DGRAM, 0); + if (socket_ < 0) { + throw std::runtime_error("fake sensor socket failed"); + } + const int flags = ::fcntl(socket_, F_GETFL, 0); + static_cast(::fcntl(socket_, F_SETFL, flags | O_NONBLOCK)); + + sockaddr_in address{}; + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + if (::bind(socket_, reinterpret_cast(&address), sizeof(address)) != 0) { + ::close(socket_); + socket_ = -1; + throw std::runtime_error("fake sensor bind failed"); + } + socklen_t address_size = sizeof(address); + if (::getsockname(socket_, reinterpret_cast(&address), &address_size) != 0) { + ::close(socket_); + socket_ = -1; + throw std::runtime_error("fake sensor getsockname failed"); + } + rdt_port_ = ntohs(address.sin_port); + worker_ = std::thread(&FakeSensor::run, this); +} + +FakeSensor::~FakeSensor() { + stopping_ = true; + if (socket_ >= 0) { + ::shutdown(socket_, SHUT_RDWR); + } + if (worker_.joinable()) { + worker_.join(); + } + if (socket_ >= 0) { + ::close(socket_); + } +} + +void FakeSensor::pause() noexcept { enabled_ = false; } + +void FakeSensor::resume() noexcept { enabled_ = true; } + +void FakeSensor::queue_payload(std::vector payload) { + std::lock_guard lock(mutex_); + payloads_.push_back(std::move(payload)); +} + +void FakeSensor::send_payload_now(std::vector payload) { + ::sockaddr_in peer{}; + { + std::lock_guard lock(mutex_); + if (!has_client_) { + throw std::runtime_error("fake sensor has no client"); + } + peer = client_; + } + static_cast(::sendto(socket_, payload.data(), payload.size(), 0, + reinterpret_cast(&peer), sizeof(peer))); +} + +void FakeSensor::queue_record(const std::uint32_t rdt_sequence, const std::uint32_t status, + std::uint32_t ft_sequence, const std::array axes) { + std::lock_guard lock(mutex_); + if (ft_sequence == 0) { + ft_sequence = ft_; + } + ft_ = ft_sequence + 4; + payloads_.push_back(make_record(rdt_sequence, status, ft_sequence, axes)); +} + +void FakeSensor::send_record_now(const std::uint32_t rdt_sequence, const std::uint32_t status, + std::uint32_t ft_sequence, + const std::array axes) { + { + std::lock_guard lock(mutex_); + if (ft_sequence == 0) { + ft_sequence = ft_; + } + ft_ = ft_sequence + 4; + } + send_payload_now(make_record(rdt_sequence, status, ft_sequence, axes)); +} + +void FakeSensor::skip_rdt(const unsigned count) noexcept { + std::lock_guard lock(mutex_); + skip_ += count; +} + +bool FakeSensor::wait_for_command(const detail::Command command, const unsigned count, + const std::chrono::milliseconds timeout) const { + const auto deadline = std::chrono::steady_clock::now() + timeout; + do { + const auto observed = commands(); + if (std::count(observed.begin(), observed.end(), command) >= + static_cast(count)) { + return true; + } + std::this_thread::sleep_for(std::chrono::milliseconds{2}); + } while (std::chrono::steady_clock::now() < deadline); + return false; +} + +bool FakeSensor::wait_for_http_request(const unsigned count, + const std::chrono::milliseconds timeout) const { + const auto deadline = std::chrono::steady_clock::now() + timeout; + do { + if (http_request_count() >= count) { + return true; + } + std::this_thread::sleep_for(std::chrono::milliseconds{2}); + } while (std::chrono::steady_clock::now() < deadline); + return false; +} + +std::vector FakeSensor::commands() const { + std::lock_guard lock(mutex_); + return commands_; +} + +std::vector FakeSensor::command_events() const { + std::lock_guard lock(mutex_); + return command_events_; +} + +void FakeSensor::set_xml_configuration(std::string xml, const int status) { + http_.set_response(std::move(xml), status); +} + +void FakeSensor::set_http_response_delay(const std::chrono::milliseconds delay) { + http_.set_response_delay(delay); +} + +void FakeSensor::record_command(const std::uint8_t *data, const std::size_t size, + const ::sockaddr_in &peer) { + if (size != 8 || data[0] != 0x12 || data[1] != 0x34) { + return; + } + const auto command = + static_cast((static_cast(data[2]) << 8U) | data[3]); + { + std::lock_guard lock(mutex_); + commands_.push_back(command); + command_events_.push_back({command, std::chrono::steady_clock::now()}); + if (command == detail::Command::StartRealtime) { + client_ = peer; + has_client_ = true; + rdt_ = 0; + } + } + if (command == detail::Command::StartRealtime) { + streaming_ = true; + } + if (command == detail::Command::StopStreaming || command == detail::Command::SetSoftwareBias) { + streaming_ = false; + } +} + +void FakeSensor::send_next() { + if (!streaming_ || !enabled_) { + return; + } + std::vector data; + ::sockaddr_in peer{}; + { + std::lock_guard lock(mutex_); + if (!has_client_) { + return; + } + peer = client_; + if (!payloads_.empty()) { + data = std::move(payloads_.front()); + payloads_.erase(payloads_.begin()); + } + if (data.empty()) { + data.resize(36); + rdt_ += 1 + skip_; + skip_ = 0; + put_u32(data, 0, rdt_); + put_u32(data, 4, ft_); + ft_ += 4; + put_u32(data, 12, 100); + put_u32(data, 16, static_cast(-200)); + put_u32(data, 20, 300); + put_u32(data, 24, 10); + put_u32(data, 28, static_cast(-20)); + put_u32(data, 32, 30); + } + } + static_cast(::sendto(socket_, data.data(), data.size(), 0, + reinterpret_cast(&peer), sizeof(peer))); +} + +void FakeSensor::run() { + auto next = std::chrono::steady_clock::now(); + const auto interval = std::chrono::duration_cast( + std::chrono::duration{1.0 / rate_hz_}); + while (!stopping_) { + ::sockaddr_in peer{}; + socklen_t peer_size = sizeof(peer); + std::array data{}; + const auto size = ::recvfrom(socket_, data.data(), data.size(), 0, + reinterpret_cast(&peer), &peer_size); + if (size > 0) { + record_command(data.data(), static_cast(size), peer); + } + const auto now = std::chrono::steady_clock::now(); + if (now >= next) { + send_next(); + next += interval; + if (next < now) { + next = now + interval; + } + } + std::this_thread::sleep_for(std::chrono::microseconds{100}); + } +} + +} // namespace netft::test diff --git a/test/core/support/fake_sensor.hpp b/test/core/support/fake_sensor.hpp new file mode 100644 index 0000000..97cb61b --- /dev/null +++ b/test/core/support/fake_sensor.hpp @@ -0,0 +1,81 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "detail/protocol.hpp" +#include "support/fake_http_server.hpp" + +namespace netft::test { + +class FakeSensor { +public: + struct CommandEvent { + detail::Command command; + std::chrono::steady_clock::time_point at; + }; + + explicit FakeSensor(double rate_hz = 200.0); + ~FakeSensor(); + FakeSensor(const FakeSensor &) = delete; + FakeSensor &operator=(const FakeSensor &) = delete; + + const std::string &host() const noexcept { return host_; } + int rdt_port() const noexcept { return rdt_port_; } + int http_port() const noexcept { return http_.port(); } + std::uint64_t http_request_count() const noexcept { return http_.request_count(); } + + void pause() noexcept; + void resume() noexcept; + void queue_record(std::uint32_t rdt_sequence, std::uint32_t status = 0, + std::uint32_t ft_sequence = 0, + std::array axes = {100, -200, 300, 10, -20, 30}); + void send_record_now(std::uint32_t rdt_sequence, std::uint32_t status = 0, + std::uint32_t ft_sequence = 0, + std::array axes = {100, -200, 300, 10, -20, 30}); + void queue_payload(std::vector payload); + void send_payload_now(std::vector payload); + void skip_rdt(unsigned count) noexcept; + bool wait_for_command(detail::Command command, unsigned count = 1, + std::chrono::milliseconds timeout = std::chrono::milliseconds{1000}) const; + bool wait_for_http_request(unsigned count = 1, + std::chrono::milliseconds timeout = std::chrono::milliseconds{ + 1000}) const; + std::vector commands() const; + std::vector command_events() const; + void set_xml_configuration(std::string xml, int status = 200); + void set_http_response_delay(std::chrono::milliseconds delay); + +private: + void run(); + void send_next(); + void record_command(const std::uint8_t *data, std::size_t size, const ::sockaddr_in &peer); + + std::string host_{"127.0.0.1"}; + FakeHttpServer http_; + int rdt_port_{}; + int socket_{-1}; + double rate_hz_{}; + std::atomic stopping_{false}; + std::atomic enabled_{true}; + std::atomic streaming_{false}; + std::thread worker_; + mutable std::mutex mutex_; + std::vector commands_; + std::vector command_events_; + std::vector> payloads_; + ::sockaddr_in client_{}; + bool has_client_{false}; + std::uint32_t rdt_{}; + std::uint32_t ft_{1000}; + std::uint32_t skip_{}; +}; + +} // namespace netft::test diff --git a/test/core/test_client_lifecycle.cpp b/test/core/test_client_lifecycle.cpp new file mode 100644 index 0000000..850ef10 --- /dev/null +++ b/test/core/test_client_lifecycle.cpp @@ -0,0 +1,714 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "detail/protocol.hpp" +#define private public +#include "detail/client_impl.hpp" +#undef private +#include "support/fake_sensor.hpp" + +namespace { + +using namespace std::chrono_literals; + +constexpr auto kChangedConfiguration = R"xml( +Fake Net F/T1000000 +1000NN-mm)xml"; + +netft::Config config_for(const netft::test::FakeSensor &sensor) { + netft::Config config; + config.sensor_host = sensor.host(); + config.rdt_port = sensor.rdt_port(); + config.http_port = sensor.http_port(); + config.receive_timeout = 120ms; + config.configuration_connect_timeout = 100ms; + config.configuration_timeout = 250ms; + config.reconnect_initial_delay = 5ms; + config.reconnect_max_delay = 20ms; + return config; +} + +template +bool wait_until(Predicate predicate, const std::chrono::milliseconds timeout = 1s) { + const auto deadline = std::chrono::steady_clock::now() + timeout; + do { + if (predicate()) { + return true; + } + std::this_thread::sleep_for(2ms); + } while (std::chrono::steady_clock::now() < deadline); + return predicate(); +} + +struct CopyThrowState { + std::atomic throw_on_copy{}; + std::atomic copy_attempts{}; + std::atomic calls{}; +}; + +struct CopyThrowingCallback { + explicit CopyThrowingCallback(std::shared_ptr state_in) + : state{std::move(state_in)} {} + + CopyThrowingCallback(const CopyThrowingCallback &other) : state{other.state} { + ++state->copy_attempts; + if (state->throw_on_copy) { + throw std::bad_alloc{}; + } + } + CopyThrowingCallback(CopyThrowingCallback &&) noexcept = default; + + void operator()(const netft::Sample &) const { ++state->calls; } + + std::shared_ptr state; +}; + +struct HookGate { + std::mutex mutex; + std::condition_variable cv; + bool entered{}; + bool released{}; +}; + +struct ClientLifecycleTestAccess { + static void fail_next_thread_creation(netft::Client &client) { + fail_next = true; + client.impl_->thread_factory_ = &create_thread; + } + + static bool worker_joinable(const netft::Client &client) { + return client.impl_->worker_.joinable(); + } + + static bool worker_exited(const netft::Client &client) { return client.impl_->worker_exited_; } + + static bool has_callback(const netft::Client &client) { + return static_cast(client.impl_->callback_); + } + + static bool stopping(const netft::Client &client) { return client.impl_->stopping_; } + + static std::uint64_t generation(const netft::Client &client) { + std::lock_guard data_lock(client.impl_->data_mutex_); + return client.impl_->generation_; + } + + static void gate_fault_publication(netft::Client &client) { + reset_gate(fault_gate); + client.impl_->fault_published_test_hook_ = &on_fault_published; + } + + static bool wait_for_fault_publication(const std::chrono::milliseconds timeout = 1s) { + return wait_for_gate(fault_gate, timeout); + } + + static void release_fault_publication() { release_gate(fault_gate); } + + static void gate_waiter_after_wake(netft::Client &client) { + reset_gate(waiter_gate); + client.impl_->wait_wake_test_hook_ = &on_wait_wake; + } + + static bool wait_for_waiter_wake(const std::chrono::milliseconds timeout = 1s) { + return wait_for_gate(waiter_gate, timeout); + } + + static void release_waiter() { release_gate(waiter_gate); } + +private: + static std::thread create_thread(netft::Client::Impl *impl) { + if (fail_next.exchange(false)) { + throw std::system_error{std::make_error_code(std::errc::resource_unavailable_try_again), + "injected thread creation failure"}; + } + return std::thread{&netft::Client::Impl::run, impl}; + } + + static void reset_gate(HookGate &gate) { + std::lock_guard lock(gate.mutex); + gate.entered = false; + gate.released = false; + } + + static bool wait_for_gate(HookGate &gate, const std::chrono::milliseconds timeout) { + std::unique_lock lock(gate.mutex); + return gate.cv.wait_for(lock, timeout, [&] { return gate.entered; }); + } + + static void release_gate(HookGate &gate) { + { + std::lock_guard lock(gate.mutex); + gate.released = true; + } + gate.cv.notify_all(); + } + + static void enter_gate(HookGate &gate) noexcept { + try { + std::unique_lock lock(gate.mutex); + gate.entered = true; + gate.cv.notify_all(); + static_cast(gate.cv.wait_for(lock, 2s, [&] { return gate.released; })); + } catch (...) { + } + } + + static void on_fault_published(netft::Client::Impl *) noexcept { enter_gate(fault_gate); } + + static void on_wait_wake(netft::Client::Impl *, std::uint64_t) noexcept { + enter_gate(waiter_gate); + } + + static inline std::atomic fail_next{}; + static inline HookGate fault_gate; + static inline HookGate waiter_gate; +}; + +TEST(ClientLifecycle, RateLimitDropsIntermediateSamplesWithoutQueueing) { + netft::test::FakeSensor sensor{1000.0}; + auto config = config_for(sensor); + config.sample_rate_limit_hz = 50.0; + netft::Client client{config}; + std::atomic delivered{}; + client.start([&](const netft::Sample &) { ++delivered; }); + + ASSERT_TRUE(client.wait_for_first_sample(500ms)); + ASSERT_TRUE(wait_until([&] { + const auto health = client.health(); + return health.delivered_count >= 3 && health.rate_limited_count > 0; + })); + client.stop(); + + const auto health = client.health(); + EXPECT_GT(health.delivered_count, 0U); + EXPECT_GT(health.received_count, health.delivered_count); + EXPECT_GT(health.rate_limited_count, 0U); + EXPECT_EQ(health.delivered_count, delivered.load()); + EXPECT_EQ(health.received_count, health.delivered_count + health.rate_limited_count); + EXPECT_DOUBLE_EQ(health.delivery_rate_hz, static_cast(health.delivered_count)); +} + +TEST(ClientLifecycle, CallbackExceptionsContinueUnderReconnectPolicy) { + netft::test::FakeSensor sensor{200.0}; + netft::Client client{config_for(sensor)}; + std::atomic calls{}; + client.start([&](const netft::Sample &) { + ++calls; + throw std::runtime_error{"consumer failed"}; + }); + + ASSERT_TRUE(wait_until([&] { return client.health().callback_error_count >= 3; })); + EXPECT_FALSE(client.faulted()); + EXPECT_GE(calls.load(), 3U); + EXPECT_EQ(client.health().delivered_count, 0U); + client.stop(); +} + +TEST(ClientLifecycle, CallbackCopyExceptionsContinueUnderReconnectPolicy) { + netft::test::FakeSensor sensor{200.0}; + sensor.pause(); + netft::Client client{config_for(sensor)}; + auto state = std::make_shared(); + netft::Client::SampleCallback callback{CopyThrowingCallback{state}}; + client.start(std::move(callback)); + ASSERT_EQ(state->copy_attempts.load(), 0U); + + state->throw_on_copy = true; + sensor.resume(); + ASSERT_TRUE(wait_until([&] { return client.health().callback_error_count >= 3; })); + EXPECT_EQ(state->calls.load(), 0U); + EXPECT_FALSE(client.faulted()); + + state->throw_on_copy = false; + ASSERT_TRUE(wait_until([&] { return state->calls.load() > 0; })); + EXPECT_TRUE(client.wait_for_first_sample(100ms)); + client.stop(); +} + +TEST(ClientLifecycle, CallbackExceptionLatchesUnderFailStopPolicy) { + netft::test::FakeSensor sensor; + sensor.pause(); + auto config = config_for(sensor); + config.recovery_policy = netft::RecoveryPolicy::FailStop; + netft::Client client{config}; + std::atomic calls{}; + client.start([&](const netft::Sample &) { + ++calls; + throw std::runtime_error{"fail-stop callback"}; + }); + sensor.queue_record(1, 0, 100); + sensor.resume(); + + ASSERT_TRUE(wait_until([&] { return client.faulted(); })); + EXPECT_EQ(client.fault_code(), netft::FaultCode::Callback); + EXPECT_EQ(calls.load(), 1U); + EXPECT_EQ(client.health().callback_error_count, 1U); + EXPECT_EQ(client.health().reconnect_count, 0U); + client.stop(); + EXPECT_EQ(client.fault_code(), netft::FaultCode::Callback); +} + +TEST(ClientLifecycle, CallbackCopyExceptionLatchesUnderFailStopPolicy) { + netft::test::FakeSensor sensor; + sensor.pause(); + auto config = config_for(sensor); + config.recovery_policy = netft::RecoveryPolicy::FailStop; + netft::Client client{config}; + auto state = std::make_shared(); + netft::Client::SampleCallback callback{CopyThrowingCallback{state}}; + client.start(std::move(callback)); + ASSERT_EQ(state->copy_attempts.load(), 0U); + state->throw_on_copy = true; + sensor.queue_record(1, 0, 100); + sensor.resume(); + + ASSERT_TRUE(wait_until([&] { return client.faulted(); })); + EXPECT_EQ(client.fault_code(), netft::FaultCode::Callback); + EXPECT_EQ(state->copy_attempts.load(), 1U); + EXPECT_EQ(state->calls.load(), 0U); + EXPECT_EQ(client.health().callback_error_count, 1U); + EXPECT_EQ(client.health().reconnect_count, 0U); + client.stop(); +} + +TEST(ClientLifecycle, EarlierSeriousStatusWinsOverCallbackFailure) { + netft::test::FakeSensor sensor; + sensor.pause(); + auto config = config_for(sensor); + config.recovery_policy = netft::RecoveryPolicy::FailStop; + config.deliver_samples_with_error_status = true; + netft::Client client{config}; + client.start([](const netft::Sample &) { throw std::runtime_error{"serious callback failed"}; }); + sensor.queue_record(1, 0x80020000U, 100); + sensor.resume(); + + ASSERT_TRUE(wait_until([&] { return client.faulted(); })); + EXPECT_EQ(client.fault_code(), netft::FaultCode::SeriousStatus); + EXPECT_EQ(client.health().callback_error_count, 1U); + EXPECT_EQ(client.health().device_error_count, 1U); + client.stop(); +} + +TEST(ClientLifecycle, CallbackFailureDoesNotConsumeDeliveryRateSlot) { + netft::test::FakeSensor sensor{500.0}; + auto config = config_for(sensor); + config.sample_rate_limit_hz = 1.0; + netft::Client client{config}; + std::atomic calls{}; + client.start([&](const netft::Sample &) { + if (++calls == 1) { + sensor.pause(); + throw std::runtime_error{"first delivery failed"}; + } + }); + + EXPECT_FALSE(client.wait_for_first_sample(50ms)); + sensor.resume(); + ASSERT_TRUE(wait_until([&] { return calls.load() >= 2; }, 300ms)); + EXPECT_EQ(client.health().delivered_count, 1U); + client.stop(); +} + +TEST(ClientLifecycle, BiasRequiresStreamingAndRestartsRealtime) { + netft::test::FakeSensor sensor; + sensor.pause(); + auto config = config_for(sensor); + config.receive_timeout = 2s; + netft::Client client{config}; + EXPECT_THROW(client.bias(), netft::NotConnectedError); + client.start([](const netft::Sample &) {}); + ASSERT_TRUE(sensor.wait_for_command(netft::detail::Command::StartRealtime)); + EXPECT_EQ(client.health().state, netft::ClientState::Connecting); + EXPECT_THROW(client.bias(), netft::NotConnectedError); + + sensor.resume(); + ASSERT_TRUE(client.wait_for_first_sample(500ms)); + EXPECT_NO_THROW(client.bias()); + EXPECT_TRUE(sensor.wait_for_command(netft::detail::Command::SetSoftwareBias)); + EXPECT_TRUE(sensor.wait_for_command(netft::detail::Command::StartRealtime, 2)); + client.stop(); +} + +TEST(ClientLifecycle, CallbackCanReenterBias) { + netft::test::FakeSensor sensor{200.0}; + netft::Client client{config_for(sensor)}; + std::atomic calls{}; + client.start([&](const netft::Sample &) { + ++calls; + client.bias(); + }); + + ASSERT_TRUE(wait_until([&] { return calls.load() >= 2 && sensor.commands().size() >= 5; })); + client.stop(); +} + +TEST(ClientLifecycle, StartRejectsActiveClientAndStopIsIdempotent) { + netft::test::FakeSensor sensor; + netft::Client client{config_for(sensor)}; + client.start([](const netft::Sample &) {}); + EXPECT_THROW(client.start([](const netft::Sample &) {}), std::logic_error); + client.stop(); + client.stop(); +} + +TEST(ClientLifecycle, CallbackStopReturnsAndExternalStopReapsWorker) { + netft::test::FakeSensor sensor{200.0}; + netft::Client client{config_for(sensor)}; + std::atomic returned{}; + client.start([&](const netft::Sample &) { + client.stop(); + returned = true; + }); + + ASSERT_TRUE(wait_until([&] { return returned.load(); })); + ASSERT_TRUE(wait_until([&] { return ClientLifecycleTestAccess::worker_exited(client); })); + EXPECT_TRUE(ClientLifecycleTestAccess::worker_joinable(client)); + client.stop(); + EXPECT_FALSE(ClientLifecycleTestAccess::worker_joinable(client)); + EXPECT_EQ(client.health().state, netft::ClientState::Stopped); + + client.start([](const netft::Sample &) {}); + ASSERT_TRUE(client.wait_for_first_sample(500ms)); + client.stop(); + EXPECT_FALSE(ClientLifecycleTestAccess::worker_joinable(client)); +} + +TEST(ClientLifecycle, CallbackStartAndConcurrentStopDoNotDeadlock) { + netft::test::FakeSensor sensor{200.0}; + netft::Client client{config_for(sensor)}; + std::atomic entered{}; + std::atomic stop_started{}; + std::atomic callback_finished{}; + client.start([&](const netft::Sample &) { + entered = true; + while (!stop_started) { + std::this_thread::yield(); + } + EXPECT_THROW(client.start([](const netft::Sample &) {}), std::logic_error); + callback_finished = true; + }); + ASSERT_TRUE(wait_until([&] { return entered.load(); })); + + std::thread stopper([&] { + stop_started = true; + client.stop(); + }); + ASSERT_TRUE(wait_until([&] { return callback_finished.load(); }, 300ms)); + stopper.join(); + client.stop(); + EXPECT_EQ(client.health().state, netft::ClientState::Stopped); +} + +TEST(ClientLifecycle, CallbackStoppedWorkerCanBeReapedByDirectRestart) { + netft::test::FakeSensor sensor{200.0}; + netft::Client client{config_for(sensor)}; + std::atomic stopped{}; + client.start([&](const netft::Sample &) { + client.stop(); + stopped = true; + }); + ASSERT_TRUE(wait_until([&] { return stopped.load(); })); + ASSERT_TRUE(wait_until([&] { return client.health().state == netft::ClientState::Stopped; })); + ASSERT_TRUE(wait_until([&] { return ClientLifecycleTestAccess::worker_exited(client); })); + EXPECT_TRUE(ClientLifecycleTestAccess::worker_joinable(client)); + const auto stopped_generation = ClientLifecycleTestAccess::generation(client); + + std::atomic restarted{}; + client.start([&](const netft::Sample &) { ++restarted; }); + EXPECT_EQ(ClientLifecycleTestAccess::generation(client), stopped_generation + 1); + ASSERT_TRUE(wait_until([&] { return restarted.load() > 0; })); + client.stop(); + EXPECT_FALSE(ClientLifecycleTestAccess::worker_joinable(client)); +} + +TEST(ClientLifecycle, FaultedWorkerCanBeDirectlyRestartedBeforeItExits) { + netft::test::FakeSensor sensor; + sensor.pause(); + auto config = config_for(sensor); + config.recovery_policy = netft::RecoveryPolicy::FailStop; + netft::Client client{config}; + ClientLifecycleTestAccess::gate_fault_publication(client); + client.start([](const netft::Sample &) {}); + ASSERT_TRUE(sensor.wait_for_command(netft::detail::Command::StartRealtime)); + const auto faulted_generation = ClientLifecycleTestAccess::generation(client); + sensor.send_record_now(1, 0x80020000U, 100); + + ASSERT_TRUE(ClientLifecycleTestAccess::wait_for_fault_publication()); + ASSERT_TRUE(client.faulted()); + ASSERT_FALSE(ClientLifecycleTestAccess::worker_exited(client)); + ASSERT_TRUE(ClientLifecycleTestAccess::worker_joinable(client)); + + std::atomic restart_entered{}; + std::atomic restart_returned{}; + std::exception_ptr restart_error; + std::atomic restarted_deliveries{}; + std::thread restarter([&] { + restart_entered = true; + try { + client.start([&](const netft::Sample &) { ++restarted_deliveries; }); + } catch (...) { + restart_error = std::current_exception(); + } + restart_returned = true; + }); + EXPECT_TRUE(wait_until([&] { return restart_entered.load(); })); + EXPECT_FALSE(wait_until([&] { return restart_returned.load(); }, 20ms)); + + ClientLifecycleTestAccess::release_fault_publication(); + restarter.join(); + EXPECT_EQ(restart_error, nullptr); + EXPECT_TRUE(restart_returned.load()); + EXPECT_EQ(ClientLifecycleTestAccess::generation(client), faulted_generation + 1); + EXPECT_FALSE(client.faulted()); + + ASSERT_TRUE(sensor.wait_for_command(netft::detail::Command::StartRealtime, 2)); + sensor.send_record_now(1, 0, 104); + EXPECT_TRUE(wait_until([&] { return restarted_deliveries.load() == 1; }, 500ms)); + client.stop(); +} + +TEST(ClientLifecycle, RestartClearsActiveStateAndPreservesLifetimeHealth) { + netft::test::FakeSensor sensor; + sensor.pause(); + auto config = config_for(sensor); + config.recovery_policy = netft::RecoveryPolicy::FailStop; + config.deliver_samples_with_error_status = true; + config.receive_timeout = 2s; + netft::Client client{config}; + + client.start([](const netft::Sample &) {}); + ASSERT_TRUE(sensor.wait_for_command(netft::detail::Command::StartRealtime)); + sensor.send_record_now(17, 0x80020000U, 100); + ASSERT_TRUE(wait_until([&] { return client.faulted(); })); + ASSERT_TRUE(wait_until([&] { return ClientLifecycleTestAccess::worker_exited(client); })); + client.stop(); + + const auto before_restart = client.health(); + ASSERT_TRUE(client.latest_sample()); + ASSERT_TRUE(before_restart.sensor_configuration); + ASSERT_EQ(before_restart.fault_code, netft::FaultCode::SeriousStatus); + ASSERT_EQ(before_restart.received_count, 1U); + ASSERT_EQ(before_restart.delivered_count, 1U); + ASSERT_EQ(before_restart.device_error_count, 1U); + ASSERT_EQ(before_restart.sensor_configuration->revision, 1U); + + sensor.set_xml_configuration(kChangedConfiguration); + client.start([](const netft::Sample &) {}); + + const auto starting = client.health(); + EXPECT_FALSE(client.faulted()); + EXPECT_EQ(starting.fault_code, netft::FaultCode::None); + EXPECT_FALSE(client.latest_sample()); + EXPECT_EQ(starting.state, netft::ClientState::Connecting); + EXPECT_TRUE(starting.last_error.empty()); + EXPECT_DOUBLE_EQ(starting.receive_rate_hz, 0.0); + EXPECT_DOUBLE_EQ(starting.delivery_rate_hz, 0.0); + EXPECT_EQ(starting.received_count, before_restart.received_count); + EXPECT_EQ(starting.delivered_count, before_restart.delivered_count); + EXPECT_EQ(starting.device_error_count, before_restart.device_error_count); + + ASSERT_TRUE(sensor.wait_for_http_request(2)); + ASSERT_TRUE(sensor.wait_for_command(netft::detail::Command::StartRealtime, 2)); + const auto rediscovered = client.health(); + ASSERT_TRUE(rediscovered.sensor_configuration); + EXPECT_EQ(rediscovered.sensor_configuration->revision, 2U); + EXPECT_EQ(rediscovered.calibration_change_count, before_restart.calibration_change_count + 1); + EXPECT_EQ(rediscovered.received_count, before_restart.received_count); + EXPECT_EQ(rediscovered.delivered_count, before_restart.delivered_count); + + sensor.send_record_now(1, 0, 104); + ASSERT_TRUE(wait_until( + [&] { return client.health().delivered_count == before_restart.delivered_count + 1; })); + const auto after_restart = client.health(); + const auto latest = client.latest_sample(); + ASSERT_TRUE(latest); + EXPECT_EQ(latest->configuration_revision, 2U); + EXPECT_EQ(latest->torque_unit, netft::TorqueUnit::NewtonMillimeter); + EXPECT_EQ(after_restart.received_count, before_restart.received_count + 1); + EXPECT_EQ(after_restart.delivered_count, before_restart.delivered_count + 1); + EXPECT_EQ(after_restart.device_error_count, before_restart.device_error_count); + client.stop(); +} + +TEST(ClientLifecycle, ConcurrentStartAndStopLeaveClientRestartable) { + netft::test::FakeSensor sensor{400.0}; + netft::Client client{config_for(sensor)}; + for (int iteration = 0; iteration < 40; ++iteration) { + std::thread starter([&] { + try { + client.start([](const netft::Sample &) {}); + } catch (const std::logic_error &) { + } + }); + std::thread stopper([&] { client.stop(); }); + starter.join(); + stopper.join(); + client.stop(); + } + + client.start([](const netft::Sample &) {}); + ASSERT_TRUE(wait_until([&] { return client.health().received_count > 0; })); + client.stop(); + EXPECT_EQ(client.health().state, netft::ClientState::Stopped); +} + +TEST(ClientLifecycle, ThreadCreationFailureRestoresPriorFaultedState) { + netft::test::FakeSensor sensor; + sensor.pause(); + auto config = config_for(sensor); + config.recovery_policy = netft::RecoveryPolicy::FailStop; + config.deliver_samples_with_error_status = true; + netft::Client client{config}; + std::atomic original_callback_calls{}; + client.start([&](const netft::Sample &) { ++original_callback_calls; }); + ASSERT_TRUE(sensor.wait_for_command(netft::detail::Command::StartRealtime)); + sensor.queue_record(17, 0x80020000U, 100); + sensor.resume(); + ASSERT_TRUE(wait_until([&] { return client.faulted(); })); + ASSERT_TRUE(wait_until([&] { return ClientLifecycleTestAccess::worker_exited(client); })); + + const auto prior_health = client.health(); + const auto prior_latest = client.latest_sample(); + const auto prior_generation = ClientLifecycleTestAccess::generation(client); + ASSERT_TRUE(prior_latest); + ASSERT_EQ(original_callback_calls.load(), 1U); + ASSERT_EQ(prior_health.state, netft::ClientState::Faulted); + ASSERT_EQ(prior_health.fault_code, netft::FaultCode::SeriousStatus); + ASSERT_EQ(prior_health.received_count, 1U); + ASSERT_EQ(prior_health.delivered_count, 1U); + + ClientLifecycleTestAccess::fail_next_thread_creation(client); + + EXPECT_THROW(client.start([](const netft::Sample &) {}), std::system_error); + const auto restored_health = client.health(); + const auto restored_latest = client.latest_sample(); + ASSERT_TRUE(restored_latest); + EXPECT_EQ(restored_health.state, prior_health.state); + EXPECT_EQ(restored_health.fault_code, prior_health.fault_code); + EXPECT_EQ(restored_health.last_error, prior_health.last_error); + EXPECT_EQ(restored_health.received_count, prior_health.received_count); + EXPECT_EQ(restored_health.delivered_count, prior_health.delivered_count); + EXPECT_EQ(restored_health.device_error_count, prior_health.device_error_count); + EXPECT_EQ(restored_health.delivery_rate_hz, prior_health.delivery_rate_hz); + EXPECT_EQ(restored_latest->rdt_sequence, prior_latest->rdt_sequence); + EXPECT_EQ(restored_latest->ft_sequence, prior_latest->ft_sequence); + EXPECT_EQ(client.fault_code(), netft::FaultCode::SeriousStatus); + EXPECT_FALSE(ClientLifecycleTestAccess::worker_joinable(client)); + EXPECT_TRUE(ClientLifecycleTestAccess::worker_exited(client)); + EXPECT_TRUE(ClientLifecycleTestAccess::has_callback(client)); + EXPECT_FALSE(ClientLifecycleTestAccess::stopping(client)); + EXPECT_EQ(ClientLifecycleTestAccess::generation(client), prior_generation); + EXPECT_TRUE(client.wait_for_first_sample(10ms)); + client.stop(); +} + +TEST(ClientLifecycle, WaiterWakesForStopAndFault) { + { + netft::test::FakeSensor sensor; + sensor.pause(); + netft::Client client{config_for(sensor)}; + std::atomic returned{}; + client.start([](const netft::Sample &) {}); + std::thread waiter([&] { + EXPECT_FALSE(client.wait_for_first_sample(2s)); + returned = true; + }); + std::this_thread::sleep_for(10ms); + client.stop(); + ASSERT_TRUE(wait_until([&] { return returned.load(); }, 200ms)); + waiter.join(); + } + { + netft::test::FakeSensor sensor; + sensor.pause(); + auto config = config_for(sensor); + config.recovery_policy = netft::RecoveryPolicy::FailStop; + config.receive_timeout = 50ms; + netft::Client client{config}; + std::atomic returned{}; + client.start([](const netft::Sample &) {}); + std::thread waiter([&] { + EXPECT_FALSE(client.wait_for_first_sample(2s)); + returned = true; + }); + ASSERT_TRUE(wait_until([&] { return client.faulted(); }, 500ms)); + ASSERT_TRUE(wait_until([&] { return returned.load(); }, 200ms)); + waiter.join(); + client.stop(); + } +} + +TEST(ClientLifecycle, OldWaiterReturnsFalseAfterNewGenerationDelivers) { + netft::test::FakeSensor sensor; + sensor.pause(); + auto config = config_for(sensor); + config.receive_timeout = 2s; + netft::Client client{config}; + client.start([](const netft::Sample &) {}); + ClientLifecycleTestAccess::gate_waiter_after_wake(client); + std::atomic waiter_entered{}; + std::atomic waiter_returned{}; + std::atomic waiter_result{true}; + std::thread waiter([&] { + waiter_entered = true; + waiter_result = client.wait_for_first_sample(2s); + waiter_returned = true; + }); + ASSERT_TRUE(wait_until([&] { return waiter_entered.load(); })); + + client.stop(); + const bool waiter_woke = ClientLifecycleTestAccess::wait_for_waiter_wake(); + client.start([](const netft::Sample &) {}); + sensor.resume(); + const bool new_generation_delivered = + wait_until([&] { return client.health().delivered_count > 0; }, 500ms); + EXPECT_FALSE(waiter_returned.load()); + + ClientLifecycleTestAccess::release_waiter(); + waiter.join(); + EXPECT_TRUE(waiter_woke); + EXPECT_TRUE(new_generation_delivered); + EXPECT_TRUE(waiter_returned.load()); + EXPECT_FALSE(waiter_result.load()); + client.stop(); +} + +TEST(ClientLifecycle, WaitForFirstSampleRequiresSuccessfulDelivery) { + netft::test::FakeSensor sensor; + sensor.pause(); + auto config = config_for(sensor); + config.recovery_policy = netft::RecoveryPolicy::FailStop; + netft::Client client{config}; + client.start([](const netft::Sample &) { throw std::runtime_error{"delivery failed"}; }); + sensor.queue_record(1, 0, 100); + sensor.resume(); + + EXPECT_FALSE(client.wait_for_first_sample(500ms)); + EXPECT_EQ(client.fault_code(), netft::FaultCode::Callback); + client.stop(); +} + +TEST(ClientLifecycle, DestructionSendsStopStreaming) { + netft::test::FakeSensor sensor; + { + netft::Client client{config_for(sensor)}; + client.start([](const netft::Sample &) {}); + ASSERT_TRUE(wait_until([&] { return client.health().received_count > 0; })); + } + EXPECT_TRUE(sensor.wait_for_command(netft::detail::Command::StopStreaming)); +} + +} // namespace diff --git a/test/core/test_client_recovery.cpp b/test/core/test_client_recovery.cpp new file mode 100644 index 0000000..3275fab --- /dev/null +++ b/test/core/test_client_recovery.cpp @@ -0,0 +1,543 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "detail/protocol.hpp" +#include "netft/client.hpp" +#include "support/fake_sensor.hpp" + +namespace { + +using namespace std::chrono_literals; + +constexpr auto kChangedConfiguration = R"xml( +Fake Net F/T1000000 +1000NN-mm)xml"; + +netft::Config config_for(const netft::test::FakeSensor &sensor) { + netft::Config config; + config.sensor_host = sensor.host(); + config.rdt_port = sensor.rdt_port(); + config.http_port = sensor.http_port(); + config.receive_timeout = 120ms; + config.configuration_connect_timeout = 100ms; + config.configuration_timeout = 250ms; + config.reconnect_initial_delay = 10ms; + config.reconnect_max_delay = 40ms; + return config; +} + +template +bool wait_until(Predicate predicate, const std::chrono::milliseconds timeout = 1s) { + const auto deadline = std::chrono::steady_clock::now() + timeout; + do { + if (predicate()) { + return true; + } + std::this_thread::sleep_for(2ms); + } while (std::chrono::steady_clock::now() < deadline); + return predicate(); +} + +std::size_t start_count(const netft::test::FakeSensor &sensor) { + const auto commands = sensor.commands(); + return static_cast( + std::count(commands.begin(), commands.end(), netft::detail::Command::StartRealtime)); +} + +std::vector +start_times(const netft::test::FakeSensor &sensor) { + std::vector times; + for (const auto &event : sensor.command_events()) { + if (event.command == netft::detail::Command::StartRealtime) { + times.push_back(event.at); + } + } + return times; +} + +TEST(ClientRecovery, CountsLossDuplicatesAndOutOfOrderBeforeOrderedDelivery) { + netft::test::FakeSensor sensor{500.0}; + sensor.pause(); + auto config = config_for(sensor); + config.recovery_policy = netft::RecoveryPolicy::FailStop; + config.receive_timeout = 1s; + std::mutex delivered_mutex; + std::vector delivered; + netft::Client client{config}; + client.start([&](const netft::Sample &sample) { + std::lock_guard lock(delivered_mutex); + delivered.push_back(sample.rdt_sequence); + }); + ASSERT_TRUE(sensor.wait_for_command(netft::detail::Command::StartRealtime)); + + sensor.send_record_now(1, 0, 100); + ASSERT_TRUE(wait_until([&] { return client.health().received_count == 1; })); + sensor.send_record_now(4, 0, 104); + ASSERT_TRUE(wait_until([&] { return client.health().received_count == 2; })); + sensor.send_record_now(4, 0x80010000U, 108); + ASSERT_TRUE(wait_until([&] { return client.health().received_count == 3; })); + sensor.send_record_now(3, 0x80010000U, 112); + ASSERT_TRUE(wait_until([&] { return client.health().received_count == 4; })); + client.stop(); + const auto health = client.health(); + std::lock_guard lock(delivered_mutex); + EXPECT_EQ(delivered, (std::vector{1, 4})); + EXPECT_EQ(health.received_count, 4U); + EXPECT_EQ(health.delivered_count, 2U); + EXPECT_EQ(health.lost_count, 2U); + EXPECT_EQ(health.duplicate_count, 1U); + EXPECT_EQ(health.out_of_order_count, 1U); + EXPECT_EQ(health.warning_count, 2U); + EXPECT_GT(health.receive_rate_hz, 0.0); + EXPECT_GT(health.delivery_rate_hz, 0.0); +} + +TEST(ClientRecovery, ValidRecordResetsConsecutiveMalformedCount) { + netft::test::FakeSensor sensor{500.0}; + sensor.pause(); + auto config = config_for(sensor); + config.recovery_policy = netft::RecoveryPolicy::FailStop; + config.receive_timeout = 1s; + std::atomic delivered{}; + netft::Client client{config}; + + for (int count = 0; count < 9; ++count) { + sensor.queue_payload({1, 2}); + } + sensor.queue_record(10, 0x80010000U, 100); + for (int count = 0; count < 9; ++count) { + sensor.queue_payload({1, 2}); + } + sensor.queue_record(13, 0, 104); + client.start([&](const netft::Sample &) { ++delivered; }); + sensor.resume(); + + ASSERT_TRUE(wait_until([&] { return delivered.load() >= 2; })); + const auto health = client.health(); + EXPECT_FALSE(client.faulted()); + EXPECT_EQ(health.malformed_count, 18U); + EXPECT_EQ(health.lost_count, 2U); + EXPECT_EQ(health.warning_count, 1U); + client.stop(); +} + +TEST(ClientRecovery, TenConsecutiveMalformedRecordsLatchMalformedStorm) { + netft::test::FakeSensor sensor{500.0}; + sensor.pause(); + auto config = config_for(sensor); + config.recovery_policy = netft::RecoveryPolicy::FailStop; + config.receive_timeout = 1s; + netft::Client client{config}; + client.start([](const netft::Sample &) {}); + ASSERT_TRUE(sensor.wait_for_command(netft::detail::Command::StartRealtime)); + + for (int count = 0; count < 10; ++count) { + sensor.queue_payload({1, 2}); + } + sensor.resume(); + + ASSERT_TRUE(wait_until([&] { return client.faulted(); })); + EXPECT_EQ(client.fault_code(), netft::FaultCode::MalformedStorm); + const auto health = client.health(); + EXPECT_EQ(health.malformed_count, 10U); + EXPECT_EQ(health.state, netft::ClientState::Faulted); + client.stop(); +} + +TEST(ClientRecovery, MalformedRecordDoesNotExtendValidRecordDeadline) { + netft::test::FakeSensor sensor{200.0}; + auto config = config_for(sensor); + config.recovery_policy = netft::RecoveryPolicy::FailStop; + config.receive_timeout = 200ms; + netft::Client client{config}; + client.start([](const netft::Sample &) {}); + ASSERT_TRUE(client.wait_for_first_sample(500ms)); + + sensor.pause(); + const auto last_valid = std::chrono::steady_clock::now(); + std::this_thread::sleep_for(150ms); + sensor.send_payload_now({1, 2}); + ASSERT_TRUE(wait_until([&] { return client.faulted(); }, 500ms)); + const auto elapsed = std::chrono::steady_clock::now() - last_valid; + + EXPECT_EQ(client.fault_code(), netft::FaultCode::Timeout); + EXPECT_EQ(client.health().malformed_count, 1U); + EXPECT_GE(elapsed, 175ms); + EXPECT_LT(elapsed, 300ms); + client.stop(); +} + +TEST(ClientRecovery, SeriousStatusUsesDeliveryPolicyAndLatchesFailStop) { + for (const bool deliver_error : {false, true}) { + netft::test::FakeSensor sensor{100.0}; + sensor.pause(); + auto config = config_for(sensor); + config.recovery_policy = netft::RecoveryPolicy::FailStop; + config.deliver_samples_with_error_status = deliver_error; + std::atomic delivered{}; + netft::Client client{config}; + client.start([&](const netft::Sample &) { ++delivered; }); + ASSERT_TRUE(sensor.wait_for_command(netft::detail::Command::StartRealtime)); + + sensor.queue_record(1, 0x80020000U, 100); + sensor.resume(); + + ASSERT_TRUE(wait_until([&] { return client.faulted(); })); + EXPECT_EQ(client.fault_code(), netft::FaultCode::SeriousStatus); + EXPECT_EQ(delivered.load(), deliver_error ? 1U : 0U); + EXPECT_EQ(client.health().device_error_count, 1U); + client.stop(); + } +} + +TEST(ClientRecovery, FailStopMapsFtStallAndBackwardFaults) { + for (const auto test_case : { + std::pair{100U, netft::FaultCode::FtStall}, + std::pair{90U, netft::FaultCode::FtBackward}, + }) { + netft::test::FakeSensor sensor{200.0}; + sensor.pause(); + auto config = config_for(sensor); + config.recovery_policy = netft::RecoveryPolicy::FailStop; + netft::Client client{config}; + client.start([](const netft::Sample &) {}); + ASSERT_TRUE(sensor.wait_for_command(netft::detail::Command::StartRealtime)); + + sensor.queue_record(1, 0, 100); + sensor.queue_record(2, 0, test_case.first); + sensor.resume(); + + ASSERT_TRUE(wait_until([&] { return client.faulted(); })); + EXPECT_EQ(client.fault_code(), test_case.second); + EXPECT_EQ(client.health().reconnect_count, 0U); + client.stop(); + } +} + +TEST(ClientRecovery, ReconnectPolicyDropsFtDiscontinuitiesAndConfirmsRestart) { + netft::test::FakeSensor sensor{500.0}; + sensor.pause(); + auto config = config_for(sensor); + config.receive_timeout = 1s; + std::mutex delivered_mutex; + std::vector delivered; + netft::Client client{config}; + client.start([&](const netft::Sample &sample) { + std::lock_guard lock(delivered_mutex); + delivered.push_back(sample.ft_sequence); + if (sample.ft_sequence == 6) { + sensor.pause(); + } + }); + ASSERT_TRUE(sensor.wait_for_command(netft::detail::Command::StartRealtime)); + + sensor.queue_record(1, 0, 0x00100000U); + sensor.queue_record(2, 0, 0x00100000U); + sensor.queue_record(3, 0, 2); + sensor.queue_record(4, 0, 6); + sensor.resume(); + + ASSERT_TRUE(wait_until([&] { return client.health().ft_restart_count == 1; })); + client.stop(); + const auto health = client.health(); + std::lock_guard lock(delivered_mutex); + EXPECT_EQ(delivered, (std::vector{0x00100000U, 6U})); + EXPECT_EQ(health.ft_stall_count, 1U); + EXPECT_EQ(health.ft_backward_count, 1U); + EXPECT_EQ(health.ft_restart_count, 1U); + EXPECT_EQ(health.last_ft_progress, "restart"); + EXPECT_EQ(health.reconnect_count, 0U); +} + +TEST(ClientRecovery, ReconnectResetsRdtOrderingForNewLowSequenceStream) { + netft::test::FakeSensor sensor{500.0}; + sensor.pause(); + auto config = config_for(sensor); + config.receive_timeout = 80ms; + std::mutex delivered_mutex; + std::vector delivered; + netft::Client client{config}; + client.start([&](const netft::Sample &sample) { + std::lock_guard lock(delivered_mutex); + delivered.push_back(sample.rdt_sequence); + }); + ASSERT_TRUE(sensor.wait_for_command(netft::detail::Command::StartRealtime)); + + sensor.send_record_now(100, 0, 0x00100000U); + ASSERT_TRUE(wait_until([&] { return client.health().delivered_count == 1; })); + ASSERT_TRUE(sensor.wait_for_command(netft::detail::Command::StartRealtime, 2, 500ms)); + sensor.send_record_now(1, 0, 0x00100004U); + ASSERT_TRUE(wait_until([&] { return client.health().delivered_count == 2; })); + + client.stop(); + const auto health = client.health(); + const auto latest = client.latest_sample(); + std::lock_guard lock(delivered_mutex); + EXPECT_EQ(delivered, (std::vector{100U, 1U})); + ASSERT_TRUE(latest); + EXPECT_EQ(latest->rdt_sequence, 1U); + EXPECT_EQ(latest->configuration_revision, 1U); + EXPECT_EQ(health.received_count, 2U); + EXPECT_EQ(health.delivered_count, 2U); + EXPECT_EQ(health.lost_count, 0U); + EXPECT_EQ(health.duplicate_count, 0U); + EXPECT_EQ(health.out_of_order_count, 0U); + EXPECT_EQ(health.timeout_count, 1U); + EXPECT_EQ(health.reconnect_count, 1U); + ASSERT_TRUE(health.sensor_configuration); + EXPECT_EQ(health.sensor_configuration->revision, 1U); + EXPECT_EQ(sensor.http_request_count(), 2U); +} + +TEST(ClientRecovery, ReconnectPreservesFtHistoryButClearsUnconfirmedRestartCandidate) { + netft::test::FakeSensor sensor{500.0}; + sensor.pause(); + auto config = config_for(sensor); + config.receive_timeout = 80ms; + std::mutex delivered_mutex; + std::vector delivered; + netft::Client client{config}; + client.start([&](const netft::Sample &sample) { + std::lock_guard lock(delivered_mutex); + delivered.push_back(sample.ft_sequence); + }); + ASSERT_TRUE(sensor.wait_for_command(netft::detail::Command::StartRealtime)); + + sensor.send_record_now(100, 0, 0x00100000U); + ASSERT_TRUE(wait_until([&] { return client.health().delivered_count == 1; })); + sensor.send_record_now(101, 0, 2); + ASSERT_TRUE(wait_until([&] { return client.health().received_count == 2; })); + { + const auto health = client.health(); + EXPECT_EQ(health.delivered_count, 1U); + EXPECT_EQ(health.ft_backward_count, 1U); + EXPECT_EQ(health.ft_restart_count, 0U); + } + + ASSERT_TRUE(sensor.wait_for_command(netft::detail::Command::StartRealtime, 2, 500ms)); + sensor.send_record_now(1, 0, 6); + ASSERT_TRUE(wait_until([&] { return client.health().received_count == 3; })); + { + const auto health = client.health(); + EXPECT_EQ(health.delivered_count, 1U); + EXPECT_EQ(health.ft_backward_count, 2U); + EXPECT_EQ(health.ft_restart_count, 0U); + EXPECT_EQ(health.out_of_order_count, 0U); + } + + sensor.send_record_now(2, 0, 10); + ASSERT_TRUE(wait_until([&] { return client.health().ft_restart_count == 1; })); + client.stop(); + + const auto health = client.health(); + const auto latest = client.latest_sample(); + std::lock_guard lock(delivered_mutex); + EXPECT_EQ(delivered, (std::vector{0x00100000U, 10U})); + ASSERT_TRUE(latest); + EXPECT_EQ(latest->rdt_sequence, 2U); + EXPECT_EQ(latest->ft_sequence, 10U); + EXPECT_EQ(latest->configuration_revision, 1U); + EXPECT_EQ(health.received_count, 4U); + EXPECT_EQ(health.delivered_count, 2U); + EXPECT_EQ(health.lost_count, 0U); + EXPECT_EQ(health.duplicate_count, 0U); + EXPECT_EQ(health.out_of_order_count, 0U); + EXPECT_EQ(health.ft_stall_count, 0U); + EXPECT_EQ(health.ft_backward_count, 2U); + EXPECT_EQ(health.ft_restart_count, 1U); + EXPECT_EQ(health.last_ft_progress, "restart"); + EXPECT_EQ(health.timeout_count, 1U); + EXPECT_EQ(health.reconnect_count, 1U); + EXPECT_EQ(health.calibration_change_count, 0U); + ASSERT_TRUE(health.sensor_configuration); + EXPECT_EQ(health.sensor_configuration->revision, 1U); + EXPECT_EQ(sensor.http_request_count(), 2U); +} + +TEST(ClientRecovery, SeriousStatusWinsWhenFtAlsoDiscontinuous) { + netft::test::FakeSensor sensor{200.0}; + sensor.pause(); + auto config = config_for(sensor); + config.recovery_policy = netft::RecoveryPolicy::FailStop; + netft::Client client{config}; + client.start([](const netft::Sample &) {}); + ASSERT_TRUE(sensor.wait_for_command(netft::detail::Command::StartRealtime)); + + sensor.queue_record(1, 0, 100); + sensor.queue_record(2, 0x80020000U, 100); + sensor.resume(); + + ASSERT_TRUE(wait_until([&] { return client.faulted(); })); + EXPECT_EQ(client.fault_code(), netft::FaultCode::SeriousStatus); + EXPECT_EQ(client.health().ft_stall_count, 1U); + EXPECT_EQ(client.health().reconnect_count, 0U); + client.stop(); +} + +TEST(ClientRecovery, FailStopMapsTimeoutSocketAndConfigurationFailures) { + { + netft::test::FakeSensor sensor{200.0}; + sensor.pause(); + auto config = config_for(sensor); + config.recovery_policy = netft::RecoveryPolicy::FailStop; + config.receive_timeout = 50ms; + netft::Client client{config}; + client.start([](const netft::Sample &) {}); + ASSERT_TRUE(wait_until([&] { return client.faulted(); })); + EXPECT_EQ(client.fault_code(), netft::FaultCode::Timeout); + EXPECT_EQ(client.health().timeout_count, 1U); + client.stop(); + } + { + netft::Config config; + config.sensor_host = "not-a-loopback-host.invalid"; + config.calibration_override = + netft::Calibration{1.0, 1.0, netft::ForceUnit::Newton, netft::TorqueUnit::NewtonMeter}; + config.recovery_policy = netft::RecoveryPolicy::FailStop; + netft::Client client{config}; + client.start([](const netft::Sample &) {}); + ASSERT_TRUE(wait_until([&] { return client.faulted(); }, 2s)); + EXPECT_EQ(client.fault_code(), netft::FaultCode::Socket); + client.stop(); + } + { + netft::test::FakeSensor sensor; + sensor.set_xml_configuration(""); + auto config = config_for(sensor); + config.recovery_policy = netft::RecoveryPolicy::FailStop; + netft::Client client{config}; + client.start([](const netft::Sample &) {}); + ASSERT_TRUE(wait_until([&] { return client.faulted(); })); + EXPECT_EQ(client.fault_code(), netft::FaultCode::SensorConfiguration); + EXPECT_EQ(start_count(sensor), 0U); + client.stop(); + } +} + +TEST(ClientRecovery, ReconnectBackoffDoublesCapsAndResetsAfterValidRecord) { + netft::test::FakeSensor sensor{500.0}; + auto config = config_for(sensor); + config.receive_timeout = 40ms; + config.reconnect_initial_delay = 20ms; + config.reconnect_max_delay = 80ms; + std::atomic delivered{}; + netft::Client client{config}; + client.start([&](const netft::Sample &) { ++delivered; }); + ASSERT_TRUE(wait_until([&] { return delivered.load() > 0; })); + + sensor.pause(); + ASSERT_TRUE(wait_until([&] { return start_times(sensor).size() >= 5; }, 1s)); + const auto starts = start_times(sensor); + ASSERT_GE(starts.size(), 5U); + const auto interval = [&](const std::size_t index) { + return std::chrono::duration_cast(starts[index] - starts[index - 1]); + }; + EXPECT_GE(interval(1), 50ms); + EXPECT_LT(interval(1), 90ms); + EXPECT_GE(interval(2), 70ms); + EXPECT_LT(interval(2), 110ms); + EXPECT_GE(interval(3), 105ms); + EXPECT_LT(interval(3), 150ms); + EXPECT_GE(interval(4), 105ms); + EXPECT_LT(interval(4), 150ms); + + const auto recovery_baseline = delivered.load(); + sensor.resume(); + ASSERT_TRUE(wait_until([&] { return delivered.load() > recovery_baseline; }, 500ms)); + sensor.pause(); + const auto paused_at = std::chrono::steady_clock::now(); + const auto prior_starts = start_times(sensor).size(); + ASSERT_TRUE(wait_until([&] { return start_times(sensor).size() > prior_starts; }, 500ms)); + const auto reset_start = start_times(sensor).back(); + EXPECT_GE(reset_start - paused_at, 45ms); + EXPECT_LT(reset_start - paused_at, 95ms); + EXPECT_FALSE(client.faulted()); + client.stop(); +} + +TEST(ClientRecovery, StopInterruptsReconnectBackoff) { + netft::test::FakeSensor sensor{100.0}; + sensor.pause(); + auto config = config_for(sensor); + config.receive_timeout = 40ms; + config.reconnect_initial_delay = 2s; + config.reconnect_max_delay = 2s; + netft::Client client{config}; + client.start([](const netft::Sample &) {}); + ASSERT_TRUE( + wait_until([&] { return client.health().state == netft::ClientState::Backoff; }, 500ms)); + + const auto stop_started = std::chrono::steady_clock::now(); + client.stop(); + const auto stop_elapsed = std::chrono::steady_clock::now() - stop_started; + + EXPECT_LT(stop_elapsed, 500ms); + EXPECT_EQ(client.health().state, netft::ClientState::Stopped); +} + +TEST(ClientRecovery, ReconnectRediscoversChangedCalibrationAndIncrementsRevision) { + netft::test::FakeSensor sensor{200.0}; + auto config = config_for(sensor); + config.receive_timeout = 60ms; + netft::Client client{config}; + client.start([](const netft::Sample &) {}); + ASSERT_TRUE(client.wait_for_first_sample(500ms)); + ASSERT_EQ(client.latest_sample()->configuration_revision, 1U); + + sensor.set_xml_configuration(kChangedConfiguration); + sensor.pause(); + ASSERT_TRUE(sensor.wait_for_http_request(2, 500ms)); + ASSERT_TRUE(sensor.wait_for_command(netft::detail::Command::StartRealtime, 2, 500ms)); + sensor.resume(); + ASSERT_TRUE(wait_until([&] { + const auto sample = client.latest_sample(); + return sample && sample->configuration_revision == 2; + })); + + const auto sample = client.latest_sample(); + ASSERT_TRUE(sample); + EXPECT_EQ(sample->torque_unit, netft::TorqueUnit::NewtonMillimeter); + EXPECT_EQ(sample->configuration_revision, 2U); + const auto health = client.health(); + ASSERT_TRUE(health.sensor_configuration); + EXPECT_EQ(health.sensor_configuration->revision, 2U); + EXPECT_EQ(health.calibration_change_count, 1U); + client.stop(); +} + +TEST(ClientRecovery, InvalidReplacementConfigurationCannotStartNewRdtSession) { + for (const std::string replacement : { + "", + R"xml(Fake1000000 +1000Nmystery)xml", + }) { + netft::test::FakeSensor sensor{200.0}; + auto config = config_for(sensor); + config.receive_timeout = 60ms; + config.reconnect_initial_delay = 10ms; + config.reconnect_max_delay = 10ms; + netft::Client client{config}; + client.start([](const netft::Sample &) {}); + ASSERT_TRUE(client.wait_for_first_sample(500ms)); + ASSERT_EQ(start_count(sensor), 1U); + + sensor.set_xml_configuration(replacement); + sensor.pause(); + ASSERT_TRUE(sensor.wait_for_http_request(3, 500ms)); + EXPECT_EQ(start_count(sensor), 1U); + EXPECT_EQ(client.latest_sample()->configuration_revision, 1U); + EXPECT_EQ(client.health().calibration_change_count, 0U); + EXPECT_FALSE(client.faulted()); + client.stop(); + } +} + +} // namespace diff --git a/test/core/test_client_stream.cpp b/test/core/test_client_stream.cpp new file mode 100644 index 0000000..c268880 --- /dev/null +++ b/test/core/test_client_stream.cpp @@ -0,0 +1,240 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "detail/protocol.hpp" +#include "netft/client.hpp" +#include "support/fake_sensor.hpp" + +namespace { + +using namespace std::chrono_literals; + +netft::Config config_for(const netft::test::FakeSensor &sensor) { + netft::Config config; + config.sensor_host = sensor.host(); + config.rdt_port = sensor.rdt_port(); + config.http_port = sensor.http_port(); + config.receive_timeout = 200ms; + config.configuration_connect_timeout = 200ms; + config.configuration_timeout = 500ms; + return config; +} + +template +bool wait_until(Predicate predicate, const std::chrono::milliseconds timeout = 1s) { + const auto deadline = std::chrono::steady_clock::now() + timeout; + do { + if (predicate()) { + return true; + } + std::this_thread::yield(); + } while (std::chrono::steady_clock::now() < deadline); + return predicate(); +} + +class TwoPartyBarrier { +public: + void arrive_and_wait() { + std::unique_lock lock(mutex_); + const auto generation = generation_; + if (++arrivals_ == 2) { + arrivals_ = 0; + ++generation_; + cv_.notify_one(); + return; + } + cv_.wait(lock, [this, generation] { return generation_ != generation; }); + } + +private: + std::mutex mutex_; + std::condition_variable cv_; + unsigned arrivals_{}; + unsigned generation_{}; +}; + +TEST(ClientStream, ConstructionPerformsNoNetworkIo) { + netft::test::FakeSensor sensor; + + netft::Client client{config_for(sensor)}; + + EXPECT_EQ(sensor.http_request_count(), 0U); + EXPECT_TRUE(sensor.commands().empty()); + EXPECT_EQ(client.health().state, netft::ClientState::Stopped); + EXPECT_FALSE(client.faulted()); + EXPECT_EQ(client.fault_code(), netft::FaultCode::None); + EXPECT_FALSE(client.latest_sample()); + EXPECT_FALSE(client.wait_for_first_sample(1ms)); + EXPECT_THROW(client.bias(), netft::NotConnectedError); +} + +TEST(ClientStream, DiscoveryCompletesBeforeRealtimeStarts) { + netft::test::FakeSensor sensor; + sensor.pause(); + sensor.set_http_response_delay(100ms); + netft::Client client{config_for(sensor)}; + + client.start([](const netft::Sample &) {}); + + ASSERT_TRUE(sensor.wait_for_http_request()); + EXPECT_FALSE(sensor.wait_for_command(netft::detail::Command::StartRealtime, 1, 20ms)); + ASSERT_TRUE(sensor.wait_for_command(netft::detail::Command::StartRealtime, 1, 500ms)); + EXPECT_EQ(sensor.http_request_count(), 1U); + client.stop(); +} + +TEST(ClientStream, ConvertsNativeUnitsAndPublishesLatestSample) { + netft::test::FakeSensor sensor; + sensor.pause(); + sensor.queue_record(1, 0, 100, + {1'000'000, -2'000'000, 3'000'000, 1'000'000, -2'000'000, 3'000'000}); + netft::Client client{config_for(sensor)}; + std::mutex callback_mutex; + std::optional callback_sample; + client.start([&](const netft::Sample &sample) { + std::lock_guard lock(callback_mutex); + if (!callback_sample) { + callback_sample = sample; + sensor.pause(); + } + }); + ASSERT_TRUE(sensor.wait_for_command(netft::detail::Command::StartRealtime)); + sensor.resume(); + + ASSERT_TRUE(client.wait_for_first_sample(500ms)); + const auto sample = client.latest_sample(); + ASSERT_TRUE(sample); + EXPECT_EQ(sample->rdt_sequence, 1U); + EXPECT_EQ(sample->ft_sequence, 100U); + 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::NewtonMeter); + EXPECT_EQ(sample->configuration_revision, 1U); + { + std::lock_guard lock(callback_mutex); + ASSERT_TRUE(callback_sample); + EXPECT_EQ(callback_sample->rdt_sequence, sample->rdt_sequence); + EXPECT_EQ(callback_sample->force, sample->force); + EXPECT_EQ(callback_sample->torque, sample->torque); + } + + const auto health = client.health(); + ASSERT_TRUE(health.sensor_configuration); + EXPECT_EQ(health.sensor_configuration->source, netft::CalibrationSource::Sensor); + EXPECT_EQ(health.sensor_configuration->revision, 1U); + EXPECT_EQ(health.received_count, 1U); + EXPECT_EQ(health.delivered_count, 1U); + client.stop(); +} + +TEST(ClientStream, CompleteCalibrationOverrideSkipsHttpDiscovery) { + netft::test::FakeSensor sensor; + sensor.pause(); + auto config = config_for(sensor); + config.calibration_override = netft::Calibration{100.0, 10.0, netft::ForceUnit::PoundForce, + netft::TorqueUnit::PoundForceFoot}; + netft::Client client{config}; + + client.start([](const netft::Sample &) {}); + + ASSERT_TRUE(sensor.wait_for_command(netft::detail::Command::StartRealtime)); + EXPECT_EQ(sensor.http_request_count(), 0U); + const auto health = client.health(); + ASSERT_TRUE(health.sensor_configuration); + EXPECT_EQ(health.sensor_configuration->source, netft::CalibrationSource::Override); + EXPECT_EQ(health.sensor_configuration->calibration.force_unit, netft::ForceUnit::PoundForce); + client.stop(); +} + +TEST(ClientStream, StopIsIdempotentAndSendsOneEffectiveCommand) { + netft::test::FakeSensor sensor; + netft::Client client{config_for(sensor)}; + client.start([](const netft::Sample &) {}); + ASSERT_TRUE(sensor.wait_for_command(netft::detail::Command::StartRealtime)); + + client.stop(); + client.stop(); + + ASSERT_TRUE(sensor.wait_for_command(netft::detail::Command::StopStreaming)); + const auto commands = sensor.commands(); + EXPECT_EQ(std::count(commands.begin(), commands.end(), netft::detail::Command::StopStreaming), 1); + EXPECT_EQ(client.health().state, netft::ClientState::Stopped); +} + +TEST(ClientStream, StopWakesBlockedReceiveBeforeLongTimeout) { + netft::test::FakeSensor sensor; + sensor.pause(); + auto config = config_for(sensor); + config.receive_timeout = 10s; + netft::Client client{config}; + client.start([](const netft::Sample &) {}); + ASSERT_TRUE(sensor.wait_for_command(netft::detail::Command::StartRealtime)); + + std::this_thread::sleep_for(20ms); + const auto before_stop = std::chrono::steady_clock::now(); + client.stop(); + const auto elapsed = std::chrono::steady_clock::now() - before_stop; + + EXPECT_LT(elapsed, 500ms); + EXPECT_EQ(client.health().state, netft::ClientState::Stopped); +} + +TEST(ClientStream, ConcurrentBiasAndRecordTrackingRemainConsistent) { + netft::test::FakeSensor sensor; + sensor.pause(); + auto config = config_for(sensor); + config.receive_timeout = 2s; + netft::Client client{config}; + client.start([](const netft::Sample &) {}); + ASSERT_TRUE(sensor.wait_for_command(netft::detail::Command::StartRealtime)); + sensor.send_record_now(0, 0, 996); + ASSERT_TRUE(client.wait_for_first_sample(500ms)); + + constexpr std::uint32_t kIterations = 200; + TwoPartyBarrier barrier; + std::exception_ptr bias_error; + std::thread bias_thread([&] { + try { + for (std::uint32_t iteration = 0; iteration < kIterations; ++iteration) { + barrier.arrive_and_wait(); + client.bias(); + barrier.arrive_and_wait(); + } + } catch (...) { + bias_error = std::current_exception(); + } + }); + + bool all_records_received = true; + for (std::uint32_t iteration = 0; iteration < kIterations; ++iteration) { + barrier.arrive_and_wait(); + sensor.send_record_now(iteration + 1, 0, 1000 + iteration * 4); + barrier.arrive_and_wait(); + all_records_received = wait_until([&client, iteration] { + return client.health().received_count >= iteration + 2; + }) && + all_records_received; + } + bias_thread.join(); + + ASSERT_EQ(bias_error, nullptr); + EXPECT_TRUE(all_records_received); + const auto health = client.health(); + EXPECT_EQ(health.received_count, kIterations + 1); + EXPECT_EQ(health.lost_count, 0U); + EXPECT_EQ(health.duplicate_count, 0U); + EXPECT_EQ(health.out_of_order_count, 0U); + client.stop(); +} + +} // namespace diff --git a/test/core/test_discovery.cpp b/test/core/test_discovery.cpp new file mode 100644 index 0000000..8374b04 --- /dev/null +++ b/test/core/test_discovery.cpp @@ -0,0 +1,401 @@ +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "detail/xml_config.hpp" +#include "netft/discovery.hpp" +#include "support/fake_http_server.hpp" + +namespace { + +constexpr std::string_view kValidXml = R"xml( +Ethernet Axia1000000 +1000000NNm)xml"; + +std::string replace_value(std::string_view xml, std::string_view tag, std::string_view value) { + std::string result{xml}; + const std::string opening = "<" + std::string{tag} + ">"; + const std::string closing = ""; + const auto begin = result.find(opening) + opening.size(); + const auto end = result.find(closing, begin); + result.replace(begin, end - begin, value); + return result; +} + +std::string remove_element(std::string_view xml, std::string_view tag) { + std::string result{xml}; + const std::string opening = "<" + std::string{tag} + ">"; + const std::string closing = ""; + const auto begin = result.find(opening); + const auto end = result.find(closing, begin) + closing.size(); + result.erase(begin, end - begin); + return result; +} + +std::string duplicate_element(std::string_view xml, std::string_view tag) { + std::string result{xml}; + const std::string opening = "<" + std::string{tag} + ">"; + const std::string closing = ""; + const auto begin = result.find(opening); + const auto end = result.find(closing, begin) + closing.size(); + result.insert(end, result.substr(begin, end - begin)); + return result; +} + +std::string insert_after_root_open(std::string_view xml, std::string_view markup) { + std::string result{xml}; + const auto position = result.find("") + std::string_view{""}.size(); + result.insert(position, markup); + return result; +} + +netft::DiscoveryOptions options_for(const FakeHttpServer &server) { + netft::DiscoveryOptions options; + options.sensor_host = server.host(); + options.http_port = server.port(); + options.connect_timeout = std::chrono::milliseconds{200}; + options.total_timeout = std::chrono::milliseconds{500}; + return options; +} + +TEST(XmlConfiguration, ParsesTheRealSensorFixtureExactly) { + const auto result = netft::detail::parse_sensor_configuration(kValidXml); + + EXPECT_EQ(result.product_name, "Ethernet Axia"); + EXPECT_DOUBLE_EQ(result.calibration.counts_per_force_unit, 1'000'000.0); + EXPECT_DOUBLE_EQ(result.calibration.counts_per_torque_unit, 1'000'000.0); + EXPECT_EQ(result.calibration.force_unit, netft::ForceUnit::Newton); + EXPECT_EQ(result.calibration.torque_unit, netft::TorqueUnit::NewtonMeter); + EXPECT_EQ(result.source, netft::CalibrationSource::Sensor); + EXPECT_EQ(result.revision, 1U); +} + +class RequiredXmlField : public ::testing::TestWithParam {}; + +TEST_P(RequiredXmlField, RejectsMissingFields) { + EXPECT_THROW(netft::detail::parse_sensor_configuration(remove_element(kValidXml, GetParam())), + netft::DiscoveryError); +} + +TEST_P(RequiredXmlField, RejectsDuplicateFields) { + EXPECT_THROW(netft::detail::parse_sensor_configuration(duplicate_element(kValidXml, GetParam())), + netft::DiscoveryError); +} + +TEST_P(RequiredXmlField, RejectsEmptyFields) { + EXPECT_THROW( + netft::detail::parse_sensor_configuration(replace_value(kValidXml, GetParam(), " \t\r\n")), + netft::DiscoveryError); +} + +TEST_P(RequiredXmlField, RejectsFieldsLongerThan128Bytes) { + EXPECT_THROW(netft::detail::parse_sensor_configuration( + replace_value(kValidXml, GetParam(), std::string(129, '1'))), + netft::DiscoveryError); +} + +INSTANTIATE_TEST_SUITE_P(EveryField, RequiredXmlField, + ::testing::Values("prodname", "cfgcpf", "cfgcpt", "scfgfu", "scfgtu")); + +struct InvalidCountCase { + const char *tag; + const char *value; +}; + +class InvalidXmlCount : public ::testing::TestWithParam {}; + +TEST_P(InvalidXmlCount, RejectsInvalidCounts) { + const auto ¶meter = GetParam(); + EXPECT_THROW(netft::detail::parse_sensor_configuration( + replace_value(kValidXml, parameter.tag, parameter.value)), + netft::DiscoveryError); +} + +INSTANTIATE_TEST_SUITE_P( + EveryInvalidForm, InvalidXmlCount, + ::testing::Values(InvalidCountCase{"cfgcpf", "not-a-number"}, + InvalidCountCase{"cfgcpf", "10remaining"}, InvalidCountCase{"cfgcpf", "0"}, + InvalidCountCase{"cfgcpf", "-1"}, InvalidCountCase{"cfgcpf", "NaN"}, + InvalidCountCase{"cfgcpf", "inf"}, InvalidCountCase{"cfgcpt", "not-a-number"}, + InvalidCountCase{"cfgcpt", "10remaining"}, InvalidCountCase{"cfgcpt", "0"}, + InvalidCountCase{"cfgcpt", "-1"}, InvalidCountCase{"cfgcpt", "NaN"}, + InvalidCountCase{"cfgcpt", "infinity"})); + +TEST(XmlConfiguration, TrimsAsciiWhitespaceAroundEveryValue) { + auto xml = replace_value(kValidXml, "prodname", " \tEthernet Axia\r\n"); + xml = replace_value(xml, "cfgcpf", "\n 1000000 \t"); + xml = replace_value(xml, "cfgcpt", "\r1000000 "); + xml = replace_value(xml, "scfgfu", " N\n"); + xml = replace_value(xml, "scfgtu", "\tNm "); + + const auto result = netft::detail::parse_sensor_configuration(xml); + EXPECT_EQ(result.product_name, "Ethernet Axia"); + EXPECT_DOUBLE_EQ(result.calibration.counts_per_force_unit, 1'000'000.0); + EXPECT_DOUBLE_EQ(result.calibration.counts_per_torque_unit, 1'000'000.0); + EXPECT_EQ(result.calibration.force_unit, netft::ForceUnit::Newton); + EXPECT_EQ(result.calibration.torque_unit, netft::TorqueUnit::NewtonMeter); +} + +TEST(XmlConfiguration, AcceptsEveryApprovedForceUnitSpelling) { + constexpr std::array, 5> cases{{ + {"lbf", netft::ForceUnit::PoundForce}, + {"N", netft::ForceUnit::Newton}, + {"klbf", netft::ForceUnit::KiloPoundForce}, + {"kN", netft::ForceUnit::KiloNewton}, + {"kgf", netft::ForceUnit::KilogramForce}, + }}; + + for (const auto &[spelling, expected] : cases) { + SCOPED_TRACE(spelling); + const auto result = + netft::detail::parse_sensor_configuration(replace_value(kValidXml, "scfgfu", spelling)); + EXPECT_EQ(result.calibration.force_unit, expected); + } +} + +TEST(XmlConfiguration, AcceptsEveryApprovedAtiTorqueUnitSpelling) { + constexpr std::array, 8> cases{{ + {"Nm", netft::TorqueUnit::NewtonMeter}, + {"N-m", netft::TorqueUnit::NewtonMeter}, + {"Nmm", netft::TorqueUnit::NewtonMillimeter}, + {"N-mm", netft::TorqueUnit::NewtonMillimeter}, + {"lbf-in", netft::TorqueUnit::PoundForceInch}, + {"lbf-ft", netft::TorqueUnit::PoundForceFoot}, + {"kg-cm", netft::TorqueUnit::KilogramForceCentimeter}, + {"kN-m", netft::TorqueUnit::KiloNewtonMeter}, + }}; + + for (const auto &[spelling, expected] : cases) { + SCOPED_TRACE(spelling); + const auto result = + netft::detail::parse_sensor_configuration(replace_value(kValidXml, "scfgtu", spelling)); + EXPECT_EQ(result.calibration.torque_unit, expected); + } +} + +TEST(XmlConfiguration, RejectsUnknownUnits) { + EXPECT_THROW( + netft::detail::parse_sensor_configuration(replace_value(kValidXml, "scfgfu", "newtons")), + netft::DiscoveryError); + EXPECT_THROW(netft::detail::parse_sensor_configuration( + replace_value(kValidXml, "scfgtu", "newton-metres")), + netft::DiscoveryError); +} + +TEST(XmlConfiguration, IgnoresRequiredElementTextInsideComments) { + const auto xml = insert_after_root_open(kValidXml, ""); + + const auto result = netft::detail::parse_sensor_configuration(xml); + + EXPECT_EQ(result.product_name, "Ethernet Axia"); +} + +TEST(XmlConfiguration, RejectsAFieldThatAppearsOnlyInsideAComment) { + const auto xml = insert_after_root_open(remove_element(kValidXml, "prodname"), + ""); + + EXPECT_THROW(netft::detail::parse_sensor_configuration(xml), netft::DiscoveryError); +} + +TEST(XmlConfiguration, IgnoresRequiredElementTextInsideCdata) { + const auto xml = + insert_after_root_open(kValidXml, "Fake Sensor]]>"); + + const auto result = netft::detail::parse_sensor_configuration(xml); + + EXPECT_EQ(result.product_name, "Ethernet Axia"); +} + +TEST(XmlConfiguration, RejectsAFieldThatAppearsOnlyInsideCdata) { + const auto xml = insert_after_root_open(remove_element(kValidXml, "prodname"), + "Fake Sensor]]>"); + + EXPECT_THROW(netft::detail::parse_sensor_configuration(xml), netft::DiscoveryError); +} + +TEST(XmlConfiguration, RejectsUnterminatedCommentsAndCdata) { + EXPECT_THROW( + netft::detail::parse_sensor_configuration(std::string{kValidXml} + "", markup + 4); + if (end == std::string_view::npos) { + throw DiscoveryError("sensor configuration contains an unterminated comment"); + } + position = end + 3; + continue; + } + if (xml.substr(markup, 9) == "", markup + 9); + if (end == std::string_view::npos) { + throw DiscoveryError("sensor configuration contains unterminated CDATA"); + } + append_required_text(elements, fields, xml.substr(markup + 9, end - markup - 9)); + position = end + 3; + continue; + } + if (xml.substr(markup, 2) == "", markup + 2); + if (end == std::string_view::npos) { + throw DiscoveryError("sensor configuration contains unterminated markup"); + } + position = end + 2; + continue; + } + if (xml.substr(markup, 2) == "= 0) { + throw DiscoveryError("sensor configuration fields must contain text only"); + } + if (required_index >= 0) { + const auto index = static_cast(required_index); + if (seen[index]) { + throw DiscoveryError("sensor configuration field '" + std::string{kRequiredTags[index]} + + "' must appear exactly once"); + } + seen[index] = true; + } + if (!self_closing) { + elements.push_back(OpenElement{name, required_index}); + } + position = end + 1; + } + + if (!elements.empty()) { + throw DiscoveryError("sensor configuration contains malformed element nesting"); + } + for (std::size_t index = 0; index < kRequiredTags.size(); ++index) { + if (!seen[index]) { + throw DiscoveryError("sensor configuration field '" + std::string{kRequiredTags[index]} + + "' must appear exactly once"); + } + const auto value = trim_ascii_whitespace(fields[index]); + if (value.empty()) { + throw DiscoveryError("sensor configuration field '" + std::string{kRequiredTags[index]} + + "' is empty"); + } + if (value.size() > kMaximumFieldLength) { + throw DiscoveryError("sensor configuration field '" + std::string{kRequiredTags[index]} + + "' is too long"); + } + fields[index] = std::string{value}; + } + return fields; +} + +bool is_ascii_digit(char character) noexcept { + return character >= '0' && character <= '9'; +} + +bool is_general_decimal(std::string_view value) noexcept { + std::size_t position{}; + if (position < value.size() && value[position] == '-') { + ++position; + } + + bool has_significand_digit = false; + while (position < value.size() && is_ascii_digit(value[position])) { + has_significand_digit = true; + ++position; + } + if (position < value.size() && value[position] == '.') { + ++position; + while (position < value.size() && is_ascii_digit(value[position])) { + has_significand_digit = true; + ++position; + } + } + if (!has_significand_digit) { + return false; + } + + if (position < value.size() && (value[position] == 'e' || value[position] == 'E')) { + ++position; + if (position < value.size() && (value[position] == '+' || value[position] == '-')) { + ++position; + } + const auto exponent_begin = position; + while (position < value.size() && is_ascii_digit(value[position])) { + ++position; + } + if (position == exponent_begin) { + return false; + } + } + return position == value.size(); +} + +double parse_positive_count(std::string_view value, std::string_view tag) { + double result{}; + std::istringstream input{std::string{value}}; + input.imbue(std::locale::classic()); + input >> std::noskipws >> result; + if (!is_general_decimal(value) || input.fail() || + input.rdbuf()->sgetc() != std::char_traits::eof() || !std::isfinite(result) || + result <= 0.0) { + throw DiscoveryError("sensor configuration field '" + std::string{tag} + + "' must be a finite positive number"); + } + return result; +} + +std::string_view normalize_torque_spelling(std::string_view value) noexcept { + if (value == "Nm") { + return "N-m"; + } + if (value == "Nmm") { + return "N-mm"; + } + if (value == "kg-cm") { + return "kgf-cm"; + } + return value; +} + +ForceUnit parse_force_unit(std::string_view value) { + const auto unit = force_unit_from_string(value); + if (!unit.has_value() || *unit == ForceUnit::Unknown) { + throw DiscoveryError("sensor configuration contains an unknown force unit"); + } + return *unit; +} + +TorqueUnit parse_torque_unit(std::string_view value) { + const auto unit = torque_unit_from_string(normalize_torque_spelling(value)); + if (!unit.has_value() || *unit == TorqueUnit::Unknown) { + throw DiscoveryError("sensor configuration contains an unknown torque unit"); + } + return *unit; +} + +} // namespace + +SensorConfiguration parse_sensor_configuration(std::string_view xml) { + const auto fields = extract_required_fields(xml); + SensorConfiguration configuration; + configuration.product_name = fields[0]; + configuration.calibration.counts_per_force_unit = parse_positive_count(fields[1], "cfgcpf"); + configuration.calibration.counts_per_torque_unit = parse_positive_count(fields[2], "cfgcpt"); + configuration.calibration.force_unit = parse_force_unit(fields[3]); + configuration.calibration.torque_unit = parse_torque_unit(fields[4]); + configuration.source = CalibrationSource::Sensor; + configuration.revision = 1; + return configuration; +} + +} // namespace netft::detail diff --git a/test/core/CMakeLists.txt b/test/core/CMakeLists.txt index 7bda573..771a3e1 100644 --- a/test/core/CMakeLists.txt +++ b/test/core/CMakeLists.txt @@ -35,7 +35,12 @@ add_netft_snapshot_test(netft_snapshot_fault_latch add_netft_snapshot_test(netft_snapshot_discovery test_discovery.cpp support/fake_http_server.cpp - ${NETFT_SNAPSHOT_SOURCE_DIR}/src/detail/xml_config.cpp) + ${NETFT_XML_CONFIG_SOURCE}) + +add_netft_snapshot_test(netft_compat_discovery + test_discovery.cpp + support/fake_http_server.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../../src/compat/xml_config.cpp) add_netft_snapshot_test(netft_snapshot_client_stream test_client_stream.cpp @@ -53,7 +58,7 @@ add_library(netft_snapshot_client_impl_test STATIC ${NETFT_SNAPSHOT_SOURCE_DIR}/src/detail/posix_transport.cpp ${NETFT_SNAPSHOT_SOURCE_DIR}/src/detail/protocol.cpp ${NETFT_SNAPSHOT_SOURCE_DIR}/src/detail/sequence.cpp - ${NETFT_SNAPSHOT_SOURCE_DIR}/src/detail/xml_config.cpp) + ${NETFT_XML_CONFIG_SOURCE}) target_include_directories(netft_snapshot_client_impl_test PRIVATE ${NETFT_SNAPSHOT_SOURCE_DIR}/src) target_link_libraries(netft_snapshot_client_impl_test PUBLIC netft_core) diff --git a/test/core/test_discovery.cpp b/test/core/test_discovery.cpp index 8374b04..ce40f58 100644 --- a/test/core/test_discovery.cpp +++ b/test/core/test_discovery.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -126,13 +127,53 @@ TEST_P(InvalidXmlCount, RejectsInvalidCounts) { INSTANTIATE_TEST_SUITE_P( EveryInvalidForm, InvalidXmlCount, ::testing::Values(InvalidCountCase{"cfgcpf", "not-a-number"}, - InvalidCountCase{"cfgcpf", "10remaining"}, InvalidCountCase{"cfgcpf", "0"}, - InvalidCountCase{"cfgcpf", "-1"}, InvalidCountCase{"cfgcpf", "NaN"}, - InvalidCountCase{"cfgcpf", "inf"}, InvalidCountCase{"cfgcpt", "not-a-number"}, + InvalidCountCase{"cfgcpf", "10remaining"}, InvalidCountCase{"cfgcpf", "+1"}, + InvalidCountCase{"cfgcpf", "0x1p2"}, InvalidCountCase{"cfgcpf", "1e"}, + InvalidCountCase{"cfgcpf", "0"}, InvalidCountCase{"cfgcpf", "-1"}, + InvalidCountCase{"cfgcpf", "NaN"}, InvalidCountCase{"cfgcpf", "inf"}, + InvalidCountCase{"cfgcpt", "not-a-number"}, InvalidCountCase{"cfgcpt", "10remaining"}, InvalidCountCase{"cfgcpt", "0"}, InvalidCountCase{"cfgcpt", "-1"}, InvalidCountCase{"cfgcpt", "NaN"}, InvalidCountCase{"cfgcpt", "infinity"})); +TEST(XmlConfiguration, AcceptsGeneralDecimalCountForms) { + constexpr std::array, 4> cases{{ + {".5", 0.5}, + {"1.", 1.0}, + {"1e+2", 100.0}, + {"2.5E-1", 0.25}, + }}; + + for (const auto &[spelling, expected] : cases) { + SCOPED_TRACE(spelling); + const auto result = + netft::detail::parse_sensor_configuration(replace_value(kValidXml, "cfgcpf", spelling)); + EXPECT_DOUBLE_EQ(result.calibration.counts_per_force_unit, expected); + } +} + +class CommaDecimalPoint final : public std::numpunct { + protected: + char do_decimal_point() const override { return ','; } +}; + +TEST(XmlConfiguration, ParsesCountsIndependentlyOfTheGlobalLocale) { + const auto original_locale = std::locale(); + std::locale::global(std::locale{std::locale::classic(), new CommaDecimalPoint}); + try { + const auto result = + netft::detail::parse_sensor_configuration(replace_value(kValidXml, "cfgcpf", "1.25")); + EXPECT_DOUBLE_EQ(result.calibration.counts_per_force_unit, 1.25); + EXPECT_THROW( + netft::detail::parse_sensor_configuration(replace_value(kValidXml, "cfgcpf", "1,25")), + netft::DiscoveryError); + } catch (...) { + std::locale::global(original_locale); + throw; + } + std::locale::global(original_locale); +} + TEST(XmlConfiguration, TrimsAsciiWhitespaceAroundEveryValue) { auto xml = replace_value(kValidXml, "prodname", " \tEthernet Axia\r\n"); xml = replace_value(xml, "cfgcpf", "\n 1000000 \t"); From 629e5547240392e94207aed886fb67f4fdbe1309 Mon Sep 17 00:00:00 2001 From: Xudong Han <42986190+han-xudong@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:25:05 +0800 Subject: [PATCH 19/25] fix: hide ros2 control implementation symbols --- CMakeLists.txt | 3 ++ src/netft_hardware_interface.cpp | 37 +++++++++++++------ test/cpp/support/ros2_control_test_access.cpp | 21 +++++++---- 3 files changed, 43 insertions(+), 18 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index bd0f94d..7191e8f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -156,6 +156,9 @@ elseif(NETFT_ROS_VERSION STREQUAL "2") endif() function(netft_configure_ros2_control_target target) + set_target_properties(${target} PROPERTIES + CXX_VISIBILITY_PRESET hidden + VISIBILITY_INLINES_HIDDEN YES) target_compile_definitions(${target} PRIVATE ${NETFT_ROS2_CONTROL_API_DEFINITION}) target_include_directories(${target} PRIVATE diff --git a/src/netft_hardware_interface.cpp b/src/netft_hardware_interface.cpp index e75061a..a1b09b7 100644 --- a/src/netft_hardware_interface.cpp +++ b/src/netft_hardware_interface.cpp @@ -32,6 +32,14 @@ #ifdef NETFT_ROS2_CONTROL_TESTING #include "ros/ros2_control_test_access.hpp" + +#if defined(_WIN32) +#define NETFT_ROS2_CONTROL_TEST_EXPORT __declspec(dllexport) +#elif defined(__GNUC__) || defined(__clang__) +#define NETFT_ROS2_CONTROL_TEST_EXPORT __attribute__((visibility("default"))) +#else +#define NETFT_ROS2_CONTROL_TEST_EXPORT +#endif #endif namespace netft_driver { @@ -787,66 +795,73 @@ int auxiliary_thread_count() noexcept } // namespace netft_driver #ifdef NETFT_ROS2_CONTROL_TESTING -extern "C" void netft_ros2_control_test_force_initial_sample_once() noexcept +extern "C" NETFT_ROS2_CONTROL_TEST_EXPORT void +netft_ros2_control_test_force_initial_sample_once() noexcept { netft_driver::ros2_control_test_access::detail::force_initial_sample_once(); } -extern "C" void netft_ros2_control_test_fail_state_write_once_at( +extern "C" NETFT_ROS2_CONTROL_TEST_EXPORT void netft_ros2_control_test_fail_state_write_once_at( const std::size_t axis) noexcept { netft_driver::ros2_control_test_access::detail::fail_state_write_once_at(axis); } -extern "C" void netft_ros2_control_test_latch_active_fault( +extern "C" NETFT_ROS2_CONTROL_TEST_EXPORT void netft_ros2_control_test_latch_active_fault( const netft_driver::ros2_control_test_access::FaultCode fault) noexcept { netft_driver::ros2_control_test_access::detail::latch_active_fault(fault); } -extern "C" bool netft_ros2_control_test_read_active_instance() noexcept +extern "C" NETFT_ROS2_CONTROL_TEST_EXPORT bool +netft_ros2_control_test_read_active_instance() noexcept { return netft_driver::ros2_control_test_access::detail::read_active_instance(); } -extern "C" netft_driver::ros2_control_test_access::FaultCode +extern "C" NETFT_ROS2_CONTROL_TEST_EXPORT netft_driver::ros2_control_test_access::FaultCode netft_ros2_control_test_active_fault_code() noexcept { return netft_driver::ros2_control_test_access::detail::active_fault_code(); } -extern "C" netft_driver::ros2_control_test_access::FaultCode +extern "C" NETFT_ROS2_CONTROL_TEST_EXPORT netft_driver::ros2_control_test_access::FaultCode netft_ros2_control_test_active_client_fault_code() noexcept { return netft_driver::ros2_control_test_access::detail::active_client_fault_code(); } -extern "C" netft_driver::ros2_control_test_access::FaultCode +extern "C" NETFT_ROS2_CONTROL_TEST_EXPORT netft_driver::ros2_control_test_access::FaultCode netft_ros2_control_test_active_latched_fault_code() noexcept { return netft_driver::ros2_control_test_access::detail::active_latched_fault_code(); } -extern "C" netft_driver::ros2_control_test_access::ActivityCounters +extern "C" NETFT_ROS2_CONTROL_TEST_EXPORT netft_driver::ros2_control_test_access::ActivityCounters netft_ros2_control_test_active_activity_counters() noexcept { return netft_driver::ros2_control_test_access::detail::active_activity_counters(); } -extern "C" bool netft_ros2_control_test_interface_write_fault_latched() noexcept +extern "C" NETFT_ROS2_CONTROL_TEST_EXPORT bool +netft_ros2_control_test_interface_write_fault_latched() noexcept { return netft_driver::ros2_control_test_access::detail::interface_write_fault_latched(); } -extern "C" void netft_ros2_control_test_throw_executor_cancel_once() noexcept +extern "C" NETFT_ROS2_CONTROL_TEST_EXPORT void +netft_ros2_control_test_throw_executor_cancel_once() noexcept { netft_driver::ros2_control_test_access::detail::throw_executor_cancel_once(); } -extern "C" int netft_ros2_control_test_auxiliary_thread_count() noexcept +extern "C" NETFT_ROS2_CONTROL_TEST_EXPORT int +netft_ros2_control_test_auxiliary_thread_count() noexcept { return netft_driver::ros2_control_test_access::detail::auxiliary_thread_count(); } + +#undef NETFT_ROS2_CONTROL_TEST_EXPORT #endif PLUGINLIB_EXPORT_CLASS( diff --git a/test/cpp/support/ros2_control_test_access.cpp b/test/cpp/support/ros2_control_test_access.cpp index f5837f0..53493b2 100644 --- a/test/cpp/support/ros2_control_test_access.cpp +++ b/test/cpp/support/ros2_control_test_access.cpp @@ -21,16 +21,23 @@ int find_testing_library(dl_phdr_info * info, std::size_t, void * data) noexcept return 0; } +void * testing_library_handle() noexcept +{ + static void * const handle = [] { + const char * library_path = nullptr; + (void)::dl_iterate_phdr(find_testing_library, &library_path); + if (library_path == nullptr) std::abort(); + void * const opened = ::dlopen(library_path, RTLD_LAZY | RTLD_NOLOAD); + if (opened == nullptr) std::abort(); + return opened; + }(); + return handle; +} + template Function resolve(const char * name) noexcept { - const char * library_path = nullptr; - (void)::dl_iterate_phdr(find_testing_library, &library_path); - if (library_path == nullptr) std::abort(); - const auto handle = ::dlopen(library_path, RTLD_LAZY | RTLD_NOLOAD); - if (handle == nullptr) std::abort(); - const auto symbol = ::dlsym(handle, name); - (void)::dlclose(handle); + const auto symbol = ::dlsym(testing_library_handle(), name); if (symbol == nullptr) std::abort(); return reinterpret_cast(symbol); } From a1a86228d336899317e18b8951bfea0cb8cfbf9f Mon Sep 17 00:00:00 2001 From: Xudong Han <42986190+han-xudong@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:29:41 +0800 Subject: [PATCH 20/25] test: wait for asynchronous stop command --- test/cpp/test_check.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/test/cpp/test_check.cpp b/test/cpp/test_check.cpp index b212d5e..ac8d42f 100644 --- a/test/cpp/test_check.cpp +++ b/test/cpp/test_check.cpp @@ -91,6 +91,7 @@ std::string endpoint_arguments(const FakeSensor &sensor) { } void expect_safe_shutdown(const FakeSensor &sensor, bool exact = false) { + ASSERT_TRUE(sensor.wait_for_command(Command::StopStreaming)); const auto commands = sensor.commands(); ASSERT_FALSE(commands.empty()); EXPECT_EQ(commands.back(), Command::StopStreaming); From 3a60dc664876ced78de0172a4fb059e5601fe3de Mon Sep 17 00:00:00 2001 From: Xudong Han <42986190+han-xudong@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:41:46 +0800 Subject: [PATCH 21/25] docs: clarify compatibility and license boundaries --- CHANGELOG.rst | 2 ++ CONTRIBUTING.md | 13 ++++++++----- README.md | 4 ++-- docs/architecture.md | 2 +- test/cpp/support/ros2_control_test_access.cpp | 2 ++ test/integration/audit_ros2_control_symbols.sh | 4 ---- 6 files changed, 15 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c487ea1..98fea8e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -8,6 +8,8 @@ Changelog for package netft_driver * Read sensor-reported calibration by default and convert its native force and torque units to N and Nm. * Add the explicit ``use_sensor_calibration=false`` manual override, with counts/N and counts/Nm inputs. * Add HTTP configuration port and timeout parameters for sensor calibration discovery. +* Support Noetic's GCC 9 standard library with an Apache-2.0 XML parser compatibility translation unit. +* Keep the ros2_control plugin ABI private by hiding core and implementation symbols. 0.2.2 (2026-07-22) ------------------ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2f6de85..519ca55 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -72,8 +72,9 @@ so the scope can be agreed upon first. 2. Copy only the library paths from an immutable upstream tag. 3. Preserve the `netft` namespace and the Apache-2.0 license. 4. Update `src/core/UPSTREAM` with the copied tag and commit. -5. Rerun the byte comparison and every snapshot and ROS test. -6. Keep ROS-specific changes out of `src/core/`; port reusable fixes upstream before copying them back. +5. Keep the Apache-2.0 `src/compat/xml_config.cpp` fallback synchronized with the copied upstream XML parser. +6. Rerun the byte comparison and every snapshot and ROS test. +7. Keep ROS-specific changes out of `src/core/`; port reusable fixes upstream before copying them back. Do not add an automated downloader or submodule for the core snapshot. @@ -92,6 +93,8 @@ issued. Do not publish private network details or sensor identifiers. ## License ROS integration contributions are licensed under the repository's -[MIT License](LICENSE). The copied `src/core/` snapshot remains under its -upstream [Apache-2.0 License](src/core/LICENSE); propose reusable core fixes -upstream first, then copy them from a released immutable upstream revision. +[MIT License](LICENSE). The copied `src/core/` snapshot and the derived +`src/compat/xml_config.cpp` fallback remain under the upstream +[Apache-2.0 License](src/core/LICENSE). Propose reusable core fixes upstream +first, then copy them from a released immutable upstream revision and update +the fallback when the XML parser changes. diff --git a/README.md b/README.md index b5d88de..eb7b501 100644 --- a/README.md +++ b/README.md @@ -344,7 +344,7 @@ boundaries. ## Core provenance and licenses -`src/core/` is a private, immutable snapshot of the `netft-cpp` v0.1.2 library. It is included in this source package and does not require an external `netft-cpp` build dependency. The snapshot retains the upstream `netft` namespace and is licensed under Apache-2.0; the ROS integration around it remains MIT-licensed. +`src/core/` is a private, immutable snapshot of the `netft-cpp` v0.1.2 library. It is included in this source package and does not require an external `netft-cpp` build dependency. The snapshot retains the upstream `netft` namespace and is licensed under Apache-2.0. On standard libraries without floating-point `std::from_chars`, the build selects the Apache-2.0 `src/compat/xml_config.cpp` compatibility translation unit. All other ROS integration code remains MIT-licensed. ## Contributing @@ -354,4 +354,4 @@ Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening an ## License -The ROS integration is MIT-licensed; see [LICENSE](LICENSE). `src/core/` is Apache-2.0; see [src/core/LICENSE](src/core/LICENSE). +The ROS integration is MIT-licensed; see [LICENSE](LICENSE). The `src/core/` snapshot and its derived `src/compat/xml_config.cpp` compatibility translation unit are Apache-2.0; see [src/core/LICENSE](src/core/LICENSE). diff --git a/docs/architecture.md b/docs/architecture.md index bdcebe4..63a23d1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,7 +6,7 @@ operator procedures, and safety guidance. ## Package boundaries -The active transport implementation is a private snapshot of netft-cpp under `src/core`. It is built as `netft_core`, uses the `netft` namespace, contains no ROS headers, and is not installed as a public SDK. `netft::Client` is the single client used by the command-line tool, standalone nodes, and ros2_control plugin. +The active transport implementation is a private snapshot of netft-cpp under `src/core`. It is built as `netft_core`, uses the `netft` namespace, contains no ROS headers, and is not installed as a public SDK. When the C++ standard library lacks floating-point `std::from_chars`, CMake substitutes the Apache-2.0 `src/compat/xml_config.cpp` translation unit for the snapshot XML parser. `netft::Client` is the single client used by the command-line tool, standalone nodes, and ros2_control plugin. ```text src/core sources diff --git a/test/cpp/support/ros2_control_test_access.cpp b/test/cpp/support/ros2_control_test_access.cpp index 53493b2..1c1142b 100644 --- a/test/cpp/support/ros2_control_test_access.cpp +++ b/test/cpp/support/ros2_control_test_access.cpp @@ -23,6 +23,8 @@ int find_testing_library(dl_phdr_info * info, std::size_t, void * data) noexcept void * testing_library_handle() noexcept { + // Keep the private test DSO loaded while cached function pointers are in use. + // Production unload behavior is exercised through pluginlib, without these hooks. static void * const handle = [] { const char * library_path = nullptr; (void)::dl_iterate_phdr(find_testing_library, &library_path); diff --git a/test/integration/audit_ros2_control_symbols.sh b/test/integration/audit_ros2_control_symbols.sh index 184f451..4f8c30a 100755 --- a/test/integration/audit_ros2_control_symbols.sh +++ b/test/integration/audit_ros2_control_symbols.sh @@ -12,10 +12,6 @@ command -v "$nm_tool" >/dev/null for plugin in "$@"; do test -f "$plugin" symbols="$("$nm_tool" -D --defined-only --demangle "$plugin")" - if ! grep -q 'netft_driver::NetFTHardwareInterface' <<<"$symbols"; then - echo "production ros2_control plugin export is missing: $plugin" >&2 - exit 1 - fi private_symbols="$( sed -E 's/^[[:xdigit:]]+[[:space:]]+[[:alpha:]][[:space:]]+//' <<<"$symbols" | grep -E \ From f7eeb31a39804850038680393599c1b389a6fd7a Mon Sep 17 00:00:00 2001 From: Xudong Han <42986190+han-xudong@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:47:58 +0800 Subject: [PATCH 22/25] test: support Noetic Python 3.8 --- test/test_launch_validation.py | 14 +++++++++++--- test/test_workflow_validation.py | 2 +- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/test/test_launch_validation.py b/test/test_launch_validation.py index 270ba22..ad514ec 100644 --- a/test/test_launch_validation.py +++ b/test/test_launch_validation.py @@ -105,6 +105,15 @@ def native_string_value_names(tree): return {string_value(item) for item in generator.iter.elts} +def subscript_string_value(node): + value = node.slice + if isinstance(value, ast.Index): + value = value.value + if isinstance(value, ast.Constant) and isinstance(value.value, str): + return value.value + return None + + def test_ros1_launch_exposes_typed_automatic_sensor_calibration_defaults(): root = ElementTree.parse(ROOT / "launch/netft.launch").getroot() arguments = {argument.attrib["name"]: argument.attrib["default"] for argument in root.findall("arg")} @@ -151,12 +160,11 @@ def test_ros2_control_launch_passes_native_strings_for_every_hardware_argument() ROS2_CONTROL_HARDWARE_ARGUMENT_DEFAULTS ) values = { - node.slice.value + subscript_string_value(node) for node in ast.walk(tree) if isinstance(node, ast.Subscript) and isinstance(node.value, ast.Name) and node.value.id == "values" - and isinstance(node.slice, ast.Constant) - and isinstance(node.slice.value, str) + and subscript_string_value(node) is not None } assert values == set(ROS2_CONTROL_HARDWARE_ARGUMENT_DEFAULTS) diff --git a/test/test_workflow_validation.py b/test/test_workflow_validation.py index 12153ff..33ff705 100644 --- a/test/test_workflow_validation.py +++ b/test/test_workflow_validation.py @@ -13,7 +13,7 @@ def workflow_run_blocks(path): if not stripped.startswith("run:"): continue indent = len(line) - len(stripped) - scalar = stripped.removeprefix("run:").strip() + scalar = stripped[len("run:") :].strip() if scalar not in {"|", ">-"}: blocks.append(scalar) continue From ee993529619ec5918af41cda1d9295d6524e737b Mon Sep 17 00:00:00 2001 From: Xudong Han <42986190+han-xudong@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:56:18 +0800 Subject: [PATCH 23/25] test: keep healthy sensor sample observable --- test/cpp/test_hardware_interface.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/cpp/test_hardware_interface.cpp b/test/cpp/test_hardware_interface.cpp index 1cc6fe3..0f3ad8d 100644 --- a/test/cpp/test_hardware_interface.cpp +++ b/test/cpp/test_hardware_interface.cpp @@ -982,7 +982,11 @@ TEST(NetFTHardwareInterface, TwoInstancesHaveIsolatedServicesDiagnosticsAndFault ASSERT_DOUBLE_EQ(axis_value(right_axis), 1.0); const auto right_commands_before_fault = right.commands(); left.pause(); - right.queue_record(900, 0, 9000, {900, -800, 700, 60, -50, 40}); + for (std::uint32_t offset = 0; offset < 250; ++offset) { + right.queue_record( + 900 + offset, 0, 9000 + 4 * offset, + {900, -800, 700, 60, -50, 40}); + } hardware_interface::HardwareReadWriteStatus read_status; ASSERT_TRUE(eventually([&] { read_status = manager->read( From be78bab6da1bc4088e39fd9f042eea0ba5acdf22 Mon Sep 17 00:00:00 2001 From: Xudong Han <42986190+han-xudong@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:56:18 +0800 Subject: [PATCH 24/25] license: adopt Apache-2.0 project-wide --- CHANGELOG.rst | 1 + CONTRIBUTING.md | 11 +-- LICENSE | 222 ++++++++++++++++++++++++++++++++++++++---- README.md | 6 +- package.xml | 1 - test/test_manifest.py | 3 + 6 files changed, 213 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 98fea8e..2b356ef 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,6 +10,7 @@ Changelog for package netft_driver * Add HTTP configuration port and timeout parameters for sensor calibration discovery. * Support Noetic's GCC 9 standard library with an Apache-2.0 XML parser compatibility translation unit. * Keep the ros2_control plugin ABI private by hiding core and implementation symbols. +* License the complete repository under Apache-2.0, matching the upstream core. 0.2.2 (2026-07-22) ------------------ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 519ca55..1ffc4ab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -92,9 +92,8 @@ issued. Do not publish private network details or sensor identifiers. ## License -ROS integration contributions are licensed under the repository's -[MIT License](LICENSE). The copied `src/core/` snapshot and the derived -`src/compat/xml_config.cpp` fallback remain under the upstream -[Apache-2.0 License](src/core/LICENSE). Propose reusable core fixes upstream -first, then copy them from a released immutable upstream revision and update -the fallback when the XML parser changes. +Contributions are licensed under the repository's +[Apache-2.0 License](LICENSE). The copied `src/core/` snapshot retains the +upstream license copy at [src/core/LICENSE](src/core/LICENSE). Propose reusable +core fixes upstream first, then copy them from a released immutable upstream +revision and update the fallback when the XML parser changes. diff --git a/LICENSE b/LICENSE index c832686..261eeb9 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,201 @@ -MIT License - -Copyright (c) 2026 ros-netft contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index eb7b501..84a53a7 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![CI](https://github.com/netft/ros-netft/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/netft/ros-netft/actions/workflows/ci.yml) [![Codecov](https://codecov.io/gh/netft/ros-netft/graph/badge.svg?branch=main)](https://app.codecov.io/gh/netft/ros-netft) [![ROS](https://img.shields.io/badge/ROS-1%20%7C%202-22314E.svg?logo=ros&logoColor=white)](https://www.ros.org/) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![License: Apache-2.0](https://img.shields.io/badge/License-Apache--2.0-blue.svg)](LICENSE) `netft_driver` acquires force and torque data from ATI Ethernet Net F/T and Ethernet Axia sensors over the UDP Raw Data Transfer (RDT) protocol. A native @@ -344,7 +344,7 @@ boundaries. ## Core provenance and licenses -`src/core/` is a private, immutable snapshot of the `netft-cpp` v0.1.2 library. It is included in this source package and does not require an external `netft-cpp` build dependency. The snapshot retains the upstream `netft` namespace and is licensed under Apache-2.0. On standard libraries without floating-point `std::from_chars`, the build selects the Apache-2.0 `src/compat/xml_config.cpp` compatibility translation unit. All other ROS integration code remains MIT-licensed. +`src/core/` is a private, immutable snapshot of the `netft-cpp` v0.1.2 library. It is included in this source package and does not require an external `netft-cpp` build dependency. The snapshot retains the upstream `netft` namespace. On standard libraries without floating-point `std::from_chars`, the build selects the `src/compat/xml_config.cpp` compatibility translation unit. ## Contributing @@ -354,4 +354,4 @@ Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening an ## License -The ROS integration is MIT-licensed; see [LICENSE](LICENSE). The `src/core/` snapshot and its derived `src/compat/xml_config.cpp` compatibility translation unit are Apache-2.0; see [src/core/LICENSE](src/core/LICENSE). +The entire repository is licensed under Apache-2.0; see [LICENSE](LICENSE). The private core snapshot retains its upstream license copy at [src/core/LICENSE](src/core/LICENSE). diff --git a/package.xml b/package.xml index 418f5a2..36aa3ab 100644 --- a/package.xml +++ b/package.xml @@ -5,7 +5,6 @@ 0.3.0 Native ROS 1, ROS 2, and ros2_control driver for ATI Net F/T RDT sensors. Xudong Han - MIT Apache-2.0 https://github.com/netft/ros-netft https://github.com/netft/ros-netft diff --git a/test/test_manifest.py b/test/test_manifest.py index f0988ef..f721c7c 100644 --- a/test/test_manifest.py +++ b/test/test_manifest.py @@ -149,6 +149,9 @@ def test_manifest_has_conditional_ros_build_types_and_dependencies(): root = ElementTree.parse(str(ROOT / "package.xml")).getroot() assert root.attrib["format"] == "3" assert root.findtext("name") == "netft_driver" + assert [element.text.strip() for element in root.findall("license")] == [ + "Apache-2.0" + ] dependencies = { (element.tag, element.text.strip(), element.attrib.get("condition")) for element in root From 3377f6eced0391ea854ac1f626ce749832245d69 Mon Sep 17 00:00:00 2001 From: Xudong Han <42986190+han-xudong@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:07:25 +0800 Subject: [PATCH 25/25] fix: address final review feedback --- CMakeLists.txt | 2 +- pixi.toml | 2 +- test/cpp/test_ros1_node.cpp | 4 ++++ test/support/fake_sensor.py | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 7191e8f..e22cd5d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,7 +9,7 @@ option(NETFT_CORE_ONLY "Build only the ROS-neutral C++ core" OFF) option(NETFT_INSTALL_TEST_TOOLS "Install private integration-test tools" OFF) find_package(Threads REQUIRED) -find_package(CURL 7.62 REQUIRED) +find_package(CURL 7.63 REQUIRED) include(CheckCXXSourceCompiles) check_cxx_source_compiles( diff --git a/pixi.toml b/pixi.toml index 7dac864..a205c05 100644 --- a/pixi.toml +++ b/pixi.toml @@ -9,7 +9,7 @@ platforms = ["linux-64"] channel-priority = "strict" [dependencies] -libcurl = ">=7.62" +libcurl = ">=7.63" python = ">=3.8" pytest = ">=8,<9" pytest-timeout = ">=2,<3" diff --git a/test/cpp/test_ros1_node.cpp b/test/cpp/test_ros1_node.cpp index b080393..714f145 100644 --- a/test/cpp/test_ros1_node.cpp +++ b/test/cpp/test_ros1_node.cpp @@ -31,6 +31,10 @@ TEST(NetFTRos1Node, UsesAtiAndSensorDiscoveryDefaults) TEST(NetFTRos1Node, MapsEveryAdapterParameterWithManualCalibration) { + int argc = 0; + if (!ros::isInitialized()) { + ros::init(argc, nullptr, "netft_ros1_node_test", ros::init_options::AnonymousName); + } if (!ros::master::check()) { GTEST_SKIP() << "a ROS master is required for ROS 1 graph assertions"; } diff --git a/test/support/fake_sensor.py b/test/support/fake_sensor.py index 510bbba..6feeb26 100644 --- a/test/support/fake_sensor.py +++ b/test/support/fake_sensor.py @@ -40,7 +40,7 @@ def do_GET(self): self.end_headers() self.wfile.write(CALIBRATION_XML) - def log_message(self, format, *args): + def log_message(self, _format, *args): pass