Skip to content
Open
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
15 changes: 15 additions & 0 deletions src/ros2_medkit_gateway/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/ros2_medkit_gateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
26 changes: 13 additions & 13 deletions src/ros2_medkit_gateway/design/lifecycle.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@

#pragma once

#include <memory>
#include <string>
#include <utility>

#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"
Expand All @@ -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<LifecycleStateReader> lifecycle_reader = nullptr);

/// GET /{entity}/status - read lifecycle status.
http::Result<dto::LifecycleStatusResponse> handle_get_status(const http::TypedRequest & req);
Expand All @@ -57,6 +60,7 @@ class LifecycleHandlers {
private:
HandlerContext & ctx_;
PluginManager * plugin_mgr_;
std::shared_ptr<LifecycleStateReader> lifecycle_reader_;
};

} // namespace handlers
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <optional>
#include <string>
#include <vector>

#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<std::string> 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<std::string> & 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<std::string> find_lifecycle_get_state_path(const std::vector<ServiceInfo> & services);

} // namespace ros2_medkit_gateway
Original file line number Diff line number Diff line change
@@ -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 <chrono>
#include <memory>
#include <mutex>
#include <optional>
#include <string>

#include <rclcpp/rclcpp.hpp>

#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<double> 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<std::string> get_state(const std::string & get_state_service_path) override;

private:
std::shared_ptr<rclcpp::Node> client_node_;
std::shared_ptr<rclcpp::executors::SingleThreadedExecutor> executor_;
std::chrono::duration<double> timeout_;
std::mutex mutex_;
};

} // namespace ros2_medkit_gateway
1 change: 1 addition & 0 deletions src/ros2_medkit_gateway/package.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<depend>sensor_msgs</depend>
<depend>rcl_interfaces</depend>
<depend>action_msgs</depend>
<depend>lifecycle_msgs</depend>
<depend>nlohmann-json-dev</depend>
<depend>libcpp-httplib-dev</depend>
<!-- jwt-cpp is vendored in src/vendored/jwt_cpp (header-only, no system package) -->
Expand Down
Original file line number Diff line number Diff line change
@@ -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<std::string> & state_label) {
return (state_label.has_value() && *state_label == "active") ? "ready" : "notReady";
}

std::optional<std::string> find_lifecycle_get_state_path(const std::vector<ServiceInfo> & services) {
std::optional<std::string> 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
49 changes: 28 additions & 21 deletions src/ros2_medkit_gateway/src/http/handlers/lifecycle_handlers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

#include "ros2_medkit_gateway/core/http/handlers/lifecycle_handlers.hpp"

#include <memory>
#include <string>
#include <utility>

Expand All @@ -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"

Expand Down Expand Up @@ -57,6 +59,11 @@ ErrorInfo to_error_info(const LifecycleProviderErrorInfo & e) {

} // namespace

LifecycleHandlers::LifecycleHandlers(HandlerContext & ctx, PluginManager * plugin_mgr,
std::shared_ptr<LifecycleStateReader> lifecycle_reader)
: ctx_(ctx), plugin_mgr_(plugin_mgr), lifecycle_reader_(std::move(lifecycle_reader)) {
}

// =============================================================================
// GET /{entity}/status
// =============================================================================
Expand Down Expand Up @@ -129,28 +136,28 @@ http::Result<dto::LifecycleStatusResponse> 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<std::string> 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.
Expand Down
4 changes: 3 additions & 1 deletion src/ros2_medkit_gateway/src/http/rest_server.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -125,7 +126,8 @@ RESTServer::RESTServer(GatewayNode * node, const std::string & host, int port, c
health_handlers_ = std::make_unique<handlers::HealthHandlers>(*handler_ctx_, route_registry_.get());
discovery_handlers_ = std::make_unique<handlers::DiscoveryHandlers>(*handler_ctx_);
data_handlers_ = std::make_unique<handlers::DataHandlers>(*handler_ctx_);
lifecycle_handlers_ = std::make_unique<handlers::LifecycleHandlers>(*handler_ctx_, node_->get_plugin_manager());
lifecycle_handlers_ = std::make_unique<handlers::LifecycleHandlers>(
*handler_ctx_, node_->get_plugin_manager(), std::make_shared<Ros2LifecycleStateReader>(node_));
operation_handlers_ = std::make_unique<handlers::OperationHandlers>(*handler_ctx_);
config_handlers_ = std::make_unique<handlers::ConfigHandlers>(*handler_ctx_);
fault_handlers_ = std::make_unique<handlers::FaultHandlers>(*handler_ctx_);
Expand Down
Loading
Loading