From c6036e45de9235441ece37d1d34489cedec3a9ed Mon Sep 17 00:00:00 2001 From: Ivan Nikolic Date: Sun, 22 Feb 2026 16:45:14 +0800 Subject: [PATCH 01/15] time system checks --- dimos/protocol/service/system_configurator.py | 151 ++++++++++++++- .../service/test_system_configurator.py | 178 ++++++++++++++++++ .../g1/blueprints/basic/unitree_g1_basic.py | 3 +- .../go2/blueprints/basic/unitree_go2_basic.py | 17 +- dimos/robot/unitree/requirements.py | 32 ++++ dimos/robot/unitree/test_requirements.py | 57 ++++++ 6 files changed, 431 insertions(+), 7 deletions(-) create mode 100644 dimos/robot/unitree/requirements.py create mode 100644 dimos/robot/unitree/test_requirements.py diff --git a/dimos/protocol/service/system_configurator.py b/dimos/protocol/service/system_configurator.py index 44b8c45276..5b02046964 100644 --- a/dimos/protocol/service/system_configurator.py +++ b/dimos/protocol/service/system_configurator.py @@ -17,10 +17,17 @@ from abc import ABC, abstractmethod from functools import cache import os +import platform import re import resource +import socket +import struct import subprocess -from typing import Any +import time +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Callable # ----------------------------- sudo helpers ----------------------------- @@ -138,6 +145,148 @@ def configure_system(checks: list[SystemConfigurator], check_only: bool = False) print("System configuration completed.") +# ----------------------------- bridge: SystemConfigurator → Blueprint.requirements() ----------------------------- + + +def system_checks(*configurators: SystemConfigurator) -> Callable[[], str | None]: + """Wrap SystemConfigurator instances into a Blueprint.requirements()-compatible callable. + + Returns a function that runs configure_system() and converts SystemExit + (raised when a critical check is declined) into an error string. + Non-critical declines return None (proceed with degraded state). + """ + + def _check() -> str | None: + try: + configure_system(list(configurators)) + except SystemExit: + labels = [type(c).__name__ for c in configurators] + return f"Required system configuration was declined: {', '.join(labels)}" + return None + + return _check + + +# ------------------------------ specific checks: clock sync ------------------------------ + + +class ClockSyncConfigurator(SystemConfigurator): + """Check that the local clock is within MAX_OFFSET_SECONDS of NTP time. + + Uses a pure-Python NTP query (RFC 4330 SNTPv4) so there are no external + dependencies. If the NTP server is unreachable the check *passes* — we + don't want unrelated network issues to block robot startup. + """ + + critical = False + MAX_OFFSET_SECONDS = 0.1 # 100 ms per issue spec + NTP_SERVER = "pool.ntp.org" + NTP_PORT = 123 + NTP_TIMEOUT = 2 # seconds + + def __init__(self) -> None: + self._offset: float | None = None # seconds, filled by check() + + # ---- NTP query ---- + + @staticmethod + def _ntp_offset(server: str = "pool.ntp.org", port: int = 123, timeout: float = 2) -> float: + """Return clock offset in seconds (local - NTP). Raises on failure.""" + # Minimal SNTPv4 request: LI=0, VN=4, Mode=3 → first byte = 0x23 + msg = b"\x23" + b"\x00" * 47 + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(timeout) + try: + t1 = time.time() + sock.sendto(msg, (server, port)) + data, _ = sock.recvfrom(1024) + t4 = time.time() + finally: + sock.close() + + if len(data) < 48: + raise ValueError(f"NTP response too short ({len(data)} bytes)") + + # Transmit Timestamp starts at byte 40 (seconds at 40, fraction at 44) + ntp_secs: int = struct.unpack("!I", data[40:44])[0] + ntp_frac: int = struct.unpack("!I", data[44:48])[0] + # NTP epoch is 1900-01-01; Unix epoch is 1970-01-01 + ntp_time: float = ntp_secs - 2208988800 + ntp_frac / (2**32) + + # Simplified offset: assume symmetric delay + t_server = ntp_time + rtt = t4 - t1 + offset: float = t_server - (t1 + rtt / 2) + return offset + + # ---- SystemConfigurator interface ---- + + def check(self) -> bool: + try: + self._offset = self._ntp_offset(self.NTP_SERVER, self.NTP_PORT, self.NTP_TIMEOUT) + except (TimeoutError, OSError, ValueError) as exc: + print(f"[clock-sync] NTP query failed ({exc}); assuming clock is OK") + self._offset = None + return True # graceful degradation — don't block on network issues + + if abs(self._offset) <= self.MAX_OFFSET_SECONDS: + return True + + print( + f"[clock-sync] WARNING: clock offset is {self._offset * 1000:+.1f} ms " + f"(threshold: ±{self.MAX_OFFSET_SECONDS * 1000:.0f} ms)" + ) + return False + + def explanation(self) -> str | None: + if self._offset is None: + return None + offset_ms = self._offset * 1000 + system = platform.system() + if system == "Linux": + cmd = "sudo timedatectl set-ntp true && sudo systemctl restart systemd-timesyncd" + elif system == "Darwin": + cmd = "sudo sntp -sS pool.ntp.org" + else: + cmd = "(manual NTP sync required for your platform)" + return ( + f"- Clock sync: local clock is off by {offset_ms:+.1f} ms " + f"(threshold: ±{self.MAX_OFFSET_SECONDS * 1000:.0f} ms)\n" + f" Fix: {cmd}" + ) + + def fix(self) -> None: + system = platform.system() + if system == "Linux": + sudo_run( + "timedatectl", + "set-ntp", + "true", + check=True, + text=True, + capture_output=True, + ) + sudo_run( + "systemctl", + "restart", + "systemd-timesyncd", + check=True, + text=True, + capture_output=True, + ) + elif system == "Darwin": + sudo_run( + "sntp", + "-sS", + self.NTP_SERVER, + check=True, + text=True, + capture_output=True, + ) + else: + print(f"[clock-sync] No automatic fix available for {system}") + + # ------------------------------ specific checks: multicast ------------------------------ diff --git a/dimos/protocol/service/test_system_configurator.py b/dimos/protocol/service/test_system_configurator.py index 07f8ede64c..2f98f56e21 100644 --- a/dimos/protocol/service/test_system_configurator.py +++ b/dimos/protocol/service/test_system_configurator.py @@ -14,6 +14,7 @@ import os import resource +import struct from unittest.mock import MagicMock, patch import pytest @@ -22,6 +23,7 @@ IDEAL_RMEM_SIZE, BufferConfiguratorLinux, BufferConfiguratorMacOS, + ClockSyncConfigurator, MaxFileConfiguratorMacOS, MulticastConfiguratorLinux, MulticastConfiguratorMacOS, @@ -31,6 +33,7 @@ _write_sysctl_int, configure_system, sudo_run, + system_checks, ) # ----------------------------- Helper function tests ----------------------------- @@ -480,3 +483,178 @@ def test_fix_raises_on_setrlimit_error(self) -> None: with patch("resource.setrlimit", side_effect=ValueError("test error")): with pytest.raises(ValueError): configurator.fix() + + +# ----------------------------- ClockSyncConfigurator tests ----------------------------- + + +class TestClockSyncConfigurator: + def test_check_passes_when_offset_within_threshold(self) -> None: + configurator = ClockSyncConfigurator() + with patch.object(ClockSyncConfigurator, "_ntp_offset", return_value=0.05): # 50ms + assert configurator.check() is True + assert configurator._offset == 0.05 + + def test_check_fails_when_offset_exceeds_threshold(self) -> None: + configurator = ClockSyncConfigurator() + with patch.object(ClockSyncConfigurator, "_ntp_offset", return_value=0.5): # 500ms + assert configurator.check() is False + assert configurator._offset == 0.5 + + def test_check_fails_with_negative_offset(self) -> None: + configurator = ClockSyncConfigurator() + with patch.object(ClockSyncConfigurator, "_ntp_offset", return_value=-0.2): # -200ms + assert configurator.check() is False + + def test_check_passes_when_ntp_unreachable(self) -> None: + configurator = ClockSyncConfigurator() + with patch.object( + ClockSyncConfigurator, "_ntp_offset", side_effect=OSError("Network unreachable") + ): + assert configurator.check() is True + assert configurator._offset is None + + def test_check_passes_on_socket_timeout(self) -> None: + configurator = ClockSyncConfigurator() + with patch.object( + ClockSyncConfigurator, "_ntp_offset", side_effect=TimeoutError("timed out") + ): + assert configurator.check() is True + + def test_check_passes_on_malformed_response(self) -> None: + configurator = ClockSyncConfigurator() + with patch.object( + ClockSyncConfigurator, "_ntp_offset", side_effect=ValueError("NTP response too short") + ): + assert configurator.check() is True + + def test_is_not_critical(self) -> None: + configurator = ClockSyncConfigurator() + assert configurator.critical is False + + def test_explanation_on_linux(self) -> None: + configurator = ClockSyncConfigurator() + configurator._offset = 0.5 # 500ms + with patch( + "dimos.protocol.service.system_configurator.platform.system", return_value="Linux" + ): + explanation = configurator.explanation() + assert explanation is not None + assert "+500.0 ms" in explanation + assert "timedatectl" in explanation + assert "systemd-timesyncd" in explanation + + def test_explanation_on_macos(self) -> None: + configurator = ClockSyncConfigurator() + configurator._offset = -0.3 # -300ms + with patch( + "dimos.protocol.service.system_configurator.platform.system", return_value="Darwin" + ): + explanation = configurator.explanation() + assert explanation is not None + assert "-300.0 ms" in explanation + assert "sntp" in explanation + + def test_explanation_returns_none_when_ntp_unreachable(self) -> None: + configurator = ClockSyncConfigurator() + configurator._offset = None + assert configurator.explanation() is None + + def test_fix_on_linux(self) -> None: + _is_root_user.cache_clear() + configurator = ClockSyncConfigurator() + with patch( + "dimos.protocol.service.system_configurator.platform.system", return_value="Linux" + ): + with patch("os.geteuid", return_value=0): + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + configurator.fix() + assert mock_run.call_count == 2 + # First call: timedatectl set-ntp true + assert "timedatectl" in mock_run.call_args_list[0][0][0] + # Second call: systemctl restart systemd-timesyncd + assert "systemctl" in mock_run.call_args_list[1][0][0] + + def test_fix_on_macos(self) -> None: + _is_root_user.cache_clear() + configurator = ClockSyncConfigurator() + with patch( + "dimos.protocol.service.system_configurator.platform.system", return_value="Darwin" + ): + with patch("os.geteuid", return_value=0): + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + configurator.fix() + assert mock_run.call_count == 1 + args = mock_run.call_args[0][0] + assert "sntp" in args + + def test_ntp_offset_with_mocked_socket(self) -> None: + # Build a minimal NTP response with a known transmit timestamp + # NTP epoch offset: 2208988800 seconds between 1900 and 1970 + fake_time = 1700000000.0 # a Unix timestamp + ntp_secs = int(fake_time) + 2208988800 + ntp_frac = 0 + response = b"\x00" * 40 + struct.pack("!II", ntp_secs, ntp_frac) + + with patch("socket.socket") as mock_socket_cls: + mock_sock = MagicMock() + mock_socket_cls.return_value = mock_sock + mock_sock.recvfrom.return_value = (response, ("pool.ntp.org", 123)) + + with patch("time.time", side_effect=[fake_time, fake_time + 0.01]): + offset = ClockSyncConfigurator._ntp_offset("pool.ntp.org", 123, 2) + # With zero RTT offset and matching times, offset should be close to 0 + # t1=fake_time, t4=fake_time+0.01, server=fake_time + # offset = fake_time - (fake_time + 0.005) = -0.005 + assert abs(offset - (-0.005)) < 0.001 + + def test_ntp_offset_raises_on_short_response(self) -> None: + with patch("socket.socket") as mock_socket_cls: + mock_sock = MagicMock() + mock_socket_cls.return_value = mock_sock + mock_sock.recvfrom.return_value = (b"\x00" * 10, ("pool.ntp.org", 123)) + + with patch("time.time", return_value=1700000000.0): + with pytest.raises(ValueError, match="too short"): + ClockSyncConfigurator._ntp_offset() + + +# ----------------------------- system_checks() bridge tests ----------------------------- + + +class TestSystemChecks: + def test_returns_none_when_all_checks_pass(self) -> None: + with patch.dict(os.environ, {"CI": ""}, clear=False): + check_fn = system_checks(MockConfigurator(passes=True)) + assert check_fn() is None + + def test_returns_none_for_non_critical_declined(self) -> None: + """Non-critical check declined → configure_system returns normally → None.""" + with patch.dict(os.environ, {"CI": ""}, clear=False): + with patch("builtins.input", return_value="n"): + check_fn = system_checks(MockConfigurator(passes=False, is_critical=False)) + assert check_fn() is None + + def test_returns_error_string_for_critical_declined(self) -> None: + """Critical check declined → SystemExit → error string.""" + with patch.dict(os.environ, {"CI": ""}, clear=False): + with patch("builtins.input", return_value="n"): + check_fn = system_checks(MockConfigurator(passes=False, is_critical=True)) + result = check_fn() + assert result is not None + assert "MockConfigurator" in result + + def test_returns_none_after_successful_fix(self) -> None: + with patch.dict(os.environ, {"CI": ""}, clear=False): + with patch("builtins.input", return_value="y"): + mock = MockConfigurator(passes=False, is_critical=True) + check_fn = system_checks(mock) + assert check_fn() is None + assert mock.fix_called + + def test_returns_none_in_ci(self) -> None: + with patch.dict(os.environ, {"CI": "true"}): + check_fn = system_checks(MockConfigurator(passes=False, is_critical=True)) + assert check_fn() is None diff --git a/dimos/robot/unitree/g1/blueprints/basic/unitree_g1_basic.py b/dimos/robot/unitree/g1/blueprints/basic/unitree_g1_basic.py index 1fb591e895..83ca8e12e3 100644 --- a/dimos/robot/unitree/g1/blueprints/basic/unitree_g1_basic.py +++ b/dimos/robot/unitree/g1/blueprints/basic/unitree_g1_basic.py @@ -21,11 +21,12 @@ uintree_g1_primitive_no_nav, ) from dimos.robot.unitree.g1.connection import g1_connection +from dimos.robot.unitree.requirements import unitree_clock_sync unitree_g1_basic = autoconnect( uintree_g1_primitive_no_nav, g1_connection(), ros_nav(), -) +).requirements(unitree_clock_sync) __all__ = ["unitree_g1_basic"] diff --git a/dimos/robot/unitree/go2/blueprints/basic/unitree_go2_basic.py b/dimos/robot/unitree/go2/blueprints/basic/unitree_go2_basic.py index cfd53abe51..5725932978 100644 --- a/dimos/robot/unitree/go2/blueprints/basic/unitree_go2_basic.py +++ b/dimos/robot/unitree/go2/blueprints/basic/unitree_go2_basic.py @@ -23,6 +23,7 @@ from dimos.msgs.sensor_msgs import Image from dimos.protocol.pubsub.impl.lcmpubsub import LCM from dimos.robot.unitree.go2.connection import go2_connection +from dimos.robot.unitree.requirements import unitree_clock_sync from dimos.web.websocket_vis.websocket_vis_module import websocket_vis # Mac has some issue with high bandwidth UDP, so we use pSHMTransport for color_image @@ -93,11 +94,17 @@ case _: with_vis = _transports_base -unitree_go2_basic = autoconnect( - with_vis, - go2_connection(), - websocket_vis(), -).global_config(n_dask_workers=4, robot_model="unitree_go2") +unitree_go2_basic = ( + autoconnect( + with_vis, + go2_connection(), + websocket_vis(), + ) + .global_config(n_dask_workers=4, robot_model="unitree_go2") + .requirements( + unitree_clock_sync, + ) +) __all__ = [ "unitree_go2_basic", diff --git a/dimos/robot/unitree/requirements.py b/dimos/robot/unitree/requirements.py new file mode 100644 index 0000000000..f1c86cfe5c --- /dev/null +++ b/dimos/robot/unitree/requirements.py @@ -0,0 +1,32 @@ +# 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. + +"""Blueprint requirement checks for Unitree robots.""" + +from __future__ import annotations + +from dimos.core.global_config import global_config +from dimos.protocol.service.system_configurator import ClockSyncConfigurator, system_checks + + +def unitree_clock_sync() -> str | None: + """Check clock synchronization for Unitree WebRTC connections. + + Skips the check for non-WebRTC connection types (sim, replay, mujoco). + Runtime check of global_config is intentional — Go2/G1 blueprints are + module-level constants that serve both hardware and sim modes. + """ + if global_config.unitree_connection_type != "webrtc": + return None + return system_checks(ClockSyncConfigurator())() diff --git a/dimos/robot/unitree/test_requirements.py b/dimos/robot/unitree/test_requirements.py new file mode 100644 index 0000000000..f6605cae7f --- /dev/null +++ b/dimos/robot/unitree/test_requirements.py @@ -0,0 +1,57 @@ +# 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. + +from unittest.mock import MagicMock, patch + +from dimos.robot.unitree.requirements import unitree_clock_sync + + +class TestUnitreeClockSync: + def test_skips_for_replay_connection(self) -> None: + with patch("dimos.robot.unitree.requirements.global_config") as mock_config: + mock_config.unitree_connection_type = "replay" + assert unitree_clock_sync() is None + + def test_skips_for_mujoco_connection(self) -> None: + with patch("dimos.robot.unitree.requirements.global_config") as mock_config: + mock_config.unitree_connection_type = "mujoco" + assert unitree_clock_sync() is None + + def test_skips_for_sim_connection(self) -> None: + with patch("dimos.robot.unitree.requirements.global_config") as mock_config: + mock_config.unitree_connection_type = "sim" + assert unitree_clock_sync() is None + + def test_runs_clock_sync_for_webrtc(self) -> None: + with patch("dimos.robot.unitree.requirements.global_config") as mock_config: + mock_config.unitree_connection_type = "webrtc" + with patch("dimos.robot.unitree.requirements.system_checks") as mock_system_checks: + mock_check_fn = MagicMock(return_value=None) + mock_system_checks.return_value = mock_check_fn + result = unitree_clock_sync() + assert result is None + mock_system_checks.assert_called_once() + mock_check_fn.assert_called_once() + + def test_returns_error_from_system_checks(self) -> None: + with patch("dimos.robot.unitree.requirements.global_config") as mock_config: + mock_config.unitree_connection_type = "webrtc" + with patch("dimos.robot.unitree.requirements.system_checks") as mock_system_checks: + mock_check_fn = MagicMock( + return_value="Required system configuration was declined: ClockSyncConfigurator" + ) + mock_system_checks.return_value = mock_check_fn + result = unitree_clock_sync() + assert result is not None + assert "ClockSyncConfigurator" in result From 7daff8ba11774685d80f8af065b6a59cfc8b9d07 Mon Sep 17 00:00:00 2001 From: Ivan Nikolic Date: Sun, 22 Feb 2026 16:51:01 +0800 Subject: [PATCH 02/15] refactor(system_configurator): split into package with separate modules Split the monolithic system_configurator.py into a package for better organization as more configurators are added. base.py has the ABC and helpers, clock_sync.py has ClockSyncConfigurator, lcm.py has LCM configurators. __init__.py re-exports everything for backward compat. --- .../service/system_configurator/__init__.py | 51 ++++ .../service/system_configurator/base.py | 161 ++++++++++ .../service/system_configurator/clock_sync.py | 139 +++++++++ .../lcm.py} | 274 +----------------- .../service/test_system_configurator.py | 30 +- 5 files changed, 376 insertions(+), 279 deletions(-) create mode 100644 dimos/protocol/service/system_configurator/__init__.py create mode 100644 dimos/protocol/service/system_configurator/base.py create mode 100644 dimos/protocol/service/system_configurator/clock_sync.py rename dimos/protocol/service/{system_configurator.py => system_configurator/lcm.py} (56%) diff --git a/dimos/protocol/service/system_configurator/__init__.py b/dimos/protocol/service/system_configurator/__init__.py new file mode 100644 index 0000000000..a4d64629c8 --- /dev/null +++ b/dimos/protocol/service/system_configurator/__init__.py @@ -0,0 +1,51 @@ +# 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. + +"""System configurator package — re-exports for backward compatibility.""" + +from dimos.protocol.service.system_configurator.base import ( + SystemConfigurator, + _is_root_user, + _read_sysctl_int, + _write_sysctl_int, + configure_system, + sudo_run, + system_checks, +) +from dimos.protocol.service.system_configurator.clock_sync import ClockSyncConfigurator +from dimos.protocol.service.system_configurator.lcm import ( + IDEAL_RMEM_SIZE, + BufferConfiguratorLinux, + BufferConfiguratorMacOS, + MaxFileConfiguratorMacOS, + MulticastConfiguratorLinux, + MulticastConfiguratorMacOS, +) + +__all__ = [ + "IDEAL_RMEM_SIZE", + "BufferConfiguratorLinux", + "BufferConfiguratorMacOS", + "ClockSyncConfigurator", + "MaxFileConfiguratorMacOS", + "MulticastConfiguratorLinux", + "MulticastConfiguratorMacOS", + "SystemConfigurator", + "_is_root_user", + "_read_sysctl_int", + "_write_sysctl_int", + "configure_system", + "sudo_run", + "system_checks", +] diff --git a/dimos/protocol/service/system_configurator/base.py b/dimos/protocol/service/system_configurator/base.py new file mode 100644 index 0000000000..5fbd00b1d9 --- /dev/null +++ b/dimos/protocol/service/system_configurator/base.py @@ -0,0 +1,161 @@ +# 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. + +from __future__ import annotations + +from abc import ABC, abstractmethod +from functools import cache +import os +import subprocess +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Callable + +# ----------------------------- sudo helpers ----------------------------- + + +@cache +def _is_root_user() -> bool: + try: + return os.geteuid() == 0 + except AttributeError: + return False + + +def sudo_run(*args: Any, **kwargs: Any) -> subprocess.CompletedProcess[str]: + if _is_root_user(): + return subprocess.run(list(args), **kwargs) + return subprocess.run(["sudo", *args], **kwargs) + + +def _read_sysctl_int(name: str) -> int | None: + try: + result = subprocess.run(["sysctl", name], capture_output=True, text=True) + if result.returncode != 0: + print( + f"[sysctl] ERROR: `sysctl {name}` rc={result.returncode} stderr={result.stderr!r}" + ) + return None + + text = result.stdout.strip().replace(":", "=") + if "=" not in text: + print(f"[sysctl] ERROR: unexpected output for {name}: {text!r}") + return None + + return int(text.split("=", 1)[1].strip()) + except Exception as error: + print(f"[sysctl] ERROR: reading {name}: {error}") + return None + + +def _write_sysctl_int(name: str, value: int) -> None: + sudo_run("sysctl", "-w", f"{name}={value}", check=True, text=True, capture_output=False) + + +# -------------------------- base class for system config checks/requirements -------------------------- + + +class SystemConfigurator(ABC): + critical: bool = False + + @abstractmethod + def check(self) -> bool: + """Return True if configured. Log errors and return False on uncertainty.""" + raise NotImplementedError + + @abstractmethod + def explanation(self) -> str | None: + """ + Return a human-readable summary of what would be done (sudo commands) if not configured. + Return None when no changes are needed. + """ + raise NotImplementedError + + @abstractmethod + def fix(self) -> None: + """Apply fixes (may attempt sudo, catch, and apply fallback measures if needed).""" + raise NotImplementedError + + +# ----------------------------- generic enforcement of system configs ----------------------------- + + +def configure_system(checks: list[SystemConfigurator], check_only: bool = False) -> None: + if os.environ.get("CI"): + print("CI environment detected: skipping system configuration.") + return + + # run checks + failing = [check for check in checks if not check.check()] + if not failing: + return + + # ask for permission to modify system + explanations: list[str] = [msg for check in failing if (msg := check.explanation()) is not None] + + if explanations: + print("System configuration changes are recommended/required:\n") + print("\n\n".join(explanations)) + print() + + if check_only: + return + + try: + answer = input("Apply these changes now? [y/N]: ").strip().lower() + except (EOFError, KeyboardInterrupt): + answer = "" + + if answer not in ("y", "yes"): + if any(check.critical for check in failing): + raise SystemExit(1) + return + + for check in failing: + try: + check.fix() + except subprocess.CalledProcessError as error: + if check.critical: + print(f"Critical fix failed rc={error.returncode}") + print(f"stdout: {error.stdout}") + print(f"stderr: {error.stderr}") + raise + print(f"Optional improvement failed: rc={error.returncode}") + print(f"stdout: {error.stdout}") + print(f"stderr: {error.stderr}") + + print("System configuration completed.") + + +# ----------------------------- bridge: SystemConfigurator → Blueprint.requirements() ----------------------------- + + +def system_checks(*configurators: SystemConfigurator) -> Callable[[], str | None]: + """Wrap SystemConfigurator instances into a Blueprint.requirements()-compatible callable. + + Returns a function that runs configure_system() and converts SystemExit + (raised when a critical check is declined) into an error string. + Non-critical declines return None (proceed with degraded state). + """ + + def _check() -> str | None: + try: + configure_system(list(configurators)) + except SystemExit: + labels = [type(c).__name__ for c in configurators] + return f"Required system configuration was declined: {', '.join(labels)}" + return None + + return _check diff --git a/dimos/protocol/service/system_configurator/clock_sync.py b/dimos/protocol/service/system_configurator/clock_sync.py new file mode 100644 index 0000000000..16fcd4bc1c --- /dev/null +++ b/dimos/protocol/service/system_configurator/clock_sync.py @@ -0,0 +1,139 @@ +# 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. + +from __future__ import annotations + +import platform +import socket +import struct +import time + +from dimos.protocol.service.system_configurator.base import SystemConfigurator, sudo_run + + +class ClockSyncConfigurator(SystemConfigurator): + """Check that the local clock is within MAX_OFFSET_SECONDS of NTP time. + + Uses a pure-Python NTP query (RFC 4330 SNTPv4) so there are no external + dependencies. If the NTP server is unreachable the check *passes* — we + don't want unrelated network issues to block robot startup. + """ + + critical = False + MAX_OFFSET_SECONDS = 0.1 # 100 ms per issue spec + NTP_SERVER = "pool.ntp.org" + NTP_PORT = 123 + NTP_TIMEOUT = 2 # seconds + + def __init__(self) -> None: + self._offset: float | None = None # seconds, filled by check() + + # ---- NTP query ---- + + @staticmethod + def _ntp_offset(server: str = "pool.ntp.org", port: int = 123, timeout: float = 2) -> float: + """Return clock offset in seconds (local - NTP). Raises on failure.""" + # Minimal SNTPv4 request: LI=0, VN=4, Mode=3 → first byte = 0x23 + msg = b"\x23" + b"\x00" * 47 + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(timeout) + try: + t1 = time.time() + sock.sendto(msg, (server, port)) + data, _ = sock.recvfrom(1024) + t4 = time.time() + finally: + sock.close() + + if len(data) < 48: + raise ValueError(f"NTP response too short ({len(data)} bytes)") + + # Transmit Timestamp starts at byte 40 (seconds at 40, fraction at 44) + ntp_secs: int = struct.unpack("!I", data[40:44])[0] + ntp_frac: int = struct.unpack("!I", data[44:48])[0] + # NTP epoch is 1900-01-01; Unix epoch is 1970-01-01 + ntp_time: float = ntp_secs - 2208988800 + ntp_frac / (2**32) + + # Simplified offset: assume symmetric delay + t_server = ntp_time + rtt = t4 - t1 + offset: float = t_server - (t1 + rtt / 2) + return offset + + # ---- SystemConfigurator interface ---- + + def check(self) -> bool: + try: + self._offset = self._ntp_offset(self.NTP_SERVER, self.NTP_PORT, self.NTP_TIMEOUT) + except (TimeoutError, OSError, ValueError) as exc: + print(f"[clock-sync] NTP query failed ({exc}); assuming clock is OK") + self._offset = None + return True # graceful degradation — don't block on network issues + + if abs(self._offset) <= self.MAX_OFFSET_SECONDS: + return True + + print( + f"[clock-sync] WARNING: clock offset is {self._offset * 1000:+.1f} ms " + f"(threshold: ±{self.MAX_OFFSET_SECONDS * 1000:.0f} ms)" + ) + return False + + def explanation(self) -> str | None: + if self._offset is None: + return None + offset_ms = self._offset * 1000 + system = platform.system() + if system == "Linux": + cmd = "sudo timedatectl set-ntp true && sudo systemctl restart systemd-timesyncd" + elif system == "Darwin": + cmd = "sudo sntp -sS pool.ntp.org" + else: + cmd = "(manual NTP sync required for your platform)" + return ( + f"- Clock sync: local clock is off by {offset_ms:+.1f} ms " + f"(threshold: ±{self.MAX_OFFSET_SECONDS * 1000:.0f} ms)\n" + f" Fix: {cmd}" + ) + + def fix(self) -> None: + system = platform.system() + if system == "Linux": + sudo_run( + "timedatectl", + "set-ntp", + "true", + check=True, + text=True, + capture_output=True, + ) + sudo_run( + "systemctl", + "restart", + "systemd-timesyncd", + check=True, + text=True, + capture_output=True, + ) + elif system == "Darwin": + sudo_run( + "sntp", + "-sS", + self.NTP_SERVER, + check=True, + text=True, + capture_output=True, + ) + else: + print(f"[clock-sync] No automatic fix available for {system}") diff --git a/dimos/protocol/service/system_configurator.py b/dimos/protocol/service/system_configurator/lcm.py similarity index 56% rename from dimos/protocol/service/system_configurator.py rename to dimos/protocol/service/system_configurator/lcm.py index 5b02046964..af3b89be37 100644 --- a/dimos/protocol/service/system_configurator.py +++ b/dimos/protocol/service/system_configurator/lcm.py @@ -14,278 +14,16 @@ from __future__ import annotations -from abc import ABC, abstractmethod -from functools import cache -import os -import platform import re import resource -import socket -import struct import subprocess -import time -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from collections.abc import Callable - -# ----------------------------- sudo helpers ----------------------------- - - -@cache -def _is_root_user() -> bool: - try: - return os.geteuid() == 0 - except AttributeError: - return False - - -def sudo_run(*args: Any, **kwargs: Any) -> subprocess.CompletedProcess[str]: - if _is_root_user(): - return subprocess.run(list(args), **kwargs) - return subprocess.run(["sudo", *args], **kwargs) - - -def _read_sysctl_int(name: str) -> int | None: - try: - result = subprocess.run(["sysctl", name], capture_output=True, text=True) - if result.returncode != 0: - print( - f"[sysctl] ERROR: `sysctl {name}` rc={result.returncode} stderr={result.stderr!r}" - ) - return None - - text = result.stdout.strip().replace(":", "=") - if "=" not in text: - print(f"[sysctl] ERROR: unexpected output for {name}: {text!r}") - return None - - return int(text.split("=", 1)[1].strip()) - except Exception as error: - print(f"[sysctl] ERROR: reading {name}: {error}") - return None - - -def _write_sysctl_int(name: str, value: int) -> None: - sudo_run("sysctl", "-w", f"{name}={value}", check=True, text=True, capture_output=False) - - -# -------------------------- base class for system config checks/requirements -------------------------- - - -class SystemConfigurator(ABC): - critical: bool = False - - @abstractmethod - def check(self) -> bool: - """Return True if configured. Log errors and return False on uncertainty.""" - raise NotImplementedError - - @abstractmethod - def explanation(self) -> str | None: - """ - Return a human-readable summary of what would be done (sudo commands) if not configured. - Return None when no changes are needed. - """ - raise NotImplementedError - - @abstractmethod - def fix(self) -> None: - """Apply fixes (may attempt sudo, catch, and apply fallback measures if needed).""" - raise NotImplementedError - - -# ----------------------------- generic enforcement of system configs ----------------------------- - - -def configure_system(checks: list[SystemConfigurator], check_only: bool = False) -> None: - if os.environ.get("CI"): - print("CI environment detected: skipping system configuration.") - return - - # run checks - failing = [check for check in checks if not check.check()] - if not failing: - return - - # ask for permission to modify system - explanations: list[str] = [msg for check in failing if (msg := check.explanation()) is not None] - - if explanations: - print("System configuration changes are recommended/required:\n") - print("\n\n".join(explanations)) - print() - - if check_only: - return - - try: - answer = input("Apply these changes now? [y/N]: ").strip().lower() - except (EOFError, KeyboardInterrupt): - answer = "" - - if answer not in ("y", "yes"): - if any(check.critical for check in failing): - raise SystemExit(1) - return - - for check in failing: - try: - check.fix() - except subprocess.CalledProcessError as error: - if check.critical: - print(f"Critical fix failed rc={error.returncode}") - print(f"stdout: {error.stdout}") - print(f"stderr: {error.stderr}") - raise - print(f"Optional improvement failed: rc={error.returncode}") - print(f"stdout: {error.stdout}") - print(f"stderr: {error.stderr}") - - print("System configuration completed.") - - -# ----------------------------- bridge: SystemConfigurator → Blueprint.requirements() ----------------------------- - - -def system_checks(*configurators: SystemConfigurator) -> Callable[[], str | None]: - """Wrap SystemConfigurator instances into a Blueprint.requirements()-compatible callable. - - Returns a function that runs configure_system() and converts SystemExit - (raised when a critical check is declined) into an error string. - Non-critical declines return None (proceed with degraded state). - """ - - def _check() -> str | None: - try: - configure_system(list(configurators)) - except SystemExit: - labels = [type(c).__name__ for c in configurators] - return f"Required system configuration was declined: {', '.join(labels)}" - return None - - return _check - - -# ------------------------------ specific checks: clock sync ------------------------------ - - -class ClockSyncConfigurator(SystemConfigurator): - """Check that the local clock is within MAX_OFFSET_SECONDS of NTP time. - - Uses a pure-Python NTP query (RFC 4330 SNTPv4) so there are no external - dependencies. If the NTP server is unreachable the check *passes* — we - don't want unrelated network issues to block robot startup. - """ - - critical = False - MAX_OFFSET_SECONDS = 0.1 # 100 ms per issue spec - NTP_SERVER = "pool.ntp.org" - NTP_PORT = 123 - NTP_TIMEOUT = 2 # seconds - - def __init__(self) -> None: - self._offset: float | None = None # seconds, filled by check() - - # ---- NTP query ---- - - @staticmethod - def _ntp_offset(server: str = "pool.ntp.org", port: int = 123, timeout: float = 2) -> float: - """Return clock offset in seconds (local - NTP). Raises on failure.""" - # Minimal SNTPv4 request: LI=0, VN=4, Mode=3 → first byte = 0x23 - msg = b"\x23" + b"\x00" * 47 - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.settimeout(timeout) - try: - t1 = time.time() - sock.sendto(msg, (server, port)) - data, _ = sock.recvfrom(1024) - t4 = time.time() - finally: - sock.close() - - if len(data) < 48: - raise ValueError(f"NTP response too short ({len(data)} bytes)") - - # Transmit Timestamp starts at byte 40 (seconds at 40, fraction at 44) - ntp_secs: int = struct.unpack("!I", data[40:44])[0] - ntp_frac: int = struct.unpack("!I", data[44:48])[0] - # NTP epoch is 1900-01-01; Unix epoch is 1970-01-01 - ntp_time: float = ntp_secs - 2208988800 + ntp_frac / (2**32) - - # Simplified offset: assume symmetric delay - t_server = ntp_time - rtt = t4 - t1 - offset: float = t_server - (t1 + rtt / 2) - return offset - - # ---- SystemConfigurator interface ---- - - def check(self) -> bool: - try: - self._offset = self._ntp_offset(self.NTP_SERVER, self.NTP_PORT, self.NTP_TIMEOUT) - except (TimeoutError, OSError, ValueError) as exc: - print(f"[clock-sync] NTP query failed ({exc}); assuming clock is OK") - self._offset = None - return True # graceful degradation — don't block on network issues - - if abs(self._offset) <= self.MAX_OFFSET_SECONDS: - return True - - print( - f"[clock-sync] WARNING: clock offset is {self._offset * 1000:+.1f} ms " - f"(threshold: ±{self.MAX_OFFSET_SECONDS * 1000:.0f} ms)" - ) - return False - - def explanation(self) -> str | None: - if self._offset is None: - return None - offset_ms = self._offset * 1000 - system = platform.system() - if system == "Linux": - cmd = "sudo timedatectl set-ntp true && sudo systemctl restart systemd-timesyncd" - elif system == "Darwin": - cmd = "sudo sntp -sS pool.ntp.org" - else: - cmd = "(manual NTP sync required for your platform)" - return ( - f"- Clock sync: local clock is off by {offset_ms:+.1f} ms " - f"(threshold: ±{self.MAX_OFFSET_SECONDS * 1000:.0f} ms)\n" - f" Fix: {cmd}" - ) - - def fix(self) -> None: - system = platform.system() - if system == "Linux": - sudo_run( - "timedatectl", - "set-ntp", - "true", - check=True, - text=True, - capture_output=True, - ) - sudo_run( - "systemctl", - "restart", - "systemd-timesyncd", - check=True, - text=True, - capture_output=True, - ) - elif system == "Darwin": - sudo_run( - "sntp", - "-sS", - self.NTP_SERVER, - check=True, - text=True, - capture_output=True, - ) - else: - print(f"[clock-sync] No automatic fix available for {system}") +from dimos.protocol.service.system_configurator.base import ( + SystemConfigurator, + _read_sysctl_int, + _write_sysctl_int, + sudo_run, +) # ------------------------------ specific checks: multicast ------------------------------ diff --git a/dimos/protocol/service/test_system_configurator.py b/dimos/protocol/service/test_system_configurator.py index 2f98f56e21..e9800f4262 100644 --- a/dimos/protocol/service/test_system_configurator.py +++ b/dimos/protocol/service/test_system_configurator.py @@ -317,14 +317,14 @@ def test_fix_runs_route_command(self) -> None: class TestBufferConfiguratorLinux: def test_check_returns_true_when_buffers_sufficient(self) -> None: configurator = BufferConfiguratorLinux() - with patch("dimos.protocol.service.system_configurator._read_sysctl_int") as mock_read: + with patch("dimos.protocol.service.system_configurator.lcm._read_sysctl_int") as mock_read: mock_read.return_value = IDEAL_RMEM_SIZE assert configurator.check() is True assert configurator.needs == [] def test_check_returns_false_when_rmem_max_low(self) -> None: configurator = BufferConfiguratorLinux() - with patch("dimos.protocol.service.system_configurator._read_sysctl_int") as mock_read: + with patch("dimos.protocol.service.system_configurator.lcm._read_sysctl_int") as mock_read: mock_read.side_effect = [1048576, IDEAL_RMEM_SIZE] # rmem_max low assert configurator.check() is False assert len(configurator.needs) == 1 @@ -332,7 +332,7 @@ def test_check_returns_false_when_rmem_max_low(self) -> None: def test_check_returns_false_when_both_low(self) -> None: configurator = BufferConfiguratorLinux() - with patch("dimos.protocol.service.system_configurator._read_sysctl_int") as mock_read: + with patch("dimos.protocol.service.system_configurator.lcm._read_sysctl_int") as mock_read: mock_read.return_value = 1048576 # Both low assert configurator.check() is False assert len(configurator.needs) == 2 @@ -347,7 +347,9 @@ def test_explanation_lists_needed_changes(self) -> None: def test_fix_writes_needed_values(self) -> None: configurator = BufferConfiguratorLinux() configurator.needs = [("net.core.rmem_max", IDEAL_RMEM_SIZE)] - with patch("dimos.protocol.service.system_configurator._write_sysctl_int") as mock_write: + with patch( + "dimos.protocol.service.system_configurator.lcm._write_sysctl_int" + ) as mock_write: configurator.fix() mock_write.assert_called_once_with("net.core.rmem_max", IDEAL_RMEM_SIZE) @@ -358,7 +360,7 @@ def test_fix_writes_needed_values(self) -> None: class TestBufferConfiguratorMacOS: def test_check_returns_true_when_buffers_sufficient(self) -> None: configurator = BufferConfiguratorMacOS() - with patch("dimos.protocol.service.system_configurator._read_sysctl_int") as mock_read: + with patch("dimos.protocol.service.system_configurator.lcm._read_sysctl_int") as mock_read: mock_read.side_effect = [ BufferConfiguratorMacOS.TARGET_BUFFER_SIZE, BufferConfiguratorMacOS.TARGET_RECVSPACE, @@ -369,7 +371,7 @@ def test_check_returns_true_when_buffers_sufficient(self) -> None: def test_check_returns_false_when_values_low(self) -> None: configurator = BufferConfiguratorMacOS() - with patch("dimos.protocol.service.system_configurator._read_sysctl_int") as mock_read: + with patch("dimos.protocol.service.system_configurator.lcm._read_sysctl_int") as mock_read: mock_read.return_value = 1024 # All low assert configurator.check() is False assert len(configurator.needs) == 3 @@ -387,7 +389,9 @@ def test_fix_writes_needed_values(self) -> None: configurator.needs = [ ("kern.ipc.maxsockbuf", BufferConfiguratorMacOS.TARGET_BUFFER_SIZE), ] - with patch("dimos.protocol.service.system_configurator._write_sysctl_int") as mock_write: + with patch( + "dimos.protocol.service.system_configurator.lcm._write_sysctl_int" + ) as mock_write: configurator.fix() mock_write.assert_called_once_with( "kern.ipc.maxsockbuf", BufferConfiguratorMacOS.TARGET_BUFFER_SIZE @@ -536,7 +540,8 @@ def test_explanation_on_linux(self) -> None: configurator = ClockSyncConfigurator() configurator._offset = 0.5 # 500ms with patch( - "dimos.protocol.service.system_configurator.platform.system", return_value="Linux" + "dimos.protocol.service.system_configurator.clock_sync.platform.system", + return_value="Linux", ): explanation = configurator.explanation() assert explanation is not None @@ -548,7 +553,8 @@ def test_explanation_on_macos(self) -> None: configurator = ClockSyncConfigurator() configurator._offset = -0.3 # -300ms with patch( - "dimos.protocol.service.system_configurator.platform.system", return_value="Darwin" + "dimos.protocol.service.system_configurator.clock_sync.platform.system", + return_value="Darwin", ): explanation = configurator.explanation() assert explanation is not None @@ -564,7 +570,8 @@ def test_fix_on_linux(self) -> None: _is_root_user.cache_clear() configurator = ClockSyncConfigurator() with patch( - "dimos.protocol.service.system_configurator.platform.system", return_value="Linux" + "dimos.protocol.service.system_configurator.clock_sync.platform.system", + return_value="Linux", ): with patch("os.geteuid", return_value=0): with patch("subprocess.run") as mock_run: @@ -580,7 +587,8 @@ def test_fix_on_macos(self) -> None: _is_root_user.cache_clear() configurator = ClockSyncConfigurator() with patch( - "dimos.protocol.service.system_configurator.platform.system", return_value="Darwin" + "dimos.protocol.service.system_configurator.clock_sync.platform.system", + return_value="Darwin", ): with patch("os.geteuid", return_value=0): with patch("subprocess.run") as mock_run: From 2953877ee60b1435eb8c899133d4bfbea8c46afd Mon Sep 17 00:00:00 2001 From: Ivan Nikolic Date: Sun, 22 Feb 2026 16:56:45 +0800 Subject: [PATCH 03/15] refactor: remove unitree_clock_sync wrapper, use system_checks directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop requirements.py and its tests — the global_config-based connection type check was unnecessary. Blueprints now call system_checks(ClockSyncConfigurator()) directly. --- .../g1/blueprints/basic/unitree_g1_basic.py | 4 +- .../go2/blueprints/basic/unitree_go2_basic.py | 4 +- dimos/robot/unitree/requirements.py | 32 ----------- dimos/robot/unitree/test_requirements.py | 57 ------------------- 4 files changed, 4 insertions(+), 93 deletions(-) delete mode 100644 dimos/robot/unitree/requirements.py delete mode 100644 dimos/robot/unitree/test_requirements.py diff --git a/dimos/robot/unitree/g1/blueprints/basic/unitree_g1_basic.py b/dimos/robot/unitree/g1/blueprints/basic/unitree_g1_basic.py index 83ca8e12e3..601ca452e6 100644 --- a/dimos/robot/unitree/g1/blueprints/basic/unitree_g1_basic.py +++ b/dimos/robot/unitree/g1/blueprints/basic/unitree_g1_basic.py @@ -17,16 +17,16 @@ from dimos.core.blueprints import autoconnect from dimos.navigation.rosnav import ros_nav +from dimos.protocol.service.system_configurator import ClockSyncConfigurator, system_checks from dimos.robot.unitree.g1.blueprints.primitive.uintree_g1_primitive_no_nav import ( uintree_g1_primitive_no_nav, ) from dimos.robot.unitree.g1.connection import g1_connection -from dimos.robot.unitree.requirements import unitree_clock_sync unitree_g1_basic = autoconnect( uintree_g1_primitive_no_nav, g1_connection(), ros_nav(), -).requirements(unitree_clock_sync) +).requirements(system_checks(ClockSyncConfigurator())) __all__ = ["unitree_g1_basic"] diff --git a/dimos/robot/unitree/go2/blueprints/basic/unitree_go2_basic.py b/dimos/robot/unitree/go2/blueprints/basic/unitree_go2_basic.py index 5725932978..ac44817627 100644 --- a/dimos/robot/unitree/go2/blueprints/basic/unitree_go2_basic.py +++ b/dimos/robot/unitree/go2/blueprints/basic/unitree_go2_basic.py @@ -22,8 +22,8 @@ from dimos.core.transport import pSHMTransport from dimos.msgs.sensor_msgs import Image from dimos.protocol.pubsub.impl.lcmpubsub import LCM +from dimos.protocol.service.system_configurator import ClockSyncConfigurator, system_checks from dimos.robot.unitree.go2.connection import go2_connection -from dimos.robot.unitree.requirements import unitree_clock_sync from dimos.web.websocket_vis.websocket_vis_module import websocket_vis # Mac has some issue with high bandwidth UDP, so we use pSHMTransport for color_image @@ -102,7 +102,7 @@ ) .global_config(n_dask_workers=4, robot_model="unitree_go2") .requirements( - unitree_clock_sync, + system_checks(ClockSyncConfigurator()), ) ) diff --git a/dimos/robot/unitree/requirements.py b/dimos/robot/unitree/requirements.py deleted file mode 100644 index f1c86cfe5c..0000000000 --- a/dimos/robot/unitree/requirements.py +++ /dev/null @@ -1,32 +0,0 @@ -# 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. - -"""Blueprint requirement checks for Unitree robots.""" - -from __future__ import annotations - -from dimos.core.global_config import global_config -from dimos.protocol.service.system_configurator import ClockSyncConfigurator, system_checks - - -def unitree_clock_sync() -> str | None: - """Check clock synchronization for Unitree WebRTC connections. - - Skips the check for non-WebRTC connection types (sim, replay, mujoco). - Runtime check of global_config is intentional — Go2/G1 blueprints are - module-level constants that serve both hardware and sim modes. - """ - if global_config.unitree_connection_type != "webrtc": - return None - return system_checks(ClockSyncConfigurator())() diff --git a/dimos/robot/unitree/test_requirements.py b/dimos/robot/unitree/test_requirements.py deleted file mode 100644 index f6605cae7f..0000000000 --- a/dimos/robot/unitree/test_requirements.py +++ /dev/null @@ -1,57 +0,0 @@ -# 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. - -from unittest.mock import MagicMock, patch - -from dimos.robot.unitree.requirements import unitree_clock_sync - - -class TestUnitreeClockSync: - def test_skips_for_replay_connection(self) -> None: - with patch("dimos.robot.unitree.requirements.global_config") as mock_config: - mock_config.unitree_connection_type = "replay" - assert unitree_clock_sync() is None - - def test_skips_for_mujoco_connection(self) -> None: - with patch("dimos.robot.unitree.requirements.global_config") as mock_config: - mock_config.unitree_connection_type = "mujoco" - assert unitree_clock_sync() is None - - def test_skips_for_sim_connection(self) -> None: - with patch("dimos.robot.unitree.requirements.global_config") as mock_config: - mock_config.unitree_connection_type = "sim" - assert unitree_clock_sync() is None - - def test_runs_clock_sync_for_webrtc(self) -> None: - with patch("dimos.robot.unitree.requirements.global_config") as mock_config: - mock_config.unitree_connection_type = "webrtc" - with patch("dimos.robot.unitree.requirements.system_checks") as mock_system_checks: - mock_check_fn = MagicMock(return_value=None) - mock_system_checks.return_value = mock_check_fn - result = unitree_clock_sync() - assert result is None - mock_system_checks.assert_called_once() - mock_check_fn.assert_called_once() - - def test_returns_error_from_system_checks(self) -> None: - with patch("dimos.robot.unitree.requirements.global_config") as mock_config: - mock_config.unitree_connection_type = "webrtc" - with patch("dimos.robot.unitree.requirements.system_checks") as mock_system_checks: - mock_check_fn = MagicMock( - return_value="Required system configuration was declined: ClockSyncConfigurator" - ) - mock_system_checks.return_value = mock_check_fn - result = unitree_clock_sync() - assert result is not None - assert "ClockSyncConfigurator" in result From d5bdf2a9ca9f3948b71dc1557b4dd17e7816b512 Mon Sep 17 00:00:00 2001 From: Ivan Nikolic Date: Sun, 22 Feb 2026 17:12:17 +0800 Subject: [PATCH 04/15] refactor: consolidate human-readable formatters into dimos/utils/human.py Replace 6 scattered duration/byte formatting implementations with two shared functions: human_duration() and human_bytes(). --- .../pubsub/benchmark/test_benchmark.py | 14 ++--- dimos/protocol/pubsub/benchmark/type.py | 45 +++------------- .../service/system_configurator/clock_sync.py | 7 +-- .../service/test_system_configurator.py | 2 +- dimos/utils/cli/lcmspy/lcmspy.py | 34 +++--------- dimos/utils/cli/lcmspy/run_lcmspy.py | 6 +-- dimos/utils/human.py | 54 +++++++++++++++++++ 7 files changed, 79 insertions(+), 83 deletions(-) create mode 100644 dimos/utils/human.py diff --git a/dimos/protocol/pubsub/benchmark/test_benchmark.py b/dimos/protocol/pubsub/benchmark/test_benchmark.py index 39a4421c35..4ab2fdb479 100644 --- a/dimos/protocol/pubsub/benchmark/test_benchmark.py +++ b/dimos/protocol/pubsub/benchmark/test_benchmark.py @@ -29,6 +29,7 @@ MsgGen, PubSubContext, ) +from dimos.utils.human import human_bytes # Message sizes for throughput benchmarking (powers of 2 from 64B to 10MB) MSG_SIZES = [ @@ -56,15 +57,6 @@ RECEIVE_TIMEOUT = 1.0 -def size_id(size: int) -> str: - """Convert byte size to human-readable string for test IDs.""" - if size >= 1048576: - return f"{size // 1048576}MB" - if size >= 1024: - return f"{size // 1024}KB" - return f"{size}B" - - def pubsub_id(testcase: Case[Any, Any]) -> str: """Extract pubsub implementation name from context manager function name.""" name: str = testcase.pubsub_context.__name__ @@ -86,7 +78,9 @@ def benchmark_results() -> Generator[BenchmarkResults, None, None]: @pytest.mark.tool -@pytest.mark.parametrize("msg_size", MSG_SIZES, ids=[size_id(s) for s in MSG_SIZES]) +@pytest.mark.parametrize( + "msg_size", MSG_SIZES, ids=[human_bytes(s, concise=True, decimals=0) for s in MSG_SIZES] +) @pytest.mark.parametrize("pubsub_context, msggen", testcases, ids=[pubsub_id(t) for t in testcases]) def test_throughput( pubsub_context: PubSubContext[Any, Any], diff --git a/dimos/protocol/pubsub/benchmark/type.py b/dimos/protocol/pubsub/benchmark/type.py index a9ef80fe7a..d9637b79c5 100644 --- a/dimos/protocol/pubsub/benchmark/type.py +++ b/dimos/protocol/pubsub/benchmark/type.py @@ -20,6 +20,7 @@ from typing import Any, Generic from dimos.protocol.pubsub.spec import MsgT, PubSub, TopicT +from dimos.utils.human import human_bytes, human_duration MsgGen = Callable[[int], tuple[TopicT, MsgT]] @@ -41,33 +42,6 @@ def __len__(self) -> int: TestData = Sequence[Case[Any, Any]] -def _format_mib(value: float) -> str: - """Format bytes as MiB with intelligent rounding. - - >= 10 MiB: integer (e.g., "42") - 1-10 MiB: 1 decimal (e.g., "2.5") - < 1 MiB: 2 decimals (e.g., "0.07") - """ - mib = value / (1024**2) - if mib >= 10: - return f"{mib:.0f}" - if mib >= 1: - return f"{mib:.1f}" - return f"{mib:.2f}" - - -def _format_iec(value: float, concise: bool = False, decimals: int = 2) -> str: - """Format bytes with IEC units (Ki/Mi/Gi = 1024^1/2/3)""" - k = 1024.0 - units = ["B", "K", "M", "G", "T"] if concise else ["B", "KiB", "MiB", "GiB", "TiB"] - - for unit in units[:-1]: - if abs(value) < k: - return f"{value:.{decimals}f}{unit}" if concise else f"{value:.{decimals}f} {unit}" - value /= k - return f"{value:.{decimals}f}{units[-1]}" if concise else f"{value:.{decimals}f} {units[-1]}" - - @dataclass class BenchmarkResult: transport: str @@ -133,11 +107,13 @@ def print_summary(self) -> None: recv_style = "yellow" if r.receive_time > 0.1 else "dim" table.add_row( r.transport, - _format_iec(r.msg_size_bytes, decimals=0), + human_bytes(r.msg_size_bytes, decimals=0), f"{r.msgs_sent:,}", f"{r.msgs_received:,}", f"{r.throughput_msgs:,.0f}", - _format_mib(r.throughput_bytes), + (lambda m: f"{m:.2f}" if m < 1 else f"{m:.1f}" if m < 10 else f"{m:.0f}")( + r.throughput_bytes / 1024**2 + ), f"[{recv_style}]{r.receive_time * 1000:.0f}ms[/{recv_style}]", f"[{loss_style}]{r.loss_pct:.1f}%[/{loss_style}]", ) @@ -211,7 +187,7 @@ def val_to_color(v: float) -> int: return gradient[int(t * (len(gradient) - 1))] reset = "\033[0m" - size_labels = [_format_iec(s, concise=True, decimals=0) for s in sizes] + size_labels = [human_bytes(s, concise=True, decimals=0) for s in sizes] col_w = max(8, max(len(s) for s in size_labels) + 1) transport_w = max(len(t) for t in transports) + 1 @@ -245,22 +221,17 @@ def print_bandwidth_heatmap(self) -> None: """Print bandwidth heatmap.""" def fmt(v: float) -> str: - return _format_iec(v, concise=True, decimals=1) + return human_bytes(v, concise=True, decimals=1) self._print_heatmap("Bandwidth (IEC)", lambda r: r.throughput_bytes, fmt) def print_latency_heatmap(self) -> None: """Print latency heatmap (time waiting for messages after publishing).""" - def fmt(v: float) -> str: - if v >= 1: - return f"{v:.1f}s" - return f"{v * 1000:.0f}ms" - self._print_heatmap( "Latency", lambda r: r.receive_time, - fmt, + lambda v: human_duration(v, signed=False), high_is_good=False, ) diff --git a/dimos/protocol/service/system_configurator/clock_sync.py b/dimos/protocol/service/system_configurator/clock_sync.py index 16fcd4bc1c..fe3d5fba95 100644 --- a/dimos/protocol/service/system_configurator/clock_sync.py +++ b/dimos/protocol/service/system_configurator/clock_sync.py @@ -20,6 +20,7 @@ import time from dimos.protocol.service.system_configurator.base import SystemConfigurator, sudo_run +from dimos.utils.human import human_duration class ClockSyncConfigurator(SystemConfigurator): @@ -85,7 +86,7 @@ def check(self) -> bool: return True print( - f"[clock-sync] WARNING: clock offset is {self._offset * 1000:+.1f} ms " + f"[clock-sync] WARNING: clock offset is {human_duration(self._offset)} " f"(threshold: ±{self.MAX_OFFSET_SECONDS * 1000:.0f} ms)" ) return False @@ -93,7 +94,7 @@ def check(self) -> bool: def explanation(self) -> str | None: if self._offset is None: return None - offset_ms = self._offset * 1000 + self._offset * 1000 system = platform.system() if system == "Linux": cmd = "sudo timedatectl set-ntp true && sudo systemctl restart systemd-timesyncd" @@ -102,7 +103,7 @@ def explanation(self) -> str | None: else: cmd = "(manual NTP sync required for your platform)" return ( - f"- Clock sync: local clock is off by {offset_ms:+.1f} ms " + f"- Clock sync: local clock is off by {human_duration(self._offset)} " f"(threshold: ±{self.MAX_OFFSET_SECONDS * 1000:.0f} ms)\n" f" Fix: {cmd}" ) diff --git a/dimos/protocol/service/test_system_configurator.py b/dimos/protocol/service/test_system_configurator.py index e9800f4262..885eabf5c5 100644 --- a/dimos/protocol/service/test_system_configurator.py +++ b/dimos/protocol/service/test_system_configurator.py @@ -545,7 +545,7 @@ def test_explanation_on_linux(self) -> None: ): explanation = configurator.explanation() assert explanation is not None - assert "+500.0 ms" in explanation + assert "+500.0 ms" in explanation or "+0.5 s" in explanation assert "timedatectl" in explanation assert "systemd-timesyncd" in explanation diff --git a/dimos/utils/cli/lcmspy/lcmspy.py b/dimos/utils/cli/lcmspy/lcmspy.py index 5493e53024..2df800591f 100755 --- a/dimos/utils/cli/lcmspy/lcmspy.py +++ b/dimos/utils/cli/lcmspy/lcmspy.py @@ -14,30 +14,11 @@ from collections import deque from dataclasses import dataclass -from enum import Enum import threading import time from dimos.protocol.service.lcmservice import LCMConfig, LCMService - - -class BandwidthUnit(Enum): - BP = "B" - KBP = "kB" - MBP = "MB" - GBP = "GB" - - -def human_readable_bytes(bytes_value: float, round_to: int = 2) -> tuple[float, BandwidthUnit]: - """Convert bytes to human-readable format with appropriate units""" - if bytes_value >= 1024**3: # GB - return round(bytes_value / (1024**3), round_to), BandwidthUnit.GBP - elif bytes_value >= 1024**2: # MB - return round(bytes_value / (1024**2), round_to), BandwidthUnit.MBP - elif bytes_value >= 1024: # KB - return round(bytes_value / 1024, round_to), BandwidthUnit.KBP - else: - return round(bytes_value, round_to), BandwidthUnit.BP +from dimos.utils.human import human_bytes class Topic: @@ -88,12 +69,10 @@ def kbps(self, time_window: float) -> float: total_kbytes = total_bytes / 1000 # Convert bytes to kB return total_kbytes / time_window # type: ignore[no-any-return] - def kbps_hr(self, time_window: float, round_to: int = 2) -> tuple[float, BandwidthUnit]: + def kbps_hr(self, time_window: float) -> str: """Return human-readable bandwidth with appropriate units""" - kbps_val = self.kbps(time_window) - # Convert kB/s to B/s for human_readable_bytes - bps = kbps_val * 1000 - return human_readable_bytes(bps, round_to) + bps = self.kbps(time_window) * 1000 + return human_bytes(bps) + "/s" # avg msg size in the last n seconds def size(self, time_window: float) -> float: @@ -107,10 +86,9 @@ def total_traffic(self) -> int: """Return total traffic passed in bytes since the beginning""" return self.total_traffic_bytes - def total_traffic_hr(self) -> tuple[float, BandwidthUnit]: + def total_traffic_hr(self) -> str: """Return human-readable total traffic with appropriate units""" - total_bytes = self.total_traffic() - return human_readable_bytes(total_bytes) + return human_bytes(self.total_traffic()) def __str__(self) -> str: return f"topic({self.name})" diff --git a/dimos/utils/cli/lcmspy/run_lcmspy.py b/dimos/utils/cli/lcmspy/run_lcmspy.py index f3d31b48ba..5be0bf28b5 100644 --- a/dimos/utils/cli/lcmspy/run_lcmspy.py +++ b/dimos/utils/cli/lcmspy/run_lcmspy.py @@ -106,14 +106,12 @@ def refresh_table(self) -> None: for t in topics: freq = t.freq(5.0) kbps = t.kbps(5.0) - bw_val, bw_unit = t.kbps_hr(5.0) - total_val, total_unit = t.total_traffic_hr() self.table.add_row( # type: ignore[union-attr] topic_text(t.name), Text(f"{freq:.1f}", style=gradient(10, freq)), - Text(f"{bw_val} {bw_unit.value}/s", style=gradient(1024 * 3, kbps)), - Text(f"{total_val} {total_unit.value}"), + Text(t.kbps_hr(5.0), style=gradient(1024 * 3, kbps)), + Text(t.total_traffic_hr()), ) diff --git a/dimos/utils/human.py b/dimos/utils/human.py new file mode 100644 index 0000000000..729d5291cb --- /dev/null +++ b/dimos/utils/human.py @@ -0,0 +1,54 @@ +# 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. + +"""Human-readable formatters for durations and byte sizes.""" + +from __future__ import annotations + + +def human_duration(seconds: float, signed: bool = True) -> str: + """Format a duration in seconds to a human-readable string. + + Examples: ``"25.1 ms"``, ``"1.3 s"``, ``"4m 12s"``, ``"2h 3m"``. + + When *signed* is True (the default), the result is prefixed with ``+`` + or ``-``. Set *signed=False* for unsigned values like latencies. + """ + sign = ("+" if seconds >= 0 else "-") if signed else "" + s = abs(seconds) + if s < 1: + return f"{sign}{s * 1000:.1f} ms" + if s < 60: + return f"{sign}{s:.1f} s" + m, s = divmod(s, 60) + if m < 60: + return f"{sign}{int(m)}m {int(s)}s" + h, m = divmod(m, 60) + return f"{sign}{int(h)}h {int(m)}m" + + +def human_bytes(value: float, concise: bool = False, decimals: int = 2) -> str: + """Format bytes with IEC units (1024-based: KiB, MiB, GiB, ...). + + *concise=True* uses short suffixes without a space (``"1.50K"``). + *decimals* controls the number of decimal places. + """ + k = 1024.0 + units = ["B", "K", "M", "G", "T"] if concise else ["B", "KiB", "MiB", "GiB", "TiB"] + + for unit in units[:-1]: + if abs(value) < k: + return f"{value:.{decimals}f}{unit}" if concise else f"{value:.{decimals}f} {unit}" + value /= k + return f"{value:.{decimals}f}{units[-1]}" if concise else f"{value:.{decimals}f} {units[-1]}" From c9c63f80a271799f195cbbdc5324ab2a08ae5549 Mon Sep 17 00:00:00 2001 From: Ivan Nikolic Date: Sun, 22 Feb 2026 17:29:35 +0800 Subject: [PATCH 05/15] fix(clock_sync): use ntpdate/sntp instead of systemd for clock fix Replace hardcoded timedatectl + systemd-timesyncd with a fallback chain: ntpdate > sntp > date -s. Avoids touching systemd on systems where timesyncd isn't installed (e.g. Arch). Also removes duplicate warning printed from check() (explanation() already handles it). --- .../service/system_configurator/clock_sync.py | 64 +++++------ .../service/test_system_configurator.py | 102 ++++++++++++++---- 2 files changed, 116 insertions(+), 50 deletions(-) diff --git a/dimos/protocol/service/system_configurator/clock_sync.py b/dimos/protocol/service/system_configurator/clock_sync.py index fe3d5fba95..9431d23c74 100644 --- a/dimos/protocol/service/system_configurator/clock_sync.py +++ b/dimos/protocol/service/system_configurator/clock_sync.py @@ -15,6 +15,7 @@ from __future__ import annotations import platform +import shutil import socket import struct import time @@ -85,56 +86,55 @@ def check(self) -> bool: if abs(self._offset) <= self.MAX_OFFSET_SECONDS: return True - print( - f"[clock-sync] WARNING: clock offset is {human_duration(self._offset)} " - f"(threshold: ±{self.MAX_OFFSET_SECONDS * 1000:.0f} ms)" - ) return False + # ---- Linux fix helpers ---- + + @staticmethod + def _linux_fix_cmd(ntp_server: str) -> str: + """Return the shell command that fix() would run on Linux.""" + if shutil.which("ntpdate"): + return f"sudo ntpdate {ntp_server}" + if shutil.which("sntp"): + return f"sudo sntp -sS {ntp_server}" + return "(install ntpdate or sntp, then re-run)" + + # ---- SystemConfigurator interface ---- + def explanation(self) -> str | None: if self._offset is None: return None - self._offset * 1000 system = platform.system() if system == "Linux": - cmd = "sudo timedatectl set-ntp true && sudo systemctl restart systemd-timesyncd" + cmd = self._linux_fix_cmd(self.NTP_SERVER) elif system == "Darwin": - cmd = "sudo sntp -sS pool.ntp.org" + cmd = f"sudo sntp -sS {self.NTP_SERVER}" else: cmd = "(manual NTP sync required for your platform)" + hint = "" + if system == "Linux" and not shutil.which("ntpdate") and not shutil.which("sntp"): + hint = "\n Tip: install ntpdate (or enable systemd-timesyncd) for automatic sync" return ( f"- Clock sync: local clock is off by {human_duration(self._offset)} " f"(threshold: ±{self.MAX_OFFSET_SECONDS * 1000:.0f} ms)\n" - f" Fix: {cmd}" + f" Fix: {cmd}{hint}" ) + def _fix_linux(self) -> None: + """One-shot NTP sync: ntpdate > sntp > date -s fallback.""" + if shutil.which("ntpdate"): + sudo_run("ntpdate", self.NTP_SERVER, check=True, text=True, capture_output=True) + elif shutil.which("sntp"): + sudo_run("sntp", "-sS", self.NTP_SERVER, check=True, text=True, capture_output=True) + elif self._offset is not None: + new_time = time.time() - self._offset + sudo_run("date", "-s", f"@{new_time:.3f}", check=True, text=True, capture_output=True) + def fix(self) -> None: system = platform.system() if system == "Linux": - sudo_run( - "timedatectl", - "set-ntp", - "true", - check=True, - text=True, - capture_output=True, - ) - sudo_run( - "systemctl", - "restart", - "systemd-timesyncd", - check=True, - text=True, - capture_output=True, - ) + self._fix_linux() elif system == "Darwin": - sudo_run( - "sntp", - "-sS", - self.NTP_SERVER, - check=True, - text=True, - capture_output=True, - ) + sudo_run("sntp", "-sS", self.NTP_SERVER, check=True, text=True, capture_output=True) else: print(f"[clock-sync] No automatic fix available for {system}") diff --git a/dimos/protocol/service/test_system_configurator.py b/dimos/protocol/service/test_system_configurator.py index 885eabf5c5..b72e661684 100644 --- a/dimos/protocol/service/test_system_configurator.py +++ b/dimos/protocol/service/test_system_configurator.py @@ -536,17 +536,39 @@ def test_is_not_critical(self) -> None: configurator = ClockSyncConfigurator() assert configurator.critical is False - def test_explanation_on_linux(self) -> None: + def test_explanation_on_linux_with_ntpdate(self) -> None: configurator = ClockSyncConfigurator() configurator._offset = 0.5 # 500ms - with patch( - "dimos.protocol.service.system_configurator.clock_sync.platform.system", - return_value="Linux", + with ( + patch( + "dimos.protocol.service.system_configurator.clock_sync.platform.system", + return_value="Linux", + ), + patch( + "dimos.protocol.service.system_configurator.clock_sync.shutil.which", + return_value="/usr/bin/ntpdate", + ), ): explanation = configurator.explanation() assert explanation is not None assert "+500.0 ms" in explanation or "+0.5 s" in explanation - assert "timedatectl" in explanation + assert "ntpdate" in explanation + + def test_explanation_on_linux_no_ntp_tools(self) -> None: + configurator = ClockSyncConfigurator() + configurator._offset = 0.5 + with ( + patch( + "dimos.protocol.service.system_configurator.clock_sync.platform.system", + return_value="Linux", + ), + patch( + "dimos.protocol.service.system_configurator.clock_sync.shutil.which", + return_value=None, + ), + ): + explanation = configurator.explanation() + assert "install ntpdate" in explanation assert "systemd-timesyncd" in explanation def test_explanation_on_macos(self) -> None: @@ -566,22 +588,66 @@ def test_explanation_returns_none_when_ntp_unreachable(self) -> None: configurator._offset = None assert configurator.explanation() is None - def test_fix_on_linux(self) -> None: + def test_fix_on_linux_with_ntpdate(self) -> None: _is_root_user.cache_clear() configurator = ClockSyncConfigurator() - with patch( - "dimos.protocol.service.system_configurator.clock_sync.platform.system", - return_value="Linux", + with ( + patch( + "dimos.protocol.service.system_configurator.clock_sync.platform.system", + return_value="Linux", + ), + patch( + "dimos.protocol.service.system_configurator.clock_sync.shutil.which", + side_effect=lambda cmd: "/usr/bin/ntpdate" if cmd == "ntpdate" else None, + ), + patch("os.geteuid", return_value=0), + patch("subprocess.run") as mock_run, ): - with patch("os.geteuid", return_value=0): - with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=0) - configurator.fix() - assert mock_run.call_count == 2 - # First call: timedatectl set-ntp true - assert "timedatectl" in mock_run.call_args_list[0][0][0] - # Second call: systemctl restart systemd-timesyncd - assert "systemctl" in mock_run.call_args_list[1][0][0] + mock_run.return_value = MagicMock(returncode=0) + configurator.fix() + assert mock_run.call_count == 1 + assert "ntpdate" in mock_run.call_args_list[0][0][0] + + def test_fix_on_linux_sntp_fallback(self) -> None: + _is_root_user.cache_clear() + configurator = ClockSyncConfigurator() + with ( + patch( + "dimos.protocol.service.system_configurator.clock_sync.platform.system", + return_value="Linux", + ), + patch( + "dimos.protocol.service.system_configurator.clock_sync.shutil.which", + side_effect=lambda cmd: "/usr/bin/sntp" if cmd == "sntp" else None, + ), + patch("os.geteuid", return_value=0), + patch("subprocess.run") as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + configurator.fix() + assert mock_run.call_count == 1 + assert "sntp" in mock_run.call_args_list[0][0][0] + + def test_fix_on_linux_date_fallback(self) -> None: + _is_root_user.cache_clear() + configurator = ClockSyncConfigurator() + configurator._offset = 1.0 + with ( + patch( + "dimos.protocol.service.system_configurator.clock_sync.platform.system", + return_value="Linux", + ), + patch( + "dimos.protocol.service.system_configurator.clock_sync.shutil.which", + return_value=None, + ), + patch("os.geteuid", return_value=0), + patch("subprocess.run") as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + configurator.fix() + assert mock_run.call_count == 1 + assert "date" in mock_run.call_args_list[0][0][0] def test_fix_on_macos(self) -> None: _is_root_user.cache_clear() From f2c499b3b6ad0c68522c238f716430c0fe8c0b49 Mon Sep 17 00:00:00 2001 From: Ivan Nikolic Date: Sun, 22 Feb 2026 17:36:55 +0800 Subject: [PATCH 06/15] refactor: add human_number() for SI-suffixed number formatting Move msgs/sec formatter from benchmark heatmap into dimos/utils/human.py as human_number() (42, 1.5k, 3.2M). --- dimos/protocol/pubsub/benchmark/type.py | 7 ++----- dimos/utils/human.py | 14 +++++++++++++- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/dimos/protocol/pubsub/benchmark/type.py b/dimos/protocol/pubsub/benchmark/type.py index d9637b79c5..4b64a08561 100644 --- a/dimos/protocol/pubsub/benchmark/type.py +++ b/dimos/protocol/pubsub/benchmark/type.py @@ -20,7 +20,7 @@ from typing import Any, Generic from dimos.protocol.pubsub.spec import MsgT, PubSub, TopicT -from dimos.utils.human import human_bytes, human_duration +from dimos.utils.human import human_bytes, human_duration, human_number MsgGen = Callable[[int], tuple[TopicT, MsgT]] @@ -212,10 +212,7 @@ def val_to_color(v: float) -> int: def print_heatmap(self) -> None: """Print msgs/sec heatmap.""" - def fmt(v: float) -> str: - return f"{v / 1000:.1f}k" if v >= 1000 else f"{v:.0f}" - - self._print_heatmap("Msgs/sec", lambda r: r.throughput_msgs, fmt) + self._print_heatmap("Msgs/sec", lambda r: r.throughput_msgs, human_number) def print_bandwidth_heatmap(self) -> None: """Print bandwidth heatmap.""" diff --git a/dimos/utils/human.py b/dimos/utils/human.py index 729d5291cb..c90c575048 100644 --- a/dimos/utils/human.py +++ b/dimos/utils/human.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Human-readable formatters for durations and byte sizes.""" +"""Human-readable formatters for durations, byte sizes, and numbers.""" from __future__ import annotations @@ -38,6 +38,18 @@ def human_duration(seconds: float, signed: bool = True) -> str: return f"{sign}{int(h)}h {int(m)}m" +def human_number(value: float, decimals: int = 1) -> str: + """Format a number with SI suffixes (k, M, G, ...). + + Examples: ``"42"``, ``"1.5k"``, ``"3.2M"``. + """ + for unit in ("", "k", "M", "G", "T"): + if abs(value) < 1000: + return f"{value:.{decimals}f}{unit}" if unit else f"{value:.0f}" + value /= 1000 + return f"{value:.{decimals}f}P" + + def human_bytes(value: float, concise: bool = False, decimals: int = 2) -> str: """Format bytes with IEC units (1024-based: KiB, MiB, GiB, ...). From 41ee2b86380a5c22abbc27fc432ef23c92fc5e56 Mon Sep 17 00:00:00 2001 From: Ivan Nikolic Date: Sun, 22 Feb 2026 18:33:45 +0800 Subject: [PATCH 07/15] blueprint configurator support --- dimos/core/blueprints.py | 35 ++++++++++++---- dimos/core/global_config.py | 2 +- dimos/protocol/service/lcmservice.py | 29 ++------------ .../service/system_configurator/__init__.py | 23 ++++++++++- .../service/system_configurator/base.py | 27 +------------ .../service/system_configurator/lcm.py | 4 +- dimos/protocol/service/test_lcmservice.py | 16 ++++++-- .../service/test_system_configurator.py | 40 ------------------- dimos/robot/cli/dimos.py | 2 - .../g1/blueprints/basic/unitree_g1_basic.py | 4 +- .../go2/blueprints/basic/unitree_go2_basic.py | 4 +- 11 files changed, 72 insertions(+), 114 deletions(-) diff --git a/dimos/core/blueprints.py b/dimos/core/blueprints.py index 605517e6cf..df263b7312 100644 --- a/dimos/core/blueprints.py +++ b/dimos/core/blueprints.py @@ -108,7 +108,7 @@ class Blueprint: remapping_map: Mapping[tuple[type[Module], str], str | type[Module] | type[Spec]] = field( default_factory=lambda: MappingProxyType({}) ) - requirement_checks: tuple[Callable[[], str | None], ...] = field(default_factory=tuple) + requirement_checks: tuple[Callable[[], str | None] | Any, ...] = field(default_factory=tuple) @classmethod def create(cls, module: type[Module], *args: Any, **kwargs: Any) -> "Blueprint": @@ -148,7 +148,7 @@ def remappings( requirement_checks=self.requirement_checks, ) - def requirements(self, *checks: Callable[[], str | None]) -> "Blueprint": + def requirements(self, *checks: "Callable[[], str | None] | SystemConfigurator") -> "Blueprint": return Blueprint( blueprints=self.blueprints, transport_map=self.transport_map, @@ -203,16 +203,35 @@ def _is_name_unique(self, name: str) -> bool: return sum(1 for n, _ in self._all_name_types if n == name) == 1 def _check_requirements(self) -> None: - errors = [] - red = "\033[31m" - reset = "\033[0m" + from dimos.protocol.service.system_configurator import ( + SystemConfigurator, + configure_system, + lcm_configurators, + ) + configurators = list(lcm_configurators()) + other_checks: list[Callable[[], str | None]] = [] for check in self.requirement_checks: - error = check() - if error: - errors.append(error) + if isinstance(check, SystemConfigurator): + configurators.append(check) + else: + other_checks.append(check) + + if configurators: + try: + configure_system(configurators) + except SystemExit: + labels = [type(c).__name__ for c in configurators] + print( + f"Required system configuration was declined: {', '.join(labels)}", + file=sys.stderr, + ) + sys.exit(1) + errors = [e for check in other_checks if (e := check())] if errors: + red = "\033[31m" + reset = "\033[0m" for error in errors: print(f"{red}Error: {error}{reset}", file=sys.stderr) sys.exit(1) diff --git a/dimos/core/global_config.py b/dimos/core/global_config.py index 080c2c8bbc..c73974dc37 100644 --- a/dimos/core/global_config.py +++ b/dimos/core/global_config.py @@ -30,7 +30,7 @@ class GlobalConfig(BaseSettings): robot_ip: str | None = None simulation: bool = False replay: bool = False - viewer_backend: ViewerBackend = "rerun-web" + viewer_backend: ViewerBackend = "rerun" n_dask_workers: int = 2 memory_limit: str = "auto" mujoco_camera_position: str | None = None diff --git a/dimos/protocol/service/lcmservice.py b/dimos/protocol/service/lcmservice.py index 4655780fb3..f414ce9e23 100644 --- a/dimos/protocol/service/lcmservice.py +++ b/dimos/protocol/service/lcmservice.py @@ -24,15 +24,7 @@ import lcm from dimos.protocol.service.spec import Service -from dimos.protocol.service.system_configurator import ( - BufferConfiguratorLinux, - BufferConfiguratorMacOS, - MaxFileConfiguratorMacOS, - MulticastConfiguratorLinux, - MulticastConfiguratorMacOS, - SystemConfigurator, - configure_system, -) +from dimos.protocol.service.system_configurator import configure_system, lcm_configurators from dimos.utils.logging_config import setup_logger logger = setup_logger() @@ -46,22 +38,9 @@ def autoconf(check_only: bool = False) -> None: - # check multicast and buffer sizes - system = platform.system() - checks: list[SystemConfigurator] = [] - if system == "Linux": - checks = [ - MulticastConfiguratorLinux(loopback_interface="lo"), - BufferConfiguratorLinux(), - ] - elif system == "Darwin": - checks = [ - MulticastConfiguratorMacOS(loopback_interface="lo0"), - BufferConfiguratorMacOS(), - MaxFileConfiguratorMacOS(), - ] - else: - logger.error(f"System configuration not supported on {system}") + checks = lcm_configurators() + if not checks: + logger.error(f"System configuration not supported on {platform.system()}") return configure_system(checks, check_only=check_only) diff --git a/dimos/protocol/service/system_configurator/__init__.py b/dimos/protocol/service/system_configurator/__init__.py index a4d64629c8..040e841c36 100644 --- a/dimos/protocol/service/system_configurator/__init__.py +++ b/dimos/protocol/service/system_configurator/__init__.py @@ -14,6 +14,8 @@ """System configurator package — re-exports for backward compatibility.""" +import platform + from dimos.protocol.service.system_configurator.base import ( SystemConfigurator, _is_root_user, @@ -21,7 +23,6 @@ _write_sysctl_int, configure_system, sudo_run, - system_checks, ) from dimos.protocol.service.system_configurator.clock_sync import ClockSyncConfigurator from dimos.protocol.service.system_configurator.lcm import ( @@ -33,6 +34,24 @@ MulticastConfiguratorMacOS, ) + +def lcm_configurators() -> list[SystemConfigurator]: + """Return the platform-appropriate LCM system configurators.""" + system = platform.system() + if system == "Linux": + return [ + MulticastConfiguratorLinux(loopback_interface="lo"), + BufferConfiguratorLinux(), + ] + elif system == "Darwin": + return [ + MulticastConfiguratorMacOS(loopback_interface="lo0"), + BufferConfiguratorMacOS(), + MaxFileConfiguratorMacOS(), + ] + return [] + + __all__ = [ "IDEAL_RMEM_SIZE", "BufferConfiguratorLinux", @@ -46,6 +65,6 @@ "_read_sysctl_int", "_write_sysctl_int", "configure_system", + "lcm_configurators", "sudo_run", - "system_checks", ] diff --git a/dimos/protocol/service/system_configurator/base.py b/dimos/protocol/service/system_configurator/base.py index 5fbd00b1d9..986df9234a 100644 --- a/dimos/protocol/service/system_configurator/base.py +++ b/dimos/protocol/service/system_configurator/base.py @@ -18,10 +18,7 @@ from functools import cache import os import subprocess -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from collections.abc import Callable +from typing import Any # ----------------------------- sudo helpers ----------------------------- @@ -137,25 +134,3 @@ def configure_system(checks: list[SystemConfigurator], check_only: bool = False) print(f"stderr: {error.stderr}") print("System configuration completed.") - - -# ----------------------------- bridge: SystemConfigurator → Blueprint.requirements() ----------------------------- - - -def system_checks(*configurators: SystemConfigurator) -> Callable[[], str | None]: - """Wrap SystemConfigurator instances into a Blueprint.requirements()-compatible callable. - - Returns a function that runs configure_system() and converts SystemExit - (raised when a critical check is declined) into an error string. - Non-critical declines return None (proceed with degraded state). - """ - - def _check() -> str | None: - try: - configure_system(list(configurators)) - except SystemExit: - labels = [type(c).__name__ for c in configurators] - return f"Required system configuration was declined: {', '.join(labels)}" - return None - - return _check diff --git a/dimos/protocol/service/system_configurator/lcm.py b/dimos/protocol/service/system_configurator/lcm.py index af3b89be37..17a2b34043 100644 --- a/dimos/protocol/service/system_configurator/lcm.py +++ b/dimos/protocol/service/system_configurator/lcm.py @@ -193,7 +193,7 @@ def check(self) -> bool: def explanation(self) -> str | None: lines = [] for key, target in self.needs: - lines.append(f"- socket buffer optimization: sudo sysctl -w {key}={target}") + lines.append(f"- LCM socket buffer optimization: sudo sysctl -w {key}={target}") return "\n".join(lines) def fix(self) -> None: @@ -230,7 +230,7 @@ def check(self) -> bool: def explanation(self) -> str | None: lines = [] for key, target in self.needs: - lines.append(f"- sudo sysctl -w {key}={target}") + lines.append(f"- LCM socket buffer optimization: sudo sysctl -w {key}={target}") return "\n".join(lines) def fix(self) -> None: diff --git a/dimos/protocol/service/test_lcmservice.py b/dimos/protocol/service/test_lcmservice.py index 4231302426..fdd2340e54 100644 --- a/dimos/protocol/service/test_lcmservice.py +++ b/dimos/protocol/service/test_lcmservice.py @@ -36,7 +36,9 @@ class TestConfigureSystemForLcm: def test_creates_linux_checks_on_linux(self) -> None: - with patch("dimos.protocol.service.lcmservice.platform.system", return_value="Linux"): + with patch( + "dimos.protocol.service.system_configurator.platform.system", return_value="Linux" + ): with patch("dimos.protocol.service.lcmservice.configure_system") as mock_configure: autoconf() mock_configure.assert_called_once() @@ -47,7 +49,9 @@ def test_creates_linux_checks_on_linux(self) -> None: assert checks[0].loopback_interface == "lo" def test_creates_macos_checks_on_darwin(self) -> None: - with patch("dimos.protocol.service.lcmservice.platform.system", return_value="Darwin"): + with patch( + "dimos.protocol.service.system_configurator.platform.system", return_value="Darwin" + ): with patch("dimos.protocol.service.lcmservice.configure_system") as mock_configure: autoconf() mock_configure.assert_called_once() @@ -59,14 +63,18 @@ def test_creates_macos_checks_on_darwin(self) -> None: assert checks[0].loopback_interface == "lo0" def test_passes_check_only_flag(self) -> None: - with patch("dimos.protocol.service.lcmservice.platform.system", return_value="Linux"): + with patch( + "dimos.protocol.service.system_configurator.platform.system", return_value="Linux" + ): with patch("dimos.protocol.service.lcmservice.configure_system") as mock_configure: autoconf(check_only=True) mock_configure.assert_called_once() assert mock_configure.call_args[1]["check_only"] is True def test_logs_error_on_unsupported_system(self) -> None: - with patch("dimos.protocol.service.lcmservice.platform.system", return_value="Windows"): + with patch( + "dimos.protocol.service.system_configurator.platform.system", return_value="Windows" + ): with patch("dimos.protocol.service.lcmservice.configure_system") as mock_configure: with patch("dimos.protocol.service.lcmservice.logger") as mock_logger: autoconf() diff --git a/dimos/protocol/service/test_system_configurator.py b/dimos/protocol/service/test_system_configurator.py index b72e661684..52c004404a 100644 --- a/dimos/protocol/service/test_system_configurator.py +++ b/dimos/protocol/service/test_system_configurator.py @@ -33,7 +33,6 @@ _write_sysctl_int, configure_system, sudo_run, - system_checks, ) # ----------------------------- Helper function tests ----------------------------- @@ -693,42 +692,3 @@ def test_ntp_offset_raises_on_short_response(self) -> None: with patch("time.time", return_value=1700000000.0): with pytest.raises(ValueError, match="too short"): ClockSyncConfigurator._ntp_offset() - - -# ----------------------------- system_checks() bridge tests ----------------------------- - - -class TestSystemChecks: - def test_returns_none_when_all_checks_pass(self) -> None: - with patch.dict(os.environ, {"CI": ""}, clear=False): - check_fn = system_checks(MockConfigurator(passes=True)) - assert check_fn() is None - - def test_returns_none_for_non_critical_declined(self) -> None: - """Non-critical check declined → configure_system returns normally → None.""" - with patch.dict(os.environ, {"CI": ""}, clear=False): - with patch("builtins.input", return_value="n"): - check_fn = system_checks(MockConfigurator(passes=False, is_critical=False)) - assert check_fn() is None - - def test_returns_error_string_for_critical_declined(self) -> None: - """Critical check declined → SystemExit → error string.""" - with patch.dict(os.environ, {"CI": ""}, clear=False): - with patch("builtins.input", return_value="n"): - check_fn = system_checks(MockConfigurator(passes=False, is_critical=True)) - result = check_fn() - assert result is not None - assert "MockConfigurator" in result - - def test_returns_none_after_successful_fix(self) -> None: - with patch.dict(os.environ, {"CI": ""}, clear=False): - with patch("builtins.input", return_value="y"): - mock = MockConfigurator(passes=False, is_critical=True) - check_fn = system_checks(mock) - assert check_fn() is None - assert mock.fix_called - - def test_returns_none_in_ci(self) -> None: - with patch.dict(os.environ, {"CI": "true"}): - check_fn = system_checks(MockConfigurator(passes=False, is_critical=True)) - assert check_fn() is None diff --git a/dimos/robot/cli/dimos.py b/dimos/robot/cli/dimos.py index c390d3b76c..02b37186a4 100644 --- a/dimos/robot/cli/dimos.py +++ b/dimos/robot/cli/dimos.py @@ -109,7 +109,6 @@ def run( ) -> None: """Start a robot blueprint""" from dimos.core.blueprints import autoconnect - from dimos.protocol import pubsub from dimos.robot.get_all_blueprints import get_blueprint_by_name, get_module_by_name from dimos.utils.logging_config import setup_exception_handler @@ -117,7 +116,6 @@ def run( cli_config_overrides: dict[str, Any] = ctx.obj global_config.update(**cli_config_overrides) - pubsub.lcm.autoconf() # type: ignore[attr-defined] blueprint = get_blueprint_by_name(robot_type.value) if extra_modules: diff --git a/dimos/robot/unitree/g1/blueprints/basic/unitree_g1_basic.py b/dimos/robot/unitree/g1/blueprints/basic/unitree_g1_basic.py index 601ca452e6..f2ec866b89 100644 --- a/dimos/robot/unitree/g1/blueprints/basic/unitree_g1_basic.py +++ b/dimos/robot/unitree/g1/blueprints/basic/unitree_g1_basic.py @@ -17,7 +17,7 @@ from dimos.core.blueprints import autoconnect from dimos.navigation.rosnav import ros_nav -from dimos.protocol.service.system_configurator import ClockSyncConfigurator, system_checks +from dimos.protocol.service.system_configurator import ClockSyncConfigurator from dimos.robot.unitree.g1.blueprints.primitive.uintree_g1_primitive_no_nav import ( uintree_g1_primitive_no_nav, ) @@ -27,6 +27,6 @@ uintree_g1_primitive_no_nav, g1_connection(), ros_nav(), -).requirements(system_checks(ClockSyncConfigurator())) +).requirements(ClockSyncConfigurator()) __all__ = ["unitree_g1_basic"] diff --git a/dimos/robot/unitree/go2/blueprints/basic/unitree_go2_basic.py b/dimos/robot/unitree/go2/blueprints/basic/unitree_go2_basic.py index ac44817627..8f31ac7206 100644 --- a/dimos/robot/unitree/go2/blueprints/basic/unitree_go2_basic.py +++ b/dimos/robot/unitree/go2/blueprints/basic/unitree_go2_basic.py @@ -22,7 +22,7 @@ from dimos.core.transport import pSHMTransport from dimos.msgs.sensor_msgs import Image from dimos.protocol.pubsub.impl.lcmpubsub import LCM -from dimos.protocol.service.system_configurator import ClockSyncConfigurator, system_checks +from dimos.protocol.service.system_configurator import ClockSyncConfigurator from dimos.robot.unitree.go2.connection import go2_connection from dimos.web.websocket_vis.websocket_vis_module import websocket_vis @@ -102,7 +102,7 @@ ) .global_config(n_dask_workers=4, robot_model="unitree_go2") .requirements( - system_checks(ClockSyncConfigurator()), + ClockSyncConfigurator(), ) ) From 0a35bb3b95a450aa1065e2ec6b5805e44c4e5950 Mon Sep 17 00:00:00 2001 From: Ivan Nikolic Date: Sun, 22 Feb 2026 18:47:12 +0800 Subject: [PATCH 08/15] clock configurator refactor --- dimos/core/global_config.py | 2 +- .../service/system_configurator/clock_sync.py | 65 +++++++++---------- .../service/system_configurator/lcm.py | 4 +- .../service/test_system_configurator.py | 33 ++++++---- 4 files changed, 54 insertions(+), 50 deletions(-) diff --git a/dimos/core/global_config.py b/dimos/core/global_config.py index c73974dc37..080c2c8bbc 100644 --- a/dimos/core/global_config.py +++ b/dimos/core/global_config.py @@ -30,7 +30,7 @@ class GlobalConfig(BaseSettings): robot_ip: str | None = None simulation: bool = False replay: bool = False - viewer_backend: ViewerBackend = "rerun" + viewer_backend: ViewerBackend = "rerun-web" n_dask_workers: int = 2 memory_limit: str = "auto" mujoco_camera_position: str | None = None diff --git a/dimos/protocol/service/system_configurator/clock_sync.py b/dimos/protocol/service/system_configurator/clock_sync.py index 9431d23c74..4f571e2dc4 100644 --- a/dimos/protocol/service/system_configurator/clock_sync.py +++ b/dimos/protocol/service/system_configurator/clock_sync.py @@ -40,6 +40,7 @@ class ClockSyncConfigurator(SystemConfigurator): def __init__(self) -> None: self._offset: float | None = None # seconds, filled by check() + self._fix_cmd: list[str] = [] # resolved by check() # ---- NTP query ---- @@ -75,6 +76,21 @@ def _ntp_offset(server: str = "pool.ntp.org", port: int = 123, timeout: float = # ---- SystemConfigurator interface ---- + def _resolve_fix_cmd(self) -> list[str]: + """Determine the best available NTP sync command for this platform.""" + system = platform.system() + if system == "Darwin": + return ["sntp", "-sS", self.NTP_SERVER] + if system == "Linux": + if shutil.which("ntpdate"): + return ["ntpdate", self.NTP_SERVER] + if shutil.which("sntp"): + return ["sntp", "-sS", self.NTP_SERVER] + if self._offset is not None: + new_time = time.time() - self._offset + return ["date", "-s", f"@{new_time:.3f}"] + return [] + def check(self) -> bool: try: self._offset = self._ntp_offset(self.NTP_SERVER, self.NTP_PORT, self.NTP_TIMEOUT) @@ -86,55 +102,32 @@ def check(self) -> bool: if abs(self._offset) <= self.MAX_OFFSET_SECONDS: return True + self._fix_cmd = self._resolve_fix_cmd() return False - # ---- Linux fix helpers ---- - - @staticmethod - def _linux_fix_cmd(ntp_server: str) -> str: - """Return the shell command that fix() would run on Linux.""" - if shutil.which("ntpdate"): - return f"sudo ntpdate {ntp_server}" - if shutil.which("sntp"): - return f"sudo sntp -sS {ntp_server}" - return "(install ntpdate or sntp, then re-run)" - # ---- SystemConfigurator interface ---- def explanation(self) -> str | None: if self._offset is None: return None - system = platform.system() - if system == "Linux": - cmd = self._linux_fix_cmd(self.NTP_SERVER) - elif system == "Darwin": - cmd = f"sudo sntp -sS {self.NTP_SERVER}" + if self._fix_cmd: + cmd = f"sudo {' '.join(self._fix_cmd)}" else: - cmd = "(manual NTP sync required for your platform)" + cmd = "(no NTP tool found — install ntpdate or sntp, then re-run)" hint = "" - if system == "Linux" and not shutil.which("ntpdate") and not shutil.which("sntp"): - hint = "\n Tip: install ntpdate (or enable systemd-timesyncd) for automatic sync" + if platform.system() == "Linux": + hint = ( + "\n Alternatively, enable automatic time sync:" + " sudo systemctl enable --now systemd-timesyncd.service" + ) return ( f"- Clock sync: local clock is off by {human_duration(self._offset)} " f"(threshold: ±{self.MAX_OFFSET_SECONDS * 1000:.0f} ms)\n" f" Fix: {cmd}{hint}" ) - def _fix_linux(self) -> None: - """One-shot NTP sync: ntpdate > sntp > date -s fallback.""" - if shutil.which("ntpdate"): - sudo_run("ntpdate", self.NTP_SERVER, check=True, text=True, capture_output=True) - elif shutil.which("sntp"): - sudo_run("sntp", "-sS", self.NTP_SERVER, check=True, text=True, capture_output=True) - elif self._offset is not None: - new_time = time.time() - self._offset - sudo_run("date", "-s", f"@{new_time:.3f}", check=True, text=True, capture_output=True) - def fix(self) -> None: - system = platform.system() - if system == "Linux": - self._fix_linux() - elif system == "Darwin": - sudo_run("sntp", "-sS", self.NTP_SERVER, check=True, text=True, capture_output=True) - else: - print(f"[clock-sync] No automatic fix available for {system}") + if not self._fix_cmd: + print(f"[clock-sync] No automatic fix available on {platform.system()}") + return + sudo_run(*self._fix_cmd, check=True, text=True, capture_output=True) diff --git a/dimos/protocol/service/system_configurator/lcm.py b/dimos/protocol/service/system_configurator/lcm.py index 17a2b34043..f25cc17cff 100644 --- a/dimos/protocol/service/system_configurator/lcm.py +++ b/dimos/protocol/service/system_configurator/lcm.py @@ -193,7 +193,7 @@ def check(self) -> bool: def explanation(self) -> str | None: lines = [] for key, target in self.needs: - lines.append(f"- LCM socket buffer optimization: sudo sysctl -w {key}={target}") + lines.append(f"- socket buffer optimization: sudo sysctl -w {key}={target}") return "\n".join(lines) def fix(self) -> None: @@ -230,7 +230,7 @@ def check(self) -> bool: def explanation(self) -> str | None: lines = [] for key, target in self.needs: - lines.append(f"- LCM socket buffer optimization: sudo sysctl -w {key}={target}") + lines.append(f"- socket buffer optimization: sudo sysctl -w {key}={target}") return "\n".join(lines) def fix(self) -> None: diff --git a/dimos/protocol/service/test_system_configurator.py b/dimos/protocol/service/test_system_configurator.py index 52c004404a..6e24949374 100644 --- a/dimos/protocol/service/test_system_configurator.py +++ b/dimos/protocol/service/test_system_configurator.py @@ -548,10 +548,12 @@ def test_explanation_on_linux_with_ntpdate(self) -> None: return_value="/usr/bin/ntpdate", ), ): + configurator._fix_cmd = configurator._resolve_fix_cmd() explanation = configurator.explanation() assert explanation is not None assert "+500.0 ms" in explanation or "+0.5 s" in explanation assert "ntpdate" in explanation + assert "systemd-timesyncd" in explanation def test_explanation_on_linux_no_ntp_tools(self) -> None: configurator = ClockSyncConfigurator() @@ -566,8 +568,10 @@ def test_explanation_on_linux_no_ntp_tools(self) -> None: return_value=None, ), ): + configurator._fix_cmd = configurator._resolve_fix_cmd() explanation = configurator.explanation() - assert "install ntpdate" in explanation + # Falls back to `date -s` when ntpdate/sntp unavailable + assert "date -s" in explanation assert "systemd-timesyncd" in explanation def test_explanation_on_macos(self) -> None: @@ -577,6 +581,7 @@ def test_explanation_on_macos(self) -> None: "dimos.protocol.service.system_configurator.clock_sync.platform.system", return_value="Darwin", ): + configurator._fix_cmd = configurator._resolve_fix_cmd() explanation = configurator.explanation() assert explanation is not None assert "-300.0 ms" in explanation @@ -602,6 +607,7 @@ def test_fix_on_linux_with_ntpdate(self) -> None: patch("os.geteuid", return_value=0), patch("subprocess.run") as mock_run, ): + configurator._fix_cmd = configurator._resolve_fix_cmd() mock_run.return_value = MagicMock(returncode=0) configurator.fix() assert mock_run.call_count == 1 @@ -622,6 +628,7 @@ def test_fix_on_linux_sntp_fallback(self) -> None: patch("os.geteuid", return_value=0), patch("subprocess.run") as mock_run, ): + configurator._fix_cmd = configurator._resolve_fix_cmd() mock_run.return_value = MagicMock(returncode=0) configurator.fix() assert mock_run.call_count == 1 @@ -643,6 +650,7 @@ def test_fix_on_linux_date_fallback(self) -> None: patch("os.geteuid", return_value=0), patch("subprocess.run") as mock_run, ): + configurator._fix_cmd = configurator._resolve_fix_cmd() mock_run.return_value = MagicMock(returncode=0) configurator.fix() assert mock_run.call_count == 1 @@ -651,17 +659,20 @@ def test_fix_on_linux_date_fallback(self) -> None: def test_fix_on_macos(self) -> None: _is_root_user.cache_clear() configurator = ClockSyncConfigurator() - with patch( - "dimos.protocol.service.system_configurator.clock_sync.platform.system", - return_value="Darwin", + with ( + patch( + "dimos.protocol.service.system_configurator.clock_sync.platform.system", + return_value="Darwin", + ), + patch("os.geteuid", return_value=0), + patch("subprocess.run") as mock_run, ): - with patch("os.geteuid", return_value=0): - with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=0) - configurator.fix() - assert mock_run.call_count == 1 - args = mock_run.call_args[0][0] - assert "sntp" in args + configurator._fix_cmd = configurator._resolve_fix_cmd() + mock_run.return_value = MagicMock(returncode=0) + configurator.fix() + assert mock_run.call_count == 1 + args = mock_run.call_args[0][0] + assert "sntp" in args def test_ntp_offset_with_mocked_socket(self) -> None: # Build a minimal NTP response with a known transmit timestamp From c0b90f9b78001b07ced4540fbf540b452787805c Mon Sep 17 00:00:00 2001 From: Ivan Nikolic Date: Sun, 22 Feb 2026 18:50:25 +0800 Subject: [PATCH 09/15] configurator API comment --- .../protocol/service/system_configurator/__init__.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/dimos/protocol/service/system_configurator/__init__.py b/dimos/protocol/service/system_configurator/__init__.py index 040e841c36..3d435fb4f5 100644 --- a/dimos/protocol/service/system_configurator/__init__.py +++ b/dimos/protocol/service/system_configurator/__init__.py @@ -35,6 +35,17 @@ ) +# TODO: This is a configurator API issue +# +# We need to use different configurators based on the underlying OS +# +# We should have separation of concerns, nothing but configurators themselves care about the OS in this context +# +# So configurators with multi-os behavior should be responsible for the right per-OS behaviour, and +# not external systems +# +# We might want to have some sort of recursive configurators +# def lcm_configurators() -> list[SystemConfigurator]: """Return the platform-appropriate LCM system configurators.""" system = platform.system() From b25ad8db138312f84b82e3ff03d08df8ba913e1e Mon Sep 17 00:00:00 2001 From: Ivan Nikolic Date: Sun, 22 Feb 2026 18:57:31 +0800 Subject: [PATCH 10/15] small fixes --- dimos/core/blueprints.py | 5 ++++- .../service/system_configurator/__init__.py | 4 ++-- .../service/system_configurator/clock_sync.py | 11 ++--------- dimos/protocol/service/system_configurator/lcm.py | 14 +++++++++----- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/dimos/core/blueprints.py b/dimos/core/blueprints.py index df263b7312..c21c1230c5 100644 --- a/dimos/core/blueprints.py +++ b/dimos/core/blueprints.py @@ -21,7 +21,10 @@ import operator import sys from types import MappingProxyType -from typing import Any, Literal, get_args, get_origin, get_type_hints +from typing import TYPE_CHECKING, Any, Literal, get_args, get_origin, get_type_hints + +if TYPE_CHECKING: + from dimos.protocol.service.system_configurator import SystemConfigurator from dimos.core.global_config import GlobalConfig, global_config from dimos.core.module import Module, is_module_type diff --git a/dimos/protocol/service/system_configurator/__init__.py b/dimos/protocol/service/system_configurator/__init__.py index 3d435fb4f5..9775165c6c 100644 --- a/dimos/protocol/service/system_configurator/__init__.py +++ b/dimos/protocol/service/system_configurator/__init__.py @@ -35,7 +35,7 @@ ) -# TODO: This is a configurator API issue +# TODO: This is a configurator API issue and inserted here temporarily # # We need to use different configurators based on the underlying OS # @@ -58,7 +58,7 @@ def lcm_configurators() -> list[SystemConfigurator]: return [ MulticastConfiguratorMacOS(loopback_interface="lo0"), BufferConfiguratorMacOS(), - MaxFileConfiguratorMacOS(), + MaxFileConfiguratorMacOS(), # TODO: this is not LCM related and shouldn't be here at all ] return [] diff --git a/dimos/protocol/service/system_configurator/clock_sync.py b/dimos/protocol/service/system_configurator/clock_sync.py index 4f571e2dc4..8add967142 100644 --- a/dimos/protocol/service/system_configurator/clock_sync.py +++ b/dimos/protocol/service/system_configurator/clock_sync.py @@ -116,15 +116,8 @@ def explanation(self) -> str | None: cmd = "(no NTP tool found — install ntpdate or sntp, then re-run)" hint = "" if platform.system() == "Linux": - hint = ( - "\n Alternatively, enable automatic time sync:" - " sudo systemctl enable --now systemd-timesyncd.service" - ) - return ( - f"- Clock sync: local clock is off by {human_duration(self._offset)} " - f"(threshold: ±{self.MAX_OFFSET_SECONDS * 1000:.0f} ms)\n" - f" Fix: {cmd}{hint}" - ) + hint = "\n (Alternatively, you can install systemd-timesyncd.service)" + return f"- clock sync: local clock is off by {human_duration(self._offset)}: {cmd}{hint}" def fix(self) -> None: if not self._fix_cmd: diff --git a/dimos/protocol/service/system_configurator/lcm.py b/dimos/protocol/service/system_configurator/lcm.py index f25cc17cff..0443ce5ccb 100644 --- a/dimos/protocol/service/system_configurator/lcm.py +++ b/dimos/protocol/service/system_configurator/lcm.py @@ -193,7 +193,7 @@ def check(self) -> bool: def explanation(self) -> str | None: lines = [] for key, target in self.needs: - lines.append(f"- socket buffer optimization: sudo sysctl -w {key}={target}") + lines.append(f"- socket buffer optimization for LCM: sudo sysctl -w {key}={target}") return "\n".join(lines) def fix(self) -> None: @@ -230,7 +230,7 @@ def check(self) -> bool: def explanation(self) -> str | None: lines = [] for key, target in self.needs: - lines.append(f"- socket buffer optimization: sudo sysctl -w {key}={target}") + lines.append(f"- socket buffer optimization for LCM: sudo sysctl -w {key}={target}") return "\n".join(lines) def fix(self) -> None: @@ -270,11 +270,15 @@ def check(self) -> bool: def explanation(self) -> str | None: lines = [] if self.can_fix_without_sudo: - lines.append(f"- Raise soft file count limit to {self.target} (no sudo required)") + lines.append( + f"- Raise soft file count limit to {self.target} for LCM (no sudo required)" + ) else: - lines.append(f"- Raise soft file count limit to {min(self.target, self.current_hard)}") lines.append( - f"- Raise hard limit via: sudo launchctl limit maxfiles {self.target} {self.target}" + f"- Raise soft file count limit to {min(self.target, self.current_hard)} for LCM" + ) + lines.append( + f"- Raise hard limit via: sudo launchctl limit maxfiles {self.target} {self.target} for LCM" ) return "\n".join(lines) From 7f5ee1bcb02f8d759c093076191834ef338bb10e Mon Sep 17 00:00:00 2001 From: Ivan Nikolic Date: Sun, 22 Feb 2026 18:59:09 +0800 Subject: [PATCH 11/15] 200ms time tolerance --- dimos/protocol/service/system_configurator/clock_sync.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dimos/protocol/service/system_configurator/clock_sync.py b/dimos/protocol/service/system_configurator/clock_sync.py index 8add967142..f7f9b69179 100644 --- a/dimos/protocol/service/system_configurator/clock_sync.py +++ b/dimos/protocol/service/system_configurator/clock_sync.py @@ -33,7 +33,7 @@ class ClockSyncConfigurator(SystemConfigurator): """ critical = False - MAX_OFFSET_SECONDS = 0.1 # 100 ms per issue spec + MAX_OFFSET_SECONDS = 0.2 # 200 ms per issue spec NTP_SERVER = "pool.ntp.org" NTP_PORT = 123 NTP_TIMEOUT = 2 # seconds From 538689133ed87fcc5de271740cdca8c8b91f9985 Mon Sep 17 00:00:00 2001 From: Ivan Nikolic Date: Sun, 22 Feb 2026 19:14:08 +0800 Subject: [PATCH 12/15] small fixes --- dimos/core/blueprints.py | 4 +++- .../service/system_configurator/__init__.py | 6 ------ .../service/system_configurator/clock_sync.py | 13 ++++++------- dimos/protocol/service/test_system_configurator.py | 8 +++++--- 4 files changed, 14 insertions(+), 17 deletions(-) diff --git a/dimos/core/blueprints.py b/dimos/core/blueprints.py index c21c1230c5..d1d039ceb8 100644 --- a/dimos/core/blueprints.py +++ b/dimos/core/blueprints.py @@ -111,7 +111,9 @@ class Blueprint: remapping_map: Mapping[tuple[type[Module], str], str | type[Module] | type[Spec]] = field( default_factory=lambda: MappingProxyType({}) ) - requirement_checks: tuple[Callable[[], str | None] | Any, ...] = field(default_factory=tuple) + requirement_checks: "tuple[Callable[[], str | None] | SystemConfigurator, ...]" = field( + default_factory=tuple + ) @classmethod def create(cls, module: type[Module], *args: Any, **kwargs: Any) -> "Blueprint": diff --git a/dimos/protocol/service/system_configurator/__init__.py b/dimos/protocol/service/system_configurator/__init__.py index 9775165c6c..b49c76876a 100644 --- a/dimos/protocol/service/system_configurator/__init__.py +++ b/dimos/protocol/service/system_configurator/__init__.py @@ -18,9 +18,6 @@ from dimos.protocol.service.system_configurator.base import ( SystemConfigurator, - _is_root_user, - _read_sysctl_int, - _write_sysctl_int, configure_system, sudo_run, ) @@ -72,9 +69,6 @@ def lcm_configurators() -> list[SystemConfigurator]: "MulticastConfiguratorLinux", "MulticastConfiguratorMacOS", "SystemConfigurator", - "_is_root_user", - "_read_sysctl_int", - "_write_sysctl_int", "configure_system", "lcm_configurators", "sudo_run", diff --git a/dimos/protocol/service/system_configurator/clock_sync.py b/dimos/protocol/service/system_configurator/clock_sync.py index f7f9b69179..4dbefcec9e 100644 --- a/dimos/protocol/service/system_configurator/clock_sync.py +++ b/dimos/protocol/service/system_configurator/clock_sync.py @@ -42,11 +42,10 @@ def __init__(self) -> None: self._offset: float | None = None # seconds, filled by check() self._fix_cmd: list[str] = [] # resolved by check() - # ---- NTP query ---- - @staticmethod def _ntp_offset(server: str = "pool.ntp.org", port: int = 123, timeout: float = 2) -> float: """Return clock offset in seconds (local - NTP). Raises on failure.""" + # Minimal SNTPv4 request: LI=0, VN=4, Mode=3 → first byte = 0x23 msg = b"\x23" + b"\x00" * 47 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) @@ -74,8 +73,6 @@ def _ntp_offset(server: str = "pool.ntp.org", port: int = 123, timeout: float = offset: float = t_server - (t1 + rtt / 2) return offset - # ---- SystemConfigurator interface ---- - def _resolve_fix_cmd(self) -> list[str]: """Determine the best available NTP sync command for this platform.""" system = platform.system() @@ -105,8 +102,6 @@ def check(self) -> bool: self._fix_cmd = self._resolve_fix_cmd() return False - # ---- SystemConfigurator interface ---- - def explanation(self) -> str | None: if self._offset is None: return None @@ -123,4 +118,8 @@ def fix(self) -> None: if not self._fix_cmd: print(f"[clock-sync] No automatic fix available on {platform.system()}") return - sudo_run(*self._fix_cmd, check=True, text=True, capture_output=True) + cmd = list(self._fix_cmd) + # Recompute the corrected time at fix-time (not stale from check-time) + if cmd[:2] == ["date", "-s"] and self._offset is not None: + cmd[2] = f"@{time.time() - self._offset:.3f}" + sudo_run(*cmd, check=True, text=True, capture_output=True) diff --git a/dimos/protocol/service/test_system_configurator.py b/dimos/protocol/service/test_system_configurator.py index 6e24949374..2824af6690 100644 --- a/dimos/protocol/service/test_system_configurator.py +++ b/dimos/protocol/service/test_system_configurator.py @@ -28,11 +28,13 @@ MulticastConfiguratorLinux, MulticastConfiguratorMacOS, SystemConfigurator, + configure_system, + sudo_run, +) +from dimos.protocol.service.system_configurator.base import ( _is_root_user, _read_sysctl_int, _write_sysctl_int, - configure_system, - sudo_run, ) # ----------------------------- Helper function tests ----------------------------- @@ -506,7 +508,7 @@ def test_check_fails_when_offset_exceeds_threshold(self) -> None: def test_check_fails_with_negative_offset(self) -> None: configurator = ClockSyncConfigurator() - with patch.object(ClockSyncConfigurator, "_ntp_offset", return_value=-0.2): # -200ms + with patch.object(ClockSyncConfigurator, "_ntp_offset", return_value=-0.5): # -500ms assert configurator.check() is False def test_check_passes_when_ntp_unreachable(self) -> None: From 8edc99502570ac0a167387f5b9e4b9d1362112f7 Mon Sep 17 00:00:00 2001 From: Ivan Nikolic Date: Tue, 24 Feb 2026 16:49:51 +0800 Subject: [PATCH 13/15] use typer.confirm and logger instead of input/print in system configurator Addresses PR review feedback: replace manual input() with typer.confirm() and all print() calls with proper logging. --- .../service/system_configurator/base.py | 41 +++++++++---------- .../service/test_system_configurator.py | 13 ++---- 2 files changed, 23 insertions(+), 31 deletions(-) diff --git a/dimos/protocol/service/system_configurator/base.py b/dimos/protocol/service/system_configurator/base.py index 986df9234a..c221af890f 100644 --- a/dimos/protocol/service/system_configurator/base.py +++ b/dimos/protocol/service/system_configurator/base.py @@ -16,10 +16,15 @@ from abc import ABC, abstractmethod from functools import cache +import logging import os import subprocess from typing import Any +import typer + +logger = logging.getLogger(__name__) + # ----------------------------- sudo helpers ----------------------------- @@ -41,19 +46,19 @@ def _read_sysctl_int(name: str) -> int | None: try: result = subprocess.run(["sysctl", name], capture_output=True, text=True) if result.returncode != 0: - print( - f"[sysctl] ERROR: `sysctl {name}` rc={result.returncode} stderr={result.stderr!r}" + logger.error( + "[sysctl] `sysctl %s` rc=%d stderr=%r", name, result.returncode, result.stderr ) return None text = result.stdout.strip().replace(":", "=") if "=" not in text: - print(f"[sysctl] ERROR: unexpected output for {name}: {text!r}") + logger.error("[sysctl] unexpected output for %s: %r", name, text) return None return int(text.split("=", 1)[1].strip()) except Exception as error: - print(f"[sysctl] ERROR: reading {name}: {error}") + logger.error("[sysctl] reading %s: %s", name, error) return None @@ -91,7 +96,7 @@ def fix(self) -> None: def configure_system(checks: list[SystemConfigurator], check_only: bool = False) -> None: if os.environ.get("CI"): - print("CI environment detected: skipping system configuration.") + logger.info("CI environment detected: skipping system configuration.") return # run checks @@ -103,19 +108,13 @@ def configure_system(checks: list[SystemConfigurator], check_only: bool = False) explanations: list[str] = [msg for check in failing if (msg := check.explanation()) is not None] if explanations: - print("System configuration changes are recommended/required:\n") - print("\n\n".join(explanations)) - print() + logger.warning("System configuration changes are recommended/required:\n") + logger.warning("\n\n".join(explanations)) if check_only: return - try: - answer = input("Apply these changes now? [y/N]: ").strip().lower() - except (EOFError, KeyboardInterrupt): - answer = "" - - if answer not in ("y", "yes"): + if not typer.confirm("\nApply these changes now?"): if any(check.critical for check in failing): raise SystemExit(1) return @@ -125,12 +124,12 @@ def configure_system(checks: list[SystemConfigurator], check_only: bool = False) check.fix() except subprocess.CalledProcessError as error: if check.critical: - print(f"Critical fix failed rc={error.returncode}") - print(f"stdout: {error.stdout}") - print(f"stderr: {error.stderr}") + logger.error("Critical fix failed rc=%d", error.returncode) + logger.error("stdout: %s", error.stdout) + logger.error("stderr: %s", error.stderr) raise - print(f"Optional improvement failed: rc={error.returncode}") - print(f"stdout: {error.stdout}") - print(f"stderr: {error.stderr}") + logger.warning("Optional improvement failed: rc=%d", error.returncode) + logger.warning("stdout: %s", error.stdout) + logger.warning("stderr: %s", error.stderr) - print("System configuration completed.") + logger.info("System configuration completed.") diff --git a/dimos/protocol/service/test_system_configurator.py b/dimos/protocol/service/test_system_configurator.py index 2824af6690..e4180b7c12 100644 --- a/dimos/protocol/service/test_system_configurator.py +++ b/dimos/protocol/service/test_system_configurator.py @@ -167,32 +167,25 @@ def test_check_only_mode_does_not_fix(self) -> None: def test_prompts_user_and_fixes_on_yes(self) -> None: with patch.dict(os.environ, {"CI": ""}, clear=False): mock_check = MockConfigurator(passes=False) - with patch("builtins.input", return_value="y"): + with patch("typer.confirm", return_value=True): configure_system([mock_check]) assert mock_check.fix_called def test_does_not_fix_on_no(self) -> None: with patch.dict(os.environ, {"CI": ""}, clear=False): mock_check = MockConfigurator(passes=False) - with patch("builtins.input", return_value="n"): + with patch("typer.confirm", return_value=False): configure_system([mock_check]) assert not mock_check.fix_called def test_exits_on_no_with_critical_check(self) -> None: with patch.dict(os.environ, {"CI": ""}, clear=False): mock_check = MockConfigurator(passes=False, is_critical=True) - with patch("builtins.input", return_value="n"): + with patch("typer.confirm", return_value=False): with pytest.raises(SystemExit) as exc_info: configure_system([mock_check]) assert exc_info.value.code == 1 - def test_handles_eof_error_on_input(self) -> None: - with patch.dict(os.environ, {"CI": ""}, clear=False): - mock_check = MockConfigurator(passes=False) - with patch("builtins.input", side_effect=EOFError): - configure_system([mock_check]) - assert not mock_check.fix_called - # ----------------------------- MulticastConfiguratorLinux tests ----------------------------- From f1fa5e68389a21d4becf9723a4cd34d6d30340a0 Mon Sep 17 00:00:00 2001 From: Ivan Nikolic Date: Tue, 24 Feb 2026 17:47:43 +0800 Subject: [PATCH 14/15] remove markdown paths-ignore from code-cleanup workflow The doclinks pre-commit hook needs to run on markdown changes too. --- .github/workflows/code-cleanup.yml | 2 -- docs/capabilities/manipulation/readme.md | 2 +- docs/platforms/humanoid/g1/index.md | 18 +++++++++--------- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/.github/workflows/code-cleanup.yml b/.github/workflows/code-cleanup.yml index 745f3852c1..5070747b77 100644 --- a/.github/workflows/code-cleanup.yml +++ b/.github/workflows/code-cleanup.yml @@ -1,8 +1,6 @@ name: code-cleanup on: pull_request: - paths-ignore: - - '**.md' permissions: contents: write diff --git a/docs/capabilities/manipulation/readme.md b/docs/capabilities/manipulation/readme.md index 4a943e6be5..0d6539b75c 100644 --- a/docs/capabilities/manipulation/readme.md +++ b/docs/capabilities/manipulation/readme.md @@ -101,7 +101,7 @@ KeyboardTeleopModule ──→ ControlCoordinator ──→ ManipulationModule ## Adding a Custom Arm -[guide is here](adding_a_custom_arm.md) +[guide is here](/docs/capabilities/manipulation/adding_a_custom_arm.md) ## Key Files diff --git a/docs/platforms/humanoid/g1/index.md b/docs/platforms/humanoid/g1/index.md index 2e04f3b023..797c865b20 100644 --- a/docs/platforms/humanoid/g1/index.md +++ b/docs/platforms/humanoid/g1/index.md @@ -13,9 +13,9 @@ The Unitree G1 is a humanoid robot platform with full-body locomotion, arm gestu ## Install First, install system dependencies for your platform: -- [Ubuntu](../../../installation/ubuntu.md) -- [macOS](../../../installation/osx.md) -- [Nix](../../../installation/nix.md) +- [Ubuntu](/docs/installation/ubuntu.md) +- [macOS](/docs/installation/osx.md) +- [Nix](/docs/installation/nix.md) Then install DimOS: @@ -159,9 +159,9 @@ primitive (sensors + vis) ## Deep Dive -- [Navigation Stack](../../../capabilities/navigation/readme.md) — path planning and autonomous exploration -- [Visualization](../../../usage/visualization.md) — Rerun, Foxglove, performance tuning -- [Data Streams](../../../usage/data_streams/) — RxPY streams, backpressure, quality filtering -- [Transports](../../../usage/transports/index.md) — LCM, SHM, DDS -- [Blueprints](../../../usage/blueprints.md) — composing modules -- [Agents](../../../capabilities/agents/readme.md) — LLM agent framework +- [Navigation Stack](/docs/capabilities/navigation/readme.md) — path planning and autonomous exploration +- [Visualization](/docs/usage/visualization.md) — Rerun, Foxglove, performance tuning +- [Data Streams](/docs/usage/data_streams) — RxPY streams, backpressure, quality filtering +- [Transports](/docs/usage/transports/index.md) — LCM, SHM, DDS +- [Blueprints](/docs/usage/blueprints.md) — composing modules +- [Agents](/docs/capabilities/agents/readme.md) — LLM agent framework From 1389b2c39d2414df3a5890754e3f44022900a8a6 Mon Sep 17 00:00:00 2001 From: Ivan Nikolic Date: Wed, 25 Feb 2026 01:31:52 +0800 Subject: [PATCH 15/15] removed autoconf from cli --- dimos/robot/cli/dimos.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/dimos/robot/cli/dimos.py b/dimos/robot/cli/dimos.py index 3979138216..03e9a91349 100644 --- a/dimos/robot/cli/dimos.py +++ b/dimos/robot/cli/dimos.py @@ -102,7 +102,6 @@ def run( ) -> None: """Start a robot blueprint""" from dimos.core.blueprints import autoconnect - from dimos.protocol import pubsub from dimos.robot.get_all_blueprints import get_by_name from dimos.utils.logging_config import setup_exception_handler @@ -110,7 +109,6 @@ def run( cli_config_overrides: dict[str, Any] = ctx.obj global_config.update(**cli_config_overrides) - pubsub.lcm.autoconf() # type: ignore[attr-defined] blueprint = autoconnect(*map(get_by_name, robot_types)) dimos = blueprint.build(cli_config_overrides=cli_config_overrides)