lerobot-teleoperator-livekit 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,31 @@
1
+ # Design docs
2
+ spec.md
3
+ implementation.md
4
+
5
+ # Rust
6
+ /target
7
+ **/*.rs.bk
8
+
9
+ # Python
10
+ __pycache__/
11
+ *.py[cod]
12
+ *.egg-info/
13
+ dist/
14
+ build/
15
+ .venv/
16
+ .pytest_cache/
17
+
18
+ # Env files — keep `.env.example` as a committed template.
19
+ .env
20
+ .env.*
21
+ !.env.example
22
+
23
+ # Built cdylib + generated UniFFI Python module (both produced by
24
+ # scripts/build_native.sh). Match at any depth for the workspace layout.
25
+ **/livekit/portal/liblivekit_portal_ffi.dylib
26
+ **/livekit/portal/liblivekit_portal_ffi.so
27
+ **/livekit/portal/livekit_portal_ffi.dll
28
+ **/livekit/portal/livekit_portal_ffi.py
29
+
30
+ # OS
31
+ .DS_Store
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: lerobot-teleoperator-livekit
3
+ Version: 0.1.0
4
+ Summary: LiveKit Portal teleoperator plugin for lerobot (robot-side)
5
+ License: Apache-2.0
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: lerobot>=0.5.0
8
+ Requires-Dist: livekit-portal
9
+ Requires-Dist: numpy>=1.24
@@ -0,0 +1,12 @@
1
+ """LiveKit Portal teleoperator plugin for lerobot.
2
+
3
+ Deployed on the **robot side**. Wraps `livekit.portal.Portal` in `Role.ROBOT`
4
+ so lerobot can drive a remote physical robot by running a teleop loop that
5
+ pushes actions over LiveKit. Importing this module registers
6
+ ``LiveKitTeleoperator`` as ``--teleop.type=livekit``.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from .teleoperator import LiveKitTeleoperator, LiveKitTeleoperatorConfig
11
+
12
+ __all__ = ["LiveKitTeleoperator", "LiveKitTeleoperatorConfig"]
@@ -0,0 +1,293 @@
1
+ """LiveKit Portal teleoperator implementation.
2
+
3
+ Runs on the physical robot. Opens a Portal as ``Role.ROBOT``, publishing
4
+ camera frames + state via ``send_feedback(...)`` and surfacing received
5
+ actions via ``get_action()``. When constructed with a local lerobot
6
+ ``Robot`` instance, it introspects ``observation_features`` /
7
+ ``action_features`` to derive motor and camera shapes automatically — the
8
+ physical robot stays in the user's loop; the plugin just brokers the wire.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import threading
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+ import numpy as np
18
+ from lerobot.teleoperators.config import TeleoperatorConfig
19
+ from lerobot.teleoperators.teleoperator import Teleoperator
20
+
21
+ from livekit.portal import DType, Portal, PortalConfig, Role
22
+
23
+
24
+ @TeleoperatorConfig.register_subclass("livekit")
25
+ @dataclass
26
+ class LiveKitTeleoperatorConfig(TeleoperatorConfig):
27
+ url: str = ""
28
+ token: str = ""
29
+ session: str = ""
30
+ fps: int = 30
31
+
32
+ # Explicit-mode fallbacks when no local Robot is passed to the constructor
33
+ # (e.g. the ``--teleop.type=livekit`` CLI path, which can't pass instances).
34
+ # Ignored when a robot is provided.
35
+ motors: tuple[str, ...] = ()
36
+ camera_names: tuple[str, ...] = ()
37
+
38
+ # Portal tuning.
39
+ slack: int | None = None
40
+ tolerance: float | None = None
41
+ state_reliable: bool = True
42
+ action_reliable: bool = True
43
+ reuse_stale_frames: bool = False
44
+
45
+
46
+ def _split_observation_features(
47
+ features: dict,
48
+ ) -> tuple[list[str], dict[str, tuple[int, ...]]]:
49
+ """Separate scalar motor keys from camera (tuple-valued) keys."""
50
+ motor_keys: list[str] = []
51
+ cameras: dict[str, tuple[int, ...]] = {}
52
+ for key, val in features.items():
53
+ if isinstance(val, tuple):
54
+ cameras[key] = val
55
+ else:
56
+ motor_keys.append(key)
57
+ return sorted(motor_keys), cameras
58
+
59
+
60
+ class LiveKitTeleoperator(Teleoperator):
61
+ """lerobot Teleoperator that forwards actions over a Portal session.
62
+
63
+ Construct with an optional local ``Robot`` instance; its features
64
+ determine the motor keys and camera list used by the network layer.
65
+ Without a robot, falls back to ``config.motors`` + ``config.camera_names``.
66
+
67
+ Typical use on the robot side::
68
+
69
+ robot = MyPhysicalRobot(...)
70
+ teleop = LiveKitTeleoperator(cfg, robot=robot)
71
+ teleop.connect()
72
+ while running:
73
+ obs = robot.get_observation()
74
+ teleop.send_feedback(obs)
75
+ action = teleop.get_action()
76
+ if action:
77
+ robot.send_action(action)
78
+ """
79
+
80
+ config_class = LiveKitTeleoperatorConfig
81
+ name = "livekit"
82
+
83
+ def __init__(
84
+ self,
85
+ config: LiveKitTeleoperatorConfig,
86
+ robot: Any | None = None,
87
+ ) -> None:
88
+ super().__init__(config)
89
+ self.config = config
90
+
91
+ self._state_keys, self._action_keys, self._cameras = self._resolve_schema(
92
+ config, robot
93
+ )
94
+ # Portal's state/action fields are the raw motor names (without the
95
+ # ".pos" suffix); lerobot observation/action dicts use the suffix.
96
+ self._state_motors = [_strip_pos(k) for k in self._state_keys]
97
+ self._action_motors = [_strip_pos(k) for k in self._action_keys]
98
+ self._camera_names = list(self._cameras.keys())
99
+
100
+ self._act_features: dict = {k: float for k in self._action_keys}
101
+ self._feedback_features: dict = {k: float for k in self._state_keys}
102
+ for name, shape in self._cameras.items():
103
+ self._feedback_features[name] = shape
104
+
105
+ self._portal: Portal | None = None
106
+ self._portal_cfg: PortalConfig | None = None
107
+ self._loop: asyncio.AbstractEventLoop | None = None
108
+ self._loop_thread: threading.Thread | None = None
109
+ self._connected = False
110
+
111
+ # -- lerobot interface ----------------------------------------------------
112
+
113
+ @property
114
+ def action_features(self) -> dict:
115
+ return self._act_features
116
+
117
+ @property
118
+ def feedback_features(self) -> dict:
119
+ return self._feedback_features
120
+
121
+ @property
122
+ def is_connected(self) -> bool:
123
+ return self._connected
124
+
125
+ @property
126
+ def is_calibrated(self) -> bool:
127
+ return True
128
+
129
+ def calibrate(self) -> None:
130
+ pass
131
+
132
+ def configure(self) -> None:
133
+ pass
134
+
135
+ def connect(self, calibrate: bool = True) -> None:
136
+ if self._connected:
137
+ return
138
+ if not self.config.url or not self.config.token:
139
+ raise RuntimeError(
140
+ "LiveKitTeleoperatorConfig.url and .token are required; mint a"
141
+ " token with Role.ROBOT grants before calling connect()."
142
+ )
143
+
144
+ self._start_loop()
145
+
146
+ self._portal_cfg = PortalConfig(self.config.session or "lerobot", Role.ROBOT)
147
+ for cam in self._camera_names:
148
+ self._portal_cfg.add_video(cam)
149
+ if self._state_motors:
150
+ self._portal_cfg.add_state_typed(
151
+ [(name, DType.F64) for name in self._state_motors]
152
+ )
153
+ if self._action_motors:
154
+ self._portal_cfg.add_action_typed(
155
+ [(name, DType.F64) for name in self._action_motors]
156
+ )
157
+ self._portal_cfg.set_fps(self.config.fps)
158
+ if self.config.slack is not None:
159
+ self._portal_cfg.set_slack(self.config.slack)
160
+ if self.config.tolerance is not None:
161
+ self._portal_cfg.set_tolerance(self.config.tolerance)
162
+ self._portal_cfg.set_state_reliable(self.config.state_reliable)
163
+ self._portal_cfg.set_action_reliable(self.config.action_reliable)
164
+ self._portal_cfg.set_reuse_stale_frames(self.config.reuse_stale_frames)
165
+
166
+ self._portal = Portal(self._portal_cfg)
167
+ self._run(self._portal.connect(self.config.url, self.config.token))
168
+ self._connected = True
169
+
170
+ def disconnect(self) -> None:
171
+ if not self._connected:
172
+ return
173
+ try:
174
+ if self._portal is not None:
175
+ self._run(self._portal.disconnect())
176
+ finally:
177
+ if self._portal is not None:
178
+ self._portal.close()
179
+ self._portal = None
180
+ if self._portal_cfg is not None:
181
+ self._portal_cfg.close()
182
+ self._portal_cfg = None
183
+ self._stop_loop()
184
+ self._connected = False
185
+
186
+ def get_action(self) -> dict[str, Any]:
187
+ """Latest action received from the operator, keyed like the local
188
+ robot's ``action_features``. Empty dict if nothing has arrived."""
189
+ if self._portal is None:
190
+ return {}
191
+ action = self._portal.get_action()
192
+ if action is None:
193
+ return {}
194
+ return {
195
+ k: float(action.values.get(m, 0.0))
196
+ for k, m in zip(self._action_keys, self._action_motors)
197
+ }
198
+
199
+ def send_feedback(self, feedback: dict[str, Any]) -> None:
200
+ """Publish the local robot's observation to the operator.
201
+
202
+ ``feedback`` is the same dict shape returned by
203
+ ``robot.get_observation()``: motor keys (e.g. ``"shoulder.pos"``) map
204
+ to floats, camera keys (matching those in ``observation_features``)
205
+ map to ``np.ndarray(H, W, 3)`` uint8 RGB. Unknown keys are ignored.
206
+ """
207
+ if self._portal is None or not self._connected:
208
+ return
209
+
210
+ if self._state_motors:
211
+ state: dict[str, float] = {}
212
+ for key, motor in zip(self._state_keys, self._state_motors):
213
+ if key in feedback:
214
+ state[motor] = float(feedback[key])
215
+ if state:
216
+ self._portal.send_state(state)
217
+
218
+ for cam in self._camera_names:
219
+ frame = feedback.get(cam)
220
+ if frame is None:
221
+ continue
222
+ if not isinstance(frame, np.ndarray):
223
+ raise TypeError(
224
+ f"camera feedback '{cam}' must be np.ndarray (H, W, 3)"
225
+ f" uint8 RGB; got {type(frame).__name__}"
226
+ )
227
+ self._portal.send_video_frame(cam, frame)
228
+
229
+ # -- schema resolution ---------------------------------------------------
230
+
231
+ @staticmethod
232
+ def _resolve_schema(
233
+ config: LiveKitTeleoperatorConfig,
234
+ robot: Any | None,
235
+ ) -> tuple[list[str], list[str], dict[str, tuple[int, ...]]]:
236
+ if robot is not None:
237
+ obs_features = dict(getattr(robot, "observation_features", {}))
238
+ act_features = dict(getattr(robot, "action_features", {}))
239
+ if not obs_features and not act_features:
240
+ raise ValueError(
241
+ "local robot has empty observation_features /"
242
+ " action_features; cannot infer schema"
243
+ )
244
+ state_keys, cameras = _split_observation_features(obs_features)
245
+ action_keys = sorted(act_features.keys())
246
+ return state_keys, action_keys, cameras
247
+
248
+ if config.motors or config.camera_names:
249
+ state_keys = sorted(f"{m}.pos" for m in config.motors)
250
+ action_keys = list(state_keys)
251
+ # Shape is metadata only; Portal accepts any resolution at runtime.
252
+ cameras = {name: () for name in config.camera_names}
253
+ return state_keys, action_keys, cameras
254
+
255
+ raise ValueError(
256
+ "LiveKitTeleoperator needs either a local Robot instance or"
257
+ " config.motors / config.camera_names to derive its schema"
258
+ )
259
+
260
+ # -- background loop plumbing --------------------------------------------
261
+
262
+ def _start_loop(self) -> None:
263
+ self._loop = asyncio.new_event_loop()
264
+ started = threading.Event()
265
+
266
+ def _runner() -> None:
267
+ asyncio.set_event_loop(self._loop)
268
+ started.set()
269
+ self._loop.run_forever()
270
+
271
+ self._loop_thread = threading.Thread(
272
+ target=_runner, name="livekit-portal-loop", daemon=True
273
+ )
274
+ self._loop_thread.start()
275
+ started.wait()
276
+
277
+ def _stop_loop(self) -> None:
278
+ if self._loop is None:
279
+ return
280
+ self._loop.call_soon_threadsafe(self._loop.stop)
281
+ if self._loop_thread is not None:
282
+ self._loop_thread.join(timeout=5.0)
283
+ self._loop.close()
284
+ self._loop = None
285
+ self._loop_thread = None
286
+
287
+ def _run(self, coro):
288
+ assert self._loop is not None, "background loop not started"
289
+ return asyncio.run_coroutine_threadsafe(coro, self._loop).result()
290
+
291
+
292
+ def _strip_pos(key: str) -> str:
293
+ return key[: -len(".pos")] if key.endswith(".pos") else key
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.20", "hatch-vcs"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "lerobot-teleoperator-livekit"
7
+ dynamic = ["version"]
8
+ description = "LiveKit Portal teleoperator plugin for lerobot (robot-side)"
9
+ requires-python = ">=3.12"
10
+ license = { text = "Apache-2.0" }
11
+ dependencies = [
12
+ "livekit-portal",
13
+ "lerobot>=0.5.0",
14
+ "numpy>=1.24",
15
+ ]
16
+
17
+ [tool.uv.sources]
18
+ livekit-portal = { workspace = true }
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["lerobot_teleoperator_livekit"]
22
+
23
+ [tool.hatch.version]
24
+ source = "vcs"
25
+ tag-pattern = "^v(?P<version>.+)$"
26
+ raw-options = { search_parent_directories = true }