Skip to content

Commit 2278bb7

Browse files
committed
feat: add topic monitor sidecar
1 parent 28b68c0 commit 2278bb7

35 files changed

Lines changed: 1253 additions & 62 deletions

File tree

dimos/robot/all_blueprints.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,6 @@
118118
"unitree-go2-webrtc-keyboard-teleop": "dimos.robot.unitree.go2.blueprints.basic.unitree_go2_webrtc_keyboard_teleop:unitree_go2_webrtc_keyboard_teleop",
119119
"unitree-go2-webrtc-rage-keyboard-teleop": "dimos.robot.unitree.go2.blueprints.basic.unitree_go2_webrtc_rage_keyboard_teleop:unitree_go2_webrtc_rage_keyboard_teleop",
120120
"unity-sim": "dimos.simulation.unity.blueprint:unity_sim",
121-
"unity-sim-selector": "dimos.simulation.unity.blueprint:unity_sim_selector",
122121
"xarm-perception": "dimos.manipulation.blueprints:xarm_perception",
123122
"xarm-perception-agent": "dimos.manipulation.blueprints:xarm_perception_agent",
124123
"xarm-perception-sim": "dimos.manipulation.blueprints:xarm_perception_sim",

dimos/robot/cli/dimos.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,26 @@ def send(
678678
topic_send(topic, message_expr)
679679

680680

681+
@topic_app.command()
682+
def monitor(
683+
run: str | None = typer.Option(
684+
None,
685+
"--run",
686+
help="Run id to use as context, or 'latest'. Defaults to latest active run; if none exists, observes the LCM bus only.",
687+
),
688+
open_browser: bool = typer.Option(
689+
True,
690+
"--open/--no-open",
691+
help="Open the Topic Monitor web UI automatically.",
692+
),
693+
) -> None:
694+
"""Open an interactive live LCM topic monitor sidecar."""
695+
696+
from dimos.robot.cli.topic import topic_monitor
697+
698+
topic_monitor(run, open_browser)
699+
700+
681701
map_app = typer.Typer(help="Voxel-map tools over recorded sqlite datasets")
682702
main.add_typer(map_app, name="map")
683703
map_app.command("global")(_map_main)

dimos/robot/cli/topic.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,25 @@ def topic_send(topic: str, message_expr: str) -> None:
142142

143143
transport.broadcast(None, message)
144144
typer.echo(f"Sent to {topic}: {message}")
145+
146+
147+
def topic_monitor(run: str | None, open_browser: bool) -> None:
148+
try:
149+
from dimos.visualization.rerun.topic_monitor import (
150+
PortAllocationError,
151+
TopicMonitorDependencyError,
152+
run_topic_monitor,
153+
)
154+
except ImportError as exc:
155+
typer.echo(
156+
"`dimos topic monitor` requires visualization dependencies. "
157+
"Install them with `uv sync --extra visualization`.",
158+
err=True,
159+
)
160+
raise typer.Exit(1) from exc
161+
162+
try:
163+
run_topic_monitor(run=run, open_browser=open_browser)
164+
except (TopicMonitorDependencyError, PortAllocationError, ValueError) as exc:
165+
typer.echo(str(exc), err=True)
166+
raise typer.Exit(1) from exc

dimos/simulation/unity/blueprint.py

Lines changed: 1 addition & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
from dimos.core.coordination.blueprints import autoconnect
2929
from dimos.core.global_config import global_config
3030
from dimos.simulation.unity.module import UnityBridgeModule
31-
from dimos.visualization.vis_module import vis_module, vis_module_with_selector
31+
from dimos.visualization.vis_module import vis_module
3232

3333

3434
def _rerun_blueprint() -> Any:
@@ -58,29 +58,3 @@ def _rerun_blueprint() -> Any:
5858
},
5959
),
6060
)
61-
62-
63-
unity_sim_selector = autoconnect(
64-
UnityBridgeModule.blueprint(
65-
headless=True,
66-
publish_images=False,
67-
unity_connect_timeout=2.0,
68-
),
69-
vis_module_with_selector(
70-
viewer_backend=global_config.viewer,
71-
rerun_config={
72-
"blueprint": _rerun_blueprint,
73-
"rerun_open": "none",
74-
"rerun_web": True,
75-
"visual_override": {
76-
"world/camera_info": UnityBridgeModule.rerun_suppress_camera_info,
77-
},
78-
"static": {
79-
"world/color_image": UnityBridgeModule.rerun_static_pinhole,
80-
},
81-
},
82-
selector_config={
83-
"title": "DimOS Unity Sim Rerun Topic Selector",
84-
},
85-
),
86-
).global_config(simulation=True)

dimos/visualization/rerun/bridge.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,13 @@ def _on_message(self, msg: Any, topic: Any) -> None:
305305
rerun_data: RerunData | None = self._visual_override_for_entity_path(entity_path)(msg)
306306
except Exception as exc:
307307
self._catalog.record_error(topic, exc)
308+
if self.config.selector_enabled:
309+
logger.warning(
310+
"Selector-managed Rerun conversion failed; topic will remain cataloged",
311+
topic=str(topic),
312+
exc_info=True,
313+
)
314+
return
308315
raise
309316

310317
if not rerun_data:
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# Copyright 2026 Dimensional Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from __future__ import annotations
16+
17+
import builtins
18+
import socket
19+
from typing import Any
20+
21+
import pytest
22+
23+
from dimos.core.run_registry import RunEntry
24+
from dimos.visualization.rerun.constants import RERUN_GRPC_PORT
25+
from dimos.visualization.rerun.topic_monitor import (
26+
PortAllocationError,
27+
TopicMonitorDependencyError,
28+
TopicMonitorSidecar,
29+
_require_visualization_dependencies,
30+
allocate_monitor_ports,
31+
open_selector_url,
32+
resolve_run_context,
33+
)
34+
35+
36+
def test_allocate_monitor_ports_avoids_default_rerun_ports() -> None:
37+
ports = allocate_monitor_ports(start=RERUN_GRPC_PORT, end=RERUN_GRPC_PORT + 20)
38+
39+
assert RERUN_GRPC_PORT not in {
40+
ports.rerun_grpc,
41+
ports.rerun_web,
42+
ports.selector_frontend,
43+
ports.selector_api,
44+
ports.reflex_backend,
45+
}
46+
47+
48+
def test_allocate_monitor_ports_fails_for_explicit_conflict() -> None:
49+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
50+
sock.bind(("127.0.0.1", 0))
51+
port = sock.getsockname()[1]
52+
sock.listen(1)
53+
try:
54+
with pytest.raises(PortAllocationError):
55+
allocate_monitor_ports(port_base=port)
56+
finally:
57+
sock.close()
58+
59+
60+
def test_topic_monitor_urls_use_allocated_connected_viewer_url() -> None:
61+
sidecar = TopicMonitorSidecar(
62+
host="127.0.0.1",
63+
ports=allocate_monitor_ports(start=11150, end=11180),
64+
)
65+
66+
urls = sidecar.urls
67+
68+
assert str(sidecar.ports.selector_frontend) in urls.selector
69+
assert str(sidecar.ports.selector_api) in urls.selector_api
70+
assert str(sidecar.ports.rerun_grpc) in urls.rerun_connect
71+
assert "url=rerun%2Bhttp" in urls.rerun_viewer
72+
73+
74+
def test_resolve_run_context_uses_latest_or_bus_only(monkeypatch: pytest.MonkeyPatch) -> None:
75+
entry = RunEntry(
76+
run_id="20260610-test",
77+
pid=123,
78+
blueprint="demo",
79+
started_at="now",
80+
log_dir="/tmp/logs",
81+
)
82+
monkeypatch.setattr(
83+
"dimos.visualization.rerun.topic_monitor.get_most_recent",
84+
lambda alive_only=True: entry,
85+
)
86+
87+
context = resolve_run_context()
88+
89+
assert context.entry is entry
90+
assert "20260610-test" in context.label
91+
92+
monkeypatch.setattr(
93+
"dimos.visualization.rerun.topic_monitor.get_most_recent",
94+
lambda alive_only=True: None,
95+
)
96+
97+
assert resolve_run_context().entry is None
98+
99+
100+
def test_resolve_run_context_fails_for_missing_explicit_run(
101+
monkeypatch: pytest.MonkeyPatch,
102+
) -> None:
103+
monkeypatch.setattr(
104+
"dimos.visualization.rerun.topic_monitor.list_runs", lambda alive_only=True: []
105+
)
106+
107+
with pytest.raises(ValueError, match="No active DimOS run"):
108+
resolve_run_context("missing")
109+
110+
111+
def test_open_selector_url_is_non_fatal() -> None:
112+
assert open_selector_url("http://localhost", opener=lambda _url: False) is False
113+
114+
def boom(_url: str) -> bool:
115+
raise RuntimeError("no browser")
116+
117+
assert open_selector_url("http://localhost", opener=boom) is False
118+
119+
120+
def test_missing_dependency_error_mentions_visualization_extra(
121+
monkeypatch: pytest.MonkeyPatch,
122+
) -> None:
123+
real_import = builtins.__import__
124+
125+
def fake_import(name: str, *args: Any, **kwargs: Any) -> object:
126+
if name == "reflex":
127+
raise ImportError("missing reflex")
128+
return real_import(name, *args, **kwargs)
129+
130+
monkeypatch.setattr(builtins, "__import__", fake_import)
131+
132+
with pytest.raises(TopicMonitorDependencyError, match="uv sync --extra visualization"):
133+
_require_visualization_dependencies()

0 commit comments

Comments
 (0)