Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ sudo apt install ros-jazzy-ros2-medkit-gateway # or ros-humble- / ros-lyrical-
ros2 launch ros2_medkit_gateway bringup.launch.py
```

> [!IMPORTANT]
> `bringup.launch.py` binds the REST API to `127.0.0.1`. Inside a container without
> `--network host` that is the container's own loopback, and the host gets
> `Connection reset by peer` or an empty reply on the published port. Pass `server_host:=0.0.0.0`
> and publish the port. Publishing it as `-p 127.0.0.1:8080:8080` keeps the API, which has no
> authentication by default, off the LAN.

> [!TIP]
> That is the whole setup. It auto-discovers every node, topic, service and action and starts
> emitting structured faults over REST - no instrumentation, no changes to your stack. Prefer
Expand Down Expand Up @@ -157,8 +164,11 @@ docker run -p 3000:80 ghcr.io/selfpatch/ros2_medkit_web_ui:latest
```

The browser calls the gateway from a different origin, so the gateway must allow that origin via
CORS (the prebuilt gateway Docker image enables it; for a native bringup set
`cors.allowed_origins`). See the [web UI tutorial](https://selfpatch.github.io/ros2_medkit/tutorials/web-ui.html).
CORS. The Docker image and `bringup.launch.py` allow `http://localhost:3000` and
`http://localhost:5173`. The origin must match exactly: `http://127.0.0.1:3000` is not
`http://localhost:3000`. To allow other origins, pass the full list, for example
`cors_allowed_origins:=http://localhost:3000,http://127.0.0.1:3000` (it replaces the defaults).
See the [web UI tutorial](https://selfpatch.github.io/ros2_medkit/tutorials/web-ui.html).

</details>

Expand Down
6 changes: 3 additions & 3 deletions docs/api/rest.rst
Original file line number Diff line number Diff line change
Expand Up @@ -229,9 +229,9 @@ Server Capabilities
"/powertrain/engine/rpm_sensor",
"/ros2_medkit_gateway",
"/_param_client_node",
"/ros2_medkit_gateway_fault_clients",
"/ros2_medkit_gateway_lifecycle_state_reader",
"/ros2_medkit_gateway_sub"
"/_ros2_medkit_gateway_fault_clients",
"/_ros2_medkit_gateway_lifecycle_state_reader",
"/_ros2_medkit_gateway_sub"
],
"peer_names": []
}
Expand Down
4 changes: 3 additions & 1 deletion docs/config/discovery-options.rst
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,9 @@ Internal Node Filtering
When ``filter_internal_nodes`` is true (the default), ROS 2 nodes whose names
start with an underscore (``_``) are excluded from the entity tree. This
filters out ROS 2 internal infrastructure nodes such as ``_ros2cli_*``,
``_param_client_node``, and similar system nodes that should not appear as
``_param_client_node``, the gateway's own helper nodes
(``_<gateway name>_fault_clients``, ``_<gateway name>_lifecycle_state_reader``,
``_<gateway name>_sub``), and similar system nodes that should not appear as
SOVD entities. The filter applies to both locally discovered Apps and
peer-discovered Apps (after stripping the peer prefix).

Expand Down
4 changes: 2 additions & 2 deletions docs/getting_started.rst
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ You should see:

.. code-block:: text

[gateway_node]: REST server starting on http://127.0.0.1:8080
[gateway_node]: REST server started successfully
[ros2_medkit_gateway]: Configuration: REST API at 127.0.0.1:8080, backstop refresh interval: 30000ms
[ros2_medkit_gateway]: ROS 2 Medkit Gateway ready on HTTP://127.0.0.1:8080

**Terminal 2 - Start demo nodes:**

Expand Down
44 changes: 41 additions & 3 deletions docs/troubleshooting.rst
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,34 @@ For network access, set host to 0.0.0.0:

ros2 launch ros2_medkit_gateway gateway.launch.py server_host:=0.0.0.0

**"Connection reset by peer" or "Empty reply from server" from a container**

.. code-block:: text

curl: (56) Recv failure: Connection reset by peer
curl: (52) Empty reply from server

The gateway runs in a container on a bridge network with a published port
(``-p 8080:8080``), ``curl`` inside the container works, and ``curl`` on the host
fails. The gateway listens on the container's loopback: ``bringup.launch.py`` and
``gateway.launch.py`` default to ``server_host:=127.0.0.1``. The startup log shows
the bind address:

.. code-block:: text

Configuration: REST API at 127.0.0.1:8080, backstop refresh interval: 30000ms

Bind all interfaces inside the container:

.. code-block:: bash

ros2 launch ros2_medkit_gateway bringup.launch.py server_host:=0.0.0.0

Publish the port as ``-p 127.0.0.1:8080:8080`` to keep the API off the LAN;
authentication is off by default. With ``--network host`` the default bind is
enough, because the container shares the host's loopback. The Docker image's
default command already binds ``0.0.0.0``.

**Topic data returns empty or timeout**

Possible causes:
Expand Down Expand Up @@ -163,9 +191,19 @@ For development, try ``network_mode: host``.

**Web UI can't connect to gateway in container**

1. Gateway must listen on ``0.0.0.0``, not ``127.0.0.1``
2. CORS must allow the UI origin
3. Port must be exposed in docker-compose
1. Gateway must listen on ``0.0.0.0``, not ``127.0.0.1``. The image's default
command does; a launch file needs the argument:

.. code-block:: bash

ros2 launch ros2_medkit_gateway bringup.launch.py server_host:=0.0.0.0

2. CORS must allow the UI origin exactly as the browser sends it (scheme, host
and port): ``http://127.0.0.1:3000`` is not ``http://localhost:3000``. The
defaults are ``http://localhost:3000`` and ``http://localhost:5173``. Pass
``cors_allowed_origins:=<origin>,<origin>`` to the launch file for others; it
replaces the defaults, so list every origin you need.
3. Port must be published (``-p 8080:8080`` or ``ports`` in docker-compose)

Fault Manager Issues
--------------------
Expand Down
13 changes: 13 additions & 0 deletions docs/tutorials/bringup.rst
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ launch arguments:
* - ``server_port``
- ``8080``
- Gateway REST API port.
* - ``cors_allowed_origins``
- ``http://localhost:3000,http://localhost:5173``
- Comma-separated browser origins allowed to call the gateway. Each must
match exactly (``http://127.0.0.1:3000`` is not ``http://localhost:3000``).
A value replaces the defaults; empty disables CORS.
* - ``enable_fault_manager``
- ``true``
- Start the fault_manager node.
Expand All @@ -62,6 +67,14 @@ launch arguments:
- Start the diagnostic_bridge (``/diagnostics`` -> faults). Opt-in, for
legacy ``diagnostic_updater`` publishers.

.. note::

In a container without ``--network host``, ``127.0.0.1`` is the container's
own loopback: the published port answers with ``Connection reset by peer``
or an empty reply. Run ``bringup.launch.py server_host:=0.0.0.0`` and publish
the port as ``-p 127.0.0.1:8080:8080`` to keep the API (no authentication by
default) off the LAN.

Verify
------

Expand Down
31 changes: 27 additions & 4 deletions docs/tutorials/docker.rst
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,28 @@ Test the gateway:
Custom Configuration
--------------------

The default configuration listens on ``0.0.0.0:8080``. CORS is enabled for the
default web UI origins (``http://localhost:3000`` and ``http://localhost:5173``)
so the web UI works out of the box; add your own UI origin(s) as needed (see
`CORS for Web UI`_ below). To use a custom configuration, mount a params file:
The image's default command runs the gateway alone with
``/etc/ros2_medkit/params.yaml``, which listens on ``0.0.0.0:8080``. CORS is
enabled for the default web UI origins (``http://localhost:3000`` and
``http://localhost:5173``) so the web UI works out of the box; add your own UI
origin(s) as needed (see `CORS for Web UI`_ below).

.. note::

A launch file run in the container binds ``127.0.0.1`` unless told
otherwise. Without ``--network host`` that is the container's own loopback,
and the published port answers with ``Connection reset by peer`` or an empty
reply. Pass ``server_host:=0.0.0.0``:

.. code-block:: bash

docker run -p 127.0.0.1:8080:8080 ghcr.io/selfpatch/ros2_medkit-jazzy:latest \
ros2 launch ros2_medkit_gateway bringup.launch.py server_host:=0.0.0.0

``127.0.0.1:8080:8080`` publishes the port on the host's loopback only, which
keeps the API (no authentication by default) off the LAN.

To use a custom configuration, mount a params file:

.. code-block:: bash

Expand Down Expand Up @@ -227,6 +245,11 @@ writes. Add your own UI origin(s):
- "http://localhost:3000"
- "https://my-dashboard.example.com"

The origin must match what the browser sends exactly: ``http://127.0.0.1:3000``
is not ``http://localhost:3000``. When the container runs a launch file, pass
the origins as ``cors_allowed_origins:=http://localhost:3000,http://127.0.0.1:3000``;
the argument replaces the defaults.

Health Checks
-------------

Expand Down
26 changes: 16 additions & 10 deletions docs/tutorials/web-ui.rst
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,11 @@ Connecting to ros2_medkit

.. tip::

If the gateway runs on a different host, ensure CORS is configured.
See :doc:`/config/server` for CORS settings.
The web UI is a different origin than the gateway, so the gateway must allow
it via CORS. The launch files and the Docker image allow
``http://localhost:3000`` and ``http://localhost:5173``, matched exactly:
``http://127.0.0.1:3000`` is rejected. See :doc:`/config/server` for CORS
settings, or pass ``cors_allowed_origins:=`` to the launch file.

Using the Interface
-------------------
Expand Down Expand Up @@ -182,21 +185,24 @@ Run both gateway and web UI together:
.. code-block:: yaml

# docker-compose.yml
version: '3.8'
services:
gateway:
image: ros:jazzy
command: >
bash -c "source /opt/ros/jazzy/setup.bash &&
ros2 launch ros2_medkit_gateway gateway.launch.py server_host:=0.0.0.0"
ports:
- "8080:8080"
image: ghcr.io/selfpatch/ros2_medkit-jazzy:latest
command: ros2 launch ros2_medkit_gateway bringup.launch.py
network_mode: host
ipc: host

web_ui:
image: ghcr.io/selfpatch/ros2_medkit_web_ui:latest
ports:
- "80:80"
- "3000:80"

The gateway shares the host network, so it joins the robot's DDS graph and its
default bind ``127.0.0.1:8080`` is the host's loopback. Open
``http://localhost:3000``: that origin is allowed by default, while
``http://127.0.0.1:3000`` is not. On a bridge network instead, run
``bringup.launch.py server_host:=0.0.0.0`` and publish ``8080`` (see
:doc:`/troubleshooting`).

Docker Image Tags
-----------------
Expand Down
32 changes: 18 additions & 14 deletions src/ros2_medkit_fault_manager/test/test_fault_manager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1060,7 +1060,7 @@ class FaultEventPublishingTest : public ::testing::Test {
std::string events_topic = ns + "/fault_manager/events";
auto qos = rclcpp::QoS(100).reliable().durability_volatile();
event_subscription_ =
test_node_->create_subscription<FaultEvent>(events_topic, qos, [this](const FaultEvent::SharedPtr msg) {
test_node_->create_subscription<FaultEvent>(events_topic, qos, [this](const FaultEvent::ConstSharedPtr & msg) {
received_events_.push_back(*msg);
});

Expand Down Expand Up @@ -1206,7 +1206,7 @@ TEST_F(FaultEventPublishingTest, NewFaultPublishesConfirmedEvent) {

// Wait for event to arrive (polling, robust under CPU contention)
ASSERT_TRUE(spin_until([this]() {
return received_events_.size() >= 1;
return !received_events_.empty();
}));

// Verify EVENT_CONFIRMED was published
Expand All @@ -1221,7 +1221,7 @@ TEST_F(FaultEventPublishingTest, UpdateExistingFaultPublishesUpdatedEvent) {
// Report a new fault first
ASSERT_TRUE(call_report_fault("TEST_FAULT_2", Fault::SEVERITY_WARN, "/test_node1"));
ASSERT_TRUE(spin_until([this]() {
return received_events_.size() >= 1;
return !received_events_.empty();
}));

// Clear received events
Expand All @@ -1230,7 +1230,7 @@ TEST_F(FaultEventPublishingTest, UpdateExistingFaultPublishesUpdatedEvent) {
// Report same fault again - should trigger EVENT_UPDATED
ASSERT_TRUE(call_report_fault("TEST_FAULT_2", Fault::SEVERITY_ERROR, "/test_node2"));
ASSERT_TRUE(spin_until([this]() {
return received_events_.size() >= 1;
return !received_events_.empty();
}));

// Verify EVENT_UPDATED was published (severity/sources changed; still one occurrence)
Expand All @@ -1244,7 +1244,7 @@ TEST_F(FaultEventPublishingTest, ClearFaultPublishesClearedEvent) {
// Report a fault first
ASSERT_TRUE(call_report_fault("TEST_FAULT_3", Fault::SEVERITY_ERROR, "/test_node"));
ASSERT_TRUE(spin_until([this]() {
return received_events_.size() >= 1;
return !received_events_.empty();
}));

// Clear received events
Expand All @@ -1253,7 +1253,7 @@ TEST_F(FaultEventPublishingTest, ClearFaultPublishesClearedEvent) {
// Clear the fault
ASSERT_TRUE(call_clear_fault("TEST_FAULT_3"));
ASSERT_TRUE(spin_until([this]() {
return received_events_.size() >= 1;
return !received_events_.empty();
}));

// Verify EVENT_CLEARED was published
Expand All @@ -1280,7 +1280,7 @@ class HealingFaultEventPublishingTest : public FaultEventPublishingTest {
TEST_F(HealingFaultEventPublishingTest, HealPublishesClearedEventSoStreamConsumersSeeTheEnd) {
ASSERT_TRUE(call_report_fault("HEAL_ME", Fault::SEVERITY_ERROR, "/test_node"));
ASSERT_TRUE(spin_until([this]() {
return received_events_.size() >= 1;
return !received_events_.empty();
}));
received_events_.clear();

Expand Down Expand Up @@ -1323,7 +1323,7 @@ TEST_F(FaultEventPublishingTest, EventContainsCorrectTimestamp) {

ASSERT_TRUE(call_report_fault("TEST_FAULT_4", Fault::SEVERITY_WARN, "/test_node"));
ASSERT_TRUE(spin_until([this]() {
return received_events_.size() >= 1;
return !received_events_.empty();
}));

auto after = fault_manager_->now();
Expand All @@ -1339,7 +1339,7 @@ TEST_F(FaultEventPublishingTest, EventContainsCorrectTimestamp) {
TEST_F(FaultEventPublishingTest, EventContainsFullFaultData) {
ASSERT_TRUE(call_report_fault("FULL_DATA_TEST", Fault::SEVERITY_CRITICAL, "/sensor/temperature"));
ASSERT_TRUE(spin_until([this]() {
return received_events_.size() >= 1;
return !received_events_.empty();
}));

ASSERT_EQ(received_events_.size(), 1u);
Expand All @@ -1362,7 +1362,7 @@ TEST_F(FaultEventPublishingTest, TimestampUsesWallClockNotSimTime) {

ASSERT_TRUE(call_report_fault("WALL_CLOCK_TEST", Fault::SEVERITY_WARN, "/test_node"));
ASSERT_TRUE(spin_until([this]() {
return received_events_.size() >= 1;
return !received_events_.empty();
}));

auto wall_after = std::chrono::system_clock::now();
Expand Down Expand Up @@ -1390,7 +1390,7 @@ TEST_F(FaultEventPublishingTest, GetFaultReturnsExpectedFault) {
// Report a fault first
ASSERT_TRUE(call_report_fault("GET_FAULT_TEST", Fault::SEVERITY_ERROR, "/test_node"));
ASSERT_TRUE(spin_until([this]() {
return received_events_.size() >= 1;
return !received_events_.empty();
}));

// Get fault via service
Expand All @@ -1414,7 +1414,7 @@ TEST_F(FaultEventPublishingTest, GetFaultReturnsEnvironmentData) {
// Report a fault
ASSERT_TRUE(call_report_fault("ENV_DATA_TEST", Fault::SEVERITY_WARN, "/sensor/temp"));
ASSERT_TRUE(spin_until([this]() {
return received_events_.size() >= 1;
return !received_events_.empty();
}));

auto response = call_get_fault("ENV_DATA_TEST");
Expand All @@ -1434,7 +1434,7 @@ TEST_F(FaultEventPublishingTest, GetFaultReturnsExtendedDataRecords) {
// Report fault twice to have first and last occurrence timestamps differ
ASSERT_TRUE(call_report_fault("EDR_TEST", Fault::SEVERITY_ERROR, "/node1"));
ASSERT_TRUE(spin_until([this]() {
return received_events_.size() >= 1;
return !received_events_.empty();
}));
ASSERT_TRUE(call_report_fault("EDR_TEST", Fault::SEVERITY_ERROR, "/node2"));
ASSERT_TRUE(spin_until([this]() {
Expand Down Expand Up @@ -1482,7 +1482,7 @@ TEST_F(FaultEventPublishingTest, ListFaultsForEntityEmptyResult) {
// Report faults from a different entity
ASSERT_TRUE(call_report_fault("SOME_FAULT", Fault::SEVERITY_ERROR, "/some/other_entity"));
ASSERT_TRUE(spin_until([this]() {
return received_events_.size() >= 1;
return !received_events_.empty();
}));

// Query faults for non-existent entity
Expand Down Expand Up @@ -1883,6 +1883,10 @@ class SnapshotCooldownTest : public ::testing::Test {
spin_thread_ = std::thread([this]() {
executor_.spin();
});
// A cancel() that lands before spin() starts is lost, and join() would block.
while (!executor_.is_spinning()) {
std::this_thread::yield();
}

ASSERT_TRUE(report_client_->wait_for_service(std::chrono::seconds(5)));
ASSERT_TRUE(clear_client_->wait_for_service(std::chrono::seconds(5)));
Expand Down
6 changes: 6 additions & 0 deletions src/ros2_medkit_gateway/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ add_library(gateway_ros2 STATIC
src/plugins/plugin_loader.cpp
src/plugins/plugin_manager.cpp
src/ros2_common/callback_groups.cpp
src/ros2_common/helper_node.cpp
src/ros2_common/ros2_subscription_executor.cpp
src/ros2_common/ros2_subscription_slot.cpp
src/script_manager.cpp
Expand Down Expand Up @@ -882,6 +883,11 @@ if(BUILD_TESTING)
target_link_libraries(test_ros2_lifecycle_state_reader gateway_ros2)
medkit_target_dependencies(test_ros2_lifecycle_state_reader rclcpp lifecycle_msgs)

# Helper nodes keep their own name under a process-wide __node remap
medkit_add_gtest(test_helper_node test/test_helper_node.cpp)
target_link_libraries(test_helper_node gateway_ros2)
medkit_target_dependencies(test_helper_node rclcpp)

# Private client nodes torn down after a first graph wait that follows rclcpp::shutdown()
medkit_add_gtest(test_graph_listener_join test/test_graph_listener_join.cpp)
target_link_libraries(test_graph_listener_join gateway_ros2)
Expand Down
Loading
Loading