# 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. ![SLAM view](../images/slam.png) ## Table of Contents - [Overview](#overview) - [Page Layout](#page-layout) - [Topics](#topics) - [Command channel](#command-channel) - [Status feeds](#status-feeds) - [File transfer](#file-transfer) - [Shared topics](#shared-topics) - [User Flow (Stepper)](#user-flow-stepper) - [Mapping](#mapping) - [Localization](#localization) - [Navigation](#navigation) - [Go to Goal](#go-to-goal) - [Patrol](#patrol) - [Patrol Limits](#patrol-limits) - [Auto-Charge](#auto-charge) - [Map Storage](#map-storage) - [Why a local cache](#why-a-local-cache) - [IndexedDB layout](#indexeddb-layout) - [Zip Import / Export](#zip-import--export) - [Status Queries](#status-queries) - [Theme + Smoothing](#theme--smoothing) - [Known Quirks](#known-quirks) - [Files](#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](../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_STATE` is intentionally not in this list** (it's globally subscribed by `app.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](../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**: 1. `slamScene.clearAll()` + worker `clear` (drops accumulated points). 2. Mint a client-side map ID (`generateMapId()` → 16 random bytes, URL-safe base64). The robot's `mapping/stop` does *not* generate a fresh ID, so we mint our own and push it via `set_map_id` after the stop. 3. Send `mapping/start`. 4. While mapping runs, `cloud_world_ds` packets arrive at ~5 Hz; the SLAM Worker dequantizes them via `libslam.wasm` into Float32Array positions and pushes them into the Three.js point cloud. User clicks **Stop & Save**: 1. Send `mapping/stop`. 2. On `mapping/stop/success`: clear the live laser cloud, send `common/set_map_id/` to label the freshly-saved map. 3. On `set_map_id/success`: open the Save Map modal. 4. 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](#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](#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: 1. If `navigationActive === false`, send `navigation/start` first (the robot wouldn't move otherwise — `set_goal_pose` on a stopped nav module just registers the target). This auto-start makes "drag a goal → robot drives there" Just Work. 2. Send `navigation/set_goal_pose/{x}/{y}/{yaw}`. 3. Place a pole marker at the goal pose for visual reference. Sub-state handling on `navigation/state_transition/`: | 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/start` MUST come first. After a previous `patrol/stop` the module is idle and rejects all `add_patrol_point` and `set_*_limit` commands. They only succeed in the INITIALIZE window after `start`. This also means the standalone "Apply" buttons on each limit input *fail when patrol is idle* — the values are persisted in a local `patrolConfig` store 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`; arms `autoChargeOnReach`. On `navigation/state_transition/REACHED` we then fire `autocharge/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_status` round-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/stop` so 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. ```ts // 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`](../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`): 1. Save the `{id, name, date}` to localStorage. 2. Sequentially fetch `map.pcd`, `map.pgm`, `map.txt` from the robot via `request_static_file` (sequential — concurrent calls would clobber the data-channel response handler chain). 3. Write the bundle into IndexedDB. **Load flow:** 1. Read the bundle from IndexedDB. 2. Render the cached PCD immediately for fast feedback. 3. If `robotSlotMapId === mapId`, skip the upload — the robot already has these files. Otherwise upload all three via `pushFile` (chunked `push_static_file`). 4. Send `common/set_map_id/{id}` to make the slot active. 5. Update `robotSlotMapId` so 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`](../src/storage/map-pcd-store.ts) using the [`fflate`](https://github.com/101arrowz/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` → mark `navigationActive`, switch the nav controls to Stop. - `autocharge=1` → log "autocharge already running". ## Theme + Smoothing - The 3D scene reacts to dark/light toggle: `SlamScene` listens to `theme().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`; the `animate()` loop lerps the robot model toward that target every render frame at `0.15`. This absorbs per-sample SLAM jitter and produces a continuously-moving model regardless of input rate. ## Known Quirks - `patrol/stop` also stops the navigation module on the firmware side; we sync `navigationActive = false` on either `patrol/stop/success` or a standalone `navigation/stop/success`. - `patrol/pause` aborts 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/100` is rejected; use `1/99` to effectively disable battery-triggered docking. - `patrol/start` auto-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-memory `add_patrol_point` calls. - Setting a goal in Goal mode auto-fires `navigation/start` if 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 flagged `GOAL_POINT_UNREACHABLE`. - The robot does *not* mint a fresh map id on `mapping/stop` — we mint client-side and push via `set_map_id` to keep saves distinct. ## Files | File | Role | |---|---| | [src/ui/components/mapping-page.ts](../src/ui/components/mapping-page.ts) | The whole feature — UI, state machine, command dispatch, server-log parsing | | [src/ui/scene/slam-scene.ts](../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](../src/ui/scene/robot-model.ts) | Go2 GLB model with joint-sync from `LowState` | | [src/workers/slam-worker.ts](../src/workers/slam-worker.ts) | `libslam.wasm` bridge (cloud dequantization) | | [src/storage/map-pcd-store.ts](../src/storage/map-pcd-store.ts) | IndexedDB CRUD + zip import/export | | [src/protocol/data-channel.ts](../src/protocol/data-channel.ts) | `requestFile` (download) + `pushFile` (chunked upload) | | [src/protocol/topics.ts](../src/protocol/topics.ts) | `RTC_TOPIC.USLAM_*` constants |