diff --git a/scripts/check_no_naked_subscriptions.sh b/scripts/check_no_naked_subscriptions.sh index cad6f346d..0215dabeb 100755 --- a/scripts/check_no_naked_subscriptions.sh +++ b/scripts/check_no_naked_subscriptions.sh @@ -45,7 +45,7 @@ ALLOWED_DIRS_PATTERN="(${GATEWAY_ROOT}/src/ros2_common/|${GATEWAY_ROOT}/include/ # Explicit files that still use the naked APIs pending provider migration. # Keep this list SHORT and add a TODO in the file referencing the follow-up. ALLOWED_LEGACY_FILES=( - "${GATEWAY_ROOT}/src/http/handlers/sse_fault_handler.cpp" # faults provider follow-up + "${GATEWAY_ROOT}/src/ros2/ros2_fault_event_source.cpp" # faults provider follow-up "${GATEWAY_ROOT}/src/trigger_fault_subscriber.cpp" # faults provider follow-up "${GATEWAY_ROOT}/src/ros2/transports/ros2_action_transport.cpp" # operations provider follow-up "${GATEWAY_ROOT}/src/ros2/transports/ros2_log_source.cpp" # /rosout adapter (LogSource port) diff --git a/src/ros2_medkit_gateway/CMakeLists.txt b/src/ros2_medkit_gateway/CMakeLists.txt index 8f1d10840..1dbddd18b 100644 --- a/src/ros2_medkit_gateway/CMakeLists.txt +++ b/src/ros2_medkit_gateway/CMakeLists.txt @@ -36,6 +36,7 @@ find_package(ament_index_cpp REQUIRED) find_package(action_msgs REQUIRED) find_package(lifecycle_msgs REQUIRED) find_package(ros2_medkit_msgs REQUIRED) +find_package(ros2_medkit_fault_detection REQUIRED) find_package(ros2_medkit_serialization REQUIRED) find_package(SQLite3 REQUIRED) @@ -116,6 +117,7 @@ target_link_libraries(gateway_core OpenSSL::SSL OpenSSL::Crypto tl::expected + ros2_medkit_fault_detection::ros2_medkit_fault_detection jwt-cpp::jwt-cpp SQLite::SQLite3 ${CMAKE_DL_LIBS} @@ -166,6 +168,7 @@ add_library(gateway_ros2 STATIC src/ros2/transports/ros2_topic_subscription_transport.cpp src/ros2/transports/ros2_topic_transport.cpp src/ros2/status/ros2_lifecycle_state_reader.cpp + src/ros2/ros2_fault_event_source.cpp src/ros2/ros2_log_sink.cpp src/ros2/ros2_type_schema_provider.cpp src/ros2/trigger_topic_subscriber.cpp diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/config.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/config.hpp index 67228c050..98f7aa1fd 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/config.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/config.hpp @@ -29,7 +29,6 @@ struct RestConfig { bool docs_enabled{true}; std::string server_host{"localhost"}; int64_t server_port{8080}; - std::string fault_events_topic; /// Logger name the REST server reports its warnings under. std::string logger_name{"rest_server"}; }; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/faults/fault_event_source.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/faults/fault_event_source.hpp new file mode 100644 index 000000000..6d5e9ee17 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/faults/fault_event_source.hpp @@ -0,0 +1,44 @@ +// Copyright 2026 bburda +// +// 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 "ros2_medkit_fault_detection/fault_types.hpp" + +namespace ros2_medkit_gateway { + +/// Push side of the fault stream: delivers each fault event the fault manager +/// publishes. Implementations must be thread-safe. +class FaultEventSource { + public: + using Callback = std::function; + + FaultEventSource() = default; + virtual ~FaultEventSource() = default; + FaultEventSource(const FaultEventSource &) = delete; + FaultEventSource & operator=(const FaultEventSource &) = delete; + FaultEventSource(FaultEventSource &&) = delete; + FaultEventSource & operator=(FaultEventSource &&) = delete; + + /// Start delivering events to `callback`; replaces any earlier callback. + /// The callback may run on any thread. + virtual void subscribe(Callback callback) = 0; + + /// Stop delivery. Returns only when no callback is running any more. + virtual void unsubscribe() = 0; +}; + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/faults/fault_json.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/faults/fault_json.hpp new file mode 100644 index 000000000..253b54967 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/faults/fault_json.hpp @@ -0,0 +1,27 @@ +// Copyright 2026 bburda +// +// 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 "ros2_medkit_fault_detection/fault_types.hpp" + +namespace ros2_medkit_gateway { + +/// Fault JSON object served by /faults and the fault stream. Times are seconds +/// as doubles; ``last_passed`` is present only when set. +nlohmann::json fault_record_to_json(const ros2_medkit::fault_detection::FaultRecord & fault); + +} // 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 d017cb5d1..8334c5300 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,6 +31,7 @@ class UpdateManager; class LogManager; class BulkDataStore; class FaultTriggerEngine; +class FaultEventSource; class FreezeFrameSource; class TopicDataProvider; class TriggerManager; @@ -79,6 +80,8 @@ class GatewayServices { virtual BulkDataStore * bulk_data_store() = 0; virtual FaultTriggerEngine * fault_trigger_engine() = 0; virtual FreezeFrameSource * freeze_frames() = 0; + // May be null: a host without a fault manager has no fault stream. + virtual FaultEventSource * fault_events() = 0; virtual TopicDataProvider * topic_data_provider() = 0; virtual TriggerManager * trigger_manager() = 0; }; 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 c7f5f9e07..51fce11a8 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 @@ -57,6 +57,7 @@ #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/ros2_fault_event_source.hpp" #include "ros2_medkit_gateway/ros2/ros2_type_schema_provider.hpp" #include "ros2_medkit_gateway/ros2/transports/ros2_action_transport.hpp" #include "ros2_medkit_gateway/ros2/transports/ros2_fault_service_transport.hpp" @@ -134,6 +135,9 @@ class GatewayNode : public rclcpp::Node, public GatewayServices { FreezeFrameSource * freeze_frames() override { return get_entity_freeze_frame_capture(); } + FaultEventSource * fault_events() override { + return fault_event_source_.get(); + } TopicDataProvider * topic_data_provider() override { return get_topic_data_provider(); } @@ -495,6 +499,8 @@ class GatewayNode : public rclcpp::Node, public GatewayServices { std::unique_ptr trigger_fault_subscriber_; // Zero-config freeze-frames for plugin-backed entities (nullptr when disabled) std::unique_ptr entity_freeze_frame_capture_; + // Destroyed after rest_server_, whose SSE fault handler holds a pointer to it. + std::unique_ptr fault_event_source_; // Config-less threshold-rule engine + its dedicated evaluation loop (issue // #235). The thread is joined in ~GatewayNode BEFORE plugin/fault shutdown. std::unique_ptr fault_trigger_engine_; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/sse_fault_handler.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/sse_fault_handler.hpp index 18e325f61..85422a0b0 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/sse_fault_handler.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/sse_fault_handler.hpp @@ -28,13 +28,13 @@ #include -#include "rclcpp/rclcpp.hpp" +#include "ros2_medkit_fault_detection/fault_types.hpp" #include "ros2_medkit_gateway/core/aggregation/peer_fault_relay.hpp" +#include "ros2_medkit_gateway/core/faults/fault_event_source.hpp" #include "ros2_medkit_gateway/core/http/sse_client_tracker.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" -#include "ros2_medkit_msgs/msg/fault_event.hpp" namespace ros2_medkit_gateway { namespace handlers { @@ -81,19 +81,19 @@ class SSEFaultHandler { * @brief Construct SSE fault handler with shared context. * @param ctx The shared handler context * @param client_tracker Shared SSE client counter (across all SSE handlers) - * @param fault_events_topic Fully qualified topic the FaultManager publishes fault events on + * + * Fault events come from ``ctx.services().fault_events()``; with no source + * the stream stays open and silent. */ - SSEFaultHandler(HandlerContext & ctx, std::shared_ptr client_tracker, - const std::string & fault_events_topic); + SSEFaultHandler(HandlerContext & ctx, std::shared_ptr client_tracker); /** * @brief Test-only constructor that overrides the keepalive interval. * @param ctx The shared handler context * @param client_tracker Shared SSE client counter (across all SSE handlers) * @param keepalive_interval Must be positive; non-positive values fall back to the 30s default. - * @param fault_events_topic Fully qualified topic the FaultManager publishes fault events on */ SSEFaultHandler(HandlerContext & ctx, std::shared_ptr client_tracker, - std::chrono::milliseconds keepalive_interval, const std::string & fault_events_topic); + std::chrono::milliseconds keepalive_interval); /// Destructor. Signals shutdown, then blocks until every stream closure has /// been destroyed: the per-stream unregister deleter locks queue_mutex_ and @@ -205,8 +205,8 @@ class SSEFaultHandler { /// without it a chain would relay the same event back round the loop. std::function make_stream_loop(uint64_t initial_last_event_id, bool relay_peers); - /// Callback for fault events from ROS 2 topic - void on_fault_event(const ros2_medkit_msgs::msg::FaultEvent::ConstSharedPtr & msg); + /// Callback for events from the fault event source + void on_fault_event(const ros2_medkit::fault_detection::FaultEventRecord & event); /// Callback for an event relayed from a peer's stream. Runs on that peer /// proxy's reader thread. @@ -224,7 +224,7 @@ class SSEFaultHandler { /// flip the entity reported to consumers. struct QueuedEvent { uint64_t id; - ros2_medkit_msgs::msg::FaultEvent event; + ros2_medkit::fault_detection::FaultEventRecord event; std::optional entity; /// Name of the peer this event was relayed from; empty when this gateway /// saw the fault on its own graph. Part of the supersede key, because the @@ -291,13 +291,13 @@ class SSEFaultHandler { /// ros2_runtime_introspection.cpp's renaming rule. Only emits a value when /// an App with the resolved id actually exists in the cache - returns /// ``std::nullopt`` otherwise so the consumer falls back to discovery. - std::optional resolve_entity_context(const ros2_medkit_msgs::msg::Fault & fault) const; + std::optional resolve_entity_context(const ros2_medkit::fault_detection::FaultRecord & fault) const; HandlerContext & ctx_; std::shared_ptr client_tracker_; - /// Subscription to fault events topic - rclcpp::Subscription::SharedPtr subscription_; + /// Fault event source; null on a host without a fault manager. + FaultEventSource * fault_source_{nullptr}; /// Relays the peers' fault streams into this one on an aggregating gateway. /// Always constructed; it opens nothing when this gateway has no peers. diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/ros2/conversions/fault_msg_conversions.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/ros2/conversions/fault_msg_conversions.hpp index 2869f394f..a4ec46ff3 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/ros2/conversions/fault_msg_conversions.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/ros2/conversions/fault_msg_conversions.hpp @@ -16,23 +16,18 @@ #include +#include "ros2_medkit_fault_detection/fault_types.hpp" #include "ros2_medkit_msgs/msg/environment_data.hpp" #include "ros2_medkit_msgs/msg/fault.hpp" +#include "ros2_medkit_msgs/msg/fault_event.hpp" namespace ros2_medkit_gateway::ros2::conversions { -/// Convert a ros2_medkit_msgs::msg::Fault to JSON. -/// -/// Produces the flat per-fault representation consumed by `list_faults` items, -/// the SSE fault-event payload, and the trigger subsystem's notifier change -/// values. Timestamps become seconds-as-double; severity gains a label. -/// -/// Lives at the ROS-coupled boundary because three independent call sites -/// each translate `ros2_medkit_msgs::msg::Fault` directly into JSON: the -/// fault-service transport adapter, the SSE fault handler (subscribes to the -/// FaultEvent topic directly), and the trigger fault subscriber. Keeping the -/// helper free standing avoids code duplication while preserving the neutral -/// FaultManager contract. +/// Message to neutral record; times become nanoseconds since the epoch. +ros2_medkit::fault_detection::FaultRecord from_msg(const ros2_medkit_msgs::msg::Fault & msg); +ros2_medkit::fault_detection::FaultEventRecord from_msg(const ros2_medkit_msgs::msg::FaultEvent & msg); + +/// Fault message to JSON through fault_record_to_json. nlohmann::json fault_to_json(const ros2_medkit_msgs::msg::Fault & fault); /// Convert a ros2_medkit_msgs::msg::EnvironmentData to JSON. diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/ros2/ros2_fault_event_source.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/ros2/ros2_fault_event_source.hpp new file mode 100644 index 000000000..6c6202eb9 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/ros2/ros2_fault_event_source.hpp @@ -0,0 +1,53 @@ +// Copyright 2026 bburda +// +// 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 "rclcpp/rclcpp.hpp" +#include "ros2_medkit_gateway/core/faults/fault_event_source.hpp" +#include "ros2_medkit_msgs/msg/fault_event.hpp" + +namespace ros2_medkit_gateway { + +/// Fault events from the fault manager's events topic. +class Ros2FaultEventSource final : public FaultEventSource { + public: + /// @param node Node the subscription lives on; must outlive this object. + /// @param topic Fully qualified events topic. + Ros2FaultEventSource(rclcpp::Node & node, std::string topic); + ~Ros2FaultEventSource() override; + Ros2FaultEventSource(const Ros2FaultEventSource &) = delete; + Ros2FaultEventSource & operator=(const Ros2FaultEventSource &) = delete; + Ros2FaultEventSource(Ros2FaultEventSource &&) = delete; + Ros2FaultEventSource & operator=(Ros2FaultEventSource &&) = delete; + + void subscribe(Callback callback) override; + void unsubscribe() override; + + private: + void on_message(const ros2_medkit_msgs::msg::FaultEvent & msg); + + rclcpp::Node & node_; + std::string topic_; + /// Held while a callback runs, so unsubscribe() waits for it. + std::mutex callback_mutex_; + Callback callback_; + rclcpp::Subscription::SharedPtr subscription_; +}; + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/package.xml b/src/ros2_medkit_gateway/package.xml index 47d9c3479..dd4d5661f 100644 --- a/src/ros2_medkit_gateway/package.xml +++ b/src/ros2_medkit_gateway/package.xml @@ -28,6 +28,7 @@ ros2_medkit_msgs ros2_medkit_serialization libsqlite3-dev + ros2_medkit_fault_detection rosidl_typesupport_cpp rosidl_typesupport_introspection_cpp rosidl_runtime_py diff --git a/src/ros2_medkit_gateway/scripts/check_core_purity.sh b/src/ros2_medkit_gateway/scripts/check_core_purity.sh index cd9d301e8..c930da6ef 100755 --- a/src/ros2_medkit_gateway/scripts/check_core_purity.sh +++ b/src/ros2_medkit_gateway/scripts/check_core_purity.sh @@ -53,6 +53,9 @@ 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/faults/fault_event_source.hpp" + "include/ros2_medkit_gateway/core/faults/fault_json.hpp" + "src/core/faults/fault_json.cpp" "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" @@ -65,7 +68,7 @@ for src in "${ROS_FREE_SOURCES[@]}"; do continue fi if ! out=$(g++ -std=c++17 -fsyntax-only -Wno-pragma-once-outside-header \ - -Iinclude -Isrc -Isrc/vendored/tl_expected/include -Isrc/vendored/cpp_httplib "$src" 2>&1); then + -Iinclude -I../ros2_medkit_fault_detection/include -Isrc -Isrc/vendored/tl_expected/include -Isrc/vendored/cpp_httplib "$src" 2>&1); then echo "DOES NOT COMPILE WITHOUT ROS: $src" echo "$out" | grep -E 'error' | head -5 violations=$((violations + 1)) diff --git a/src/ros2_medkit_gateway/src/core/faults/fault_json.cpp b/src/ros2_medkit_gateway/src/core/faults/fault_json.cpp new file mode 100644 index 000000000..794170bd2 --- /dev/null +++ b/src/ros2_medkit_gateway/src/core/faults/fault_json.cpp @@ -0,0 +1,74 @@ +// Copyright 2026 bburda +// +// 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/fault_json.hpp" + +namespace ros2_medkit_gateway { + +namespace fd = ros2_medkit::fault_detection; + +namespace { + +/// Seconds as sec + nanosec / 1e9 with the time normalised so nanosec is +/// in [0, 1e9), the form builtin_interfaces/Time carries. +double to_seconds(int64_t ns) { + constexpr int64_t kNsPerSec = 1000000000LL; + int64_t sec = ns / kNsPerSec; + int64_t rem = ns % kNsPerSec; + if (rem < 0) { + --sec; + rem += kNsPerSec; + } + return static_cast(sec) + static_cast(rem) / 1e9; +} + +} // namespace + +nlohmann::json fault_record_to_json(const fd::FaultRecord & fault) { + nlohmann::json j; + j["fault_code"] = fault.fault_code; + j["severity"] = fault.severity; + j["description"] = fault.description; + j["first_occurred"] = to_seconds(fault.first_occurred_ns); + j["last_occurred"] = to_seconds(fault.last_occurred_ns); + // Omitted while zero: the fault never reported a PASSED event. + if (fault.last_passed_ns != 0) { + j["last_passed"] = to_seconds(fault.last_passed_ns); + } + j["occurrence_count"] = fault.occurrence_count; + j["status"] = fault.status; + j["reporting_sources"] = fault.reporting_sources; + + switch (fault.severity) { + case fd::severity::kInfo: + j["severity_label"] = "INFO"; + break; + case fd::severity::kWarn: + j["severity_label"] = "WARN"; + break; + case fd::severity::kError: + j["severity_label"] = "ERROR"; + break; + case fd::severity::kCritical: + j["severity_label"] = "CRITICAL"; + break; + default: + j["severity_label"] = "UNKNOWN"; + break; + } + + return j; +} + +} // 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 d80c162a0..cb546c81e 100644 --- a/src/ros2_medkit_gateway/src/gateway_node.cpp +++ b/src/ros2_medkit_gateway/src/gateway_node.cpp @@ -1581,7 +1581,7 @@ GatewayNode::GatewayNode(const rclcpp::NodeOptions & options) : Node("ros2_medki rest_config.docs_enabled = get_parameter("docs.enabled").as_bool(); rest_config.server_host = server_host_; rest_config.server_port = server_port_; - rest_config.fault_events_topic = build_fault_manager_events_topic(this); + fault_event_source_ = std::make_unique(*this, build_fault_manager_events_topic(this)); rest_config.logger_name = get_logger().get_name(); rest_server_ = diff --git a/src/ros2_medkit_gateway/src/http/handlers/sse_fault_handler.cpp b/src/ros2_medkit_gateway/src/http/handlers/sse_fault_handler.cpp index b79518b59..66a4a9bbd 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/sse_fault_handler.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/sse_fault_handler.cpp @@ -29,16 +29,17 @@ #include #include "ros2_medkit_gateway/aggregation/aggregation_manager.hpp" +#include "ros2_medkit_gateway/core/faults/fault_json.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/error_info.hpp" -#include "ros2_medkit_gateway/gateway_node.hpp" #include "ros2_medkit_gateway/http/detail/primitives.hpp" -#include "ros2_medkit_gateway/ros2/conversions/fault_msg_conversions.hpp" namespace ros2_medkit_gateway { namespace handlers { +namespace fd = ros2_medkit::fault_detection; + namespace { std::string sanitize_sse_event_type(std::string event_type) { @@ -49,13 +50,12 @@ std::string sanitize_sse_event_type(std::string event_type) { } // namespace -SSEFaultHandler::SSEFaultHandler(HandlerContext & ctx, std::shared_ptr client_tracker, - const std::string & fault_events_topic) - : SSEFaultHandler(ctx, std::move(client_tracker), std::chrono::seconds(kKeepaliveIntervalSec), fault_events_topic) { +SSEFaultHandler::SSEFaultHandler(HandlerContext & ctx, std::shared_ptr client_tracker) + : SSEFaultHandler(ctx, std::move(client_tracker), std::chrono::seconds(kKeepaliveIntervalSec)) { } SSEFaultHandler::SSEFaultHandler(HandlerContext & ctx, std::shared_ptr client_tracker, - std::chrono::milliseconds keepalive_interval, const std::string & fault_events_topic) + std::chrono::milliseconds keepalive_interval) : ctx_(ctx) , client_tracker_(std::move(client_tracker)) , keepalive_interval_(keepalive_interval > std::chrono::milliseconds::zero() @@ -67,13 +67,12 @@ SSEFaultHandler::SSEFaultHandler(HandlerContext & ctx, std::shared_ptrcreate_subscription( - fault_events_topic, rclcpp::QoS(100).reliable(), - [this](const ros2_medkit_msgs::msg::FaultEvent::ConstSharedPtr & msg) { - on_fault_event(msg); - }); + fault_source_ = ctx_.services().fault_events(); + if (fault_source_ != nullptr) { + fault_source_->subscribe([this](const fd::FaultEventRecord & event) { + on_fault_event(event); + }); + } // The aggregation manager is looked up per call rather than captured here: // it is wired onto the context during gateway start-up and this handler is @@ -103,14 +102,13 @@ SSEFaultHandler::SSEFaultHandler(HandlerContext & ctx, std::shared_ptrmax_clients()); + MEDKIT_LOG_INFO(HandlerContext::logger(), "SSE fault handler initialized, event source %s, max_clients=%zu", + fault_source_ != nullptr ? "attached" : "absent", client_tracker_->max_clients()); } SSEFaultHandler::~SSEFaultHandler() { // Signal shutdown and wake up any waiting clients request_shutdown(); - subscription_.reset(); // Before the queue-lock wait below: closing a peer stream joins its reader // thread, and that thread may be inside on_peer_event holding nothing but // wanting queue_mutex_. @@ -129,6 +127,9 @@ SSEFaultHandler::~SSEFaultHandler() { } void SSEFaultHandler::request_shutdown() { + if (fault_source_ != nullptr) { + fault_source_->unsubscribe(); + } if (peer_relay_) { peer_relay_->shutdown(); } @@ -167,7 +168,7 @@ std::deque::iterator SSEFaultHandler::find_superse if (!entry.auto_cleared_codes.empty()) { continue; } - if (updates_only && entry.event_type != ros2_medkit_msgs::msg::FaultEvent::EVENT_UPDATED) { + if (updates_only && entry.event_type != fd::fault_event_type::kUpdated) { continue; } if (newest_index[key] != i) { @@ -237,13 +238,13 @@ SSEFaultHandler::EvictionStats SSEFaultHandler::evict_to_capacity_locked() { return stats; } -void SSEFaultHandler::on_fault_event(const ros2_medkit_msgs::msg::FaultEvent::ConstSharedPtr & msg) { +void SSEFaultHandler::on_fault_event(const fd::FaultEventRecord & event) { // Snapshot entity context before acquiring the queue lock so cache state // is pinned to the fault arrival timestamp and the formatting path stays // lock-free with respect to the cache. - auto entity = resolve_entity_context(msg->fault); + auto entity = resolve_entity_context(event.fault); QueuedEvent queued; - queued.event = *msg; + queued.event = event; queued.entity = std::move(entity); enqueue(std::move(queued)); } @@ -654,11 +655,17 @@ std::string SSEFaultHandler::format_sse_event(const QueuedEvent & queued) { nlohmann::json json_event; json_event["event_type"] = sanitized_event_type; - json_event["fault"] = ros2::conversions::fault_to_json(queued.event.fault); + json_event["fault"] = fault_record_to_json(queued.event.fault); // Convert timestamp to seconds with nanosecond precision - double timestamp_sec = - static_cast(queued.event.timestamp.sec) + static_cast(queued.event.timestamp.nanosec) * 1e-9; + constexpr int64_t kNsPerSec = 1000000000LL; + int64_t timestamp_s = queued.event.timestamp_ns / kNsPerSec; + int64_t timestamp_rem = queued.event.timestamp_ns % kNsPerSec; + if (timestamp_rem < 0) { + --timestamp_s; + timestamp_rem += kNsPerSec; + } + double timestamp_sec = static_cast(timestamp_s) + static_cast(timestamp_rem) * 1e-9; json_event["timestamp"] = timestamp_sec; // Correlation payload: symptom codes auto-cleared with this event's root @@ -690,7 +697,7 @@ std::string SSEFaultHandler::format_sse_event(const QueuedEvent & queued) { } std::optional -SSEFaultHandler::resolve_entity_context(const ros2_medkit_msgs::msg::Fault & fault) const { +SSEFaultHandler::resolve_entity_context(const fd::FaultRecord & fault) const { if (fault.reporting_sources.empty()) { return std::nullopt; } diff --git a/src/ros2_medkit_gateway/src/http/rest_server.cpp b/src/ros2_medkit_gateway/src/http/rest_server.cpp index 8e22d8669..a64ae2275 100644 --- a/src/ros2_medkit_gateway/src/http/rest_server.cpp +++ b/src/ros2_medkit_gateway/src/http/rest_server.cpp @@ -211,8 +211,8 @@ RESTServer::RESTServer(GatewayServices & services, std::shared_ptr( - *handler_ctx_, sse_client_tracker_, std::chrono::seconds(keepalive_used), rest_config.fault_events_topic); + sse_fault_handler_ = std::make_unique(*handler_ctx_, sse_client_tracker_, + std::chrono::seconds(keepalive_used)); bulkdata_handlers_ = std::make_unique(*handler_ctx_); // Validated as int64 before narrowing: narrowing first wraps a value above // INT_MAX into a small positive one that passes the check, so 4294967300 diff --git a/src/ros2_medkit_gateway/src/ros2/conversions/fault_msg_conversions.cpp b/src/ros2_medkit_gateway/src/ros2/conversions/fault_msg_conversions.cpp index 9d6833187..c4903d201 100644 --- a/src/ros2_medkit_gateway/src/ros2/conversions/fault_msg_conversions.cpp +++ b/src/ros2_medkit_gateway/src/ros2/conversions/fault_msg_conversions.cpp @@ -16,52 +16,46 @@ #include +#include "ros2_medkit_gateway/core/faults/fault_json.hpp" #include "ros2_medkit_gateway/core/http/http_utils.hpp" namespace ros2_medkit_gateway::ros2::conversions { +namespace fd = ros2_medkit::fault_detection; + namespace { -double to_seconds(const builtin_interfaces::msg::Time & t) { - return t.sec + static_cast(t.nanosec) / 1e9; +int64_t time_to_ns(const builtin_interfaces::msg::Time & t) { + return static_cast(t.sec) * 1000000000LL + t.nanosec; } } // namespace -nlohmann::json fault_to_json(const ros2_medkit_msgs::msg::Fault & fault) { - nlohmann::json j; - j["fault_code"] = fault.fault_code; - j["severity"] = fault.severity; - j["description"] = fault.description; - j["first_occurred"] = to_seconds(fault.first_occurred); - j["last_occurred"] = to_seconds(fault.last_occurred); - // Omitted while zero: the fault never reported a PASSED event. - if (fault.last_passed.sec != 0 || fault.last_passed.nanosec != 0u) { - j["last_passed"] = to_seconds(fault.last_passed); - } - j["occurrence_count"] = fault.occurrence_count; - j["status"] = fault.status; - j["reporting_sources"] = fault.reporting_sources; - - switch (fault.severity) { - case ros2_medkit_msgs::msg::Fault::SEVERITY_INFO: - j["severity_label"] = "INFO"; - break; - case ros2_medkit_msgs::msg::Fault::SEVERITY_WARN: - j["severity_label"] = "WARN"; - break; - case ros2_medkit_msgs::msg::Fault::SEVERITY_ERROR: - j["severity_label"] = "ERROR"; - break; - case ros2_medkit_msgs::msg::Fault::SEVERITY_CRITICAL: - j["severity_label"] = "CRITICAL"; - break; - default: - j["severity_label"] = "UNKNOWN"; - break; - } +fd::FaultRecord from_msg(const ros2_medkit_msgs::msg::Fault & msg) { + fd::FaultRecord record; + record.fault_code = msg.fault_code; + record.severity = msg.severity; + record.description = msg.description; + record.first_occurred_ns = time_to_ns(msg.first_occurred); + record.last_occurred_ns = time_to_ns(msg.last_occurred); + record.last_passed_ns = time_to_ns(msg.last_passed); + record.occurrence_count = msg.occurrence_count; + record.status = msg.status; + record.reporting_sources = msg.reporting_sources; + return record; +} + +fd::FaultEventRecord from_msg(const ros2_medkit_msgs::msg::FaultEvent & msg) { + fd::FaultEventRecord record; + record.event_type = msg.event_type; + record.fault = from_msg(msg.fault); + record.timestamp_ns = time_to_ns(msg.timestamp); + record.auto_cleared_codes = msg.auto_cleared_codes; + return record; +} - return j; +nlohmann::json fault_to_json(const ros2_medkit_msgs::msg::Fault & fault) { + return fault_record_to_json(from_msg(fault)); } nlohmann::json environment_data_to_json(const ros2_medkit_msgs::msg::EnvironmentData & env_data) { diff --git a/src/ros2_medkit_gateway/src/ros2/ros2_fault_event_source.cpp b/src/ros2_medkit_gateway/src/ros2/ros2_fault_event_source.cpp new file mode 100644 index 000000000..241bac37e --- /dev/null +++ b/src/ros2_medkit_gateway/src/ros2/ros2_fault_event_source.cpp @@ -0,0 +1,60 @@ +// Copyright 2026 bburda +// +// 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/ros2/ros2_fault_event_source.hpp" + +#include + +#include "ros2_medkit_gateway/ros2/conversions/fault_msg_conversions.hpp" + +namespace ros2_medkit_gateway { + +Ros2FaultEventSource::Ros2FaultEventSource(rclcpp::Node & node, std::string topic) + : node_(node), topic_(std::move(topic)) { +} + +Ros2FaultEventSource::~Ros2FaultEventSource() { + unsubscribe(); +} + +void Ros2FaultEventSource::subscribe(Callback callback) { + std::lock_guard lock(callback_mutex_); + callback_ = std::move(callback); + if (!subscription_) { + subscription_ = node_.create_subscription( + topic_, rclcpp::QoS(100).reliable(), [this](const ros2_medkit_msgs::msg::FaultEvent::ConstSharedPtr & msg) { + on_message(*msg); + }); + } +} + +void Ros2FaultEventSource::unsubscribe() { + rclcpp::Subscription::SharedPtr dropped; + { + std::lock_guard lock(callback_mutex_); + callback_ = nullptr; + dropped = std::move(subscription_); + } + // Released outside the lock: the last reference may be dropped here. + dropped.reset(); +} + +void Ros2FaultEventSource::on_message(const ros2_medkit_msgs::msg::FaultEvent & msg) { + std::lock_guard lock(callback_mutex_); + if (callback_) { + callback_(ros2::conversions::from_msg(msg)); + } +} + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/test/null_gateway_services.hpp b/src/ros2_medkit_gateway/test/null_gateway_services.hpp index c69bf8422..43c915a98 100644 --- a/src/ros2_medkit_gateway/test/null_gateway_services.hpp +++ b/src/ros2_medkit_gateway/test/null_gateway_services.hpp @@ -68,6 +68,9 @@ class NullGatewayServices final : public GatewayServices { FreezeFrameSource * freeze_frames() override { return nullptr; } + FaultEventSource * fault_events() override { + return nullptr; + } TopicDataProvider * topic_data_provider() override { return nullptr; } diff --git a/src/ros2_medkit_gateway/test/test_sse_fault_handler.cpp b/src/ros2_medkit_gateway/test/test_sse_fault_handler.cpp index 8397e0e81..ec30bdff3 100644 --- a/src/ros2_medkit_gateway/test/test_sse_fault_handler.cpp +++ b/src/ros2_medkit_gateway/test/test_sse_fault_handler.cpp @@ -199,7 +199,7 @@ class SSEFaultHandlerTest : public ::testing::Test { ctx_ = std::make_unique(*node_, cors_config, auth_config, tls_config, nullptr); tracker_ = std::make_shared(4); - handler_ = std::make_unique(*ctx_, tracker_, build_fault_manager_events_topic(node_.get())); + handler_ = std::make_unique(*ctx_, tracker_); publisher_node_ = std::make_shared("test_sse_fault_handler_publisher_" + std::to_string(test_id)); fault_events_topic_ = ros2_medkit_gateway::build_fault_manager_events_topic(node_.get()); @@ -283,7 +283,7 @@ TEST_F(SSEFaultHandlerTest, HandleStreamSetsSseHeadersAndChunkedProvider) { TEST_F(SSEFaultHandlerTest, HandleStreamRejectsWhenClientLimitIsReached) { auto limited_tracker = std::make_shared(1); - SSEFaultHandler limited_handler(*ctx_, limited_tracker, build_fault_manager_events_topic(node_.get())); + SSEFaultHandler limited_handler(*ctx_, limited_tracker); auto req_one = make_stream_request("127.0.0.1"); auto req_two = make_stream_request("127.0.0.2"); @@ -575,7 +575,7 @@ TEST_F(SSEFaultHandlerTest, StreamSanitizesNewlinesInEventType) { TEST_F(SSEFaultHandlerTest, StreamSendsKeepaliveCommentAfterTimeout) { auto fast_tracker = std::make_shared(1); - SSEFaultHandler fast_handler(*ctx_, fast_tracker, 10ms, build_fault_manager_events_topic(node_.get())); + SSEFaultHandler fast_handler(*ctx_, fast_tracker, 10ms); auto req = make_stream_request("127.0.0.1"); httplib::Response res; @@ -593,7 +593,7 @@ TEST_F(SSEFaultHandlerTest, NonPositiveKeepaliveOverrideLogsWarning) { testing::internal::CaptureStderr(); { auto warn_tracker = std::make_shared(1); - SSEFaultHandler warn_handler(*ctx_, warn_tracker, 0ms, build_fault_manager_events_topic(node_.get())); + SSEFaultHandler warn_handler(*ctx_, warn_tracker, 0ms); } auto logs = testing::internal::GetCapturedStderr();