Skip to content

feat(hosted-teleop): Go2 teleoperation over Cloudflare/LiveKit broker#2798

Open
ruthwikdasyam wants to merge 232 commits into
mainfrom
ruthwik/hostedteleop/2
Open

feat(hosted-teleop): Go2 teleoperation over Cloudflare/LiveKit broker#2798
ruthwikdasyam wants to merge 232 commits into
mainfrom
ruthwik/hostedteleop/2

Conversation

@ruthwikdasyam

@ruthwikdasyam ruthwikdasyam commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Problem

Teleoperating a robot over the internet (not just LAN) needs a dial-out architecture: robots behind NAT/cellular can't accept inbound connections, so they must connect out to a broker, and browser/VR operators reach them through it. dimos had no such path — only the LAN-only quest WebSocket teleop.

Closes DIM-XXX

Solution

Robot-side hosted teleoperation over WebRTC, via the dimensional-teleop broker (Cloudflare Realtime or LiveKit SFU). One session per robot carries video, commands, telemetry and safety.

Transport layer - CloudflareTransport / LiveKitTransport (+ *VideoTransport) bind directly to blueprint streams; a per-process BrokerProvider (CF) / LiveKitBrokerProvider owns the single session. Commands, state and video all ride it - no separate teleop module owning its own PeerConnection.

Go2HostedConnection - colocates every broker-bound stream on one module (the driver is dedicated_worker, so they must share a process to share a session). Adds:

  • Drive with stale/out-of-order cmd_vel drop + firmware deadman
  • Command plane: allow-listed sport commands, rage mode, obstacle avoidance, head LED — on a single-worker executor with nonce dedup, bounded backlog, and an urgent bypass so E-STOP never queues behind a slow command
  • E-STOP latch + stop-on-operator-lost
  • Operator minimap: occupancy grid + robot pose on a dedicated map_unreliable channel (coarsened + PNG under the 16 KB datachannel ceiling)
  • Click-to-navigate: map click → planner goal, live operator steering overrides autonomy
  • Operator→robot audio: recvonly track → the Go2's speaker
  • Glass-to-glass latency benchmark (frame-embedded capture-time stamp)
  • Shared plane (HostedConnectionMixin + CameraMuxMixin) — state dispatch, cmd_ack, telemetry, and an operator-selectable camera mux; a new robot implements a handful of hooks.

Clock-sync ping/pong is answered inline in the provider (no module hop, so RTT/offset stay jitter-free). Legacy HostedTeleopModule is deprecated. Arm/manipulation hosted teleop was split out to a follow-up branch to keep this Go2-focused.

Coverage: unit tests for the command plane, camera mux, clock-sync ping, stream/video stats, speaker track, and the LiveKit frame packing. The live WebRTC session paths are exercised by the opt-in broker e2e/benchmark cases (they need a real SFU + operator), not unit tests.

How to Test

# Cloudflare transport (keyboard/VR operator at https://teleop.dimensionalos.com)
dimos run teleop-hosted-go2-transport -o transports.broker.api_key=dtk_live_...

# LiveKit backend, or the dual-RealSense multicam / minimap variant:
dimos run teleop-hosted-go2-livekit   -o transports.broker.api_key=dtk_live_...
dimos run teleop-hosted-go2-multicam  -o transports.broker.api_key=dtk_live_...
Glass-to-glass latency benchmark: add -o go2hostedconnection.latency_stamp=true and read the operator HUD.

Contributor License Agreement

  • I have read and approved the CLA.

ruthwikdasyam and others added 30 commits May 7, 2026 15:28
…e SFU

Implements a new pubsub transport backed by WebRTC DataChannels over
Cloudflare's Realtime SFU. Two new classes in
dimos/protocol/pubsub/impl/webrtcpubsub.py:

- CloudflareSession: manages the WebRTC PeerConnection lifecycle.
  Opens two CF sessions (publisher + subscriber) so a single process
  can do loopback pubsub. Runs aiortc on a dedicated background
  asyncio thread with its own ThreadPoolExecutor (so we don't leak
  asyncio_N worker threads). Uses negotiated=True placeholder DCs
  with id=100 during transport establishment to avoid stream-id
  collisions with CF-assigned ids.

- WebRTCPubSub: bytes-on-the-wire pubsub facade matching the
  LCMPubSubBase / BytesSharedMemory interface (string topics, bytes
  payloads). Lazily creates pub/sub DataChannel pairs on first
  publish/subscribe per topic.

Also adds:
- WebRTCTransport in dimos/core/transport.py (mirrors LCMTransport
  pattern, no encoding - bytes only).
- WebRTC benchmark testcase in
  dimos/protocol/pubsub/benchmark/testdata.py, gated on aiortc +
  CF_TELEOP_APP_ID / CF_TELEOP_APP_SECRET env vars.
- Integration test in
  dimos/protocol/pubsub/impl/test_webrtcpubsub.py covering basic
  pub/sub, latency, and throughput (all live tests skip without CF
  credentials).
- aiortc + httpx as new 'webrtc' optional extra in pyproject.toml.

Live benchmark (us-east-2 -> CF edge):
- 64-256B:  ~10K msgs/s, 0% loss
- 1KiB:     ~7K msgs/s, 0% loss
- >= 64KiB: dropped (above SCTP message size)
- Median single-RTT: ~2.5 ms
…sport

- Add BrokerProvider: DataChannelProvider that works through the hosted
  teleop broker (dimensional-teleop) instead of directly with CF credentials.
  Handles session registration, heartbeat loop, and DataChannel creation
  when an operator joins via the broker's bridge-datachannel API.

- Extend WebRTCTransport with optional msg_type parameter for typed LCM
  encode/decode with fingerprint-based filtering. Multiple transports can
  share a single multiplexed DataChannel and each receives only its type.

- Add hosted teleop blueprints (dimos/teleop/hosted/) demonstrating the
  module-free architecture: make_teleop_hosted_go2() uses pure transport
  (zero modules), make_teleop_hosted_go2_scaled() adds a thin
  TeleopScalerModule for speed scaling only.

- Add unit tests for typed mode, fingerprint filtering, multiplexed
  dispatch, and BrokerProvider credential validation.
- Rebase on main and regenerate uv.lock (resolve conflict)
- Add _LoopbackProvider (in-process, no network) to benchmark testdata
- Enables local WebRTC transport benchmarking without CF credentials
- All 12 message sizes pass locally (2.78s total)
The previous lock regen dropped the `exclude-newer-span` marker, leaving
only the frozen `exclude-newer` timestamp. uv then treats every resolve as
"cooldown was newly added" and forces a re-resolve against today minus 7
days — which currently excludes md-babel-py 1.2.0 (published 2026-05-15)
and breaks `uv sync --extra all` / `uv lock`.

Re-adding the span line tells uv the lock was generated with P7D
semantics, so the existing pinned versions are honored.
- Remove __init__.py files (project policy: no init files)
- Remove section markers from test_webrtcpubsub.py
- Regenerate all_blueprints.py (adds TeleopScalerModule)
- Fix WebRTCTransport.__reduce__ to preserve msg_type across pickle
- Fix CloudflareProvider.publish() race: snapshot loop ref before use
- Fix CloudflareProvider.subscribe() race: check sub_channels inside lock
- Add comment clarifying TwistStamped→Twist type safety in blueprint
- Add return type annotations to CloudflareProvider event handlers
- Fix type: ignore codes to match actual mypy errors (attr-defined)
- Add type annotation to __setstate__ dict parameter
- Add type: ignore[arg-type] for WebRTCPubSub→PubSub duck typing in benchmark
- Remove TwistStamped subclass comment from blueprint
@ruthwikdasyam ruthwikdasyam marked this pull request as ready for review July 9, 2026 22:19
@greptile-apps

greptile-apps Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces robot-side hosted teleoperation for the Unitree Go2 over WebRTC via the dimensional-teleop broker, supporting both Cloudflare Realtime and LiveKit SFUs. It replaces the monolithic HostedTeleopModule with focused, composable modules (Go2CommandModule, CameraMuxModule, HostedStatsModule, MapCompressModule) that share one broker session per process through a common transport layer.

  • Command plane: Go2CommandModule adds an allow-listed sport command dispatcher, E-STOP latch with safety-epoch fencing, stale/future/out-of-order drive guard, and click-to-navigate planner integration, all serialized on a single ThreadPoolExecutor with an urgent bypass for E-STOP.
  • Media and telemetry: CameraMuxModule composites N cameras into one operator-selectable video feed with optional glass-to-glass latency stamps; MapCompressModule coarsens the occupancy grid to PNG for the operator minimap; operator audio from the browser can be routed to the Go2's speaker via PCMAudioTrack.
  • Transport layer: LiveKitTransport/LiveKitVideoTransport are added as drop-in LiveKit counterparts to the existing Cloudflare transports; BrokerProvider gains audio receive, map_unreliable channel, and inline clock-sync ping/pong.

Confidence Score: 4/5

Safe to merge with one targeted fix to the drive guard in Go2CommandModule — a NaN twist timestamp silently disables the out-of-order filter for the rest of the session.

The drive guard in _on_cmd_vel_in uses float(twist.ts) without a math.isfinite check. A NaN timestamp passes all three guards, sets _last_cmd_ts = NaN, and from that point ts <= NaN is always False — no subsequent drive frame is ever dropped as out-of-order for the rest of the session. The same safety concern prompted math.isfinite guards in _handle_nav_goal and math.isnan in _handle_light, but it was missed here. All other safety mechanisms (E-STOP latch, epoch fencing, urgency bypass) appear correct, and the rest of the PR is well-structured and tested.

dimos/teleop/hosted/go2_command.py — _on_cmd_vel_in needs a math.isfinite(ts) guard after ts = float(twist.ts).

Important Files Changed

Filename Overview
dimos/teleop/hosted/go2_command.py New command/E-STOP/drive guard module. Missing math.isfinite guard on twist.ts in _on_cmd_vel_in permanently disables the out-of-order filter if NaN arrives.
dimos/teleop/hosted/command_executor.py New serialized command executor mixin with nonce dedup, safety epoch fencing, and bounded backlog. Clean implementation.
dimos/teleop/hosted/camera_mux.py New camera mux module. _last_mux_pub is read/written outside _cam_lock, allowing concurrent camera callbacks to exceed the FPS cap in multicam mode.
dimos/protocol/pubsub/impl/webrtc/providers/broker.py Extended with map_unreliable channel, operator audio receive, codec preference, and inline clock-sync ping. Operator-lost detection keyed on state_reliable channel lifecycle is precise.
dimos/protocol/pubsub/impl/webrtc/providers/livekit_broker.py New LiveKit provider. Float/uint16 image handling is correct. participant_disconnected fires for any participant; known operator-lost over-triggering is documented in a previous review comment.
dimos/robot/unitree/go2/connection.py Added audio_in speaker attach, new @rpc command stubs, and improved teardown. _attach_speaker doesn't guard against a None datachannel attribute before dispatching switchAudioChannel.
dimos/robot/unitree/go2/speaker.py New PCMAudioTrack for operator mic to Go2 speaker. Drain-queue behavior, bounded frame queue, and skip-on-malformed logic are all clean.
dimos/teleop/hosted/map_compress.py New map compressor. Throttling, block-max coarsening, 16 KB ceiling guard, and colorization all correct.
dimos/teleop/hosted/hosted_stats.py New stats module. Clean separation of stats from commands; telemetry thread uses a stop event and daemon thread correctly.
dimos/teleop/hosted/blueprints/cloudflare.py New Cloudflare-backed blueprint. n_workers=1 correctly forces all broker-transport modules into one process/session.
dimos/teleop/hosted/blueprints/livekit.py New LiveKit-backed blueprint, structurally identical to the Cloudflare variant. Transport swap is clean.
dimos/robot/unitree/connection.py Added sport_command, switch_joystick, set_light, and stop_movement; set_obstacle_avoidance now returns bool.
dimos/protocol/pubsub/impl/webrtc/providers/spec.py Added set_audio_sink helper and wait_open utility. Clean additions.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Op as Operator Browser
    participant Broker as CF/LiveKit Broker
    participant Prov as BrokerProvider
    participant Cmd as Go2CommandModule
    participant Mux as CameraMuxModule
    participant MM as MovementManager
    participant Go2 as GO2Connection

    Op->>Broker: cmd_unreliable (TwistStamped)
    Broker->>Prov: DataChannel
    Prov->>Cmd: _on_cmd_vel_in (stale/future/reorder guard)
    Cmd->>MM: tele_cmd_vel (guarded Twist)
    MM->>Go2: cmd_vel

    Op->>Broker: state_reliable (estop / sport_cmd)
    Broker->>Prov: DataChannel message
    Prov->>Cmd: _on_state_json
    Cmd->>Go2: sport_command() via RPC

    Go2->>Mux: color_image
    Mux->>Prov: mux_image set_video_frame
    Prov->>Broker: video track
    Broker->>Op: WebRTC video

    Op->>Broker: ping
    Broker->>Prov: state_reliable
    Prov->>Prov: _maybe_answer_ping inline
    Prov->>Broker: pong state_reliable_back
    Broker->>Op: pong
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Op as Operator Browser
    participant Broker as CF/LiveKit Broker
    participant Prov as BrokerProvider
    participant Cmd as Go2CommandModule
    participant Mux as CameraMuxModule
    participant MM as MovementManager
    participant Go2 as GO2Connection

    Op->>Broker: cmd_unreliable (TwistStamped)
    Broker->>Prov: DataChannel
    Prov->>Cmd: _on_cmd_vel_in (stale/future/reorder guard)
    Cmd->>MM: tele_cmd_vel (guarded Twist)
    MM->>Go2: cmd_vel

    Op->>Broker: state_reliable (estop / sport_cmd)
    Broker->>Prov: DataChannel message
    Prov->>Cmd: _on_state_json
    Cmd->>Go2: sport_command() via RPC

    Go2->>Mux: color_image
    Mux->>Prov: mux_image set_video_frame
    Prov->>Broker: video track
    Broker->>Op: WebRTC video

    Op->>Broker: ping
    Broker->>Prov: state_reliable
    Prov->>Prov: _maybe_answer_ping inline
    Prov->>Broker: pong state_reliable_back
    Broker->>Op: pong
Loading

Reviews (7): Last reviewed commit: "fix(hosted-teleop): publish cmd_vel_stam..." | Re-trigger Greptile

Comment thread dimos/protocol/pubsub/impl/webrtc/providers/livekit_broker.py
Comment thread dimos/protocol/pubsub/impl/webrtc/providers/livekit_broker.py
Comment thread dimos/teleop/quest_hosted/go2_hosted_connection.py Outdated
Comment thread dimos/teleop/quest_hosted/go2_hosted_connection.py Outdated
@github-actions github-actions Bot added the ready-to-merge Required CI checks have passed on this PR label Jul 9, 2026
…nd filter + video encoding, truthful obstacle-avoidance ack, camera_select guard
@github-actions github-actions Bot added ready-to-merge Required CI checks have passed on this PR and removed ready-to-merge Required CI checks have passed on this PR labels Jul 9, 2026
Comment thread dimos/teleop/quest_hosted/blueprints.py Outdated
teleop_hosted_go2_transport = (
autoconnect(
unitree_go2_basic.disabled_modules(GO2Connection),
Go2HostedConnection.blueprint(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for hostedconnection module? handled at transport layer thats the point of webrtctransport

# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can move this file to somewhere other than /quest_hosted, since blurpints in here ar enot quest related

@@ -15,20 +15,18 @@
"""Hosted Teleop Module — Cloudflare Realtime SFU client.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remember to delete this in a later PR

allow_acrobatics: bool = False # let the operator trigger FrontJump/Pounce


class Go2HostedConnection(GO2Connection, HostedConnectionMixin):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SHould be deleted. Should be a HostedStatsModule that just does lightweight stats publishing to webrtc. The issue is from ruthwik is that if this is a seperate module it starts two cloudflare sessions, whichis a problem on my end. Also This file handles audio, which should be handled seperately as well. May also need to paramertize cloudflare datachannels at the blueprint layer which was on the TODO list anyway

@github-actions github-actions Bot removed the ready-to-merge Required CI checks have passed on this PR label Jul 12, 2026
Comment on lines +409 to +422
ts = float(twist.ts)
age = time.time() - ts
if age > self.config.cmd_stale_after_sec:
logger.debug("dropping stale cmd_vel: age=%.3fs", age)
return
if age < 0: # future-stamped (clock skew / corrupt ts): don't let it
# advance _last_cmd_ts, or every in-order frame until wall-clock
# catches up would be dropped as "out-of-order" (drive stall).
logger.debug("dropping future-stamped cmd_vel: age=%.3fs", age)
return
if ts <= self._last_cmd_ts:
logger.debug("dropping out-of-order cmd_vel: ts=%.3f last=%.3f", ts, self._last_cmd_ts)
return
self._last_cmd_ts = ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 NaN timestamp permanently disables the out-of-order filter

If twist.ts decodes as NaN (e.g. a browser bug, or a malformed LCM field), then ts = float(NaN) propagates through every comparison silently: NaN > 0.5False (not stale), NaN < 0False (not future), NaN <= self._last_cmd_tsFalse (not out-of-order), so the frame is forwarded and self._last_cmd_ts = NaN. From that point on, ts <= NaN is False for every valid timestamp, so the reorder filter is permanently disabled for the rest of the session. Reordered UDP drive frames that arrive late will no longer be dropped and may drive the robot in an unexpected direction.

By contrast, _handle_nav_goal and _handle_light both call math.isfinite on operator-supplied floats. The same guard is missing here. Add if not math.isfinite(ts): logger.debug("dropping non-finite ts"); return immediately after ts = float(twist.ts) to keep the safety property intact across all inputs.

@github-actions github-actions Bot added the ready-to-merge Required CI checks have passed on this PR label Jul 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-to-merge Required CI checks have passed on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants