From a8703059403df0c32a8afbeed510cdcc1a37e9dd Mon Sep 17 00:00:00 2001
From: Source robotics <30388414+PCrnjak@users.noreply.github.com>
Date: Thu, 5 Nov 2026 23:54:53 +0100
Subject: [PATCH 1/3] Add configurable Cartesian teleoperation and simulator
lab
---
.github/workflows/ci.yml | 1 +
README.md | 23 +-
docs/TELEOPERATION.md | 141 +++++
examples/custom_robot.py | 48 ++
examples/teleoperation.toml | 30 +
pyproject.toml | 2 +-
src/source_robotics_leap_motion/__init__.py | 20 +-
src/source_robotics_leap_motion/_geometry.py | 90 +++
.../adapters/__init__.py | 7 +
.../adapters/base.py | 15 +
.../adapters/callback.py | 60 ++
.../adapters/simulated.py | 45 ++
src/source_robotics_leap_motion/cli.py | 25 +
src/source_robotics_leap_motion/control.py | 280 ++++++++++
.../control_config.py | 150 +++++
.../control_models.py | 55 ++
src/source_robotics_leap_motion/errors.py | 8 +
src/source_robotics_leap_motion/lab.css | 511 ++++++++++++++++++
src/source_robotics_leap_motion/lab.html | 147 +++++
src/source_robotics_leap_motion/lab.js | 344 ++++++++++++
src/source_robotics_leap_motion/lab.py | 271 ++++++++++
src/source_robotics_leap_motion/mapping.py | 27 +-
.../teleoperation.py | 185 +++++++
src/source_robotics_leap_motion/tracker.py | 5 +
tests/test_control.py | 263 +++++++++
tests/test_control_config.py | 62 +++
tests/test_lab.py | 81 +++
tests/test_mapping.py | 20 +-
tests/test_teleoperation.py | 240 ++++++++
29 files changed, 3144 insertions(+), 12 deletions(-)
create mode 100644 docs/TELEOPERATION.md
create mode 100644 examples/custom_robot.py
create mode 100644 examples/teleoperation.toml
create mode 100644 src/source_robotics_leap_motion/_geometry.py
create mode 100644 src/source_robotics_leap_motion/adapters/__init__.py
create mode 100644 src/source_robotics_leap_motion/adapters/base.py
create mode 100644 src/source_robotics_leap_motion/adapters/callback.py
create mode 100644 src/source_robotics_leap_motion/adapters/simulated.py
create mode 100644 src/source_robotics_leap_motion/control.py
create mode 100644 src/source_robotics_leap_motion/control_config.py
create mode 100644 src/source_robotics_leap_motion/control_models.py
create mode 100644 src/source_robotics_leap_motion/lab.css
create mode 100644 src/source_robotics_leap_motion/lab.html
create mode 100644 src/source_robotics_leap_motion/lab.js
create mode 100644 src/source_robotics_leap_motion/lab.py
create mode 100644 src/source_robotics_leap_motion/teleoperation.py
create mode 100644 tests/test_control.py
create mode 100644 tests/test_control_config.py
create mode 100644 tests/test_lab.py
create mode 100644 tests/test_teleoperation.py
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f4ca6b0..fa827cf 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -28,6 +28,7 @@ jobs:
- run: python -m ruff format --check src tests examples tools/build_runtime_release.py tools/configure_release.py tools/check_release.py tools/probe_private_runtime.py tools/benchmark_latest.py
- run: source-leap --demo --frames 3 --json
- run: source-leap --demo --latest --health --frames 3 --json
+ - run: source-leap teleop --demo --headless --auto-arm --frames 3 --json
build:
runs-on: ubuntu-latest
steps:
diff --git a/README.md b/README.md
index 5c39b3d..a2f516c 100644
--- a/README.md
+++ b/README.md
@@ -17,9 +17,24 @@ require Windows approval. See [release status and validation](docs/BETA_VALIDATI
and [publication steps](docs/RELEASING.md). Linux/macOS currently use an installed
vendor tracking service and remain experimental for hardware use.
-This checkout contains **0.1.0b2 development**, adding latest-frame acquisition and
-tracking health. These additions are available from source; they are not in the
-published 0.1.0b1 package. See [the latest-frame guide](docs/LATEST_FRAMES.md).
+This checkout contains **0.1.0b3 development**, adding configurable Cartesian
+teleoperation, callback integration, and a browser lab with simulated output. It also
+includes b2's latest-frame acquisition and tracking health. These additions are
+available from source; they are not in the published 0.1.0b1 package. See
+[the teleoperation guide](docs/TELEOPERATION.md) and [latest-frame guide](docs/LATEST_FRAMES.md).
+
+Try the simulated robot target with a synthetic hand, or with your Leap controller:
+
+```powershell
+.\.venv\Scripts\source-leap.exe teleop --demo
+.\.venv\Scripts\source-leap.exe teleop
+```
+
+The local browser viewer starts disarmed. Arm to calibrate, clutch to reposition
+your hand, and disarm to hold output. Both commands use a simulated Cartesian arm.
+The callback API accepts your own controller; joint-only arms supply their kinematics
+in the adapter. Configure workspace, speed limits, axes, gains, smoothing, rotation,
+and gripper mapping in [teleoperation.toml](examples/teleoperation.toml).
**Try it in this project's virtual environment.** Python 3.15.2 and the package are
already installed in `.venv`. No activation is necessary:
@@ -42,7 +57,7 @@ python -m pip install .
source-leap --demo
```
-Python **3.10 or newer** is required. The wheel has been installed and tested on
+Python **3.10 or newer** is required. The published b1 wheel was 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`
diff --git a/docs/TELEOPERATION.md b/docs/TELEOPERATION.md
new file mode 100644
index 0000000..c9e1dba
--- /dev/null
+++ b/docs/TELEOPERATION.md
@@ -0,0 +1,141 @@
+**Generic teleoperation — 0.1.0b3 development**
+
+The package now includes relative Cartesian control, a synchronous adapter contract,
+and a browser lab with a simulated output. The lab is bundled with the Python package
+and needs no web framework, numerical toolbox, robot SDK, or JavaScript build step.
+
+```powershell
+.\.venv\Scripts\source-leap.exe teleop --demo
+.\.venv\Scripts\source-leap.exe teleop
+.\.venv\Scripts\source-leap.exe teleop --demo --config examples\teleoperation.toml
+```
+
+The first command uses a synthetic hand. The second uses the Leap controller. Both
+send targets exclusively to `SimulatedArm`, which supplies immediate Cartesian
+feedback. The viewer shows a workspace, tool position/orientation, recent path,
+normalized gripper openness, frame rate, and local tracking age. It is a target
+visualizer, with no robot joint geometry, inverse kinematics, collisions, or dynamics.
+
+Click **Arm & calibrate** with the selected hand visible. Move the hand to move the
+tool target. Click **Clutch** to hold output while repositioning, then **Resume** to
+capture the new hand reference. Holding Space also clutches; Escape disarms. Losing
+focus while holding Space disarms. Hand loss requires explicit rearming. Reconnect
+tracking explicitly after a device/service disconnect, then arm again.
+
+`--no-browser` prints the loopback URL without opening it. `--duration` and `--frames`
+bound a run. `--headless --json` prints lab telemetry; `--auto-arm` explicitly requests
+one initial simulated-arm calibration. It never rearms after loss. `--replay PATH`
+uses recorded timing and does not need the controller. Default CLI bounds and speeds
+belong to the simulation; a custom integration must supply its own configuration.
+
+**API and ownership.** The application owns a latest-mode `Tracker`; `Teleoperator`
+owns the adapter inside its context. Controller and adapter methods run on the calling
+application thread. Tracking acquisition keeps its separate owning worker. No robot
+connection or additional thread is created by import or configuration construction.
+
+```python
+from source_robotics_leap_motion import Pose, TeleoperationConfig, Teleoperator, Tracker, Vector3
+from source_robotics_leap_motion.adapters import SimulatedArm
+
+config = TeleoperationConfig.from_toml("examples/teleoperation.toml")
+robot = SimulatedArm(Pose(Vector3(0.3, 0.0, 0.3)))
+
+with Tracker.demo(mode="latest", max_frame_age=config.max_frame_age) as tracker:
+ with Teleoperator(tracker, robot=robot, config=config) as teleop:
+ teleop.arm()
+ for _ in range(120):
+ teleop.step()
+```
+
+Use `CallbackAdapter(send=..., hold=..., feedback=..., open=..., close=...)` to wrap
+an existing SDK. `send` receives an immutable `PoseTarget`. `hold(reason)` is mandatory
+and must cancel or hold motion through that controller's interface. `feedback` returns
+`RobotFeedback(Pose(...), gripper=...)`. Without feedback, pass an explicit known
+reference to `arm(reference)` and `release_clutch(reference)`. Orientation/gripper
+capabilities default to false and must be declared when implemented. Unsupported
+configured commands are rejected before opening the adapter.
+
+The runnable [callback example](../examples/custom_robot.py) wraps the simulated
+controller through these exact callbacks. Replacing that boundary is the integration
+work for an arm. Cartesian controllers can accept mapped poses directly after unit
+conversion. Joint-only controllers need kinematics and joint limits in their adapter
+or robotics stack. The core implements Cartesian pose output; velocity/joint output
+modes, SDK-specific adapters, and a network receiver are future additions.
+
+**Commands and conventions.** Positions and bounds use metres, linear speed uses
+metres/second, and angular speed uses radians/second. `orientation=None` leaves
+orientation to the controller; `gripper=None` leaves the gripper to the controller.
+Enabled gripper commands and feedback are normalized to [0, 1]; the adapter converts
+them to its own actuator units. Initial gripper feedback/reference is required when
+gripper control is enabled.
+
+Commands include a random calibration session ID, increasing sequence, source frame
+ID/timestamp/clock domain, and `valid_for_s`. A receiver uses its own monotonic receipt
+clock to expire commands. Source timestamps do not synchronize machines. Adapters
+must bound blocking SDK operations; application polling cannot stop a robot after
+the application or network disappears. Implement expiry/hold in the receiving
+controller and retain its own motion/limit enforcement.
+
+**Control behavior.**
+
+| State | Output and transition |
+| --- | --- |
+| `disarmed` | No target commands. Explicit arm calibrates one selected hand against supplied robot feedback/reference. |
+| `active` | Distinct fresh hand frames can produce bounded targets. Arm itself emits no target. |
+| `clutched` | Hold invoked; no target commands. Resume waits for a fresh frame, re-anchors to the supplied held robot reference, and emits nothing on that re-anchor frame. |
+| `tracking_lost` | Hold invoked once on the loss transition. Returning frames do not rearm. |
+| `fault` | Adapter/control error latched. Close and reopen before rearming. |
+| `closed` | Hold and adapter close attempted; tracker ownership stays with the application. |
+
+Loss triggers include missing/ambiguous selected hands, hand identity changes, stale
+local age, local update gaps, non-advancing/regressing source timestamps, source time
+gaps, device changes, and clock-domain changes. Duplicates produce no output and do
+not extend the tracking deadline. Empty-hand frames still mean the camera streams;
+they also cause hand-based control to hold. Adapter exceptions preserve their original
+cause; a failed send attempts hold before surfacing `AdapterError`.
+
+Position mapping is `robot_reference + gain * R * (hand - hand_reference)`, using
+the configured proper axis rotation `R`. Each update clamps the desired position to
+the workspace, smooths toward it, then bounds the norm of the target increment by
+`max_linear_speed * elapsed`. Deadband uses the unsmoothed position error. This
+limits commanded Cartesian targets; it does not model the robot's physical motion.
+
+`mapping.rotation` is an optional normalized x/y/z/w quaternion for arbitrary frame
+alignment. It applies after signed axis assignment; gains apply in robot axes after
+that rotation. The total `R` is used for position and orientation mapping together.
+Its default is identity, preserving existing axis/gain-only configurations.
+
+Optional rotation mapping uses world-frame relative rotation:
+`delta = q_hand * inverse(q_hand_reference)` and
+`q_target = R * delta**rotation_gain * inverse(R) * q_robot_reference`.
+The multiplication convention applies the rightmost rotation first, as documented
+for [rotation composition](https://docs.scipy.org/doc/scipy/reference/generated/scipy.spatial.transform.Rotation.__mul__.html).
+Quaternion smoothing follows the shortest rotation path using
+[spherical interpolation](https://docs.scipy.org/doc/scipy/reference/generated/scipy.spatial.transform.Slerp.html),
+then caps the commanded angular increment. The implementation uses standard-library
+math and has no SciPy dependency. Exponential smoothing uses elapsed local time and
+`alpha = 1 - exp(-elapsed / time_constant)`. Zero time constant disables it.
+
+The output frequency is a maximum, not a periodic-delivery guarantee. Filtering and
+speed history reset on calibration and clutch re-anchoring. Configured hand/clock
+changes and long gaps require a new calibration. The control engine retains one
+accepted frame, one hand anchor, and one command. Latest acquisition has one pending
+frame; the lab retains the latest observed frame and a 240-point browser path.
+
+**Validation.** Tests cover calibration, configured axes/gains, no-output states,
+tracking and identity loss, duplicate deadlines, clutch continuity, workspace/speed
+bounds over a 500-frame trajectory, quaternion composition, angular limits, gripper
+mapping, smoothing, command rate caps, adapter capabilities/failures/cleanup, strict
+configuration, CLI telemetry, and packaged local HTTP assets/actions. Actual robot
+motion and clean-PC driver installation remain separate hardware validation work.
+
+On Windows, the installed b3 wheel passed **179 tests on each of Python 3.10.11,
+3.11.9, 3.12.10, 3.13.17, 3.14.10, and 3.15.2**. Each environment received 30 live
+sequential frames and 30 latest-mode frames. The previously recorded 1,047-frame
+session, containing 50 complete left-hand samples, generated 18 simulated targets
+through a clutch/resume cycle. All targets stayed within the configured workspace
+and speed bounds, and hand loss produced no further commands. Wheel metadata,
+dependency checks, CLI examples, and the bundled viewer assets were also checked.
+Chrome UI checks exercised calibration, target movement, clutch hold, and resume.
+Raw local evidence is in `.tools/compatibility-results-0.1.0b3.json` and
+`.tools/control-recording-b3.json`. No physical robot has been commanded.
diff --git a/examples/custom_robot.py b/examples/custom_robot.py
new file mode 100644
index 0000000..ddc6993
--- /dev/null
+++ b/examples/custom_robot.py
@@ -0,0 +1,48 @@
+"""Runnable callback integration using a simulated controller.
+
+Replace the adapter callbacks with your controller's SDK calls. Convert SI units
+at that boundary, implement a real hold/cancel operation, and enforce command expiry
+in the robot controller. The built-in example never connects to a physical robot.
+"""
+
+import argparse
+import time
+from pathlib import Path
+
+from source_robotics_leap_motion import Pose, TeleoperationConfig, Teleoperator, Tracker
+from source_robotics_leap_motion.adapters import CallbackAdapter, SimulatedArm
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--demo", action="store_true", help="synthetic hand instead of camera")
+ parser.add_argument("--duration", type=float, default=10)
+ args = parser.parse_args()
+ config = TeleoperationConfig.from_toml(Path(__file__).with_name("teleoperation.toml"))
+ center = (config.workspace.minimum + config.workspace.maximum) * 0.5
+ controller = SimulatedArm(Pose(center))
+ robot = CallbackAdapter(
+ send=controller.send,
+ hold=controller.hold,
+ feedback=controller.read_feedback,
+ open=controller.open,
+ close=controller.close,
+ orientation=True,
+ gripper=True,
+ )
+ factory = Tracker.demo if args.demo else Tracker
+ with factory(mode="latest", max_frame_age=config.max_frame_age) as tracker:
+ with Teleoperator(tracker, robot=robot, config=config) as teleop:
+ teleop.arm() # Explicit calibration using fresh hand data and robot feedback.
+ started = time.monotonic()
+ while time.monotonic() - started < args.duration:
+ target = teleop.step()
+ if target is not None:
+ print(target.position)
+ if teleop.status.state == "tracking_lost":
+ print("Output held:", teleop.status.reason)
+ break # Deliberate rearming is required after loss.
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/teleoperation.toml b/examples/teleoperation.toml
new file mode 100644
index 0000000..d612824
--- /dev/null
+++ b/examples/teleoperation.toml
@@ -0,0 +1,30 @@
+# Example bounds for the simulated arm. Supply your own bounds for a real robot.
+[workspace]
+minimum = [0.0, -0.3, 0.0]
+maximum = [0.6, 0.3, 0.6]
+
+[mapping]
+axes = ["-z", "-x", "y"]
+# Optional arbitrary frame rotation after axis assignment; normalized x/y/z/w.
+rotation = [0.0, 0.0, 0.0, 1.0]
+gain = [0.75, 0.75, 0.75]
+
+[teleoperation]
+handedness = "right"
+max_linear_speed = 0.2 # metres/second, norm of Cartesian target movement
+control_orientation = true
+rotation_gain = 1.0
+max_angular_speed = 1.0 # radians/second
+smoothing_time_constant = 0.05 # seconds; zero disables smoothing
+position_deadband = 0.001 # metres
+max_frame_age = 0.1 # seconds since local receipt
+tracking_timeout = 0.15 # seconds between valid updates
+max_output_hz = 60.0 # maximum command frequency, not a fixed-rate guarantee
+
+# Omit this table to disable gripper control.
+[gripper]
+source = "grab" # "grab" or "pinch"
+open_value = 1.0
+closed_value = 0.0
+max_speed = 1.0 # normalized units/second
+deadband = 0.01
diff --git a/pyproject.toml b/pyproject.toml
index 78d8793..050e0dd 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "source-robotics-leap-motion"
-version = "0.1.0b2"
+version = "0.1.0b3"
description = "Modern Leap Motion tracking and configurable robot-arm teleoperation primitives"
readme = "README.md"
requires-python = ">=3.10"
diff --git a/src/source_robotics_leap_motion/__init__.py b/src/source_robotics_leap_motion/__init__.py
index 3895799..5c8e5e3 100644
--- a/src/source_robotics_leap_motion/__init__.py
+++ b/src/source_robotics_leap_motion/__init__.py
@@ -1,38 +1,56 @@
"""Leap Motion tracking for modern Python. Importing never opens hardware."""
+from .control import TeleoperationController, TeleoperationStatus
+from .control_config import GripperMapping, TeleoperationConfig, Workspace
+from .control_models import Pose, PoseTarget, RobotCapabilities, RobotFeedback
from .errors import (
+ AdapterError,
ConnectionError,
LeapError,
RecordingError,
RuntimeNotFoundError,
StaleFrameError,
+ TeleoperationError,
TrackingTimeoutError,
)
from .health import TrackingHealth
from .mapping import Mapping
from .models import Bone, Finger, Frame, Hand, Palm, Quaternion, Vector3
from .recording import Recorder, read_recording
+from .teleoperation import Teleoperator
from .tracker import Tracker
-__version__ = "0.1.0b2"
+__version__ = "0.1.0b3"
__all__ = [
+ "AdapterError",
"Bone",
"ConnectionError",
"Finger",
"Frame",
"Hand",
+ "GripperMapping",
"LeapError",
"Mapping",
"Palm",
+ "Pose",
+ "PoseTarget",
"Quaternion",
"Recorder",
"RecordingError",
"RuntimeNotFoundError",
+ "RobotCapabilities",
+ "RobotFeedback",
"StaleFrameError",
"Tracker",
"TrackingHealth",
"TrackingTimeoutError",
+ "TeleoperationConfig",
+ "TeleoperationController",
+ "TeleoperationError",
+ "TeleoperationStatus",
+ "Teleoperator",
"Vector3",
+ "Workspace",
"read_recording",
]
diff --git a/src/source_robotics_leap_motion/_geometry.py b/src/source_robotics_leap_motion/_geometry.py
new file mode 100644
index 0000000..f8fc48c
--- /dev/null
+++ b/src/source_robotics_leap_motion/_geometry.py
@@ -0,0 +1,90 @@
+"""Small quaternion operations, independent of numerical or robot SDKs."""
+
+import math
+
+from .models import Quaternion, Vector3
+
+
+def rotate(q: Quaternion, v: Vector3) -> Vector3:
+ q = unit(q.x, q.y, q.z, q.w)
+ t = Vector3(q.y * v.z - q.z * v.y, q.z * v.x - q.x * v.z, q.x * v.y - q.y * v.x) * 2
+ return (
+ v + t * q.w + Vector3(q.y * t.z - q.z * t.y, q.z * t.x - q.x * t.z, q.x * t.y - q.y * t.x)
+ )
+
+
+def unit(x: float, y: float, z: float, w: float) -> Quaternion:
+ length = math.sqrt(x * x + y * y + z * z + w * w)
+ return Quaternion(x / length, y / length, z / length, w / length)
+
+
+def inverse(q: Quaternion) -> Quaternion:
+ return unit(-q.x, -q.y, -q.z, q.w)
+
+
+def multiply(a: Quaternion, b: Quaternion) -> Quaternion:
+ return unit(
+ a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y,
+ a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x,
+ a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w,
+ a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z,
+ )
+
+
+def angle(a: Quaternion, b: Quaternion) -> float:
+ a = unit(a.x, a.y, a.z, a.w)
+ b = unit(b.x, b.y, b.z, b.w)
+ dot = abs(a.x * b.x + a.y * b.y + a.z * b.z + a.w * b.w)
+ return 2 * math.acos(min(1.0, dot))
+
+
+def slerp(a: Quaternion, b: Quaternion, fraction: float) -> Quaternion:
+ a = unit(a.x, a.y, a.z, a.w)
+ b = unit(b.x, b.y, b.z, b.w)
+ av = (a.x, a.y, a.z, a.w)
+ bv = (b.x, b.y, b.z, b.w)
+ dot = sum(x * y for x, y in zip(av, bv, strict=True))
+ if dot < 0:
+ bv = tuple(-value for value in bv)
+ dot = -dot
+ if dot > 0.9995:
+ return unit(*(x + fraction * (y - x) for x, y in zip(av, bv, strict=True)))
+ theta = math.acos(min(1.0, dot))
+ left = math.sin((1 - fraction) * theta) / math.sin(theta)
+ right = math.sin(fraction * theta) / math.sin(theta)
+ return unit(*(left * x + right * y for x, y in zip(av, bv, strict=True)))
+
+
+def power(q: Quaternion, gain: float) -> Quaternion:
+ q = unit(q.x, q.y, q.z, q.w)
+ sign = 1 if q.w >= 0 else -1
+ sine = math.sqrt(q.x * q.x + q.y * q.y + q.z * q.z)
+ if sine < 1e-12:
+ return Quaternion()
+ half_angle = math.atan2(sine, abs(q.w)) * gain
+ scale = sign * math.sin(half_angle) / sine
+ return unit(q.x * scale, q.y * scale, q.z * scale, math.cos(half_angle))
+
+
+def axis_rotation(axes: tuple[str, str, str]) -> Quaternion:
+ matrix = [[0.0] * 3 for _ in range(3)]
+ for row, axis in enumerate(axes):
+ matrix[row]["xyz".index(axis.lstrip("-"))] = -1 if axis.startswith("-") else 1
+ trace = sum(matrix[i][i] for i in range(3))
+ if trace > 0:
+ scale = math.sqrt(trace + 1) * 2
+ return unit(
+ (matrix[2][1] - matrix[1][2]) / scale,
+ (matrix[0][2] - matrix[2][0]) / scale,
+ (matrix[1][0] - matrix[0][1]) / scale,
+ scale / 4,
+ )
+ i = max(range(3), key=lambda j: matrix[j][j])
+ j, k = (i + 1) % 3, (i + 2) % 3
+ scale = math.sqrt(1 + matrix[i][i] - matrix[j][j] - matrix[k][k]) * 2
+ values = [0.0] * 4
+ values[i] = scale / 4
+ values[j] = (matrix[j][i] + matrix[i][j]) / scale
+ values[k] = (matrix[k][i] + matrix[i][k]) / scale
+ values[3] = (matrix[k][j] - matrix[j][k]) / scale
+ return unit(*values)
diff --git a/src/source_robotics_leap_motion/adapters/__init__.py b/src/source_robotics_leap_motion/adapters/__init__.py
new file mode 100644
index 0000000..27cf984
--- /dev/null
+++ b/src/source_robotics_leap_motion/adapters/__init__.py
@@ -0,0 +1,7 @@
+"""Robot integration contracts; imports never connect to a controller."""
+
+from .base import RobotAdapter
+from .callback import CallbackAdapter
+from .simulated import SimulatedArm
+
+__all__ = ["CallbackAdapter", "RobotAdapter", "SimulatedArm"]
diff --git a/src/source_robotics_leap_motion/adapters/base.py b/src/source_robotics_leap_motion/adapters/base.py
new file mode 100644
index 0000000..bfadf86
--- /dev/null
+++ b/src/source_robotics_leap_motion/adapters/base.py
@@ -0,0 +1,15 @@
+"""Cartesian adapter contract. Joint-only controllers supply their own kinematics."""
+
+from typing import Protocol
+
+from ..control_models import PoseTarget, RobotCapabilities, RobotFeedback
+
+
+class RobotAdapter(Protocol):
+ capabilities: RobotCapabilities
+
+ def open(self) -> None: ...
+ def read_feedback(self) -> RobotFeedback | None: ...
+ def send(self, target: PoseTarget) -> None: ...
+ def hold(self, reason: str) -> None: ...
+ def close(self) -> None: ...
diff --git a/src/source_robotics_leap_motion/adapters/callback.py b/src/source_robotics_leap_motion/adapters/callback.py
new file mode 100644
index 0000000..4a329f1
--- /dev/null
+++ b/src/source_robotics_leap_motion/adapters/callback.py
@@ -0,0 +1,60 @@
+"""Integrate an existing controller through explicit target and hold callbacks."""
+
+from collections.abc import Callable
+
+from ..control_models import PoseTarget, RobotCapabilities, RobotFeedback
+from ..errors import AdapterError
+
+
+class CallbackAdapter:
+ def __init__(
+ self,
+ send: Callable[[PoseTarget], None],
+ *,
+ hold: Callable[[str], None],
+ feedback: Callable[[], RobotFeedback] | None = None,
+ open: Callable[[], None] | None = None,
+ close: Callable[[], None] | None = None,
+ orientation: bool = False,
+ gripper: bool = False,
+ ) -> None:
+ self.capabilities = RobotCapabilities(
+ orientation=orientation, gripper=gripper, feedback=feedback is not None
+ )
+ self._send, self._hold, self._feedback = send, hold, feedback
+ self._open, self._close = open, close
+ self._opened = False
+
+ def open(self) -> None:
+ if self._opened:
+ raise AdapterError("Callback adapter is already open")
+ # Mark the attempt so close can release a partially opened controller.
+ self._opened = True
+ if self._open is not None:
+ self._open()
+
+ def read_feedback(self) -> RobotFeedback | None:
+ self._require_open()
+ return None if self._feedback is None else self._feedback()
+
+ def send(self, target: PoseTarget) -> None:
+ self._require_open()
+ if target.orientation is not None and not self.capabilities.orientation:
+ raise AdapterError("Adapter does not support orientation commands")
+ if target.gripper is not None and not self.capabilities.gripper:
+ raise AdapterError("Adapter does not support gripper commands")
+ self._send(target)
+
+ def hold(self, reason: str) -> None:
+ self._require_open()
+ self._hold(reason)
+
+ def close(self) -> None:
+ if self._opened:
+ self._opened = False
+ if self._close is not None:
+ self._close()
+
+ def _require_open(self) -> None:
+ if not self._opened:
+ raise AdapterError("Open the callback adapter first")
diff --git a/src/source_robotics_leap_motion/adapters/simulated.py b/src/source_robotics_leap_motion/adapters/simulated.py
new file mode 100644
index 0000000..f6fc711
--- /dev/null
+++ b/src/source_robotics_leap_motion/adapters/simulated.py
@@ -0,0 +1,45 @@
+"""Immediate Cartesian feedback for demos; no joint geometry or physical dynamics."""
+
+from ..control_models import Pose, PoseTarget, RobotCapabilities, RobotFeedback
+from ..errors import AdapterError
+
+
+class SimulatedArm:
+ capabilities = RobotCapabilities(orientation=True, gripper=True, feedback=True)
+
+ def __init__(self, initial_pose: Pose, *, gripper: float = 1.0) -> None:
+ self._feedback = RobotFeedback(initial_pose, gripper)
+ self.last_target: PoseTarget | None = None
+ self.targets_sent = 0
+ self.hold_reason = "not_open"
+ self._opened = False
+
+ def open(self) -> None:
+ if self._opened:
+ raise AdapterError("Simulated arm is already open")
+ self._opened = True
+
+ def read_feedback(self) -> RobotFeedback:
+ self._require_open()
+ return self._feedback
+
+ def send(self, target: PoseTarget) -> None:
+ self._require_open()
+ self._feedback = RobotFeedback(
+ Pose(target.position, target.orientation or self._feedback.pose.orientation),
+ self._feedback.gripper if target.gripper is None else target.gripper,
+ )
+ self.last_target = target
+ self.targets_sent += 1
+ self.hold_reason = ""
+
+ def hold(self, reason: str) -> None:
+ self._require_open()
+ self.hold_reason = reason
+
+ def close(self) -> None:
+ self._opened = False
+
+ def _require_open(self) -> None:
+ if not self._opened:
+ raise AdapterError("Open the simulated arm first")
diff --git a/src/source_robotics_leap_motion/cli.py b/src/source_robotics_leap_motion/cli.py
index f6c0f76..5f87dd8 100644
--- a/src/source_robotics_leap_motion/cli.py
+++ b/src/source_robotics_leap_motion/cli.py
@@ -106,6 +106,26 @@ def _parser() -> argparse.ArgumentParser:
replay.add_argument("--json", action="store_true")
replay.add_argument("--health", action="store_true")
replay.add_argument("--timeout", type=_positive_float, default=5)
+ lab = commands.add_parser("teleop", help="inspect hand control with a simulated Cartesian arm")
+ lab.add_argument("--demo", action="store_true", help="use a synthetic hand")
+ lab.add_argument("--replay", help="use a recorded hand session at recorded speed")
+ lab.add_argument("--config", help="teleoperation TOML with workspace and speed limits")
+ lab.add_argument("--headless", action="store_true", help="run without the browser viewer")
+ lab.add_argument(
+ "--auto-arm", action="store_true", help="automatically calibrate the simulated arm once"
+ )
+ lab.add_argument("--json", action="store_true", help="headless lab telemetry as JSON Lines")
+ lab.add_argument(
+ "--no-browser", action="store_true", help="print the viewer URL without opening it"
+ )
+ lab.add_argument(
+ "--port", type=int, default=0, help="loopback viewer port; zero selects a free port"
+ )
+ lab.add_argument("--duration", type=_positive_float)
+ lab.add_argument("--frames", type=_positive_int)
+ runtime = lab.add_mutually_exclusive_group()
+ runtime.add_argument("--library")
+ runtime.add_argument("--runtime-dir")
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(
@@ -360,6 +380,7 @@ def main(argv: list[str] | None = None) -> int:
"record",
"replay",
"runtime",
+ "teleop",
"--version",
"--help",
"-h",
@@ -375,6 +396,10 @@ def main(argv: list[str] | None = None) -> int:
return _doctor(args)
if args.command == "runtime":
return _runtime(args)
+ if args.command == "teleop":
+ from .lab import run_lab
+
+ return run_lab(args)
return _run(args)
except KeyboardInterrupt:
print("Stopped.", file=sys.stderr)
diff --git a/src/source_robotics_leap_motion/control.py b/src/source_robotics_leap_motion/control.py
new file mode 100644
index 0000000..717fd29
--- /dev/null
+++ b/src/source_robotics_leap_motion/control.py
@@ -0,0 +1,280 @@
+"""Deterministic relative Cartesian control; this module performs no robot I/O."""
+
+from __future__ import annotations
+
+import math
+import time
+import uuid
+from dataclasses import dataclass
+from typing import Literal
+
+from ._geometry import angle, axis_rotation, inverse, multiply, power, slerp
+from .control_config import TeleoperationConfig
+from .control_models import Pose, PoseTarget, RobotFeedback
+from .errors import TeleoperationError
+from .models import Frame, Hand
+
+ControlState = Literal["disarmed", "active", "clutched", "tracking_lost", "fault", "closed"]
+
+
+@dataclass(frozen=True, slots=True)
+class TeleoperationStatus:
+ state: ControlState
+ reason: str
+ session_id: str | None
+ sequence: int
+ hand_id: int | None
+ last_target: PoseTarget | None
+ workspace_limited: bool = False
+ speed_limited: bool = False
+
+
+class TeleoperationController:
+ """Call arm explicitly, then update with fresh frames using one application thread.
+
+ The caller supplies local receipt age. The managed Teleoperator integrates this
+ with Tracker's latest mode. No recovery path automatically rearms the controller.
+ Local `now` values must come from one monotonic clock; source timestamps are only
+ compared with other timestamps in that same source clock domain.
+ """
+
+ def __init__(self, config: TeleoperationConfig) -> None:
+ self.config = config
+ self._state: ControlState = "disarmed"
+ self._reason = "not_armed"
+ self._session: str | None = None
+ self._sequence = 0
+ self._hand: Hand | None = None
+ self._reference: RobotFeedback | None = None
+ self._pose: Pose | None = None
+ self._gripper: float | None = None
+ self._last_target: PoseTarget | None = None
+ self._last_frame: Frame | None = None
+ self._last_valid_at: float | None = None
+ self._last_output_at: float | None = None
+ self._last_now: float | None = None
+ self._resume: RobotFeedback | None = None
+ self._workspace_limited = self._speed_limited = False
+ self._rotation = multiply(config.mapping.rotation, axis_rotation(config.mapping.axes))
+
+ @property
+ def status(self) -> TeleoperationStatus:
+ return TeleoperationStatus(
+ self._state,
+ self._reason,
+ self._session,
+ self._sequence,
+ None if self._hand is None else self._hand.id,
+ self._last_target,
+ self._workspace_limited,
+ self._speed_limited,
+ )
+
+ def _now(self, now: float | None) -> float:
+ value = time.monotonic() if now is None else now
+ if not math.isfinite(value) or value < 0:
+ self.fault("invalid_control_time")
+ raise ValueError("Control time must be finite and non-negative")
+ if self._last_now is not None and value < self._last_now:
+ self.fault("local_clock_regressed")
+ raise TeleoperationError("Control time moved backwards")
+ self._last_now = value
+ return value
+
+ def _select(self, frame: Frame) -> Hand | None:
+ hands = [hand for hand in frame.hands if hand.handedness == self.config.handedness]
+ return hands[0] if len(hands) == 1 else None
+
+ def _check_reference(self, reference: RobotFeedback) -> None:
+ if not isinstance(reference, RobotFeedback):
+ raise TeleoperationError("Supply robot feedback or an explicit RobotFeedback reference")
+ if not self.config.workspace.contains(reference.pose.position):
+ raise TeleoperationError("Robot reference pose is outside the configured workspace")
+ if self.config.gripper is not None and reference.gripper is None:
+ raise TeleoperationError("Gripper control requires an explicit gripper reference")
+
+ def _anchor(self, hand: Hand, reference: RobotFeedback, now: float) -> None:
+ self._hand = hand
+ self._reference = reference
+ self._pose = reference.pose
+ self._gripper = reference.gripper
+ self._last_output_at = now
+ self._workspace_limited = self._speed_limited = False
+
+ def arm(
+ self,
+ frame: Frame,
+ reference: RobotFeedback,
+ *,
+ now: float | None = None,
+ frame_age: float = 0.0,
+ ) -> None:
+ if self._state not in ("disarmed", "tracking_lost"):
+ raise TeleoperationError("Disarm before calibrating and arming a new session")
+ now = self._now(now)
+ self._check_reference(reference)
+ if not math.isfinite(frame_age) or not 0 <= frame_age <= self.config.max_frame_age:
+ raise TeleoperationError("Calibration requires a fresh frame")
+ hand = self._select(frame)
+ if hand is None:
+ raise TeleoperationError(
+ f"Calibration requires exactly one {self.config.handedness} hand"
+ )
+ self._anchor(hand, reference, now)
+ self._last_frame = frame
+ self._last_valid_at = now - frame_age
+ self._last_target = None
+ self._resume = None
+ self._sequence = 0
+ self._session = uuid.uuid4().hex
+ self._state, self._reason = "active", "armed"
+
+ def disarm(self, reason: str = "disarmed") -> None:
+ if self._state in ("fault", "closed"):
+ return
+ self._state, self._reason = "disarmed", reason
+ self._resume = None
+
+ def tracking_lost(self, reason: str = "tracking_lost") -> None:
+ if self._state in ("active", "clutched"):
+ self._state, self._reason = "tracking_lost", reason
+ self._resume = None
+
+ def fault(self, reason: str) -> None:
+ self._state, self._reason = "fault", reason
+ self._resume = None
+
+ def close(self) -> None:
+ self._state, self._reason = "closed", "closed"
+ self._resume = None
+
+ def clutch(self) -> None:
+ if self._state != "active":
+ raise TeleoperationError("Clutch requires an active session")
+ self._state, self._reason = "clutched", "clutch_engaged"
+
+ def release_clutch(self, reference: RobotFeedback) -> None:
+ if self._state != "clutched":
+ raise TeleoperationError("Clutch release requires a clutched session")
+ self._check_reference(reference)
+ self._resume = reference
+ self._reason = "waiting_for_reanchor"
+
+ def update(
+ self,
+ frame: Frame | None,
+ *,
+ now: float | None = None,
+ frame_age: float = 0.0,
+ ) -> PoseTarget | None:
+ now = self._now(now)
+ if self._state not in ("active", "clutched"):
+ return None
+ if (
+ self._last_valid_at is not None
+ and now - self._last_valid_at > self.config.tracking_timeout
+ ):
+ self.tracking_lost("tracking_gap")
+ return None
+ if not math.isfinite(frame_age) or frame_age < 0:
+ self.fault("invalid_frame_age")
+ raise ValueError("Frame age must be finite and non-negative")
+ if frame is None:
+ return None
+ if frame_age > self.config.max_frame_age:
+ self.tracking_lost("stale_frame")
+ return None
+ previous = self._last_frame
+ if previous is not None:
+ if (frame.clock_domain, frame.device_id) != (previous.clock_domain, previous.device_id):
+ self.tracking_lost("source_changed")
+ return None
+ if (frame.id, frame.timestamp_us) == (previous.id, previous.timestamp_us):
+ return None
+ delta = (frame.timestamp_us - previous.timestamp_us) / 1_000_000
+ if delta <= 0 or delta > self.config.tracking_timeout:
+ self.tracking_lost("source_clock_reset" if delta <= 0 else "source_gap")
+ return None
+ hand = self._select(frame)
+ if hand is None:
+ self.tracking_lost("hand_missing")
+ return None
+ if self._hand is None or hand.id != self._hand.id:
+ self.tracking_lost("hand_changed")
+ return None
+ self._last_frame = frame
+ self._last_valid_at = now - frame_age
+ if self._state == "clutched":
+ if self._resume is not None:
+ self._anchor(hand, self._resume, now)
+ self._resume = None
+ self._state, self._reason = "active", "clutch_reanchored"
+ return None
+ if now - self._last_output_at + 1e-12 < 1 / self.config.max_output_hz:
+ return None
+ try:
+ return self._target(frame, hand, now)
+ except Exception:
+ self.fault("invalid_target")
+ raise
+
+ def _target(self, frame: Frame, hand: Hand, now: float) -> PoseTarget:
+ config = self.config
+ dt = now - self._last_output_at
+ alpha = (
+ 1.0
+ if config.smoothing_time_constant == 0
+ else -math.expm1(-dt / config.smoothing_time_constant)
+ )
+ desired = config.mapping.map_position(
+ hand.palm.position,
+ hand_origin=self._hand.palm.position,
+ robot_origin=self._reference.pose.position,
+ )
+ bounded = config.workspace.clamp(desired)
+ self._workspace_limited = desired != bounded
+ delta = (bounded - self._pose.position) * alpha
+ if (bounded - self._pose.position).norm <= config.position_deadband:
+ delta = delta * 0
+ limit = config.max_linear_speed * dt
+ self._speed_limited = delta.norm > limit
+ if delta.norm > limit:
+ delta = delta * (limit / delta.norm)
+ position = config.workspace.clamp(self._pose.position + delta)
+ orientation = self._pose.orientation
+ if config.control_orientation:
+ camera_delta = multiply(hand.palm.orientation, inverse(self._hand.palm.orientation))
+ robot_delta = multiply(multiply(self._rotation, camera_delta), inverse(self._rotation))
+ desired_orientation = multiply(
+ power(robot_delta, config.rotation_gain), self._reference.pose.orientation
+ )
+ smoothed = slerp(orientation, desired_orientation, alpha)
+ distance = angle(orientation, smoothed)
+ fraction = min(1.0, config.max_angular_speed * dt / distance) if distance > 0 else 1.0
+ self._speed_limited |= fraction < 1
+ orientation = slerp(orientation, smoothed, fraction)
+ if config.gripper is not None:
+ strength = (
+ hand.grab_strength if config.gripper.source == "grab" else hand.pinch_strength
+ )
+ goal = config.gripper.map(strength)
+ delta_grip = (goal - self._gripper) * alpha
+ if abs(goal - self._gripper) <= config.gripper.deadband:
+ delta_grip = 0.0
+ step = config.gripper.max_speed * dt
+ self._gripper += max(-step, min(step, delta_grip))
+ self._pose = Pose(position, orientation)
+ self._sequence += 1
+ self._last_output_at = now
+ self._last_target = PoseTarget(
+ position,
+ orientation if config.control_orientation else None,
+ self._gripper if config.gripper is not None else None,
+ self._session,
+ self._sequence,
+ frame.id,
+ frame.timestamp_us,
+ frame.clock_domain,
+ min(config.max_frame_age, config.tracking_timeout),
+ )
+ return self._last_target
diff --git a/src/source_robotics_leap_motion/control_config.py b/src/source_robotics_leap_motion/control_config.py
new file mode 100644
index 0000000..005748b
--- /dev/null
+++ b/src/source_robotics_leap_motion/control_config.py
@@ -0,0 +1,150 @@
+"""Strict, dependency-free teleoperation settings in SI units."""
+
+from __future__ import annotations
+
+import math
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Literal
+
+from ._compat import tomllib
+from .mapping import Mapping, _parse_rotation
+from .models import Vector3
+
+
+def _number(value: float, name: str, *, zero: bool = False) -> None:
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
+ raise ValueError(f"{name} must be a finite number")
+ if value < 0 or (value == 0 and not zero):
+ raise ValueError(f"{name} must be {'non-negative' if zero else 'positive'}")
+
+
+@dataclass(frozen=True, slots=True)
+class Workspace:
+ minimum: Vector3
+ maximum: Vector3
+
+ def __post_init__(self) -> None:
+ if any(a > b for a, b in zip(self.minimum, self.maximum, strict=True)):
+ raise ValueError("Workspace minimum must not exceed maximum")
+
+ def clamp(self, position: Vector3) -> Vector3:
+ return Vector3(
+ *(
+ max(lo, min(hi, x))
+ for x, lo, hi in zip(position, self.minimum, self.maximum, strict=True)
+ )
+ )
+
+ def contains(self, position: Vector3) -> bool:
+ return self.clamp(position) == position
+
+
+@dataclass(frozen=True, slots=True)
+class GripperMapping:
+ source: Literal["grab", "pinch"] = "grab"
+ open_value: float = 1.0
+ closed_value: float = 0.0
+ max_speed: float = 1.0
+ deadband: float = 0.01
+
+ def __post_init__(self) -> None:
+ if self.source not in ("grab", "pinch"):
+ raise ValueError("Gripper source must be 'grab' or 'pinch'")
+ for name in ("open_value", "closed_value", "deadband"):
+ value = getattr(self, name)
+ _number(value, name, zero=True)
+ if value > 1:
+ raise ValueError(f"{name} must be in [0, 1]")
+ _number(self.max_speed, "Gripper max_speed")
+
+ def map(self, strength: float) -> float:
+ return self.open_value + (self.closed_value - self.open_value) * strength
+
+
+@dataclass(frozen=True, slots=True)
+class TeleoperationConfig:
+ workspace: Workspace
+ max_linear_speed: float
+ mapping: Mapping = Mapping()
+ handedness: Literal["left", "right"] = "right"
+ smoothing_time_constant: float = 0.05
+ position_deadband: float = 0.001
+ control_orientation: bool = False
+ rotation_gain: float = 1.0
+ max_angular_speed: float = 1.0
+ gripper: GripperMapping | None = None
+ max_frame_age: float = 0.1
+ tracking_timeout: float = 0.15
+ max_output_hz: float = 60.0
+
+ def __post_init__(self) -> None:
+ if self.handedness not in ("left", "right"):
+ raise ValueError("Handedness must be 'left' or 'right'")
+ if not isinstance(self.control_orientation, bool):
+ raise ValueError("control_orientation must be true or false")
+ for name in (
+ "max_linear_speed",
+ "max_angular_speed",
+ "max_frame_age",
+ "tracking_timeout",
+ "max_output_hz",
+ ):
+ _number(getattr(self, name), name)
+ for name in ("smoothing_time_constant", "position_deadband", "rotation_gain"):
+ _number(getattr(self, name), name, zero=True)
+ if self.max_output_hz > 1000:
+ raise ValueError("max_output_hz must not exceed 1000")
+ if self.max_frame_age > self.tracking_timeout:
+ raise ValueError("max_frame_age must not exceed tracking_timeout")
+
+ @classmethod
+ def from_toml(cls, path: str | Path) -> TeleoperationConfig:
+ with Path(path).open("rb") as stream:
+ document = tomllib.load(stream)
+ tables = {"mapping", "workspace", "teleoperation", "gripper"}
+ if set(document) - tables:
+ raise ValueError("Unknown configuration tables: " + ", ".join(set(document) - tables))
+ if not {"workspace", "teleoperation"} <= set(document):
+ raise ValueError("Configuration requires [workspace] and [teleoperation] tables")
+ for name, table in document.items():
+ if not isinstance(table, dict):
+ raise ValueError(f"{name} must be a table")
+ workspace = document["workspace"]
+ if set(workspace) != {"minimum", "maximum"}:
+ raise ValueError("Workspace requires exactly minimum and maximum")
+ mapping = document.get("mapping", {})
+ if set(mapping) - {"axes", "gain", "rotation"}:
+ raise ValueError("Unknown mapping settings")
+ axes = mapping.get("axes", ["x", "y", "z"])
+ if not isinstance(axes, list) or any(not isinstance(axis, str) for axis in axes):
+ raise ValueError("Mapping axes must be an array of axis names")
+ rotation = _parse_rotation(mapping.get("rotation", [0, 0, 0, 1]))
+ values = dict(document["teleoperation"])
+ if "max_linear_speed" not in values:
+ raise ValueError("teleoperation.max_linear_speed is required")
+ allowed = set(cls.__dataclass_fields__) - {"workspace", "mapping", "gripper"}
+ if set(values) - allowed:
+ raise ValueError("Unknown teleoperation settings: " + ", ".join(set(values) - allowed))
+ gripper = document.get("gripper")
+ if gripper is not None and set(gripper) - set(GripperMapping.__dataclass_fields__):
+ raise ValueError("Unknown gripper settings")
+ return cls(
+ workspace=Workspace(_vector(workspace["minimum"]), _vector(workspace["maximum"])),
+ mapping=Mapping(tuple(axes), _vector(mapping.get("gain", [1, 1, 1])), rotation),
+ gripper=None if gripper is None else GripperMapping(**gripper),
+ **values,
+ )
+
+
+def _vector(values) -> Vector3:
+ if not isinstance(values, list) or len(values) != 3:
+ raise ValueError("Configuration vectors require three numbers")
+ for value in values:
+ if (
+ isinstance(value, bool)
+ or not isinstance(value, (int, float))
+ or not math.isfinite(value)
+ ):
+ raise ValueError("Configuration vectors require finite numbers")
+ return Vector3(*values)
diff --git a/src/source_robotics_leap_motion/control_models.py b/src/source_robotics_leap_motion/control_models.py
new file mode 100644
index 0000000..83ad609
--- /dev/null
+++ b/src/source_robotics_leap_motion/control_models.py
@@ -0,0 +1,55 @@
+"""Owned Cartesian commands and explicit robot integration capabilities."""
+
+from dataclasses import dataclass
+
+from .models import Quaternion, Vector3
+
+
+@dataclass(frozen=True, slots=True)
+class Pose:
+ position: Vector3
+ orientation: Quaternion = Quaternion()
+
+ def __post_init__(self) -> None:
+ if not isinstance(self.position, Vector3) or not isinstance(self.orientation, Quaternion):
+ raise ValueError("A Pose requires a Vector3 position and Quaternion orientation")
+
+
+@dataclass(frozen=True, slots=True)
+class RobotFeedback:
+ pose: Pose
+ gripper: float | None = None
+
+ def __post_init__(self) -> None:
+ if not isinstance(self.pose, Pose):
+ raise ValueError("Robot feedback must contain a Pose")
+ if self.gripper is not None and not 0 <= self.gripper <= 1:
+ raise ValueError("Gripper feedback must be normalized to [0, 1]")
+
+
+@dataclass(frozen=True, slots=True)
+class PoseTarget:
+ """A Cartesian command; None leaves orientation/gripper under robot control.
+
+ Positions are metres, orientation is x/y/z/w in the configured robot frame,
+ and gripper values are normalized. Receivers expire commands using their own
+ monotonic receipt time and ``valid_for_s``, never the source clock.
+ """
+
+ position: Vector3
+ orientation: Quaternion | None
+ gripper: float | None
+ session_id: str
+ sequence: int
+ source_frame_id: int
+ source_timestamp_us: int
+ clock_domain: str
+ valid_for_s: float
+
+
+@dataclass(frozen=True, slots=True)
+class RobotCapabilities:
+ cartesian_position: bool = True
+ orientation: bool = False
+ gripper: bool = False
+ feedback: bool = False
diff --git a/src/source_robotics_leap_motion/errors.py b/src/source_robotics_leap_motion/errors.py
index dfbbbff..01d9378 100644
--- a/src/source_robotics_leap_motion/errors.py
+++ b/src/source_robotics_leap_motion/errors.py
@@ -23,3 +23,11 @@ class StaleFrameError(TrackingTimeoutError):
class RecordingError(LeapError):
"""A recording is invalid, unsupported, or truncated."""
+
+
+class TeleoperationError(LeapError):
+ """Invalid control state, calibration, or robot integration."""
+
+
+class AdapterError(TeleoperationError):
+ """Robot adapter operation failed; inspect the chained original exception."""
diff --git a/src/source_robotics_leap_motion/lab.css b/src/source_robotics_leap_motion/lab.css
new file mode 100644
index 0000000..cbb42cd
--- /dev/null
+++ b/src/source_robotics_leap_motion/lab.css
@@ -0,0 +1,511 @@
+:root {
+ font-family:
+ Inter,
+ Segoe UI,
+ Arial,
+ sans-serif;
+ color: #172b36;
+ background: #eef2f4;
+ font-synthesis: none;
+ --green: #177d67;
+ --line: #dde5e9;
+}
+* {
+ box-sizing: border-box;
+}
+body {
+ margin: 0;
+}
+button {
+ font: inherit;
+ cursor: pointer;
+}
+header {
+ height: 80px;
+ background: #fff;
+ border-bottom: 1px solid var(--line);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 38px;
+}
+.brand {
+ font-weight: 800;
+ font-size: 15px;
+ letter-spacing: 2px;
+ display: flex;
+ align-items: center;
+ gap: 13px;
+}
+.mark {
+ width: 30px;
+ height: 30px;
+ background: var(--green);
+ border-radius: 9px;
+ color: white;
+ display: grid;
+ place-items: center;
+ font-size: 19px;
+}
+.tag {
+ font-size: 11px;
+ letter-spacing: 1.4px;
+ background: #eaf4f0;
+ border: 1px solid #cee6dd;
+ color: #176e57;
+ border-radius: 30px;
+ padding: 9px 13px;
+ font-weight: 700;
+}
+main {
+ max-width: 1490px;
+ padding: 35px 38px;
+ margin: auto;
+}
+.heading {
+ display: flex;
+ align-items: end;
+ justify-content: space-between;
+ margin-bottom: 26px;
+}
+.eyebrow {
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 2px;
+ color: #6a7c88;
+ margin-bottom: 10px;
+}
+h1 {
+ font-size: 33px;
+ font-weight: 650;
+ letter-spacing: -1px;
+ margin: 0;
+}
+h2 {
+ font-size: 15px;
+ margin: 0;
+}
+.subtitle {
+ color: #74838b;
+ font-size: 14px;
+ margin: 10px 0 0;
+}
+.connection {
+ font-size: 12px;
+ color: #6b7f87;
+}
+.dot {
+ display: inline-block;
+ background: #94a7ad;
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ margin-right: 7px;
+}
+.dot.on {
+ background: #1b9b77;
+ box-shadow: 0 0 0 4px #e4f4ec;
+}
+.layout {
+ display: grid;
+ grid-template-columns: 305px minmax(400px, 1fr);
+ gap: 23px;
+}
+.card {
+ background: white;
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ box-shadow: 0 3px 12px #223a4a03;
+}
+.side {
+ display: grid;
+ gap: 17px;
+ align-content: start;
+}
+.section {
+ padding: 23px;
+}
+.section-label {
+ font-size: 10px;
+ letter-spacing: 1.7px;
+ font-weight: 750;
+ color: #7b8c95;
+ margin-bottom: 16px;
+}
+.source {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 20px;
+}
+.source-icon {
+ background: #eaf3f0;
+ border-radius: 11px;
+ width: 42px;
+ height: 42px;
+ display: grid;
+ place-items: center;
+ color: #177d67;
+ font-size: 22px;
+}
+.source-name {
+ font-size: 14px;
+ font-weight: 650;
+ text-transform: capitalize;
+}
+.source-note {
+ font-size: 12px;
+ color: #819099;
+ margin-top: 4px;
+}
+.stats {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+}
+.stats strong {
+ font-size: 24px;
+ font-weight: 600;
+ letter-spacing: -1px;
+}
+.stats span {
+ font-size: 11px;
+ color: #7c8d97;
+ display: block;
+ margin-top: 5px;
+}
+.unit {
+ font-size: 12px;
+ color: #8b99a0;
+ margin-left: 3px;
+}
+.state-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+.badge {
+ font-size: 10px;
+ letter-spacing: 1px;
+ border-radius: 6px;
+ padding: 7px 9px;
+ background: #edf1f4;
+ color: #667c87;
+ font-weight: 750;
+}
+.badge.active {
+ background: #dcf4e7;
+ color: #177651;
+}
+.badge.clutched {
+ background: #fff0d2;
+ color: #9d6c14;
+}
+.badge.tracking_lost,
+.badge.fault {
+ background: #ffe9e6;
+ color: #a54638;
+}
+#reason {
+ line-height: 1.6;
+ color: #7a8b94;
+ font-size: 12px;
+ min-height: 38px;
+ margin: 14px 0 19px;
+}
+.controls {
+ display: grid;
+ gap: 9px;
+}
+.controls button {
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #fff;
+ padding: 12px;
+ font-size: 12px;
+ font-weight: 650;
+ color: #375362;
+}
+.controls button.primary {
+ background: var(--green);
+ color: white;
+ border-color: var(--green);
+}
+.controls button.stop {
+ color: #9b514d;
+ background: #fffafa;
+ border-color: #f0dedb;
+}
+.controls button:disabled {
+ opacity: 0.4;
+ cursor: default;
+}
+.controls button:hover:not(:disabled) {
+ filter: brightness(0.96);
+}
+.keyhint {
+ font-size: 10px;
+ color: #98a5ac;
+ text-align: center;
+ margin-top: 14px;
+}
+kbd {
+ font: 10px inherit;
+ border: 1px solid #dce4e8;
+ border-radius: 4px;
+ padding: 2px 4px;
+ background: #f8fafb;
+}
+.mapping-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 0;
+ font-size: 12px;
+ border-bottom: 1px solid #f0f3f5;
+}
+.mapping-row:last-child {
+ border: 0;
+}
+.axis {
+ font-family: Consolas, monospace;
+ color: #637b89;
+}
+.gain {
+ font-variant-numeric: tabular-nums;
+ background: #f2f6f7;
+ border-radius: 5px;
+ padding: 4px 7px;
+ color: #4b636e;
+}
+.mapping-note {
+ font-size: 11px;
+ line-height: 1.6;
+ color: #95a1a8;
+ margin: 13px 0 0;
+}
+.stage {
+ overflow: hidden;
+}
+.stage-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 23px 27px;
+ border-bottom: 1px solid #ecf0f2;
+}
+.stage-head span {
+ font-size: 11px;
+ color: #82929a;
+}
+.legend {
+ display: flex;
+ gap: 17px;
+}
+.legend span {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+.legend i {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: #1c8a70;
+}
+.legend .trail {
+ background: #bdd4cb;
+}
+.canvas-wrap {
+ height: 490px;
+ position: relative;
+ background: linear-gradient(150deg, #fbfdfd, #f3f7f8);
+}
+canvas {
+ width: 100%;
+ height: 100%;
+ display: block;
+ cursor: grab;
+}
+.view-label {
+ position: absolute;
+ top: 22px;
+ left: 25px;
+ font-size: 10px;
+ letter-spacing: 1px;
+ color: #99a8b0;
+ pointer-events: none;
+}
+.view-help {
+ position: absolute;
+ bottom: 17px;
+ right: 23px;
+ font-size: 10px;
+ color: #9aa9af;
+ pointer-events: none;
+}
+.coordinates {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 20px;
+ padding: 25px 28px;
+ border-top: 1px solid #e9eff1;
+}
+.coord-label {
+ font-size: 10px;
+ letter-spacing: 1px;
+ color: #8b9aa4;
+ margin-bottom: 10px;
+}
+.coord-value {
+ font-family: Consolas, monospace;
+ font-size: 28px;
+ color: #315365;
+ letter-spacing: -1px;
+}
+.coord-value small {
+ font:
+ 11px Segoe UI,
+ sans-serif;
+ color: #98a7ae;
+ margin-left: 4px;
+}
+.bottom {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 20px;
+ margin-top: 20px;
+}
+.mini {
+ padding: 19px 24px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+.mini p {
+ margin: 5px 0 0;
+ font-size: 11px;
+ color: #8b9ba2;
+}
+.grip-track {
+ height: 6px;
+ border-radius: 8px;
+ background: #e8efed;
+ width: 115px;
+ overflow: hidden;
+}
+.grip-fill {
+ height: 100%;
+ background: #288a71;
+ width: 100%;
+ transition: width 0.1s;
+}
+.metric {
+ font-variant-numeric: tabular-nums;
+ color: #416675;
+ font-size: 23px;
+}
+.footer {
+ margin-top: 22px;
+ font-size: 11px;
+ color: #8b9aa4;
+ display: flex;
+ justify-content: space-between;
+ line-height: 1.7;
+}
+.message {
+ color: #ac6650;
+ font-size: 12px;
+ min-height: 17px;
+ margin-top: 12px;
+}
+#reconnect {
+ border: 0;
+ background: none;
+ color: #477c70;
+ font-size: 11px;
+ text-decoration: underline;
+ padding: 0;
+ margin-top: 10px;
+}
+.limit {
+ color: #b47b24 !important;
+}
+.empty {
+ opacity: 0.5;
+}
+@media (max-width: 1000px) {
+ main {
+ padding: 25px 20px;
+ }
+ header {
+ padding: 0 22px;
+ }
+ .layout {
+ grid-template-columns: 255px minmax(300px, 1fr);
+ }
+ .section {
+ padding: 19px;
+ }
+ .canvas-wrap {
+ height: 460px;
+ }
+}
+@media (max-width: 720px) {
+ .layout {
+ display: flex;
+ flex-direction: column-reverse;
+ }
+ .side {
+ grid-template-columns: 1fr 1fr;
+ }
+ .side .card:last-child {
+ grid-column: 1/-1;
+ }
+ .heading {
+ align-items: start;
+ gap: 12px;
+ }
+ h1 {
+ font-size: 27px;
+ }
+ .connection {
+ max-width: 100px;
+ line-height: 1.8;
+ }
+ .canvas-wrap {
+ height: 340px;
+ }
+ .coordinates {
+ padding: 20px;
+ gap: 8px;
+ }
+ .coord-value {
+ font-size: 23px;
+ }
+ .bottom {
+ gap: 10px;
+ }
+ .mini {
+ padding: 16px;
+ }
+ .grip-track {
+ width: 65px;
+ }
+ .footer {
+ gap: 20px;
+ }
+ .tag {
+ font-size: 9px;
+ padding: 8px;
+ }
+ .brand {
+ font-size: 12px;
+ letter-spacing: 1px;
+ }
+ .legend {
+ gap: 10px;
+ }
+}
diff --git a/src/source_robotics_leap_motion/lab.html b/src/source_robotics_leap_motion/lab.html
new file mode 100644
index 0000000..856860c
--- /dev/null
+++ b/src/source_robotics_leap_motion/lab.html
@@ -0,0 +1,147 @@
+
+
+
+
+
+
+ Teleoperation Lab · Source Robotics
+
+
+
+
+
+
+
+
HAND TRACKING / CARTESIAN CONTROL
+
Teleoperation lab
+
Calibrate your reach. Shape the movement.
+
+
+ Connecting
+
+
+
+
+
+
+
+
Robot workspace
+
+ Tool targetPath
+
+
+
+
CARTESIAN TARGET / ORBIT VIEW
+
+
Drag to orbit · scroll to zoom
+
+
+
+
+
+
+
Gripper
+
Normalized openness
+
+
+
+
+
+
Targets delivered
+
Waiting for calibration
+
+ 0
+
+
+
+
+
+
+
+
+
diff --git a/src/source_robotics_leap_motion/lab.js b/src/source_robotics_leap_motion/lab.js
new file mode 100644
index 0000000..2285c68
--- /dev/null
+++ b/src/source_robotics_leap_motion/lab.js
@@ -0,0 +1,344 @@
+const token = document.querySelector("meta[name=local-session]").content,
+ $ = (id) => document.getElementById(id);
+let data = null,
+ online = false,
+ busy = false,
+ requestError = "",
+ keyClutch = false,
+ actionChain = Promise.resolve(),
+ trail = [],
+ lastSequence = -1;
+const reasons = {
+ not_armed: "Place your hand, then arm to capture a reference.",
+ armed: "Hand movement is mapped from your calibrated reference.",
+ clutch_engaged: "Target held. Reposition your hand, then resume.",
+ waiting_for_reanchor:
+ "Waiting for a fresh hand frame to capture a new reference.",
+ clutch_reanchored: "Reference updated. Movement resumes from the held pose.",
+ hand_missing: "Hand tracking lost. Place your hand and arm again.",
+ hand_changed: "A different hand was detected. Arm again to calibrate.",
+ tracking_gap: "Tracking paused. Arm again when frames are fresh.",
+ stale_frame: "Frames expired. Fresh tracking and rearming are required.",
+ disconnected: "Controller disconnected. Reconnect tracking, then arm again.",
+ source_gap: "Source timing changed. Arm again to calibrate.",
+ source_clock_reset: "Source clock restarted. Arm again to calibrate.",
+ source_changed: "Tracking source changed. Arm again to calibrate.",
+ disarmed: "Session disarmed. Arm to capture a fresh reference.",
+};
+function action(name) {
+ actionChain = actionChain.then(async () => {
+ try {
+ const r = await fetch("/action", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Source-Leap-Token": token,
+ },
+ body: JSON.stringify({ action: name }),
+ });
+ if (!r.ok) throw Error((await r.json()).error);
+ requestError = "";
+ $("message").textContent = "";
+ } catch (e) {
+ requestError = e.message;
+ $("message").textContent = e.message;
+ }
+ });
+ return actionChain;
+}
+function toggleClutch() {
+ if (data) action(data.state === "clutched" ? "resume" : "clutch");
+}
+document.addEventListener("keydown", (e) => {
+ if (e.code === "Escape") {
+ action("disarm");
+ keyClutch = false;
+ }
+ if (e.code === "Space") {
+ e.preventDefault();
+ if (online && !e.repeat && data?.state === "active") {
+ keyClutch = true;
+ action("clutch");
+ }
+ }
+});
+document.addEventListener("keyup", (e) => {
+ if (e.code === "Space" && keyClutch) {
+ e.preventDefault();
+ keyClutch = false;
+ action("resume");
+ }
+});
+window.addEventListener("blur", () => {
+ if (keyClutch) {
+ keyClutch = false;
+ action("disarm");
+ }
+});
+async function poll() {
+ if (busy) return;
+ busy = true;
+ try {
+ const response = await fetch("/state", { cache: "no-store" });
+ if (!response.ok) throw Error("Session unavailable");
+ const next = await response.json();
+ if (!next.robot) return;
+ data = next;
+ online = true;
+ update();
+ } catch (e) {
+ online = false;
+ $("connection").textContent = "Session ended";
+ $("connection-dot").className = "dot";
+ $("state").textContent = "OFFLINE";
+ $("state").className = "badge";
+ $("reason").textContent =
+ "The lab connection ended. Restart the command and reload this page.";
+ $("arm").disabled = $("clutch").disabled = $("disarm").disabled = true;
+ } finally {
+ busy = false;
+ }
+}
+function update() {
+ const d = data,
+ t = d.tracking,
+ c = d.control,
+ p = d.robot.pose.position;
+ $("connection").textContent = "Local session";
+ $("connection-dot").className = "dot on";
+ $("source").textContent = d.source;
+ $("hand-label").textContent = d.hand
+ ? `${d.handedness} hand · #${d.hand.id}`
+ : `No ${d.handedness} hand visible`;
+ $("fps").textContent = t.received_fps.toFixed(0);
+ $("age").textContent =
+ t.last_frame_age_s === null ? "—" : (t.last_frame_age_s * 1000).toFixed(0);
+ $("state").textContent = d.state.replaceAll("_", " ").toUpperCase();
+ $("state").className = "badge " + d.state;
+ $("reason").textContent = reasons[d.reason] || d.reason.replaceAll("_", " ");
+ $("message").textContent = d.message || requestError;
+ $("arm").disabled =
+ !["disarmed", "tracking_lost"].includes(d.state) ||
+ !d.hand ||
+ t.status !== "streaming";
+ $("clutch").disabled = !["active", "clutched"].includes(d.state);
+ $("clutch").textContent =
+ d.state === "clutched"
+ ? "Resume · re-anchor hand"
+ : "Clutch · reposition hand";
+ $("disarm").disabled = ["closed", "fault"].includes(d.state);
+ for (const a of ["x", "y", "z"])
+ $(a).innerHTML = p[a].toFixed(3) + "m";
+ $("sequence").textContent = c.sequence;
+ const limited = c.workspace_limited || c.speed_limited;
+ $("limit").textContent = c.workspace_limited
+ ? "Workspace limit applied"
+ : c.speed_limited
+ ? "Speed limit applied"
+ : d.state === "active"
+ ? "Following calibrated hand"
+ : "Output held";
+ $("limit").className = limited ? "limit" : "";
+ $("grip").style.width = (d.robot.gripper ?? 0) * 100 + "%";
+ $("grip-label").textContent =
+ d.robot.gripper === null
+ ? "Not controlled"
+ : Math.round(d.robot.gripper * 100) + "% open";
+ $("mapping").replaceChildren();
+ const rotation = d.config.mapping.rotation;
+ $("frame-rotation").textContent =
+ !rotation ||
+ Math.abs(rotation.x) + Math.abs(rotation.y) + Math.abs(rotation.z) < 1e-9
+ ? "Frame rotation: identity"
+ : "Frame rotation (xyzw): " +
+ [rotation.x, rotation.y, rotation.z, rotation.w]
+ .map((value) => value.toFixed(3))
+ .join(", ");
+ d.config.mapping.axes.forEach((axis, i) => {
+ const row = document.createElement("div");
+ row.className = "mapping-row";
+ const label = document.createElement("span");
+ label.className = "axis";
+ label.textContent = axis.toUpperCase() + " → " + ["X", "Y", "Z"][i];
+ const gain = document.createElement("span");
+ gain.className = "gain";
+ gain.textContent = Object.values(d.config.mapping.gain)[i].toFixed(2) + "×";
+ row.append(label, gain);
+ $("mapping").append(row);
+ });
+ if (c.sequence !== lastSequence) {
+ lastSequence = c.sequence;
+ if (c.sequence === 0) trail = [];
+ trail.push({ ...p });
+ if (trail.length > 240) trail.shift();
+ }
+}
+const canvas = $("scene"),
+ ctx = canvas.getContext("2d");
+let yaw = -0.75,
+ pitch = 0.55,
+ zoom = 1,
+ drag = null;
+canvas.addEventListener("pointerdown", (e) => {
+ drag = { x: e.clientX, y: e.clientY };
+ canvas.setPointerCapture(e.pointerId);
+});
+canvas.addEventListener("pointermove", (e) => {
+ if (drag) {
+ yaw += (e.clientX - drag.x) * 0.008;
+ pitch = Math.max(0.15, Math.min(1.3, pitch + (e.clientY - drag.y) * 0.006));
+ drag = { x: e.clientX, y: e.clientY };
+ }
+});
+canvas.addEventListener("pointerup", () => (drag = null));
+canvas.addEventListener("pointercancel", () => (drag = null));
+canvas.addEventListener(
+ "wheel",
+ (e) => {
+ e.preventDefault();
+ zoom = Math.max(0.6, Math.min(2, zoom * Math.exp(-e.deltaY * 0.001)));
+ },
+ { passive: false },
+);
+function draw() {
+ const rect = canvas.getBoundingClientRect(),
+ dpr = window.devicePixelRatio || 1,
+ w = rect.width,
+ h = rect.height;
+ if (
+ canvas.width !== Math.round(w * dpr) ||
+ canvas.height !== Math.round(h * dpr)
+ ) {
+ canvas.width = Math.round(w * dpr);
+ canvas.height = Math.round(h * dpr);
+ }
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ ctx.clearRect(0, 0, w, h);
+ if (!data) {
+ ctx.fillStyle = "#8da0aa";
+ ctx.font = "13px Segoe UI";
+ ctx.textAlign = "center";
+ ctx.fillText("Waiting for the tracking session…", w / 2, h / 2);
+ requestAnimationFrame(draw);
+ return;
+ }
+ const lo = data.config.workspace.minimum,
+ hi = data.config.workspace.maximum,
+ mid = { x: (lo.x + hi.x) / 2, y: (lo.y + hi.y) / 2, z: (lo.z + hi.z) / 2 },
+ extent = Math.max(hi.x - lo.x, hi.y - lo.y, hi.z - lo.z, 0.1),
+ scale = ((Math.min(w, h) * 0.58) / extent) * zoom;
+ function project(p) {
+ const x = p.x - mid.x,
+ y = p.y - mid.y,
+ z = p.z - mid.z;
+ const a = Math.cos(yaw) * x - Math.sin(yaw) * y,
+ b = Math.sin(yaw) * x + Math.cos(yaw) * y;
+ return [
+ w / 2 + a * scale,
+ h * 0.5 + (Math.sin(pitch) * b - Math.cos(pitch) * z) * scale,
+ ];
+ }
+ function line(a, b, color, width = 1, dash = []) {
+ const pa = project(a),
+ pb = project(b);
+ ctx.beginPath();
+ ctx.strokeStyle = color;
+ ctx.lineWidth = width;
+ ctx.setLineDash(dash);
+ ctx.moveTo(...pa);
+ ctx.lineTo(...pb);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ }
+ for (let i = 0; i <= 10; i++) {
+ const f = i / 10;
+ line(
+ { x: lo.x + (hi.x - lo.x) * f, y: lo.y, z: lo.z },
+ { x: lo.x + (hi.x - lo.x) * f, y: hi.y, z: lo.z },
+ "#dde7e9",
+ );
+ line(
+ { x: lo.x, y: lo.y + (hi.y - lo.y) * f, z: lo.z },
+ { x: hi.x, y: lo.y + (hi.y - lo.y) * f, z: lo.z },
+ "#dde7e9",
+ );
+ }
+ const corners = [];
+ for (const x of [lo.x, hi.x])
+ for (const y of [lo.y, hi.y])
+ for (const z of [lo.z, hi.z]) corners.push({ x, y, z });
+ for (let i = 0; i < 8; i++)
+ for (const bit of [1, 2, 4])
+ if ((i ^ bit) > i)
+ line(corners[i], corners[i ^ bit], "#cad9de", 1, [3, 4]);
+ for (let i = 1; i < trail.length; i++)
+ line(trail[i - 1], trail[i], "#9dc9b9", 2);
+ const p = data.robot.pose.position,
+ ground = { x: p.x, y: p.y, z: lo.z };
+ line(ground, p, "#b7ccc4", 1, [4, 4]);
+ const [sx, sy] = project(p);
+ ctx.beginPath();
+ ctx.arc(sx, sy, 18, 0, Math.PI * 2);
+ ctx.fillStyle = online ? "#d5eae380" : "#dde3e780";
+ ctx.fill();
+ ctx.beginPath();
+ ctx.arc(sx, sy, 7, 0, Math.PI * 2);
+ ctx.fillStyle =
+ data.state === "active"
+ ? "#177d67"
+ : data.state === "clutched"
+ ? "#ba8a35"
+ : "#7d969f";
+ ctx.fill();
+ ctx.strokeStyle = "#fff";
+ ctx.lineWidth = 2;
+ ctx.stroke();
+ const length = extent * 0.13,
+ q = data.robot.pose.orientation;
+ function rot(v) {
+ const u = { x: q.x, y: q.y, z: q.z },
+ uv = {
+ x: u.y * v.z - u.z * v.y,
+ y: u.z * v.x - u.x * v.z,
+ z: u.x * v.y - u.y * v.x,
+ },
+ uuv = {
+ x: u.y * uv.z - u.z * uv.y,
+ y: u.z * uv.x - u.x * uv.z,
+ z: u.x * uv.y - u.y * uv.x,
+ };
+ return {
+ x: p.x + v.x + 2 * (q.w * uv.x + uuv.x),
+ y: p.y + v.y + 2 * (q.w * uv.y + uuv.y),
+ z: p.z + v.z + 2 * (q.w * uv.z + uuv.z),
+ };
+ }
+ for (const [v, color] of [
+ [{ x: length, y: 0, z: 0 }, "#c8746f"],
+ [{ x: 0, y: length, z: 0 }, "#5f9e86"],
+ [{ x: 0, y: 0, z: length }, "#608bb2"],
+ ])
+ line(p, rot(v), color, 2);
+ const origin = { ...lo };
+ for (const [a, color] of [
+ ["x", "#b57875"],
+ ["y", "#5d9681"],
+ ["z", "#678ba5"],
+ ]) {
+ const endpoint = { ...origin, [a]: lo[a] + extent * 0.18 };
+ line(origin, endpoint, color, 2);
+ const screen = project(endpoint);
+ ctx.fillStyle = color;
+ ctx.font = "11px Segoe UI";
+ ctx.textAlign = "center";
+ ctx.fillText(a.toUpperCase(), screen[0], screen[1] - 8);
+ }
+ requestAnimationFrame(draw);
+}
+$("arm").addEventListener("click", () => action("arm"));
+$("clutch").addEventListener("click", toggleClutch);
+$("disarm").addEventListener("click", () => action("disarm"));
+$("reconnect").addEventListener("click", () => action("reconnect"));
+poll();
+setInterval(poll, 50);
+requestAnimationFrame(draw);
diff --git a/src/source_robotics_leap_motion/lab.py b/src/source_robotics_leap_motion/lab.py
new file mode 100644
index 0000000..45b5a3b
--- /dev/null
+++ b/src/source_robotics_leap_motion/lab.py
@@ -0,0 +1,271 @@
+"""Loopback-only browser lab. Its only output adapter is SimulatedArm."""
+
+from __future__ import annotations
+
+import json
+import queue
+import secrets
+import sys
+import threading
+import time
+import webbrowser
+from contextlib import nullcontext
+from dataclasses import asdict
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+
+from .adapters.simulated import SimulatedArm
+from .backends.replay import ReplayBackend
+from .control_config import GripperMapping, TeleoperationConfig, Workspace
+from .control_models import Pose
+from .errors import TeleoperationError, TrackingTimeoutError
+from .mapping import Mapping
+from .models import Vector3
+from .teleoperation import Teleoperator
+from .tracker import Tracker
+
+
+def simulation_config() -> TeleoperationConfig:
+ return TeleoperationConfig(
+ workspace=Workspace(Vector3(0, -0.3, 0), Vector3(0.6, 0.3, 0.6)),
+ max_linear_speed=0.2,
+ max_angular_speed=1.0,
+ mapping=Mapping(("-z", "-x", "y"), Vector3(0.75, 0.75, 0.75)),
+ control_orientation=True,
+ gripper=GripperMapping(),
+ )
+
+
+class LabServer:
+ def __init__(self, port: int = 0) -> None:
+ self.commands: queue.Queue[str] = queue.Queue(maxsize=16)
+ self._lock = threading.Lock()
+ self._state = b'{"state":"starting"}'
+ self._token = secrets.token_urlsafe(32)
+ self._html = (
+ Path(__file__)
+ .with_name("lab.html")
+ .read_text(encoding="utf-8")
+ .replace("__TOKEN__", self._token)
+ .encode("utf-8")
+ )
+ self._assets = {
+ "/lab.css": (
+ Path(__file__).with_name("lab.css").read_bytes(),
+ "text/css; charset=utf-8",
+ ),
+ "/lab.js": (
+ Path(__file__).with_name("lab.js").read_bytes(),
+ "text/javascript; charset=utf-8",
+ ),
+ }
+ lab = self
+
+ class Handler(BaseHTTPRequestHandler):
+ def log_message(self, *_args):
+ pass
+
+ def reply(self, status, body, content_type="application/json; charset=utf-8"):
+ self.send_response(status)
+ self.send_header("Content-Type", content_type)
+ self.send_header("Content-Length", str(len(body)))
+ self.send_header("Cache-Control", "no-store")
+ self.send_header("X-Content-Type-Options", "nosniff")
+ self.send_header(
+ "Content-Security-Policy",
+ "default-src 'self'; "
+ "script-src 'self'; style-src 'self'; "
+ "frame-ancestors 'none'; base-uri 'none'",
+ )
+ self.end_headers()
+ self.wfile.write(body)
+
+ def do_GET(self):
+ if self.path == "/":
+ self.reply(200, lab._html, "text/html; charset=utf-8")
+ elif self.path == "/state":
+ with lab._lock:
+ state = lab._state
+ self.reply(200, state)
+ elif self.path in lab._assets:
+ self.reply(200, *lab._assets[self.path])
+ else:
+ self.reply(404, b'{"error":"not found"}')
+
+ def do_POST(self):
+ if self.path != "/action":
+ self.reply(404, b'{"error":"not found"}')
+ return
+ if (
+ self.headers.get("X-Source-Leap-Token") != lab._token
+ or self.headers.get("Origin", lab.url) != lab.url
+ ):
+ self.reply(403, b'{"error":"invalid local session"}')
+ return
+ try:
+ size = int(self.headers.get("Content-Length", "0"))
+ if not 0 < size <= 512:
+ raise ValueError("Invalid body size")
+ action = json.loads(self.rfile.read(size))["action"]
+ if action not in ("arm", "clutch", "resume", "disarm", "reconnect"):
+ raise ValueError("Unknown action")
+ lab.commands.put_nowait(action)
+ except queue.Full:
+ self.reply(429, b'{"error":"action queue is full"}')
+ return
+ except (ValueError, KeyError, TypeError):
+ self.reply(400, b'{"error":"invalid action"}')
+ return
+ self.reply(202, b'{"accepted":true}')
+
+ self._server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
+ self._server.daemon_threads = True
+ self._thread = threading.Thread(
+ target=self._server.serve_forever, name="source-leap-lab", daemon=True
+ )
+
+ @property
+ def url(self) -> str:
+ return f"http://127.0.0.1:{self._server.server_port}"
+
+ def publish(self, state: dict) -> None:
+ payload = json.dumps(state, allow_nan=False).encode("utf-8")
+ with self._lock:
+ self._state = payload
+
+ def __enter__(self):
+ self._thread.start()
+ return self
+
+ def __exit__(self, *_exc):
+ self._server.shutdown()
+ self._server.server_close()
+ self._thread.join(2)
+
+
+def snapshot(teleop: Teleoperator, robot: SimulatedArm, source: str, message: str) -> dict:
+ status = teleop.status
+ hand = (
+ None
+ if teleop.last_frame is None
+ else next(
+ (
+ hand
+ for hand in teleop.last_frame.hands
+ if hand.handedness == teleop.config.handedness
+ ),
+ None,
+ )
+ )
+ return {
+ "schema": "source-leap.teleoperation-lab",
+ "version": 1,
+ "state": status.state,
+ "reason": status.reason,
+ "message": message,
+ "source": source,
+ "handedness": teleop.config.handedness,
+ "hand": None if hand is None else {"id": hand.id, "position": asdict(hand.palm.position)},
+ "robot": asdict(robot.read_feedback()),
+ "control": asdict(status),
+ "tracking": teleop.tracker.health.to_dict(),
+ "config": asdict(teleop.config),
+ }
+
+
+def run_lab(args) -> int:
+ if not 0 <= args.port <= 65535:
+ raise ValueError("Viewer port must be between 0 and 65535")
+ if args.json and not args.headless:
+ raise ValueError("--json requires --headless")
+ if args.demo and (args.library or args.runtime_dir or args.replay):
+ raise ValueError("--demo cannot be combined with a live runtime or replay")
+ if args.replay and (args.library or args.runtime_dir):
+ raise ValueError("--replay cannot be combined with a live runtime")
+ config = TeleoperationConfig.from_toml(args.config) if args.config else simulation_config()
+ if args.demo:
+ tracker = Tracker.demo(mode="latest", max_frame_age=config.max_frame_age)
+ source = "synthetic hand"
+ elif args.replay:
+ tracker = Tracker(
+ backend=ReplayBackend(args.replay, realtime=True),
+ mode="latest",
+ max_frame_age=config.max_frame_age,
+ )
+ source = "recorded hand"
+ else:
+ tracker = Tracker(
+ mode="latest",
+ library_path=args.library,
+ runtime_dir=args.runtime_dir,
+ max_frame_age=config.max_frame_age,
+ )
+ source = "Leap controller"
+ center = (config.workspace.minimum + config.workspace.maximum) * 0.5
+ robot = SimulatedArm(Pose(center))
+ server = None if args.headless else LabServer(args.port)
+ auto_pending = args.auto_arm
+ processed = 0
+ last_frame = None
+ message = ""
+ with (
+ server if server is not None else nullcontext(),
+ tracker,
+ Teleoperator(tracker, robot=robot, config=config) as teleop,
+ ):
+ started = time.monotonic()
+ if server is not None:
+ server.publish(snapshot(teleop, robot, source, message))
+ print("Teleoperation lab: " + server.url, flush=True)
+ if not args.no_browser:
+ webbrowser.open(server.url)
+ try:
+ while args.duration is None or time.monotonic() - started < args.duration:
+ loop_started = time.monotonic()
+ actions = []
+ if server is not None:
+ while not server.commands.empty():
+ actions.append(server.commands.get_nowait())
+ if auto_pending and not actions:
+ actions.append("arm")
+ for action in actions:
+ try:
+ if action == "arm":
+ teleop.arm(timeout=0.05)
+ auto_pending = False
+ elif action == "clutch":
+ teleop.clutch()
+ elif action == "resume":
+ teleop.release_clutch()
+ elif action == "reconnect":
+ teleop.reconnect()
+ else:
+ auto_pending = False
+ teleop.disarm()
+ message = ""
+ except (TeleoperationError, TrackingTimeoutError) as error:
+ message = str(error)
+ timeout = 0.05
+ if args.duration is not None:
+ remaining = args.duration - (time.monotonic() - started)
+ if remaining <= 0:
+ break
+ timeout = min(timeout, remaining)
+ teleop.step(timeout=timeout)
+ state = snapshot(teleop, robot, source, message)
+ if teleop.last_frame is not last_frame:
+ last_frame = teleop.last_frame
+ processed += 1
+ if args.json:
+ print(json.dumps(state, allow_nan=False, separators=(",", ":")))
+ if server is not None:
+ server.publish(state)
+ if args.frames is not None and processed >= args.frames:
+ break
+ time.sleep(max(0.0, 1 / config.max_output_hz - (time.monotonic() - loop_started)))
+ except StopIteration:
+ pass
+ print(
+ f"Simulated targets: {robot.targets_sent}; tracking frames: {processed}.", file=sys.stderr
+ )
+ return 0
diff --git a/src/source_robotics_leap_motion/mapping.py b/src/source_robotics_leap_motion/mapping.py
index 45d7ce9..387b2d6 100644
--- a/src/source_robotics_leap_motion/mapping.py
+++ b/src/source_robotics_leap_motion/mapping.py
@@ -7,7 +7,8 @@
from pathlib import Path
from ._compat import tomllib
-from .models import Vector3
+from ._geometry import rotate
+from .models import Quaternion, Vector3
ZERO = Vector3()
@@ -16,14 +17,17 @@
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)
+ target = robot_origin + gain * rotation * axes * (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)
+ rotation: Quaternion = Quaternion()
def __post_init__(self) -> None:
+ if not isinstance(self.rotation, Quaternion):
+ raise ValueError("Frame rotation must be a Quaternion")
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):
@@ -48,8 +52,9 @@ def map_position(
getattr(delta, axis.lstrip("-")) * (-1 if axis.startswith("-") else 1)
for axis in self.axes
]
+ aligned = rotate(self.rotation, Vector3(*rotated))
return robot_origin + Vector3(
- *(component * gain for component, gain in zip(rotated, self.gain, strict=True))
+ *(component * gain for component, gain in zip(aligned, self.gain, strict=True))
)
@classmethod
@@ -59,10 +64,22 @@ def from_toml(cls, path: str | Path) -> Mapping:
if set(document) != {"mapping"}:
raise ValueError("Configuration must contain one [mapping] table")
config = document["mapping"]
- unknown = set(config) - {"axes", "gain"}
+ unknown = set(config) - {"axes", "gain", "rotation"}
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))
+ rotation = _parse_rotation(config.get("rotation", [0, 0, 0, 1]))
+ return cls(tuple(config.get("axes", ("x", "y", "z"))), Vector3(*gain), rotation)
+
+
+def _parse_rotation(values) -> Quaternion:
+ if not isinstance(values, list) or len(values) != 4:
+ raise ValueError("Frame rotation requires four x/y/z/w values")
+ if any(
+ isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value)
+ for value in values
+ ):
+ raise ValueError("Frame rotation requires four finite numbers")
+ return Quaternion(*values)
diff --git a/src/source_robotics_leap_motion/teleoperation.py b/src/source_robotics_leap_motion/teleoperation.py
new file mode 100644
index 0000000..72f2422
--- /dev/null
+++ b/src/source_robotics_leap_motion/teleoperation.py
@@ -0,0 +1,185 @@
+"""Synchronous orchestration of latest-mode tracking and an explicit robot adapter."""
+
+from __future__ import annotations
+
+from ._compat import Self
+from .adapters.base import RobotAdapter
+from .control import TeleoperationController, TeleoperationStatus
+from .control_config import TeleoperationConfig
+from .control_models import PoseTarget, RobotFeedback
+from .errors import (
+ AdapterError,
+ ConnectionError,
+ StaleFrameError,
+ TeleoperationError,
+ TrackingTimeoutError,
+)
+from .models import Frame
+from .tracker import Tracker
+
+
+class Teleoperator:
+ """Owns the robot adapter; the caller owns the surrounding Tracker context.
+
+ Call arm explicitly, then step from one application thread. Adapter operations
+ are synchronous and must be bounded. Controller-side command expiry is required
+ to handle loss of this application or its network connection.
+ """
+
+ def __init__(
+ self, tracker: Tracker, *, robot: RobotAdapter, config: TeleoperationConfig
+ ) -> None:
+ self.tracker, self.robot, self.config = tracker, robot, config
+ self.controller = TeleoperationController(config)
+ self._opened = False
+ self._disconnected = False
+ self.last_frame: Frame | None = None
+
+ @property
+ def status(self) -> TeleoperationStatus:
+ return self.controller.status
+
+ def __enter__(self) -> Self:
+ if self._opened:
+ raise TeleoperationError("Teleoperator is already open")
+ if self.tracker.health.status == "closed" or self.tracker.health.mode != "latest":
+ raise TeleoperationError("Open a latest-mode Tracker before the Teleoperator")
+ if self.tracker.max_frame_age > self.config.max_frame_age:
+ raise TeleoperationError("Tracker max_frame_age must not exceed the control age limit")
+ capabilities = self.robot.capabilities
+ if not capabilities.cartesian_position:
+ raise TeleoperationError(
+ "Adapter must accept Cartesian positions; supply kinematics there"
+ )
+ if self.config.control_orientation and not capabilities.orientation:
+ raise TeleoperationError("Adapter does not support configured orientation control")
+ if self.config.gripper is not None and not capabilities.gripper:
+ raise TeleoperationError("Adapter does not support configured gripper control")
+ self.controller = TeleoperationController(self.config)
+ self._disconnected = False
+ self.last_frame = None
+ try:
+ self._call("open")
+ self._opened = True
+ self._call("hold", "disarmed")
+ except BaseException:
+ self._opened = False
+ self.robot.close()
+ raise
+ return self
+
+ def _require_open(self) -> None:
+ if not self._opened:
+ raise TeleoperationError("Open the Teleoperator with a context manager first")
+ if self.status.state == "fault":
+ raise TeleoperationError("Adapter fault is latched; close and reopen before rearming")
+
+ def _call(self, operation: str, *arguments):
+ try:
+ return getattr(self.robot, operation)(*arguments)
+ except Exception as error:
+ self.controller.fault(f"adapter_{operation}_failed")
+ extra = ""
+ if operation in ("send", "read_feedback"):
+ try:
+ self.robot.hold("adapter_fault")
+ except Exception as hold_error:
+ extra = f"; hold also failed: {hold_error}"
+ raise AdapterError(f"Adapter {operation} failed: {error}{extra}") from error
+
+ def _reference(self, reference: RobotFeedback | None) -> RobotFeedback:
+ if reference is None and self.robot.capabilities.feedback:
+ reference = self._call("read_feedback")
+ if not isinstance(reference, RobotFeedback):
+ raise TeleoperationError(
+ "Provide RobotFeedback explicitly when feedback is unavailable"
+ )
+ return reference
+
+ def arm(self, reference: RobotFeedback | None = None, *, timeout: float | None = None) -> None:
+ self._require_open()
+ if self.status.state not in ("disarmed", "tracking_lost"):
+ raise TeleoperationError("Disarm before calibrating and arming a new session")
+ if self._disconnected:
+ raise TeleoperationError("Reconnect tracking before rearming")
+ reference = self._reference(reference)
+ frame = self.tracker.read(self.config.tracking_timeout if timeout is None else timeout)
+ self.last_frame = frame
+ health = self.tracker.health
+ if health.status != "streaming":
+ raise TeleoperationError("Calibration requires tracking that is currently streaming")
+ self.controller.arm(frame, reference, frame_age=health.last_frame_age_s or 0.0)
+
+ def disarm(self) -> None:
+ self._require_open()
+ self.controller.disarm()
+ self._call("hold", "disarmed")
+
+ def clutch(self) -> None:
+ self._require_open()
+ self.controller.clutch()
+ self._call("hold", "clutched")
+
+ def release_clutch(self, reference: RobotFeedback | None = None) -> None:
+ self._require_open()
+ self.controller.release_clutch(self._reference(reference))
+
+ def reconnect(self) -> None:
+ self._require_open()
+ self.disarm()
+ self._disconnected = True
+ self.tracker.reconnect()
+ self._disconnected = False
+
+ def step(self, *, timeout: float | None = None) -> PoseTarget | None:
+ self._require_open()
+ if self._disconnected:
+ return None
+ before = self.status.state
+ try:
+ frame = self.tracker.read(self.config.tracking_timeout if timeout is None else timeout)
+ except StaleFrameError:
+ self.controller.tracking_lost("stale_frame")
+ frame = None
+ except TrackingTimeoutError:
+ frame = None
+ except ConnectionError:
+ self.controller.tracking_lost("disconnected")
+ self._disconnected = True
+ frame = None
+ except Exception:
+ self.controller.fault("tracking_failed")
+ self._call("hold", "tracking_failed")
+ raise
+ if frame is not None:
+ self.last_frame = frame
+ health = self.tracker.health
+ if health.status != "streaming":
+ reason = "stale_frame" if health.status == "stale" else "tracking_" + health.status
+ self.controller.tracking_lost(reason)
+ self._disconnected |= health.status in ("disconnected", "error", "closed")
+ frame = None
+ try:
+ target = self.controller.update(frame, frame_age=health.last_frame_age_s or 0.0)
+ except Exception:
+ self.controller.fault("control_failed")
+ self._call("hold", "control_failed")
+ raise
+ if before in ("active", "clutched") and self.status.state == "tracking_lost":
+ self._call("hold", self.status.reason)
+ if target is not None:
+ self._call("send", target)
+ return target
+
+ def close(self) -> None:
+ if not self._opened:
+ return
+ self._opened = False
+ self.controller.close()
+ try:
+ self._call("hold", "closed")
+ finally:
+ self._call("close")
+
+ def __exit__(self, *_exc) -> None:
+ self.close()
diff --git a/src/source_robotics_leap_motion/tracker.py b/src/source_robotics_leap_motion/tracker.py
index 76e0cdb..0448807 100644
--- a/src/source_robotics_leap_motion/tracker.py
+++ b/src/source_robotics_leap_motion/tracker.py
@@ -119,6 +119,11 @@ def __enter__(self) -> Self:
self._opened = True
return self
+ @property
+ def max_frame_age(self) -> float:
+ """Configured local age limit; enforced on reads in latest mode."""
+ return self._max_frame_age
+
@property
def health(self) -> TrackingHealth:
"""Current session statistics. Reading health never accesses the device."""
diff --git a/tests/test_control.py b/tests/test_control.py
new file mode 100644
index 0000000..b066a11
--- /dev/null
+++ b/tests/test_control.py
@@ -0,0 +1,263 @@
+import math
+from dataclasses import replace
+
+import pytest
+
+from source_robotics_leap_motion import (
+ Frame,
+ GripperMapping,
+ Hand,
+ Mapping,
+ Palm,
+ Pose,
+ Quaternion,
+ RobotFeedback,
+ TeleoperationConfig,
+ TeleoperationController,
+ TeleoperationError,
+ Vector3,
+ Workspace,
+)
+
+
+def config(**values):
+ defaults = dict(
+ workspace=Workspace(Vector3(-1, -1, -1), Vector3(1, 1, 1)),
+ max_linear_speed=100,
+ max_angular_speed=100,
+ smoothing_time_constant=0,
+ position_deadband=0,
+ max_output_hz=1000,
+ tracking_timeout=0.5,
+ )
+ defaults.update(values)
+ return TeleoperationConfig(**defaults)
+
+
+def frame(
+ index=0,
+ position=None,
+ *,
+ orientation=None,
+ hand_id=1,
+ hands=True,
+ timestamp=None,
+ grab=0,
+ pinch=0,
+):
+ hand = Hand(
+ hand_id,
+ "right",
+ Palm(position or Vector3(), orientation or Quaternion()),
+ grab_strength=grab,
+ pinch_strength=pinch,
+ )
+ return Frame(
+ index, index * 100_000 if timestamp is None else timestamp, (hand,) if hands else ()
+ )
+
+
+def armed(**values):
+ controller = TeleoperationController(config(**values))
+ controller.arm(frame(), RobotFeedback(Pose(Vector3()), 1.0), now=0)
+ return controller
+
+
+def test_disarmed_controller_never_emits_and_arm_does_not_emit():
+ controller = TeleoperationController(config())
+ assert controller.update(frame(), now=0) is None
+ controller.arm(frame(), RobotFeedback(Pose(Vector3())), now=0)
+ assert controller.status.last_target is None
+ assert controller.update(frame(), now=0.01) is None
+ assert controller.update(frame(1, Vector3(0.1, 0, 0)), now=0.1).position.x == 0.1
+
+
+def test_relative_calibration_preserves_robot_pose_and_configured_axes():
+ controller = TeleoperationController(
+ config(mapping=Mapping(("-z", "-x", "y"), Vector3(0.5, 0, 2)))
+ )
+ controller.arm(
+ frame(position=Vector3(0, 0.3, 0)), RobotFeedback(Pose(Vector3(0.2, 0.1, 0.2))), now=0
+ )
+ target = controller.update(frame(1, Vector3(0.1, 0.4, -0.2)), now=0.1)
+ assert tuple(target.position) == pytest.approx((0.3, 0.1, 0.4))
+ assert target.orientation is None and target.gripper is None
+ assert target.sequence == 1 and target.source_frame_id == 1
+
+
+@pytest.mark.parametrize(
+ "failure",
+ [
+ "missing",
+ "hand_changed",
+ "clock",
+ "device",
+ "rewind",
+ "frozen_clock",
+ "source_gap",
+ "local_gap",
+ "stale",
+ ],
+)
+@pytest.mark.parametrize("clutched", [False, True])
+def test_tracking_failures_latch_and_fresh_frames_do_not_rearm(failure, clutched):
+ controller = armed()
+ if failure == "rewind":
+ controller.disarm()
+ controller.arm(frame(timestamp=200_000), RobotFeedback(Pose(Vector3())), now=0)
+ if clutched:
+ controller.clutch()
+ candidate = frame(1)
+ now, age = 0.1, 0
+ if failure == "missing":
+ candidate = frame(1, hands=False)
+ elif failure == "hand_changed":
+ candidate = frame(1, hand_id=2)
+ elif failure == "clock":
+ candidate = replace(candidate, clock_domain="other")
+ elif failure == "device":
+ candidate = replace(candidate, device_id=2)
+ elif failure == "frozen_clock":
+ candidate = frame(1, timestamp=0)
+ elif failure == "source_gap":
+ candidate = frame(1, timestamp=600_000)
+ elif failure == "local_gap":
+ now = 0.6
+ elif failure == "stale":
+ age = 0.11
+ assert controller.update(candidate, now=now, frame_age=age) is None
+ assert controller.status.state == "tracking_lost"
+ assert controller.update(frame(2), now=0.7) is None
+ assert controller.status.state == "tracking_lost"
+
+
+def test_duplicates_do_not_extend_tracking_deadline_and_explicit_rearm_changes_session():
+ controller = armed()
+ session = controller.status.session_id
+ for now in (0.1, 0.3, 0.6):
+ assert controller.update(frame(), now=now) is None
+ assert controller.status.state == "tracking_lost"
+ controller.arm(frame(10), RobotFeedback(Pose(Vector3())), now=0.7)
+ assert controller.status.session_id != session
+ assert controller.status.sequence == 0
+
+
+def test_clutch_holds_and_reanchors_without_position_or_rotation_jump():
+ controller = armed(control_orientation=True)
+ first = controller.update(frame(1, Vector3(0.1, 0, 0)), now=0.1)
+ controller.clutch()
+ assert controller.update(frame(2, Vector3(0.8, 0, 0)), now=0.2) is None
+ controller.release_clutch(RobotFeedback(Pose(first.position)))
+ assert controller.update(frame(3, Vector3(0.9, 0, 0)), now=0.3) is None
+ assert controller.status.state == "active"
+ target = controller.update(frame(4, Vector3(1, 0, 0)), now=0.4)
+ assert target.position.x == pytest.approx(0.2)
+ assert target.sequence == 2
+
+
+def test_workspace_and_vector_speed_limit_hold_over_a_long_trajectory():
+ bounds = Workspace(Vector3(-0.2, -0.2, -0.2), Vector3(0.2, 0.2, 0.2))
+ controller = armed(workspace=bounds, max_linear_speed=0.2)
+ previous = Vector3()
+ for index in range(1, 501):
+ t = index / 100
+ target = controller.update(
+ frame(index, Vector3(math.sin(t) * 2, 2, math.cos(t) * 2), timestamp=index * 10_000),
+ now=t,
+ )
+ assert bounds.contains(target.position)
+ assert (target.position - previous).norm <= 0.002 + 1e-12
+ previous = target.position
+ assert controller.status.workspace_limited
+
+
+def test_smoothing_is_time_based_and_deadband_applies_before_smoothing():
+ controller = armed(smoothing_time_constant=0.1, position_deadband=0.001)
+ target = controller.update(frame(1, Vector3(0.01, 0, 0)), now=0.1)
+ assert target.position.x == pytest.approx(0.01 * (1 - math.exp(-1)))
+ before = target.position
+ target = controller.update(frame(2, before + Vector3(0.0005, 0, 0)), now=0.2)
+ assert target.position == before
+
+
+def test_relative_orientation_is_mapped_in_robot_axes_and_composed_in_world_order():
+ root_half = math.sqrt(0.5)
+ controller = TeleoperationController(
+ config(control_orientation=True, mapping=Mapping(("-z", "-x", "y")))
+ )
+ controller.arm(
+ frame(), RobotFeedback(Pose(Vector3(), Quaternion(0, root_half, 0, root_half))), now=0
+ )
+ target = controller.update(
+ frame(1, orientation=Quaternion(0, 0, root_half, root_half)), now=0.1
+ )
+ q = target.orientation
+ assert (q.x, q.y, q.z, q.w) == pytest.approx((-0.5, 0.5, -0.5, 0.5))
+
+
+def test_rotation_gain_and_angular_speed_are_limited():
+ controller = armed(control_orientation=True, max_angular_speed=1, rotation_gain=0.5)
+ target = controller.update(frame(1, orientation=Quaternion(0, 0, 1, 0)), now=0.1)
+ assert target.orientation.z == pytest.approx(math.sin(0.05))
+ assert target.orientation.w == pytest.approx(math.cos(0.05))
+ assert controller.status.speed_limited
+
+
+def test_extra_frame_rotation_applies_to_position_and_orientation_together():
+ s = math.sqrt(0.5)
+ controller = armed(control_orientation=True, mapping=Mapping(rotation=Quaternion(0, 0, s, s)))
+ target = controller.update(
+ frame(1, Vector3(0.1, 0, 0), orientation=Quaternion(s, 0, 0, s)), now=0.1
+ )
+ assert tuple(target.position) == pytest.approx((0, 0.1, 0))
+ q = target.orientation
+ assert (q.x, q.y, q.z, q.w) == pytest.approx((0, s, 0, s))
+
+
+def test_gripper_mapping_limits_speed_and_holds_during_clutch():
+ controller = armed(gripper=GripperMapping(source="pinch", max_speed=0.5, deadband=0))
+ target = controller.update(frame(1, pinch=1), now=0.1)
+ assert target.gripper == pytest.approx(0.95)
+ controller.clutch()
+ assert controller.update(frame(2, pinch=0), now=0.2) is None
+ assert controller.status.last_target.gripper == pytest.approx(0.95)
+
+
+def test_output_frequency_is_a_cap_and_skipped_inputs_still_refresh_tracking():
+ controller = armed(max_output_hz=10)
+ for index in range(1, 10):
+ assert controller.update(frame(index, timestamp=index * 10_000), now=index / 100) is None
+ assert controller.update(frame(10, timestamp=100_000), now=0.1).sequence == 1
+
+
+@pytest.mark.parametrize(
+ "reference,frame_age",
+ [
+ (RobotFeedback(Pose(Vector3(2, 0, 0))), 0),
+ (RobotFeedback(Pose(Vector3())), 0.11),
+ ],
+)
+def test_invalid_calibration_stays_disarmed(reference, frame_age):
+ controller = TeleoperationController(config())
+ with pytest.raises(TeleoperationError):
+ controller.arm(frame(), reference, now=0, frame_age=frame_age)
+ assert controller.status.state == "disarmed"
+
+
+def test_gripper_requires_known_reference_and_missing_hand_cannot_calibrate():
+ controller = TeleoperationController(config(gripper=GripperMapping()))
+ with pytest.raises(TeleoperationError, match="gripper reference"):
+ controller.arm(frame(), RobotFeedback(Pose(Vector3())), now=0)
+ with pytest.raises(TeleoperationError, match="exactly one"):
+ controller.arm(frame(hands=False), RobotFeedback(Pose(Vector3()), 1), now=0)
+
+
+def test_local_clock_regression_faults_and_cannot_rearm_without_new_controller():
+ controller = armed()
+ controller.update(frame(1), now=0.1)
+ with pytest.raises(TeleoperationError, match="backwards"):
+ controller.update(frame(2), now=0.05)
+ assert controller.status.state == "fault"
+ controller.disarm()
+ with pytest.raises(TeleoperationError):
+ controller.arm(frame(2), RobotFeedback(Pose(Vector3())), now=0.2)
diff --git a/tests/test_control_config.py b/tests/test_control_config.py
new file mode 100644
index 0000000..7d6fc29
--- /dev/null
+++ b/tests/test_control_config.py
@@ -0,0 +1,62 @@
+import pytest
+
+from source_robotics_leap_motion import GripperMapping, TeleoperationConfig, Vector3, Workspace
+
+
+@pytest.mark.parametrize(
+ "values",
+ [
+ {"max_linear_speed": 0},
+ {"max_linear_speed": float("nan")},
+ {"max_linear_speed": True},
+ {"smoothing_time_constant": -1},
+ {"position_deadband": -1},
+ {"max_frame_age": 0.2},
+ {"max_output_hz": 1001},
+ {"handedness": "both"},
+ {"control_orientation": "true"},
+ ],
+)
+def test_invalid_settings_are_rejected(values):
+ settings = {"workspace": Workspace(Vector3(), Vector3(1, 1, 1)), "max_linear_speed": 1}
+ settings.update(values)
+ with pytest.raises(ValueError):
+ TeleoperationConfig(**settings)
+
+
+def test_workspace_rejects_inverted_bounds():
+ with pytest.raises(ValueError, match="minimum"):
+ Workspace(Vector3(1, 0, 0), Vector3())
+
+
+@pytest.mark.parametrize(
+ "settings",
+ [{"source": "fist"}, {"max_speed": 0}, {"open_value": 1.1}, {"closed_value": float("nan")}],
+)
+def test_invalid_gripper_settings(settings):
+ with pytest.raises(ValueError):
+ GripperMapping(**settings)
+
+
+def test_toml_configuration_and_typo_rejection(tmp_path):
+ path = tmp_path / "arm.toml"
+ text = """[workspace]
+minimum = [0, -0.3, 0]
+maximum = [0.6, 0.3, 0.6]
+[mapping]
+axes = ["-z", "-x", "y"]
+gain = [0.5, 0.5, 0.5]
+[teleoperation]
+max_linear_speed = 0.2
+control_orientation = true
+[gripper]
+source = "pinch"
+"""
+ path.write_text(text, encoding="utf-8")
+ config = TeleoperationConfig.from_toml(path)
+ assert config.mapping.axes == ("-z", "-x", "y")
+ assert config.gripper.source == "pinch"
+ assert config.control_orientation
+ path.write_text(text.replace("max_linear_speed", "max_linear_speeed"), encoding="utf-8")
+ with pytest.raises(ValueError, match="max_linear_speed"):
+ TeleoperationConfig.from_toml(path)
diff --git a/tests/test_lab.py b/tests/test_lab.py
new file mode 100644
index 0000000..96a0bc5
--- /dev/null
+++ b/tests/test_lab.py
@@ -0,0 +1,81 @@
+import json
+import urllib.error
+import urllib.request
+
+import pytest
+
+from source_robotics_leap_motion.cli import main
+from source_robotics_leap_motion.lab import LabServer
+
+
+def test_headless_demo_starts_disarmed_and_never_sends_without_activation(capsys):
+ assert main(["teleop", "--demo", "--headless", "--json", "--frames", "3"]) == 0
+ captured = capsys.readouterr()
+ states = [json.loads(line) for line in captured.out.splitlines()]
+ assert len(states) == 3
+ assert all(state["state"] == "disarmed" for state in states)
+ assert all(state["control"]["sequence"] == 0 for state in states)
+ assert "Simulated targets: 0" in captured.err
+
+
+def test_headless_demo_can_explicitly_arm_simulation(capsys):
+ assert main(["teleop", "--demo", "--headless", "--auto-arm", "--json", "--frames", "3"]) == 0
+ states = [json.loads(line) for line in capsys.readouterr().out.splitlines()]
+ assert len(states) == 3
+ assert states[-1]["state"] == "active"
+ assert states[-1]["control"]["sequence"] > 0
+
+
+def test_lab_server_serves_packaged_viewer_and_authenticated_bounded_actions():
+ server = LabServer()
+ with server:
+ server.publish({"state": "disarmed"})
+ with urllib.request.urlopen(server.url, timeout=3) as response:
+ html = response.read().decode()
+ assert "Teleoperation lab" in html
+ assert "__TOKEN__" not in html
+ assert "frame-ancestors 'none'" in response.headers["Content-Security-Policy"]
+ with urllib.request.urlopen(server.url + "/state", timeout=3) as response:
+ assert json.load(response) == {"state": "disarmed"}
+ for asset, content_type in [("lab.js", "text/javascript"), ("lab.css", "text/css")]:
+ with urllib.request.urlopen(server.url + "/" + asset, timeout=3) as response:
+ assert response.headers["Content-Type"].startswith(content_type)
+ assert len(response.read()) > 100
+
+ def action(token, origin, body=b'{"action":"arm"}'):
+ request = urllib.request.Request(
+ server.url + "/action",
+ data=body,
+ headers={"X-Source-Leap-Token": token, "Origin": origin},
+ method="POST",
+ )
+ return urllib.request.urlopen(request, timeout=3)
+
+ for token, origin in [("wrong", server.url), (server._token, "https://example.com")]:
+ with pytest.raises(urllib.error.HTTPError) as caught:
+ action(token, origin)
+ assert caught.value.code == 403
+ with action(server._token, server.url) as response:
+ assert response.status == 202
+ assert server.commands.get_nowait() == "arm"
+ with pytest.raises(urllib.error.HTTPError) as caught:
+ action(server._token, server.url, b'{"action":"arbitrary_robot_command"}')
+ assert caught.value.code == 400
+ for _ in range(16):
+ server.commands.put_nowait("arm")
+ with pytest.raises(urllib.error.HTTPError) as caught:
+ action(server._token, server.url)
+ assert caught.value.code == 429
+ assert not server._thread.is_alive()
+
+
+@pytest.mark.parametrize(
+ "arguments",
+ [
+ ["--json"],
+ ["--demo", "--replay", "anything.jsonl"],
+ ["--port", "-1"],
+ ],
+)
+def test_invalid_lab_options_report_errors(arguments, capsys):
+ assert main(["teleop", *arguments]) == 2
diff --git a/tests/test_mapping.py b/tests/test_mapping.py
index 5144aa2..5305658 100644
--- a/tests/test_mapping.py
+++ b/tests/test_mapping.py
@@ -1,6 +1,24 @@
+import math
+
import pytest
-from source_robotics_leap_motion import Mapping, Vector3
+from source_robotics_leap_motion import Mapping, Quaternion, Vector3
+
+
+def test_arbitrary_frame_rotation_precedes_robot_axis_gains():
+ # Rotate 45 degrees around z, then scale the robot x axis by two.
+ mapping = Mapping(
+ rotation=Quaternion(0, 0, math.sin(math.pi / 8), math.cos(math.pi / 8)),
+ gain=Vector3(2, 1, 1),
+ )
+ point = mapping.map_position(Vector3(1, 0, 0))
+ assert tuple(point) == pytest.approx((math.sqrt(2), math.sqrt(0.5), 0))
+
+
+def test_mapping_toml_supports_explicit_quaternion_rotation(tmp_path):
+ path = tmp_path / "rotation.toml"
+ path.write_text("[mapping]\nrotation = [0.0, 0.0, 1.0, 0.0]\n", encoding="utf-8")
+ assert Mapping.from_toml(path).map_position(Vector3(1, 0, 0)).x == pytest.approx(-1)
def test_mapping_preserves_calibrated_robot_origin_and_relative_axes():
diff --git a/tests/test_teleoperation.py b/tests/test_teleoperation.py
new file mode 100644
index 0000000..0437440
--- /dev/null
+++ b/tests/test_teleoperation.py
@@ -0,0 +1,240 @@
+from collections import deque
+from types import SimpleNamespace
+
+import pytest
+
+from source_robotics_leap_motion import (
+ AdapterError,
+ ConnectionError,
+ Frame,
+ Hand,
+ Palm,
+ Pose,
+ RobotCapabilities,
+ RobotFeedback,
+ StaleFrameError,
+ TeleoperationConfig,
+ TeleoperationError,
+ Teleoperator,
+ TrackingTimeoutError,
+ Vector3,
+ Workspace,
+)
+from source_robotics_leap_motion.adapters import CallbackAdapter, SimulatedArm
+
+
+class Source:
+ max_frame_age = 0.05
+
+ def __init__(self):
+ self.health = SimpleNamespace(status="streaming", mode="latest", last_frame_age_s=0)
+ self.now = 0
+ self.events = deque()
+ self.index = 0
+ self.reconnects = 0
+
+ def read(self, timeout):
+ self.now += 0.05
+ if self.events:
+ event = self.events.popleft()
+ if isinstance(event, BaseException):
+ raise event
+ return event
+ self.index += 1
+ return Frame(
+ self.index,
+ self.index * 50_000,
+ (Hand(1, "right", Palm(Vector3(self.index / 100, 0, 0))),),
+ )
+
+ def reconnect(self):
+ self.reconnects += 1
+ self.health.status = "streaming"
+
+
+@pytest.fixture
+def setup(monkeypatch):
+ source = Source()
+ monkeypatch.setattr("source_robotics_leap_motion.control.time.monotonic", lambda: source.now)
+ config = TeleoperationConfig(
+ Workspace(Vector3(-1, -1, -1), Vector3(1, 1, 1)),
+ max_linear_speed=1,
+ smoothing_time_constant=0,
+ position_deadband=0,
+ )
+ robot = SimulatedArm(Pose(Vector3()))
+ return source, config, robot
+
+
+def test_managed_session_starts_disarmed_owns_adapter_and_keeps_tracker_open(setup):
+ source, config, robot = setup
+ manager = Teleoperator(source, robot=robot, config=config)
+ with manager:
+ assert robot.hold_reason == "disarmed"
+ assert manager.step() is None and robot.targets_sent == 0
+ manager.arm()
+ assert robot.targets_sent == 0
+ assert manager.step() is robot.last_target
+ manager.clutch()
+ assert robot.hold_reason == "clutched"
+ before = robot.targets_sent
+ assert manager.step() is None
+ assert robot.targets_sent == before
+ manager.release_clutch()
+ assert manager.step() is None
+ assert manager.step() is not None
+ assert manager.status.state == "closed"
+ assert source.health.status == "streaming"
+ with pytest.raises(AdapterError):
+ robot.read_feedback()
+ manager.close()
+
+
+@pytest.mark.parametrize(
+ "event", [StaleFrameError("stale"), ConnectionError("unplugged"), Frame(200, 150_000)]
+)
+def test_tracking_loss_sends_hold_once_and_requires_explicit_rearm(setup, event):
+ source, config, robot = setup
+ holds = []
+ callback = CallbackAdapter(
+ lambda target: None, hold=holds.append, feedback=lambda: RobotFeedback(Pose(Vector3()))
+ )
+ with Teleoperator(source, robot=callback, config=config) as manager:
+ manager.arm()
+ source.events.append(event)
+ assert manager.step() is None
+ assert manager.status.state == "tracking_lost"
+ assert len(holds) == 2 # Startup hold and one loss transition.
+ manager.step()
+ assert len(holds) == 2
+ if isinstance(event, ConnectionError):
+ with pytest.raises(TeleoperationError, match="Reconnect"):
+ manager.arm()
+ manager.reconnect()
+ assert source.reconnects == 1
+ manager.arm()
+ assert manager.status.state == "active"
+
+
+def test_short_poll_timeout_does_not_disarm_but_expired_tracking_does(setup):
+ source, config, robot = setup
+ with Teleoperator(source, robot=robot, config=config) as manager:
+ manager.arm()
+ source.events.append(TrackingTimeoutError("poll"))
+ assert manager.step(timeout=0.01) is None
+ assert manager.status.state == "active"
+ source.now += 0.6
+ source.events.append(TrackingTimeoutError("poll"))
+ manager.step()
+ assert manager.status.state == "tracking_lost"
+ assert robot.hold_reason == "tracking_gap"
+
+
+def test_callback_without_feedback_requires_an_explicit_reference(setup):
+ source, config, _robot = setup
+ commands = []
+ callback = CallbackAdapter(commands.append, hold=lambda reason: None)
+ with Teleoperator(source, robot=callback, config=config) as manager:
+ with pytest.raises(TeleoperationError, match="RobotFeedback"):
+ manager.arm()
+ manager.arm(RobotFeedback(Pose(Vector3(0.3, 0, 0))))
+ manager.step()
+ assert commands[0].position.x == pytest.approx(0.31)
+
+
+@pytest.mark.parametrize("operation", ["send", "read_feedback"])
+def test_adapter_errors_latch_fault_preserve_cause_and_attempt_hold(setup, operation):
+ source, config, robot = setup
+ original = getattr(robot, operation)
+
+ def fail(*args):
+ raise OSError("Controller offline")
+
+ with Teleoperator(source, robot=robot, config=config) as manager:
+ manager.arm()
+ setattr(robot, operation, fail)
+ with pytest.raises(AdapterError, match="Controller offline") as caught:
+ manager.step() if operation == "send" else manager._reference(None)
+ assert isinstance(caught.value.__cause__, OSError)
+ assert manager.status.state == "fault"
+ assert robot.hold_reason == "adapter_fault"
+ with pytest.raises(TeleoperationError, match="fault"):
+ manager.arm()
+ setattr(robot, operation, original)
+
+
+def test_invalid_tracking_packet_holds_before_propagating_error(setup):
+ source, config, robot = setup
+ with Teleoperator(source, robot=robot, config=config) as manager:
+ manager.arm()
+ source.events.append(ValueError("Bad packet"))
+ with pytest.raises(ValueError, match="Bad packet"):
+ manager.step()
+ assert manager.status.state == "fault"
+ assert robot.hold_reason == "tracking_failed"
+
+
+def test_health_failure_observed_after_read_prevents_target_delivery(setup):
+ source, config, robot = setup
+ with Teleoperator(source, robot=robot, config=config) as manager:
+ manager.arm()
+ source.health.status = "disconnected"
+ assert manager.step() is None
+ assert robot.targets_sent == 0
+ assert manager.status.state == "tracking_lost"
+ assert robot.hold_reason == "tracking_disconnected"
+
+
+@pytest.mark.parametrize(
+ "mode,age,status",
+ [("sequential", 0.05, "streaming"), ("latest", 0.2, "streaming"), ("latest", 0.05, "closed")],
+)
+def test_incompatible_tracking_is_rejected_before_robot_open(setup, mode, age, status):
+ source, config, robot = setup
+ source.health.mode, source.health.status, source.max_frame_age = mode, status, age
+ with pytest.raises(TeleoperationError):
+ with Teleoperator(source, robot=robot, config=config):
+ pass
+ assert not robot._opened
+
+
+def test_unsupported_adapter_capabilities_fail_before_open(setup):
+ source, config, robot = setup
+ robot.capabilities = RobotCapabilities(cartesian_position=False)
+ with pytest.raises(TeleoperationError, match="kinematics"):
+ with Teleoperator(source, robot=robot, config=config):
+ pass
+ assert not robot._opened
+
+
+def test_partial_callback_startup_and_failed_hold_still_release_resources(setup):
+ source, config, _robot = setup
+ closed = []
+
+ def failed_open():
+ raise OSError("Startup failed")
+
+ callback = CallbackAdapter(
+ lambda target: None,
+ hold=lambda reason: None,
+ open=failed_open,
+ close=lambda: closed.append(True),
+ )
+ with pytest.raises(AdapterError, match="Startup failed"):
+ with Teleoperator(source, robot=callback, config=config):
+ pass
+ assert closed == [True]
+
+
+def test_close_releases_adapter_even_when_hold_fails(setup):
+ source, config, robot = setup
+ manager = Teleoperator(source, robot=robot, config=config)
+ manager.__enter__()
+
+ def fail(reason):
+ raise OSError("Hold failed")
+
+ robot.hold = fail
+ with pytest.raises(AdapterError, match="Hold failed"):
+ manager.close()
+ assert not robot._opened
From ae7b4c5e8c7dc9bf885c33845faa8040d6d804c7 Mon Sep 17 00:00:00 2001
From: Source robotics <30388414+PCrnjak@users.noreply.github.com>
Date: Fri, 6 Nov 2026 00:01:23 +0100
Subject: [PATCH 2/3] Keep simulator ports exclusive on Windows
---
docs/TELEOPERATION.md | 2 +-
src/source_robotics_leap_motion/lab.py | 13 ++++++++++++-
tests/test_lab.py | 7 +++++++
3 files changed, 20 insertions(+), 2 deletions(-)
diff --git a/docs/TELEOPERATION.md b/docs/TELEOPERATION.md
index c9e1dba..0fdde7e 100644
--- a/docs/TELEOPERATION.md
+++ b/docs/TELEOPERATION.md
@@ -129,7 +129,7 @@ mapping, smoothing, command rate caps, adapter capabilities/failures/cleanup, st
configuration, CLI telemetry, and packaged local HTTP assets/actions. Actual robot
motion and clean-PC driver installation remain separate hardware validation work.
-On Windows, the installed b3 wheel passed **179 tests on each of Python 3.10.11,
+On Windows, the installed b3 wheel passed **180 tests on each of Python 3.10.11,
3.11.9, 3.12.10, 3.13.17, 3.14.10, and 3.15.2**. Each environment received 30 live
sequential frames and 30 latest-mode frames. The previously recorded 1,047-frame
session, containing 50 complete left-hand samples, generated 18 simulated targets
diff --git a/src/source_robotics_leap_motion/lab.py b/src/source_robotics_leap_motion/lab.py
index 45b5a3b..6f09cee 100644
--- a/src/source_robotics_leap_motion/lab.py
+++ b/src/source_robotics_leap_motion/lab.py
@@ -5,6 +5,7 @@
import json
import queue
import secrets
+import socket
import sys
import threading
import time
@@ -25,6 +26,16 @@
from .tracker import Tracker
+class _LabHTTPServer(ThreadingHTTPServer):
+ # Windows SO_REUSEADDR permits competing listeners. Keep each lab exclusive.
+ allow_reuse_address = sys.platform != "win32"
+
+ def server_bind(self):
+ if sys.platform == "win32":
+ self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
+ super().server_bind()
+
+
def simulation_config() -> TeleoperationConfig:
return TeleoperationConfig(
workspace=Workspace(Vector3(0, -0.3, 0), Vector3(0.6, 0.3, 0.6)),
@@ -118,7 +129,7 @@ def do_POST(self):
return
self.reply(202, b'{"accepted":true}')
- self._server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
+ self._server = _LabHTTPServer(("127.0.0.1", port), Handler)
self._server.daemon_threads = True
self._thread = threading.Thread(
target=self._server.serve_forever, name="source-leap-lab", daemon=True
diff --git a/tests/test_lab.py b/tests/test_lab.py
index 96a0bc5..7c6af7a 100644
--- a/tests/test_lab.py
+++ b/tests/test_lab.py
@@ -1,6 +1,7 @@
import json
import urllib.error
import urllib.request
+from urllib.parse import urlsplit
import pytest
@@ -69,6 +70,12 @@ def action(token, origin, body=b'{"action":"arm"}'):
assert not server._thread.is_alive()
+def test_lab_port_cannot_be_shared_by_two_simulator_processes():
+ with LabServer() as server:
+ with pytest.raises(OSError):
+ LabServer(urlsplit(server.url).port)
+
+
@pytest.mark.parametrize(
"arguments",
[
From 895dfdb54508c808663900585c09b1f75e57d5e7 Mon Sep 17 00:00:00 2001
From: Source robotics <30388414+PCrnjak@users.noreply.github.com>
Date: Fri, 6 Nov 2026 13:01:55 +0100
Subject: [PATCH 3/3] Document passing b3 live acceptance and release checks
[skip ci]
---
README.md | 55 ++++++++++++++++++++---------------
docs/B3_VALIDATION.md | 55 +++++++++++++++++++++++++++++++++++
docs/CLEAN_MACHINE_CHECK.md | 58 +++++++++++++++++++++++++++++++++++++
docs/TELEOPERATION.md | 2 +-
4 files changed, 145 insertions(+), 25 deletions(-)
create mode 100644 docs/B3_VALIDATION.md
create mode 100644 docs/CLEAN_MACHINE_CHECK.md
diff --git a/README.md b/README.md
index 05fa1c8..495df9c 100644
--- a/README.md
+++ b/README.md
@@ -4,42 +4,43 @@ A Python package for the **Original Leap Motion Controller**, with owned hand da
recording/replay, and configurable coordinate mapping for custom robot integrations.
It runs on current Python without `LeapPython.pyd` or a Python 3.8 process.
-Beta **0.1.0b2** is published on [PyPI](https://pypi.org/project/source-robotics-leap-motion/).
-Install it, then start tracking:
+Install the package from [PyPI](https://pypi.org/project/source-robotics-leap-motion/),
+then start tracking:
```powershell
-python -m pip install source-robotics-leap-motion
+python -m pip install --upgrade source-robotics-leap-motion
source-leap
```
The Windows engine downloads automatically when needed. First-use driver setup may
-require Windows approval. See [release status and validation](docs/BETA_VALIDATION.md)
-and [publication steps](docs/RELEASING.md). Linux/macOS currently use an installed
+require Windows approval. See [release status and validation](https://github.com/Source-Robotics/Source-Robotics-Leap-Motion/blob/main/docs/BETA_VALIDATION.md)
+and [publication steps](https://github.com/Source-Robotics/Source-Robotics-Leap-Motion/blob/main/docs/RELEASING.md). Linux/macOS currently use an installed
vendor tracking service and remain experimental for hardware use.
-This checkout contains **0.1.0b3 development**, adding configurable Cartesian
-teleoperation, callback integration, and a browser lab with simulated output. These
-teleoperation features are available from source. Latest-frame acquisition and
-tracking health are included in the published **0.1.0b2** package. See
-[the teleoperation guide](docs/TELEOPERATION.md) and [latest-frame guide](docs/LATEST_FRAMES.md).
+Version **0.1.0b3** adds configurable Cartesian teleoperation, callback integration,
+and a browser lab with simulated output. It includes latest-frame acquisition and
+tracking health. See
+[the teleoperation guide](https://github.com/Source-Robotics/Source-Robotics-Leap-Motion/blob/main/docs/TELEOPERATION.md) and [latest-frame guide](https://github.com/Source-Robotics/Source-Robotics-Leap-Motion/blob/main/docs/LATEST_FRAMES.md).
Try the simulated robot target with a synthetic hand, or with your Leap controller:
```powershell
-.\.venv\Scripts\source-leap.exe teleop --demo
-.\.venv\Scripts\source-leap.exe teleop
+source-leap teleop --demo
+source-leap teleop
```
The local browser viewer starts disarmed. Arm to calibrate, clutch to reposition
your hand, and disarm to hold output. Both commands use a simulated Cartesian arm.
The callback API accepts your own controller; joint-only arms supply their kinematics
in the adapter. Configure workspace, speed limits, axes, gains, smoothing, rotation,
-and gripper mapping in [teleoperation.toml](examples/teleoperation.toml).
+and gripper mapping in [teleoperation.toml](https://github.com/Source-Robotics/Source-Robotics-Leap-Motion/blob/main/examples/teleoperation.toml).
-**Try it in this project's virtual environment.** Python 3.15.2 and the package are
-already installed in `.venv`. No activation is necessary:
+**Run from a source checkout.** Create a virtual environment with Python 3.10 or
+newer. No activation is necessary when calling its executables directly:
```powershell
+python -m venv .venv
+.\.venv\Scripts\python.exe -m pip install .
.\.venv\Scripts\source-leap.exe --demo
.\.venv\Scripts\source-leap.exe setup
.\.venv\Scripts\source-leap.exe doctor --probe
@@ -57,14 +58,20 @@ python -m pip install .
source-leap --demo
```
-Python **3.10 or newer** is required. The published b2 wheel was installed and tested on
+Python **3.10 or newer** is required. The b3 wheel was 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 107 tests, received live camera frames, and replayed a recorded
+Each version passed all 180 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 live b3 acceptance run checked calibration, clutch/resume, loss of a visible hand,
+explicit rearming, disarming, and shutdown against simulated output. Clean-Windows
+driver setup and real robot-controller validation remain separate hardware checks.
+See the [b3 acceptance evidence](https://github.com/Source-Robotics/Source-Robotics-Leap-Motion/blob/main/docs/B3_VALIDATION.md) and
+[clean-machine checklist](https://github.com/Source-Robotics/Source-Robotics-Leap-Motion/blob/main/docs/CLEAN_MACHINE_CHECK.md).
+
+The [compatibility and performance report](https://github.com/Source-Robotics/Source-Robotics-Leap-Motion/blob/main/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.
@@ -81,7 +88,7 @@ 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).
+[the hardware validation notes](https://github.com/Source-Robotics/Source-Robotics-Leap-Motion/blob/main/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
@@ -131,9 +138,9 @@ 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).
+See [the prototype, evidence, and bounded test command](https://github.com/Source-Robotics/Source-Robotics-Leap-Motion/blob/main/docs/MINIMAL_RUNTIME.md).
The proposed first-use flow and Windows/Linux/macOS targets are documented in
-[installation and platforms](docs/INSTALLATION_PLATFORMS.md).
+[installation and platforms](https://github.com/Source-Robotics/Source-Robotics-Leap-Motion/blob/main/docs/INSTALLATION_PLATFORMS.md).
**Read hand data.** The same API accepts live, synthetic, and replay sources:
@@ -180,7 +187,7 @@ The default remains sequential, which is appropriate when recording every frame.
received/delivered/skipped/duplicate counts, timeouts, timestamp resets, and hand
count. No hands still means the camera is streaming. Age is measured **since Python
received the frame**, not end-to-end camera latency. It cannot detect every delay
-inside a service or transport. See [semantics and validation](docs/LATEST_FRAMES.md).
+inside a service or transport. See [semantics and validation](https://github.com/Source-Robotics/Source-Robotics-Leap-Motion/blob/main/docs/LATEST_FRAMES.md).
```powershell
.\.venv\Scripts\source-leap.exe --latest --health --duration 10
@@ -260,6 +267,6 @@ falls back to another backend. Normal users should not need either option.
```
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
+is preserved in [docs/LEGACY_README.md](https://github.com/Source-Robotics/Source-Robotics-Leap-Motion/blob/main/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).
+in [docs/NATIVE_ABI.md](https://github.com/Source-Robotics/Source-Robotics-Leap-Motion/blob/main/docs/NATIVE_ABI.md).
diff --git a/docs/B3_VALIDATION.md b/docs/B3_VALIDATION.md
new file mode 100644
index 0000000..1ce587a
--- /dev/null
+++ b/docs/B3_VALIDATION.md
@@ -0,0 +1,55 @@
+**b3 acceptance evidence — 6 November 2026**
+
+The Original Leap Motion Controller was tested on the development Windows x64 PC,
+using CPython 3.15.2 and its existing legacy 3.2.1 service. Control output went to
+the simulated Cartesian arm. No physical robot was commanded or driver replaced.
+
+**Guided live-hand run.** A 34.3-second run processed
+2,777 frames, including 2,369 complete hand
+samples (five fingers, four bones per finger), and generated 979
+simulated targets. Calibration, clutch hold, re-anchoring without a command on the
+re-anchor frame, removal of a visible hand, latched hold after its return, explicit
+rearming with a new session, disarming, and adapter/tracker shutdown passed.
+
+Preliminary runs also held output when a hand briefly disappeared. The successful
+run used a stable-hand warmup and a shorter guided cycle to complete each transition.
+The test harness changed; the product's conservative loss/rearm policy did not.
+
+The Python control client used 2.87% of one
+core averaged over this mixed hand-present/hand-absent run. Local receipt age at
+sampling was 0.085 ms median and
+0.205 ms p95. These ages start when Python receives
+a distinct frame; they are not exposure-to-command or camera latency measurements.
+CPU figures exclude the vendor service and are not a matched comparison with the old SDK.
+
+**Twenty-minute camera soak.** 1200.0 seconds processed
+115,180 frames, including 7,179 complete hand
+samples. The camera continued streaming with the hand absent for much of the run.
+There were 0 read timeouts. Average delivery was
+96.0 FPS; Python-client CPU averaged
+2.51% of one core.
+
+Final working set was 31.66 MiB and lifetime peak
+working set was 31.66 MiB. These are Windows
+process counters for this Python client, collected with
+[GetProcessMemoryInfo](https://learn.microsoft.com/en-us/windows/win32/api/psapi/nf-psapi-getprocessmemoryinfo),
+not the vendor engine or total system footprint. History in the runner was bounded.
+The tracker closed and left 0 reader threads.
+
+**Package checks.** The installed b3 code passed 180 tests on each Windows CPython
+3.10–3.15 environment, plus all 18 Windows/Linux/macOS CI matrix jobs and the build.
+The existing real-hand recording passed simulated control checks on each interpreter.
+A fresh Python 3.10 candidate installation passed the headless control demo, packaged
+HTML/JavaScript/CSS serving, dependency checks, and thread-free import. Artifact
+inspection found no native binaries or personal recordings. Release documentation
+updates leave the tested package code, assets, and pinned runtime unchanged.
+
+**Remaining hardware evidence.** The owner has no spare clean Windows PC available
+and chose to continue with the simulator. Clean-PC USB driver setup, actual robot
+adapter/controller behavior, and Linux/macOS USB operation remain unverified.
+The [clean-machine checklist](CLEAN_MACHINE_CHECK.md) records the test procedure.
+Fresh venv and hosted software CI results are not a substitute for those device tests.
+
+Raw local evidence is retained in `.tools/live-b3-acceptance.json`, its earlier-attempt
+reports, `.tools/soak-b3-result.json`, `.tools/b3-candidate-install.json`,
+`.tools/b3-artifact-audit.json`, and `.tools/compatibility-results-0.1.0b3.json`.
diff --git a/docs/CLEAN_MACHINE_CHECK.md b/docs/CLEAN_MACHINE_CHECK.md
new file mode 100644
index 0000000..5e9a757
--- /dev/null
+++ b/docs/CLEAN_MACHINE_CHECK.md
@@ -0,0 +1,58 @@
+**Clean Windows installation acceptance — Original Leap Motion Controller**
+
+This check requires a separate Windows x64 PC with no previous Leap/Ultraleap
+installation or driver. A fresh venv on the development PC does not provide that
+evidence. Hosted CI verifies clean software installation and simulated behavior,
+but has no USB controller attached.
+
+Use a supported 64-bit CPython 3.10–3.15 installation. Create a fresh venv outside
+the source checkout, then install by package name from PyPI. Record the actual
+package version before testing.
+
+```powershell
+python -m venv leap-check
+.\leap-check\Scripts\python.exe -m pip install source-robotics-leap-motion
+.\leap-check\Scripts\source-leap.exe --version
+.\leap-check\Scripts\source-leap.exe teleop --demo --headless --auto-arm --frames 30
+.\leap-check\Scripts\source-leap.exe doctor --json
+```
+
+The demo command requires b3 or later. If it is not yet published, use the prepared
+release-candidate wheel explicitly and record that fact; a local-wheel test is not
+the same as verifying the public package-name installation.
+
+Follow these steps with the physical controller:
+
+1. Start with the camera unplugged. Run `source-leap setup --json`. It should report
+ the absent camera distinctly from a missing driver; it must not install a driver
+ just because no frames arrived.
+2. Plug in the Original controller. Inspect setup's device status. If Windows reports
+ a missing driver, run `source-leap setup --install-driver` and approve Windows'
+ administrator prompt. Record the driver publisher/version and every manual step.
+3. Run `source-leap doctor --probe --json`, then `source-leap --latest --health --duration 10`.
+ Confirm live frames with no hands, then full hand data when a hand is visible.
+4. Run `source-leap teleop`. Arm, clutch, resume, remove the hand, and return it.
+ Confirm a returning hand stays held until explicit rearming. Output goes to the
+ simulated Cartesian arm.
+5. Close all package clients. Confirm the private engine shuts down after its final
+ client. Reopen tracking and repeat the frame probe. Check repeated startups and
+ one abrupt client exit.
+6. Reuse the populated cache with network access unavailable. Tracking should work
+ without another download. Reboot and repeat the probe to verify driver persistence.
+
+Keep the console reports locally. Capture the package/interpreter versions, whether a vendor
+service was present, runtime download/cache behavior, device/driver state, live frame
+and skeleton counts, shutdown/restart outcome, and any unexpected errors.
+
+Passing means the package installs without a compiler or Python 3.8, first use obtains
+the pinned runtime, setup diagnoses the device correctly, the minimum driver path
+works, and tracking/restart/cache reuse succeed. A GUI vendor installation used to
+make the test work must be recorded as an additional prerequisite rather than called
+driver-only success.
+
+**Current evidence.** Package-name installation and live frames were verified in fresh
+project-local venvs on an already working Windows PC. Private-engine startup/sharing/
+crash cleanup was tested separately with its existing driver. No separate clean
+Windows USB installation has passed yet. This PC has no Windows Sandbox executable,
+no installed WSL distribution, and no running Docker engine. Linux/macOS USB and actual
+robot-controller checks also remain pending; their software CI results are separate.
diff --git a/docs/TELEOPERATION.md b/docs/TELEOPERATION.md
index 0fdde7e..97092ac 100644
--- a/docs/TELEOPERATION.md
+++ b/docs/TELEOPERATION.md
@@ -1,4 +1,4 @@
-**Generic teleoperation — 0.1.0b3 development**
+**Generic teleoperation — 0.1.0b3**
The package now includes relative Cartesian control, a synchronous adapter contract,
and a browser lab with a simulated output. The lab is bundled with the Python package