diff --git a/.github/workflows/build.yml b/.github/workflows/build-ros1.yml similarity index 79% rename from .github/workflows/build.yml rename to .github/workflows/build-ros1.yml index 5f8c180..f11e138 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build-ros1.yml @@ -11,24 +11,16 @@ on: - main jobs: - build-cpython: + build-ros1: runs-on: ${{ matrix.os }} strategy: matrix: name: [ - "ubuntu-py37", - "ubuntu-py38", "ubuntu-py39", "ubuntu-py310", "ubuntu-py311", ] include: - - name: "ubuntu-py37" - os: ubuntu-latest - python-version: "3.7" - - name: "ubuntu-py38" - os: ubuntu-latest - python-version: "3.8" - name: "ubuntu-py39" os: ubuntu-latest python-version: "3.9" @@ -53,15 +45,15 @@ jobs: python -m pip install --no-cache-dir -r requirements-dev.txt - name: Set up docker containers run: | - docker build -t gramaziokohler/rosbridge:integration_tests ./docker - docker run -d -p 9090:9090 --name rosbridge gramaziokohler/rosbridge:integration_tests /bin/bash -c "roslaunch /integration-tests.launch" + docker build -t gramaziokohler/rosbridge:integration_tests_ros1 ./docker/ros1 + docker run -d -p 9090:9090 --name rosbridge gramaziokohler/rosbridge:integration_tests_ros1 /bin/bash -c "roslaunch /integration-tests.launch" docker ps -a - name: Run linter run: | invoke check - name: Run tests run: | - pytest + pytest tests/ros1 - name: Tear down docker containers run: | docker rm -f rosbridge diff --git a/.github/workflows/build-ros2.yml b/.github/workflows/build-ros2.yml new file mode 100644 index 0000000..29a94ab --- /dev/null +++ b/.github/workflows/build-ros2.yml @@ -0,0 +1,59 @@ +name: build + +on: + push: + branches: + - main + tags: + - 'v*' + pull_request: + branches: + - main + +jobs: + build-ros2: + runs-on: ${{ matrix.os }} + strategy: + matrix: + name: [ + "ubuntu-py39", + "ubuntu-py310", + "ubuntu-py311", + ] + include: + - name: "ubuntu-py39" + os: ubuntu-latest + python-version: "3.9" + - name: "ubuntu-py310" + os: ubuntu-latest + python-version: "3.10" + - name: "ubuntu-py311" + os: ubuntu-latest + python-version: "3.11" + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install wheel + - name: Install + run: | + python -m pip install --no-cache-dir -r requirements-dev.txt + - name: Set up docker containers + run: | + docker build -t gramaziokohler/rosbridge:integration_tests_ros2 ./docker/ros2 + docker run -d -p 9090:9090 --name rosbridge gramaziokohler/rosbridge:integration_tests_ros2 /bin/bash -c "ros2 launch /integration-tests.launch" + docker ps -a + - name: Run linter + run: | + invoke check + - name: Run tests + run: | + pytest tests/ros2 + - name: Tear down docker containers + run: | + docker rm -f rosbridge diff --git a/AUTHORS.rst b/AUTHORS.rst index b5f29ff..43af378 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -10,3 +10,4 @@ Authors * Hiroyuki Obinata `@obi-t4 `_ * Pedro Pereira `@MisterOwlPT `_ * Domenic Rodriguez `@DomenicP `_ +* Ilia Baranov `@iliabaranov `_ diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e915c28..2a36fb1 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -12,6 +12,8 @@ Unreleased **Added** +* Added a ROS2-compatible header class in ``roslibpy.ros2.Header``. + **Changed** **Fixed** diff --git a/README.rst b/README.rst index c9b9ff1..bdeabfb 100644 --- a/README.rst +++ b/README.rst @@ -44,6 +44,8 @@ local ROS environment, allowing usage from platforms other than Linux. The API of **roslibpy** is modeled to closely match that of `roslibjs`_. +ROS1 is fully supported. ROS2 support is still in progress. + Main features ------------- diff --git a/docker/Dockerfile b/docker/ros1/Dockerfile similarity index 85% rename from docker/Dockerfile rename to docker/ros1/Dockerfile index 87ed789..0495d7e 100644 --- a/docker/Dockerfile +++ b/docker/ros1/Dockerfile @@ -13,11 +13,9 @@ RUN apt-get update && apt-get install -y \ # Clear apt-cache to reduce image size && rm -rf /var/lib/apt/lists/* -# Copy entrypoint -COPY ./ros_entrypoint.sh / +# Copy launch COPY ./integration-tests.launch / EXPOSE 9090 -ENTRYPOINT ["/ros_entrypoint.sh"] CMD ["bash"] diff --git a/docker/integration-tests.launch b/docker/ros1/integration-tests.launch similarity index 100% rename from docker/integration-tests.launch rename to docker/ros1/integration-tests.launch diff --git a/docker/ros2/Dockerfile b/docker/ros2/Dockerfile new file mode 100644 index 0000000..029eff5 --- /dev/null +++ b/docker/ros2/Dockerfile @@ -0,0 +1,21 @@ +FROM ros:iron +LABEL maintainer "Gonzalo Casas " + +SHELL ["/bin/bash","-c"] + +# Install rosbridge +RUN apt-get update && apt-get install -y \ + ros-iron-rosbridge-suite \ + # ros-iron-tf2-web-republisher \ + # ros-iron-ros-tutorials \ + # ros-iron-actionlib-tutorials \ + --no-install-recommends \ + # Clear apt-cache to reduce image size + && rm -rf /var/lib/apt/lists/* + +# Copy launch +COPY ./integration-tests.launch / + +EXPOSE 9090 + +CMD ["bash"] diff --git a/docker/ros2/integration-tests.launch b/docker/ros2/integration-tests.launch new file mode 100644 index 0000000..1aedc6d --- /dev/null +++ b/docker/ros2/integration-tests.launch @@ -0,0 +1,4 @@ + + + + diff --git a/docker/ros_entrypoint.sh b/docker/ros_entrypoint.sh deleted file mode 100755 index e393ff5..0000000 --- a/docker/ros_entrypoint.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash -set -e - -# Source ROS distro environment -source "/opt/ros/$ROS_DISTRO/setup.bash" - -exec "$@" diff --git a/docs/index.rst b/docs/index.rst index 7d074c2..5736c82 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -44,6 +44,8 @@ local ROS environment, allowing usage from platforms other than Linux. The API of **roslibpy** is modeled to closely match that of `roslibjs `_. +ROS1 is fully supported. ROS2 support is still in progress. + ======== Contents ======== diff --git a/docs/reference/index.rst b/docs/reference/index.rst index a15ca8b..9556e53 100644 --- a/docs/reference/index.rst +++ b/docs/reference/index.rst @@ -10,3 +10,4 @@ API Reference .. automodule:: roslibpy .. automodule:: roslibpy.actionlib .. automodule:: roslibpy.tf +.. automodule:: roslibpy.ros2 diff --git a/src/roslibpy/__init__.py b/src/roslibpy/__init__.py index 76c39b5..201890a 100644 --- a/src/roslibpy/__init__.py +++ b/src/roslibpy/__init__.py @@ -41,6 +41,19 @@ Main ROS concepts ================= +ROS1 vs ROS2 +------------ + +This library has been tested to work with ROS1. ROS2 should work, but it is still +in the works. + +One area in which ROS1 and ROS2 differ is in the header interface. To use ROS2, use +the header defined in the `roslibpy.ros2` module. + +.. autoclass:: roslibpy.ros2.Header + :members: + + Topics ------ diff --git a/src/roslibpy/core.py b/src/roslibpy/core.py index 90ca6f5..6424155 100644 --- a/src/roslibpy/core.py +++ b/src/roslibpy/core.py @@ -48,7 +48,11 @@ def __init__(self, values=None): class Header(UserDict): - """Represents a message header of the ROS type std_msgs/Header.""" + """Represents a message header of the ROS type std_msgs/Header. + + This header is only compatible with ROS1. For ROS2 headers, use :class:`roslibpy.ros2.Header`. + + """ def __init__(self, seq=None, stamp=None, frame_id=None): self.data = {} diff --git a/src/roslibpy/ros2/__init__.py b/src/roslibpy/ros2/__init__.py new file mode 100644 index 0000000..02364ee --- /dev/null +++ b/src/roslibpy/ros2/__init__.py @@ -0,0 +1,16 @@ +from roslibpy import Header as ROS1Header +from roslibpy import Time + +__all__ = [ + "Header", +] + + +class Header(ROS1Header): + """Represents a message header of the ROS type std_msgs/Header.""" + + def __init__(self, stamp=None, frame_id=None): + super(Header, self).__init__(stamp=stamp, frame_id=frame_id) + self.data["stamp"] = Time(stamp["secs"], stamp["nsecs"]) if stamp else None + self.data["frame_id"] = frame_id + del self.data["seq"] diff --git a/tests/test_actionlib.py b/tests/ros1/test_actionlib.py similarity index 100% rename from tests/test_actionlib.py rename to tests/ros1/test_actionlib.py diff --git a/tests/test_core.py b/tests/ros1/test_core.py similarity index 100% rename from tests/test_core.py rename to tests/ros1/test_core.py diff --git a/tests/test_param.py b/tests/ros1/test_param.py similarity index 100% rename from tests/test_param.py rename to tests/ros1/test_param.py diff --git a/tests/test_ros.py b/tests/ros1/test_ros.py similarity index 100% rename from tests/test_ros.py rename to tests/ros1/test_ros.py diff --git a/tests/test_rosapi.py b/tests/ros1/test_rosapi.py similarity index 100% rename from tests/test_rosapi.py rename to tests/ros1/test_rosapi.py diff --git a/tests/test_service.py b/tests/ros1/test_service.py similarity index 100% rename from tests/test_service.py rename to tests/ros1/test_service.py diff --git a/tests/test_tf.py b/tests/ros1/test_tf.py similarity index 100% rename from tests/test_tf.py rename to tests/ros1/test_tf.py diff --git a/tests/test_topic.py b/tests/ros1/test_topic.py similarity index 100% rename from tests/test_topic.py rename to tests/ros1/test_topic.py diff --git a/tests/ros2/test_core.py b/tests/ros2/test_core.py new file mode 100644 index 0000000..cbe5f32 --- /dev/null +++ b/tests/ros2/test_core.py @@ -0,0 +1,59 @@ +import pytest + +from roslibpy import Header, Time + +REF_FLOAT_SECS_TIME = 1610122759.677662 + + +def test_time_from_sec_based_on_time_module(): + t = Time.from_sec(REF_FLOAT_SECS_TIME) + assert t.secs == 1610122759 + assert t.nsecs == 677661895 + + +def test_to_nsec(): + t = Time.from_sec(REF_FLOAT_SECS_TIME) + assert t.to_nsec() == 1610122759677661895 + + +def test_to_sec(): + t = Time.from_sec(REF_FLOAT_SECS_TIME) + assert t.to_sec() == REF_FLOAT_SECS_TIME + + +def test_is_zero(): + assert Time(0, 0).is_zero() + assert Time(1, 0).is_zero() is False + + +def test_header_ctor_supports_time(): + header = Header(stamp=Time.from_sec(REF_FLOAT_SECS_TIME)) + assert header["stamp"]["secs"] == 1610122759 + assert header["stamp"]["secs"] == header["stamp"].secs + assert header["stamp"].to_sec() == REF_FLOAT_SECS_TIME + + +def test_header_ctor_supports_dict(): + header = Header(stamp=dict(secs=1610122759, nsecs=677661895)) + assert header["stamp"]["secs"] == 1610122759 + assert header["stamp"]["secs"] == header["stamp"].secs + assert header["stamp"].to_sec() == REF_FLOAT_SECS_TIME + + +def test_time_accepts_only_ints(): + with pytest.raises(ValueError): + Time(1.3, 1.0) + with pytest.raises(ValueError): + Time(100.0, 3.1) + + t = Time(110.0, 0.0) + assert t.secs == 110 + assert t.nsecs == 0 + + +def test_time_properties_are_readonly(): + t = Time.now() + with pytest.raises(AttributeError): + t.secs = 10 + with pytest.raises(AttributeError): + t.nsecs = 10 diff --git a/tests/ros2/test_ros.py b/tests/ros2/test_ros.py new file mode 100644 index 0000000..aef72c5 --- /dev/null +++ b/tests/ros2/test_ros.py @@ -0,0 +1,95 @@ +from __future__ import print_function + +import threading +import time + +from roslibpy import Ros + +host = "127.0.0.1" +port = 9090 +url = "ws://%s:%d" % (host, port) + + +def test_reconnect_does_not_trigger_on_client_close(): + ros = Ros(host, port) + ros.run() + + assert ros.is_connected, "ROS initially connected" + time.sleep(0.5) + event = threading.Event() + ros.on("close", lambda m: event.set()) + ros.close() + event.wait(5) + + assert not ros.is_connected, "Successful disconnect" + assert not ros.is_connecting, "Not trying to re-connect" + + +def test_connection(): + ros = Ros(host, port) + ros.run() + assert ros.is_connected + ros.close() + + +def test_url_connection(): + ros = Ros(url) + ros.run() + assert ros.is_connected + ros.close() + + +def test_closing_event(): + ros = Ros(url) + ros.run() + ctx = dict(closing_event_called=False, was_still_connected=False) + + def handle_closing(): + ctx["closing_event_called"] = True + ctx["was_still_connected"] = ros.is_connected + time.sleep(1.5) + + ts_start = time.time() + ros.on("closing", handle_closing) + ros.close() + ts_end = time.time() + closing_was_handled_synchronously_before_close = ts_end - ts_start >= 1.5 + + assert ctx["closing_event_called"] + assert ctx["was_still_connected"] + assert closing_was_handled_synchronously_before_close + + +def test_multithreaded_connect_disconnect(): + CONNECTIONS = 30 + clients = [] + + def connect(clients): + ros = Ros(url) + ros.run() + clients.append(ros) + + # First connect all + threads = [] + for _ in range(CONNECTIONS): + thread = threading.Thread(target=connect, args=(clients,)) + thread.daemon = False + thread.start() + threads.append(thread) + + for thread in threads: + thread.join() + + # Assert connection status + for ros in clients: + assert ros.is_connected + + # Now disconnect all + for ros in clients: + ros.close() + + time.sleep(0.5) + + # Assert connection status + for ros in clients: + assert not ros.is_connected diff --git a/tests/ros2/test_rosapi.py b/tests/ros2/test_rosapi.py new file mode 100644 index 0000000..2f37ac1 --- /dev/null +++ b/tests/ros2/test_rosapi.py @@ -0,0 +1,47 @@ +import threading + +import pytest + +from roslibpy import Ros + +host = "127.0.0.1" +port = 9090 +url = "ws://%s:%d" % (host, port) + + +def test_rosapi_topics(): + context = dict(wait=threading.Event(), result=None) + ros = Ros(host, port) + ros.run() + + def callback(topic_list): + context["result"] = topic_list + context["wait"].set() + + ros.get_topics(callback) + if not context["wait"].wait(5): + raise Exception + + assert "/rosout" in context["result"]["topics"] + ros.close() + + +def test_rosapi_topics_blocking(): + ros = Ros(host, port) + ros.run() + topic_list = ros.get_topics() + + print(topic_list) + assert "/rosout" in topic_list + + ros.close() + + +def test_connection_fails_when_missing_port(): + with pytest.raises(Exception): + Ros(host) + + +def test_connection_fails_when_schema_not_ws(): + with pytest.raises(Exception): + Ros("http://%s:%d" % (host, port)) diff --git a/tests/ros2/test_topic.py b/tests/ros2/test_topic.py new file mode 100644 index 0000000..b899176 --- /dev/null +++ b/tests/ros2/test_topic.py @@ -0,0 +1,94 @@ +from __future__ import print_function + +import threading +import time + +from roslibpy import Message, Ros, Time, Topic +from roslibpy.ros2 import Header + + +def test_topic_pubsub(): + context = dict(wait=threading.Event(), counter=0) + + ros = Ros("127.0.0.1", 9090) + ros.run() + + listener = Topic(ros, "/chatter", "std_msgs/String") + publisher = Topic(ros, "/chatter", "std_msgs/String") + + def receive_message(message): + context["counter"] += 1 + assert message["data"] == "hello world", "Unexpected message content" + + if context["counter"] == 3: + listener.unsubscribe() + context["wait"].set() + + def start_sending(): + while True: + if context["counter"] >= 3: + break + publisher.publish(Message({"data": "hello world"})) + time.sleep(0.1) + publisher.unadvertise() + + def start_receiving(): + listener.subscribe(receive_message) + + t1 = threading.Thread(target=start_receiving) + t2 = threading.Thread(target=start_sending) + + t1.start() + t2.start() + + if not context["wait"].wait(10): + raise Exception + + t1.join() + t2.join() + + assert context["counter"] >= 3, "Expected at least 3 messages but got " + str(context["counter"]) + ros.close() + + +def test_topic_with_header(): + context = dict(wait=threading.Event()) + + ros = Ros("127.0.0.1", 9090) + ros.run() + + listener = Topic(ros, "/points", "geometry_msgs/PointStamped") + publisher = Topic(ros, "/points", "geometry_msgs/PointStamped") + + def receive_message(message): + assert message["header"]["frame_id"] == "base" + assert message["point"]["x"] == 0.0 + assert message["point"]["y"] == 1.0 + assert message["point"]["z"] == 2.0 + listener.unsubscribe() + context["wait"].set() + + def start_sending(): + for _ in range(3): + msg = dict(header=Header(stamp=Time.now(), frame_id="base"), point=dict(x=0.0, y=1.0, z=2.0)) + publisher.publish(Message(msg)) + time.sleep(0.1) + + publisher.unadvertise() + + def start_receiving(): + listener.subscribe(receive_message) + + t1 = threading.Thread(target=start_receiving) + t2 = threading.Thread(target=start_sending) + + t1.start() + t2.start() + + if not context["wait"].wait(10): + raise Exception + + t1.join() + t2.join() + + ros.close()