diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/faults/freeze_frame_source.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/faults/freeze_frame_source.hpp new file mode 100644 index 000000000..3ca53cdad --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/faults/freeze_frame_source.hpp @@ -0,0 +1,94 @@ +// 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 + +namespace ros2_medkit_gateway { + +/// Read side of the entity freeze-frames served on fault detail and trigger +/// values. Implementations must be thread-safe. +class FreezeFrameSource { + 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. + struct Frame { + std::string entity_id; + nlohmann::json values; ///< compact {resource_id: value} dict + int64_t captured_at_ns{0}; + /// True when the frame comes from the startup catch-up: captured_at_ns is + /// then the gateway's start, possibly long after the fault confirmed. + bool startup_catchup{false}; + std::optional 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; + }; + + FreezeFrameSource() = default; + virtual ~FreezeFrameSource() = default; + FreezeFrameSource(const FreezeFrameSource &) = delete; + FreezeFrameSource & operator=(const FreezeFrameSource &) = delete; + FreezeFrameSource(FreezeFrameSource &&) = delete; + FreezeFrameSource & operator=(FreezeFrameSource &&) = delete; + + /// Frames captured for a fault code (empty when none). Thread-safe. + virtual std::vector frames_for(const std::string & fault_code) const = 0; + + /// 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). 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 list-data-shaped content (DataProvider::list_data or x-plc-data + /// route) carries values at all: a non-empty items array. The entity's + /// `connected` flag is deliberately not consulted - a bridge serving its last + /// known values while the link is down is the case a loss-of-comms fault most + /// needs frozen. The row of nulls a cold cache yields is rejected by + /// values_have_data() instead. + static bool content_has_live_data(const nlohmann::json & content); + + /// True when the payload's top-level `connected` flag reports the link down. + /// Consulted only by the gateway's fault-trigger value fetcher: a down link + /// serves frozen last-known values, and a threshold rule must hold state on + /// them (fetcher yields nullopt) instead of firing on a stale number for the + /// whole outage. The freeze-frame paths deliberately ignore this flag. + static bool content_reports_disconnected(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); +}; + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/gateway_services.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/gateway_services.hpp index 158d6c5eb..d017cb5d1 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/gateway_services.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/gateway_services.hpp @@ -31,7 +31,7 @@ class UpdateManager; class LogManager; class BulkDataStore; class FaultTriggerEngine; -class EntityFreezeFrameCapture; +class FreezeFrameSource; class TopicDataProvider; class TriggerManager; class SubscriptionManager; @@ -78,7 +78,7 @@ class GatewayServices { virtual LogManager * log_manager() = 0; virtual BulkDataStore * bulk_data_store() = 0; virtual FaultTriggerEngine * fault_trigger_engine() = 0; - virtual EntityFreezeFrameCapture * freeze_frames() = 0; + virtual FreezeFrameSource * freeze_frames() = 0; virtual TopicDataProvider * topic_data_provider() = 0; virtual TriggerManager * trigger_manager() = 0; }; 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 d3c041ee4..7f83656f8 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 @@ -31,6 +31,7 @@ #include #include "rclcpp/rclcpp.hpp" +#include "ros2_medkit_gateway/core/faults/freeze_frame_source.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" @@ -58,36 +59,8 @@ namespace ros2_medkit_gateway { * 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 { +class EntityFreezeFrameCapture : public FreezeFrameSource { 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. - struct Frame { - std::string entity_id; - nlohmann::json values; ///< compact {resource_id: value} dict - int64_t captured_at_ns{0}; - /// True when the frame comes from the startup catch-up: captured_at_ns is - /// then the gateway's start, possibly long after the fault confirmed. - bool startup_catchup{false}; - std::optional 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 /// the entity is not plugin-owned). Called from the internal capture thread. using DataProviderResolver = std::function; @@ -137,33 +110,7 @@ class EntityFreezeFrameCapture { 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). 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 list-data-shaped content (DataProvider::list_data or x-plc-data - /// route) carries values at all: a non-empty items array. The entity's - /// `connected` flag is deliberately not consulted - a bridge serving its last - /// known values while the link is down is the case a loss-of-comms fault most - /// needs frozen. The row of nulls a cold cache yields is rejected by - /// values_have_data() instead. - static bool content_has_live_data(const nlohmann::json & content); - - /// True when the payload's top-level `connected` flag reports the link down. - /// Consulted only by the gateway's fault-trigger value fetcher: a down link - /// serves frozen last-known values, and a threshold rule must hold state on - /// them (fetcher yields nullopt) instead of firing on a stale number for the - /// whole outage. The freeze-frame paths deliberately ignore this flag. - static bool content_reports_disconnected(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); + std::vector frames_for(const std::string & fault_code) const override; /// Parse a fault-transport ListFaults reply body into standing faults. /// Returns nullopt when the body is not shaped like a ListFaults answer (no 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 f11b29628..c7f5f9e07 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 @@ -131,7 +131,7 @@ class GatewayNode : public rclcpp::Node, public GatewayServices { FaultTriggerEngine * fault_trigger_engine() override { return get_fault_trigger_engine(); } - EntityFreezeFrameCapture * freeze_frames() override { + FreezeFrameSource * freeze_frames() override { return get_entity_freeze_frame_capture(); } TopicDataProvider * topic_data_provider() override { 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 e7667f229..bc4727104 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 @@ -22,8 +22,8 @@ #include #include "ros2_medkit_gateway/core/faults/fault_types.hpp" +#include "ros2_medkit_gateway/core/faults/freeze_frame_source.hpp" #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" @@ -217,7 +217,7 @@ class FaultHandlers { * 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); + const std::vector & frames); private: HandlerContext & ctx_; diff --git a/src/ros2_medkit_gateway/scripts/check_core_purity.sh b/src/ros2_medkit_gateway/scripts/check_core_purity.sh index 4c917fc57..cd9d301e8 100755 --- a/src/ros2_medkit_gateway/scripts/check_core_purity.sh +++ b/src/ros2_medkit_gateway/scripts/check_core_purity.sh @@ -52,6 +52,7 @@ done ROS_FREE_SOURCES=( "include/ros2_medkit_gateway/core/gateway_services.hpp" "include/ros2_medkit_gateway/core/discovery/entity_discovery.hpp" + "include/ros2_medkit_gateway/core/faults/freeze_frame_source.hpp" "include/ros2_medkit_gateway/core/discovery/linking_result.hpp" "include/ros2_medkit_gateway/core/types/type_schema_provider.hpp" "include/ros2_medkit_gateway/http/handlers/handler_context.hpp" diff --git a/src/ros2_medkit_gateway/src/core/faults/freeze_frame_source.cpp b/src/ros2_medkit_gateway/src/core/faults/freeze_frame_source.cpp new file mode 100644 index 000000000..3f1dac695 --- /dev/null +++ b/src/ros2_medkit_gateway/src/core/faults/freeze_frame_source.cpp @@ -0,0 +1,80 @@ +// 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/core/faults/freeze_frame_source.hpp" + +namespace ros2_medkit_gateway { + +namespace { + +/// 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 + +nlohmann::json FreezeFrameSource::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 = string_field(item, "id"); + if (key.empty()) { + key = string_field(item, "name"); + } + if (key.empty()) { + continue; + } + values[key] = item.contains("value") ? item["value"] : nlohmann::json{}; + } + return values; +} + +bool FreezeFrameSource::content_has_live_data(const nlohmann::json & content) { + if (!content.is_object()) { + return false; + } + // Judged on the values, not on the link flag. A bridge that serves its last + // known values while disconnected is exactly what a loss-of-comms fault wants + // frozen - refusing on `connected: false` denied a frame to the one fault + // where the values before the link died are the whole story. The all-null row + // a genuinely cold cache yields is still rejected, by values_have_data(). + return content.contains("items") && content["items"].is_array() && !content["items"].empty(); +} + +bool FreezeFrameSource::content_reports_disconnected(const nlohmann::json & content) { + return content.is_object() && content.contains("connected") && content["connected"].is_boolean() && + !content["connected"].get(); +} + +bool FreezeFrameSource::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(); +} + +} // 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 01bde9674..0a57698c2 100644 --- a/src/ros2_medkit_gateway/src/entity_freeze_frame_capture.cpp +++ b/src/ros2_medkit_gateway/src/entity_freeze_frame_capture.cpp @@ -34,13 +34,6 @@ constexpr size_t kMaxLoggedFaultCodes = 1024; /// the standing-fault snapshot; past it the catch-up proceeds best-effort. constexpr std::chrono::seconds kEventsMatchTimeout{10}; -/// 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, @@ -104,56 +97,6 @@ EntityFreezeFrameCapture::frames_for(const std::string & fault_code) const { 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 = string_field(item, "id"); - if (key.empty()) { - key = string_field(item, "name"); - } - if (key.empty()) { - continue; - } - values[key] = item.contains("value") ? item["value"] : nlohmann::json{}; - } - return values; -} - -bool EntityFreezeFrameCapture::content_has_live_data(const nlohmann::json & content) { - if (!content.is_object()) { - return false; - } - // Judged on the values, not on the link flag. A bridge that serves its last - // known values while disconnected is exactly what a loss-of-comms fault wants - // frozen - refusing on `connected: false` denied a frame to the one fault - // where the values before the link died are the whole story. The all-null row - // a genuinely cold cache yields is still rejected, by values_have_data(). - return content.contains("items") && content["items"].is_array() && !content["items"].empty(); -} - -bool EntityFreezeFrameCapture::content_reports_disconnected(const nlohmann::json & content) { - return content.is_object() && content.contains("connected") && content["connected"].is_boolean() && - !content["connected"].get(); -} - -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::standing_faults_from_list_reply(const nlohmann::json & data) { if (!data.is_object()) { 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 ce5423121..0c8d80939 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp @@ -29,6 +29,7 @@ #include "ros2_medkit_gateway/aggregation/aggregation_manager.hpp" #include "ros2_medkit_gateway/core/faults/fault_scope.hpp" +#include "ros2_medkit_gateway/core/faults/freeze_frame_source.hpp" #include "ros2_medkit_gateway/core/http/entity_path_utils.hpp" #include "ros2_medkit_gateway/core/http/error_codes.hpp" #include "ros2_medkit_gateway/core/http/fan_out_helpers.hpp" @@ -40,7 +41,6 @@ #include "ros2_medkit_gateway/core/providers/fault_provider.hpp" #include "ros2_medkit_gateway/dto/faults.hpp" #include "ros2_medkit_gateway/dto/json_writer.hpp" -#include "ros2_medkit_gateway/entity_freeze_frame_capture.hpp" #include "ros2_medkit_gateway/http/handlers/handler_support.hpp" namespace ros2_medkit_gateway { @@ -240,8 +240,7 @@ bool FaultHandlers::fault_in_source_scope(const json & fault, const std::set & frames) { +json FaultHandlers::merge_entity_freeze_frames(json env_data, const std::vector & frames) { if (frames.empty()) { return env_data; } diff --git a/src/ros2_medkit_gateway/src/http/handlers/trigger_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/trigger_handlers.cpp index 9d5996ee1..3716ddb66 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/trigger_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/trigger_handlers.cpp @@ -27,13 +27,13 @@ #include #include "ros2_medkit_gateway/core/data_point_suggest.hpp" +#include "ros2_medkit_gateway/core/faults/freeze_frame_source.hpp" #include "ros2_medkit_gateway/core/http/error_codes.hpp" #include "ros2_medkit_gateway/core/http/http_utils.hpp" #include "ros2_medkit_gateway/core/models/entity_types.hpp" #include "ros2_medkit_gateway/core/models/thread_safe_entity_cache.hpp" #include "ros2_medkit_gateway/core/plugins/plugin_manager.hpp" #include "ros2_medkit_gateway/core/providers/data_provider.hpp" -#include "ros2_medkit_gateway/entity_freeze_frame_capture.hpp" #include "ros2_medkit_gateway/http/handlers/handler_support.hpp" using json = nlohmann::json; @@ -261,8 +261,8 @@ TriggerHandlers::post_trigger(const http::TypedRequest & req, dto::TriggerCreate // a bridge with a dead PLC link answers 200 with connected=false and an // empty list, which must read as "unreadable right now", never as "this // entity has zero data points". - if (content && EntityFreezeFrameCapture::content_has_live_data(*content)) { - const auto values = EntityFreezeFrameCapture::values_from_list_content(*content); + if (content && FreezeFrameSource::content_has_live_data(*content)) { + const auto values = FreezeFrameSource::values_from_list_content(*content); if (values.is_object()) { std::vector names; names.reserve(values.size()); diff --git a/src/ros2_medkit_gateway/test/null_gateway_services.hpp b/src/ros2_medkit_gateway/test/null_gateway_services.hpp index 26d4aef0a..c69bf8422 100644 --- a/src/ros2_medkit_gateway/test/null_gateway_services.hpp +++ b/src/ros2_medkit_gateway/test/null_gateway_services.hpp @@ -65,7 +65,7 @@ class NullGatewayServices final : public GatewayServices { FaultTriggerEngine * fault_trigger_engine() override { return nullptr; } - EntityFreezeFrameCapture * freeze_frames() override { + FreezeFrameSource * freeze_frames() override { return nullptr; } TopicDataProvider * topic_data_provider() override {