From 4da4bae29d69ef467467b51b753f6b3d4bea60bd Mon Sep 17 00:00:00 2001 From: Michal Faferek Date: Thu, 16 Jul 2026 17:39:11 +0200 Subject: [PATCH 1/7] feat: zero-config freeze-frame - capture the faulting entity's own data by default Fault manager: when no snapshot config matches a fault code, capture the reporting source node's own published topics (snapshots.entity_default, default on). Gateway: on confirm of a plugin-entity fault, snapshot the entity's current DataProvider values and merge them into the fault detail (entity_freeze_frame.enabled, default on). Explicit config always wins. Closes #537 --- docs/config/fault-manager.rst | 7 +- docs/config/server.rst | 8 + docs/tutorials/snapshots.rst | 72 +++++- .../config/snapshots.yaml | 5 +- .../snapshot_capture.hpp | 16 +- .../src/fault_manager_node.cpp | 11 +- .../src/snapshot_capture.cpp | 95 ++++++- .../test/test_snapshot_capture.cpp | 126 ++++++++++ src/ros2_medkit_gateway/CMakeLists.txt | 21 ++ .../entity_freeze_frame_capture.hpp | 100 ++++++++ .../ros2_medkit_gateway/gateway_node.hpp | 9 + .../http/handlers/fault_handlers.hpp | 16 ++ .../src/entity_freeze_frame_capture.cpp | 120 +++++++++ src/ros2_medkit_gateway/src/gateway_node.cpp | 23 +- .../src/http/handlers/fault_handlers.cpp | 44 +++- .../demo_nodes/test_data_provider_plugin.cpp | 179 +++++++++++++ .../test/test_entity_freeze_frame_capture.cpp | 237 ++++++++++++++++++ .../features/test_entity_freeze_frame.test.py | 205 +++++++++++++++ 18 files changed, 1280 insertions(+), 14 deletions(-) create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/entity_freeze_frame_capture.hpp create mode 100644 src/ros2_medkit_gateway/src/entity_freeze_frame_capture.cpp create mode 100644 src/ros2_medkit_gateway/test/demo_nodes/test_data_provider_plugin.cpp create mode 100644 src/ros2_medkit_gateway/test/test_entity_freeze_frame_capture.cpp create mode 100644 src/ros2_medkit_integration_tests/test/features/test_entity_freeze_frame.test.py diff --git a/docs/config/fault-manager.rst b/docs/config/fault-manager.rst index 5fc72e6e9..ad7a3726e 100644 --- a/docs/config/fault-manager.rst +++ b/docs/config/fault-manager.rst @@ -193,7 +193,12 @@ Basic Snapshot Settings - Maximum message size to capture (bytes). Larger messages are truncated. * - ``snapshots.default_topics`` - ``[]`` - - List of topics to capture for all faults. + - List of topics to capture for all faults. Empty entries are ignored. + * - ``snapshots.entity_default`` + - ``true`` + - Zero-config fallback: when no explicit config matches a fault code, + capture the reporting source node's own published topics. Set to + ``false`` to opt out. * - ``snapshots.config_file`` - ``""`` - Path to YAML file with fault-specific snapshot configurations. diff --git a/docs/config/server.rst b/docs/config/server.rst index d0eec3b41..1cce92ee3 100644 --- a/docs/config/server.rst +++ b/docs/config/server.rst @@ -196,6 +196,14 @@ Configure how the gateway connects to the fault manager services and event topic - float - ``5.0`` - Timeout for fault manager service calls such as ``list_faults`` and ``get_snapshots``. + * - ``entity_freeze_frame.enabled`` + - bool + - ``true`` + - Zero-config freeze-frames for plugin-backed entities: when a fault from a + plugin-owned entity confirms, snapshot the entity's current data values + (via its plugin's DataProvider) and merge them into the fault detail's + ``environment_data.snapshots``. Explicit snapshot config in the fault + manager always wins when present. Only active when plugins are loaded. When ``fault_manager.namespace`` is set, the gateway also subscribes to the matching fault event topic (for example ``/robot1/fault_manager/events`` instead of the default diff --git a/docs/tutorials/snapshots.rst b/docs/tutorials/snapshots.rst index 8fcc6ebd8..0f69db1f3 100644 --- a/docs/tutorials/snapshots.rst +++ b/docs/tutorials/snapshots.rst @@ -11,9 +11,9 @@ preserve topic data when faults are confirmed, enabling post-mortem debugging. Overview -------- -When a fault transitions to CONFIRMED status, the system can automatically -capture data from configured ROS 2 topics. This snapshot preserves the -system state at the moment of fault occurrence, similar to: +When a fault transitions to CONFIRMED status, the system automatically +captures data from ROS 2 topics. This snapshot preserves the system state +at the moment of fault occurrence, similar to: - **AUTOSAR DEM freeze frames** - diagnostic data captured at fault detection - **SOVD environment data** - system context for fault analysis @@ -24,6 +24,11 @@ Snapshots are useful for: - Understanding system state when a fault occurred - Post-mortem analysis without real-time access to the robot +Capture works out of the box with **zero configuration**: when no explicit +snapshot config matches a fault code, the faulting entity's own data is +captured by default (see :ref:`entity-default-freeze-frames`). Explicit +configuration always overrides the zero-config fallback when present. + .. note:: Snapshots are automatically deleted when a fault is cleared via the @@ -69,7 +74,12 @@ Configure snapshot capture via fault manager parameters: - Enable/disable snapshot capture * - ``snapshots.default_topics`` - ``[]`` - - Topics to capture for all faults + - Topics to capture for all faults (empty entries are ignored) + * - ``snapshots.entity_default`` + - ``true`` + - Zero-config fallback: when no explicit config matches a fault code, + capture the reporting source node's own published topics. Set to + ``false`` to opt out (unconfigured faults then get no freeze-frame). * - ``snapshots.config_file`` - ``""`` - Path to YAML config for fault-specific topics @@ -130,6 +140,8 @@ For fault-specific topic capture, create a YAML configuration file: 1. ``fault_specific`` - Exact match for fault code 2. ``patterns`` - Regex pattern match (first matching pattern wins) 3. ``default_topics`` - Fallback for all faults +4. Entity-default - zero-config fallback when nothing above matches + (``snapshots.entity_default``, on by default) **Launch with config file:** @@ -140,6 +152,58 @@ For fault-specific topic capture, create a YAML configuration file: -p snapshots.config_file:=/path/to/snapshots.yaml \ -p snapshots.default_topics:="['/diagnostics']" +.. _entity-default-freeze-frames: + +Zero-Config Entity Freeze-Frames +-------------------------------- + +With no snapshot configuration at all, every confirmed fault still carries +at-fault-time context: the faulting entity's own current data. Two paths +cover the two kinds of entities, both on by default with an opt-out flag, +and explicit config (``fault_specific`` / ``patterns`` / ``default_topics``) +always wins when it matches. + +**ROS-backed entities** (``snapshots.entity_default``, fault manager): when +no explicit config matches the fault code, the fault manager captures the +topics *published by the fault's reporting source node(s)* (resolved from +``source_id``), excluding per-node noise (``/rosout``, +``/parameter_events``) and capped at 16 topics. These captures always sample +on demand, even when ``snapshots.background_capture`` is enabled, because +the topics are not known until the fault confirms. If the source is not a +live node (e.g. a plugin entity id) or publishes nothing, no freeze-frame +row is written. + +**Plugin-backed entities** (``entity_freeze_frame.enabled``, gateway): PLC +apps bridged by protocol plugins report faults under their bare SOVD entity +id and their live values are not ROS topics, so the fault manager cannot +capture them. Instead, the gateway snapshots the entity's current data +values (from the owning plugin's ``DataProvider``, i.e. the latest polled +values) when the fault confirms, and merges them into the fault detail's +``environment_data.snapshots`` as a standard ``freeze_frame`` entry named +after the entity - unless the fault manager already captured a freeze-frame +for that fault (explicit config wins). Disable with: + +.. code-block:: bash + + ros2 run ros2_medkit_gateway gateway_node --ros-args \ + -p entity_freeze_frame.enabled:=false + +Example plugin-entity freeze-frame in the fault response: + +.. code-block:: json + + { + "type": "freeze_frame", + "name": "beckhoff_plc_app", + "data": {"tank_level": 87.5, "pump_running": true}, + "x-medkit": { + "topic": "", + "message_type": "", + "full_data": {"tank_level": 87.5, "pump_running": true}, + "captured_at": "2026-07-14T12:00:00.000Z" + } + } + Querying Snapshots ------------------ diff --git a/src/ros2_medkit_fault_manager/config/snapshots.yaml b/src/ros2_medkit_fault_manager/config/snapshots.yaml index a0dd647c6..6fe994416 100644 --- a/src/ros2_medkit_fault_manager/config/snapshots.yaml +++ b/src/ros2_medkit_fault_manager/config/snapshots.yaml @@ -6,7 +6,10 @@ # Topic resolution priority: # 1. fault_specific - exact fault code match (highest priority) # 2. patterns - regex pattern match on fault code -# 3. default_topics - fallback for all other faults (lowest priority) +# 3. default_topics - fallback for all other faults +# 4. entity-default - zero-config fallback when nothing above matches: +# the reporting source node's own published topics are captured +# (parameter snapshots.entity_default, on by default) # # Usage: # ros2 run ros2_medkit_fault_manager fault_manager_node \ diff --git a/src/ros2_medkit_fault_manager/include/ros2_medkit_fault_manager/snapshot_capture.hpp b/src/ros2_medkit_fault_manager/include/ros2_medkit_fault_manager/snapshot_capture.hpp index 91051f0f6..184d4a486 100644 --- a/src/ros2_medkit_fault_manager/include/ros2_medkit_fault_manager/snapshot_capture.hpp +++ b/src/ros2_medkit_fault_manager/include/ros2_medkit_fault_manager/snapshot_capture.hpp @@ -115,6 +115,12 @@ struct SnapshotConfig { /// Default topics to capture if no specific or pattern match std::vector default_topics; + /// Zero-config fallback when NO explicit config matches a fault code: capture + /// the topics published by the fault's reporting source node(s) instead of + /// skipping the capture entirely. Explicit config (fault_specific / patterns / + /// default_topics) always wins when present. + bool entity_default{true}; + /// Rosbag recording configuration (optional) RosbagConfig rosbag; }; @@ -151,7 +157,9 @@ class SnapshotCapture { /// Capture snapshots for a fault that was just confirmed /// /// If the fault code resolves to no capture set (not in fault_specific, no pattern - /// match, no default_topics), capture returns early: no freeze_frames row is written + /// match, no default_topics), the entity-default fallback (when enabled) captures + /// the reporting source node's own published topics instead. Only when that also + /// resolves nothing does capture return early: no freeze_frames row is written /// (no empty {} row) and FaultStorage::get_freeze_frame() returns nullopt for it. /// @param fault_code The fault code that was confirmed void capture(const std::string & fault_code); @@ -171,6 +179,12 @@ class SnapshotCapture { /// Priority: fault_specific > patterns > default_topics std::vector resolve_topics(const std::string & fault_code) const; + /// Entity-default fallback: topics published by the fault's reporting source + /// node(s), excluding per-node noise (/rosout, /parameter_events). Empty when + /// no source resolves to a live node (e.g. plugin entity ids - the gateway + /// covers those). Never throws; any failure degrades to empty. + std::vector resolve_entity_topics(const std::string & fault_code) const; + /// Capture a single topic on-demand (creates temporary subscription) /// On success also records the captured value into @p freeze_frame under the topic key. /// @return true if capture was successful diff --git a/src/ros2_medkit_fault_manager/src/fault_manager_node.cpp b/src/ros2_medkit_fault_manager/src/fault_manager_node.cpp index 2ced5abb2..17e4fdcb8 100644 --- a/src/ros2_medkit_fault_manager/src/fault_manager_node.cpp +++ b/src/ros2_medkit_fault_manager/src/fault_manager_node.cpp @@ -16,6 +16,7 @@ #include +#include #include #include #include @@ -1061,9 +1062,17 @@ SnapshotConfig FaultManagerNode::create_snapshot_config() { } config.max_message_size = static_cast(max_message_size_param); - // Default topics (catch-all) + // Default topics (catch-all). Empty entries are dropped: launch files cannot + // express an empty string array, so [''] is the conventional "no defaults" + // and must not shadow the entity-default fallback below. config.default_topics = declare_parameter>("snapshots.default_topics", std::vector{}); + config.default_topics.erase(std::remove(config.default_topics.begin(), config.default_topics.end(), std::string{}), + config.default_topics.end()); + + // Zero-config fallback: capture the reporting node's own published topics + // when no explicit config matches the fault code (opt-out). + config.entity_default = declare_parameter("snapshots.entity_default", true); // Load fault_specific and patterns from YAML config file if provided auto config_file = declare_parameter("snapshots.config_file", ""); diff --git a/src/ros2_medkit_fault_manager/src/snapshot_capture.cpp b/src/ros2_medkit_fault_manager/src/snapshot_capture.cpp index 14b715107..b1ccf1cda 100644 --- a/src/ros2_medkit_fault_manager/src/snapshot_capture.cpp +++ b/src/ros2_medkit_fault_manager/src/snapshot_capture.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include "ros2_medkit_fault_manager/time_utils.hpp" #include "ros2_medkit_serialization/json_serializer.hpp" @@ -123,6 +124,13 @@ void SnapshotCapture::capture(const std::string & fault_code) { } auto topics = resolve_topics(fault_code); + // Zero-config fallback: no explicit config matched, so capture the reporting + // source node's own published topics (explicit config always wins above). + bool entity_scoped = false; + if (topics.empty() && config_.entity_default) { + topics = resolve_entity_topics(fault_code); + entity_scoped = !topics.empty(); + } if (topics.empty()) { // Unconfigured fault code: resolve_topics() yielded nothing, so capture returns early. // No freeze_frames row is written (we never persist an empty {} row) and no @@ -132,15 +140,17 @@ void SnapshotCapture::capture(const std::string & fault_code) { return; } - RCLCPP_INFO(node_->get_logger(), "Capturing snapshots for fault '%s' (%zu topics)", fault_code.c_str(), - topics.size()); + RCLCPP_INFO(node_->get_logger(), "Capturing snapshots for fault '%s' (%zu topics%s)", fault_code.c_str(), + topics.size(), entity_scoped ? ", entity-default" : ""); // Accumulate a compact dict of captured topic values alongside the per-topic snapshots. nlohmann::json freeze_frame = nlohmann::json::object(); size_t captured_count = 0; for (const auto & topic : topics) { bool success = false; - if (config_.background_capture) { + // Entity-default topics are not known at construction, so the background + // cache never holds them - always sample those on demand. + if (config_.background_capture && !entity_scoped) { success = capture_topic_from_cache(fault_code, topic, freeze_frame); } else { success = capture_topic_on_demand(fault_code, topic, freeze_frame); @@ -161,6 +171,14 @@ void SnapshotCapture::capture(const std::string & fault_code) { // An empty capture must never clobber an existing non-empty frame: a re-confirm while // the source publishers are down would otherwise replace the retained record with {}. if (captured_count == 0) { + // Entity-default runs are best-effort: an empty one must not create a row + // (absence still means "nothing configured captured anything") nor clobber + // a retained frame from an earlier explicit-config capture. + if (entity_scoped) { + RCLCPP_DEBUG(node_->get_logger(), "Entity-default capture for fault '%s' sampled nothing; no row written", + fault_code.c_str()); + return; + } auto existing = storage_->get_freeze_frame(fault_code); if (existing.has_value() && existing->data != "{}") { RCLCPP_WARN(node_->get_logger(), "Nothing captured for fault '%s'; keeping previously retained freeze-frame", @@ -201,6 +219,77 @@ std::vector SnapshotCapture::resolve_topics(const std::string & fau return {}; } +std::vector SnapshotCapture::resolve_entity_topics(const std::string & fault_code) const { + // Bound the on-demand sweep: each silent-but-published topic costs up to + // timeout_sec, so a node with many declared topics must not stall a capture + // pool worker indefinitely. + static constexpr size_t kMaxEntityTopics = 16; + + std::set topics; + // Guarded like RosbagCapture::resolve_entity_topics: this runs on a capture + // pool worker with no outer catch, and storage_->get_fault() can throw on + // the sqlite backend. Any failure degrades to "no entity-default capture". + try { + auto fault = storage_->get_fault(fault_code); + if (!fault || fault->reporting_sources.empty()) { + return {}; + } + + // reporting_sources hold the reporting node's FQN (e.g. "/planner_server"). + // Split each into (name, namespace) to match against topic endpoints. + std::set> wanted; + for (const auto & source : fault->reporting_sources) { + std::string ns = "/"; + std::string name = source; + const auto slash = source.rfind('/'); + if (slash != std::string::npos) { + name = source.substr(slash + 1); + ns = (slash == 0) ? "/" : source.substr(0, slash); + } + if (!name.empty()) { + wanted.emplace(name, ns); + } + } + + // The entity's "own data" = topics it publishes. Every node publishes + // /rosout and /parameter_events; those say nothing about the entity. + static const std::set kExcluded = {"/rosout", "/parameter_events"}; + + auto published_by_wanted = [&wanted](const std::vector & eps) { + for (const auto & ep : eps) { + if (wanted.count({ep.node_name(), ep.node_namespace()})) { + return true; + } + } + return false; + }; + for (const auto & [topic, types] : node_->get_topic_names_and_types()) { + if (kExcluded.count(topic)) { + continue; + } + if (published_by_wanted(node_->get_publishers_info_by_topic(topic))) { + topics.insert(topic); + if (topics.size() >= kMaxEntityTopics) { + RCLCPP_WARN(node_->get_logger(), + "Entity-default capture for fault '%s' capped at %zu topics; remaining topics skipped", + fault_code.c_str(), kMaxEntityTopics); + break; + } + } + } + } catch (const std::exception & e) { + RCLCPP_WARN(node_->get_logger(), "Entity-default topic resolution failed for fault '%s': %s", fault_code.c_str(), + e.what()); + return {}; + } + + if (!topics.empty()) { + RCLCPP_INFO(node_->get_logger(), "Entity-default capture for fault '%s': %zu topic(s) from the reporting node(s)", + fault_code.c_str(), topics.size()); + } + return {topics.begin(), topics.end()}; +} + bool SnapshotCapture::capture_topic_on_demand(const std::string & fault_code, const std::string & topic, nlohmann::json & freeze_frame) { // Get topic type diff --git a/src/ros2_medkit_fault_manager/test/test_snapshot_capture.cpp b/src/ros2_medkit_fault_manager/test/test_snapshot_capture.cpp index 0cfc13b2a..013d4e297 100644 --- a/src/ros2_medkit_fault_manager/test/test_snapshot_capture.cpp +++ b/src/ros2_medkit_fault_manager/test/test_snapshot_capture.cpp @@ -428,6 +428,132 @@ TEST_F(SnapshotCaptureTest, DisabledCaptureWritesNoFreezeFrame) { EXPECT_FALSE(storage_->get_freeze_frame("ANY_FAULT").has_value()); } +// Entity-default (zero-config) capture tests. The reporting source is this +// test node's own FQN, so resolve_entity_topics finds the topics it publishes. + +class EntityDefaultCaptureTest : public SnapshotCaptureTest { + protected: + /// Store a CONFIRMED fault whose reporting source is this test node. + void store_fault_from_this_node(const std::string & fault_code) { + ros2_medkit_fault_manager::DebounceConfig debounce; // threshold -1: first FAILED confirms + storage_->report_fault_event(fault_code, 0 /*EVENT_FAILED*/, ros2_medkit_msgs::msg::Fault::SEVERITY_ERROR, + "test fault", node_->get_fully_qualified_name(), rclcpp::Clock().now(), debounce); + } + + /// Wait until this node's publisher on @p topic is visible on the graph. + void wait_for_publisher(const std::string & topic) { + auto start = std::chrono::steady_clock::now(); + while (node_->count_publishers(topic) == 0 && std::chrono::steady_clock::now() - start < std::chrono::seconds(5)) { + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + ASSERT_GT(node_->count_publishers(topic), 0u); + } +}; + +// @verifies REQ_INTEROP_088 +TEST_F(EntityDefaultCaptureTest, EntityDefaultCapturesSourceNodeTopics) { + auto pub = node_->create_publisher("/entity/own_metric", rclcpp::QoS(10)); + + SnapshotConfig config; + config.enabled = true; + config.background_capture = false; + config.timeout_sec = 5.0; + // No fault_specific / patterns / default_topics: only the entity-default + // fallback can produce a capture set. + SnapshotCapture capture(node_.get(), storage_.get(), config); + + store_fault_from_this_node("ENTITY_FAULT"); + + ScopedPublisherThread pub_thread([&pub](std::atomic & stop) { + while (!stop.load()) { + std_msgs::msg::Float64 msg; + msg.data = 13.5; + pub->publish(msg); + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + }); + wait_for_publisher("/entity/own_metric"); + + capture.capture("ENTITY_FAULT"); + + auto frame = storage_->get_freeze_frame("ENTITY_FAULT"); + ASSERT_TRUE(frame.has_value()); + auto parsed = nlohmann::json::parse(frame->data); + ASSERT_TRUE(parsed.contains("/entity/own_metric")); + EXPECT_DOUBLE_EQ(parsed["/entity/own_metric"]["data"].get(), 13.5); + // Per-node noise topics never enter the entity capture set. + EXPECT_FALSE(parsed.contains("/rosout")); + EXPECT_FALSE(parsed.contains("/parameter_events")); +} + +// @verifies REQ_INTEROP_088 +TEST_F(EntityDefaultCaptureTest, EntityDefaultDisabledWritesNoFreezeFrame) { + auto pub = node_->create_publisher("/entity/opted_out", rclcpp::QoS(10)); + + SnapshotConfig config; + config.enabled = true; + config.entity_default = false; + SnapshotCapture capture(node_.get(), storage_.get(), config); + + store_fault_from_this_node("OPTED_OUT_FAULT"); + wait_for_publisher("/entity/opted_out"); + + capture.capture("OPTED_OUT_FAULT"); + + EXPECT_FALSE(storage_->get_freeze_frame("OPTED_OUT_FAULT").has_value()); +} + +// @verifies REQ_INTEROP_088 +TEST_F(EntityDefaultCaptureTest, ExplicitConfigWinsOverEntityDefault) { + auto configured_pub = node_->create_publisher("/entity/configured", rclcpp::QoS(10)); + auto own_pub = node_->create_publisher("/entity/own_other", rclcpp::QoS(10)); + + SnapshotConfig config; + config.enabled = true; + config.background_capture = false; + config.timeout_sec = 5.0; + config.fault_specific["CONFIGURED_FAULT"] = {"/entity/configured"}; + SnapshotCapture capture(node_.get(), storage_.get(), config); + + store_fault_from_this_node("CONFIGURED_FAULT"); + + ScopedPublisherThread pub_thread([&configured_pub, &own_pub](std::atomic & stop) { + while (!stop.load()) { + std_msgs::msg::Float64 msg; + msg.data = 1.0; + configured_pub->publish(msg); + own_pub->publish(msg); + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + }); + wait_for_publisher("/entity/configured"); + + capture.capture("CONFIGURED_FAULT"); + + auto frame = storage_->get_freeze_frame("CONFIGURED_FAULT"); + ASSERT_TRUE(frame.has_value()); + auto parsed = nlohmann::json::parse(frame->data); + // Only the explicitly configured topic is captured, never the node's other topics. + EXPECT_TRUE(parsed.contains("/entity/configured")); + EXPECT_FALSE(parsed.contains("/entity/own_other")); +} + +// @verifies REQ_INTEROP_088 +TEST_F(EntityDefaultCaptureTest, UnresolvableSourceWritesNoRow) { + SnapshotConfig config; + config.enabled = true; + SnapshotCapture capture(node_.get(), storage_.get(), config); + + // Plugin-style bare entity id: not a live node, resolves to no topics. + ros2_medkit_fault_manager::DebounceConfig debounce; + storage_->report_fault_event("PLC_FAULT", 0 /*EVENT_FAILED*/, ros2_medkit_msgs::msg::Fault::SEVERITY_ERROR, + "plc fault", "beckhoff_plc_app", rclcpp::Clock().now(), debounce); + + capture.capture("PLC_FAULT"); + + EXPECT_FALSE(storage_->get_freeze_frame("PLC_FAULT").has_value()); +} + int main(int argc, char ** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); diff --git a/src/ros2_medkit_gateway/CMakeLists.txt b/src/ros2_medkit_gateway/CMakeLists.txt index d4cad2a65..62004534c 100644 --- a/src/ros2_medkit_gateway/CMakeLists.txt +++ b/src/ros2_medkit_gateway/CMakeLists.txt @@ -171,6 +171,7 @@ add_library(gateway_ros2 STATIC src/discovery/manifest/manifest_parser.cpp src/discovery/manifest/runtime_linker.cpp src/discovery/merge_pipeline.cpp + src/entity_freeze_frame_capture.cpp src/fault_manager_paths.cpp src/gateway_node.cpp src/http/fan_out_helpers.cpp @@ -787,6 +788,12 @@ if(BUILD_TESTING) medkit_target_dependencies(test_sse_fault_handler rclcpp ros2_medkit_msgs) medkit_set_test_domain(test_sse_fault_handler) + # Zero-config entity freeze-frame capture tests + ament_add_gtest(test_entity_freeze_frame_capture test/test_entity_freeze_frame_capture.cpp) + target_link_libraries(test_entity_freeze_frame_capture gateway_ros2) + medkit_target_dependencies(test_entity_freeze_frame_capture rclcpp ros2_medkit_msgs) + medkit_set_test_domain(test_entity_freeze_frame_capture) + # Add update manager tests ament_add_gtest(test_update_manager test/test_update_manager.cpp) target_link_libraries(test_update_manager gateway_ros2) @@ -875,6 +882,19 @@ if(BUILD_TESTING) install(TARGETS ${_plugin} LIBRARY DESTINATION lib/${PROJECT_NAME}) endforeach() + # Demo PLC-like plugin: IntrospectionProvider + DataProvider + FaultProvider + # (.so for the zero-config entity freeze-frame integration test) + add_library(test_data_provider_plugin MODULE test/demo_nodes/test_data_provider_plugin.cpp) + target_include_directories(test_data_provider_plugin PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include) + target_link_libraries(test_data_provider_plugin + nlohmann_json::nlohmann_json + tl::expected + cpp_httplib_target + OpenSSL::SSL + OpenSSL::Crypto + ) + install(TARGETS test_data_provider_plugin LIBRARY DESTINATION lib/${PROJECT_NAME}) + # Minimal plugin with no provider query functions (only required exports) add_library(test_minimal_plugin MODULE test/demo_nodes/test_minimal_plugin.cpp) target_include_directories(test_minimal_plugin PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include) @@ -1081,6 +1101,7 @@ if(BUILD_TESTING) test_cyclic_subscription_handlers test_sse_transport_provider test_sse_fault_handler + test_entity_freeze_frame_capture test_update_manager test_script_manager test_default_script_provider diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/entity_freeze_frame_capture.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/entity_freeze_frame_capture.hpp new file mode 100644 index 000000000..3c6b9959c --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/entity_freeze_frame_capture.hpp @@ -0,0 +1,100 @@ +// Copyright 2026 mfaferek93 +// +// 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. + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "rclcpp/rclcpp.hpp" +#include "ros2_medkit_gateway/core/providers/data_provider.hpp" +#include "ros2_medkit_msgs/msg/fault_event.hpp" + +namespace ros2_medkit_gateway { + +/** + * @brief Zero-config freeze-frames for plugin-backed entities. + * + * Plugin entities (PLC apps bridged by protocol plugins) report faults under + * their bare SOVD entity id, and their live values are not ROS topics - the + * fault_manager's snapshot capture can never freeze-frame them. This class + * closes that gap on the gateway side: it subscribes to fault_manager events + * and, when a fault confirms with a plugin-owned reporting source, snapshots + * that entity's current data values (DataProvider::list_data, which serves + * from the plugin's latest polled values) at fault time. + * + * The frames are merged into the fault detail's environment_data.snapshots + * only when the fault_manager captured no freeze-frame itself - explicit + * snapshot config always wins (see FaultHandlers::merge_entity_freeze_frames). + */ +class EntityFreezeFrameCapture { + public: + /// One captured frame: the entity's data values at fault-confirm time. + struct Frame { + std::string entity_id; + nlohmann::json values; ///< compact {resource_id: value} dict + int64_t captured_at_ns{0}; + }; + + /// Resolves an entity id to its owning plugin's DataProvider (nullptr when + /// the entity is not plugin-owned). Called from the subscription callback. + using DataProviderResolver = std::function; + + /** + * @param node ROS 2 node for creating the subscription (caller manages lifetime) + * @param resolver entity-to-DataProvider resolver (typically wraps PluginManager) + * @param max_faults retained-frame bound; oldest fault's frames evicted past it + */ + EntityFreezeFrameCapture(rclcpp::Node * node, DataProviderResolver resolver, size_t max_faults = 256); + + ~EntityFreezeFrameCapture(); + + // Non-copyable, non-movable + EntityFreezeFrameCapture(const EntityFreezeFrameCapture &) = delete; + EntityFreezeFrameCapture & operator=(const EntityFreezeFrameCapture &) = delete; + EntityFreezeFrameCapture(EntityFreezeFrameCapture &&) = delete; + EntityFreezeFrameCapture & operator=(EntityFreezeFrameCapture &&) = delete; + + /// Frames captured for a fault code (empty when none). Thread-safe. + std::vector frames_for(const std::string & fault_code) const; + + /// Build the compact {resource_id: value} dict from a DataProvider::list_data + /// response. Items without a "value" field map to null; a response without an + /// "items" array is kept verbatim (plugin-defined shape). + static nlohmann::json values_from_list_content(const nlohmann::json & content); + + private: + void on_fault_event(const ros2_medkit_msgs::msg::FaultEvent::ConstSharedPtr & msg); + + rclcpp::Subscription::SharedPtr subscription_; + DataProviderResolver resolver_; + rclcpp::Logger logger_; + const size_t max_faults_; + + /// Guards frames_ and insertion_order_: subscription callback writes, + /// HTTP handler threads read. + mutable std::mutex mutex_; + std::unordered_map> frames_; + std::deque insertion_order_; ///< eviction order (FIFO) +}; + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/gateway_node.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/gateway_node.hpp index 32885a740..adc78e925 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/gateway_node.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/gateway_node.hpp @@ -52,6 +52,7 @@ #include "ros2_medkit_gateway/core/subscription_transport.hpp" #include "ros2_medkit_gateway/core/trigger_store.hpp" #include "ros2_medkit_gateway/discovery/discovery_manager.hpp" +#include "ros2_medkit_gateway/entity_freeze_frame_capture.hpp" #include "ros2_medkit_gateway/ros2/transports/ros2_action_transport.hpp" #include "ros2_medkit_gateway/ros2/transports/ros2_fault_service_transport.hpp" #include "ros2_medkit_gateway/ros2/transports/ros2_log_source.hpp" @@ -180,6 +181,12 @@ class GatewayNode : public rclcpp::Node { */ PluginManager * get_plugin_manager() const; + /** + * @brief Get the zero-config entity freeze-frame capture + * @return Raw pointer, or nullptr when disabled / no plugins loaded + */ + EntityFreezeFrameCapture * get_entity_freeze_frame_capture() const; + /** * @brief Get the ResourceSamplerRegistry instance * @return Raw pointer to ResourceSamplerRegistry (valid for lifetime of GatewayNode) @@ -351,6 +358,8 @@ class GatewayNode : public rclcpp::Node { std::unique_ptr trigger_store_; std::unique_ptr trigger_mgr_; std::unique_ptr trigger_fault_subscriber_; + // Zero-config freeze-frames for plugin-backed entities (nullptr when disabled) + std::unique_ptr entity_freeze_frame_capture_; std::unique_ptr trigger_topic_subscriber_; // Adapter routing manager-side subscribe() calls onto trigger_topic_subscriber_. std::shared_ptr trigger_topic_transport_; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/fault_handlers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/fault_handlers.hpp index cc5cb301b..28effa558 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/fault_handlers.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/fault_handlers.hpp @@ -19,8 +19,10 @@ #include #include #include +#include #include "ros2_medkit_gateway/dto/faults.hpp" +#include "ros2_medkit_gateway/entity_freeze_frame_capture.hpp" #include "ros2_medkit_gateway/http/handlers/handler_context.hpp" #include "ros2_medkit_gateway/http/response_types.hpp" #include "ros2_medkit_gateway/http/typed_router.hpp" @@ -175,6 +177,20 @@ class FaultHandlers { */ static bool fault_in_source_scope(const nlohmann::json & fault, const std::set & source_fqns); + /** + * @brief Merge zero-config entity freeze-frames into environment data. + * + * Appends one intermediate-shape freeze_frame snapshot per captured frame + * (same shape as the transport emits, so build_sovd_fault_response processes + * them identically) UNLESS the fault_manager already captured a freeze-frame + * for this fault - explicit snapshot config always wins over the zero-config + * entity capture. + * + * Public static for direct unit testing; called by `get_fault`. + */ + static nlohmann::json merge_entity_freeze_frames(nlohmann::json env_data, + const std::vector & frames); + private: HandlerContext & ctx_; }; diff --git a/src/ros2_medkit_gateway/src/entity_freeze_frame_capture.cpp b/src/ros2_medkit_gateway/src/entity_freeze_frame_capture.cpp new file mode 100644 index 000000000..bfc21159e --- /dev/null +++ b/src/ros2_medkit_gateway/src/entity_freeze_frame_capture.cpp @@ -0,0 +1,120 @@ +// Copyright 2026 mfaferek93 +// +// 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. + +#include "ros2_medkit_gateway/entity_freeze_frame_capture.hpp" + +#include + +#include "ros2_medkit_gateway/fault_manager_paths.hpp" + +namespace ros2_medkit_gateway { + +EntityFreezeFrameCapture::EntityFreezeFrameCapture(rclcpp::Node * node, DataProviderResolver resolver, + size_t max_faults) + : resolver_(std::move(resolver)), logger_(node->get_logger()), max_faults_(max_faults > 0 ? max_faults : 1) { + const auto fault_events_topic = build_fault_manager_events_topic(node); + subscription_ = node->create_subscription( + fault_events_topic, rclcpp::QoS(100).reliable(), + [this](const ros2_medkit_msgs::msg::FaultEvent::ConstSharedPtr & msg) { + on_fault_event(msg); + }); + + RCLCPP_INFO(logger_, "EntityFreezeFrameCapture initialized, subscribed to %s", fault_events_topic.c_str()); +} + +EntityFreezeFrameCapture::~EntityFreezeFrameCapture() { + subscription_.reset(); +} + +std::vector +EntityFreezeFrameCapture::frames_for(const std::string & fault_code) const { + std::lock_guard lock(mutex_); + auto it = frames_.find(fault_code); + return it != frames_.end() ? it->second : std::vector{}; +} + +nlohmann::json EntityFreezeFrameCapture::values_from_list_content(const nlohmann::json & content) { + if (!content.contains("items") || !content["items"].is_array()) { + return content; + } + nlohmann::json values = nlohmann::json::object(); + for (const auto & item : content["items"]) { + if (!item.is_object()) { + continue; + } + std::string key = item.value("id", item.value("name", "")); + if (key.empty()) { + continue; + } + values[key] = item.contains("value") ? item["value"] : nlohmann::json{}; + } + return values; +} + +void EntityFreezeFrameCapture::on_fault_event(const ros2_medkit_msgs::msg::FaultEvent::ConstSharedPtr & msg) { + // Mirror the fault_manager's own capture trigger: freeze-frames are taken + // when a fault confirms, not on every update. + if (msg->event_type != ros2_medkit_msgs::msg::FaultEvent::EVENT_CONFIRMED) { + return; + } + + std::vector frames; + for (const auto & source : msg->fault.reporting_sources) { + DataProvider * provider = resolver_ ? resolver_(source) : nullptr; + if (provider == nullptr) { + continue; // not a plugin-owned entity (ROS sources are the fault_manager's job) + } + + // list_data is expected to serve from the plugin's latest polled values + // (cheap); plugin code still gets exception-guarded like every other + // provider call site. + try { + auto result = provider->list_data(source); + if (!result) { + RCLCPP_WARN(logger_, "Entity freeze-frame for fault '%s': list_data('%s') failed: %s", + msg->fault.fault_code.c_str(), source.c_str(), result.error().message.c_str()); + continue; + } + Frame frame; + frame.entity_id = source; + frame.values = values_from_list_content(result->content); + frame.captured_at_ns = + std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()) + .count(); + frames.push_back(std::move(frame)); + } catch (const std::exception & e) { + RCLCPP_WARN(logger_, "Entity freeze-frame for fault '%s': plugin threw for entity '%s': %s", + msg->fault.fault_code.c_str(), source.c_str(), e.what()); + } + } + + if (frames.empty()) { + return; + } + + std::lock_guard lock(mutex_); + const std::string & fault_code = msg->fault.fault_code; + if (frames_.find(fault_code) == frames_.end()) { + insertion_order_.push_back(fault_code); + while (frames_.size() >= max_faults_ && !insertion_order_.empty()) { + frames_.erase(insertion_order_.front()); + insertion_order_.pop_front(); + } + } + frames_[fault_code] = std::move(frames); + + RCLCPP_DEBUG(logger_, "Captured entity freeze-frame(s) for fault '%s'", fault_code.c_str()); +} + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/gateway_node.cpp b/src/ros2_medkit_gateway/src/gateway_node.cpp index d6a56cdfe..c076ecd78 100644 --- a/src/ros2_medkit_gateway/src/gateway_node.cpp +++ b/src/ros2_medkit_gateway/src/gateway_node.cpp @@ -243,6 +243,9 @@ GatewayNode::GatewayNode(const rclcpp::NodeOptions & options) : Node("ros2_medki // Plugin framework parameters declare_parameter("plugins", std::vector{}); + // Zero-config freeze-frames for plugin-backed entities (opt-out) + declare_parameter("entity_freeze_frame.enabled", true); + // Locking parameters declare_parameter("locking.enabled", true); declare_parameter("locking.default_max_expiration", 3600); @@ -734,6 +737,16 @@ GatewayNode::GatewayNode(const rclcpp::NodeOptions & options) : Node("ros2_medki // needed because refresh_cache() runs before the HTTP server accepts requests. } + // Zero-config freeze-frames for plugin-backed entities: snapshot the faulting + // entity's current data values when its fault confirms. Only useful when a + // plugin can own entities, so gated on loaded plugins. + if (get_parameter("entity_freeze_frame.enabled").as_bool() && plugin_mgr_->has_plugins()) { + entity_freeze_frame_capture_ = + std::make_unique(this, [this](const std::string & entity_id) { + return plugin_mgr_ ? plugin_mgr_->get_data_provider_for_entity(entity_id) : nullptr; + }); + } + // Initialize log manager (subscribes to /rosout, delegates to plugin if available) static constexpr int kMinBufferSize = 1; static constexpr int kMaxBufferSize = 100000; @@ -1665,7 +1678,11 @@ GatewayNode::~GatewayNode() { if (resource_change_notifier_) { resource_change_notifier_->shutdown(); } - // 6. Shutdown plugins + // 6. Drop the entity freeze-frame subscription BEFORE plugin shutdown: its + // callback resolves into plugin DataProviders, which must not be reachable + // once shutdown_all() has run. + entity_freeze_frame_capture_.reset(); + // Shutdown plugins if (plugin_mgr_) { plugin_mgr_->shutdown_all(); } @@ -1776,6 +1793,10 @@ PluginManager * GatewayNode::get_plugin_manager() const { return plugin_mgr_.get(); } +EntityFreezeFrameCapture * GatewayNode::get_entity_freeze_frame_capture() const { + return entity_freeze_frame_capture_.get(); +} + ResourceSamplerRegistry * GatewayNode::get_sampler_registry() const { return sampler_registry_.get(); } diff --git a/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp index b0c166b63..e09ec1e92 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp @@ -218,6 +218,40 @@ bool FaultHandlers::fault_in_source_scope(const json & fault, const std::set & frames) { + if (frames.empty()) { + return env_data; + } + if (!env_data.contains("snapshots") || !env_data["snapshots"].is_array()) { + env_data["snapshots"] = json::array(); + } + // Explicit snapshot config wins: if the fault_manager captured any + // freeze-frame for this fault, the zero-config entity frames stay out. + for (const auto & s : env_data["snapshots"]) { + if (s.value("snapshot_type", s.value("type", "")) == "freeze_frame") { + return env_data; + } + } + for (const auto & frame : frames) { + // Intermediate transport shape (fault_msg_conversions): the freeze_frame + // branch of build_sovd_fault_response turns this into the standard wire + // entry (type/name/data + x-medkit.full_data/captured_at). + json snap; + snap["type"] = "freeze_frame"; + snap["snapshot_type"] = "freeze_frame"; + snap["name"] = frame.entity_id; + snap["data"] = frame.values.dump(); + snap["topic"] = ""; // entity data values, not a ROS topic + snap["message_type"] = ""; + snap["captured_at_ns"] = frame.captured_at_ns; + env_data["snapshots"].push_back(std::move(snap)); + } + return env_data; +} + // Static method: Build SOVD-compliant fault response from transport-supplied JSON. // // The transport adapter performs ros2_medkit_msgs -> JSON translation; the @@ -677,7 +711,10 @@ http::Result FaultHandlers::get_fault(const http::TypedR const auto & cache = ctx_.node()->get_thread_safe_cache(); auto source_fqns = HandlerContext::resolve_entity_source_fqns(cache, entity_info); if (FaultHandlers::fault_in_source_scope(owned_fault_json, source_fqns)) { - const auto & env_data_json = mgr_result.data.value("environment_data", json::object()); + json env_data_json = mgr_result.data.value("environment_data", json::object()); + if (auto * capture = ctx_.node()->get_entity_freeze_frame_capture()) { + env_data_json = merge_entity_freeze_frames(std::move(env_data_json), capture->frames_for(fault_code)); + } auto detail = build_sovd_fault_response(owned_fault_json, env_data_json, entity_path_info->entity_path); return wrap_detail_result(dto::JsonWriter::write(detail)); } @@ -734,7 +771,10 @@ http::Result FaultHandlers::get_fault(const http::TypedR {"fault_code", fault_code}})); } - const auto & env_data_json = result.data.value("environment_data", json::object()); + json env_data_json = result.data.value("environment_data", json::object()); + if (auto * capture = ctx_.node()->get_entity_freeze_frame_capture()) { + env_data_json = merge_entity_freeze_frames(std::move(env_data_json), capture->frames_for(fault_code)); + } auto detail = build_sovd_fault_response(fault_json, env_data_json, entity_path_info->entity_path); return wrap_detail_result(dto::JsonWriter::write(detail)); diff --git a/src/ros2_medkit_gateway/test/demo_nodes/test_data_provider_plugin.cpp b/src/ros2_medkit_gateway/test/demo_nodes/test_data_provider_plugin.cpp new file mode 100644 index 000000000..ef8c841b2 --- /dev/null +++ b/src/ros2_medkit_gateway/test/demo_nodes/test_data_provider_plugin.cpp @@ -0,0 +1,179 @@ +// Copyright 2026 mfaferek93 +// +// 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. + +#include +#include + +#include "ros2_medkit_gateway/core/plugins/gateway_plugin.hpp" +#include "ros2_medkit_gateway/core/plugins/plugin_context.hpp" +#include "ros2_medkit_gateway/core/plugins/plugin_types.hpp" +#include "ros2_medkit_gateway/core/providers/data_provider.hpp" +#include "ros2_medkit_gateway/core/providers/fault_provider.hpp" +#include "ros2_medkit_gateway/core/providers/introspection_provider.hpp" + +using namespace ros2_medkit_gateway; + +/** + * @brief Demo plugin mimicking a PLC protocol bridge (.so for integration tests). + * + * Introspects one external app entity ("test_plc_app") whose current values are + * served by DataProvider::list_data with per-item "value" fields, like the + * OPC-UA plugin. Used by test_entity_freeze_frame.test.py to prove a fault + * reported under the app's bare id carries a zero-config freeze-frame of the + * entity's own values. + */ +class TestDataProviderPlugin : public GatewayPlugin, + public IntrospectionProvider, + public DataProvider, + public FaultProvider { + public: + static constexpr const char * kAppId = "test_plc_app"; + + std::string name() const override { + return "test_data_provider"; + } + + void configure(const nlohmann::json & /*config*/) override { + } + + void set_context(PluginContext & context) override { + ctx_ = &context; + } + + // --- IntrospectionProvider --- + IntrospectionResult introspect(const IntrospectionInput & /*input*/) override { + IntrospectionResult result; + + Area area; + area.id = "test_plc_area"; + area.name = "Test PLC Cell"; + area.namespace_path = "/test_plc_area"; + area.source = "plugin"; + result.new_entities.areas.push_back(std::move(area)); + + Component comp; + comp.id = "test_plc"; + comp.name = "Test PLC Runtime"; + comp.namespace_path = "/test_plc_area"; + comp.fqn = "/test_plc_area/test_plc"; + comp.area = "test_plc_area"; + comp.source = "plugin"; + result.new_entities.components.push_back(std::move(comp)); + + App app; + app.id = kAppId; + app.name = "Test PLC Process"; + app.component_id = "test_plc"; + app.external = true; // faults report under the bare app id, not a ROS FQN + app.is_online = true; + app.source = "plugin"; + result.new_entities.apps.push_back(std::move(app)); + + return result; + } + + // --- DataProvider --- + tl::expected list_data(const std::string & entity_id) override { + if (entity_id != kAppId) { + return tl::make_unexpected( + DataProviderErrorInfo{DataProviderError::EntityNotFound, "No data for entity: " + entity_id, 404}); + } + nlohmann::json items = nlohmann::json::array(); + items.push_back({{"id", "temperature"}, + {"name", "Temperature"}, + {"category", "currentData"}, + {"value", 42.5}, + {"unit", "degC"}}); + items.push_back( + {{"id", "pressure"}, {"name", "Pressure"}, {"category", "currentData"}, {"value", 3.2}, {"unit", "bar"}}); + return dto::DataListResult{nlohmann::json{{"items", std::move(items)}}}; + } + + tl::expected read_data(const std::string & entity_id, + const std::string & resource_name) override { + auto list = list_data(entity_id); + if (!list) { + return tl::make_unexpected(list.error()); + } + for (const auto & item : list->content["items"]) { + if (item.value("id", "") == resource_name) { + return dto::DataValue{nlohmann::json{{"id", resource_name}, {"value", item["value"]}}}; + } + } + return tl::make_unexpected( + DataProviderErrorInfo{DataProviderError::ResourceNotFound, "No such resource: " + resource_name, 404}); + } + + tl::expected write_data(const std::string & /*entity_id*/, + const std::string & resource_name, + const nlohmann::json & /*value*/) override { + return tl::make_unexpected( + DataProviderErrorInfo{DataProviderError::ReadOnly, "Resource is read-only: " + resource_name, 405}); + } + + // --- FaultProvider (the fault_manager owns this entity's faults; the list + // projects them via PluginContext like the OPC-UA plugin, and get_fault + // returns not-found so the handler serves the fault_manager's enriched + // record with environment_data) --- + tl::expected list_faults(const std::string & entity_id) override { + nlohmann::json items = nlohmann::json::array(); + if (ctx_ != nullptr) { + auto faults = ctx_->list_entity_faults(entity_id); + if (faults.is_array()) { + for (const auto & f : faults) { + items.push_back({{"code", f.value("fault_code", "")}, + {"severity", f.value("severity", 0)}, + {"status", f.value("status", "")}}); + } + } + } + return dto::FaultListResult{nlohmann::json{{"items", std::move(items)}}}; + } + + tl::expected get_fault(const std::string & /*entity_id*/, + const std::string & fault_code) override { + return tl::make_unexpected( + FaultProviderErrorInfo{FaultProviderError::FaultNotFound, "Fault not found: " + fault_code, 404}); + } + + tl::expected clear_fault(const std::string & /*entity_id*/, + const std::string & fault_code) override { + return dto::FaultClearResult{nlohmann::json{{"code", fault_code}, {"cleared", true}}}; + } + + private: + PluginContext * ctx_{nullptr}; +}; + +// --- Plugin exports --- + +extern "C" GATEWAY_PLUGIN_EXPORT int plugin_api_version() { + return PLUGIN_API_VERSION; +} + +extern "C" GATEWAY_PLUGIN_EXPORT GatewayPlugin * create_plugin() { + return new TestDataProviderPlugin(); +} + +extern "C" GATEWAY_PLUGIN_EXPORT IntrospectionProvider * get_introspection_provider(GatewayPlugin * plugin) { + return static_cast(plugin); +} + +extern "C" GATEWAY_PLUGIN_EXPORT DataProvider * get_data_provider(GatewayPlugin * plugin) { + return static_cast(plugin); +} + +extern "C" GATEWAY_PLUGIN_EXPORT FaultProvider * get_fault_provider(GatewayPlugin * plugin) { + return static_cast(plugin); +} diff --git a/src/ros2_medkit_gateway/test/test_entity_freeze_frame_capture.cpp b/src/ros2_medkit_gateway/test/test_entity_freeze_frame_capture.cpp new file mode 100644 index 000000000..38cf5d097 --- /dev/null +++ b/src/ros2_medkit_gateway/test/test_entity_freeze_frame_capture.cpp @@ -0,0 +1,237 @@ +// Copyright 2026 mfaferek93 +// +// 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. + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "ros2_medkit_gateway/entity_freeze_frame_capture.hpp" +#include "ros2_medkit_gateway/http/handlers/fault_handlers.hpp" +#include "ros2_medkit_msgs/msg/fault_event.hpp" + +using json = nlohmann::json; +using namespace std::chrono_literals; +using ros2_medkit_gateway::DataProvider; +using ros2_medkit_gateway::DataProviderError; +using ros2_medkit_gateway::DataProviderErrorInfo; +using ros2_medkit_gateway::EntityFreezeFrameCapture; +using ros2_medkit_gateway::handlers::FaultHandlers; +using ros2_medkit_msgs::msg::Fault; +using ros2_medkit_msgs::msg::FaultEvent; + +namespace { + +/// Fake plugin DataProvider serving fixed PLC-like values for one entity. +class FakePlcDataProvider : public DataProvider { + public: + explicit FakePlcDataProvider(std::string entity_id) : entity_id_(std::move(entity_id)) { + } + + tl::expected + list_data(const std::string & entity_id) override { + if (entity_id != entity_id_) { + return tl::make_unexpected(DataProviderErrorInfo{DataProviderError::EntityNotFound, "not found", 404}); + } + json items = json::array(); + items.push_back({{"id", "temperature"}, {"name", "Temperature"}, {"value", 42.5}}); + items.push_back({{"id", "pressure"}, {"name", "Pressure"}, {"value", 3.2}}); + return ros2_medkit_gateway::dto::DataListResult{json{{"items", std::move(items)}}}; + } + + tl::expected + read_data(const std::string & /*entity_id*/, const std::string & /*resource_name*/) override { + return tl::make_unexpected(DataProviderErrorInfo{DataProviderError::Internal, "unused", 500}); + } + + tl::expected + write_data(const std::string & /*entity_id*/, const std::string & /*resource_name*/, + const json & /*value*/) override { + return tl::make_unexpected(DataProviderErrorInfo{DataProviderError::ReadOnly, "read-only", 405}); + } + + private: + std::string entity_id_; +}; + +FaultEvent make_confirmed_event(const std::string & fault_code, const std::vector & sources) { + FaultEvent event; + event.event_type = FaultEvent::EVENT_CONFIRMED; + event.fault.fault_code = fault_code; + event.fault.severity = Fault::SEVERITY_ERROR; + event.fault.status = Fault::STATUS_CONFIRMED; + event.fault.reporting_sources = sources; + return event; +} + +class EntityFreezeFrameCaptureTest : public ::testing::Test { + protected: + void SetUp() override { + node_ = std::make_shared("entity_freeze_frame_test_node"); + publisher_ = node_->create_publisher("/fault_manager/events", rclcpp::QoS(100).reliable()); + provider_ = std::make_unique("plc_app"); + } + + /// Publish an event and spin until the capture holds frames (or timeout). + bool publish_and_wait(EntityFreezeFrameCapture & capture, const FaultEvent & event) { + publisher_->publish(event); + const auto deadline = std::chrono::steady_clock::now() + 5s; + while (std::chrono::steady_clock::now() < deadline) { + rclcpp::spin_some(node_); + if (!capture.frames_for(event.fault.fault_code).empty()) { + return true; + } + std::this_thread::sleep_for(10ms); + } + return false; + } + + std::shared_ptr node_; + rclcpp::Publisher::SharedPtr publisher_; + std::unique_ptr provider_; +}; + +} // namespace + +/// @verifies REQ_INTEROP_088 +TEST_F(EntityFreezeFrameCaptureTest, ConfirmedPluginFaultCapturesEntityValues) { + EntityFreezeFrameCapture capture(node_.get(), [this](const std::string & entity_id) -> DataProvider * { + return entity_id == "plc_app" ? provider_.get() : nullptr; + }); + + ASSERT_TRUE(publish_and_wait(capture, make_confirmed_event("PLC_OVERPRESSURE", {"plc_app"}))); + + auto frames = capture.frames_for("PLC_OVERPRESSURE"); + ASSERT_EQ(frames.size(), 1u); + EXPECT_EQ(frames[0].entity_id, "plc_app"); + EXPECT_DOUBLE_EQ(frames[0].values.value("temperature", 0.0), 42.5); + EXPECT_DOUBLE_EQ(frames[0].values.value("pressure", 0.0), 3.2); + EXPECT_GT(frames[0].captured_at_ns, 0); +} + +/// @verifies REQ_INTEROP_088 +TEST_F(EntityFreezeFrameCaptureTest, NonPluginSourceCapturesNothing) { + EntityFreezeFrameCapture capture(node_.get(), [](const std::string &) -> DataProvider * { + return nullptr; // ROS FQN sources are never plugin-owned + }); + + auto event = make_confirmed_event("ROS_FAULT", {"/powertrain/engine/temp_sensor"}); + publisher_->publish(event); + // Give the callback a chance to run, then assert nothing was captured. + for (int i = 0; i < 20; ++i) { + rclcpp::spin_some(node_); + std::this_thread::sleep_for(10ms); + } + EXPECT_TRUE(capture.frames_for("ROS_FAULT").empty()); +} + +/// @verifies REQ_INTEROP_088 +TEST_F(EntityFreezeFrameCaptureTest, NonConfirmedEventsAreIgnored) { + EntityFreezeFrameCapture capture(node_.get(), [this](const std::string & entity_id) -> DataProvider * { + return entity_id == "plc_app" ? provider_.get() : nullptr; + }); + + auto event = make_confirmed_event("PLC_UPDATED_ONLY", {"plc_app"}); + event.event_type = FaultEvent::EVENT_UPDATED; + publisher_->publish(event); + for (int i = 0; i < 20; ++i) { + rclcpp::spin_some(node_); + std::this_thread::sleep_for(10ms); + } + EXPECT_TRUE(capture.frames_for("PLC_UPDATED_ONLY").empty()); +} + +TEST(ValuesFromListContent, BuildsCompactDictFromItems) { + json content = {{"items", json::array({{{"id", "a"}, {"value", 1}}, + {{"name", "b"}, {"value", "on"}}, // falls back to "name" key + {{"id", "c"}}})}}; // no value -> null + auto values = EntityFreezeFrameCapture::values_from_list_content(content); + EXPECT_EQ(values["a"], 1); + EXPECT_EQ(values["b"], "on"); + EXPECT_TRUE(values["c"].is_null()); +} + +TEST(ValuesFromListContent, NonItemsShapeKeptVerbatim) { + json content = {{"status", "ok"}, {"raw", 7}}; + EXPECT_EQ(EntityFreezeFrameCapture::values_from_list_content(content), content); +} + +TEST(MergeEntityFreezeFrames, AppendsWhenNoConfiguredFreezeFrame) { + json env_data = {{"snapshots", json::array()}}; + EntityFreezeFrameCapture::Frame frame; + frame.entity_id = "plc_app"; + frame.values = {{"temperature", 42.5}}; + frame.captured_at_ns = 1234; + + auto merged = FaultHandlers::merge_entity_freeze_frames(env_data, {frame}); + ASSERT_EQ(merged["snapshots"].size(), 1u); + const auto & snap = merged["snapshots"][0]; + EXPECT_EQ(snap["type"], "freeze_frame"); + EXPECT_EQ(snap["name"], "plc_app"); + EXPECT_EQ(json::parse(snap["data"].get())["temperature"], 42.5); + EXPECT_EQ(snap["captured_at_ns"], 1234); +} + +TEST(MergeEntityFreezeFrames, ExplicitConfigWins) { + // A fault_manager-captured freeze-frame (explicit snapshot config) must + // suppress the zero-config entity frames. + json env_data = {{"snapshots", json::array({{{"type", "freeze_frame"}, + {"snapshot_type", "freeze_frame"}, + {"name", "scan"}, + {"data", "{}"}, + {"topic", "/scan"}, + {"message_type", "sensor_msgs/msg/LaserScan"}, + {"captured_at_ns", 1}}})}}; + EntityFreezeFrameCapture::Frame frame; + frame.entity_id = "plc_app"; + frame.values = {{"temperature", 42.5}}; + + auto merged = FaultHandlers::merge_entity_freeze_frames(env_data, {frame}); + ASSERT_EQ(merged["snapshots"].size(), 1u); + EXPECT_EQ(merged["snapshots"][0]["name"], "scan"); +} + +TEST(MergeEntityFreezeFrames, RosbagSnapshotDoesNotSuppressEntityFrames) { + // Only a freeze-frame counts as "explicit config captured": a rosbag-only + // environment still gets the entity frame appended. + json env_data = { + {"snapshots", + json::array({{{"type", "rosbag"}, {"snapshot_type", "rosbag"}, {"name", "rosbag_X"}, {"fault_code", "X"}}})}}; + EntityFreezeFrameCapture::Frame frame; + frame.entity_id = "plc_app"; + frame.values = {{"temperature", 42.5}}; + + auto merged = FaultHandlers::merge_entity_freeze_frames(env_data, {frame}); + ASSERT_EQ(merged["snapshots"].size(), 2u); + EXPECT_EQ(merged["snapshots"][1]["name"], "plc_app"); +} + +TEST(MergeEntityFreezeFrames, NoFramesNoChange) { + json env_data = {{"snapshots", json::array()}}; + auto merged = FaultHandlers::merge_entity_freeze_frames(env_data, {}); + EXPECT_EQ(merged, env_data); +} + +int main(int argc, char ** argv) { + ::testing::InitGoogleTest(&argc, argv); + rclcpp::init(argc, argv); + int result = RUN_ALL_TESTS(); + rclcpp::shutdown(); + return result; +} diff --git a/src/ros2_medkit_integration_tests/test/features/test_entity_freeze_frame.test.py b/src/ros2_medkit_integration_tests/test/features/test_entity_freeze_frame.test.py new file mode 100644 index 000000000..7edb785c9 --- /dev/null +++ b/src/ros2_medkit_integration_tests/test/features/test_entity_freeze_frame.test.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +# Copyright 2026 mfaferek93 +# +# 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. + +"""Zero-config freeze-frames: the faulting entity's own data, no snapshot config. + +The fault manager runs WITHOUT any snapshot configuration (empty +``snapshots.default_topics``, no config file). Two entity-default paths are +proven end to end: + +1. Plugin-backed entity (the headline PLC case): a fault reported under the + demo data-provider plugin's bare app id carries a freeze-frame of that + entity's current data values, captured by the gateway from the plugin's + DataProvider at confirm time. +2. ROS-backed entity: a fault reported under a demo node's FQN carries a + freeze-frame of that node's own published topics, captured by the fault + manager's entity-default fallback. +""" + +import os +import time +import unittest + +from ament_index_python.packages import get_package_prefix +import launch_testing +import rclpy +from rclpy.node import Node +import requests +from ros2_medkit_msgs.msg import Fault +from ros2_medkit_msgs.srv import ReportFault + +from ros2_medkit_test_utils.constants import ALLOWED_EXIT_CODES +from ros2_medkit_test_utils.gateway_test_case import GatewayTestCase +from ros2_medkit_test_utils.launch_helpers import create_test_launch + + +PLUGIN_APP = 'test_plc_app' +PLUGIN_FAULT_CODE = 'PLC_OVERPRESSURE' +ROS_APP = 'temp_sensor' +ROS_SOURCE = '/powertrain/engine/temp_sensor' +ROS_FAULT_CODE = 'ENGINE_TEMP_SENSOR_DEGRADED' + + +def _get_plugin_path(): + """Get path to the demo data-provider plugin .so.""" + pkg_prefix = get_package_prefix('ros2_medkit_gateway') + return os.path.join( + pkg_prefix, 'lib', 'ros2_medkit_gateway', + 'libtest_data_provider_plugin.so', + ) + + +def generate_test_description(): + return create_test_launch( + demo_nodes=['temp_sensor'], + fault_manager=True, + # No snapshot config at all: entity-default capture is the only path + # that can produce a freeze-frame. + fault_manager_params={ + # [''] is the launch-file spelling of "no default topics" + # (dropped by the node); entity-default is the only capture path. + 'snapshots.default_topics': [''], + 'snapshots.rosbag.enabled': False, + # Generous on-demand sampling window for slow demo publishers. + 'snapshots.timeout_sec': 5.0, + }, + gateway_params={ + 'plugins': ['test_data_provider'], + 'plugins.test_data_provider.path': _get_plugin_path(), + }, + ) + + +class TestEntityFreezeFrame(GatewayTestCase): + """Faults carry the faulting entity's own data with zero snapshot config.""" + + MIN_EXPECTED_APPS = 2 + REQUIRED_APPS = {PLUGIN_APP, ROS_APP} + + @classmethod + def setUpClass(cls): + super().setUpClass() + rclpy.init() + cls._reporter = Node('entity_freeze_frame_fault_reporter') + cls._report_client = cls._reporter.create_client( + ReportFault, '/fault_manager/report_fault' + ) + assert cls._report_client.wait_for_service(timeout_sec=15.0), \ + 'report_fault service not available' + + @classmethod + def tearDownClass(cls): + cls._reporter.destroy_node() + rclpy.shutdown() + + def _report_fault(self, fault_code, source_id, times=4): + """Report a fault, fire-and-forget (see test_external_app_fault_rollup).""" + for _ in range(times): + req = ReportFault.Request() + req.fault_code = fault_code + req.event_type = ReportFault.Request.EVENT_FAILED + req.severity = Fault.SEVERITY_ERROR + req.description = 'entity freeze-frame test fault' + req.source_id = source_id + self._report_client.call_async(req) + rclpy.spin_once(self._reporter, timeout_sec=0.1) + + def _wait_for_freeze_frame(self, entity_endpoint, fault_code, *, max_wait=30.0): + """Poll the fault detail until a freeze_frame snapshot appears.""" + deadline = time.monotonic() + max_wait + last = None + while time.monotonic() < deadline: + try: + response = requests.get( + f'{self.BASE_URL}{entity_endpoint}/faults/{fault_code}', + timeout=5, + ) + if response.status_code == 200: + last = response.json() + snapshots = last.get( + 'environment_data', {}).get('snapshots', []) + frames = [s for s in snapshots + if s.get('type') == 'freeze_frame'] + if frames: + return frames + except requests.exceptions.RequestException: + pass + time.sleep(0.5) + raise AssertionError( + f'No freeze_frame snapshot for {fault_code} at {entity_endpoint} ' + f'within {max_wait}s; last response: {last}' + ) + + def test_plugin_entity_fault_carries_entity_values(self): + """A PLC-style plugin fault carries the entity's own data values. + + @verifies REQ_INTEROP_088 + """ + self._report_fault(PLUGIN_FAULT_CODE, PLUGIN_APP) + + frames = self._wait_for_freeze_frame( + f'/apps/{PLUGIN_APP}', PLUGIN_FAULT_CODE) + frame = next( + (f for f in frames if f.get('name') == PLUGIN_APP), None) + self.assertIsNotNone( + frame, f'No frame named {PLUGIN_APP} in {frames}') + + # The frame holds the plugin entity's current values as served by + # its DataProvider (compact {resource_id: value} dict). + self.assertEqual(frame['data'].get('temperature'), 42.5) + self.assertEqual(frame['data'].get('pressure'), 3.2) + + # Standard freeze_frame x-medkit metadata block (fleet_ui contract). + x_medkit = frame.get('x-medkit', {}) + self.assertIn('full_data', x_medkit) + self.assertIn('captured_at', x_medkit) + + def test_ros_entity_fault_carries_own_topic_data(self): + """A ROS-node fault carries the node's own published topic data. + + @verifies REQ_INTEROP_088 + """ + self._report_fault(ROS_FAULT_CODE, ROS_SOURCE) + + frames = self._wait_for_freeze_frame( + f'/apps/{ROS_APP}', ROS_FAULT_CODE) + + # The fault manager's entity-default capture aggregates the source + # node's own topics; temp_sensor publishes .../temperature. + all_topics = set() + for frame in frames: + x_medkit = frame.get('x-medkit', {}) + topic = x_medkit.get('topic', '') + if topic: + all_topics.add(topic) + full_data = x_medkit.get('full_data', {}) + if isinstance(full_data, dict): + all_topics.update(full_data.keys()) + self.assertTrue( + any('temperature' in t for t in all_topics), + f'temp_sensor topic not captured; topics seen: {all_topics}', + ) + + +@launch_testing.post_shutdown_test() +class TestShutdown(unittest.TestCase): + + def test_exit_codes(self, proc_info): + """Check all processes exited cleanly (SIGTERM allowed).""" + for info in proc_info: + self.assertIn( + info.returncode, ALLOWED_EXIT_CODES, + f'{info.process_name} exited with code {info.returncode}' + ) From dac2d7718794a39cbcc97259e7e57a31b293e20c Mon Sep 17 00:00:00 2001 From: Michal Faferek Date: Thu, 16 Jul 2026 19:42:26 +0200 Subject: [PATCH 2/7] fix(gateway): route entity freeze-frame through Ros2SubscriptionSlot The zero-config freeze-frame capture created a naked create_subscription on the main node, tripping the issue #375 regression gate. Subscribe via the shared Ros2SubscriptionExecutor instead (wired in main once the executor exists), and update the unit test to drive the slot path. --- .../entity_freeze_frame_capture.hpp | 11 ++- .../ros2_medkit_gateway/gateway_node.hpp | 7 ++ .../src/entity_freeze_frame_capture.cpp | 21 +++-- src/ros2_medkit_gateway/src/gateway_node.cpp | 24 +++--- src/ros2_medkit_gateway/src/main.cpp | 6 ++ .../test/test_entity_freeze_frame_capture.cpp | 79 +++++++++++++++---- 6 files changed, 115 insertions(+), 33 deletions(-) diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/entity_freeze_frame_capture.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/entity_freeze_frame_capture.hpp index 3c6b9959c..4e72c11f7 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/entity_freeze_frame_capture.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/entity_freeze_frame_capture.hpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -27,6 +28,7 @@ #include "rclcpp/rclcpp.hpp" #include "ros2_medkit_gateway/core/providers/data_provider.hpp" +#include "ros2_medkit_gateway/ros2_common/ros2_subscription_slot.hpp" #include "ros2_medkit_msgs/msg/fault_event.hpp" namespace ros2_medkit_gateway { @@ -60,11 +62,14 @@ class EntityFreezeFrameCapture { using DataProviderResolver = std::function; /** - * @param node ROS 2 node for creating the subscription (caller manages lifetime) + * @param node ROS 2 node used to resolve the fault-events topic name and logger + * @param exec shared subscription executor; the fault-events subscription is + * created and torn down on its serial worker (issue #375 invariant) * @param resolver entity-to-DataProvider resolver (typically wraps PluginManager) * @param max_faults retained-frame bound; oldest fault's frames evicted past it */ - EntityFreezeFrameCapture(rclcpp::Node * node, DataProviderResolver resolver, size_t max_faults = 256); + EntityFreezeFrameCapture(rclcpp::Node * node, ros2_common::Ros2SubscriptionExecutor & exec, + DataProviderResolver resolver, size_t max_faults = 256); ~EntityFreezeFrameCapture(); @@ -85,7 +90,7 @@ class EntityFreezeFrameCapture { private: void on_fault_event(const ros2_medkit_msgs::msg::FaultEvent::ConstSharedPtr & msg); - rclcpp::Subscription::SharedPtr subscription_; + std::unique_ptr subscription_slot_; DataProviderResolver resolver_; rclcpp::Logger logger_; const size_t max_faults_; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/gateway_node.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/gateway_node.hpp index adc78e925..17fc574b3 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/gateway_node.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/gateway_node.hpp @@ -181,6 +181,13 @@ class GatewayNode : public rclcpp::Node { */ PluginManager * get_plugin_manager() const; + /** + * @brief Wire the zero-config entity freeze-frame capture onto the shared + * subscription executor. Call once after the executor is built and + * plugins are loaded (main.cpp). No-op when disabled or no plugins. + */ + void init_entity_freeze_frame_capture(ros2_common::Ros2SubscriptionExecutor & exec); + /** * @brief Get the zero-config entity freeze-frame capture * @return Raw pointer, or nullptr when disabled / no plugins loaded diff --git a/src/ros2_medkit_gateway/src/entity_freeze_frame_capture.cpp b/src/ros2_medkit_gateway/src/entity_freeze_frame_capture.cpp index bfc21159e..d51262d58 100644 --- a/src/ros2_medkit_gateway/src/entity_freeze_frame_capture.cpp +++ b/src/ros2_medkit_gateway/src/entity_freeze_frame_capture.cpp @@ -20,21 +20,30 @@ namespace ros2_medkit_gateway { -EntityFreezeFrameCapture::EntityFreezeFrameCapture(rclcpp::Node * node, DataProviderResolver resolver, - size_t max_faults) +EntityFreezeFrameCapture::EntityFreezeFrameCapture(rclcpp::Node * node, ros2_common::Ros2SubscriptionExecutor & exec, + DataProviderResolver resolver, size_t max_faults) : resolver_(std::move(resolver)), logger_(node->get_logger()), max_faults_(max_faults > 0 ? max_faults : 1) { + // Resolve the topic from the gateway node (it owns fault_manager.namespace); + // the subscription itself is created on the executor's dedicated _sub node so + // it never races rcl's hash-map on the main node (issue #375). const auto fault_events_topic = build_fault_manager_events_topic(node); - subscription_ = node->create_subscription( - fault_events_topic, rclcpp::QoS(100).reliable(), - [this](const ros2_medkit_msgs::msg::FaultEvent::ConstSharedPtr & msg) { + auto slot = ros2_common::Ros2SubscriptionSlot::create_typed( + exec, fault_events_topic, rclcpp::QoS(100).reliable(), + [this](std::shared_ptr msg) { on_fault_event(msg); }); + if (!slot) { + RCLCPP_ERROR(logger_, "EntityFreezeFrameCapture: failed to subscribe to %s: %s", fault_events_topic.c_str(), + slot.error().c_str()); + return; + } + subscription_slot_ = std::move(*slot); RCLCPP_INFO(logger_, "EntityFreezeFrameCapture initialized, subscribed to %s", fault_events_topic.c_str()); } EntityFreezeFrameCapture::~EntityFreezeFrameCapture() { - subscription_.reset(); + subscription_slot_.reset(); } std::vector diff --git a/src/ros2_medkit_gateway/src/gateway_node.cpp b/src/ros2_medkit_gateway/src/gateway_node.cpp index c076ecd78..df60264bc 100644 --- a/src/ros2_medkit_gateway/src/gateway_node.cpp +++ b/src/ros2_medkit_gateway/src/gateway_node.cpp @@ -737,15 +737,9 @@ GatewayNode::GatewayNode(const rclcpp::NodeOptions & options) : Node("ros2_medki // needed because refresh_cache() runs before the HTTP server accepts requests. } - // Zero-config freeze-frames for plugin-backed entities: snapshot the faulting - // entity's current data values when its fault confirms. Only useful when a - // plugin can own entities, so gated on loaded plugins. - if (get_parameter("entity_freeze_frame.enabled").as_bool() && plugin_mgr_->has_plugins()) { - entity_freeze_frame_capture_ = - std::make_unique(this, [this](const std::string & entity_id) { - return plugin_mgr_ ? plugin_mgr_->get_data_provider_for_entity(entity_id) : nullptr; - }); - } + // Zero-config freeze-frames need the shared subscription executor, which is + // built after the node (main.cpp). Its wiring is deferred to + // init_entity_freeze_frame_capture(), called once the executor exists. // Initialize log manager (subscribes to /rosout, delegates to plugin if available) static constexpr int kMinBufferSize = 1; @@ -1793,6 +1787,18 @@ PluginManager * GatewayNode::get_plugin_manager() const { return plugin_mgr_.get(); } +void GatewayNode::init_entity_freeze_frame_capture(ros2_common::Ros2SubscriptionExecutor & exec) { + // Snapshot the faulting entity's current data values when its fault confirms. + // Only useful when a plugin can own entities, so gated on loaded plugins. + if (!get_parameter("entity_freeze_frame.enabled").as_bool() || !plugin_mgr_ || !plugin_mgr_->has_plugins()) { + return; + } + entity_freeze_frame_capture_ = + std::make_unique(this, exec, [this](const std::string & entity_id) { + return plugin_mgr_ ? plugin_mgr_->get_data_provider_for_entity(entity_id) : nullptr; + }); +} + EntityFreezeFrameCapture * GatewayNode::get_entity_freeze_frame_capture() const { return entity_freeze_frame_capture_.get(); } diff --git a/src/ros2_medkit_gateway/src/main.cpp b/src/ros2_medkit_gateway/src/main.cpp index d0431e99e..b174bd751 100644 --- a/src/ros2_medkit_gateway/src/main.cpp +++ b/src/ros2_medkit_gateway/src/main.cpp @@ -134,6 +134,12 @@ int main(int argc, char ** argv) { auto data_provider = std::make_shared(sub_exec, serializer, dp_cfg); node->set_topic_data_provider(data_provider); + // Zero-config entity freeze-frames subscribe through the same serial worker + // (issue #375). Wired here because the executor is built after the node; the + // slot is dropped in ~GatewayNode, which runs after sub_exec.reset() below + // and so takes the executor's shutdown fast path. + node->init_entity_freeze_frame_capture(*sub_exec); + // Spin in a try/catch so an uncaught handler exception falls through to the // explicit teardown block below. Without this, an escaping throw bypasses // the teardown ordering and triggers exactly the rclcpp abort described diff --git a/src/ros2_medkit_gateway/test/test_entity_freeze_frame_capture.cpp b/src/ros2_medkit_gateway/test/test_entity_freeze_frame_capture.cpp index 38cf5d097..9a936dd79 100644 --- a/src/ros2_medkit_gateway/test/test_entity_freeze_frame_capture.cpp +++ b/src/ros2_medkit_gateway/test/test_entity_freeze_frame_capture.cpp @@ -24,6 +24,7 @@ #include "ros2_medkit_gateway/entity_freeze_frame_capture.hpp" #include "ros2_medkit_gateway/http/handlers/fault_handlers.hpp" +#include "ros2_medkit_gateway/ros2_common/ros2_subscription_executor.hpp" #include "ros2_medkit_msgs/msg/fault_event.hpp" using json = nlohmann::json; @@ -33,6 +34,7 @@ using ros2_medkit_gateway::DataProviderError; using ros2_medkit_gateway::DataProviderErrorInfo; using ros2_medkit_gateway::EntityFreezeFrameCapture; using ros2_medkit_gateway::handlers::FaultHandlers; +using ros2_medkit_gateway::ros2_common::Ros2SubscriptionExecutor; using ros2_medkit_msgs::msg::Fault; using ros2_medkit_msgs::msg::FaultEvent; @@ -84,26 +86,73 @@ class EntityFreezeFrameCaptureTest : public ::testing::Test { protected: void SetUp() override { node_ = std::make_shared("entity_freeze_frame_test_node"); - publisher_ = node_->create_publisher("/fault_manager/events", rclcpp::QoS(100).reliable()); + // Publisher lives on a node the main executor never spins (see + // Ros2SubscriptionSlotTest): publishers mutate their node's rcutils_hash_map + // under the hood, which TSan otherwise flags against the spun node. + publisher_node_ = std::make_shared("entity_freeze_frame_test_publisher"); + publisher_ = publisher_node_->create_publisher("/fault_manager/events", rclcpp::QoS(100).reliable()); provider_ = std::make_unique("plc_app"); + + executor_ = std::make_shared(); + executor_->add_node(node_); + spin_thread_ = std::thread([this] { + executor_->spin(); + }); + // The capture creates its fault-events subscription on this executor's + // dedicated _sub node, off the main node (issue #375). + sub_exec_ = std::make_unique(node_); + } + + void TearDown() override { + if (executor_) { + executor_->cancel(); + } + if (spin_thread_.joinable()) { + spin_thread_.join(); + } + sub_exec_.reset(); + executor_.reset(); + publisher_.reset(); + publisher_node_.reset(); + node_.reset(); } - /// Publish an event and spin until the capture holds frames (or timeout). + /// Block until the capture's subscription has matched our publisher, so a + /// reliable-QoS publish is not dropped by a late-joining subscriber. + bool wait_for_match() { + const auto deadline = std::chrono::steady_clock::now() + 5s; + while (std::chrono::steady_clock::now() < deadline) { + if (publisher_->get_subscription_count() > 0) { + return true; + } + std::this_thread::sleep_for(10ms); + } + return false; + } + + /// Publish an event repeatedly until the capture holds frames (or timeout). + /// The callback runs on the subscription executor worker, not this thread. bool publish_and_wait(EntityFreezeFrameCapture & capture, const FaultEvent & event) { - publisher_->publish(event); + if (!wait_for_match()) { + return false; + } const auto deadline = std::chrono::steady_clock::now() + 5s; while (std::chrono::steady_clock::now() < deadline) { - rclcpp::spin_some(node_); + publisher_->publish(event); if (!capture.frames_for(event.fault.fault_code).empty()) { return true; } - std::this_thread::sleep_for(10ms); + std::this_thread::sleep_for(20ms); } return false; } std::shared_ptr node_; + std::shared_ptr publisher_node_; rclcpp::Publisher::SharedPtr publisher_; + std::shared_ptr executor_; + std::thread spin_thread_; + std::unique_ptr sub_exec_; std::unique_ptr provider_; }; @@ -111,7 +160,7 @@ class EntityFreezeFrameCaptureTest : public ::testing::Test { /// @verifies REQ_INTEROP_088 TEST_F(EntityFreezeFrameCaptureTest, ConfirmedPluginFaultCapturesEntityValues) { - EntityFreezeFrameCapture capture(node_.get(), [this](const std::string & entity_id) -> DataProvider * { + EntityFreezeFrameCapture capture(node_.get(), *sub_exec_, [this](const std::string & entity_id) -> DataProvider * { return entity_id == "plc_app" ? provider_.get() : nullptr; }); @@ -127,15 +176,15 @@ TEST_F(EntityFreezeFrameCaptureTest, ConfirmedPluginFaultCapturesEntityValues) { /// @verifies REQ_INTEROP_088 TEST_F(EntityFreezeFrameCaptureTest, NonPluginSourceCapturesNothing) { - EntityFreezeFrameCapture capture(node_.get(), [](const std::string &) -> DataProvider * { + EntityFreezeFrameCapture capture(node_.get(), *sub_exec_, [](const std::string &) -> DataProvider * { return nullptr; // ROS FQN sources are never plugin-owned }); + ASSERT_TRUE(wait_for_match()); auto event = make_confirmed_event("ROS_FAULT", {"/powertrain/engine/temp_sensor"}); - publisher_->publish(event); - // Give the callback a chance to run, then assert nothing was captured. - for (int i = 0; i < 20; ++i) { - rclcpp::spin_some(node_); + // Deliver repeatedly so the callback demonstrably ran and filtered it. + for (int i = 0; i < 25; ++i) { + publisher_->publish(event); std::this_thread::sleep_for(10ms); } EXPECT_TRUE(capture.frames_for("ROS_FAULT").empty()); @@ -143,15 +192,15 @@ TEST_F(EntityFreezeFrameCaptureTest, NonPluginSourceCapturesNothing) { /// @verifies REQ_INTEROP_088 TEST_F(EntityFreezeFrameCaptureTest, NonConfirmedEventsAreIgnored) { - EntityFreezeFrameCapture capture(node_.get(), [this](const std::string & entity_id) -> DataProvider * { + EntityFreezeFrameCapture capture(node_.get(), *sub_exec_, [this](const std::string & entity_id) -> DataProvider * { return entity_id == "plc_app" ? provider_.get() : nullptr; }); + ASSERT_TRUE(wait_for_match()); auto event = make_confirmed_event("PLC_UPDATED_ONLY", {"plc_app"}); event.event_type = FaultEvent::EVENT_UPDATED; - publisher_->publish(event); - for (int i = 0; i < 20; ++i) { - rclcpp::spin_some(node_); + for (int i = 0; i < 25; ++i) { + publisher_->publish(event); std::this_thread::sleep_for(10ms); } EXPECT_TRUE(capture.frames_for("PLC_UPDATED_ONLY").empty()); From b5238e4cbd64ec34e8f7457c26406ae0d4062def Mon Sep 17 00:00:00 2001 From: Michal Faferek Date: Thu, 16 Jul 2026 22:14:28 +0200 Subject: [PATCH 3/7] fix(gateway): freeze-frame fallback via in-process x-plc-data dispatch Plugins without a DataProvider (the commercial PLC bridges) serve live values only through their registered routes, so entity freeze-frames came out empty. Dispatch the owning plugin's x-plc-data route in-process at fault confirm, gated on connected + non-empty items. --- src/ros2_medkit_gateway/CMakeLists.txt | 14 ++ .../core/plugins/plugin_manager.hpp | 22 +++ .../entity_freeze_frame_capture.hpp | 34 +++- .../src/entity_freeze_frame_capture.cpp | 62 +++++- src/ros2_medkit_gateway/src/gateway_node.cpp | 11 +- .../src/plugins/plugin_manager.cpp | 177 +++++++++++++----- .../demo_nodes/test_route_data_plugin.cpp | 166 ++++++++++++++++ .../test/test_entity_freeze_frame_capture.cpp | 85 +++++++++ .../test/test_plugin_manager.cpp | 99 ++++++++++ .../features/test_entity_freeze_frame.test.py | 63 +++++-- 10 files changed, 664 insertions(+), 69 deletions(-) create mode 100644 src/ros2_medkit_gateway/test/demo_nodes/test_route_data_plugin.cpp diff --git a/src/ros2_medkit_gateway/CMakeLists.txt b/src/ros2_medkit_gateway/CMakeLists.txt index 62004534c..1ce4655c8 100644 --- a/src/ros2_medkit_gateway/CMakeLists.txt +++ b/src/ros2_medkit_gateway/CMakeLists.txt @@ -895,6 +895,20 @@ if(BUILD_TESTING) ) install(TARGETS test_data_provider_plugin LIBRARY DESTINATION lib/${PROJECT_NAME}) + # Demo commercial-bridge-shaped plugin: Introspection + Fault providers only, + # live values served exclusively via a registered x-plc-data route (no + # get_data_provider export). Proves the freeze-frame route-dispatch fallback. + add_library(test_route_data_plugin MODULE test/demo_nodes/test_route_data_plugin.cpp) + target_include_directories(test_route_data_plugin PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include) + target_link_libraries(test_route_data_plugin + nlohmann_json::nlohmann_json + tl::expected + cpp_httplib_target + OpenSSL::SSL + OpenSSL::Crypto + ) + install(TARGETS test_route_data_plugin LIBRARY DESTINATION lib/${PROJECT_NAME}) + # Minimal plugin with no provider query functions (only required exports) add_library(test_minimal_plugin MODULE test/demo_nodes/test_minimal_plugin.cpp) target_include_directories(test_minimal_plugin PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include) diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/plugins/plugin_manager.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/plugins/plugin_manager.hpp index e7f196f1e..cf2239603 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/plugins/plugin_manager.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/plugins/plugin_manager.hpp @@ -215,6 +215,20 @@ class PluginManager : public LogProviderRegistry { /// Get FaultProvider for a specific entity (if plugin-owned) FaultProvider * get_fault_provider_for_entity(const std::string & entity_id) const; + /** + * @brief Fetch an entity's current data by dispatching the owning plugin's + * own `apps//x-plc-data` GET route in-process. + * + * Fallback for plugins that serve live values only through registered HTTP + * routes (the commercial PLC bridges) instead of a DataProvider. The route + * table is matched exactly like the HTTP server would and the handler is + * invoked with a synthesized request - no loopback connection. + * + * @return Parsed JSON body on a 200 response; nullopt when the entity is + * not plugin-owned, no route matches, or the handler fails. + */ + std::optional fetch_entity_data_via_route(const std::string & entity_id); + /// Check if an entity is owned by a plugin /// @return Plugin name if owned, nullopt otherwise std::optional get_entity_owner(const std::string & entity_id) const; @@ -237,8 +251,16 @@ class PluginManager : public LogProviderRegistry { OperationProvider * operation_provider = nullptr; LifecycleProvider * lifecycle_provider = nullptr; FaultProvider * fault_provider = nullptr; + /// get_routes() result, fetched once per plugin (contract: called once); + /// shared by register_routes() and fetch_entity_data_via_route(). + std::vector routes; + bool routes_cached = false; }; + /// Fetch and cache lp.routes on first use (unique lock must be held). + /// A throwing get_routes() disables the plugin. @return false when disabled. + bool cache_routes_locked(LoadedPlugin & lp); + /// Disable a plugin after a lifecycle error (nulls providers, resets plugin). /// Also clears provider pointers in load_result to prevent dangling references. void disable_plugin(LoadedPlugin & lp); diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/entity_freeze_frame_capture.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/entity_freeze_frame_capture.hpp index 4e72c11f7..8c85d4266 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/entity_freeze_frame_capture.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/entity_freeze_frame_capture.hpp @@ -20,8 +20,10 @@ #include #include #include +#include #include #include +#include #include #include @@ -61,15 +63,24 @@ class EntityFreezeFrameCapture { /// the entity is not plugin-owned). Called from the subscription callback. using DataProviderResolver = std::function; + /// Fallback for plugins without a DataProvider: fetches the entity's current + /// values by dispatching the owning plugin's own `x-plc-data` route + /// in-process (typically wraps PluginManager::fetch_entity_data_via_route). + /// Returns the parsed route response, nullopt when unavailable. + using RouteDataFetcher = std::function(const std::string & entity_id)>; + /** * @param node ROS 2 node used to resolve the fault-events topic name and logger * @param exec shared subscription executor; the fault-events subscription is * created and torn down on its serial worker (issue #375 invariant) * @param resolver entity-to-DataProvider resolver (typically wraps PluginManager) + * @param route_fetcher x-plc-data route fallback for entities whose plugin + * has no DataProvider (the commercial PLC bridges); may be null * @param max_faults retained-frame bound; oldest fault's frames evicted past it */ EntityFreezeFrameCapture(rclcpp::Node * node, ros2_common::Ros2SubscriptionExecutor & exec, - DataProviderResolver resolver, size_t max_faults = 256); + DataProviderResolver resolver, RouteDataFetcher route_fetcher = nullptr, + size_t max_faults = 256); ~EntityFreezeFrameCapture(); @@ -87,19 +98,34 @@ class EntityFreezeFrameCapture { /// "items" array is kept verbatim (plugin-defined shape). static nlohmann::json values_from_list_content(const nlohmann::json & content); + /// True when an x-plc-data route response carries real live values: not + /// flagged disconnected, with a non-empty items array. A PLC that is down + /// must not freeze-frame a row of nulls. + static bool route_content_has_live_data(const nlohmann::json & content); + private: void on_fault_event(const ros2_medkit_msgs::msg::FaultEvent::ConstSharedPtr & msg); + /// Capture via the plugin's own x-plc-data route (no DataProvider exported). + /// Returns nullopt when the route yields nothing usable. + std::optional capture_via_route(const std::string & entity_id, const std::string & fault_code); + + /// Log a fallback failure once per fault code (faults re-confirm on every + /// clear/re-report cycle; one line per code is enough for an operator). + void log_fallback_failure_once(const std::string & fault_code, const std::string & message); + std::unique_ptr subscription_slot_; DataProviderResolver resolver_; + RouteDataFetcher route_fetcher_; rclcpp::Logger logger_; const size_t max_faults_; - /// Guards frames_ and insertion_order_: subscription callback writes, - /// HTTP handler threads read. + /// Guards frames_, insertion_order_ and fallback_logged_: subscription + /// callback writes, HTTP handler threads read. mutable std::mutex mutex_; std::unordered_map> frames_; - std::deque insertion_order_; ///< eviction order (FIFO) + std::deque insertion_order_; ///< eviction order (FIFO) + std::unordered_set fallback_logged_; ///< fault codes already warned about }; } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/entity_freeze_frame_capture.cpp b/src/ros2_medkit_gateway/src/entity_freeze_frame_capture.cpp index d51262d58..b0b8f78cf 100644 --- a/src/ros2_medkit_gateway/src/entity_freeze_frame_capture.cpp +++ b/src/ros2_medkit_gateway/src/entity_freeze_frame_capture.cpp @@ -21,8 +21,12 @@ namespace ros2_medkit_gateway { EntityFreezeFrameCapture::EntityFreezeFrameCapture(rclcpp::Node * node, ros2_common::Ros2SubscriptionExecutor & exec, - DataProviderResolver resolver, size_t max_faults) - : resolver_(std::move(resolver)), logger_(node->get_logger()), max_faults_(max_faults > 0 ? max_faults : 1) { + DataProviderResolver resolver, RouteDataFetcher route_fetcher, + size_t max_faults) + : resolver_(std::move(resolver)) + , route_fetcher_(std::move(route_fetcher)) + , logger_(node->get_logger()) + , max_faults_(max_faults > 0 ? max_faults : 1) { // Resolve the topic from the gateway node (it owns fault_manager.namespace); // the subscription itself is created on the executor's dedicated _sub node so // it never races rcl's hash-map on the main node (issue #375). @@ -71,6 +75,50 @@ nlohmann::json EntityFreezeFrameCapture::values_from_list_content(const nlohmann return values; } +bool EntityFreezeFrameCapture::route_content_has_live_data(const nlohmann::json & content) { + if (!content.is_object()) { + return false; + } + if (content.contains("connected") && content["connected"].is_boolean() && !content["connected"].get()) { + return false; + } + return content.contains("items") && content["items"].is_array() && !content["items"].empty(); +} + +std::optional +EntityFreezeFrameCapture::capture_via_route(const std::string & entity_id, const std::string & fault_code) { + std::optional content; + try { + content = route_fetcher_(entity_id); + } catch (const std::exception & e) { + log_fallback_failure_once(fault_code, std::string("x-plc-data dispatch threw: ") + e.what()); + return std::nullopt; + } + if (!content) { + return std::nullopt; // not plugin-owned, no x-plc-data route, or handler error + } + if (!route_content_has_live_data(*content)) { + log_fallback_failure_once(fault_code, "entity '" + entity_id + "' x-plc-data reported no live values"); + return std::nullopt; + } + Frame frame; + frame.entity_id = entity_id; + frame.values = values_from_list_content(*content); + frame.captured_at_ns = + std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); + return frame; +} + +void EntityFreezeFrameCapture::log_fallback_failure_once(const std::string & fault_code, const std::string & message) { + { + std::lock_guard lock(mutex_); + if (!fallback_logged_.insert(fault_code).second) { + return; + } + } + RCLCPP_WARN(logger_, "Entity freeze-frame for fault '%s': %s", fault_code.c_str(), message.c_str()); +} + void EntityFreezeFrameCapture::on_fault_event(const ros2_medkit_msgs::msg::FaultEvent::ConstSharedPtr & msg) { // Mirror the fault_manager's own capture trigger: freeze-frames are taken // when a fault confirms, not on every update. @@ -82,7 +130,15 @@ void EntityFreezeFrameCapture::on_fault_event(const ros2_medkit_msgs::msg::Fault for (const auto & source : msg->fault.reporting_sources) { DataProvider * provider = resolver_ ? resolver_(source) : nullptr; if (provider == nullptr) { - continue; // not a plugin-owned entity (ROS sources are the fault_manager's job) + // No DataProvider: the owning plugin may still serve live values through + // its own x-plc-data route (the commercial PLC bridges). For non-plugin + // (ROS) sources the fetcher resolves no owner and returns nullopt. + if (route_fetcher_) { + if (auto frame = capture_via_route(source, msg->fault.fault_code)) { + frames.push_back(std::move(*frame)); + } + } + continue; } // list_data is expected to serve from the plugin's latest polled values diff --git a/src/ros2_medkit_gateway/src/gateway_node.cpp b/src/ros2_medkit_gateway/src/gateway_node.cpp index df60264bc..97055fec1 100644 --- a/src/ros2_medkit_gateway/src/gateway_node.cpp +++ b/src/ros2_medkit_gateway/src/gateway_node.cpp @@ -1793,9 +1793,16 @@ void GatewayNode::init_entity_freeze_frame_capture(ros2_common::Ros2Subscription if (!get_parameter("entity_freeze_frame.enabled").as_bool() || !plugin_mgr_ || !plugin_mgr_->has_plugins()) { return; } - entity_freeze_frame_capture_ = - std::make_unique(this, exec, [this](const std::string & entity_id) { + entity_freeze_frame_capture_ = std::make_unique( + this, exec, + [this](const std::string & entity_id) { return plugin_mgr_ ? plugin_mgr_->get_data_provider_for_entity(entity_id) : nullptr; + }, + // Plugins without a DataProvider (the commercial PLC bridges) serve live + // values only through their own x-plc-data route - dispatch it in-process + // as the capture fallback. + [this](const std::string & entity_id) -> std::optional { + return plugin_mgr_ ? plugin_mgr_->fetch_entity_data_via_route(entity_id) : std::nullopt; }); } diff --git a/src/ros2_medkit_gateway/src/plugins/plugin_manager.cpp b/src/ros2_medkit_gateway/src/plugins/plugin_manager.cpp index ec7e5f8c3..f0677e695 100644 --- a/src/ros2_medkit_gateway/src/plugins/plugin_manager.cpp +++ b/src/ros2_medkit_gateway/src/plugins/plugin_manager.cpp @@ -18,8 +18,10 @@ #include #include +#include #include "ros2_medkit_gateway/core/http/error_codes.hpp" +#include "ros2_medkit_gateway/core/http/http_utils.hpp" #include "ros2_medkit_gateway/core/plugins/plugin_http_types.hpp" namespace ros2_medkit_gateway { @@ -221,6 +223,9 @@ void PluginManager::disable_plugin(LoadedPlugin & lp) { lp.load_result.data_provider = nullptr; lp.load_result.operation_provider = nullptr; lp.load_result.fault_provider = nullptr; + // Cached route handlers capture the plugin instance - drop them with it. + lp.routes.clear(); + lp.routes_cached = false; lp.load_result.plugin.reset(); } @@ -284,60 +289,144 @@ void PluginManager::register_transport(std::unique_ptrregister_transport(std::move(provider)); } +bool PluginManager::cache_routes_locked(LoadedPlugin & lp) { + if (lp.routes_cached) { + return true; + } + try { + lp.routes = lp.load_result.plugin->get_routes(); + lp.routes_cached = true; + return true; + } catch (const std::exception & e) { + RCLCPP_ERROR(logger(), "Plugin '%s' threw during get_routes(): %s - disabling", + lp.load_result.plugin->name().c_str(), e.what()); + disable_plugin(lp); + } catch (...) { + RCLCPP_ERROR(logger(), "Plugin '%s' threw unknown exception during get_routes() - disabling", + lp.load_result.plugin->name().c_str()); + disable_plugin(lp); + } + return false; +} + void PluginManager::register_routes(httplib::Server & server, const std::string & api_prefix) { std::unique_lock lock(plugins_mutex_); for (auto & lp : plugins_) { - if (!lp.load_result.plugin) { + if (!lp.load_result.plugin || !cache_routes_locked(lp)) { continue; } - try { - auto routes = lp.load_result.plugin->get_routes(); - for (auto & route : routes) { - std::string full_pattern = api_prefix + "/" + route.pattern; - auto handler_fn = route.handler; // capture by value for lambda - auto plugin_name = lp.load_result.plugin->name(); - auto httplib_handler = [handler_fn, plugin_name, full_pattern](const httplib::Request & req, - httplib::Response & res) { - try { - PluginRequest plugin_req(&req); - PluginResponse plugin_res(&res); - handler_fn(plugin_req, plugin_res); - } catch (const std::exception & e) { - RCLCPP_ERROR(rclcpp::get_logger("plugin_manager"), "Plugin '%s' handler threw on %s: %s", - plugin_name.c_str(), full_pattern.c_str(), e.what()); - PluginResponse plugin_res(&res); - plugin_res.send_error(500, ERR_PLUGIN_ERROR, "Internal plugin error"); - } catch (...) { - RCLCPP_ERROR(rclcpp::get_logger("plugin_manager"), "Plugin '%s' handler threw unknown exception on %s", - plugin_name.c_str(), full_pattern.c_str()); - PluginResponse plugin_res(&res); - plugin_res.send_error(500, ERR_PLUGIN_ERROR, "Internal plugin error"); + for (auto & route : lp.routes) { + std::string full_pattern = api_prefix + "/" + route.pattern; + auto handler_fn = route.handler; // capture by value for lambda + auto plugin_name = lp.load_result.plugin->name(); + auto httplib_handler = [handler_fn, plugin_name, full_pattern](const httplib::Request & req, + httplib::Response & res) { + try { + PluginRequest plugin_req(&req); + PluginResponse plugin_res(&res); + handler_fn(plugin_req, plugin_res); + } catch (const std::exception & e) { + RCLCPP_ERROR(rclcpp::get_logger("plugin_manager"), "Plugin '%s' handler threw on %s: %s", plugin_name.c_str(), + full_pattern.c_str(), e.what()); + PluginResponse plugin_res(&res); + plugin_res.send_error(500, ERR_PLUGIN_ERROR, "Internal plugin error"); + } catch (...) { + RCLCPP_ERROR(rclcpp::get_logger("plugin_manager"), "Plugin '%s' handler threw unknown exception on %s", + plugin_name.c_str(), full_pattern.c_str()); + PluginResponse plugin_res(&res); + plugin_res.send_error(500, ERR_PLUGIN_ERROR, "Internal plugin error"); + } + }; + + if (route.method == "GET") { + server.Get(full_pattern, httplib_handler); + } else if (route.method == "POST") { + server.Post(full_pattern, httplib_handler); + } else if (route.method == "PUT") { + server.Put(full_pattern, httplib_handler); + } else if (route.method == "DELETE") { + server.Delete(full_pattern, httplib_handler); + } else { + RCLCPP_WARN(logger(), "Plugin '%s' registered route with unknown method '%s' - skipping", + lp.load_result.plugin->name().c_str(), route.method.c_str()); + } + } + } +} + +std::optional PluginManager::fetch_entity_data_via_route(const std::string & entity_id) { + const std::string full_path = std::string(API_BASE_PATH) + "/apps/" + entity_id + "/x-plc-data"; + + // Copy the owning plugin's matching GET handler out under the lock and + // invoke it after release: handlers run arbitrary plugin code that may call + // back into this manager (shared_mutex is not reentrant). + std::function handler; + std::string matched_pattern; + { + // Unique lock: first call may populate the route cache. + std::unique_lock lock(plugins_mutex_); + auto own_it = entity_ownership_.find(entity_id); + if (own_it == entity_ownership_.end()) { + return std::nullopt; + } + for (auto & lp : plugins_) { + if (!lp.load_result.plugin || lp.load_result.plugin->name() != own_it->second) { + continue; + } + if (!cache_routes_locked(lp)) { + return std::nullopt; + } + for (const auto & route : lp.routes) { + if (route.method != "GET") { + continue; + } + try { + if (std::regex_match(full_path, std::regex(API_BASE_PATH + ("/" + route.pattern)))) { + handler = route.handler; + matched_pattern = route.pattern; + break; } - }; - - if (route.method == "GET") { - server.Get(full_pattern, httplib_handler); - } else if (route.method == "POST") { - server.Post(full_pattern, httplib_handler); - } else if (route.method == "PUT") { - server.Put(full_pattern, httplib_handler); - } else if (route.method == "DELETE") { - server.Delete(full_pattern, httplib_handler); - } else { - RCLCPP_WARN(logger(), "Plugin '%s' registered route with unknown method '%s' - skipping", - lp.load_result.plugin->name().c_str(), route.method.c_str()); + } catch (const std::regex_error &) { + continue; // malformed plugin pattern - the HTTP server would reject it too } } - } catch (const std::exception & e) { - RCLCPP_ERROR(logger(), "Plugin '%s' threw during get_routes(): %s - disabling", - lp.load_result.plugin->name().c_str(), e.what()); - disable_plugin(lp); - } catch (...) { - RCLCPP_ERROR(logger(), "Plugin '%s' threw unknown exception during get_routes() - disabling", - lp.load_result.plugin->name().c_str()); - disable_plugin(lp); + break; } } + if (!handler) { + return std::nullopt; + } + + // Synthesize the request exactly as the HTTP server presents it: full API + // path plus regex captures, so path_param() and path()-based entity-type + // checks in the handler behave identically to a real request. + httplib::Request req; + req.method = "GET"; + req.path = full_path; + std::regex_match(req.path, req.matches, std::regex(API_BASE_PATH + ("/" + matched_pattern))); + httplib::Response res; + res.status = 200; // the server defaults handled routes to 200; send_error overrides + + try { + PluginRequest plugin_req(&req); + PluginResponse plugin_res(&res); + handler(plugin_req, plugin_res); + } catch (const std::exception & e) { + RCLCPP_WARN(logger(), "In-process x-plc-data dispatch for entity '%s' threw: %s", entity_id.c_str(), e.what()); + return std::nullopt; + } catch (...) { + RCLCPP_WARN(logger(), "In-process x-plc-data dispatch for entity '%s' threw unknown exception", entity_id.c_str()); + return std::nullopt; + } + + if (res.status != 200) { + return std::nullopt; + } + auto body = nlohmann::json::parse(res.body, nullptr, /*allow_exceptions=*/false); + if (body.is_discarded()) { + return std::nullopt; + } + return body; } void PluginManager::shutdown_all() { diff --git a/src/ros2_medkit_gateway/test/demo_nodes/test_route_data_plugin.cpp b/src/ros2_medkit_gateway/test/demo_nodes/test_route_data_plugin.cpp new file mode 100644 index 000000000..42c78803c --- /dev/null +++ b/src/ros2_medkit_gateway/test/demo_nodes/test_route_data_plugin.cpp @@ -0,0 +1,166 @@ +// Copyright 2026 mfaferek93 +// +// 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. + +#include +#include +#include + +#include "ros2_medkit_gateway/core/http/error_codes.hpp" +#include "ros2_medkit_gateway/core/plugins/gateway_plugin.hpp" +#include "ros2_medkit_gateway/core/plugins/plugin_context.hpp" +#include "ros2_medkit_gateway/core/plugins/plugin_types.hpp" +#include "ros2_medkit_gateway/core/providers/fault_provider.hpp" +#include "ros2_medkit_gateway/core/providers/introspection_provider.hpp" + +using namespace ros2_medkit_gateway; + +/** + * @brief Demo plugin mirroring the commercial PLC bridge shape (.so for tests). + * + * Deliberately does NOT export get_data_provider: like the S7comm/Modbus/ADS + * bridges it exports only create_plugin + get_introspection_provider + + * get_fault_provider and serves its live values exclusively through a + * registered `apps//x-plc-data` route (poller-snapshot style response + * with `connected` + `items[]`). Used by test_entity_freeze_frame.test.py to + * prove the gateway's in-process route-dispatch fallback captures a + * zero-config freeze-frame for such plugins. + */ +class TestRouteDataPlugin : public GatewayPlugin, public IntrospectionProvider, public FaultProvider { + public: + static constexpr const char * kAppId = "test_route_plc_app"; + + std::string name() const override { + return "test_route_data"; + } + + void configure(const nlohmann::json & /*config*/) override { + } + + void set_context(PluginContext & context) override { + ctx_ = &context; + } + + std::vector get_routes() override { + return { + {"GET", R"(apps/([^/]+)/x-plc-data)", + [this](const PluginRequest & req, PluginResponse & res) { + handle_plc_data(req, res); + }}, + }; + } + + // --- IntrospectionProvider --- + IntrospectionResult introspect(const IntrospectionInput & /*input*/) override { + IntrospectionResult result; + + Area area; + area.id = "test_route_plc_area"; + area.name = "Test Route PLC Cell"; + area.namespace_path = "/test_route_plc_area"; + area.source = "plugin"; + result.new_entities.areas.push_back(std::move(area)); + + Component comp; + comp.id = "test_route_plc"; + comp.name = "Test Route PLC Runtime"; + comp.namespace_path = "/test_route_plc_area"; + comp.fqn = "/test_route_plc_area/test_route_plc"; + comp.area = "test_route_plc_area"; + comp.source = "plugin"; + result.new_entities.components.push_back(std::move(comp)); + + App app; + app.id = kAppId; + app.name = "Test Route PLC Process"; + app.component_id = "test_route_plc"; + app.external = true; // faults report under the bare app id, not a ROS FQN + app.is_online = true; + app.source = "plugin"; + result.new_entities.apps.push_back(std::move(app)); + + return result; + } + + // --- FaultProvider (same projection as test_data_provider_plugin: the list + // mirrors the fault_manager's records; get_fault returns not-found so the + // handler serves the enriched record with environment_data) --- + tl::expected list_faults(const std::string & entity_id) override { + nlohmann::json items = nlohmann::json::array(); + if (ctx_ != nullptr) { + auto faults = ctx_->list_entity_faults(entity_id); + if (faults.is_array()) { + for (const auto & f : faults) { + items.push_back({{"code", f.value("fault_code", "")}, + {"severity", f.value("severity", 0)}, + {"status", f.value("status", "")}}); + } + } + } + return dto::FaultListResult{nlohmann::json{{"items", std::move(items)}}}; + } + + tl::expected get_fault(const std::string & /*entity_id*/, + const std::string & fault_code) override { + return tl::make_unexpected( + FaultProviderErrorInfo{FaultProviderError::FaultNotFound, "Fault not found: " + fault_code, 404}); + } + + tl::expected clear_fault(const std::string & /*entity_id*/, + const std::string & fault_code) override { + return dto::FaultClearResult{nlohmann::json{{"code", fault_code}, {"cleared", true}}}; + } + + private: + void handle_plc_data(const PluginRequest & req, PluginResponse & res) { + auto entity_id = req.path_param(1); + if (ctx_ != nullptr) { + // Same gate as the real PLC bridges: entity must exist and match the + // route's entity type (exercises the synthesized request's path()). + auto entity = ctx_->validate_entity_for_route(req, res, entity_id); + if (!entity) { + return; + } + } + if (entity_id != kAppId) { + res.send_error(404, ERR_RESOURCE_NOT_FOUND, "No PLC data mapped for entity: " + entity_id); + return; + } + // Poller-snapshot style response, canned values (S7/ADS bridge shape). + res.send_json({{"connected", true}, + {"entity_id", entity_id}, + {"items", nlohmann::json::array({{{"name", "level"}, {"value", 87.5}, {"unit", "mm"}}, + {{"name", "alarm"}, {"value", true}}})}, + {"timestamp", 1234567890}}); + } + + PluginContext * ctx_{nullptr}; +}; + +// --- Plugin exports (intentionally NO get_data_provider) --- + +extern "C" GATEWAY_PLUGIN_EXPORT int plugin_api_version() { + return PLUGIN_API_VERSION; +} + +extern "C" GATEWAY_PLUGIN_EXPORT GatewayPlugin * create_plugin() { + return new TestRouteDataPlugin(); +} + +extern "C" GATEWAY_PLUGIN_EXPORT IntrospectionProvider * get_introspection_provider(GatewayPlugin * plugin) { + return static_cast(plugin); +} + +extern "C" GATEWAY_PLUGIN_EXPORT FaultProvider * get_fault_provider(GatewayPlugin * plugin) { + return static_cast(plugin); +} diff --git a/src/ros2_medkit_gateway/test/test_entity_freeze_frame_capture.cpp b/src/ros2_medkit_gateway/test/test_entity_freeze_frame_capture.cpp index 9a936dd79..cce84ada3 100644 --- a/src/ros2_medkit_gateway/test/test_entity_freeze_frame_capture.cpp +++ b/src/ros2_medkit_gateway/test/test_entity_freeze_frame_capture.cpp @@ -14,9 +14,11 @@ #include +#include #include #include #include +#include #include #include #include @@ -206,6 +208,89 @@ TEST_F(EntityFreezeFrameCaptureTest, NonConfirmedEventsAreIgnored) { EXPECT_TRUE(capture.frames_for("PLC_UPDATED_ONLY").empty()); } +/// @verifies REQ_INTEROP_088 +TEST_F(EntityFreezeFrameCaptureTest, RouteFallbackCapturesWhenPluginHasNoDataProvider) { + // Mirror the commercial PLC bridges: no DataProvider, values only through + // the plugin's x-plc-data route (dispatched in-process by the fetcher). + EntityFreezeFrameCapture capture( + node_.get(), *sub_exec_, + [](const std::string &) -> DataProvider * { + return nullptr; + }, + [](const std::string & entity_id) -> std::optional { + if (entity_id != "route_plc_app") { + return std::nullopt; + } + return json{{"connected", true}, + {"items", json::array({{{"name", "level"}, {"value", 87.5}, {"unit", "mm"}}, + {{"name", "alarm"}, {"value", true}}})}}; + }); + + ASSERT_TRUE(publish_and_wait(capture, make_confirmed_event("PLC_ROUTE_LEVEL_HIGH", {"route_plc_app"}))); + + auto frames = capture.frames_for("PLC_ROUTE_LEVEL_HIGH"); + ASSERT_EQ(frames.size(), 1u); + EXPECT_EQ(frames[0].entity_id, "route_plc_app"); + EXPECT_DOUBLE_EQ(frames[0].values.value("level", 0.0), 87.5); + EXPECT_EQ(frames[0].values.value("alarm", false), true); + EXPECT_GT(frames[0].captured_at_ns, 0); +} + +/// @verifies REQ_INTEROP_088 +TEST_F(EntityFreezeFrameCaptureTest, RouteFallbackSkipsDisconnectedPlc) { + // A disconnected PLC must not freeze-frame a row of stale/null values. + EntityFreezeFrameCapture capture( + node_.get(), *sub_exec_, + [](const std::string &) -> DataProvider * { + return nullptr; + }, + [](const std::string &) -> std::optional { + return json{{"connected", false}, {"items", json::array({{{"name", "level"}, {"value", nullptr}}})}}; + }); + + ASSERT_TRUE(wait_for_match()); + auto event = make_confirmed_event("PLC_DISCONNECTED", {"route_plc_app"}); + for (int i = 0; i < 25; ++i) { + publisher_->publish(event); + std::this_thread::sleep_for(10ms); + } + EXPECT_TRUE(capture.frames_for("PLC_DISCONNECTED").empty()); +} + +/// @verifies REQ_INTEROP_088 +TEST_F(EntityFreezeFrameCaptureTest, DataProviderWinsOverRouteFallback) { + std::atomic fetcher_called{false}; + EntityFreezeFrameCapture capture( + node_.get(), *sub_exec_, + [this](const std::string & entity_id) -> DataProvider * { + return entity_id == "plc_app" ? provider_.get() : nullptr; + }, + [&fetcher_called](const std::string &) -> std::optional { + fetcher_called = true; + return json{{"connected", true}, {"items", json::array({{{"name", "level"}, {"value", 1.0}}})}}; + }); + + ASSERT_TRUE(publish_and_wait(capture, make_confirmed_event("PLC_PROVIDER_FIRST", {"plc_app"}))); + + auto frames = capture.frames_for("PLC_PROVIDER_FIRST"); + ASSERT_EQ(frames.size(), 1u); + EXPECT_DOUBLE_EQ(frames[0].values.value("temperature", 0.0), 42.5); // provider values, not route + EXPECT_FALSE(fetcher_called.load()); +} + +TEST(RouteContentHasLiveData, GatesOnConnectedAndItems) { + using Capture = EntityFreezeFrameCapture; + EXPECT_TRUE(Capture::route_content_has_live_data( + json{{"connected", true}, {"items", json::array({{{"name", "a"}, {"value", 1}}})}})); + // No "connected" field: items alone are enough (plugin-defined shape). + EXPECT_TRUE(Capture::route_content_has_live_data(json{{"items", json::array({{{"name", "a"}, {"value", 1}}})}})); + EXPECT_FALSE(Capture::route_content_has_live_data( + json{{"connected", false}, {"items", json::array({{{"name", "a"}, {"value", 1}}})}})); + EXPECT_FALSE(Capture::route_content_has_live_data(json{{"connected", true}, {"items", json::array()}})); + EXPECT_FALSE(Capture::route_content_has_live_data(json{{"connected", true}})); + EXPECT_FALSE(Capture::route_content_has_live_data(json::array())); +} + TEST(ValuesFromListContent, BuildsCompactDictFromItems) { json content = {{"items", json::array({{{"id", "a"}, {"value", 1}}, {{"name", "b"}, {"value", "on"}}, // falls back to "name" key diff --git a/src/ros2_medkit_gateway/test/test_plugin_manager.cpp b/src/ros2_medkit_gateway/test/test_plugin_manager.cpp index 7af2aea8e..623acc8ee 100644 --- a/src/ros2_medkit_gateway/test/test_plugin_manager.cpp +++ b/src/ros2_medkit_gateway/test/test_plugin_manager.cpp @@ -160,6 +160,43 @@ class MockRoutePlugin : public GatewayPlugin { std::string last_path_param_; }; +/// Commercial-bridge-shaped plugin: serves live values only through a +/// registered x-plc-data route (no DataProvider), like the S7comm/Modbus/ADS +/// bridges. Exercises PluginManager::fetch_entity_data_via_route. +class MockPlcRoutePlugin : public GatewayPlugin { + public: + std::string name() const override { + return "mock_plc"; + } + void configure(const json & /*cfg*/) override { + } + std::vector get_routes() override { + ++get_routes_calls_; + return { + {"GET", R"(apps/([^/]+)/x-plc-data)", + [this](const PluginRequest & req, PluginResponse & res) { + last_path_ = req.path(); + auto entity = req.path_param(1); + if (entity != "plc_app") { + res.send_error(404, "resource-not-found", "No PLC data mapped for entity: " + entity); + return; + } + res.send_json({{"connected", connected_}, + {"items", json::array({{{"name", "level"}, {"value", 87.5}, {"unit", "mm"}}, + {{"name", "alarm"}, {"value", true}}})}}); + }}, + // Longer sibling pattern must not shadow the exact match above. + {"GET", R"(apps/([^/]+)/x-plc-data/([^/]+))", + [](const PluginRequest & /*req*/, PluginResponse & res) { + res.send_json({{"single", true}}); + }}, + }; + } + bool connected_ = true; + int get_routes_calls_ = 0; + std::string last_path_; +}; + /// Plugin that throws during get_routes class MockThrowOnGetRoutes : public GatewayPlugin, public IntrospectionProvider { public: @@ -371,6 +408,68 @@ TEST(PluginManagerTest, RegisterRoutesWrapsPluginHandlers) { EXPECT_EQ(raw->last_path_param_, "test_entity"); } +TEST(PluginManagerTest, FetchEntityDataViaRouteDispatchesOwningPluginHandler) { + PluginManager mgr; + auto plugin = std::make_unique(); + auto * raw = plugin.get(); + mgr.add_plugin(std::move(plugin)); + mgr.configure_plugins(); + mgr.register_entity_ownership("mock_plc", {"plc_app"}); + + auto body = mgr.fetch_entity_data_via_route("plc_app"); + ASSERT_TRUE(body.has_value()); + EXPECT_EQ((*body)["connected"], true); + ASSERT_EQ((*body)["items"].size(), 2u); + EXPECT_EQ((*body)["items"][0]["name"], "level"); + EXPECT_EQ((*body)["items"][0]["value"], 87.5); + // The handler saw a real-looking request: full API path with regex captures. + EXPECT_EQ(raw->last_path_, "/api/v1/apps/plc_app/x-plc-data"); +} + +TEST(PluginManagerTest, FetchEntityDataViaRouteUnownedEntityReturnsNullopt) { + PluginManager mgr; + mgr.add_plugin(std::make_unique()); + mgr.configure_plugins(); + + EXPECT_FALSE(mgr.fetch_entity_data_via_route("plc_app").has_value()); +} + +TEST(PluginManagerTest, FetchEntityDataViaRouteErrorResponseReturnsNullopt) { + PluginManager mgr; + mgr.add_plugin(std::make_unique()); + mgr.configure_plugins(); + // Owned by the plugin, but its handler 404s for this entity. + mgr.register_entity_ownership("mock_plc", {"unmapped_app"}); + + EXPECT_FALSE(mgr.fetch_entity_data_via_route("unmapped_app").has_value()); +} + +TEST(PluginManagerTest, FetchEntityDataViaRouteNoMatchingRouteReturnsNullopt) { + PluginManager mgr; + mgr.add_plugin(std::make_unique()); // registers x-test-route only + mgr.configure_plugins(); + mgr.register_entity_ownership("mock_route", {"plc_app"}); + + EXPECT_FALSE(mgr.fetch_entity_data_via_route("plc_app").has_value()); +} + +TEST(PluginManagerTest, GetRoutesCalledOnceAcrossRegisterAndFetch) { + PluginManager mgr; + auto plugin = std::make_unique(); + auto * raw = plugin.get(); + mgr.add_plugin(std::move(plugin)); + mgr.configure_plugins(); + mgr.register_entity_ownership("mock_plc", {"plc_app"}); + + // Fetch before server setup (lazy cache), then register, then fetch again: + // the "called once during REST server setup" plugin contract must hold. + EXPECT_TRUE(mgr.fetch_entity_data_via_route("plc_app").has_value()); + httplib::Server srv; + mgr.register_routes(srv, "/api/v1"); + EXPECT_TRUE(mgr.fetch_entity_data_via_route("plc_app").has_value()); + EXPECT_EQ(raw->get_routes_calls_, 1); +} + TEST(PluginManagerTest, ShutdownAllIdempotent) { PluginManager mgr; auto plugin = std::make_unique(); diff --git a/src/ros2_medkit_integration_tests/test/features/test_entity_freeze_frame.test.py b/src/ros2_medkit_integration_tests/test/features/test_entity_freeze_frame.test.py index 7edb785c9..33f038007 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_entity_freeze_frame.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_entity_freeze_frame.test.py @@ -16,14 +16,18 @@ """Zero-config freeze-frames: the faulting entity's own data, no snapshot config. The fault manager runs WITHOUT any snapshot configuration (empty -``snapshots.default_topics``, no config file). Two entity-default paths are +``snapshots.default_topics``, no config file). Three entity-default paths are proven end to end: -1. Plugin-backed entity (the headline PLC case): a fault reported under the - demo data-provider plugin's bare app id carries a freeze-frame of that - entity's current data values, captured by the gateway from the plugin's - DataProvider at confirm time. -2. ROS-backed entity: a fault reported under a demo node's FQN carries a +1. Plugin-backed entity with a DataProvider: a fault reported under the demo + data-provider plugin's bare app id carries a freeze-frame of that entity's + current data values, captured by the gateway from the plugin's DataProvider + at confirm time. +2. Plugin-backed entity WITHOUT a DataProvider (the commercial PLC bridge + shape): the demo route-data plugin exports only introspection + fault + providers and serves live values through its registered x-plc-data route. + The gateway captures the freeze-frame by dispatching that route in-process. +3. ROS-backed entity: a fault reported under a demo node's FQN carries a freeze-frame of that node's own published topics, captured by the fault manager's entity-default fallback. """ @@ -47,18 +51,17 @@ PLUGIN_APP = 'test_plc_app' PLUGIN_FAULT_CODE = 'PLC_OVERPRESSURE' +ROUTE_PLUGIN_APP = 'test_route_plc_app' +ROUTE_PLUGIN_FAULT_CODE = 'PLC_ROUTE_LEVEL_HIGH' ROS_APP = 'temp_sensor' ROS_SOURCE = '/powertrain/engine/temp_sensor' ROS_FAULT_CODE = 'ENGINE_TEMP_SENSOR_DEGRADED' -def _get_plugin_path(): - """Get path to the demo data-provider plugin .so.""" +def _get_plugin_path(so_name): + """Get path to a demo plugin .so.""" pkg_prefix = get_package_prefix('ros2_medkit_gateway') - return os.path.join( - pkg_prefix, 'lib', 'ros2_medkit_gateway', - 'libtest_data_provider_plugin.so', - ) + return os.path.join(pkg_prefix, 'lib', 'ros2_medkit_gateway', so_name) def generate_test_description(): @@ -76,8 +79,11 @@ def generate_test_description(): 'snapshots.timeout_sec': 5.0, }, gateway_params={ - 'plugins': ['test_data_provider'], - 'plugins.test_data_provider.path': _get_plugin_path(), + 'plugins': ['test_data_provider', 'test_route_data'], + 'plugins.test_data_provider.path': + _get_plugin_path('libtest_data_provider_plugin.so'), + 'plugins.test_route_data.path': + _get_plugin_path('libtest_route_data_plugin.so'), }, ) @@ -85,8 +91,8 @@ def generate_test_description(): class TestEntityFreezeFrame(GatewayTestCase): """Faults carry the faulting entity's own data with zero snapshot config.""" - MIN_EXPECTED_APPS = 2 - REQUIRED_APPS = {PLUGIN_APP, ROS_APP} + MIN_EXPECTED_APPS = 3 + REQUIRED_APPS = {PLUGIN_APP, ROUTE_PLUGIN_APP, ROS_APP} @classmethod def setUpClass(cls): @@ -166,6 +172,31 @@ def test_plugin_entity_fault_carries_entity_values(self): self.assertIn('full_data', x_medkit) self.assertIn('captured_at', x_medkit) + def test_route_only_plugin_fault_carries_entity_values(self): + """A commercial-bridge-shaped plugin (no DataProvider) still carries + its entity's own values, captured via in-process x-plc-data dispatch. + + @verifies REQ_INTEROP_088 + """ + self._report_fault(ROUTE_PLUGIN_FAULT_CODE, ROUTE_PLUGIN_APP) + + frames = self._wait_for_freeze_frame( + f'/apps/{ROUTE_PLUGIN_APP}', ROUTE_PLUGIN_FAULT_CODE) + frame = next( + (f for f in frames if f.get('name') == ROUTE_PLUGIN_APP), None) + self.assertIsNotNone( + frame, f'No frame named {ROUTE_PLUGIN_APP} in {frames}') + + # The frame holds the canned values the plugin serves exclusively + # through its registered x-plc-data route (keyed by item "name"). + self.assertEqual(frame['data'].get('level'), 87.5) + self.assertEqual(frame['data'].get('alarm'), True) + + # Standard freeze_frame x-medkit metadata block (fleet_ui contract). + x_medkit = frame.get('x-medkit', {}) + self.assertIn('full_data', x_medkit) + self.assertIn('captured_at', x_medkit) + def test_ros_entity_fault_carries_own_topic_data(self): """A ROS-node fault carries the node's own published topic data. From d056dd33ce5c1e9eeecd1c089b6604a4dbe2dd96 Mon Sep 17 00:00:00 2001 From: Michal Faferek Date: Thu, 16 Jul 2026 23:11:27 +0200 Subject: [PATCH 4/7] fix(gateway): serve fault detail for plugin entities without a FaultProvider Plugins that report via ReportFault only (s7comm, modbus) got a 404 from the per-entity fault detail, hiding the fault manager's freeze-frames. Fall through to the shared scoped listing instead. --- src/ros2_medkit_gateway/CMakeLists.txt | 8 +- .../src/http/handlers/fault_handlers.cpp | 271 +++++++++--------- .../demo_nodes/test_route_data_plugin.cpp | 55 +--- .../features/test_entity_freeze_frame.test.py | 21 +- 4 files changed, 172 insertions(+), 183 deletions(-) diff --git a/src/ros2_medkit_gateway/CMakeLists.txt b/src/ros2_medkit_gateway/CMakeLists.txt index 1ce4655c8..a467d981e 100644 --- a/src/ros2_medkit_gateway/CMakeLists.txt +++ b/src/ros2_medkit_gateway/CMakeLists.txt @@ -895,9 +895,11 @@ if(BUILD_TESTING) ) install(TARGETS test_data_provider_plugin LIBRARY DESTINATION lib/${PROJECT_NAME}) - # Demo commercial-bridge-shaped plugin: Introspection + Fault providers only, - # live values served exclusively via a registered x-plc-data route (no - # get_data_provider export). Proves the freeze-frame route-dispatch fallback. + # Demo commercial-bridge-shaped plugin: IntrospectionProvider ONLY, live + # values served exclusively via a registered x-plc-data route (no + # get_data_provider / get_fault_provider exports, like the S7comm bridge). + # Proves the freeze-frame route-dispatch fallback and the fault_manager + # fall-through for FaultProvider-less plugin entities. add_library(test_route_data_plugin MODULE test/demo_nodes/test_route_data_plugin.cpp) target_include_directories(test_route_data_plugin PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include) target_link_libraries(test_route_data_plugin diff --git a/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp index e09ec1e92..7bb97acf6 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp @@ -461,30 +461,31 @@ http::Result FaultHandlers::list_faults(const http::TypedR } const auto entity_info = *entity_result; - // Delegate to plugin FaultProvider if entity is plugin-owned + // Delegate to plugin FaultProvider if entity is plugin-owned. Plugins + // without a FaultProvider report via ReportFault only (the native path): + // their faults live in the fault_manager, so fall through to the shared + // scoped listing below like any other entity. if (entity_info.is_plugin) { auto * pmgr = ctx_.node()->get_plugin_manager(); auto * fault_prov = pmgr ? pmgr->get_fault_provider_for_entity(entity_id) : nullptr; - if (fault_prov == nullptr) { - return tl::make_unexpected( - make_error(404, ERR_RESOURCE_NOT_FOUND, "No fault provider for plugin entity '" + entity_id + "'")); - } - try { - auto result = fault_prov->list_faults(entity_id); - if (!result) { + if (fault_prov != nullptr) { + try { + auto result = fault_prov->list_faults(entity_id); + if (!result) { + return tl::make_unexpected( + make_plugin_error(result.error().http_status, result.error().message, json{{"entity_id", entity_id}})); + } + return std::move(*result); + } catch (const std::exception & e) { + RCLCPP_ERROR(HandlerContext::logger(), "Plugin FaultProvider threw for entity '%s': %s", entity_id.c_str(), + e.what()); + return tl::make_unexpected(make_plugin_error(500, "Plugin threw exception", json{{"entity_id", entity_id}})); + } catch (...) { + RCLCPP_ERROR(HandlerContext::logger(), "Plugin FaultProvider threw unknown exception for entity '%s'", + entity_id.c_str()); return tl::make_unexpected( - make_plugin_error(result.error().http_status, result.error().message, json{{"entity_id", entity_id}})); + make_plugin_error(500, "Plugin threw unknown exception", json{{"entity_id", entity_id}})); } - return std::move(*result); - } catch (const std::exception & e) { - RCLCPP_ERROR(HandlerContext::logger(), "Plugin FaultProvider threw for entity '%s': %s", entity_id.c_str(), - e.what()); - return tl::make_unexpected(make_plugin_error(500, "Plugin threw exception", json{{"entity_id", entity_id}})); - } catch (...) { - RCLCPP_ERROR(HandlerContext::logger(), "Plugin FaultProvider threw unknown exception for entity '%s'", - entity_id.c_str()); - return tl::make_unexpected( - make_plugin_error(500, "Plugin threw unknown exception", json{{"entity_id", entity_id}})); } } @@ -693,50 +694,50 @@ http::Result FaultHandlers::get_fault(const http::TypedR if (entity_info.is_plugin) { auto * pmgr = ctx_.node()->get_plugin_manager(); auto * fault_prov = pmgr ? pmgr->get_fault_provider_for_entity(entity_id) : nullptr; - if (fault_prov == nullptr) { - return tl::make_unexpected( - make_error(404, ERR_RESOURCE_NOT_FOUND, "No fault provider for plugin entity '" + entity_id + "'")); - } - - // Prefer the fault_manager's environment-enriched record when this entity - // OWNS the fault (it was reported to the fault_manager under an app the - // entity owns, per the fault-scope resolution). This carries the - // freeze-frame/rosbag snapshots, consistent with the non-plugin detail - // path. Fall through to the plugin's own provider for faults the - // fault_manager does not hold (e.g. on-demand UDS DTCs). - if (auto * fault_mgr = ctx_.node()->get_fault_manager(); fault_mgr != nullptr) { - auto mgr_result = fault_mgr->get_fault_with_env(fault_code, ""); - if (mgr_result.success) { - const auto & owned_fault_json = mgr_result.data.value("fault", json::object()); - const auto & cache = ctx_.node()->get_thread_safe_cache(); - auto source_fqns = HandlerContext::resolve_entity_source_fqns(cache, entity_info); - if (FaultHandlers::fault_in_source_scope(owned_fault_json, source_fqns)) { - json env_data_json = mgr_result.data.value("environment_data", json::object()); - if (auto * capture = ctx_.node()->get_entity_freeze_frame_capture()) { - env_data_json = merge_entity_freeze_frames(std::move(env_data_json), capture->frames_for(fault_code)); + // Plugins without a FaultProvider report via ReportFault only (the + // native path): skip the plugin delegation entirely and serve the + // fault_manager's enriched record through the shared path below. + if (fault_prov != nullptr) { + // Prefer the fault_manager's environment-enriched record when this entity + // OWNS the fault (it was reported to the fault_manager under an app the + // entity owns, per the fault-scope resolution). This carries the + // freeze-frame/rosbag snapshots, consistent with the non-plugin detail + // path. Fall through to the plugin's own provider for faults the + // fault_manager does not hold (e.g. on-demand UDS DTCs). + if (auto * fault_mgr = ctx_.node()->get_fault_manager(); fault_mgr != nullptr) { + auto mgr_result = fault_mgr->get_fault_with_env(fault_code, ""); + if (mgr_result.success) { + const auto & owned_fault_json = mgr_result.data.value("fault", json::object()); + const auto & cache = ctx_.node()->get_thread_safe_cache(); + auto source_fqns = HandlerContext::resolve_entity_source_fqns(cache, entity_info); + if (FaultHandlers::fault_in_source_scope(owned_fault_json, source_fqns)) { + json env_data_json = mgr_result.data.value("environment_data", json::object()); + if (auto * capture = ctx_.node()->get_entity_freeze_frame_capture()) { + env_data_json = merge_entity_freeze_frames(std::move(env_data_json), capture->frames_for(fault_code)); + } + auto detail = build_sovd_fault_response(owned_fault_json, env_data_json, entity_path_info->entity_path); + return wrap_detail_result(dto::JsonWriter::write(detail)); } - auto detail = build_sovd_fault_response(owned_fault_json, env_data_json, entity_path_info->entity_path); - return wrap_detail_result(dto::JsonWriter::write(detail)); } } - } - try { - auto result = fault_prov->get_fault(entity_id, fault_code); - if (!result) { + try { + auto result = fault_prov->get_fault(entity_id, fault_code); + if (!result) { + return tl::make_unexpected( + make_plugin_error(result.error().http_status, result.error().message, json{{"entity_id", entity_id}})); + } + return std::move(*result); + } catch (const std::exception & e) { + RCLCPP_ERROR(HandlerContext::logger(), "Plugin FaultProvider threw for entity '%s': %s", entity_id.c_str(), + e.what()); + return tl::make_unexpected(make_plugin_error(500, "Plugin threw exception", json{{"entity_id", entity_id}})); + } catch (...) { + RCLCPP_ERROR(HandlerContext::logger(), "Plugin FaultProvider threw unknown exception for entity '%s'", + entity_id.c_str()); return tl::make_unexpected( - make_plugin_error(result.error().http_status, result.error().message, json{{"entity_id", entity_id}})); + make_plugin_error(500, "Plugin threw unknown exception", json{{"entity_id", entity_id}})); } - return std::move(*result); - } catch (const std::exception & e) { - RCLCPP_ERROR(HandlerContext::logger(), "Plugin FaultProvider threw for entity '%s': %s", entity_id.c_str(), - e.what()); - return tl::make_unexpected(make_plugin_error(500, "Plugin threw exception", json{{"entity_id", entity_id}})); - } catch (...) { - RCLCPP_ERROR(HandlerContext::logger(), "Plugin FaultProvider threw unknown exception for entity '%s'", - entity_id.c_str()); - return tl::make_unexpected( - make_plugin_error(500, "Plugin threw unknown exception", json{{"entity_id", entity_id}})); } } @@ -833,54 +834,54 @@ FaultHandlers::clear_fault(const http::TypedRequest & req) { if (entity_info.is_plugin) { auto * pmgr = ctx_.node()->get_plugin_manager(); auto * fault_prov = pmgr ? pmgr->get_fault_provider_for_entity(entity_id) : nullptr; - if (fault_prov == nullptr) { - return tl::make_unexpected( - make_error(404, ERR_RESOURCE_NOT_FOUND, "No fault provider for plugin entity '" + entity_id + "'")); - } - - // Cross-entity clear guard: the plugin clear_fault forwards the code to - // the fault_manager with no scope check, so without this a - // DELETE /{A}/faults/{code} could clear a fault owned by entity B. When - // the fault_manager holds this fault, require it to be in this entity's - // source scope before delegating; reject out-of-scope. Faults the - // fault_manager does not hold (plugin-internal, e.g. on-demand UDS DTCs) - // fall through to the plugin provider unchanged. Mirrors the ownership - // check in the plugin get_fault branch and the non-plugin clear path. - if (auto * fault_mgr = ctx_.node()->get_fault_manager(); fault_mgr != nullptr) { - auto mgr_result = fault_mgr->get_fault_with_env(fault_code, ""); - if (mgr_result.success) { - const auto & owned_fault_json = mgr_result.data.value("fault", json::object()); - const auto & cache = ctx_.node()->get_thread_safe_cache(); - auto source_fqns = HandlerContext::resolve_entity_source_fqns(cache, entity_info); - if (!FaultHandlers::fault_in_source_scope(owned_fault_json, source_fqns)) { - return tl::make_unexpected( - make_error(404, ERR_RESOURCE_NOT_FOUND, "Fault not found", - json{{"details", - "Fault is not in scope for this entity: every reporting source must be one of the " - "entity's owned apps, and a mixed-source fault that includes any out-of-entity " - "reporter is rejected to prevent cross-entity clear"}, - {entity_info.id_field, entity_id}, - {"fault_code", fault_code}})); + // Plugins without a FaultProvider report via ReportFault only (the + // native path): fall through to the scope-checked fault_manager clear + // below like any other entity. + if (fault_prov != nullptr) { + // Cross-entity clear guard: the plugin clear_fault forwards the code to + // the fault_manager with no scope check, so without this a + // DELETE /{A}/faults/{code} could clear a fault owned by entity B. When + // the fault_manager holds this fault, require it to be in this entity's + // source scope before delegating; reject out-of-scope. Faults the + // fault_manager does not hold (plugin-internal, e.g. on-demand UDS DTCs) + // fall through to the plugin provider unchanged. Mirrors the ownership + // check in the plugin get_fault branch and the non-plugin clear path. + if (auto * fault_mgr = ctx_.node()->get_fault_manager(); fault_mgr != nullptr) { + auto mgr_result = fault_mgr->get_fault_with_env(fault_code, ""); + if (mgr_result.success) { + const auto & owned_fault_json = mgr_result.data.value("fault", json::object()); + const auto & cache = ctx_.node()->get_thread_safe_cache(); + auto source_fqns = HandlerContext::resolve_entity_source_fqns(cache, entity_info); + if (!FaultHandlers::fault_in_source_scope(owned_fault_json, source_fqns)) { + return tl::make_unexpected( + make_error(404, ERR_RESOURCE_NOT_FOUND, "Fault not found", + json{{"details", + "Fault is not in scope for this entity: every reporting source must be one of the " + "entity's owned apps, and a mixed-source fault that includes any out-of-entity " + "reporter is rejected to prevent cross-entity clear"}, + {entity_info.id_field, entity_id}, + {"fault_code", fault_code}})); + } } } - } - try { - auto result = fault_prov->clear_fault(entity_id, fault_code); - if (!result) { + try { + auto result = fault_prov->clear_fault(entity_id, fault_code); + if (!result) { + return tl::make_unexpected( + make_plugin_error(result.error().http_status, result.error().message, json{{"entity_id", entity_id}})); + } + return Outcome{std::move(*result)}; + } catch (const std::exception & e) { + RCLCPP_ERROR(HandlerContext::logger(), "Plugin FaultProvider threw for entity '%s': %s", entity_id.c_str(), + e.what()); + return tl::make_unexpected(make_plugin_error(500, "Plugin threw exception", json{{"entity_id", entity_id}})); + } catch (...) { + RCLCPP_ERROR(HandlerContext::logger(), "Plugin FaultProvider threw unknown exception for entity '%s'", + entity_id.c_str()); return tl::make_unexpected( - make_plugin_error(result.error().http_status, result.error().message, json{{"entity_id", entity_id}})); + make_plugin_error(500, "Plugin threw unknown exception", json{{"entity_id", entity_id}})); } - return Outcome{std::move(*result)}; - } catch (const std::exception & e) { - RCLCPP_ERROR(HandlerContext::logger(), "Plugin FaultProvider threw for entity '%s': %s", entity_id.c_str(), - e.what()); - return tl::make_unexpected(make_plugin_error(500, "Plugin threw exception", json{{"entity_id", entity_id}})); - } catch (...) { - RCLCPP_ERROR(HandlerContext::logger(), "Plugin FaultProvider threw unknown exception for entity '%s'", - entity_id.c_str()); - return tl::make_unexpected( - make_plugin_error(500, "Plugin threw unknown exception", json{{"entity_id", entity_id}})); } } @@ -960,48 +961,48 @@ http::Result FaultHandlers::clear_all_faults(const http::TypedR return tl::make_unexpected(lock_err.error()); } - // Delegate to plugin FaultProvider if entity is plugin-owned + // Delegate to plugin FaultProvider if entity is plugin-owned. Plugins + // without a FaultProvider report via ReportFault only (the native path): + // fall through to the scope-checked fault_manager clear below. if (entity_info.is_plugin) { auto * pmgr = ctx_.node()->get_plugin_manager(); auto * fault_prov = pmgr ? pmgr->get_fault_provider_for_entity(entity_id) : nullptr; - if (fault_prov == nullptr) { - return tl::make_unexpected( - make_error(404, ERR_RESOURCE_NOT_FOUND, "No fault provider for plugin entity '" + entity_id + "'")); - } - try { - auto list_result = fault_prov->list_faults(entity_id); - if (!list_result) { - return tl::make_unexpected(make_plugin_error(list_result.error().http_status, list_result.error().message, - json{{"entity_id", entity_id}})); - } - if (list_result->content.contains("items") && list_result->content["items"].is_array()) { - std::vector failed_codes; - for (const auto & fault : list_result->content["items"]) { - auto code = fault.value("code", ""); - if (code.empty()) { - continue; + if (fault_prov != nullptr) { + try { + auto list_result = fault_prov->list_faults(entity_id); + if (!list_result) { + return tl::make_unexpected(make_plugin_error(list_result.error().http_status, list_result.error().message, + json{{"entity_id", entity_id}})); + } + if (list_result->content.contains("items") && list_result->content["items"].is_array()) { + std::vector failed_codes; + for (const auto & fault : list_result->content["items"]) { + auto code = fault.value("code", ""); + if (code.empty()) { + continue; + } + auto clear_result = fault_prov->clear_fault(entity_id, code); + if (!clear_result) { + failed_codes.push_back(code); + } } - auto clear_result = fault_prov->clear_fault(entity_id, code); - if (!clear_result) { - failed_codes.push_back(code); + if (!failed_codes.empty()) { + return tl::make_unexpected( + make_plugin_error(500, "Failed to clear " + std::to_string(failed_codes.size()) + " fault(s)", + json{{"entity_id", entity_id}, {"failed_codes", failed_codes}})); } } - if (!failed_codes.empty()) { - return tl::make_unexpected( - make_plugin_error(500, "Failed to clear " + std::to_string(failed_codes.size()) + " fault(s)", - json{{"entity_id", entity_id}, {"failed_codes", failed_codes}})); - } + return http::NoContent{}; + } catch (const std::exception & e) { + RCLCPP_ERROR(HandlerContext::logger(), "Plugin FaultProvider threw for entity '%s': %s", entity_id.c_str(), + e.what()); + return tl::make_unexpected(make_plugin_error(500, "Plugin threw exception", json{{"entity_id", entity_id}})); + } catch (...) { + RCLCPP_ERROR(HandlerContext::logger(), "Plugin FaultProvider threw unknown exception for entity '%s'", + entity_id.c_str()); + return tl::make_unexpected( + make_plugin_error(500, "Plugin threw unknown exception", json{{"entity_id", entity_id}})); } - return http::NoContent{}; - } catch (const std::exception & e) { - RCLCPP_ERROR(HandlerContext::logger(), "Plugin FaultProvider threw for entity '%s': %s", entity_id.c_str(), - e.what()); - return tl::make_unexpected(make_plugin_error(500, "Plugin threw exception", json{{"entity_id", entity_id}})); - } catch (...) { - RCLCPP_ERROR(HandlerContext::logger(), "Plugin FaultProvider threw unknown exception for entity '%s'", - entity_id.c_str()); - return tl::make_unexpected( - make_plugin_error(500, "Plugin threw unknown exception", json{{"entity_id", entity_id}})); } } diff --git a/src/ros2_medkit_gateway/test/demo_nodes/test_route_data_plugin.cpp b/src/ros2_medkit_gateway/test/demo_nodes/test_route_data_plugin.cpp index 42c78803c..a1722a079 100644 --- a/src/ros2_medkit_gateway/test/demo_nodes/test_route_data_plugin.cpp +++ b/src/ros2_medkit_gateway/test/demo_nodes/test_route_data_plugin.cpp @@ -20,7 +20,6 @@ #include "ros2_medkit_gateway/core/plugins/gateway_plugin.hpp" #include "ros2_medkit_gateway/core/plugins/plugin_context.hpp" #include "ros2_medkit_gateway/core/plugins/plugin_types.hpp" -#include "ros2_medkit_gateway/core/providers/fault_provider.hpp" #include "ros2_medkit_gateway/core/providers/introspection_provider.hpp" using namespace ros2_medkit_gateway; @@ -28,15 +27,17 @@ using namespace ros2_medkit_gateway; /** * @brief Demo plugin mirroring the commercial PLC bridge shape (.so for tests). * - * Deliberately does NOT export get_data_provider: like the S7comm/Modbus/ADS - * bridges it exports only create_plugin + get_introspection_provider + - * get_fault_provider and serves its live values exclusively through a - * registered `apps//x-plc-data` route (poller-snapshot style response - * with `connected` + `items[]`). Used by test_entity_freeze_frame.test.py to - * prove the gateway's in-process route-dispatch fallback captures a - * zero-config freeze-frame for such plugins. + * Deliberately exports ONLY create_plugin + get_introspection_provider - no + * DataProvider, no FaultProvider - like the S7comm bridge. Live values are + * served exclusively through a registered `apps//x-plc-data` route + * (poller-snapshot style response with `connected` + `items[]`); faults are + * reported to the fault_manager via ReportFault only. Used by + * test_entity_freeze_frame.test.py to prove the gateway's in-process + * route-dispatch fallback captures a zero-config freeze-frame for such + * plugins, and that their faults are served through the fault_manager + * fall-through despite the missing FaultProvider. */ -class TestRouteDataPlugin : public GatewayPlugin, public IntrospectionProvider, public FaultProvider { +class TestRouteDataPlugin : public GatewayPlugin, public IntrospectionProvider { public: static constexpr const char * kAppId = "test_route_plc_app"; @@ -92,35 +93,6 @@ class TestRouteDataPlugin : public GatewayPlugin, public IntrospectionProvider, return result; } - // --- FaultProvider (same projection as test_data_provider_plugin: the list - // mirrors the fault_manager's records; get_fault returns not-found so the - // handler serves the enriched record with environment_data) --- - tl::expected list_faults(const std::string & entity_id) override { - nlohmann::json items = nlohmann::json::array(); - if (ctx_ != nullptr) { - auto faults = ctx_->list_entity_faults(entity_id); - if (faults.is_array()) { - for (const auto & f : faults) { - items.push_back({{"code", f.value("fault_code", "")}, - {"severity", f.value("severity", 0)}, - {"status", f.value("status", "")}}); - } - } - } - return dto::FaultListResult{nlohmann::json{{"items", std::move(items)}}}; - } - - tl::expected get_fault(const std::string & /*entity_id*/, - const std::string & fault_code) override { - return tl::make_unexpected( - FaultProviderErrorInfo{FaultProviderError::FaultNotFound, "Fault not found: " + fault_code, 404}); - } - - tl::expected clear_fault(const std::string & /*entity_id*/, - const std::string & fault_code) override { - return dto::FaultClearResult{nlohmann::json{{"code", fault_code}, {"cleared", true}}}; - } - private: void handle_plc_data(const PluginRequest & req, PluginResponse & res) { auto entity_id = req.path_param(1); @@ -147,7 +119,8 @@ class TestRouteDataPlugin : public GatewayPlugin, public IntrospectionProvider, PluginContext * ctx_{nullptr}; }; -// --- Plugin exports (intentionally NO get_data_provider) --- +// --- Plugin exports (intentionally ONLY the two below - no get_data_provider, +// no get_fault_provider - matching the leanest commercial bridge) --- extern "C" GATEWAY_PLUGIN_EXPORT int plugin_api_version() { return PLUGIN_API_VERSION; @@ -160,7 +133,3 @@ extern "C" GATEWAY_PLUGIN_EXPORT GatewayPlugin * create_plugin() { extern "C" GATEWAY_PLUGIN_EXPORT IntrospectionProvider * get_introspection_provider(GatewayPlugin * plugin) { return static_cast(plugin); } - -extern "C" GATEWAY_PLUGIN_EXPORT FaultProvider * get_fault_provider(GatewayPlugin * plugin) { - return static_cast(plugin); -} diff --git a/src/ros2_medkit_integration_tests/test/features/test_entity_freeze_frame.test.py b/src/ros2_medkit_integration_tests/test/features/test_entity_freeze_frame.test.py index 33f038007..5cdb95f7b 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_entity_freeze_frame.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_entity_freeze_frame.test.py @@ -173,8 +173,10 @@ def test_plugin_entity_fault_carries_entity_values(self): self.assertIn('captured_at', x_medkit) def test_route_only_plugin_fault_carries_entity_values(self): - """A commercial-bridge-shaped plugin (no DataProvider) still carries - its entity's own values, captured via in-process x-plc-data dispatch. + """A commercial-bridge-shaped plugin (no DataProvider, no + FaultProvider) still carries its entity's own values, captured via + in-process x-plc-data dispatch and served through the fault_manager + fall-through. @verifies REQ_INTEROP_088 """ @@ -197,6 +199,21 @@ def test_route_only_plugin_fault_carries_entity_values(self): self.assertIn('full_data', x_medkit) self.assertIn('captured_at', x_medkit) + # The plugin has no FaultProvider: the entity fault list and clear + # must be served by the fault_manager fall-through, not 404. + list_resp = requests.get( + f'{self.BASE_URL}/apps/{ROUTE_PLUGIN_APP}/faults', timeout=5) + self.assertEqual(list_resp.status_code, 200) + codes = [i.get('fault_code', i.get('code')) + for i in list_resp.json().get('items', [])] + self.assertIn(ROUTE_PLUGIN_FAULT_CODE, codes) + + clear_resp = requests.delete( + f'{self.BASE_URL}/apps/{ROUTE_PLUGIN_APP}/faults/' + f'{ROUTE_PLUGIN_FAULT_CODE}', + timeout=5) + self.assertIn(clear_resp.status_code, (200, 204)) + def test_ros_entity_fault_carries_own_topic_data(self): """A ROS-node fault carries the node's own published topic data. From cab80bc037aaaf303d196c28d2e0804a98c8b565 Mon Sep 17 00:00:00 2001 From: Michal Faferek Date: Fri, 17 Jul 2026 10:51:10 +0200 Subject: [PATCH 5/7] fix(fault_manager): skip non-FQN sources in entity-default capture A bare plugin entity id parsed as (name, ns="/") could match an unrelated root-namespace node of the same name and freeze-frame its topics. Also document and pin the gateway frame retention semantics: kept across clear, overwritten on every re-confirm. --- .../snapshot_capture.hpp | 7 +-- .../src/snapshot_capture.cpp | 21 +++++---- .../test/test_snapshot_capture.cpp | 32 +++++++++++++ .../entity_freeze_frame_capture.hpp | 5 ++ .../test/test_entity_freeze_frame_capture.cpp | 47 ++++++++++++++++++- 5 files changed, 99 insertions(+), 13 deletions(-) diff --git a/src/ros2_medkit_fault_manager/include/ros2_medkit_fault_manager/snapshot_capture.hpp b/src/ros2_medkit_fault_manager/include/ros2_medkit_fault_manager/snapshot_capture.hpp index 184d4a486..986cf2552 100644 --- a/src/ros2_medkit_fault_manager/include/ros2_medkit_fault_manager/snapshot_capture.hpp +++ b/src/ros2_medkit_fault_manager/include/ros2_medkit_fault_manager/snapshot_capture.hpp @@ -180,9 +180,10 @@ class SnapshotCapture { std::vector resolve_topics(const std::string & fault_code) const; /// Entity-default fallback: topics published by the fault's reporting source - /// node(s), excluding per-node noise (/rosout, /parameter_events). Empty when - /// no source resolves to a live node (e.g. plugin entity ids - the gateway - /// covers those). Never throws; any failure degrades to empty. + /// node(s), excluding per-node noise (/rosout, /parameter_events). Non-FQN + /// sources (bare plugin entity ids - the gateway covers those) are skipped, + /// and empty is returned when no source resolves to a live node. Never + /// throws; any failure degrades to empty. std::vector resolve_entity_topics(const std::string & fault_code) const; /// Capture a single topic on-demand (creates temporary subscription) diff --git a/src/ros2_medkit_fault_manager/src/snapshot_capture.cpp b/src/ros2_medkit_fault_manager/src/snapshot_capture.cpp index b1ccf1cda..79154e519 100644 --- a/src/ros2_medkit_fault_manager/src/snapshot_capture.cpp +++ b/src/ros2_medkit_fault_manager/src/snapshot_capture.cpp @@ -235,21 +235,26 @@ std::vector SnapshotCapture::resolve_entity_topics(const std::strin return {}; } - // reporting_sources hold the reporting node's FQN (e.g. "/planner_server"). - // Split each into (name, namespace) to match against topic endpoints. + // Only ROS node FQNs (e.g. "/ns/planner_server") participate. Plugin + // entities report under bare SOVD ids (e.g. "tank_process"); treating one + // as (name, ns="/") could match an unrelated root-namespace node of the + // same name and freeze-frame its topics. Their zero-config frames come + // from the gateway's EntityFreezeFrameCapture instead. std::set> wanted; for (const auto & source : fault->reporting_sources) { - std::string ns = "/"; - std::string name = source; - const auto slash = source.rfind('/'); - if (slash != std::string::npos) { - name = source.substr(slash + 1); - ns = (slash == 0) ? "/" : source.substr(0, slash); + if (source.size() < 2 || source.front() != '/') { + continue; } + const auto slash = source.rfind('/'); + const std::string name = source.substr(slash + 1); + const std::string ns = (slash == 0) ? "/" : source.substr(0, slash); if (!name.empty()) { wanted.emplace(name, ns); } } + if (wanted.empty()) { + return {}; + } // The entity's "own data" = topics it publishes. Every node publishes // /rosout and /parameter_events; those say nothing about the entity. diff --git a/src/ros2_medkit_fault_manager/test/test_snapshot_capture.cpp b/src/ros2_medkit_fault_manager/test/test_snapshot_capture.cpp index 013d4e297..efda244a8 100644 --- a/src/ros2_medkit_fault_manager/test/test_snapshot_capture.cpp +++ b/src/ros2_medkit_fault_manager/test/test_snapshot_capture.cpp @@ -554,6 +554,38 @@ TEST_F(EntityDefaultCaptureTest, UnresolvableSourceWritesNoRow) { EXPECT_FALSE(storage_->get_freeze_frame("PLC_FAULT").has_value()); } +// @verifies REQ_INTEROP_088 +TEST_F(EntityDefaultCaptureTest, BarePluginIdNeverMatchesSameNamedNode) { + // A bare plugin entity id that happens to equal a live root-namespace node's + // name (this test node) must not be parsed as (name, ns="/") and capture + // that unrelated node's topics. + auto pub = node_->create_publisher("/entity/bare_id_metric", rclcpp::QoS(10)); + + SnapshotConfig config; + config.enabled = true; + config.background_capture = false; + config.timeout_sec = 5.0; + SnapshotCapture capture(node_.get(), storage_.get(), config); + + ros2_medkit_fault_manager::DebounceConfig debounce; + storage_->report_fault_event("BARE_ID_FAULT", 0 /*EVENT_FAILED*/, ros2_medkit_msgs::msg::Fault::SEVERITY_ERROR, + "plugin fault", node_->get_name(), rclcpp::Clock().now(), debounce); + + ScopedPublisherThread pub_thread([&pub](std::atomic & stop) { + while (!stop.load()) { + std_msgs::msg::Float64 msg; + msg.data = 7.0; + pub->publish(msg); + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + }); + wait_for_publisher("/entity/bare_id_metric"); + + capture.capture("BARE_ID_FAULT"); + + EXPECT_FALSE(storage_->get_freeze_frame("BARE_ID_FAULT").has_value()); +} + int main(int argc, char ** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/entity_freeze_frame_capture.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/entity_freeze_frame_capture.hpp index 8c85d4266..89d1903c0 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/entity_freeze_frame_capture.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/entity_freeze_frame_capture.hpp @@ -49,6 +49,11 @@ namespace ros2_medkit_gateway { * The frames are merged into the fault detail's environment_data.snapshots * only when the fault_manager captured no freeze-frame itself - explicit * snapshot config always wins (see FaultHandlers::merge_entity_freeze_frames). + * + * Retention mirrors the fault_manager's freeze-frame semantics: frames are + * kept across EVENT_CLEARED (the confirmed-state record stays attached to + * the cleared fault's detail) and overwritten on every EVENT_CONFIRMED, so + * a re-occurrence re-samples the plugin at its own confirm time. */ class EntityFreezeFrameCapture { public: diff --git a/src/ros2_medkit_gateway/test/test_entity_freeze_frame_capture.cpp b/src/ros2_medkit_gateway/test/test_entity_freeze_frame_capture.cpp index cce84ada3..8247a3c40 100644 --- a/src/ros2_medkit_gateway/test/test_entity_freeze_frame_capture.cpp +++ b/src/ros2_medkit_gateway/test/test_entity_freeze_frame_capture.cpp @@ -16,6 +16,7 @@ #include #include +#include #include #include #include @@ -42,19 +43,24 @@ using ros2_medkit_msgs::msg::FaultEvent; namespace { -/// Fake plugin DataProvider serving fixed PLC-like values for one entity. +/// Fake plugin DataProvider serving PLC-like values for one entity. The +/// temperature is settable so tests can prove a re-confirm re-samples. class FakePlcDataProvider : public DataProvider { public: explicit FakePlcDataProvider(std::string entity_id) : entity_id_(std::move(entity_id)) { } + void set_temperature(double value) { + temperature_.store(value); + } + tl::expected list_data(const std::string & entity_id) override { if (entity_id != entity_id_) { return tl::make_unexpected(DataProviderErrorInfo{DataProviderError::EntityNotFound, "not found", 404}); } json items = json::array(); - items.push_back({{"id", "temperature"}, {"name", "Temperature"}, {"value", 42.5}}); + items.push_back({{"id", "temperature"}, {"name", "Temperature"}, {"value", temperature_.load()}}); items.push_back({{"id", "pressure"}, {"name", "Pressure"}, {"value", 3.2}}); return ros2_medkit_gateway::dto::DataListResult{json{{"items", std::move(items)}}}; } @@ -72,6 +78,7 @@ class FakePlcDataProvider : public DataProvider { private: std::string entity_id_; + std::atomic temperature_{42.5}; }; FaultEvent make_confirmed_event(const std::string & fault_code, const std::vector & sources) { @@ -208,6 +215,42 @@ TEST_F(EntityFreezeFrameCaptureTest, NonConfirmedEventsAreIgnored) { EXPECT_TRUE(capture.frames_for("PLC_UPDATED_ONLY").empty()); } +/// @verifies REQ_INTEROP_088 +TEST_F(EntityFreezeFrameCaptureTest, FramesRetainedAcrossClearAndOverwrittenOnReconfirm) { + EntityFreezeFrameCapture capture(node_.get(), *sub_exec_, [this](const std::string & entity_id) -> DataProvider * { + return entity_id == "plc_app" ? provider_.get() : nullptr; + }); + + ASSERT_TRUE(publish_and_wait(capture, make_confirmed_event("PLC_CYCLING", {"plc_app"}))); + ASSERT_DOUBLE_EQ(capture.frames_for("PLC_CYCLING")[0].values.value("temperature", 0.0), 42.5); + + // Clearing retains the confirmed-state record, mirroring the fault_manager's + // freeze-frame retention across clear_fault. + auto cleared = make_confirmed_event("PLC_CYCLING", {"plc_app"}); + cleared.event_type = FaultEvent::EVENT_CLEARED; + for (int i = 0; i < 25; ++i) { + publisher_->publish(cleared); + std::this_thread::sleep_for(10ms); + } + auto retained = capture.frames_for("PLC_CYCLING"); + ASSERT_EQ(retained.size(), 1u); + EXPECT_DOUBLE_EQ(retained[0].values.value("temperature", 0.0), 42.5); + + // The next confirm re-samples the plugin, replacing the retained frame, so + // a new occurrence never serves the previous incident's values past confirm. + provider_->set_temperature(99.0); + const auto reconfirm = make_confirmed_event("PLC_CYCLING", {"plc_app"}); + const auto deadline = std::chrono::steady_clock::now() + 5s; + bool overwritten = false; + while (std::chrono::steady_clock::now() < deadline && !overwritten) { + publisher_->publish(reconfirm); + std::this_thread::sleep_for(20ms); + auto latest = capture.frames_for("PLC_CYCLING"); + overwritten = !latest.empty() && std::abs(latest[0].values.value("temperature", 0.0) - 99.0) < 1e-9; + } + EXPECT_TRUE(overwritten); +} + /// @verifies REQ_INTEROP_088 TEST_F(EntityFreezeFrameCaptureTest, RouteFallbackCapturesWhenPluginHasNoDataProvider) { // Mirror the commercial PLC bridges: no DataProvider, values only through From ba559d2750aaeca95f10d1b1078dba20b43e3e7e Mon Sep 17 00:00:00 2001 From: Michal Faferek Date: Fri, 17 Jul 2026 13:42:53 +0200 Subject: [PATCH 6/7] fix(gateway): harden entity freeze-frame capture Run plugin capture on a dedicated worker so a slow list_data/x-plc-data read cannot stall the shared subscription executor. Gate both capture paths on live values (no {} / all-null rows), make the item parser total for non-string ids, and bound the once-per-code warn set. --- .../core/plugins/plugin_manager.hpp | 5 + .../entity_freeze_frame_capture.hpp | 63 ++++++-- .../src/entity_freeze_frame_capture.cpp | 151 +++++++++++++++--- src/ros2_medkit_gateway/src/gateway_node.cpp | 6 +- .../test/test_entity_freeze_frame_capture.cpp | 114 ++++++++++++- 5 files changed, 290 insertions(+), 49 deletions(-) diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/plugins/plugin_manager.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/plugins/plugin_manager.hpp index cf2239603..97d4ccc0b 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/plugins/plugin_manager.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/plugins/plugin_manager.hpp @@ -224,6 +224,11 @@ class PluginManager : public LogProviderRegistry { * table is matched exactly like the HTTP server would and the handler is * invoked with a synthesized request - no loopback connection. * + * The handler runs on the caller's thread, possibly concurrently with HTTP + * server threads serving the same route - plugin route handlers must be + * thread-safe (already the contract across concurrent HTTP workers; this + * entry point just adds one more caller). + * * @return Parsed JSON body on a 200 response; nullopt when the entity is * not plugin-owned, no route matches, or the handler fails. */ diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/entity_freeze_frame_capture.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/entity_freeze_frame_capture.hpp index 89d1903c0..a5cc49053 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/entity_freeze_frame_capture.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/entity_freeze_frame_capture.hpp @@ -14,6 +14,7 @@ #pragma once +#include #include #include #include @@ -22,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -47,8 +49,9 @@ namespace ros2_medkit_gateway { * from the plugin's latest polled values) at fault time. * * The frames are merged into the fault detail's environment_data.snapshots - * only when the fault_manager captured no freeze-frame itself - explicit - * snapshot config always wins (see FaultHandlers::merge_entity_freeze_frames). + * only when the fault_manager captured no freeze-frame itself - a configured + * freeze-frame always wins, while a rosbag-only capture does not suppress the + * entity frames (see FaultHandlers::merge_entity_freeze_frames). * * Retention mirrors the fault_manager's freeze-frame semantics: frames are * kept across EVENT_CLEARED (the confirmed-state record stays attached to @@ -65,19 +68,23 @@ class EntityFreezeFrameCapture { }; /// Resolves an entity id to its owning plugin's DataProvider (nullptr when - /// the entity is not plugin-owned). Called from the subscription callback. + /// the entity is not plugin-owned). Called from the internal capture thread. using DataProviderResolver = std::function; /// Fallback for plugins without a DataProvider: fetches the entity's current /// values by dispatching the owning plugin's own `x-plc-data` route /// in-process (typically wraps PluginManager::fetch_entity_data_via_route). - /// Returns the parsed route response, nullopt when unavailable. + /// Returns the parsed route response, nullopt when unavailable. Called from + /// the internal capture thread, concurrently with HTTP threads serving the + /// same route - dispatched handlers must be thread-safe. using RouteDataFetcher = std::function(const std::string & entity_id)>; /** * @param node ROS 2 node used to resolve the fault-events topic name and logger * @param exec shared subscription executor; the fault-events subscription is - * created and torn down on its serial worker (issue #375 invariant) + * created and torn down on its serial worker (issue #375 invariant). + * The subscription callback only enqueues - plugin calls run on a + * dedicated capture thread so a slow read never stalls that worker. * @param resolver entity-to-DataProvider resolver (typically wraps PluginManager) * @param route_fetcher x-plc-data route fallback for entities whose plugin * has no DataProvider (the commercial PLC bridges); may be null @@ -100,17 +107,35 @@ class EntityFreezeFrameCapture { /// Build the compact {resource_id: value} dict from a DataProvider::list_data /// response. Items without a "value" field map to null; a response without an - /// "items" array is kept verbatim (plugin-defined shape). + /// "items" array is kept verbatim (plugin-defined shape). Total: malformed + /// items (non-string id/name) are skipped, never thrown on. static nlohmann::json values_from_list_content(const nlohmann::json & content); - /// True when an x-plc-data route response carries real live values: not - /// flagged disconnected, with a non-empty items array. A PLC that is down - /// must not freeze-frame a row of nulls. - static bool route_content_has_live_data(const nlohmann::json & content); + /// True when list-data-shaped content (DataProvider::list_data or x-plc-data + /// route) carries real live values: not flagged disconnected, with a + /// non-empty items array. A PLC that is down must not freeze-frame a row + /// of nulls. + static bool content_has_live_data(const nlohmann::json & content); + + /// True when a compact values dict holds at least one non-null value. + /// Rejects the {} / all-null rows a cold poll cache or dead link yields. + static bool values_have_data(const nlohmann::json & values); private: + /// Subscription-worker side: filter for EVENT_CONFIRMED and enqueue only. void on_fault_event(const ros2_medkit_msgs::msg::FaultEvent::ConstSharedPtr & msg); + /// capture_thread_ main loop: drains queued confirm events. + void capture_worker(); + + /// Per-event capture (all plugin calls happen here, on capture_thread_). + void capture_for_event(const ros2_medkit_msgs::msg::FaultEvent & event); + + /// Build a frame from list-data-shaped content, enforcing the shared + /// no-row-of-nulls invariant on both capture paths. + std::optional frame_from_content(const std::string & entity_id, const std::string & fault_code, + const nlohmann::json & content); + /// Capture via the plugin's own x-plc-data route (no DataProvider exported). /// Returns nullopt when the route yields nothing usable. std::optional capture_via_route(const std::string & entity_id, const std::string & fault_code); @@ -125,12 +150,24 @@ class EntityFreezeFrameCapture { rclcpp::Logger logger_; const size_t max_faults_; - /// Guards frames_, insertion_order_ and fallback_logged_: subscription - /// callback writes, HTTP handler threads read. + /// Guards frames_, insertion_order_ and fallback_logged_: capture thread + /// writes, HTTP handler threads read. mutable std::mutex mutex_; + /// Keyed by fault_code only: cross-entity isolation relies on the + /// fault_manager keeping reporting_sources append-only for a code and on + /// get_fault gating by source scope. A per-source clear upstream would need + /// per-entity eviction here too. std::unordered_map> frames_; std::deque insertion_order_; ///< eviction order (FIFO) - std::unordered_set fallback_logged_; ///< fault codes already warned about + std::unordered_set fallback_logged_; ///< fault codes already warned about (bounded) + + /// Confirm events pending capture: fed by the subscription worker, drained + /// by capture_thread_. Bounded - oldest event dropped when full. + std::mutex queue_mutex_; + std::condition_variable queue_cv_; + std::deque queue_; + bool stop_{false}; + std::thread capture_thread_; }; } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/entity_freeze_frame_capture.cpp b/src/ros2_medkit_gateway/src/entity_freeze_frame_capture.cpp index b0b8f78cf..44aba5886 100644 --- a/src/ros2_medkit_gateway/src/entity_freeze_frame_capture.cpp +++ b/src/ros2_medkit_gateway/src/entity_freeze_frame_capture.cpp @@ -20,6 +20,24 @@ namespace ros2_medkit_gateway { +namespace { + +/// Capture backlog bound: a confirm burst beyond this drops the oldest event. +constexpr size_t kMaxQueuedEvents = 64; + +/// fallback_logged_ bound: past this the set resets (re-arming one warn per +/// code) instead of growing forever on churny/synthetic fault codes. +constexpr size_t kMaxLoggedFaultCodes = 1024; + +/// Read a string field totally: json::value() throws type_error.302 when the +/// key is present but not a string, and plugin content is untrusted. +std::string string_field(const nlohmann::json & item, const char * field) { + const auto it = item.find(field); + return it != item.end() && it->is_string() ? it->get() : std::string(); +} + +} // namespace + EntityFreezeFrameCapture::EntityFreezeFrameCapture(rclcpp::Node * node, ros2_common::Ros2SubscriptionExecutor & exec, DataProviderResolver resolver, RouteDataFetcher route_fetcher, size_t max_faults) @@ -42,12 +60,29 @@ EntityFreezeFrameCapture::EntityFreezeFrameCapture(rclcpp::Node * node, ros2_com return; } subscription_slot_ = std::move(*slot); + capture_thread_ = std::thread([this] { + capture_worker(); + }); RCLCPP_INFO(logger_, "EntityFreezeFrameCapture initialized, subscribed to %s", fault_events_topic.c_str()); } EntityFreezeFrameCapture::~EntityFreezeFrameCapture() { + // Not a synchronous barrier: the slot posts an async destroy with a bounded + // deadline, so use-after-free safety also relies on the owner tearing down + // the subscription executor (joining its worker) before this object's node + // dies - keep that ordering in main.cpp / gateway_node shutdown. subscription_slot_.reset(); + { + std::lock_guard lock(queue_mutex_); + stop_ = true; + } + queue_cv_.notify_all(); + // Joins through any in-flight plugin call: a hung read delays shutdown + // rather than leaving a capture racing plugin unload. + if (capture_thread_.joinable()) { + capture_thread_.join(); + } } std::vector @@ -66,7 +101,10 @@ nlohmann::json EntityFreezeFrameCapture::values_from_list_content(const nlohmann if (!item.is_object()) { continue; } - std::string key = item.value("id", item.value("name", "")); + std::string key = string_field(item, "id"); + if (key.empty()) { + key = string_field(item, "name"); + } if (key.empty()) { continue; } @@ -75,7 +113,7 @@ nlohmann::json EntityFreezeFrameCapture::values_from_list_content(const nlohmann return values; } -bool EntityFreezeFrameCapture::route_content_has_live_data(const nlohmann::json & content) { +bool EntityFreezeFrameCapture::content_has_live_data(const nlohmann::json & content) { if (!content.is_object()) { return false; } @@ -85,6 +123,39 @@ bool EntityFreezeFrameCapture::route_content_has_live_data(const nlohmann::json return content.contains("items") && content["items"].is_array() && !content["items"].empty(); } +bool EntityFreezeFrameCapture::values_have_data(const nlohmann::json & values) { + if (values.is_object()) { + for (const auto & entry : values.items()) { + if (!entry.value().is_null()) { + return true; + } + } + return false; + } + return !values.is_null(); +} + +std::optional +EntityFreezeFrameCapture::frame_from_content(const std::string & entity_id, const std::string & fault_code, + const nlohmann::json & content) { + if (!content_has_live_data(content)) { + log_fallback_failure_once(fault_code, "entity '" + entity_id + "' reported no live values"); + return std::nullopt; + } + Frame frame; + frame.entity_id = entity_id; + frame.values = values_from_list_content(content); + if (!values_have_data(frame.values)) { + // Items present but nothing usable in them (all-null values, or no usable + // ids): still a dead/cold row - no frame, same invariant as above. + log_fallback_failure_once(fault_code, "entity '" + entity_id + "' values are all null"); + return std::nullopt; + } + frame.captured_at_ns = + std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); + return frame; +} + std::optional EntityFreezeFrameCapture::capture_via_route(const std::string & entity_id, const std::string & fault_code) { std::optional content; @@ -97,21 +168,15 @@ EntityFreezeFrameCapture::capture_via_route(const std::string & entity_id, const if (!content) { return std::nullopt; // not plugin-owned, no x-plc-data route, or handler error } - if (!route_content_has_live_data(*content)) { - log_fallback_failure_once(fault_code, "entity '" + entity_id + "' x-plc-data reported no live values"); - return std::nullopt; - } - Frame frame; - frame.entity_id = entity_id; - frame.values = values_from_list_content(*content); - frame.captured_at_ns = - std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); - return frame; + return frame_from_content(entity_id, fault_code, *content); } void EntityFreezeFrameCapture::log_fallback_failure_once(const std::string & fault_code, const std::string & message) { { std::lock_guard lock(mutex_); + if (fallback_logged_.size() >= kMaxLoggedFaultCodes) { + fallback_logged_.clear(); + } if (!fallback_logged_.insert(fault_code).second) { return; } @@ -126,15 +191,56 @@ void EntityFreezeFrameCapture::on_fault_event(const ros2_medkit_msgs::msg::Fault return; } + // This runs on the shared subscription worker (which also serves all /data + // sampling and subscribe/unsubscribe), so only enqueue here: the plugin + // calls run on capture_thread_, where a slow or live read cannot stall the + // worker and a handler that re-enters the executor cannot self-deadlock. + { + std::lock_guard lock(queue_mutex_); + if (stop_) { + return; + } + if (queue_.size() >= kMaxQueuedEvents) { + RCLCPP_WARN(logger_, "Entity freeze-frame capture backlog full; dropping oldest confirm event"); + queue_.pop_front(); + } + queue_.push_back(msg); + } + queue_cv_.notify_one(); +} + +void EntityFreezeFrameCapture::capture_worker() { + for (;;) { + ros2_medkit_msgs::msg::FaultEvent::ConstSharedPtr event; + { + std::unique_lock lock(queue_mutex_); + queue_cv_.wait(lock, [this] { + return stop_ || !queue_.empty(); + }); + if (stop_) { + // Drop the backlog: the owner destroys this right before plugin + // shutdown, and a late capture would call into an unloading plugin. + return; + } + event = queue_.front(); + queue_.pop_front(); + } + capture_for_event(*event); + } +} + +void EntityFreezeFrameCapture::capture_for_event(const ros2_medkit_msgs::msg::FaultEvent & event) { + const std::string & fault_code = event.fault.fault_code; + std::vector frames; - for (const auto & source : msg->fault.reporting_sources) { + for (const auto & source : event.fault.reporting_sources) { DataProvider * provider = resolver_ ? resolver_(source) : nullptr; if (provider == nullptr) { // No DataProvider: the owning plugin may still serve live values through // its own x-plc-data route (the commercial PLC bridges). For non-plugin // (ROS) sources the fetcher resolves no owner and returns nullopt. if (route_fetcher_) { - if (auto frame = capture_via_route(source, msg->fault.fault_code)) { + if (auto frame = capture_via_route(source, fault_code)) { frames.push_back(std::move(*frame)); } } @@ -147,20 +253,14 @@ void EntityFreezeFrameCapture::on_fault_event(const ros2_medkit_msgs::msg::Fault try { auto result = provider->list_data(source); if (!result) { - RCLCPP_WARN(logger_, "Entity freeze-frame for fault '%s': list_data('%s') failed: %s", - msg->fault.fault_code.c_str(), source.c_str(), result.error().message.c_str()); + log_fallback_failure_once(fault_code, "list_data('" + source + "') failed: " + result.error().message); continue; } - Frame frame; - frame.entity_id = source; - frame.values = values_from_list_content(result->content); - frame.captured_at_ns = - std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()) - .count(); - frames.push_back(std::move(frame)); + if (auto frame = frame_from_content(source, fault_code, result->content)) { + frames.push_back(std::move(*frame)); + } } catch (const std::exception & e) { - RCLCPP_WARN(logger_, "Entity freeze-frame for fault '%s': plugin threw for entity '%s': %s", - msg->fault.fault_code.c_str(), source.c_str(), e.what()); + log_fallback_failure_once(fault_code, "plugin threw for entity '" + source + "': " + e.what()); } } @@ -169,7 +269,6 @@ void EntityFreezeFrameCapture::on_fault_event(const ros2_medkit_msgs::msg::Fault } std::lock_guard lock(mutex_); - const std::string & fault_code = msg->fault.fault_code; if (frames_.find(fault_code) == frames_.end()) { insertion_order_.push_back(fault_code); while (frames_.size() >= max_faults_ && !insertion_order_.empty()) { diff --git a/src/ros2_medkit_gateway/src/gateway_node.cpp b/src/ros2_medkit_gateway/src/gateway_node.cpp index 97055fec1..aaf6ee140 100644 --- a/src/ros2_medkit_gateway/src/gateway_node.cpp +++ b/src/ros2_medkit_gateway/src/gateway_node.cpp @@ -1672,9 +1672,9 @@ GatewayNode::~GatewayNode() { if (resource_change_notifier_) { resource_change_notifier_->shutdown(); } - // 6. Drop the entity freeze-frame subscription BEFORE plugin shutdown: its - // callback resolves into plugin DataProviders, which must not be reachable - // once shutdown_all() has run. + // 6. Drop the entity freeze-frame capture BEFORE plugin shutdown: its + // capture thread (joined here) resolves into plugin DataProviders and + // routes, which must not be reachable once shutdown_all() has run. entity_freeze_frame_capture_.reset(); // Shutdown plugins if (plugin_mgr_) { diff --git a/src/ros2_medkit_gateway/test/test_entity_freeze_frame_capture.cpp b/src/ros2_medkit_gateway/test/test_entity_freeze_frame_capture.cpp index 8247a3c40..f24847e31 100644 --- a/src/ros2_medkit_gateway/test/test_entity_freeze_frame_capture.cpp +++ b/src/ros2_medkit_gateway/test/test_entity_freeze_frame_capture.cpp @@ -81,6 +81,37 @@ class FakePlcDataProvider : public DataProvider { std::atomic temperature_{42.5}; }; +/// Fake DataProvider serving fixed list_data content (for gate tests). +class StaticContentDataProvider : public DataProvider { + public: + StaticContentDataProvider(std::string entity_id, json content) + : entity_id_(std::move(entity_id)), content_(std::move(content)) { + } + + tl::expected + list_data(const std::string & entity_id) override { + if (entity_id != entity_id_) { + return tl::make_unexpected(DataProviderErrorInfo{DataProviderError::EntityNotFound, "not found", 404}); + } + return ros2_medkit_gateway::dto::DataListResult{content_}; + } + + tl::expected + read_data(const std::string & /*entity_id*/, const std::string & /*resource_name*/) override { + return tl::make_unexpected(DataProviderErrorInfo{DataProviderError::Internal, "unused", 500}); + } + + tl::expected + write_data(const std::string & /*entity_id*/, const std::string & /*resource_name*/, + const json & /*value*/) override { + return tl::make_unexpected(DataProviderErrorInfo{DataProviderError::ReadOnly, "read-only", 405}); + } + + private: + std::string entity_id_; + json content_; +}; + FaultEvent make_confirmed_event(const std::string & fault_code, const std::vector & sources) { FaultEvent event; event.event_type = FaultEvent::EVENT_CONFIRMED; @@ -321,17 +352,75 @@ TEST_F(EntityFreezeFrameCaptureTest, DataProviderWinsOverRouteFallback) { EXPECT_FALSE(fetcher_called.load()); } -TEST(RouteContentHasLiveData, GatesOnConnectedAndItems) { +/// @verifies REQ_INTEROP_088 +TEST_F(EntityFreezeFrameCaptureTest, DataProviderWithoutLiveValuesCapturesNothing) { + // Same "no row = nothing captured" invariant as the route path: an empty + // items array (cold poll cache), all-null values (down link) or a + // disconnected flag must not freeze-frame a row of {} / nulls. + StaticContentDataProvider cold_provider("cold_app", json{{"items", json::array()}}); + StaticContentDataProvider null_provider("null_app", json{{"items", json::array({{{"id", "level"}}})}}); + StaticContentDataProvider down_provider( + "down_app", json{{"connected", false}, {"items", json::array({{{"id", "level"}, {"value", 1.0}}})}}); + EntityFreezeFrameCapture capture(node_.get(), *sub_exec_, [&](const std::string & entity_id) -> DataProvider * { + if (entity_id == "cold_app") { + return &cold_provider; + } + if (entity_id == "null_app") { + return &null_provider; + } + if (entity_id == "down_app") { + return &down_provider; + } + return nullptr; + }); + + ASSERT_TRUE(wait_for_match()); + auto event = make_confirmed_event("PLC_NO_LIVE_DATA", {"cold_app", "null_app", "down_app"}); + for (int i = 0; i < 25; ++i) { + publisher_->publish(event); + std::this_thread::sleep_for(10ms); + } + EXPECT_TRUE(capture.frames_for("PLC_NO_LIVE_DATA").empty()); +} + +/// @verifies REQ_INTEROP_088 +TEST_F(EntityFreezeFrameCaptureTest, OldestFaultEvictedPastMaxFaults) { + EntityFreezeFrameCapture capture( + node_.get(), *sub_exec_, + [this](const std::string & entity_id) -> DataProvider * { + return entity_id == "plc_app" ? provider_.get() : nullptr; + }, + nullptr, /*max_faults=*/2); + + ASSERT_TRUE(publish_and_wait(capture, make_confirmed_event("PLC_EVICT_A", {"plc_app"}))); + ASSERT_TRUE(publish_and_wait(capture, make_confirmed_event("PLC_EVICT_B", {"plc_app"}))); + ASSERT_TRUE(publish_and_wait(capture, make_confirmed_event("PLC_EVICT_C", {"plc_app"}))); + + EXPECT_TRUE(capture.frames_for("PLC_EVICT_A").empty()); // FIFO-evicted + EXPECT_FALSE(capture.frames_for("PLC_EVICT_B").empty()); + EXPECT_FALSE(capture.frames_for("PLC_EVICT_C").empty()); +} + +TEST(ContentHasLiveData, GatesOnConnectedAndItems) { using Capture = EntityFreezeFrameCapture; - EXPECT_TRUE(Capture::route_content_has_live_data( + EXPECT_TRUE(Capture::content_has_live_data( json{{"connected", true}, {"items", json::array({{{"name", "a"}, {"value", 1}}})}})); // No "connected" field: items alone are enough (plugin-defined shape). - EXPECT_TRUE(Capture::route_content_has_live_data(json{{"items", json::array({{{"name", "a"}, {"value", 1}}})}})); - EXPECT_FALSE(Capture::route_content_has_live_data( + EXPECT_TRUE(Capture::content_has_live_data(json{{"items", json::array({{{"name", "a"}, {"value", 1}}})}})); + EXPECT_FALSE(Capture::content_has_live_data( json{{"connected", false}, {"items", json::array({{{"name", "a"}, {"value", 1}}})}})); - EXPECT_FALSE(Capture::route_content_has_live_data(json{{"connected", true}, {"items", json::array()}})); - EXPECT_FALSE(Capture::route_content_has_live_data(json{{"connected", true}})); - EXPECT_FALSE(Capture::route_content_has_live_data(json::array())); + EXPECT_FALSE(Capture::content_has_live_data(json{{"connected", true}, {"items", json::array()}})); + EXPECT_FALSE(Capture::content_has_live_data(json{{"connected", true}})); + EXPECT_FALSE(Capture::content_has_live_data(json::array())); +} + +TEST(ValuesHaveData, RejectsEmptyAndAllNull) { + using Capture = EntityFreezeFrameCapture; + EXPECT_TRUE(Capture::values_have_data(json{{"a", 1}})); + EXPECT_TRUE(Capture::values_have_data(json{{"a", nullptr}, {"b", 0.0}})); + EXPECT_FALSE(Capture::values_have_data(json::object())); + EXPECT_FALSE(Capture::values_have_data(json{{"a", nullptr}, {"b", nullptr}})); + EXPECT_FALSE(Capture::values_have_data(json(nullptr))); } TEST(ValuesFromListContent, BuildsCompactDictFromItems) { @@ -349,6 +438,17 @@ TEST(ValuesFromListContent, NonItemsShapeKeptVerbatim) { EXPECT_EQ(EntityFreezeFrameCapture::values_from_list_content(content), content); } +TEST(ValuesFromListContent, NonStringIdOrNameNeverThrows) { + // json::value() throws type_error.302 on present-but-non-string keys; + // plugin content is untrusted, so the builder must be total instead. + json content = {{"items", json::array({{{"id", 42}, {"name", "b"}, {"value", 2}}, // int id -> name fallback + {{"id", 7}, {"value", 1}}, // no usable key -> skipped + {{"name", true}}})}}; // no usable key -> skipped + json values; + ASSERT_NO_THROW(values = EntityFreezeFrameCapture::values_from_list_content(content)); + EXPECT_EQ(values, json({{"b", 2}})); +} + TEST(MergeEntityFreezeFrames, AppendsWhenNoConfiguredFreezeFrame) { json env_data = {{"snapshots", json::array()}}; EntityFreezeFrameCapture::Frame frame; From b00761d874aa7814b54976577bcd1e83eb28d1e9 Mon Sep 17 00:00:00 2001 From: Michal Faferek Date: Fri, 17 Jul 2026 13:42:53 +0200 Subject: [PATCH 7/7] fix(fault_manager): empty explicit snapshot entry is an opt-out A fault code present in fault_specific or matched by a pattern no longer falls through to entity-default capture when its topic list is empty - that is an explicit "capture nothing" for the code. --- .../config/snapshots.yaml | 4 +++ .../snapshot_capture.hpp | 12 +++++-- .../src/snapshot_capture.cpp | 17 +++++---- .../test/test_snapshot_capture.cpp | 35 +++++++++++++++++++ 4 files changed, 59 insertions(+), 9 deletions(-) diff --git a/src/ros2_medkit_fault_manager/config/snapshots.yaml b/src/ros2_medkit_fault_manager/config/snapshots.yaml index 6fe994416..bfdb5fcbd 100644 --- a/src/ros2_medkit_fault_manager/config/snapshots.yaml +++ b/src/ros2_medkit_fault_manager/config/snapshots.yaml @@ -11,6 +11,10 @@ # the reporting source node's own published topics are captured # (parameter snapshots.entity_default, on by default) # +# A fault code listed in fault_specific (or matched by a pattern) with an +# EMPTY topic list is an explicit per-fault opt-out: nothing is captured and +# the entity-default fallback is skipped for that code. +# # Usage: # ros2 run ros2_medkit_fault_manager fault_manager_node \ # --ros-args -p snapshots.config_file:=/path/to/snapshots.yaml diff --git a/src/ros2_medkit_fault_manager/include/ros2_medkit_fault_manager/snapshot_capture.hpp b/src/ros2_medkit_fault_manager/include/ros2_medkit_fault_manager/snapshot_capture.hpp index 986cf2552..829a4a4d5 100644 --- a/src/ros2_medkit_fault_manager/include/ros2_medkit_fault_manager/snapshot_capture.hpp +++ b/src/ros2_medkit_fault_manager/include/ros2_medkit_fault_manager/snapshot_capture.hpp @@ -158,8 +158,10 @@ class SnapshotCapture { /// /// If the fault code resolves to no capture set (not in fault_specific, no pattern /// match, no default_topics), the entity-default fallback (when enabled) captures - /// the reporting source node's own published topics instead. Only when that also - /// resolves nothing does capture return early: no freeze_frames row is written + /// the reporting source node's own published topics instead. A code explicitly + /// listed in fault_specific or matched by a pattern never falls through - a + /// present-but-empty topic list is a per-fault opt-out. Only when nothing + /// resolves does capture return early: no freeze_frames row is written /// (no empty {} row) and FaultStorage::get_freeze_frame() returns nullopt for it. /// @param fault_code The fault code that was confirmed void capture(const std::string & fault_code); @@ -177,7 +179,11 @@ class SnapshotCapture { private: /// Resolve which topics to capture for a given fault code /// Priority: fault_specific > patterns > default_topics - std::vector resolve_topics(const std::string & fault_code) const; + /// @param[out] explicit_match true when the code hit fault_specific or a + /// pattern (even with an empty list - an explicit opt-out) or + /// default_topics applied; such codes never fall through to the + /// entity-default capture. + std::vector resolve_topics(const std::string & fault_code, bool & explicit_match) const; /// Entity-default fallback: topics published by the fault's reporting source /// node(s), excluding per-node noise (/rosout, /parameter_events). Non-FQN diff --git a/src/ros2_medkit_fault_manager/src/snapshot_capture.cpp b/src/ros2_medkit_fault_manager/src/snapshot_capture.cpp index 79154e519..17a00f173 100644 --- a/src/ros2_medkit_fault_manager/src/snapshot_capture.cpp +++ b/src/ros2_medkit_fault_manager/src/snapshot_capture.cpp @@ -123,11 +123,13 @@ void SnapshotCapture::capture(const std::string & fault_code) { return; } - auto topics = resolve_topics(fault_code); - // Zero-config fallback: no explicit config matched, so capture the reporting - // source node's own published topics (explicit config always wins above). + bool explicit_match = false; + auto topics = resolve_topics(fault_code, explicit_match); + // Zero-config fallback: nothing in the explicit config even mentioned this + // code. A present-but-empty fault_specific/pattern entry is an explicit + // per-fault opt-out (explicit_match == true) and must NOT fall through here. bool entity_scoped = false; - if (topics.empty() && config_.entity_default) { + if (topics.empty() && !explicit_match && config_.entity_default) { topics = resolve_entity_topics(fault_code); entity_scoped = !topics.empty(); } @@ -194,7 +196,9 @@ void SnapshotCapture::capture(const std::string & fault_code) { storage_->store_freeze_frame(frame); } -std::vector SnapshotCapture::resolve_topics(const std::string & fault_code) const { +std::vector SnapshotCapture::resolve_topics(const std::string & fault_code, bool & explicit_match) const { + explicit_match = true; + // Priority 1: Exact match in fault_specific auto it = config_.fault_specific.find(fault_code); if (it != config_.fault_specific.end()) { @@ -210,12 +214,13 @@ std::vector SnapshotCapture::resolve_topics(const std::string & fau } } - // Priority 3: Default topics + // Priority 3: Default topics (empty means "not configured", not an opt-out) if (!config_.default_topics.empty()) { RCLCPP_DEBUG(node_->get_logger(), "Using default_topics for '%s'", fault_code.c_str()); return config_.default_topics; } + explicit_match = false; return {}; } diff --git a/src/ros2_medkit_fault_manager/test/test_snapshot_capture.cpp b/src/ros2_medkit_fault_manager/test/test_snapshot_capture.cpp index efda244a8..c398188e6 100644 --- a/src/ros2_medkit_fault_manager/test/test_snapshot_capture.cpp +++ b/src/ros2_medkit_fault_manager/test/test_snapshot_capture.cpp @@ -538,6 +538,41 @@ TEST_F(EntityDefaultCaptureTest, ExplicitConfigWinsOverEntityDefault) { EXPECT_FALSE(parsed.contains("/entity/own_other")); } +// @verifies REQ_INTEROP_088 +TEST_F(EntityDefaultCaptureTest, ExplicitEmptyConfigEntryOptsOutOfEntityDefault) { + // A present-but-empty fault_specific or pattern entry is an explicit + // "capture nothing for this code" and must not fall through to the + // entity-default capture of the node's own topics. + auto pub = node_->create_publisher("/entity/own_optout", rclcpp::QoS(10)); + + SnapshotConfig config; + config.enabled = true; + config.background_capture = false; + config.timeout_sec = 5.0; + config.fault_specific["OPTED_OUT_SPECIFIC"] = {}; + config.patterns["^OPTED_OUT_PATTERN_.*"] = {}; + SnapshotCapture capture(node_.get(), storage_.get(), config); + + store_fault_from_this_node("OPTED_OUT_SPECIFIC"); + store_fault_from_this_node("OPTED_OUT_PATTERN_X"); + + ScopedPublisherThread pub_thread([&pub](std::atomic & stop) { + while (!stop.load()) { + std_msgs::msg::Float64 msg; + msg.data = 7.0; + pub->publish(msg); + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + }); + wait_for_publisher("/entity/own_optout"); + + capture.capture("OPTED_OUT_SPECIFIC"); + capture.capture("OPTED_OUT_PATTERN_X"); + + EXPECT_FALSE(storage_->get_freeze_frame("OPTED_OUT_SPECIFIC").has_value()); + EXPECT_FALSE(storage_->get_freeze_frame("OPTED_OUT_PATTERN_X").has_value()); +} + // @verifies REQ_INTEROP_088 TEST_F(EntityDefaultCaptureTest, UnresolvableSourceWritesNoRow) { SnapshotConfig config;