From 7c2f78299e7ae9f9c2da88f67f61db70df5c67eb Mon Sep 17 00:00:00 2001
From: Source Robotics Automation The leap motion sdk(v3.2) recompiled for python 3.8.
+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 package is built locally; it has **not been published to PyPI** yet. Runtime URLs
+are pinned to this repository's `v0.1.0b1` release. The GitHub release must be publicly
+available before automatic downloads work for new users. See [release status and
+validation](docs/BETA_VALIDATION.md) and [publication steps](docs/RELEASING.md).
+
+**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..c63ed00
--- /dev/null
+++ b/docs/BETA_VALIDATION.md
@@ -0,0 +1,48 @@
+**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 is currently private and PyPI publication is pending. Do not interpret
+the configured public download URL as an already available public release.
+
+**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
+must be checked after the source branch is pushed; configuration alone is not a pass.
+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 intended public pip 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..fcad3cc
--- /dev/null
+++ b/docs/INSTALLATION_PLATFORMS.md
@@ -0,0 +1,71 @@
+**Installation and platforms - decision notes, 5 November 2026**
+
+The project owner confirms that redistribution is permitted. This is recorded as
+the project's permission basis. The current alpha remains a local prototype: its
+wheel contains Python code, the engine lives separately under `.tools`, and nothing
+has been published to PyPI. Permission does not itself change the built artifacts.
+
+**Intended published experience.** This flow is the target, not the current release:
+
+```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 therefore the initial delivery recommendation. Runtime URLs/hosting,
+checksums, caching, interrupted-download handling, and automatic discovery are not
+implemented yet. Download delivery does not reduce the engine's 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 | Live service reuse and a private 3.2.1 engine verified. Automatic delivery and clean-PC USB setup pending. |
+| Ubuntu 22.04 x86_64/amd64 | Hyperion 6.2.0; native Linux packages | Native loader, lifecycle integration, and real hardware testing pending. |
+| macOS 11+, Intel | Hyperion 6.2.0 Intel download | Native loader, lifecycle integration, and real hardware testing pending. |
+| macOS 11+, Apple Silicon | Hyperion 6.2.0 Apple Silicon download | Native loader, lifecycle integration, and real 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 also currently requires Windows x64. Linux
+and macOS require their own vendor engine/library and process/device integration.
+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 next implementation target is Windows automatic runtime delivery/discovery plus
+a first-use prerequisite check, followed by Linux and macOS native integration.
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 @@
+Leap-Motion-Python-3.8
+
+Just put all of these file in your project folder and it shoud work !
+This version is for Python 3.8 x64
+The file SamplePython38.py is the basic demo rewrote for Python 3.8.
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..8f18d97
--- /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`. It is currently
+private. Publishing a Python wheel while its pinned runtime URL is private would
+leave new users unable to download the engine. Keep publication gated on anonymous
+availability of that runtime asset. A draft GitHub release can be prepared first.
+
+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..c53df95
--- /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..b7a9a4f
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,53 @@
+[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.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()
From d37096f05dc5b04756b29b0ec2d92b902b265bd9 Mon Sep 17 00:00:00 2001
From: Source Robotics Automation
+# Source-Robotics-Leap-Motion
+Leap motion source robotics demo
+
+
diff --git a/pyproject.toml b/pyproject.toml
index b7a9a4f..88f484c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -28,6 +28,12 @@ classifiers = [
[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"