From 95c54e26af33b54594e8f218bd4fd99dd7239445 Mon Sep 17 00:00:00 2001 From: Xudong Han <42986190+han-xudong@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:32:05 +0800 Subject: [PATCH] Remove the legacy bundled CLI --- .github/ISSUE_TEMPLATE/bug_report.yml | 3 +- .github/ISSUE_TEMPLATE/feature_request.yml | 2 +- .github/workflows/ci.yml | 1 - CHANGELOG.md | 7 + CMakeLists.txt | 19 - README.md | 98 +--- app/cli.cpp | 594 --------------------- app/cli.hpp | 36 -- app/main.cpp | 39 -- pixi.toml | 6 +- test/CMakeLists.txt | 42 -- test/assert_exit_code.cmake | 23 - test/hardware_test.sh | 188 ------- test/install_test.sh | 31 +- test/test_cli.cpp | 564 ------------------- 15 files changed, 34 insertions(+), 1619 deletions(-) delete mode 100644 app/cli.cpp delete mode 100644 app/cli.hpp delete mode 100644 app/main.cpp delete mode 100644 test/assert_exit_code.cmake delete mode 100644 test/hardware_test.sh delete mode 100644 test/test_cli.cpp diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index def5f21..aab8f18 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,5 +1,5 @@ name: Bug report -description: Report reproducible incorrect behavior in the SDK or CLI +description: Report reproducible incorrect behavior in the C++ SDK title: "[Bug]: " labels: - bug @@ -21,7 +21,6 @@ body: label: Component options: - C++ SDK - - CLI - Discovery and calibration - Build or packaging - Tests or automation diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index cd529f9..a527898 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -15,7 +15,7 @@ body: id: proposal attributes: label: Proposed solution - description: Describe the API, CLI, or workflow you would expect. + description: Describe the API or integration workflow you would expect. validations: required: true - type: textarea diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee81219..2c9b082 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -186,7 +186,6 @@ jobs: "-DCMAKE_TOOLCHAIN_FILE=$env:VCPKG_INSTALLATION_ROOT/scripts/buildsystems/vcpkg.cmake" -DVCPKG_TARGET_TRIPLET=x64-windows -DBUILD_SHARED_LIBS=OFF - -DNETFT_BUILD_CLI=OFF -DBUILD_TESTING=ON - name: Build run: cmake --build build/windows --config Release diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fff727..45b7748 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this project are documented in this file. +## Unreleased + +### Removed + +- Remove the legacy bundled CLI and its `NETFT_BUILD_CLI` option. Command-line workflows now + live in the independent [netft-cli](https://github.com/netft/netft-cli) project. + ## 0.3.1 - 2026-07-31 ### Fixed diff --git a/CMakeLists.txt b/CMakeLists.txt index 7b816fd..8a261ec 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -7,7 +7,6 @@ include(GNUInstallDirs) include(CMakePackageConfigHelpers) option(NETFT_SANITIZERS "Enable address and undefined behavior sanitizers" OFF) -option(NETFT_BUILD_CLI "Build the netft command-line tool" ON) add_library(netft_sanitizers INTERFACE) if(NETFT_SANITIZERS) @@ -68,17 +67,6 @@ target_link_libraries(netft $ ) -if(NETFT_BUILD_CLI) - add_library(netft_cli_lib STATIC app/cli.cpp) - target_compile_features(netft_cli_lib PUBLIC cxx_std_17) - target_include_directories(netft_cli_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/app) - target_link_libraries(netft_cli_lib PUBLIC netft::netft) - - add_executable(netft_cli app/main.cpp) - set_target_properties(netft_cli PROPERTIES OUTPUT_NAME netft) - target_link_libraries(netft_cli PRIVATE netft_cli_lib) -endif() - install( DIRECTORY include/netft DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} @@ -90,13 +78,6 @@ install( LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} ) -if(NETFT_BUILD_CLI) - install( - TARGETS netft_cli - RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} - ) -endif() - set(netft_cmake_install_dir "${CMAKE_INSTALL_LIBDIR}/cmake/netft") configure_package_config_file( cmake/netftConfig.cmake.in diff --git a/README.md b/README.md index 4a25e17..32aa9df 100644 --- a/README.md +++ b/README.md @@ -7,23 +7,24 @@ [![C++17](https://img.shields.io/badge/C%2B%2B-17-blue)](https://en.cppreference.com/w/cpp/17) [![License](https://img.shields.io/github/license/netft/netft-cpp?label=license)](LICENSE) -`netft-cpp` is a standalone C++17 SDK and command-line tool for receiving calibrated -force/torque samples from ATI Net F/T Ethernet sensors over the RDT protocol, with HTTP -configuration discovery, stream health reporting, and explicit recovery policies. +`netft-cpp` is a standalone C++17 SDK for receiving calibrated force/torque samples from ATI +Net F/T Ethernet sensors over the RDT protocol, with HTTP configuration discovery, stream +health reporting, and explicit recovery policies. For an end-user command-line application, +see [netft-cli](https://github.com/netft/netft-cli). -## Highlights +## Features - Discovers calibration scales and measurement units from the sensor before streaming. - Tracks sequence, status, delivery, and recovery health with reconnect and fail-stop policies. -- Installs as the `netft::netft` CMake target and includes a human- and JSON-readable CLI. +- Installs shared or static libraries as the `netft::netft` CMake target. ## Supported platforms -| Platform | Architectures | SDK | CLI | -| --- | --- | --- | --- | -| Linux | x86-64, AArch64 | Tested | Tested | -| macOS | x86-64, Apple silicon | Tested | Tested | -| Windows | x86-64 | Tested | Not currently supported | +| Platform | Architectures | Support | +| --- | --- | --- | +| Linux | x86-64, AArch64 | Tested | +| macOS | x86-64, Apple silicon | Tested | +| Windows | x86-64 | Tested | Building requires a C++17 compiler, CMake 3.16 or newer, threads, and libcurl 7.63.0 or newer. GoogleTest is required only when `BUILD_TESTING=ON`. The checked-in Pixi environment provides @@ -32,37 +33,31 @@ project with externally supplied dependencies. ## Installation -Pixi provides the reproducible development environment: +Configure, build, and install the SDK with CMake: ```bash git clone https://github.com/netft/netft-cpp.git cd netft-cpp -pixi install -pixi run cmake -S . -B build/release -G Ninja \ +cmake -S . -B build/release \ -DCMAKE_BUILD_TYPE=Release \ -DBUILD_SHARED_LIBS=ON \ -DBUILD_TESTING=OFF \ -DCMAKE_INSTALL_PREFIX="$PWD/install" -pixi run cmake --build build/release -pixi run cmake --install build/release +cmake --build build/release +cmake --install build/release ``` -Set `BUILD_SHARED_LIBS=OFF` for a static library. The same CMake commands work with system -packages instead of Pixi when the required compiler, CMake, Threads, and libcurl dependencies -are available. Homebrew can provide the macOS dependencies, while vcpkg can provide the Windows -dependencies. Pass their installation prefixes or toolchain file to CMake as appropriate. For -a non-system shared-library prefix, configure the platform dynamic loader for `install/lib` -before running `install/bin/netft`. - -On Windows, configure with `-DNETFT_BUILD_CLI=OFF`. The SDK uses WinSock 2 and links `ws2_32` -automatically; the current CLI still relies on POSIX file and process facilities. +Set `BUILD_SHARED_LIBS=OFF` for a static library. Homebrew can provide the macOS dependencies, +while vcpkg can provide the Windows dependencies; pass their installation prefix or toolchain +file to CMake as appropriate. The repository's Pixi environment and development tasks are +documented in [CONTRIBUTING.md](CONTRIBUTING.md). ## CMake usage After installation, consume the package with CMake config mode: ```cmake -find_package(netft 0.1 CONFIG REQUIRED) +find_package(netft 0.3 CONFIG REQUIRED) add_executable(read_sensor main.cpp) target_link_libraries(read_sensor PRIVATE netft::netft) @@ -94,27 +89,6 @@ int main() { `192.168.1.1` is the ATI [factory-default sensor address](https://www.ati-ia.com/app_content/Documents/9620-05-Net%20FT.pdf). Replace it with the address configured for your sensor when it has been moved to another network. -## CLI usage - -The installed `netft` executable provides three commands: - -```bash -netft --help -netft info --host 192.168.1.1 -netft monitor --host 192.168.1.1 --duration 10 -netft monitor --host 192.168.1.1 --duration 10 --json --output sample.json -netft bias --host 192.168.1.1 -``` - -`info` reads and prints sensor configuration without starting an RDT stream. `monitor` keeps -the latest sample and reports a summary after five seconds by default. `bias` waits for a -sample, applies software bias, and succeeds only after receiving a later sample. `--json` -selects JSON output, while `--output PATH` replaces the output file atomically. - -CLI exit codes are `0` for success without reported device warnings or errors, `1` when a -completed operation reports device warnings or errors, `2` for invalid usage or an operational -failure, and `130` for interruption by `SIGINT`. - ## Automatic discovery and units Without a manual override, the client requests `http://HOST:HTTP_PORT/netftapi2.xml` before @@ -200,7 +174,7 @@ sensor, network, application, controller, and independent safety system as one i any controller. Do not copy the example address or calibration into another installation. - Applying software bias changes the measurement zero and can change downstream control and limit behavior. Unload or fixture the sensor as required, stop hazardous motion, and keep - people clear before calling `Client::bias()` or running `netft bias`. + people clear before calling `Client::bias()`. - Test disconnects, stale data, device status errors, and the selected recovery policy under controlled conditions. This software is not a substitute for an emergency stop or a safety-rated control path. @@ -208,38 +182,10 @@ sensor, network, application, controller, and independent safety system as one i Repository tests use simulated HTTP and UDP sensors. Connecting tests or examples to physical hardware is always an explicit, operator-approved action. -### Opt-in hardware verification - -The hardware harness builds and installs a fresh Release CLI into a temporary prefix, checks -the sensor-selected calibration returned by automatic discovery, and monitors a two-second -stream without printing individual samples. It requires an explicit sensor host and does not -assume a particular unit system or calibration scale: - -```bash -NETFT_SENSOR_HOST=192.168.1.1 pixi run hardware-test -``` - -The default `hardware-test` task explicitly forces `NETFT_ALLOW_BIAS=0`, so an inherited shell -setting cannot enable bias. Review the reported product, units, scales, rates, and counters -against the intended installation before using the results. - -The bias variant must be treated as a separate physical operation. Before every individual -run, obtain fresh operator authorization, verify that the named sensor is the intended device, -unload or fixture it as required, stop hazardous motion, and keep people clear. Only after -those checks may an operator deliberately run: - -```bash -NETFT_SENSOR_HOST=192.168.1.1 pixi run hardware-bias-test -``` - -Authorization from an earlier run must not be reused. The bias task opts in by setting -`NETFT_ALLOW_BIAS=1`; the harness then requires the CLI result to report -`"bias_applied": true`. - ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for the development workflow, tests, formatting rules, -hardware-test policy, versioning, and `ros-netft` backports. +hardware testing policy, versioning, and `ros-netft` backports. ## License diff --git a/app/cli.cpp b/app/cli.cpp deleted file mode 100644 index b421d34..0000000 --- a/app/cli.cpp +++ /dev/null @@ -1,594 +0,0 @@ -#include "cli.hpp" - -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "netft/client.hpp" -#include "netft/discovery.hpp" -#include "netft/status.hpp" - -namespace netft::cli { -namespace { - -struct Summary { - SensorConfiguration configuration; - HealthSnapshot health; - Sample sample; - double elapsed_s{}; - std::optional requested_duration_s; - bool bias_applied{}; -}; - -double parse_positive_double(std::string_view name, const std::string &text) { - std::size_t consumed{}; - double value{}; - try { - value = std::stod(text, &consumed); - } catch (const std::exception &) { - throw UsageError(std::string{name} + " must be a positive number"); - } - if (consumed != text.size() || !std::isfinite(value) || value <= 0.0) { - throw UsageError(std::string{name} + " must be a positive number"); - } - return value; -} - -int parse_port(std::string_view name, const std::string &text) { - std::size_t consumed{}; - long value{}; - try { - value = std::stol(text, &consumed); - } catch (const std::exception &) { - throw UsageError(std::string{name} + " must be in the range 1..65535"); - } - if (consumed != text.size() || value < 1 || value > 65535) { - throw UsageError(std::string{name} + " must be in the range 1..65535"); - } - return static_cast(value); -} - -std::string take_value(const std::vector &arguments, std::size_t &index) { - if (++index >= arguments.size()) { - throw UsageError(arguments[index - 1] + " requires a value"); - } - return arguments[index]; -} - -void validate_utf8(std::string_view value) { - const auto byte_at = [&value](const std::size_t index) { - return static_cast(value[index]); - }; - const auto is_continuation = [&byte_at](const std::size_t index) { - return (byte_at(index) & 0xc0U) == 0x80U; - }; - const auto invalid = [] { throw std::runtime_error("cannot serialize invalid UTF-8 string"); }; - - for (std::size_t index = 0; index < value.size();) { - const auto first = byte_at(index); - if (first <= 0x7fU) { - ++index; - continue; - } - if (first >= 0xc2U && first <= 0xdfU) { - if (index + 1 >= value.size() || !is_continuation(index + 1)) { - invalid(); - } - index += 2; - continue; - } - if (first >= 0xe0U && first <= 0xefU) { - if (index + 2 >= value.size() || !is_continuation(index + 1) || !is_continuation(index + 2)) { - invalid(); - } - const auto second = byte_at(index + 1); - if ((first == 0xe0U && second < 0xa0U) || (first == 0xedU && second > 0x9fU)) { - invalid(); - } - index += 3; - continue; - } - if (first >= 0xf0U && first <= 0xf4U) { - if (index + 3 >= value.size() || !is_continuation(index + 1) || !is_continuation(index + 2) || - !is_continuation(index + 3)) { - invalid(); - } - const auto second = byte_at(index + 1); - if ((first == 0xf0U && second < 0x90U) || (first == 0xf4U && second > 0x8fU)) { - invalid(); - } - index += 4; - continue; - } - invalid(); - } -} - -std::string json_escape(std::string_view value) { - validate_utf8(value); - std::ostringstream escaped; - for (const unsigned char character : value) { - switch (character) { - case '"': - escaped << "\\\""; - break; - case '\\': - escaped << "\\\\"; - break; - case '\b': - escaped << "\\b"; - break; - case '\f': - escaped << "\\f"; - break; - case '\n': - escaped << "\\n"; - break; - case '\r': - escaped << "\\r"; - break; - case '\t': - escaped << "\\t"; - break; - default: - if (character < 0x20U) { - escaped << "\\u" << std::hex << std::setw(4) << std::setfill('0') - << static_cast(character) << std::dec; - } else { - escaped << static_cast(character); - } - } - } - return escaped.str(); -} - -std::string source_name(const CalibrationSource source) { - return source == CalibrationSource::Sensor ? "sensor" : "override"; -} - -void use_json_locale(std::ostringstream &output) { output.imbue(std::locale::classic()); } - -void append_json_number(std::ostringstream &output, const double value) { - if (!std::isfinite(value)) { - throw std::runtime_error("cannot serialize non-finite JSON number"); - } - use_json_locale(output); - output << std::setprecision(std::numeric_limits::max_digits10) << value; -} - -void append_configuration_json(std::ostringstream &output, const SensorConfiguration &configuration, - const Config &config) { - output << R"("product":")" << json_escape(configuration.product_name) << R"(","endpoint":")" - << json_escape(config.sensor_host) << ':' << config.rdt_port - << R"(","configuration_source":")" << source_name(configuration.source) - << R"(","force_unit":")" << to_string(configuration.calibration.force_unit) - << R"(","torque_unit":")" << to_string(configuration.calibration.torque_unit) - << R"(","counts_per_force_unit":)"; - append_json_number(output, configuration.calibration.counts_per_force_unit); - output << ",\"counts_per_torque_unit\":"; - append_json_number(output, configuration.calibration.counts_per_torque_unit); - output << ",\"configuration_revision\":" << configuration.revision; -} - -template -void append_array(std::ostringstream &output, const std::array &values) { - output << '['; - for (std::size_t index = 0; index < values.size(); ++index) { - if (index != 0) { - output << ','; - } - append_json_number(output, values[index]); - } - output << ']'; -} - -std::string serialize_json(const SensorConfiguration &configuration, const Config &config) { - std::ostringstream output; - use_json_locale(output); - output << '{'; - append_configuration_json(output, configuration, config); - output << "}\n"; - return output.str(); -} - -std::string serialize_json(const Summary &summary, const Config &config) { - const auto &health = summary.health; - std::ostringstream output; - use_json_locale(output); - output << '{'; - append_configuration_json(output, summary.configuration, config); - if (summary.requested_duration_s) { - output << ",\"requested_duration_s\":"; - append_json_number(output, *summary.requested_duration_s); - } - output << ",\"elapsed_s\":"; - append_json_number(output, summary.elapsed_s); - output << ",\"sample_count\":" << health.delivered_count - << ",\"received_count\":" << health.received_count - << ",\"delivered_count\":" << health.delivered_count - << ",\"rate_limited_count\":" << health.rate_limited_count << ",\"receive_rate_hz\":"; - append_json_number(output, health.receive_rate_hz); - output << ",\"delivery_rate_hz\":"; - append_json_number(output, health.delivery_rate_hz); - output << ",\"lost_count\":" << health.lost_count - << ",\"duplicate_count\":" << health.duplicate_count - << ",\"out_of_order_count\":" << health.out_of_order_count - << ",\"malformed_count\":" << health.malformed_count - << ",\"reconnect_count\":" << health.reconnect_count - << ",\"timeout_count\":" << health.timeout_count - << ",\"warning_count\":" << health.warning_count - << ",\"device_error_count\":" << health.device_error_count - << ",\"device_status\":" << summary.sample.status << R"(,"fault_code":")" - << to_string(health.fault_code) << R"(","last_rdt_sequence":)" - << summary.sample.rdt_sequence << ",\"last_ft_sequence\":" << summary.sample.ft_sequence - << ",\"last_force\":"; - append_array(output, summary.sample.force); - output << ",\"last_torque\":"; - append_array(output, summary.sample.torque); - if (summary.bias_applied) { - output << ",\"bias_applied\":true"; - } - output << "}\n"; - return output.str(); -} - -std::string serialize_human(const SensorConfiguration &configuration, const Config &config) { - std::ostringstream output; - output << "Product: " << configuration.product_name << '\n' - << "Endpoint: " << config.sensor_host << ':' << config.rdt_port << '\n' - << "Calibration: " << source_name(configuration.source) << ", " - << configuration.calibration.counts_per_force_unit << " counts/" - << to_string(configuration.calibration.force_unit) << ", " - << configuration.calibration.counts_per_torque_unit << " counts/" - << to_string(configuration.calibration.torque_unit) << '\n'; - return output.str(); -} - -std::string serialize_human(const Summary &summary, const Config &config) { - std::ostringstream output; - output << serialize_human(summary.configuration, config) - << "Samples: " << summary.health.delivered_count << " delivered, " - << summary.health.lost_count << " lost, " << summary.health.warning_count << " warnings, " - << summary.health.device_error_count << " errors\n" - << "Receive rate [Hz]: " << summary.health.receive_rate_hz << '\n' - << "Lost records: " << summary.health.lost_count << '\n' - << "Device status: " << summary.sample.status << '\n' - << "Reconnects: " << summary.health.reconnect_count << '\n' - << "Force [" << to_string(summary.sample.force_unit) << "]: " << summary.sample.force[0] - << ' ' << summary.sample.force[1] << ' ' << summary.sample.force[2] << "\nTorque [" - << to_string(summary.sample.torque_unit) << "]: " << summary.sample.torque[0] << ' ' - << summary.sample.torque[1] << ' ' << summary.sample.torque[2] << '\n'; - if (summary.bias_applied) { - output << "Bias applied: yes\n"; - } - return output.str(); -} - -void write_atomic(const std::string &path, const std::string &contents) { - if (path.empty()) { - throw std::invalid_argument("output path must not be empty"); - } - std::string temporary = path + ".tmp.XXXXXX"; - std::vector name(temporary.begin(), temporary.end()); - name.push_back('\0'); - const int descriptor = ::mkstemp(name.data()); - if (descriptor < 0) { - throw std::runtime_error("cannot create temporary output file: " + - std::string{std::strerror(errno)}); - } - const std::string temporary_path{name.data()}; - FILE *file = ::fdopen(descriptor, "wb"); - if (file == nullptr) { - const auto message = std::string{std::strerror(errno)}; - ::close(descriptor); - ::unlink(temporary_path.c_str()); - throw std::runtime_error("cannot open temporary output file: " + message); - } - - bool success = std::fwrite(contents.data(), 1, contents.size(), file) == contents.size(); - success = std::fflush(file) == 0 && success; - success = std::fclose(file) == 0 && success; - if (!success) { - ::unlink(temporary_path.c_str()); - throw std::runtime_error("cannot write output file"); - } - if (std::rename(temporary_path.c_str(), path.c_str()) != 0) { - const auto message = std::string{std::strerror(errno)}; - ::unlink(temporary_path.c_str()); - throw std::runtime_error("cannot replace output file: " + message); - } -} - -bool was_interrupted(const volatile std::sig_atomic_t *interrupted) { - return interrupted != nullptr && *interrupted != 0; -} - -void emit(const Options &options, std::ostream &output, const std::string &contents) { - if (options.output_path.empty()) { - output << contents; - } else { - write_atomic(options.output_path, contents); - } -} - -int result_code(const HealthSnapshot &health) { - if (health.warning_count > 0 || health.device_error_count > 0) { - return 1; - } - return health.fault_code == FaultCode::None ? 0 : 2; -} - -int run_info(const Options &options, std::ostream &output, - const volatile std::sig_atomic_t *interrupted) { - DiscoveryOptions discovery; - discovery.sensor_host = options.config.sensor_host; - discovery.http_port = options.config.http_port; - discovery.connect_timeout = options.config.configuration_connect_timeout; - discovery.total_timeout = options.config.configuration_timeout; - const auto configuration = discover_sensor(discovery); - if (was_interrupted(interrupted)) { - return 130; - } - emit(options, output, - options.json ? serialize_json(configuration, options.config) - : serialize_human(configuration, options.config)); - return 0; -} - -int run_monitor(const Options &options, std::ostream &output, std::ostream &errors, - const volatile std::sig_atomic_t *interrupted) { - auto config = options.config; - config.deliver_samples_with_error_status = true; - Client client{config}; - const auto start = std::chrono::steady_clock::now(); - client.start([](const Sample &) {}); - const auto deadline = start + options.duration; - while (std::chrono::steady_clock::now() < deadline && !client.faulted() && - !was_interrupted(interrupted)) { - std::this_thread::sleep_for(std::chrono::milliseconds{2}); - } - const bool signal_received = was_interrupted(interrupted); - client.stop(); - if (signal_received) { - return 130; - } - - const auto sample = client.latest_sample(); - const auto health = client.health(); - if (!sample || !health.sensor_configuration || health.delivered_count == 0) { - errors << "netft: no sample received"; - if (!health.last_error.empty()) { - errors << ": " << health.last_error; - } - errors << '\n'; - return 2; - } - Summary summary{*health.sensor_configuration, - health, - *sample, - std::chrono::duration(std::chrono::steady_clock::now() - start).count(), - options.duration.count(), - false}; - emit(options, output, - options.json ? serialize_json(summary, options.config) - : serialize_human(summary, options.config)); - return result_code(health); -} - -int run_bias(const Options &options, std::ostream &output, std::ostream &errors, - const volatile std::sig_atomic_t *interrupted) { - Client client{options.config}; - const auto start = std::chrono::steady_clock::now(); - client.start([](const Sample &) {}); - while (!client.wait_for_first_sample(std::chrono::milliseconds{20}) && !client.faulted() && - !was_interrupted(interrupted) && - std::chrono::steady_clock::now() - start < std::chrono::seconds{2}) { - } - if (was_interrupted(interrupted)) { - client.stop(); - return 130; - } - auto before = client.latest_sample(); - auto before_health = client.health(); - if (!before || before_health.delivered_count == 0 || client.faulted()) { - client.stop(); - errors << "netft: no sample received before bias\n"; - return 2; - } - - try { - client.bias(); - } catch (const std::exception &error) { - client.stop(); - errors << "netft: bias failed: " << error.what() << '\n'; - return 2; - } - - std::optional after; - HealthSnapshot after_health; - const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds{2}; - while (std::chrono::steady_clock::now() < deadline && !client.faulted() && - !was_interrupted(interrupted)) { - after = client.latest_sample(); - after_health = client.health(); - if (after && after->rdt_sequence != before->rdt_sequence && - after_health.delivered_count > before_health.delivered_count) { - break; - } - std::this_thread::sleep_for(std::chrono::milliseconds{2}); - } - const bool signal_received = was_interrupted(interrupted); - client.stop(); - if (signal_received) { - return 130; - } - if (!after || !after_health.sensor_configuration || after->rdt_sequence == before->rdt_sequence || - after_health.delivered_count <= before_health.delivered_count) { - errors << "netft: no post-bias sample received\n"; - return 2; - } - - Summary summary{*after_health.sensor_configuration, - after_health, - *after, - std::chrono::duration(std::chrono::steady_clock::now() - start).count(), - std::nullopt, - true}; - emit(options, output, - options.json ? serialize_json(summary, options.config) - : serialize_human(summary, options.config)); - return result_code(after_health); -} - -} // namespace - -Options parse_options(const std::vector &arguments) { - Options options; - if (arguments.size() == 1 && arguments.front() == "--help") { - options.help = true; - return options; - } - if (arguments.empty()) { - throw UsageError("missing command"); - } - if (arguments.front() == "info") { - options.command = Command::Info; - } else if (arguments.front() == "monitor") { - options.command = Command::Monitor; - } else if (arguments.front() == "bias") { - options.command = Command::Bias; - } else { - throw UsageError("unknown command: " + arguments.front()); - } - - std::optional counts_per_force; - std::optional counts_per_torque; - std::optional force_unit; - std::optional torque_unit; - bool duration_supplied = false; - for (std::size_t index = 1; index < arguments.size(); ++index) { - const auto &argument = arguments[index]; - if (argument == "--help") { - options.help = true; - } else if (argument == "--json") { - options.json = true; - } else if (argument == "--host") { - options.config.sensor_host = take_value(arguments, index); - } else if (argument == "--rdt-port") { - options.config.rdt_port = parse_port("--rdt-port", take_value(arguments, index)); - } else if (argument == "--http-port") { - options.config.http_port = parse_port("--http-port", take_value(arguments, index)); - } else if (argument == "--duration") { - duration_supplied = true; - options.duration = std::chrono::duration{ - parse_positive_double("--duration", take_value(arguments, index))}; - } else if (argument == "--output") { - options.output_path = take_value(arguments, index); - if (options.output_path.empty()) { - throw UsageError("--output must not be empty"); - } - } else if (argument == "--counts-per-force-unit") { - counts_per_force = - parse_positive_double("--counts-per-force-unit", take_value(arguments, index)); - } else if (argument == "--counts-per-torque-unit") { - counts_per_torque = - parse_positive_double("--counts-per-torque-unit", take_value(arguments, index)); - } else if (argument == "--force-unit") { - force_unit = force_unit_from_string(take_value(arguments, index)); - if (!force_unit || *force_unit == ForceUnit::Unknown) { - throw UsageError("--force-unit is not supported"); - } - } else if (argument == "--torque-unit") { - torque_unit = torque_unit_from_string(take_value(arguments, index)); - if (!torque_unit || *torque_unit == TorqueUnit::Unknown) { - throw UsageError("--torque-unit is not supported"); - } - } else { - throw UsageError("unknown option: " + argument); - } - } - - const unsigned override_count = static_cast(counts_per_force.has_value()) + - static_cast(counts_per_torque.has_value()) + - static_cast(force_unit.has_value()) + - static_cast(torque_unit.has_value()); - if (override_count != 0 && override_count != 4) { - throw UsageError("manual calibration requires all four calibration options"); - } - if (override_count == 4) { - if (options.command == Command::Info) { - throw UsageError("manual calibration is not valid for info"); - } - options.config.calibration_override = - Calibration{*counts_per_force, *counts_per_torque, *force_unit, *torque_unit}; - } - if (duration_supplied && options.command != Command::Monitor) { - throw UsageError("--duration is only valid for monitor"); - } - try { - validate(options.config); - } catch (const std::exception &error) { - throw UsageError(error.what()); - } - return options; -} - -std::string usage() { - return "Usage: netft [options]\n" - "\n" - "Commands:\n" - " info Discover and print sensor calibration\n" - " monitor Monitor the latest sample (default: 5 seconds)\n" - " bias Apply software bias after receiving a sample\n" - "\n" - "Options:\n" - " --host HOST Sensor host\n" - " --rdt-port PORT RDT UDP port\n" - " --http-port PORT Configuration HTTP port\n" - " --duration SECONDS Monitor duration\n" - " --json Emit JSON\n" - " --output PATH Atomically write output to PATH\n" - " --counts-per-force-unit VALUE Manual force scale\n" - " --counts-per-torque-unit VALUE Manual torque scale\n" - " --force-unit UNIT Manual force unit\n" - " --torque-unit UNIT Manual torque unit\n" - " --help Show this help\n"; -} - -int run(const Options &options, std::ostream &output, std::ostream &errors, - const volatile std::sig_atomic_t *interrupted) { - try { - switch (options.command) { - case Command::Info: - return run_info(options, output, interrupted); - case Command::Monitor: - return run_monitor(options, output, errors, interrupted); - case Command::Bias: - return run_bias(options, output, errors, interrupted); - } - } catch (const std::exception &error) { - if (was_interrupted(interrupted)) { - return 130; - } - errors << "netft: " << error.what() << '\n'; - return 2; - } - errors << "netft: invalid command\n"; - return 2; -} - -} // namespace netft::cli diff --git a/app/cli.hpp b/app/cli.hpp deleted file mode 100644 index 483bc88..0000000 --- a/app/cli.hpp +++ /dev/null @@ -1,36 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -#include "netft/types.hpp" - -namespace netft::cli { - -enum class Command { Info, Monitor, Bias }; - -class UsageError : public std::invalid_argument { -public: - using std::invalid_argument::invalid_argument; -}; - -struct Options { - Command command{Command::Info}; - Config config; - std::chrono::duration duration{5.0}; - bool json{false}; - bool help{false}; - std::string output_path; -}; - -Options parse_options(const std::vector &arguments); -std::string usage(); - -int run(const Options &options, std::ostream &output, std::ostream &errors, - const volatile std::sig_atomic_t *interrupted = nullptr); - -} // namespace netft::cli diff --git a/app/main.cpp b/app/main.cpp deleted file mode 100644 index 3ae4d36..0000000 --- a/app/main.cpp +++ /dev/null @@ -1,39 +0,0 @@ -#include -#include -#include -#include - -#include "cli.hpp" - -namespace { - -volatile std::sig_atomic_t interrupted = 0; - -extern "C" void handle_sigint(int /*unused*/) { interrupted = 1; } - -} // namespace - -int main(int argc, char **argv) { - std::signal(SIGINT, handle_sigint); - - std::vector arguments; - arguments.reserve(static_cast(argc > 0 ? argc - 1 : 0)); - for (int index = 1; index < argc; ++index) { - arguments.emplace_back(argv[index]); - } - - try { - const auto options = netft::cli::parse_options(arguments); - if (options.help) { - std::cout << netft::cli::usage(); - return 0; - } - return netft::cli::run(options, std::cout, std::cerr, &interrupted); - } catch (const netft::cli::UsageError &error) { - std::cerr << "netft: " << error.what() << "\n\n" << netft::cli::usage(); - return 2; - } catch (const std::exception &error) { - std::cerr << "netft: " << error.what() << '\n'; - return 2; - } -} diff --git a/pixi.toml b/pixi.toml index e0be34b..83d65ad 100644 --- a/pixi.toml +++ b/pixi.toml @@ -26,12 +26,10 @@ test = { cmd = "ctest --test-dir build --output-on-failure", depends-on = ["buil install-test = "bash test/install_test.sh" shared-test = "bash test/install_test.sh shared" static-test = "bash test/install_test.sh static" -hardware-test = "NETFT_ALLOW_BIAS=0 bash test/hardware_test.sh" -hardware-bias-test = "NETFT_ALLOW_BIAS=1 bash test/hardware_test.sh" format = "git ls-files -z '*.cpp' '*.hpp' | xargs -0 clang-format -i" format-check = "git ls-files -z '*.cpp' '*.hpp' | xargs -0 clang-format --dry-run --Werror" -tidy = { cmd = "cmake -S . -B build/tidy -G Ninja -DBUILD_TESTING=OFF -DCMAKE_EXPORT_COMPILE_COMMANDS=ON && clang-tidy -p build/tidy --config-file=.clang-tidy $(git ls-files 'src/*.cpp' 'app/*.cpp' ':!src/detail/udp_transport_windows.cpp')", depends-on = [] } -coverage = "cmake -S . -B build/coverage -G Ninja -DCMAKE_BUILD_TYPE=Debug -DBUILD_SHARED_LIBS=ON -DBUILD_TESTING=ON -DCMAKE_CXX_FLAGS='--coverage -O0 -g' -DCMAKE_EXE_LINKER_FLAGS=--coverage -DCMAKE_SHARED_LINKER_FLAGS=--coverage && cmake --build build/coverage && ctest --test-dir build/coverage --output-on-failure && gcovr --root . --filter 'src/' --filter 'app/' --exclude 'test/' --exclude-unreachable-branches --exclude-throw-branches --xml-pretty --output coverage.xml build/coverage" +tidy = { cmd = "cmake -S . -B build/tidy -G Ninja -DBUILD_TESTING=OFF -DCMAKE_EXPORT_COMPILE_COMMANDS=ON && clang-tidy -p build/tidy --config-file=.clang-tidy $(git ls-files 'src/*.cpp' ':!src/detail/udp_transport_windows.cpp')", depends-on = [] } +coverage = "cmake -S . -B build/coverage -G Ninja -DCMAKE_BUILD_TYPE=Debug -DBUILD_SHARED_LIBS=ON -DBUILD_TESTING=ON -DCMAKE_CXX_FLAGS='--coverage -O0 -g' -DCMAKE_EXE_LINKER_FLAGS=--coverage -DCMAKE_SHARED_LINKER_FLAGS=--coverage && cmake --build build/coverage && ctest --test-dir build/coverage --output-on-failure && gcovr --root . --filter 'src/' --exclude 'test/' --exclude-unreachable-branches --exclude-throw-branches --xml-pretty --output coverage.xml build/coverage" gcc-10-test = "bash test/gcc10_compatibility_test.sh" workflow-check = "actionlint" diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 6b8a1cb..d19098b 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -163,48 +163,6 @@ target_link_libraries(netft_client_lifecycle PRIVATE add_test(NAME netft_client_lifecycle COMMAND netft_client_lifecycle) -if(NETFT_BUILD_CLI) - add_executable(netft_cli_test - test_cli.cpp - support/fake_http_server.cpp - support/fake_sensor.cpp - ) - target_include_directories(netft_cli_test PRIVATE - ${CMAKE_CURRENT_SOURCE_DIR} - ${CMAKE_CURRENT_SOURCE_DIR}/../app - ${CMAKE_CURRENT_SOURCE_DIR}/../src - ) - target_compile_definitions(netft_cli_test PRIVATE - NETFT_CLI_PATH="$" - ) - target_link_libraries(netft_cli_test PRIVATE - netft_cli_lib - netft_test_socket - ${NETFT_GTEST_MAIN_TARGET} - ) - add_dependencies(netft_cli_test netft_cli) - - add_test(NAME netft_cli COMMAND netft_cli_test) - - add_test( - NAME netft_cli_help - COMMAND ${CMAKE_COMMAND} - -DPROGRAM=$ - -DEXPECTED_EXIT_CODE=0 - -DARGUMENTS=--help - -P ${CMAKE_CURRENT_SOURCE_DIR}/assert_exit_code.cmake - ) - - add_test( - NAME netft_cli_invalid_command - COMMAND ${CMAKE_COMMAND} - -DPROGRAM=$ - -DEXPECTED_EXIT_CODE=2 - -DARGUMENTS=invalid-command - -P ${CMAKE_CURRENT_SOURCE_DIR}/assert_exit_code.cmake - ) -endif() - if(UNIX) add_test( NAME netft_curl_minimum diff --git a/test/assert_exit_code.cmake b/test/assert_exit_code.cmake deleted file mode 100644 index c350247..0000000 --- a/test/assert_exit_code.cmake +++ /dev/null @@ -1,23 +0,0 @@ -if(NOT DEFINED PROGRAM OR NOT DEFINED EXPECTED_EXIT_CODE) - message(FATAL_ERROR "PROGRAM and EXPECTED_EXIT_CODE are required") -endif() - -execute_process( - COMMAND "${PROGRAM}" ${ARGUMENTS} - TIMEOUT 30 - RESULT_VARIABLE actual_exit_code - OUTPUT_QUIET - ERROR_QUIET -) - -if(NOT "${actual_exit_code}" MATCHES "^-?[0-9]+$") - message(FATAL_ERROR - "Failed to execute ${PROGRAM}: ${actual_exit_code}" - ) -endif() - -if(NOT actual_exit_code EQUAL EXPECTED_EXIT_CODE) - message(FATAL_ERROR - "Expected exit code ${EXPECTED_EXIT_CODE}, got ${actual_exit_code}" - ) -endif() diff --git a/test/hardware_test.sh b/test/hardware_test.sh deleted file mode 100644 index 5c6d176..0000000 --- a/test/hardware_test.sh +++ /dev/null @@ -1,188 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -if [[ -z "${NETFT_SENSOR_HOST:-}" ]]; then - printf 'NETFT_SENSOR_HOST must be set to the operator-approved sensor host\n' >&2 - exit 2 -fi - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" -temp_parent="$(cd "${TMPDIR:-/tmp}" && pwd -P)" -if [[ "${temp_parent}" == / ]]; then - printf 'refusing to create hardware-test files directly under /\n' >&2 - exit 2 -fi - -test_root="$(mktemp -d "${temp_parent}/netft-hardware-test.XXXXXX")" -case "${test_root}" in - "${temp_parent}"/netft-hardware-test.??????) ;; - *) - printf 'mktemp returned an unexpected path; refusing cleanup: %s\n' "${test_root}" >&2 - exit 2 - ;; -esac - -cleanup() { - case "${test_root}" in - "${temp_parent}"/netft-hardware-test.??????) - if [[ -d "${test_root}" ]]; then - rm -rf -- "${test_root}" - fi - ;; - *) - printf 'refusing to clean an unvalidated path: %s\n' "${test_root}" >&2 - ;; - esac -} -trap cleanup EXIT - -build_dir="${test_root}/build" -prefix="${test_root}/prefix" - -cmake -S "${repo_root}" -B "${build_dir}" -G Ninja \ - -DCMAKE_BUILD_TYPE=Release \ - -DBUILD_SHARED_LIBS=OFF \ - -DBUILD_TESTING=OFF \ - -DCMAKE_INSTALL_PREFIX="${prefix}" -cmake --build "${build_dir}" -cmake --install "${build_dir}" - -cli="${prefix}/bin/netft" -if [[ ! -x "${cli}" ]]; then - printf 'installed netft CLI is missing or not executable: %s\n' "${cli}" >&2 - exit 2 -fi - -validate_json() { - local kind="$1" - local payload="$2" - NETFT_HARDWARE_JSON="${payload}" python - "${kind}" <<'PY' -import json -import math -import os -import sys - - -def fail(message): - raise SystemExit(f"hardware test validation failed: {message}") - - -def require_object(value): - if not isinstance(value, dict): - fail("top-level JSON value must be an object") - return value - - -def require_text(document, key): - value = document.get(key) - if not isinstance(value, str) or not value: - fail(f"{key} must be a nonempty string") - return value - - -def require_number(document, key, minimum=None, exact=None): - value = document.get(key) - if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): - fail(f"{key} must be a finite number") - if minimum is not None and value < minimum: - fail(f"{key} must be at least {minimum}, observed {value}") - if exact is not None and value != exact: - fail(f"{key} must equal {exact}, observed {value}") - return value - - -def require_integer(document, key, minimum=None, exact=None): - value = document.get(key) - if isinstance(value, bool) or not isinstance(value, int): - fail(f"{key} must be an integer") - if minimum is not None and value < minimum: - fail(f"{key} must be at least {minimum}, observed {value}") - if exact is not None and value != exact: - fail(f"{key} must equal {exact}, observed {value}") - return value - - -try: - document = require_object(json.loads(os.environ["NETFT_HARDWARE_JSON"])) -except (json.JSONDecodeError, UnicodeDecodeError) as error: - fail(f"invalid JSON: {error}") - -kind = sys.argv[1] -product = require_text(document, "product") -source = require_text(document, "configuration_source") -if source != "sensor": - fail(f"configuration_source must be sensor, observed {source}") - -force_unit = require_text(document, "force_unit") -known_force_units = {"lbf", "N", "klbf", "kN", "kgf"} -if force_unit not in known_force_units: - fail(f"force_unit is not recognized: {force_unit}") - -torque_unit = require_text(document, "torque_unit") -known_torque_units = {"lbf-in", "lbf-ft", "N-m", "N-mm", "kgf-cm", "kN-m"} -if torque_unit not in known_torque_units: - fail(f"torque_unit is not recognized: {torque_unit}") - -force_scale = require_number(document, "counts_per_force_unit", minimum=0) -torque_scale = require_number(document, "counts_per_torque_unit", minimum=0) -if force_scale <= 0 or torque_scale <= 0: - fail("calibration scales must be positive") - -if kind == "info": - print("Observed sensor configuration:") - print(f" product={product}") - print(f" force={force_scale} counts/{force_unit}") - print(f" torque={torque_scale} counts/{torque_unit}") -elif kind == "monitor": - sample_count = require_integer(document, "sample_count", minimum=1) - received_count = require_integer(document, "received_count", minimum=1000) - delivered_count = require_integer(document, "delivered_count", minimum=1) - if sample_count != delivered_count: - fail( - f"sample_count ({sample_count}) must equal delivered_count ({delivered_count})" - ) - if delivered_count > received_count: - fail( - f"delivered_count ({delivered_count}) exceeds received_count ({received_count})" - ) - malformed_count = require_integer(document, "malformed_count", exact=0) - device_error_count = require_integer(document, "device_error_count", exact=0) - fault_code = require_text(document, "fault_code") - if fault_code != "none": - fail(f"fault_code must be none, observed {fault_code}") - receive_rate = require_number(document, "receive_rate_hz", minimum=0) - delivery_rate = require_number(document, "delivery_rate_hz", minimum=0) - if receive_rate <= 0 or delivery_rate <= 0: - fail("receive and delivery rates must be positive") - print("Observed monitor summary:") - print( - f" received={received_count} delivered={delivered_count} " - f"sample_count={sample_count}" - ) - print( - f" receive_rate_hz={receive_rate} delivery_rate_hz={delivery_rate} " - f"malformed={malformed_count} device_errors={device_error_count}" - ) - print(f" fault_code={fault_code}; monitor exited successfully and requested stop") -elif kind == "bias": - if document.get("bias_applied") is not True: - fail("bias_applied must be true") - print("Observed authorized bias result: bias_applied=true") -else: - fail(f"unknown validation kind: {kind}") -PY -} - -info_json="$("${cli}" info --host "${NETFT_SENSOR_HOST}" --json)" -validate_json info "${info_json}" - -monitor_json="$("${cli}" monitor --host "${NETFT_SENSOR_HOST}" --duration 2 --json)" -validate_json monitor "${monitor_json}" - -if [[ "${NETFT_ALLOW_BIAS:-0}" == 1 ]]; then - bias_json="$("${cli}" bias --host "${NETFT_SENSOR_HOST}" --json)" - validate_json bias "${bias_json}" -else - printf 'Software bias skipped (NETFT_ALLOW_BIAS is not exactly 1).\n' -fi diff --git a/test/install_test.sh b/test/install_test.sh index 32cab6d..45ffe81 100755 --- a/test/install_test.sh +++ b/test/install_test.sh @@ -63,7 +63,7 @@ test -f "$prefix/$install_include_dir/netft/discovery.hpp" test -f "$prefix/$install_include_dir/netft/export.hpp" test -f "$prefix/$install_include_dir/netft/status.hpp" test -f "$prefix/$install_include_dir/netft/types.hpp" -test -x "$prefix/bin/netft" +test ! -e "$prefix/bin/netft" find "$prefix/lib" -maxdepth 1 -name "$library_pattern" -print -quit \ | grep -q . test -f "$prefix/lib/cmake/netft/netftConfig.cmake" @@ -88,21 +88,12 @@ cmake -S "$incompatible_consumer_source" \ "${cmake_compiler_args[@]}" \ -DNETFT_PROBE_PREFIX="$prefix" -if find "$prefix" -name '*netft_cli_lib*' -print -quit | grep -q .; then - echo "private netft_cli_lib was installed" >&2 - exit 1 -fi - for targets_file in "$prefix"/lib/cmake/netft/netftTargets*.cmake; do if grep -Fq "$repo_root" "$targets_file" || \ grep -Fq "$package_build" "$targets_file"; then echo "exported targets contain a source or build path: $targets_file" >&2 exit 1 fi - if grep -Fq 'netft_cli_lib' "$targets_file"; then - echo "exported targets reference private netft_cli_lib: $targets_file" >&2 - exit 1 - fi done if [[ "$mode" == "shared" ]]; then @@ -172,24 +163,4 @@ cmake -S "$repo_root/test/consumer" -B "$consumer_build" -G Ninja \ cmake --build "$consumer_build" "$consumer_build/netft_consumer" -if [[ "$mode" == "shared" ]]; then - cli_dependencies="$( - LD_LIBRARY_PATH="$prefix/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ - ldd "$prefix/bin/netft" - )" - grep -Fq "$prefix/lib/libnetft.so.1" <<<"$cli_dependencies" - if grep -Fq 'netft_cli_lib' <<<"$cli_dependencies"; then - echo "$cli_dependencies" >&2 - exit 1 - fi - if grep -Fq 'not found' <<<"$cli_dependencies"; then - echo "$cli_dependencies" >&2 - exit 1 - fi - LD_LIBRARY_PATH="$prefix/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ - "$prefix/bin/netft" --help >/dev/null -else - "$prefix/bin/netft" --help >/dev/null -fi - echo "netft $mode install/consumer test passed" diff --git a/test/test_cli.cpp b/test/test_cli.cpp deleted file mode 100644 index ec0b199..0000000 --- a/test/test_cli.cpp +++ /dev/null @@ -1,564 +0,0 @@ -#include - -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "cli.hpp" -#include "detail/protocol.hpp" -#include "support/fake_sensor.hpp" - -extern char **environ; - -namespace { - -using namespace std::chrono_literals; - -netft::cli::Options parse(std::initializer_list arguments) { - std::vector values; - for (const auto *argument : arguments) { - values.emplace_back(argument); - } - return netft::cli::parse_options(values); -} - -netft::cli::Options options_for(netft::cli::Command command, - const netft::test::FakeSensor &sensor) { - netft::cli::Options options; - options.command = command; - options.config.sensor_host = sensor.host(); - options.config.rdt_port = sensor.rdt_port(); - options.config.http_port = sensor.http_port(); - options.config.receive_timeout = 50ms; - options.config.configuration_connect_timeout = 100ms; - options.config.configuration_timeout = 250ms; - options.duration = 50ms; - options.json = true; - return options; -} - -bool sample_count_matches_delivered_count(const std::string &json) { - std::smatch sample_match; - std::smatch delivered_match; - const std::regex sample_pattern{"\\\"sample_count\\\":([0-9]+)"}; - const std::regex delivered_pattern{"\\\"delivered_count\\\":([0-9]+)"}; - return std::regex_search(json, sample_match, sample_pattern) && - std::regex_search(json, delivered_match, delivered_pattern) && - sample_match[1] == delivered_match[1]; -} - -std::uint64_t json_unsigned(const std::string &json, const std::string &key) { - std::smatch match; - const std::regex pattern{"\\\"" + key + "\\\":([0-9]+)"}; - if (!std::regex_search(json, match, pattern)) { - throw std::runtime_error("missing JSON unsigned field: " + key); - } - return std::stoull(match[1]); -} - -std::filesystem::path temporary_path(const std::string &suffix) { - return std::filesystem::temp_directory_path() / - ("netft-cli-" + std::to_string(::getpid()) + "-" + suffix); -} - -pid_t spawn_cli(const std::vector &arguments, int stdout_fd, int stderr_fd) { - std::vector storage; - storage.reserve(arguments.size() + 1); - storage.emplace_back(NETFT_CLI_PATH); - storage.insert(storage.end(), arguments.begin(), arguments.end()); - std::vector argv; - argv.reserve(storage.size() + 1); - for (auto &argument : storage) { - argv.push_back(argument.data()); - } - argv.push_back(nullptr); - - posix_spawn_file_actions_t actions; - if (::posix_spawn_file_actions_init(&actions) != 0) { - throw std::runtime_error("cannot initialize spawn file actions"); - } - const int stdout_result = ::posix_spawn_file_actions_adddup2(&actions, stdout_fd, STDOUT_FILENO); - const int stderr_result = ::posix_spawn_file_actions_adddup2(&actions, stderr_fd, STDERR_FILENO); - pid_t pid{}; - const int spawn_result = - stdout_result == 0 && stderr_result == 0 - ? ::posix_spawn(&pid, NETFT_CLI_PATH, &actions, nullptr, argv.data(), environ) - : EINVAL; - ::posix_spawn_file_actions_destroy(&actions); - if (spawn_result != 0) { - throw std::runtime_error("cannot spawn netft CLI"); - } - return pid; -} - -class CommaDecimalPoint final : public std::numpunct { -protected: - char do_decimal_point() const override { return ','; } - char do_thousands_sep() const override { return '_'; } - std::string do_grouping() const override { return "\3"; } -}; - -class GlobalLocaleGuard { -public: - explicit GlobalLocaleGuard(const std::locale &replacement) - : previous_(std::locale::global(replacement)) {} - ~GlobalLocaleGuard() { std::locale::global(previous_); } - -private: - std::locale previous_; -}; - -TEST(CliParser, RecognizesHelpAndDefaultsMonitorToFiveSeconds) { - EXPECT_TRUE(parse({"--help"}).help); - const auto options = parse({"monitor", "--host", "sensor.local"}); - EXPECT_EQ(options.command, netft::cli::Command::Monitor); - EXPECT_EQ(options.config.sensor_host, "sensor.local"); - EXPECT_DOUBLE_EQ(options.duration.count(), 5.0); -} - -TEST(CliParser, RejectsMissingUnknownAndInvalidValues) { - EXPECT_THROW(parse({}), netft::cli::UsageError); - EXPECT_THROW(parse({"unknown"}), netft::cli::UsageError); - EXPECT_THROW(parse({"monitor", "--rdt-port", "0"}), netft::cli::UsageError); - EXPECT_THROW(parse({"monitor", "--http-port", "65536"}), netft::cli::UsageError); - EXPECT_THROW(parse({"monitor", "--duration", "nan"}), netft::cli::UsageError); - EXPECT_THROW(parse({"monitor", "--duration", "0"}), netft::cli::UsageError); -} - -TEST(CliParser, RejectsEveryUnsupportedBoundaryWithoutInspectingCopy) { - const std::vector> cases{ - {"monitor", "--host"}, - {"monitor", "--duration", "not-a-number"}, - {"monitor", "--rdt-port", "not-a-port"}, - {"monitor", "--output", ""}, - {"monitor", "--force-unit", "unsupported"}, - {"monitor", "--torque-unit", "unsupported"}, - {"monitor", "--unknown"}, - {"info", "--duration", "1"}, - {"bias", "--duration", "1"}, - {"monitor", "--host", " "}, - {"info", "--counts-per-force-unit", "1", "--counts-per-torque-unit", "1", "--force-unit", "N", - "--torque-unit", "N-mm"}, - }; - - for (const auto &arguments : cases) { - EXPECT_THROW(netft::cli::parse_options(arguments), netft::cli::UsageError); - } -} - -TEST(CliParser, RequiresCompleteManualCalibration) { - EXPECT_THROW(parse({"monitor", "--counts-per-force-unit", "1000000"}), netft::cli::UsageError); - - const auto options = - parse({"monitor", "--counts-per-force-unit", "1000000", "--counts-per-torque-unit", "2000000", - "--force-unit", "N", "--torque-unit", "N-mm"}); - ASSERT_TRUE(options.config.calibration_override); - EXPECT_EQ(options.config.calibration_override->force_unit, netft::ForceUnit::Newton); - EXPECT_EQ(options.config.calibration_override->torque_unit, netft::TorqueUnit::NewtonMillimeter); -} - -TEST(CliInfo, DiscoversConfigurationWithoutStartingRdt) { - netft::test::FakeSensor sensor; - auto options = options_for(netft::cli::Command::Info, sensor); - std::ostringstream output; - std::ostringstream errors; - - EXPECT_EQ(netft::cli::run(options, output, errors), 0); - EXPECT_TRUE(errors.str().empty()); - EXPECT_TRUE(sensor.wait_for_http_request()); - EXPECT_FALSE(sensor.wait_for_command(netft::detail::Command::StartRealtime, 1, 20ms)); - const auto text = output.str(); - EXPECT_NE(text.find("\"product\":\"Fake Net F/T\""), std::string::npos); - EXPECT_NE(text.find("\"endpoint\":\"127.0.0.1:"), std::string::npos); - EXPECT_NE(text.find("\"configuration_source\":\"sensor\""), std::string::npos); - EXPECT_NE(text.find("\"force_unit\":\"N\""), std::string::npos); - EXPECT_NE(text.find("\"torque_unit\":\"N-m\""), std::string::npos); - EXPECT_EQ(text.find("\"sample_count\""), std::string::npos); -} - -TEST(CliInfo, RejectsInvalidUtf8WithoutReplacingAtomicOutput) { - const std::vector invalid_products{ - std::string{"invalid"} + static_cast(0x80), - std::string{"invalid"} + static_cast(0xc0) + static_cast(0xaf), - std::string{"invalid"} + static_cast(0xe0) + static_cast(0x80) + - static_cast(0xaf), - std::string{"invalid"} + static_cast(0xf0) + static_cast(0x80) + - static_cast(0x80) + static_cast(0xaf), - std::string{"invalid"} + static_cast(0xe2) + '(' + static_cast(0xa1), - std::string{"invalid"} + static_cast(0xe2) + static_cast(0x82), - std::string{"invalid"} + static_cast(0xed) + static_cast(0xa0) + - static_cast(0x80), - std::string{"invalid"} + static_cast(0xf4) + static_cast(0x90) + - static_cast(0x80) + static_cast(0x80), - std::string{"invalid"} + static_cast(0xf5) + static_cast(0x80) + - static_cast(0x80) + static_cast(0x80), - }; - - for (std::size_t index = 0; index < invalid_products.size(); ++index) { - SCOPED_TRACE(index); - netft::test::FakeSensor sensor; - sensor.set_xml_configuration("" + invalid_products[index] + - "10000001000000" - "NNm"); - auto options = options_for(netft::cli::Command::Info, sensor); - const auto path = temporary_path("invalid-utf8-" + std::to_string(index) + ".json"); - const std::string original_contents = std::to_string(::getpid()) + std::to_string(index); - { - std::ofstream existing{path}; - existing << original_contents; - } - options.output_path = path.string(); - std::ostringstream output; - std::ostringstream errors; - - EXPECT_EQ(netft::cli::run(options, output, errors), 2); - EXPECT_TRUE(output.str().empty()); - EXPECT_FALSE(errors.str().empty()); - std::ifstream existing{path}; - const std::string contents{std::istreambuf_iterator{existing}, {}}; - EXPECT_EQ(contents, original_contents); - std::filesystem::remove(path); - } -} - -TEST(CliInfo, PreservesValidUtf8WhileEscapingJsonControls) { - netft::test::FakeSensor sensor; - const std::string product = std::string{"\xc3\xa9\xe5\x8a\x9b"} + "\"" + - "\xe4\xbc\xa0\n\xe6\x84\x9f\xe5\x99\xa8\xf0\x9f\xa7\xad"; - sensor.set_xml_configuration("" + product + - "10000001000000" - "NNm"); - auto options = options_for(netft::cli::Command::Info, sensor); - std::ostringstream output; - std::ostringstream errors; - - ASSERT_EQ(netft::cli::run(options, output, errors), 0); - EXPECT_TRUE(errors.str().empty()); - EXPECT_NE(output.str().find(std::string{"\"product\":\"\xc3\xa9\xe5\x8a\x9b"} + - "\\\"\xe4\xbc\xa0\\n\xe6\x84\x9f\xe5\x99\xa8" - "\xf0\x9f\xa7\xad\""), - std::string::npos); -} - -TEST(CliInfo, SerializesEveryJsonControlBranch) { - netft::test::FakeSensor sensor; - std::string product{"controls"}; - product.push_back('"'); - product.push_back('\\'); - product.push_back('\b'); - product.push_back('\f'); - product.push_back('\n'); - product.push_back('\r'); - product.push_back('\t'); - product.push_back(static_cast(0x01)); - sensor.set_xml_configuration("" + product + - "10000001000000" - "NNm"); - auto options = options_for(netft::cli::Command::Info, sensor); - std::ostringstream output; - std::ostringstream errors; - - ASSERT_EQ(netft::cli::run(options, output, errors), 0); - EXPECT_NE(output.str().find(R"json("product":"controls\"\\\b\f\n\r\t\u0001")json"), - std::string::npos); - EXPECT_TRUE(errors.str().empty()); -} - -TEST(CliInfo, HumanOutputIsNonempty) { - netft::test::FakeSensor sensor; - auto options = options_for(netft::cli::Command::Info, sensor); - options.json = false; - std::ostringstream output; - std::ostringstream errors; - - EXPECT_EQ(netft::cli::run(options, output, errors), 0); - EXPECT_FALSE(output.str().empty()); - EXPECT_TRUE(errors.str().empty()); -} - -TEST(CliMonitor, EmitsStableJsonAndUsesDeliveredCountAsSampleCount) { - netft::test::FakeSensor sensor; - auto options = options_for(netft::cli::Command::Monitor, sensor); - std::ostringstream output; - std::ostringstream errors; - - EXPECT_EQ(netft::cli::run(options, output, errors), 0); - const auto text = output.str(); - for (const auto *key : - {"elapsed_s", "sample_count", "received_count", "delivered_count", - "rate_limited_count", "receive_rate_hz", "delivery_rate_hz", "lost_count", - "duplicate_count", "out_of_order_count", "malformed_count", "reconnect_count", - "timeout_count", "warning_count", "device_error_count", "device_status", - "fault_code", "last_rdt_sequence", "last_ft_sequence", "last_force", - "last_torque", "requested_duration_s"}) { - EXPECT_NE(text.find(std::string{"\""} + key + "\""), std::string::npos) << key; - } - const auto health = sensor.commands(); - EXPECT_FALSE(health.empty()); - EXPECT_TRUE(errors.str().empty()); - EXPECT_TRUE(sample_count_matches_delivered_count(text)); -} - -TEST(CliMonitor, JsonUsesClassicLocaleForEveryFloatingPointValue) { - GlobalLocaleGuard locale_guard{std::locale{std::locale::classic(), new CommaDecimalPoint}}; - netft::test::FakeSensor sensor; - auto options = options_for(netft::cli::Command::Monitor, sensor); - std::ostringstream output; - std::ostringstream errors; - - ASSERT_EQ(netft::cli::run(options, output, errors), 0); - EXPECT_TRUE(errors.str().empty()); - EXPECT_NE(output.str().find("\"requested_duration_s\":0.0"), std::string::npos); - EXPECT_NE( - output.str().find("\"endpoint\":\"127.0.0.1:" + std::to_string(sensor.rdt_port()) + "\""), - std::string::npos); -} - -TEST(CliMonitor, RejectsNonfiniteJsonWithoutEmittingInvalidDocument) { - netft::test::FakeSensor sensor; - sensor.pause(); - constexpr std::array maximum_axes{ - std::numeric_limits::max(), std::numeric_limits::max(), - std::numeric_limits::max(), std::numeric_limits::max(), - std::numeric_limits::max(), std::numeric_limits::max()}; - for (std::uint32_t sequence = 1; sequence <= 64; ++sequence) { - sensor.queue_record(sequence, 0, 1000 + sequence * 4, maximum_axes); - } - auto options = options_for(netft::cli::Command::Monitor, sensor); - options.config.calibration_override = - netft::Calibration{1e-300, 1e-300, netft::ForceUnit::Newton, netft::TorqueUnit::NewtonMeter}; - const auto output_path = temporary_path("nonfinite.json"); - const std::string original_contents = std::to_string(::getpid()); - { - std::ofstream existing{output_path}; - existing << original_contents; - } - options.output_path = output_path.string(); - sensor.resume(); - std::ostringstream output; - std::ostringstream errors; - - EXPECT_EQ(netft::cli::run(options, output, errors), 2); - EXPECT_TRUE(output.str().empty()); - EXPECT_FALSE(errors.str().empty()); - std::ifstream existing{output_path}; - const std::string contents{std::istreambuf_iterator{existing}, {}}; - EXPECT_EQ(contents, original_contents); - std::filesystem::remove(output_path); -} - -TEST(CliMonitor, HumanOutputIsNonempty) { - netft::test::FakeSensor sensor; - auto options = options_for(netft::cli::Command::Monitor, sensor); - options.json = false; - std::ostringstream output; - std::ostringstream errors; - - ASSERT_EQ(netft::cli::run(options, output, errors), 0); - EXPECT_TRUE(errors.str().empty()); - EXPECT_FALSE(output.str().empty()); -} - -TEST(CliMonitor, ReturnsOneForDeviceWarnings) { - netft::test::FakeSensor sensor; - sensor.pause(); - sensor.queue_record(1, 0x80010000U, 100); - auto options = options_for(netft::cli::Command::Monitor, sensor); - options.duration = 80ms; - sensor.resume(); - std::ostringstream output; - std::ostringstream errors; - - EXPECT_EQ(netft::cli::run(options, output, errors), 1); - EXPECT_NE(output.str().find("\"warning_count\":"), std::string::npos); -} - -TEST(CliMonitor, SeriousOnlyStreamReturnsOneWithStatusEvidence) { - netft::test::FakeSensor sensor; - sensor.pause(); - constexpr std::uint32_t serious_status = 0x00000001U; - for (std::uint32_t sequence = 1; sequence <= 16; ++sequence) { - sensor.queue_record(sequence, serious_status, 1000 + sequence * 4); - } - auto options = options_for(netft::cli::Command::Monitor, sensor); - options.duration = 80ms; - sensor.resume(); - std::ostringstream output; - std::ostringstream errors; - - EXPECT_EQ(netft::cli::run(options, output, errors), 1); - EXPECT_TRUE(errors.str().empty()); - EXPECT_NE(output.str().find("\"device_error_count\":1"), std::string::npos); - EXPECT_NE(output.str().find("\"device_status\":1"), std::string::npos); -} - -TEST(CliMonitor, ReturnsTwoWhenNoSampleArrives) { - netft::test::FakeSensor sensor; - sensor.pause(); - auto options = options_for(netft::cli::Command::Monitor, sensor); - options.duration = 30ms; - std::ostringstream output; - std::ostringstream errors; - - EXPECT_EQ(netft::cli::run(options, output, errors), 2); - EXPECT_TRUE(output.str().empty()); - EXPECT_FALSE(errors.str().empty()); -} - -TEST(CliMonitor, ReturnsTwoAfterAStoredClientTimeout) { - netft::test::FakeSensor sensor; - sensor.pause(); - auto options = options_for(netft::cli::Command::Monitor, sensor); - options.config.receive_timeout = 20ms; - options.config.recovery_policy = netft::RecoveryPolicy::FailStop; - options.duration = 80ms; - std::ostringstream output; - std::ostringstream errors; - - EXPECT_EQ(netft::cli::run(options, output, errors), 2); - EXPECT_TRUE(output.str().empty()); - EXPECT_FALSE(errors.str().empty()); -} - -TEST(CliBias, BiasesAfterFirstSampleAndReportsLaterSample) { - netft::test::FakeSensor sensor{10.0}; - auto options = options_for(netft::cli::Command::Bias, sensor); - options.config.receive_timeout = 250ms; - std::ostringstream output; - std::ostringstream errors; - - EXPECT_EQ(netft::cli::run(options, output, errors), 0); - EXPECT_TRUE(sensor.wait_for_command(netft::detail::Command::SetSoftwareBias)); - const auto commands = sensor.commands(); - ASSERT_GE(commands.size(), 3U); - EXPECT_EQ(commands[0], netft::detail::Command::StartRealtime); - EXPECT_EQ(commands[1], netft::detail::Command::SetSoftwareBias); - EXPECT_EQ(commands[2], netft::detail::Command::StartRealtime); - EXPECT_NE(output.str().find("\"bias_applied\":true"), std::string::npos); - EXPECT_TRUE(sample_count_matches_delivered_count(output.str())); - EXPECT_EQ(json_unsigned(output.str(), "last_rdt_sequence"), 2U); - EXPECT_GE(json_unsigned(output.str(), "delivered_count"), 3U); -} - -TEST(CliBias, StopsWhenAlreadyInterruptedBeforeAFirstSample) { - netft::test::FakeSensor sensor; - sensor.pause(); - auto options = options_for(netft::cli::Command::Bias, sensor); - volatile std::sig_atomic_t interrupted = 1; - std::ostringstream output; - std::ostringstream errors; - - EXPECT_EQ(netft::cli::run(options, output, errors, &interrupted), 130); - EXPECT_TRUE(output.str().empty()); -} - -TEST(CliBias, ReturnsTwoWhenTheClientFaultsBeforeAFirstSample) { - netft::test::FakeSensor sensor; - sensor.pause(); - auto options = options_for(netft::cli::Command::Bias, sensor); - options.config.receive_timeout = 20ms; - options.config.recovery_policy = netft::RecoveryPolicy::FailStop; - std::ostringstream output; - std::ostringstream errors; - - EXPECT_EQ(netft::cli::run(options, output, errors), 2); - EXPECT_TRUE(output.str().empty()); - EXPECT_FALSE(errors.str().empty()); -} - -TEST(CliOutput, WritesResultToRequestedFile) { - netft::test::FakeSensor sensor; - auto options = options_for(netft::cli::Command::Info, sensor); - const auto path = std::filesystem::temp_directory_path() / - ("netft-cli-" + std::to_string(::getpid()) + ".json"); - std::filesystem::remove(path); - options.output_path = path.string(); - std::ostringstream output; - std::ostringstream errors; - - ASSERT_EQ(netft::cli::run(options, output, errors), 0); - EXPECT_TRUE(output.str().empty()); - std::ifstream file{path}; - const std::string text{std::istreambuf_iterator{file}, {}}; - EXPECT_FALSE(text.empty()); - std::filesystem::remove(path); -} - -TEST(CliOutput, PreservesAnExistingDirectoryWhenReplacementFails) { - netft::test::FakeSensor sensor; - auto options = options_for(netft::cli::Command::Info, sensor); - const auto path = temporary_path("output-directory"); - std::filesystem::remove_all(path); - ASSERT_TRUE(std::filesystem::create_directory(path)); - options.output_path = path.string(); - std::ostringstream output; - std::ostringstream errors; - - EXPECT_EQ(netft::cli::run(options, output, errors), 2); - EXPECT_TRUE(output.str().empty()); - EXPECT_TRUE(std::filesystem::is_directory(path)); - std::filesystem::remove(path); -} - -TEST(CliProcess, SigintStopsClientAndReturns130) { - netft::test::FakeSensor sensor; - const int null_fd = ::open("/dev/null", O_WRONLY); - ASSERT_GE(null_fd, 0); - const auto pid = spawn_cli({"monitor", "--host", sensor.host(), "--rdt-port", - std::to_string(sensor.rdt_port()), "--http-port", - std::to_string(sensor.http_port()), "--duration", "30", "--json"}, - null_fd, null_fd); - ::close(null_fd); - - ASSERT_TRUE(sensor.wait_for_command(netft::detail::Command::StartRealtime)); - ASSERT_EQ(::kill(pid, SIGINT), 0); - int status{}; - ASSERT_EQ(::waitpid(pid, &status, 0), pid); - ASSERT_TRUE(WIFEXITED(status)); - EXPECT_EQ(WEXITSTATUS(status), 130); - EXPECT_TRUE(sensor.wait_for_command(netft::detail::Command::StopStreaming)); -} - -TEST(CliProcess, SigintDuringInfoSuppressesResultAndReturns130AfterDiscovery) { - netft::test::FakeSensor sensor; - sensor.set_http_response_delay(200ms); - const auto stdout_path = temporary_path("info-sigint.out"); - std::filesystem::remove(stdout_path); - const int stdout_fd = ::open(stdout_path.c_str(), O_CREAT | O_TRUNC | O_WRONLY, 0600); - const int null_fd = ::open("/dev/null", O_WRONLY); - ASSERT_GE(stdout_fd, 0); - ASSERT_GE(null_fd, 0); - const auto pid = spawn_cli({"info", "--host", sensor.host(), "--http-port", - std::to_string(sensor.http_port()), "--json"}, - stdout_fd, null_fd); - ::close(stdout_fd); - ::close(null_fd); - - ASSERT_TRUE(sensor.wait_for_http_request()); - ASSERT_EQ(::kill(pid, SIGINT), 0); - int status{}; - EXPECT_EQ(::waitpid(pid, &status, WNOHANG), 0) << "info exited before delayed discovery unwound"; - ASSERT_EQ(::waitpid(pid, &status, 0), pid); - ASSERT_TRUE(WIFEXITED(status)); - EXPECT_EQ(WEXITSTATUS(status), 130); - std::ifstream file{stdout_path}; - const std::string text{std::istreambuf_iterator{file}, {}}; - EXPECT_TRUE(text.empty()); - std::filesystem::remove(stdout_path); -} - -} // namespace