19 KiB
3D LiDAR Mapping (SLAM) — Unitree Go2
The Mapping page in this UI talks to the robot's uslam_server (the on-board SLAM module) over WebRTC and lets the user build maps, localize on saved maps, navigate to a goal, run a patrol loop, and dock for auto-charging — all from the browser. This document covers the topics involved, the user-facing flow, the local data model, and the firmware-side state machine.
Table of Contents
- Overview
- Page Layout
- Topics
- User Flow (Stepper)
- Mapping
- Localization
- Navigation
- Auto-Charge
- Map Storage
- Status Queries
- Theme + Smoothing
- Known Quirks
- Files
Overview
The SLAM stack on the Go2 is exposed by uslam_server (ARM64 ELF at /unitree/module/unitree_lidar_slam/bin/uslam_server). It speaks DDS internally and the WebRTC bridge surfaces those topics to the browser. All command traffic is plain string publications on a single topic; data feeds (point clouds, odometry, paths) come back on per-module topics. There is no API-ID protocol here — just module/action[/arg1/arg2/...] strings.
The on-board module exposes ~56 commands across 8 namespaces (mapping, localization, navigation, patrol, autocharge, frontend, control, common). This UI drives the user-facing subset (the same one the official Unitree app uses).
Page Layout
| Region | Content |
|---|---|
| Header | back button, title, motor temp, battery, network type, theme, BT — same shape as the Control NavBar |
| Stepper banner | Step 1: Mapping ➜ Step 2: Localization ➜ Step 3: Navigation with green/yellow/grey state |
| Live detail bar | Compact line under the stepper showing nav/patrol/charge sub-state |
| Left sidebar | Send Command (template picker), Server Log (with source-topic indicator + Copy) |
| 3D viewport | Three.js scene + draggable PiP video feed |
| Right sidebar | Step 1: Mapping, Step 2: Localization, Step 3: Navigation (Goal/Patrol tabs), Autocharge |
Topics
Constants are defined in src/protocol/topics.ts.
Command channel
| Topic | Direction | Format |
|---|---|---|
rt/uslam/client_command (USLAM_CMD) |
client → robot | string module/action[/arg.../arg] |
Every SLAM command we send goes on this topic. The robot acknowledges via paired success/failed messages on the server-log topic (e.g. mapping/start/success, localization/set_initial_pose/failed).
Status feeds
All received over the WebRTC DataChannel.
| Topic | Constant | Payload | Purpose |
|---|---|---|---|
rt/uslam/server_log |
USLAM_SERVER_LOG |
std_msgs/String | Command acks, state transitions, log lines |
rt/uslam/frontend/cloud_world_ds |
USLAM_CLOUD_WORLD |
binary, voxel-compressed | Live downsampled world cloud (during mapping) |
rt/uslam/frontend/odom |
USLAM_ODOM |
nav_msgs/Odometry | Mapping-frame odometry |
rt/uslam/cloud_map |
USLAM_CLOUD_MAP |
binary (PCD) | Final map cloud (delivered post-stop) |
rt/uslam/localization/cloud_world |
USLAM_LOC_CLOUD |
sensor_msgs/PointCloud2 | Real-time localization scan in world frame |
rt/uslam/localization/odom |
USLAM_LOC_ODOM |
nav_msgs/Odometry | Robot pose during localization |
rt/uslam/navigation/global_path |
USLAM_NAV_PATH |
sensor_msgs/PointCloud2 | Planned global path |
rt/mapping/grid_map |
USLAM_GRID_MAP |
binary | 2-D occupancy grid (subscribed but not currently rendered) |
Subscription strategy (matches the APK's deferred-subscription pattern):
- On page entry — subscribe BASE topics:
USLAM_SERVER_LOG,USLAM_CLOUD_WORLD,USLAM_ODOM,USLAM_CLOUD_MAP,USLAM_GRID_MAP. - On
[Localization] initialization succeed!— additionally subscribe LOC topics:USLAM_LOC_CLOUD,USLAM_LOC_ODOM,USLAM_NAV_PATH. - On localization stop / failure / page leave — unsubscribe LOC topics.
- On page leave — unsubscribe BASE topics;
LOW_STATEis intentionally not in this list (it's globally subscribed byapp.ts:enableVideoAndSubscribe()and shared with the NavBar).
File transfer
Map artefacts (.pcd, .pgm, .txt) move over the RTC_INNER_REQ WebRTC subprotocol with related_bussiness: "uslam_final_pcd". Two ops:
| Op | req_type |
Direction |
|---|---|---|
| Read a file | request_static_file |
client → robot, response carries chunked base64 |
| Write a file | push_static_file |
client → robot, multi-chunk upload with per-chunk ack {file_status: "ok"} |
pushFile in src/protocol/data-channel.ts implements the upload side: 30 KB chunks, 500 ms breather every 5 chunks, per-chunk timeout 10 s, sequential to keep the response handler chain unambiguous.
Shared topics
| Topic | Constant | Notes |
|---|---|---|
rt/lf/lowstate |
LOW_STATE |
Battery, motor temps — globally subscribed by app.ts; surfaced in the mapping page header |
rt/utlidar/switch |
LIDAR_SWITCH |
Hardware on/off |
User Flow (Stepper)
The right sidebar mirrors the stepper sequence:
| Step | Section | Goal |
|---|---|---|
| 1 | Mapping | Build a fresh point-cloud map (or load a saved one) |
| 2 | Localization | Tell the robot where it is on that map |
| 3 | Navigation | Drive to a goal or run a patrol loop |
| extra | Autocharge | Dock and charge (manual or automatic) |
Each step is gated:
- Mapping is always available.
- Localization unlocks once a map is loaded (
mapLoaded === true). - Navigation unlocks once
mapLoaded && localized. - Autocharge unlocks with the same condition as Navigation.
The stepper colour-codes the steps: green = passed, yellow = current, grey = not reached.
Mapping
User clicks New Map:
slamScene.clearAll()+ workerclear(drops accumulated points).- Mint a client-side map ID (
generateMapId()→ 16 random bytes, URL-safe base64). The robot'smapping/stopdoes not generate a fresh ID, so we mint our own and push it viaset_map_idafter the stop. - Send
mapping/start. - While mapping runs,
cloud_world_dspackets arrive at ~5 Hz; the SLAM Worker dequantizes them vialibslam.wasminto Float32Array positions and pushes them into the Three.js point cloud.
User clicks Stop & Save:
- Send
mapping/stop. - On
mapping/stop/success: clear the live laser cloud, sendcommon/set_map_id/<minted-id>to label the freshly-saved map. - On
set_map_id/success: open the Save Map modal. - After the user names it, write the metadata to
localStorage(go2_slam_maps) and download the full bundle (PCD + PGM + TXT) into IndexedDB.
The robot only has one physical map slot at /unitree/module/unitree_lidar_slam/data/map_data/. Subsequent mappings overwrite the same files. That's why we cache locally — see Map Storage.
Localization
User drags a pose marker on the map → release fires localization/set_initial_pose/{x}/{y}/{yaw} (yaw in radians, CCW from +X). 100 ms later we auto-issue localization/start.
Server-log strings drive the UI transitions:
[Localization] initialization succeed!→ subscribe LOC topics, show robot model, light up Localization step + Navigation gate.[Localization] initialization failed!→ unsubscribe LOC topics, highlight "Set Initial Pose" with a hint to re-anchor.
Once localized, the live laser scan (USLAM_LOC_CLOUD) is rendered in white (dark theme) or accent blue (light theme) on top of the cached map cloud, and the Go2 model tracks USLAM_LOC_ODOM with frame-rate-driven smoothing (see Theme + Smoothing).
Navigation
The Navigation step has two tabs: Go to Goal and Patrol.
Go to Goal
User holds-and-drags a pose on the map. On release:
- If
navigationActive === false, sendnavigation/startfirst (the robot wouldn't move otherwise —set_goal_poseon a stopped nav module just registers the target). This auto-start makes "drag a goal → robot drives there" Just Work. - Send
navigation/set_goal_pose/{x}/{y}/{yaw}. - Place a pole marker at the goal pose for visual reference.
Sub-state handling on navigation/state_transition/<STATE>:
| State | Behaviour |
|---|---|
WAITING / TRACKING |
informational only |
REACHED |
clear goal marker + path; toast "goal reached"; if autoChargeOnReach is armed, fire autocharge/start |
NO_PATH |
clear path; toast "No path to goal" |
TIMEOUT |
toast "Navigation timed out" |
GOAL_OCCUPIED |
toast "Goal location is occupied" |
GOAL_CHANGED |
toast "Goal changed" |
FAILURE |
clear path + goal marker; no main-state change — navigationActive stays true so the user can dispatch another goal without re-pressing Start |
GOAL_CANCELLED |
informational; clear path |
Patrol
Add waypoints (hold-and-drag), then Execute Patrol. The execute sequence matches the APK's handleSureExecutePatrol:
1. patrol/start ← MUST be first; puts the module in INITIALIZE
2. wait 500 ms
3. patrol/clear_all_patrol_points
4. patrol/add_patrol_point/{x}/{y}/{yaw} × N
5. patrol/set_total_time_limit/{n}
patrol/set_patrol_time_limit/{n}
patrol/set_charge_time_limit/{n}
patrol/set_patrol_number_limit/{n}
patrol/set_bms_soc_limit/{min}/{max}
6. patrol/go
Important:
patrol/startMUST come first. After a previouspatrol/stopthe module is idle and rejects alladd_patrol_pointandset_*_limitcommands. They only succeed in the INITIALIZE window afterstart. This also means the standalone "Apply" buttons on each limit input fail when patrol is idle — the values are persisted in a localpatrolConfigstore and re-sent every Execute Patrol.
Yaw is sent as-is, in radians, CCW from +X. There is no ± π adjustment despite earlier APK code suggesting one — it produces values outside [−π, π] that the navigator flags as GOAL_POINT_UNREACHABLE. The patrol module persists waypoints to /unitree/.../map_data/patrol_points.txt; if that file has stale yaws from an older build, patrol/start will load them instead of your fresh add_patrol_point calls. Truncate it manually if you suspect drift.
Patrol Limits
All values stored in a per-page patrolConfig and re-sent on every Execute Patrol.
| Limit | Default | Trigger when reached |
|---|---|---|
patrol/set_patrol_time_limit/{s} |
-1 (unlimited) |
REACH_PATROL_TIME_LIMIT → NEED_CHARGE → dock |
patrol/set_total_time_limit/{s} |
-1 (unlimited) |
REACH_TOTAL_TIME_LIMIT |
patrol/set_charge_time_limit/{s} |
-1 |
maximum dock dwell time |
patrol/set_patrol_number_limit/{n} |
-1 (unlimited) |
REACH_PATROL_NUMBER_LIMIT |
patrol/set_bms_soc_limit/{min}/{max} |
10/80 |
min: SOC drops below → NEED_CHARGE. max: SOC charged up to → resume. Binary rejects 0/100 as out-of-range; use 1/99 to effectively disable |
patrol/clear_user_config resets all to firmware defaults.
There is no get_*_limit query — the binary only exposes setters. Current values are cached client-side once Apply is pressed.
Auto-Charge
The autocharge module is a lidar-only plate detector — no AprilTag, no fiducial. It uses PCL EuclideanClusterExtraction + KdTreeFLANN to look for a flat 300×200 mm cluster at the configured plate_distance (default 0.47 m). State sequence:
GO_TO_CHARGE_BOARD ← driving up to the dock
CONNECTING ← contact attempt
TIMEOUT_DETECT ← couldn't see/align with the plate
TIMEOUT_CONNECT_POWER ← contacts didn't mate
SUCCESS / FAILURE / EXIT
UI flow (Autocharge section):
- Go to Charging Station: ensures
navigation/start+ 1 s delay +navigation/set_goal_pose/-0.150/0.000/0.000; armsautoChargeOnReach. Onnavigation/state_transition/REACHEDwe then fireautocharge/start. - Cancel Charge: sends
navigation/stop+autocharge/stop, clears markers (mirrors APK's_A). - Plate distance (m):
autocharge/set_plate_distance/{m}— adjust if the dock isn't being detected. - Get Status:
autocharge/get_statusround-trip with 2 s timeout. - Disable in Patrol: one-click sets
bms_soc_limit/1/99 + patrol_time_limit/-1 + charge_time_limit/0 + autocharge/stopso the patrol module never enters NEED_CHARGE on its own.
Retry on autocharge/state_transition/FAILURE is capped at 5 attempts (AUTO_CHARGE_MAX_RETRIES); each retry re-issues the dock goal pose and re-arms autoChargeOnReach.
Map Storage
Why a local cache
The robot has one physical map slot. Anything saved there gets overwritten on the next mapping/start. The official Unitree app stores maps in their cloud and re-uploads them when the user picks one. We replicate the same architecture with IndexedDB instead of cloud, plus a push_static_file upload to the robot before each Load.
IndexedDB layout
Database go2_slam, store pcds, schema v2.
// keyed by robot map ID (URL-safe base64)
interface MapBundle {
pcd: ArrayBuffer; // 3D point cloud — required, used to render
pgm?: ArrayBuffer; // 2D occupancy grid (used by the robot, not the viewer)
txt?: ArrayBuffer; // origin/resolution metadata
}
src/storage/map-pcd-store.ts wraps the standard CRUD: putBundle, getBundle, deleteBundle, listBundleIds. Bundle payloads are stored alongside a localStorage index (go2_slam_maps) of {id, name, date} records that drives the saved-maps UI list.
Save flow (after set_map_id/success):
- Save the
{id, name, date}to localStorage. - Sequentially fetch
map.pcd,map.pgm,map.txtfrom the robot viarequest_static_file(sequential — concurrent calls would clobber the data-channel response handler chain). - Write the bundle into IndexedDB.
Load flow:
- Read the bundle from IndexedDB.
- Render the cached PCD immediately for fast feedback.
- If
robotSlotMapId === mapId, skip the upload — the robot already has these files. Otherwise upload all three viapushFile(chunkedpush_static_file). - Send
common/set_map_id/{id}to make the slot active. - Update
robotSlotMapIdso future Loads of the same id can skip the upload.
The fallback path (no cached bundle) renders whatever is currently in the robot's slot without writing it back to IndexedDB — set_map_id only relabels and the bytes might not actually correspond to that id.
Zip Import / Export
Each saved-map row has Export, and the Saved Maps section header has Import .zip.
Zip layout:
metadata.json { id, name, date }
map.pcd required
map.pgm optional
map.txt optional
Implemented in map-pcd-store.ts using the fflate zip codec. Useful for sharing maps between devices, archiving, or hand-editing the PCD with PCL viewers.
Status Queries
All Promise-based, 2 s timeout, single-resolver pattern matching the APK:
| Query | Topic | Resolves with |
|---|---|---|
queryMappingStatus() |
mapping/get_status |
'1' active / '0' inactive / '-1' timeout |
queryLocalizationStatus() |
localization/get_status |
'1' / '0' / '-1' |
queryPatrolStatus() |
patrol/get_status |
'1' / '0' / '-1' |
queryNavigationStatus() |
navigation/get_status |
'1' / '0' / '-1' |
queryAutoChargeStatus() |
autocharge/get_status |
'1' / '0' / '-1' |
queryCurrentMapId() |
common/get_map_id |
id string or empty on timeout |
preloadRobotState() runs all five in parallel on page entry, then restores UI state:
mapping=1→ show mapping in progress.loc=1→ mark localized, subscribe LOC topics, fetch active map id, render its cached PCD.patrol=1→ enter patrol mode, request waypoints back from the robot.nav=1→ marknavigationActive, switch the nav controls to Stop.autocharge=1→ log "autocharge already running".
Theme + Smoothing
- The 3D scene reacts to dark/light toggle:
SlamScenelistens totheme().onChange()and re-tints the renderer clear color, scene background, grid lines, and the live laser cloud (white on dark, accent blue on light). - Robot-pose smoothing is render-driven, not event-driven. Odom updates are throttled to 50 ms (matches APK at 20 Hz) and only set a
targetPos/targetQuat; theanimate()loop lerps the robot model toward that target every render frame at0.15. This absorbs per-sample SLAM jitter and produces a continuously-moving model regardless of input rate.
Known Quirks
patrol/stopalso stops the navigation module on the firmware side; we syncnavigationActive = falseon eitherpatrol/stop/successor a standalonenavigation/stop/success.patrol/pauseaborts the current navigation goal (GOAL_CANCELLED → FAILURE); resume re-plans from the current pose, which can occasionally fail one segment before recovering.set_bms_soc_limit/0/100is rejected; use1/99to effectively disable battery-triggered docking.patrol/startauto-loads from/unitree/.../map_data/patrol_points.txt. If the file is stale (e.g. from an older build that subtracted π from yaws), the robot uses the file values, not your in-memoryadd_patrol_pointcalls.- Setting a goal in Goal mode auto-fires
navigation/startif not running, so a fresh page can drag-to-go without an explicit Start press. - Yaw values must stay within
[−π, π]; values outside that range are flaggedGOAL_POINT_UNREACHABLE. - The robot does not mint a fresh map id on
mapping/stop— we mint client-side and push viaset_map_idto keep saves distinct.
Files
| File | Role |
|---|---|
| src/ui/components/mapping-page.ts | The whole feature — UI, state machine, command dispatch, server-log parsing |
| src/ui/scene/slam-scene.ts | Three.js scene: ground plane, point clouds, robot marker, goal/patrol markers, drag-to-set-pose |
| src/ui/scene/robot-model.ts | Go2 GLB model with joint-sync from LowState |
| src/workers/slam-worker.ts | libslam.wasm bridge (cloud dequantization) |
| src/storage/map-pcd-store.ts | IndexedDB CRUD + zip import/export |
| src/protocol/data-channel.ts | requestFile (download) + pushFile (chunked upload) |
| src/protocol/topics.ts | RTC_TOPIC.USLAM_* constants |
