lerobot-robot-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-robot-livekit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LiveKit Portal robot plugin for lerobot (operator-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 robot plugin for lerobot.
|
|
2
|
+
|
|
3
|
+
Deployed on the **operator side**. Makes a remote physical robot appear as a
|
|
4
|
+
local ``Robot`` to any lerobot workflow (teleoperation, data recording,
|
|
5
|
+
policy evaluation). Importing this module registers ``LiveKitRobot`` as
|
|
6
|
+
``--robot.type=livekit``.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .robot import LiveKitRobot, LiveKitRobotConfig
|
|
11
|
+
|
|
12
|
+
__all__ = ["LiveKitRobot", "LiveKitRobotConfig"]
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""LiveKit Portal robot implementation.
|
|
2
|
+
|
|
3
|
+
Runs on the operator side. Opens a Portal as ``Role.OPERATOR`` and presents
|
|
4
|
+
the remote physical robot as a local lerobot ``Robot``. When constructed
|
|
5
|
+
with a local lerobot ``Teleoperator`` (e.g. a leader arm, a gamepad), it
|
|
6
|
+
introspects ``action_features`` to derive motor keys automatically. the
|
|
7
|
+
local teleop stays in the user's loop and generates the actions that
|
|
8
|
+
LiveKitRobot forwards over the wire.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import threading
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from lerobot.robots.config import RobotConfig
|
|
18
|
+
from lerobot.robots.robot import Robot
|
|
19
|
+
|
|
20
|
+
from livekit.portal import DType, Portal, PortalConfig, Role, i420_bytes_to_numpy_rgb
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@RobotConfig.register_subclass("livekit")
|
|
24
|
+
@dataclass
|
|
25
|
+
class LiveKitRobotConfig(RobotConfig):
|
|
26
|
+
url: str = ""
|
|
27
|
+
token: str = ""
|
|
28
|
+
session: str = ""
|
|
29
|
+
fps: int = 30
|
|
30
|
+
|
|
31
|
+
# Explicit-mode fallbacks used only when no local Teleoperator is passed.
|
|
32
|
+
motors: tuple[str, ...] = ()
|
|
33
|
+
camera_names: tuple[str, ...] = ()
|
|
34
|
+
camera_height: int = 480
|
|
35
|
+
camera_width: int = 640
|
|
36
|
+
|
|
37
|
+
# Portal tuning.
|
|
38
|
+
slack: int | None = None
|
|
39
|
+
tolerance: float | None = None
|
|
40
|
+
state_reliable: bool = True
|
|
41
|
+
action_reliable: bool = True
|
|
42
|
+
reuse_stale_frames: bool = False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class LiveKitRobot(Robot):
|
|
46
|
+
"""lerobot Robot that receives synced observations from a remote physical
|
|
47
|
+
robot over a Portal session and publishes actions back to it.
|
|
48
|
+
|
|
49
|
+
Construct with an optional local ``Teleoperator`` instance; its
|
|
50
|
+
``action_features`` determine the motor keys used over the wire. State
|
|
51
|
+
is assumed to mirror the action schema (standard lerobot convention),
|
|
52
|
+
and camera names must be provided separately — the local teleop doesn't
|
|
53
|
+
know what cameras the remote robot has.
|
|
54
|
+
|
|
55
|
+
Typical operator-side use::
|
|
56
|
+
|
|
57
|
+
leader = MyLeaderArmTeleop(...)
|
|
58
|
+
robot = LiveKitRobot(cfg, teleop=leader)
|
|
59
|
+
robot.connect()
|
|
60
|
+
while running:
|
|
61
|
+
obs = robot.get_observation()
|
|
62
|
+
action = leader.get_action()
|
|
63
|
+
robot.send_action(action)
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
config_class = LiveKitRobotConfig
|
|
67
|
+
name = "livekit"
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
config: LiveKitRobotConfig,
|
|
72
|
+
teleop: Any | None = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
super().__init__(config)
|
|
75
|
+
self.config = config
|
|
76
|
+
|
|
77
|
+
self._state_keys, self._action_keys, self._cameras = self._resolve_schema(
|
|
78
|
+
config, teleop
|
|
79
|
+
)
|
|
80
|
+
self._state_motors = [_strip_pos(k) for k in self._state_keys]
|
|
81
|
+
self._action_motors = [_strip_pos(k) for k in self._action_keys]
|
|
82
|
+
self._camera_names = list(self._cameras.keys())
|
|
83
|
+
|
|
84
|
+
self._obs_features: dict = {k: float for k in self._state_keys}
|
|
85
|
+
for name, shape in self._cameras.items():
|
|
86
|
+
self._obs_features[name] = shape
|
|
87
|
+
self._act_features: dict = {k: float for k in self._action_keys}
|
|
88
|
+
|
|
89
|
+
self._portal: Portal | None = None
|
|
90
|
+
self._portal_cfg: PortalConfig | None = None
|
|
91
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
92
|
+
self._loop_thread: threading.Thread | None = None
|
|
93
|
+
self._connected = False
|
|
94
|
+
self._last_observation_timestamp_us: int | None = None
|
|
95
|
+
|
|
96
|
+
# -- lerobot interface ----------------------------------------------------
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def observation_features(self) -> dict:
|
|
100
|
+
return self._obs_features
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def action_features(self) -> dict:
|
|
104
|
+
return self._act_features
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def is_connected(self) -> bool:
|
|
108
|
+
return self._connected
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def is_calibrated(self) -> bool:
|
|
112
|
+
return True
|
|
113
|
+
|
|
114
|
+
def calibrate(self) -> None:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
def configure(self) -> None:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
def connect(self, calibrate: bool = True) -> None:
|
|
121
|
+
if self._connected:
|
|
122
|
+
return
|
|
123
|
+
if not self.config.url or not self.config.token:
|
|
124
|
+
raise RuntimeError(
|
|
125
|
+
"LiveKitRobotConfig.url and .token are required; mint a token"
|
|
126
|
+
" with Role.OPERATOR grants before calling connect()."
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
self._start_loop()
|
|
130
|
+
|
|
131
|
+
self._portal_cfg = PortalConfig(self.config.session or "lerobot", Role.OPERATOR)
|
|
132
|
+
for cam in self._camera_names:
|
|
133
|
+
self._portal_cfg.add_video(cam)
|
|
134
|
+
if self._state_motors:
|
|
135
|
+
self._portal_cfg.add_state_typed(
|
|
136
|
+
[(name, DType.F64) for name in self._state_motors]
|
|
137
|
+
)
|
|
138
|
+
if self._action_motors:
|
|
139
|
+
self._portal_cfg.add_action_typed(
|
|
140
|
+
[(name, DType.F64) for name in self._action_motors]
|
|
141
|
+
)
|
|
142
|
+
self._portal_cfg.set_fps(self.config.fps)
|
|
143
|
+
if self.config.slack is not None:
|
|
144
|
+
self._portal_cfg.set_slack(self.config.slack)
|
|
145
|
+
if self.config.tolerance is not None:
|
|
146
|
+
self._portal_cfg.set_tolerance(self.config.tolerance)
|
|
147
|
+
self._portal_cfg.set_state_reliable(self.config.state_reliable)
|
|
148
|
+
self._portal_cfg.set_action_reliable(self.config.action_reliable)
|
|
149
|
+
self._portal_cfg.set_reuse_stale_frames(self.config.reuse_stale_frames)
|
|
150
|
+
|
|
151
|
+
self._portal = Portal(self._portal_cfg)
|
|
152
|
+
self._run(self._portal.connect(self.config.url, self.config.token))
|
|
153
|
+
self._connected = True
|
|
154
|
+
|
|
155
|
+
def disconnect(self) -> None:
|
|
156
|
+
if not self._connected:
|
|
157
|
+
return
|
|
158
|
+
try:
|
|
159
|
+
if self._portal is not None:
|
|
160
|
+
self._run(self._portal.disconnect())
|
|
161
|
+
finally:
|
|
162
|
+
if self._portal is not None:
|
|
163
|
+
self._portal.close()
|
|
164
|
+
self._portal = None
|
|
165
|
+
if self._portal_cfg is not None:
|
|
166
|
+
self._portal_cfg.close()
|
|
167
|
+
self._portal_cfg = None
|
|
168
|
+
self._stop_loop()
|
|
169
|
+
self._connected = False
|
|
170
|
+
|
|
171
|
+
def get_observation(self) -> dict[str, Any]:
|
|
172
|
+
"""Latest synced observation from the remote robot, shaped for
|
|
173
|
+
lerobot (``{motor}.pos -> float``, ``{camera} -> np.ndarray(H,W,3)``
|
|
174
|
+
uint8 RGB). Empty dict until the first observation syncs.
|
|
175
|
+
|
|
176
|
+
The sender-side timestamp of this observation is available via
|
|
177
|
+
:attr:`last_observation_timestamp_us` after the call returns.
|
|
178
|
+
"""
|
|
179
|
+
if self._portal is None:
|
|
180
|
+
return {}
|
|
181
|
+
obs = self._portal.get_observation()
|
|
182
|
+
if obs is None:
|
|
183
|
+
return {}
|
|
184
|
+
self._last_observation_timestamp_us = obs.timestamp_us
|
|
185
|
+
out: dict[str, Any] = {}
|
|
186
|
+
for key, motor in zip(self._state_keys, self._state_motors):
|
|
187
|
+
if motor in obs.state:
|
|
188
|
+
out[key] = float(obs.state[motor])
|
|
189
|
+
for cam in self._camera_names:
|
|
190
|
+
frame = obs.frames.get(cam)
|
|
191
|
+
if frame is not None:
|
|
192
|
+
out[cam] = i420_bytes_to_numpy_rgb(
|
|
193
|
+
frame.data, frame.width, frame.height
|
|
194
|
+
)
|
|
195
|
+
return out
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def last_observation_timestamp_us(self) -> int | None:
|
|
199
|
+
"""Sender's system time in µs (epoch) for the most recent observation
|
|
200
|
+
returned by :meth:`get_observation`, or ``None`` if none yet."""
|
|
201
|
+
return self._last_observation_timestamp_us
|
|
202
|
+
|
|
203
|
+
def metrics(self):
|
|
204
|
+
"""Snapshot of the underlying Portal's metrics (RTT, sync delta, jitter,
|
|
205
|
+
buffer fill, drops). Returns ``None`` when disconnected."""
|
|
206
|
+
if self._portal is None:
|
|
207
|
+
return None
|
|
208
|
+
return self._portal.metrics()
|
|
209
|
+
|
|
210
|
+
def send_action(self, action: dict[str, Any]) -> dict[str, Any]:
|
|
211
|
+
"""Publish an action to the remote robot. Returns ``action`` unchanged
|
|
212
|
+
so callers can record it."""
|
|
213
|
+
if self._portal is None or not self._connected:
|
|
214
|
+
return action
|
|
215
|
+
values: dict[str, float] = {}
|
|
216
|
+
for key, motor in zip(self._action_keys, self._action_motors):
|
|
217
|
+
if key in action:
|
|
218
|
+
values[motor] = float(action[key])
|
|
219
|
+
if values:
|
|
220
|
+
self._portal.send_action(values)
|
|
221
|
+
return action
|
|
222
|
+
|
|
223
|
+
# -- schema resolution ---------------------------------------------------
|
|
224
|
+
|
|
225
|
+
@staticmethod
|
|
226
|
+
def _resolve_schema(
|
|
227
|
+
config: LiveKitRobotConfig,
|
|
228
|
+
teleop: Any | None,
|
|
229
|
+
) -> tuple[list[str], list[str], dict[str, tuple[int, ...]]]:
|
|
230
|
+
camera_shape = (config.camera_height, config.camera_width, 3)
|
|
231
|
+
cameras = {name: camera_shape for name in config.camera_names}
|
|
232
|
+
|
|
233
|
+
if teleop is not None:
|
|
234
|
+
act_features = dict(getattr(teleop, "action_features", {}))
|
|
235
|
+
if not act_features:
|
|
236
|
+
raise ValueError(
|
|
237
|
+
"local teleop has empty action_features; cannot infer"
|
|
238
|
+
" schema"
|
|
239
|
+
)
|
|
240
|
+
action_keys = sorted(act_features.keys())
|
|
241
|
+
# lerobot convention: observation mirrors action for telemetry
|
|
242
|
+
# (each commanded motor reports its actual position back).
|
|
243
|
+
state_keys = list(action_keys)
|
|
244
|
+
return state_keys, action_keys, cameras
|
|
245
|
+
|
|
246
|
+
if config.motors or config.camera_names:
|
|
247
|
+
state_keys = sorted(f"{m}.pos" for m in config.motors)
|
|
248
|
+
action_keys = list(state_keys)
|
|
249
|
+
return state_keys, action_keys, cameras
|
|
250
|
+
|
|
251
|
+
raise ValueError(
|
|
252
|
+
"LiveKitRobot needs either a local Teleoperator instance or"
|
|
253
|
+
" config.motors / config.camera_names to derive its schema"
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# -- background loop plumbing --------------------------------------------
|
|
257
|
+
|
|
258
|
+
def _start_loop(self) -> None:
|
|
259
|
+
self._loop = asyncio.new_event_loop()
|
|
260
|
+
started = threading.Event()
|
|
261
|
+
|
|
262
|
+
def _runner() -> None:
|
|
263
|
+
asyncio.set_event_loop(self._loop)
|
|
264
|
+
started.set()
|
|
265
|
+
self._loop.run_forever()
|
|
266
|
+
|
|
267
|
+
self._loop_thread = threading.Thread(
|
|
268
|
+
target=_runner, name="livekit-portal-loop", daemon=True
|
|
269
|
+
)
|
|
270
|
+
self._loop_thread.start()
|
|
271
|
+
started.wait()
|
|
272
|
+
|
|
273
|
+
def _stop_loop(self) -> None:
|
|
274
|
+
if self._loop is None:
|
|
275
|
+
return
|
|
276
|
+
self._loop.call_soon_threadsafe(self._loop.stop)
|
|
277
|
+
if self._loop_thread is not None:
|
|
278
|
+
self._loop_thread.join(timeout=5.0)
|
|
279
|
+
self._loop.close()
|
|
280
|
+
self._loop = None
|
|
281
|
+
self._loop_thread = None
|
|
282
|
+
|
|
283
|
+
def _run(self, coro):
|
|
284
|
+
assert self._loop is not None, "background loop not started"
|
|
285
|
+
return asyncio.run_coroutine_threadsafe(coro, self._loop).result()
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _strip_pos(key: str) -> str:
|
|
289
|
+
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-robot-livekit"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "LiveKit Portal robot plugin for lerobot (operator-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_robot_livekit"]
|
|
22
|
+
|
|
23
|
+
[tool.hatch.version]
|
|
24
|
+
source = "vcs"
|
|
25
|
+
tag-pattern = "^v(?P<version>.+)$"
|
|
26
|
+
raw-options = { search_parent_directories = true }
|