Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions dimos/robot/all_blueprints.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@
"arm-teleop-module": "dimos.teleop.quest.quest_extensions.ArmTeleopModule",
"b-box-navigation-module": "dimos.navigation.bbox_navigation.BBoxNavigationModule",
"b1-connection-module": "dimos.robot.unitree.b1.connection.B1ConnectionModule",
"babylon-viewer-module": "dimos.web.viewer.module.BabylonViewerModule",
"basic-path-follower": "dimos.navigation.basic_path_follower.module.BasicPathFollower",
"camera-module": "dimos.hardware.sensors.camera.module.CameraModule",
"cartesian-motion-controller": "dimos.manipulation.control.servo_control.cartesian_motion_controller.CartesianMotionController",
Expand Down Expand Up @@ -194,6 +195,7 @@
"joystick-module": "dimos.robot.unitree.b1.joystick_module.JoystickModule",
"keyboard-teleop": "dimos.robot.unitree.keyboard_teleop.KeyboardTeleop",
"keyboard-teleop-module": "dimos.teleop.keyboard.keyboard_teleop_module.KeyboardTeleopModule",
"lcm-web-socket-bridge-module": "dimos.web.lcm_bridge.module.LcmWebSocketBridgeModule",
"local-planner": "dimos.navigation.cmu_nav.modules.local_planner.local_planner.LocalPlanner",
"manipulation-module": "dimos.manipulation.manipulation_module.ManipulationModule",
"map": "dimos.robot.unitree.type.map.Map",
Expand Down
51 changes: 51 additions & 0 deletions dimos/robot/unitree/g1/blueprints/basic/unitree_g1_groot_wbc.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@

from __future__ import annotations

import os
from pathlib import Path
from typing import Any, cast

Expand Down Expand Up @@ -293,6 +294,56 @@ def _precomposed_g1_scene(package: ScenePackage) -> Path | None:
(VoxelGridMapper, "lidar", "pointcloud"),
(ControlCoordinator, "twist_command", "cmd_vel"),
]

def _babylon_viewer() -> Any | None:
"""Browser operator console (dimos/web/viewer) alongside the sim.

Mirrors the robot from coordinator joint state + odom; pointclouds,
path and camera stream over the LCM<->WS bridge; WASD in the page
publishes cmd_vel — the same source-blind channel the planner uses.
On by default in sim; DIMOS_ENABLE_BABYLON=0 to suppress.
"""
if os.environ.get("DIMOS_ENABLE_BABYLON", "1").lower() in ("0", "false", "no"):
return None
from dimos.web.viewer.module import BabylonViewerModule

kwargs: dict[str, Any] = dict(
mjcf_path=_ROBOT_ONLY_MJCF_PATH,
mesh_dir=LfsPath("g1_urdf/meshes"),
port=int(os.environ.get("DIMOS_BABYLON_PORT", "8091")),
)
if global_config.scene_package is not None:
from dimos.simulation.scenes.catalog import resolve_scene_package

try:
package = resolve_scene_package(global_config.scene_package)
except (FileNotFoundError, ValueError):
package = None
# Prefer a Babylon-cooked GLB; legacy packages only carry the
# generic browser visual.
visual = (
package.browser_visual_path("babylon") or package.visual_path
if package is not None
else None
)
if package is not None and visual is not None and visual.exists():
# Same GLB + alignment the sim world was cooked from, so the
# browser shows the world MuJoCo is stepping.
kwargs.update(
scene_path=str(visual),
scene_scale=package.alignment.scale,
scene_translation=package.alignment.translation,
scene_rotation_zyx_deg=package.alignment.rotation_zyx_deg,
scene_y_up=package.alignment.y_up,
)
return BabylonViewerModule.blueprint(**kwargs)

_babylon_bp = _babylon_viewer()
if _babylon_bp is not None:
from dimos.web.viewer.module import BabylonViewerModule as _BabylonMod

_nav_stack = autoconnect(_nav_stack, _babylon_bp)
_remappings.append((_BabylonMod, "joint_state", "coordinator_joint_state"))
else:
from dimos.robot.unitree.g1.wholebody_connection import G1WholeBodyConnection

Expand Down
96 changes: 96 additions & 0 deletions dimos/web/lcm_bridge/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# LCM ↔ WebSocket bridge

The dimos bus, in the browser. A subscribe-all bridge that forwards LCM
small-message packets to WebSocket clients and republishes packets clients
send back — so browser code publishes and subscribes like any other bus
peer, using [`@dimos/msgs`](https://jsr.io/@dimos/msgs) to encode/decode.

This is the load-tested extraction of the Babylon scene viewer's `/lcm-ws`
endpoint, contributed as a concrete v0 for the Dimos Web discussion:

- [#2710 Dimos Web Proposal](https://github.com/dimensionalOS/dimos/issues/2710) —
this is a working data point for "the bridge is a DimOS module" (question 2)
and the local/LAN case (question 3).
- [#2502 TS API Spec](https://github.com/dimensionalOS/dimos/issues/2502) —
the central `DimosWebsocket` server sketched there subsumes this; until it
exists, this module is the smallest thing that gives any blueprint a
browser-reachable bus.
- [#2708 Dimos Web SDK](https://github.com/dimensionalOS/dimos/issues/2708) —
the flow-control here (latest-wins buffering, per-channel rate caps) is
the operational evidence behind the per-stream QoS requirements.

## Usage

Standalone, in any blueprint:

```python
from dimos.web.lcm_bridge.module import LcmWebSocketBridgeModule

autoconnect(
my_robot_blueprint,
LcmWebSocketBridgeModule.blueprint(
port=9669,
channel_rate_hz={"/global_map": 1.0}, # throttle the WS leg only
topic_blocklist=["/camera_image_raw*"], # never forward these
),
)
```

Browser:

```html
<script type="module" src="http://robot:9669/lcm_client.js"></script>
<script type="module">
const { subscribe, publish } = window.dimosLcm;
const { geometry_msgs } = window.dimosMsgs;
subscribe("/odom", geometry_msgs.PoseStamped, (msg) => console.log(msg));
publish("/cmd_vel", new geometry_msgs.Twist(...));
</script>
```

Embedded in an existing Starlette app (the Babylon viewer pattern):

```python
from dimos.web.lcm_bridge.bridge import LcmWebSocketBridge

bridge = LcmWebSocketBridge(channel_rate_hz={"/global_map": 1.0})
bridge.start()
app = Starlette(routes=[*my_routes, *bridge.routes()])
```

`GET /` on the standalone module returns JSON debug counters
(clients, forwarded, rate_capped, filtered, published_from_clients).

Requires the `web` extra (`starlette` + `uvicorn`).

## Flow control (why this isn't a naive forwarder)

Learned running multi-MB pointclouds at 10 Hz into browser tabs:

- **Latest-wins buffering** — one slot per (client, channel); when the bus
outpaces a client's socket, stale packets are overwritten, never queued.
The naive queue-per-packet design put browsers 10–15 s behind real time.
- **Single drain task per client** — exactly one in-flight send per socket;
a stalled client is closed (its JS auto-reconnects) instead of holding
buffers hostage.
- **Per-channel rate caps** — fnmatch pattern → max Hz, applied to the
WebSocket leg only; in-process bus consumers are unaffected.

## Known limitation: LCM only

The bridge taps the **LCM** bus. Runs using the Zenoh transport default
(`--transport zenoh` / `DIMOS_TRANSPORT`) publish nothing on LCM, so the
bridge forwards nothing — run with `--transport lcm` for now. A Zenoh
subscribe-all twin is the natural follow-up (payloads are the same
encoded bytes; only the tap changes), and is exactly the
transport-agnostic central bridge #2502 sketches.

## Deliberately out of scope (v0)

- **Per-connection QoS** (client-requested rates/filters) — that's the
`setQos` API in #2502; here filtering and caps are server config.
- **Fragmented LCM messages** (>~64 KB) — not bridged. Big payloads (video,
raw pointclouds) should be encoded/decimated server-side first, per #2708.
- **WebTransport / brokered remote access** — #2710's transport and broker
questions; this module is the same-host/LAN case.
- **Auth** — LAN-trust, like every other dimos listener today.
Loading
Loading