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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions docs/api/rest.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1501,13 +1501,19 @@ Query and manage faults.
- ``freeze_frame``: Data captured at fault confirmation. Entity frames for
faults that were already confirmed when the gateway started are captured
at gateway start instead and carry ``"capture_origin": "startup"`` in
their ``x-medkit`` block. For a plugin-backed entity that reports its
link down, the values are the plugin's last known ones and may predate
the confirmation by the length of the outage; such entries carry
``connected`` (the payload's link flag, ``false`` for the loss-of-comms
case) and ``source_timestamp`` (the payload's own timestamp, verbatim)
in ``x-medkit``, both only when the plugin's payload reports them.
``captured_at`` always dates the capture, not the values.
their ``x-medkit`` block. An entity frame also carries ``source`` in
``x-medkit``, naming the path that read the values
(``plugin_data_provider`` for the owning plugin's DataProvider,
``plugin_x_plc_data_route`` for its ``x-plc-data`` route). These values
are not a ROS message, so ``topic`` and ``message_type`` are empty and
``source`` is the only field saying where the numbers came from. For a
plugin-backed entity that reports its link down, the values are the
plugin's last known ones and may predate the confirmation by the length of
the outage. Such entries carry ``connected`` (the payload's link flag,
``false`` for the loss-of-comms case) and ``source_timestamp`` (the
payload's own timestamp, verbatim) in ``x-medkit``, both only when the
plugin's payload reports them. ``captured_at`` always dates the capture,
not the values.
- ``rosbag``: Recording file available via bulk-data endpoint

**Response codes:**
Expand Down
21 changes: 21 additions & 0 deletions docs/tutorials/snapshots.rst
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,26 @@ with:
ros2 run ros2_medkit_gateway gateway_node --ros-args \
-p entity_freeze_frame.enabled:=false

A plugin entity's values are not a ROS message, so ``topic`` and
``message_type`` are empty on these frames. ``x-medkit.source`` names the
capture path instead, so a consumer can still tell where the values came
from:

.. list-table::
:header-rows: 1
:widths: 30 70

* - ``x-medkit.source``
- Meaning
* - ``plugin_data_provider``
- Read through the owning plugin's ``DataProvider::list_data``.
* - ``plugin_x_plc_data_route``
- Read by dispatching the owning plugin's own ``x-plc-data`` route
in-process (plugins that export no ``DataProvider``).

The field is absent on freeze-frames captured by the fault manager from a ROS
topic. Those carry a real ``topic`` and ``message_type`` instead.

Example plugin-entity freeze-frame in the fault response:

.. code-block:: json
Expand All @@ -227,6 +247,7 @@ Example plugin-entity freeze-frame in the fault response:
"x-medkit": {
"topic": "",
"message_type": "",
"source": "plugin_x_plc_data_route",
"full_data": {"tank_level": 87.5, "pump_running": true},
"captured_at": "2026-07-14T12:00:00.000Z"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ namespace ros2_medkit_gateway {
*/
class EntityFreezeFrameCapture {
public:
/// Capture-path identifiers stored in Frame::source and served as
/// ``x-medkit.source``. The plugin's own DataProvider, and the in-process
/// dispatch of the plugin's `x-plc-data` route for plugins that export no
/// DataProvider.
static constexpr const char * kSourceDataProvider = "plugin_data_provider";
static constexpr const char * kSourceXPlcDataRoute = "plugin_x_plc_data_route";

/// One captured frame: the entity's data values at fault-confirm time.
/// captured_at_ns dates the capture, not the values - a disconnected entity
/// serves its last known values, whose age is bounded only by the outage.
Expand All @@ -72,6 +79,13 @@ class EntityFreezeFrameCapture {
bool startup_catchup{false};
std::optional<bool> connected; ///< payload's top-level link flag, when reported
nlohmann::json source_timestamp; ///< payload's own "timestamp" field verbatim (null when absent)
/// Which capture path read the values (kSourceDataProvider /
/// kSourceXPlcDataRoute), served as ``x-medkit.source``. These values are
/// entity data, not a ROS message, so ``topic`` and ``message_type`` are
/// empty on the wire and would otherwise leave a consumer with nothing at
/// all saying where the numbers came from. Empty when the caller named no
/// path.
std::string source;
};

/// Resolves an entity id to its owning plugin's DataProvider (nullptr when
Expand Down Expand Up @@ -187,9 +201,10 @@ class EntityFreezeFrameCapture {
bool capture_for_event(const ros2_medkit_msgs::msg::FaultEvent & event, bool startup_catchup = false);

/// Build a frame from list-data-shaped content, enforcing the shared
/// no-row-of-nulls invariant on both capture paths.
/// no-row-of-nulls invariant on both capture paths. @p source names the path
/// that read the content and is stored verbatim in Frame::source.
std::optional<Frame> frame_from_content(const std::string & entity_id, const std::string & fault_code,
const nlohmann::json & content);
const nlohmann::json & content, const std::string & source);

/// Capture via the plugin's own x-plc-data route (no DataProvider exported).
/// Returns nullopt when the route yields nothing usable.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,27 @@ class GatewayNode : public rclcpp::Node {
std::unique_ptr<std::thread> server_thread_;
};

/**
* @brief Is this node FQN the gateway's own, rather than a diagnosable peer?
*
* True for the gateway node itself and for the helper nodes it creates inside
* its own process: the subscription executor's `<fqn>_sub`, the fault-service
* transport's `<fqn>_fault_clients`, and the lifecycle reader's
* `<fqn>_lifecycle_state_reader`. None of these begins with '_', so the ROS 2
* hidden-node convention does not cover them and the gateway would otherwise
* count them as peers and list them as diagnosable Apps - reporting on itself.
*
* A fault_manager node sharing the process is NOT ours: it is a separate,
* diagnosable component and stays visible.
*
* Exact matches only. A prefix test would also claim a genuine peer named
* `<fqn>_monitor` or `<fqn>2`, and dropping a real node is the worse error.
*
* @param node_fqn Fully qualified node name to test ("/ns/node")
* @param self_fqn The gateway node's own FQN. An empty value matches nothing
*/
bool is_own_gateway_node(const std::string & node_fqn, const std::string & self_fqn);

/**
* @brief Filter ROS 2 internal nodes from an app list
*
Expand All @@ -505,11 +526,18 @@ class GatewayNode : public rclcpp::Node {
* before checking for the underscore prefix, using the routing table for precise
* prefix detection.
*
* Also removes local apps bound to one of the gateway's own nodes
* (is_own_gateway_node), which the underscore rule cannot see. The test is on
* the bound node FQN, and only for apps with no routing-table entry: a peer's
* helper nodes are the peer's business and are left to the peer's own filter.
*
* @param apps App vector to filter in place
* @param peer_routing_table Maps entity_id -> peer_name for remote entities
* @param self_fqn The gateway node's own FQN. Empty disables the self check
* @return Number of apps removed
*/
size_t filter_internal_node_apps(std::vector<App> & apps,
const std::unordered_map<std::string, std::string> & peer_routing_table);
const std::unordered_map<std::string, std::string> & peer_routing_table,
const std::string & self_fqn);

} // namespace ros2_medkit_gateway
7 changes: 4 additions & 3 deletions src/ros2_medkit_gateway/src/entity_freeze_frame_capture.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -188,13 +188,14 @@ EntityFreezeFrameCapture::standing_faults_from_list_reply(const nlohmann::json &

std::optional<EntityFreezeFrameCapture::Frame>
EntityFreezeFrameCapture::frame_from_content(const std::string & entity_id, const std::string & fault_code,
const nlohmann::json & content) {
const nlohmann::json & content, const std::string & source) {
if (!content_has_live_data(content)) {
log_fallback_failure_once(fault_code, "entity '" + entity_id + "' returned no data items");
return std::nullopt;
}
Frame frame;
frame.entity_id = entity_id;
frame.source = source;
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
Expand Down Expand Up @@ -228,7 +229,7 @@ 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
}
return frame_from_content(entity_id, fault_code, *content);
return frame_from_content(entity_id, fault_code, *content, kSourceXPlcDataRoute);
}

void EntityFreezeFrameCapture::log_fallback_failure_once(const std::string & fault_code, const std::string & message) {
Expand Down Expand Up @@ -410,7 +411,7 @@ bool EntityFreezeFrameCapture::capture_for_event(const ros2_medkit_msgs::msg::Fa
log_fallback_failure_once(fault_code, "list_data('" + source + "') failed: " + result.error().message);
continue;
}
if (auto frame = frame_from_content(source, fault_code, result->content)) {
if (auto frame = frame_from_content(source, fault_code, result->content, kSourceDataProvider)) {
frames.push_back(std::move(*frame));
}
} catch (const std::exception & e) {
Expand Down
47 changes: 38 additions & 9 deletions src/ros2_medkit_gateway/src/gateway_node.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
#include "ros2_medkit_gateway/gateway_node.hpp"

#include <algorithm>
#include <array>
#include <cctype>
#include <chrono>
#include <cinttypes>
Expand Down Expand Up @@ -1596,6 +1597,27 @@ GatewayNode::GatewayNode(const rclcpp::NodeOptions & options) : Node("ros2_medki
});
}

bool is_own_gateway_node(const std::string & node_fqn, const std::string & self_fqn) {
if (self_fqn.empty() || node_fqn.empty()) {
return false;
}
if (node_fqn == self_fqn) {
return true;
}
// The helper nodes the gateway creates inside its own process, each named
// after this node plus a fixed suffix. Where each one is set:
// "_sub" Ros2SubscriptionExecutor::Config
// (subscription_node_name_suffix)
// "_fault_clients" Ros2FaultServiceTransport
// "_lifecycle_state_reader" Ros2LifecycleStateReader
// Exact matches only: a prefix test would also claim a genuine peer named
// "<fqn>_monitor" or "<fqn>2", and hiding a real node is the worse error.
static constexpr std::array<const char *, 3> kHelperSuffixes{"_sub", "_fault_clients", "_lifecycle_state_reader"};
return std::any_of(kHelperSuffixes.begin(), kHelperSuffixes.end(), [&](const char * suffix) {
return node_fqn == self_fqn + suffix;
});
}

size_t GatewayNode::count_peer_nodes(const std::vector<std::pair<std::string, std::string>> & nodes_and_namespaces,
const std::string & self_fqn) {
size_t count = 0;
Expand All @@ -1608,10 +1630,7 @@ size_t GatewayNode::count_peer_nodes(const std::vector<std::pair<std::string, st
fqn += "/";
}
fqn += name;
// Exclude the gateway's own nodes by exact FQN: the main node and its known
// internal helpers. A plain prefix match would also drop a genuine peer whose
// name starts with the gateway name (e.g. "<fqn>_monitor" or "<fqn>2").
if (fqn == self_fqn || fqn == self_fqn + "_sub" || fqn == self_fqn + "_fault_clients") {
if (is_own_gateway_node(fqn, self_fqn)) {
continue;
}
++count;
Expand Down Expand Up @@ -2457,14 +2476,15 @@ void GatewayNode::refresh_cache() {
}
}

// Filter ROS 2 internal nodes (underscore prefix convention).
// Filter ROS 2 internal nodes (underscore prefix convention) and this
// gateway's own helper nodes.
// Controlled by discovery.runtime.filter_internal_nodes parameter (default: true).
// Covers local heuristic apps (which bypass the merge pipeline orphan filter
// in runtime_only mode) and any peer apps that slipped through fetch_entities.
if (filter_internal_nodes_) {
auto removed = filter_internal_node_apps(apps, peer_routing_table);
auto removed = filter_internal_node_apps(apps, peer_routing_table, get_fully_qualified_name());
if (removed > 0) {
RCLCPP_DEBUG(get_logger(), "Filtered %zu internal node apps (_ prefix)", removed);
RCLCPP_DEBUG(get_logger(), "Filtered %zu internal node apps (_ prefix or own helper node)", removed);
}
}

Expand Down Expand Up @@ -2561,9 +2581,10 @@ void GatewayNode::stop_rest_server() {
}

size_t filter_internal_node_apps(std::vector<App> & apps,
const std::unordered_map<std::string, std::string> & peer_routing_table) {
const std::unordered_map<std::string, std::string> & peer_routing_table,
const std::string & self_fqn) {
auto before = apps.size();
auto end = std::remove_if(apps.begin(), apps.end(), [&peer_routing_table](const App & app) {
auto end = std::remove_if(apps.begin(), apps.end(), [&peer_routing_table, &self_fqn](const App & app) {
std::string original_id = app.id;
auto rt_it = peer_routing_table.find(app.id);
if (rt_it != peer_routing_table.end()) {
Expand All @@ -2573,6 +2594,14 @@ size_t filter_internal_node_apps(std::vector<App> & apps,
if (original_id.size() > prefix.size() && original_id.compare(0, prefix.size(), prefix) == 0) {
original_id = original_id.substr(prefix.size());
}
} else if (is_own_gateway_node(app.effective_fqn(), self_fqn)) {
// A local app bound to one of this gateway's own nodes. Those names do
// not start with '_' ("<gateway>_sub", "<gateway>_fault_clients", ...),
// so only the FQN test catches them, and without it the gateway
// advertises its own plumbing as diagnosable apps. Remote entities are
// skipped deliberately: a peer's helper nodes carry the same FQNs and are
// the peer's own filter's business.
return true;
}
// ROS 2 internal nodes use _ prefix convention
return !original_id.empty() && original_id[0] == '_';
Expand Down
13 changes: 13 additions & 0 deletions src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,13 @@ json FaultHandlers::merge_entity_freeze_frames(json env_data,
snap["topic"] = ""; // entity data values, not a ROS topic
snap["message_type"] = "";
snap["captured_at_ns"] = frame.captured_at_ns;
// Capture provenance. topic/message_type stay empty because these values
// are not a ROS message, which leaves "source" as the only field naming
// where the numbers came from - so carry it whenever the capture named a
// path.
if (!frame.source.empty()) {
snap["source"] = frame.source;
}
if (frame.startup_catchup) {
// Values were read at gateway start, not when the fault confirmed;
// absent marker = captured on the confirm edge.
Expand Down Expand Up @@ -344,6 +351,12 @@ dto::FaultDetail FaultHandlers::build_sovd_fault_response(const json & fault_jso
snap["x-medkit"]["capture_origin"] = s["capture_origin"];
}
// Entity-frame provenance (merge_entity_freeze_frames), only when known.
// "source" names the capture path (a plugin DataProvider or the
// plugin's x-plc-data route). A consumer reads it instead of the
// empty topic/message_type an entity frame necessarily carries.
if (s.contains("source") && s["source"].is_string()) {
snap["x-medkit"]["source"] = s["source"];
}
if (s.contains("connected") && s["connected"].is_boolean()) {
snap["x-medkit"]["connected"] = s["connected"];
}
Expand Down
48 changes: 48 additions & 0 deletions src/ros2_medkit_gateway/test/test_entity_freeze_frame_capture.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,15 @@ TEST_F(EntityFreezeFrameCaptureTest, DisconnectedEntityWithLastKnownValuesIsCapt
ASSERT_TRUE(frames[0].connected.has_value());
EXPECT_FALSE(*frames[0].connected);
EXPECT_EQ(frames[0].source_timestamp, 1234567890);
// Which path read the values. This capture had no DataProvider and went
// through the route fallback, so the frame must name that path. The
// DataProvider flavour of the same case asserts the other constant, which is
// what stops the two from being swapped at their call sites unnoticed.
EXPECT_EQ(frames[0].source, EntityFreezeFrameCapture::kSourceXPlcDataRoute);
// The wire value itself, not just the symbol: swapping what the two constants
// hold is an API break for every consumer of x-medkit.source, and comparing
// symbol against symbol would not see it.
EXPECT_EQ(frames[0].source, "plugin_x_plc_data_route");
}

/// @verifies REQ_INTEROP_088
Expand Down Expand Up @@ -642,6 +651,11 @@ TEST_F(EntityFreezeFrameCaptureTest, DisconnectedDataProviderWithLastKnownValues
ASSERT_TRUE(frames[0].connected.has_value());
EXPECT_FALSE(*frames[0].connected);
EXPECT_TRUE(frames[0].source_timestamp.is_null()); // provider content has no timestamp field
// The provider path names itself, and the route path (same case, above) names
// the other constant: the pair is what makes a swap of the two call sites
// visible. The literal pins the wire value the API reference documents.
EXPECT_EQ(frames[0].source, EntityFreezeFrameCapture::kSourceDataProvider);
EXPECT_EQ(frames[0].source, "plugin_data_provider");
}

/// @verifies REQ_INTEROP_088
Expand Down Expand Up @@ -787,6 +801,40 @@ TEST(MergeEntityFreezeFrames, AppendsWhenNoConfiguredFreezeFrame) {
EXPECT_FALSE(snap.contains("capture_origin")); // confirm-edge frames carry no marker
}

TEST(MergeEntityFreezeFrames, CarriesCapturePathAsSource) {
// An entity frame has no ROS topic, so topic/message_type are necessarily
// empty, so "source" is the only field left saying where they came from.
json env_data = {{"snapshots", json::array()}};
EntityFreezeFrameCapture::Frame frame;
frame.entity_id = "plc_app";
frame.values = {{"temperature", 42.5}};
frame.captured_at_ns = 1234;
frame.source = EntityFreezeFrameCapture::kSourceXPlcDataRoute;

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["source"], EntityFreezeFrameCapture::kSourceXPlcDataRoute);
EXPECT_EQ(snap["topic"], "");
EXPECT_EQ(snap["message_type"], "");
}

TEST(MergeEntityFreezeFrames, OmitsSourceForAFrameThatNamesNoPath) {
// A merge-helper contract, not a control for the capture tests: both capture
// paths always name themselves (asserted from real captures in
// Disconnected{Entity,DataProvider}WithLastKnownValuesIsCaptured), so this
// frame is one only a caller can build. The helper must then leave the key
// out rather than invent a provenance the wire consumer would trust.
json env_data = {{"snapshots", json::array()}};
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_FALSE(merged["snapshots"][0].contains("source"));
}

TEST(MergeEntityFreezeFrames, StartupCatchUpFrameCarriesCaptureOrigin) {
json env_data = {{"snapshots", json::array()}};
EntityFreezeFrameCapture::Frame frame;
Expand Down
Loading
Loading