diff --git a/src/ros2_medkit_gateway/CMakeLists.txt b/src/ros2_medkit_gateway/CMakeLists.txt index 470febc6f..11bd65f7e 100644 --- a/src/ros2_medkit_gateway/CMakeLists.txt +++ b/src/ros2_medkit_gateway/CMakeLists.txt @@ -41,6 +41,7 @@ find_package(yaml_cpp_vendor REQUIRED) medkit_find_yaml_cpp() 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_serialization REQUIRED) find_package(SQLite3 REQUIRED) @@ -159,6 +160,7 @@ add_library(gateway_ros2 STATIC src/ros2/transports/ros2_service_transport.cpp 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/trigger_topic_subscriber.cpp src/default_script_provider.cpp src/discovery/discovery_manager.cpp @@ -224,6 +226,7 @@ medkit_target_dependencies(gateway_ros2 rcl_interfaces ament_index_cpp action_msgs + lifecycle_msgs ros2_medkit_msgs ros2_medkit_serialization rosidl_typesupport_cpp @@ -768,6 +771,17 @@ if(BUILD_TESTING) medkit_target_dependencies(test_lifecycle_handlers rclcpp) medkit_set_test_domain(test_lifecycle_handlers) + # Lifecycle status helpers (pure C++17, no ROS node) + ament_add_gtest(test_lifecycle_status_helpers test/test_lifecycle_status_helpers.cpp) + target_link_libraries(test_lifecycle_status_helpers gateway_core) + medkit_set_test_domain(test_lifecycle_status_helpers) + + # GetState-backed lifecycle state reader (ROS 2 in-process service) + ament_add_gtest(test_ros2_lifecycle_state_reader test/test_ros2_lifecycle_state_reader.cpp) + target_link_libraries(test_ros2_lifecycle_state_reader gateway_ros2) + medkit_target_dependencies(test_ros2_lifecycle_state_reader rclcpp lifecycle_msgs) + medkit_set_test_domain(test_ros2_lifecycle_state_reader) + # Add operation handler tests ament_add_gtest(test_operation_handlers test/test_operation_handlers.cpp) target_link_libraries(test_operation_handlers gateway_ros2) @@ -973,6 +987,7 @@ if(BUILD_TESTING) test_tls_config test_fault_manager test_error_info + test_ros2_lifecycle_state_reader test_ros2_subscription_executor test_ros2_subscription_slot test_topic_data_provider_interface diff --git a/src/ros2_medkit_gateway/README.md b/src/ros2_medkit_gateway/README.md index e056c85cd..ff490df34 100644 --- a/src/ros2_medkit_gateway/README.md +++ b/src/ros2_medkit_gateway/README.md @@ -129,7 +129,7 @@ All endpoints are prefixed with `/api/v1` for API versioning. - `PUT /api/v1/components/{component_id}/status/shutdown` - Request controlled component shutdown - `PUT /api/v1/components/{component_id}/status/force-shutdown` - Request forced component shutdown -`GET /status` always returns a response (derived from the entity cache when no lifecycle plugin is registered). `PUT /status/{action}` returns `501 Not Implemented` until a substrate plugin registers a `LifecycleProvider` for the entity. Accepted transitions return `202` with a `Location` header pointing back to `GET /status`. +`GET /status` always returns a response. For apps, the status is read from the ROS 2 lifecycle `GetState` service when the node is a managed lifecycle node (`active` -> `ready`; any other state, or an unreachable/timed-out read, -> `notReady`); plain (unmanaged) nodes fall back to graph presence (`is_online`). For components, a local component is `ready` while the gateway is reachable. `PUT /status/{action}` returns `501 Not Implemented` until a substrate plugin registers a `LifecycleProvider` for the entity. Accepted transitions return `202` with a `Location` header pointing back to `GET /status`. ### Vendor Extension Endpoints diff --git a/src/ros2_medkit_gateway/design/lifecycle.rst b/src/ros2_medkit_gateway/design/lifecycle.rst index 422f7f473..a77c2ddef 100644 --- a/src/ros2_medkit_gateway/design/lifecycle.rst +++ b/src/ros2_medkit_gateway/design/lifecycle.rst @@ -86,19 +86,19 @@ The optional transition URI fields are only present when the registered ``LifecycleProvider`` signals support for that transition. Each field value is the absolute path the client should call to trigger the transition. -**App status (no provider registered):** derived from ``App::is_online`` in -the entity cache. The entity cache marks an App online when it has at least -one node in the ROS 2 graph. ``is_online = true`` maps to ``"ready"``; -``false`` maps to ``"notReady"``. - -**Component status (no provider registered):** derived from a real liveness -signal, because ``Component`` has no online flag of its own. The synthetic host -Component populated by ``HostInfoProvider`` (the one carrying ``host_metadata``) -is ``"ready"`` while the gateway is reachable - if the client can reach this -endpoint, the host is up. Any other Component is ``"ready"`` when at least one of -its hosted Apps is online (``App::is_online`` over the Component's hosted apps), -and ``"notReady"`` otherwise. ``host_metadata`` is only used to identify the host -Component; it is not itself a liveness signal. +**App status:** for a managed lifecycle node (one exposing ``get_state`` and ``change_state``), +the status is read from the node's lifecycle state via ``lifecycle_msgs/srv/GetState``: the +``active`` state is ``"ready"``; any other state, or an unreachable/timed-out read, is +``"notReady"``. This subsumes liveness, since a dead node's ``get_state`` service is gone. For a +plain node with no lifecycle services, the status falls back to ``App::is_online`` (presence in +the ROS 2 graph), which is the best signal available for an unmanaged node. The GetState read runs +on a private node and executor (spun inline), so it never blocks or races the gateway executor. + +**Component status:** a local component is ``"ready"`` while the gateway is serving the +request (the substrate is reachable), independent of how many hosted apps are online, +including zero. SOVD ``notReady`` means stopped, restarting, or unreachable, which a +reachable local substrate is not; a down hosted app is that app's own ``notReady``. Remote +components are reached through aggregation forwarding, so peer reachability drives their status. When a ``LifecycleProvider`` is registered for the entity the provider's response takes precedence over the cache-derived default. The handler also diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/lifecycle_handlers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/lifecycle_handlers.hpp index 2defedf97..1df15a9dd 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/lifecycle_handlers.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/http/handlers/lifecycle_handlers.hpp @@ -14,9 +14,11 @@ #pragma once +#include #include #include +#include "ros2_medkit_gateway/core/status/lifecycle_state_reader.hpp" #include "ros2_medkit_gateway/dto/lifecycle.hpp" #include "ros2_medkit_gateway/http/handlers/handler_context.hpp" #include "ros2_medkit_gateway/http/response_types.hpp" @@ -42,9 +44,10 @@ namespace handlers { */ class LifecycleHandlers { public: - /// Construct lifecycle handlers with shared context and optional plugin manager. - LifecycleHandlers(HandlerContext & ctx, PluginManager * plugin_mgr) : ctx_(ctx), plugin_mgr_(plugin_mgr) { - } + /// Construct lifecycle handlers with shared context, optional plugin manager, and optional lifecycle reader. + /// lifecycle_reader is nullptr by default so existing 2-argument call sites compile unchanged. + LifecycleHandlers(HandlerContext & ctx, PluginManager * plugin_mgr, + std::shared_ptr lifecycle_reader = nullptr); /// GET /{entity}/status - read lifecycle status. http::Result handle_get_status(const http::TypedRequest & req); @@ -57,6 +60,7 @@ class LifecycleHandlers { private: HandlerContext & ctx_; PluginManager * plugin_mgr_; + std::shared_ptr lifecycle_reader_; }; } // namespace handlers diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/status/lifecycle_state_reader.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/status/lifecycle_state_reader.hpp new file mode 100644 index 000000000..1acaf393c --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/status/lifecycle_state_reader.hpp @@ -0,0 +1,45 @@ +// 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 "ros2_medkit_gateway/core/discovery/models/common.hpp" + +namespace ros2_medkit_gateway { + +/// Reads the current lifecycle state of a managed (rclcpp_lifecycle) node. +/// Implemented in gateway_ros2 over lifecycle_msgs/srv/GetState; stubbed in tests. +class LifecycleStateReader { + public: + virtual ~LifecycleStateReader() = default; + + /// Return the lifecycle state label (e.g. "active", "inactive", "unconfigured") + /// for the node whose GetState service is at get_state_service_path, or nullopt + /// if the service is unreachable or the call times out. + virtual std::optional get_state(const std::string & get_state_service_path) = 0; +}; + +/// Map a lifecycle state label to the SOVD status. "active" -> "ready"; any other +/// state, or a missing/unreachable read (nullopt), -> "notReady". +std::string lifecycle_status_from_state(const std::optional & state_label); + +/// If services expose both lifecycle_msgs/srv/GetState and lifecycle_msgs/srv/ChangeState +/// (a managed lifecycle node), return the GetState service full_path. Otherwise nullopt. +std::optional find_lifecycle_get_state_path(const std::vector & services); + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/ros2/status/ros2_lifecycle_state_reader.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/ros2/status/ros2_lifecycle_state_reader.hpp new file mode 100644 index 000000000..526d82091 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/ros2/status/ros2_lifecycle_state_reader.hpp @@ -0,0 +1,57 @@ +// 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 +#include + +#include + +#include "ros2_medkit_gateway/core/status/lifecycle_state_reader.hpp" + +namespace ros2_medkit_gateway { + +/// LifecycleStateReader backed by lifecycle_msgs/srv/GetState. The GetState client +/// runs on a private node driven by a private SingleThreadedExecutor that is spun +/// inline on the calling thread (no background spin), so it never races the host +/// gateway node's MultiThreadedExecutor (the private-node/private-executor idea is +/// borrowed from ros2_fault_service_transport.cpp; unlike that transport, the target +/// service path varies per app, so the client is created per call rather than once +/// in the constructor). Calls are serialized by an internal mutex: a slow or +/// unreachable lifecycle node holds the mutex for up to ~2x the timeout and blocks +/// other status reads, so the default timeout is kept short. +class Ros2LifecycleStateReader : public LifecycleStateReader { + public: + explicit Ros2LifecycleStateReader(rclcpp::Node * host, + std::chrono::duration timeout = std::chrono::milliseconds(500)); + ~Ros2LifecycleStateReader() override; + Ros2LifecycleStateReader(const Ros2LifecycleStateReader &) = delete; + Ros2LifecycleStateReader & operator=(const Ros2LifecycleStateReader &) = delete; + Ros2LifecycleStateReader(Ros2LifecycleStateReader &&) = delete; + Ros2LifecycleStateReader & operator=(Ros2LifecycleStateReader &&) = delete; + + std::optional get_state(const std::string & get_state_service_path) override; + + private: + std::shared_ptr client_node_; + std::shared_ptr executor_; + std::chrono::duration timeout_; + std::mutex mutex_; +}; + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/package.xml b/src/ros2_medkit_gateway/package.xml index 80406f7b2..48b8efc48 100644 --- a/src/ros2_medkit_gateway/package.xml +++ b/src/ros2_medkit_gateway/package.xml @@ -17,6 +17,7 @@ sensor_msgs rcl_interfaces action_msgs + lifecycle_msgs nlohmann-json-dev libcpp-httplib-dev diff --git a/src/ros2_medkit_gateway/src/core/status/lifecycle_status_helpers.cpp b/src/ros2_medkit_gateway/src/core/status/lifecycle_status_helpers.cpp new file mode 100644 index 000000000..e412d7055 --- /dev/null +++ b/src/ros2_medkit_gateway/src/core/status/lifecycle_status_helpers.cpp @@ -0,0 +1,39 @@ +// 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/status/lifecycle_state_reader.hpp" + +namespace ros2_medkit_gateway { + +std::string lifecycle_status_from_state(const std::optional & state_label) { + return (state_label.has_value() && *state_label == "active") ? "ready" : "notReady"; +} + +std::optional find_lifecycle_get_state_path(const std::vector & services) { + std::optional get_state_path; + bool has_change_state = false; + for (const auto & svc : services) { + if (svc.type == "lifecycle_msgs/srv/GetState") { + get_state_path = svc.full_path; + } else if (svc.type == "lifecycle_msgs/srv/ChangeState") { + has_change_state = true; + } + } + if (get_state_path.has_value() && has_change_state) { + return get_state_path; + } + return std::nullopt; +} + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/http/handlers/lifecycle_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/lifecycle_handlers.cpp index 2b6133b3f..7f82e1eb9 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/lifecycle_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/lifecycle_handlers.cpp @@ -14,6 +14,7 @@ #include "ros2_medkit_gateway/core/http/handlers/lifecycle_handlers.hpp" +#include #include #include @@ -22,6 +23,7 @@ #include "ros2_medkit_gateway/core/http/error_codes.hpp" #include "ros2_medkit_gateway/core/plugins/plugin_manager.hpp" #include "ros2_medkit_gateway/core/providers/lifecycle_provider.hpp" +#include "ros2_medkit_gateway/core/status/lifecycle_state_reader.hpp" #include "ros2_medkit_gateway/gateway_node.hpp" #include "ros2_medkit_gateway/http/handlers/handler_support.hpp" @@ -57,6 +59,11 @@ ErrorInfo to_error_info(const LifecycleProviderErrorInfo & e) { } // namespace +LifecycleHandlers::LifecycleHandlers(HandlerContext & ctx, PluginManager * plugin_mgr, + std::shared_ptr lifecycle_reader) + : ctx_(ctx), plugin_mgr_(plugin_mgr), lifecycle_reader_(std::move(lifecycle_reader)) { +} + // ============================================================================= // GET /{entity}/status // ============================================================================= @@ -129,28 +136,28 @@ http::Result LifecycleHandlers::handle_get_status( if (entity.type == EntityType::APP) { auto app = cache.get_app(entity_id); - resp.status = (app && app->is_online) ? "ready" : "notReady"; - } else { - // Component readiness from a real liveness signal: the synthetic host - // component is ready while the gateway is reachable (we are serving this - // request); any other component is ready when at least one of its hosted - // Apps is online. host_metadata alone is not a liveness signal. - auto component = cache.get_component(entity_id); - bool ready = false; - if (component) { - if (component->host_metadata.has_value()) { - ready = true; - } else { - for (const auto & app_id : cache.get_apps_for_component(entity_id)) { - auto app = cache.get_app(app_id); - if (app && app->is_online) { - ready = true; - break; - } - } - } + std::optional get_state_path; + if (app && lifecycle_reader_) { + get_state_path = find_lifecycle_get_state_path(app->services); + } + if (app && lifecycle_reader_ && get_state_path.has_value()) { + // Managed lifecycle node: read the real state. "active" is the only ready state. + auto state = lifecycle_reader_->get_state(*get_state_path); + resp.status = lifecycle_status_from_state(state); + } else { + // Plain node, no reader wired, or a node exposing get_state without + // change_state (deliberately treated as unmanaged): graph presence is the + // best available signal. + resp.status = (app && app->is_online) ? "ready" : "notReady"; } - resp.status = ready ? "ready" : "notReady"; + } else { + // A local component is operational while the gateway is serving this + // request (the substrate is reachable). SOVD notReady means stopped, + // restarting, or unreachable; a reachable local substrate is none of + // these, regardless of how many hosted apps are online. A down hosted + // app is that app's own notReady, not the component's. Remote components + // are handled earlier by aggregation forwarding. + resp.status = "ready"; } // No transition URIs for the default (no-provider) path. diff --git a/src/ros2_medkit_gateway/src/http/rest_server.cpp b/src/ros2_medkit_gateway/src/http/rest_server.cpp index 737772378..4a1e3b4fd 100644 --- a/src/ros2_medkit_gateway/src/http/rest_server.cpp +++ b/src/ros2_medkit_gateway/src/http/rest_server.cpp @@ -23,6 +23,7 @@ #include "ros2_medkit_gateway/core/http/error_codes.hpp" #include "ros2_medkit_gateway/core/http/http_utils.hpp" #include "ros2_medkit_gateway/gateway_node.hpp" +#include "ros2_medkit_gateway/ros2/status/ros2_lifecycle_state_reader.hpp" #include "../openapi/route_registry.hpp" #include "../openapi/schema_builder.hpp" @@ -125,7 +126,8 @@ RESTServer::RESTServer(GatewayNode * node, const std::string & host, int port, c health_handlers_ = std::make_unique(*handler_ctx_, route_registry_.get()); discovery_handlers_ = std::make_unique(*handler_ctx_); data_handlers_ = std::make_unique(*handler_ctx_); - lifecycle_handlers_ = std::make_unique(*handler_ctx_, node_->get_plugin_manager()); + lifecycle_handlers_ = std::make_unique( + *handler_ctx_, node_->get_plugin_manager(), std::make_shared(node_)); operation_handlers_ = std::make_unique(*handler_ctx_); config_handlers_ = std::make_unique(*handler_ctx_); fault_handlers_ = std::make_unique(*handler_ctx_); diff --git a/src/ros2_medkit_gateway/src/ros2/status/ros2_lifecycle_state_reader.cpp b/src/ros2_medkit_gateway/src/ros2/status/ros2_lifecycle_state_reader.cpp new file mode 100644 index 000000000..57aca29d2 --- /dev/null +++ b/src/ros2_medkit_gateway/src/ros2/status/ros2_lifecycle_state_reader.cpp @@ -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. + +#include "ros2_medkit_gateway/ros2/status/ros2_lifecycle_state_reader.hpp" + +#include + +#include + +namespace ros2_medkit_gateway { + +Ros2LifecycleStateReader::Ros2LifecycleStateReader(rclcpp::Node * host, std::chrono::duration timeout) + : timeout_(timeout) { + client_node_ = std::make_shared(std::string(host->get_name()) + "_lifecycle_state_reader"); + executor_ = std::make_shared(); + executor_->add_node(client_node_); +} + +Ros2LifecycleStateReader::~Ros2LifecycleStateReader() { + if (executor_ && client_node_) { + executor_->remove_node(client_node_); + } +} + +std::optional Ros2LifecycleStateReader::get_state(const std::string & get_state_service_path) { + std::lock_guard lock(mutex_); + const auto clamped = std::chrono::duration(std::max(timeout_.count(), 0.0)); + + auto client = client_node_->create_client(get_state_service_path); + if (!client->wait_for_service(clamped)) { + return std::nullopt; + } + auto request = std::make_shared(); + auto future = client->async_send_request(request); + if (executor_->spin_until_future_complete(future, clamped) != rclcpp::FutureReturnCode::SUCCESS) { + client->remove_pending_request(future.request_id); + return std::nullopt; + } + return future.get()->current_state.label; +} + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/test/test_lifecycle_handlers.cpp b/src/ros2_medkit_gateway/test/test_lifecycle_handlers.cpp index 7e30e76cf..eedf0dcbf 100644 --- a/src/ros2_medkit_gateway/test/test_lifecycle_handlers.cpp +++ b/src/ros2_medkit_gateway/test/test_lifecycle_handlers.cpp @@ -32,6 +32,7 @@ #include "ros2_medkit_gateway/core/plugins/gateway_plugin.hpp" #include "ros2_medkit_gateway/core/plugins/plugin_manager.hpp" #include "ros2_medkit_gateway/core/providers/lifecycle_provider.hpp" +#include "ros2_medkit_gateway/core/status/lifecycle_state_reader.hpp" #include "ros2_medkit_gateway/dto/json_writer.hpp" #include "ros2_medkit_gateway/dto/lifecycle.hpp" #include "ros2_medkit_gateway/gateway_node.hpp" @@ -47,6 +48,7 @@ using ros2_medkit_gateway::LifecycleProvider; using ros2_medkit_gateway::LifecycleProviderError; using ros2_medkit_gateway::LifecycleProviderErrorInfo; using ros2_medkit_gateway::PluginManager; +using ros2_medkit_gateway::ServiceInfo; using ros2_medkit_gateway::ThreadSafeEntityCache; using ros2_medkit_gateway::TlsConfig; using ros2_medkit_gateway::dto::JsonWriter; @@ -66,6 +68,14 @@ httplib::Request make_request_with_match(const std::string & path, const std::st return req; } +class StubLifecycleStateReader : public ros2_medkit_gateway::LifecycleStateReader { + public: + std::optional next; + std::optional get_state(const std::string & /*get_state_service_path*/) override { + return next; + } +}; + } // namespace // ============================================================================= @@ -202,7 +212,7 @@ TEST_F(LifecycleHandlersTest, GetStatusOfflineAppReturnsNotReady) { EXPECT_EQ(result->status, "notReady"); } -// GET /components/{host_controller}/status -> 200, status == "ready" (has host_metadata) +// GET /components/{host_controller}/status -> 200, status == "ready" (reachable local component) TEST_F(LifecycleHandlersTest, GetStatusHostComponentReturnsReady) { auto raw = make_request_with_match("/api/v1/components/host_controller/status", R"(/api/v1/components/([^/]+)/status)"); @@ -213,18 +223,18 @@ TEST_F(LifecycleHandlersTest, GetStatusHostComponentReturnsReady) { EXPECT_EQ(result->status, "ready"); } -// GET /components/{bridge_component}/status -> 200, status == "notReady" (no host_metadata, no provider) -TEST_F(LifecycleHandlersTest, GetStatusNonHostComponentReturnsNotReady) { +// GET /components/{bridge_component}/status -> 200, status == "ready" (reachable local component, zero apps) +TEST_F(LifecycleHandlersTest, GetStatusComponentZeroAppsReturnsReady) { auto raw = make_request_with_match("/api/v1/components/bridge_component/status", R"(/api/v1/components/([^/]+)/status)"); http::TypedRequest req(raw); auto result = handlers_->handle_get_status(req); ASSERT_TRUE(result.has_value()); - EXPECT_EQ(result->status, "notReady"); + EXPECT_EQ(result->status, "ready"); // reachable local component, zero apps -> ready } -// GET /components/{live_subsystem}/status -> "ready" (a hosted App is online) +// GET /components/{live_subsystem}/status -> "ready" (reachable local component; one hosted App online) TEST_F(LifecycleHandlersTest, GetStatusComponentWithOnlineHostedAppReturnsReady) { auto raw = make_request_with_match("/api/v1/components/live_subsystem/status", R"(/api/v1/components/([^/]+)/status)"); @@ -235,15 +245,15 @@ TEST_F(LifecycleHandlersTest, GetStatusComponentWithOnlineHostedAppReturnsReady) EXPECT_EQ(result->status, "ready"); } -// GET /components/{dead_subsystem}/status -> "notReady" (its only hosted App is offline) -TEST_F(LifecycleHandlersTest, GetStatusComponentWithOnlyOfflineHostedAppReturnsNotReady) { +// GET /components/{dead_subsystem}/status -> "ready" (down hosted app is the app's notReady, not the component's) +TEST_F(LifecycleHandlersTest, GetStatusComponentAllAppsOfflineReturnsReady) { auto raw = make_request_with_match("/api/v1/components/dead_subsystem/status", R"(/api/v1/components/([^/]+)/status)"); http::TypedRequest req(raw); auto result = handlers_->handle_get_status(req); ASSERT_TRUE(result.has_value()); - EXPECT_EQ(result->status, "notReady"); + EXPECT_EQ(result->status, "ready"); // a down hosted app is the app's notReady, not the component's } // PUT /apps/{online_sensor}/status/restart -> 501 (no provider registered) @@ -654,3 +664,77 @@ TEST_F(LifecycleHandlersWithProviderTest, GetStatusProviderInvalidStatusReturns5 EXPECT_EQ(result.error().http_status, 500); EXPECT_EQ(result.error().code, ros2_medkit_gateway::ERR_PLUGIN_ERROR); } + +// ============================================================================= +// Stub-reader lifecycle app status tests. +// Verify that handle_get_status reads lifecycle state for managed nodes and falls +// back to is_online for plain apps. +// ============================================================================= + +TEST_F(LifecycleHandlersTest, GetStatusLifecycleAppActiveIsReady) { + auto stub = std::make_shared(); + stub->next = "active"; + LifecycleHandlers lc_handlers(*ctx_, nullptr, stub); + + App lc_app; + lc_app.id = "lc_sensor"; + lc_app.name = "LcSensor"; + lc_app.is_online = true; + lc_app.bound_fqn = "/lc_sensor"; + ServiceInfo gs; + gs.full_path = "/lc_sensor/get_state"; + gs.type = "lifecycle_msgs/srv/GetState"; + ServiceInfo cs; + cs.full_path = "/lc_sensor/change_state"; + cs.type = "lifecycle_msgs/srv/ChangeState"; + lc_app.services.push_back(gs); + lc_app.services.push_back(cs); + auto & cache = const_cast(gateway_node_->get_thread_safe_cache()); + cache.update_all({}, {}, {lc_app}, {}); + + auto raw = make_request_with_match("/api/v1/apps/lc_sensor/status", R"(/api/v1/apps/([^/]+)/status)"); + http::TypedRequest req(raw); + auto result = lc_handlers.handle_get_status(req); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->status, "ready"); +} + +TEST_F(LifecycleHandlersTest, GetStatusLifecycleAppInactiveIsNotReady) { + auto stub = std::make_shared(); + stub->next = "inactive"; + LifecycleHandlers lc_handlers(*ctx_, nullptr, stub); + + App lc_app; + lc_app.id = "lc_idle"; + lc_app.name = "LcIdle"; + lc_app.is_online = true; // in the graph, but inactive + lc_app.bound_fqn = "/lc_idle"; + ServiceInfo gs; + gs.full_path = "/lc_idle/get_state"; + gs.type = "lifecycle_msgs/srv/GetState"; + ServiceInfo cs; + cs.full_path = "/lc_idle/change_state"; + cs.type = "lifecycle_msgs/srv/ChangeState"; + lc_app.services.push_back(gs); + lc_app.services.push_back(cs); + auto & cache = const_cast(gateway_node_->get_thread_safe_cache()); + cache.update_all({}, {}, {lc_app}, {}); + + auto raw = make_request_with_match("/api/v1/apps/lc_idle/status", R"(/api/v1/apps/([^/]+)/status)"); + http::TypedRequest req(raw); + auto result = lc_handlers.handle_get_status(req); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->status, "notReady"); +} + +TEST_F(LifecycleHandlersTest, GetStatusPlainAppStillUsesIsOnline) { + auto stub = std::make_shared(); + stub->next = "inactive"; // must be ignored for a plain app + LifecycleHandlers lc_handlers(*ctx_, nullptr, stub); + // online_sensor (seeded in SetUp) has no lifecycle services. + auto raw = make_request_with_match("/api/v1/apps/online_sensor/status", R"(/api/v1/apps/([^/]+)/status)"); + http::TypedRequest req(raw); + auto result = lc_handlers.handle_get_status(req); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->status, "ready"); // is_online == true, lifecycle read not used +} diff --git a/src/ros2_medkit_gateway/test/test_lifecycle_status_helpers.cpp b/src/ros2_medkit_gateway/test/test_lifecycle_status_helpers.cpp new file mode 100644 index 000000000..35dd6ed27 --- /dev/null +++ b/src/ros2_medkit_gateway/test/test_lifecycle_status_helpers.cpp @@ -0,0 +1,62 @@ +// 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 + +#include "ros2_medkit_gateway/core/status/lifecycle_state_reader.hpp" + +using ros2_medkit_gateway::find_lifecycle_get_state_path; +using ros2_medkit_gateway::lifecycle_status_from_state; +using ros2_medkit_gateway::ServiceInfo; + +TEST(LifecycleStatusHelpers, ActiveIsReady) { + EXPECT_EQ(lifecycle_status_from_state(std::optional("active")), "ready"); +} + +TEST(LifecycleStatusHelpers, NonActiveIsNotReady) { + EXPECT_EQ(lifecycle_status_from_state(std::optional("inactive")), "notReady"); + EXPECT_EQ(lifecycle_status_from_state(std::optional("unconfigured")), "notReady"); + EXPECT_EQ(lifecycle_status_from_state(std::optional("finalized")), "notReady"); +} + +TEST(LifecycleStatusHelpers, NulloptIsNotReady) { + EXPECT_EQ(lifecycle_status_from_state(std::nullopt), "notReady"); +} + +TEST(LifecycleStatusHelpers, FindPathRequiresBothServices) { + std::vector only_get; + ServiceInfo gs; + gs.full_path = "/lc_node/get_state"; + gs.type = "lifecycle_msgs/srv/GetState"; + only_get.push_back(gs); + EXPECT_FALSE(find_lifecycle_get_state_path(only_get).has_value()); + + std::vector both = only_get; + ServiceInfo cs; + cs.full_path = "/lc_node/change_state"; + cs.type = "lifecycle_msgs/srv/ChangeState"; + both.push_back(cs); + auto path = find_lifecycle_get_state_path(both); + ASSERT_TRUE(path.has_value()); + EXPECT_EQ(*path, "/lc_node/get_state"); +} + +TEST(LifecycleStatusHelpers, FindPathPlainNodeReturnsNullopt) { + std::vector plain; + ServiceInfo trig; + trig.full_path = "/plain/trigger"; + trig.type = "std_srvs/srv/Trigger"; + plain.push_back(trig); + EXPECT_FALSE(find_lifecycle_get_state_path(plain).has_value()); +} diff --git a/src/ros2_medkit_gateway/test/test_ros2_lifecycle_state_reader.cpp b/src/ros2_medkit_gateway/test/test_ros2_lifecycle_state_reader.cpp new file mode 100644 index 000000000..c90240f41 --- /dev/null +++ b/src/ros2_medkit_gateway/test/test_ros2_lifecycle_state_reader.cpp @@ -0,0 +1,92 @@ +// 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 +#include +#include + +#include +#include +#include +#include + +#include "ros2_medkit_gateway/ros2/status/ros2_lifecycle_state_reader.hpp" + +using ros2_medkit_gateway::Ros2LifecycleStateReader; + +class Ros2LifecycleStateReaderTest : public ::testing::Test { + protected: + static void SetUpTestSuite() { + rclcpp::init(0, nullptr); + } + static void TearDownTestSuite() { + if (rclcpp::ok()) { + rclcpp::shutdown(); + } + } + + void SetUp() override { + service_node_ = std::make_shared("fake_lifecycle_node"); + service_ = service_node_->create_service( + "/fake_lifecycle_node/get_state", + [this](const std::shared_ptr & /*request*/, + const std::shared_ptr & resp) { + resp->current_state.id = state_id_; + resp->current_state.label = state_label_; + }); + exec_.add_node(service_node_); + spin_ = std::thread([this]() { + exec_.spin(); + }); + reader_ = std::make_unique(service_node_.get(), std::chrono::seconds(3)); + } + + void TearDown() override { + exec_.cancel(); + if (spin_.joinable()) { + spin_.join(); + } + reader_.reset(); + exec_.remove_node(service_node_); + service_.reset(); + service_node_.reset(); + } + + uint8_t state_id_{lifecycle_msgs::msg::State::PRIMARY_STATE_ACTIVE}; + std::string state_label_{"active"}; + rclcpp::executors::SingleThreadedExecutor exec_; + std::shared_ptr service_node_; + rclcpp::Service::SharedPtr service_; + std::thread spin_; + std::unique_ptr reader_; +}; + +TEST_F(Ros2LifecycleStateReaderTest, ReadsActiveLabel) { + auto state = reader_->get_state("/fake_lifecycle_node/get_state"); + ASSERT_TRUE(state.has_value()); + EXPECT_EQ(*state, "active"); +} + +TEST_F(Ros2LifecycleStateReaderTest, ReadsInactiveLabel) { + state_label_ = "inactive"; + state_id_ = lifecycle_msgs::msg::State::PRIMARY_STATE_INACTIVE; + auto state = reader_->get_state("/fake_lifecycle_node/get_state"); + ASSERT_TRUE(state.has_value()); + EXPECT_EQ(*state, "inactive"); +} + +TEST_F(Ros2LifecycleStateReaderTest, UnreachableServiceReturnsNullopt) { + Ros2LifecycleStateReader r(service_node_.get(), std::chrono::milliseconds(300)); + EXPECT_FALSE(r.get_state("/no_such_node/get_state").has_value()); +} diff --git a/src/ros2_medkit_integration_tests/CMakeLists.txt b/src/ros2_medkit_integration_tests/CMakeLists.txt index 10739a726..37542e392 100644 --- a/src/ros2_medkit_integration_tests/CMakeLists.txt +++ b/src/ros2_medkit_integration_tests/CMakeLists.txt @@ -21,6 +21,7 @@ find_package(rcl_interfaces REQUIRED) find_package(example_interfaces REQUIRED) find_package(ros2_medkit_msgs REQUIRED) find_package(diagnostic_msgs REQUIRED) +find_package(rclcpp_lifecycle REQUIRED) # === Demo node executables === @@ -72,6 +73,9 @@ add_executable(demo_param_beacon_node demo_nodes/param_beacon_node.cpp) target_include_directories(demo_param_beacon_node PRIVATE ${_demo_include_dir}) medkit_target_dependencies(demo_param_beacon_node rclcpp) +add_executable(managed_lifecycle demo_nodes/managed_lifecycle_node.cpp) +medkit_target_dependencies(managed_lifecycle rclcpp rclcpp_lifecycle) + install(TARGETS demo_engine_temp_sensor demo_rpm_sensor @@ -84,6 +88,7 @@ install(TARGETS demo_lidar_sensor demo_beacon_publisher demo_param_beacon_node + managed_lifecycle DESTINATION lib/${PROJECT_NAME} ) diff --git a/src/ros2_medkit_integration_tests/demo_nodes/managed_lifecycle_node.cpp b/src/ros2_medkit_integration_tests/demo_nodes/managed_lifecycle_node.cpp new file mode 100644 index 000000000..41b35d705 --- /dev/null +++ b/src/ros2_medkit_integration_tests/demo_nodes/managed_lifecycle_node.cpp @@ -0,0 +1,83 @@ +// 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 +#include +#include + +#include +#include + +using CallbackReturn = rclcpp_lifecycle::node_interfaces::LifecycleNodeInterface::CallbackReturn; + +// A managed lifecycle node for status integration tests. It DEFAULTS to staying +// unconfigured (auto_activate:=false): it is in the ROS graph (so is_online would +// wrongly say "ready") but its lifecycle state is "unconfigured", so accurate +// status must read "notReady". This is the case that proves the lifecycle reader +// is consulted instead of is_online. With auto_activate:=true it configures and +// activates itself shortly after start (status -> ready). +class ManagedLifecycleNode : public rclcpp_lifecycle::LifecycleNode { + public: + ManagedLifecycleNode() : rclcpp_lifecycle::LifecycleNode("managed_lifecycle") { + auto_activate_ = this->declare_parameter("auto_activate", false); + if (auto_activate_) { + timer_ = this->create_wall_timer(std::chrono::milliseconds(300), [this]() { + timer_->cancel(); + this->configure(); + this->activate(); + }); + } + } + + CallbackReturn on_configure(const rclcpp_lifecycle::State & /*state*/) override { + return CallbackReturn::SUCCESS; + } + CallbackReturn on_activate(const rclcpp_lifecycle::State & /*state*/) override { + return CallbackReturn::SUCCESS; + } + CallbackReturn on_deactivate(const rclcpp_lifecycle::State & /*state*/) override { + return CallbackReturn::SUCCESS; + } + CallbackReturn on_cleanup(const rclcpp_lifecycle::State & /*state*/) override { + return CallbackReturn::SUCCESS; + } + CallbackReturn on_shutdown(const rclcpp_lifecycle::State & /*state*/) override { + return CallbackReturn::SUCCESS; + } + + private: + bool auto_activate_{false}; + rclcpp::TimerBase::SharedPtr timer_; +}; + +int main(int argc, char ** argv) { + // Wrap the body so no exception escapes main (bugprone-exception-escape): the + // run_demo_node helper used by the other demo nodes only accepts an + // rclcpp::Node, so a LifecycleNode needs its own main. + try { + rclcpp::init(argc, argv); + auto node = std::make_shared(); + rclcpp::executors::SingleThreadedExecutor exec; + exec.add_node(node->get_node_base_interface()); + exec.spin(); + rclcpp::shutdown(); + } catch (const std::exception & e) { + fprintf(stderr, "managed_lifecycle: %s\n", e.what()); + return 1; + } catch (...) { + fprintf(stderr, "managed_lifecycle: unknown exception\n"); + return 1; + } + return 0; +} diff --git a/src/ros2_medkit_integration_tests/package.xml b/src/ros2_medkit_integration_tests/package.xml index 5966a7bd1..d255dda1b 100644 --- a/src/ros2_medkit_integration_tests/package.xml +++ b/src/ros2_medkit_integration_tests/package.xml @@ -15,6 +15,7 @@ rclcpp rclcpp_action + rclcpp_lifecycle std_msgs std_srvs sensor_msgs diff --git a/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/launch_helpers.py b/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/launch_helpers.py index 78c777da0..05e9b3c20 100644 --- a/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/launch_helpers.py +++ b/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/launch_helpers.py @@ -53,6 +53,8 @@ # Operations (services / actions) 'calibration': ('demo_calibration_service', 'calibration', '/powertrain/engine'), 'long_calibration': ('demo_long_calibration_action', 'long_calibration', '/powertrain/engine'), + # Lifecycle demo (stays unconfigured by default; auto_activate:=true activates it) + 'managed_lifecycle': ('managed_lifecycle', 'managed_lifecycle', ''), } # Convenience groupings for callers that want subsets of demo nodes. diff --git a/src/ros2_medkit_integration_tests/test/features/test_lifecycle.test.py b/src/ros2_medkit_integration_tests/test/features/test_lifecycle.test.py index a860437f4..6e580b3a8 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_lifecycle.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_lifecycle.test.py @@ -33,7 +33,7 @@ def generate_test_description(): return create_test_launch( - demo_nodes=['temp_sensor'], + demo_nodes=['temp_sensor', 'managed_lifecycle'], fault_manager=False, ) @@ -42,8 +42,8 @@ def generate_test_description(): class TestLifecycleStatus(GatewayTestCase): """Lifecycle status endpoint tests for apps and components.""" - MIN_EXPECTED_APPS = 1 - REQUIRED_APPS = {'temp_sensor'} + MIN_EXPECTED_APPS = 2 + REQUIRED_APPS = {'temp_sensor', 'managed_lifecycle'} # Cache for the dynamically-discovered host component ID. _host_component_id = None @@ -116,6 +116,19 @@ def test_app_status_structure(self): self.assertIn('status', data) self.assertIn(data['status'], ['ready', 'notReady']) + def test_managed_lifecycle_unconfigured_is_not_ready(self): + # @verifies REQ_INTEROP_076 + # An unconfigured lifecycle node is present in the ROS 2 graph (is_online + # alone would report "ready"), but its lifecycle state is "unconfigured", + # so the accurate status read returns "notReady". This proves the lifecycle + # state is read via GetState instead of bare graph presence. + data = self.poll_endpoint_until( + '/apps/managed_lifecycle/status', + lambda d: d if d.get('status') == 'notReady' else None, + timeout=20.0, + ) + self.assertEqual(data.get('status'), 'notReady') + def test_app_status_nonexistent_returns_404(self): """GET /apps/{app_id}/status returns 404 for a nonexistent app.