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
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ All config lives under `ros_portal`.
|---|---:|---:|---:|---|
| `$schema` | string | no | - | Link to JSON schema. Use `https://raw.githubusercontent.com/livekit/ros-portal/main/src/ros_portal_config/schema/ros_portal_config.schema.json` and an IDE YAML plugin to validate config files and add autocomplete. |
| `version` | string | yes | - | Configuration schema version, currently `"0.0.1"`. |
| `ros_threads` | integer | no | `0` | ROS executor thread count. `0` uses the available CPU-core count, matching `rclcpp` default. |
| `ros_threads` | integer | no | `0` | Standalone `ros_portal_node` executor thread count. `0` uses the available CPU-core count. In composition, configure the container's `thread_num` parameter instead. |
| `services` | list | no | `[]` | Service route declarations. |
| `topics` | list | no | `[]` | Topic route declarations. |

Expand Down
16 changes: 16 additions & 0 deletions docs/design/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,22 @@ ROS Portal is implemented as a single ROS2 node (`RosPortal`) that:
against the local ROS installation, and republishes accepted CDR payloads
into ROS using the track name as the local topic name.

The node supports two deployment layouts. `ros_portal_node` runs it in a
dedicated process with its own multithreaded executor. The registered
`ros_portal::RosPortalComponent` adapter loads the same implementation into a
generic ROS2 component container. Component mode defers the initial blocking
LiveKit connection attempt until after construction, when the container's
executor is running.

The bundled launch file uses a multithreaded component container. The container
owns its executor, so `thread_num` replaces the standalone `ros_threads`
configuration in this layout. A single worker is supported. More workers let
other callbacks run during synchronous LiveKit RPC operations.

Component instances in one process share a reference-counted LiveKit SDK
runtime. They release the runtime after the last ROS Portal component destroys
its LiveKit resources.

## Topic Discovery

For outbound topic patterns, ROS Portal reconciles subscriptions after each ROS
Expand Down
12 changes: 12 additions & 0 deletions docs/limitations.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Current Limitations

## ROS2 Composition

- A component container can use one worker thread. A synchronous LiveKit RPC
operation occupies that worker until the operation completes. Other ROS
callbacks in the container wait during this time.
- `LIVEKIT_URL`, `LIVEKIT_TOKEN`, LiveKit logging, memory, and fault isolation
are process-wide in composition. A failure that terminates the container
also terminates every component it hosts.
- ROS Portal reference-counts the LiveKit runtime across its component
instances. Other LiveKit components must coordinate process-global SDK
ownership with ROS Portal.

## Video Tracks
- `VideoSource` and `LocalVideoTrack` are created from the first received image
dimensions. If camera resolution changes mid-stream, the track is not
Expand Down
47 changes: 47 additions & 0 deletions docs/running.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,53 @@ source /opt/livekit/ros/$ROS_DISTRO/setup.bash
ros-portal-$ROS_DISTRO config_path:=/path/to/config.yaml
```

## Running ROS Portal in Composition

The package registers `ros_portal::RosPortalComponent`. The bundled composable
launch file starts this component in a multithreaded container:

```bash
# LIVEKIT_URL and LIVEKIT_TOKEN are set in the environment
source install/setup.bash
ros2 launch ros_portal ros_portal_composable.launch.py \
config_path:=/path/to/config.yaml \
container_threads:=4
```

Use `container_threads:=0` to let `rclcpp` select the worker count. The
`ros_threads` configuration field only controls the standalone
`ros_portal_node` executable.

To compose ROS Portal with other components, add its descriptor to the same
multithreaded container in the parent launch file:

```python
from launch_ros.actions import ComposableNodeContainer
from launch_ros.descriptions import ComposableNode

container = ComposableNodeContainer(
name='robot_container',
namespace='',
package='rclcpp_components',
executable='component_container_mt',
parameters=[{'thread_num': 4}],
composable_node_descriptions=[
ComposableNode(
package='ros_portal',
plugin='ros_portal::RosPortalComponent',
name='ros_portal',
parameters=[{'config_path': '/path/to/config.yaml'}],
),
# Add other component descriptions here.
],
output='screen',
)
```

The container process must inherit `LIVEKIT_URL` and `LIVEKIT_TOKEN`. These
environment variables, LiveKit logging, and process failure boundaries are
shared by every component in the container.

## Forwarding Custom Topics

Forwarding custom message types work like any other ROS type:
Expand Down
19 changes: 19 additions & 0 deletions src/ros_portal/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,25 @@ target_link_libraries(ros_portal_lib
)
livekit_set_runtime_rpath(ros_portal_lib)

# ---------------------------------------------------------------------------
# Composable ROS Portal node
# ---------------------------------------------------------------------------
ament_auto_add_library(ros_portal_component SHARED
src/ros_portal_component.cpp
)
target_link_libraries(ros_portal_component
ros_portal_lib
"${LIVEKIT_CORE_TARGET}"
${LIVEKIT_EXTRA_LINK_LIBS}
)
medkit_target_dependencies(ros_portal_component
rclcpp_components
)
livekit_set_runtime_rpath(ros_portal_component)
rclcpp_components_register_nodes(ros_portal_component
"ros_portal::RosPortalComponent"
)

# ---------------------------------------------------------------------------
# Main ROS Portal node
# ---------------------------------------------------------------------------
Expand Down
18 changes: 18 additions & 0 deletions src/ros_portal/include/ros_portal/ros_portal.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,25 @@ class RosPortal : public rclcpp::Node, public livekit::RoomDelegate {
/// should poll this until it returns true rather than assuming immediacy.
bool hasParticipant(const std::string& participant_id) const;

protected:
/// @brief Initialize ROS Portal without making the initial blocking LiveKit
/// connection attempt.
///
/// Component containers own the executor and must not be blocked while a
/// component is being constructed. The regular connection timer makes the
/// first attempt after construction instead.
/// @return True if local initialization completed, false for expected
/// startup failures that have already been logged.
bool initializeForComposition();

private:
/// @brief Initialize ROS Portal and optionally connect immediately.
/// @param connect_immediately Whether to make the first LiveKit connection
/// attempt before returning.
/// @return True if initialization completed, false for expected startup
/// failures that have already been logged.
bool initializeImpl(const bool connect_immediately);

/// @brief Poll the room connection state and make a scheduled connection
/// attempt when needed.
void pollConnection();
Expand Down
90 changes: 90 additions & 0 deletions src/ros_portal/launch/ros_portal_composable.launch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#!/usr/bin/env python3

# Copyright 2026 LiveKit
#
# 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 pathlib import Path

from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument
from launch.actions import OpaqueFunction
from launch.actions import SetEnvironmentVariable
from launch.substitutions import LaunchConfiguration
from launch.substitutions import PathJoinSubstitution
from launch_ros.actions import ComposableNodeContainer
from launch_ros.descriptions import ComposableNode
from launch_ros.substitutions import FindPackageShare


def _launch_setup(context, *args, **kwargs):
config_path_value = LaunchConfiguration('config_path').perform(context).strip()
if config_path_value:
config_path = Path(config_path_value).expanduser()
if not config_path.is_file():
raise RuntimeError(f'Config file does not exist: {config_path}')
config_path_value = str(config_path)

container_name = LaunchConfiguration('container_name').perform(context).strip()
if not container_name:
raise RuntimeError('container_name launch argument must be non-empty')

container_threads_value = LaunchConfiguration('container_threads').perform(context).strip()
try:
container_threads = int(container_threads_value)
except ValueError as exc:
raise RuntimeError('container_threads must be an integer') from exc
if container_threads < 0:
raise RuntimeError('container_threads must be non-negative')

return [
ComposableNodeContainer(
name=container_name,
namespace='',
package='rclcpp_components',
executable='component_container_mt',
output='screen',
emulate_tty=True,
parameters=[{'thread_num': container_threads}],
composable_node_descriptions=[
ComposableNode(
package='ros_portal',
plugin='ros_portal::RosPortalComponent',
name='ros_portal',
namespace=LaunchConfiguration('ns').perform(context),
parameters=[{'config_path': config_path_value}],
),
],
),
]


def generate_launch_description():
default_config = PathJoinSubstitution([
FindPackageShare('ros_portal'),
'config',
'all_topics.yaml',
])

return LaunchDescription([
SetEnvironmentVariable('RCUTILS_COLORIZED_OUTPUT', '1'),
DeclareLaunchArgument('config_path', default_value=default_config),
DeclareLaunchArgument('container_name', default_value='ros_portal_container'),
DeclareLaunchArgument(
'container_threads',
default_value='0',
description='Component container worker threads. Use 0 for the rclcpp default.',
),
DeclareLaunchArgument('ns', default_value='/'),
OpaqueFunction(function=_launch_setup),
])
3 changes: 3 additions & 0 deletions src/ros_portal/package.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
<depend>geometry_msgs</depend>
<depend>nlohmann-json-dev</depend>
<depend>rclcpp</depend>
<depend>rclcpp_components</depend>
<depend>ros2_medkit_serialization</depend>
<depend>rosbag2_cpp</depend>
<depend>rosbag2_storage</depend>
Expand All @@ -52,7 +53,9 @@
<test_depend>ament_cmake_pytest</test_depend>
<test_depend>ament_lint_auto</test_depend>
<test_depend>ament_lint_common</test_depend>
<test_depend>launch_testing_ament_cmake</test_depend>
<test_depend>nav_msgs</test_depend>
<test_depend>rclpy</test_depend>
<test_depend>std_srvs</test_depend>
<test_depend>test_msgs</test_depend>

Expand Down
13 changes: 11 additions & 2 deletions src/ros_portal/src/ros_portal.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,11 @@ RosPortal::RosPortal(const rclcpp::NodeOptions& options)
initializeDiagnostics();
}

bool RosPortal::initialize() {
bool RosPortal::initialize() { return initializeImpl(true); }

bool RosPortal::initializeForComposition() { return initializeImpl(false); }

bool RosPortal::initializeImpl(const bool connect_immediately) {
if (initialized_.load(std::memory_order_relaxed)) {
RCLCPP_WARN(this->get_logger(), "ROS Portal is already initialized");
return true;
Expand Down Expand Up @@ -224,8 +228,13 @@ bool RosPortal::initialize() {
// Mark initialized before the first poll so pollConnection() is not skipped.
// Connect immediately to avoid a 1s timer delay, then log initialized.
initialized_.store(true, std::memory_order_relaxed);
pollConnection();
RCLCPP_INFO(this->get_logger(), "ROS Portal initialized");

if (connect_immediately) {
// Standalone startup retains its immediate first connection attempt. A
// component lets the timer perform this blocking work after construction.
pollConnection();
}
return true;
}

Expand Down
Loading
Loading