diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9506cd5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Preserve the byte inventory when Windows enables core.autocrlf. +/core/** -text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b9dc0b..90ef954 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - - uses: prefix-dev/setup-pixi@v0.10.0 + - uses: prefix-dev/setup-pixi@a09b6247153796b190642a2b53fac4241043cf6f # v0.10.0 with: cache: true - name: Verify controlled core snapshot @@ -85,7 +85,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - - uses: prefix-dev/setup-pixi@v0.10.0 + - uses: prefix-dev/setup-pixi@a09b6247153796b190642a2b53fac4241043cf6f # v0.10.0 with: cache: true - name: Configure @@ -113,7 +113,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - - uses: prefix-dev/setup-pixi@v0.10.0 + - uses: prefix-dev/setup-pixi@a09b6247153796b190642a2b53fac4241043cf6f # v0.10.0 with: cache: true - name: Configure @@ -145,7 +145,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - - uses: prefix-dev/setup-pixi@v0.10.0 + - uses: prefix-dev/setup-pixi@a09b6247153796b190642a2b53fac4241043cf6f # v0.10.0 with: cache: true - name: Verify pinned CMake version @@ -169,7 +169,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - - uses: prefix-dev/setup-pixi@v0.10.0 + - uses: prefix-dev/setup-pixi@a09b6247153796b190642a2b53fac4241043cf6f # v0.10.0 with: cache: true - name: Configure @@ -205,7 +205,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - - uses: prefix-dev/setup-pixi@v0.10.0 + - uses: prefix-dev/setup-pixi@a09b6247153796b190642a2b53fac4241043cf6f # v0.10.0 with: cache: true - name: Configure diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 33c2546..34becf0 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -35,18 +35,18 @@ jobs: persist-credentials: false - name: Initialize Python CodeQL if: matrix.language == 'python' - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} - name: Initialize C++ CodeQL with inline suppressions if: matrix.language == 'c-cpp' - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} packs: ${{ matrix.suppression-pack }} - - uses: prefix-dev/setup-pixi@v0.10.0 + - uses: prefix-dev/setup-pixi@a09b6247153796b190642a2b53fac4241043cf6f # v0.10.0 if: matrix.language == 'c-cpp' with: cache: true @@ -54,4 +54,4 @@ jobs: if: matrix.language == 'c-cpp' run: pixi run install - name: Analyze - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4 diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 56341cb..06f51e6 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -20,13 +20,13 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - - uses: prefix-dev/setup-pixi@v0.10.0 + - uses: prefix-dev/setup-pixi@a09b6247153796b190642a2b53fac4241043cf6f # v0.10.0 with: cache: true - name: Generate Python and native coverage run: pixi run coverage - name: Upload Python coverage - uses: codecov/codecov-action@v7 + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7 continue-on-error: true with: files: coverage.xml @@ -34,7 +34,7 @@ jobs: flags: python use_oidc: true - name: Upload native coverage - uses: codecov/codecov-action@v7 + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7 continue-on-error: true with: files: coverage-native.xml diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 3bbdecd..07668c0 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -5,6 +5,11 @@ on: push: branches: [main] workflow_dispatch: + inputs: + windows_only: + description: Build only the Windows CPython 3.10 smoke wheel + type: boolean + default: false permissions: contents: read @@ -15,6 +20,7 @@ concurrency: jobs: curl-build: + if: ${{ !inputs.windows_only }} runs-on: ubuntu-24.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -46,6 +52,8 @@ jobs: with: python-version: "3.14" cache: pip + - name: Verify controlled core snapshot + run: python tools/sync_core.py verify - name: Install wheel build frontend run: python -m pip install cibuildwheel==3.4.1 auditwheel - name: Build CPython 3.10 x86_64 wheel @@ -85,6 +93,8 @@ jobs: with: python-version: "3.14" cache: pip + - name: Verify controlled core snapshot + run: python tools/sync_core.py verify - name: Install wheel build frontend run: python -m pip install cibuildwheel==3.4.1 delocate - name: Build CPython 3.10 native macOS wheel @@ -105,7 +115,7 @@ jobs: if-no-files-found: error windows-smoke: - if: github.event_name == 'pull_request' + if: ${{ always() && !cancelled() && ((github.event_name == 'pull_request' && needs.curl-build.result == 'success') || inputs.windows_only) }} needs: curl-build runs-on: windows-2025 steps: @@ -116,6 +126,8 @@ jobs: with: python-version: "3.14" cache: pip + - name: Verify controlled core snapshot + run: python tools/sync_core.py verify - name: Install wheel build frontend run: python -m pip install cibuildwheel==3.4.1 pefile - name: Build CPython 3.10 Windows wheel @@ -135,7 +147,7 @@ jobs: if-no-files-found: error wheels: - if: github.event_name != 'pull_request' + if: github.event_name != 'pull_request' && !inputs.windows_only needs: curl-build strategy: fail-fast: false @@ -154,6 +166,8 @@ jobs: with: python-version: "3.14" cache: pip + - name: Verify controlled core snapshot + run: python tools/sync_core.py verify - name: Install wheel build frontend run: python -m pip install cibuildwheel==3.4.1 auditwheel - name: Build supported wheels @@ -173,7 +187,7 @@ jobs: if-no-files-found: error macos-wheels: - if: github.event_name != 'pull_request' + if: github.event_name != 'pull_request' && !inputs.windows_only needs: curl-build strategy: fail-fast: false @@ -192,6 +206,8 @@ jobs: with: python-version: "3.14" cache: pip + - name: Verify controlled core snapshot + run: python tools/sync_core.py verify - name: Install wheel build frontend run: python -m pip install cibuildwheel==3.4.1 delocate - name: Build supported native macOS wheels @@ -211,7 +227,7 @@ jobs: if-no-files-found: error windows-wheels: - if: github.event_name != 'pull_request' + if: github.event_name != 'pull_request' && !inputs.windows_only needs: curl-build runs-on: windows-2025 steps: @@ -222,6 +238,8 @@ jobs: with: python-version: "3.14" cache: pip + - name: Verify controlled core snapshot + run: python tools/sync_core.py verify - name: Install wheel build frontend run: python -m pip install cibuildwheel==3.4.1 pefile - name: Build supported Windows wheels diff --git a/CHANGELOG.md b/CHANGELOG.md index a2057bc..cbc20a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project are documented in this file. ## Unreleased +## 2.1.2 - 2026-10-08 + +- Integrate netft-cpp 0.3.4's calibration and checked-time protections. +- Support exact-commit snapshot synchronization and pin reviewed build actions. +- Explain latest-value versus bounded capture queues, drop counters, legacy migration and seven-repository maintenance. + ## 2.1.1 - 2026-08-14 ### Fixed diff --git a/CMakeLists.txt b/CMakeLists.txt index a2b3116..6626d27 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.16) -project(pynetft VERSION 2.1.1 LANGUAGES CXX) +project(pynetft VERSION 2.1.2 LANGUAGES CXX) option(PYNETFT_BUILD_TESTING "Build pyNetFT native tests" OFF) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5237e6c..c617bfb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,13 +85,17 @@ Pass the authorized address only through `NETFT_SENSOR_HOST`. Never commit it. T ## Cross-repository synchronization -The `netft` organization maintains three separate repositories: +The `netft` organization maintains seven separate repositories: - `netft-cpp` is the standalone C++ core and source of truth for shared protocol behavior; - `pyNetFT` contains a pinned private core snapshot, Python bindings, and the Python public API; and -- `ros-netft` contains its own private core snapshot and ROS integration. +- `ros-netft` contains its own private core snapshot and ROS integration; +- `netft-cli` owns terminal workflows and its reviewed core adaptation; +- `netft-viewer` owns desktop interaction, companion and packaging; +- `netft-docs` maintains shared user guides and version-pinned references at https://netft.dev; and +- `.github` maintains organization navigation, common templates and issue routing. -There is no automatic runtime or build-time dependency between these repositories. Synchronize a released core into each consumer through separate, reviewable pull requests. Record the exact upstream tag and commit, port consumer-specific integration independently, run that repository's full supported tests, and document intentional differences. Do not claim the snapshots update in lockstep. +There is no automatic runtime or build-time dependency between these repositories. Synchronize a released core into each consumer through separate, reviewable pull requests. Record the exact upstream tag and commit, port consumer-specific integration independently, run the necessary checks for the affected contract and supported release platforms, and document intentional differences. Do not claim the snapshots update in lockstep. ## Release workflow @@ -124,3 +128,14 @@ Maintainers must verify the publisher owner, repository, workflow filename, and Keep each pull request limited to one coherent change. Describe the problem, chosen behavior, tests run, supported platforms, hardware involvement, and any core or ROS synchronization impact. Update public documentation and `CHANGELOG.md` when users need to know about a change. All contributions are submitted under the [Apache License 2.0](https://github.com/netft/pyNetFT/blob/main/LICENSE). The prior pyNetFT MIT text and third-party license notices remain in `LICENSES/`. + +### Current core candidate + +The private snapshot uses the exact netft-cpp `v0.3.4` release commit `1ce68a08a9387d9a36d22c3c259e44533199c347`; `UPSTREAM` and its content manifest record that release identity. Update from a clean upstream checkout using: + +```sh +python tools/sync_core.py sync --source /path/to/netft-cpp --tag v0.3.4 +python tools/sync_core.py verify +``` + +The manifest checks the selected bytes, including provenance. It detects drift and does not authenticate a remote release. Consumer build files remain private; fixes to core source belong upstream. diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index b24722c..e7a6ce4 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.16) -project(netft VERSION 0.3.3 LANGUAGES CXX) +project(netft VERSION 0.3.4 LANGUAGES CXX) include(CTest) include(GNUInstallDirs) diff --git a/core/SNAPSHOT.sha256 b/core/SNAPSHOT.sha256 index 6006ee5..84f2eab 100644 --- a/core/SNAPSHOT.sha256 +++ b/core/SNAPSHOT.sha256 @@ -1,6 +1,6 @@ -3db6fdfbb62d6073fa3f77784235cfe2eff06a1f102fb1d0f3a3f6da02dafa01 CMakeLists.txt +b2329e8fa84798edb7ddf912d6ea79455bd8181d607b28ae94297e2f409398f4 CMakeLists.txt c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4 LICENSE -926aedecaf33e0d9a178360ea455ee9cbdd54537f89d75835409fdb28fdcd908 UPSTREAM +ce471ce02972730df08fb35bc3c3ea60346dc4e79f7780ad1d55126d1cb61395 UPSTREAM 40701f04e67bb18052b6c8f1db60cf5d41baab09e5396f5a88c1eb14ad1a7139 cmake/netftConfig.cmake.in e4a52fe7c7e13de95a29bdbd688d295a4a8ddc1805276274b0c14d4092f7253d include/netft/client.hpp 4f7fb383da5456fd8477e1981f4ff3eba9b82ec3a98f3c395e7d482faf75c112 include/netft/discovery.hpp @@ -8,7 +8,7 @@ e4a52fe7c7e13de95a29bdbd688d295a4a8ddc1805276274b0c14d4092f7253d include/netft/ 912892bcb8bb5f95392c9c74983d91aab103b7adc5735505b0ac97e1012893b1 include/netft/status.hpp a7c899858aa7a0a28780504f14c1ecdaebb375607119a03500903f3782f45a68 include/netft/types.hpp 7199f02fa0fd88059323f507edbf073b2a4827c692d1fd824893cbad8ea9215b src/client.cpp -a7e0bcba7a8cd5ccb1ab04658ea735f2b6a3f2bac81c3bce95578e3c14349650 src/detail/client_impl.cpp +d4a7c89afa6a98ef687e7ecb266c8ce3287ec2f01c4f14d29278cf3cce140640 src/detail/client_impl.cpp b202cc6e250866e681d16b480911f77d865d9187300111f8482bc4a8b0424b34 src/detail/client_impl.hpp ad8744385642693bb4666c428a5dbd0d6eff350b0079a6c9ff720512e89b6a53 src/detail/fault_latch.cpp be488c557e17044948cd62e6de07bf54862d389a584b40b78336479cecdaf23c src/detail/fault_latch.hpp @@ -16,11 +16,12 @@ f5953a7fc4fd5eee56f7aa3568fe7417010e3e767cfbb0ee5922ee5f8aa2a9aa src/detail/pro 8ef13600d67b2bb77181c5403e9b51b7f48b6f8ef06381fb1f07c5b9b2a90d8a src/detail/protocol.hpp fe9034a4a32138c3469005a44a1fbd61cfe41df25207723635a22e8ed376dbf5 src/detail/sequence.cpp 72daab01251425dd7fd5d26334760f4937f94e674a16df0fb38301b0ee78b9c1 src/detail/sequence.hpp +8fdc8f3373008f41ea032478ac44ef5274013eeb4ea56f5fdd265895672ee0ae src/detail/time.hpp 78098e0c98c84103653b7aa9e2baf689cde656b52e1e13873f7491aed7f35ff5 src/detail/udp_transport.hpp -6c4fb6a8b6bdfc9f1364ac0d76423e9972e1eb30bb987aa6865e191e7d93f9ea src/detail/udp_transport_posix.cpp -9a9e34da6e9d66e0918e85da61ebc60f9ae3806a0471c327f818e8ccd210426e src/detail/udp_transport_windows.cpp -3e908eac916b3cf457387fd878dd77fb46e3b5a9eee37dfc60be788649476475 src/detail/xml_config.cpp +f6e7e14de70a366e3d25b139dee8f061cdfff4c5173c78b831e564125b81d4b2 src/detail/udp_transport_posix.cpp +d48e67880a9c0db5f2c9b4045cc7caddb7fdfbb934cb676d25914c9cf2d5cfe5 src/detail/udp_transport_windows.cpp +95a05fe378b735b433ea10f438e9f5338120830e054b30ed11cd8db12d760cea src/detail/xml_config.cpp f10ff61cd2cc645abca23a7abce37439273569c6fe139a4fb07041532edb908d src/detail/xml_config.hpp 5a25276d0307c6a341aaee525cc9a66f00cda06e30bd345716ff5ee17a57e3e7 src/discovery.cpp 2598c182a2322925c2d99e1d0bba3b57215e451ba1ecc8df6b108b885f089c1f src/status.cpp -6017859e709c6b80fd5dc444554e497410e1c7d19a12ae05502f0c9922c58762 src/types.cpp +2b59ff7f0c88207e47d295168eaf4383fbecc008b5e4f1346fb63fddfe02b72b src/types.cpp diff --git a/core/UPSTREAM b/core/UPSTREAM index c7bc616..a257e23 100644 --- a/core/UPSTREAM +++ b/core/UPSTREAM @@ -1,4 +1,4 @@ repository=https://github.com/netft/netft-cpp -tag=v0.3.3 -commit=3259b8576b16fb150b51e9ef9090366c5e6efcba +tag=v0.3.4 +commit=1ce68a08a9387d9a36d22c3c259e44533199c347 paths=CMakeLists.txt,LICENSE,cmake,include,src diff --git a/core/src/detail/client_impl.cpp b/core/src/detail/client_impl.cpp index 5ebc20c..c8f7167 100644 --- a/core/src/detail/client_impl.cpp +++ b/core/src/detail/client_impl.cpp @@ -1,7 +1,9 @@ #include "detail/client_impl.hpp" +#include "detail/time.hpp" #include #include +#include #include #include #include @@ -301,12 +303,13 @@ void Client::Impl::bias() { } bool Client::Impl::wait_for_first_sample(const std::chrono::duration timeout) { + const auto deadline = detail::checked_deadline(std::chrono::steady_clock::now(), 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] { + first_sample_cv_.wait_until(data_lock, deadline, [this, captured_generation] { return generation_ != captured_generation || delivered_generation_ == captured_generation || stopping_ || faulted(); }); @@ -406,7 +409,9 @@ void Client::Impl::run() noexcept { } std::unique_lock data_lock(data_mutex_); - first_sample_cv_.wait_for(data_lock, backoff, [this] { return stopping_.load(); }); + first_sample_cv_.wait_until(data_lock, + detail::checked_deadline(detail::SteadyClock::now(), backoff), + [this] { return stopping_.load(); }); data_lock.unlock(); backoff = std::min(backoff * 2.0, config_.reconnect_max_delay); } @@ -461,7 +466,7 @@ Client::Impl::SessionOutcome Client::Impl::receive_session() { } const auto timeout = config_.receive_timeout; - auto deadline = std::chrono::steady_clock::now() + timeout; + auto deadline = detail::checked_deadline(std::chrono::steady_clock::now(), timeout); std::array buffer{}; while (!stopping_) { const auto now = std::chrono::steady_clock::now(); @@ -511,7 +516,7 @@ Client::Impl::SessionOutcome Client::Impl::receive_session() { consecutive_malformed_ = 0; } received_valid_record = true; - deadline = received_at + timeout; + deadline = detail::checked_deadline(received_at, timeout); if (const auto outcome = handle_record(record, received_at)) { return {*outcome, {}, received_valid_record}; } @@ -631,6 +636,11 @@ Client::Impl::handle_record(const detail::RawRecord &record, sample.torque = {record.tx / calibration.counts_per_torque_unit, record.ty / calibration.counts_per_torque_unit, record.tz / calibration.counts_per_torque_unit}; + const auto finite = [](double value) { return std::isfinite(value); }; + if (!std::all_of(sample.force.begin(), sample.force.end(), finite) || + !std::all_of(sample.torque.begin(), sample.torque.end(), finite)) { + return SessionResult::SensorConfiguration; + } sample.force_unit = calibration.force_unit; sample.torque_unit = calibration.torque_unit; sample.configuration_revision = configuration.revision; diff --git a/core/src/detail/time.hpp b/core/src/detail/time.hpp new file mode 100644 index 0000000..b5c1aae --- /dev/null +++ b/core/src/detail/time.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#include +#include + +namespace netft::detail { +using SteadyClock = std::chrono::steady_clock; + +inline SteadyClock::duration checked_duration(std::chrono::duration duration) { + const auto ticks = static_cast(duration.count()) * SteadyClock::period::den / + SteadyClock::period::num; + if (!std::isfinite(ticks) || ticks < 0 || + ticks >= static_cast(SteadyClock::duration::max().count())) { + throw std::invalid_argument("duration is outside the supported steady-clock range"); + } + return SteadyClock::duration{static_cast(ticks)}; +} + +inline SteadyClock::time_point checked_deadline(SteadyClock::time_point origin, + std::chrono::duration duration) { + const auto ticks = checked_duration(duration); + if (origin > SteadyClock::time_point::max() - ticks) { + throw std::invalid_argument("deadline is outside the supported steady-clock range"); + } + return origin + ticks; +} +} // namespace netft::detail diff --git a/core/src/detail/udp_transport_posix.cpp b/core/src/detail/udp_transport_posix.cpp index 5af2b7f..923f7f8 100644 --- a/core/src/detail/udp_transport_posix.cpp +++ b/core/src/detail/udp_transport_posix.cpp @@ -1,3 +1,4 @@ +#include "detail/time.hpp" #include "detail/udp_transport.hpp" #include @@ -106,7 +107,7 @@ std::size_t UdpTransport::receive(std::uint8_t *data, const std::size_t capacity } pollfd descriptor{socket, POLLIN, 0}; - const auto deadline = std::chrono::steady_clock::now() + timeout; + const auto deadline = checked_deadline(std::chrono::steady_clock::now(), timeout); auto remaining = timeout; int poll_result{}; if (wait_started_hook != nullptr) { diff --git a/core/src/detail/udp_transport_windows.cpp b/core/src/detail/udp_transport_windows.cpp index 1330f54..7209db2 100644 --- a/core/src/detail/udp_transport_windows.cpp +++ b/core/src/detail/udp_transport_windows.cpp @@ -1,3 +1,4 @@ +#include "detail/time.hpp" #include "detail/udp_transport.hpp" #ifndef NOMINMAX @@ -145,8 +146,9 @@ std::size_t UdpTransport::receive(std::uint8_t *data, const std::size_t capacity if (wait_started_hook != nullptr) { wait_started_hook(wait_started_context); } - const auto bounded_timeout = std::max(timeout, std::chrono::duration::zero()); - const auto deadline = std::chrono::steady_clock::now() + bounded_timeout; + static_cast(checked_duration(timeout)); + const auto bounded_timeout = timeout; + const auto deadline = checked_deadline(std::chrono::steady_clock::now(), bounded_timeout); constexpr int kShutdownCheckIntervalMilliseconds = 50; constexpr auto kShutdownCheckInterval = std::chrono::milliseconds{kShutdownCheckIntervalMilliseconds}; diff --git a/core/src/detail/xml_config.cpp b/core/src/detail/xml_config.cpp index 3e6bb03..dd40fef 100644 --- a/core/src/detail/xml_config.cpp +++ b/core/src/detail/xml_config.cpp @@ -278,6 +278,11 @@ SensorConfiguration parse_sensor_configuration(std::string_view xml) { 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]); + try { + validate(configuration.calibration); + } catch (const std::invalid_argument &error) { + throw DiscoveryError(error.what()); + } configuration.source = CalibrationSource::Sensor; configuration.revision = 1; return configuration; diff --git a/core/src/types.cpp b/core/src/types.cpp index 3f2824b..5698a53 100644 --- a/core/src/types.cpp +++ b/core/src/types.cpp @@ -1,8 +1,10 @@ #include "netft/types.hpp" +#include "detail/time.hpp" #include #include #include +#include #include namespace netft { @@ -20,17 +22,43 @@ void require_positive_finite(std::string_view name, double value) { } } +void require_safe_timeout(std::string_view name, double value, bool http = false) { + require_positive_finite(name, value); + if (detail::checked_duration(std::chrono::duration{value}) <= + detail::SteadyClock::duration::zero()) { + throw std::invalid_argument(std::string{name} + " is below the steady-clock resolution"); + } + static_cast( + detail::checked_deadline(detail::SteadyClock::now(), std::chrono::duration{value})); + if (http && std::ceil(static_cast(value) * 1000.0L) >= + static_cast(std::numeric_limits::max())) { + throw std::invalid_argument(std::string{name} + " is outside the HTTP timer range"); + } +} + 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"); } } +void require_safe_count(std::string_view name, double value) { + require_positive_finite(name, value); + // INT32_MIN has the largest magnitude. Round the minimum divisor up so + // every raw count remains representable, including under directed rounding. + constexpr double magnitude = -static_cast(std::numeric_limits::min()); + constexpr double minimum = (magnitude / std::numeric_limits::max()) * + (1.0 + std::numeric_limits::epsilon()); + if (value < minimum) { + throw std::invalid_argument(std::string{name} + " cannot represent the full raw count range"); + } +} + } // 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); + require_safe_count("counts_per_force_unit", calibration.counts_per_force_unit); + require_safe_count("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"); @@ -43,12 +71,12 @@ void validate(const Config &config) { } 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()); + require_safe_timeout("receive_timeout", config.receive_timeout.count()); + require_safe_timeout("configuration_connect_timeout", + config.configuration_connect_timeout.count(), true); + require_safe_timeout("configuration_timeout", config.configuration_timeout.count(), true); + require_safe_timeout("reconnect_initial_delay", config.reconnect_initial_delay.count()); + require_safe_timeout("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"); } diff --git a/docs/api.md b/docs/api.md index 13b31b1..5032b27 100644 --- a/docs/api.md +++ b/docs/api.md @@ -109,11 +109,11 @@ If a callback raises, pyNetFT stops the run and stores the original exception. ` | `recovery_policy` | `RecoveryPolicy` | `RECONNECT` | Reconnect after recoverable failures or stop on the first fault. | | `calibration_override` | `Calibration \| None` | `None` | Complete independently verified calibration; `None` discovers it from the sensor. | -`Calibration(counts_per_force_unit, counts_per_torque_unit, force_unit, torque_unit)` is immutable. Both counts must be finite and positive. A calibration override is complete; force or torque values cannot be overridden independently. +`Calibration(counts_per_force_unit, counts_per_torque_unit, force_unit, torque_unit)` is immutable. Both counts must be finite and positive. Since 2.1.2 they must also keep every signed 32-bit raw count representable after conversion. A calibration override is complete; force or torque values cannot be overridden independently. `SensorConfiguration(product_name, calibration, source, revision)` is immutable. `source` distinguishes sensor discovery from a caller override. `revision` changes when an effective configuration change is observed. -Validation occurs when `Config` and `queue_size` are passed to `Client`. `queue_size` must be greater than zero. `sensor_host` must contain a non-whitespace host, and both ports must be in `1..65535`. All five configured timeout and reconnect-delay values must be finite and greater than zero; `reconnect_max_delay` must be at least `reconnect_initial_delay`. `sample_rate_limit_hz` must be finite and non-negative. A calibration override must have finite positive counts and neither unit may be `UNKNOWN`. +Validation occurs when `Config` and `queue_size` are passed to `Client`. `queue_size` must be greater than zero. `sensor_host` must contain a non-whitespace host, and both ports must be in `1..65535`. All five configured timeout and reconnect-delay values must be finite and greater than zero; this version also checks native clock resolution/range and HTTP millisecond range; `reconnect_max_delay` must be at least `reconnect_initial_delay`. `sample_rate_limit_hz` must be finite and non-negative. A calibration override must have finite positive counts and neither unit may be `UNKNOWN`. The per-read timeout accepted by `samples()` must be finite, non-negative, and representable by the native monotonic clock; invalid values raise `ConfigurationError`. `wait_for_first_sample()` applies the same timeout validation. @@ -184,3 +184,22 @@ NetFTError Its methods are `connect()`, `disconnect()`, `bias()`, `get_data()`, `get_converted_data()`, and `start_streaming(duration=10, delay=0.1, print_data=True)`. Reading or biasing before `connect()` raises `ConnectionError`. `Response` has mutable `rdt_sequence`, `ft_sequence`, `status`, and list-valued `FTData` fields. See the [2.0 migration guide](https://github.com/netft/pyNetFT/blob/main/docs/migration-2.md) for exact replacements. + +## Choose latest-value access or capture + +For displays and feedback that need current data, use `queue_size=1` and read `latest_sample()`; this does not consume the iterator queue. A latest-value view intentionally skips intermediate measurements. For capture, give one iterator consumer a queue sized for the longest expected consumer stall: delivered rate multiplied by stall seconds, plus headroom. At a synthetic 2000 samples/s and a 0.5 s stall, 4096 entries provides headroom; this is a sizing example, not a hardware specification. + +```python +from itertools import islice +from pynetft import Client, Config + +with Client(Config(sensor_host="192.168.1.1"), queue_size=4096) as client: + captured = list(islice(client.samples(timeout=1.0), 1000)) + health = client.health() + if health.python_queue_dropped_count or health.lost_count: + raise RuntimeError("capture contains queue drops or missing RDT packets") +``` + +This example bounds retained samples and every iterator wait. Increasing queue size cannot eliminate network loss or sustain a consumer slower than the producer indefinitely. Inspect rate limiting, device status, malformed/out-of-order counters and recorded sequence numbers for your capture criteria. `python_queue_dropped_count` measures delivery-queue eviction; `lost_count` measures the native RDT sequence observation. Neither alone proves a complete measurement history. + +Legacy users should migrate `connect`/`disconnect` to `Client` context management, `get_data` to `samples` for capture, and `get_converted_data` to `Sample.wrench`. `latest_sample()` is suitable for polling the newest value. Preserve units explicitly: legacy manual calibration uses N/Nmm, whereas discovered samples carry the configured units. See the migration guide linked above for the full compatibility policy. diff --git a/pixi.toml b/pixi.toml index 0363c54..9813b1e 100644 --- a/pixi.toml +++ b/pixi.toml @@ -68,6 +68,6 @@ check = { depends-on = [ "native-tidy", ] } wheel = "python -m build --sdist --wheel --no-isolation" -wheel-check = "python tools/check_wheel.py dist/pynetft-2.1.1-*.whl" -sdist-check = "python tools/check_sdist.py dist/pynetft-2.1.1.tar.gz" +wheel-check = "python tools/check_wheel.py dist/pynetft-2.1.2-*.whl" +sdist-check = "python tools/check_sdist.py dist/pynetft-2.1.2.tar.gz" hardware-test = "python tools/hardware_test.py" diff --git a/pyproject.toml b/pyproject.toml index a0a7d32..48fde5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ build-backend = "scikit_build_core.build" [project] name = "pynetft" -version = "2.1.1" +version = "2.1.2" description = "Python client for ATI Net F/T Ethernet force/torque sensors" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/artifact/test_wheel.py b/tests/artifact/test_wheel.py index 76ae632..f740e71 100644 --- a/tests/artifact/test_wheel.py +++ b/tests/artifact/test_wheel.py @@ -24,7 +24,7 @@ def test_native_extension_and_type_marker_are_installed() -> None: import pynetft from pynetft import _native - assert pynetft.__version__ == "2.1.1" + assert pynetft.__version__ == "2.1.2" assert _native.__version__ == pynetft.__version__ assert resources.files("pynetft").joinpath("py.typed").is_file() diff --git a/tests/python/test_import.py b/tests/python/test_import.py index cb064aa..dd89128 100644 --- a/tests/python/test_import.py +++ b/tests/python/test_import.py @@ -5,7 +5,7 @@ def test_installed_version_and_native_module_agree() -> None: - assert pynetft.__version__ == "2.1.1" + assert pynetft.__version__ == "2.1.2" assert version("pynetft") == pynetft.__version__ assert _native.__version__ == pynetft.__version__ diff --git a/tests/quality/test_sync_core.py b/tests/quality/test_sync_core.py index 70c11fd..2fafda7 100644 --- a/tests/quality/test_sync_core.py +++ b/tests/quality/test_sync_core.py @@ -34,13 +34,34 @@ def test_sync_removes_files_outside_the_selected_snapshot( git(source, "add", ".") git(source, "commit", "-m", "fixture") git(source, "tag", "v-test") + git(source, "remote", "add", "origin", "https://github.com/netft/netft-cpp.git") destination = tmp_path / "core" destination.mkdir() stale = destination / "removed-upstream-path.txt" stale.write_text("stale\n", encoding="utf-8") - sync_core.sync(source, destination, "v-test") + sync_core.sync(source, destination, None, candidate=git(source, "rev-parse", "HEAD")) assert not stale.exists() sync_core.verify(destination) + + assert "tag=unreleased" in (destination / "UPSTREAM").read_text() + + +def test_snapshot_manifest_order_is_platform_independent(tmp_path: Path) -> None: + from pathlib import PureWindowsPath + + names = ["cmake/config.in", "UPSTREAM", "LICENSE", "CMakeLists.txt"] + for name in names: + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"snapshot\n") + # Windows Path comparison folds case; a Linux-generated manifest must not. + assert sorted(map(PureWindowsPath, names)) != sorted( + map(PureWindowsPath, names), key=lambda path: path.as_posix() + ) + sync_core.write_manifest(tmp_path) + recorded = (tmp_path / "SNAPSHOT.sha256").read_text().splitlines() + assert [line.split(" ", 1)[1] for line in recorded] == sorted(names) + sync_core.verify(tmp_path) diff --git a/tools/sync_core.py b/tools/sync_core.py index 17d6adb..7685d70 100644 --- a/tools/sync_core.py +++ b/tools/sync_core.py @@ -3,6 +3,7 @@ import argparse import hashlib +import re import shutil import subprocess import tempfile @@ -25,7 +26,8 @@ def digest(path: Path) -> str: def snapshot_files(root: Path) -> list[Path]: return sorted( - path for path in root.rglob("*") if path.is_file() and path.name != "SNAPSHOT.sha256" + (path for path in root.rglob("*") if path.is_file() and path.name != "SNAPSHOT.sha256"), + key=lambda path: path.relative_to(root).as_posix(), ) @@ -42,11 +44,29 @@ def verify(root: Path) -> None: f"{digest(path)} {path.relative_to(root).as_posix()}" for path in snapshot_files(root) ] if actual != expected: - raise SystemExit("core snapshot checksum mismatch") - - -def sync(source: Path, destination: Path, tag: str) -> None: - commit = git(source, "rev-parse", f"{tag}^{{commit}}") + expected_by_path = {line.split(" ", 1)[1]: line.split(" ", 1)[0] for line in expected} + actual_by_path = {line.split(" ", 1)[1]: line.split(" ", 1)[0] for line in actual} + differences = [ + f"{name}: expected={expected_by_path.get(name, 'missing')} " + f"actual={actual_by_path.get(name, 'missing')}" + for name in sorted(expected_by_path.keys() | actual_by_path.keys()) + if expected_by_path.get(name) != actual_by_path.get(name) + ] + raise SystemExit("core snapshot checksum mismatch\n" + "\n".join(differences)) + + +def sync(source: Path, destination: Path, tag: str | None, *, candidate: str | None = None) -> None: + if candidate is not None and not re.fullmatch(r"[0-9a-f]{40}", candidate): + raise SystemExit("candidate must be a full lowercase commit SHA") + if git(source, "remote", "get-url", "origin") != "https://github.com/netft/netft-cpp.git": + raise SystemExit("source repository mismatch") + reference = candidate or tag + if not reference: + raise SystemExit("source identity is required") + commit = git(source, "rev-parse", f"{reference}^{{commit}}") + if candidate is not None and commit != candidate: + raise SystemExit("candidate identity mismatch") + tag = "unreleased" if candidate else tag head = git(source, "rev-parse", "HEAD") if head != commit: raise SystemExit(f"{source} HEAD does not match {tag}") @@ -101,12 +121,14 @@ def main() -> None: subparsers = parser.add_subparsers(dest="command", required=True) sync_parser = subparsers.add_parser("sync") sync_parser.add_argument("--source", type=Path, required=True) - sync_parser.add_argument("--tag", required=True) + identity = sync_parser.add_mutually_exclusive_group(required=True) + identity.add_argument("--tag") + identity.add_argument("--commit") subparsers.add_parser("verify") arguments = parser.parse_args() root = Path(__file__).resolve().parents[1] if arguments.command == "sync": - sync(arguments.source.resolve(), root / "core", arguments.tag) + sync(arguments.source.resolve(), root / "core", arguments.tag, candidate=arguments.commit) else: verify(root / "core")