Version: 2.0 | SDK: Spot 4.1.1 | ROS 2: Jazzy (GCS) / Galactic (V60)
- System Overview
- Component Inventory
- SpotCommandCenter — Web Dashboard
- hetero_follow — Multi-Robot ROS 2 Demo
- Data Flow Diagrams
- Network Topology
- ROS 2 Topic Graph
- Control Law — Pure Pursuit
- Safety Architecture
- Simulation Mode
- Deployment Model
- Key Design Decisions
- Limitations & Known Issues
- Future Roadmap
The system has two co-existing subsystems operating on overlapping hardware:
┌─────────────────────────────────────────────────────────────────────────────┐
│ GROUND CONTROL STATION (GCS) │
│ 192.168.168.x / Ubuntu 24.04 │
│ │
│ ┌─────────────────────────────┐ ┌──────────────────────────────────────┐ │
│ │ SpotCommandCenter │ │ hetero_follow (ROS 2 Jazzy) │ │
│ │ │ │ │ │
│ │ React UI ←→ Flask API │ │ spot_bridge ←→ v60_follower │ │
│ │ :3000 :8000 │ │ (Spot SDK) (pure-pursuit) │ │
│ └──────────┬──────────────────┘ └──────────────┬───────────────────────┘ │
│ │ HTTP/WS │ ROS 2 / CycloneDDS │
└─────────────┼──────────────────────────────────────┼────────────────────────┘
│ Spot SDK gRPC │ DDS (domain 123)
▼ ▼
┌──────────────────┐ ┌────────────────────────┐
│ Boston Dynamics │ │ Ghost Robotics V60 │
│ Spot │ │ 192.168.168.105 │
│ 192.168.168.y │ │ ROS 2 Galactic │
└──────────────────┘ └────────────────────────┘
- SpotCommandCenter is a browser-based dashboard for direct control, live video, and telemetry of Spot.
- hetero_follow is a ROS 2 workspace that makes the Ghost Robotics V60 autonomously track Spot using a pure-pursuit controller.
- Both subsystems connect to Spot simultaneously but use different channels (HTTP/SDK vs ROS 2 DDS bus).
| Component | Tech | Location | Port / Topic |
|---|---|---|---|
| React Frontend | React 18, Axios, WebSocket | frontend/ |
:3000 |
| Flask Backend | Python 3.12, Flask, Spot SDK 4.1.1 | backend/ |
:8000 |
| spot_pose_publisher | ROS 2 Node, Spot SDK | spot_bridge/ |
/spot/pose |
| spot_estop_monitor | ROS 2 Node, Spot SDK | spot_bridge/ |
/estop |
| pursuit_controller | ROS 2 Node | v60_follower/ |
/mcu/command/manual_twist |
| safety_monitor | ROS 2 Node | v60_follower/ |
/mcu/command/manual_twist |
| figure8_publisher | ROS 2 Node (sim only) | v60_follower/ |
/spot/pose, /odom |
| Ghost V60 MCU | ROS 2 Galactic (onboard) | 192.168.168.105 | /odom, /mcu/command/manual_twist |
┌────────────────────────────────────────────────────────────────────┐
│ TIER 1 — Spot Robot │
│ 7 cameras · IMU · Odometry · Joint States · Battery │
└──────────────────────────────┬─────────────────────────────────────┘
│ Spot SDK gRPC (protobuf)
┌──────────────────────────────▼─────────────────────────────────────┐
│ TIER 2 — Backend (Python Flask :8000) │
│ │
│ ┌──────────────┐ ┌────────────────┐ ┌──────────────────────┐ │
│ │ SpotRobot │ │ PARVS Streaming│ │ REST API │ │
│ │ Controller │ │ Engine │ │ /api/robot/* │ │
│ │ (SDK Bridge) │ │ (WebSocket) │ │ /api/camera/* │ │
│ └──────┬───────┘ └───────┬────────┘ └──────────────────────┘ │
│ │ robot_state │ binary tile packets │
│ ┌──────▼───────────────────▼──────────────────────────────────┐ │
│ │ RobotManager (multi-robot registry) │ │
│ │ robots: { spot: SpotController, v60: GhostController } │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ Optional: GestureController · VisionProcessor · VideoRecorder │
│ CoreIOClient · ArmController · GhostSDKProxy │
└──────────────────────────────┬─────────────────────────────────────┘
│ HTTP JSON / WebSocket binary
┌──────────────────────────────▼─────────────────────────────────────┐
│ TIER 3 — Frontend (React :3000) │
│ │
│ Login → JWT → Authenticated routes │
│ │
│ ┌─────────────┐ ┌────────────┐ ┌────────────┐ ┌───────────────┐ │
│ │ CameraFeed │ │ControlPanel│ │ RobotStatus│ │ GhostDashboard│ │
│ │ CameraGrid │ │ ArmControl │ │ Telemetry │ │ GhostSensor │ │
│ │ SurroundView│ │ GripperCtrl│ │ FaultLog │ │ Panel │ │
│ └─────────────┘ └────────────┘ └────────────┘ └───────────────┘ │
│ │
│ Also: MissionControl · GestureControl · DataAcquisition │
│ VisionPanel · PoseHistory · VideoRecording · WiFiPanel │
└────────────────────────────────────────────────────────────────────┘
| Module | Responsibility |
|---|---|
app.py |
Flask app factory, route registration, init_robot() startup |
robot_controller.py |
SpotRobotController — wraps all Spot SDK calls |
robot_manager.py |
RobotManager — registry for Spot + Ghost; active_robot() routing |
ghost_controller.py |
GhostRobotController — Ghost SDK proxy |
ghost_sdk_proxy.py |
Low-level Ghost SDK HTTP bridge |
camera_stream.py |
Camera frame acquisition + PARVS pipeline entrypoint |
arm_controller.py |
Spot arm + gripper kinematics |
auth.py |
JWT issue / verify decorators |
config.py |
ROBOT_IP, ROBOT_USERNAME, ROBOT_PASSWORD, JWT_SECRET from .env |
vision/ |
YOLO / SAM2 / RT-DETR inference pipeline |
gesture/ |
MediaPipe-based gesture recognition → movement mapping |
video_recorder.py |
Frame capture to disk (recordings/) |
coreio_client.py |
CoreIO payload HTTP client |
POST /api/auth/login → JWT token
GET /api/robot/status → battery, pose, e-stop state
POST /api/robot/command → move / stand / sit / self-right
GET /api/robot/select/<id> → switch active robot (spot | ghost)
GET /api/camera/<source> → JPEG frame (MJPEG polling)
WS /ws/stream → PARVS binary tile stream
POST /api/arm/move → joint-space arm command
POST /api/mission/execute → waypoint mission
POST /api/data/acquire → trigger data acquisition service
Browser → POST /api/auth/login {user, pass}
← 200 { token: "eyJ..." }
Browser → GET /api/robot/status
Authorization: Bearer eyJ...
← 200 { battery: 87, ... }
hetero_follow/
├── spot_bridge/ ─── ROS 2 package — Spot SDK → DDS
│ └── spot_bridge/
│ ├── spot_pose_publisher.py (Node)
│ └── spot_estop_monitor.py (Node)
├── v60_follower/ ─── ROS 2 package — pursuit + safety
│ └── v60_follower/
│ ├── pursuit_math.py (pure Python — no ROS deps)
│ ├── pursuit_controller.py (Node)
│ ├── safety_monitor.py (Node)
│ └── figure8_publisher.py (Node — sim only)
├── config/
│ └── follow_params.yaml
├── launch/
│ └── follow_demo.launch.py
└── tests/
├── test_pursuit_math.py
└── test_pose_filtering.py
- Credentials:
SPOT_IP,SPOT_USERNAME,SPOT_PASSWORDenv vars - Acquires a Spot SDK lease on startup; releases on shutdown
- Calls
robot_state_client.get_robot_state()at 20 Hz - Extracts body → odom transform via
get_a_tform_b(ODOM_FRAME_NAME, BODY_FRAME_NAME) - Publishes
geometry_msgs/PoseStampedon/spot/pose - Reconnect with exponential backoff (1 s → 2 s → … → 30 s cap)
- Polls
estop_client.get_status()at 5 Hz - Publishes
std_msgs/Boolon/estop - Conservative default: publishes
True(stopped) on any connectivity failure
- Subscribes:
/spot/pose(PoseStamped),/odom(Odometry),/estop(Bool) - Publishes:
/mcu/command/manual_twist(Twist) at 20 Hz - Stale-pose guard: if
/spot/posetimestamp >stale_pose_timeout(0.5 s) → zero vel - E-stop guard: if
/estop= True → zero vel (safety_monitor also overrides) - Math kernel in
pursuit_math.py— fully testable without ROS 2
- Subscribes
/estop - On estop engage: floods
/mcu/command/manual_twistwith zero Twist at 50 Hz for 2.0 s - Separate node — overrides pursuit_controller output even if that node hangs
- Publisher on the same topic wins by recency (last-write-wins on V60 MCU subscriber)
- Publishes synthetic Spot pose on a Lissajous figure-8 path to
/spot/pose - Publishes static V60 odometry (at −1.5 m) to
/odom - Runs at 20 Hz; parameter
OMEGAcontrols figure-8 speed
Spot Hardware
│
│ gRPC (Spot SDK 4.1.1)
▼
spot_pose_publisher ──── /spot/pose (PoseStamped, 20 Hz) ───────────────┐
│
spot_estop_monitor ───── /estop (Bool, 5 Hz) ──────────────────────┐ │
│ │
V60 onboard MCU │ │
│ │ │
│ CycloneDDS (domain 123) │ │
▼ ▼ ▼
/odom (Odometry, ~50 Hz) ──────────────────────► pursuit_controller ─────┐
safety_monitor ─────────┤
│
/mcu/command/manual_twist
(Twist, 20 Hz) │
▼
V60 MCU actuators
figure8_publisher
│
├── /spot/pose (synthetic Lissajous figure-8)
│
└── /odom (static V60 start position)
│
▼
pursuit_controller
│
└── /mcu/command/manual_twist (velocity commands, echoed to terminal)
Browser
│
├─(1) POST /api/auth/login ──► Flask ──► JWT issued
│
├─(2) GET /api/robot/status ──► Flask ──► SpotSDK ──► Spot
│ ◄──────── robot state ◄────────
│
├─(3) POST /api/robot/command { "command": "stand" }
│ ──► Flask ──► robot_controller.stand() ──► Spot gRPC
│
└─(4) WebSocket /ws/stream
──► PARVS engine tiles ──► Canvas compositor ──► display
Layer 2 flat subnet: 192.168.168.0/24
ROS_DOMAIN_ID = 123
RMW = rmw_cyclonedds_cpp
┌────────────────────────────────────────────────────────┐
│ │
│ GCS Laptop Spot Ghost V60 │
│ 192.168.168.x 192.168.168.y 192.168.168.105 │
│ │
│ ←────────── Spot SDK gRPC (TCP) ──────────► │
│ │
│ ←─────────── DDS multicast (UDP) ────────────────► │
│ /spot/pose /estop → GCS publishes │
│ /odom /mcu/command/manual_twist ← GCS controls │
│ │
└────────────────────────────────────────────────────────┘
Ports used:
:8000 Flask REST API (GCS)
:3000 React dev server (GCS)
:7402 Spot SDK gRPC (Spot onboard)
UDP CycloneDDS discovery + data (ephemeral, multicast)
┌─────────────────────┐
│ spot_pose_publisher │
│ (spot_bridge pkg) │
└──────────┬──────────┘
│ /spot/pose
│ geometry_msgs/PoseStamped @20Hz
┌─────────────────────┐
│ spot_estop_monitor │
│ (spot_bridge pkg) │
└──────────┬──────────┘
│ /estop
│ std_msgs/Bool @5Hz
│
┌───────────────────┼───────────────────┐
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌────────────────┐ ┌───────────────┐
│ /odom │ │pursuit_ │ │ safety_monitor│
│ (V60 MCU) │──►│controller │ │ │
│ nav_msgs/ │ │ │ │ │
│ Odometry │ └───────┬────────┘ └───────┬───────┘
└──────────────┘ │ │
│ /mcu/command/manual_twist
│ geometry_msgs/Twist @20Hz / @50Hz
▼
┌──────────────┐
│ V60 MCU │
│ actuators │
└──────────────┘
Topic registry:
| Topic | Type | Publisher | Subscribers | Rate |
|---|---|---|---|---|
/spot/pose |
geometry_msgs/PoseStamped |
spot_pose_publisher |
pursuit_controller |
20 Hz |
/estop |
std_msgs/Bool |
spot_estop_monitor |
pursuit_controller, safety_monitor |
5 Hz |
/odom |
nav_msgs/Odometry |
V60 MCU | pursuit_controller |
~50 Hz |
/mcu/command/manual_twist |
geometry_msgs/Twist |
pursuit_controller, safety_monitor |
V60 MCU | 20–50 Hz |
/gx5/nav/odom |
nav_msgs/Odometry |
V60 IMU unit | — (backup, unused v0.1) | ~100 Hz |
All math lives in pursuit_math.py (zero ROS dependencies, fully unit-tested).
where follow_distance (default 1.5 m) and
Arrival zone (
Normal tracking: $$ v = \text{clamp}(k_v \cdot |\Delta|,\ 0,\ v_{max}) $$ $$ \omega = \text{clamp}(k_\omega \cdot e_\theta,\ \pm\omega_{max}) $$
Large heading penalty (
| Parameter | Value | Description |
|---|---|---|
follow_distance |
1.5 m | Desired gap |
arrival_threshold |
0.2 m | Arrival zone radius |
stale_pose_timeout |
0.5 s | Comms loss cutoff |
max_linear_vel |
1.0 m/s | Speed cap |
max_angular_vel |
1.2 rad/s | Turn rate cap |
k_linear |
0.8 | Distance gain |
k_angular |
1.5 | Heading gain |
k_heading_align |
0.8 | Final alignment gain |
Safety is implemented in independent, overlapping layers:
Layer 1 — Stale Pose Guard (pursuit_controller)
If /spot/pose timestamp > 0.5s old → publish zero Twist immediately
Layer 2 — E-Stop Subscriber (pursuit_controller)
If /estop = True → publish zero Twist, skip control loop
Layer 3 — SafetyMonitor node (independent process)
If /estop = True → flood zero Twist at 50 Hz for 2.0s
Operates even if pursuit_controller is deadlocked or crashing
Layer 4 — V60 Hardware Watchdog
V60 MCU has an onboard watchdog — stops motors if no cmd_vel received for ~500ms
Layer 5 — Spot SDK E-Stop
spot_estop_monitor publishes True conservatively (on any SDK failure)
Triggers Layers 2+3 automatically on Spot connectivity loss
Why two zero-vel publishers? Layers 2 and 3 publish on the same topic. The V60 MCU processes the last received message — if pursuit_controller is stuck in a Python exception, safety_monitor still floods zeros independently.
ros2 launch v60_follower follow_demo.launch.py sim_mode:=trueIn simulation mode:
figure8_publisherreplacesspot_pose_publisher+spot_estop_monitor- No Spot SDK calls, no Spot robot needed
- No actual V60 connection needed —
/mcu/command/manual_twistis published but not consumed by real hardware pursuit_controller+safety_monitorrun identically to real mode
Lissajous figure-8 path:
$$
x(t) = A\sin(t), \quad y(t) = B\sin(2t)
$$
where
GCS (bare metal Ubuntu 24.04)
└── backend/ python app.py → :8000
└── frontend/ npm start → :3000
└── hetero_follow/ ros2 launch ... → DDS bus
services:
backend: spot-backend:latest → :5000
frontend: spot-frontend:latest → :3000
nginx: nginx:alpine → :80 / :443 (reverse proxy)| Variable | Used By | Default |
|---|---|---|
ROBOT_IP |
Flask backend | 192.168.1.100 |
ROBOT_USERNAME |
Flask backend | — |
ROBOT_PASSWORD |
Flask backend | — |
JWT_SECRET |
Flask auth | — |
SPOT_IP |
spot_bridge nodes | — |
SPOT_USERNAME |
spot_bridge nodes | — |
SPOT_PASSWORD |
spot_bridge nodes | — |
ROS_DOMAIN_ID |
all ROS 2 nodes | 123 |
RMW_IMPLEMENTATION |
all ROS 2 nodes | rmw_cyclonedds_cpp |
CYCLONEDDS_URI |
CycloneDDS | /home/ghost/.cyclonedds.xml (V60) |
The Flask backend uses the Spot SDK for direct control and video streaming. spot_pose_publisher (ROS 2) also holds a lease for pose reading. These coexist because spot_pose_publisher acquires an independent lease and only reads state — it does not issue commands. Both can run simultaneously.
The Ghost V60 runs ROS 2 Galactic with CycloneDDS. The GCS runs ROS 2 Jazzy. CycloneDDS on both ends is the only RMW that wire-compatible across these versions on the same L2 subnet. ROS_DOMAIN_ID=123 isolates the demo bus from any other ROS 2 traffic on the network.
The control law math (compute_twist, wrap_to_pi, clamp, yaw_from_quaternion) has no ROS dependency. Keeping it in a separate file enables 24 unit tests to run with plain pytest — no ROS 2 runtime, no DDS daemon, no mock nodes needed.
safety_monitor is a second ROS 2 node that publishes on the same /mcu/command/manual_twist topic. Publishing zeros from a separate process means the V60 stops even if pursuit_controller's Python thread is blocked on an exception or stuck in a slow control loop.
Both robots' odometry frames are assumed to coincide at launch (physical co-location). This avoids a TF2 calibration setup at the cost of a pre-flight requirement. Option B (static transform from tape-measure or AprilTag) is tracked as a future enhancement.
On startup, init_robot() tries to connect to real Spot. On any failure (SDK not installed, robot unreachable, wrong credentials) it falls back to MockSpotRobotController with deterministic fake data. This allows the dashboard to be developed and demonstrated without a physical robot.
| Issue | Impact | Mitigation |
|---|---|---|
| Frame co-location (Option A) | Drift accumulates over time | Pre-flight alignment; Option B in roadmap |
| V60 odom drift | Path error grows over long runs | GPS-aided /gx5/nav/odom wiring planned |
| Spot lease exclusivity | Dashboard + ROS 2 bridge both need Spot simultaneously | spot_bridge uses read-only state; dashboard holds control lease |
| No obstacle avoidance | V60 follows blindly | Clear the path; hardware estop available |
| ROS 2 version skew (Jazzy vs Galactic) | Potential message ABI break on custom types | Only using standard msgs; confirmed working |
sim_mode does not simulate V60 movement |
Controller output is ignored | Open-loop verification only for now |
- Option B frame calibration — AprilTag or tape-measure static TF between Spot odom and V60 odom
- GPS-aided fallback — wire
/gx5/nav/odomas backup odometry source - n-follower support — namespaced topics for multiple V60s tracking one Spot
- RViz2 visualization — TF tree + path visualization for demo
- SpotCommandCenter ROS 2 bridge panel — live ROS 2 topic monitor embedded in the React dashboard
- CI pipeline — GitHub Actions running
pytest tests/on every push (no ROS 2 required) - Closed-loop sim — integrate V60 kinematics so
pursuit_controlleroutput drives the simulated/odom