Add Unitree Go2 robot + spatial-memory navigation base

unitree_go2 robot: high-level sport-mode body-velocity control over DDS
(unitree_sdk2py) straight from the host — no companion computer, unlike
the G1's low-level ZMQ bridge. Actions x.vel/y.vel/theta.vel; observation
is planar odometry (rt/sportmodestate) + the built-in front camera via
VideoClient. SDK imported only in connect() so configs/features/tests
work without it; 20 tests via mocks. unitree_go2 pyproject extra.

lerobot.navigation package: BaseController protocol + StubBaseController +
SafeBaseController (velocity clamp, occupancy gate, keyframe watchdog,
e-stop latch) + RobotBaseController wrapping any Robot on the standard
REP-103 mobile-base contract, carrying the world<->body velocity and
odometry<->world pose frame math. Robot-agnostic: the SDK lives only in
the robot class. 29 tests, SDK/torch-free.

First step of consolidating the dyna360 DynaMem navigation stack into
lerobot; dyna360 is a source to copy from, not a runtime dependency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Pepijn
2026-07-20 17:45:41 +02:00
parent ddc2aa7a27
commit ff94e6385b
9 changed files with 1355 additions and 0 deletions
+5
View File
@@ -187,6 +187,11 @@ unitree_g1 = [
"lerobot[matplotlib-dep]",
"lerobot[pygame-dep]",
]
# Go2 talks plain DDS from the host — no bridge server, no extra deps beyond
# the SDK itself (cyclonedds-based, hence Linux-only).
unitree_go2 = [
"unitree_sdk2py>=1.0.1; sys_platform == 'linux'",
]
# reachy2-sdk caps grpcio<=1.73.1 and protobuf<=6.32.0; quarantined here so downstream users aren't held back. reachy2-sdk is unlikely to release new versions.
reachy2 = [
"reachy2_sdk>=1.0.15,<1.1.0",
+60
View File
@@ -0,0 +1,60 @@
# `lerobot.navigation` — spatial-memory navigation
Online spatio-semantic mapping (DynaMem-style), A* planning, obstacle
avoidance and open-vocabulary goto/explore for LeRobot mobile bases.
Ported from the dyna360 research stack; the physical robot layer lives in
`lerobot.robots` (e.g. [`unitree_go2`](../robots/unitree_go2)).
## Idea
Drive any LeRobot `Robot` on the standard REP-103 mobile-base contract —
body-velocity actions `x.vel`/`y.vel`/`theta.vel` and planar odometry
`x.pos`/`y.pos`/`theta.pos` — from a spatial memory that is built and
updated online from the robot's camera. With no prompt the base explores
autonomously; given a text prompt it queries the map and navigates to the
matching object, or explores to find it if it isn't there (or has moved).
## Architecture
The navigation layer talks to hardware only through LeRobot's own `Robot`
interface, so it is robot-agnostic and carries no SDK dependency.
```
BaseController (protocol) world-frame move()/pose() seam
├── StubBaseController kinematic integrator (sim, tests)
├── RobotBaseController wraps any Robot; world<->body +
│ odometry<->world frame math
└── SafeBaseController velocity clamp, occupancy gate,
keyframe watchdog, e-stop latch
```
World frame is OpenCV (x right, y down, z forward); the base moves in the
XZ plane. `RobotBaseController.feed_observation(obs)` updates pose from
the observation the navigation loop already fetches (closed-loop
odometry), avoiding an extra camera read; absent odometry it integrates
open-loop so sim matches hardware.
## Status (branch `feat/unitree-go2`)
Implemented:
- `base_controller.py` — the controller seam above. SDK/torch-free;
tests in `tests/navigation/test_base_controller.py`.
Planned (porting from dyna360, everything lands here — dyna360 is a
source to copy from, not a runtime dependency):
- `geometry.py` — LingBot-Map streaming reconstruction runner
(`{points, local_points, conf, camera_poses}`), scale-anchored to
odometry. (Pi3X is not carried over.)
- `voxel_map.py` — 5 cm `SegmentVoxelMap`, no point-cloud retention;
one SigLIP2 feature per segment.
- `occupancy.py` — occupancy grid + A* + frontiers; `value_map.py`.
- `features.py` (SigLIP2), `segmenter.py` (SAM2) — run offboard.
- `agent.py`, `skills.py`, `dog_cli.py` — the interactive explore/query
REPL (the deliverable).
## Target platform
Unitree Go2 EDU, no companion computer: the workstation (single RTX 5090)
talks DDS straight to the dog; geometry is monocular LingBot-Map from the
built-in front camera, scale-anchored to sport-mode odometry; the map is
5 cm voxels. See [`robots/unitree_go2`](../robots/unitree_go2).
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Spatial-memory navigation for LeRobot mobile bases.
Online spatio-semantic mapping (DynaMem-style), A* planning, obstacle
avoidance and open-vocabulary goto/explore, driving any LeRobot ``Robot``
that exposes body-velocity actions and planar odometry. Ported from the
dyna360 research stack; the physical robot layer lives in
``lerobot.robots`` (e.g. ``unitree_go2``).
"""
from .base_controller import (
BaseController,
RobotBaseController,
SafeBaseController,
StubBaseController,
odometry_to_world_pose,
world_velocity_to_body,
)
__all__ = [
"BaseController",
"RobotBaseController",
"SafeBaseController",
"StubBaseController",
"odometry_to_world_pose",
"world_velocity_to_body",
]
+389
View File
@@ -0,0 +1,389 @@
#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Base controller for spatial-memory navigation.
The navigation/skills layer commands motion in a single **world frame**
(OpenCV convention: x right, y down, z forward — the base lives in the XZ
plane, y is gravity) and reads back an SE(3) pose. :class:`BaseController`
is that seam. Three implementations:
- :class:`StubBaseController` — kinematic integrator, no hardware; sim +
unit tests.
- :class:`RobotBaseController` — drives any LeRobot :class:`Robot` whose
action space is body-frame velocities ``x.vel`` (forward, m/s),
``y.vel`` (left, m/s), ``theta.vel`` (CCW yaw, rad/s) and whose
observation carries planar odometry ``x.pos``/``y.pos``/``theta.pos``
(REP-103: x forward, y left, yaw CCW). The Unitree Go2 satisfies this
out of the box; so would a LeKiwi base.
- :class:`SafeBaseController` — wraps any of the above with velocity
clamping, an optional occupancy gate, a keyframe watchdog and an
e-stop latch.
All frame conversions between the world frame and a robot's body/odometry
frame live in :func:`world_velocity_to_body` and
:func:`odometry_to_world_pose`; nothing else needs to know the mapping.
"""
from __future__ import annotations
import logging
import math
import time
from abc import abstractmethod
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Protocol, runtime_checkable
import numpy as np
if TYPE_CHECKING:
from lerobot.robots import Robot
logger = logging.getLogger(__name__)
# --------------------------------------------------------------------- #
# BaseController protocol
# --------------------------------------------------------------------- #
@runtime_checkable
class BaseController(Protocol):
"""Mobile-base interface used by the navigation/skills layer.
Velocities are in **world** frame XZ (m/s); ``yaw_rate`` is rad/s
about the world's Y axis (turning around the up vector). ``pose``
is 4×4 SE(3) camera-to-world (OpenCV).
"""
@abstractmethod
def move(self, vx: float, vz: float, yaw_rate: float = 0.0, dt: float = 0.05) -> None: ...
@abstractmethod
def stop(self) -> None: ...
@abstractmethod
def pose(self) -> np.ndarray: ...
@abstractmethod
def position(self) -> tuple[float, float, float]: ...
# --------------------------------------------------------------------- #
# Frame math (pure functions)
# --------------------------------------------------------------------- #
def world_velocity_to_body(
vx_world: float,
vz_world: float,
yaw_rate_rad_s: float,
heading_rad: float,
) -> tuple[float, float, float]:
"""World-frame velocity → body-frame ``(x.vel, y.vel, theta.vel)``.
Returns ``(vx_forward, vy_left, vyaw)`` in m/s, m/s, rad/s — the
action a REP-103 base expects. At heading ``h`` the body axes in the
world XZ plane are forward = (sin h, cos h), left = (cos h, sin h)
(left = up × forward, up = y). The navigation world's positive yaw
is clockwise about the up vector; a REP-103 base's ``theta.vel`` is
counter-clockwise, hence the sign flip.
"""
s, c = math.sin(heading_rad), math.cos(heading_rad)
vx_fwd = vx_world * s + vz_world * c
vy_left = -vx_world * c + vz_world * s
return vx_fwd, vy_left, -yaw_rate_rad_s
def odometry_to_world_pose(
x_fwd: float,
y_left: float,
yaw: float,
origin: tuple[float, float, float],
) -> tuple[np.ndarray, float]:
"""Planar odometry ``(x_fwd, y_left, yaw)`` → world pose + heading.
``origin`` is the ``(x_fwd, y_left, yaw)`` sample captured when the
controller first saw odometry, so the run starts at identity
regardless of where the robot's odometry origin sits. The result is
the OpenCV world convention, planarized: height Y is 0 and only yaw
survives of the orientation — pitch/roll gait wobble is the camera's
concern, not the base's.
Odometry frame is REP-103 (x forward, y left, yaw CCW about z-up).
Mapping to OpenCV world: ``x_world = y_odom``, ``z_world = x_odom``,
``heading = yaw``.
"""
ox, oy, oyaw = origin
dx, dy = x_fwd - ox, y_left - oy
c0, s0 = math.cos(-oyaw), math.sin(-oyaw)
x_rel = c0 * dx - s0 * dy
y_rel = s0 * dx + c0 * dy
yaw_rel = yaw - oyaw
x_world, z_world = -y_rel, x_rel
heading = -yaw_rel
ch, sh = math.cos(heading), math.sin(heading)
pose = np.eye(4, dtype=np.float64)
pose[0, 0], pose[0, 2] = ch, sh
pose[2, 0], pose[2, 2] = -sh, ch
pose[0, 3], pose[2, 3] = x_world, z_world
return pose, heading
def _heading_pose(x: float, z: float, heading: float) -> np.ndarray:
"""Build a planar world pose from position + heading."""
c, s = math.cos(heading), math.sin(heading)
pose = np.eye(4, dtype=np.float64)
pose[0, 0], pose[0, 2] = c, s
pose[2, 0], pose[2, 2] = -s, c
pose[0, 3], pose[2, 3] = x, z
return pose
# --------------------------------------------------------------------- #
# Stub controller (kinematic, no hardware)
# --------------------------------------------------------------------- #
@dataclass
class StubBaseController:
"""Kinematic stub: integrates each ``move()`` into pose exactly.
No latency, slip or dynamics — for sim and skill-layer unit tests.
"""
initial_pose: np.ndarray | None = None
max_lin_speed: float = 1.0
max_yaw_rate: float = 1.0
def __post_init__(self) -> None:
self._pose = (
np.asarray(self.initial_pose, dtype=np.float64).copy()
if self.initial_pose is not None
else np.eye(4, dtype=np.float64)
)
if self._pose.shape != (4, 4):
raise ValueError(f"initial_pose must be (4, 4); got {self._pose.shape}")
self._heading = 0.0
self._stopped = False
def move(self, vx: float, vz: float, yaw_rate: float = 0.0, dt: float = 0.05) -> None:
vx = float(np.clip(vx, -self.max_lin_speed, self.max_lin_speed))
vz = float(np.clip(vz, -self.max_lin_speed, self.max_lin_speed))
yaw_rate = float(np.clip(yaw_rate, -self.max_yaw_rate, self.max_yaw_rate))
if dt <= 0:
return
self._pose[0, 3] += vx * dt
self._pose[2, 3] += vz * dt
if yaw_rate != 0.0:
self._heading += yaw_rate * dt
self._pose = _heading_pose(self._pose[0, 3], self._pose[2, 3], self._heading)
self._stopped = False
def stop(self) -> None:
self._stopped = True
def pose(self) -> np.ndarray:
return self._pose.copy()
def position(self) -> tuple[float, float, float]:
p = self._pose[:3, 3]
return float(p[0]), float(p[1]), float(p[2])
@property
def is_stopped(self) -> bool:
return self._stopped
# --------------------------------------------------------------------- #
# Robot-backed controller
# --------------------------------------------------------------------- #
@dataclass(frozen=True)
class RobotBaseControllerConfig:
"""Behaviour knobs for :class:`RobotBaseController`."""
max_lin_speed: float = 0.6
"""Hard cap on per-axis world linear velocity (m/s)."""
max_yaw_rate: float = 1.2
"""Hard cap on yaw rate (rad/s)."""
pose_from_odometry: bool = True
"""Report pose from the robot's odometry (closed-loop). When False,
integrate pose open-loop from commanded velocities."""
class RobotBaseController(BaseController):
""":class:`BaseController` over any LeRobot :class:`Robot`.
The robot must accept body-velocity actions ``x.vel`` (forward),
``y.vel`` (left), ``theta.vel`` (CCW yaw) and — for closed-loop pose
— report odometry ``x.pos``/``y.pos``/``theta.pos`` in its
observation. This is the standard REP-103 mobile-base contract, which
``UnitreeGo2`` implements.
Pose is refreshed from observations the navigation loop already
fetches: call :meth:`feed_observation` each keyframe rather than
having the controller poll the robot (which would trigger an extra
camera read). Absent any fed observation, pose falls back to
open-loop integration so sim/dry-run behaves like the stub.
"""
def __init__(self, robot: Robot, cfg: RobotBaseControllerConfig | None = None) -> None:
self.robot = robot
self.cfg = cfg or RobotBaseControllerConfig()
self._pose = np.eye(4, dtype=np.float64)
self._heading = 0.0
self._stopped = False
self._odom_origin: tuple[float, float, float] | None = None
self._have_odom = False
# ----- odometry feed --------------------------------------------------
def feed_observation(self, obs: dict) -> None:
"""Update pose from an observation the nav loop already fetched."""
if not self.cfg.pose_from_odometry:
return
if not {"x.pos", "y.pos", "theta.pos"} <= obs.keys():
return
sample = (float(obs["x.pos"]), float(obs["y.pos"]), float(obs["theta.pos"]))
if self._odom_origin is None:
self._odom_origin = sample
self._pose, self._heading = odometry_to_world_pose(*sample, self._odom_origin)
self._have_odom = True
# ----- BaseController API --------------------------------------------
def move(self, vx: float, vz: float, yaw_rate: float = 0.0, dt: float = 0.05) -> None:
vx = float(np.clip(vx, -self.cfg.max_lin_speed, self.cfg.max_lin_speed))
vz = float(np.clip(vz, -self.cfg.max_lin_speed, self.cfg.max_lin_speed))
yaw_rate = float(np.clip(yaw_rate, -self.cfg.max_yaw_rate, self.cfg.max_yaw_rate))
if dt <= 0:
return
vx_fwd, vy_left, vyaw = world_velocity_to_body(vx, vz, yaw_rate, self._heading)
self.robot.send_action({"x.vel": vx_fwd, "y.vel": vy_left, "theta.vel": vyaw})
# Open-loop pose only when we have no odometry to trust.
if not (self.cfg.pose_from_odometry and self._have_odom):
self._pose[0, 3] += vx * dt
self._pose[2, 3] += vz * dt
if yaw_rate != 0.0:
self._heading += yaw_rate * dt
self._pose = _heading_pose(self._pose[0, 3], self._pose[2, 3], self._heading)
self._stopped = False
def stop(self) -> None:
self._stopped = True
try:
self.robot.send_action({"x.vel": 0.0, "y.vel": 0.0, "theta.vel": 0.0})
except Exception:
logger.exception("stop(): failed to send zero-velocity action")
def pose(self) -> np.ndarray:
return self._pose.copy()
def position(self) -> tuple[float, float, float]:
p = self._pose[:3, 3]
return float(p[0]), float(p[1]), float(p[2])
@property
def is_stopped(self) -> bool:
return self._stopped
# --------------------------------------------------------------------- #
# Safety wrapper
# --------------------------------------------------------------------- #
@dataclass
class SafeBaseController(BaseController):
"""Wrap any :class:`BaseController` with safety layers:
- **velocity clamp** on every ``move()``;
- **occupancy gate**: when ``occupancy_provider`` is set, predict
the next position and refuse (latch e-stop) if it lands in an
obstacle cell. The provider returns an object exposing
``world_to_cell(x, z) -> (iz, ix)`` and an ``is_obstacle(iz, ix)
-> bool`` predicate; ``None`` means "no map yet, allow";
- **watchdog**: if no keyframe has been fed in
``watchdog_timeout_s`` (caller ticks :meth:`feed_watchdog` per
map update), ``move()`` latches stop until :meth:`reset_watchdog`.
"""
inner: BaseController
max_lin_speed: float = 0.6
max_yaw_rate: float = 1.2
occupancy_provider: object = None # callable[[], grid | None] when set
watchdog_timeout_s: float = 2.0
e_stop_latched: bool = False
_last_keyframe_walltime: float = field(default_factory=time.monotonic, init=False)
def feed_watchdog(self) -> None:
self._last_keyframe_walltime = time.monotonic()
def reset_watchdog(self) -> None:
self.e_stop_latched = False
self._last_keyframe_walltime = time.monotonic()
def latch_estop(self, reason: str = "external") -> None:
logger.warning("SafeBaseController e-stop latched: %s", reason)
self.e_stop_latched = True
self.inner.stop()
def move(self, vx: float, vz: float, yaw_rate: float = 0.0, dt: float = 0.05) -> None:
if self.e_stop_latched:
return
if (time.monotonic() - self._last_keyframe_walltime) > self.watchdog_timeout_s:
self.latch_estop(f"watchdog: no keyframe in last {self.watchdog_timeout_s:.2f}s")
return
vx = float(np.clip(vx, -self.max_lin_speed, self.max_lin_speed))
vz = float(np.clip(vz, -self.max_lin_speed, self.max_lin_speed))
yaw_rate = float(np.clip(yaw_rate, -self.max_yaw_rate, self.max_yaw_rate))
if self.occupancy_provider is not None:
try:
grid = self.occupancy_provider()
except Exception:
logger.exception("occupancy_provider raised; refusing move")
return
if grid is not None and self._would_enter_obstacle(grid, vx, vz, dt):
self.latch_estop("about to enter obstacle cell")
return
self.inner.move(vx, vz, yaw_rate, dt)
def stop(self) -> None:
self.inner.stop()
def pose(self) -> np.ndarray:
return self.inner.pose()
def position(self) -> tuple[float, float, float]:
return self.inner.position()
def _would_enter_obstacle(self, grid, vx: float, vz: float, dt: float) -> bool:
pos = self.inner.position()
next_x = pos[0] + vx * dt
next_z = pos[2] + vz * dt
iz, ix = grid.world_to_cell(next_x, next_z)
return bool(grid.is_obstacle(iz, ix))
@@ -0,0 +1,20 @@
#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from .config_unitree_go2 import UnitreeGo2Config
from .unitree_go2 import UnitreeGo2
__all__ = ["UnitreeGo2", "UnitreeGo2Config"]
@@ -0,0 +1,57 @@
#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from dataclasses import dataclass, field
from lerobot.cameras import CameraConfig
from ..config import RobotConfig
@RobotConfig.register_subclass("unitree_go2")
@dataclass
class UnitreeGo2Config(RobotConfig):
"""Configuration for the Unitree Go2 quadruped (EDU).
The host machine talks DDS directly to the dog over Ethernet/WiFi via
``unitree_sdk2py`` — no onboard companion computer is required. Actions
are high-level sport-mode body velocities; observations are sport-mode
odometry plus the dog's built-in front camera.
"""
# Network interface on the host that is wired/bridged to the Go2
# (the dog lives on 192.168.123.x when connected over Ethernet).
network_interface: str = "eth0"
# DDS domain id (0 for a stock Go2).
domain_id: int = 0
# Safety clamps applied in send_action() before commands reach the dog.
# The Go2 accepts far more (vx up to ~3.7 m/s) — keep indoor-sane defaults.
max_x_vel: float = 1.0 # m/s, body forward
max_y_vel: float = 0.5 # m/s, body left
max_theta_vel: float = 1.5 # rad/s, CCW about z-up
# Built-in front camera, served through the SDK VideoClient.
use_front_camera: bool = True
front_camera_width: int = 1280
front_camera_height: int = 720
# Send BalanceStand once on connect so the dog is ready to walk.
stand_on_connect: bool = True
# Additional external cameras (standard LeRobot camera configs).
cameras: dict[str, CameraConfig] = field(default_factory=dict)
@@ -0,0 +1,260 @@
#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unitree Go2 quadruped (EDU) — high-level sport-mode integration.
Unlike :class:`~lerobot.robots.unitree_g1.UnitreeG1` (low-level joint
control at 250 Hz through an on-robot ZMQ bridge), the Go2 is driven with
sport-mode **body velocity commands** at tens of Hz, which work fine over
plain DDS from any Linux host on the dog's network — no bridge server, no
onboard companion computer.
Setup:
1. Connect the host to the Go2 via Ethernet (dog is on 192.168.123.x)
or put both on the same WiFi network.
2. ``pip install unitree_sdk2py`` (Linux only — rides on cyclonedds).
3. Find your interface name (``ip link``), then e.g.::
lerobot-teleoperate \
--robot.type=unitree_go2 \
--robot.network_interface=enp2s0 \
--teleop.type=gamepad
Actions are body-frame velocities ``x.vel`` (forward, m/s), ``y.vel``
(left, m/s), ``theta.vel`` (CCW yaw, rad/s) — the exact arguments of the
SDK's ``SportClient.Move``. Observations are planar sport-mode odometry
(``*.pos`` pose + ``*.vel`` body velocities) and the built-in front
camera, plus any extra configured cameras.
"""
from __future__ import annotations
import logging
import threading
from functools import cached_property
from typing import Any
import cv2
import numpy as np
from lerobot.cameras import make_cameras_from_configs
from lerobot.types import RobotAction, RobotObservation
from lerobot.utils.import_utils import require_package
from ..robot import Robot
from .config_unitree_go2 import UnitreeGo2Config
logger = logging.getLogger(__name__)
# DDS topic names follow Unitree SDK naming conventions
SPORT_MODE_STATE_TOPIC = "rt/sportmodestate"
class UnitreeGo2(Robot):
"""LeRobot interface to a Unitree Go2 over unitree_sdk2py sport mode."""
config_class = UnitreeGo2Config
name = "unitree_go2"
def __init__(self, config: UnitreeGo2Config):
super().__init__(config)
self.config = config
self._cameras = make_cameras_from_configs(config.cameras)
# SDK handles — populated in connect(); the SDK import lives there
# too so that configs, features and tests work on SDK-less hosts.
self._sport = None
self._video = None
self._state_subscriber = None
self._state_lock = threading.Lock()
self._latest_state = None # last SportModeState_ message
self._connected = False
# ------------------------------------------------------------------ #
# Features
# ------------------------------------------------------------------ #
@cached_property
def _odom_ft(self) -> dict[str, type]:
return {
"x.pos": float,
"y.pos": float,
"theta.pos": float,
"x.vel": float,
"y.vel": float,
"theta.vel": float,
}
@property
def _cameras_ft(self) -> dict[str, tuple]:
ft: dict[str, tuple] = {}
if self.config.use_front_camera:
ft["front"] = (self.config.front_camera_height, self.config.front_camera_width, 3)
for name, cam in self._cameras.items():
ft[name] = (cam.height, cam.width, 3)
return ft
@property
def observation_features(self) -> dict:
return {**self._odom_ft, **self._cameras_ft}
@property
def action_features(self) -> dict:
return {"x.vel": float, "y.vel": float, "theta.vel": float}
# ------------------------------------------------------------------ #
# Lifecycle
# ------------------------------------------------------------------ #
@property
def is_connected(self) -> bool:
return self._connected
def connect(self, calibrate: bool = True) -> None:
if self._connected:
return
require_package("unitree-sdk2py", extra="unitree_go2", import_name="unitree_sdk2py")
from unitree_sdk2py.core.channel import ChannelFactoryInitialize, ChannelSubscriber
from unitree_sdk2py.go2.sport.sport_client import SportClient
from unitree_sdk2py.idl.unitree_go.msg.dds_ import SportModeState_
ChannelFactoryInitialize(self.config.domain_id, self.config.network_interface)
sport = SportClient()
sport.SetTimeout(5.0)
sport.Init()
self._sport = sport
subscriber = ChannelSubscriber(SPORT_MODE_STATE_TOPIC, SportModeState_)
subscriber.Init(self._on_sport_state, 10)
self._state_subscriber = subscriber
if self.config.use_front_camera:
from unitree_sdk2py.go2.video.video_client import VideoClient
video = VideoClient()
video.SetTimeout(3.0)
video.Init()
self._video = video
for cam in self._cameras.values():
cam.connect()
if self.config.stand_on_connect:
self._sport.BalanceStand()
self._connected = True
self.configure()
logger.info(
"%s connected (iface=%s, domain=%d)",
self,
self.config.network_interface,
self.config.domain_id,
)
def disconnect(self) -> None:
if self._sport is not None:
try:
self._sport.StopMove()
except Exception:
logger.exception("StopMove on disconnect failed")
for cam in self._cameras.values():
try:
cam.disconnect()
except Exception:
logger.exception("camera disconnect failed")
self._sport = None
self._video = None
self._state_subscriber = None
self._connected = False
# Sport mode needs no calibration.
@property
def is_calibrated(self) -> bool:
return True
def calibrate(self) -> None:
pass
def configure(self) -> None:
pass
# ------------------------------------------------------------------ #
# I/O
# ------------------------------------------------------------------ #
def get_observation(self) -> RobotObservation:
if not self._connected:
raise ConnectionError(f"{self} is not connected.")
obs: dict[str, Any] = dict.fromkeys(self._odom_ft, 0.0)
with self._state_lock:
state = self._latest_state
if state is not None:
obs["x.pos"] = float(state.position[0])
obs["y.pos"] = float(state.position[1])
obs["theta.pos"] = float(state.imu_state.rpy[2])
obs["x.vel"] = float(state.velocity[0])
obs["y.vel"] = float(state.velocity[1])
obs["theta.vel"] = float(state.yaw_speed)
if self.config.use_front_camera:
obs["front"] = self._read_front_camera()
for name, cam in self._cameras.items():
obs[name] = cam.async_read()
return obs
def send_action(self, action: RobotAction) -> RobotAction:
if not self._connected:
raise ConnectionError(f"{self} is not connected.")
vx = float(np.clip(action.get("x.vel", 0.0), -self.config.max_x_vel, self.config.max_x_vel))
vy = float(np.clip(action.get("y.vel", 0.0), -self.config.max_y_vel, self.config.max_y_vel))
vyaw = float(
np.clip(action.get("theta.vel", 0.0), -self.config.max_theta_vel, self.config.max_theta_vel)
)
self._sport.Move(vx, vy, vyaw)
return {"x.vel": vx, "y.vel": vy, "theta.vel": vyaw}
# ------------------------------------------------------------------ #
# Internals
# ------------------------------------------------------------------ #
def _on_sport_state(self, msg) -> None:
with self._state_lock:
self._latest_state = msg
def _read_front_camera(self) -> np.ndarray:
"""Fetch one frame from the built-in front camera (RGB, HxWx3)."""
h, w = self.config.front_camera_height, self.config.front_camera_width
code, data = self._video.GetImageSample()
if code != 0 or data is None:
logger.warning("front camera GetImageSample failed (code=%s)", code)
return np.zeros((h, w, 3), dtype=np.uint8)
frame = cv2.imdecode(np.frombuffer(bytes(data), dtype=np.uint8), cv2.IMREAD_COLOR)
if frame is None:
logger.warning("front camera frame failed to decode")
return np.zeros((h, w, 3), dtype=np.uint8)
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
if frame.shape[:2] != (h, w):
frame = cv2.resize(frame, (w, h), interpolation=cv2.INTER_AREA)
return frame
+320
View File
@@ -0,0 +1,320 @@
#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for the navigation base controller.
Hardware-free and SDK-free: the frame math is pure, the stub is
kinematic, and the robot-backed controller is exercised through a fake
Robot that records actions and serves canned odometry.
"""
from __future__ import annotations
import math
import time
import numpy as np
import pytest
from lerobot.navigation.base_controller import (
BaseController,
RobotBaseController,
RobotBaseControllerConfig,
SafeBaseController,
StubBaseController,
odometry_to_world_pose,
world_velocity_to_body,
)
# ----- world_velocity_to_body ---------------------------------------------
def test_forward_maps_to_body_x():
"""heading=0, world +z (forward) → (vx>0, 0, 0)."""
vx_f, vy_l, vyaw = world_velocity_to_body(0.0, 0.3, 0.0, heading_rad=0.0)
assert vx_f == pytest.approx(0.3)
assert vy_l == pytest.approx(0.0)
assert vyaw == pytest.approx(0.0)
def test_world_right_maps_to_negative_left():
"""heading=0, world +x is the robot's RIGHT → negative y.vel."""
vx_f, vy_l, _ = world_velocity_to_body(0.3, 0.0, 0.0, heading_rad=0.0)
assert vx_f == pytest.approx(0.0)
assert vy_l == pytest.approx(-0.3)
def test_world_x_is_forward_after_quarter_turn():
vx_f, vy_l, _ = world_velocity_to_body(0.3, 0.0, 0.0, heading_rad=math.pi / 2)
assert vx_f == pytest.approx(0.3)
assert vy_l == pytest.approx(0.0, abs=1e-9)
def test_yaw_rate_sign_flips():
_, _, vyaw = world_velocity_to_body(0.0, 0.0, 0.5, heading_rad=0.0)
assert vyaw == pytest.approx(-0.5)
def test_velocity_magnitude_preserved_under_rotation():
vx_f, vy_l, _ = world_velocity_to_body(0.3, 0.4, 0.0, heading_rad=1.234)
assert math.hypot(vx_f, vy_l) == pytest.approx(0.5)
# ----- odometry_to_world_pose ---------------------------------------------
def test_odometry_at_origin_is_identity():
pose, heading = odometry_to_world_pose(1.0, 2.0, 0.3, origin=(1.0, 2.0, 0.3))
np.testing.assert_allclose(pose, np.eye(4), atol=1e-12)
assert heading == pytest.approx(0.0)
def test_odometry_forward_maps_to_world_z():
pose, heading = odometry_to_world_pose(1.0, 0.0, 0.0, origin=(0.0, 0.0, 0.0))
assert pose[0, 3] == pytest.approx(0.0)
assert pose[2, 3] == pytest.approx(1.0)
assert heading == pytest.approx(0.0)
def test_odometry_left_maps_to_world_negative_x():
pose, _ = odometry_to_world_pose(0.0, 1.0, 0.0, origin=(0.0, 0.0, 0.0))
assert pose[0, 3] == pytest.approx(-1.0)
assert pose[2, 3] == pytest.approx(0.0)
def test_odometry_yaw_sign_flip():
pose, heading = odometry_to_world_pose(0.0, 0.0, 0.5, origin=(0.0, 0.0, 0.0))
assert heading == pytest.approx(-0.5)
fwd = pose[:3, 2]
np.testing.assert_allclose(fwd, [math.sin(-0.5), 0.0, math.cos(-0.5)], atol=1e-12)
def test_odometry_origin_yaw_is_derotated():
"""Motion along the boot-time heading is always world +z, whatever
direction the robot faced when odometry started."""
origin = (0.0, 0.0, math.pi / 2)
pose, heading = odometry_to_world_pose(0.0, 1.0, math.pi / 2, origin=origin)
assert pose[0, 3] == pytest.approx(0.0, abs=1e-12)
assert pose[2, 3] == pytest.approx(1.0)
assert heading == pytest.approx(0.0)
# ----- StubBaseController --------------------------------------------------
def test_stub_is_basecontroller():
assert isinstance(StubBaseController(), BaseController)
def test_stub_integrates_forward():
c = StubBaseController()
c.move(0.0, 0.2, dt=1.0)
assert c.position()[2] == pytest.approx(0.2)
def test_stub_clamps_velocity():
c = StubBaseController(max_lin_speed=0.1)
c.move(5.0, 0.0, dt=1.0)
assert c.position()[0] == pytest.approx(0.1)
# ----- RobotBaseController -------------------------------------------------
class FakeRobot:
"""Minimal Robot stand-in: records actions, serves canned odometry."""
def __init__(self) -> None:
self.actions: list[dict] = []
self.obs: dict = {}
def send_action(self, action: dict) -> dict:
self.actions.append(action)
return action
def get_observation(self) -> dict:
return self.obs
def _robot_controller(**cfg_kwargs) -> tuple[RobotBaseController, FakeRobot]:
robot = FakeRobot()
cfg = RobotBaseControllerConfig(**cfg_kwargs)
return RobotBaseController(robot, cfg), robot
def _odom(x=0.0, y=0.0, yaw=0.0) -> dict:
return {"x.pos": x, "y.pos": y, "theta.pos": yaw}
def test_robot_controller_is_basecontroller():
ctl, _ = _robot_controller()
assert isinstance(ctl, BaseController)
def test_forward_command_reaches_send_action():
ctl, robot = _robot_controller()
ctl.feed_observation(_odom()) # heading 0
ctl.move(vx=0.0, vz=0.3, dt=0.05)
assert robot.actions[-1] == {
"x.vel": pytest.approx(0.3),
"y.vel": pytest.approx(0.0),
"theta.vel": pytest.approx(0.0),
}
def test_command_uses_odometry_heading():
"""After the robot turns to heading +π/2, a world +x command comes out
as pure body-forward. First sample fixes the origin."""
ctl, robot = _robot_controller()
ctl.feed_observation(_odom()) # origin, heading 0
ctl.feed_observation(_odom(yaw=-math.pi / 2)) # turned; heading +π/2
ctl.move(vx=0.3, vz=0.0, dt=0.05)
assert robot.actions[-1]["x.vel"] == pytest.approx(0.3)
assert robot.actions[-1]["y.vel"] == pytest.approx(0.0, abs=1e-9)
def test_command_is_clamped_before_send():
ctl, robot = _robot_controller(max_lin_speed=0.1)
ctl.feed_observation(_odom())
ctl.move(vx=0.0, vz=9.0, dt=0.05)
assert robot.actions[-1]["x.vel"] == pytest.approx(0.1)
def test_pose_comes_from_odometry_not_integration():
ctl, _ = _robot_controller()
ctl.feed_observation(_odom())
ctl.move(0.0, 0.3, dt=1.0) # would integrate 0.3 m open-loop
ctl.feed_observation(_odom(x=0.05)) # ...but odometry says 5 cm forward
assert ctl.position()[2] == pytest.approx(0.05)
def test_origin_is_first_odometry_sample():
ctl, _ = _robot_controller()
ctl.feed_observation(_odom(x=3.0, y=-1.0, yaw=0.7))
np.testing.assert_allclose(ctl.pose(), np.eye(4), atol=1e-12)
def test_open_loop_fallback_without_odometry():
"""No odometry fed → integrate open-loop like the stub."""
ctl, _ = _robot_controller()
ctl.move(0.0, 0.2, dt=1.0)
assert ctl.position()[2] == pytest.approx(0.2)
def test_stop_sends_zero_velocity():
ctl, robot = _robot_controller()
ctl.stop()
assert robot.actions[-1] == {"x.vel": 0.0, "y.vel": 0.0, "theta.vel": 0.0}
assert ctl.is_stopped
def test_robot_controller_matches_stub_open_loop():
"""Open-loop pose integration matches StubBaseController for the same
command sequence — sim runs must transfer to the real base."""
ctl, _ = _robot_controller(max_lin_speed=1.0)
stub = StubBaseController()
for vx, vz, yaw in [(0.2, 0.0, 0.0), (0.0, 0.3, 0.5), (0.1, 0.1, -0.2)]:
ctl.move(vx, vz, yaw, dt=0.5)
stub.move(vx, vz, yaw, dt=0.5)
np.testing.assert_allclose(ctl.pose(), stub.pose(), atol=1e-9)
# ----- SafeBaseController --------------------------------------------------
class FakeGrid:
"""Occupancy stand-in with a single obstacle cell."""
def __init__(self, obstacle_cell=(5, 6), cell_size=0.1, origin_x=-0.5, origin_z=-0.5):
self.obstacle_cell = obstacle_cell
self.cell_size = cell_size
self.origin_x = origin_x
self.origin_z = origin_z
def world_to_cell(self, x: float, z: float) -> tuple[int, int]:
ix = int((x - self.origin_x) / self.cell_size)
iz = int((z - self.origin_z) / self.cell_size)
return iz, ix
def is_obstacle(self, iz: int, ix: int) -> bool:
return (iz, ix) == self.obstacle_cell
def test_safe_passes_normal_moves():
inner = StubBaseController()
safe = SafeBaseController(inner=inner)
safe.feed_watchdog()
safe.move(0.0, 0.1, dt=1.0)
assert inner.position()[2] == pytest.approx(0.1)
def test_safe_clamps_speed():
inner = StubBaseController(max_lin_speed=100.0)
safe = SafeBaseController(inner=inner, max_lin_speed=0.5)
safe.feed_watchdog()
safe.move(10.0, 0.0, dt=1.0)
assert inner.position()[0] == pytest.approx(0.5)
def test_safe_watchdog_latches_on_stale_keyframes():
inner = StubBaseController()
safe = SafeBaseController(inner=inner, watchdog_timeout_s=0.05)
safe.feed_watchdog()
time.sleep(0.1)
safe.move(0.0, 0.1, dt=1.0)
assert safe.e_stop_latched
safe.move(0.0, 10.0, dt=1.0) # refused
assert inner.position()[2] == pytest.approx(0.0, abs=1e-6)
def test_safe_reset_watchdog_re_enables_motion():
inner = StubBaseController()
safe = SafeBaseController(inner=inner, watchdog_timeout_s=0.05)
safe.feed_watchdog()
time.sleep(0.1)
safe.move(0.0, 0.1)
assert safe.e_stop_latched
safe.reset_watchdog()
safe.move(0.0, 0.1, dt=1.0)
assert inner.position()[2] == pytest.approx(0.1)
def test_safe_refuses_move_into_obstacle():
inner = StubBaseController()
grid = FakeGrid(obstacle_cell=(5, 6))
safe = SafeBaseController(inner=inner, occupancy_provider=lambda: grid)
safe.feed_watchdog()
# +0.15 m in x from origin lands mid-column ix=6 (origin_x=-0.5, cell=0.1).
safe.move(vx=0.15, vz=0.0, dt=1.0)
assert safe.e_stop_latched
assert inner.position()[0] == pytest.approx(0.0, abs=1e-6)
def test_safe_allows_move_into_free_cell():
inner = StubBaseController()
grid = FakeGrid(obstacle_cell=(99, 99))
safe = SafeBaseController(inner=inner, occupancy_provider=lambda: grid)
safe.feed_watchdog()
safe.move(vx=0.1, vz=0.0, dt=1.0)
assert inner.position()[0] == pytest.approx(0.1)
def test_safe_allows_when_no_map_yet():
inner = StubBaseController()
safe = SafeBaseController(inner=inner, occupancy_provider=lambda: None)
safe.feed_watchdog()
safe.move(vx=0.1, vz=0.0, dt=1.0)
assert inner.position()[0] == pytest.approx(0.1)
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for the Unitree Go2 robot.
The SDK is only imported inside ``UnitreeGo2.connect()``, so everything
here runs without unitree_sdk2py installed: the sport client, state
subscriber and video client are replaced with mocks.
"""
from types import SimpleNamespace
from unittest.mock import MagicMock
import cv2
import numpy as np
import pytest
from lerobot.robots.unitree_go2 import UnitreeGo2, UnitreeGo2Config
# ---------------------------------------------------------------------------
# Config (no SDK needed)
# ---------------------------------------------------------------------------
class TestUnitreeGo2Config:
def test_registered_type_name(self):
assert UnitreeGo2Config().type == "unitree_go2"
def test_default_config(self):
cfg = UnitreeGo2Config()
assert cfg.domain_id == 0
assert cfg.use_front_camera is True
assert cfg.stand_on_connect is True
assert cfg.cameras == {}
def test_safety_clamps_are_positive(self):
cfg = UnitreeGo2Config()
assert cfg.max_x_vel > 0
assert cfg.max_y_vel > 0
assert cfg.max_theta_vel > 0
# ---------------------------------------------------------------------------
# Features (no SDK needed)
# ---------------------------------------------------------------------------
def _make_robot(**cfg_kwargs) -> UnitreeGo2:
cfg = UnitreeGo2Config(id="test_go2", **cfg_kwargs)
return UnitreeGo2(cfg)
class TestFeatures:
def test_action_features(self):
robot = _make_robot()
assert robot.action_features == {"x.vel": float, "y.vel": float, "theta.vel": float}
def test_observation_features_with_front_camera(self):
robot = _make_robot()
ft = robot.observation_features
assert ft["front"] == (720, 1280, 3)
for key in ("x.pos", "y.pos", "theta.pos", "x.vel", "y.vel", "theta.vel"):
assert ft[key] is float
def test_observation_features_without_front_camera(self):
robot = _make_robot(use_front_camera=False)
assert "front" not in robot.observation_features
def test_features_available_before_connect(self):
robot = _make_robot()
assert not robot.is_connected
assert robot.observation_features
assert robot.action_features
def test_is_calibrated_always_true(self):
assert _make_robot().is_calibrated is True
# ---------------------------------------------------------------------------
# I/O with mocked SDK handles
# ---------------------------------------------------------------------------
def _connected_robot(**cfg_kwargs) -> UnitreeGo2:
"""A robot with mocked SDK handles, as if connect() had run."""
robot = _make_robot(**cfg_kwargs)
robot._sport = MagicMock()
robot._video = MagicMock()
robot._connected = True
return robot
def _fake_state(x=0.0, y=0.0, yaw=0.0, vx=0.0, vy=0.0, yaw_speed=0.0):
return SimpleNamespace(
position=[x, y, 0.0],
velocity=[vx, vy, 0.0],
yaw_speed=yaw_speed,
imu_state=SimpleNamespace(rpy=[0.0, 0.0, yaw]),
)
class TestSendAction:
def test_action_reaches_sport_move(self):
robot = _connected_robot()
sent = robot.send_action({"x.vel": 0.3, "y.vel": -0.1, "theta.vel": 0.5})
robot._sport.Move.assert_called_once_with(0.3, -0.1, 0.5)
assert sent == {"x.vel": 0.3, "y.vel": -0.1, "theta.vel": 0.5}
def test_action_is_clamped(self):
robot = _connected_robot(max_x_vel=0.5, max_y_vel=0.2, max_theta_vel=1.0)
sent = robot.send_action({"x.vel": 9.0, "y.vel": -9.0, "theta.vel": -9.0})
robot._sport.Move.assert_called_once_with(0.5, -0.2, -1.0)
assert sent == {"x.vel": 0.5, "y.vel": -0.2, "theta.vel": -1.0}
def test_missing_keys_default_to_zero(self):
robot = _connected_robot()
sent = robot.send_action({})
robot._sport.Move.assert_called_once_with(0.0, 0.0, 0.0)
assert sent == {"x.vel": 0.0, "y.vel": 0.0, "theta.vel": 0.0}
def test_raises_when_not_connected(self):
robot = _make_robot()
with pytest.raises(ConnectionError):
robot.send_action({"x.vel": 0.1})
class TestGetObservation:
def test_odometry_fields(self):
robot = _connected_robot(use_front_camera=False)
robot._latest_state = _fake_state(x=1.0, y=2.0, yaw=0.3, vx=0.1, vy=-0.05, yaw_speed=0.2)
obs = robot.get_observation()
assert obs["x.pos"] == pytest.approx(1.0)
assert obs["y.pos"] == pytest.approx(2.0)
assert obs["theta.pos"] == pytest.approx(0.3)
assert obs["x.vel"] == pytest.approx(0.1)
assert obs["y.vel"] == pytest.approx(-0.05)
assert obs["theta.vel"] == pytest.approx(0.2)
def test_odometry_zero_before_first_state(self):
robot = _connected_robot(use_front_camera=False)
obs = robot.get_observation()
assert all(obs[k] == 0.0 for k in robot._odom_ft)
def test_front_camera_decodes_to_configured_shape(self):
robot = _connected_robot(front_camera_width=64, front_camera_height=48)
raw = np.full((48, 64, 3), 128, dtype=np.uint8)
ok, jpeg = cv2.imencode(".jpg", raw)
assert ok
robot._video.GetImageSample.return_value = (0, jpeg.tobytes())
obs = robot.get_observation()
assert obs["front"].shape == (48, 64, 3)
assert obs["front"].dtype == np.uint8
def test_front_camera_resizes_native_frames(self):
robot = _connected_robot(front_camera_width=64, front_camera_height=48)
native = np.zeros((720, 1280, 3), dtype=np.uint8)
ok, jpeg = cv2.imencode(".jpg", native)
assert ok
robot._video.GetImageSample.return_value = (0, jpeg.tobytes())
assert robot.get_observation()["front"].shape == (48, 64, 3)
def test_front_camera_failure_returns_black_frame(self):
robot = _connected_robot(front_camera_width=64, front_camera_height=48)
robot._video.GetImageSample.return_value = (1, None)
frame = robot.get_observation()["front"]
assert frame.shape == (48, 64, 3)
assert frame.sum() == 0
def test_observation_matches_features(self):
robot = _connected_robot(front_camera_width=64, front_camera_height=48)
raw = np.zeros((48, 64, 3), dtype=np.uint8)
_, jpeg = cv2.imencode(".jpg", raw)
robot._video.GetImageSample.return_value = (0, jpeg.tobytes())
obs = robot.get_observation()
assert set(obs.keys()) == set(robot.observation_features.keys())
def test_raises_when_not_connected(self):
robot = _make_robot()
with pytest.raises(ConnectionError):
robot.get_observation()
class TestDisconnect:
def test_disconnect_stops_motion(self):
robot = _connected_robot()
sport = robot._sport
robot.disconnect()
sport.StopMove.assert_called_once()
assert not robot.is_connected