diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa827cf..1e0c9fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,8 @@ jobs: - 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 + - run: source-leap teleop --demo --interpolate --headless --auto-arm --frames 3 --json + - run: python examples/receiver_expiry.py build: runs-on: ubuntu-latest steps: diff --git a/README.md b/README.md index fdc8264..0808788 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A Python package for the **Original Leap Motion Controller**. Read hand skeleton record and replay motion, and map hand movement to a simulator or your own robot controller through configurable adapters. -**Status:** [0.1.0b3 is published on PyPI](https://pypi.org/project/source-robotics-leap-motion/). +**Status:** [Published Windows beta on PyPI](https://pypi.org/project/source-robotics-leap-motion/). Windows tracking and the simulated robot workflow have passed live-controller tests. Clean-PC driver installation and Linux/macOS controller hardware tests are still pending. @@ -123,7 +123,8 @@ health counters, configurable Cartesian control, and callback adapters for robot - [User guides and documentation index](https://github.com/Source-Robotics/Source-Robotics-Leap-Motion/blob/main/docs/README.md) - [Python API, recording, and mapping](https://github.com/Source-Robotics/Source-Robotics-Leap-Motion/blob/main/docs/USAGE.md) - [Robot adapters and teleoperation](https://github.com/Source-Robotics/Source-Robotics-Leap-Motion/blob/main/docs/TELEOPERATION.md) -- [Compatibility and measured validation](https://github.com/Source-Robotics/Source-Robotics-Leap-Motion/blob/main/docs/B3_VALIDATION.md) +- [Interpolated receiver and gripper filtering (b4)](https://github.com/Source-Robotics/Source-Robotics-Leap-Motion/blob/main/docs/RECEIVER.md) +- [Compatibility and measured validation](https://github.com/Source-Robotics/Source-Robotics-Leap-Motion/blob/main/docs/B4_VALIDATION.md) - [Contributing and development setup](https://github.com/Source-Robotics/Source-Robotics-Leap-Motion/blob/main/CONTRIBUTING.md) ## Repository layout diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2293c29..b0bda22 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -27,6 +27,7 @@ flowchart LR | `mapping.py`, `_geometry.py` | Coordinate transforms and quaternion operations | | `control.py`, `control_config.py`, `control_models.py` | Pure control state machine, strict configuration, poses, commands, and feedback | | `teleoperation.py`, `adapters/` | Synchronous control/adapter integration, callback adapter, and simulated arm | +| `receiver.py`, `adapters/receiver.py` | Transport-independent interpolation/expiry state machine and optional simulated receiver worker | | `runtime*.py`, `delivery.py`, `setup.py`, `_broker.py` | Discovery, pinned delivery/cache, explicit device setup, and shared runtime ownership | | `cli.py`, `lab.py`, `lab.html`, `lab.js`, `lab.css` | Console entry point and packaged loopback browser lab | @@ -57,6 +58,9 @@ drivers, or an SDK-specific network receiver. A controller integration provides where needed and enforces command expiry/hold at the receiving controller. See [teleoperation](TELEOPERATION.md) for states, ownership, and adapter callbacks. +The optional [receiver](RECEIVER.md) advances independently of incoming commands, +rejects old-session/duplicate packets, and latches command expiry using its own clock. +It provides bounded simulated feedback, not a physical robot watchdog or network transport. ## Runtime ownership diff --git a/docs/B4_VALIDATION.md b/docs/B4_VALIDATION.md new file mode 100644 index 0000000..93eceb3 --- /dev/null +++ b/docs/B4_VALIDATION.md @@ -0,0 +1,81 @@ +# b4 validation — 6 November 2026 + +This report covers independent gripper filtering/cadence and the optional simulated +receiver in 0.1.0b4. The Leap tracking engine, runtime pin, and base dependencies are +unchanged. The implementation uses the package's existing geometry/contracts and +standard library; UltraGrasp supplied design ideas, with no external source bundled. + +## Installed Windows wheel + +The candidate wheel passed **233 tests on each** of CPython 3.10.11, 3.11.9, +3.12.10, 3.13.17, 3.14.10, and 3.15.2 in isolated Windows x64 venvs. Imports resolved +from site-packages, the import/metadata versions matched, and pip check passed. + +Each environment also received 30 live sequential frames and 30 latest-mode frames, +round-tripped the 1,047-frame recording containing 50 complete hand samples, ran +30 interpolated demo frames, and executed the deterministic expiry example. +No hands were visible during the new live probes; full-skeleton input came from +the previously recorded real hand. These checks do not establish a new camera FPS, +tracking-accuracy, or end-to-end latency improvement. + +## Combined recorded-hand run + +The real 1,047-frame recording drove the control engine and deterministic receiver +together using its recorded intervals. The run generated **18 pose commands +and 9 gripper updates**, with a 0.12-second independent gripper time constant +and 30 Hz gripper cap. Receiver Cartesian workspace, linear/angular speed, and +normalized gripper speed checks passed. Clutch held output, explicit resume used a +fresh re-anchor and sender sequence watermark, and tracking loss stayed latched. +There were **zero commands after loss and zero receiver rejections** in this valid run. + +## One-minute simulated receiver soak + +A 60.0-second synthetic-hand run processed 3,457 control steps and +3,440 simulated targets. Five deliberate 0.22-second sender pauses caused +receiver-owned expiry before the sender called it again. Tracking loss emitted no +additional command; explicit recalibration created six distinct sessions in total. +The receiver worker and tracking reader both closed cleanly. + +Python CPU averaged 2.55% of one core for this synthetic, headless +process, including its tracking and receiver workers. This excludes the vendor engine +and browser and is not a hardware or prior-version performance comparison. + +## Receiver and lifecycle regressions + +Behavioral tests verify interpolation continuity under replacement/jitter, per-channel +speed/workspace enforcement, local timeout caps, expiry at the correct instant after +a delayed tick, and expiry while the sender is not calling the simulated adapter. +Duplicates, wrong-session commands, invalid fields, and out-of-order packets do not +refresh deadlines. Pose-only packets do not extend prior gripper/rotation deadlines. + +Clutch resume supplies the sender's last produced sequence, so even previously unseen +packets delayed from before clutching are rejected. Expired streams require a new +session; invalid receiver clocks latch faults. Construction starts no worker, and +close joins it even after an injected worker/close failure. Existing immediate +simulation and omitted gripper settings retain their previous behavior. + +## Platform CI and packaging + +Code commit: `693c666192ba9846ccbc09ebc8e913f9670a59d1`. +Platform CI results are available at the linked run; completion is still pending. +[CI run](https://github.com/Source-Robotics/Source-Robotics-Leap-Motion/actions/runs/42253682504). + +Hosted CI covers Python 3.10–3.15, software behavior, installed CLI demos, and package +builds. Local checks include Ruff, JavaScript syntax, wheel-from-sdist builds, strict +Twine metadata, source-archive contents, documentation links, and candidate installs. +Versioned runtime and vendor notice contents are retained; no native code, numerical +toolbox, robot SDK, recording, or private runtime is added to the Python wheel. + +## Scope + +Output was simulated. No physical robot command, service-state change, or USB driver +installation occurred. The receiver worker is a software scheduling demonstration, +not a real-time guarantee or physical robot watchdog. An actual integration must +enforce expiry/hold where it can still act after the sender or network disappears. + +The earlier live calibration/clutch/hand-loss acceptance and 20-minute camera soak +remain documented in [b3 validation](B3_VALIDATION.md). Clean-Windows driver-only +installation, Linux/macOS controller hardware, and actual robot integration remain +pending. + +[Receiver guide](RECEIVER.md) · [Documentation index](README.md) diff --git a/docs/README.md b/docs/README.md index c5ee600..e6d448b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,8 @@ # Documentation Start with [installation and the two Windows setup paths](../README.md#install-and-run-on-windows). -The current public package is **0.1.0b3**. Windows tracking and simulated output have +Package versions are listed on [PyPI](https://pypi.org/project/source-robotics-leap-motion/). +Windows tracking and simulated output have live hardware evidence; clean-PC driver setup and Linux/macOS hardware remain pending. ## Using the package @@ -10,6 +11,7 @@ live hardware evidence; clean-PC driver setup and Linux/macOS hardware remain pe | --- | --- | | [Python API and CLI](USAGE.md) | Hand data, units, recording/replay, mapping, and diagnostics | | [Teleoperation and adapters](TELEOPERATION.md) | Browser lab, calibration, clutch, limits, TOML, and controller callbacks | +| [Interpolated receiver](RECEIVER.md) | Receiver-side timing/expiry, bounded interpolation, and independent gripper filtering in b4 | | [Latest frames and health](LATEST_FRAMES.md) | Slow consumers, freshness, skipped frames, and health semantics | | [Installation and platforms](INSTALLATION_PLATFORMS.md) | Supported setup paths and platform limitations | | [Runtime setup](RUNTIME.md) | Automatic download/cache, driver prompts, offline setup, and private engines | @@ -33,7 +35,8 @@ CI does not establish USB/controller support on an untested operating system. | Report | Scope | | --- | --- | -| [b3 acceptance](B3_VALIDATION.md) | Current live-hand behavior, 20-minute soak, Python matrix, and remaining checks | +| [b4 receiver validation](B4_VALIDATION.md) | Installed-wheel matrix, independent gripper filtering, interpolation/expiry, and recorded-hand receiver replay | +| [b3 acceptance](B3_VALIDATION.md) | Live-hand behavior, 20-minute camera soak, Python matrix, and remaining hardware checks | | [Clean Windows checklist](CLEAN_MACHINE_CHECK.md) | Procedure for a PC without an existing Leap installation; still pending | | [b1 runtime validation](BETA_VALIDATION.md) | Earlier delivery, caching, shared leases, restart, and crash cleanup | | [Compatibility and performance](COMPATIBILITY_PERFORMANCE.md) | Earlier Python-version checks and old-SDK comparison measurements | diff --git a/docs/RECEIVER.md b/docs/RECEIVER.md new file mode 100644 index 0000000..231a73b --- /dev/null +++ b/docs/RECEIVER.md @@ -0,0 +1,151 @@ +# Interpolated simulated receiver + +> Requires 0.1.0b4 or newer. If that version is not yet available from PyPI, use a +> [source checkout](../CONTRIBUTING.md#set-up-a-source-checkout). + +Version **0.1.0b4** adds an optional simulated command receiver and independent gripper +filtering. The base install remains dependency-light. These components use the +standard library and the package's own geometry; no robot SDK or numerical toolbox +is required. + +## Try it + +```powershell +source-leap teleop --demo --interpolate +``` + +Remove `--demo` to use the connected Leap controller with simulated output. The +viewer starts disarmed. Arm and calibrate normally; its receiver indicator reports +the receiver state and accepted-command count. The viewport uses the interpolated +simulated feedback. Omitting `--interpolate` keeps immediate simulated feedback. + +For a bounded run without opening a browser: + +```powershell +source-leap teleop --demo --interpolate --headless --auto-arm --frames 60 --json +``` + +JSON adds a `receiver` status object; it is `null` for the immediate simulator. +The lab requests a 200 Hz receiver worker and a 40 ms interpolation horizon, with +workspace/speed bounds taken from the teleoperation configuration. This is a software +simulation, not a robot dynamics model or a real-time scheduling guarantee. + +## Independent gripper filtering + +The existing `[gripper]` TOML table accepts two optional settings: + +```toml +[gripper] +source = "grab" +open_value = 1.0 +closed_value = 0.0 +max_speed = 1.0 +deadband = 0.01 +smoothing_time_constant = 0.12 +max_output_hz = 30.0 +``` + +Omit the new settings to retain the previous behavior: gripper smoothing inherits +`teleoperation.smoothing_time_constant`, and every emitted pose command can carry a +gripper value. A separate time constant changes gripper responsiveness independently +of position/orientation smoothing. A separate rate cap reduces gripper updates while +pose updates continue at their own cap. It must not exceed the total teleoperation +command-rate cap. Zero time constant disables smoothing; both frequency caps remain +upper bounds dependent on fresh frames, not fixed-rate guarantees. + +Between gripper updates, `PoseTarget.gripper` is `None`, meaning no new gripper +setpoint. Speed limits use the elapsed time since the last gripper output. Calibration +and clutch re-anchoring reset both its reference and timing. The supplied simulated +receivers preserve existing gripper motion until its own command deadline; pose-only +packets do not extend that deadline. Choose a gripper cadence appropriate to command +lifetime; slower updates can intentionally leave held intervals between valid commands. + +## Receiver contract + +`CartesianReceiver` is a deterministic, transport-independent state machine. Call +`begin_session(session_id)` explicitly, then `accept(PoseTarget)` for incoming commands +and `advance()` from the receiving side's own loop. It retains one bounded interpolation +plan per channel rather than a FIFO of old targets. All time-dependent methods accept +an optional `now=` value from one monotonic clock for deterministic tests/replay. + +- Session IDs identify new calibrations. Packets from other sessions are rejected. +- Sequence numbers must increase; rejected duplicates/out-of-order packets do not + refresh expiry. +- Expiry uses local receipt time plus the smaller of `valid_for_s` and the configured + `command_timeout`. Camera timestamps and wall clocks never extend that deadline. +- Interpolation starts from the current simulated pose, preserving continuity when + replacing a target. Receiver workspace, linear/angular speed, and gripper speed + bounds are enforced independently of sender-side checks. +- A delayed `advance()` samples motion at the expiry instant before holding; it does + not move to the overdue target at the eventual tick time. +- Missing orientation/gripper fields leave their previous plans in place without + refreshing those channels' own expiry. Position packets cannot keep an old gripper + or rotation command valid indefinitely. +- Stream expiry latches. Ordinary packets cannot rearm it. A new explicit session is + required. A deliberate clutch hold/resume can retain its session and sequence + watermark; the resume handshake supplies the sender's last produced sequence, + rejecting even unseen packets delayed from before the clutch. Other holds require + a new session. +- Invalid/regressed receiver time latches a fault; close/reopen is required. + +The [expiry example](../examples/receiver_expiry.py) demonstrates this using explicit +clock values, without sleeping, starting threads, or opening devices: + +```powershell +python examples/receiver_expiry.py +``` + +## Use the receiver with Teleoperator + +`SimulatedReceiver` implements the robot adapter contract and owns one interpolation/ +expiry worker. Construction/import starts no threads. `open()` starts its worker; +`close()` wakes and joins it. `read_feedback()` returns owned simulated feedback. +The worker continues to expire commands while the sending application is idle. + +```python +from source_robotics_leap_motion import ( + Pose, ReceiverConfig, TeleoperationConfig, Teleoperator, Tracker, +) +from source_robotics_leap_motion.adapters import SimulatedReceiver + +config = TeleoperationConfig.from_toml("examples/teleoperation.toml") +center = (config.workspace.minimum + config.workspace.maximum) * 0.5 +robot = SimulatedReceiver( + Pose(center), + config=ReceiverConfig( + workspace=config.workspace, + max_linear_speed=config.max_linear_speed, + max_angular_speed=config.max_angular_speed, + max_gripper_speed=config.gripper.max_speed, + ), +) + +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() +``` + +Session-aware adapters declare `RobotCapabilities.command_sessions=True` and implement +`begin_session(session_id, after_sequence)`. `Teleoperator` calls it after explicit arm calibration +and after a requested clutch resume has received a fresh re-anchor frame. Existing +adapters default to `False` and keep their previous lifecycle. +`CallbackAdapter(..., begin_session=callback)` wraps this optional handshake. +The pure receiver accepts the watermark as the keyword `after_sequence=`; initial +calibration defaults to zero. A resumed sender must supply its current sequence, +which must not be lower than the receiver's accepted sequence. + +This receiver supplies simulation and an integration boundary, not a network protocol +or hardware controller. A physical integration must run expiry/hold where it can +still act after the sending process or network disappears. The simulated worker +inside the sender process cannot supply that physical guarantee. + +## Design reference + +The separate robot-loop and gripper-filter ideas were informed by +[UltraGrasp](https://github.com/kvasios/ultragrasp/tree/3495255321b036b9bcaca99d06f4f7882991d8cf). +This implementation uses the existing package contracts and geometry; no UltraGrasp, +UMI, or Ultraleap binding code is bundled by this addition. + +[Documentation index](README.md) diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 94bc248..7ac40e2 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -1,7 +1,7 @@ # Releasing -Package and runtime releases have separate versions. The published Python package -is **0.1.0b3**; it reuses the pinned Windows 3.2.1 runtime hosted with **v0.1.0b1**. +Package and runtime releases have separate versions. Python package versions b3 and +b4 reuse the pinned Windows 3.2.1 runtime hosted with **v0.1.0b1**. A package or documentation change does not require regenerating the native archive. Published wheel/sdist/runtime files are immutable: use a new version for changed bytes. diff --git a/docs/TELEOPERATION.md b/docs/TELEOPERATION.md index 97092ac..81a344c 100644 --- a/docs/TELEOPERATION.md +++ b/docs/TELEOPERATION.md @@ -11,8 +11,9 @@ and needs no web framework, numerical toolbox, robot SDK, or JavaScript build st ``` 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, +send targets to simulated output. `SimulatedArm` supplies immediate Cartesian +feedback. Add `--interpolate` for the optional [simulated receiver](RECEIVER.md), +with its own interpolation and command-expiry loop. 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. @@ -55,6 +56,11 @@ reference to `arm(reference)` and `release_clutch(reference)`. Orientation/gripp capabilities default to false and must be declared when implemented. Unsupported configured commands are rejected before opening the adapter. +Gripper smoothing and its output-rate cap can be set independently in `[gripper]`; +see [gripper filtering and timing](RECEIVER.md#independent-gripper-filtering). +Session-aware receivers add an explicit `begin_session` handshake on arm and after +fresh clutch re-anchoring; existing adapters keep their previous lifecycle. + 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 diff --git a/examples/README.md b/examples/README.md index 87f193e..7727c8f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -9,9 +9,11 @@ venv's interpreter; [CONTRIBUTING.md](../CONTRIBUTING.md) shows setup without ac | [latest_frames.py](latest_frames.py) | `python examples/latest_frames.py` | Live camera with a slower consumer and health diagnostics; stop with Ctrl+C | | [custom_mapping.py](custom_mapping.py) | `python examples/custom_mapping.py` | Synthetic hand; prints mapped positions | | [custom_robot.py](custom_robot.py) | `python examples/custom_robot.py --demo --duration 2` | Synthetic hand through callbacks to a simulated controller | +| [receiver_expiry.py](receiver_expiry.py) | `python examples/receiver_expiry.py` | Deterministic interpolation, command expiry, and explicit rearming; no hardware or threads | | [my_arm.toml](my_arm.toml) | Used by `custom_mapping.py` | Signed axis mapping and position gains | | [teleoperation.toml](teleoperation.toml) | `source-leap teleop --demo --config examples/teleoperation.toml` | Complete simulation workspace, mapping, speed, orientation, and gripper settings | The runnable adapter example never connects to a physical robot. Replace its callbacks with your controller SDK, units, feedback, and hold/expiry handling when integrating an arm. See [teleoperation](../docs/TELEOPERATION.md) and [API usage](../docs/USAGE.md). +The [receiver guide](../docs/RECEIVER.md) explains `source-leap teleop --demo --interpolate`. diff --git a/examples/receiver_expiry.py b/examples/receiver_expiry.py new file mode 100644 index 0000000..840b008 --- /dev/null +++ b/examples/receiver_expiry.py @@ -0,0 +1,50 @@ +"""Show receiver-side expiry with a deterministic clock and no hardware or threads.""" + +from source_robotics_leap_motion import ( + CartesianReceiver, + Pose, + PoseTarget, + ReceiverConfig, + RobotFeedback, + Vector3, + Workspace, +) + + +def main() -> None: + receiver = CartesianReceiver( + RobotFeedback(Pose(Vector3()), gripper=1.0), + config=ReceiverConfig( + Workspace(Vector3(-1, -1, -1), Vector3(1, 1, 1)), + max_linear_speed=0.2, + interpolation_time=0.04, + command_timeout=0.15, + ), + ) + receiver.begin_session("example-calibration-1", now=0) + command = PoseTarget( + position=Vector3(0.1, 0, 0), + orientation=None, + gripper=0.5, + session_id="example-calibration-1", + sequence=1, + source_frame_id=1, + source_timestamp_us=1_000, + clock_domain="example-camera", + valid_for_s=0.1, + ) + assert receiver.accept(command, now=0) + for now in (0.025, 0.05, 0.1, 1.0): + feedback = receiver.advance(now=now) + print(f"t={now:.3f}s x={feedback.pose.position.x:.3f}m {receiver.status.state}") + assert receiver.status.state == "expired" + # Delayed old packets cannot restart output, even with another source timestamp. + assert not receiver.accept(command, now=1.0) + # A deliberate new calibration/session is required before accepting more output. + receiver.begin_session("example-calibration-2", now=1.0) + print("New session explicitly armed; waiting for a new command.") + receiver.close(now=1.0) + + +if __name__ == "__main__": + main() diff --git a/examples/teleoperation.toml b/examples/teleoperation.toml index d612824..ba7037d 100644 --- a/examples/teleoperation.toml +++ b/examples/teleoperation.toml @@ -28,3 +28,7 @@ open_value = 1.0 closed_value = 0.0 max_speed = 1.0 # normalized units/second deadband = 0.01 +# Optional: independent gripper responsiveness and update cap. +# Omit both to inherit pose smoothing and emit on every pose command. +smoothing_time_constant = 0.12 +max_output_hz = 30.0 diff --git a/pyproject.toml b/pyproject.toml index 8da2a15..071682b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "source-robotics-leap-motion" -version = "0.1.0b3" +version = "0.1.0b4" 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 5c8e5e3..9b21113 100644 --- a/src/source_robotics_leap_motion/__init__.py +++ b/src/source_robotics_leap_motion/__init__.py @@ -16,15 +16,17 @@ from .health import TrackingHealth from .mapping import Mapping from .models import Bone, Finger, Frame, Hand, Palm, Quaternion, Vector3 +from .receiver import CartesianReceiver, ReceiverConfig, ReceiverStatus from .recording import Recorder, read_recording from .teleoperation import Teleoperator from .tracker import Tracker -__version__ = "0.1.0b3" +__version__ = "0.1.0b4" __all__ = [ "AdapterError", "Bone", + "CartesianReceiver", "ConnectionError", "Finger", "Frame", @@ -37,6 +39,8 @@ "PoseTarget", "Quaternion", "Recorder", + "ReceiverConfig", + "ReceiverStatus", "RecordingError", "RuntimeNotFoundError", "RobotCapabilities", diff --git a/src/source_robotics_leap_motion/adapters/__init__.py b/src/source_robotics_leap_motion/adapters/__init__.py index 27cf984..10fb0c1 100644 --- a/src/source_robotics_leap_motion/adapters/__init__.py +++ b/src/source_robotics_leap_motion/adapters/__init__.py @@ -1,7 +1,14 @@ """Robot integration contracts; imports never connect to a controller.""" -from .base import RobotAdapter +from .base import RobotAdapter, SessionRobotAdapter from .callback import CallbackAdapter +from .receiver import SimulatedReceiver from .simulated import SimulatedArm -__all__ = ["CallbackAdapter", "RobotAdapter", "SimulatedArm"] +__all__ = [ + "CallbackAdapter", + "RobotAdapter", + "SessionRobotAdapter", + "SimulatedArm", + "SimulatedReceiver", +] diff --git a/src/source_robotics_leap_motion/adapters/base.py b/src/source_robotics_leap_motion/adapters/base.py index bfadf86..1aaec9a 100644 --- a/src/source_robotics_leap_motion/adapters/base.py +++ b/src/source_robotics_leap_motion/adapters/base.py @@ -13,3 +13,9 @@ def read_feedback(self) -> RobotFeedback | None: ... def send(self, target: PoseTarget) -> None: ... def hold(self, reason: str) -> None: ... def close(self) -> None: ... + + +class SessionRobotAdapter(RobotAdapter, Protocol): + """An adapter with an explicit receiver handshake on arm and clutch resume.""" + + def begin_session(self, session_id: str, after_sequence: int) -> None: ... diff --git a/src/source_robotics_leap_motion/adapters/callback.py b/src/source_robotics_leap_motion/adapters/callback.py index 4a329f1..cb45f73 100644 --- a/src/source_robotics_leap_motion/adapters/callback.py +++ b/src/source_robotics_leap_motion/adapters/callback.py @@ -17,12 +17,17 @@ def __init__( close: Callable[[], None] | None = None, orientation: bool = False, gripper: bool = False, + begin_session: Callable[[str, int], None] | None = None, ) -> None: self.capabilities = RobotCapabilities( - orientation=orientation, gripper=gripper, feedback=feedback is not None + orientation=orientation, + gripper=gripper, + feedback=feedback is not None, + command_sessions=begin_session is not None, ) self._send, self._hold, self._feedback = send, hold, feedback self._open, self._close = open, close + self._begin_session = begin_session self._opened = False def open(self) -> None: @@ -37,6 +42,11 @@ def read_feedback(self) -> RobotFeedback | None: self._require_open() return None if self._feedback is None else self._feedback() + def begin_session(self, session_id: str, after_sequence: int) -> None: + self._require_open() + if self._begin_session is not None: + self._begin_session(session_id, after_sequence) + def send(self, target: PoseTarget) -> None: self._require_open() if target.orientation is not None and not self.capabilities.orientation: diff --git a/src/source_robotics_leap_motion/adapters/receiver.py b/src/source_robotics_leap_motion/adapters/receiver.py new file mode 100644 index 0000000..ef38c11 --- /dev/null +++ b/src/source_robotics_leap_motion/adapters/receiver.py @@ -0,0 +1,153 @@ +"""A simulated Cartesian receiver with its own interpolation/expiry worker.""" + +from __future__ import annotations + +import threading +import time + +from ..control_config import _number +from ..control_models import Pose, PoseTarget, RobotCapabilities, RobotFeedback +from ..errors import AdapterError +from ..receiver import CartesianReceiver, ReceiverConfig, ReceiverStatus + + +class SimulatedReceiver: + """No physical dynamics, network transport, or robot connection. + + Open starts one worker; close wakes and joins it. The worker advances bounded + interpolation and freezes at command expiry even while the sender is idle. + """ + + capabilities = RobotCapabilities( + orientation=True, gripper=True, feedback=True, command_sessions=True + ) + + def __init__( + self, + initial_pose: Pose, + *, + config: ReceiverConfig, + gripper: float = 1.0, + update_hz: float = 200.0, + ) -> None: + _number(update_hz, "Receiver update_hz") + if update_hz > 1000: + raise ValueError("Receiver update_hz must not exceed 1000") + self.config, self.update_hz = config, update_hz + self._receiver = CartesianReceiver(RobotFeedback(initial_pose, gripper), config=config) + self._lock = threading.Lock() + self._stop = threading.Event() + self._thread: threading.Thread | None = None + self._opened = False + self._error: Exception | None = None + self.last_target: PoseTarget | None = None + self.targets_sent = 0 + + def _require_open(self) -> None: + if not self._opened: + raise AdapterError("Open the simulated receiver first") + + def _check_worker(self) -> None: + if self._error is not None: + raise AdapterError("Simulated receiver worker failed") from self._error + + @property + def worker_alive(self) -> bool: + return self._thread is not None and self._thread.is_alive() + + @property + def receiver_status(self) -> ReceiverStatus: + with self._lock: + if self._opened and self._error is None: + self._receiver.advance() + return self._receiver.status + + @property + def hold_reason(self) -> str: + status = self.receiver_status + return "" if status.state == "active" else status.reason + + def open(self) -> None: + with self._lock: + if self._opened or self.worker_alive: + raise AdapterError("Simulated receiver is already open") + self._receiver = CartesianReceiver(self._receiver.feedback, config=self.config) + self._error = None + self._stop = threading.Event() + self._opened = True + self._thread = threading.Thread( + target=self._run, name="source-leap-receiver", daemon=True + ) + try: + self._thread.start() + except BaseException: + self._opened = False + self._stop.set() + self._receiver.close() + raise + + def begin_session(self, session_id: str, after_sequence: int) -> None: + with self._lock: + self._require_open() + self._check_worker() + self._receiver.begin_session(session_id, after_sequence=after_sequence) + + def read_feedback(self) -> RobotFeedback: + with self._lock: + self._require_open() + self._check_worker() + return self._receiver.advance() + + def send(self, target: PoseTarget) -> None: + with self._lock: + self._require_open() + self._check_worker() + if not self._receiver.accept(target): + status = self._receiver.status + raise AdapterError( + f"Receiver rejected command: {status.last_rejection} ({status.reason})" + ) + self.last_target = target + self.targets_sent += 1 + + def hold(self, reason: str) -> None: + with self._lock: + self._require_open() + self._receiver.hold(reason) + + def close(self) -> None: + thread = None + try: + with self._lock: + thread = self._thread + self._stop.set() + self._opened = False + self._receiver.close() + finally: + if thread is not None and thread is not threading.current_thread(): + thread.join(timeout=1.0) + if thread.is_alive(): + raise AdapterError("Simulated receiver worker did not close") + + def _run(self) -> None: + period = 1.0 / self.update_hz + deadline = time.monotonic() + while not self._stop.is_set(): + try: + with self._lock: + if not self._opened: + return + self._receiver.advance() + except Exception as error: + with self._lock: + if not self._opened: + return + self._error = error + self._receiver.fault("receiver_worker_failed") + self._stop.set() + return + deadline += period + now = time.monotonic() + if deadline <= now: + deadline = now + period # Skip missed ticks rather than catching up in a burst. + self._stop.wait(max(0.0, deadline - now)) diff --git a/src/source_robotics_leap_motion/cli.py b/src/source_robotics_leap_motion/cli.py index 5f87dd8..e2a8a70 100644 --- a/src/source_robotics_leap_motion/cli.py +++ b/src/source_robotics_leap_motion/cli.py @@ -110,6 +110,11 @@ def _parser() -> argparse.ArgumentParser: 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( + "--interpolate", + action="store_true", + help="use the simulated receiver with interpolation and independent command expiry", + ) 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" diff --git a/src/source_robotics_leap_motion/control.py b/src/source_robotics_leap_motion/control.py index 717fd29..ef7cd9c 100644 --- a/src/source_robotics_leap_motion/control.py +++ b/src/source_robotics_leap_motion/control.py @@ -52,6 +52,7 @@ def __init__(self, config: TeleoperationConfig) -> None: self._last_frame: Frame | None = None self._last_valid_at: float | None = None self._last_output_at: float | None = None + self._last_gripper_at: float | None = None self._last_now: float | None = None self._resume: RobotFeedback | None = None self._workspace_limited = self._speed_limited = False @@ -99,6 +100,7 @@ def _anchor(self, hand: Hand, reference: RobotFeedback, now: float) -> None: self._pose = reference.pose self._gripper = reference.gripper self._last_output_at = now + self._last_gripper_at = now self._workspace_limited = self._speed_limited = False def arm( @@ -253,23 +255,31 @@ def _target(self, frame: Frame, hand: Hand, now: float) -> PoseTarget: 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: + gripper_output = None + grip = config.gripper + grip_dt = now - self._last_gripper_at + grip_hz = None if grip is None else grip.max_output_hz + if grip is not None and (grip_hz is None or grip_dt + 1e-12 >= 1 / grip_hz): + strength = hand.grab_strength if grip.source == "grab" else hand.pinch_strength + tau = grip.smoothing_time_constant + if tau is None: + tau = config.smoothing_time_constant + grip_alpha = 1.0 if tau == 0 else -math.expm1(-grip_dt / tau) + goal = grip.map(strength) + delta_grip = (goal - self._gripper) * grip_alpha + if abs(goal - self._gripper) <= grip.deadband: delta_grip = 0.0 - step = config.gripper.max_speed * dt + step = grip.max_speed * grip_dt self._gripper += max(-step, min(step, delta_grip)) + gripper_output = self._gripper + self._last_gripper_at = now 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, + gripper_output, self._session, self._sequence, frame.id, diff --git a/src/source_robotics_leap_motion/control_config.py b/src/source_robotics_leap_motion/control_config.py index 005748b..c1aee35 100644 --- a/src/source_robotics_leap_motion/control_config.py +++ b/src/source_robotics_leap_motion/control_config.py @@ -47,6 +47,8 @@ class GripperMapping: closed_value: float = 0.0 max_speed: float = 1.0 deadband: float = 0.01 + smoothing_time_constant: float | None = None + max_output_hz: float | None = None def __post_init__(self) -> None: if self.source not in ("grab", "pinch"): @@ -57,6 +59,12 @@ def __post_init__(self) -> None: if value > 1: raise ValueError(f"{name} must be in [0, 1]") _number(self.max_speed, "Gripper max_speed") + if self.smoothing_time_constant is not None: + _number(self.smoothing_time_constant, "Gripper smoothing_time_constant", zero=True) + if self.max_output_hz is not None: + _number(self.max_output_hz, "Gripper max_output_hz") + if self.max_output_hz > 1000: + raise ValueError("Gripper max_output_hz must not exceed 1000") def map(self, strength: float) -> float: return self.open_value + (self.closed_value - self.open_value) * strength @@ -97,6 +105,12 @@ def __post_init__(self) -> None: 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") + if ( + self.gripper is not None + and self.gripper.max_output_hz is not None + and self.gripper.max_output_hz > self.max_output_hz + ): + raise ValueError("Gripper max_output_hz must not exceed teleoperation.max_output_hz") @classmethod def from_toml(cls, path: str | Path) -> TeleoperationConfig: diff --git a/src/source_robotics_leap_motion/control_models.py b/src/source_robotics_leap_motion/control_models.py index 83ad609..386a9c8 100644 --- a/src/source_robotics_leap_motion/control_models.py +++ b/src/source_robotics_leap_motion/control_models.py @@ -53,3 +53,4 @@ class RobotCapabilities: orientation: bool = False gripper: bool = False feedback: bool = False + command_sessions: bool = False diff --git a/src/source_robotics_leap_motion/lab.html b/src/source_robotics_leap_motion/lab.html index 856860c..123f2d9 100644 --- a/src/source_robotics_leap_motion/lab.html +++ b/src/source_robotics_leap_motion/lab.html @@ -49,6 +49,7 @@

Teleoperation lab

+

Immediate simulated feedback

@@ -86,7 +87,7 @@

Control session

Robot workspace

- Tool targetSimulated toolPath
diff --git a/src/source_robotics_leap_motion/lab.js b/src/source_robotics_leap_motion/lab.js index 2285c68..f0d708f 100644 --- a/src/source_robotics_leap_motion/lab.js +++ b/src/source_robotics_leap_motion/lab.js @@ -107,6 +107,9 @@ function update() { $("connection").textContent = "Local session"; $("connection-dot").className = "dot on"; $("source").textContent = d.source; + $("receiver-status").textContent = d.receiver + ? `Interpolated receiver: ${d.receiver.state} · ${d.receiver.accepted_commands} commands` + : "Immediate simulated feedback"; $("hand-label").textContent = d.hand ? `${d.handedness} hand · #${d.hand.id}` : `No ${d.handedness} hand visible`; diff --git a/src/source_robotics_leap_motion/lab.py b/src/source_robotics_leap_motion/lab.py index 6f09cee..82fe559 100644 --- a/src/source_robotics_leap_motion/lab.py +++ b/src/source_robotics_leap_motion/lab.py @@ -1,4 +1,4 @@ -"""Loopback-only browser lab. Its only output adapter is SimulatedArm.""" +"""Loopback-only browser lab with exclusively simulated output adapters.""" from __future__ import annotations @@ -15,6 +15,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path +from .adapters.receiver import SimulatedReceiver from .adapters.simulated import SimulatedArm from .backends.replay import ReplayBackend from .control_config import GripperMapping, TeleoperationConfig, Workspace @@ -22,6 +23,7 @@ from .errors import TeleoperationError, TrackingTimeoutError from .mapping import Mapping from .models import Vector3 +from .receiver import ReceiverConfig from .teleoperation import Teleoperator from .tracker import Tracker @@ -154,7 +156,9 @@ def __exit__(self, *_exc): self._thread.join(2) -def snapshot(teleop: Teleoperator, robot: SimulatedArm, source: str, message: str) -> dict: +def snapshot( + teleop: Teleoperator, robot: SimulatedArm | SimulatedReceiver, source: str, message: str +) -> dict: status = teleop.status hand = ( None @@ -178,6 +182,7 @@ def snapshot(teleop: Teleoperator, robot: SimulatedArm, source: str, message: st "handedness": teleop.config.handedness, "hand": None if hand is None else {"id": hand.id, "position": asdict(hand.palm.position)}, "robot": asdict(robot.read_feedback()), + "receiver": asdict(robot.receiver_status) if isinstance(robot, SimulatedReceiver) else None, "control": asdict(status), "tracking": teleop.tracker.health.to_dict(), "config": asdict(teleop.config), @@ -213,7 +218,21 @@ def run_lab(args) -> int: ) source = "Leap controller" center = (config.workspace.minimum + config.workspace.maximum) * 0.5 - robot = SimulatedArm(Pose(center)) + robot = ( + SimulatedReceiver( + Pose(center), + config=ReceiverConfig( + workspace=config.workspace, + max_linear_speed=config.max_linear_speed, + max_angular_speed=config.max_angular_speed, + max_gripper_speed=1.0 if config.gripper is None else config.gripper.max_speed, + interpolation_time=min(0.04, config.tracking_timeout), + command_timeout=config.tracking_timeout, + ), + ) + if args.interpolate + else SimulatedArm(Pose(center)) + ) server = None if args.headless else LabServer(args.port) auto_pending = args.auto_arm processed = 0 diff --git a/src/source_robotics_leap_motion/receiver.py b/src/source_robotics_leap_motion/receiver.py new file mode 100644 index 0000000..14da532 --- /dev/null +++ b/src/source_robotics_leap_motion/receiver.py @@ -0,0 +1,285 @@ +"""Deterministic Cartesian receiver with local expiry and bounded interpolation. + +No transport, robot SDK, thread, or source-clock synchronization is involved. +Call advance from a receiver-owned loop independently of the sending application. +""" + +from __future__ import annotations + +import math +import time +from dataclasses import dataclass +from typing import Literal + +from ._geometry import angle, slerp +from .control_config import Workspace, _number +from .control_models import Pose, PoseTarget, RobotFeedback +from .models import Quaternion, Vector3 + + +@dataclass(frozen=True, slots=True) +class ReceiverConfig: + workspace: Workspace + max_linear_speed: float + max_angular_speed: float = 1.0 + max_gripper_speed: float = 1.0 + interpolation_time: float = 0.04 + command_timeout: float = 0.15 + + def __post_init__(self) -> None: + if not isinstance(self.workspace, Workspace): + raise ValueError("Receiver workspace must be a Workspace") + for name in ( + "max_linear_speed", + "max_angular_speed", + "max_gripper_speed", + "command_timeout", + ): + _number(getattr(self, name), "Receiver " + name) + _number(self.interpolation_time, "Receiver interpolation_time", zero=True) + if self.interpolation_time > self.command_timeout: + raise ValueError("Receiver interpolation_time must not exceed command_timeout") + + +@dataclass(frozen=True, slots=True) +class ReceiverStatus: + state: Literal["disarmed", "armed", "active", "held", "expired", "fault", "closed"] + reason: str + session_id: str | None + sequence: int + accepted_commands: int + rejected_commands: int + last_rejection: str | None + last_receipt_age_s: float | None + + +@dataclass(frozen=True, slots=True) +class _Ramp: + start: Vector3 | Quaternion | float + goal: Vector3 | Quaternion | float + started: float + duration: float + expires: float + + def sample(self, now: float): + elapsed = max(0.0, min(now, self.expires) - self.started) + fraction = 1.0 if self.duration == 0 else min(1.0, elapsed / self.duration) + if fraction >= 1: + return self.goal + if isinstance(self.start, Vector3): + return self.start + (self.goal - self.start) * fraction + if isinstance(self.start, Quaternion): + return slerp(self.start, self.goal, fraction) + return self.start + (self.goal - self.start) * fraction + + +class CartesianReceiver: + """Keep one plan per channel and never replay a FIFO of stale targets. + + Explicit begin_session is required before accepting commands and after expiry. + Session IDs must be unique per new calibration. A held clutch can resume the + same session, retaining its sequence watermark; other holds require a new ID. + Source timestamps are metadata only. Every deadline uses local receipt time. + """ + + def __init__(self, initial: RobotFeedback, *, config: ReceiverConfig) -> None: + if not isinstance(initial, RobotFeedback): + raise ValueError("Supply initial RobotFeedback") + if not config.workspace.contains(initial.pose.position): + raise ValueError("Initial receiver pose is outside its workspace") + self.config = config + self._feedback = initial + self._state = "disarmed" + self._reason = "not_armed" + self._session: str | None = None + self._sequence = self._accepted = self._rejected = 0 + self._rejection: str | None = None + self._last_now: float | None = None + self._received: float | None = None + self._deadline: float | None = None + self._resume_allowed = False + self._position: _Ramp | None = None + self._orientation: _Ramp | None = None + self._gripper: _Ramp | None = None + + @property + def feedback(self) -> RobotFeedback: + return self._feedback + + @property + def status(self) -> ReceiverStatus: + age = None if self._received is None else self._last_now - self._received + return ReceiverStatus( + self._state, + self._reason, + self._session, + self._sequence, + self._accepted, + self._rejected, + self._rejection, + age, + ) + + def _now(self, now: float | None) -> float: + value = time.monotonic() if now is None else now + if ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not math.isfinite(value) + or value < 0 + ): + self.fault("invalid_receiver_time") + raise ValueError("Receiver time must be finite and non-negative") + if self._last_now is not None and value < self._last_now: + self.fault("receiver_clock_regressed") + raise ValueError("Receiver time moved backwards") + self._last_now = value + return value + + def _cancel(self) -> None: + self._position = self._orientation = self._gripper = None + self._deadline = None + + def fault(self, reason: str) -> None: + self._cancel() + self._resume_allowed = False + self._state, self._reason = "fault", reason + + def advance(self, *, now: float | None = None) -> RobotFeedback: + now = self._now(now) + if self._state not in ("armed", "active"): + return self._feedback + effective = min(now, self._deadline) + pose = self._feedback.pose + position = pose.position if self._position is None else self._position.sample(effective) + orientation = ( + pose.orientation if self._orientation is None else self._orientation.sample(effective) + ) + gripper = ( + self._feedback.gripper if self._gripper is None else self._gripper.sample(effective) + ) + self._feedback = RobotFeedback(Pose(position, orientation), gripper) + if now >= self._deadline: + self._cancel() + self._resume_allowed = False + self._state, self._reason = "expired", "command_expired" + return self._feedback + + def begin_session( + self, session_id: str, *, after_sequence: int | None = None, now: float | None = None + ) -> None: + self.advance(now=now) + if not isinstance(session_id, str) or not session_id or len(session_id) > 128: + raise ValueError("Session ID must be a non-empty string of at most 128 characters") + if self._state in ("fault", "closed"): + raise ValueError("Reopen the receiver after fault or close") + same = session_id == self._session + if same and not self._resume_allowed: + raise ValueError("Use a new session after disarming or command expiry") + if after_sequence is None: + if same: + raise ValueError("Resume requires the sender's sequence watermark") + after_sequence = 0 + if type(after_sequence) is not int or after_sequence < 0: + raise ValueError("Session sequence watermark must be a non-negative integer") + if self._state in ("armed", "active"): + raise ValueError("Hold the current receiver session before starting another") + if same and after_sequence < self._sequence: + raise ValueError("Session sequence watermark must not move backwards") + self._cancel() + self._sequence = after_sequence + self._session = session_id + self._received = None + self._resume_allowed = False + self._deadline = self._last_now + self.config.command_timeout + self._state, self._reason = "armed", "waiting_for_command" + + def _reject(self, reason: str) -> bool: + self._rejected += 1 + self._rejection = reason + return False + + def accept(self, target: PoseTarget, *, now: float | None = None) -> bool: + self.advance(now=now) + if self._state not in ("armed", "active"): + return self._reject("receiver_not_armed") + if not isinstance(target, PoseTarget): + return self._reject("invalid_target") + if target.session_id != self._session: + return self._reject("session_mismatch") + if type(target.sequence) is not int or target.sequence <= self._sequence: + return self._reject("sequence_not_increasing") + if ( + not isinstance(target.position, Vector3) + or (target.orientation is not None and not isinstance(target.orientation, Quaternion)) + or ( + target.gripper is not None + and ( + isinstance(target.gripper, bool) + or not isinstance(target.gripper, (int, float)) + or not math.isfinite(target.gripper) + or not 0 <= target.gripper <= 1 + ) + ) + or isinstance(target.valid_for_s, bool) + or not isinstance(target.valid_for_s, (int, float)) + or not math.isfinite(target.valid_for_s) + or target.valid_for_s <= 0 + or type(target.source_frame_id) is not int + or target.source_frame_id < 0 + or type(target.source_timestamp_us) is not int + or target.source_timestamp_us < 0 + or not isinstance(target.clock_domain, str) + or not target.clock_domain + ): + return self._reject("invalid_target") + if not self.config.workspace.contains(target.position): + return self._reject("outside_workspace") + if target.gripper is not None and self._feedback.gripper is None: + return self._reject("missing_gripper_reference") + now = self._last_now + deadline = now + min(target.valid_for_s, self.config.command_timeout) + current = self._feedback + duration = max( + self.config.interpolation_time, + (target.position - current.pose.position).norm / self.config.max_linear_speed, + ) + self._position = _Ramp(current.pose.position, target.position, now, duration, deadline) + if target.orientation is not None: + duration = max( + self.config.interpolation_time, + angle(current.pose.orientation, target.orientation) / self.config.max_angular_speed, + ) + self._orientation = _Ramp( + current.pose.orientation, target.orientation, now, duration, deadline + ) + if target.gripper is not None: + duration = max( + self.config.interpolation_time, + abs(target.gripper - current.gripper) / self.config.max_gripper_speed, + ) + self._gripper = _Ramp(current.gripper, target.gripper, now, duration, deadline) + self._received, self._deadline = now, deadline + self._sequence = target.sequence + self._accepted += 1 + self._rejection = None + self._state, self._reason = "active", "interpolating" + return True + + def hold(self, reason: str = "held", *, now: float | None = None) -> None: + self.advance(now=now) + if self._state in ("fault", "closed"): + return + self._resume_allowed = reason == "clutched" and ( + self._state in ("armed", "active") or self._resume_allowed + ) + self._cancel() + self._state, self._reason = "held", reason + + def close(self, *, now: float | None = None) -> None: + try: + self.advance(now=now) + finally: + self._cancel() + self._resume_allowed = False + self._state, self._reason = "closed", "closed" diff --git a/src/source_robotics_leap_motion/teleoperation.py b/src/source_robotics_leap_motion/teleoperation.py index 72f2422..1d24b24 100644 --- a/src/source_robotics_leap_motion/teleoperation.py +++ b/src/source_robotics_leap_motion/teleoperation.py @@ -55,6 +55,10 @@ def __enter__(self) -> Self: 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") + if capabilities.command_sessions and not callable( + getattr(self.robot, "begin_session", None) + ): + raise TeleoperationError("Session-aware adapter must implement begin_session") self.controller = TeleoperationController(self.config) self._disconnected = False self.last_frame = None @@ -80,7 +84,7 @@ def _call(self, operation: str, *arguments): except Exception as error: self.controller.fault(f"adapter_{operation}_failed") extra = "" - if operation in ("send", "read_feedback"): + if operation in ("send", "read_feedback", "begin_session"): try: self.robot.hold("adapter_fault") except Exception as hold_error: @@ -109,6 +113,8 @@ def arm(self, reference: RobotFeedback | None = None, *, timeout: float | None = 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) + if self.robot.capabilities.command_sessions: + self._call("begin_session", self.status.session_id, self.status.sequence) def disarm(self) -> None: self._require_open() @@ -167,6 +173,9 @@ def step(self, *, timeout: float | None = None) -> PoseTarget | None: raise if before in ("active", "clutched") and self.status.state == "tracking_lost": self._call("hold", self.status.reason) + if before == "clutched" and self.status.state == "active": + if self.robot.capabilities.command_sessions: + self._call("begin_session", self.status.session_id, self.status.sequence) if target is not None: self._call("send", target) return target diff --git a/tests/test_control.py b/tests/test_control.py index b066a11..ac74d36 100644 --- a/tests/test_control.py +++ b/tests/test_control.py @@ -223,6 +223,58 @@ def test_gripper_mapping_limits_speed_and_holds_during_clutch(): assert controller.status.last_target.gripper == pytest.approx(0.95) +def test_gripper_smoothing_is_independent_of_position_smoothing(): + controller = armed( + smoothing_time_constant=0, + gripper=GripperMapping(smoothing_time_constant=1, max_speed=100, deadband=0), + ) + target = controller.update(frame(1, Vector3(0.1, 0, 0), grab=1), now=0.1) + assert target.position.x == pytest.approx(0.1) + assert target.gripper == pytest.approx(math.exp(-0.1)) + + +def test_gripper_defaults_inherit_pose_smoothing_and_emit_on_each_pose_command(): + controller = armed( + smoothing_time_constant=1, + gripper=GripperMapping(max_speed=100, deadband=0), + ) + target = controller.update(frame(1, Vector3(1, 0, 0), grab=1), now=0.1) + assert target.position.x == pytest.approx(1 - math.exp(-0.1)) + assert target.gripper == pytest.approx(math.exp(-0.1)) + + +def test_slower_gripper_rate_does_not_slow_pose_updates_or_bypass_gripper_speed_limits(): + controller = armed( + max_output_hz=100, + gripper=GripperMapping(max_output_hz=20, max_speed=0.5, deadband=0), + ) + outputs = [] + for index in range(1, 11): + target = controller.update( + frame(index, Vector3(index / 100, 0, 0), grab=1, timestamp=index * 10_000), + now=index / 100, + ) + assert target is not None + if target.gripper is not None: + outputs.append((index, target.gripper)) + assert outputs == [(5, pytest.approx(0.975)), (10, pytest.approx(0.95))] + assert controller.status.sequence == 10 + + +def test_clutch_reanchor_resets_gripper_timing_and_reference(): + controller = armed( + tracking_timeout=2, + gripper=GripperMapping(max_output_hz=20, max_speed=0.5, deadband=0), + ) + controller.update(frame(1, grab=1), now=0.1) + controller.clutch() + controller.update(frame(2), now=0.2) + controller.release_clutch(RobotFeedback(Pose(Vector3()), 0.5)) + assert controller.update(frame(3), now=0.7) is None + assert controller.update(frame(4), now=0.71).gripper is None + assert controller.update(frame(5), now=0.75).gripper == pytest.approx(0.525) + + 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): diff --git a/tests/test_control_config.py b/tests/test_control_config.py index 7d6fc29..c61ccc6 100644 --- a/tests/test_control_config.py +++ b/tests/test_control_config.py @@ -31,7 +31,17 @@ def test_workspace_rejects_inverted_bounds(): @pytest.mark.parametrize( "settings", - [{"source": "fist"}, {"max_speed": 0}, {"open_value": 1.1}, {"closed_value": float("nan")}], + [ + {"source": "fist"}, + {"max_speed": 0}, + {"open_value": 1.1}, + {"closed_value": float("nan")}, + {"smoothing_time_constant": -1}, + {"smoothing_time_constant": True}, + {"max_output_hz": 0}, + {"max_output_hz": float("inf")}, + {"max_output_hz": 1001}, + ], ) def test_invalid_gripper_settings(settings): with pytest.raises(ValueError): @@ -51,12 +61,28 @@ def test_toml_configuration_and_typo_rejection(tmp_path): control_orientation = true [gripper] source = "pinch" +smoothing_time_constant = 0.2 +max_output_hz = 20 """ 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 + assert config.gripper.smoothing_time_constant == 0.2 + assert config.gripper.max_output_hz == 20 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) + + +def test_gripper_frequency_cannot_exceed_the_total_command_cap(): + from source_robotics_leap_motion import GripperMapping + + with pytest.raises(ValueError, match="teleoperation.max_output_hz"): + TeleoperationConfig( + Workspace(Vector3(), Vector3(1, 1, 1)), + 1, + max_output_hz=30, + gripper=GripperMapping(max_output_hz=60), + ) diff --git a/tests/test_lab.py b/tests/test_lab.py index 7c6af7a..20b7f96 100644 --- a/tests/test_lab.py +++ b/tests/test_lab.py @@ -27,6 +27,31 @@ def test_headless_demo_can_explicitly_arm_simulation(capsys): assert states[-1]["control"]["sequence"] > 0 +def test_headless_demo_can_use_the_interpolating_receiver_and_closes_its_worker(capsys): + import threading + + assert ( + main( + [ + "teleop", + "--demo", + "--interpolate", + "--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]["receiver"]["accepted_commands"] > 0 + assert states[-1]["receiver"]["session_id"] == states[-1]["control"]["session_id"] + assert not any(t.name == "source-leap-receiver" for t in threading.enumerate()) + + def test_lab_server_serves_packaged_viewer_and_authenticated_bounded_actions(): server = LabServer() with server: diff --git a/tests/test_receiver.py b/tests/test_receiver.py new file mode 100644 index 0000000..20c90ff --- /dev/null +++ b/tests/test_receiver.py @@ -0,0 +1,250 @@ +import math +from dataclasses import replace + +import pytest + +from source_robotics_leap_motion import ( + CartesianReceiver, + Pose, + PoseTarget, + Quaternion, + ReceiverConfig, + RobotFeedback, + Vector3, + Workspace, +) +from source_robotics_leap_motion._geometry import angle + + +def config(**overrides): + values = dict( + workspace=Workspace(Vector3(-2, -2, -2), Vector3(2, 2, 2)), + max_linear_speed=1, + max_angular_speed=1, + max_gripper_speed=1, + interpolation_time=0.1, + command_timeout=0.5, + ) + values.update(overrides) + return ReceiverConfig(**values) + + +def receiver(**overrides): + return CartesianReceiver(RobotFeedback(Pose(Vector3()), 1.0), config=config(**overrides)) + + +def target(sequence=1, *, position=None, session="one", ttl=0.5, orientation=None, gripper=None): + position = Vector3(1, 0, 0) if position is None else position + return PoseTarget(position, orientation, gripper, session, sequence, 10, 1_000, "camera", ttl) + + +def test_session_handshake_is_explicit_and_commands_never_arm_a_receiver(): + rx = receiver() + assert not rx.accept(target(), now=0) + assert rx.feedback.pose.position == Vector3() + rx.begin_session("one", now=0) + assert rx.status.state == "armed" + assert not rx.accept(target(session="other"), now=0) + assert rx.accept(target(), now=0) + assert rx.status.accepted_commands == 1 + + +def test_interpolation_is_continuous_when_a_new_target_replaces_the_old_one(): + rx = receiver(max_linear_speed=10) + rx.begin_session("one", now=0) + rx.accept(target(), now=0) + before = rx.advance(now=0.04) + assert before.pose.position.x == pytest.approx(0.4) + assert rx.accept(target(2, position=Vector3(-0.5, 0, 0)), now=0.04) + assert rx.feedback == before + assert rx.advance(now=0.09).pose.position.x == pytest.approx(-0.05) + assert before.pose.position.x == pytest.approx(0.4) # Owned feedback remains unchanged. + + +def test_delayed_tick_stops_at_the_expiry_instant_not_the_eventual_tick_time(): + rx = receiver(max_linear_speed=0.2) + rx.begin_session("one", now=0) + rx.accept(target(ttl=0.1), now=0) + result = rx.advance(now=10) + assert result.pose.position.x == pytest.approx(0.02) + assert rx.status.state == "expired" + assert not rx.accept(target(2), now=10) + with pytest.raises(ValueError, match="new session"): + rx.begin_session("one", now=10) + rx.begin_session("two", now=10) + assert not rx.accept(target(3, session="one"), now=10) + assert rx.accept(target(1, session="two"), now=10) + + +def test_duplicates_and_out_of_order_commands_do_not_refresh_expiry(): + rx = receiver() + rx.begin_session("one", now=0) + rx.accept(target(5, ttl=0.1), now=0) + for index, sequence in enumerate((5, 3, 0), start=1): + assert not rx.accept(target(sequence), now=index * 0.02) + rx.advance(now=0.1) + assert rx.status.state == "expired" + assert rx.status.sequence == 5 + assert rx.status.rejected_commands == 3 + + +def test_source_clocks_cannot_extend_the_local_timeout_cap(): + rx = receiver(command_timeout=0.2) + rx.begin_session("one", now=5) + command = replace(target(ttl=1000), source_timestamp_us=10**20, clock_domain="remote-future") + assert rx.accept(command, now=5) + assert rx.advance(now=5.2).pose.position.x == pytest.approx(0.2) + assert rx.status.state == "expired" + + +def test_no_first_command_also_expires_and_holds_the_initial_pose(): + rx = receiver(command_timeout=0.2) + rx.begin_session("one", now=0) + assert rx.advance(now=0.2).pose.position == Vector3() + assert rx.status.state == "expired" + assert not rx.accept(target(), now=0.2) + + +def test_clutch_holds_and_explicit_resume_keeps_the_sequence_watermark(): + rx = receiver() + rx.begin_session("one", now=0) + rx.accept(target(5), now=0) + rx.hold("clutched", now=0.03) + held = rx.feedback + rx.hold("clutched", now=0.04) # Repeating a hold is idempotent. + assert rx.advance(now=8) == held + assert not rx.accept(target(6), now=8) + rx.begin_session("one", after_sequence=5, now=8) + assert not rx.accept(target(5), now=8) + assert rx.accept(target(6), now=8) + rx.hold("hand_missing", now=8.01) + with pytest.raises(ValueError, match="new session"): + rx.begin_session("one", now=8.01) + + +def test_resume_rejects_unseen_packets_sent_before_clutching(): + rx = receiver() + rx.begin_session("one", now=0) + rx.accept(target(5), now=0) + rx.hold("clutched", now=0.03) + with pytest.raises(ValueError, match="watermark"): + rx.begin_session("one", now=0.04) + with pytest.raises(ValueError, match="watermark"): + rx.begin_session("one", after_sequence=4, now=0.04) + # The sender had produced commands through #10, even though only #5 arrived. + rx.begin_session("one", after_sequence=10, now=0.04) + assert not rx.accept(target(9), now=0.04) + assert not rx.accept(target(10), now=0.04) + assert rx.accept(target(11), now=0.04) + + +def test_pose_only_updates_preserve_gripper_and_rotation_plans_without_refreshing_them(): + rx = receiver() + rx.begin_session("one", now=0) + rotation = Quaternion(0, 0, math.sin(0.5), math.cos(0.5)) + rx.accept(target(orientation=rotation, gripper=0, ttl=0.1), now=0) + rx.accept(target(2, ttl=0.5), now=0.05) + assert rx.advance(now=0.075).gripper == pytest.approx(0.925) + result = rx.advance(now=0.2) + assert rx.status.state == "active" + assert result.gripper == pytest.approx(0.9) + assert angle(Quaternion(), result.pose.orientation) == pytest.approx(0.1) + assert result.pose.position.x == pytest.approx(0.2) + + +def test_each_channel_obeys_its_speed_limit_with_jitter_and_replacement(): + rx = receiver(max_linear_speed=0.2, max_angular_speed=0.5, max_gripper_speed=0.3) + rx.begin_session("one", now=0) + rx.accept(target(orientation=Quaternion(0, 0, 1, 0), gripper=0), now=0) + previous, previous_time = rx.feedback, 0.0 + for index in range(1, 81): + now = index * 0.005 + current = rx.advance(now=now) + dt = now - previous_time + assert (current.pose.position - previous.pose.position).norm <= 0.2 * dt + 1e-12 + assert angle(previous.pose.orientation, current.pose.orientation) <= 0.5 * dt + 1e-9 + assert abs(current.gripper - previous.gripper) <= 0.3 * dt + 1e-12 + assert rx.config.workspace.contains(current.pose.position) + if index % 7 == 0: + rx.accept(target(index, position=Vector3(-1, 1, 0)), now=now) + previous, previous_time = current, now + + +@pytest.mark.parametrize( + "changes", + [ + {"sequence": True}, + {"sequence": 1.5}, + {"valid_for_s": float("nan")}, + {"valid_for_s": float("inf")}, + {"valid_for_s": 0}, + {"valid_for_s": True}, + {"position": None}, + {"orientation": "invalid"}, + {"gripper": float("nan")}, + {"gripper": 2}, + {"gripper": True}, + {"source_frame_id": -1}, + {"source_timestamp_us": -1}, + {"clock_domain": ""}, + ], +) +def test_invalid_packets_do_not_move_or_refresh_the_receiver(changes): + rx = receiver() + rx.begin_session("one", now=0) + assert not rx.accept(replace(target(), **changes), now=0) + assert rx.status.accepted_commands == 0 + assert rx.advance(now=0.5).pose.position == Vector3() + assert rx.status.state == "expired" + + +def test_workspace_and_missing_gripper_reference_are_receiver_side_checks(): + rx = CartesianReceiver(RobotFeedback(Pose(Vector3())), config=config()) + rx.begin_session("one", now=0) + assert not rx.accept(target(position=Vector3(3, 0, 0)), now=0) + assert rx.status.last_rejection == "outside_workspace" + assert not rx.accept(target(gripper=0), now=0) + assert rx.status.last_rejection == "missing_gripper_reference" + assert rx.accept(target(), now=0) + + +@pytest.mark.parametrize("now", [-1, float("nan"), float("inf"), True]) +def test_invalid_receiver_clock_latches_a_fault(now): + rx = receiver() + rx.begin_session("one", now=0) + with pytest.raises(ValueError): + rx.advance(now=now) + assert rx.status.state == "fault" + with pytest.raises(ValueError, match="Reopen"): + rx.begin_session("two", now=1) + + +def test_regressed_clock_faults_and_close_cancels_all_motion(): + rx = receiver() + rx.begin_session("one", now=1) + rx.accept(target(), now=1) + feedback = rx.advance(now=1.02) + with pytest.raises(ValueError, match="backwards"): + rx.advance(now=1.01) + assert rx.advance(now=2) == feedback + rx.close(now=2) + assert rx.advance(now=3) == feedback + assert rx.status.state == "closed" + with pytest.raises(ValueError, match="Reopen"): + rx.begin_session("two", now=3) + + +@pytest.mark.parametrize( + "settings", + [ + {"max_linear_speed": 0}, + {"max_angular_speed": float("nan")}, + {"max_gripper_speed": True}, + {"command_timeout": 0}, + {"interpolation_time": -1}, + {"interpolation_time": 1}, + ], +) +def test_invalid_receiver_configuration_is_rejected(settings): + with pytest.raises(ValueError): + config(**settings) diff --git a/tests/test_receiver_adapter.py b/tests/test_receiver_adapter.py new file mode 100644 index 0000000..c71f45c --- /dev/null +++ b/tests/test_receiver_adapter.py @@ -0,0 +1,87 @@ +import threading +import time + +import pytest + +from source_robotics_leap_motion import ( + AdapterError, + Pose, + PoseTarget, + ReceiverConfig, + Vector3, + Workspace, +) +from source_robotics_leap_motion.adapters import SimulatedReceiver + + +def robot(): + return SimulatedReceiver( + Pose(Vector3()), + config=ReceiverConfig( + Workspace(Vector3(-1, -1, -1), Vector3(1, 1, 1)), + 1, + interpolation_time=0, + command_timeout=0.5, + ), + update_hz=200, + ) + + +def test_constructor_is_idle_and_worker_expires_while_the_sender_is_not_calling_it(): + initial_threads = set(threading.enumerate()) + rx = robot() + assert set(threading.enumerate()) == initial_threads + rx.open() + try: + assert rx.worker_alive + rx.begin_session("one", 0) + rx.send(PoseTarget(Vector3(1, 0, 0), None, None, "one", 1, 1, 1, "camera", 0.03)) + # Inspect cached status directly: polling must not perform the watchdog work. + deadline = time.monotonic() + 2 + while time.monotonic() < deadline: + with rx._lock: + expired = rx._receiver.status.state == "expired" + feedback = rx._receiver.feedback + if expired: + break + threading.Event().wait(0.005) + assert expired + assert feedback.pose.position.x == pytest.approx(0.03) + with pytest.raises(AdapterError, match="not_armed"): + rx.send(PoseTarget(Vector3(), None, None, "one", 2, 2, 2, "camera", 0.1)) + finally: + rx.close() + assert not rx.worker_alive + assert rx.receiver_status.state == "closed" + + +def test_failed_worker_is_reported_and_even_failed_close_joins_it(monkeypatch): + rx = robot() + rx.open() + + def fail(*, now=None): + raise OSError("receiver clock failed") + + with rx._lock: + monkeypatch.setattr(rx._receiver, "advance", fail) + assert rx._stop.wait(2) + with pytest.raises(AdapterError, match="worker failed"): + rx.read_feedback() + with pytest.raises(OSError, match="clock failed"): + rx.close() + assert not rx.worker_alive + + +def test_reopening_starts_a_new_idle_receiver_and_does_not_replay_targets(): + rx = robot() + rx.open() + rx.begin_session("one", 0) + rx.close() + rx.open() + try: + assert rx.receiver_status.state == "disarmed" + with pytest.raises(AdapterError, match="not_armed"): + rx.send(PoseTarget(Vector3(), None, None, "one", 1, 1, 1, "camera", 0.1)) + finally: + rx.close() + assert not rx.worker_alive diff --git a/tests/test_teleoperation.py b/tests/test_teleoperation.py index 0437440..6b94aa7 100644 --- a/tests/test_teleoperation.py +++ b/tests/test_teleoperation.py @@ -238,3 +238,67 @@ def fail(reason): with pytest.raises(AdapterError, match="Hold failed"): manager.close() assert not robot._opened + + +def test_receiver_handshakes_follow_explicit_arm_and_completed_clutch_reanchor(setup): + source, config, robot = setup + sessions = [] + adapter = CallbackAdapter( + robot.send, + hold=robot.hold, + feedback=robot.read_feedback, + open=robot.open, + close=robot.close, + begin_session=lambda session, sequence: sessions.append((session, sequence)), + ) + with Teleoperator(source, robot=adapter, config=config) as teleop: + assert sessions == [] + teleop.arm() + first = teleop.status.session_id + assert sessions == [(first, 0)] + teleop.step() + sent = robot.targets_sent + teleop.clutch() + teleop.release_clutch() + assert sessions == [(first, 0)] + assert teleop.step() is None + assert sessions == [(first, 0), (first, sent)] + assert robot.targets_sent == sent + teleop.step() + source.events.append(Frame(100, 200_000, ())) + teleop.step() + assert teleop.status.state == "tracking_lost" + teleop.arm() + assert len(sessions) == 3 and sessions[-1][0] != first and sessions[-1][1] == 0 + + +def test_failed_receiver_handshake_holds_and_latches_adapter_fault(setup): + source, config, robot = setup + + def fail(session, sequence): + raise OSError("receiver refused session") + + adapter = CallbackAdapter( + robot.send, + hold=robot.hold, + feedback=robot.read_feedback, + open=robot.open, + close=robot.close, + begin_session=fail, + ) + with Teleoperator(source, robot=adapter, config=config) as teleop: + with pytest.raises(AdapterError, match="refused session") as error: + teleop.arm() + assert isinstance(error.value.__cause__, OSError) + assert teleop.status.state == "fault" + assert robot.hold_reason == "adapter_fault" + assert robot.targets_sent == 0 + + +def test_missing_session_handshake_is_rejected_before_open(setup): + source, config, robot = setup + robot.capabilities = RobotCapabilities(feedback=True, command_sessions=True) + with pytest.raises(TeleoperationError, match="begin_session"): + with Teleoperator(source, robot=robot, config=config): + pass + assert not robot._opened