diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..e97c271
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,43 @@
+name: Test and build
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+permissions:
+ contents: read
+concurrency:
+ group: ci-${{ github.head_ref || github.ref_name }}
+ cancel-in-progress: true
+jobs:
+ tests:
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, windows-latest, macos-latest]
+ python: ['3.10', '3.11', '3.12', '3.13', '3.14', '3.15']
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-python@v7
+ with:
+ python-version: ${{ matrix.python }}
+ - run: python -m pip install .[dev]
+ - run: python -m pytest -q
+ - run: python -m ruff check src tests examples tools/build_runtime_release.py tools/configure_release.py tools/check_release.py tools/probe_private_runtime.py
+ - run: python -m ruff format --check src tests examples tools/build_runtime_release.py tools/configure_release.py tools/check_release.py tools/probe_private_runtime.py
+ - run: source-leap --demo --frames 3 --json
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-python@v7
+ with:
+ python-version: '3.15'
+ - run: python -m pip install build twine
+ - run: python -m build
+ - run: python -m twine check dist/*
+ - uses: actions/upload-artifact@v4
+ with:
+ name: python-distributions
+ path: dist/*
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..2447d5e
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,34 @@
+name: Publish Python package
+on:
+ workflow_dispatch:
+permissions:
+ contents: read
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-python@v7
+ with:
+ python-version: '3.15'
+ - run: python -m pip install .[dev] twine
+ - run: python -m pytest -q
+ - run: python -m build
+ - run: python -m twine check dist/*
+ - run: python tools/check_release.py
+ - uses: actions/upload-artifact@v4
+ with:
+ name: publish-distributions
+ path: dist/*
+ publish:
+ needs: build
+ runs-on: ubuntu-latest
+ environment: pypi
+ permissions:
+ id-token: write
+ steps:
+ - uses: actions/download-artifact@v4
+ with:
+ name: publish-distributions
+ path: dist/
+ - uses: pypa/gh-action-pypi-publish@release/v1
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1f2c507
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+.venv/
+.tools/
+.pytest_cache/
+.ruff_cache/
+__pycache__/
+*.py[cod]
+dist/
+build/
+*.egg-info/
+recordings/
+motion_data.txt
+
diff --git a/LICENSE b/LICENSE
index 04f09b5..b83b716 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,5 +1,6 @@
MIT License
+Copyright (c) 2021 ano0002
Copyright (c) 2026 Source Robotics
Permission is hereby granted, free of charge, to any person obtaining a copy
diff --git a/PACKAGE_DESIGN.md b/PACKAGE_DESIGN.md
new file mode 100644
index 0000000..676792a
--- /dev/null
+++ b/PACKAGE_DESIGN.md
@@ -0,0 +1,154 @@
+**Product design for `source-robotics-leap-motion` - revised 5 November 2026**
+
+The deployment preference is now a minimal runtime managed by this package, so users do not separately install the vendor GUI suite. The project owner has confirmed redistribution permission. Runtime delivery, the supported minimum Windows components, and any unavoidable USB-driver setup still need to be completed and tested before making that installation promise. See [minimal runtime investigation](docs/MINIMAL_RUNTIME.md). Alpha 0.1.0a3 adds local bundle preparation and a managed launcher. The private engine has delivered real hand skeletons as an ordinary user with the installed service stopped: 1,047 frames over ten seconds, including 50 full hand samples. Clean-PC installation and robust repeated camera handoff remain separate checks.
+
+Build a reusable Python library and a small companion command line tool. Users should install it with `pip install source-robotics-leap-motion`, obtain hand data with a short Python example, and connect different robot arms through configuration and small adapters. The user has confirmed the exact device as **Original Leap Motion Controller**; this is the first hardware target. The current Windows machine is the initial integration platform. PAROL6 and CM6 remain optional examples; their kinematics, home positions, protocols, and motor libraries must not determine the core API.
+
+This design supersedes the robot-specific implementation priorities in [the earlier assessment](MODERNIZATION_PLAN.md). Implementation has started as local alpha `0.1.0a3`; see [README.md](README.md) for the implemented API and commands. Python 3.10 through 3.15 have now been verified in separate venvs; see [compatibility and performance](docs/COMPATIBILITY_PERFORMANCE.md). The complete teleoperation design below remains a roadmap, and the package has not been published.
+
+The first implementation also includes a pure-Python protocol-v6 WebSocket backend for existing legacy services. It was verified with this PC's service `3.2.1+45911`, Python 3.15.2, and the actual Original Leap Motion Controller: 3,399 live frames captured over 30 seconds, including 53 left-hand frames with a complete finger skeleton. Recording and replay passed. No runtime upgrade was required. Auto mode tries installed LeapC first and then the loopback legacy endpoint. This extends the installation options below: an existing working legacy tracking service can be reused without `LeapPython.pyd` or Python 3.8. The only Python runtime dependency is the automatically installed `websocket-client` package. See [hardware validation](docs/HARDWARE_VALIDATION.md) for the evidence and remaining checks.
+
+**The installation promise needs two clear parts.** One pip command can install our Python library, CLI, and permitted native client components without a compiler, manual DLL copying, environment-variable setup, or a Python 3.8 helper process. Live tracking also needs the vendor's tracking engine and device support. LeapC is a client of a separate hand-tracking service; it does not itself run the hand-tracking engine. [Vendor LeapC architecture](https://docs.ultraleap.com/api-reference/tracking-api/leapc-guide.html)
+
+| Computer state | Intended first-use experience |
+| --- | --- |
+| Compatible vendor runtime already installed and working | `pip install`, then use the API or CLI. Detect the runtime and camera automatically. |
+| Vendor runtime missing | The first explicit CLI launch detects what is missing and offers the vendor-supported setup flow, subject to the selected runtime's distribution and automation terms. There may be a license screen and an operating-system approval prompt. |
+| Existing incompatible runtime, including the current 3.2.1 service | Explain the incompatibility and offer an intentional migration path. Preserve existing installation details and do not replace system tracking software merely on import. |
+| No connected camera or unsupported platform | Import, replay, configuration, and non-hardware operations still work. Opening live tracking provides a precise diagnostic. |
+
+A normal wheel installation copies package files and creates entry points; it is not a portable system-driver installation mechanism. Put prerequisite handling in explicit first use, not a build hook. On Windows, creating a service requires administrator privileges. Therefore, zero prompts on an untouched PC is not currently a promise we can make. Minimize manual work and report the tested experience accurately. [Wheel installation specification](https://packaging.python.org/en/latest/specifications/binary-distribution-format/), [Windows service access rights](https://learn.microsoft.com/en-us/windows/win32/services/service-security-and-access-rights)
+
+Ultraleap currently lists Hyperion 6.2.0 for the original controller on Windows. Treat this as the first runtime candidate to test with the actual device. The existing project and service use 3.2.1; replacing that runtime is an integration change to verify separately from packaging. [Original controller downloads](https://www.ultraleap.com/downloads/leap-controller/)
+
+The project owner confirmed redistribution permission on 5 November 2026. Use that confirmation as the project's permission basis for runtime packaging; do not keep treating permission as an unanswered request. Runtime delivery, platform selection, accompanying notices, and clean-machine checks remain implementation work. The existing wheel still excludes vendor assets. See [installation and platforms](docs/INSTALLATION_PLATFORMS.md).
+
+If a supported userspace deployment of the tracking engine exists for the chosen runtime, investigate it as a way to reduce setup. Do not assume such a deployment works, bypass its license, or budget for replacing the proprietary tracking engine. Until tested, the supported service installation is the baseline.
+
+**Separate hand tracking from robot operation.** The library should provide three independently useful layers:
+
+| Layer | Responsibility | Customization |
+| --- | --- | --- |
+| Tracking | Connect, discover devices, poll events, copy owned frames, handle timestamps and reconnects. Expose palm, wrist, fingers/bones, handedness, IDs, orientation, pinch and grab data supported by the runtime. | Device selection, tracking mode, hand selection, event or iterator access. |
+| Teleoperation | Map a selected hand into a Cartesian target, delta, or velocity intent. Apply calibration, coordinate transforms, gains, filtering, clutch/activation, workspace and motion limits. | Translation/orientation modes, axis mapping, reference frames, per-axis gains, filter settings, gesture/gripper mapping, output rate. |
+| Robot adapters | Translate validated intent to a controller's documented interface and expose capabilities/status. | User callback, generic pose transport, or an optional controller-specific adapter. |
+
+```mermaid
+flowchart LR
+ A[Original Leap controller] --> B[Vendor tracking service]
+ B --> C[Python tracking API]
+ C --> D[Configurable teleoperation]
+ D --> E[Robot adapter]
+ E --> F[Robot controller]
+ C --> G[Recording and replay]
+ D --> G
+```
+
+Supporting different arms means a stable extension point, documented coordinate conventions, and adapters. It cannot mean an unknown controller will move correctly without its endpoint, command interface, frame alignment, and limits. A robot that accepts Cartesian targets can use a small adapter. A joint-only controller needs robot-specific kinematics supplied by that adapter or a separate robotics stack. Keep inverse kinematics and hardware motor drivers out of the base dependency set.
+
+Expose robot-independent data in explicit units: positions in metres, time units in named fields, velocities in metres per second and radians per second, and rotations through a documented quaternion type with named `x`, `y`, `z`, `w` components. Device timestamps retain their clock domain. Convert vendor coordinates once at the boundary; allow the teleoperation frame transform to be configured independently.
+
+Raw frames must remain available so users can implement unusual mappings. Keep frame data independent of the vendor's native pointers. A frame containing no hands is a valid frame, not necessarily a disconnect. Retain handedness and tracked identity; acquiring a different hand or recovering from a long gap resets relevant calibration and filter state according to an explicit policy.
+
+**A short API should be the primary interface.** This is the proposed shape, not runnable code in the current folder:
+
+```python
+from source_robotics_leap_motion import Tracker
+
+with Tracker() as tracker:
+ for frame in tracker:
+ hand = frame.right_hand
+ if hand is not None:
+ print(hand.palm.position)
+```
+
+For a custom arm, supply a callback or implement a small adapter. The teleoperation API can resemble this:
+
+```python
+from source_robotics_leap_motion import Mapping, Teleoperator, Tracker
+
+# my_robot implements the adapter contract for the user's controller.
+mapping = Mapping.from_toml("my_arm.toml")
+
+with Tracker() as tracker:
+ with Teleoperator(tracker, robot=my_robot, mapping=mapping) as teleop:
+ teleop.run()
+```
+
+The adapter contract should describe capabilities, connection lifecycle, target submission, feedback when available, and hold/stop behavior. Distinguish pose, delta, velocity, orientation, and gripper support so the core rejects unsupported combinations. A callback adapter provides the lowest-effort integration path; a managed adapter adds feedback and lifecycle handling.
+
+A teleoperator starts disarmed. Calibration obtains the robot reference pose from feedback or an explicitly supplied starting pose. The core must not invent a home pose or apply one arm's workspace bounds to another. Activation, clutching, tracking loss, and rearming have documented transitions. Filtering and timing have deterministic reset rules. Controller-side enforcement remains necessary when the Python process or network disappears; an output callback alone cannot provide a receiver watchdog.
+
+**Use LeapC without an extension tied to a Python minor version.** Evaluate a minimal `ctypes` binding first. Keep the declarations in one internal backend, generated or checked against the exact SDK headers. Verify native structure sizes, alignment, offsets, calling convention, function signatures, and pointer lifetimes. Test with actual hand frames and device/status events before claiming current-Python compatibility. The higher-level public API should not expose `ctypes` types. [Python ctypes documentation](https://docs.python.org/3/library/ctypes.html)
+
+The core wheel can remain Python-only if it discovers a separately installed native client. If permitted native clients are bundled, distribute correctly tagged platform wheels; a Windows DLL does not belong in a supposedly platform-independent payload. Using `ctypes` removes the project's own compiled Python extension but does not remove operating-system, architecture, native runtime, or SDK compatibility requirements. [Python package formats](https://packaging.python.org/en/latest/discussions/package-formats/)
+
+Resolve native libraries from explicit configuration, supported installation locations, or packaged resources. Report the chosen library and version. Do not ask users to copy DLLs, modify PATH, or compile bindings. An explicit override is useful for developers; the default path should need none.
+
+Keep runtime discovery read-only. `import source_robotics_leap_motion` must not open USB devices, start threads, install software, elevate privileges, or contact robot endpoints. Opening `Tracker()` owns the hardware connection lifecycle. Only an explicit setup action may offer system changes; the CLI can offer that action when live tracking prerequisites are missing.
+
+**Pip is the user-facing installation path.** Development may use a lockfile and an environment manager, but users should not need that tooling. Use the requested distribution name and the import name `source_robotics_leap_motion`. Package-name availability and publication credentials must be checked before release; a search result is not a reservation.
+
+Aim for a dependency-light base install: tracking, typed data, essential transforms/filtering, configuration, and a basic CLI. Keep plotting, robot SDKs, ROS integration, and scientific toolboxes optional. Choose the minimum Python version from actual support needs while testing the latest stable Python; do not pin all users to an old minor version to satisfy a binary binding. Initially target and verify standard CPython 3.15 x64 on Windows; expand the declared support matrix only after installation and hardware checks.
+
+The companion CLI should provide direct usefulness immediately after installation. These commands describe the proposed interface:
+
+```powershell
+pip install source-robotics-leap-motion
+source-leap
+source-leap doctor
+source-leap record session.jsonl
+source-leap replay session.jsonl
+source-leap stream --udp 127.0.0.1:5001
+```
+
+`source-leap` defaults to a local hand-data monitor. It detects a missing prerequisite and offers the supported setup path rather than a traceback. Streaming and robot-control operations are explicit choices. A live installation should show hand status and positions without requiring an arm, a config file, or a separate plotting package.
+
+Provide a versioned generic pose schema for custom receivers, plus an example receiver. Define units, quaternion convention, sequence numbers, stream identity, source timestamps, validity/activation state, and gripper representation. Use receiver-local time to expire commands; clocks on different machines cannot be presumed synchronized. A schema or UDP socket alone is not a universal robot driver. Historical PAROL6/CM6 encoders can live as examples or optional adapters after the generic contract is stable.
+
+**Keep the implementation small and reviewable.** A suitable starting layout is:
+
+```text
+pyproject.toml
+src/source_robotics_leap_motion/
+ __init__.py
+ cli.py
+ tracker.py
+ models.py
+ runtime.py
+ backends/
+ leapc.py
+ replay.py
+ mapping.py
+ filters.py
+ teleoperation.py
+ recording.py
+ adapters/
+ base.py
+ callback.py
+ udp.py
+examples/
+ read_hands.py
+ custom_robot.py
+ receive_pose.py
+ my_arm.toml
+tests/
+```
+
+The existing scripts demonstrate coordinate mapping, relative calibration, gripper gestures, logging, and UDP output. Extract those behaviors into the new design where useful. Retain the originals as historical references. Do not make the new package import `Leap.py`, open the existing append-only log, import the old motor libraries, or launch a Python 3.8 subprocess.
+
+**Implementation order and release evidence.**
+
+1. **Prove installation and tracking together.** Obtain the selected SDK/runtime artifacts, inspect their current distribution terms, and verify the original camera against them. Build an installable local wheel with a real LeapC-backed `Tracker`, a basic monitor, and diagnostics. Test on current Python. This first milestone answers whether the desired installation experience is achievable, including what a clean PC still requires.
+
+2. **Stabilize the tracking API.** Add owned typed frames, raw hand/skeleton data, reliable connection/empty-frame events, recording/replay, and clean shutdown. Test disconnects, reconnects, timestamps, missing runtime, and two-hand identity. Check native layouts against the headers. Validate the installed wheel outside the source folder so the legacy files cannot accidentally satisfy imports.
+
+3. **Add generic teleoperation.** Implement configured transforms and gains, relative calibration, optional rotation control, bounded filtering, clutch/activation, gripper mapping, and explicit output modes. Verify behavior with synthetic inputs and recorded frames. Provide a callback adapter, a generic pose transport, and an example receiver. Use a fake arm for repeatable integration tests.
+
+4. **Test the clean-machine promise.** In a fresh Windows environment, run `pip install` from the built wheel and launch the monitor. Exercise both an already-installed runtime and a missing runtime. Record any unavoidable installer/license/OS prompts and verify the supported setup flow. Confirm no compiler or old Python is required. An existing successful developer environment is insufficient evidence for this claim.
+
+5. **Prepare a release.** Add supported-platform documentation, API examples, meaningful CI checks, compatibility matrix, versioned distribution artifacts, license notices, and package metadata. Verify the package contains no personal recordings or unintended vendor binaries. Publish only after the install and hardware evidence support the advertised behavior.
+
+Behavioral tests should cover mapping/frame conventions, gains, calibration, identity changes, timing gaps, filter initialization/reset, activation transitions, invalid numeric input, adapter capability errors, sequence handling, and cleanup. Test the installed package, rather than only importing from the checkout. A bounded queue and filter history should keep the live path's memory use stable over long sessions; measure that rather than claiming it from code inspection.
+
+The acceptance target is **one pip command for the package, immediate hand-data access when the supported runtime is present, minimal first-use setup when it is absent, and a documented extension point for different arms**. A literal zero-extra-step clean-PC claim remains unproven and depends on the vendor runtime's technical and distribution constraints. Resolving that is part of the first prototype, not a reason to tie the package to PAROL6, CM6, or Python 3.8.
diff --git a/README.md b/README.md
index 56e034f..e378b85 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,215 @@
-# Source-Robotics-Leap-Motion
-Leap motion source robotics demo
+**Source Robotics Leap Motion - 0.1.0b1 release candidate**
-
+A Python package for the **Original Leap Motion Controller**, with owned hand data,
+recording/replay, and configurable coordinate mapping for custom robot integrations.
+It runs on current Python without `LeapPython.pyd` or a Python 3.8 process.
+
+The beta is distributed through this repository's `v0.1.0b1` GitHub release. PyPI
+publication is being configured. Install the wheel directly, then start tracking:
+
+```powershell
+python -m pip install https://github.com/Source-Robotics/Source-Robotics-Leap-Motion/releases/download/v0.1.0b1/source_robotics_leap_motion-0.1.0b1-py3-none-any.whl
+source-leap
+```
+
+The Windows engine downloads automatically when needed. First-use driver setup may
+require Windows approval. See [release status and validation](docs/BETA_VALIDATION.md)
+and [publication steps](docs/RELEASING.md). Linux/macOS currently use an installed
+vendor tracking service and remain experimental for hardware use.
+
+**Try it in this project's virtual environment.** Python 3.15.2 and the package are
+already installed in `.venv`. No activation is necessary:
+
+```powershell
+.\.venv\Scripts\source-leap.exe --demo
+.\.venv\Scripts\source-leap.exe setup
+.\.venv\Scripts\source-leap.exe doctor --probe
+.\.venv\Scripts\source-leap.exe
+```
+
+The first command shows synthetic hand motion. The last uses a connected controller.
+Stop with Ctrl+C. Add `--frames 60` or `--duration 5` for a bounded run.
+`source-leap.cmd --demo` is a convenience wrapper for this checkout.
+
+To install the source in another existing environment:
+
+```powershell
+python -m pip install .
+source-leap --demo
+```
+
+Python **3.10 or newer** is required. The wheel has been installed and tested on
+**CPython 3.10.11, 3.11.9, 3.12.10, 3.13.17, 3.14.10, and 3.15.2, Windows x64**.
+Each version passed all 86 tests, received live camera frames, and replayed a recorded
+real hand. Python 3.10 additionally uses small `tomli` and `typing-extensions`
+backports, installed automatically by pip. All versions use `websocket-client`.
+Python 3.9 and earlier are not supported by the new package.
+
+The [compatibility and performance report](docs/COMPATIBILITY_PERFORMANCE.md) explains
+the test matrix, measured stream rate, comparison with the original Python 3.8 SDK,
+and remaining limits. The tracking engine and driver are unchanged; no improvement
+in tracking accuracy or camera frame rate is claimed.
+
+**Automatic runtime selection.** `Tracker()` and `source-leap` reuse an installed
+compatible service or find the cached private runtime automatically. If neither is
+available, the release catalog supplies a pinned Windows download. Its SHA-256 and
+file sizes are checked, interrupted transfers resume, and installation is atomic.
+The compressed download is removed after installation to keep one extracted copy.
+Imports perform no downloads, device access, or background startup.
+
+The service path supports LeapC and the local protocol-v6 WebSocket endpoint.
+On this PC, the fallback was verified with service `3.2.1+45911` on
+`127.0.0.1:6437` and the actual Original Leap Motion Controller. A 30-second check
+captured 3,399 frames, including 53 frames containing a left hand with all five
+fingers and four bones per finger. Recording and replay also passed. See
+[the hardware validation notes](docs/HARDWARE_VALIDATION.md).
+
+An existing working legacy service can therefore be used without upgrading it.
+The native LeapC loader supports Windows x64, Linux x64, and macOS Intel/Apple
+Silicon. Linux/macOS hardware integration remains experimental and needs a compatible
+vendor service. Windows private-engine operation has been hardware-tested. No vendor
+DLLs or model data are included in the Python wheel; the Windows runtime is a separate
+GitHub release asset. Clean-PC driver setup still needs a real hardware test.
+
+**First-use setup.** `source-leap setup` reports device status and probes actual frames.
+An unplugged device is distinguished from Windows problem code 28 (missing driver).
+Only an explicit `--install-driver` action or an accepted interactive prompt requests
+Windows approval. A working driver is not replaced. `--open-downloads` opens the
+official platform setup page. Linux instructions point to the vendor's core service
+package; macOS instructions select the appropriate vendor installation.
+
+An offline Windows installation is also available:
+
+```powershell
+.\.venv\Scripts\source-leap.exe setup --archive dist\runtime\source-leap-runtime-windows-x86_64-3.2.1.zip
+```
+
+`SOURCE_LEAP_CACHE` selects a development/test cache. Normal users need no environment
+variables. `--catalog PATH` is a setup/deployment override for a release catalog.
+
+**Private Windows runtime.** Developers can prepare a local engine copy without the
+control panel or visualizer:
+
+```powershell
+.\.venv\Scripts\source-leap.exe runtime prepare "C:\Program Files (x86)\Leap Motion\Core Services" --destination .tools\private-runtime-321 --dependencies .
+.\.venv\Scripts\source-leap.exe runtime inspect .tools\private-runtime-321
+.\.venv\Scripts\source-leap.exe monitor --runtime-dir .tools\private-runtime-321
+```
+
+Preparation copies local files and records checksums. Applications acquire leases on
+a hidden coordinator that starts the engine and waits for an actual camera frame.
+Closing one application keeps the engine alive for other clients. The engine exits
+after the last client leaves, including crashed clients. Configuration and logs live
+outside the engine directory. The package never stops or installs system services.
+`Tracker(runtime_dir="...")` selects a developer bundle explicitly.
+
+The base engine contains seven files, about **248 MB** before compression. The release
+archive adds driver files and notices: **251 MB extracted, 244 MB compressed**.
+The private engine delivered 1,047 frames in ten
+seconds, about 105 FPS, including 50 full hand skeletons. It ran as the normal user
+while the installed service was stopped. First-frame startup took about 2.6 seconds.
+The project owner confirmed redistribution permission on 5 November 2026. The
+installed beta wheel also passed two-client sharing, repeated startup, crash cleanup,
+and a live-frame check of the restored installed service. Clean-PC USB setup and
+other-platform hardware tests remain pending.
+See [the prototype, evidence, and bounded test command](docs/MINIMAL_RUNTIME.md).
+The proposed first-use flow and Windows/Linux/macOS targets are documented in
+[installation and platforms](docs/INSTALLATION_PLATFORMS.md).
+
+**Read hand data.** The same API accepts live, synthetic, and replay sources:
+
+```python
+from source_robotics_leap_motion import Tracker
+
+with Tracker() as tracker: # use Tracker.demo() to try without a camera
+ print("Backend:", tracker.backend_name)
+ for frame in tracker:
+ if hand := frame.right_hand:
+ print(hand.palm.position, hand.grab_strength)
+```
+
+Positions, widths, and distances use **metres**; palm velocity uses metres/second.
+Camera axes remain Leap's axes: +x right, +y up, +z toward the user. Quaternions have
+named `x`, `y`, `z`, `w` components. Frame timestamps are microseconds in the named
+`clock_domain`, not Unix time. An empty-hand frame is valid; absence of frames produces
+`TrackingTimeoutError`. Service/camera disconnections produce `ConnectionError`.
+
+Frames are immutable Python snapshots and retain no SDK pointers. LeapC includes
+finger/arm rotations. The legacy backend exposes finger joint positions when supplied
+by protocol v6, derives palm orientation from normal/direction, and reports unavailable
+bone rotations and pinch distance as `None`. It never substitutes a made-up rotation.
+
+**Record and replay.** Existing recordings are protected by default:
+
+```powershell
+.\.venv\Scripts\source-leap.exe record recordings\session.jsonl --frames 300
+.\.venv\Scripts\source-leap.exe replay recordings\session.jsonl --realtime
+.\.venv\Scripts\source-leap.exe record recordings\demo.jsonl --demo --frames 60
+.\.venv\Scripts\source-leap.exe replay recordings\demo.jsonl --json
+```
+
+The destination folder is created automatically. JSON output contains only frames on stdout;
+status and errors go to stderr. Each recording has a schema version, timestamp and
+coordinate conventions, and one frame per line. Reading streams the file without
+loading the whole session. Headerless historical `motion_data.txt` files use a different
+format and are not accepted as recordings.
+
+```python
+from source_robotics_leap_motion import Recorder, Tracker, read_recording
+
+with Tracker.demo(realtime=False) as tracker, Recorder("demo.jsonl") as recording:
+ for _ in range(60):
+ recording.write(tracker.read())
+
+for frame in read_recording("demo.jsonl"):
+ print(frame.timestamp_us, frame.right_hand.palm.position)
+```
+
+**Customize coordinate mapping.** The package provides a pure mapping function, with
+no hard-coded robot origin or workspace:
+
+```python
+from source_robotics_leap_motion import Mapping, Vector3
+
+mapping = Mapping(axes=("-z", "-x", "y"), gain=Vector3(0.5, 0.5, 0.5))
+target = mapping.map_position(
+ hand.palm.position,
+ hand_origin=calibrated_hand_position,
+ robot_origin=current_robot_position,
+)
+```
+
+Gains apply in output coordinates. Zero locks an axis. Signed axis permutations must
+preserve handedness. `Mapping.from_toml("examples/my_arm.toml")` loads the same settings.
+Run `python examples/custom_mapping.py` for a complete synthetic example that only
+prints targets.
+
+This alpha supplies tracking and mapping primitives. Robot adapters, activation/clutch
+states, motion limits, adaptive filtering, and continuous robot control are the next
+milestone; a production `Teleoperator` is **not implemented**. There is no automatic
+connection to an arm, no motor-driver dependency, and no implicit movement command.
+
+**Diagnostics.** `doctor` inspects fixed runtime locations without loading native
+code. `doctor --probe` also opens tracking and waits for a frame. Exit code 0 indicates
+the requested check passed, 2 indicates a configuration/tracking error, and Ctrl+C
+returns 130. A WebSocket connection can succeed while the camera is unplugged, paused,
+or otherwise not streaming; a successful greeting alone is not a live-frame test.
+
+Developers can explicitly select a LeapC library with `--library PATH` or the
+`SOURCE_LEAP_LIBRARY` environment variable. An explicit selection never silently
+falls back to another backend. Normal users should not need either option.
+
+**Development and validation.**
+
+```powershell
+.\.venv\Scripts\python.exe -m pip install -e ".[dev]"
+.\.venv\Scripts\python.exe -m pytest -q
+.\.venv\Scripts\python.exe -m ruff check src tests examples
+.\.venv\Scripts\python.exe -m ruff format --check src tests examples
+.\.venv\Scripts\python.exe -m build --wheel
+```
+
+The original scripts and binaries remain historical references. The original README
+is preserved in [docs/LEGACY_README.md](docs/LEGACY_README.md). Current scope and design
+are in [PACKAGE_DESIGN.md](PACKAGE_DESIGN.md); native-interface verification is described
+in [docs/NATIVE_ABI.md](docs/NATIVE_ABI.md).
diff --git a/docs/BETA_VALIDATION.md b/docs/BETA_VALIDATION.md
new file mode 100644
index 0000000..24814c7
--- /dev/null
+++ b/docs/BETA_VALIDATION.md
@@ -0,0 +1,53 @@
+**Beta 0.1.0b1 validation - 5 November 2026**
+
+This release candidate implements automatic runtime delivery and cache selection,
+shared engine ownership, first-use setup, and experimental POSIX LeapC loading.
+The repository was made public with the owner's explicit approval. The GitHub beta
+release is prepared with a public runtime URL; PyPI publication depends on authorizing
+its Trusted Publisher. The direct GitHub wheel installation is the initial beta path.
+
+**Windows checks.** All checks used project-local Python virtual environments.
+
+- The 244,070,193-byte release archive was served by a local HTTP server, downloaded,
+ verified against its pinned SHA-256, and installed atomically. A fresh process reused
+ the cache with the server unavailable. The compressed cache copy was removed.
+- Windows device inspection reported the Original controller present, with problem
+ code zero. No driver installation or replacement was performed on this working PC.
+- The beta wheel was installed into an isolated venv. With the installed service
+ temporarily stopped, plain `Tracker()` selected the cached engine automatically.
+- Two simultaneous clients acquired leases. Closing the first left the second
+ receiving 30 more camera frames. Closing the last client stopped the engine.
+- Two complete engine restart cycles received real camera frames and cleaned up.
+- A separate client received a frame and exited abruptly without closing its tracker.
+ Its lease closed automatically, and the coordinator stopped the unused engine.
+- The installed service was restored, and a real-frame probe passed afterward.
+ Results: `.tools/private-probes/f8562a2404a64767bc4f6202a4e9f828/summary.json`.
+- The 86-test suite covers transport resume/restart, checksum failures, extraction
+ validation, atomic caching, device-status distinctions, shared leases, connection
+ recovery, native loading, data models, mapping, CLI behavior, and recording/replay.
+
+The earlier hand capture remains the live skeleton evidence: 1,047 frames over ten
+seconds, including 50 complete left-hand samples with arm data, about 105 FPS.
+No new CPU/latency improvement is claimed by this installer/lifecycle work.
+
+**Other platforms.** Native loading uses the vendor header's Windows calling
+convention on Windows and C calling convention on Linux/macOS. The SDK structures
+remain packed as specified by the vendor header. Background-frame policy is requested
+explicitly. Loader selection is unit-tested. Installed library discovery includes
+the documented Ubuntu paths and the macOS application SDK path.
+
+GitHub CI is configured for Python 3.10-3.15 on Windows, Linux, and macOS. CI results
+were checked on source commit `7c2f782`: every Windows and Linux Python job passed,
+along with the package build and metadata checks. macOS 3.11 and 3.13 jobs passed;
+other macOS jobs were still waiting for hosted runners at the time of this report.
+The Windows installed-wheel matrix separately passed all 86 tests on each Python
+minor version from 3.10 through 3.15, with live camera frames and private-recording replay.
+No Linux or Mac hardware test has been performed on this Windows PC. Windows-managed
+runtime archives are deliberately rejected on other platforms, which use an installed
+native vendor service and platform-specific setup instructions.
+
+**Remaining release checks.** A clean Windows hardware installation is still needed
+to validate driver-only setup without any previous vendor installation. A working
+developer PC does not provide that evidence. Linux/macOS real-device tests and long
+sessions also remain. Public runtime availability and PyPI publisher configuration
+are required before new users can use the short PyPI package-name installation flow.
diff --git a/docs/COMPATIBILITY_PERFORMANCE.md b/docs/COMPATIBILITY_PERFORMANCE.md
new file mode 100644
index 0000000..ad989a0
--- /dev/null
+++ b/docs/COMPATIBILITY_PERFORMANCE.md
@@ -0,0 +1,147 @@
+**Compatibility and performance - alpha 0.1.0a3, 5 November 2026**
+
+The initial alpha required Python 3.12+. Alpha 0.1.0a2 lowers that requirement to
+Python 3.10 and verifies the installed wheel on each minor version through 3.15.
+Alpha 0.1.0a3 repeats the matrix with 62 tests, including local runtime preparation,
+first-frame readiness, launcher reuse/ownership/failure handling, real Windows job
+cleanup, and simulated service restoration after a delayed Windows stop error.
+All checks below used Windows x64 and the existing Leap tracking service 3.2.1+45911.
+
+| Python tested | Automated tests | Live camera frames | Recorded real-hand replay |
+| --- | --- | --- | --- |
+| 3.10.11 | 62 passed | 30 received | passed |
+| 3.11.9 | 62 passed | 30 received | passed |
+| 3.12.10 | 62 passed | 30 received | passed |
+| 3.13.17 | 62 passed | 30 received | passed |
+| 3.14.10 | 62 passed | 30 received | passed |
+| 3.15.2 | 62 passed | 30 received | passed |
+
+Each interpreter used a separate project-local venv. The wheel was installed into
+site-packages, without relying on the source checkout or its editable install.
+Dependency checks passed. The short live matrix checks contained empty-hand frames;
+the real-hand replay check used an actual previously recorded five-finger skeleton.
+Full live hand tracking was separately verified on 3.15.2. These are tests of the
+listed patch versions, not every possible patch, operating system, or tracking runtime.
+The managed launcher's existing-service mode separately received 30 live frames.
+On Python 3.15.2 the private copied engine also delivered 1,047 frames over ten seconds,
+including 50 full hand samples, with the system service stopped. All six versions
+replayed that private recording exactly. See [minimal runtime evidence](MINIMAL_RUNTIME.md).
+The private capture was mostly empty-hand frames, with recording enabled: its roughly
+105 FPS is a functional observation, not an improvement claim or a CPU/latency comparison.
+The detailed comparison measurements below are the earlier alpha-2 measurements.
+
+Python 3.10 automatically installs `typing-extensions` for `Self` and `tomli` for
+TOML parsing. On 3.11+, the standard library provides these facilities. UTC recording
+timestamps use `timezone.utc` throughout. Internal deadlines use `perf_counter` to
+keep timing precision consistent across the supported Windows interpreters. The
+matrix caught a short-duration failure on 3.12 before this clock change; it passes
+after the fix. Python 3.9 and earlier are not supported by this package.
+
+**What changed to make modern Python work.** The camera driver and proprietary
+tracking engine were not replaced. The original project imported a SWIG-generated
+`Leap.py` and `LeapPython.pyd` linked specifically to `python38.dll`. The new working
+path connects to the installed service's public protocol-v6 WebSocket endpoint at
+`ws://127.0.0.1:6437/v6.json`, used by LeapJS. That interface is independent of the
+CPython extension ABI, so a current Python client can read the existing service.
+
+The package parses the messages into owned, immutable snapshots with documented
+units, validates numeric data, and provides diagnostics, hand selection, configurable
+coordinate mapping, recording/replay, and a CLI. There is also a separate native
+LeapC backend, but it has not yet been validated for live frames with a compatible
+newer runtime. The following performance results concern the working WebSocket path.
+
+| Supplied data | Representation |
+| --- | --- |
+| Frame identity and acquisition time | frame ID and service-clock timestamp in microseconds |
+| Hand identity and handedness | tracked ID plus left/right |
+| Palm position and velocity | metres and metres/second |
+| Palm orientation | quaternion derived from the service's normal/direction vectors |
+| Palm normal, direction and width | unit vectors and metres |
+| Grab and pinch | normalized strength, 0 to 1 |
+| Fingers | identity/name, extended state, four bone segments per finger when supplied |
+| Arm | elbow/wrist segment and width when supplied |
+| Tracking visibility | time visible in microseconds |
+
+Bone rotations and pinch distance are currently `None` in the WebSocket backend.
+No robot-specific inverse kinematics, motor commands, activation states, adaptive
+filtering, or complete teleoperation controller are supplied by this alpha.
+
+**Observed rate.** The earlier standalone capture received 3,399 frames in 30 seconds,
+about 113 frames/second. Simultaneous comparison runs observed about 101 to 109
+frames/second. The service/camera supplies this varying rate; the library does not
+force a fixed sensor rate. The human-readable CLI prints at most about 10 status
+updates/second, while it continues consuming the full stream. Empty-hand frames
+still count as camera frames. There is no claim that changing Python raises the
+camera rate or improves the tracking algorithm's accuracy.
+
+**Comparison method.** `tools/benchmark_tracking.py` runs three read-only consumers
+against the same live stream: the original Python 3.8.10 SDK reading palm data, that
+SDK materializing comparable hand/skeleton fields into Python tuples, and the new
+Python 3.15.2 package producing validated snapshots. The old code is a minimal SDK
+reader, not the original robot-sending scripts. Per-frame printing, recording, robot
+transport, and filtering are disabled in all workers.
+
+Windows QueryPerformanceCounter provides a shared clock across the processes.
+Delivery comparisons match frame ID and acquisition timestamp, then compare the
+time when each client's Python data is ready. This measures relative client delivery,
+not absolute camera-to-robot or motion-to-action latency. Native tuples and validated
+package objects have different construction/validation work, so the readers are
+representative workloads rather than instruction-for-instruction equivalents.
+
+**Initial full-hand run, before optimization.** Over 20 seconds a hand was visible
+throughout. Both original readers received 2,176 frames; the new package received
+2,175. All received frames contained hands.
+
+| Reader | Frames/second | Process CPU, percent of one core |
+| --- | --- | --- |
+| Original Python 3.8, palm only | 108.80 | 2.50% |
+| Original Python 3.8, hand/skeleton readout | 108.80 | 3.67% |
+| New Python 3.15 package, before optimization | 108.75 | 10.86% |
+
+For 2,175 matched hand frames, new data became ready 1.383 ms later than the old
+skeleton reader at the median, and 2.113 ms later at the 95th percentile. The frame
+rate was maintained, but this version was clearly more expensive in CPU and delivery
+time. It would have been incorrect to describe it as faster.
+
+**Measured optimization.** Profiling one captured 5,841-byte hand message found a
+redundant Python-level UTF-8 validation pass costing approximately 572 microseconds
+per message. Python's strict UTF-8 decoding of the same message took approximately
+0.5 microseconds. The library already performs that strict decoding before returning
+text, and JSON/numeric validation follows it.
+
+The backend now uses `skip_utf8_validation=True` to remove the redundant pass; strict
+UTF-8 decoding and application-data validation remain. These microbenchmarks are
+illustrative costs on this machine, not portable performance guarantees.
+
+**Follow-up after optimization.** This 20-second run had only 111 hand frames;
+most other frames were empty. It therefore cannot be used as a full-hand CPU
+comparison against the initial run.
+
+| Reader | Frames/second | Process CPU, percent of one core, mixed run |
+| --- | --- | --- |
+| Original Python 3.8, palm only | 107.65 | 1.80% |
+| Original Python 3.8, hand/skeleton readout | 107.45 | 2.27% |
+| New Python 3.15 package, optimized | 108.90 | 1.64% |
+
+Across the 111 matched hand frames, the new package was ready 0.039 ms later than
+the old skeleton reader at the median, and 1.376 ms later at the 95th percentile.
+Against the palm-only reader, those differences were 0.178 ms and 1.480 ms. OS
+scheduling stalls affected the run. The differing frame counts are not evidence of
+increased camera FPS, and the lower mixed-run CPU figure does not establish lower
+CPU for sustained full-hand input. A longer repeat with a continuously visible hand
+is still needed before making a strong performance claim.
+
+The demonstrated result is broad Python compatibility, preserved stream throughput,
+and reduced avoidable client overhead. A universal latency/CPU improvement over the
+native SDK has not been established. Tracking accuracy uses the same engine.
+
+The benchmark's local summaries are under `.tools/benchmarks`; raw hand captures and
+machine-specific data are excluded from distributions. The first full-hand run is
+`1793887813464487700/summary.json`; the optimized mixed run is
+`1793888002722575600/summary.json`.
+
+For actual robot integration, the next performance work should include an explicit
+latest-frame mode with bounded buffering, frame-age checks, and testing the native
+LeapC backend on a compatible service. Current `Tracker.read()` consumes the next
+queued frame. A slow consumer can build a backlog; meeting the sensor rate in these
+read-only tests is not a real-time robot-control guarantee.
diff --git a/docs/HARDWARE_VALIDATION.md b/docs/HARDWARE_VALIDATION.md
new file mode 100644
index 0000000..7cce68a
--- /dev/null
+++ b/docs/HARDWARE_VALIDATION.md
@@ -0,0 +1,54 @@
+**Original Leap Motion Controller - live validation, 5 November 2026**
+
+The first alpha was tested against the user's connected **Original Leap Motion
+Controller** in this project's Python virtual environment.
+
+| Component | Observed value |
+| --- | --- |
+| Package | source-robotics-leap-motion 0.1.0a1 |
+| Interpreter | CPython 3.15.2, x64 |
+| Operating system | Windows 11 |
+| Installed tracking service | 3.2.1+45911 |
+| Selected backend | legacy-websocket |
+| Local endpoint | ws://127.0.0.1:6437/v6.json |
+| Capture duration | 30 seconds |
+| Frames captured | 3,399 |
+| Frames containing a hand | 53 |
+| Observed handedness | left |
+| Finger skeleton | five fingers, four bones per finger |
+| Arm data | present |
+
+The capture exercised real palm positions, derived palm orientation, finger and arm
+joint positions, frame timestamps, and coordinate mapping. Quaternion unit length
+was checked on every tracked-hand frame. Timestamps did not move backwards. All
+frames, including valid empty-hand frames, were written to the versioned recording
+format and read back successfully. The replayed count and exact first/last snapshots
+matched the capture.
+
+The temporary capture and machine-readable summary are under `.tools`, excluded
+from both source and wheel distributions. They were not made into public test
+fixtures. The local wheel installed in a separate clean venv also received a live
+camera frame through `doctor --probe`.
+
+This verifies that the current-Python package can obtain hand data from the original
+controller using the existing legacy service, without a Python 3.8 helper or a service
+upgrade. The capture is a functional check, not a latency benchmark or certification
+of robot-control timing.
+
+Since the initial capture, installed-wheel checks have passed on Python 3.10 through
+3.15; see [the compatibility report](COMPATIBILITY_PERFORMANCE.md). Alpha 3 also ran
+the copied engine privately, as the normal user with the installed service stopped.
+That ten-second capture produced 1,047 frames, including 50 left-hand frames with
+five fingers, four bones per finger, and arm data. Every recorded snapshot replayed
+exactly, timestamps were nondecreasing, and palm orientations had unit length.
+
+The private child exited and the system configuration hash stayed unchanged. One
+restoration reported the Windows service as running without a camera stream. A
+subsequent service restart restored actual live frames. The development helper now
+checks live frames after restoration and handles a delayed stop error. See
+[private-runtime evidence and limitations](MINIMAL_RUNTIME.md).
+
+Outstanding checks include repeated camera handoff, two-hand operation on hardware,
+disconnect/reconnect behavior, extended sessions, clean-PC USB setup, other platforms,
+and native LeapC streaming with a compatible newer service. The current automated
+suite has 62 passing tests. Robot control is not implemented by this alpha.
diff --git a/docs/INSTALLATION_PLATFORMS.md b/docs/INSTALLATION_PLATFORMS.md
new file mode 100644
index 0000000..6f91f1a
--- /dev/null
+++ b/docs/INSTALLATION_PLATFORMS.md
@@ -0,0 +1,77 @@
+**Installation and platforms - decision notes, 5 November 2026**
+
+The project owner confirms redistribution permission and approved public publication
+of the GitHub repository and beta release. Version 0.1.0b1 implements Windows runtime
+delivery, cache selection, first-use device setup, and shared engine ownership.
+The Python wheel and native runtime are separate assets. PyPI publisher authorization
+is still required for the short package-name installation command.
+
+**Intended PyPI experience.** Until PyPI publication is configured, install the beta
+wheel from its GitHub release URL as shown in the README:
+
+```text
+pip install source-robotics-leap-motion
+source-leap
+```
+
+On first explicit launch, select the correct runtime for the operating system and
+CPU, reuse a compatible running service where available, and obtain/cache the
+runtime if it is missing. Check the connected camera and OS device access. If setup
+is needed, offer a one-time driver/device-access setup or open the official vendor
+instructions. Verify a camera frame before reporting success. Later launches should
+reuse the available runtime and start tracking directly.
+
+Setup belongs on first launch or an explicit setup command. A wheel installation
+unpacks files and generates entry points; it is not an interactive OS driver setup
+hook. Imports must stay free of downloads, elevation, and device connections.
+[Wheel installation specification](https://packaging.python.org/en/latest/specifications/binary-distribution-format/)
+
+**Runtime delivery.** The tested Windows payload is about 248 MB on disk and 241 MB
+as ZIP deflate. PyPI's default individual-file limit is 100 MB for personal projects
+and 200 MB for organization-owned projects. A wheel containing this payload would
+need an increased limit. A small pip package with a versioned first-use runtime
+download is the implemented delivery path. A pinned GitHub Release URL, checksums,
+atomic caching, transfer resume/restart handling, and automatic discovery are now
+implemented. With driver files and notices, the release asset is about 244 MB
+compressed and 251 MB extracted. Download delivery does not reduce disk footprint.
+[PyPI storage limits](https://docs.pypi.org/project-management/storage-limits/)
+
+A bundled runtime wheel remains an alternative after a suitable upload limit is
+available. Each native payload must have the correct platform/architecture tag.
+
+**Original Leap Motion Controller platform targets.**
+
+| Platform | Vendor availability | This project's current status |
+| --- | --- | --- |
+| Windows x64 | Hyperion 6.2.0; archived 3.2.1 also listed | Private 3.2.1 engine, delivery/cache reuse, client sharing, restart and crash cleanup verified. Clean-PC driver setup pending. |
+| Ubuntu 22.04 x86_64/amd64 | Hyperion 6.2.0; native Linux packages | Experimental native loader and setup guidance implemented; use an installed vendor service. Hardware testing pending. |
+| macOS 11+, Intel | Hyperion 6.2.0 Intel download | Experimental native loader and setup guidance implemented. Hardware testing pending. |
+| macOS 11+, Apple Silicon | Hyperion 6.2.0 Apple Silicon download | Experimental native loader and setup guidance implemented. Hardware testing pending. |
+
+These vendor targets come from the Original controller's download page. They are not
+claims that our current Windows implementation works on those other platforms.
+[Original controller downloads](https://www.ultraleap.com/downloads/leap-controller/)
+
+The managed launcher currently uses a Windows executable, Windows jobs, and Windows
+process flags. The native LeapC backend now selects WinDLL on Windows and CDLL on
+Linux/macOS, with platform-specific discovery and background-frame policy. Linux
+and macOS use their own installed vendor engine/library; the Windows archive cannot
+be installed on those platforms.
+The common Python frame, mapping, and recording interfaces should remain the same.
+Cross-platform hardware support must be tested before advertising it.
+
+Linux's documented target is Ubuntu 22.04 on amd64, not arbitrary distributions or
+Raspberry Pi/ARM. The vendor provides `ultraleap-hand-tracking-service` separately
+from the control panel and OpenXR packages. A service-only setup is a useful initial
+Linux path. OS package/device-access setup may still require administrator approval.
+[Official Linux installation and packages](https://docs.ultraleap.com/linux/index.html)
+
+For Windows, the locally installed runtime contains USB INF/CAT files and driver
+installation helpers. A driver-only distribution must be tested on a clean machine.
+An unplugged camera must not be diagnosed as a missing driver solely because no
+frames arrived. The generic vendor download link offers the complete tracking suite;
+it is a fallback instruction path, not evidence of a ready driver-only download.
+
+The remaining installation gates are PyPI publisher setup, clean-PC driver-only
+validation, and Linux/macOS real-device validation. No Windows driver was replaced
+on this already working PC.
diff --git a/docs/LEGACY_README.md b/docs/LEGACY_README.md
new file mode 100644
index 0000000..cfb8d25
--- /dev/null
+++ b/docs/LEGACY_README.md
@@ -0,0 +1,8 @@
+
The leap motion sdk(v3.2) recompiled for python 3.8.
+Just put all of these file in your project folder and it shoud work !
+
diff --git a/docs/MINIMAL_RUNTIME.md b/docs/MINIMAL_RUNTIME.md
new file mode 100644
index 0000000..7e79ba8
--- /dev/null
+++ b/docs/MINIMAL_RUNTIME.md
@@ -0,0 +1,126 @@
+**Local runtime prototype - alpha 0.1.0a3, 5 November 2026**
+
+The target is one Python-package installation that manages the essential tracking
+runtime, without a separate control panel, visualizer, demos, or OpenXR integration.
+Alpha 3 implements local preparation and process management. It does not yet supply
+vendor binaries, download an engine, install a driver, or deliver that clean-PC promise.
+
+**Implemented.** `runtime prepare` copies an explicit allowlist from a local legacy
+installation. It records SHA-256 checksums, adopts matching files on repeat runs,
+and refuses to overwrite different runtime files. `runtime inspect` verifies the
+manifest, required names, file sizes, and checksums. These are corruption checks;
+they do not establish vendor authenticity or distribution rights.
+
+`ManagedLegacyBackend` checks local port 6437 and reuses an existing protocol-v6
+service when allowed. Otherwise it validates the bundle, writes its own configuration,
+and starts `LeapSvc64.exe --run --config=...` with no console window. Camera images
+and remote WebSocket access are disabled in that configuration. The package connects
+to the engine through the same legacy tracking backend already verified on hardware.
+Readiness requires the first actual camera frame, not merely a WebSocket greeting;
+the launcher caches that frame for the caller. This covers the model/camera startup
+interval, which was about 2.6 seconds on this PC.
+
+The launcher owns only its child process. Startup failures and normal context exit
+clean up that process, connection, log, and Windows job handle. A Windows job configured
+with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` also ends assigned children when its final
+handle closes. The launcher never stops, installs, or removes system services.
+[Windows process assignment](https://learn.microsoft.com/en-us/windows/win32/api/jobapi2/nf-jobapi2-assignprocesstojobobject),
+[Windows job limits](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-jobobject_basic_limit_information)
+
+**Candidate files from this PC's service 3.2.1.** The project-local copy is
+`.tools/private-runtime-321`:
+
+| Component | Bytes |
+| --- | ---: |
+| `LeapSvc64.exe` | 9,935,288 |
+| `Tracking.ldat` | 236,511,170 |
+| Three firmware `.ldat` files | 193,601 |
+| `msvcp120.dll` and `msvcr120.dll` | 1,623,360 |
+| Total runtime payload | 248,263,419 |
+
+That is about 248 MB decimal, or 237 MiB, uncompressed. No Qt/control-panel files
+were copied. Two alternate models, totaling another 116,978,286 bytes, are omitted
+by default and can be included with `--extra-models`. The seven-file set produced real
+hand tracking; it is a tested candidate, not proof of the smallest possible file set.
+The manifest, configuration, and logs add a small amount beyond the payload above.
+A streaming ZIP-deflate measurement at level 6 produced 241,380,986 bytes for these
+files plus the manifest. No archive was saved or redistributed. The model dominates
+both disk and compressed size; excluding GUI components does not make it a tiny runtime.
+
+**Evidence and limits.**
+
+- The copied executable runs `--help` with these local support DLLs. Its actual help
+ lists `--run` for interactive service operation and `--config` for a configuration path.
+- The managed launcher's reuse mode received 30 live camera frames. Exclusive mode
+ refused the occupied port without starting another engine or modifying the service.
+- All 62 tests passed from the installed wheel in project-local venvs on CPython
+ 3.10.11, 3.11.9, 3.12.10, 3.13.17, 3.14.10, and 3.15.2. Tests include bundle validation,
+ readiness, reuse, startup failures, termination fallback, a real child killed by job
+ closure, and service restoration after a simulated delayed Windows stop error.
+- After Windows approval, the private engine captured 526 empty-hand frames in five
+ seconds at 104.8 FPS. A second capture produced 1,047 frames over ten seconds at
+ 104.6 FPS, including 50 complete left-hand samples. Each sample had five fingers,
+ four bones per finger, and arm data. The engine ran as the ordinary user while the
+ installed service was stopped. No control panel or visualizer ran for capture.
+- The hand capture started in 2.55 seconds, its child exited on cleanup, and the
+ system `config.json` hash stayed unchanged. All 1,047 snapshots replayed exactly;
+ timestamps were nondecreasing and palm orientations had unit length. The capture
+ was mostly empty-hand frames, so it is not a sustained-hand performance benchmark.
+- One restoration reached service status `Running` but did not resume camera frames.
+ A later service restart restored verified live frames. During recovery, Windows
+ reported a stop error but completed the stop afterward. The helper has been corrected
+ to restore the original running state even after that error, uses non-waiting stop
+ requests and bounded status waits, and now checks actual frames after restoration.
+ Repeated, reliable camera handoff remains a production-readiness check.
+- Existing USB drivers remain installed. Supplied INF files associate the camera with
+ WinUSB, but that does not establish what happens on a clean PC.
+
+**Try the prepared bundle.** These commands run in the project's existing venv:
+
+```powershell
+.\.venv\Scripts\source-leap.exe runtime inspect .tools\private-runtime-321
+.\.venv\Scripts\source-leap.exe runtime probe .tools\private-runtime-321 --frames 30
+```
+
+The probe reports `"mode": "existing"` if the installed service is active. Read that
+mode before attributing success to the copied engine.
+
+For a bounded exclusive test on this PC, run this from a **normal** PowerShell terminal:
+
+```powershell
+powershell -NoProfile -ExecutionPolicy Bypass -File .\tools\Test-PrivateRuntime.ps1
+```
+
+Approve the Windows prompt. Only `Pause-LeapForProbe.ps1` runs elevated. It temporarily
+stops the existing service, signals readiness, and restores the original running
+state in `finally`, with a separate 60-second pause watchdog. The private engine runs
+under the ordinary parent account. The Python probe captures for five seconds and
+reports frame rate, hand frames, child exit, and whether the existing system config
+hash changed. Results go under the local runtime's `state/probe-...` directory.
+This helper is a development test, not the proposed end-user installation flow.
+`-CaptureSeconds 10` extends capture to ten seconds. A local recording is saved with
+each test. If restoration reports no frames, the check fails even if service status
+is `Running`; that status alone is insufficient evidence of working tracking.
+
+The verified hand capture is under
+`.tools/private-runtime-321/state/probe-10d13e0141ef4d479f4382e2fed2f3c0`, with
+`summary.json`, `tracking.jsonl`, and `validated.json`. All are excluded from packages.
+
+To test without that helper when the existing service is already stopped:
+
+```powershell
+.\.venv\Scripts\python.exe tools\probe_private_runtime.py .tools\private-runtime-321
+```
+
+**What remains before shipping.** Clean-PC USB setup, repeatable camera lifecycle,
+long sessions, and operation without any other vendor installation must be verified.
+The project owner confirmed on 5 November 2026 that redistribution is permitted.
+That confirmation is the project's permission basis; permission is no longer treated
+as a pending request. Runtime delivery and its accompanying notices still need to be
+assembled. No vendor engine, DLL, or model is currently included in our wheel or
+source distribution, and the package is not on PyPI. See
+[the installation and platform plan](INSTALLATION_PLATFORMS.md).
+
+The vendor's Linux packages separate the core service from GUI components. That is
+useful deployment precedent, but it does not validate this Windows portable prototype.
+[Vendor Linux package breakdown](https://docs.ultraleap.com/linux/index.html)
diff --git a/docs/NATIVE_ABI.md b/docs/NATIVE_ABI.md
new file mode 100644
index 0000000..7b4271c
--- /dev/null
+++ b/docs/NATIVE_ABI.md
@@ -0,0 +1,58 @@
+**Native backend verification - 5 November 2026**
+
+The native backend uses `ctypes.WinDLL` for the Windows `LEAP_CALL` calling convention,
+explicit argument/return declarations, and packed structures. It is restricted to
+Windows x64 in this alpha. `LeapCloseConnection` and `LeapDestroyConnection` return
+void in the checked header.
+
+Declarations were checked against `LeapSDK/include/LeapC.h` extracted from Ultraleap's
+official Windows 6.2.0 installer. The installer had a valid Authenticode signature
+from Ultraleap Limited. It was extracted without being executed. The extracted SDK
+is development material under `.tools/sdk`, excluded from package distributions.
+The large installer download was removed after extraction.
+
+Source download:
+https://s3.eu-west-1.amazonaws.com/downloads.ultraleap.com/software/tracking-software/6.2.0/tracking-software-windows-6.2.0.exe
+
+Header SHA-256:
+`27d512f953bc7dcf2d52475e5e771519b9905bc4d9a88641b1877c2897641765`
+
+Client DLL SHA-256:
+`a1127ed0b573a9431a50d8249ac21c7440e6480014b3fbc683225d90d87145f8`
+
+The SDK header specifies packing of 1 byte. The expected x64 layout is:
+
+| Structure | Size in bytes |
+| --- | --- |
+| LEAP_VECTOR | 12 |
+| LEAP_QUATERNION | 16 |
+| LEAP_BONE | 44 |
+| LEAP_DIGIT | 184 |
+| LEAP_PALM | 80 |
+| LEAP_HAND | 1084 |
+| LEAP_FRAME_HEADER | 24 |
+| LEAP_TRACKING_EVENT | 48 |
+| LEAP_CONNECTION_MESSAGE | 20 |
+
+Important hand offsets are palm 40, digits 120, and arm 1040. Tests check these
+contracts and exercise full copying, nested skeleton ownership, unit conversion,
+invalid arrays, invalid numeric data, and empty-hand frames. No returned Python
+object depends on message memory that the next native poll may invalidate.
+
+These checks are header review and Python layout/conversion tests, not a compiled C
+layout comparison or proof of actual hand tracking through this DLL. Loading the
+6.2.0 client succeeded on CPython 3.15.2, but it did not connect to the installed
+legacy 3.2.1 service. Full native-frame and reconnect validation remain outstanding.
+
+The service's legacy WebSocket endpoint separately negotiated protocol 6 with service
+version `3.2.1+45911`. The legacy backend uses that public local protocol through
+`websocket-client`, with no Python extension or old interpreter. Tests exercise
+decoding, palm rotation conventions, skeleton unit conversion, fallback selection,
+and missing-data representation. After the user connected the Original Leap Motion
+Controller, a 30-second live check captured 3,399 frames, including 53 frames with a
+left hand and a complete finger skeleton. Recording and replay passed. This verifies
+the legacy backend on actual hardware; it does not establish native LeapC streaming.
+See [HARDWARE_VALIDATION.md](HARDWARE_VALIDATION.md).
+
+Protocol references: [LeapJS connection](https://github.com/leapmotion/leapjs/blob/master/lib/connection/base.js),
+[Ultraleap LeapC structures](https://docs.ultraleap.com/api-reference/tracking-api/group/group___structs.html).
diff --git a/docs/RELEASING.md b/docs/RELEASING.md
new file mode 100644
index 0000000..109b267
--- /dev/null
+++ b/docs/RELEASING.md
@@ -0,0 +1,64 @@
+**Release preparation for 0.1.0b1**
+
+The source target is `Source-Robotics/Source-Robotics-Leap-Motion`. The owner explicitly
+approved making it public and publishing the beta. Keep future publication gated on
+anonymous availability of the pinned runtime asset. A draft release can be prepared
+first; publication makes its asset available to ordinary package users.
+
+The local release outputs are:
+
+```text
+dist/source_robotics_leap_motion-0.1.0b1-py3-none-any.whl
+dist/source_robotics_leap_motion-0.1.0b1.tar.gz
+dist/runtime/source-leap-runtime-windows-x86_64-3.2.1.zip
+dist/runtime/runtime-descriptor.json
+dist/runtime/SHA256SUMS
+```
+
+The runtime asset contains the tested engine, model, firmware, VC++ support libraries,
+original USB INF/CAT files and dependencies, and notices. It excludes GUI programs,
+camera recordings, user configuration, certificates, and private keys. The Python
+wheel excludes all vendor executables, DLLs, and model files.
+
+To regenerate assets from the locally cleared runtime:
+
+```powershell
+.\.venv\Scripts\python.exe tools\build_runtime_release.py .tools\private-runtime-321 --output dist\runtime --drivers "C:\Program Files (x86)\Leap Motion\Core Services\Drivers" --notice docs\VENDOR_NOTICE.txt
+.\.venv\Scripts\python.exe tools\configure_release.py https://github.com/Source-Robotics/Source-Robotics-Leap-Motion --tag v0.1.0b1
+.\.venv\Scripts\python.exe -m build --no-isolation
+```
+
+The configured catalog pins the exact archive bytes. Regenerating the ZIP can change
+its hash; rerun configuration and rebuild the wheel whenever the archive changes.
+Upload the runtime ZIP under its exact filename to the `v0.1.0b1` GitHub release.
+
+After the runtime is publicly accessible:
+
+```powershell
+.\.venv\Scripts\python.exe tools\check_release.py
+```
+
+This verifies the public asset size and the Python wheel's contents. Runtime downloads
+also verify the full pinned hash during setup; the release HEAD check alone is not
+the authenticity check. Test a clean-machine first launch before declaring the
+driver-only setup verified.
+
+For PyPI, configure a Trusted Publisher for:
+
+```text
+Project: source-robotics-leap-motion
+Owner: Source-Robotics
+Repository: Source-Robotics-Leap-Motion
+Workflow: publish.yml
+Environment: pypi
+```
+
+No long-lived PyPI token is required by the prepared workflow. The PyPI account owner
+must authorize the publisher (a pending publisher can create the first project).
+The manually triggered publication workflow runs tests, builds distributions,
+checks metadata and public runtime availability, then publishes through OIDC.
+[PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/using-a-publisher/)
+
+The CI workflow tests installed packages on Windows/Linux/macOS. Hosted runners have
+no Leap controller: passing CI verifies software behavior and layout, not device
+support on every platform. Keep hardware results separate in the validation report.
diff --git a/docs/REPOSITORY_LEGACY_README.md b/docs/REPOSITORY_LEGACY_README.md
new file mode 100644
index 0000000..1e4c754
--- /dev/null
+++ b/docs/REPOSITORY_LEGACY_README.md
@@ -0,0 +1,4 @@
+# Source-Robotics-Leap-Motion
+Leap motion source robotics demo
+
+
diff --git a/docs/VENDOR_NOTICE.txt b/docs/VENDOR_NOTICE.txt
new file mode 100644
index 0000000..31cf596
--- /dev/null
+++ b/docs/VENDOR_NOTICE.txt
@@ -0,0 +1,18 @@
+Source Robotics Leap Motion runtime asset notices
+
+This separate runtime archive contains proprietary Leap Motion tracking software,
+model data, firmware, and USB driver files from runtime 3.2.1, together with Microsoft
+Visual C++ 2013 runtime libraries. These components retain their respective owners'
+copyrights and licensing terms. The Python project's MIT license does not relicense
+these third-party components.
+
+The project owner confirmed redistribution permission on 5 November 2026. This
+notice records provenance; it does not replace the applicable vendor agreements.
+Preserve the supplied INF files, catalogs, copyright notices, and any accompanying
+agreement required for the released assets.
+
+Leap Motion / Ultraleap software: https://www.ultraleap.com/
+Microsoft runtime libraries: https://www.microsoft.com/
+
+The runtime files are provided separately from the Python wheel. Camera recordings,
+user configuration, certificates, private keys, and GUI applications are excluded.
diff --git a/examples/custom_mapping.py b/examples/custom_mapping.py
new file mode 100644
index 0000000..c62b5ea
--- /dev/null
+++ b/examples/custom_mapping.py
@@ -0,0 +1,20 @@
+"""Print mapped positions. This example does not connect to or move a robot."""
+
+from pathlib import Path
+
+from source_robotics_leap_motion import Mapping, Tracker, Vector3
+
+mapping = Mapping.from_toml(Path(__file__).with_name("my_arm.toml"))
+
+with Tracker.demo(realtime=False) as tracker:
+ first = tracker.read().right_hand
+ # In a real integration, get this reference from the robot's own feedback.
+ example_robot_origin = Vector3(0.2, 0, 0.2)
+ for _ in range(10):
+ hand = tracker.read().right_hand
+ target = mapping.map_position(
+ hand.palm.position,
+ hand_origin=first.palm.position,
+ robot_origin=example_robot_origin,
+ )
+ print(target)
diff --git a/examples/my_arm.toml b/examples/my_arm.toml
new file mode 100644
index 0000000..688330f
--- /dev/null
+++ b/examples/my_arm.toml
@@ -0,0 +1,6 @@
+# Output x = -input z; output y = -input x; output z = input y.
+# Inputs are already metres. Gain 0.5 means half-size hand movement.
+[mapping]
+axes = ["-z", "-x", "y"]
+gain = [0.5, 0.5, 0.5]
+
diff --git a/examples/read_hands.py b/examples/read_hands.py
new file mode 100644
index 0000000..9d1c590
--- /dev/null
+++ b/examples/read_hands.py
@@ -0,0 +1,16 @@
+"""Run with --demo to try the API before connecting a camera."""
+
+import argparse
+
+from source_robotics_leap_motion import Tracker
+
+parser = argparse.ArgumentParser()
+parser.add_argument("--demo", action="store_true")
+args = parser.parse_args()
+
+with Tracker.demo() if args.demo else Tracker() as tracker:
+ for index, frame in enumerate(tracker):
+ if hand := frame.right_hand:
+ print(hand.palm.position)
+ if index >= 9:
+ break
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..88f484c
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,59 @@
+[build-system]
+requires = ["hatchling>=1.27"]
+build-backend = "hatchling.build"
+
+[project]
+name = "source-robotics-leap-motion"
+version = "0.1.0b1"
+description = "Modern Leap Motion tracking and configurable robot-arm teleoperation primitives"
+readme = "README.md"
+requires-python = ">=3.10"
+license = "MIT"
+authors = [{name = "Source Robotics"}]
+dependencies = [
+ "websocket-client>=1.8,<2",
+ "typing-extensions>=4.4; python_version < '3.11'",
+ "tomli>=2; python_version < '3.11'",
+]
+classifiers = [
+ "Development Status :: 4 - Beta",
+ "Operating System :: Microsoft :: Windows",
+ "Operating System :: POSIX :: Linux",
+ "Operating System :: MacOS",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3 :: Only",
+ "Topic :: Scientific/Engineering",
+]
+
+[project.optional-dependencies]
+dev = ["build>=1.2", "pytest>=8", "ruff>=0.14"]
+
+[project.urls]
+Homepage = "https://github.com/Source-Robotics/Source-Robotics-Leap-Motion"
+Documentation = "https://github.com/Source-Robotics/Source-Robotics-Leap-Motion#readme"
+Issues = "https://github.com/Source-Robotics/Source-Robotics-Leap-Motion/issues"
+Releases = "https://github.com/Source-Robotics/Source-Robotics-Leap-Motion/releases"
+
+[project.scripts]
+source-leap = "source_robotics_leap_motion.cli:main"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/source_robotics_leap_motion"]
+
+[tool.hatch.build]
+exclude = ["**/*.dll", "**/*.pyd", "**/*.exe", "**/*.ldat", "**/runtime-manifest.json"]
+
+[tool.hatch.build.targets.sdist]
+include = ["/src", "/tests", "/examples", "/tools", "/docs", "/README.md", "/PACKAGE_DESIGN.md", "/LICENSE", "/pyproject.toml", "/.github"]
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+addopts = "-ra"
+
+[tool.ruff]
+target-version = "py310"
+line-length = 100
+
+[tool.ruff.lint]
+select = ["E", "F", "I", "UP", "B"]
+
diff --git a/src/source_robotics_leap_motion/__init__.py b/src/source_robotics_leap_motion/__init__.py
new file mode 100644
index 0000000..88d997d
--- /dev/null
+++ b/src/source_robotics_leap_motion/__init__.py
@@ -0,0 +1,34 @@
+"""Leap Motion tracking for modern Python. Importing never opens hardware."""
+
+from .errors import (
+ ConnectionError,
+ LeapError,
+ RecordingError,
+ RuntimeNotFoundError,
+ TrackingTimeoutError,
+)
+from .mapping import Mapping
+from .models import Bone, Finger, Frame, Hand, Palm, Quaternion, Vector3
+from .recording import Recorder, read_recording
+from .tracker import Tracker
+
+__version__ = "0.1.0b1"
+
+__all__ = [
+ "Bone",
+ "ConnectionError",
+ "Finger",
+ "Frame",
+ "Hand",
+ "LeapError",
+ "Mapping",
+ "Palm",
+ "Quaternion",
+ "Recorder",
+ "RecordingError",
+ "RuntimeNotFoundError",
+ "Tracker",
+ "TrackingTimeoutError",
+ "Vector3",
+ "read_recording",
+]
diff --git a/src/source_robotics_leap_motion/__main__.py b/src/source_robotics_leap_motion/__main__.py
new file mode 100644
index 0000000..eb53e2f
--- /dev/null
+++ b/src/source_robotics_leap_motion/__main__.py
@@ -0,0 +1,3 @@
+from .cli import main
+
+raise SystemExit(main())
diff --git a/src/source_robotics_leap_motion/_broker.py b/src/source_robotics_leap_motion/_broker.py
new file mode 100644
index 0000000..aabf483
--- /dev/null
+++ b/src/source_robotics_leap_motion/_broker.py
@@ -0,0 +1,119 @@
+"""Private engine coordinator. Client sockets are leases; no permanent service."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import secrets
+import selectors
+import socket
+import time
+from pathlib import Path
+
+from ._locking import file_lock
+from .backends.managed import ManagedLegacyBackend
+from .delivery import _write_json
+
+
+def serve(
+ directory: Path, state: Path, *, startup_timeout=10, idle_timeout=1, backend_factory=None
+):
+ factory = backend_factory or ManagedLegacyBackend
+ engine = factory(
+ directory, connect_timeout=startup_timeout, reuse_existing=False, state_dir=state
+ )
+ token = secrets.token_hex(32)
+ ready = state / "broker.json"
+ selector = selectors.DefaultSelector()
+ clients = {}
+ state.mkdir(parents=True, exist_ok=True)
+ try:
+ engine.open()
+ engine.release_tracking_client()
+ with socket.socket() as server:
+ server.bind(("127.0.0.1", 0))
+ server.listen(16)
+ server.setblocking(False)
+ selector.register(server, selectors.EVENT_READ)
+ _write_json(
+ ready, {"pid": os.getpid(), "port": server.getsockname()[1], "token": token}
+ )
+ deadline = (
+ time.perf_counter() + 5
+ ) # The parent may die before obtaining its first lease.
+ while True:
+ active = any(client["authenticated"] for client in clients.values())
+ if not active and not clients and time.perf_counter() >= deadline:
+ try:
+ with file_lock(state.parent / "engine.lock", timeout=0):
+ if not selector.select(timeout=0):
+ engine.close()
+ break
+ except TimeoutError:
+ continue
+ for key, _events in selector.select(timeout=0.1):
+ connection = key.fileobj
+ if connection is server:
+ client, _address = server.accept()
+ client.setblocking(False)
+ selector.register(client, selectors.EVENT_READ)
+ clients[client] = {
+ "authenticated": False,
+ "buffer": b"",
+ "expires": time.perf_counter() + 2,
+ }
+ continue
+ entry = clients[connection]
+ try:
+ chunk = connection.recv(1024)
+ if not chunk:
+ raise OSError("Lease closed")
+ if entry["authenticated"]:
+ raise OSError("No messages are accepted after the lease handshake")
+ entry["buffer"] += chunk
+ if len(entry["buffer"]) > 1024:
+ raise OSError("Oversized lease handshake")
+ if b"\n" in entry["buffer"]:
+ request = json.loads(entry["buffer"])
+ if not secrets.compare_digest(str(request.get("token", "")), token):
+ raise OSError("Invalid lease token")
+ connection.sendall(b'{"ready":true}\n')
+ entry["authenticated"] = True
+ except (OSError, ValueError, AttributeError):
+ selector.unregister(connection)
+ connection.close()
+ del clients[connection]
+ deadline = time.perf_counter() + idle_timeout
+ for connection, entry in list(clients.items()):
+ if not entry["authenticated"] and time.perf_counter() >= entry["expires"]:
+ selector.unregister(connection)
+ connection.close()
+ del clients[connection]
+ if not engine.is_running:
+ raise RuntimeError("The private engine stopped")
+ finally:
+ for connection in clients:
+ connection.close()
+ selector.close()
+ try:
+ engine.close()
+ finally:
+ try:
+ if json.loads(ready.read_text())["token"] == token:
+ ready.unlink()
+ except (OSError, ValueError, KeyError):
+ pass
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--directory", type=Path, required=True)
+ parser.add_argument("--state", type=Path, required=True)
+ parser.add_argument("--timeout", type=float, default=10)
+ args = parser.parse_args()
+ serve(args.directory, args.state, startup_timeout=args.timeout)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/source_robotics_leap_motion/_compat.py b/src/source_robotics_leap_motion/_compat.py
new file mode 100644
index 0000000..a021ece
--- /dev/null
+++ b/src/source_robotics_leap_motion/_compat.py
@@ -0,0 +1,13 @@
+"""Small standard-library backports needed only on Python 3.10."""
+
+import sys
+
+if sys.version_info >= (3, 11):
+ from typing import Self
+
+ import tomllib
+else:
+ import tomli as tomllib
+ from typing_extensions import Self
+
+__all__ = ["Self", "tomllib"]
diff --git a/src/source_robotics_leap_motion/_locking.py b/src/source_robotics_leap_motion/_locking.py
new file mode 100644
index 0000000..64e1280
--- /dev/null
+++ b/src/source_robotics_leap_motion/_locking.py
@@ -0,0 +1,43 @@
+"""OS file locks released automatically if a process exits."""
+
+from __future__ import annotations
+
+import os
+import time
+from contextlib import contextmanager
+from pathlib import Path
+
+
+@contextmanager
+def file_lock(path: Path, timeout: float = 60):
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with path.open("a+b") as stream:
+ stream.seek(0, 2)
+ if stream.tell() == 0:
+ stream.write(b"\0")
+ stream.flush()
+ deadline = time.perf_counter() + timeout
+ while True:
+ try:
+ stream.seek(0)
+ if os.name == "nt":
+ import msvcrt
+
+ msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1)
+ else:
+ import fcntl
+
+ fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
+ break
+ except OSError:
+ if time.perf_counter() >= deadline:
+ raise TimeoutError(f"Another setup or tracking process holds {path}") from None
+ time.sleep(0.05)
+ try:
+ yield
+ finally:
+ stream.seek(0)
+ if os.name == "nt":
+ msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1)
+ else:
+ fcntl.flock(stream.fileno(), fcntl.LOCK_UN)
diff --git a/src/source_robotics_leap_motion/backends/__init__.py b/src/source_robotics_leap_motion/backends/__init__.py
new file mode 100644
index 0000000..131cebe
--- /dev/null
+++ b/src/source_robotics_leap_motion/backends/__init__.py
@@ -0,0 +1 @@
+"""Tracking sources. Importing this module performs no runtime discovery."""
diff --git a/src/source_robotics_leap_motion/backends/_job.py b/src/source_robotics_leap_motion/backends/_job.py
new file mode 100644
index 0000000..c020a70
--- /dev/null
+++ b/src/source_robotics_leap_motion/backends/_job.py
@@ -0,0 +1,85 @@
+"""Windows job ownership: closing our handle ends only our private process tree."""
+
+import ctypes as C
+from ctypes import wintypes as W
+
+
+class BasicLimits(C.Structure):
+ _fields_ = [
+ ("process_time", C.c_int64),
+ ("job_time", C.c_int64),
+ ("flags", W.DWORD),
+ ("minimum_working_set", C.c_size_t),
+ ("maximum_working_set", C.c_size_t),
+ ("active_process_limit", W.DWORD),
+ ("affinity", C.c_size_t),
+ ("priority_class", W.DWORD),
+ ("scheduling_class", W.DWORD),
+ ]
+
+
+class IOAccounting(C.Structure):
+ _fields_ = [
+ (name, C.c_uint64)
+ for name in (
+ "read_operations",
+ "write_operations",
+ "other_operations",
+ "read_bytes",
+ "write_bytes",
+ "other_bytes",
+ )
+ ]
+
+
+class ExtendedLimits(C.Structure):
+ _fields_ = [
+ ("basic", BasicLimits),
+ ("io", IOAccounting),
+ ("process_memory", C.c_size_t),
+ ("job_memory", C.c_size_t),
+ ("peak_process_memory", C.c_size_t),
+ ("peak_job_memory", C.c_size_t),
+ ]
+
+
+class ProcessJob:
+ def __init__(self):
+ self._kernel = C.WinDLL("kernel32", use_last_error=True)
+ signatures = {
+ "CreateJobObjectW": ([C.c_void_p, W.LPCWSTR], W.HANDLE),
+ "SetInformationJobObject": ([W.HANDLE, C.c_int, C.c_void_p, W.DWORD], W.BOOL),
+ "OpenProcess": ([W.DWORD, W.BOOL, W.DWORD], W.HANDLE),
+ "AssignProcessToJobObject": ([W.HANDLE, W.HANDLE], W.BOOL),
+ "CloseHandle": ([W.HANDLE], W.BOOL),
+ }
+ for name, (arguments, result) in signatures.items():
+ function = getattr(self._kernel, name)
+ function.argtypes = arguments
+ function.restype = result
+ self._handle = self._kernel.CreateJobObjectW(None, None)
+ if not self._handle:
+ raise C.WinError(C.get_last_error())
+ limits = ExtendedLimits()
+ limits.basic.flags = 0x00002000 # JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
+ if not self._kernel.SetInformationJobObject(
+ self._handle, 9, C.byref(limits), C.sizeof(limits)
+ ):
+ error = C.WinError(C.get_last_error())
+ self.close()
+ raise error
+
+ def assign(self, pid: int) -> None:
+ process = self._kernel.OpenProcess(0x0101, False, pid) # SET_QUOTA | TERMINATE
+ if not process:
+ raise C.WinError(C.get_last_error())
+ try:
+ if not self._kernel.AssignProcessToJobObject(self._handle, process):
+ raise C.WinError(C.get_last_error())
+ finally:
+ self._kernel.CloseHandle(process)
+
+ def close(self) -> None:
+ if self._handle:
+ self._kernel.CloseHandle(self._handle)
+ self._handle = None
diff --git a/src/source_robotics_leap_motion/backends/_native.py b/src/source_robotics_leap_motion/backends/_native.py
new file mode 100644
index 0000000..974177e
--- /dev/null
+++ b/src/source_robotics_leap_motion/backends/_native.py
@@ -0,0 +1,92 @@
+"""Internal LeapC declarations. Packed ABI; public objects never retain native memory.
+
+Checked against Ultraleap's LeapC declarations. See docs/NATIVE_ABI.md.
+"""
+
+import ctypes as C
+
+
+class Packed(C.Structure):
+ _layout_ = "ms"
+ _pack_ = 1
+
+
+class NativeVector(Packed):
+ _fields_ = [(axis, C.c_float) for axis in ("x", "y", "z")]
+
+
+class NativeQuaternion(Packed):
+ _fields_ = [(axis, C.c_float) for axis in ("x", "y", "z", "w")]
+
+
+class NativeBone(Packed):
+ _fields_ = [
+ ("prev_joint", NativeVector),
+ ("next_joint", NativeVector),
+ ("width", C.c_float),
+ ("rotation", NativeQuaternion),
+ ]
+
+
+class NativeDigit(Packed):
+ _fields_ = [
+ ("finger_id", C.c_int32),
+ ("bones", NativeBone * 4),
+ ("is_extended", C.c_uint32),
+ ]
+
+
+class NativePalm(Packed):
+ _fields_ = [
+ ("position", NativeVector),
+ ("stabilized_position", NativeVector),
+ ("velocity", NativeVector),
+ ("normal", NativeVector),
+ ("width", C.c_float),
+ ("direction", NativeVector),
+ ("orientation", NativeQuaternion),
+ ]
+
+
+class NativeHand(Packed):
+ _fields_ = [
+ ("id", C.c_uint32),
+ ("flags", C.c_uint32),
+ ("type", C.c_uint32),
+ ("confidence", C.c_float),
+ ("visible_time", C.c_uint64),
+ ("pinch_distance", C.c_float),
+ ("grab_angle", C.c_float),
+ ("pinch_strength", C.c_float),
+ ("grab_strength", C.c_float),
+ ("palm", NativePalm),
+ ("digits", NativeDigit * 5),
+ ("arm", NativeBone),
+ ]
+
+
+class NativeFrameHeader(Packed):
+ _fields_ = [
+ ("reserved", C.c_void_p),
+ ("frame_id", C.c_int64),
+ ("timestamp", C.c_int64),
+ ]
+
+
+class NativeTrackingEvent(Packed):
+ _fields_ = [
+ ("info", NativeFrameHeader),
+ ("tracking_frame_id", C.c_int64),
+ ("nHands", C.c_uint32),
+ ("pHands", C.POINTER(NativeHand)),
+ ("framerate", C.c_float),
+ ]
+
+
+class NativeMessage(Packed):
+ _fields_ = [
+ ("size", C.c_uint32),
+ ("type", C.c_uint32),
+ ("pointer", C.c_void_p),
+ ("device_id", C.c_uint32),
+ ]
diff --git a/src/source_robotics_leap_motion/backends/auto.py b/src/source_robotics_leap_motion/backends/auto.py
new file mode 100644
index 0000000..e9cee82
--- /dev/null
+++ b/src/source_robotics_leap_motion/backends/auto.py
@@ -0,0 +1,77 @@
+"""Select installed LeapC first, with a local legacy-service fallback."""
+
+from ..delivery import cached_runtime, ensure_runtime
+from ..errors import ConnectionError, RuntimeNotFoundError
+from .leapc import LeapCBackend
+from .legacy import LegacyWebSocketBackend
+
+
+class AutoBackend:
+ name = "auto"
+
+ def __init__(self, *, connect_timeout: float = 5.0):
+ self.connect_timeout = connect_timeout
+ self._selected = None
+
+ def open(self) -> None:
+ native = LeapCBackend(connect_timeout=self.connect_timeout)
+ try:
+ native.open()
+ self._selected = native
+ except (RuntimeNotFoundError, ConnectionError) as native_error:
+ native.close()
+ # A cached runtime may already be owned by our coordinator. Acquire a
+ # lease before opening its WebSocket; an unleased client would otherwise
+ # lose tracking when another application releases the last real lease.
+ try:
+ directory = cached_runtime()
+ except RuntimeNotFoundError:
+ directory = None
+ if directory is not None:
+ from .shared import SharedRuntimeBackend
+
+ managed = SharedRuntimeBackend(directory, connect_timeout=self.connect_timeout)
+ try:
+ managed.open()
+ self._selected = managed
+ self.name = managed.name
+ return
+ except (RuntimeNotFoundError, ConnectionError):
+ managed.close()
+ legacy = LegacyWebSocketBackend(connect_timeout=self.connect_timeout)
+ try:
+ legacy.open()
+ self._selected = legacy
+ except ConnectionError as legacy_error:
+ legacy.close()
+ from .shared import SharedRuntimeBackend
+
+ try:
+ directory = ensure_runtime()
+ managed = SharedRuntimeBackend(directory, connect_timeout=self.connect_timeout)
+ try:
+ managed.open()
+ except BaseException:
+ managed.close()
+ raise
+ self._selected = managed
+ except (RuntimeNotFoundError, ConnectionError) as setup_error:
+ raise RuntimeNotFoundError(
+ f"No usable tracking backend.\nLeapC: {native_error}\n"
+ f"Legacy service: {legacy_error}\nSetup: {setup_error}"
+ ) from setup_error
+ self.name = self._selected.name
+
+ @property
+ def details(self):
+ return getattr(self._selected, "details", None)
+
+ def read(self, timeout: float):
+ if self._selected is None:
+ raise RuntimeError("Tracking backend is not open")
+ return self._selected.read(timeout)
+
+ def close(self) -> None:
+ if self._selected is not None:
+ self._selected.close()
+ self._selected = None
diff --git a/src/source_robotics_leap_motion/backends/demo.py b/src/source_robotics_leap_motion/backends/demo.py
new file mode 100644
index 0000000..56168fb
--- /dev/null
+++ b/src/source_robotics_leap_motion/backends/demo.py
@@ -0,0 +1,51 @@
+"""Deterministic synthetic frames for trying the API without a camera."""
+
+import math
+import time
+
+from ..models import Frame, Hand, Palm, Vector3
+
+
+class DemoBackend:
+ name = "demo"
+
+ def __init__(self, rate: float = 60.0, *, realtime: bool = True) -> None:
+ if not math.isfinite(rate) or not 1 <= rate <= 1000:
+ raise ValueError("Demo frame rate must be between 1 and 1000 Hz")
+ self.rate = rate
+ self.realtime = realtime
+ self._index = 0
+ self._next = 0.0
+
+ def open(self) -> None:
+ self._index = 0
+ self._next = time.perf_counter()
+
+ def read(self, timeout: float) -> Frame | None:
+ delay = self._next - time.perf_counter()
+ if self.realtime and delay > 0:
+ time.sleep(min(delay, timeout))
+ if delay > timeout:
+ return None
+ elapsed = self._index / self.rate
+ position = Vector3(0.06 * math.sin(elapsed), 0.25, -0.06 * math.cos(elapsed))
+ hand = Hand(
+ id=1,
+ handedness="right",
+ palm=Palm(position=position),
+ grab_strength=(1 + math.sin(elapsed)) / 2,
+ visible_time_us=round(elapsed * 1_000_000),
+ )
+ frame = Frame(
+ id=self._index,
+ timestamp_us=round(elapsed * 1_000_000),
+ hands=(hand,),
+ framerate=self.rate,
+ clock_domain="demo",
+ )
+ self._index += 1
+ self._next = time.perf_counter() + 1 / self.rate
+ return frame
+
+ def close(self) -> None:
+ pass
diff --git a/src/source_robotics_leap_motion/backends/leapc.py b/src/source_robotics_leap_motion/backends/leapc.py
new file mode 100644
index 0000000..bc5c833
--- /dev/null
+++ b/src/source_robotics_leap_motion/backends/leapc.py
@@ -0,0 +1,202 @@
+"""Synchronous LeapC polling. Every returned frame owns all of its data."""
+
+from __future__ import annotations
+
+import ctypes as C
+import math
+import os
+import struct
+import sys
+import time
+
+from ..errors import ConnectionError, RuntimeNotFoundError
+from ..models import Bone, Finger, Frame, Hand, Palm, Quaternion, Vector3
+from ..runtime import find_library
+from ._native import NativeHand, NativeMessage, NativeTrackingEvent
+
+TIMEOUT = 0xE2010004
+CONNECTION = 1
+CONNECTION_LOST = 2
+DEVICE = 3
+DEVICE_FAILURE = 4
+TRACKING = 0x100
+DEVICE_LOST = 0x104
+
+
+def _vector(value, scale: float = 1.0) -> Vector3:
+ return Vector3(value.x * scale, value.y * scale, value.z * scale)
+
+
+def _quaternion(value) -> Quaternion:
+ norm = math.sqrt(value.x**2 + value.y**2 + value.z**2 + value.w**2)
+ if not math.isfinite(norm) or norm < 1e-8:
+ raise ValueError("Invalid rotation in native tracking frame")
+ return Quaternion(value.x / norm, value.y / norm, value.z / norm, value.w / norm)
+
+
+def _bone(value) -> Bone:
+ return Bone(
+ _vector(value.prev_joint, 0.001),
+ _vector(value.next_joint, 0.001),
+ value.width * 0.001,
+ _quaternion(value.rotation),
+ )
+
+
+def copy_hand(value: NativeHand) -> Hand:
+ if value.type not in (0, 1):
+ raise ValueError(f"Unexpected native hand type: {value.type}")
+ palm = value.palm
+ fingers = tuple(
+ Finger(
+ id=digit.finger_id,
+ name=name,
+ bones=tuple(_bone(bone) for bone in digit.bones),
+ extended=bool(digit.is_extended),
+ )
+ for name, digit in zip(
+ ("thumb", "index", "middle", "ring", "pinky"), value.digits, strict=True
+ )
+ )
+ return Hand(
+ id=value.id,
+ handedness="left" if value.type == 0 else "right",
+ palm=Palm(
+ position=_vector(palm.position, 0.001),
+ orientation=_quaternion(palm.orientation),
+ velocity=_vector(palm.velocity, 0.001),
+ normal=_vector(palm.normal),
+ direction=_vector(palm.direction),
+ width=palm.width * 0.001,
+ ),
+ grab_strength=value.grab_strength,
+ pinch_strength=value.pinch_strength,
+ pinch_distance=value.pinch_distance * 0.001,
+ visible_time_us=value.visible_time,
+ fingers=fingers,
+ arm=_bone(value.arm),
+ )
+
+
+def copy_frame(event: NativeTrackingEvent, device_id: int) -> Frame:
+ if event.nHands > 16 or (event.nHands and not event.pHands):
+ raise ValueError("Invalid native tracking hand array")
+ return Frame(
+ id=event.tracking_frame_id,
+ timestamp_us=event.info.timestamp,
+ hands=tuple(copy_hand(event.pHands[index]) for index in range(event.nHands)),
+ framerate=event.framerate,
+ device_id=device_id,
+ )
+
+
+class LeapCBackend:
+ name = "leapc"
+
+ def __init__(self, library_path: str | None = None, *, connect_timeout: float = 5.0):
+ self.library_path = library_path
+ self.connect_timeout = connect_timeout
+ self._dll = None
+ self._directory = None
+ self._handle = C.c_void_p()
+ self._connected = False
+
+ def _check(self, result: int, operation: str) -> None:
+ if result:
+ raise ConnectionError(f"LeapC {operation} failed (0x{result:08X})")
+
+ def open(self) -> None:
+ if sys.platform not in ("win32", "linux", "darwin") or struct.calcsize("P") != 8:
+ raise RuntimeNotFoundError(
+ "Native LeapC tracking requires 64-bit Windows, Linux, or macOS"
+ )
+ path = find_library(self.library_path)
+ try:
+ if sys.platform == "win32":
+ self._directory = os.add_dll_directory(str(path.parent))
+ self._dll = C.WinDLL(str(path))
+ else:
+ self._dll = C.CDLL(str(path))
+ signatures = {
+ "LeapCreateConnection": ([C.c_void_p, C.POINTER(C.c_void_p)], C.c_uint32),
+ "LeapOpenConnection": ([C.c_void_p], C.c_uint32),
+ "LeapSetPolicyFlags": ([C.c_void_p, C.c_uint64, C.c_uint64], C.c_uint32),
+ "LeapPollConnection": (
+ [C.c_void_p, C.c_uint32, C.POINTER(NativeMessage)],
+ C.c_uint32,
+ ),
+ "LeapCloseConnection": ([C.c_void_p], None),
+ "LeapDestroyConnection": ([C.c_void_p], None),
+ }
+ for name, (args, result) in signatures.items():
+ function = getattr(self._dll, name)
+ function.argtypes = args
+ function.restype = result
+ except (OSError, AttributeError) as exc:
+ self.close()
+ raise RuntimeNotFoundError(f"Cannot load compatible LeapC at {path}: {exc}") from exc
+ try:
+ self._check(self._dll.LeapCreateConnection(None, C.byref(self._handle)), "create")
+ self._check(self._dll.LeapOpenConnection(self._handle), "open")
+ end = time.perf_counter() + self.connect_timeout
+ while not self._connected and (remaining := end - time.perf_counter()) > 0:
+ self._poll(min(remaining, 0.1))
+ if not self._connected:
+ raise ConnectionError(
+ "LeapC loaded, but the tracking service did not connect. "
+ "The installed legacy 3.x service may need a compatible runtime update. "
+ "Run 'source-leap doctor' for details."
+ )
+ self._check(self._dll.LeapSetPolicyFlags(self._handle, 1, 0), "background policy")
+ except BaseException:
+ self.close()
+ raise
+
+ def _poll(self, timeout: float) -> Frame | None:
+ message = NativeMessage()
+ message.size = C.sizeof(message)
+ result = self._dll.LeapPollConnection(
+ self._handle, max(1, math.ceil(timeout * 1000)), C.byref(message)
+ )
+ if result == TIMEOUT:
+ return None
+ self._check(result, "poll")
+ if message.type == CONNECTION:
+ self._connected = True
+ elif message.type == CONNECTION_LOST:
+ self._connected = False
+ raise ConnectionError("Connection to the tracking service was lost")
+ elif message.type in (DEVICE_LOST, DEVICE_FAILURE):
+ raise ConnectionError("Tracking camera disconnected or reported a device failure")
+ elif message.type == TRACKING:
+ if not message.pointer:
+ raise ConnectionError("LeapC returned an empty tracking-event pointer")
+ event = C.cast(message.pointer, C.POINTER(NativeTrackingEvent)).contents
+ try:
+ return copy_frame(event, message.device_id)
+ except ValueError as exc:
+ raise ConnectionError(f"Rejected invalid native frame: {exc}") from exc
+ return None
+
+ def read(self, timeout: float) -> Frame | None:
+ if not self._handle.value:
+ raise RuntimeError("Native connection is not open")
+ end = time.perf_counter() + timeout
+ while (remaining := end - time.perf_counter()) > 0:
+ if (frame := self._poll(min(remaining, 0.1))) is not None:
+ return frame
+ return None
+
+ def close(self) -> None:
+ try:
+ if self._dll is not None and self._handle.value:
+ try:
+ self._dll.LeapCloseConnection(self._handle)
+ finally:
+ self._dll.LeapDestroyConnection(self._handle)
+ finally:
+ self._handle = C.c_void_p()
+ self._connected = False
+ if self._directory is not None:
+ self._directory.close()
+ self._directory = None
diff --git a/src/source_robotics_leap_motion/backends/legacy.py b/src/source_robotics_leap_motion/backends/legacy.py
new file mode 100644
index 0000000..77bde7a
--- /dev/null
+++ b/src/source_robotics_leap_motion/backends/legacy.py
@@ -0,0 +1,199 @@
+"""Protocol-v6 JSON from the loopback WebSocket server in legacy Leap services.
+
+This backend uses current Python, not the legacy LeapPython extension. It does
+not install or replace the service. Unavailable bone rotations are reported as None.
+"""
+
+from __future__ import annotations
+
+import json
+import math
+import time
+
+from websocket import WebSocketException, WebSocketTimeoutException, create_connection
+
+from ..errors import ConnectionError
+from ..models import Bone, Finger, Frame, Hand, Palm, Quaternion, Vector3
+
+URL = "ws://127.0.0.1:6437/v6.json"
+FINGER_NAMES = ("thumb", "index", "middle", "ring", "pinky")
+
+
+def _vector(data, scale: float = 1.0) -> Vector3:
+ if not isinstance(data, list) or len(data) != 3:
+ raise ValueError("Expected a three-component vector")
+ if any(type(value) not in (int, float) for value in data):
+ raise ValueError("Vector components must be numbers")
+ return Vector3(*(value * scale for value in data))
+
+
+def _cross(a: Vector3, b: Vector3) -> Vector3:
+ return Vector3(a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x)
+
+
+def _unit(value: Vector3) -> Vector3:
+ if value.norm < 1e-8:
+ raise ValueError("Invalid hand direction or normal")
+ return value * (1 / value.norm)
+
+
+def palm_orientation(normal: Vector3, direction: Vector3) -> Quaternion:
+ # Match LeapC palm basis: {normal x direction, -normal, -direction}.
+ y = _unit(normal) * -1
+ x = _unit(_cross(y, direction * -1))
+ z = _unit(_cross(x, y))
+ m = ((x.x, y.x, z.x), (x.y, y.y, z.y), (x.z, y.z, z.z))
+ trace = m[0][0] + m[1][1] + m[2][2]
+ if trace > 0:
+ scale = math.sqrt(trace + 1) * 2
+ values = (
+ (m[2][1] - m[1][2]) / scale,
+ (m[0][2] - m[2][0]) / scale,
+ (m[1][0] - m[0][1]) / scale,
+ scale / 4,
+ )
+ else:
+ i = max(range(3), key=lambda index: m[index][index])
+ j, k = (i + 1) % 3, (i + 2) % 3
+ scale = math.sqrt(1 + m[i][i] - m[j][j] - m[k][k]) * 2
+ q = [0.0] * 4
+ q[i] = scale / 4
+ q[j] = (m[j][i] + m[i][j]) / scale
+ q[k] = (m[k][i] + m[i][k]) / scale
+ q[3] = (m[k][j] - m[j][k]) / scale
+ values = tuple(q)
+ return Quaternion(*values)
+
+
+def _finger(pointable: dict) -> Finger:
+ kind = pointable["type"]
+ if type(kind) is not int or not 0 <= kind < 5:
+ raise ValueError("Invalid finger type")
+ keys = ("carpPosition", "mcpPosition", "pipPosition", "dipPosition", "btipPosition")
+ bones = ()
+ if all(key in pointable for key in keys):
+ joints = [_vector(pointable[key], 0.001) for key in keys]
+ bones = tuple(
+ Bone(start, end, pointable["width"] * 0.001, rotation=None)
+ for start, end in zip(joints[:-1], joints[1:], strict=True)
+ )
+ return Finger(pointable["id"], FINGER_NAMES[kind], bones, bool(pointable["extended"]))
+
+
+def decode_frame(data: dict) -> Frame:
+ try:
+ if type(data["id"]) is not int or type(data["timestamp"]) is not int:
+ raise ValueError("Frame id and timestamp must be integers")
+ hands = []
+ if not isinstance(data["hands"], list) or len(data["hands"]) > 16:
+ raise ValueError("Invalid hand array")
+ for hand in data["hands"]:
+ normal = _vector(hand["palmNormal"])
+ direction = _vector(hand["direction"])
+ fingers = tuple(
+ _finger(pointable)
+ for pointable in data.get("pointables", [])
+ if pointable.get("handId") == hand["id"] and not pointable.get("tool", False)
+ )
+ arm = None
+ if "elbow" in hand and "wrist" in hand:
+ arm = Bone(
+ _vector(hand["elbow"], 0.001),
+ _vector(hand["wrist"], 0.001),
+ hand.get("armWidth", 0) * 0.001,
+ rotation=None,
+ )
+ hands.append(
+ Hand(
+ id=hand["id"],
+ handedness=hand["type"],
+ palm=Palm(
+ position=_vector(hand["palmPosition"], 0.001),
+ orientation=palm_orientation(normal, direction),
+ velocity=_vector(hand["palmVelocity"], 0.001),
+ normal=normal,
+ direction=direction,
+ width=hand["palmWidth"] * 0.001,
+ ),
+ grab_strength=hand["grabStrength"],
+ pinch_strength=hand["pinchStrength"],
+ visible_time_us=round(hand.get("timeVisible", 0) * 1_000_000),
+ fingers=fingers,
+ arm=arm,
+ )
+ )
+ return Frame(
+ id=data["id"],
+ timestamp_us=data["timestamp"],
+ hands=tuple(hands),
+ framerate=data.get("currentFrameRate", 0),
+ clock_domain="leap_service",
+ )
+ except (KeyError, TypeError, ValueError, OverflowError, AttributeError) as exc:
+ raise ConnectionError(f"Invalid legacy tracking frame: {exc}") from exc
+
+
+class LegacyWebSocketBackend:
+ name = "legacy-websocket"
+
+ def __init__(self, *, connect_timeout: float = 3.0):
+ self.connect_timeout = connect_timeout
+ self.service_version = None
+ self._socket = None
+
+ def open(self) -> None:
+ try:
+ self._socket = create_connection(
+ URL,
+ timeout=self.connect_timeout,
+ origin="http://localhost",
+ http_no_proxy=["127.0.0.1", "localhost"],
+ # recv() still performs strict UTF-8 decoding in C. Avoid a second,
+ # Python-level byte-validation pass over every local JSON message.
+ skip_utf8_validation=True,
+ )
+ greeting = json.loads(self._socket.recv())
+ if greeting.get("version") != 6:
+ raise ConnectionError("Legacy tracking server did not negotiate JSON protocol v6")
+ self.service_version = greeting.get("serviceVersion")
+ self._socket.send(json.dumps({"focused": True}))
+ self._socket.send(json.dumps({"background": True}))
+ except (OSError, WebSocketException, ValueError, AttributeError) as exc:
+ self.close()
+ raise ConnectionError(
+ f"Cannot connect to the local Leap WebSocket service: {exc}"
+ ) from exc
+
+ def read(self, timeout: float) -> Frame | None:
+ if self._socket is None:
+ raise RuntimeError("Legacy connection is not open")
+ end = time.perf_counter() + timeout
+ while (remaining := end - time.perf_counter()) > 0:
+ self._socket.settimeout(min(remaining, 0.1))
+ try:
+ raw = self._socket.recv()
+ if not raw:
+ raise ConnectionError("The local Leap WebSocket connection closed")
+ if len(raw) > 2 * 1024 * 1024:
+ raise ConnectionError("Oversized legacy tracking message")
+ data = json.loads(raw)
+ except WebSocketTimeoutException:
+ continue
+ except (OSError, WebSocketException, ValueError) as exc:
+ raise ConnectionError(f"Legacy tracking connection failed: {exc}") from exc
+ if not isinstance(data, dict):
+ raise ConnectionError("Invalid legacy tracking message")
+ if "hands" in data:
+ return decode_frame(data)
+ event = data.get("event", {})
+ if (
+ event.get("type") == "deviceEvent"
+ and event.get("state", {}).get("attached") is False
+ ):
+ raise ConnectionError("The Leap camera was unplugged")
+ return None
+
+ def close(self) -> None:
+ if self._socket is not None:
+ socket, self._socket = self._socket, None
+ socket.close(timeout=0.2)
diff --git a/src/source_robotics_leap_motion/backends/managed.py b/src/source_robotics_leap_motion/backends/managed.py
new file mode 100644
index 0000000..1acd09c
--- /dev/null
+++ b/src/source_robotics_leap_motion/backends/managed.py
@@ -0,0 +1,196 @@
+"""On-demand local legacy engine. Never installs, stops, or modifies system services."""
+
+from __future__ import annotations
+
+import json
+import math
+import socket
+import struct
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+from ..errors import ConnectionError, RuntimeNotFoundError
+from ..runtime_bundle import inspect_runtime
+from .legacy import LegacyWebSocketBackend
+
+
+def _port_in_use() -> bool:
+ try:
+ with socket.create_connection(("127.0.0.1", 6437), timeout=0.25):
+ return True
+ except OSError:
+ return False
+
+
+class ManagedLegacyBackend:
+ name = "managed-legacy"
+
+ def __init__(
+ self,
+ directory: str | Path,
+ *,
+ connect_timeout: float = 10,
+ reuse_existing: bool = True,
+ state_dir: str | Path | None = None,
+ ):
+ if not math.isfinite(connect_timeout) or connect_timeout <= 0:
+ raise ValueError("Connection timeout must be positive and finite")
+ self.directory = Path(directory).resolve()
+ self.connect_timeout = connect_timeout
+ self.reuse_existing = reuse_existing
+ self.state_dir = (
+ Path(state_dir).resolve() if state_dir is not None else self.directory / "state"
+ )
+ self.reused_existing = False
+ self._backend = None
+ self._pending_frame = None
+ self._process = None
+ self._job = None
+ self._log = None
+
+ @property
+ def details(self) -> dict:
+ return {
+ "mode": "existing" if self.reused_existing else "private",
+ "directory": str(self.directory),
+ "pid": self._process.pid if self._process is not None else None,
+ }
+
+ def open(self) -> None:
+ if self._backend is not None or self._process is not None:
+ raise RuntimeError("Managed runtime is already open")
+ if sys.platform != "win32" or struct.calcsize("P") != 8:
+ raise RuntimeNotFoundError("The managed legacy runtime prototype requires Windows x64")
+ inspect_runtime(self.directory, verify=False)
+ self.reused_existing = False
+ if _port_in_use():
+ if not self.reuse_existing:
+ raise ConnectionError(
+ "Port 6437 is already in use. Exclusive private testing requires the existing "
+ "Leap service to be stopped first. This launcher never stops system services."
+ )
+ existing = LegacyWebSocketBackend(connect_timeout=self.connect_timeout)
+ try:
+ existing.open()
+ except BaseException:
+ existing.close()
+ raise
+ self._backend = existing
+ self.reused_existing = True
+ return
+ bundle = inspect_runtime(self.directory, verify=True)
+ state = self.state_dir
+ state.mkdir(parents=True, exist_ok=True)
+ config = state / "config.json"
+ config.write_text(
+ json.dumps(
+ {
+ "configuration": {
+ "websockets_enabled": True,
+ "websockets_allow_remote": False,
+ "background_app_mode": 2,
+ "images_mode": 0,
+ "api_namespace": "source_robotics_private",
+ }
+ },
+ indent=2,
+ ),
+ encoding="utf-8",
+ )
+ try:
+ from ._job import ProcessJob
+
+ self._job = ProcessJob()
+ self._log = (state / "engine-output.log").open("ab")
+ self._process = subprocess.Popen(
+ [bundle["engine"], "--run", "--config=" + str(config)],
+ cwd=self.directory,
+ stdin=subprocess.DEVNULL,
+ stdout=self._log,
+ stderr=subprocess.STDOUT,
+ creationflags=subprocess.CREATE_NO_WINDOW,
+ )
+ self._job.assign(self._process.pid)
+ deadline = time.perf_counter() + self.connect_timeout
+ while time.perf_counter() < deadline:
+ if self._process.poll() is not None:
+ raise ConnectionError(
+ f"Private engine exited with code {self._process.returncode}. "
+ f"See {state / 'engine-output.log'}"
+ )
+ candidate = LegacyWebSocketBackend(connect_timeout=0.25)
+ try:
+ candidate.open()
+ remaining = deadline - time.perf_counter()
+ if remaining <= 0:
+ raise ConnectionError("Private engine connection exceeded startup deadline")
+ # The WebSocket starts before the camera and tracking model are ready.
+ # Keep the first frame so callers see real readiness without losing it.
+ first_frame = candidate.read(remaining)
+ if first_frame is None:
+ raise ConnectionError(
+ "Private engine connected but produced no camera frames within "
+ f"{self.connect_timeout:g}s. See {state / 'engine-output.log'}"
+ )
+ self._pending_frame = first_frame
+ self._backend = candidate
+ return
+ except BaseException as exc:
+ candidate.close()
+ if not isinstance(exc, ConnectionError):
+ raise
+ time.sleep(0.05)
+ raise ConnectionError(
+ f"Private engine startup timed out. See {state / 'engine-output.log'}"
+ )
+ except BaseException:
+ self.close()
+ raise
+
+ def read(self, timeout: float):
+ if self._backend is None:
+ raise RuntimeError("Managed tracking is not open")
+ if self._process is not None and self._process.poll() is not None:
+ raise ConnectionError(f"Private engine stopped with code {self._process.returncode}")
+ if self._pending_frame is not None:
+ frame, self._pending_frame = self._pending_frame, None
+ return frame
+ return self._backend.read(timeout)
+
+ @property
+ def is_running(self) -> bool:
+ return self._process is not None and self._process.poll() is None
+
+ def release_tracking_client(self) -> None:
+ """A coordinator owns the engine while applications own tracking clients."""
+ if self._backend is not None:
+ self._backend.close()
+ self._backend = None
+ self._pending_frame = None
+
+ def close(self) -> None:
+ try:
+ if self._backend is not None:
+ self._backend.close()
+ finally:
+ self._backend = None
+ self._pending_frame = None
+ try:
+ if self._process is not None:
+ if self._process.poll() is None:
+ self._process.terminate()
+ try:
+ self._process.wait(timeout=3)
+ except subprocess.TimeoutExpired:
+ self._process.kill()
+ self._process.wait(timeout=3)
+ finally:
+ self._process = None
+ if self._job is not None:
+ self._job.close()
+ self._job = None
+ if self._log is not None:
+ self._log.close()
+ self._log = None
diff --git a/src/source_robotics_leap_motion/backends/replay.py b/src/source_robotics_leap_motion/backends/replay.py
new file mode 100644
index 0000000..3e6c990
--- /dev/null
+++ b/src/source_robotics_leap_motion/backends/replay.py
@@ -0,0 +1,63 @@
+"""A replay source with optional acquisition-time pacing."""
+
+import math
+import time
+from pathlib import Path
+
+from ..errors import RecordingError
+from ..recording import read_recording
+
+
+class ReplayBackend:
+ name = "replay"
+
+ def __init__(self, path: str | Path, *, realtime: bool = False, speed: float = 1.0):
+ if not math.isfinite(speed) or speed <= 0:
+ raise ValueError("Replay speed must be positive and finite")
+ self.path = Path(path)
+ self.realtime = realtime
+ self.speed = speed
+ self._frames = None
+ self._pending = None
+ self._origin = None
+ self._clock_domain = None
+ self._last_timestamp = None
+ self._start = 0.0
+
+ def open(self) -> None:
+ self._frames = read_recording(self.path)
+ self._pending = None
+ self._origin = None
+ self._clock_domain = None
+ self._last_timestamp = None
+ self._start = time.perf_counter()
+
+ def read(self, timeout: float):
+ if self._frames is None:
+ raise RuntimeError("Replay is not open")
+ if self._pending is None:
+ self._pending = next(self._frames)
+ frame = self._pending
+ if self._origin is None:
+ self._origin = frame.timestamp_us
+ self._clock_domain = frame.clock_domain
+ if self.realtime:
+ if frame.clock_domain != self._clock_domain or (
+ self._last_timestamp is not None and frame.timestamp_us < self._last_timestamp
+ ):
+ raise RecordingError("Paced replay requires one monotonically increasing clock")
+ due = self._start + (frame.timestamp_us - self._origin) / 1_000_000 / self.speed
+ delay = due - time.perf_counter()
+ if delay > 0:
+ time.sleep(min(delay, timeout))
+ if delay > timeout:
+ return None
+ self._last_timestamp = frame.timestamp_us
+ self._pending = None
+ return frame
+
+ def close(self) -> None:
+ if self._frames is not None:
+ self._frames.close()
+ self._frames = None
+ self._pending = None
diff --git a/src/source_robotics_leap_motion/backends/shared.py b/src/source_robotics_leap_motion/backends/shared.py
new file mode 100644
index 0000000..4b00865
--- /dev/null
+++ b/src/source_robotics_leap_motion/backends/shared.py
@@ -0,0 +1,122 @@
+"""Reuse an existing service, or lease a private engine shared by local clients."""
+
+from __future__ import annotations
+
+import json
+import socket
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+from .._locking import file_lock
+from ..errors import ConnectionError
+from ..paths import cache_root
+from ..runtime_bundle import inspect_runtime
+from .legacy import LegacyWebSocketBackend
+from .managed import _port_in_use
+
+
+def _lease(state: Path):
+ connection = None
+ try:
+ data = json.loads((state / "broker.json").read_text())
+ if type(data["port"]) is not int or not 1 <= data["port"] <= 65535:
+ return None
+ connection = socket.create_connection(("127.0.0.1", data["port"]), timeout=0.5)
+ connection.sendall(json.dumps({"token": data["token"]}).encode() + b"\n")
+ response = b""
+ while not response.endswith(b"\n") and len(response) < 1024:
+ chunk = connection.recv(1024)
+ if not chunk:
+ break
+ response += chunk
+ if json.loads(response).get("ready") is True:
+ return connection
+ except (OSError, ValueError, KeyError, TypeError, AttributeError):
+ pass
+ if connection is not None:
+ connection.close()
+ return None
+
+
+class SharedRuntimeBackend:
+ name = "managed-legacy"
+
+ def __init__(self, directory, *, connect_timeout=10):
+ self.directory = Path(directory).resolve()
+ self.connect_timeout = max(connect_timeout, 10)
+ self._connection = None
+ self._backend = None
+ self._mode = None
+
+ @property
+ def details(self):
+ return {"mode": self._mode, "directory": str(self.directory)}
+
+ def open(self):
+ if self._backend is not None:
+ raise RuntimeError("Shared tracking is already open")
+ inspect_runtime(self.directory, verify=False)
+ root = cache_root()
+ state = root / "engine"
+ try:
+ with file_lock(root / "engine.lock", timeout=self.connect_timeout + 5):
+ self._connection = _lease(state)
+ if self._connection is None and not _port_in_use():
+ state.mkdir(parents=True, exist_ok=True)
+ # Only coordinator-owned state; stale credentials cannot identify a live lease.
+ (state / "broker.json").unlink(missing_ok=True)
+ with (state / "broker-output.log").open("ab") as output:
+ child = subprocess.Popen(
+ [
+ sys.executable,
+ "-m",
+ "source_robotics_leap_motion._broker",
+ "--directory",
+ str(self.directory),
+ "--state",
+ str(state),
+ "--timeout",
+ str(self.connect_timeout),
+ ],
+ stdin=subprocess.DEVNULL,
+ stdout=output,
+ stderr=subprocess.STDOUT,
+ creationflags=subprocess.CREATE_NO_WINDOW
+ if sys.platform == "win32"
+ else 0,
+ start_new_session=sys.platform != "win32",
+ )
+ deadline = time.perf_counter() + self.connect_timeout + 2
+ while self._connection is None:
+ self._connection = _lease(state)
+ if self._connection is not None:
+ break
+ if child.poll() is not None or time.perf_counter() >= deadline:
+ raise ConnectionError(
+ "Private engine coordinator did not become ready. "
+ f"See {state / 'broker-output.log'}"
+ )
+ time.sleep(0.05)
+ self._mode = "shared-private" if self._connection is not None else "existing"
+ self._backend = LegacyWebSocketBackend(connect_timeout=self.connect_timeout)
+ self._backend.open()
+ except BaseException:
+ self.close()
+ raise
+
+ def read(self, timeout):
+ if self._backend is None:
+ raise RuntimeError("Shared tracking is not open")
+ return self._backend.read(timeout)
+
+ def close(self):
+ try:
+ if self._backend is not None:
+ self._backend.close()
+ finally:
+ self._backend = None
+ if self._connection is not None:
+ self._connection.close()
+ self._connection = None
diff --git a/src/source_robotics_leap_motion/cli.py b/src/source_robotics_leap_motion/cli.py
new file mode 100644
index 0000000..2774e9b
--- /dev/null
+++ b/src/source_robotics_leap_motion/cli.py
@@ -0,0 +1,364 @@
+"""Command line monitor, diagnostics, recording, and replay."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import logging
+import math
+import sys
+import time
+from contextlib import nullcontext
+
+from . import __version__
+from .backends.replay import ReplayBackend
+from .errors import LeapError, TrackingTimeoutError
+from .recording import Recorder, frame_to_dict
+from .runtime import diagnose
+from .runtime_bundle import inspect_runtime, prepare_local_runtime
+from .tracker import Tracker
+
+
+def _positive_float(value: str) -> float:
+ number = float(value)
+ if not math.isfinite(number) or number <= 0:
+ raise argparse.ArgumentTypeError("must be positive and finite")
+ return number
+
+
+def _positive_int(value: str) -> int:
+ number = int(value)
+ if number <= 0:
+ raise argparse.ArgumentTypeError("must be positive")
+ return number
+
+
+def _tracking_options(parser: argparse.ArgumentParser) -> None:
+ parser.add_argument(
+ "--demo", action="store_true", help="use synthetic hands; no hardware needed"
+ )
+ parser.add_argument("--frames", type=_positive_int, help="stop after this many frames")
+ parser.add_argument("--duration", type=_positive_float, help="stop after this many seconds")
+ parser.add_argument("--json", action="store_true", help="write every frame as JSON Lines")
+ runtime = parser.add_mutually_exclusive_group()
+ runtime.add_argument("--library", help="explicit LeapC library path for development")
+ runtime.add_argument("--runtime-dir", help="use a prepared local legacy runtime if needed")
+ parser.add_argument(
+ "--timeout", type=_positive_float, default=5, help="tracking deadline in seconds"
+ )
+
+
+def _parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(prog="source-leap", description="Leap Motion tracking tools")
+ parser.add_argument("--version", action="version", version=__version__)
+ commands = parser.add_subparsers(dest="command", required=True)
+ setup = commands.add_parser("setup", help="check device access and prepare tracking")
+ setup.add_argument(
+ "--archive", help="install a local runtime release archive without downloading"
+ )
+ setup.add_argument("--catalog", help="release catalog path for deployment/testing")
+ setup.add_argument(
+ "--download-only", action="store_true", help="prepare runtime without opening the camera"
+ )
+ setup.add_argument(
+ "--install-driver",
+ action="store_true",
+ help="request Windows approval for a missing driver",
+ )
+ setup.add_argument(
+ "--open-downloads", action="store_true", help="open the official platform setup page"
+ )
+ setup.add_argument("--json", action="store_true")
+ setup.add_argument("--timeout", type=_positive_float, default=10)
+ monitor = commands.add_parser("monitor", help="show hands (default command)")
+ _tracking_options(monitor)
+ doctor = commands.add_parser("doctor", help="inspect runtime prerequisites")
+ doctor.add_argument("--json", action="store_true")
+ runtime = doctor.add_mutually_exclusive_group()
+ runtime.add_argument("--library")
+ runtime.add_argument("--runtime-dir")
+ doctor.add_argument("--probe", action="store_true", help="also attempt to receive a live frame")
+ doctor.add_argument("--timeout", type=_positive_float, default=3)
+ record = commands.add_parser("record", help="record a new JSON Lines session")
+ record.add_argument("path")
+ record.add_argument("--overwrite", action="store_true", help="replace an existing recording")
+ _tracking_options(record)
+ replay = commands.add_parser("replay", help="inspect a recorded session")
+ replay.add_argument("path")
+ replay.add_argument("--realtime", action="store_true", help="pace using recorded timestamps")
+ replay.add_argument("--speed", type=_positive_float, default=1)
+ replay.add_argument("--frames", type=_positive_int)
+ replay.add_argument("--duration", type=_positive_float)
+ replay.add_argument("--json", action="store_true")
+ replay.add_argument("--timeout", type=_positive_float, default=5)
+ runtime = commands.add_parser("runtime", help="prepare or inspect a local runtime prototype")
+ actions = runtime.add_subparsers(dest="runtime_action", required=True)
+ prepare = actions.add_parser(
+ "prepare", help="copy the core from an existing local installation"
+ )
+ prepare.add_argument("source")
+ prepare.add_argument("--destination", required=True)
+ prepare.add_argument("--dependencies", help="directory containing the VC++ runtime DLLs")
+ prepare.add_argument(
+ "--extra-models", action="store_true", help="also copy alternate model files"
+ )
+ inspect = actions.add_parser("inspect", help="verify the local bundle's file checksums")
+ inspect.add_argument("directory")
+ probe = actions.add_parser("probe", help="test tracking through a local runtime")
+ probe.add_argument("directory")
+ probe.add_argument(
+ "--exclusive", action="store_true", help="refuse reuse of an existing service"
+ )
+ probe.add_argument("--frames", type=_positive_int, default=30)
+ probe.add_argument("--timeout", type=_positive_float, default=10)
+ return parser
+
+
+def _setup(args) -> int:
+ from .delivery import cached_runtime, ensure_runtime, install_archive
+ from .setup import install_windows_driver, setup_report
+
+ if args.archive:
+ install_archive(args.archive)
+ if args.download_only or args.catalog:
+ ensure_runtime(catalog=args.catalog)
+ report = setup_report()
+ if args.open_downloads:
+ import webbrowser
+
+ report["download_page_opened"] = webbrowser.open(report["download_url"])
+ needs_driver = report["device"]["status"] == "driver-required"
+ install_driver = args.install_driver
+ interactive = sys.stdin.isatty() and not args.json
+ if needs_driver and not install_driver and interactive:
+ install_driver = input(
+ "Windows reports a missing Leap driver. Install it now? [y/N] "
+ ).strip().lower() in ("y", "yes")
+ if install_driver:
+ directory = cached_runtime() or ensure_runtime(catalog=args.catalog)
+ report["driver_setup"] = install_windows_driver(directory)
+ report["device"] = setup_report()["device"]
+ exit_code = 0
+ if not args.download_only:
+ try:
+ with Tracker(timeout=args.timeout) as tracker:
+ frame = tracker.read()
+ report["tracking"] = {
+ "ready": True,
+ "backend": tracker.backend_name,
+ "hands": len(frame.hands),
+ "runtime": tracker.runtime_details,
+ }
+ except LeapError as exc:
+ report["tracking"] = {"ready": False, "error": str(exc)}
+ exit_code = 2
+ if args.json:
+ print(json.dumps(report, indent=2))
+ else:
+ print(f"Platform: {report['platform']}")
+ print(f"Controller: {report['device']['status']}")
+ print(f"Runtime cache: {report['cached_runtime'] or 'not installed'}")
+ if "tracking" in report:
+ tracking = report["tracking"]
+ print("Tracking: ready" if tracking["ready"] else "Tracking: " + tracking["error"])
+ if exit_code:
+ for instruction in report["instructions"]:
+ print(instruction)
+ print("Setup instructions: " + report["download_url"])
+ return exit_code
+
+
+def _doctor(args) -> int:
+ report = diagnose(args.library).to_dict()
+ exit_code = 0 if report["library"] or report["cached_runtime"] else 2
+ if args.probe:
+ try:
+ with Tracker(
+ library_path=args.library, runtime_dir=args.runtime_dir, timeout=args.timeout
+ ) as tracker:
+ report["selected_backend"] = tracker.backend_name
+ if tracker.runtime_details is not None:
+ report["local_runtime"] = tracker.runtime_details
+ frame = tracker.read()
+ report["probe"] = {"received_frame": True, "hands": len(frame.hands)}
+ exit_code = 0
+ except LeapError as exc:
+ report["probe"] = {"received_frame": False, "error": str(exc)}
+ exit_code = 2
+ if args.json:
+ print(json.dumps(report, indent=2))
+ else:
+ print(f"Source Robotics Leap Motion {__version__}")
+ print(f"Python: {report['python']} ({report['architecture_bits']}-bit)")
+ print(f"Interpreter: {report['executable']}")
+ print(f"LeapC: {report['library'] or 'not found'}")
+ print(f"Cached runtime: {report['cached_runtime'] or 'not installed'}")
+ service = report["service_executable"] or "not detected in Windows registry"
+ print(f"Tracking service: {service}")
+ for note in report["notes"]:
+ print(f"- {note}")
+ if "probe" in report:
+ probe = report["probe"]
+ print(
+ "Probe: "
+ + (
+ f"received a frame with {probe['hands']} hand(s)"
+ if probe["received_frame"]
+ else probe["error"]
+ )
+ )
+ if exit_code:
+ print(f"Runtime download: {report['download_url']}")
+ return exit_code
+
+
+def _runtime(args) -> int:
+ if args.runtime_action == "prepare":
+ result = prepare_local_runtime(
+ args.source,
+ args.destination,
+ dependency_dir=args.dependencies,
+ extra_models=args.extra_models,
+ )
+ elif args.runtime_action == "inspect":
+ result = inspect_runtime(args.directory)
+ else:
+ from .backends.managed import ManagedLegacyBackend
+
+ if args.exclusive:
+ backend = ManagedLegacyBackend(
+ args.directory, connect_timeout=args.timeout, reuse_existing=False
+ )
+ else:
+ from .backends.shared import SharedRuntimeBackend
+
+ backend = SharedRuntimeBackend(args.directory, connect_timeout=args.timeout)
+ with Tracker(backend=backend, timeout=args.timeout) as tracker:
+ hand_frames = 0
+ for _ in range(args.frames):
+ hand_frames += bool(tracker.read().hands)
+ result = {
+ **backend.details,
+ "frames": args.frames,
+ "hand_frames": hand_frames,
+ }
+ print(json.dumps(result, indent=2))
+ return 0
+
+
+def _run(args) -> int:
+ if args.command == "replay":
+ tracker = Tracker(
+ backend=ReplayBackend(args.path, realtime=args.realtime, speed=args.speed),
+ timeout=args.timeout,
+ )
+ elif args.demo:
+ if args.library or args.runtime_dir:
+ raise ValueError("--demo cannot be combined with a runtime or library selection")
+ tracker = Tracker.demo()
+ else:
+ tracker = Tracker(
+ library_path=args.library, runtime_dir=args.runtime_dir, timeout=args.timeout
+ )
+ count = 0
+ last_print = -math.inf
+ started = time.perf_counter()
+ recorder = Recorder(args.path, overwrite=args.overwrite) if args.command == "record" else None
+ # Open tracking first so a missing runtime does not create an empty recording.
+ with tracker, recorder if recorder is not None else nullcontext():
+ started = time.perf_counter()
+ if not args.json:
+ source = (
+ "recording"
+ if args.command == "replay"
+ else "DEMO (synthetic)"
+ if args.demo
+ else "live"
+ )
+ print(f"Source: {source}. Positions in metres. Ctrl+C to stop.", file=sys.stderr)
+ while True:
+ elapsed = time.perf_counter() - started
+ if args.duration is not None and elapsed >= args.duration:
+ break
+ timeout = args.timeout
+ if args.duration is not None:
+ timeout = min(timeout, args.duration - elapsed)
+ try:
+ frame = tracker.read(timeout=timeout)
+ except StopIteration:
+ break
+ except TrackingTimeoutError:
+ if args.duration is not None and time.perf_counter() - started >= args.duration:
+ break
+ raise
+ if recorder is not None:
+ recorder.write(frame)
+ count += 1
+ now = time.perf_counter()
+ if args.json:
+ print(json.dumps(frame_to_dict(frame), allow_nan=False, separators=(",", ":")))
+ elif now - last_print >= 0.1:
+ description = (
+ " | ".join(
+ f"{hand.handedness} #{hand.id}: "
+ f"({hand.palm.position.x:+.3f}, {hand.palm.position.y:+.3f}, "
+ f"{hand.palm.position.z:+.3f}) grab={hand.grab_strength:.2f}"
+ for hand in frame.hands
+ )
+ or "no hands"
+ )
+ print(f"Frame {frame.id}: {description}", flush=True)
+ last_print = now
+ if args.frames is not None and count >= args.frames:
+ break
+ print(f"Processed {count} frame(s).", file=sys.stderr)
+ return 0
+
+
+def main(argv: list[str] | None = None) -> int:
+ arguments = list(sys.argv[1:] if argv is None else argv)
+ commands = {
+ "monitor",
+ "doctor",
+ "setup",
+ "record",
+ "replay",
+ "runtime",
+ "--version",
+ "--help",
+ "-h",
+ }
+ if not arguments or arguments[0] not in commands:
+ arguments.insert(0, "monitor")
+ args = _parser().parse_args(arguments)
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
+ try:
+ if args.command == "setup":
+ return _setup(args)
+ if args.command == "doctor":
+ return _doctor(args)
+ if args.command == "runtime":
+ return _runtime(args)
+ return _run(args)
+ except KeyboardInterrupt:
+ print("Stopped.", file=sys.stderr)
+ return 130
+ except (LeapError, OSError, ValueError) as exc:
+ if (
+ args.command == "monitor"
+ and not args.demo
+ and not args.json
+ and args.library is None
+ and args.runtime_dir is None
+ and sys.stdin.isatty()
+ ):
+ print(str(exc), file=sys.stderr)
+ if input("Run tracking setup now? [Y/n] ").strip().lower() in ("", "y", "yes"):
+ try:
+ result = _setup(_parser().parse_args(["setup"]))
+ return _run(args) if result == 0 else result
+ except (LeapError, OSError, ValueError) as setup_error:
+ print(f"source-leap: {setup_error}", file=sys.stderr)
+ return 2
+ print(f"source-leap: {exc}", file=sys.stderr)
+ return 2
diff --git a/src/source_robotics_leap_motion/delivery.py b/src/source_robotics_leap_motion/delivery.py
new file mode 100644
index 0000000..ddd3243
--- /dev/null
+++ b/src/source_robotics_leap_motion/delivery.py
@@ -0,0 +1,252 @@
+"""Pinned runtime downloads, resumable transfers, validated extraction, and caching."""
+
+from __future__ import annotations
+
+import http.client
+import json
+import logging
+import os
+import re
+import shutil
+import stat
+import tempfile
+import urllib.error
+import urllib.parse
+import urllib.request
+import zipfile
+from pathlib import Path
+
+from ._locking import file_lock
+from .errors import RuntimeNotFoundError
+from .paths import cache_root, platform_key
+from .runtime_bundle import ALLOWED_FILES, MANIFEST, file_hash, inspect_runtime
+
+CATALOG_SCHEMA = "source-robotics-leap-motion/catalog"
+MAX_PAYLOAD = 2 * 1024**3
+
+
+def _write_json(path: Path, value: dict) -> None:
+ temporary = path.with_name(path.name + "." + os.urandom(6).hex() + ".tmp")
+ try:
+ temporary.write_text(json.dumps(value, indent=2), encoding="utf-8")
+ temporary.replace(path)
+ finally:
+ temporary.unlink(missing_ok=True)
+
+
+def load_catalog(path: str | Path | None = None) -> dict:
+ selected = path or os.environ.get("SOURCE_LEAP_CATALOG")
+ source = Path(selected) if selected else Path(__file__).with_name("runtime_catalog.json")
+ try:
+ if source.stat().st_size > 65536:
+ raise ValueError("catalog exceeds size limit")
+ data = json.loads(source.read_text(encoding="utf-8"))
+ if data["schema"] != CATALOG_SCHEMA or data["version"] != 1:
+ raise ValueError("unsupported catalog")
+ if not isinstance(data["runtimes"], dict):
+ raise ValueError("runtimes must be an object")
+ return data
+ except (OSError, ValueError, TypeError, KeyError) as exc:
+ raise RuntimeNotFoundError(f"Cannot read runtime catalog: {exc}") from exc
+
+
+def _descriptor(data: dict) -> dict:
+ try:
+ if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9._-]{0,59}", data["id"]):
+ raise ValueError("invalid runtime id")
+ if not re.fullmatch(r"[0-9a-f]{64}", data["sha256"]):
+ raise ValueError("invalid SHA-256")
+ for name in ("bytes", "unpacked_bytes"):
+ if type(data[name]) is not int or not 0 < data[name] <= MAX_PAYLOAD:
+ raise ValueError(f"invalid {name}")
+ parsed = urllib.parse.urlsplit(data["url"])
+ local_test = parsed.scheme == "http" and parsed.hostname in ("127.0.0.1", "localhost")
+ if not (parsed.scheme == "https" or local_test) or not parsed.hostname:
+ raise ValueError("runtime downloads require HTTPS")
+ if parsed.username or parsed.password or parsed.fragment:
+ raise ValueError("credentials/fragments are not allowed in download URLs")
+ return data
+ except (KeyError, ValueError, TypeError) as exc:
+ raise RuntimeNotFoundError(f"Invalid runtime download descriptor: {exc}") from exc
+
+
+def _download(entry: dict, target: Path, progress=None) -> Path:
+ partial = target.with_suffix(".partial")
+ if target.exists():
+ if target.stat().st_size == entry["bytes"] and file_hash(target) == entry["sha256"]:
+ return target
+ target.unlink()
+ offset = partial.stat().st_size if partial.exists() else 0
+ if offset > entry["bytes"]:
+ partial.unlink()
+ offset = 0
+ if offset == entry["bytes"]:
+ if file_hash(partial) == entry["sha256"]:
+ partial.replace(target)
+ return target
+ partial.unlink()
+ offset = 0
+ headers = {"User-Agent": "source-robotics-leap-motion", "Accept-Encoding": "identity"}
+ if offset:
+ headers["Range"] = f"bytes={offset}-"
+ try:
+ logging.getLogger(__name__).info(
+ "Downloading tracking runtime: %.1f MB%s",
+ entry["bytes"] / 1_000_000,
+ " (resuming)" if offset else "",
+ )
+ request = urllib.request.Request(entry["url"], headers=headers)
+ with urllib.request.urlopen(request, timeout=30) as response:
+ final_url = urllib.parse.urlsplit(response.geturl())
+ initial_url = urllib.parse.urlsplit(entry["url"])
+ if initial_url.scheme == "https" and final_url.scheme != "https":
+ raise ValueError("download redirected away from HTTPS")
+ if response.status == 206:
+ expected = f"bytes {offset}-{entry['bytes'] - 1}/{entry['bytes']}"
+ if response.headers.get("Content-Range") != expected:
+ partial.unlink(missing_ok=True)
+ raise ValueError("invalid resumed Content-Range")
+ elif response.status == 200:
+ offset = 0 # A server may ignore Range; restart instead of appending.
+ else:
+ raise ValueError(f"unexpected HTTP status {response.status}")
+ with partial.open("ab" if offset else "wb") as stream:
+ received = offset
+ while chunk := response.read(1024 * 1024):
+ received += len(chunk)
+ if received > entry["bytes"]:
+ raise ValueError("download exceeds its pinned size")
+ stream.write(chunk)
+ if progress is not None:
+ progress(received, entry["bytes"])
+ if partial.stat().st_size != entry["bytes"]:
+ raise OSError("Download interrupted; run setup again to resume")
+ if file_hash(partial) != entry["sha256"]:
+ partial.unlink()
+ raise ValueError("runtime archive SHA-256 mismatch")
+ partial.replace(target)
+ return target
+ except (OSError, ValueError, http.client.HTTPException) as exc:
+ raise RuntimeNotFoundError(f"Runtime download failed: {exc}") from exc
+
+
+def _extract(archive: Path, destination: Path, maximum: int) -> None:
+ try:
+ with zipfile.ZipFile(archive) as bundle:
+ members = bundle.infolist()
+ names = [member.filename for member in members]
+ if len(names) != len(set(names)) or MANIFEST not in names:
+ raise ValueError("missing manifest or duplicate archive members")
+ if not set(names) <= ALLOWED_FILES | {MANIFEST}:
+ raise ValueError("unsupported archive member")
+ manifest_info = bundle.getinfo(MANIFEST)
+ if manifest_info.file_size > 65536:
+ raise ValueError("manifest exceeds size limit")
+ manifest = json.loads(bundle.read(MANIFEST))
+ entries = manifest["files"]
+ sizes = {entry["name"]: entry["bytes"] for entry in entries}
+ if len(sizes) != len(entries) or set(names) != set(sizes) | {MANIFEST}:
+ raise ValueError("archive contents differ from its manifest")
+ if any(type(size) is not int or size < 0 for size in sizes.values()):
+ raise ValueError("invalid uncompressed size")
+ if sum(sizes.values()) > maximum:
+ raise ValueError("uncompressed payload exceeds its size limit")
+ for member in members:
+ if member.flag_bits & 1 or stat.S_ISLNK(member.external_attr >> 16):
+ raise ValueError("encrypted or linked archive member")
+ expected = (
+ manifest_info.file_size
+ if member.filename == MANIFEST
+ else sizes[member.filename]
+ )
+ if member.file_size != expected:
+ raise ValueError("archive member size mismatch")
+ target = destination / member.filename
+ target.parent.mkdir(parents=True, exist_ok=True)
+ with bundle.open(member) as origin, target.open("xb") as output:
+ shutil.copyfileobj(origin, output, 1024 * 1024)
+ inspect_runtime(destination, verify=True)
+ except (OSError, ValueError, KeyError, TypeError, zipfile.BadZipFile) as exc:
+ raise RuntimeNotFoundError(f"Invalid runtime archive: {exc}") from exc
+
+
+def _install(archive: Path, root: Path, runtime_id: str, maximum: int) -> Path:
+ if platform_key() != "windows-x86_64":
+ raise RuntimeNotFoundError(
+ "Managed runtime archives currently target Windows x64. "
+ "Run source-leap setup for this platform's native service instructions."
+ )
+ destination = root / "runtimes" / runtime_id
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ if destination.exists():
+ inspect_runtime(destination)
+ else:
+ staging = Path(tempfile.mkdtemp(prefix=".install-", dir=destination.parent))
+ try:
+ _extract(archive, staging, maximum)
+ staging.replace(destination)
+ finally:
+ if staging.exists():
+ if staging.resolve().parent != destination.parent.resolve():
+ raise RuntimeNotFoundError(
+ "Refusing cleanup outside the runtime staging directory"
+ )
+ shutil.rmtree(staging) # mkdtemp path under our explicit runtimes directory.
+ _write_json(root / "active.json", {"id": runtime_id, "platform": platform_key()})
+ return destination
+
+
+def install_archive(archive: str | Path, *, root: Path | None = None) -> Path:
+ """Explicit offline import. The local file is trusted by the caller, then validated."""
+ archive = Path(archive).resolve()
+ if not archive.is_file() or archive.stat().st_size > MAX_PAYLOAD:
+ raise RuntimeNotFoundError("Runtime archive is missing or too large")
+ root = (root or cache_root()).resolve()
+ runtime_id = "local-" + file_hash(archive)[:24]
+ with file_lock(root / "setup.lock"):
+ return _install(archive, root, runtime_id, MAX_PAYLOAD)
+
+
+def cached_runtime(*, root: Path | None = None) -> Path | None:
+ root = root or cache_root()
+ try:
+ data = json.loads((root / "active.json").read_text(encoding="utf-8"))
+ if data["platform"] != platform_key() or not re.fullmatch(
+ r"[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}", data["id"]
+ ):
+ raise ValueError("invalid cached runtime selection")
+ directory = root / "runtimes" / data["id"]
+ inspect_runtime(directory, verify=False)
+ return directory
+ except FileNotFoundError:
+ return None
+ except (OSError, ValueError, KeyError, TypeError) as exc:
+ raise RuntimeNotFoundError(f"Invalid runtime cache; run source-leap setup: {exc}") from exc
+
+
+def ensure_runtime(
+ *, catalog: str | Path | None = None, root: Path | None = None, progress=None
+) -> Path:
+ root = (root or cache_root()).resolve()
+ if (existing := cached_runtime(root=root)) is not None:
+ return existing
+ data = load_catalog(catalog)
+ selected = data["runtimes"].get(platform_key())
+ if selected is None:
+ raise RuntimeNotFoundError(
+ f"No automatic runtime download is configured for {platform_key()} in this build. "
+ "Run 'source-leap setup' for device setup or use 'source-leap setup --archive PATH' "
+ "with the release's offline runtime archive."
+ )
+ entry = _descriptor(selected)
+ with file_lock(root / "setup.lock", timeout=180):
+ if (existing := cached_runtime(root=root)) is not None:
+ return existing
+ downloads = root / "downloads"
+ downloads.mkdir(parents=True, exist_ok=True)
+ archive = _download(entry, downloads / (entry["sha256"] + ".zip"), progress)
+ result = _install(
+ archive, root, entry["id"] + "-" + entry["sha256"][:12], entry["unpacked_bytes"]
+ )
+ archive.unlink(missing_ok=True) # Keep one extracted copy, not a second 241 MB archive.
+ return result
diff --git a/src/source_robotics_leap_motion/errors.py b/src/source_robotics_leap_motion/errors.py
new file mode 100644
index 0000000..5a373b1
--- /dev/null
+++ b/src/source_robotics_leap_motion/errors.py
@@ -0,0 +1,21 @@
+"""Public exceptions, independent of the native runtime."""
+
+
+class LeapError(Exception):
+ """Base error for tracking, recording, and teleoperation."""
+
+
+class RuntimeNotFoundError(LeapError):
+ """A compatible LeapC client library could not be found or loaded."""
+
+
+class ConnectionError(LeapError):
+ """The tracking service is unavailable or disconnected."""
+
+
+class TrackingTimeoutError(LeapError):
+ """No tracking frame arrived within the requested deadline."""
+
+
+class RecordingError(LeapError):
+ """A recording is invalid, unsupported, or truncated."""
diff --git a/src/source_robotics_leap_motion/mapping.py b/src/source_robotics_leap_motion/mapping.py
new file mode 100644
index 0000000..45d7ce9
--- /dev/null
+++ b/src/source_robotics_leap_motion/mapping.py
@@ -0,0 +1,68 @@
+"""Pure coordinate mapping primitives; no network or robot side effects."""
+
+from __future__ import annotations
+
+import math
+from dataclasses import dataclass
+from pathlib import Path
+
+from ._compat import tomllib
+from .models import Vector3
+
+ZERO = Vector3()
+
+
+@dataclass(frozen=True, slots=True)
+class Mapping:
+ """Map metre positions into an arm's frame using a proper rotation and gains.
+
+ target = robot_origin + gain * rotation * (hand_position - hand_origin)
+ Defaults are identity; no robot-specific bounds or home pose are assumed.
+ """
+
+ axes: tuple[str, str, str] = ("x", "y", "z")
+ gain: Vector3 = Vector3(1, 1, 1)
+
+ def __post_init__(self) -> None:
+ if len(self.axes) != 3 or {axis.lstrip("-") for axis in self.axes} != {"x", "y", "z"}:
+ raise ValueError("Axes must be a signed permutation of x, y, z")
+ if any(axis not in ("x", "y", "z", "-x", "-y", "-z") for axis in self.axes):
+ raise ValueError("Axis names must be x, y, z, -x, -y, or -z")
+ order = ["xyz".index(axis.lstrip("-")) for axis in self.axes]
+ inversions = sum(order[i] > order[j] for i in range(3) for j in range(i + 1, 3))
+ negatives = sum(axis.startswith("-") for axis in self.axes)
+ if (inversions + negatives) % 2:
+ raise ValueError("Axis mapping must preserve handedness (a rotation, not a reflection)")
+ if any(not math.isfinite(value) or value < 0 for value in self.gain):
+ raise ValueError("Gains must be finite and non-negative; use zero to lock an axis")
+
+ def map_position(
+ self,
+ position: Vector3,
+ *,
+ hand_origin: Vector3 = ZERO,
+ robot_origin: Vector3 = ZERO,
+ ) -> Vector3:
+ delta = position - hand_origin
+ rotated = [
+ getattr(delta, axis.lstrip("-")) * (-1 if axis.startswith("-") else 1)
+ for axis in self.axes
+ ]
+ return robot_origin + Vector3(
+ *(component * gain for component, gain in zip(rotated, self.gain, strict=True))
+ )
+
+ @classmethod
+ def from_toml(cls, path: str | Path) -> Mapping:
+ with Path(path).open("rb") as stream:
+ document = tomllib.load(stream)
+ if set(document) != {"mapping"}:
+ raise ValueError("Configuration must contain one [mapping] table")
+ config = document["mapping"]
+ unknown = set(config) - {"axes", "gain"}
+ if unknown:
+ raise ValueError(f"Unknown mapping settings: {', '.join(sorted(unknown))}")
+ gain = config.get("gain", [1.0, 1.0, 1.0])
+ if len(gain) != 3:
+ raise ValueError("Gain must have three values")
+ return cls(tuple(config.get("axes", ("x", "y", "z"))), Vector3(*gain))
diff --git a/src/source_robotics_leap_motion/models.py b/src/source_robotics_leap_motion/models.py
new file mode 100644
index 0000000..5c9a912
--- /dev/null
+++ b/src/source_robotics_leap_motion/models.py
@@ -0,0 +1,134 @@
+"""Owned, immutable tracking data. Distances are metres; timestamps are microseconds."""
+
+from __future__ import annotations
+
+import math
+from dataclasses import dataclass
+from typing import Literal
+
+
+@dataclass(frozen=True, slots=True)
+class Vector3:
+ x: float = 0.0
+ y: float = 0.0
+ z: float = 0.0
+
+ def __post_init__(self) -> None:
+ if not all(math.isfinite(v) for v in self):
+ raise ValueError("Vector coordinates must be finite")
+
+ def __iter__(self):
+ return iter((self.x, self.y, self.z))
+
+ def __add__(self, other: Vector3) -> Vector3:
+ return Vector3(self.x + other.x, self.y + other.y, self.z + other.z)
+
+ def __sub__(self, other: Vector3) -> Vector3:
+ return Vector3(self.x - other.x, self.y - other.y, self.z - other.z)
+
+ def __mul__(self, scale: float) -> Vector3:
+ return Vector3(self.x * scale, self.y * scale, self.z * scale)
+
+ @property
+ def norm(self) -> float:
+ return math.sqrt(self.x**2 + self.y**2 + self.z**2)
+
+
+@dataclass(frozen=True, slots=True)
+class Quaternion:
+ """Rotation with named x/y/z/w components. Identity is (0, 0, 0, 1)."""
+
+ x: float = 0.0
+ y: float = 0.0
+ z: float = 0.0
+ w: float = 1.0
+
+ def __post_init__(self) -> None:
+ values = (self.x, self.y, self.z, self.w)
+ if not all(math.isfinite(v) for v in values):
+ raise ValueError("Quaternion components must be finite")
+ if not math.isclose(sum(v * v for v in values), 1.0, abs_tol=1e-3):
+ raise ValueError("Quaternion must have unit length")
+
+
+@dataclass(frozen=True, slots=True)
+class Palm:
+ position: Vector3
+ orientation: Quaternion = Quaternion()
+ velocity: Vector3 = Vector3()
+ normal: Vector3 = Vector3(0.0, -1.0, 0.0)
+ direction: Vector3 = Vector3(0.0, 0.0, -1.0)
+ width: float = 0.0
+
+ def __post_init__(self) -> None:
+ if not math.isfinite(self.width) or self.width < 0:
+ raise ValueError("Palm width must be finite and non-negative")
+
+
+@dataclass(frozen=True, slots=True)
+class Bone:
+ previous_joint: Vector3
+ next_joint: Vector3
+ width: float
+ rotation: Quaternion | None
+
+ def __post_init__(self) -> None:
+ if not math.isfinite(self.width) or self.width < 0:
+ raise ValueError("Bone width must be finite and non-negative")
+
+
+@dataclass(frozen=True, slots=True)
+class Finger:
+ id: int
+ name: Literal["thumb", "index", "middle", "ring", "pinky"]
+ bones: tuple[Bone, ...]
+ extended: bool
+
+
+@dataclass(frozen=True, slots=True)
+class Hand:
+ id: int
+ handedness: Literal["left", "right"]
+ palm: Palm
+ grab_strength: float = 0.0
+ pinch_strength: float = 0.0
+ pinch_distance: float | None = None
+ visible_time_us: int = 0
+ fingers: tuple[Finger, ...] = ()
+ arm: Bone | None = None
+
+ def __post_init__(self) -> None:
+ if self.handedness not in ("left", "right"):
+ raise ValueError("Handedness must be 'left' or 'right'")
+ if not all(0 <= strength <= 1 for strength in (self.grab_strength, self.pinch_strength)):
+ raise ValueError("Grab and pinch strengths must be between 0 and 1")
+ if self.pinch_distance is not None and (
+ not math.isfinite(self.pinch_distance) or self.pinch_distance < 0
+ ):
+ raise ValueError("Pinch distance must be finite and non-negative")
+ if self.visible_time_us < 0:
+ raise ValueError("Visible time must be non-negative")
+
+
+@dataclass(frozen=True, slots=True)
+class Frame:
+ id: int
+ timestamp_us: int
+ hands: tuple[Hand, ...] = ()
+ framerate: float = 0.0
+ device_id: int = 0
+ clock_domain: str = "leap_service"
+
+ def __post_init__(self) -> None:
+ if self.timestamp_us < 0:
+ raise ValueError("Frame timestamp must be non-negative")
+ if not math.isfinite(self.framerate) or self.framerate < 0:
+ raise ValueError("Frame rate must be finite and non-negative")
+
+ @property
+ def right_hand(self) -> Hand | None:
+ return next((hand for hand in self.hands if hand.handedness == "right"), None)
+
+ @property
+ def left_hand(self) -> Hand | None:
+ return next((hand for hand in self.hands if hand.handedness == "left"), None)
diff --git a/src/source_robotics_leap_motion/paths.py b/src/source_robotics_leap_motion/paths.py
new file mode 100644
index 0000000..e7633be
--- /dev/null
+++ b/src/source_robotics_leap_motion/paths.py
@@ -0,0 +1,29 @@
+"""Fixed application locations; no directory searches or import-time writes."""
+
+from __future__ import annotations
+
+import os
+import sys
+from pathlib import Path
+
+
+def cache_root() -> Path:
+ if override := os.environ.get("SOURCE_LEAP_CACHE"):
+ return Path(override).expanduser().resolve()
+ if sys.platform == "win32":
+ base = Path(os.environ.get("LOCALAPPDATA", str(Path.home() / "AppData/Local")))
+ elif sys.platform == "darwin":
+ base = Path.home() / "Library/Caches"
+ else:
+ base = Path(os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache")))
+ return base / "source-robotics-leap-motion"
+
+
+def platform_key() -> str:
+ import platform
+
+ machine = platform.machine().lower()
+ architecture = {"amd64": "x86_64", "x64": "x86_64", "aarch64": "arm64"}.get(machine, machine)
+ return (
+ {"win32": "windows", "darwin": "macos"}.get(sys.platform, sys.platform) + "-" + architecture
+ )
diff --git a/src/source_robotics_leap_motion/py.typed b/src/source_robotics_leap_motion/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/src/source_robotics_leap_motion/recording.py b/src/source_robotics_leap_motion/recording.py
new file mode 100644
index 0000000..811bd11
--- /dev/null
+++ b/src/source_robotics_leap_motion/recording.py
@@ -0,0 +1,192 @@
+"""Versioned JSON Lines recordings with owned SI-unit frames."""
+
+from __future__ import annotations
+
+import json
+import math
+from collections.abc import Iterator
+from dataclasses import asdict
+from datetime import datetime, timezone
+from pathlib import Path
+
+from ._compat import Self
+from .errors import RecordingError
+from .models import Bone, Finger, Frame, Hand, Palm, Quaternion, Vector3
+
+SCHEMA = "source-robotics-leap-motion/recording"
+SCHEMA_VERSION = 1
+MAX_LINE_BYTES = 2 * 1024 * 1024
+
+
+def frame_to_dict(frame: Frame) -> dict:
+ return asdict(frame)
+
+
+def _integer(value) -> int:
+ if type(value) is not int or value < 0:
+ raise ValueError("Expected a non-negative integer")
+ return value
+
+
+def _number(value) -> float:
+ if type(value) not in (int, float) or not math.isfinite(value):
+ raise ValueError("Expected a finite number")
+ return float(value)
+
+
+def _vector(data: dict) -> Vector3:
+ return Vector3(*(_number(data[axis]) for axis in ("x", "y", "z")))
+
+
+def _quaternion(data: dict) -> Quaternion:
+ return Quaternion(*(_number(data[axis]) for axis in ("x", "y", "z", "w")))
+
+
+def _bone(data: dict) -> Bone:
+ return Bone(
+ _vector(data["previous_joint"]),
+ _vector(data["next_joint"]),
+ _number(data["width"]),
+ _quaternion(data["rotation"]) if data["rotation"] is not None else None,
+ )
+
+
+def frame_from_dict(data: dict) -> Frame:
+ """Decode a frame strictly enough to reject corrupt or non-finite input."""
+ try:
+ hands = []
+ if not isinstance(data["hands"], list) or len(data["hands"]) > 16:
+ raise ValueError("Invalid hand list")
+ for hand in data["hands"]:
+ palm = hand["palm"]
+ fingers = []
+ for finger in hand["fingers"]:
+ if finger["name"] not in ("thumb", "index", "middle", "ring", "pinky"):
+ raise ValueError("Invalid finger name")
+ if type(finger["extended"]) is not bool:
+ raise ValueError("Expected boolean finger extension")
+ fingers.append(
+ Finger(
+ _integer(finger["id"]),
+ finger["name"],
+ tuple(_bone(bone) for bone in finger["bones"]),
+ finger["extended"],
+ )
+ )
+ hands.append(
+ Hand(
+ id=_integer(hand["id"]),
+ handedness=hand["handedness"],
+ palm=Palm(
+ _vector(palm["position"]),
+ _quaternion(palm["orientation"]),
+ _vector(palm["velocity"]),
+ _vector(palm["normal"]),
+ _vector(palm["direction"]),
+ _number(palm["width"]),
+ ),
+ grab_strength=_number(hand["grab_strength"]),
+ pinch_strength=_number(hand["pinch_strength"]),
+ pinch_distance=(
+ _number(hand["pinch_distance"])
+ if hand["pinch_distance"] is not None
+ else None
+ ),
+ visible_time_us=_integer(hand["visible_time_us"]),
+ fingers=tuple(fingers),
+ arm=_bone(hand["arm"]) if hand["arm"] else None,
+ )
+ )
+ if not isinstance(data["clock_domain"], str) or not data["clock_domain"]:
+ raise ValueError("Missing timestamp clock domain")
+ return Frame(
+ id=_integer(data["id"]),
+ timestamp_us=_integer(data["timestamp_us"]),
+ hands=tuple(hands),
+ framerate=_number(data["framerate"]),
+ device_id=_integer(data["device_id"]),
+ clock_domain=data["clock_domain"],
+ )
+ except (KeyError, TypeError, ValueError, OverflowError) as exc:
+ raise RecordingError(f"Invalid frame: {exc}") from exc
+
+
+class Recorder:
+ """Write one session. Existing recordings are protected unless overwrite=True."""
+
+ def __init__(self, path: str | Path, *, overwrite: bool = False) -> None:
+ self.path = Path(path)
+ self.overwrite = overwrite
+ self._file = None
+
+ def __enter__(self) -> Self:
+ if self._file is not None:
+ raise RuntimeError("Recorder is already open")
+ self.path.parent.mkdir(parents=True, exist_ok=True)
+ self._file = self.path.open("w" if self.overwrite else "x", encoding="utf-8")
+ metadata = {
+ "schema": SCHEMA,
+ "version": SCHEMA_VERSION,
+ "created_utc": datetime.now(timezone.utc).isoformat(),
+ "units": "metres",
+ "coordinate_frame": "leap_camera",
+ "quaternion_order": "xyzw",
+ }
+ try:
+ self._file.write(json.dumps(metadata) + "\n")
+ except BaseException:
+ self._file.close()
+ self._file = None
+ raise
+ return self
+
+ def write(self, frame: Frame) -> None:
+ if self._file is None:
+ raise RuntimeError("Open Recorder in a with block first")
+ line = json.dumps({"frame": frame_to_dict(frame)}, allow_nan=False, separators=(",", ":"))
+ if len(line.encode("utf-8")) > MAX_LINE_BYTES:
+ raise RecordingError("Frame is too large for the recording format")
+ self._file.write(line + "\n")
+
+ def __exit__(self, *_exc) -> None:
+ if self._file is not None:
+ self._file.close()
+ self._file = None
+
+
+def _json_line(stream, line_number: int) -> dict | None:
+ line = stream.readline(MAX_LINE_BYTES + 1)
+ if not line:
+ return None
+ if len(line) > MAX_LINE_BYTES:
+ raise RecordingError(f"Line {line_number} exceeds the recording size limit")
+ try:
+ result = json.loads(line)
+ except (ValueError, UnicodeError, RecursionError) as exc:
+ raise RecordingError(f"Invalid JSON on line {line_number}: {exc}") from exc
+ if not isinstance(result, dict):
+ raise RecordingError(f"Expected a JSON object on line {line_number}")
+ return result
+
+
+def read_recording(path: str | Path) -> Iterator[Frame]:
+ """Stream recordings; memory does not grow with session length."""
+ with Path(path).open("rb") as stream:
+ metadata = _json_line(stream, 1)
+ if metadata is None or metadata.get("schema") != SCHEMA:
+ raise RecordingError("Not a source-robotics-leap-motion recording")
+ if metadata.get("version") != SCHEMA_VERSION:
+ raise RecordingError(f"Unsupported recording version: {metadata.get('version')}")
+ if (
+ metadata.get("units") != "metres"
+ or metadata.get("coordinate_frame") != "leap_camera"
+ or metadata.get("quaternion_order") != "xyzw"
+ ):
+ raise RecordingError("Unsupported recording units or coordinate conventions")
+ line_number = 2
+ while (record := _json_line(stream, line_number)) is not None:
+ try:
+ yield frame_from_dict(record["frame"])
+ except (KeyError, RecordingError) as exc:
+ raise RecordingError(f"Invalid frame on line {line_number}: {exc}") from exc
+ line_number += 1
diff --git a/src/source_robotics_leap_motion/runtime.py b/src/source_robotics_leap_motion/runtime.py
new file mode 100644
index 0000000..0c4828f
--- /dev/null
+++ b/src/source_robotics_leap_motion/runtime.py
@@ -0,0 +1,127 @@
+"""Read-only runtime discovery. No installer hooks or directory walks."""
+
+from __future__ import annotations
+
+import os
+import platform
+import struct
+import sys
+from dataclasses import asdict, dataclass
+from pathlib import Path
+
+from .errors import RuntimeNotFoundError
+
+DOWNLOAD_URL = "https://www.ultraleap.com/downloads/leap-controller/"
+
+
+def library_candidates(explicit: str | Path | None = None) -> tuple[Path, ...]:
+ if explicit is not None:
+ return (Path(explicit).expanduser().resolve(),)
+ if override := os.environ.get("SOURCE_LEAP_LIBRARY"):
+ return (Path(override).expanduser().resolve(),)
+ candidates: list[Path] = []
+ if sdk := os.environ.get("LEAPSDK_INSTALL_LOCATION"):
+ relative = {"win32": "lib/x64/LeapC.dll", "darwin": "lib/libLeapC.dylib"}.get(
+ sys.platform, "lib/libLeapC.so"
+ )
+ candidates.append(Path(sdk) / relative)
+ if sys.platform == "win32":
+ program_files = Path(os.environ.get("ProgramFiles", "C:/Program Files"))
+ candidates.append(program_files / "Ultraleap/LeapSDK/lib/x64/LeapC.dll")
+ elif sys.platform == "darwin":
+ candidates.append(
+ Path("/Applications/Ultraleap Hand Tracking.app/Contents/LeapSDK/lib/libLeapC.dylib")
+ )
+ else:
+ candidates.extend(
+ Path(path)
+ for path in (
+ "/usr/lib/ultraleap-hand-tracking-service/libLeapC.so",
+ "/usr/lib/x86_64-linux-gnu/libLeapC.so",
+ "/usr/share/doc/ultraleap-hand-tracking-service/libLeapC.so",
+ "/opt/ultraleap/LeapSDK/lib/libLeapC.so",
+ )
+ )
+ return tuple(dict.fromkeys(candidates))
+
+
+def find_library(explicit: str | Path | None = None) -> Path:
+ candidates = library_candidates(explicit)
+ for candidate in candidates:
+ if candidate.is_file():
+ return candidate.resolve()
+ searched = ", ".join(str(path) for path in candidates)
+ raise RuntimeNotFoundError(
+ "LeapC client library was not found. Install the compatible Ultraleap tracking "
+ f"software for the Original Leap Motion Controller: {DOWNLOAD_URL}\n"
+ "The old Leap.dll / LeapPython.pyd cannot be used by this package.\n"
+ f"Checked: {searched}"
+ )
+
+
+def _service_executable() -> str | None:
+ if sys.platform != "win32":
+ return None
+ import winreg
+
+ try:
+ with winreg.OpenKey(
+ winreg.HKEY_LOCAL_MACHINE, r"SYSTEM\CurrentControlSet\Services\LeapService"
+ ) as key:
+ return winreg.QueryValueEx(key, "ImagePath")[0]
+ except OSError:
+ return None
+
+
+@dataclass(frozen=True, slots=True)
+class RuntimeReport:
+ python: str
+ executable: str
+ platform: str
+ architecture_bits: int
+ library: str | None
+ checked_paths: tuple[str, ...]
+ service_executable: str | None
+ notes: tuple[str, ...]
+ cached_runtime: str | None = None
+ download_url: str = DOWNLOAD_URL
+
+ def to_dict(self) -> dict:
+ return asdict(self)
+
+
+def diagnose(library_path: str | None = None) -> RuntimeReport:
+ from .delivery import cached_runtime
+
+ candidates = library_candidates(library_path)
+ library = next((str(path.resolve()) for path in candidates if path.is_file()), None)
+ service = _service_executable()
+ notes = []
+ try:
+ cached = cached_runtime()
+ except RuntimeNotFoundError as exc:
+ cached = None
+ notes.append(str(exc))
+ if library is None:
+ notes.append(
+ "LeapC client missing; auto mode can still try the local legacy WebSocket service."
+ )
+ if service and "leap motion" in service.lower():
+ notes.append(
+ "A legacy Leap Motion service installation was detected. "
+ "A current LeapC client alone does not update that service."
+ )
+ if sys.platform != "win32" or struct.calcsize("P") != 8:
+ notes.append("Linux/macOS native support is experimental; hardware validation is pending.")
+ notes.append("Library discovery does not prove service or camera connectivity; use --probe.")
+ return RuntimeReport(
+ python=platform.python_version(),
+ executable=sys.executable,
+ platform=platform.platform(),
+ architecture_bits=struct.calcsize("P") * 8,
+ library=library,
+ checked_paths=tuple(str(path) for path in candidates),
+ service_executable=service,
+ notes=tuple(notes),
+ cached_runtime=str(cached) if cached else None,
+ )
diff --git a/src/source_robotics_leap_motion/runtime_bundle.py b/src/source_robotics_leap_motion/runtime_bundle.py
new file mode 100644
index 0000000..5fb8b32
--- /dev/null
+++ b/src/source_robotics_leap_motion/runtime_bundle.py
@@ -0,0 +1,148 @@
+"""Prepare a local legacy runtime for experiments; never downloads vendor assets."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import shutil
+from pathlib import Path
+
+from .errors import RuntimeNotFoundError
+
+SCHEMA = "source-robotics-leap-motion/local-runtime"
+MANIFEST = "runtime-manifest.json"
+ENGINE = "LeapSvc64.exe"
+CORE_FILES = (
+ ENGINE,
+ "Tracking.ldat",
+ "CyBootProgrammer.ldat",
+ "CyPeripheral.ldat",
+ "CyUVCBootstrap.ldat",
+)
+DEPENDENCIES = ("msvcp120.dll", "msvcr120.dll")
+OPTIONAL_MODELS = ("Tracking-desktop.ldat", "Tracking-1202.ldat")
+DRIVER_FILES = (
+ "Drivers/cyfx3.inf",
+ "Drivers/cyfx3.cat",
+ "Drivers/ov580.inf",
+ "Drivers/ov580.cat",
+ "Drivers/WdfCoInstaller01011.dll",
+ "Drivers/winusbcoinstaller2.dll",
+)
+ALLOWED_FILES = frozenset(
+ CORE_FILES + DEPENDENCIES + OPTIONAL_MODELS + DRIVER_FILES + ("NOTICE.txt",)
+)
+
+
+def file_hash(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as stream:
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def prepare_local_runtime(
+ source: str | Path,
+ destination: str | Path,
+ *,
+ dependency_dir: str | Path | None = None,
+ extra_models: bool = False,
+) -> dict:
+ """Copy an explicit allowlist from an existing installation, with no GUI files.
+
+ This is a local-development operation. It establishes no redistribution rights.
+ Existing matching files can be adopted; differing files are never overwritten.
+ """
+ source = Path(source).resolve()
+ destination = Path(destination).resolve()
+ if source == destination:
+ raise ValueError("Choose a separate destination for the private runtime")
+ selected = list(CORE_FILES)
+ if extra_models:
+ selected.extend(OPTIONAL_MODELS)
+ files = {name: source / name for name in selected}
+ dependency_locations = [source]
+ if dependency_dir is not None:
+ dependency_locations.append(Path(dependency_dir).resolve())
+ dependency_locations.append(Path(os.environ.get("SystemRoot", "C:/Windows")) / "System32")
+ for name in DEPENDENCIES:
+ candidate = next(
+ (
+ directory / name
+ for directory in dependency_locations
+ if (directory / name).is_file()
+ ),
+ None,
+ )
+ if candidate is None:
+ raise RuntimeNotFoundError(f"Missing {name}; supply its directory with --dependencies")
+ files[name] = candidate
+ for path in files.values():
+ if not path.is_file():
+ raise RuntimeNotFoundError(f"Missing legacy runtime component: {path}")
+ destination.mkdir(parents=True, exist_ok=True)
+ entries = []
+ for name, origin in files.items():
+ target = destination / name
+ digest = file_hash(origin)
+ if target.exists():
+ if not target.is_file() or file_hash(target) != digest:
+ raise FileExistsError(f"Refusing to overwrite a different runtime file: {target}")
+ else:
+ shutil.copyfile(origin, target)
+ entries.append({"name": name, "bytes": target.stat().st_size, "sha256": digest})
+ manifest = {
+ "schema": SCHEMA,
+ "version": 1,
+ "kind": "legacy-windows-x64",
+ "engine": ENGINE,
+ "files": entries,
+ "provenance": "local copy of an existing installation",
+ "redistribution_cleared": False,
+ }
+ (destination / MANIFEST).write_text(json.dumps(manifest, indent=2), encoding="utf-8")
+ return inspect_runtime(destination)
+
+
+def inspect_runtime(directory: str | Path, *, verify: bool = True) -> dict:
+ directory = Path(directory).resolve()
+ try:
+ manifest_path = directory / MANIFEST
+ if manifest_path.stat().st_size > 64 * 1024:
+ raise ValueError("Runtime manifest exceeds the size limit")
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
+ if (
+ manifest["schema"] != SCHEMA
+ or manifest["version"] != 1
+ or manifest["kind"] != "legacy-windows-x64"
+ or manifest["engine"] != ENGINE
+ ):
+ raise ValueError("Unsupported runtime manifest")
+ names = [entry["name"] for entry in manifest["files"]]
+ if len(names) != len(set(names)) or not set(names) <= ALLOWED_FILES:
+ raise ValueError("Runtime manifest contains duplicate or unsupported file names")
+ if not set(CORE_FILES + DEPENDENCIES) <= set(names):
+ raise ValueError("Runtime manifest is missing required components")
+ total = 0
+ for entry in manifest["files"]:
+ path = directory / entry["name"]
+ if not path.resolve().is_relative_to(directory) or not path.is_file():
+ raise ValueError(f"Missing or externally linked runtime file: {entry['name']}")
+ size = path.stat().st_size
+ if size != entry["bytes"]:
+ raise ValueError(f"Runtime file size mismatch: {entry['name']}")
+ if verify and file_hash(path) != entry["sha256"]:
+ raise ValueError(f"Runtime file checksum mismatch: {entry['name']}")
+ total += size
+ return {
+ "directory": str(directory),
+ "engine": str(directory / ENGINE),
+ "files": len(names),
+ "bytes": total,
+ "redistribution_cleared": manifest.get("redistribution_cleared", False),
+ "checksum_verified": verify,
+ }
+ except (OSError, KeyError, TypeError, ValueError) as exc:
+ raise RuntimeNotFoundError(f"Invalid local runtime at {directory}: {exc}") from exc
diff --git a/src/source_robotics_leap_motion/runtime_catalog.json b/src/source_robotics_leap_motion/runtime_catalog.json
new file mode 100644
index 0000000..23aa4cc
--- /dev/null
+++ b/src/source_robotics_leap_motion/runtime_catalog.json
@@ -0,0 +1,13 @@
+{
+ "schema": "source-robotics-leap-motion/catalog",
+ "version": 1,
+ "runtimes": {
+ "windows-x86_64": {
+ "id": "legacy-windows-3.2.1",
+ "url": "https://github.com/Source-Robotics/Source-Robotics-Leap-Motion/releases/download/v0.1.0b1/source-leap-runtime-windows-x86_64-3.2.1.zip",
+ "bytes": 244070193,
+ "sha256": "54b986a36324a94c8465c4d0ed910a7e28491fe7036c71c9ccb1e6aa244f1fab",
+ "unpacked_bytes": 251082723
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/source_robotics_leap_motion/setup.py b/src/source_robotics_leap_motion/setup.py
new file mode 100644
index 0000000..2a14b77
--- /dev/null
+++ b/src/source_robotics_leap_motion/setup.py
@@ -0,0 +1,137 @@
+"""Device-aware first-use setup; system changes require an explicit action."""
+
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+from .delivery import cached_runtime, load_catalog
+from .errors import RuntimeNotFoundError
+from .paths import platform_key
+from .runtime import DOWNLOAD_URL, diagnose
+from .runtime_bundle import inspect_runtime
+
+LINUX_URL = "https://docs.ultraleap.com/linux/index.html"
+
+
+def device_status() -> dict:
+ if sys.platform != "win32":
+ return {"status": "unknown", "note": "Use a live tracking probe to verify device access."}
+ script = r"""$ErrorActionPreference = 'Stop'
+@(Get-PnpDevice -PresentOnly | Where-Object {
+ $_.InstanceId -match 'VID_F182|VID_2936|VID_05A9&PID_F580'
+} | ForEach-Object {
+ $code = Get-PnpDeviceProperty -InstanceId $_.InstanceId `
+ -KeyName 'DEVPKEY_Device_ProblemCode' -ErrorAction SilentlyContinue
+ [pscustomobject]@{ name=$_.FriendlyName; status=$_.Status; problem_code=$code.Data }
+}) | ConvertTo-Json -Compress
+"""
+ try:
+ result = subprocess.run(
+ ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script],
+ capture_output=True,
+ text=True,
+ timeout=15,
+ creationflags=subprocess.CREATE_NO_WINDOW,
+ )
+ if result.returncode:
+ return {"status": "unknown", "note": "Windows did not permit device inspection."}
+ devices = json.loads(result.stdout or "[]") or []
+ if isinstance(devices, dict):
+ devices = [devices]
+ if not devices:
+ return {"status": "not-connected", "devices": []}
+ if any(item.get("problem_code") == 28 for item in devices):
+ status = "driver-required"
+ elif any(item.get("problem_code") not in (0, None) for item in devices):
+ status = "device-error"
+ else:
+ status = "present"
+ return {"status": status, "devices": devices}
+ except (OSError, ValueError, TypeError, AttributeError, subprocess.TimeoutExpired):
+ return {"status": "unknown", "note": "Device inspection was unavailable."}
+
+
+def setup_report() -> dict:
+ diagnostics = diagnose()
+ cached = cached_runtime()
+ available = platform_key() in load_catalog()["runtimes"]
+ instructions = {
+ "win32": [
+ "Connect the Original Leap Motion Controller directly to a USB port.",
+ "If Windows reports a missing driver, run source-leap setup --install-driver.",
+ ],
+ "linux": [
+ "Install the vendor core service for Ubuntu 22.04 amd64 using its APT instructions.",
+ "The service-only package is ultraleap-hand-tracking-service; the GUI is optional.",
+ "Start the vendor service and run source-leap doctor --probe.",
+ ],
+ "darwin": [
+ "Install the matching Intel or Apple Silicon vendor hand-tracking software.",
+ "Start its tracking service and grant OS access when requested.",
+ "Run source-leap doctor --probe.",
+ ],
+ }.get(sys.platform, ["This operating system has not been integrated."])
+ return {
+ "platform": platform_key(),
+ "native_library": diagnostics.library,
+ "cached_runtime": str(cached) if cached else None,
+ "automatic_download_configured": available,
+ "device": device_status(),
+ "instructions": instructions,
+ "download_url": LINUX_URL if sys.platform == "linux" else DOWNLOAD_URL,
+ "validation_note": (
+ "Legacy Windows x64 tracking is tested; clean-PC driver setup is pending."
+ if platform_key() == "windows-x86_64"
+ else "Native integration is experimental; platform hardware testing is pending."
+ ),
+ }
+
+
+def install_windows_driver(directory: str | Path) -> list[dict]:
+ if sys.platform != "win32":
+ raise RuntimeNotFoundError(
+ "The driver installer is Windows-only; follow the platform setup instructions"
+ )
+ directory = Path(directory).resolve()
+ inspect_runtime(directory)
+ if device_status()["status"] != "driver-required":
+ raise RuntimeNotFoundError(
+ "Windows has not identified a missing Leap driver. Connect the controller and run "
+ "source-leap setup. Existing working drivers are not replaced."
+ )
+ files = [directory / "Drivers" / name for name in ("cyfx3.inf", "ov580.inf")]
+ if not all(path.is_file() for path in files):
+ raise RuntimeNotFoundError(
+ "This runtime archive has no driver files. Use the official download page."
+ )
+ tool = Path(os.environ.get("SystemRoot", "C:/Windows")) / "System32/pnputil.exe"
+ results = []
+ for driver in files:
+ arguments = subprocess.list2cmdline(["/add-driver", str(driver), "/install"])
+
+ def literal(value):
+ return "'" + str(value).replace("'", "''") + "'"
+
+ script = (
+ "$ErrorActionPreference='Stop'; $p=Start-Process -FilePath "
+ + literal(tool)
+ + " -ArgumentList "
+ + literal(arguments)
+ + " -Verb RunAs -WindowStyle Hidden -Wait -PassThru; exit $p.ExitCode"
+ )
+ result = subprocess.run(
+ ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode not in (0, 3010):
+ raise RuntimeNotFoundError(
+ f"Windows driver setup failed or approval was canceled (code {result.returncode}). "
+ "Use the official vendor setup instructions."
+ )
+ results.append({"driver": driver.name, "reboot_required": result.returncode == 3010})
+ return results
diff --git a/src/source_robotics_leap_motion/tracker.py b/src/source_robotics_leap_motion/tracker.py
new file mode 100644
index 0000000..b4b3cdd
--- /dev/null
+++ b/src/source_robotics_leap_motion/tracker.py
@@ -0,0 +1,120 @@
+"""Context-managed tracking with interchangeable live, demo, and replay sources."""
+
+from __future__ import annotations
+
+import math
+import os
+from collections.abc import Iterator
+from typing import Protocol
+
+from ._compat import Self
+from .errors import TrackingTimeoutError
+from .models import Frame
+
+
+class FrameSource(Protocol):
+ def open(self) -> None: ...
+ def read(self, timeout: float) -> Frame | None: ...
+ def close(self) -> None: ...
+
+
+class Tracker:
+ """Use inside a with block. A timeout is distinct from an empty-hand frame.
+
+ Native frames use Leap's camera axes (+x right, +y up, +z toward the user).
+ Positions and velocities are converted to metres and metres/second.
+ """
+
+ def __init__(
+ self,
+ *,
+ backend: FrameSource | None = None,
+ library_path: str | None = None,
+ runtime_dir: str | None = None,
+ timeout: float = 5.0,
+ ) -> None:
+ if not math.isfinite(timeout) or timeout <= 0:
+ raise ValueError("Timeout must be positive and finite")
+ if sum(option is not None for option in (backend, library_path, runtime_dir)) > 1:
+ raise ValueError("Choose a backend, a native library, or a local runtime directory")
+ self._backend = backend
+ self._library_path = library_path
+ self._runtime_dir = runtime_dir
+ self.timeout = timeout
+ self._opened = False
+
+ @classmethod
+ def demo(cls, *, rate: float = 60.0, realtime: bool = True) -> Self:
+ from .backends.demo import DemoBackend
+
+ return cls(backend=DemoBackend(rate, realtime=realtime))
+
+ def __enter__(self) -> Self:
+ if self._opened:
+ raise RuntimeError("Tracker is already open")
+ if self._backend is None:
+ if self._runtime_dir is not None:
+ from .backends.shared import SharedRuntimeBackend
+
+ self._backend = SharedRuntimeBackend(
+ self._runtime_dir, connect_timeout=self.timeout
+ )
+ elif self._library_path is not None or os.environ.get("SOURCE_LEAP_LIBRARY"):
+ from .backends.leapc import LeapCBackend
+
+ self._backend = LeapCBackend(self._library_path, connect_timeout=self.timeout)
+ else:
+ from .backends.auto import AutoBackend
+
+ self._backend = AutoBackend(connect_timeout=self.timeout)
+ try:
+ self._backend.open()
+ except BaseException:
+ self._backend.close()
+ raise
+ self._opened = True
+ return self
+
+ @property
+ def backend_name(self) -> str:
+ return getattr(self._backend, "name", "custom")
+
+ @property
+ def runtime_details(self) -> dict | None:
+ return getattr(self._backend, "details", None)
+
+ def read(self, timeout: float | None = None) -> Frame:
+ if not self._opened or self._backend is None:
+ raise RuntimeError("Open the tracker with 'with Tracker() as tracker:' first")
+ deadline = self.timeout if timeout is None else timeout
+ if not math.isfinite(deadline) or deadline <= 0:
+ raise ValueError("Timeout must be positive and finite")
+ frame = self._backend.read(deadline)
+ if frame is None:
+ raise TrackingTimeoutError(
+ f"No tracking frames within {deadline:g}s. Check camera USB connection "
+ "and tracking service. An empty-hand frame would still be returned."
+ )
+ return frame
+
+ def __iter__(self) -> Iterator[Frame]:
+ while self._opened:
+ try:
+ yield self.read()
+ except StopIteration:
+ return
+
+ def close(self) -> None:
+ try:
+ if self._backend is not None:
+ self._backend.close()
+ finally:
+ self._opened = False
+
+ def reconnect(self) -> Self:
+ """Explicitly discard a failed connection and open a fresh tracking session."""
+ self.close()
+ return self.__enter__()
+
+ def __exit__(self, *_exc) -> None:
+ self.close()
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..94ba5cb
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,7 @@
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def isolated_application_cache(tmp_path, monkeypatch):
+ monkeypatch.setenv("SOURCE_LEAP_CACHE", str(tmp_path / "app-cache"))
+ monkeypatch.delenv("SOURCE_LEAP_CATALOG", raising=False)
diff --git a/tests/test_broker.py b/tests/test_broker.py
new file mode 100644
index 0000000..de1f141
--- /dev/null
+++ b/tests/test_broker.py
@@ -0,0 +1,62 @@
+import threading
+import time
+
+from source_robotics_leap_motion._broker import serve
+from source_robotics_leap_motion.backends.shared import _lease
+
+
+def test_two_clients_keep_engine_alive_until_last_lease_closes(tmp_path):
+ class Engine:
+ is_running = True
+ closed = threading.Event()
+
+ def __init__(self, *args, **kwargs):
+ pass
+
+ def open(self):
+ pass
+
+ def release_tracking_client(self):
+ pass
+
+ def close(self):
+ self.is_running = False
+ self.closed.set()
+
+ state = tmp_path / "engine"
+ errors = []
+
+ def worker():
+ try:
+ serve(tmp_path, state, idle_timeout=0.05, backend_factory=Engine)
+ except BaseException as error:
+ errors.append(error)
+
+ thread = threading.Thread(target=worker, daemon=True)
+ thread.start()
+ first = second = None
+ try:
+ deadline = time.perf_counter() + 3
+ while first is None and time.perf_counter() < deadline:
+ first = _lease(state)
+ if first is None:
+ time.sleep(0.02)
+ assert first is not None
+ second = _lease(state)
+ assert second is not None
+ first.close()
+ first = None
+ assert not Engine.closed.wait(timeout=0.2)
+ second.close()
+ second = None
+ thread.join(timeout=3)
+ assert not thread.is_alive()
+ assert Engine.closed.is_set()
+ assert not (state / "broker.json").exists()
+ assert not errors
+ finally:
+ if first is not None:
+ first.close()
+ if second is not None:
+ second.close()
+ thread.join(timeout=3)
diff --git a/tests/test_cli_runtime.py b/tests/test_cli_runtime.py
new file mode 100644
index 0000000..9d032df
--- /dev/null
+++ b/tests/test_cli_runtime.py
@@ -0,0 +1,67 @@
+import json
+import subprocess
+import sys
+from pathlib import Path
+
+from source_robotics_leap_motion.cli import main
+from source_robotics_leap_motion.runtime import library_candidates
+
+
+def test_cli_demo_json_contains_only_frames_on_stdout(capsys):
+ assert main(["--demo", "--frames", "3", "--json"]) == 0
+ captured = capsys.readouterr()
+ frames = [json.loads(line) for line in captured.out.splitlines()]
+ assert len(frames) == 3
+ assert all(frame["clock_domain"] == "demo" for frame in frames)
+ assert "synthetic" not in captured.out # JSON is machine-readable, no banner.
+
+
+def test_short_duration_exits_normally(capsys):
+ assert main(["--demo", "--duration", "0.02"]) == 0
+
+
+def test_cli_record_and_replay(tmp_path, capsys):
+ path = tmp_path / "demo.jsonl"
+ assert main(["record", str(path), "--demo", "--frames", "2"]) == 0
+ capsys.readouterr()
+ assert main(["replay", str(path), "--json"]) == 0
+ captured = capsys.readouterr()
+ assert len(captured.out.splitlines()) == 2
+
+
+def test_missing_explicit_library_has_actionable_diagnostic(tmp_path, capsys):
+ library = str(tmp_path / "LeapC.dll")
+ assert main(["doctor", "--library", library, "--json", "--probe"]) == 2
+ captured = capsys.readouterr()
+ report = json.loads(captured.out)
+ assert report["checked_paths"] == [library]
+ assert not report["probe"]["received_frame"]
+ assert "Original Leap Motion Controller" in report["probe"]["error"]
+
+
+def test_explicit_library_does_not_fall_back_to_an_unrelated_install(tmp_path, monkeypatch):
+ monkeypatch.setenv("SOURCE_LEAP_LIBRARY", str(tmp_path / "other.dll"))
+ explicit = tmp_path / "chosen.dll"
+ assert library_candidates(explicit) == (explicit.resolve(),)
+
+
+def test_import_has_no_native_loading_or_background_threads():
+ script = (
+ "import ctypes, threading; "
+ "ctypes.CDLL = lambda *a, **k: (_ for _ in ()).throw(AssertionError('native load')); "
+ "ctypes.WinDLL = ctypes.CDLL; "
+ "before = set(threading.enumerate()); "
+ "import source_robotics_leap_motion; "
+ "assert set(threading.enumerate()) == before"
+ )
+ result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True)
+ assert result.returncode == 0, result.stderr
+
+
+def test_record_does_not_create_file_when_tracking_cannot_open(tmp_path, capsys):
+ path = tmp_path / "session.jsonl"
+ result = main(
+ ["record", str(path), "--library", str(tmp_path / "missing.dll"), "--frames", "1"]
+ )
+ assert result == 2
+ assert not Path(path).exists()
diff --git a/tests/test_delivery.py b/tests/test_delivery.py
new file mode 100644
index 0000000..6ffc22f
--- /dev/null
+++ b/tests/test_delivery.py
@@ -0,0 +1,188 @@
+import hashlib
+import json
+import threading
+import zipfile
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+
+import pytest
+
+from source_robotics_leap_motion import delivery
+from source_robotics_leap_motion.errors import RuntimeNotFoundError
+from source_robotics_leap_motion.runtime_bundle import (
+ CORE_FILES,
+ DEPENDENCIES,
+ MANIFEST,
+ SCHEMA,
+)
+
+
+@pytest.fixture
+def runtime_archive(tmp_path, monkeypatch):
+ monkeypatch.setattr(delivery, "platform_key", lambda: "windows-x86_64")
+ files = {name: (name * 3).encode() for name in CORE_FILES + DEPENDENCIES}
+ manifest = {
+ "schema": SCHEMA,
+ "version": 1,
+ "kind": "legacy-windows-x64",
+ "engine": CORE_FILES[0],
+ "redistribution_cleared": True,
+ "files": [
+ {"name": name, "bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}
+ for name, data in files.items()
+ ],
+ }
+ archive = tmp_path / "runtime.zip"
+ with zipfile.ZipFile(archive, "w") as bundle:
+ for name, data in files.items():
+ bundle.writestr(name, data)
+ bundle.writestr(MANIFEST, json.dumps(manifest))
+ return archive
+
+
+def test_offline_import_is_atomic_idempotent_and_uses_cache(runtime_archive, tmp_path):
+ root = tmp_path / "cache"
+ directory = delivery.install_archive(runtime_archive, root=root)
+ assert directory.parent == root / "runtimes"
+ assert delivery.cached_runtime(root=root) == directory
+ assert delivery.install_archive(runtime_archive, root=root) == directory
+ assert not list(directory.parent.glob(".install-*"))
+
+
+@pytest.mark.parametrize("member", ["../escape.exe", "/absolute.exe", "not-allowed.exe"])
+def test_archive_rejects_unexpected_or_escaping_members(runtime_archive, tmp_path, member):
+ with zipfile.ZipFile(runtime_archive, "a") as bundle:
+ bundle.writestr(member, b"bad")
+ root = tmp_path / "cache"
+ with pytest.raises(RuntimeNotFoundError, match="unsupported archive member"):
+ delivery.install_archive(runtime_archive, root=root)
+ assert not (root / "active.json").exists()
+ assert not list((root / "runtimes").glob(".install-*"))
+
+
+def test_corrupt_member_never_becomes_active(runtime_archive, tmp_path):
+ replacement = tmp_path / "corrupt.zip"
+ with zipfile.ZipFile(runtime_archive) as original, zipfile.ZipFile(replacement, "w") as changed:
+ for member in original.infolist():
+ data = original.read(member)
+ if member.filename == "Tracking.ldat":
+ data = b"X" + data[1:]
+ changed.writestr(member, data)
+ with pytest.raises(RuntimeNotFoundError, match="checksum mismatch"):
+ delivery.install_archive(replacement, root=tmp_path / "cache")
+ assert not (tmp_path / "cache/active.json").exists()
+
+
+@pytest.fixture
+def download_server(runtime_archive):
+ data = runtime_archive.read_bytes()
+ requests = []
+ options = {"ignore_range": False, "bad_range": False}
+
+ class Handler(BaseHTTPRequestHandler):
+ def do_GET(self):
+ header = self.headers.get("Range")
+ requests.append(header)
+ offset = int(header.split("=")[1].split("-")[0]) if header else 0
+ if header and not options["ignore_range"]:
+ self.send_response(206)
+ value = f"bytes {offset}-{len(data) - 1}/{len(data)}"
+ self.send_header("Content-Range", "bytes 0-1/2" if options["bad_range"] else value)
+ else:
+ self.send_response(200)
+ offset = 0
+ self.send_header("Content-Length", str(len(data) - offset))
+ self.end_headers()
+ self.wfile.write(data[offset:])
+
+ def log_message(self, *args):
+ pass
+
+ server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ entry = {
+ "id": "test-runtime",
+ "url": f"http://127.0.0.1:{server.server_port}/runtime.zip",
+ "sha256": hashlib.sha256(data).hexdigest(),
+ "bytes": len(data),
+ "unpacked_bytes": 100000,
+ }
+ try:
+ yield entry, data, requests, options
+ finally:
+ server.shutdown()
+ server.server_close()
+ thread.join(timeout=2)
+
+
+@pytest.mark.parametrize("ignore_range", [False, True])
+def test_download_resumes_or_restarts_when_server_ignores_range(
+ download_server, tmp_path, ignore_range
+):
+ entry, data, requests, options = download_server
+ options["ignore_range"] = ignore_range
+ target = tmp_path / "archive.zip"
+ target.with_suffix(".partial").write_bytes(data[:37])
+ assert delivery._download(entry, target).read_bytes() == data
+ assert requests == ["bytes=37-"]
+
+
+def test_invalid_resume_response_is_rejected(download_server, tmp_path):
+ entry, data, _requests, options = download_server
+ options["bad_range"] = True
+ target = tmp_path / "archive.zip"
+ target.with_suffix(".partial").write_bytes(data[:37])
+ with pytest.raises(RuntimeNotFoundError, match="Content-Range"):
+ delivery._download(entry, target)
+ assert not target.exists()
+
+
+def test_pinned_hash_is_checked_before_extracting(download_server, tmp_path):
+ entry, _data, _requests, _options = download_server
+ entry["sha256"] = "0" * 64
+ with pytest.raises(RuntimeNotFoundError, match="SHA-256 mismatch"):
+ delivery._download(entry, tmp_path / "archive.zip")
+
+
+def test_catalog_download_installs_once_and_reuses_without_network(download_server, tmp_path):
+ entry, _data, requests, _options = download_server
+ catalog = tmp_path / "catalog.json"
+ catalog.write_text(
+ json.dumps(
+ {
+ "schema": delivery.CATALOG_SCHEMA,
+ "version": 1,
+ "runtimes": {delivery.platform_key(): entry},
+ }
+ )
+ )
+ root = tmp_path / "cache"
+ first = delivery.ensure_runtime(catalog=catalog, root=root)
+ assert delivery.ensure_runtime(catalog=catalog, root=root) == first
+ assert len(requests) == 1
+ assert not list((root / "downloads").glob("*.zip"))
+
+
+def test_cached_pointer_cannot_escape_cache(tmp_path):
+ (tmp_path / "active.json").write_text(
+ json.dumps(
+ {
+ "id": "../outside",
+ "platform": delivery.platform_key(),
+ }
+ )
+ )
+ with pytest.raises(RuntimeNotFoundError, match="Invalid runtime cache"):
+ delivery.cached_runtime(root=tmp_path)
+
+
+def test_catalog_requires_secure_public_transport():
+ entry = {
+ "id": "valid",
+ "sha256": "0" * 64,
+ "bytes": 10,
+ "unpacked_bytes": 20,
+ "url": "http://example.com/engine.zip",
+ }
+ with pytest.raises(RuntimeNotFoundError, match="HTTPS"):
+ delivery._descriptor(entry)
diff --git a/tests/test_legacy.py b/tests/test_legacy.py
new file mode 100644
index 0000000..96e7c38
--- /dev/null
+++ b/tests/test_legacy.py
@@ -0,0 +1,197 @@
+import json
+
+import pytest
+
+from source_robotics_leap_motion import ConnectionError, Quaternion, Vector3
+from source_robotics_leap_motion.backends.auto import AutoBackend
+from source_robotics_leap_motion.backends.legacy import (
+ LegacyWebSocketBackend,
+ decode_frame,
+ palm_orientation,
+)
+from source_robotics_leap_motion.errors import RuntimeNotFoundError
+
+
+@pytest.fixture
+def legacy_frame():
+ return {
+ "id": 100,
+ "timestamp": 123456,
+ "currentFrameRate": 115.0,
+ "hands": [
+ {
+ "id": 7,
+ "type": "right",
+ "palmNormal": [0, -1, 0],
+ "direction": [0, 0, -1],
+ "palmPosition": [125, 250, -75],
+ "palmVelocity": [1000, 0, 0],
+ "palmWidth": 80,
+ "grabStrength": 0.8,
+ "pinchStrength": 0.2,
+ "timeVisible": 2,
+ "elbow": [125, 100, 150],
+ "wrist": [125, 200, 0],
+ "armWidth": 50,
+ }
+ ],
+ "pointables": [
+ {
+ "id": 12,
+ "handId": 7,
+ "type": 1,
+ "extended": True,
+ "width": 15,
+ "carpPosition": [0, 1, 2],
+ "mcpPosition": [0, 2, 2],
+ "pipPosition": [0, 3, 2],
+ "dipPosition": [0, 4, 2],
+ "btipPosition": [0, 5, 2],
+ }
+ ],
+ }
+
+
+def test_legacy_frame_converts_units_and_preserves_missing_rotation(legacy_frame):
+ frame = decode_frame(legacy_frame)
+ hand = frame.right_hand
+ assert hand.palm.position == Vector3(0.125, 0.25, -0.075)
+ assert hand.palm.velocity.x == 1
+ assert hand.palm.orientation == Quaternion()
+ assert hand.visible_time_us == 2_000_000
+ assert hand.pinch_distance is None
+ assert len(hand.fingers[0].bones) == 4
+ assert hand.fingers[0].bones[-1].next_joint.y == 0.005
+ assert hand.fingers[0].bones[-1].rotation is None
+
+
+@pytest.mark.parametrize(
+ "normal,direction,expected",
+ [
+ (Vector3(0, 1, 0), Vector3(0, 0, 1), Quaternion(1, 0, 0, 0)),
+ (Vector3(0, -1, 0), Vector3(0, 0, 1), Quaternion(0, 1, 0, 0)),
+ (Vector3(0, 1, 0), Vector3(0, 0, -1), Quaternion(0, 0, 1, 0)),
+ ],
+)
+def test_palm_orientation_handles_180_degree_rotations(normal, direction, expected):
+ assert palm_orientation(normal, direction) == expected
+
+
+def test_legacy_empty_frame_is_valid():
+ assert decode_frame({"id": 0, "timestamp": 0, "hands": []}).hands == ()
+
+
+def test_legacy_invalid_normal_is_rejected(legacy_frame):
+ legacy_frame["hands"][0]["palmNormal"] = [0, 0, 0]
+ with pytest.raises(ConnectionError):
+ decode_frame(legacy_frame)
+
+
+def test_legacy_connection_reads_greeting_then_frames(monkeypatch, legacy_frame):
+ from source_robotics_leap_motion.backends import legacy
+
+ class Socket:
+ def __init__(self):
+ self.messages = iter(
+ [
+ json.dumps({"version": 6, "serviceVersion": "3.2.1+45911"}),
+ json.dumps(legacy_frame),
+ ]
+ )
+ self.closed = False
+
+ def recv(self):
+ return next(self.messages)
+
+ def send(self, message):
+ assert set(json.loads(message)) <= {"focused", "background"}
+
+ def settimeout(self, timeout):
+ assert 0 < timeout <= 0.1
+
+ def close(self, timeout):
+ self.closed = True
+
+ socket = Socket()
+ monkeypatch.setattr(legacy, "create_connection", lambda *args, **kwargs: socket)
+ backend = LegacyWebSocketBackend()
+ backend.open()
+ assert backend.service_version == "3.2.1+45911"
+ assert backend.read(1).right_hand.id == 7
+ backend.close()
+ assert socket.closed
+
+
+def test_auto_falls_back_without_requiring_old_python(monkeypatch):
+ from source_robotics_leap_motion.backends import auto
+
+ class Native:
+ def __init__(self, **kwargs):
+ pass
+
+ def open(self):
+ raise RuntimeNotFoundError("No native client")
+
+ def close(self):
+ pass
+
+ class Legacy(Native):
+ name = "legacy-websocket"
+
+ def open(self):
+ pass
+
+ def read(self, timeout):
+ return decode_frame({"id": 0, "timestamp": 0, "hands": []})
+
+ monkeypatch.setattr(auto, "LeapCBackend", Native)
+ monkeypatch.setattr(auto, "LegacyWebSocketBackend", Legacy)
+ backend = AutoBackend()
+ backend.open()
+ assert backend.name == "legacy-websocket"
+ assert backend.read(1).hands == ()
+ backend.close()
+
+
+def test_auto_starts_cached_runtime_when_no_service_is_available(monkeypatch, tmp_path):
+ from unittest.mock import Mock
+
+ from source_robotics_leap_motion.backends import auto, shared
+
+ failed = Mock()
+ failed.open.side_effect = ConnectionError("No installed service")
+ monkeypatch.setattr(auto, "LeapCBackend", Mock(return_value=failed))
+ monkeypatch.setattr(auto, "LegacyWebSocketBackend", Mock(return_value=failed))
+ monkeypatch.setattr(auto, "ensure_runtime", lambda: tmp_path)
+ managed = Mock(name="private runtime")
+ managed.name = "managed-legacy"
+ managed.details = {"mode": "shared-private"}
+ managed.read.return_value = decode_frame({"id": 7, "timestamp": 1, "hands": []})
+ monkeypatch.setattr(shared, "SharedRuntimeBackend", Mock(return_value=managed))
+ backend = AutoBackend()
+ backend.open()
+ assert backend.details == {"mode": "shared-private"}
+ assert backend.read(1).id == 7
+ backend.close()
+ managed.close.assert_called_once()
+
+
+def test_auto_acquires_private_lease_instead_of_using_unleased_websocket(monkeypatch, tmp_path):
+ from unittest.mock import Mock
+
+ from source_robotics_leap_motion.backends import auto, shared
+
+ native = Mock()
+ native.open.side_effect = RuntimeNotFoundError("No native library")
+ monkeypatch.setattr(auto, "LeapCBackend", Mock(return_value=native))
+ monkeypatch.setattr(auto, "cached_runtime", lambda: tmp_path)
+ legacy = Mock()
+ monkeypatch.setattr(auto, "LegacyWebSocketBackend", legacy)
+ private = Mock()
+ private.name = "managed-legacy"
+ monkeypatch.setattr(shared, "SharedRuntimeBackend", Mock(return_value=private))
+ backend = AutoBackend()
+ backend.open()
+ legacy.assert_not_called()
+ private.open.assert_called_once()
+ backend.close()
diff --git a/tests/test_managed_runtime.py b/tests/test_managed_runtime.py
new file mode 100644
index 0000000..2e5d422
--- /dev/null
+++ b/tests/test_managed_runtime.py
@@ -0,0 +1,272 @@
+import json
+import subprocess
+import sys
+from types import SimpleNamespace
+from unittest.mock import Mock
+
+import pytest
+
+from source_robotics_leap_motion import Tracker
+from source_robotics_leap_motion.backends import _job, managed
+from source_robotics_leap_motion.cli import main
+from source_robotics_leap_motion.errors import ConnectionError, RuntimeNotFoundError
+from source_robotics_leap_motion.runtime_bundle import (
+ CORE_FILES,
+ DEPENDENCIES,
+ MANIFEST,
+ OPTIONAL_MODELS,
+ inspect_runtime,
+ prepare_local_runtime,
+)
+
+
+@pytest.fixture
+def assets(tmp_path):
+ source = tmp_path / "installed"
+ source.mkdir()
+ for name in CORE_FILES + DEPENDENCIES + OPTIONAL_MODELS:
+ (source / name).write_bytes((name + " test fixture").encode())
+ return source, tmp_path / "private"
+
+
+def test_prepare_copies_only_core_and_dependencies_and_detects_corruption(assets):
+ source, destination = assets
+ (source / "LeapControlPanel.exe").write_bytes(b"not needed")
+ report = prepare_local_runtime(source, destination)
+ assert report["files"] == 7
+ assert report["checksum_verified"]
+ assert not report["redistribution_cleared"]
+ assert {path.name for path in destination.iterdir()} == set(CORE_FILES + DEPENDENCIES) | {
+ MANIFEST
+ }
+ # Idempotent adoption of matching files is allowed.
+ assert prepare_local_runtime(source, destination) == report
+ model = destination / "Tracking.ldat"
+ original = model.read_bytes()
+ model.write_bytes(b"X" + original[1:])
+ with pytest.raises(RuntimeNotFoundError, match="checksum mismatch"):
+ inspect_runtime(destination)
+
+
+def test_prepare_never_overwrites_a_different_existing_engine(assets):
+ source, destination = assets
+ destination.mkdir()
+ engine = destination / CORE_FILES[0]
+ engine.write_bytes(b"keep this file")
+ with pytest.raises(FileExistsError, match="Refusing to overwrite"):
+ prepare_local_runtime(source, destination)
+ assert engine.read_bytes() == b"keep this file"
+ assert not (destination / MANIFEST).exists()
+
+
+def test_prepare_checks_missing_core_before_creating_destination(assets):
+ source, destination = assets
+ (source / "Tracking.ldat").unlink()
+ with pytest.raises(RuntimeNotFoundError, match="Missing legacy runtime component"):
+ prepare_local_runtime(source, destination)
+ assert not destination.exists()
+
+
+def test_prepare_supports_explicit_dependency_location_and_alternate_models(assets, tmp_path):
+ source, destination = assets
+ dependencies = tmp_path / "dependencies"
+ dependencies.mkdir()
+ for name in DEPENDENCIES:
+ (source / name).rename(dependencies / name)
+ report = prepare_local_runtime(
+ source, destination, dependency_dir=dependencies, extra_models=True
+ )
+ assert report["files"] == 9
+ assert all((destination / name).exists() for name in OPTIONAL_MODELS)
+
+
+@pytest.mark.parametrize("change", ["escape", "duplicate", "missing", "engine", "oversize"])
+def test_inspection_rejects_invalid_manifests(assets, change):
+ source, destination = assets
+ prepare_local_runtime(source, destination)
+ path = destination / MANIFEST
+ data = json.loads(path.read_text())
+ if change == "escape":
+ data["files"][0]["name"] = "../outside.exe"
+ elif change == "duplicate":
+ data["files"].append(data["files"][0])
+ elif change == "missing":
+ data["files"].pop(0)
+ elif change == "engine":
+ data["engine"] = "other.exe"
+ path.write_text(" " * 65537 if change == "oversize" else json.dumps(data))
+ with pytest.raises(RuntimeNotFoundError):
+ inspect_runtime(destination)
+
+
+def test_runtime_cli_prepare_and_inspect(assets, capsys):
+ source, destination = assets
+ assert main(["runtime", "prepare", str(source), "--destination", str(destination)]) == 0
+ prepared = json.loads(capsys.readouterr().out)
+ assert main(["runtime", "inspect", str(destination)]) == 0
+ assert json.loads(capsys.readouterr().out) == prepared
+
+
+@pytest.fixture
+def launcher(assets, monkeypatch):
+ source, destination = assets
+ prepare_local_runtime(source, destination)
+ monkeypatch.setattr(managed.sys, "platform", "win32")
+ backend = Mock()
+ backend.read.return_value = object()
+ backend_factory = Mock(return_value=backend)
+ monkeypatch.setattr(managed, "LegacyWebSocketBackend", backend_factory)
+ busy = Mock(return_value=False)
+ monkeypatch.setattr(managed, "_port_in_use", busy)
+ process = Mock(pid=12345, returncode=None)
+ process.poll.return_value = None
+ spawn = Mock(return_value=process)
+ monkeypatch.setattr(managed.subprocess, "Popen", spawn)
+ monkeypatch.setattr(managed.subprocess, "CREATE_NO_WINDOW", 0x08000000, raising=False)
+ job = Mock()
+ job_factory = Mock(return_value=job)
+ monkeypatch.setattr(_job, "ProcessJob", job_factory)
+ runtime = managed.ManagedLegacyBackend(destination)
+ return SimpleNamespace(
+ runtime=runtime,
+ backend=backend,
+ backend_factory=backend_factory,
+ busy=busy,
+ process=process,
+ spawn=spawn,
+ job=job,
+ job_factory=job_factory,
+ directory=destination,
+ )
+
+
+def test_existing_service_is_reused_without_owning_or_terminating_it(launcher):
+ launcher.busy.return_value = True
+ with Tracker(backend=launcher.runtime) as tracker:
+ assert tracker.read() is launcher.backend.read.return_value
+ assert tracker.runtime_details["mode"] == "existing"
+ assert tracker.runtime_details["pid"] is None
+ launcher.backend.close.assert_called_once()
+ launcher.spawn.assert_not_called()
+ launcher.job_factory.assert_not_called()
+
+
+def test_exclusive_probe_never_stops_an_existing_service(launcher):
+ launcher.busy.return_value = True
+ launcher.runtime.reuse_existing = False
+ with pytest.raises(ConnectionError, match="already in use"):
+ launcher.runtime.open()
+ launcher.spawn.assert_not_called()
+ launcher.backend_factory.assert_not_called()
+
+
+def test_private_engine_is_hidden_owned_and_closed(launcher):
+ with Tracker(backend=launcher.runtime) as tracker:
+ assert tracker.runtime_details["mode"] == "private"
+ assert tracker.runtime_details["pid"] == 12345
+ launcher.job.assign.assert_called_once_with(12345)
+ arguments, options = launcher.spawn.call_args
+ assert arguments[0][1] == "--run"
+ assert options["creationflags"] == subprocess.CREATE_NO_WINDOW
+ assert options["cwd"] == launcher.directory
+ config = json.loads((launcher.directory / "state" / "config.json").read_text())
+ assert not config["configuration"]["websockets_allow_remote"]
+ assert config["configuration"]["images_mode"] == 0
+ assert tracker.read() is launcher.backend.read.return_value
+ launcher.process.terminate.assert_called_once()
+ launcher.process.wait.assert_called_once_with(timeout=3)
+ launcher.job.close.assert_called_once()
+ assert launcher.runtime._log is None
+ # Closing again must not touch any process, existing or private.
+ launcher.runtime.close()
+ launcher.process.terminate.assert_called_once()
+
+
+@pytest.mark.parametrize("failure", ["job", "connect", "child_exit"])
+def test_failed_startup_cleans_up_owned_process_and_job(launcher, failure):
+ if failure == "job":
+ launcher.job.assign.side_effect = OSError("job assignment failed")
+ elif failure == "connect":
+ launcher.backend.open.side_effect = KeyboardInterrupt
+ else:
+ launcher.process.poll.return_value = 7
+ launcher.process.returncode = 7
+ with pytest.raises((OSError, KeyboardInterrupt, ConnectionError)):
+ launcher.runtime.open()
+ launcher.job.close.assert_called_once()
+ assert launcher.runtime._process is None
+ assert launcher.runtime._log is None
+ if failure != "child_exit":
+ launcher.process.terminate.assert_called_once()
+
+
+def test_unresponsive_owned_process_is_killed_on_close(launcher):
+ launcher.runtime.open()
+ launcher.process.wait.side_effect = [subprocess.TimeoutExpired("engine", 3), 0]
+ launcher.runtime.close()
+ launcher.process.kill.assert_called_once()
+ launcher.job.close.assert_called_once()
+
+
+def test_child_exit_is_reported_instead_of_reading_another_server(launcher):
+ launcher.runtime.open()
+ reads_at_startup = launcher.backend.read.call_count
+ launcher.process.poll.return_value = 8
+ launcher.process.returncode = 8
+ try:
+ with pytest.raises(ConnectionError, match="stopped with code 8"):
+ launcher.runtime.read(1)
+ assert launcher.backend.read.call_count == reads_at_startup
+ finally:
+ launcher.runtime.close()
+
+
+def test_private_startup_requires_a_frame_and_preserves_it_for_the_caller(launcher):
+ first, second = object(), object()
+ launcher.backend.read.side_effect = [first, second]
+ with Tracker(backend=launcher.runtime) as tracker:
+ launcher.backend.read.assert_called_once()
+ assert tracker.read() is first
+ launcher.backend.read.assert_called_once() # No dropped startup frame or extra read.
+ assert tracker.read() is second
+ assert launcher.backend.read.call_count == 2
+
+
+def test_socket_without_camera_frames_does_not_count_as_private_readiness(launcher):
+ launcher.runtime.connect_timeout = 0.01
+ launcher.backend.read.return_value = None
+ with pytest.raises(ConnectionError, match="startup timed out"):
+ launcher.runtime.open()
+ launcher.backend.close.assert_called()
+ launcher.process.terminate.assert_called_once()
+ launcher.job.close.assert_called_once()
+ assert launcher.runtime._pending_frame is None
+
+
+@pytest.mark.skipif(sys.platform != "win32", reason="native Windows process ownership")
+def test_windows_job_closes_a_real_child_process():
+ # No Leap service, camera, or elevation: exercise the actual Windows job API.
+ child = subprocess.Popen(
+ [sys.executable, "-c", "import time; time.sleep(60)"],
+ creationflags=subprocess.CREATE_NO_WINDOW,
+ )
+ job = None
+ try:
+ job = _job.ProcessJob()
+ job.assign(child.pid)
+ assert child.poll() is None
+ job.close()
+ child.wait(timeout=3) # Job termination can report exit code zero on Windows.
+ finally:
+ if job is not None:
+ job.close()
+ if child.poll() is None:
+ child.kill()
+ child.wait(timeout=3)
+
+
+def test_runtime_selection_is_explicit():
+ with pytest.raises(ValueError, match="Choose a backend"):
+ Tracker(runtime_dir="runtime", library_path="LeapC.dll")
+ with pytest.raises(ValueError, match="positive and finite"):
+ managed.ManagedLegacyBackend("runtime", connect_timeout=float("nan"))
diff --git a/tests/test_mapping.py b/tests/test_mapping.py
new file mode 100644
index 0000000..5144aa2
--- /dev/null
+++ b/tests/test_mapping.py
@@ -0,0 +1,37 @@
+import pytest
+
+from source_robotics_leap_motion import Mapping, Vector3
+
+
+def test_mapping_preserves_calibrated_robot_origin_and_relative_axes():
+ mapping = Mapping(axes=("-z", "-x", "y"), gain=Vector3(0.5, 0.5, 0.5))
+ hand_origin = Vector3(0.01, 0.25, 0.02)
+ robot_origin = Vector3(0.2, 0, 0.2)
+ assert (
+ mapping.map_position(hand_origin, hand_origin=hand_origin, robot_origin=robot_origin)
+ == robot_origin
+ )
+ target = mapping.map_position(
+ hand_origin + Vector3(0.02, 0.04, -0.06),
+ hand_origin=hand_origin,
+ robot_origin=robot_origin,
+ )
+ assert tuple(target) == pytest.approx((0.23, -0.01, 0.22))
+
+
+def test_axis_lock_and_independent_gains():
+ mapping = Mapping(gain=Vector3(0, 0.5, 2))
+ assert mapping.map_position(Vector3(1, 2, 3)) == Vector3(0, 1, 6)
+
+
+@pytest.mark.parametrize("axes", [("x", "x", "z"), ("-x", "y", "z"), ("--x", "y", "z")])
+def test_invalid_or_reflecting_coordinate_frames_are_rejected(axes):
+ with pytest.raises(ValueError):
+ Mapping(axes=axes)
+
+
+def test_unknown_config_setting_is_not_silently_ignored(tmp_path):
+ path = tmp_path / "arm.toml"
+ path.write_text("[mapping]\ngian = [1, 1, 1]\n", encoding="utf-8")
+ with pytest.raises(ValueError, match="gian"):
+ Mapping.from_toml(path)
diff --git a/tests/test_recording.py b/tests/test_recording.py
new file mode 100644
index 0000000..7e91ac5
--- /dev/null
+++ b/tests/test_recording.py
@@ -0,0 +1,73 @@
+import json
+from dataclasses import replace
+
+import pytest
+from test_tracking import native_event
+
+from source_robotics_leap_motion import Recorder, RecordingError, Tracker, read_recording
+from source_robotics_leap_motion.backends.leapc import copy_frame
+from source_robotics_leap_motion.backends.replay import ReplayBackend
+from source_robotics_leap_motion.recording import frame_from_dict, frame_to_dict
+
+
+def test_skeleton_and_empty_frames_round_trip(tmp_path):
+ event, _hands = native_event()
+ frames = [copy_frame(event, 3), replace(copy_frame(event, 3), id=8, hands=())]
+ path = tmp_path / "session.jsonl"
+ with Recorder(path) as recorder:
+ for frame in frames:
+ recorder.write(frame)
+ assert list(read_recording(path)) == frames
+ with Tracker(backend=ReplayBackend(path)) as tracker:
+ assert list(tracker) == frames
+
+
+def test_existing_recording_is_not_overwritten(tmp_path):
+ path = tmp_path / "session.jsonl"
+ path.write_text("important recording", encoding="utf-8")
+ with pytest.raises(FileExistsError), Recorder(path):
+ pass
+ assert path.read_text(encoding="utf-8") == "important recording"
+
+
+@pytest.mark.parametrize("field,value", [("version", 999), ("units", "millimetres")])
+def test_incompatible_recording_is_rejected(tmp_path, field, value):
+ path = tmp_path / "session.jsonl"
+ with Recorder(path):
+ pass
+ header = json.loads(path.read_text(encoding="utf-8"))
+ header[field] = value
+ path.write_text(json.dumps(header) + "\n", encoding="utf-8")
+ with pytest.raises(RecordingError):
+ list(read_recording(path))
+
+
+def test_truncated_frame_reports_line_number(tmp_path):
+ path = tmp_path / "session.jsonl"
+ with Recorder(path):
+ pass
+ with path.open("a", encoding="utf-8") as stream:
+ stream.write('{"frame":')
+ with pytest.raises(RecordingError, match="line 2"):
+ list(read_recording(path))
+
+
+def test_non_finite_recording_values_are_rejected():
+ with Tracker.demo(realtime=False) as tracker:
+ data = json.loads(json.dumps(frame_to_dict(tracker.read())))
+ data["hands"][0]["palm"]["position"]["x"] = float("nan")
+ with pytest.raises(RecordingError, match="finite"):
+ frame_from_dict(data)
+
+
+def test_paced_replay_rejects_clock_reset(tmp_path):
+ path = tmp_path / "session.jsonl"
+ with Tracker.demo(realtime=False) as tracker:
+ frame = tracker.read()
+ with Recorder(path) as recorder:
+ recorder.write(replace(frame, timestamp_us=100))
+ recorder.write(replace(frame, timestamp_us=99))
+ with Tracker(backend=ReplayBackend(path, realtime=True)) as tracker:
+ tracker.read()
+ with pytest.raises(RecordingError, match="monotonically"):
+ tracker.read()
diff --git a/tests/test_service_helper.py b/tests/test_service_helper.py
new file mode 100644
index 0000000..b7fb0f4
--- /dev/null
+++ b/tests/test_service_helper.py
@@ -0,0 +1,86 @@
+import json
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+
+@pytest.mark.skipif(
+ sys.platform != "win32" or shutil.which("powershell.exe") is None,
+ reason="Windows PowerShell helper regression test",
+)
+@pytest.mark.parametrize("delayed_stop", [False, True])
+def test_service_helper_restores_original_running_state_even_after_stop_error(
+ tmp_path, delayed_stop
+):
+ # Shadow service commands in a separate PowerShell process. No real service or UAC.
+ helper = Path(__file__).resolve().parents[1] / "tools" / "Pause-LeapForProbe.ps1"
+ (tmp_path / "done").touch()
+ harness = tmp_path / "mock-service.ps1"
+ mock_output = tmp_path / "mock-result.json"
+
+ def quote(path):
+ return "'" + str(path).replace("'", "''") + "'"
+
+ harness.write_text(
+ """
+$ErrorActionPreference = 'Stop'
+$global:fakeService = [pscustomobject]@{ Status = 'Running'; Starts = 0; NoWait = $false }
+$global:fakeService | Add-Member -MemberType ScriptMethod -Name Refresh -Value {}
+$global:fakeService | Add-Member -MemberType ScriptMethod -Name Start -Value {
+ $this.Starts += 1
+ $this.Status = 'Running'
+}
+$global:fakeService | Add-Member -MemberType ScriptMethod -Name WaitForStatus -Value {
+ param($wanted, $timeout)
+ if ($wanted -eq 'Stopped' -and $this.Status -eq 'StopPending') {
+ $this.Status = 'Stopped'
+ }
+ if ($this.Status -ne $wanted) { throw 'Unexpected fake service state' }
+}
+function Get-Service { param($Name); return $global:fakeService }
+function Stop-Service {
+ param($Name, [switch]$NoWait)
+ $global:fakeService.NoWait = $NoWait.IsPresent
+ if (DELAYED_STOP) {
+ $global:fakeService.Status = 'StopPending'
+ throw 'Simulated delayed Windows stop error'
+ }
+ $global:fakeService.Status = 'Stopped'
+}
+function Start-Sleep { param($Milliseconds) }
+""".replace("DELAYED_STOP", "$true" if delayed_stop else "$false")
+ + "\n& "
+ + quote(helper)
+ + " -ControlDirectory "
+ + quote(tmp_path)
+ + "\n$global:fakeService | ConvertTo-Json | Set-Content -Encoding UTF8 -LiteralPath "
+ + quote(mock_output),
+ encoding="utf-8-sig",
+ )
+ result = subprocess.run(
+ [
+ "powershell.exe",
+ "-NoProfile",
+ "-NonInteractive",
+ "-ExecutionPolicy",
+ "Bypass",
+ "-File",
+ str(harness),
+ ],
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ assert result.returncode == 0, result.stderr
+ service = json.loads(mock_output.read_text(encoding="utf-8-sig"))
+ restored = json.loads((tmp_path / "restored.json").read_text(encoding="utf-8-sig"))
+ assert service["Starts"] == 1
+ assert service["NoWait"]
+ assert restored["was_running"]
+ assert restored["stop_requested"]
+ assert restored["restored"]
+ assert restored["final_status"] == "Running"
+ assert bool(restored.get("error")) == delayed_stop
diff --git a/tests/test_setup.py b/tests/test_setup.py
new file mode 100644
index 0000000..78e6d23
--- /dev/null
+++ b/tests/test_setup.py
@@ -0,0 +1,79 @@
+import json
+from types import SimpleNamespace
+from unittest.mock import Mock
+
+import pytest
+
+from source_robotics_leap_motion import setup
+from source_robotics_leap_motion.backends import leapc
+from source_robotics_leap_motion.backends._native import NativeMessage
+from source_robotics_leap_motion.errors import RuntimeNotFoundError
+
+
+@pytest.mark.parametrize(
+ "devices,expected",
+ [
+ ([], "not-connected"),
+ ([{"name": "Leap", "problem_code": 0}], "present"),
+ ([{"name": "Leap", "problem_code": 28}], "driver-required"),
+ ([{"name": "Leap", "problem_code": 43}], "device-error"),
+ ],
+)
+def test_driver_detection_distinguishes_missing_driver_from_absent_camera(
+ monkeypatch, devices, expected
+):
+ monkeypatch.setattr(setup.sys, "platform", "win32")
+ monkeypatch.setattr(setup.subprocess, "CREATE_NO_WINDOW", 0, raising=False)
+ monkeypatch.setattr(
+ setup.subprocess,
+ "run",
+ lambda *a, **k: SimpleNamespace(
+ returncode=0,
+ stdout=json.dumps(devices),
+ ),
+ )
+ assert setup.device_status()["status"] == expected
+
+
+def test_driver_inspection_denied_is_not_a_missing_driver(monkeypatch):
+ monkeypatch.setattr(setup.sys, "platform", "win32")
+ monkeypatch.setattr(setup.subprocess, "CREATE_NO_WINDOW", 0, raising=False)
+ monkeypatch.setattr(setup.subprocess, "run", lambda *a, **k: SimpleNamespace(returncode=1))
+ assert setup.device_status()["status"] == "unknown"
+
+
+def test_driver_install_does_not_replace_a_working_driver(monkeypatch, tmp_path):
+ monkeypatch.setattr(setup.sys, "platform", "win32")
+ monkeypatch.setattr(setup, "inspect_runtime", lambda path: {})
+ monkeypatch.setattr(setup, "device_status", lambda: {"status": "present"})
+ with pytest.raises(RuntimeNotFoundError, match="Existing working drivers"):
+ setup.install_windows_driver(tmp_path)
+
+
+@pytest.mark.parametrize("platform", ["linux", "darwin"])
+def test_posix_native_backend_uses_cdecl_loader(monkeypatch, tmp_path, platform):
+ import ctypes as C
+
+ library = Mock()
+ library.LeapCreateConnection.side_effect = lambda config, handle: (
+ setattr(C.cast(handle, C.POINTER(C.c_void_p)).contents, "value", 123) or 0
+ )
+ library.LeapOpenConnection.return_value = 0
+ library.LeapSetPolicyFlags.return_value = 0
+
+ def poll(handle, timeout, message):
+ C.cast(message, C.POINTER(NativeMessage)).contents.type = leapc.CONNECTION
+ return 0
+
+ library.LeapPollConnection.side_effect = poll
+ loader = Mock(return_value=library)
+ monkeypatch.setattr(leapc.sys, "platform", platform)
+ monkeypatch.setattr(leapc, "find_library", lambda selected: tmp_path / "libLeapC.so")
+ monkeypatch.setattr(leapc.C, "CDLL", loader)
+ native = leapc.LeapCBackend()
+ native.open()
+ loader.assert_called_once()
+ library.LeapSetPolicyFlags.assert_called_once_with(native._handle, 1, 0)
+ native.close()
+ library.LeapCloseConnection.assert_called_once()
+ library.LeapDestroyConnection.assert_called_once()
diff --git a/tests/test_tracking.py b/tests/test_tracking.py
new file mode 100644
index 0000000..f150251
--- /dev/null
+++ b/tests/test_tracking.py
@@ -0,0 +1,160 @@
+import ctypes as C
+import math
+
+import pytest
+
+from source_robotics_leap_motion import (
+ ConnectionError,
+ Frame,
+ Tracker,
+ TrackingTimeoutError,
+ Vector3,
+)
+from source_robotics_leap_motion.backends._native import NativeHand, NativeTrackingEvent
+from source_robotics_leap_motion.backends.leapc import copy_frame
+
+
+def native_event():
+ hand = NativeHand()
+ hand.id = 42
+ hand.type = 1
+ hand.palm.position.x = 125
+ hand.palm.position.y = 250
+ hand.palm.velocity.z = 1000
+ hand.palm.orientation.w = 1
+ hand.arm.rotation.w = 1
+ for digit in hand.digits:
+ for bone in digit.bones:
+ bone.rotation.w = 1
+ hand.digits[1].bones[3].next_joint.x = 200
+ hands = (NativeHand * 1)(hand)
+ event = NativeTrackingEvent()
+ event.info.timestamp = 123456
+ event.tracking_frame_id = 7
+ event.nHands = 1
+ event.pHands = hands
+ event.framerate = 120
+ return event, hands
+
+
+def test_native_snapshot_owns_memory_and_converts_units():
+ event, hands = native_event()
+ frame = copy_frame(event, device_id=3)
+ hands[0].palm.position.x = 99999
+ hands[0].digits[1].bones[3].next_joint.x = 99999
+ assert frame.right_hand.id == 42
+ assert frame.right_hand.palm.position == Vector3(0.125, 0.25, 0)
+ assert frame.right_hand.palm.velocity.z == 1
+ assert frame.right_hand.fingers[1].bones[3].next_joint.x == pytest.approx(0.2)
+ assert frame.device_id == 3
+ assert frame.timestamp_us == 123456
+
+
+@pytest.mark.parametrize("count", [1, 999])
+def test_native_rejects_impossible_or_null_hand_arrays(count):
+ event = NativeTrackingEvent()
+ event.nHands = count
+ with pytest.raises(ValueError):
+ copy_frame(event, 0)
+
+
+def test_no_hands_is_a_valid_frame():
+ frame = copy_frame(NativeTrackingEvent(), 0)
+ assert frame.hands == ()
+ assert frame.right_hand is None
+ assert frame.left_hand is None
+
+
+def test_native_rejects_non_finite_position():
+ event, _hands = native_event()
+ event.pHands[0].palm.position.x = math.nan
+ with pytest.raises(ValueError, match="finite"):
+ copy_frame(event, 0)
+
+
+class Source:
+ def __init__(self):
+ self.closed = 0
+
+ def open(self):
+ pass
+
+ def read(self, timeout):
+ return None
+
+ def close(self):
+ self.closed += 1
+
+
+def test_timeout_is_distinct_from_empty_frame_and_closes_connection():
+ source = Source()
+ with pytest.raises(TrackingTimeoutError), Tracker(backend=source) as tracker:
+ tracker.read(timeout=0.01)
+ assert source.closed == 1
+
+
+def test_source_is_closed_even_if_open_fails():
+ class FailedSource(Source):
+ def open(self):
+ raise OSError("Failed to open")
+
+ source = FailedSource()
+ with pytest.raises(OSError), Tracker(backend=source):
+ pass
+ assert source.closed == 1
+
+
+def test_demo_is_reproducible_and_tracker_can_reopen():
+ tracker = Tracker.demo(realtime=False)
+ with tracker:
+ first = tracker.read()
+ assert tracker.read().timestamp_us > first.timestamp_us
+ with tracker:
+ assert tracker.read() == first
+ with pytest.raises(RuntimeError):
+ tracker.read()
+
+
+def test_disconnect_does_not_reuse_old_frame_and_reconnect_is_explicit():
+ class Device:
+ opens = 0
+ closed = 0
+
+ def open(self):
+ self.opens += 1
+
+ def read(self, timeout):
+ if self.opens == 1:
+ raise ConnectionError("Camera disconnected")
+ return Frame(2, 200)
+
+ def close(self):
+ self.closed += 1
+
+ device = Device()
+ with Tracker(backend=device) as tracker:
+ with pytest.raises(ConnectionError, match="Camera disconnected"):
+ tracker.read()
+ assert tracker.reconnect() is tracker
+ assert tracker.read().id == 2
+ assert device.opens == 2
+ assert device.closed == 2
+
+
+def test_frame_with_two_hands_selects_by_handedness():
+ from dataclasses import replace
+
+ with Tracker.demo(realtime=False) as tracker:
+ right = tracker.read().right_hand
+ left = replace(right, id=2, handedness="left")
+ frame = Frame(1, 0, hands=(left, right))
+ assert frame.right_hand is right
+ assert frame.left_hand is left
+
+
+def test_packed_x64_hand_layout_matches_sdk_contract():
+ # Documented header contract, not sizeof() values derived from our declaration.
+ assert C.sizeof(NativeHand) == 1084
+ assert NativeHand.palm.offset == 40
+ assert NativeHand.digits.offset == 120
+ assert NativeHand.arm.offset == 1040
diff --git a/tools/Pause-LeapForProbe.ps1 b/tools/Pause-LeapForProbe.ps1
new file mode 100644
index 0000000..bcd939e
--- /dev/null
+++ b/tools/Pause-LeapForProbe.ps1
@@ -0,0 +1,52 @@
+param(
+ [Parameter(Mandatory=$true)][string]$ControlDirectory
+)
+
+$ErrorActionPreference = 'Stop'
+$controlPath = (Resolve-Path -LiteralPath $ControlDirectory).Path
+$readyPath = Join-Path $controlPath 'ready.json'
+$donePath = Join-Path $controlPath 'done'
+$resultPath = Join-Path $controlPath 'restored.json'
+$result = @{ restored = $false; stopped_by_helper = $false; stop_requested = $false }
+$service = Get-Service -Name LeapService
+$wasRunning = $service.Status -eq 'Running'
+$result.was_running = $wasRunning
+try {
+ if ($wasRunning) {
+ $result.stop_requested = $true
+ Stop-Service -Name LeapService -NoWait
+ $result.stopped_by_helper = $true
+ $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(10))
+ }
+ @{ service = 'Stopped'; maximum_pause_seconds = 60 } |
+ ConvertTo-Json | Set-Content -LiteralPath $readyPath -Encoding UTF8
+ $deadline = [DateTime]::UtcNow.AddSeconds(60)
+ while (-not (Test-Path -LiteralPath $donePath) -and [DateTime]::UtcNow -lt $deadline) {
+ Start-Sleep -Milliseconds 100
+ }
+} catch {
+ $result.error = $_.Exception.Message
+} finally {
+ try {
+ # A stop request can time out and still take effect afterward. Restore the
+ # original running state even when Stop-Service or WaitForStatus threw.
+ if ($wasRunning) {
+ $service.Refresh()
+ if ($service.Status -eq 'StopPending') {
+ $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(30))
+ }
+ # Give USB teardown a brief settling interval before reopening the camera.
+ if ($service.Status -eq 'Stopped') {
+ Start-Sleep -Milliseconds 1000
+ $service.Start()
+ }
+ $service.WaitForStatus('Running', [TimeSpan]::FromSeconds(10))
+ }
+ $service.Refresh()
+ $result.final_status = $service.Status.ToString()
+ $result.restored = (-not $wasRunning) -or $service.Status -eq 'Running'
+ } catch {
+ $result.restore_error = $_.Exception.Message
+ }
+ $result | ConvertTo-Json | Set-Content -LiteralPath $resultPath -Encoding UTF8
+}
diff --git a/tools/Test-PrivateRuntime.ps1 b/tools/Test-PrivateRuntime.ps1
new file mode 100644
index 0000000..2497c89
--- /dev/null
+++ b/tools/Test-PrivateRuntime.ps1
@@ -0,0 +1,82 @@
+# Development-only: elevate the bounded service helper, never the tracking engine.
+param(
+ [string]$RuntimeDirectory = (Join-Path $PSScriptRoot '..\.tools\private-runtime-321'),
+ [ValidateRange(1,15)][int]$CaptureSeconds = 5,
+ [switch]$Lifecycle,
+ [string]$PythonPath
+)
+
+$ErrorActionPreference = 'Stop'
+$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
+$principal = [Security.Principal.WindowsPrincipal]::new($identity)
+if ($principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
+ throw 'Run this test from a normal terminal so that the engine remains non-administrator.'
+}
+$projectPath = Split-Path -Parent $PSScriptRoot
+if (-not $PythonPath) { $PythonPath = Join-Path $projectPath '.venv\Scripts\python.exe' }
+if (-not (Test-Path -LiteralPath $pythonPath)) {
+ throw 'This checkout needs its .venv before running the development test.'
+}
+$runtimePath = (Resolve-Path -LiteralPath $RuntimeDirectory).Path
+$controlPath = Join-Path $projectPath ('.tools\private-probes\' + [Guid]::NewGuid().ToString('N'))
+New-Item -ItemType Directory -Path $controlPath -Force | Out-Null
+$helperPath = Join-Path $PSScriptRoot 'Pause-LeapForProbe.ps1'
+$probePath = Join-Path $PSScriptRoot 'probe_private_runtime.py'
+if ($Lifecycle) { $probePath = Join-Path $PSScriptRoot 'probe_runtime_lifecycle.py' }
+$readyPath = Join-Path $controlPath 'ready.json'
+$restoredPath = Join-Path $controlPath 'restored.json'
+$helperStarted = $false
+$exitCode = 2
+try {
+ $helperArguments = '-NoProfile -ExecutionPolicy Bypass -File "{0}" -ControlDirectory "{1}"' -f $helperPath, $controlPath
+ Write-Host 'Approve the Windows prompt to pause LeapService for this bounded test.'
+ Start-Process -FilePath 'powershell.exe' -Verb RunAs -WindowStyle Hidden -ArgumentList $helperArguments | Out-Null
+ $helperStarted = $true
+ $deadline = [DateTime]::UtcNow.AddSeconds(20)
+ while (-not (Test-Path -LiteralPath $readyPath)) {
+ if (Test-Path -LiteralPath $restoredPath) {
+ throw (Get-Content -LiteralPath $restoredPath -Raw)
+ }
+ if ([DateTime]::UtcNow -gt $deadline) {
+ throw 'The service-pause helper did not become ready.'
+ }
+ Start-Sleep -Milliseconds 100
+ }
+ Write-Host 'Running the private engine with the normal user account...'
+ if ($Lifecycle) {
+ & $pythonPath $probePath $runtimePath --summary (Join-Path $controlPath 'summary.json')
+ } else {
+ & $pythonPath $probePath $runtimePath --duration $CaptureSeconds --summary (Join-Path $controlPath 'summary.json') --record (Join-Path $controlPath 'tracking.jsonl')
+ }
+ $exitCode = $LASTEXITCODE
+} catch {
+ Write-Host $_.Exception.Message
+} finally {
+ New-Item -ItemType File -Path (Join-Path $controlPath 'done') -Force | Out-Null
+ if ($helperStarted) {
+ $deadline = [DateTime]::UtcNow.AddSeconds(15)
+ while (-not (Test-Path -LiteralPath $restoredPath) -and [DateTime]::UtcNow -lt $deadline) {
+ Start-Sleep -Milliseconds 100
+ }
+ if (Test-Path -LiteralPath $restoredPath) {
+ $restored = Get-Content -LiteralPath $restoredPath -Raw | ConvertFrom-Json
+ Write-Host ('Installed service: ' + $restored.final_status)
+ if (-not $restored.restored) {
+ Write-Host ('Restoration failed. See ' + $restoredPath)
+ $exitCode = 2
+ } elseif ($restored.was_running) {
+ Write-Host 'Verifying that the restored service sends camera frames...'
+ & $pythonPath -m source_robotics_leap_motion doctor --probe --json --timeout 10
+ if ($LASTEXITCODE -ne 0) {
+ Write-Host 'Service is running, but the restored camera stream check failed.'
+ $exitCode = 2
+ }
+ }
+ } else {
+ Write-Host 'No restoration result yet. The helper has a separate 60-second watchdog.'
+ $exitCode = 2
+ }
+ }
+ Write-Host ('Test results: ' + $controlPath)
+}
+exit $exitCode
diff --git a/tools/benchmark_tracking.py b/tools/benchmark_tracking.py
new file mode 100644
index 0000000..161e99b
--- /dev/null
+++ b/tools/benchmark_tracking.py
@@ -0,0 +1,255 @@
+"""Compare data delivery from the old SDK and current package on Windows.
+
+Run this with the project venv. The old SDK worker needs a Python 3.8 interpreter
+and the historical Leap.py/LeapPython.pyd in the project root. Nothing is sent to
+a robot. Workers share a QPC clock and consume the same tracking service.
+"""
+
+import argparse
+import ctypes
+import json
+import math
+import platform
+import statistics
+import subprocess
+import sys
+import threading
+import time
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+KERNEL = ctypes.WinDLL("kernel32", use_last_error=True)
+KERNEL.QueryPerformanceCounter.argtypes = [ctypes.POINTER(ctypes.c_int64)]
+KERNEL.QueryPerformanceCounter.restype = ctypes.c_int
+KERNEL.QueryPerformanceFrequency.argtypes = [ctypes.POINTER(ctypes.c_int64)]
+KERNEL.QueryPerformanceFrequency.restype = ctypes.c_int
+FREQUENCY = ctypes.c_int64()
+KERNEL.QueryPerformanceFrequency(ctypes.byref(FREQUENCY))
+
+
+def clock_ns():
+ counter = ctypes.c_int64()
+ KERNEL.QueryPerformanceCounter(ctypes.byref(counter))
+ return counter.value * 1_000_000_000 // FREQUENCY.value
+
+
+def percentile(values, fraction):
+ if not values:
+ return None
+ values = sorted(values)
+ position = (len(values) - 1) * fraction
+ lower = math.floor(position)
+ upper = math.ceil(position)
+ return values[lower] + (values[upper] - values[lower]) * (position - lower)
+
+
+def distribution(values):
+ return {
+ "count": len(values),
+ "median": statistics.median(values) if values else None,
+ "p95": percentile(values, 0.95),
+ "min": min(values) if values else None,
+ "max": max(values) if values else None,
+ }
+
+
+def old_snapshot(frame, palm_only=False):
+ """Read equivalent common fields into Python-owned tuples through SWIG getters."""
+ def vector(value, scale=0.001):
+ return (value.x * scale, value.y * scale, value.z * scale)
+
+ hands = []
+ for hand in frame.hands:
+ if palm_only:
+ hands.append((vector(hand.palm_position), hand.grab_strength))
+ continue
+ fingers = []
+ for finger in hand.fingers:
+ bones = []
+ for index in range(4):
+ bone = finger.bone(index)
+ bones.append((vector(bone.prev_joint), vector(bone.next_joint), bone.width * 0.001))
+ fingers.append((finger.id, finger.type, finger.is_extended, tuple(bones)))
+ arm = hand.arm
+ hands.append((
+ hand.id, hand.is_left, vector(hand.palm_position), vector(hand.palm_velocity),
+ vector(hand.palm_normal, 1.0), vector(hand.direction, 1.0), hand.palm_width * 0.001,
+ hand.grab_strength, hand.pinch_strength, hand.time_visible, tuple(fingers),
+ vector(arm.elbow_position), vector(arm.wrist_position), arm.width * 0.001,
+ ))
+ return tuple(hands)
+
+
+def summarize(samples, duration, cpu_seconds, errors):
+ arrivals = [sample["ready_ns"] for sample in samples]
+ hand_samples = [sample for sample in samples if sample["hands"]]
+ return {
+ "python": platform.python_version(),
+ "duration_seconds": duration,
+ "received_frames": len(samples),
+ "unique_frame_ids": len({sample["id"] for sample in samples}),
+ "frames_per_second": len(samples) / duration,
+ "hand_frames": len(hand_samples),
+ "cpu_seconds": cpu_seconds,
+ "cpu_percent_of_one_core": cpu_seconds / duration * 100,
+ "interarrival_ms": distribution([
+ (later - earlier) / 1_000_000 for earlier, later in zip(arrivals, arrivals[1:])
+ ]),
+ "hand_processing_us": distribution([sample["processing_us"] for sample in hand_samples]),
+ "empty_processing_us": distribution([
+ sample["processing_us"] for sample in samples if not sample["hands"]
+ ]),
+ "errors": errors,
+ }
+
+
+def old_worker(args):
+ sys.path.insert(0, str(ROOT))
+ import Leap
+
+ samples = []
+ errors = []
+ end = args.start_ns + round(args.duration * 1_000_000_000)
+ lock = threading.Lock()
+
+ class Listener(Leap.Listener):
+ def on_frame(self, controller):
+ entry = clock_ns()
+ if not args.start_ns <= entry < end:
+ return
+ try:
+ frame = controller.frame()
+ if frame.id < 0:
+ return
+ data = old_snapshot(frame, palm_only=args.worker == "old-palm")
+ ready = clock_ns()
+ sample = {
+ "id": frame.id, "timestamp_us": frame.timestamp,
+ "ready_ns": ready, "hands": len(data),
+ "processing_us": (ready - entry) / 1000,
+ }
+ with lock:
+ samples.append(sample)
+ except Exception as error:
+ errors.append(str(error))
+
+ controller = Leap.Controller()
+ listener = Listener()
+ controller.add_listener(listener)
+ controller.set_policy(Leap.Controller.POLICY_BACKGROUND_FRAMES)
+ try:
+ while clock_ns() < args.start_ns:
+ time.sleep(0.001)
+ cpu_start = time.process_time()
+ while clock_ns() < end:
+ time.sleep(0.01)
+ cpu = time.process_time() - cpu_start
+ finally:
+ controller.remove_listener(listener)
+ with lock:
+ copied = list(samples)
+ return {"summary": summarize(copied, args.duration, cpu, errors), "samples": copied}
+
+
+def new_worker(args):
+ from source_robotics_leap_motion import Tracker
+ from source_robotics_leap_motion.backends.legacy import LegacyWebSocketBackend
+
+ backend = LegacyWebSocketBackend()
+ samples = []
+ end = args.start_ns + round(args.duration * 1_000_000_000)
+ raw_hand = None
+ with Tracker(backend=backend, timeout=3) as tracker:
+ original_recv = backend._socket.recv
+ last = {}
+
+ def observed_recv():
+ raw = original_recv()
+ last["received_ns"] = clock_ns()
+ last["raw"] = raw
+ return raw
+
+ backend._socket.recv = observed_recv
+ # Drain warm-up frames to avoid measuring an artificial connection backlog.
+ while clock_ns() < args.start_ns:
+ tracker.read()
+ cpu_start = time.process_time()
+ while clock_ns() < end:
+ frame = tracker.read()
+ ready = clock_ns()
+ if ready >= end:
+ break
+ samples.append({
+ "id": frame.id, "timestamp_us": frame.timestamp_us, "ready_ns": ready,
+ "hands": len(frame.hands),
+ "processing_us": (ready - last["received_ns"]) / 1000,
+ })
+ if frame.hands and raw_hand is None:
+ raw_hand = last["raw"]
+ cpu = time.process_time() - cpu_start
+ result = {"summary": summarize(samples, args.duration, cpu, []), "samples": samples}
+ if raw_hand:
+ raw_path = Path(args.output).with_name("raw-hand-frame.json")
+ raw_path.write_text(raw_hand, encoding="utf-8")
+ result["raw_hand_fixture"] = str(raw_path)
+ return result
+
+
+def compare(args):
+ output = Path(args.output or ROOT / ".tools" / "benchmarks" / str(time.time_ns()))
+ output.mkdir(parents=True, exist_ok=True)
+ start = clock_ns() + 5_000_000_000
+ workers = []
+ for mode, python in (
+ ("old-palm", args.old_python), ("old-full", args.old_python), ("new", sys.executable)
+ ):
+ path = output / (mode + ".json")
+ process = subprocess.Popen([
+ str(python), str(Path(__file__).resolve()), "--worker", mode,
+ "--duration", str(args.duration), "--start-ns", str(start), "--output", str(path),
+ ], cwd=ROOT, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
+ workers.append((mode, process, path))
+ data = {}
+ for mode, process, path in workers:
+ stdout, stderr = process.communicate(timeout=args.duration + 20)
+ if process.returncode:
+ raise RuntimeError(mode + ": " + stdout + stderr)
+ data[mode] = json.loads(path.read_text(encoding="utf-8"))
+ result = {mode: item["summary"] for mode, item in data.items()}
+ new_samples = {(s["id"], s["timestamp_us"]): s for s in data["new"]["samples"]}
+ for mode in ("old-palm", "old-full"):
+ differences = []
+ hand_differences = []
+ for sample in data[mode]["samples"]:
+ other = new_samples.get((sample["id"], sample["timestamp_us"]))
+ if other is not None:
+ delta_us = (other["ready_ns"] - sample["ready_ns"]) / 1000
+ differences.append(delta_us)
+ if sample["hands"] and other["hands"]:
+ hand_differences.append(delta_us)
+ result["new_ready_minus_" + mode + "_us"] = distribution(differences)
+ result["new_ready_minus_" + mode + "_hand_us"] = distribution(hand_differences)
+ (output / "summary.json").write_text(json.dumps(result, indent=2), encoding="utf-8")
+ print(json.dumps(result, indent=2))
+ print("Results:", output)
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--old-python", type=Path, default=ROOT / ".tools/python38/python.exe")
+ parser.add_argument("--duration", type=float, default=15)
+ parser.add_argument("--output")
+ parser.add_argument("--worker", choices=("old-palm", "old-full", "new"))
+ parser.add_argument("--start-ns", type=int)
+ args = parser.parse_args()
+ if not math.isfinite(args.duration) or args.duration <= 0:
+ parser.error("--duration must be positive and finite")
+ if args.worker:
+ result = new_worker(args) if args.worker == "new" else old_worker(args)
+ Path(args.output).write_text(json.dumps(result), encoding="utf-8")
+ else:
+ compare(args)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/build_runtime_release.py b/tools/build_runtime_release.py
new file mode 100644
index 0000000..32e8818
--- /dev/null
+++ b/tools/build_runtime_release.py
@@ -0,0 +1,102 @@
+"""Build a minimal Windows runtime release archive and its pinned download catalog."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import zipfile
+from pathlib import Path
+
+from source_robotics_leap_motion.delivery import CATALOG_SCHEMA
+from source_robotics_leap_motion.runtime_bundle import (
+ DRIVER_FILES,
+ MANIFEST,
+ file_hash,
+ inspect_runtime,
+)
+
+
+def build(
+ runtime: Path,
+ output: Path,
+ *,
+ drivers: Path | None = None,
+ notice: Path | None = None,
+ base_url: str | None = None,
+) -> dict:
+ report = inspect_runtime(runtime)
+ if not report["redistribution_cleared"]:
+ raise ValueError("The selected runtime manifest does not record redistribution clearance")
+ manifest = json.loads((runtime / MANIFEST).read_text(encoding="utf-8"))
+ origins = {entry["name"]: runtime / entry["name"] for entry in manifest["files"]}
+ if drivers is not None:
+ for name in DRIVER_FILES:
+ origins[name] = drivers / Path(name).name
+ if notice is not None:
+ origins["NOTICE.txt"] = notice
+ for origin in origins.values():
+ if not origin.is_file():
+ raise ValueError(f"Missing release file: {origin}")
+ manifest["files"] = [
+ {"name": name, "bytes": origin.stat().st_size, "sha256": file_hash(origin)}
+ for name, origin in sorted(origins.items())
+ ]
+ output.mkdir(parents=True, exist_ok=True)
+ filename = "source-leap-runtime-windows-x86_64-3.2.1.zip"
+ archive = output / filename
+ temporary = archive.with_suffix(".tmp")
+ with zipfile.ZipFile(
+ temporary, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6
+ ) as bundle:
+ for name, origin in sorted(origins.items()):
+ bundle.write(origin, arcname=name)
+ bundle.writestr(MANIFEST, json.dumps(manifest, indent=2))
+ temporary.replace(archive)
+ descriptor = {
+ "id": "legacy-windows-3.2.1",
+ "url": (base_url.rstrip("/") + "/" + filename) if base_url else None,
+ "bytes": archive.stat().st_size,
+ "sha256": file_hash(archive),
+ "unpacked_bytes": sum(entry["bytes"] for entry in manifest["files"]),
+ }
+ catalog = {
+ "schema": CATALOG_SCHEMA,
+ "version": 1,
+ "runtimes": {"windows-x86_64": descriptor} if base_url else {},
+ }
+ (output / "runtime_catalog.json").write_text(json.dumps(catalog, indent=2), encoding="utf-8")
+ (output / "runtime-descriptor.json").write_text(
+ json.dumps(descriptor, indent=2), encoding="utf-8"
+ )
+ (output / "SHA256SUMS").write_text(
+ descriptor["sha256"] + " " + filename + "\n", encoding="utf-8"
+ )
+ return {"archive": str(archive), **descriptor, "public_url_configured": base_url is not None}
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("runtime", type=Path)
+ parser.add_argument("--output", type=Path, required=True)
+ parser.add_argument("--drivers", type=Path)
+ parser.add_argument("--notice", type=Path)
+ parser.add_argument(
+ "--base-url", help="GitHub Release download URL, ending before the asset filename"
+ )
+ args = parser.parse_args()
+ print(
+ json.dumps(
+ build(
+ args.runtime,
+ args.output,
+ drivers=args.drivers,
+ notice=args.notice,
+ base_url=args.base_url,
+ ),
+ indent=2,
+ )
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/check_release.py b/tools/check_release.py
new file mode 100644
index 0000000..8fe093b
--- /dev/null
+++ b/tools/check_release.py
@@ -0,0 +1,47 @@
+"""Check public runtime availability and the contents of release artifacts."""
+
+from __future__ import annotations
+
+import argparse
+import urllib.request
+import zipfile
+from pathlib import Path
+
+from source_robotics_leap_motion import __version__
+from source_robotics_leap_motion.delivery import _descriptor, load_catalog
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--offline", action="store_true", help="skip public runtime availability check"
+ )
+ parser.add_argument("--dist", type=Path, default=Path("dist"))
+ args = parser.parse_args()
+ catalog = load_catalog()
+ entry = catalog["runtimes"].get("windows-x86_64")
+ if entry is None:
+ raise SystemExit(
+ "Release not configured: run tools/configure_release.py with the repository URL"
+ )
+ _descriptor(entry)
+ if not args.offline:
+ request = urllib.request.Request(entry["url"], method="HEAD")
+ with urllib.request.urlopen(request, timeout=30) as response:
+ if int(response.headers.get("Content-Length", -1)) != entry["bytes"]:
+ raise SystemExit("Public runtime download size differs from the pinned catalog")
+ wheels = list(args.dist.glob(f"source_robotics_leap_motion-{__version__}-*.whl"))
+ if not wheels:
+ raise SystemExit("No built wheel to publish")
+ for wheel in wheels:
+ with zipfile.ZipFile(wheel) as archive:
+ for name in archive.namelist():
+ if Path(name).suffix.lower() in (".exe", ".dll", ".pyd", ".ldat", ".jsonl"):
+ raise SystemExit(f"Unexpected native/private data in Python wheel: {name}")
+ if "source_robotics_leap_motion/runtime_catalog.json" not in archive.namelist():
+ raise SystemExit("Wheel is missing its runtime catalog")
+ print("Release checks passed")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/configure_release.py b/tools/configure_release.py
new file mode 100644
index 0000000..eaeadb0
--- /dev/null
+++ b/tools/configure_release.py
@@ -0,0 +1,54 @@
+"""Pin local runtime release metadata to a concrete GitHub release URL."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import urllib.parse
+from pathlib import Path
+
+from source_robotics_leap_motion.delivery import CATALOG_SCHEMA, _descriptor
+
+
+def configure(repository: str, tag: str, descriptor: Path, output: Path) -> dict:
+ parsed = urllib.parse.urlsplit(repository.rstrip("/"))
+ path = parsed.path.removesuffix(".git")
+ if (
+ parsed.scheme != "https"
+ or parsed.netloc != "github.com"
+ or not re.fullmatch(r"/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", path)
+ or parsed.query
+ or parsed.fragment
+ ):
+ raise ValueError("Use a repository URL such as https://github.com/owner/repository")
+ if not re.fullmatch(r"v[0-9A-Za-z._-]+", tag):
+ raise ValueError("Use a version tag such as v0.1.0b1")
+ entry = json.loads(descriptor.read_text(encoding="utf-8"))
+ entry["url"] = (
+ f"https://github.com{path}/releases/download/{tag}/"
+ "source-leap-runtime-windows-x86_64-3.2.1.zip"
+ )
+ _descriptor(entry)
+ catalog = {"schema": CATALOG_SCHEMA, "version": 1, "runtimes": {"windows-x86_64": entry}}
+ output.parent.mkdir(parents=True, exist_ok=True)
+ output.write_text(json.dumps(catalog, indent=2), encoding="utf-8")
+ return catalog
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("repository")
+ parser.add_argument("--tag", default="v0.1.0b1")
+ parser.add_argument(
+ "--descriptor", type=Path, default=Path("dist/runtime/runtime-descriptor.json")
+ )
+ parser.add_argument(
+ "--output", type=Path, default=Path("src/source_robotics_leap_motion/runtime_catalog.json")
+ )
+ args = parser.parse_args()
+ print(json.dumps(configure(args.repository, args.tag, args.descriptor, args.output), indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/probe_private_runtime.py b/tools/probe_private_runtime.py
new file mode 100644
index 0000000..cef93f8
--- /dev/null
+++ b/tools/probe_private_runtime.py
@@ -0,0 +1,111 @@
+"""Bounded, read-only camera test of a private runtime. Does not control services."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import math
+import os
+import time
+from contextlib import nullcontext
+from pathlib import Path
+
+from source_robotics_leap_motion import LeapError, Recorder, Tracker, TrackingTimeoutError
+from source_robotics_leap_motion.backends.managed import ManagedLegacyBackend
+from source_robotics_leap_motion.runtime_bundle import file_hash
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("directory", type=Path)
+ parser.add_argument("--duration", type=float, default=5)
+ parser.add_argument("--summary", type=Path)
+ parser.add_argument("--record", type=Path)
+ args = parser.parse_args()
+ if not math.isfinite(args.duration) or not 1 <= args.duration <= 15:
+ parser.error("--duration must be between 1 and 15 seconds")
+ system_config = (
+ Path(os.environ.get("ProgramData", "C:/ProgramData")) / "Leap Motion" / "config.json"
+ )
+ before = file_hash(system_config) if system_config.is_file() else None
+ backend = ManagedLegacyBackend(args.directory, connect_timeout=10, reuse_existing=False)
+ report = {
+ "directory": str(args.directory.resolve()),
+ "frames": 0,
+ "hand_frames": 0,
+ "full_skeleton_hands": 0,
+ "arm_samples": 0,
+ }
+ handedness = set()
+ recorder = Recorder(args.record) if args.record else None
+
+ def observe(frame):
+ report["frames"] += 1
+ report["hand_frames"] += bool(frame.hands)
+ for hand in frame.hands:
+ handedness.add(hand.handedness)
+ report["full_skeleton_hands"] += len(hand.fingers) == 5 and all(
+ len(finger.bones) == 4 for finger in hand.fingers
+ )
+ report["arm_samples"] += hand.arm is not None
+ if recorder is not None:
+ recorder.write(frame)
+
+ child = None
+ first_arrival = last_arrival = None
+ exit_code = 0
+ started = time.perf_counter()
+ try:
+ with (
+ Tracker(backend=backend, timeout=2) as tracker,
+ recorder if recorder is not None else nullcontext(),
+ ):
+ report.update(backend.details)
+ child = backend._process # Keep the owned handle to verify exit after context cleanup.
+ report["connection_seconds"] = time.perf_counter() - started
+ # The socket can be ready before the camera and model finish loading.
+ startup_deadline = time.perf_counter() + 15
+ while True:
+ remaining = startup_deadline - time.perf_counter()
+ if remaining <= 0:
+ raise TrackingTimeoutError(
+ "Private engine produced no frames during 15s startup"
+ )
+ try:
+ frame = tracker.read(timeout=min(2, remaining))
+ break
+ except TrackingTimeoutError:
+ continue
+ first_arrival = last_arrival = time.perf_counter()
+ report["first_frame_seconds"] = first_arrival - started
+ observe(frame)
+ deadline = time.perf_counter() + args.duration
+ while time.perf_counter() < deadline:
+ frame = tracker.read()
+ last_arrival = time.perf_counter()
+ if first_arrival is None:
+ first_arrival = last_arrival
+ observe(frame)
+ except (LeapError, OSError) as error:
+ report["error"] = str(error)
+ exit_code = 2
+ finally:
+ report["child_exited"] = child.poll() is not None if child is not None else None
+ report["handedness_seen"] = sorted(handedness)
+ if args.record is not None:
+ report["recording"] = str(args.record.resolve())
+ after = file_hash(system_config) if system_config.is_file() else None
+ report["system_config_unchanged"] = before == after if before is not None else None
+ if first_arrival is not None and last_arrival > first_arrival:
+ elapsed = last_arrival - first_arrival
+ report["capture_seconds"] = elapsed
+ report["observed_fps"] = (report["frames"] - 1) / elapsed
+ output = args.summary or args.directory / "state" / "probe-summary.json"
+ output.parent.mkdir(parents=True, exist_ok=True)
+ output.write_text(json.dumps(report, indent=2), encoding="utf-8")
+ print(json.dumps(report, indent=2))
+ return exit_code
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/probe_runtime_lifecycle.py b/tools/probe_runtime_lifecycle.py
new file mode 100644
index 0000000..b3f71aa
--- /dev/null
+++ b/tools/probe_runtime_lifecycle.py
@@ -0,0 +1,90 @@
+"""Exercise private engine sharing, application crashes, and repeated startup."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+from source_robotics_leap_motion import Tracker
+from source_robotics_leap_motion.backends.managed import _port_in_use
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("directory", type=Path)
+ parser.add_argument("--summary", type=Path)
+ parser.add_argument("--crash-worker", action="store_true")
+ args = parser.parse_args()
+ directory = args.directory.resolve()
+ cache = directory.parent.parent
+ os.environ["SOURCE_LEAP_CACHE"] = str(cache)
+ if args.crash_worker:
+ with Tracker(timeout=10) as tracker:
+ tracker.read()
+ print("Worker received a frame; exiting without cleanup", flush=True)
+ os._exit(17)
+ if _port_in_use():
+ raise RuntimeError(
+ "Exclusive lifecycle test requires the installed Leap service to be stopped"
+ )
+ ready = cache / "engine/broker.json"
+ report = {"directory": str(directory), "shared_frames": 0, "restart_cycles": 0}
+
+ def wait_idle():
+ deadline = time.perf_counter() + 8
+ while time.perf_counter() < deadline:
+ if not ready.exists() and not _port_in_use():
+ return
+ time.sleep(0.05)
+ raise RuntimeError("Private engine did not shut down after the last client left")
+
+ first = Tracker(timeout=10)
+ second = Tracker(timeout=10)
+ try:
+ with first, second:
+ assert first.runtime_details["mode"] == "shared-private"
+ assert second.runtime_details["mode"] == "shared-private"
+ first.read()
+ second.read()
+ first.close()
+ for _ in range(30):
+ second.read()
+ report["shared_frames"] += 1
+ report["second_client_survived_first_close"] = True
+ wait_idle()
+ report["last_client_shutdown"] = True
+ for _ in range(2):
+ with Tracker(timeout=10) as tracker:
+ for _frame in range(10):
+ tracker.read()
+ wait_idle()
+ report["restart_cycles"] += 1
+ crash = subprocess.run(
+ [sys.executable, str(Path(__file__).resolve()), str(directory), "--crash-worker"],
+ capture_output=True,
+ text=True,
+ timeout=18,
+ )
+ assert crash.returncode == 17, crash.stdout + crash.stderr
+ wait_idle()
+ report["crashed_application_cleanup"] = True
+ report["passed"] = True
+ except BaseException as error:
+ report["error"] = str(error)
+ report["passed"] = False
+ raise
+ finally:
+ first.close()
+ second.close()
+ if args.summary:
+ args.summary.write_text(json.dumps(report, indent=2), encoding="utf-8")
+ print(json.dumps(report, indent=2))
+
+
+if __name__ == "__main__":
+ main()