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.
- lerobot_teleoperator_livekit-0.1.0/.gitignore +31 -0
- lerobot_teleoperator_livekit-0.1.0/PKG-INFO +9 -0
- lerobot_teleoperator_livekit-0.1.0/lerobot_teleoperator_livekit/__init__.py +12 -0
- lerobot_teleoperator_livekit-0.1.0/lerobot_teleoperator_livekit/teleoperator.py +293 -0
- lerobot_teleoperator_livekit-0.1.0/pyproject.toml +26 -0
|
@@ -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 }
|