A clean robotics OS for the Unitree Go2. Independent of dimos. Rerun-first.
Rules: explicit wiring over auto-discovery · asyncio for I/O, threads where forced · one DB, one way to do things · readable beats generalisable.
The Go2's onboard computer (Orin NX) runs Unitree's proprietary locomotion controller — a black box that handles gait, balance, and leg control. You never touch any of that. On top of it, Unitree runs a WebRTC bridge that rhino connects to over the local network (the robot acts as a WiFi access point, or joins your LAN).
The robot publishes three data streams over WebRTC:
- Camera — H.264-encoded video at 1280×720 @ 30 fps
- LiDAR — raw scan packets (range + angle per beam, projected to base_link frame by the SDK)
- Low-state — joint encoder + IMU data at ~50 Hz; the SDK derives odometry from this (leg kinematics + IMU fusion)
The robot subscribes to one command type:
- Velocity command —
(vx, vy, ω)in the robot's body frame;vx= forward,vy= left,ω= yaw rate. The locomotion controller converts this into stepping behaviour internally. - Sport commands — named strings (
"standup","liedown","jump", etc.) for discrete behaviours.
That's the full interface. You say "walk 0.3 m/s forward and turn 0.1 rad/s left" — the robot decides how to move its legs.
All intelligence runs on the host. The robot is a sensor+actuator platform. The data loop:
Go2 (WebRTC)
│ camera frames, lidar scans, odometry
▼
rhino (host PC)
├── OccupancyMapper — raycasts each lidar scan into a 2D log-odds grid
├── Navigator — A* on the costmap → planned path → P-controller → (vx, vy, ω)
├── FrontierExplorer — picks unvisited frontiers as navigation goals
├── RerunLogger — streams everything to the Rerun viewer
└── FastAPI + MCP — web dashboard + Claude tool access
│ velocity commands (vx, vy, ω)
▼
Go2 locomotion controller → legs move
The Go2's odometry is proprioceptive — it integrates leg kinematics and IMU readings. It does not use LiDAR or cameras for localisation. In a typical indoor space (≤50 m²) it stays accurate enough for 10–15 minutes of exploration at 0.1 m map resolution. There is no loop-closure SLAM. For longer sessions or larger spaces, drift will cause the map to warp — that's a known limitation of this design (same as dimos).
odom_loopreceives aPosefrom the Go2, updatesstate.latest_poselidar_loopreceives aLidarScan, readsstate.latest_pose, callsmapper.update()in a thread executormapper.update()raycasts the scan: marks free cells along each beam, occupied at the endpoint; updates the log-odds gridcostmap.pyinflates obstacles by the robot's footprint radius → cost grid for A*nav.run()wakes on a timer (default 1 s), runs A* in executor from current pose to current goalcontrollerreads current pose, finds the nearest look-ahead point on the path, computes position and heading error, outputs(vx, vy, ω)platform.send_vel(vx, vy, ω)pushes the command over WebRTC to the robot- Repeat until
|position_error| < arrival_tolerance
FrontierExplorer detects the boundary between known-free and unknown cells (frontiers), picks the nearest reachable one, and posts it as a Goal to Navigator. When the robot arrives, FrontierExplorer picks the next frontier.
MujocoGo2 uses the same interface as Go2Platform. It launches a Mujoco subprocess (the Go2's physics model, same XML as dimos), communicates via shared memory, and surfaces the same three queues + send_vel. The rest of rhino is unaware it's running in sim.
Go2 WebRTC connection · Mujoco subprocess + SHM pattern · 2D log-odds occupancy grid · costmap inflation · A* replanning · BFS frontier exploration · P-controller velocity tracking · sport commands · MCP server · FastAPI + SSE · React dashboard · Rerun visualisation.
Dropped: LCM, Blueprint system, Foxglove, automatic VLM/POI detection, OpenAI, manipulation, drones, recording/replay, Pinocchio/Drake, Textual TUI, 3D voxel mapping.
| Component | How it runs |
|---|---|
| SDK callbacks (real robot) | background thread → call_soon_threadsafe → asyncio queues |
| Mujoco SHM polling (sim) | 3 daemon threads → call_soon_threadsafe → asyncio queues |
| A* + raycasting | loop.run_in_executor(None, ...) — CPU-bound, must not block event loop |
| Rerun SDK | sync calls from async tasks — fast enough not to matter |
| FastAPI + MCP | asyncio, uvicorn |
rhino/
├── pyproject.toml # uv · entry: rhino = "rhino.main:app"
├── rhino/
│ ├── main.py # wires all components; nothing else does
│ ├── config.py # nested dataclasses (RhinoConfig → MapConfig, NavConfig, …)
│ ├── storage.py # SQLite: manual POIs (shared by api + mcp)
│ ├── platforms/
│ │ ├── base.py # Platform protocol + CameraFrame, LidarScan, Pose, Goal, RobotStatus, POI
│ │ └── go2/
│ │ ├── robot.py # Go2Platform — real robot via unitree-webrtc-connect-leshy
│ │ ├── sim/ # MujocoGo2 — subprocess + SHM
│ │ └── skills.py # standup, liedown, execute_sport
│ ├── mapping/
│ │ ├── occupancy.py # log-odds 2D grid, dynamic extent, raycasting
│ │ └── costmap.py # obstacle inflation
│ ├── navigation/
│ │ ├── planner.py # A* on costmap + P-controller path following
│ │ ├── controller.py # pose error → (vx, vy, ω)
│ │ └── explorer.py # BFS frontier detection + loop
│ ├── viz/
│ │ └── rerun.py # RerunLogger — every rr.log() call lives here
│ └── server/
│ ├── api.py # FastAPI: REST endpoints + legacy embedded teleop UI
│ ├── mcp.py # McpServer (mcp SDK, tools registered in __init__)
│ └── state.py # AppState: latest pose, camera, status
└── web/ # React + Vite dev frontend (port 5173)
├── package.json
├── vite.config.ts # proxies /api → localhost:8000
└── src/
├── App.tsx # root: state polling, page routing (Dashboard | Plan)
├── types.ts # TypeScript interfaces matching API responses
├── styles.css
└── components/
├── Topbar.tsx # nav tabs, explore/mode/stop actions
├── RobotFleet.tsx # left sidebar: robot cards (add robots here for bimanual)
├── MapPane.tsx # occupancy map + SVG overlays + pan/zoom
├── RobotPanel.tsx # right panel: camera stream, WASD teleop, sport commands
└── PlanSidebar.tsx # POI list + click-to-place on map
No perception/ directory. POIs are manually tagged by the user — no automatic detection.
class Platform(Protocol):
camera_queue: asyncio.Queue[CameraFrame] # maxsize=2, drops old frames if full
lidar_queue: asyncio.Queue[LidarScan] # maxsize=4
odom_queue: asyncio.Queue[Pose] # maxsize=8
async def start(self) -> None: ...
async def stop(self) -> None: ...
def send_vel(self, vx: float, vy: float, omega: float) -> None: ...
def send_cmd(self, cmd: str, **kwargs) -> None: ...
def get_status(self) -> RobotStatus: ...POIs are created manually — by the user clicking on the map in the web UI, or via an MCP tool. There is no automatic detection.
@dataclass
class POI:
id: str # UUID
label: str
x: float # world frame
y: float
z: float # 0.0 for floor-level; kept for Rerun 3D display
created_at: floatWeb UI flow: user clicks a point on MapPane canvas → canvas pixel converts to world (x, y) via the map's origin + resolution → a label dialog appears → POST /api/pois → saved to SQLite → SSE poi_update event → all clients re-render POI markers on the map. Alternatively, a "Tag here" button tags the robot's current position.
MCP flow: tag_poi(label) reads state.latest_pose and saves it as a POI. Useful for telling Claude "remember this spot".
POIs persist across sessions (SQLite). They are also shown in Rerun as labelled 3D points.
async def main(cfg: RhinoConfig) -> None:
platform = MujocoGo2(cfg.sim_cfg) if cfg.sim else Go2Platform(cfg.robot)
await platform.start()
storage = Storage(cfg.storage)
state = AppState()
rerun = RerunLogger(cfg.rerun)
mapper = OccupancyMapper(cfg.map)
nav = Navigator(mapper, platform, cfg.nav)
explorer = FrontierExplorer(mapper, nav)
asyncio.create_task(camera_loop(platform, rerun, state))
asyncio.create_task(lidar_loop(platform, mapper, state, rerun))
asyncio.create_task(odom_loop(platform, nav, state, rerun))
asyncio.create_task(nav.run())
asyncio.create_task(explorer.run())
api = ApiServer(state, mapper, nav, explorer, platform, storage, cfg.server)
mcp = McpServer(platform, nav, explorer, storage, state, cfg.server)
try:
await asyncio.gather(api.serve(), mcp.serve())
finally:
await platform.stop()Registered as closures inside McpServer.__init__:
send_velocity · relative_move · standup · execute_sport · observe (returns latest frame as base64 JPEG) · get_robot_status · navigate_to (fires background task, returns immediately) · get_nav_status · explore · tag_poi(label) (saves current pose as POI) · list_pois · go_to_poi(id) (fires background nav task)
world/robot — Transform3D (robot pose)
world/camera — Image (BGR→RGB)
world/lidar — Points3D
world/occupancy — Image (grayscale grid)
world/costmap — Image
world/path — LineStrips3D
world/pois/{id} — Points3D + label
GET /api/state pose, status (battery, mode, is_standing, vx/vy/ω), path
GET /api/map occupancy PNG with robot + path baked in (legacy teleop UI)
GET /api/map/raw clean occupancy PNG — used by the React frontend
GET /api/map/info {origin_x, origin_y, resolution, width, height}
GET /api/camera/stream MJPEG stream at up to 30 fps
POST /api/navigate {x, y, yaw?}
POST /api/navigate/cancel stop navigation and clear goal
GET /api/navigate/status {goal, exploring, mode}
POST /api/navigate/mode {mode: "astar" | "direct"}
POST /api/explore/start enable frontier exploration
POST /api/explore/stop disable frontier exploration
POST /api/velocity {vx, vy, omega}
POST /api/stop zero velocity + cancel navigation
POST /api/cmd/{command} sport command (StandUp, Dance1, FrontFlip, …)
GET /api/pois list saved POIs
POST /api/pois {label, x?, y?} — x/y defaults to current pose
DELETE /api/pois/{id} remove POI
POST /api/pois/{id}/navigate navigate to POI
GET /api/health {"status": "ok"}
unitree-webrtc-connect-leshy = ">=2.0.7"
mujoco = ">=3.3.4"
numpy = ">=1.26"
scipy = ">=1.12"
opencv-python = ">=4.9"
rerun-sdk = ">=0.20.0"
fastapi = ">=0.115"
uvicorn = ">=0.30"
sse-starlette = ">=2.0"
mcp = ">=1.0"
aiosqlite = ">=0.20"No openai, no open3d, no aiohttp, no dimos-lcm.
Requirements: uv · Node.js 18+
# Backend
uv sync
uv run rhino --sim # simulation (MuJoCo)
uv run rhino --robot-ip 192.168.123.161 # real Go2
# Dev frontend (separate terminal) — proxies /api → localhost:8000
cd web
npm install # first time only
npm run dev # http://localhost:5173The legacy single-page teleop UI is still served at http://localhost:8000 by the backend.
Phase 1 — Sim + Rerun: MujocoGo2 + sensor loops + RerunLogger. Milestone: camera, lidar, and robot pose visible in Rerun from sim.
Phase 2 — Mapping: OccupancyMapper + Costmap. Milestone: map builds as robot moves; steer with platform.send_vel() from REPL.
Phase 3 — Navigation: Navigator + Controller + FrontierExplorer + skills.py. Milestone: autonomous room exploration in sim; goals settable from REPL.
Phase 4 — Web + MCP + POIs: AppState + Storage + FastAPI + McpServer + React frontend. Milestone: live web dashboard; click map to tag POIs; navigate to them from UI or Claude Desktop.
Phase 5 — Real robot: Go2Platform. Milestone: Phase 1–4 running on real hardware; NavConfig tolerances tuned to observed Go2 behaviour.
Create platforms/<name>/robot.py implementing Platform and platforms/<name>/skills.py. Add one branch in main.py. Nothing else changes.