diff --git a/src/rai_core/rai/tools/ros2/generic/topics.py b/src/rai_core/rai/tools/ros2/generic/topics.py index ad4a1cb67..1b2a8252a 100644 --- a/src/rai_core/rai/tools/ros2/generic/topics.py +++ b/src/rai_core/rai/tools/ros2/generic/topics.py @@ -28,6 +28,7 @@ from rai.communication.ros2.api.conversion import ros2_message_to_dict from rai.messages import MultimodalArtifact, preprocess_image from rai.tools.ros2.base import BaseROS2Tool, BaseROS2Toolkit +from rai.tools.timeout import require_positive_timeout from rai.tools.ros2.generic.interface_parser import render_interface_string @@ -112,6 +113,7 @@ class ReceiveROS2MessageTool(BaseROS2Tool): def _run(self, topic: str, timeout_sec: float = 1.0) -> str: if not self.is_readable(topic): raise ValueError(f"Topic {topic} is not readable") + timeout_sec = require_positive_timeout(timeout_sec) message = self.connector.receive_message(topic, timeout_sec=timeout_sec) return str({"payload": message.payload, "metadata": message.metadata}) @@ -133,6 +135,7 @@ def _run( ) -> Tuple[str, MultimodalArtifact]: if not self.is_readable(topic): raise ValueError(f"Topic {topic} is not readable") + timeout_sec = require_positive_timeout(timeout_sec) message = self.connector.receive_message(topic, timeout_sec=timeout_sec) msg_type = type(message.payload) if msg_type == Image: @@ -264,6 +267,7 @@ class GetROS2TransformTool(BaseROS2Tool): STALE_TRANSFORM_THRESHOLD_SEC: float = 1.0 def _run(self, target_frame: str, source_frame: str, timeout_sec: float) -> str: + timeout_sec = require_positive_timeout(timeout_sec) transform = self.connector.get_transform( target_frame=target_frame, source_frame=source_frame, diff --git a/src/rai_core/rai/tools/timeout.py b/src/rai_core/rai/tools/timeout.py index 6d814853f..af163a70b 100644 --- a/src/rai_core/rai/tools/timeout.py +++ b/src/rai_core/rai/tools/timeout.py @@ -159,3 +159,23 @@ def wrapper(self, *args, **kwargs): return wrapper return decorator + + +def require_positive_timeout(timeout_sec: object, *, name: str = "timeout_sec") -> float: + """Validate timeout is a real positive number (bool rejected). + + Returns the value as float. Raises ValueError on invalid input. + """ + if timeout_sec is True or timeout_sec is False: + raise ValueError(f"{name} must be a positive number") + if isinstance(timeout_sec, int) and not isinstance(timeout_sec, bool): + value = float(timeout_sec) + elif isinstance(timeout_sec, float): + value = float(timeout_sec) + else: + raise ValueError(f"{name} must be a positive number") + if value != value: # NaN + raise ValueError(f"{name} must be a positive number") + if value <= 0: + raise ValueError(f"{name} must be positive") + return value diff --git a/tests/tools/test_require_positive_timeout.py b/tests/tools/test_require_positive_timeout.py new file mode 100644 index 000000000..08cdfec93 --- /dev/null +++ b/tests/tools/test_require_positive_timeout.py @@ -0,0 +1,30 @@ +# Copyright (C) 2026 Robotec.AI +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import math + +import pytest + +from rai.tools.timeout import require_positive_timeout + + +@pytest.mark.parametrize("value", [0, -1, -0.01, True, False, "1", None, math.nan, object()]) +def test_require_positive_timeout_rejects_invalid(value): + with pytest.raises(ValueError, match="timeout"): + require_positive_timeout(value) + + +@pytest.mark.parametrize("value,expected", [(1, 1.0), (0.5, 0.5), (2, 2.0)]) +def test_require_positive_timeout_accepts_positive(value, expected): + assert require_positive_timeout(value) == expected