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 }