Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -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 <cstdint>
#include <optional>
#include <string>
#include <vector>

#include <nlohmann/json.hpp>

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<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;
};

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<Frame> 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
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class UpdateManager;
class LogManager;
class BulkDataStore;
class FaultTriggerEngine;
class EntityFreezeFrameCapture;
class FreezeFrameSource;
class TopicDataProvider;
class TriggerManager;
class SubscriptionManager;
Expand Down Expand Up @@ -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;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
#include <nlohmann/json.hpp>

#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"
Expand Down Expand Up @@ -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<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
/// the entity is not plugin-owned). Called from the internal capture thread.
using DataProviderResolver = std::function<DataProvider *(const std::string & entity_id)>;
Expand Down Expand Up @@ -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<Frame> 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<Frame> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
#include <vector>

#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"
Expand Down Expand Up @@ -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<EntityFreezeFrameCapture::Frame> & frames);
const std::vector<FreezeFrameSource::Frame> & frames);

private:
HandlerContext & ctx_;
Expand Down
1 change: 1 addition & 0 deletions src/ros2_medkit_gateway/scripts/check_core_purity.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
80 changes: 80 additions & 0 deletions src/ros2_medkit_gateway/src/core/faults/freeze_frame_source.cpp
Original file line number Diff line number Diff line change
@@ -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>() : 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>();
}

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
57 changes: 0 additions & 57 deletions src/ros2_medkit_gateway/src/entity_freeze_frame_capture.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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>() : std::string();
}

} // namespace

EntityFreezeFrameCapture::EntityFreezeFrameCapture(rclcpp::Node * node, ros2_common::Ros2SubscriptionExecutor & exec,
Expand Down Expand Up @@ -104,56 +97,6 @@ EntityFreezeFrameCapture::frames_for(const std::string & fault_code) const {
return it != frames_.end() ? it->second : std::vector<Frame>{};
}

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>();
}

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<std::vector<EntityFreezeFrameCapture::StandingFault>>
EntityFreezeFrameCapture::standing_faults_from_list_reply(const nlohmann::json & data) {
if (!data.is_object()) {
Expand Down
Loading
Loading