Skip to content

Headless Apple-Silicon (arm64/CPU) sim port: VNC GUIs, SLAM, autonomous nav2, and a ROS debug MCP server#5

Open
correll wants to merge 19 commits into
mainfrom
mac-arm64-sim-port
Open

Headless Apple-Silicon (arm64/CPU) sim port: VNC GUIs, SLAM, autonomous nav2, and a ROS debug MCP server#5
correll wants to merge 19 commits into
mainfrom
mac-arm64-sim-port

Conversation

@correll

@correll correll commented Jul 9, 2026

Copy link
Copy Markdown

Summary

Adds a headless, CPU-only Apple-Silicon (arm64) port of the HAMS simulation — ROS 2 (Humble) + MuJoCo/RoboCasa — that runs entirely under Colima/Docker on a Mac with no Isaac, no NVIDIA, no GPU. The change is almost entirely additive (+2618 / −13 across 21 files): new arm64 Docker images, a standalone docker/docker-compose.mac.yml, and helper scripts. The existing Linux/Isaac path is untouched.

From a cold docker compose up, this brings the H1-2 up in a RoboCasa kitchen and can autonomously map and navigate it — verified end to end (robot walked ~3 m avoiding furniture, upright throughout, 0 falls).

What's included

Base sim port

  • BaseDockerfile.arm64, RobocasaDockerfile.arm64, RosDockerfile.slim.arm64 — arm64 images.
  • docker-compose.mac.yml — both containers on network_mode: host + ipc: host, FastDDS (rmw_fastrtps_cpp), named volumes for the colcon build/install (virtiofs stalls symlink-install).

GUIs over VNC/noVNC (XQuartz can't do indirect GLX on Apple Silicon)

  • In-container Xvfb + llvmpipe software GL + x11vnc + websockify + fluxbox, streamed over noVNC. HAMS_DISPLAY=vnc (MuJoCo viewer, :6080) and HAMS_RVIZ=vnc (RViz, :6081) are independent.
  • mac_vnc_tunnel.sh — Colima forwards no container ports, so an SSH tunnel over Colima's own SSH exposes the noVNC (and MCP) ports to the Mac.

Robot control

  • robot_cli.shrob_pose / rob_grip / rob_stand / rob_walk / rob_go / rob_explore helpers.
  • Lower-body controller: HAMS_LOWERBODY=fame (RMA policy stands unsupported) or switch (switchable stand↔walk with a gated FAME→walk handover).

Perception + 2D SLAM (HAMS_SLAM=1)

  • Sim ground-truth /odom + odom→pelvis TF from the pelvis free-joint (skips a heavy FAST-LIO build).
  • pointcloud_to_laserscan (Livox cloud → /converted_scan) → slam_toolbox/map.
  • Lidar self-filter in the bridge: mask the robot's own body geoms so the torso lidar doesn't see its own legs/arms.

Autonomous navigation (HAMS_NAV2=1)

  • nav2 (Smac planner + RegulatedPurePursuit) on the SLAM map + a costmap that now includes the full 3D Livox cloud.
  • frontier_explore.py — nearest-frontier exploration with a failure blacklist.
  • HAMS_SPAWN_BACKOFF — spawn the robot in open floor so nav2 has room to plan.

Stability & robustness fixes (this is where most of the recent debugging went)

  • nav2 costmap — the global obstacle layer's params were buried under a stray ros__parameters: key, so the planner had zero sensor sources and drove into furniture. Flattened, and added the 3D cloud so it sees obstacles the flat scan slice misses.
  • Motor watchdog — the sim's rt/lowcmd watchdog used a 100 ms wall-clock timeout, far too tight on a ~0.2× sim, so it zeroed the motors and dropped the robot ~0.7 s after the tether released. Made it sim-time based (HAMS_CMD_TIMEOUT).
  • X-lock cleanupdocker restart left a stale /tmp/.X99-lock that crashed Xvfb and took the whole sim container down. Cleared before start, in both launch scripts.

ROS debugging MCP server (HAMS_ROS_MCP=1)

  • ros_mcp_server.py runs inside the ros container (where DDS is reachable) and exposes an HTTP MCP endpoint, tunneled to the Mac like the viewers. Warm rclpy subscribers back tools like robot_status, wait_for, costmap_summary, drive — so tooling can inspect/drive the robot without the slow ros2 CLI dance.

Docs — a macOS section in README.md, plus docs/NAVIGATION_DEMO.md and docs/ROS_MCP_DEBUG.md.

Key gotchas (baked into the code + docs)

  • Both containers need ipc: host, or FastDDS SHM data silently fails across the container boundary (discovery works, data doesn't).
  • The two Xvfb must use different displays (:99 / :100) — the containers share one network namespace.
  • Restart both containers coherently: the sim owns /clock, so restarting only one desyncs sim time (TF extrapolation errors, a frozen 0×0 SLAM map).
  • HAMS_SPAWN_BACKOFF and HAMS_ROS_MCP are baked at container-create — apply with compose up --force-recreate, not a bare docker restart.

Testing

  • Autonomous nav, end to end: frontier exploration walked the robot ~3 m through the kitchen, upright the whole time (uprightness 1.0), with the global costmap marking thousands of obstacle cells and no falls or watchdog trips.
  • MCP server: launcher auto-start verified; live robot_status / scan_status / wait_for / costmap_summary over an MCP round-trip; reachable from the Mac through the tunnel.

Known limitations

  • The sim runs ~0.1–0.2× real-time on CPU (cameras off roughly doubles it).
  • The walk policy is stable via the switchable controller (handed over from FAME) but the raw HAMS_LOWERBODY=walk policy just marches in place; FAME can occasionally topple.
  • Some exploration goals time out on the slow sim and get blacklisted — expected, not a failure.
  • The CL_Assets submodule is required at runtime; the MuJoCo :99 viewer and the RoboCasa fall-logger aren't reachable from the ros-side MCP server (a second endpoint could add those).

🤖 Generated with Claude Code

curobotlab and others added 19 commits July 7, 2026 14:33
Standalone Colima-based bringup of ROS + MuJoCo/RoboCasa on Apple Silicon,
headless and CPU-only (Isaac omitted). Verified end-to-end: both containers
boot and exchange data (/clock ~86 Hz, /joint_states ~18 Hz, sim receives
/lowcmd), with the Pink IK frame_task_server and safety_node running.

New:
- docker/docker-compose.mac.yml: standalone compose (no NVIDIA/X11/dri/input);
  robocasa + ros on network_mode:host, RMW=rmw_fastrtps_cpp.
- docker/{Base,Robocasa}Dockerfile.arm64, docker/RosDockerfile.slim.arm64:
  arm64 images; slim ROS image drops the heavy x86 ML stack.
- docker/scripts/launch_{robocasa,ros}_mac.sh: osmesa+headless sim launcher;
  slim 5-package colcon build + minimal bringup.
- core_ws/.../h1_sim_bringup_mac.launch.py: core nodes only (state pubs, Pink
  IK, safety, camera TF) — no vision/Nav2/rviz.

Two cross-container fixes needed on arm64/Colima:
- ipc:host on both services. FastDDS prefers shared-memory transport for
  same-host peers; with private IPC namespaces (separate /dev/shm) discovery
  works over UDP but data is silently dropped (sim logs "Command timeout!
  Releasing motors"). x86 uses CycloneDDS/UDP and avoids this.
- colcon --build-base/--install-base on named volumes (native ext4), not the
  virtiofs bind-mount, where --symlink-install stalls for minutes at 0% CPU.
  h12_ros2_model derives CL_Assets from its build dir, so CL_Assets is also
  mounted at /opt/CL_Assets for the ros build.

Also: gitignore process core dumps; drop a stray blank line in h12_mujoco.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… to the Mac arm64 port

MuJoCo's and RViz's OpenGL windows cannot forward to macOS XQuartz (broken
indirect GLX on Apple Silicon), so render them in-container with software GL
(llvmpipe) into an Xvfb and stream pixels out over VNC/noVNC.

- RobocasaDockerfile.arm64 / RosDockerfile.slim.arm64: add xvfb, x11vnc, novnc,
  websockify, fluxbox, libgl1-mesa-dri (+ ros-humble-rviz2 for the ros image),
  appended last so they don't bust the heavy asset/colcon layers.
- launch_robocasa_mac.sh: HAMS_DISPLAY=vnc renders the MuJoCo viewer to Xvfb :99,
  served on noVNC :6080. Default headless (OSMesa) is unchanged.
- launch_ros_mac.sh: HAMS_RVIZ=vnc renders RViz to Xvfb :100, served on :6081.
  Distinct display number is required — both containers share one network
  namespace (network_mode: host), so two Xvfb on :99 collide on the abstract
  X socket.
- docker-compose.mac.yml: pass HAMS_DISPLAY (robocasa) and HAMS_RVIZ (ros).
- mac_vnc_tunnel.sh: SSH-tunnel the noVNC/VNC ports to the Mac (Colima does not
  forward host-network container ports); forwards both viewers.
- h1_sim.rviz: minimal RViz config (RobotModel + TF, fixed frame pelvis).
- robot_cli.sh: rob_pose (named-config postures) + rob_grip helpers to drive the
  H1. rob_reach/FrameTask is stubbed out — it currently crashes frame_task_server.
- README.md: add a self-contained macOS (Apple Silicon) section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ros2 launch h1_bringup <TAB> failed: the package was never colcon-built
(colcon-ros refused it — its in-workspace exec_depends estop/livox_ros_driver2/
fast_lio/model_server aren't built on this CPU-only image), and interactive
shells sourced the stale host install instead of the real build.

- launch_ros_mac.sh: add h1_bringup to the build list, dropping empty package.sh
  stubs for the 4 unbuilt deps first so colcon installs h1_bringup's launch/
  config/rviz files and registers it (stubs carry no ament marker, so they never
  appear in `ros2 pkg list`). Only h1_sim_bringup_mac.launch.py runs here.
- RosDockerfile.slim.arm64: source /opt/core_ws/install (the real INSTALL_BASE,
  a named volume) in .bashrc instead of the virtiofs /home/code/core_ws/install,
  so `docker exec … bash` sees the same packages as the running bringup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The MuJoCo bridge already publishes /livox/pointcloud (sensor_msgs/PointCloud2),
but its frame_id 'lidar_link' had no TF, so RViz couldn't place it.

- h1_sim_bringup_mac.launch.py: static TF livox_link -> lidar_link (identity;
  they're co-located) so the cloud transforms into the robot/TF tree.
- h1_sim.rviz: add a PointCloud2 display on /livox/pointcloud (best-effort QoS,
  AxisColor by Z). Verified: ~20k pts/scan render around the robot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…popup

Lower-body controller (HAMS_LOWERBODY, default off):
- HAMS_LOWERBODY=fame runs the RMA fame_node — it releases the elastic-band
  tether (after the IK inits) and BALANCES the robot standing unsupported
  (verified: no fall). It stands/squats but does not locomote. HAMS_LOWERBODY=walk
  runs the TorchScript walk policy, which currently falls ~4s after release in the
  RoboCasa sim (kept for completeness). True forward walking needs a sim-tuned
  policy — future work.
- launch_ros_mac.sh builds unitree_hg + h12_lowerbody_controller; the bringup
  launches fame_node/walking_node gated on HAMS_LOWERBODY; compose threads it.
- RosDockerfile: add ros-humble-rosidl-generator-dds-idl (unitree_hg needs it to
  generate DDS IDL). torch is already in the image.

Wallpaper popup:
- fluxbox's default style calls fbsetbg, which pops an xmessage "wallpaper cannot
  be set" error (no wallpaper setter installed). Both images now replace
  /usr/bin/fbsetbg with a shim that sets a solid root via fbsetroot (bundled). The
  fluxbox rootCommand init override does NOT stop it — fbsetbg is invoked from the
  style, so replacing the binary is what works.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…pping

Makes the H1 walk and build a live occupancy map on the Mac port.

- Switchable lower-body controller: HAMS_LOWERBODY=switch runs
  lowerbody_controller_node (band-held idle -> /lowerbody/start_{fame,walk} with a
  gated handover). FAME stands/manipulates; walk locomotes. From a stable FAME
  stance the walk policy stays UP and translates (0 falls), vs. the raw walk node
  that toppled in ~4s.
- Sim ground-truth odometry: mujoco_ros_bridge publishes odom -> pelvis TF +
  /odom from the pelvis free-joint world pose. This is the SLAM odom source, so no
  FAST-LIO / Livox-SDK build is needed.
- 2D SLAM (HAMS_SLAM=1 in the bringup): pointcloud_to_laserscan flattens the Livox
  hemisphere cloud into /converted_scan (tuned per h1_navigation), and slam_toolbox
  builds /map against /odom. Verified: ~126 scan returns/frame, occupancy /map
  updating in RViz as the robot walks. Packages baked into the ros image.
- RViz: add Map + LaserScan displays, fixed frame odom, top-down view following
  the pelvis.
- robot_cli.sh: rob_stand / rob_walk / rob_go / rob_stop / rob_odom helpers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three fixes to make the walk+map demo actually build a map:

- HAMS_SLAM was never threaded through compose to the ros container, so the
  bringup never launched pointcloud_to_laserscan / slam_toolbox (earlier "working"
  runs were manual). Add the passthrough.
- pointcloud_to_laserscan's h1_navigation tuning (range_min 0.6, band -0.90..0.55)
  filtered every return out for close-quarters kitchen spawns (drops the counter
  the robot is against, keeps only the floor which is below the band). Relax to
  range_min 0.4, band -0.85..1.2, range_max 8 -> ~128 returns; verified the
  occupancy /map builds in RViz as the robot walks/turns.
- HAMS_CAMERAS=0 drops the 3 RGBD camera renders (the heaviest per-step CPU cost);
  ~doubles sim RTF (0.1 -> 0.2x) for locomotion/SLAM. Lidar + odom unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The walk policy is blind (tracks /cmd_vel, no obstacle awareness). nav2 adds the
avoidance layer: it plans a collision-free path on the SLAM occupancy map + a
lidar obstacle costmap and drives /cmd_vel, which the walk policy follows.

- nav2_config_mac.yaml: nav2_config.yaml with the odom frame swapped from
  FAST-LIO's camera_init to the sim's odom, and inflation_radius trimmed to 0.2.
  RegulatedPurePursuit controller + Smac hybrid planner, costmap from /map +
  /converted_scan, robot_base_frame pelvis.
- Bringup: HAMS_NAV2=1 includes nav2_bringup/navigation_launch.py (implies SLAM).
  Verified: the full stack (controller/planner/bt_navigator/costmaps/velocity_
  smoother) comes up ACTIVE, and its velocity_smoother publishes /cmd_vel -> the
  walk policy consumes it directly. Send goals via RViz "2D Nav Goal" or
  /navigate_to_pose.
- ros image: bake ros-humble-nav2-bringup.

Note: nav2 rejects a goal when the robot's start cell is occupied ("start in
lethal space") -- the robot must be standing in open floor, not pressed against a
counter, for planning to succeed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
place_robot_collision_free gains extra_backoff: after clearing the spawn
collision, keep backing the robot off the counter (its -x) up to N metres,
stopping before a new collision behind, so it stands in open floor with room in
front. HAMS_SPAWN_BACKOFF (metres, default 0 = at the counter for manipulation)
drives it from h12_mujoco. Verified: "backed robot 1.00 m further into open floor".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The torso lidar returns the robot's own legs/feet at ~0.4m; mapping them puts a
phantom obstacle under the robot so nav2 rejects the start. 0.65 drops most of it
(the arms/thighs in gait still reach ~0.65m -- a proper self-filter is the full
fix). Pairs with HAMS_SPAWN_BACKOFF (robot spawns off the counter, so 0.65 keeps
the real walls).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The torso-mounted lidar looks down and its rays hit the robot's own legs/arms
(mj_multiRay's bodyexclude only drops the one torso body). Those self-returns get
mapped as a phantom obstacle under the robot, so nav2 rejects the start as "lethal
space". Flag every geom belonging to a robot body (name prefix robot0_/gripper0_)
and drop rays that hit them in _publish_lidar_scan, using the per-geom mask so real
obstacles are kept even when a limb is at the same range. Default on for this
sim (HAMS_LIDAR_SELF_FILTER=0 restores the limbs as occluders). Verified: the
closest scan return jumps from ~0.4 m (legs) to ~0.7 m (real structure).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the green planned-path display and the SetGoal tool (-> /goal_pose) so you can
click navigation goals directly in RViz. Completes the autonomous-nav demo view:
occupancy map + laser scan + robot + planned path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
frontier_explore.py: reads the slam /map, finds frontiers (free cells touching
unknown, not against walls), clusters them (scipy.ndimage), and sends the nearest
sizeable one as a nav2 goal; loops until no frontiers remain. Nearest-frontier
policy with a failure blacklist and per-goal timeout. robot_cli.sh: rob_explore
helper (start_walk + run it). Needs HAMS_SLAM=1 HAMS_NAV2=1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The global costmap's obstacle_layer had its params nested under an extra
`ros__parameters:` level, so nav2 silently ignored them and gave the layer
zero observation sources. The planner was steering on the static SLAM /map
alone -- a thin 2D slice that misses appliances -- so it routed the robot
straight into them (looked like "MuJoCo collision is broken"; it wasn't:
the robot correctly could not pass through, and nav2's progress-checker
aborted while the frontier explorer blacklisted and moved on).

Fixes:
- Flatten the global static/obstacle/inflation layer params to sit directly
  under the plugin key (matching the working local_costmap). The obstacle
  layer now subscribes to laser_scan_sensor + pointcloud_sensor, and the
  inflation layer uses its configured radius instead of defaults.
- Add the full 3D /livox/pointcloud as a marking source to BOTH costmaps,
  height band 0.15-1.8 m (drops the floor at z~0, keeps counter/appliance
  height; pelvis stands at ~0.97 m). Self-returns are already stripped by
  the bridge's lidar_self_filter.
- Fix the local voxel layer's z_voxels 60 -> 16 (nav2 hard-caps at 16, so
  it was silently clipped to 0.8 m tall, below counter height) with
  z_resolution 0.125 -> 2.0 m tall.

Verified: global_costmap now "Subscribed to Topics: laser_scan_sensor
pointcloud_sensor" (was empty), voxel_grid 16-z-cap error gone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The unitree_interface motor-command watchdog zeroed all motors if no
rt/lowcmd arrived within 0.1s of WALL time. On the headless CPU sim running
~0.2x real-time, that 0.1s wall window is only ~20ms of sim time, so the
compute-starved control loop (policy inference sharing cores with MuJoCo +
nav2 + slam) kept overshooting it -- the watchdog released the motors and
the robot dropped ~0.7s after the elastic band let go ("Command timeout!
Releasing motors" right before each fall).

Measure the watchdog in SIM time (data.time) instead, so the threshold is
independent of how fast the sim actually runs, and default it to 0.5s sim
(override via HAMS_CMD_TIMEOUT). A paused sim no longer trips it either.

Verified: robot now survives the band release and stands freely to sim
t=48s with no watchdog fire (was: fell at sim t~20s, 0.67s after release).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`docker restart` reuses the container filesystem, so a leftover
/tmp/.X99-lock (and its abstract socket) from the previous run made Xvfb
abort with "Server is already active for display 99" -- the entrypoint then
exit(1)'d and the whole sim container died on restart (no MuJoCo, no /clock,
no VNC). Kill any stale Xvfb and rm the lock/socket before starting the VNC
stack, in both the robocasa (:99) and ros (:100) launch scripts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add docs/NAVIGATION_DEMO.md (SLAM + nav2 + frontier exploration walkthrough:
run command, what to watch, the env knobs, pipeline diagram, and nav-specific
gotchas), link it from the README, and add a "Navigation demo" subsection.

Also correct docs that the fixes made stale:
- the walk policy stays upright now (via the switchable controller + the
  sim-time motor watchdog) -- was documented as "falls in this sim";
- HAMS_LOWERBODY=switch is the stand<->walk mode;
- HAMS_SPAWN_BACKOFF is baked at container-create (recreate, don't restart)
  and 1.5-2.0 keeps the robot off the counters;
- restart the two containers coherently (the sim owns /clock).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Give Claude Code first-class tools to inspect and drive the sim H1 instead of
shelling in and parsing ros2 CLI output. The server runs INSIDE the ros container
(where the FastDDS graph is reachable) and exposes one HTTP MCP port; reach it from
the Mac over the same SSH tunnel as the noVNC viewers (Colima forwards no ports, so
running an MCP server on the Mac + DDS-across-the-boundary is the trap this avoids).

A warm rclpy node subscribes once to the hot topics (/odom, global costmap, /plan,
/converted_scan, /clock) and caches the latest message, so status calls answer
instantly instead of paying node-startup + DDS-discovery per query. Tools:
robot_status, costmap_summary, nav_status, scan_status, set_lowerbody, drive,
wait_for, plus generic list/echo/hz/call_service/node_list passthroughs.

- docker/scripts/ros_mcp_server.py — the server (FastMCP streamable-http, port 6082).
  Sensor topics (/odom, /converted_scan) are best-effort publishers, so subscribe
  best-effort or a reliable reader silently gets nothing.
- launch_ros_mac.sh — HAMS_ROS_MCP=1 starts it (pip installs `mcp` on first run).
- docker-compose.mac.yml — HAMS_ROS_MCP / HAMS_ROS_MCP_PORT passthrough.
- mac_vnc_tunnel.sh — forward 6082 alongside the viewer ports.
- docs/ROS_MCP_DEBUG.md — setup, `claude mcp add` registration, tool list; linked
  from the README.

Verified end-to-end: launcher auto-starts the server; MCP round-trip returns live
robot_status (pose/uprightness/posture), scan_status, wait_for, costmap_summary;
tunnel reaches http://localhost:6082/mcp from the Mac.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants