From fef5a25933b00f69f34b94459d8a14198584b958 Mon Sep 17 00:00:00 2001 From: Ivan Nikolic Date: Wed, 7 Jan 2026 09:51:49 +0800 Subject: [PATCH 1/3] check for large files by pre-commit --- .pre-commit-config.yaml | 9 +++++- bin/hooks/largefiles_check | 62 ++++++++++++++++++++++++++++++++++++++ bin/{ => hooks}/lfs_check | 0 docs/data.md | 2 +- pyproject.toml | 9 ++++++ 5 files changed, 80 insertions(+), 2 deletions(-) create mode 100755 bin/hooks/largefiles_check rename bin/{ => hooks}/lfs_check (100%) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0d520f20be..7fb4dd1ea6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -74,7 +74,14 @@ repos: name: LFS data always_run: true pass_filenames: false - entry: bin/lfs_check + entry: bin/hooks/lfs_check + language: script + + - id: largefiles-check + name: Large files check + always_run: true + pass_filenames: false + entry: bin/hooks/largefiles_check language: script - id: doclinks diff --git a/bin/hooks/largefiles_check b/bin/hooks/largefiles_check new file mode 100755 index 0000000000..013f7c9ccb --- /dev/null +++ b/bin/hooks/largefiles_check @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Pre-commit hook to detect large files that should be in LFS.""" + +import argparse +import fnmatch +import os +import shutil +import subprocess +import sys + +import tomllib + +parser = argparse.ArgumentParser() +parser.add_argument("--all", action="store_true", help="Check all files in repo, not just staged") +args = parser.parse_args() + +# Check git-lfs is installed +if not shutil.which("git-lfs"): + print("git-lfs is not installed.") + print("\nInstall with:") + print(" Arch: pacman -S git-lfs") + print(" Ubuntu: apt install git-lfs") + print(" macOS: brew install git-lfs") + print("\nThen run: git lfs install") + sys.exit(1) + +# Load config +with open("pyproject.toml", "rb") as f: + config = tomllib.load(f).get("tool", {}).get("largefiles", {}) + +max_size_kb = config.get("max_size_kb", 50) +max_bytes = max_size_kb * 1024 +ignore_patterns = config.get("ignore", []) + +# Get LFS files to exclude +lfs_files = set( + subprocess.run( + ["git", "lfs", "ls-files", "-n"], capture_output=True, text=True + ).stdout.splitlines() +) + +# Get files to check +if args.all: + files_cmd = ["git", "ls-files"] +else: + files_cmd = ["git", "diff", "--cached", "--name-only"] + +violations = [] +for file in subprocess.run(files_cmd, capture_output=True, text=True).stdout.splitlines(): + if file in lfs_files: + continue + if any(fnmatch.fnmatch(file, p) for p in ignore_patterns): + continue + if os.path.isfile(file) and os.path.getsize(file) > max_bytes: + violations.append((file, os.path.getsize(file))) + +if violations: + print(f"Large files detected (limit: {max_size_kb}KB):") + for f, size in sorted(violations, key=lambda x: -x[1]): + print(f" {size // 1024}KB {f}") + print("\nEither add to LFS or to [tool.largefiles].ignore in pyproject.toml") + sys.exit(1) diff --git a/bin/lfs_check b/bin/hooks/lfs_check similarity index 100% rename from bin/lfs_check rename to bin/hooks/lfs_check diff --git a/docs/data.md b/docs/data.md index a30a0e3328..34313098f9 100644 --- a/docs/data.md +++ b/docs/data.md @@ -194,7 +194,7 @@ The [`lfs_push`](/bin/lfs_push) script: 2. Uploads to Git LFS 3. Stages the compressed file -A pre-commit hook ([`bin/lfs_check`](/bin/lfs_check#L26)) blocks commits if you have uncompressed directories in `data/` without a corresponding `.tar.gz` in `data/.lfs/`. +A pre-commit hook ([`bin/hooks/lfs_check`](/bin/hooks/lfs_check#L26)) blocks commits if you have uncompressed directories in `data/` without a corresponding `.tar.gz` in `data/.lfs/`. ## Location Resolution diff --git a/pyproject.toml b/pyproject.toml index 206263d209..f9cd2d8574 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -350,6 +350,15 @@ addopts = "-v -p no:warnings -ra --color=yes -m 'not vis and not benchmark and n asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" +[tool.largefiles] +max_size_kb = 50 +ignore = [ + "uv.lock", + "*/package-lock.json", + "dimos/dashboard/dimos.rbl", + "dimos/web/dimos_interface/themes.json", +] + [tool.uv] # Build dependencies for packages that don't declare them properly extra-build-dependencies = { detectron2 = ["torch"], contact-graspnet-pytorch = ["numpy"] } From 8cd7a2eddf4e1da02e381751e26e620a4a35fa46 Mon Sep 17 00:00:00 2001 From: Ivan Nikolic Date: Wed, 7 Jan 2026 10:24:11 +0800 Subject: [PATCH 2/3] merge dev --- .gitattributes | 7 +- .pre-commit-config.yaml | 12 +- assets/dimos_terminal.png | Bin 25557 -> 130 bytes bin/hooks/largefiles_check | 4 +- dimos/agents/agent.py | 6 +- dimos/agents/cli/human.py | 2 +- dimos/agents/skills/conftest.py | 2 - .../skills/test_unitree_skill_container.py | 22 +- dimos/agents/spec.py | 11 +- dimos/agents/test_mock_agent.py | 6 +- dimos/core/README_BLUEPRINTS.md | 73 +++- dimos/core/blueprints.py | 66 +++- dimos/core/module.py | 4 +- dimos/core/skill_module.py | 9 +- dimos/core/stream.py | 3 +- dimos/dashboard/rerun_init.py | 7 +- dimos/e2e_tests/test_dimos_cli_e2e.py | 4 +- dimos/hardware/sensors/camera/module.py | 1 - dimos/protocol/mcp/README.md | 30 ++ dimos/protocol/mcp/__init__.py | 17 + dimos/protocol/mcp/__main__.py | 36 ++ dimos/protocol/mcp/bridge.py | 53 +++ dimos/protocol/mcp/mcp.py | 133 +++++++ dimos/protocol/mcp/test_mcp_module.py | 208 ++++++++++ dimos/robot/all_blueprints.py | 1 + .../robot/unitree_webrtc/modular/__init__.py | 1 - .../modular/connection_module.py | 340 ---------------- .../unitree_webrtc/modular/ivan_unitree.py | 98 ----- .../unitree_webrtc/unitree_go2_blueprints.py | 6 + .../unitree_webrtc/unitree_skill_container.py | 27 +- .../foxglove_bridge/run_foxglove_bridge.py | 2 +- dimos/utils/docs/test_doclinks.py | 24 +- dimos/web/dimos_interface/public/icon.png | Bin 2147 -> 129 bytes .../assets/alignment_timeline.png | 3 + .../assets/alignment_timeline2.png | 3 + .../assets/alignment_timeline3.png | 3 + docs/api/sensor_streams/temporal_alignment.md | 371 ++++++++++-------- examples/language-interop/README.md | 20 + examples/language-interop/assets/lcmspy.png | 3 + examples/language-interop/cpp/.gitignore | 1 + examples/language-interop/cpp/CMakeLists.txt | 28 ++ examples/language-interop/cpp/README.md | 17 + examples/language-interop/cpp/main.cpp | 68 ++++ examples/language-interop/lua/.gitignore | 3 + examples/language-interop/lua/README.md | 38 ++ examples/language-interop/lua/main.lua | 59 +++ examples/language-interop/lua/setup.sh | 100 +++++ examples/language-interop/ts/README.md | 34 ++ examples/language-interop/ts/deno.json | 9 + examples/language-interop/ts/deno.lock | 21 + examples/language-interop/ts/main.ts | 43 ++ examples/language-interop/ts/web/index.html | 213 ++++++++++ examples/language-interop/ts/web/server.ts | 69 ++++ examples/simplerobot/README.md | 50 +++ examples/simplerobot/simplerobot.py | 158 ++++++++ examples/simplerobot/vis.py | 95 +++++ pyproject.toml | 3 + uv.lock | 50 +++ 58 files changed, 1961 insertions(+), 716 deletions(-) create mode 100644 dimos/protocol/mcp/README.md create mode 100644 dimos/protocol/mcp/__init__.py create mode 100644 dimos/protocol/mcp/__main__.py create mode 100644 dimos/protocol/mcp/bridge.py create mode 100644 dimos/protocol/mcp/mcp.py create mode 100644 dimos/protocol/mcp/test_mcp_module.py delete mode 100644 dimos/robot/unitree_webrtc/modular/__init__.py delete mode 100644 dimos/robot/unitree_webrtc/modular/connection_module.py delete mode 100644 dimos/robot/unitree_webrtc/modular/ivan_unitree.py create mode 100644 docs/api/sensor_streams/assets/alignment_timeline.png create mode 100644 docs/api/sensor_streams/assets/alignment_timeline2.png create mode 100644 docs/api/sensor_streams/assets/alignment_timeline3.png create mode 100644 examples/language-interop/README.md create mode 100644 examples/language-interop/assets/lcmspy.png create mode 100644 examples/language-interop/cpp/.gitignore create mode 100644 examples/language-interop/cpp/CMakeLists.txt create mode 100644 examples/language-interop/cpp/README.md create mode 100644 examples/language-interop/cpp/main.cpp create mode 100644 examples/language-interop/lua/.gitignore create mode 100644 examples/language-interop/lua/README.md create mode 100644 examples/language-interop/lua/main.lua create mode 100755 examples/language-interop/lua/setup.sh create mode 100644 examples/language-interop/ts/README.md create mode 100644 examples/language-interop/ts/deno.json create mode 100644 examples/language-interop/ts/deno.lock create mode 100644 examples/language-interop/ts/main.ts create mode 100644 examples/language-interop/ts/web/index.html create mode 100644 examples/language-interop/ts/web/server.ts create mode 100644 examples/simplerobot/README.md create mode 100644 examples/simplerobot/simplerobot.py create mode 100644 examples/simplerobot/vis.py diff --git a/.gitattributes b/.gitattributes index ee95be7e08..8f05eb707f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,8 +4,9 @@ # Ensure Python files always use LF for line endings. *.py text eol=lf # Treat designated file types as binary and do not alter their contents or line endings. -*.png binary -*.jpg binary +*.png filter=lfs diff=lfs merge=lfs -text binary +*.jpg filter=lfs diff=lfs merge=lfs -text binary +*.jpeg filter=lfs diff=lfs merge=lfs -text binary *.ico binary *.pdf binary # Explicit LFS tracking for test files @@ -15,5 +16,3 @@ *.mov filter=lfs diff=lfs merge=lfs -text binary *.gif filter=lfs diff=lfs merge=lfs -text binary *.foxe filter=lfs diff=lfs merge=lfs -text binary -docs/**/*.png filter=lfs diff=lfs merge=lfs -text -docs/**/*.jpg filter=lfs diff=lfs merge=lfs -text diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7fb4dd1ea6..cc44a82bba 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,4 +1,5 @@ default_stages: [pre-commit] +default_install_hook_types: [pre-commit, commit-msg] exclude: (dimos/models/.*)|(deprecated) repos: @@ -81,8 +82,9 @@ repos: name: Large files check always_run: true pass_filenames: false - entry: bin/hooks/largefiles_check - language: script + entry: python bin/hooks/largefiles_check + language: python + additional_dependencies: ['tomli'] - id: doclinks name: Doclinks @@ -91,3 +93,9 @@ repos: entry: python -m dimos.utils.docs.doclinks docs/ language: system files: ^docs/.*\.md$ + + - id: filter-commit-message + name: Filter generated signatures from commit message + entry: sed -i '/^.*Generated with/,$d' + language: system + stages: [commit-msg] diff --git a/assets/dimos_terminal.png b/assets/dimos_terminal.png index 77f00e47fa51512315d9130d58d4c7a19053bf88..a71b06e1cc4b37e15a098f3c5e7c20a59156533a 100644 GIT binary patch literal 130 zcmWN?OA^8$3;@tQr{DsXPYHqEh6WL*FI5ftzMh12zM6}5x)J)_Y*9egJ Nl9h~a(;fg&{Q&BCCx`$5 literal 25557 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 L+PLi8KVkm|@5kWh diff --git a/bin/hooks/largefiles_check b/bin/hooks/largefiles_check index 013f7c9ccb..8984e1aa74 100755 --- a/bin/hooks/largefiles_check +++ b/bin/hooks/largefiles_check @@ -8,7 +8,7 @@ import shutil import subprocess import sys -import tomllib +import tomli parser = argparse.ArgumentParser() parser.add_argument("--all", action="store_true", help="Check all files in repo, not just staged") @@ -26,7 +26,7 @@ if not shutil.which("git-lfs"): # Load config with open("pyproject.toml", "rb") as f: - config = tomllib.load(f).get("tool", {}).get("largefiles", {}) + config = tomli.load(f).get("tool", {}).get("largefiles", {}) max_size_kb = config.get("max_size_kb", 50) max_bytes = max_size_kb * 1024 diff --git a/dimos/agents/agent.py b/dimos/agents/agent.py index 17f1871210..bf5ded4f00 100644 --- a/dimos/agents/agent.py +++ b/dimos/agents/agent.py @@ -33,11 +33,7 @@ from dimos.agents.spec import AgentSpec, Model, Provider from dimos.agents.system_prompt import SYSTEM_PROMPT from dimos.core import DimosCluster, rpc -from dimos.protocol.skill.coordinator import ( - SkillCoordinator, - SkillState, - SkillStateDict, -) +from dimos.protocol.skill.coordinator import SkillCoordinator, SkillState, SkillStateDict from dimos.protocol.skill.skill import SkillContainer from dimos.protocol.skill.type import Output from dimos.utils.logging_config import setup_logger diff --git a/dimos/agents/cli/human.py b/dimos/agents/cli/human.py index a0a85e55d5..e842b3cc8a 100644 --- a/dimos/agents/cli/human.py +++ b/dimos/agents/cli/human.py @@ -47,7 +47,7 @@ def stop(self) -> None: super().stop() @rpc - def set_LlmAgent_register_skills(self, callable: RpcCall) -> None: + def set_AgentSpec_register_skills(self, callable: RpcCall) -> None: callable.set_rpc(self.rpc) # type: ignore[arg-type] callable(self, run_implicit_name="human") diff --git a/dimos/agents/skills/conftest.py b/dimos/agents/skills/conftest.py index 6cf50f9b2d..0e2e3e0636 100644 --- a/dimos/agents/skills/conftest.py +++ b/dimos/agents/skills/conftest.py @@ -72,8 +72,6 @@ def google_maps_skill_container(mocker): @pytest.fixture def unitree_skills(mocker): container = UnitreeSkillContainer() - container._move = mocker.Mock() - container._publish_request = mocker.Mock() container.start() yield container container.stop() diff --git a/dimos/agents/skills/test_unitree_skill_container.py b/dimos/agents/skills/test_unitree_skill_container.py index 16088875c5..29dfade979 100644 --- a/dimos/agents/skills/test_unitree_skill_container.py +++ b/dimos/agents/skills/test_unitree_skill_container.py @@ -13,29 +13,19 @@ # limitations under the License. -def test_pounce(create_unitree_skills_agent, unitree_skills) -> None: +def test_pounce(mocker, create_unitree_skills_agent, unitree_skills) -> None: agent = create_unitree_skills_agent(fixture="test_pounce.json") + publish_request_mock = mocker.Mock() + unitree_skills.get_rpc_calls = mocker.Mock(return_value=publish_request_mock) response = agent.query("pounce") assert "front pounce" in response.lower() - unitree_skills._publish_request.assert_called_once_with( - "rt/api/sport/request", {"api_id": 1032} - ) - - -def test_show_your_love(create_unitree_skills_agent, unitree_skills) -> None: - agent = create_unitree_skills_agent(fixture="test_show_your_love.json") - - response = agent.query("show your love") - - assert "finger heart" in response.lower() - unitree_skills._publish_request.assert_called_once_with( - "rt/api/sport/request", {"api_id": 1036} - ) + publish_request_mock.assert_called_once_with("rt/api/sport/request", {"api_id": 1032}) -def test_did_you_mean(unitree_skills) -> None: +def test_did_you_mean(mocker, unitree_skills) -> None: + unitree_skills.get_rpc_calls = mocker.Mock() assert ( unitree_skills.execute_sport_command("Pounce") == "There's no 'Pounce' command. Did you mean: ['FrontPounce', 'Pose']" diff --git a/dimos/agents/spec.py b/dimos/agents/spec.py index 37262dc497..b0a0324e89 100644 --- a/dimos/agents/spec.py +++ b/dimos/agents/spec.py @@ -17,7 +17,10 @@ from abc import ABC, abstractmethod from dataclasses import dataclass, field from enum import Enum -from typing import Any, Union +from typing import TYPE_CHECKING, Any, Union + +if TYPE_CHECKING: + from dimos.protocol.skill.skill import SkillContainer from langchain.chat_models.base import _SUPPORTED_PROVIDERS from langchain_core.language_models.chat_models import BaseChatModel @@ -177,6 +180,12 @@ def append_history(self, *msgs: list[AIMessage | HumanMessage]): ... # type: ig @abstractmethod def history(self) -> list[AnyMessage]: ... + @rpc + @abstractmethod + def register_skills( + self, container: "SkillContainer", run_implicit_name: str | None = None + ) -> None: ... + @rpc @abstractmethod def query(self, query: str): ... # type: ignore[no-untyped-def] diff --git a/dimos/agents/test_mock_agent.py b/dimos/agents/test_mock_agent.py index 9bc3cc5098..c711e23143 100644 --- a/dimos/agents/test_mock_agent.py +++ b/dimos/agents/test_mock_agent.py @@ -26,7 +26,7 @@ from dimos.msgs.geometry_msgs import PoseStamped, Vector3 from dimos.msgs.sensor_msgs import Image from dimos.protocol.skill.test_coordinator import SkillContainerTest -from dimos.robot.unitree_webrtc.modular.connection_module import ConnectionModule +from dimos.robot.unitree.connection.go2 import GO2Connection from dimos.robot.unitree_webrtc.type.lidar import LidarMessage @@ -157,11 +157,11 @@ def test_tool_call_implicit_detections() -> None: system_prompt="You are a helpful robot assistant with camera capabilities.", ) - robot_connection = dimos.deploy(ConnectionModule, connection_type="fake") + robot_connection = dimos.deploy(GO2Connection, connection_type="fake") robot_connection.lidar.transport = LCMTransport("/lidar", LidarMessage) robot_connection.odom.transport = LCMTransport("/odom", PoseStamped) robot_connection.video.transport = LCMTransport("/image", Image) - robot_connection.movecmd.transport = LCMTransport("/cmd_vel", Vector3) + robot_connection.cmd_vel.transport = LCMTransport("/cmd_vel", Vector3) robot_connection.camera_info.transport = LCMTransport("/camera_info", CameraInfo) robot_connection.start() diff --git a/dimos/core/README_BLUEPRINTS.md b/dimos/core/README_BLUEPRINTS.md index 7e9dd56e87..0a3e2ceaf5 100644 --- a/dimos/core/README_BLUEPRINTS.md +++ b/dimos/core/README_BLUEPRINTS.md @@ -184,15 +184,32 @@ class ModuleB(Module): And you want to call `ModuleA.get_time` in `ModuleB.request_the_time`. -You can do so by defining a method like `set__`. It will be called with an `RpcCall` that will call the original `ModuleA.get_time`. So you can write this: +To do this, you can request a link to the method you want to call in `rpc_calls`. Calling `get_time_rcp` will call the original `ModuleA.get_time`. ```python -class ModuleA(Module): +class ModuleB(Module): + rpc_calls: list[str] = [ + "ModuleA.get_time", + ] - @rpc - def get_time(self) -> str: - ... + def request_the_time(self) -> None: + get_time_rpc = self.get_rpc_calls("ModuleA.get_time") + print(get_time_rpc()) +``` + +You can also request multiple methods at a time: + +```python +method1_rpc, method2_rpc = self.get_rpc_calls("ModuleX.m1", "ModuleX.m2") +``` + +## Alternative RPC calls + +There is an alternative way of receiving RPC methods. It is useful when you want to perform an action at the time you receive the RPC methods. + +You can use it by defining a method like `set__`: +```python class ModuleB(Module): @rpc # Note that it has to be an rpc method. def set_ModuleA_get_time(self, rpc_call: RpcCall) -> None: @@ -205,9 +222,51 @@ class ModuleB(Module): Note that `RpcCall.rpc` does not serialize, so you have to set it to the one from the module with `rpc_call.set_rpc(self.rpc)` +## Calling an interface + +In the previous examples, you can only call methods in a module called `ModuleA`. But what if you want to deploy an alternative module in your blueprint? + +You can do so by extracting the common interface as an `ABC` (abstract base class) and linking to the `ABC` instead one particular class. + +```python +class TimeInterface(ABC): + @abstractmethod + def get_time(self): ... + +class ProperTime(TimeInterface): + def get_time(self): + return "13:00" + +class BadTime(TimeInterface): + def get_time(self): + return "01:00 PM" + + +class ModuleB(Module): + rpc_calls: list[str] = [ + "TimeInterface.get_time", # TimeInterface instead of ProperTime or BadTime + ] + + def request_the_time(self) -> None: + get_time_rpc = self.get_rpc_calls("TimeInterface.get_time") + print(get_time_rpc()) +``` + +The actual method that you get in `get_time_rpc` depends on which module is deployed. If you deploy `ProperTime`, you get `ProperTime.get_time`: + +```python +blueprint = autoconnect( + ProperTime.blueprint(), + # get_rpc_calls("TimeInterface.get_time") returns ProperTime.get_time + ModuleB.blueprint(), +) +``` + +If both are deployed, the blueprint will throw an error because it's ambiguous. + ## Defining skills -Skills have to be registered with `LlmAgent.register_skills(self)`. +Skills have to be registered with `AgentSpec.register_skills(self)`. ```python class SomeSkill(Module): @@ -217,7 +276,7 @@ class SomeSkill(Module): ... @rpc - def set_LlmAgent_register_skills(self, register_skills: RpcCall) -> None: + def set_AgentSpec_register_skills(self, register_skills: RpcCall) -> None: register_skills.set_rpc(self.rpc) register_skills(RPCClient(self, self.__class__)) diff --git a/dimos/core/blueprints.py b/dimos/core/blueprints.py index 1560554eed..1fa51629bf 100644 --- a/dimos/core/blueprints.py +++ b/dimos/core/blueprints.py @@ -105,6 +105,25 @@ def requirements(self, *checks: Callable[[], str | None]) -> "ModuleBlueprintSet requirement_checks=self.requirement_checks + tuple(checks), ) + def _check_ambiguity( + self, + requested_method_name: str, + interface_methods: Mapping[str, list[tuple[type[Module], Callable[..., Any]]]], + requesting_module: type[Module], + ) -> None: + if ( + requested_method_name in interface_methods + and len(interface_methods[requested_method_name]) > 1 + ): + modules_str = ", ".join( + impl[0].__name__ for impl in interface_methods[requested_method_name] + ) + raise ValueError( + f"Ambiguous RPC method '{requested_method_name}' requested by " + f"{requesting_module.__name__}. Multiple implementations found: " + f"{modules_str}. Please use a concrete class name instead." + ) + def _get_transport_for(self, name: str, type: type) -> Any: transport = self.transport_map.get((name, type), None) if transport: @@ -225,8 +244,14 @@ def _connect_rpc_methods(self, module_coordinator: ModuleCoordinator) -> None: # Gather all RPC methods. rpc_methods = {} rpc_methods_dot = {} - # Track interface methods to detect ambiguity - interface_methods = defaultdict(list) # interface_name.method -> [(module_class, method)] + + # Track interface methods to detect ambiguity. + interface_methods: defaultdict[str, list[tuple[type[Module], Callable[..., Any]]]] = ( + defaultdict(list) + ) # interface_name_method -> [(module_class, method)] + interface_methods_dot: defaultdict[str, list[tuple[type[Module], Callable[..., Any]]]] = ( + defaultdict(list) + ) # interface_name.method -> [(module_class, method)] for blueprint in self.blueprints: for method_name in blueprint.module.rpcs.keys(): # type: ignore[attr-defined] @@ -236,7 +261,7 @@ def _connect_rpc_methods(self, module_coordinator: ModuleCoordinator) -> None: rpc_methods_dot[f"{blueprint.module.__name__}.{method_name}"] = method # Also register under any interface names - for base in blueprint.module.__bases__: + for base in blueprint.module.mro(): # Check if this base is an abstract interface with the method if ( base is not Module @@ -245,40 +270,45 @@ def _connect_rpc_methods(self, module_coordinator: ModuleCoordinator) -> None: and getattr(base, method_name, None) is not None ): interface_key = f"{base.__name__}.{method_name}" - interface_methods[interface_key].append((blueprint.module, method)) + interface_methods_dot[interface_key].append((blueprint.module, method)) + interface_key_underscore = f"{base.__name__}_{method_name}" + interface_methods[interface_key_underscore].append( + (blueprint.module, method) + ) # Check for ambiguity in interface methods and add non-ambiguous ones - for interface_key, implementations in interface_methods.items(): + for interface_key, implementations in interface_methods_dot.items(): if len(implementations) == 1: rpc_methods_dot[interface_key] = implementations[0][1] + for interface_key, implementations in interface_methods.items(): + if len(implementations) == 1: + rpc_methods[interface_key] = implementations[0][1] # Fulfil method requests (so modules can call each other). for blueprint in self.blueprints: instance = module_coordinator.get_instance(blueprint.module) + for method_name in blueprint.module.rpcs.keys(): # type: ignore[attr-defined] if not method_name.startswith("set_"): continue + linked_name = method_name.removeprefix("set_") + + self._check_ambiguity(linked_name, interface_methods, blueprint.module) + if linked_name not in rpc_methods: continue + getattr(instance, method_name)(rpc_methods[linked_name]) + for requested_method_name in instance.get_rpc_method_names(): # type: ignore[union-attr] - # Check if this is an ambiguous interface method - if ( - requested_method_name in interface_methods - and len(interface_methods[requested_method_name]) > 1 - ): - modules_str = ", ".join( - impl[0].__name__ for impl in interface_methods[requested_method_name] - ) - raise ValueError( - f"Ambiguous RPC method '{requested_method_name}' requested by " - f"{blueprint.module.__name__}. Multiple implementations found: " - f"{modules_str}. Please use a concrete class name instead." - ) + self._check_ambiguity( + requested_method_name, interface_methods_dot, blueprint.module + ) if requested_method_name not in rpc_methods_dot: continue + instance.set_rpc_method( # type: ignore[union-attr] requested_method_name, rpc_methods_dot[requested_method_name] ) diff --git a/dimos/core/module.py b/dimos/core/module.py index 62afc94f40..08e428d3c7 100644 --- a/dimos/core/module.py +++ b/dimos/core/module.py @@ -15,7 +15,6 @@ from collections.abc import Callable from dataclasses import dataclass from functools import partial -import inspect import sys import threading from typing import ( @@ -36,7 +35,7 @@ from dimos.core import colors from dimos.core.core import T, rpc -from dimos.core.introspection.module import INTERNAL_RPCS, extract_module_info, render_module_io +from dimos.core.introspection.module import extract_module_info, render_module_io from dimos.core.resource import Resource from dimos.core.rpc_client import RpcCall from dimos.core.stream import In, Out, RemoteIn, RemoteOut, Transport @@ -276,7 +275,6 @@ def __get__( @classmethod def _module_info_class(cls) -> "ModuleInfo": """Class-level module_info() - returns ModuleInfo from annotations.""" - from dimos.core.introspection.module import ModuleInfo hints = get_type_hints(cls) diff --git a/dimos/core/skill_module.py b/dimos/core/skill_module.py index 212d7bbb99..fa5abd381f 100644 --- a/dimos/core/skill_module.py +++ b/dimos/core/skill_module.py @@ -18,10 +18,15 @@ class SkillModule(Module): - """Use this module if you want to auto-register skills to an LlmAgent.""" + """Use this module if you want to auto-register skills to an AgentSpec.""" @rpc - def set_LlmAgent_register_skills(self, callable: RpcCall) -> None: + def set_AgentSpec_register_skills(self, callable: RpcCall) -> None: + callable.set_rpc(self.rpc) # type: ignore[arg-type] + callable(RPCClient(self, self.__class__)) + + @rpc + def set_MCPModule_register_skills(self, callable: RpcCall) -> None: callable.set_rpc(self.rpc) # type: ignore[arg-type] callable(RPCClient(self, self.__class__)) diff --git a/dimos/core/stream.py b/dimos/core/stream.py index 9530ab7c32..66d8cf4ef5 100644 --- a/dimos/core/stream.py +++ b/dimos/core/stream.py @@ -238,8 +238,7 @@ def transport(self) -> Transport[T]: @transport.setter def transport(self, value: Transport[T]) -> None: - # just for type checking - ... + self._transport = value def connect(self, value: Out[T]) -> None: value.subscribe(self.transport.publish) # type: ignore[arg-type] diff --git a/dimos/dashboard/rerun_init.py b/dimos/dashboard/rerun_init.py index 81beb40d6a..4ccec8209d 100644 --- a/dimos/dashboard/rerun_init.py +++ b/dimos/dashboard/rerun_init.py @@ -64,7 +64,7 @@ _rerun_init_lock = threading.Lock() -def init_rerun_server(viewer_mode: str = "rerun-web") -> str: +def init_rerun_server(viewer_mode: str = "rerun-web", memory_limit: str = "4GB") -> str: """Initialize Rerun server in the main process. Starts the gRPC server and optionally the web/native viewer. @@ -72,6 +72,7 @@ def init_rerun_server(viewer_mode: str = "rerun-web") -> str: Args: viewer_mode: One of "rerun-web", "rerun-native", or "rerun-grpc-only" + memory_limit: Maximum memory for Rerun viewer (e.g., "16GB", "25%"). Default 16GB. Returns: Server address for workers to connect to. @@ -89,8 +90,8 @@ def init_rerun_server(viewer_mode: str = "rerun-web") -> str: if viewer_mode == "rerun-native": # Spawn native viewer (requires display) - rr.spawn(port=RERUN_GRPC_PORT, connect=True) - logger.info("Rerun: spawned native viewer", port=RERUN_GRPC_PORT) + rr.spawn(port=RERUN_GRPC_PORT, connect=True, memory_limit=memory_limit) + logger.info("Rerun: spawned native viewer", port=RERUN_GRPC_PORT, memory_limit=memory_limit) elif viewer_mode == "rerun-web": # Start gRPC + web viewer (headless friendly) server_uri = rr.serve_grpc(grpc_port=RERUN_GRPC_PORT) diff --git a/dimos/e2e_tests/test_dimos_cli_e2e.py b/dimos/e2e_tests/test_dimos_cli_e2e.py index 2a9f715440..7571e113ad 100644 --- a/dimos/e2e_tests/test_dimos_cli_e2e.py +++ b/dimos/e2e_tests/test_dimos_cli_e2e.py @@ -20,7 +20,7 @@ @pytest.mark.skipif(bool(os.getenv("CI")), reason="LCM spy doesn't work in CI.") @pytest.mark.skipif(not os.getenv("OPENAI_API_KEY"), reason="OPENAI_API_KEY not set.") def test_dimos_skills(lcm_spy, start_blueprint, human_input) -> None: - lcm_spy.save_topic("/rpc/DemoCalculatorSkill/set_LlmAgent_register_skills/res") + lcm_spy.save_topic("/rpc/DemoCalculatorSkill/set_AgentSpec_register_skills/res") lcm_spy.save_topic("/rpc/HumanInput/start/res") lcm_spy.save_topic("/agent") lcm_spy.save_topic("/rpc/DemoCalculatorSkill/sum_numbers/req") @@ -28,7 +28,7 @@ def test_dimos_skills(lcm_spy, start_blueprint, human_input) -> None: start_blueprint("demo-skill") - lcm_spy.wait_for_saved_topic("/rpc/DemoCalculatorSkill/set_LlmAgent_register_skills/res") + lcm_spy.wait_for_saved_topic("/rpc/DemoCalculatorSkill/set_AgentSpec_register_skills/res") lcm_spy.wait_for_saved_topic("/rpc/HumanInput/start/res") lcm_spy.wait_for_saved_topic_content("/agent", b"AIMessage") diff --git a/dimos/hardware/sensors/camera/module.py b/dimos/hardware/sensors/camera/module.py index de2c3b8c78..10c541723a 100644 --- a/dimos/hardware/sensors/camera/module.py +++ b/dimos/hardware/sensors/camera/module.py @@ -19,7 +19,6 @@ import reactivex as rx from reactivex import operators as ops -from reactivex.observable import Observable from dimos.agents import Output, Reducer, Stream, skill from dimos.core import Module, ModuleConfig, Out, rpc diff --git a/dimos/protocol/mcp/README.md b/dimos/protocol/mcp/README.md new file mode 100644 index 0000000000..2a3c382484 --- /dev/null +++ b/dimos/protocol/mcp/README.md @@ -0,0 +1,30 @@ +# DimOS MCP Server + +Expose DimOS robot skills to Claude Code via Model Context Protocol. + +## Setup + +Add to Claude Code (one command): +```bash +claude mcp add --transport stdio dimos --scope project -- python -m dimos.protocol.mcp +``` + +## Usage + +**Terminal 1** - Start DimOS: +```bash +dimos --replay run unitree-go2-agentic +``` + +**Claude Code** - Use robot skills: +``` +> move forward 1 meter +> go to the kitchen +> tag this location as "desk" +``` + +## How It Works + +1. `llm_agent(mcp_port=9990)` in the blueprint starts a TCP server +2. Claude Code spawns the bridge (`--bridge`) which connects to `localhost:9990` +3. Skills are exposed as MCP tools (e.g., `relative_move`, `navigate_with_text`) diff --git a/dimos/protocol/mcp/__init__.py b/dimos/protocol/mcp/__init__.py new file mode 100644 index 0000000000..51432ba0cf --- /dev/null +++ b/dimos/protocol/mcp/__init__.py @@ -0,0 +1,17 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# 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. + +from dimos.protocol.mcp.mcp import MCPModule + +__all__ = ["MCPModule"] diff --git a/dimos/protocol/mcp/__main__.py b/dimos/protocol/mcp/__main__.py new file mode 100644 index 0000000000..a58e59d367 --- /dev/null +++ b/dimos/protocol/mcp/__main__.py @@ -0,0 +1,36 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# 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. + +"""CLI entry point for Dimensional MCP Bridge. + +Connects Claude Code (or other MCP clients) to a running DimOS agent. + +Usage: + python -m dimos.protocol.mcp # Bridge to running DimOS on default port +""" + +from __future__ import annotations + +import asyncio + +from dimos.protocol.mcp.bridge import main as bridge_main + + +def main() -> None: + """Main entry point - connects to running DimOS via bridge.""" + asyncio.run(bridge_main()) + + +if __name__ == "__main__": + main() diff --git a/dimos/protocol/mcp/bridge.py b/dimos/protocol/mcp/bridge.py new file mode 100644 index 0000000000..0b09997798 --- /dev/null +++ b/dimos/protocol/mcp/bridge.py @@ -0,0 +1,53 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# 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. + + +"""MCP Bridge - Connects stdio (Claude Code) to TCP (DimOS Agent).""" + +import asyncio +import os +import sys + +DEFAULT_PORT = 9990 + + +async def main() -> None: + port = int(os.environ.get("MCP_PORT", DEFAULT_PORT)) + host = os.environ.get("MCP_HOST", "localhost") + + reader, writer = await asyncio.open_connection(host, port) + sys.stderr.write(f"MCP Bridge connected to {host}:{port}\n") + + async def stdin_to_tcp() -> None: + loop = asyncio.get_event_loop() + while True: + line = await loop.run_in_executor(None, sys.stdin.readline) + if not line: + break + writer.write(line.encode()) + await writer.drain() + + async def tcp_to_stdout() -> None: + while True: + data = await reader.readline() + if not data: + break + sys.stdout.write(data.decode()) + sys.stdout.flush() + + await asyncio.gather(stdin_to_tcp(), tcp_to_stdout()) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/dimos/protocol/mcp/mcp.py b/dimos/protocol/mcp/mcp.py new file mode 100644 index 0000000000..f7427cd613 --- /dev/null +++ b/dimos/protocol/mcp/mcp.py @@ -0,0 +1,133 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# 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. +from __future__ import annotations + +import asyncio +import json +from typing import TYPE_CHECKING, Any +import uuid + +from dimos.core import Module, rpc +from dimos.protocol.skill.coordinator import SkillCoordinator, SkillStateEnum + +if TYPE_CHECKING: + from dimos.protocol.skill.coordinator import SkillState + + +class MCPModule(Module): + def __init__(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def] + super().__init__(*args, **kwargs) + self.coordinator = SkillCoordinator() + self._server: asyncio.AbstractServer | None = None + self._server_future: object | None = None + + @rpc + def start(self) -> None: + super().start() + self.coordinator.start() + self._start_server() + + @rpc + def stop(self) -> None: + if self._server: + self._server.close() + loop = self._loop + assert loop is not None + asyncio.run_coroutine_threadsafe(self._server.wait_closed(), loop).result() + self._server = None + if self._server_future and hasattr(self._server_future, "cancel"): + self._server_future.cancel() + self.coordinator.stop() + super().stop() + + @rpc + def register_skills(self, container) -> None: # type: ignore[no-untyped-def] + self.coordinator.register_skills(container) + + def _start_server(self, port: int = 9990) -> None: + async def handle_client(reader, writer) -> None: # type: ignore[no-untyped-def] + while True: + if not (data := await reader.readline()): + break + response = await self._handle_request(json.loads(data.decode())) + writer.write(json.dumps(response).encode() + b"\n") + await writer.drain() + writer.close() + + async def start_server() -> None: + self._server = await asyncio.start_server(handle_client, "0.0.0.0", port) + await self._server.serve_forever() + + loop = self._loop + assert loop is not None + self._server_future = asyncio.run_coroutine_threadsafe(start_server(), loop) + + async def _handle_request(self, request: dict[str, Any]) -> dict[str, Any]: + method = request.get("method", "") + params = request.get("params", {}) or {} + req_id = request.get("id") + if method == "initialize": + init_result = { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "dimensional", "version": "1.0.0"}, + } + return {"jsonrpc": "2.0", "id": req_id, "result": init_result} + if method == "tools/list": + tools = [ + { + "name": c.name, + "description": c.schema.get("function", {}).get("description", ""), + "inputSchema": c.schema.get("function", {}).get("parameters", {}), + } + for c in self.coordinator.skills().values() + if not c.hide_skill + ] + return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": tools}} + if method == "tools/call": + name = params.get("name") + args = params.get("arguments") or {} + if not isinstance(name, str): + return { + "jsonrpc": "2.0", + "id": req_id, + "error": {"code": -32602, "message": "Missing or invalid tool name"}, + } + if not isinstance(args, dict): + args = {} + call_id = str(uuid.uuid4()) + self.coordinator.call_skill(call_id, name, args) + result: SkillState | None = self.coordinator._skill_state.get(call_id) + try: + await asyncio.wait_for(self.coordinator.wait_for_updates(), timeout=5.0) + except asyncio.TimeoutError: + pass + if result is None: + text = "Skill not found" + elif result.state == SkillStateEnum.completed: + text = str(result.content()) if result.content() else "Completed" + elif result.state == SkillStateEnum.error: + text = f"Error: {result.content()}" + else: + text = f"Started ({result.state.name})" + return { + "jsonrpc": "2.0", + "id": req_id, + "result": {"content": [{"type": "text", "text": text}]}, + } + return { + "jsonrpc": "2.0", + "id": req_id, + "error": {"code": -32601, "message": f"Unknown: {method}"}, + } diff --git a/dimos/protocol/mcp/test_mcp_module.py b/dimos/protocol/mcp/test_mcp_module.py new file mode 100644 index 0000000000..1deb5b9057 --- /dev/null +++ b/dimos/protocol/mcp/test_mcp_module.py @@ -0,0 +1,208 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# 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. + +from __future__ import annotations + +import asyncio +import json +import os +from pathlib import Path +import socket +import subprocess +import sys +import threading + +import pytest + +from dimos.protocol.mcp.mcp import MCPModule +from dimos.protocol.skill.coordinator import SkillStateEnum +from dimos.protocol.skill.skill import SkillContainer, skill + + +def test_unitree_blueprint_has_mcp() -> None: + contents = Path("dimos/robot/unitree_webrtc/unitree_go2_blueprints.py").read_text() + assert "agentic_mcp" in contents + assert "MCPModule.blueprint()" in contents + + +def test_mcp_module_request_flow() -> None: + class DummySkill: + def __init__(self) -> None: + self.name = "add" + self.hide_skill = False + self.schema = {"function": {"description": "", "parameters": {"type": "object"}}} + + class DummyState: + def __init__(self, content: int) -> None: + self.state = SkillStateEnum.completed + self._content = content + + def content(self) -> int: + return self._content + + class DummyCoordinator: + def __init__(self) -> None: + self._skill_state: dict[str, DummyState] = {} + + def skills(self) -> dict[str, DummySkill]: + return {"add": DummySkill()} + + def call_skill(self, call_id: str, _name: str, args: dict[str, int]) -> None: + self._skill_state[call_id] = DummyState(args["x"] + args["y"]) + + async def wait_for_updates(self) -> bool: + return True + + mcp = MCPModule.__new__(MCPModule) + mcp.coordinator = DummyCoordinator() + + response = asyncio.run(mcp._handle_request({"method": "tools/list", "id": 1})) + assert response["result"]["tools"][0]["name"] == "add" + + response = asyncio.run( + mcp._handle_request( + { + "method": "tools/call", + "id": 2, + "params": {"name": "add", "arguments": {"x": 2, "y": 3}}, + } + ) + ) + assert response["result"]["content"][0]["text"] == "5" + + +def test_mcp_module_handles_hidden_and_errors() -> None: + class DummySkill: + def __init__(self, name: str, hide_skill: bool) -> None: + self.name = name + self.hide_skill = hide_skill + self.schema = {"function": {"description": "", "parameters": {"type": "object"}}} + + class DummyState: + def __init__(self, state: SkillStateEnum, content: str | None) -> None: + self.state = state + self._content = content + + def content(self) -> str | None: + return self._content + + class DummyCoordinator: + def __init__(self) -> None: + self._skill_state: dict[str, DummyState] = {} + self._skills = { + "visible": DummySkill("visible", False), + "hidden": DummySkill("hidden", True), + "fail": DummySkill("fail", False), + } + + def skills(self) -> dict[str, DummySkill]: + return self._skills + + def call_skill(self, call_id: str, name: str, _args: dict[str, int]) -> None: + if name == "fail": + self._skill_state[call_id] = DummyState(SkillStateEnum.error, "boom") + elif name in self._skills: + self._skill_state[call_id] = DummyState(SkillStateEnum.running, None) + + async def wait_for_updates(self) -> bool: + return True + + mcp = MCPModule.__new__(MCPModule) + mcp.coordinator = DummyCoordinator() + + response = asyncio.run(mcp._handle_request({"method": "tools/list", "id": 1})) + tool_names = {tool["name"] for tool in response["result"]["tools"]} + assert "visible" in tool_names + assert "hidden" not in tool_names + + response = asyncio.run( + mcp._handle_request( + {"method": "tools/call", "id": 2, "params": {"name": "fail", "arguments": {}}} + ) + ) + assert "Error:" in response["result"]["content"][0]["text"] + + +def test_mcp_end_to_end_lcm_bridge() -> None: + try: + import lcm # type: ignore[import-untyped] + + lcm.LCM() + except Exception as exc: + if os.environ.get("CI"): + pytest.fail(f"LCM unavailable for MCP end-to-end test: {exc}") + pytest.skip("LCM unavailable for MCP end-to-end test.") + + try: + socket.socket(socket.AF_INET, socket.SOCK_STREAM).close() + except PermissionError: + if os.environ.get("CI"): + pytest.fail("Socket creation not permitted in CI environment.") + pytest.skip("Socket creation not permitted in this environment.") + + class TestSkills(SkillContainer): + @skill() + def add(self, x: int, y: int) -> int: + return x + y + + mcp = MCPModule() + mcp.start() + + try: + mcp.register_skills(TestSkills()) + + env = {"MCP_HOST": "127.0.0.1", "MCP_PORT": "9990"} + proc = subprocess.Popen( + [sys.executable, "-m", "dimos.protocol.mcp"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env={**os.environ, **env}, + text=True, + ) + try: + request = {"jsonrpc": "2.0", "id": 1, "method": "tools/list"} + proc.stdin.write(json.dumps(request) + "\n") + proc.stdin.flush() + stdout = proc.stdout.readline() + assert '"tools"' in stdout + assert '"add"' in stdout + finally: + proc.terminate() + proc.wait(timeout=5) + + proc = subprocess.Popen( + [sys.executable, "-m", "dimos.protocol.mcp"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env={**os.environ, **env}, + text=True, + ) + try: + request = { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": {"name": "add", "arguments": {"x": 2, "y": 3}}, + } + proc.stdin.write(json.dumps(request) + "\n") + proc.stdin.flush() + stdout = proc.stdout.readline() + assert "5" in stdout + finally: + proc.terminate() + proc.wait(timeout=5) + finally: + mcp.stop() diff --git a/dimos/robot/all_blueprints.py b/dimos/robot/all_blueprints.py index 7dbbc9c67a..9b118cbd60 100644 --- a/dimos/robot/all_blueprints.py +++ b/dimos/robot/all_blueprints.py @@ -22,6 +22,7 @@ "unitree-go2-detection": "dimos.robot.unitree_webrtc.unitree_go2_blueprints:detection", "unitree-go2-spatial": "dimos.robot.unitree_webrtc.unitree_go2_blueprints:spatial", "unitree-go2-agentic": "dimos.robot.unitree_webrtc.unitree_go2_blueprints:agentic", + "unitree-go2-agentic-mcp": "dimos.robot.unitree_webrtc.unitree_go2_blueprints:agentic_mcp", "unitree-go2-agentic-ollama": "dimos.robot.unitree_webrtc.unitree_go2_blueprints:agentic_ollama", "unitree-go2-agentic-huggingface": "dimos.robot.unitree_webrtc.unitree_go2_blueprints:agentic_huggingface", "unitree-g1": "dimos.robot.unitree_webrtc.unitree_g1_blueprints:standard", diff --git a/dimos/robot/unitree_webrtc/modular/__init__.py b/dimos/robot/unitree_webrtc/modular/__init__.py deleted file mode 100644 index 5c2169cc9b..0000000000 --- a/dimos/robot/unitree_webrtc/modular/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from dimos.robot.unitree_webrtc.modular.connection_module import deploy_connection diff --git a/dimos/robot/unitree_webrtc/modular/connection_module.py b/dimos/robot/unitree_webrtc/modular/connection_module.py deleted file mode 100644 index b6a08f9857..0000000000 --- a/dimos/robot/unitree_webrtc/modular/connection_module.py +++ /dev/null @@ -1,340 +0,0 @@ -#!/usr/bin/env python3 - -#!/usr/bin/env python3 - -# Copyright 2025-2026 Dimensional Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# 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. -from dataclasses import dataclass -import functools -import logging -import os -import queue -import warnings - -from dimos_lcm.sensor_msgs import CameraInfo -import reactivex as rx -from reactivex import operators as ops -from reactivex.observable import Observable - -from dimos.agents import Output, Reducer, Stream, skill # type: ignore[attr-defined] -from dimos.constants import DEFAULT_CAPACITY_COLOR_IMAGE -from dimos.core import DimosCluster, In, LCMTransport, Module, ModuleConfig, Out, pSHMTransport, rpc -from dimos.core.global_config import GlobalConfig -from dimos.msgs.geometry_msgs import PoseStamped, Quaternion, Transform, Twist, Vector3 -from dimos.msgs.sensor_msgs.Image import Image -from dimos.msgs.std_msgs import Header -from dimos.robot.unitree.connection.connection import UnitreeWebRTCConnection -from dimos.robot.unitree_webrtc.type.lidar import LidarMessage -from dimos.utils.data import get_data -from dimos.utils.decorators import simple_mcache -from dimos.utils.logging_config import setup_logger -from dimos.utils.testing import TimedSensorReplay, TimedSensorStorage - -logger = setup_logger(level=logging.INFO) - -# Suppress verbose loggers -logging.getLogger("aiortc.codecs.h264").setLevel(logging.ERROR) -logging.getLogger("lcm_foxglove_bridge").setLevel(logging.ERROR) -logging.getLogger("websockets.server").setLevel(logging.ERROR) -logging.getLogger("FoxgloveServer").setLevel(logging.ERROR) -logging.getLogger("asyncio").setLevel(logging.ERROR) -logging.getLogger("root").setLevel(logging.WARNING) - - -# Suppress warnings -warnings.filterwarnings("ignore", message="coroutine.*was never awaited") -warnings.filterwarnings("ignore", message="H264Decoder.*failed to decode") - -image_resize_factor = 1 -originalwidth, originalheight = (1280, 720) - - -class FakeRTC(UnitreeWebRTCConnection): - dir_name = "unitree_go2_office_walk2" - - # we don't want UnitreeWebRTCConnection to init - def __init__( # type: ignore[no-untyped-def] - self, - **kwargs, - ) -> None: - get_data(self.dir_name) - self.replay_config = { - "loop": kwargs.get("loop"), - "seek": kwargs.get("seek"), - "duration": kwargs.get("duration"), - } - - def connect(self) -> None: - pass - - def start(self) -> None: - pass - - def standup(self) -> None: - print("standup suppressed") - - def liedown(self) -> None: - print("liedown suppressed") - - @simple_mcache - def lidar_stream(self): # type: ignore[no-untyped-def] - print("lidar stream start") - lidar_store = TimedSensorReplay(f"{self.dir_name}/lidar") # type: ignore[var-annotated] - return lidar_store.stream(**self.replay_config) # type: ignore[arg-type] - - @simple_mcache - def odom_stream(self): # type: ignore[no-untyped-def] - print("odom stream start") - odom_store = TimedSensorReplay(f"{self.dir_name}/odom") # type: ignore[var-annotated] - return odom_store.stream(**self.replay_config) # type: ignore[arg-type] - - # we don't have raw video stream in the data set - @simple_mcache - def video_stream(self): # type: ignore[no-untyped-def] - print("video stream start") - video_store = TimedSensorReplay(f"{self.dir_name}/video") # type: ignore[var-annotated] - - return video_store.stream(**self.replay_config) # type: ignore[arg-type] - - def move(self, vector: Twist, duration: float = 0.0) -> None: # type: ignore[override] - pass - - def publish_request(self, topic: str, data: dict): # type: ignore[no-untyped-def, type-arg] - """Fake publish request for testing.""" - return {"status": "ok", "message": "Fake publish"} - - -@dataclass -class ConnectionModuleConfig(ModuleConfig): - ip: str | None = None - connection_type: str = "fake" # or "fake" or "mujoco" - loop: bool = False # For fake connection - speed: float = 1.0 # For fake connection - - -class ConnectionModule(Module): - camera_info: Out[CameraInfo] - odom: Out[PoseStamped] - lidar: Out[LidarMessage] - video: Out[Image] - movecmd: In[Twist] - - connection = None - - default_config = ConnectionModuleConfig - - # mega temporary, skill should have a limit decorator for number of - # parallel calls - video_running: bool = False - - def __init__(self, connection_type: str = "webrtc", *args, **kwargs) -> None: # type: ignore[no-untyped-def] - self.connection_config = kwargs - self.connection_type = connection_type - Module.__init__(self, *args, **kwargs) - - @skill(stream=Stream.passive, output=Output.image, reducer=Reducer.latest) # type: ignore[arg-type] - def video_stream_tool(self) -> Image: # type: ignore[misc] - """implicit video stream skill, don't call this directly""" - if self.video_running: - return "video stream already running" - self.video_running = True - _queue = queue.Queue(maxsize=1) # type: ignore[var-annotated] - self.connection.video_stream().subscribe(_queue.put) # type: ignore[attr-defined] - - yield from iter(_queue.get, None) - - @rpc - def record(self, recording_name: str) -> None: - lidar_store: TimedSensorStorage = TimedSensorStorage(f"{recording_name}/lidar") # type: ignore[type-arg] - lidar_store.save_stream(self.connection.lidar_stream()).subscribe(lambda x: x) # type: ignore[arg-type, attr-defined] - - odom_store: TimedSensorStorage = TimedSensorStorage(f"{recording_name}/odom") # type: ignore[type-arg] - odom_store.save_stream(self.connection.odom_stream()).subscribe(lambda x: x) # type: ignore[arg-type, attr-defined] - - video_store: TimedSensorStorage = TimedSensorStorage(f"{recording_name}/video") # type: ignore[type-arg] - video_store.save_stream(self.connection.video_stream()).subscribe(lambda x: x) # type: ignore[arg-type, attr-defined] - - @rpc - def start(self): # type: ignore[no-untyped-def] - """Start the connection and subscribe to sensor streams.""" - - super().start() - - match self.connection_type: - case "webrtc": - self.connection = UnitreeWebRTCConnection(**self.connection_config) - case "fake": - self.connection = FakeRTC(**self.connection_config, seek=12.0) - case "mujoco": - from dimos.robot.unitree_webrtc.mujoco_connection import MujocoConnection - - self.connection = MujocoConnection(GlobalConfig()) # type: ignore[assignment] - self.connection.start() # type: ignore[union-attr] - case _: - raise ValueError(f"Unknown connection type: {self.connection_type}") - - unsub = self.connection.odom_stream().subscribe( # type: ignore[union-attr] - lambda odom: self._publish_tf(odom) and self.odom.publish(odom) # type: ignore[func-returns-value] - ) - self._disposables.add(unsub) - - # Connect sensor streams to outputs - unsub = self.connection.lidar_stream().subscribe(self.lidar.publish) # type: ignore[union-attr] - self._disposables.add(unsub) - - # self.connection.lidar_stream().subscribe(lambda lidar: print("LIDAR", lidar.ts)) - # self.connection.video_stream().subscribe(lambda video: print("IMAGE", video.ts)) - # self.connection.odom_stream().subscribe(lambda odom: print("ODOM", odom.ts)) - - def resize(image: Image) -> Image: - return image.resize( - int(originalwidth / image_resize_factor), int(originalheight / image_resize_factor) - ) - - unsub = self.connection.video_stream().subscribe(self.video.publish) # type: ignore[union-attr] - self._disposables.add(unsub) - unsub = self.camera_info_stream().subscribe(self.camera_info.publish) - self._disposables.add(unsub) - unsub = self.movecmd.subscribe(self.connection.move) # type: ignore[union-attr] - self._disposables.add(unsub) # type: ignore[arg-type] - - @rpc - def stop(self) -> None: - if self.connection: - self.connection.stop() - - super().stop() - - @classmethod - def _odom_to_tf(cls, odom: PoseStamped) -> list[Transform]: - camera_link = Transform( - translation=Vector3(0.3, 0.0, 0.0), - rotation=Quaternion(0.0, 0.0, 0.0, 1.0), - frame_id="base_link", - child_frame_id="camera_link", - ts=odom.ts, - ) - - camera_optical = Transform( - translation=Vector3(0.0, 0.0, 0.0), - rotation=Quaternion(-0.5, 0.5, -0.5, 0.5), - frame_id="camera_link", - child_frame_id="camera_optical", - ts=odom.ts, - ) - - sensor = Transform( - translation=Vector3(0.0, 0.0, 0.0), - rotation=Quaternion(0.0, 0.0, 0.0, 1.0), - frame_id="world", - child_frame_id="sensor", - ts=odom.ts, - ) - - return [ - Transform.from_pose("base_link", odom), - camera_link, - camera_optical, - sensor, - ] - - def _publish_tf(self, msg) -> None: # type: ignore[no-untyped-def] - self.odom.publish(msg) - self.tf.publish(*self._odom_to_tf(msg)) - - @rpc - def publish_request(self, topic: str, data: dict): # type: ignore[no-untyped-def, type-arg] - """Publish a request to the WebRTC connection. - Args: - topic: The RTC topic to publish to - data: The data dictionary to publish - Returns: - The result of the publish request - """ - return self.connection.publish_request(topic, data) # type: ignore[union-attr] - - @classmethod - def _camera_info(cls) -> Out[CameraInfo]: - fx, fy, cx, cy = list( - map( - lambda x: int(x / image_resize_factor), - [819.553492, 820.646595, 625.284099, 336.808987], - ) - ) - width, height = tuple( - map( - lambda x: int(x / image_resize_factor), - [originalwidth, originalheight], - ) - ) - - # Camera matrix K (3x3) - K = [fx, 0, cx, 0, fy, cy, 0, 0, 1] - - # No distortion coefficients for now - D = [0.0, 0.0, 0.0, 0.0, 0.0] - - # Identity rotation matrix - R = [1, 0, 0, 0, 1, 0, 0, 0, 1] - - # Projection matrix P (3x4) - P = [fx, 0, cx, 0, 0, fy, cy, 0, 0, 0, 1, 0] - - base_msg = { - "D_length": len(D), - "height": height, - "width": width, - "distortion_model": "plumb_bob", - "D": D, - "K": K, - "R": R, - "P": P, - "binning_x": 0, - "binning_y": 0, - } - - return CameraInfo(**base_msg, header=Header("camera_optical")) # type: ignore[no-any-return] - - @functools.cache - def camera_info_stream(self) -> Observable[CameraInfo]: - return rx.interval(1).pipe(ops.map(lambda _: self._camera_info())) - - -def deploy_connection(dimos: DimosCluster, **kwargs): # type: ignore[no-untyped-def] - foxglove_bridge = dimos.deploy(FoxgloveBridge) # type: ignore[attr-defined, name-defined] - foxglove_bridge.start() - - connection = dimos.deploy( # type: ignore[attr-defined] - ConnectionModule, - ip=os.getenv("ROBOT_IP"), - connection_type=os.getenv("CONNECTION_TYPE", "fake"), - **kwargs, - ) - - connection.odom.transport = LCMTransport("/odom", PoseStamped) - - connection.video.transport = pSHMTransport( - "/image", default_capacity=DEFAULT_CAPACITY_COLOR_IMAGE - ) - - connection.lidar.transport = pSHMTransport( - "/lidar", default_capacity=DEFAULT_CAPACITY_COLOR_IMAGE - ) - - connection.video.transport = LCMTransport("/image", Image) - connection.lidar.transport = LCMTransport("/lidar", LidarMessage) - connection.movecmd.transport = LCMTransport("/cmd_vel", Twist) - connection.camera_info.transport = LCMTransport("/camera_info", CameraInfo) - - return connection diff --git a/dimos/robot/unitree_webrtc/modular/ivan_unitree.py b/dimos/robot/unitree_webrtc/modular/ivan_unitree.py deleted file mode 100644 index e3d2a9a00f..0000000000 --- a/dimos/robot/unitree_webrtc/modular/ivan_unitree.py +++ /dev/null @@ -1,98 +0,0 @@ -# Copyright 2025-2026 Dimensional Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# 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. - -import logging -import time - -from dimos.agents.spec import Model, Provider -from dimos.core import LCMTransport, start -from dimos.msgs.foxglove_msgs import ImageAnnotations -from dimos.msgs.sensor_msgs import Image -from dimos.msgs.vision_msgs import Detection2DArray -from dimos.perception.detection.module2D import Detection2DModule -from dimos.perception.detection.reid import ReidModule -from dimos.protocol.pubsub import lcm # type: ignore[attr-defined] -from dimos.robot.foxglove_bridge import FoxgloveBridge -from dimos.robot.unitree_webrtc.modular import deploy_connection # type: ignore[attr-defined] -from dimos.robot.unitree_webrtc.modular.connection_module import ConnectionModule -from dimos.utils.logging_config import setup_logger - -logger = setup_logger(level=logging.INFO) - - -def detection_unitree() -> None: - dimos = start(8) - connection = deploy_connection(dimos) - - def goto(pose) -> bool: # type: ignore[no-untyped-def] - print("NAVIGATION REQUESTED:", pose) - return True - - detector = dimos.deploy( # type: ignore[attr-defined] - Detection2DModule, - camera_info=ConnectionModule._camera_info(), - ) - - detector.image.connect(connection.video) - - detector.annotations.transport = LCMTransport("/annotations", ImageAnnotations) - detector.detections.transport = LCMTransport("/detections", Detection2DArray) - - detector.detected_image_0.transport = LCMTransport("/detected/image/0", Image) - detector.detected_image_1.transport = LCMTransport("/detected/image/1", Image) - detector.detected_image_2.transport = LCMTransport("/detected/image/2", Image) - - reid = dimos.deploy(ReidModule) # type: ignore[attr-defined] - - reid.image.connect(connection.video) - reid.detections.connect(detector.detections) - reid.annotations.transport = LCMTransport("/reid/annotations", ImageAnnotations) - - detector.start() - connection.start() - reid.start() - - from dimos.agents import Agent # type: ignore[attr-defined] - from dimos.agents.cli.human import HumanInput - - agent = Agent( - system_prompt="You are a helpful assistant for controlling a Unitree Go2 robot.", - model=Model.GPT_4O, # Could add CLAUDE models to enum - provider=Provider.OPENAI, # type: ignore[attr-defined] # Would need ANTHROPIC provider - ) - - human_input = dimos.deploy(HumanInput) # type: ignore[attr-defined] - agent.register_skills(human_input) - agent.register_skills(detector) - - bridge = FoxgloveBridge( - shm_channels=[ - "/image#sensor_msgs.Image", - "/lidar#sensor_msgs.PointCloud2", - ] - ) - time.sleep(1) - bridge.start() - - try: - while True: - time.sleep(1) - except KeyboardInterrupt: - connection.stop() - logger.info("Shutting down...") - - -if __name__ == "__main__": - lcm.autoconf() - detection_unitree() diff --git a/dimos/robot/unitree_webrtc/unitree_go2_blueprints.py b/dimos/robot/unitree_webrtc/unitree_go2_blueprints.py index 2d962af981..46d951650c 100644 --- a/dimos/robot/unitree_webrtc/unitree_go2_blueprints.py +++ b/dimos/robot/unitree_webrtc/unitree_go2_blueprints.py @@ -43,6 +43,7 @@ ) from dimos.perception.detection.moduleDB import ObjectDBModule, detectionDB_module from dimos.perception.spatial_perception import spatial_memory +from dimos.protocol.mcp.mcp import MCPModule from dimos.robot.foxglove_bridge import foxglove_bridge from dimos.robot.unitree.connection.go2 import GO2Connection, go2_connection from dimos.robot.unitree_webrtc.unitree_skill_container import unitree_skills @@ -165,6 +166,11 @@ _common_agentic, ) +agentic_mcp = autoconnect( + agentic, + MCPModule.blueprint(), +) + agentic_ollama = autoconnect( spatial, llm_agent( diff --git a/dimos/robot/unitree_webrtc/unitree_skill_container.py b/dimos/robot/unitree_webrtc/unitree_skill_container.py index 5c52128a32..c3dea43424 100644 --- a/dimos/robot/unitree_webrtc/unitree_skill_container.py +++ b/dimos/robot/unitree_webrtc/unitree_skill_container.py @@ -18,7 +18,6 @@ import difflib import math import time -from typing import TYPE_CHECKING from unitree_webrtc_connect.constants import RTC_TOPIC @@ -31,9 +30,6 @@ from dimos.robot.unitree_webrtc.unitree_skills import UNITREE_WEBRTC_CONTROLS from dimos.utils.logging_config import setup_logger -if TYPE_CHECKING: - from dimos.core.rpc_client import RpcCall - logger = setup_logger() @@ -47,13 +43,12 @@ class UnitreeSkillContainer(SkillModule): """Container for Unitree Go2 robot skills using the new framework.""" - _publish_request: RpcCall | None = None - rpc_calls: list[str] = [ "NavigationInterface.set_goal", "NavigationInterface.get_state", "NavigationInterface.is_goal_reached", "NavigationInterface.cancel_goal", + "GO2Connection.publish_request", ] @rpc @@ -66,16 +61,6 @@ def start(self) -> None: def stop(self) -> None: super().stop() - @rpc - def set_ConnectionModule_move(self, callable: RpcCall) -> None: - self._move = callable - self._move.set_rpc(self.rpc) # type: ignore[arg-type] - - @rpc - def set_ConnectionModule_publish_request(self, callable: RpcCall) -> None: - self._publish_request = callable - self._publish_request.set_rpc(self.rpc) # type: ignore[arg-type] - @skill() def relative_move(self, forward: float = 0.0, left: float = 0.0, degrees: float = 0.0) -> str: """Move the robot relative to its current position. @@ -96,6 +81,7 @@ def relative_move(self, forward: float = 0.0, left: float = 0.0, degrees: float # Move 3 meters left, and face that direction relative_move(forward=0, left=3, degrees=90) """ + forward, left, degrees = float(forward), float(left), float(degrees) tf = self.tf.get("world", "base_link") if tf is None: @@ -166,8 +152,11 @@ def current_time(self): # type: ignore[no-untyped-def] @skill() def execute_sport_command(self, command_name: str) -> str: - if self._publish_request is None: - return f"Error: Robot not connected (cannot execute {command_name})" + try: + publish_request = self.get_rpc_calls("GO2Connection.publish_request") + except Exception: + logger.error("GO2Connection not connected properly") + return "Failed to connect to GO2Connection." if command_name not in _UNITREE_COMMANDS: suggestions = difflib.get_close_matches( @@ -178,7 +167,7 @@ def execute_sport_command(self, command_name: str) -> str: id_, _ = _UNITREE_COMMANDS[command_name] try: - self._publish_request(RTC_TOPIC["SPORT_MOD"], {"api_id": id_}) + publish_request(RTC_TOPIC["SPORT_MOD"], {"api_id": id_}) return f"'{command_name}' command executed successfully." except Exception as e: logger.error(f"Failed to execute {command_name}: {e}") diff --git a/dimos/utils/cli/foxglove_bridge/run_foxglove_bridge.py b/dimos/utils/cli/foxglove_bridge/run_foxglove_bridge.py index 9d069bf7c2..949a500f21 100644 --- a/dimos/utils/cli/foxglove_bridge/run_foxglove_bridge.py +++ b/dimos/utils/cli/foxglove_bridge/run_foxglove_bridge.py @@ -36,7 +36,7 @@ def bridge_thread() -> None: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: - bridge_instance = FoxgloveBridge(host="0.0.0.0", port=8765, debug=True, num_threads=4) + bridge_instance = FoxgloveBridge(host="0.0.0.0", port=8765, debug=False, num_threads=4) loop.run_until_complete(bridge_instance.run()) except Exception as e: diff --git a/dimos/utils/docs/test_doclinks.py b/dimos/utils/docs/test_doclinks.py index 48f4bbdc21..7313ec3676 100644 --- a/dimos/utils/docs/test_doclinks.py +++ b/dimos/utils/docs/test_doclinks.py @@ -133,7 +133,7 @@ def test_auto_links_symbol(self, file_index): content = "The `Configurable` class is in [`service/spec.py`]()" doc_path = REPO_ROOT / "docs/test.md" - new_content, changes, errors = process_markdown( + new_content, _changes, errors = process_markdown( content, REPO_ROOT, doc_path, @@ -151,7 +151,7 @@ def test_preserves_existing_line_fragment(self, file_index): content = "See [`service/spec.py`](#L99)" doc_path = REPO_ROOT / "docs/test.md" - new_content, changes, errors = process_markdown( + new_content, _changes, _errors = process_markdown( content, REPO_ROOT, doc_path, @@ -187,7 +187,7 @@ def test_skips_non_file_refs(self, file_index): content = "The `MyClass` is documented at [`MyClass`]()" doc_path = REPO_ROOT / "docs/test.md" - new_content, changes, errors = process_markdown( + _new_content, changes, errors = process_markdown( content, REPO_ROOT, doc_path, @@ -205,7 +205,7 @@ def test_errors_on_ambiguous(self, file_index): content = "See [`spec.py`]() for details" # Multiple spec.py files doc_path = REPO_ROOT / "docs/test.md" - new_content, changes, errors = process_markdown( + _new_content, _changes, errors = process_markdown( content, REPO_ROOT, doc_path, @@ -223,7 +223,7 @@ def test_errors_on_not_found(self, file_index): content = "See [`nonexistent/file.py`]() for details" doc_path = REPO_ROOT / "docs/test.md" - new_content, changes, errors = process_markdown( + _new_content, _changes, errors = process_markdown( content, REPO_ROOT, doc_path, @@ -241,7 +241,7 @@ def test_github_mode(self, file_index): content = "See [`service/spec.py`]()" doc_path = REPO_ROOT / "docs/test.md" - new_content, changes, errors = process_markdown( + new_content, _changes, _errors = process_markdown( content, REPO_ROOT, doc_path, @@ -258,7 +258,7 @@ def test_relative_mode(self, file_index): content = "See [`service/spec.py`]()" doc_path = REPO_ROOT / "docs/concepts/test.md" - new_content, changes, errors = process_markdown( + new_content, _changes, _errors = process_markdown( content, REPO_ROOT, doc_path, @@ -313,7 +313,7 @@ def test_case_insensitive_lookup(self, file_index, doc_index): content = "See [CONFIGURATION](.md) for details" doc_path = REPO_ROOT / "docs/test.md" - new_content, changes, errors = process_markdown( + new_content, _changes, errors = process_markdown( content, REPO_ROOT, doc_path, @@ -333,7 +333,7 @@ def test_doc_link_github_mode(self, file_index, doc_index): content = "See [Configuration](.md)" doc_path = REPO_ROOT / "docs/test.md" - new_content, changes, errors = process_markdown( + new_content, _changes, _errors = process_markdown( content, REPO_ROOT, doc_path, @@ -352,7 +352,7 @@ def test_doc_link_relative_mode(self, file_index, doc_index): content = "See [Development](.md)" doc_path = REPO_ROOT / "docs/concepts/test.md" - new_content, changes, errors = process_markdown( + new_content, _changes, errors = process_markdown( content, REPO_ROOT, doc_path, @@ -372,7 +372,7 @@ def test_doc_not_found_error(self, file_index, doc_index): content = "See [NonexistentDoc](.md)" doc_path = REPO_ROOT / "docs/test.md" - new_content, changes, errors = process_markdown( + _new_content, _changes, errors = process_markdown( content, REPO_ROOT, doc_path, @@ -391,7 +391,7 @@ def test_skips_regular_links(self, file_index, doc_index): content = "See [regular link](https://example.com) here" doc_path = REPO_ROOT / "docs/test.md" - new_content, changes, errors = process_markdown( + new_content, _changes, _errors = process_markdown( content, REPO_ROOT, doc_path, diff --git a/dimos/web/dimos_interface/public/icon.png b/dimos/web/dimos_interface/public/icon.png index 2ade10a7c58bb1ca3efdda8461918ca8ba36d0e6..4b0b2f153a2278ec782d5a381af0522552940e72 100644 GIT binary patch literal 129 zcmWN?K@!3s3;@7;U%>|~BqbF38!|4YDp&*pozw&UsErBa$qM)hPl8)W`@xWrvuFIA#cv MdFt(341!;$AF!$?B>(^b literal 2147 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE8Azrw%`pX1Ea{HEjtmSN`?>!lvI6-E$sR$z z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$Ysfr0TvfKP}kPysU|%P1Ps zA@KkI|4I3mLFr*yNswPKFm8o3Eq#M|+ZdbJ zeP^p;t$t?xDZ$NO9qhl%cKo)BbJqOyROu^{y!(R8UIkcv@Oi&Oc9BNHMem%GHED|;cdb#|#0@|fGA4PuyG(zUC%qoX;VkfoEM{P2eh9*h<_qo% z0tMMiJbhi+pK^1H3UW2)+KK>E=xk3H$B>F!Z|A-Y7YdX(_FPFyYqe6>4yDD@*3L?5 z)19H47LrvRB{nPa=DwS0+h(Wb{n;yMY#=@}Wy^!|f;0W)lCN}ZCkUftZa z_GCuRt$oe+=dQ6Xe|zupolV=P#%JaRb8hxp{J{U@k+Q{#+iWGjcXn<_-fvu0W-Ru? zX2F^_78>pCe8HSQS`)m&?x@BZOITl$Js;J7>bm-|zN@QSj{6I6valAGKFRHipDeNZ z)x^V-_v#d8+lo#)d^x6~?S+9z>y#Hc^NT!Ry`H|QvG~;0{<+ib_1AI7R7}h=_7>#W z`r-N&zn8qbrW`JPyH{uJo}#JT%*|1P61$FZ$H@1!_A;*1)}9{5r`Bq|MJYk|5o=ys z^1~Av_rFftvPoA{=5klVb2EJdy;}3Xrk6FkYK!V&2oq2btamiPbGg zdGk=&vgS;yf79%W7yCskW-ZTIe16{PhuzaVWsMCa<8Ll&S-igE*J=)zxc!~-Df#!0 z8gEQ+vfEs>yiMrgZA;Cteq3K>C`6w`HLg(PhT&lx}~>vc4zB- z-Pd*+vzaD7oOpBR&YSWlzh9j9bN<}N-2VcCUd#>Qe95}#M@ZgH#qx~ig={P(%q%M! z8B7HhJhK-T&@OG*(dJ-av7l0_DVuY}KcD5gcX$OF7+4e-7&#mmn8;wBWoFD$aNvbn z!p3yPp+SlxfJDKwx0v$!pGE3U_#(b%n!#6lruF_7nb#UM0?wZM;ms1-=J}Fq{|1j( zMPsr51_8eMKVG${*7k3S7D^La;hl2mP|txcHlY`yPqU=W*c9~LL;Q6}&bNC-_HPxY zZQ=Z?@2%Xu>sjuV*h>e^?u*JxJ&h?0+Wn`jlU|?lnpnK{h zunI!bkei>9nO2EgL(KHT2A~E7kPXH8X(i=}MX3xKB_##LR{HvxxryniK%AMJt(RYv zzURE`T%cMBklK)p(%d8~E0_G_(%jU%5-Y0!pweOn!{z^X>!Ydh1es!G<&m11o>9VJ zYG~Bo8{7y~BaUPaR84qhN=XJtiA)Xi9iS3PBqhF?xv3?U1*r^RSLqkzrQ2@`^FuSo zHw37P!O+au%*@Qx*vMpC*XHj)CHzR{1ZP&IG8i~HO<8331}No#Bo!Lu$&i+rlM3{@ zeo1bDep+H#W^#UBv3_b=ijkR_iG`7&kx^P&qOpa6rKy>TSz3~rp`oFvf!P)z%YL9) N44$rjF6*2UngCKLn -diagram source +
Pikchr ```pikchr fold output=assets/alignment_overview.svg color = white @@ -22,59 +21,226 @@ arrow from Align.e right 0.4in Out: box "(image, pointcloud)" rad 5px fit wid 170% ht 170% ``` +
+ ![output](assets/alignment_overview.svg) - ## Basic Usage -```python session=align +Below we setup replay of real camera and lidar data from the Unitree Go2 robot, you can check if interested + +
+Stream Setup + +You can read more about [sensor storage here](storage_replay.md) and [LFS data store here](/docs/data.md) + +```python session=align no-result from reactivex import Subject +from dimos.utils.testing import TimedSensorReplay from dimos.types.timestamped import Timestamped, align_timestamped +from reactivex import operators as ops +import reactivex as rx + +# Load recorded Go2 sensor streams +video_replay = TimedSensorReplay("unitree_go2_bigoffice/video") +lidar_replay = TimedSensorReplay("unitree_go2_bigoffice/lidar") + +# this is a bit tricky, we find the first video frame timestamp, then add 2 seconds to it +seek_ts = video_replay.first_timestamp() + 2 + +# Lists to collect items as they flow through streams +video_frames = [] +lidar_scans = [] + +# We are using from_timestamp=... and not seek=... because seek seeks through recording +# timestamps, from_timestamp matches actual message timestamp. +# It's possible for sensor data to come in late, but with correct capture time timestamps +video_stream = video_replay.stream(from_timestamp=seek_ts, duration=2.0).pipe( + ops.do_action(lambda x: video_frames.append(x)) +) -# Create streams -camera = Subject() -lidar = Subject() +lidar_stream = lidar_replay.stream(from_timestamp=seek_ts, duration=2.0).pipe( + ops.do_action(lambda x: lidar_scans.append(x)) +) + +``` + + +
+ +Streams would normally come from an actual robot into your module via `IN` inputs, [`detection/module3D.py`](/dimos/perception/detection/module3D.py#L11) is a good example of this. + +Assume we have them, let's align them. -# Align camera frames with lidar scans +```python session=align +# Align video (primary) with lidar (secondary) # match_tolerance: max time difference for a match (seconds) # buffer_size: how long to keep messages waiting for matches (seconds) -aligned = align_timestamped( - camera, - lidar, - match_tolerance=0.1, - buffer_size=2.0, -) +aligned_pairs = align_timestamped( + video_stream, + lidar_stream, + match_tolerance=0.025, # 25ms tolerance + buffer_size=5.0, # how long to wait for match +).pipe(ops.to_list()).run() + +print(f"Video: {len(video_frames)} frames, Lidar: {len(lidar_scans)} scans") +print(f"Aligned pairs: {len(aligned_pairs)} out of {len(video_frames)} video frames") + +# Show a matched pair +if aligned_pairs: + img, pc = aligned_pairs[0] + dt = abs(img.ts - pc.ts) + print(f"\nFirst matched pair: Δ{dt*1000:.1f}ms") +``` -results = [] -aligned.subscribe(lambda pair: results.append(pair)) + +``` +Video: 29 frames, Lidar: 15 scans +Aligned pairs: 11 out of 29 video frames -# Helper to create timestamped messages -class Msg(Timestamped): - def __init__(self, ts: float, data: str): - super().__init__(ts) - self.data = data +First matched pair: Δ11.3ms +``` -# Emit some data -camera.on_next(Msg(1.0, "frame_1")) -camera.on_next(Msg(2.0, "frame_2")) +
+Visualization helper + +```python session=align fold no-result +import matplotlib +import matplotlib.pyplot as plt + +def plot_alignment_timeline(video_frames, lidar_scans, aligned_pairs, path): + """Single timeline: video above axis, lidar below, green lines for matches.""" + matplotlib.use('Agg') + plt.style.use('dark_background') + + # Get base timestamp for relative times (frames have .ts attribute) + base_ts = video_frames[0].ts + video_ts = [f.ts - base_ts for f in video_frames] + lidar_ts = [s.ts - base_ts for s in lidar_scans] + + # Find matched timestamps + matched_video_ts = set(img.ts for img, _ in aligned_pairs) + matched_lidar_ts = set(pc.ts for _, pc in aligned_pairs) + + fig, ax = plt.subplots(figsize=(12, 2.5)) + + # Video markers above axis (y=0.3) - circles, cyan when matched + for frame in video_frames: + rel_ts = frame.ts - base_ts + matched = frame.ts in matched_video_ts + ax.plot(rel_ts, 0.3, 'o', color='cyan' if matched else '#688', markersize=8) + + # Lidar markers below axis (y=-0.3) - squares, orange when matched + for scan in lidar_scans: + rel_ts = scan.ts - base_ts + matched = scan.ts in matched_lidar_ts + ax.plot(rel_ts, -0.3, 's', color='orange' if matched else '#a86', markersize=8) + + # Green lines connecting matched pairs + for img, pc in aligned_pairs: + img_rel = img.ts - base_ts + pc_rel = pc.ts - base_ts + ax.plot([img_rel, pc_rel], [0.3, -0.3], '-', color='lime', alpha=0.6, linewidth=1) + + # Axis styling + ax.axhline(y=0, color='white', linewidth=0.5, alpha=0.3) + ax.set_xlim(-0.1, max(video_ts + lidar_ts) + 0.1) + ax.set_ylim(-0.6, 0.6) + ax.set_xlabel('Time (s)') + ax.set_yticks([0.3, -0.3]) + ax.set_yticklabels(['Video', 'Lidar']) + ax.set_title(f'{len(aligned_pairs)} matched from {len(video_frames)} video + {len(lidar_scans)} lidar') + plt.tight_layout() + plt.savefig(path, transparent=True) + plt.close() +``` -# Lidar arrives - matches frame_1 (within 0.05s tolerance) -lidar.on_next(Msg(1.05, "scan_1")) -print(f"matched: {results[-1][0].data} <-> {results[-1][1].data}") +
+ +```python session=align output=assets/alignment_timeline.png +plot_alignment_timeline(video_frames, lidar_scans, aligned_pairs, '{output}') +``` + + +![output](assets/alignment_timeline.png) + +if we loosen up our match tolerance we might get multiple pairs matching the same lidar frame -# Lidar arrives - matches frame_2 -lidar.on_next(Msg(1.98, "scan_2")) -print(f"matched: {results[-1][0].data} <-> {results[-1][1].data}") +```python session=align +aligned_pairs = align_timestamped( + video_stream, + lidar_stream, + match_tolerance=0.05, # 50ms tolerance + buffer_size=5.0, # how long to wait for match +).pipe(ops.to_list()).run() + +print(f"Video: {len(video_frames)} frames, Lidar: {len(lidar_scans)} scans") +print(f"Aligned pairs: {len(aligned_pairs)} out of {len(video_frames)} video frames") ``` ``` -matched: frame_1 <-> scan_1 -matched: frame_2 <-> scan_2 +Video: 58 frames, Lidar: 30 scans +Aligned pairs: 23 out of 58 video frames +``` + + +```python session=align output=assets/alignment_timeline2.png +plot_alignment_timeline(video_frames, lidar_scans, aligned_pairs, '{output}') +``` + + +![output](assets/alignment_timeline2.png) + +## We can combine frame alignment with a quality filter + +more on [quality filtering here](quality_filter.md) + +```python session=align +from dimos.msgs.sensor_msgs.Image import Image, sharpness_barrier + +# Lists to collect items as they flow through streams +video_frames = [] +lidar_scans = [] + +video_stream = video_replay.stream(from_timestamp=seek_ts, duration=2.0).pipe( + sharpness_barrier(3.0), + ops.do_action(lambda x: video_frames.append(x)) +) + +lidar_stream = lidar_replay.stream(from_timestamp=seek_ts, duration=2.0).pipe( + ops.do_action(lambda x: lidar_scans.append(x)) +) + +aligned_pairs = align_timestamped( + video_stream, + lidar_stream, + match_tolerance=0.025, # 25ms tolerance + buffer_size=5.0, # how long to wait for match +).pipe(ops.to_list()).run() + +print(f"Video: {len(video_frames)} frames, Lidar: {len(lidar_scans)} scans") +print(f"Aligned pairs: {len(aligned_pairs)} out of {len(video_frames)} video frames") + +``` + + +``` +Video: 6 frames, Lidar: 15 scans +Aligned pairs: 1 out of 6 video frames +``` + +```python session=align output=assets/alignment_timeline3.png +plot_alignment_timeline(video_frames, lidar_scans, aligned_pairs, '{output}') ``` + +![output](assets/alignment_timeline3.png) + +We are very picky but data is high quality. best frame, with closest lidar match in this window. + ## How It Works The primary stream (first argument) drives emissions. When a primary message arrives: @@ -107,11 +273,11 @@ Buffer: box "Buffer" "primary" rad 5px fit wid 170% ht 170% text "waiting..." at (Buffer.w.x - 0.4in, Buffer.w.y - 0.15in) ``` + + ![output](assets/alignment_flow.svg) - - ## Parameters | Parameter | Type | Default | Description | @@ -121,90 +287,7 @@ text "waiting..." at (Buffer.w.x - 0.4in, Buffer.w.y - 0.15in) | `match_tolerance` | `float` | 0.1 | Max time difference for a match (seconds) | | `buffer_size` | `float` | 1.0 | How long to buffer unmatched messages (seconds) | -## Multiple Secondary Streams - -Align a primary with multiple secondaries - the result tuple contains all matched messages: - -```python session=align -# New streams -camera2 = Subject() -lidar2 = Subject() -imu = Subject() - -aligned_multi = align_timestamped( - camera2, - lidar2, - imu, - match_tolerance=0.05, - buffer_size=1.0, -) - -multi_results = [] -aligned_multi.subscribe(lambda x: multi_results.append(x)) - -# All three must arrive within tolerance -camera2.on_next(Msg(1.0, "frame")) -lidar2.on_next(Msg(1.02, "scan")) -# Still waiting for IMU... -print(f"results so far: {len(multi_results)}") - -imu.on_next(Msg(1.03, "imu_reading")) -print(f"after IMU: {len(multi_results)}") -print(f"matched: ({multi_results[0][0].data}, {multi_results[0][1].data}, {multi_results[0][2].data})") -``` - - -``` -results so far: 0 -after IMU: 1 -matched: (frame, scan, imu_reading) -``` - -## With Backpressure - -In practice, you often combine alignment with [`backpressure`](/docs/api/sensor_streams/advanced_streams.md) for slow processors: - -```python session=align -from dimos.utils.reactive import backpressure -from reactivex.scheduler import ThreadPoolScheduler -from reactivex import operators as ops -import time -scheduler = ThreadPoolScheduler(max_workers=2) - -# Simulated streams -fast_camera = Subject() -fast_lidar = Subject() - -# Slow processing that needs the latest aligned pair -def slow_process(pair): - frame, scan = pair - time.sleep(0.1) # Simulate slow ML inference - return f"processed_{frame.data}" - -# backpressure ensures slow_process gets latest pair, not queued old ones -processed = backpressure( - align_timestamped(fast_camera, fast_lidar, match_tolerance=0.1), - scheduler=scheduler -).pipe(ops.map(slow_process)) - -slow_results = [] -processed.subscribe(lambda x: slow_results.append(x)) - -# Rapid emissions -for i in range(5): - fast_camera.on_next(Msg(float(i), f"f{i}")) - fast_lidar.on_next(Msg(float(i) + 0.01, f"s{i}")) - -time.sleep(0.5) -print(f"processed {len(slow_results)} pairs (skipped {5 - len(slow_results)})") -scheduler.executor.shutdown(wait=True) -``` - - -``` -processed 2 pairs (skipped 3) -``` ## Usage in Modules @@ -228,57 +311,3 @@ class Detection3DModule(Detection2DModule): ``` The 2D detection stream (camera + ML model) is the primary, matched with raw pointcloud data from lidar. The longer `buffer_size=20.0` accounts for variable ML inference times. - -## Edge Cases - -### Unmatched Messages - -Messages that can't be matched within tolerance are dropped: - -```python session=align -camera3 = Subject() -lidar3 = Subject() - -dropped = align_timestamped(camera3, lidar3, match_tolerance=0.05, buffer_size=1.0) - -drop_results = [] -dropped.subscribe(lambda x: drop_results.append(x)) - -# These won't match - timestamps too far apart -camera3.on_next(Msg(1.0, "frame")) -lidar3.on_next(Msg(1.2, "scan")) # 0.2s diff > 0.05s tolerance - -print(f"matches: {len(drop_results)}") -``` - - -``` -matches: 0 -``` - -### Buffer Expiry - -Old buffered primaries are cleaned up when secondaries progress past them: - -```python session=align -camera4 = Subject() -lidar4 = Subject() - -expired = align_timestamped(camera4, lidar4, match_tolerance=0.05, buffer_size=0.5) - -exp_results = [] -expired.subscribe(lambda x: exp_results.append(x)) - -# Primary at t=1.0 waiting for secondary -camera4.on_next(Msg(1.0, "old_frame")) - -# Secondary arrives much later - primary is no longer matchable -lidar4.on_next(Msg(2.0, "late_scan")) - -print(f"matches: {len(exp_results)}") # old_frame expired -``` - - -``` -matches: 0 -``` diff --git a/examples/language-interop/README.md b/examples/language-interop/README.md new file mode 100644 index 0000000000..52ae561ddb --- /dev/null +++ b/examples/language-interop/README.md @@ -0,0 +1,20 @@ +# Language Interop Examples + +Demonstrates controlling a dimos robot from non-Python languages. + +## Usage + +1. Start the robot (in another terminal): + ```bash + cd ../simplerobot + python simplerobot.py + ``` + +2. Run any language example: + - [TypeScript](ts/) - CLI and browser-based web UI + - [C++](cpp/) + - [Lua](lua/) + +3. (Optional) Monitor traffic with `lcmspy` + +![lcmspy](assets/lcmspy.png) diff --git a/examples/language-interop/assets/lcmspy.png b/examples/language-interop/assets/lcmspy.png new file mode 100644 index 0000000000..dc6a824f69 --- /dev/null +++ b/examples/language-interop/assets/lcmspy.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:87d3af9d9105d048c3e55faff52981c15cc1bfd8168c58a3d8c8f603aa8b7769 +size 5110 diff --git a/examples/language-interop/cpp/.gitignore b/examples/language-interop/cpp/.gitignore new file mode 100644 index 0000000000..567609b123 --- /dev/null +++ b/examples/language-interop/cpp/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/examples/language-interop/cpp/CMakeLists.txt b/examples/language-interop/cpp/CMakeLists.txt new file mode 100644 index 0000000000..a0b8481cef --- /dev/null +++ b/examples/language-interop/cpp/CMakeLists.txt @@ -0,0 +1,28 @@ +cmake_minimum_required(VERSION 3.14) +project(robot_control) + +set(CMAKE_CXX_STANDARD 17) + +include(FetchContent) + +# Fetch dimos-lcm for message headers +FetchContent_Declare(dimos_lcm + GIT_REPOSITORY https://github.com/dimensionalOS/dimos-lcm.git + GIT_TAG main + GIT_SHALLOW TRUE +) +FetchContent_MakeAvailable(dimos_lcm) + +# Find LCM via pkg-config +find_package(PkgConfig REQUIRED) +pkg_check_modules(LCM REQUIRED lcm) + +add_executable(robot_control main.cpp) + +target_include_directories(robot_control PRIVATE + ${dimos_lcm_SOURCE_DIR}/generated/cpp_lcm_msgs + ${LCM_INCLUDE_DIRS} +) + +target_link_libraries(robot_control PRIVATE ${LCM_LIBRARIES}) +target_link_directories(robot_control PRIVATE ${LCM_LIBRARY_DIRS}) diff --git a/examples/language-interop/cpp/README.md b/examples/language-interop/cpp/README.md new file mode 100644 index 0000000000..ea11cd4505 --- /dev/null +++ b/examples/language-interop/cpp/README.md @@ -0,0 +1,17 @@ +# C++ Robot Control Example + +Subscribes to `/odom` and publishes velocity commands to `/cmd_vel`. + +## Build + +```bash +mkdir build && cd build +cmake .. +make +./robot_control +``` + +## Dependencies + +- [lcm](https://lcm-proj.github.io/) - install via package manager +- Message headers fetched automatically from [dimos-lcm](https://github.com/dimensionalOS/dimos-lcm) diff --git a/examples/language-interop/cpp/main.cpp b/examples/language-interop/cpp/main.cpp new file mode 100644 index 0000000000..fef2607365 --- /dev/null +++ b/examples/language-interop/cpp/main.cpp @@ -0,0 +1,68 @@ +// C++ robot control example +// Subscribes to robot pose and publishes twist commands + +#include +#include +#include +#include +#include +#include + +#include "geometry_msgs/PoseStamped.hpp" +#include "geometry_msgs/Twist.hpp" + +class RobotController { +public: + RobotController() : lcm_(), running_(true) {} + + void onPose(const lcm::ReceiveBuffer*, const std::string&, + const geometry_msgs::PoseStamped* msg) { + const auto& pos = msg->pose.position; + const auto& ori = msg->pose.orientation; + printf("[pose] x=%.2f y=%.2f z=%.2f | qw=%.2f\n", + pos.x, pos.y, pos.z, ori.w); + } + + void run() { + lcm_.subscribe("/odom#geometry_msgs.PoseStamped", &RobotController::onPose, this); + + printf("Robot control started\n"); + printf("Subscribing to /odom, publishing to /cmd_vel\n"); + printf("Press Ctrl+C to stop.\n\n"); + + // Publisher thread + std::thread pub_thread([this]() { + double t = 0; + while (running_) { + geometry_msgs::Twist twist; + twist.linear.x = 0.5; + twist.linear.y = 0; + twist.linear.z = 0; + twist.angular.x = 0; + twist.angular.y = 0; + twist.angular.z = std::sin(t) * 0.3; + + lcm_.publish("/cmd_vel#geometry_msgs.Twist", &twist); + printf("[twist] linear=%.2f angular=%.2f\n", twist.linear.x, twist.angular.z); + t += 0.1; + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + }); + + // Handle incoming messages + while (lcm_.handle() == 0) {} + + running_ = false; + pub_thread.join(); + } + +private: + lcm::LCM lcm_; + std::atomic running_; +}; + +int main() { + RobotController controller; + controller.run(); + return 0; +} diff --git a/examples/language-interop/lua/.gitignore b/examples/language-interop/lua/.gitignore new file mode 100644 index 0000000000..f87dd2d125 --- /dev/null +++ b/examples/language-interop/lua/.gitignore @@ -0,0 +1,3 @@ +lcm/ +dimos-lcm/ +msgs/ diff --git a/examples/language-interop/lua/README.md b/examples/language-interop/lua/README.md new file mode 100644 index 0000000000..b804194e55 --- /dev/null +++ b/examples/language-interop/lua/README.md @@ -0,0 +1,38 @@ +# Lua Robot Control Example + +Subscribes to robot odometry and publishes twist commands using LCM. + +## Prerequisites + +- Lua 5.4 +- LuaSocket (`sudo luarocks install luasocket`) +- System dependencies: `glib`, `cmake` + +## Setup + +```bash +./setup.sh +``` + +This will: +1. Clone and build official [LCM](https://github.com/lcm-proj/lcm) Lua bindings +2. Clone [dimos-lcm](https://github.com/dimensionalOS/dimos-lcm) message definitions + +## Run + +```bash +lua main.lua +``` + +## Output + +``` +Robot control started +Subscribing to /odom, publishing to /cmd_vel +Press Ctrl+C to stop. + +[pose] x=15.29 y=9.62 z=0.00 | qw=0.57 +[twist] linear=0.50 angular=0.00 +[pose] x=15.28 y=9.63 z=0.00 | qw=0.57 +... +``` diff --git a/examples/language-interop/lua/main.lua b/examples/language-interop/lua/main.lua new file mode 100644 index 0000000000..f6d2dccca1 --- /dev/null +++ b/examples/language-interop/lua/main.lua @@ -0,0 +1,59 @@ +#!/usr/bin/env lua + +-- Lua robot control example +-- Subscribes to robot pose and publishes twist commands + +-- Add local msgs folder to path +local script_dir = arg[0]:match("(.*/)") or "./" +package.path = script_dir .. "msgs/?.lua;" .. package.path +package.path = script_dir .. "msgs/?/init.lua;" .. package.path + +local lcm = require("lcm") +local PoseStamped = require("geometry_msgs.PoseStamped") +local Twist = require("geometry_msgs.Twist") +local Vector3 = require("geometry_msgs.Vector3") + +local lc = lcm.lcm.new() + +print("Robot control started") +print("Subscribing to /odom, publishing to /cmd_vel") +print("Press Ctrl+C to stop.\n") + +-- Subscribe to pose +lc:subscribe("/odom#geometry_msgs.PoseStamped", function(channel, msg) + msg = PoseStamped.decode(msg) + local pos = msg.pose.position + local ori = msg.pose.orientation + print(string.format("[pose] x=%.2f y=%.2f z=%.2f | qw=%.2f", + pos.x, pos.y, pos.z, ori.w)) +end) + +-- Publisher loop +local t = 0 +local socket = require("socket") +local last_pub = socket.gettime() + +while true do + -- Handle incoming messages + lc:handle() + + -- Publish at ~10 Hz + local now = socket.gettime() + if now - last_pub >= 0.1 then + local twist = Twist:new() + twist.linear = Vector3:new() + twist.linear.x = 0.5 + twist.linear.y = 0 + twist.linear.z = 0 + twist.angular = Vector3:new() + twist.angular.x = 0 + twist.angular.y = 0 + twist.angular.z = math.sin(t) * 0.3 + + lc:publish("/cmd_vel#geometry_msgs.Twist", twist:encode()) + print(string.format("[twist] linear=%.2f angular=%.2f", twist.linear.x, twist.angular.z)) + + t = t + 0.1 + last_pub = now + end +end diff --git a/examples/language-interop/lua/setup.sh b/examples/language-interop/lua/setup.sh new file mode 100755 index 0000000000..682d676183 --- /dev/null +++ b/examples/language-interop/lua/setup.sh @@ -0,0 +1,100 @@ +#!/bin/bash +# Setup script for LCM Lua bindings +# Clones official LCM repo and builds Lua bindings +# +# Tested on: Arch Linux, Ubuntu, macOS (with Homebrew) + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +LCM_DIR="$SCRIPT_DIR/lcm" + +echo "=== LCM Lua Setup ===" + +# Detect Lua version +if command -v lua &>/dev/null; then + LUA_VERSION=$(lua -v 2>&1 | grep -oE '[0-9]+\.[0-9]+' | head -1) + echo "Detected Lua version: $LUA_VERSION" +else + echo "Error: lua not found in PATH" + exit 1 +fi + +# Detect Lua paths using pkg-config if available +if command -v pkg-config &>/dev/null && pkg-config --exists "lua$LUA_VERSION" 2>/dev/null; then + LUA_INCLUDE_DIR=$(pkg-config --variable=includedir "lua$LUA_VERSION") + LUA_LIBRARY=$(pkg-config --libs "lua$LUA_VERSION" | grep -oE '/[^ ]+\.so' | head -1 || echo "") +elif command -v pkg-config &>/dev/null && pkg-config --exists lua 2>/dev/null; then + LUA_INCLUDE_DIR=$(pkg-config --variable=includedir lua) + LUA_LIBRARY=$(pkg-config --libs lua | grep -oE '/[^ ]+\.so' | head -1 || echo "") +fi + +# Platform-specific defaults +if [[ "$OSTYPE" == "darwin"* ]]; then + # macOS with Homebrew + LUA_INCLUDE_DIR="${LUA_INCLUDE_DIR:-$(brew --prefix lua 2>/dev/null)/include/lua}" + LUA_LIBRARY="${LUA_LIBRARY:-$(brew --prefix lua 2>/dev/null)/lib/liblua.dylib}" + LUA_CPATH_BASE="${LUA_CPATH_BASE:-/usr/local/lib/lua}" +else + # Linux defaults + LUA_INCLUDE_DIR="${LUA_INCLUDE_DIR:-/usr/include}" + LUA_LIBRARY="${LUA_LIBRARY:-/usr/lib/liblua.so}" + LUA_CPATH_BASE="${LUA_CPATH_BASE:-/usr/local/lib/lua}" +fi + +echo "Lua include: $LUA_INCLUDE_DIR" +echo "Lua library: $LUA_LIBRARY" + +# Clone LCM if not present +if [ ! -d "$LCM_DIR" ]; then + echo "Cloning LCM..." + git clone --depth 1 https://github.com/lcm-proj/lcm.git "$LCM_DIR" +else + echo "LCM already cloned" +fi + +# Build Lua bindings using cmake +echo "Building LCM Lua bindings..." +cd "$LCM_DIR" +mkdir -p build && cd build + +# Configure with Lua support +cmake .. \ + -DLCM_ENABLE_LUA=ON \ + -DLCM_ENABLE_PYTHON=OFF \ + -DLCM_ENABLE_JAVA=OFF \ + -DLCM_ENABLE_TESTS=OFF \ + -DLCM_ENABLE_EXAMPLES=OFF \ + -DLUA_INCLUDE_DIR="$LUA_INCLUDE_DIR" \ + -DLUA_LIBRARY="$LUA_LIBRARY" + +# Build just the lua target +make lcm-lua -j$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4) + +# Install the lua module +LUA_CPATH_DIR="$LUA_CPATH_BASE/$LUA_VERSION" +echo "Installing lcm.so to $LUA_CPATH_DIR" +sudo mkdir -p "$LUA_CPATH_DIR" +sudo cp lcm-lua/lcm.so "$LUA_CPATH_DIR/" + +# Get dimos-lcm message definitions +DIMOS_LCM_DIR="$SCRIPT_DIR/dimos-lcm" +MSGS_DST="$SCRIPT_DIR/msgs" + +echo "Getting message definitions..." +if [ -d "$DIMOS_LCM_DIR" ]; then + echo "Updating dimos-lcm..." + cd "$DIMOS_LCM_DIR" && git pull +else + echo "Cloning dimos-lcm..." + git clone --depth 1 https://github.com/dimensionalOS/dimos-lcm.git "$DIMOS_LCM_DIR" +fi + +# Link/copy messages +rm -rf "$MSGS_DST" +cp -r "$DIMOS_LCM_DIR/generated/lua_lcm_msgs" "$MSGS_DST" +echo "Messages installed to $MSGS_DST" + +echo "" +echo "=== Setup complete ===" +echo "Run: lua main.lua" diff --git a/examples/language-interop/ts/README.md b/examples/language-interop/ts/README.md new file mode 100644 index 0000000000..373d7df3be --- /dev/null +++ b/examples/language-interop/ts/README.md @@ -0,0 +1,34 @@ +# TypeScript Robot Control Examples + +Subscribes to `/odom` and publishes velocity commands to `/cmd_vel`. + +## CLI Example + +```bash +deno task start +``` + +## Web Example + +Browser-based control with WebSocket bridge: + +```bash +cd web +deno run --allow-net --allow-read --unstable-net server.ts +``` + +Open http://localhost:8080 in your browser. + +Features: +- Real-time pose display +- Arrow keys / WASD for control +- Click buttons to send twist commands + +The browser imports `@dimos/msgs` via [esm.sh](https://esm.sh) and encodes/decodes LCM packets directly - the server just forwards raw binary between WebSocket and UDP multicast. + +## Dependencies + +Main documentation for TS interop: + +- [@dimos/lcm](https://jsr.io/@dimos/lcm) +- [@dimos/msgs](https://jsr.io/@dimos/msgs) diff --git a/examples/language-interop/ts/deno.json b/examples/language-interop/ts/deno.json new file mode 100644 index 0000000000..b64363a64a --- /dev/null +++ b/examples/language-interop/ts/deno.json @@ -0,0 +1,9 @@ +{ + "imports": { + "@dimos/lcm": "jsr:@dimos/lcm", + "@dimos/msgs": "jsr:@dimos/msgs" + }, + "tasks": { + "start": "deno run --allow-net --unstable-net main.ts" + } +} diff --git a/examples/language-interop/ts/deno.lock b/examples/language-interop/ts/deno.lock new file mode 100644 index 0000000000..9529ebdb34 --- /dev/null +++ b/examples/language-interop/ts/deno.lock @@ -0,0 +1,21 @@ +{ + "version": "5", + "specifiers": { + "jsr:@dimos/lcm@*": "0.2.0", + "jsr:@dimos/msgs@*": "0.1.4" + }, + "jsr": { + "@dimos/lcm@0.2.0": { + "integrity": "03399f5e4800f28a0c294981e0210d784232fc65a57707de19052ad805bd5fea" + }, + "@dimos/msgs@0.1.4": { + "integrity": "564bc30b4bc41a562c296c257a15055283ca0cbd66d0627991ede5295832d0c4" + } + }, + "workspace": { + "dependencies": [ + "jsr:@dimos/lcm@*", + "jsr:@dimos/msgs@*" + ] + } +} diff --git a/examples/language-interop/ts/main.ts b/examples/language-interop/ts/main.ts new file mode 100644 index 0000000000..9026304f2c --- /dev/null +++ b/examples/language-interop/ts/main.ts @@ -0,0 +1,43 @@ +#!/usr/bin/env -S deno run --allow-net --unstable-net + +// TypeScript robot control example +// Subscribes to robot odometry and publishes twist commands + +import { LCM } from "@dimos/lcm"; +import { geometry_msgs } from "@dimos/msgs"; + +const lcm = new LCM(); +await lcm.start(); + +console.log("Robot control started"); +console.log("Subscribing to /odom, publishing to /cmd_vel"); +console.log("Press Ctrl+C to stop.\n"); + +// Subscribe to pose - prints robot position +lcm.subscribe("/odom", geometry_msgs.PoseStamped, (msg) => { + const pos = msg.data.pose.position; + const ori = msg.data.pose.orientation; + console.log( + `[pose] x=${pos.x.toFixed(2)} y=${pos.y.toFixed(2)} z=${pos.z.toFixed(2)} | qw=${ori.w.toFixed(2)}` + ); +}); + +// Publish twist commands at 10 Hz - simple forward motion +let t = 0; +const interval = setInterval(async () => { + if (!lcm.isRunning()) { + clearInterval(interval); + return; + } + + const twist = new geometry_msgs.Twist({ + linear: new geometry_msgs.Vector3({ x: 0.5, y: 0, z: 0 }), + angular: new geometry_msgs.Vector3({ x: 0, y: 0, z: Math.sin(t) * 0.3 }), + }); + + await lcm.publish("/cmd_vel", twist); + console.log(`[twist] linear=${twist.linear.x.toFixed(2)} angular=${twist.angular.z.toFixed(2)}`); + t += 0.1; +}, 100); + +await lcm.run(); diff --git a/examples/language-interop/ts/web/index.html b/examples/language-interop/ts/web/index.html new file mode 100644 index 0000000000..1ce89604b6 --- /dev/null +++ b/examples/language-interop/ts/web/index.html @@ -0,0 +1,213 @@ + + + + + + + Robot Control + + + +

Robot Control

+
Connecting...
+ +
+
+

Pose

+
X--
+
Y--
+
Z--
+
Qw--
+
+ +
+

Controls

+
+
+ +
+ + + +
+ +
+
+
+ +
+

Log

+
+
+ + + + diff --git a/examples/language-interop/ts/web/server.ts b/examples/language-interop/ts/web/server.ts new file mode 100644 index 0000000000..5b8ec035be --- /dev/null +++ b/examples/language-interop/ts/web/server.ts @@ -0,0 +1,69 @@ +#!/usr/bin/env -S deno run --allow-net --allow-read --unstable-net + +// LCM to WebSocket Bridge for Robot Control +// Forwards robot pose to browser, receives twist commands from browser + +import { LCM } from "jsr:@dimos/lcm"; +import { decodePacket, geometry_msgs } from "jsr:@dimos/msgs"; + +const PORT = 8080; +const clients = new Set(); + +Deno.serve({ port: PORT }, async (req) => { + const url = new URL(req.url); + + if (req.headers.get("upgrade") === "websocket") { + const { socket, response } = Deno.upgradeWebSocket(req); + socket.onopen = () => { console.log("Client connected"); clients.add(socket); }; + socket.onclose = () => { console.log("Client disconnected"); clients.delete(socket); }; + socket.onerror = () => clients.delete(socket); + + // Forward binary LCM packets from browser directly to UDP + socket.binaryType = "arraybuffer"; + socket.onmessage = async (event) => { + if (event.data instanceof ArrayBuffer) { + const packet = new Uint8Array(event.data); + try { + // we don't need to decode, just showing we can + const { channel, data } = decodePacket(packet); + console.log(`[ws->lcm] ${channel}`, data); + await lcm.publishPacket(packet); + } catch (e) { + console.error("Forward error:", e); + } + } + }; + + return response; + } + + if (url.pathname === "/" || url.pathname === "/index.html") { + const html = await Deno.readTextFile(new URL("./index.html", import.meta.url)); + return new Response(html, { headers: { "content-type": "text/html" } }); + } + + return new Response("Not found", { status: 404 }); +}); + +console.log(`Server: http://localhost:${PORT}`); + +const lcm = new LCM(); +await lcm.start(); + +// Subscribe to pose and just log to show how server can decode messages for itself +lcm.subscribe("/odom", geometry_msgs.PoseStamped, (msg) => { + const pos = msg.data.pose.position; + const ori = msg.data.pose.orientation; + console.log(`[pose] x=${pos.x.toFixed(2)} y=${pos.y.toFixed(2)} z=${pos.z.toFixed(2)}`); +}); + +// Forward all raw packets to browser (we are decoding LCM directly in the browser) +lcm.subscribePacket((packet) => { + for (const client of clients) { + if (client.readyState === WebSocket.OPEN) { + client.send(packet); + } + } +}); + +await lcm.run(); diff --git a/examples/simplerobot/README.md b/examples/simplerobot/README.md new file mode 100644 index 0000000000..bc6686f77c --- /dev/null +++ b/examples/simplerobot/README.md @@ -0,0 +1,50 @@ +# SimpleRobot + +A minimal virtual robot for testing and development. It implements some of the same LCM interface as real robots, making it ideal for testing third-party integrations (see `examples/language-interop/`) or experimeting with dimos Module patterns + +## Interface + +| Topic | Type | Direction | Description | +|------------|---------------|-----------|-----------------------------------------| +| `/cmd_vel` | `Twist` | Subscribe | Velocity commands (linear.x, angular.z) | +| `/odom` | `PoseStamped` | Publish | Current pose at 30Hz | + +Physical robots typically publish multiple poses in a relationship as `TransformStamped` in a TF tree, while SimpleRobot publishes `PoseStamped` directly for simplicity. + +For details on this check [Transforms](/docs/api/transforms.md) + +## Usage + +```bash +# With pygame visualization +python examples/simplerobot/simplerobot.py + +# Headless mode +python examples/simplerobot/simplerobot.py --headless + +# Run self-test demo +python examples/simplerobot/simplerobot.py --headless --selftest +``` + +Use `lcmspy` in another terminal to inspect messages. Press `q` or `Esc` to quit visualization. + +## Sending Commands + +From any language with LCM bindings, publish `Twist` messages to `/cmd_vel`: + +```python +from dimos.core import LCMTransport +from dimos.msgs.geometry_msgs import Twist + +transport = LCMTransport("/cmd_vel", Twist) +transport.publish(Twist(linear=(0.5, 0, 0), angular=(0, 0, 0.3))) +``` + +See `examples/language-interop/` for C++, TypeScript, and Lua examples. + +## Physics + +SimpleRobot uses a 2D unicycle model: +- `linear.x` drives forward/backward +- `angular.z` rotates left/right +- Commands timeout after 0.5s (robot stops if no new commands) diff --git a/examples/simplerobot/simplerobot.py b/examples/simplerobot/simplerobot.py new file mode 100644 index 0000000000..b959fa7d6f --- /dev/null +++ b/examples/simplerobot/simplerobot.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# 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. + +# Copyright 2026 Dimensional Inc. +# SPDX-License-Identifier: Apache-2.0 + +""" +Simple virtual robot demonstrating a dimos Module with In/Out ports. + +Subscribes to Twist commands and publishes PoseStamped. +""" + +from dataclasses import dataclass +import math +import time +from typing import Any + +import reactivex as rx + +from dimos.core import In, Module, ModuleConfig, Out, rpc +from dimos.msgs.geometry_msgs import Pose, PoseStamped, Quaternion, Twist, Vector3 + + +def apply_twist(pose: Pose, twist: Twist, dt: float) -> Pose: + """Apply a velocity command to a pose (unicycle model).""" + yaw = pose.yaw + twist.angular.z * dt + return Pose( + position=( + pose.x + twist.linear.x * math.cos(yaw) * dt, + pose.y + twist.linear.x * math.sin(yaw) * dt, + pose.z, + ), + orientation=Quaternion.from_euler(Vector3(0, 0, yaw)), + ) + + +@dataclass +class SimpleRobotConfig(ModuleConfig): + frame_id: str = "world" + update_rate: float = 30.0 + cmd_timeout: float = 0.5 + + +class SimpleRobot(Module[SimpleRobotConfig]): + """A 2D robot that integrates velocity commands into pose.""" + + cmd_vel: In[Twist] + pose: Out[PoseStamped] + default_config = SimpleRobotConfig + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._pose = Pose() + self._vel = Twist() + self._vel_time = 0.0 + + @rpc + def start(self) -> None: + self._disposables.add(self.cmd_vel.observable().subscribe(self._on_twist)) + self._disposables.add( + rx.interval(1.0 / self.config.update_rate).subscribe(lambda _: self._update()) + ) + self._disposables.add( + rx.interval(1.0).subscribe(lambda _: print(f"\033[34m{self._pose}\033[0m")) + ) + + def _on_twist(self, twist: Twist) -> None: + self._vel = twist + self._vel_time = time.time() + print(f"\033[32m{twist}\033[0m") + + def _update(self) -> None: + now = time.time() + dt = 1.0 / self.config.update_rate + + vel = self._vel if now - self._vel_time < self.config.cmd_timeout else Twist() + + self._pose = apply_twist(self._pose, vel, dt) + + self.pose.publish( + PoseStamped( + ts=now, + frame_id=self.config.frame_id, + position=self._pose.position, + orientation=self._pose.orientation, + ) + ) + + +if __name__ == "__main__": + import argparse + + from dimos.core import LCMTransport + + parser = argparse.ArgumentParser(description="Simple virtual robot") + parser.add_argument("--headless", action="store_true") + parser.add_argument("--selftest", action="store_true", help="Run demo movements") + args = parser.parse_args() + + # If running in a dimos cluster we'd call + # + # from dimos.core import start + # dimos = start() + # robot = dimos.deploy(SimpleRobot) + # + # but this is a standalone example + # and we don't mind running in the main thread + + robot = SimpleRobot() + robot.pose.transport = LCMTransport("/odom", PoseStamped) + robot.cmd_vel.transport = LCMTransport("/cmd_vel", Twist) + robot.start() + + if not args.headless: + from vis import start_visualization + + start_visualization(robot) + + print("Robot running.") + print(" Publishing: /odom (PoseStamped)") + print(" Subscribing: /cmd_vel (Twist)") + print(" Run 'lcmspy' in another terminal to see LCM messages") + print(" Check /examples/language-interop for sending commands from LUA, C++, TS etc.") + print(" Ctrl+C to exit") + + try: + if args.selftest: + time.sleep(1) + print("Forward...") + for _ in range(8): + robot._on_twist(Twist(linear=(1.0, 0, 0))) + time.sleep(0.25) + print("Turn...") + for _ in range(12): + robot._on_twist(Twist(linear=(0.5, 0, 0), angular=(0, 0, 0.5))) + time.sleep(0.25) + print("Stop") + robot._on_twist(Twist()) + time.sleep(1) + else: + while True: + time.sleep(1) + except KeyboardInterrupt: + print("\nStopping...") + finally: + robot.stop() diff --git a/examples/simplerobot/vis.py b/examples/simplerobot/vis.py new file mode 100644 index 0000000000..951a6be1b9 --- /dev/null +++ b/examples/simplerobot/vis.py @@ -0,0 +1,95 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# 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. + +"""Pygame visualization for SimpleRobot.""" + +import math +import threading + + +def run_visualization(robot, window_size=(800, 800), meters_per_pixel=0.02): + """Run pygame visualization for a robot. Call from a thread.""" + import pygame + + pygame.init() + screen = pygame.display.set_mode(window_size) + pygame.display.set_caption("Simple Robot") + clock = pygame.time.Clock() + font = pygame.font.Font(None, 24) + + BG = (30, 30, 40) + GRID = (50, 50, 60) + ROBOT = (100, 200, 255) + ARROW = (255, 150, 100) + TEXT = (200, 200, 200) + + w, h = window_size + cx, cy = w // 2, h // 2 + running = True + + while running: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + if event.type == pygame.KEYDOWN and event.key in (pygame.K_ESCAPE, pygame.K_q): + running = False + + pose, vel = robot._pose, robot._vel + + screen.fill(BG) + + # Grid (1m spacing) + grid_spacing = int(1.0 / meters_per_pixel) + for x in range(0, w, grid_spacing): + pygame.draw.line(screen, GRID, (x, 0), (x, h)) + for y in range(0, h, grid_spacing): + pygame.draw.line(screen, GRID, (0, y), (w, y)) + + # Robot position in screen coords + rx = cx + int(pose.x / meters_per_pixel) + ry = cy - int(pose.y / meters_per_pixel) + + # Robot body + pygame.draw.circle(screen, ROBOT, (rx, ry), 20) + + # Direction arrow + ax = rx + int(45 * math.cos(pose.yaw)) + ay = ry - int(45 * math.sin(pose.yaw)) + pygame.draw.line(screen, ARROW, (rx, ry), (ax, ay), 3) + for sign in [-1, 1]: + hx = ax - int(10 * math.cos(pose.yaw + sign * 0.5)) + hy = ay + int(10 * math.sin(pose.yaw + sign * 0.5)) + pygame.draw.line(screen, ARROW, (ax, ay), (hx, hy), 3) + + # Info text + info = [ + f"Position: ({pose.x:.2f}, {pose.y:.2f}) m", + f"Heading: {math.degrees(pose.yaw):.1f}°", + f"Velocity: {vel.linear.x:.2f} m/s", + f"Angular: {math.degrees(vel.angular.z):.1f}°/s", + ] + for i, text in enumerate(info): + screen.blit(font.render(text, True, TEXT), (10, 10 + i * 25)) + + pygame.display.flip() + clock.tick(60) + + pygame.quit() + + +def start_visualization(robot, **kwargs): + """Start visualization in a background thread.""" + thread = threading.Thread(target=run_visualization, args=(robot,), kwargs=kwargs, daemon=True) + thread.start() + return thread diff --git a/pyproject.toml b/pyproject.toml index f9cd2d8574..700fbee22a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,9 @@ dependencies = [ "sse-starlette>=2.2.1", "uvicorn>=0.34.0", + # MCP Server + "mcp>=1.0.0", + # Agents "langchain>=1,<2", "langchain-chroma>=1,<2", diff --git a/uv.lock b/uv.lock index 6a89d211df..d497efcad4 100644 --- a/uv.lock +++ b/uv.lock @@ -1480,6 +1480,7 @@ dependencies = [ { name = "lap" }, { name = "lark" }, { name = "llvmlite" }, + { name = "mcp" }, { name = "moondream" }, { name = "numba" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1656,6 +1657,7 @@ requires-dist = [ { name = "lvis", marker = "extra == 'cuda'" }, { name = "lxml-stubs", marker = "extra == 'dev'", specifier = ">=0.5.1,<1" }, { name = "matplotlib", marker = "extra == 'manipulation'", specifier = ">=3.7.1" }, + { name = "mcp", specifier = ">=1.0.0" }, { name = "mmcv", marker = "extra == 'cuda'", specifier = ">=2.1.0" }, { name = "mmengine", marker = "extra == 'cuda'", specifier = ">=0.10.3" }, { name = "moondream" }, @@ -2666,6 +2668,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + [[package]] name = "huggingface-hub" version = "0.36.0" @@ -4248,6 +4259,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, ] +[[package]] +name = "mcp" +version = "1.25.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/2d/649d80a0ecf6a1f82632ca44bec21c0461a9d9fc8934d38cb5b319f2db5e/mcp-1.25.0.tar.gz", hash = "sha256:56310361ebf0364e2d438e5b45f7668cbb124e158bb358333cd06e49e83a6802", size = 605387, upload-time = "2025-12-19T10:19:56.985Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/fc/6dc7659c2ae5ddf280477011f4213a74f806862856b796ef08f028e664bf/mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a", size = 233076, upload-time = "2025-12-19T10:19:55.416Z" }, +] + [[package]] name = "mdit-py-plugins" version = "0.5.0" @@ -6805,6 +6841,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pylibsrtp" version = "1.0.0" From 4a9ffce871552395976ebb023b3605db1cdab3d9 Mon Sep 17 00:00:00 2001 From: Ivan Nikolic Date: Wed, 7 Jan 2026 10:26:47 +0800 Subject: [PATCH 3/3] Address PR review comments for largefiles hook - Add check=True to subprocess calls to fail on git errors - tomli import was already fixed in previous commit --- bin/hooks/largefiles_check | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bin/hooks/largefiles_check b/bin/hooks/largefiles_check index 8984e1aa74..190183ecc6 100755 --- a/bin/hooks/largefiles_check +++ b/bin/hooks/largefiles_check @@ -33,11 +33,10 @@ max_bytes = max_size_kb * 1024 ignore_patterns = config.get("ignore", []) # Get LFS files to exclude -lfs_files = set( - subprocess.run( - ["git", "lfs", "ls-files", "-n"], capture_output=True, text=True - ).stdout.splitlines() +result = subprocess.run( + ["git", "lfs", "ls-files", "-n"], capture_output=True, text=True, check=True ) +lfs_files = set(result.stdout.splitlines()) # Get files to check if args.all: @@ -46,7 +45,8 @@ else: files_cmd = ["git", "diff", "--cached", "--name-only"] violations = [] -for file in subprocess.run(files_cmd, capture_output=True, text=True).stdout.splitlines(): +result = subprocess.run(files_cmd, capture_output=True, text=True, check=True) +for file in result.stdout.splitlines(): if file in lfs_files: continue if any(fnmatch.fnmatch(file, p) for p in ignore_patterns):