Skip to content
Open
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
1 change: 1 addition & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"name": "ROS2 ros2_medkit Development",
"runArgs": ["--network=host"],
"build": {
"dockerfile": "Dockerfile",
"context": "..",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
"components"
]
},
"description": "List all discovered components across all areas. Returns component metadata including id, namespace, fqn, type, parent area, and available operations (services and actions). Each operation includes type_info with schema information: services have request/response schemas, actions have goal/result/feedback schemas."
"description": "List all discovered components across all areas. Returns component metadata including id, namespace, fqn, type, area, source (node or topic), and available operations (services and actions). The 'source' field indicates whether the component was discovered from a ROS 2 node ('node') or from topic namespaces ('topic' - for systems like Isaac Sim)."
},
"response": []
},
Expand Down
3 changes: 2 additions & 1 deletion src/ros2_medkit_fault_manager/src/fault_storage.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ InMemoryFaultStorage::get_faults(bool filter_by_severity, uint8_t severity,
// Determine which statuses to include
std::set<std::string> status_filter;
if (statuses.empty()) {
// Default: CONFIRMED only
// Default: PENDING and CONFIRMED (exclude CLEARED)
status_filter.insert(ros2_medkit_msgs::msg::Fault::STATUS_PENDING);
status_filter.insert(ros2_medkit_msgs::msg::Fault::STATUS_CONFIRMED);
} else {
for (const auto & s : statuses) {
Expand Down
7 changes: 4 additions & 3 deletions src/ros2_medkit_fault_manager/test/test_fault_manager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -62,16 +62,17 @@ TEST_F(FaultStorageTest, ReportExistingFaultUpdates) {
EXPECT_EQ(fault->reporting_sources.size(), 2u);
}

TEST_F(FaultStorageTest, GetFaultsDefaultReturnsConfirmedOnly) {
TEST_F(FaultStorageTest, GetFaultsDefaultReturnsPendingAndConfirmed) {
rclcpp::Clock clock;
auto timestamp = clock.now();

// Report a fault (starts as PENDING)
storage_.report_fault("FAULT_1", Fault::SEVERITY_ERROR, "Test", "/node1", timestamp);

// Default query should return empty (only PENDING exists)
// Default query should return PENDING faults (PENDING + CONFIRMED by default)
auto faults = storage_.get_faults(false, 0, {});
EXPECT_EQ(faults.size(), 0u);
EXPECT_EQ(faults.size(), 1u);
EXPECT_EQ(faults[0].status, Fault::STATUS_PENDING);
}

TEST_F(FaultStorageTest, GetFaultsWithPendingStatus) {
Expand Down
15 changes: 15 additions & 0 deletions src/ros2_medkit_gateway/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,17 @@ if(BUILD_TESTING)
ament_add_gtest(test_configuration_manager test/test_configuration_manager.cpp)
target_link_libraries(test_configuration_manager gateway_lib)

# Add NativeTopicSampler tests
find_package(std_msgs REQUIRED)
ament_add_gtest(test_native_topic_sampler test/test_native_topic_sampler.cpp)
target_link_libraries(test_native_topic_sampler gateway_lib)
ament_target_dependencies(test_native_topic_sampler std_msgs)

# Add DiscoveryManager tests
ament_add_gtest(test_discovery_manager test/test_discovery_manager.cpp)
target_link_libraries(test_discovery_manager gateway_lib)
ament_target_dependencies(test_discovery_manager std_msgs)

# Apply coverage flags to test targets
if(ENABLE_COVERAGE)
target_compile_options(test_gateway_node PRIVATE --coverage -O0 -g)
Expand All @@ -135,6 +146,10 @@ if(BUILD_TESTING)
target_link_options(test_operation_manager PRIVATE --coverage)
target_compile_options(test_configuration_manager PRIVATE --coverage -O0 -g)
target_link_options(test_configuration_manager PRIVATE --coverage)
target_compile_options(test_native_topic_sampler PRIVATE --coverage -O0 -g)
target_link_options(test_native_topic_sampler PRIVATE --coverage)
target_compile_options(test_discovery_manager PRIVATE --coverage -O0 -g)
target_link_options(test_discovery_manager PRIVATE --coverage)
endif()

# Integration testing
Expand Down
39 changes: 31 additions & 8 deletions src/ros2_medkit_gateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,23 +90,26 @@ curl http://localhost:8080/api/v1/components
"namespace": "/powertrain/engine",
"fqn": "/powertrain/engine/temp_sensor",
"type": "Component",
"area": "powertrain"
"area": "powertrain",
"source": "node"
},
{
"id": "rpm_sensor",
"namespace": "/powertrain/engine",
"fqn": "/powertrain/engine/rpm_sensor",
"id": "carter1",
"namespace": "/carter1",
"fqn": "/carter1",
"type": "Component",
"area": "powertrain"
"area": "carter1",
"source": "topic"
}
]
```

**Response Fields:**
- `id` - Component name (node name)
- `id` - Component name (node name or namespace for topic-based)
- `namespace` - ROS 2 namespace where the component is running
- `fqn` - Fully qualified name (namespace + node name)
- `type` - Always "Component"
- `source` - Discovery source: `"node"` (standard ROS 2 node) or `"topic"` (discovered from topic namespaces)
- `area` - Parent area this component belongs to

#### GET /api/v1/areas/{area_id}/components
Expand All @@ -126,14 +129,16 @@ curl http://localhost:8080/api/v1/areas/powertrain/components
"namespace": "/powertrain/engine",
"fqn": "/powertrain/engine/temp_sensor",
"type": "Component",
"area": "powertrain"
"area": "powertrain",
"source": "node"
},
{
"id": "rpm_sensor",
"namespace": "/powertrain/engine",
"fqn": "/powertrain/engine/rpm_sensor",
"type": "Component",
"area": "powertrain"
"area": "powertrain",
"source": "node"
}
]
```
Expand Down Expand Up @@ -659,6 +664,24 @@ cors:
- **REST Server**: HTTP server using cpp-httplib
- **Entity Cache**: In-memory cache of discovered areas and components, updated periodically

### Topic-Based Discovery

In addition to standard ROS 2 node discovery, the gateway supports **topic-based discovery** for systems that publish topics without creating discoverable nodes (e.g., NVIDIA Isaac Sim, hardware bridges).

**How it works:**
1. Gateway scans all topics in the ROS 2 graph
2. Extracts unique namespace prefixes (e.g., `/carter1/odom` → `carter1`)
3. Creates virtual "components" for namespaces that have topics but no nodes
4. These components have `"source": "topic"` to distinguish them from node-based components

**Example:** Isaac Sim publishes topics like `/carter1/odom`, `/carter1/cmd_vel`, `/carter2/imu` without creating ROS 2 nodes. The gateway discovers:
- Component `carter1` with topics: `/carter1/odom`, `/carter1/cmd_vel`
- Component `carter2` with topics: `/carter2/imu`

**System topic filtering:** The following topics are filtered out during discovery:
- `/parameter_events`, `/rosout`, `/clock`
- Note: `/tf` and `/tf_static` are NOT filtered (useful for diagnostics)

### Area Organization

The gateway organizes nodes into "areas" based on their namespace:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
#include <memory>
#include <optional>
#include <rclcpp/rclcpp.hpp>
#include <set>
#include <string>
#include <vector>

Expand All @@ -34,6 +35,25 @@ class DiscoveryManager {
std::vector<Area> discover_areas();
std::vector<Component> discover_components();

/**
* @brief Discover components from topic namespaces (topic-based discovery)
*
* Creates "virtual" components for topic namespaces that don't have
* corresponding ROS 2 nodes. This is useful for systems like Isaac Sim
* that publish topics without creating proper ROS 2 nodes.
*
* Example: Topics ["/carter1/odom", "/carter1/cmd_vel", "/carter2/odom"]
* Creates components: carter1, carter2 (if no matching nodes exist)
*
* Components are created with:
* - id: namespace name (e.g., "carter1")
* - source: "topic" (to distinguish from node-based components)
* - topics.publishes: all topics under this namespace
*
* @return Vector of topic-based components (excludes namespaces with existing nodes)
*/
std::vector<Component> discover_topic_components();

/// Discover all services in the system with their types
std::vector<ServiceInfo> discover_services();

Expand Down Expand Up @@ -87,6 +107,9 @@ class DiscoveryManager {
/// Extract the last segment from a path (e.g., "/a/b/c" -> "c")
std::string extract_name_from_path(const std::string & path);

/// Get set of namespaces that have ROS 2 nodes (for deduplication)
std::set<std::string> get_node_namespaces();

/// Check if a service path belongs to a component namespace
bool path_belongs_to_namespace(const std::string & path, const std::string & ns) const;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,13 +147,14 @@ struct Component {
std::string fqn;
std::string type = "Component";
std::string area;
std::string source = "node"; ///< Discovery source: "node" or "topic"
std::vector<ServiceInfo> services;
std::vector<ActionInfo> actions;
ComponentTopics topics; ///< Topics this component publishes/subscribes

json to_json() const {
json j = {{"id", id}, {"namespace", namespace_path}, {"fqn", fqn}, {"type", type},
{"area", area}, {"topics", topics.to_json()}};
json j = {{"id", id}, {"namespace", namespace_path}, {"fqn", fqn}, {"type", type}, {"area", area},
{"source", source}, {"topics", topics.to_json()}};

// Add operations array combining services and actions
json operations = json::array();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
#include <nlohmann/json.hpp>
#include <optional>
#include <rclcpp/rclcpp.hpp>
#include <set>
#include <string>
#include <utility>
#include <vector>
Expand Down Expand Up @@ -199,6 +200,85 @@ class NativeTopicSampler {
*/
ComponentTopics get_component_topics(const std::string & component_fqn);

/**
* @brief Result of topic-based discovery containing namespaces and their topics
*
* This struct aggregates all topic-based discovery results to avoid
* multiple ROS 2 graph queries (N+1 query problem).
*/
struct TopicDiscoveryResult {
std::set<std::string> namespaces; ///< Unique namespace prefixes
std::map<std::string, ComponentTopics> topics_by_ns; ///< Topics grouped by namespace
};

/**
* @brief Discover namespaces and their topics in a single graph query
*
* This method performs a single call to get_topic_names_and_types() and
* processes all topics to extract namespaces and group topics by namespace.
* This avoids the N+1 query problem of calling discover_topic_namespaces()
* followed by get_topics_for_namespace() for each namespace.
*
* Example: Topics ["/carter1/odom", "/carter1/cmd_vel", "/carter2/imu"]
* Returns: {
* namespaces: {"carter1", "carter2"},
* topics_by_ns: {
* "/carter1": {publishes: ["/carter1/odom", "/carter1/cmd_vel"]},
* "/carter2": {publishes: ["/carter2/imu"]}
* }
* }
*
* @return TopicDiscoveryResult with namespaces and topics grouped by namespace
*/
TopicDiscoveryResult discover_topics_by_namespace();

/**
* @brief Discover unique namespace prefixes from all topics
*
* Extracts the first segment of each topic path to identify namespaces.
* Used for topic-based component discovery when nodes are not available
* (e.g., Isaac Sim publishing topics without creating ROS 2 nodes).
*
* Example: Topics ["/carter1/odom", "/carter2/cmd_vel", "/tf"]
* Returns: {"carter1", "carter2"} (root topics like /tf are excluded)
*
* @note Consider using discover_topics_by_namespace() instead to avoid N+1 queries
*
* @return Set of unique namespace prefixes (without leading slash)
*/
std::set<std::string> discover_topic_namespaces();

/**
* @brief Get all topics under a specific namespace prefix
*
* Returns ComponentTopics containing all topics that start with the given
* namespace prefix. For topic-based discovery, all matched topics are
* placed in the 'publishes' list since direction cannot be determined
* without node information.
*
* @note Consider using discover_topics_by_namespace() instead to avoid N+1 queries
*
* @param ns_prefix Namespace prefix including leading slash (e.g., "/carter1")
* @return ComponentTopics with matching topics in publishes list
*/
ComponentTopics get_topics_for_namespace(const std::string & ns_prefix);

/**
* @brief Check if a topic is a ROS 2 system/infrastructure topic
*
* System topics are filtered out during topic-based discovery to avoid
* creating spurious components. Filtered topics include:
* - /parameter_events
* - /rosout
* - /clock
*
* Note: /tf and /tf_static are NOT filtered (useful for diagnostics).
*
* @param topic_name Full topic path
* @return true if this is a system topic that should be filtered
*/
static bool is_system_topic(const std::string & topic_name);

private:
/**
* @brief Parse YAML-formatted message string to JSON
Expand Down
73 changes: 73 additions & 0 deletions src/ros2_medkit_gateway/src/discovery_manager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ std::vector<Area> DiscoveryManager::discover_areas() {
area_set.insert(area);
}

// Also include areas from topic namespaces (for topic-based discovery)
if (topic_sampler_) {
auto topic_namespaces = topic_sampler_->discover_topic_namespaces();
for (const auto & ns : topic_namespaces) {
area_set.insert(ns);
}
}

// Convert set to vector of Area structs
std::vector<Area> areas;
for (const auto & area_name : area_set) {
Expand Down Expand Up @@ -360,6 +368,71 @@ std::string DiscoveryManager::extract_name_from_path(const std::string & path) {
return path;
}

std::set<std::string> DiscoveryManager::get_node_namespaces() {
std::set<std::string> namespaces;

auto node_graph = node_->get_node_graph_interface();
auto names_and_namespaces = node_graph->get_node_names_and_namespaces();

for (const auto & name_and_ns : names_and_namespaces) {
std::string ns = name_and_ns.second;
std::string area = extract_area_from_namespace(ns);
if (area != "root") {
namespaces.insert(area);
}
}

return namespaces;
}

std::vector<Component> DiscoveryManager::discover_topic_components() {
std::vector<Component> components;

if (!topic_sampler_) {
RCLCPP_DEBUG(node_->get_logger(), "Topic sampler not set, skipping topic-based discovery");
return components;
}

// Single graph query - get all namespaces and their topics at once (avoids N+1 queries)
auto discovery_result = topic_sampler_->discover_topics_by_namespace();

// Get namespaces that already have nodes (to avoid duplicates)
auto node_namespaces = get_node_namespaces();

RCLCPP_DEBUG(node_->get_logger(), "Topic-based discovery: %zu topic namespaces, %zu node namespaces",
discovery_result.namespaces.size(), node_namespaces.size());

for (const auto & ns : discovery_result.namespaces) {
// Skip if there's already a node with this namespace
if (node_namespaces.count(ns) > 0) {
RCLCPP_DEBUG(node_->get_logger(), "Skipping namespace '%s' - already has nodes", ns.c_str());
continue;
}

Component comp;
comp.id = ns;
comp.namespace_path = "/" + ns;
comp.fqn = "/" + ns;
comp.area = ns;
comp.source = "topic";

// Get topics from cached result (no additional graph query)
std::string ns_prefix = "/" + ns;
auto it = discovery_result.topics_by_ns.find(ns_prefix);
if (it != discovery_result.topics_by_ns.end()) {
comp.topics = it->second;
}

RCLCPP_DEBUG(node_->get_logger(), "Created topic-based component '%s' with %zu topics", ns.c_str(),
comp.topics.publishes.size());

components.push_back(comp);
}

RCLCPP_INFO(node_->get_logger(), "Discovered %zu topic-based components", components.size());
return components;
}

bool DiscoveryManager::path_belongs_to_namespace(const std::string & path, const std::string & ns) const {
if (ns.empty() || ns == "/") {
// Root namespace - check if path has only one segment after leading slash
Expand Down
Loading
Loading