From 1a9258283571193994a9bb545fbd8c71c9edd2c8 Mon Sep 17 00:00:00 2001 From: Source robotics <30388414+PCrnjak@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:26:25 +0100 Subject: [PATCH 1/4] Add independent gripper filtering and an interpolated simulated receiver --- .github/workflows/ci.yml | 2 + README.md | 1 + docs/ARCHITECTURE.md | 4 + docs/README.md | 1 + docs/RECEIVER.md | 146 +++++++++ docs/TELEOPERATION.md | 10 +- examples/README.md | 2 + examples/receiver_expiry.py | 50 ++++ examples/teleoperation.toml | 4 + pyproject.toml | 2 +- src/source_robotics_leap_motion/__init__.py | 6 +- .../adapters/__init__.py | 11 +- .../adapters/base.py | 6 + .../adapters/callback.py | 12 +- .../adapters/receiver.py | 153 ++++++++++ src/source_robotics_leap_motion/cli.py | 5 + src/source_robotics_leap_motion/control.py | 28 +- .../control_config.py | 14 + .../control_models.py | 1 + src/source_robotics_leap_motion/lab.html | 3 +- src/source_robotics_leap_motion/lab.js | 3 + src/source_robotics_leap_motion/lab.py | 25 +- src/source_robotics_leap_motion/receiver.py | 276 ++++++++++++++++++ .../teleoperation.py | 11 +- tests/test_control.py | 52 ++++ tests/test_control_config.py | 28 +- tests/test_lab.py | 25 ++ tests/test_receiver.py | 234 +++++++++++++++ tests/test_receiver_adapter.py | 87 ++++++ tests/test_teleoperation.py | 64 ++++ 30 files changed, 1244 insertions(+), 22 deletions(-) create mode 100644 docs/RECEIVER.md create mode 100644 examples/receiver_expiry.py create mode 100644 src/source_robotics_leap_motion/adapters/receiver.py create mode 100644 src/source_robotics_leap_motion/receiver.py create mode 100644 tests/test_receiver.py create mode 100644 tests/test_receiver_adapter.py 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..b314724 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,7 @@ 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) +- [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/B3_VALIDATION.md) - [Contributing and development setup](https://github.com/Source-Robotics/Source-Robotics-Leap-Motion/blob/main/CONTRIBUTING.md) 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/README.md b/docs/README.md index c5ee600..228f9df 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,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 | diff --git a/docs/RECEIVER.md b/docs/RECEIVER.md new file mode 100644 index 0000000..81a4a4c --- /dev/null +++ b/docs/RECEIVER.md @@ -0,0 +1,146 @@ +# Interpolated simulated receiver + +> These additions are prepared for 0.1.0b4. Until that version is published, use a +> [source checkout](../CONTRIBUTING.md#set-up-a-source-checkout). PyPI currently provides b3. + +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; 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)`. `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. + +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/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..86a5379 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) -> None: ... diff --git a/src/source_robotics_leap_motion/adapters/callback.py b/src/source_robotics_leap_motion/adapters/callback.py index 4a329f1..65edb83 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], 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) -> None: + self._require_open() + if self._begin_session is not None: + self._begin_session(session_id) + 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..e5b6dfe --- /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) -> None: + with self._lock: + self._require_open() + self._check_worker() + self._receiver.begin_session(session_id) + + 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