Files

330 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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/<minted-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>`:
| 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 |