From a38746cf7f16ed5f4163f597882da23b54c39e78 Mon Sep 17 00:00:00 2001 From: Martino Russi Date: Sat, 18 Jul 2026 18:46:14 +0200 Subject: [PATCH] feat(unitree_g1): read physical wireless remote onboard for locomotion In onboard mode the laptop client has no robot->laptop feedback, so the physical Unitree remote (whose bytes ride in lowstate) was never parsed and joystick commands never reached the controller. Parse the remote directly from local lowstate in the onboard controller loop; it takes priority over laptop/exo axes when active. Also add axis/button debug logging to both runners. Co-authored-by: Cursor --- .../robots/unitree_g1/run_g1_onboard.py | 9 ++- .../robots/unitree_g1/run_g1_teleop_client.py | 8 ++- src/lerobot/robots/unitree_g1/unitree_g1.py | 62 +++++++++++++++++++ 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/src/lerobot/robots/unitree_g1/run_g1_onboard.py b/src/lerobot/robots/unitree_g1/run_g1_onboard.py index d6baa333d..6f1f921cd 100644 --- a/src/lerobot/robots/unitree_g1/run_g1_onboard.py +++ b/src/lerobot/robots/unitree_g1/run_g1_onboard.py @@ -100,8 +100,13 @@ def main() -> None: robot.send_action(action) n += 1 - if n % 200 == 0: - logger.info("Applied %d actions", n) + if n % 60 == 0: + axes = { + k: round(float(action.get(k, 0.0)), 3) + for k in ("remote.lx", "remote.ly", "remote.rx", "remote.ry") + } + btn = {k: action.get(k) for k in ("remote.button.0", "remote.button.4") if k in action} + logger.info("Applied %d actions | axes=%s buttons=%s", n, axes, btn) finally: logger.info("Shutting down onboard controller...") robot.disconnect() diff --git a/src/lerobot/robots/unitree_g1/run_g1_teleop_client.py b/src/lerobot/robots/unitree_g1/run_g1_teleop_client.py index a02f302eb..4855355dd 100644 --- a/src/lerobot/robots/unitree_g1/run_g1_teleop_client.py +++ b/src/lerobot/robots/unitree_g1/run_g1_teleop_client.py @@ -101,8 +101,12 @@ def main() -> None: except zmq.Again: pass n += 1 - if n % 200 == 0: - logger.info("Sent %d actions", n) + if n % 60 == 0: + axes = { + k: round(float(action.get(k, 0.0)), 3) + for k in ("remote.lx", "remote.ly", "remote.rx", "remote.ry") + } + logger.info("Sent %d actions | axes=%s", n, axes) if period: time.sleep(max(0.0, period - (time.time() - t0))) except KeyboardInterrupt: diff --git a/src/lerobot/robots/unitree_g1/unitree_g1.py b/src/lerobot/robots/unitree_g1/unitree_g1.py index 45209853a..1c355aa53 100644 --- a/src/lerobot/robots/unitree_g1/unitree_g1.py +++ b/src/lerobot/robots/unitree_g1/unitree_g1.py @@ -53,6 +53,7 @@ if TYPE_CHECKING or _unitree_sdk_available: LowState_ as hg_LowState, ) from unitree_sdk2py.utils.crc import CRC + from unitree_sdk2py.utils.joystick import Joystick else: _SDKChannelFactoryInitialize = None _SDKChannelPublisher = None @@ -61,6 +62,17 @@ else: hg_LowCmd = None hg_LowState = None CRC = None + Joystick = None + + +# Wireless-remote button byte layout (little-endian uint16 from bytes 2-3), mapped to +# the positional button indices the locomotion controllers expect. Mirrors the exo +# teleoperator's RemoteController so the onboard path reads the physical Unitree remote +# identically. +_REMOTE_BUTTON_MAP: list[str] = [ + "RB", "LB", "start", "back", "RT", "LT", "", "", + "A", "B", "X", "Y", "up", "right", "down", "left", +] logger = logging.getLogger(__name__) @@ -163,6 +175,10 @@ class UnitreeG1(Robot): self.controller_input = default_remote_input() self.controller_output = {} + # Onboard-only: parser for the physical Unitree wireless remote (read straight + # from local lowstate so joystick locomotion works without a laptop round-trip). + self._joystick = None + def _subscribe_lowstate(self): # polls robot state @ 250Hz while not self._shutdown_event.is_set(): start_time = time.time() @@ -277,6 +293,13 @@ class UnitreeG1(Robot): with self._controller_action_lock: controller_input = dict(self.controller_input) + # Onboard: the physical Unitree remote (in local lowstate) takes + # priority for locomotion when active; otherwise laptop/exo axes stand. + if self.config.onboard: + wl = self._wireless_remote_input(lowstate) + if wl is not None: + controller_input.update(wl) + # Run controller step controller_action = self.controller.run_step(controller_input, lowstate) @@ -299,6 +322,40 @@ class UnitreeG1(Robot): def configure(self) -> None: pass + def _wireless_remote_input(self, lowstate) -> dict | None: + """Parse the physical Unitree remote from lowstate into controller inputs. + + Onboard only. Returns None when the remote is idle so the laptop-provided + (exo) axes keep control; otherwise the physical remote takes priority — the + same precedence the exo teleoperator applies laptop-side. + """ + js = self._joystick + if js is None: + return None + wr = getattr(lowstate, "wireless_remote", None) + if not wr or len(wr) < 24: + return None + try: + js.extract(wr) + except Exception: # noqa: BLE001 + return None + + axes = { + "remote.lx": float(js.lx.data), + "remote.ly": float(js.ly.data), + "remote.rx": float(js.rx.data), + "remote.ry": float(js.ry.data), + } + active = any(abs(v) > 1e-2 for v in axes.values()) + out = dict(axes) + for i, name in enumerate(_REMOTE_BUTTON_MAP): + if name: + val = float(getattr(js, name).data) + out[f"remote.button.{i}"] = val + if val: + active = True + return out if active else None + def _release_motion_control(self) -> None: """Release the robot's built-in motion services so we can send raw lowcmd. @@ -338,6 +395,11 @@ class UnitreeG1(Robot): else: self._ChannelFactoryInitialize(0) self._release_motion_control() + # Read the physical wireless remote directly from lowstate for locomotion. + self._joystick = Joystick() + for axis in (self._joystick.lx, self._joystick.ly, self._joystick.rx, self._joystick.ry): + axis.smooth = 1.0 + axis.deadzone = 0.0 else: self._ChannelFactoryInitialize(0, config=self.config)