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 @@
Immediate simulated feedback