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) == "") {
+ const auto end = xml.find("?>", 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