AutoAPMS
Streamlining behaviors in ROS 2
Loading...
Searching...
No Matches
ros_action_node.hpp
1// Copyright 2024 Robin Müller
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15#pragma once
16
17#include <chrono>
18#include <memory>
19#include <string>
20
21#include "action_msgs/srv/cancel_goal.hpp"
22#include "auto_apms_behavior_tree_core/exceptions.hpp"
23#include "auto_apms_behavior_tree_core/node/base/ros_action_node_base.hpp"
24#include "auto_apms_util/logging.hpp"
25#include "rclcpp/executors.hpp"
26#include "rclcpp_action/rclcpp_action.hpp"
27
29{
30
31enum ActionNodeErrorCode
32{
33 SERVER_UNREACHABLE,
34 SEND_GOAL_TIMEOUT,
35 GOAL_REJECTED_BY_SERVER,
36 INVALID_GOAL
37};
38
44inline const char * toStr(const ActionNodeErrorCode & err)
45{
46 switch (err) {
47 case SERVER_UNREACHABLE:
48 return "SERVER_UNREACHABLE";
49 case SEND_GOAL_TIMEOUT:
50 return "SEND_GOAL_TIMEOUT";
51 case GOAL_REJECTED_BY_SERVER:
52 return "GOAL_REJECTED_BY_SERVER";
53 case INVALID_GOAL:
54 return "INVALID_GOAL";
55 }
56 return nullptr;
57}
58
96template <class ActionT>
97class RosActionNode : public RosActionNodeBase
98{
99 using ActionClient = typename rclcpp_action::Client<ActionT>;
100 using ActionClientPtr = std::shared_ptr<ActionClient>;
101 using GoalHandle = typename rclcpp_action::ClientGoalHandle<ActionT>;
102
103 struct ActionClientInstance
104 {
105 ActionClientInstance(
106 rclcpp::Node::SharedPtr node, rclcpp::CallbackGroup::SharedPtr group, const std::string & action_name);
107
108 ActionClientPtr action_client;
109 std::string name;
110 };
111
112public:
113 using ActionType = ActionT;
114 using Goal = typename ActionT::Goal;
115 using Feedback = typename ActionT::Feedback;
116 using WrappedResult = typename rclcpp_action::ClientGoalHandle<ActionT>::WrappedResult;
117 using Config = BT::NodeConfig;
118 using Context = RosNodeContext;
119
129 explicit RosActionNode(const std::string & instance_name, const Config & config, Context context);
130
131 virtual ~RosActionNode() = default;
132
140 static BT::PortsList providedBasicPorts(BT::PortsList addition)
141 {
142 BT::PortsList basic = {BT::InputPort<std::string>("topic", "Name of the ROS 2 action.")};
143 basic.insert(addition.begin(), addition.end());
144 return basic;
145 }
146
151 static BT::PortsList providedPorts() { return providedBasicPorts({}); }
152
160 virtual void onHalt();
161
171 virtual bool setGoal(Goal & goal);
172
183 virtual BT::NodeStatus onResultReceived(const WrappedResult & result);
184
195 virtual BT::NodeStatus onFeedback(const Feedback & feedback);
196
206 virtual BT::NodeStatus onFailure(ActionNodeErrorCode error);
207
214
220 bool createClient(const std::string & action_name);
221
226 std::string getActionName() const;
227
228protected:
229 void halt() override final;
230
231 BT::NodeStatus tick() override final;
232
233private:
234 bool dynamic_client_instance_ = false;
235 std::shared_ptr<ActionClientInstance> client_instance_;
236 std::shared_future<typename GoalHandle::SharedPtr> future_goal_handle_;
237 typename GoalHandle::SharedPtr goal_handle_;
238 rclcpp::Time time_goal_sent_;
239 BT::NodeStatus on_feedback_state_change_;
240 bool goal_response_received_; // We must use this additional flag because goal_handle_ may be nullptr if rejected
241 bool goal_rejected_;
242 bool result_received_;
243 bool cancel_requested_;
244 WrappedResult result_;
245};
246
247// #####################################################################################################################
248// ################################ DEFINITIONS ##############################################
249// #####################################################################################################################
250
251template <class ActionT>
252RosActionNode<ActionT>::ActionClientInstance::ActionClientInstance(
253 rclcpp::Node::SharedPtr node, rclcpp::CallbackGroup::SharedPtr group, const std::string & action_name)
254{
255 action_client = rclcpp_action::create_client<ActionT>(node, action_name, group);
256 name = action_name;
257}
258
259template <class ActionT>
260inline RosActionNode<ActionT>::RosActionNode(const std::string & instance_name, const Config & config, Context context)
261: RosActionNodeBase(instance_name, config, context)
262{
263 // The base class stores the context/logger and applies the node manifest 'port_alias' feature.
264
265 if (const BT::Expected<std::string> expected_name = context_.getTopicName(this)) {
266 createClient(expected_name.value());
267 } else {
268 // We assume that determining the action name requires a blackboard pointer, which cannot be evaluated at
269 // construction time. The expression will be evaluated each time before the node is ticked the first time after
270 // successful execution.
271 dynamic_client_instance_ = true;
272 }
273}
274
275template <class ActionT>
277{
278}
279
280template <class ActionT>
281inline bool RosActionNode<ActionT>::setGoal(Goal & /*goal*/)
282{
283 return true;
284}
285
286template <class ActionT>
287inline BT::NodeStatus RosActionNode<ActionT>::onResultReceived(const WrappedResult & result)
288{
289 std::string result_str;
290 switch (result.code) {
291 case rclcpp_action::ResultCode::ABORTED:
292 result_str = "ABORTED";
293 break;
294 case rclcpp_action::ResultCode::CANCELED:
295 result_str = "CANCELED";
296 break;
297 case rclcpp_action::ResultCode::SUCCEEDED:
298 result_str = "SUCCEEDED";
299 break;
300 case rclcpp_action::ResultCode::UNKNOWN:
301 result_str = "UNKNOWN";
302 break;
303 }
304 RCLCPP_DEBUG(
305 logger_, "%s - Goal completed. Received result %s.", context_.getFullyQualifiedTreeNodeName(this).c_str(),
306 result_str.c_str());
307 if (result.code == rclcpp_action::ResultCode::SUCCEEDED) return BT::NodeStatus::SUCCESS;
308 if (cancel_requested_ && result.code == rclcpp_action::ResultCode::CANCELED) return BT::NodeStatus::SUCCESS;
309 return BT::NodeStatus::FAILURE;
310}
311
312template <class ActionT>
313inline BT::NodeStatus RosActionNode<ActionT>::onFeedback(const Feedback & /*feedback*/)
314{
315 return BT::NodeStatus::RUNNING;
316}
317
318template <class ActionT>
319inline BT::NodeStatus RosActionNode<ActionT>::onFailure(ActionNodeErrorCode error)
320{
321 const std::string msg = context_.getFullyQualifiedTreeNodeName(this) + " - Unexpected error " +
322 std::to_string(error) + ": " + toStr(error) + ".";
323 RCLCPP_ERROR_STREAM(logger_, msg);
324 throw exceptions::RosNodeError(msg);
325}
326
327template <class T>
329{
330 rclcpp::executors::SingleThreadedExecutor::SharedPtr executor_ptr = context_.getWaitablesExecutor();
331 if (!executor_ptr) {
332 throw exceptions::RosNodeError(
333 context_.getFullyQualifiedTreeNodeName(this) + " - Cannot cancel goal for action '" + client_instance_->name +
334 "' since the pointer to the associated ROS 2 executor expired.");
335 }
336
337 if (future_goal_handle_.valid()) {
338 RCLCPP_DEBUG(
339 logger_, "%s - Awaiting goal response before trying to cancel goal...",
340 context_.getFullyQualifiedTreeNodeName(this).c_str());
341 // Here the discussion is if we should block or put a timer for the waiting
342 const rclcpp::FutureReturnCode ret =
343 executor_ptr->spin_until_future_complete(future_goal_handle_, context_.getRegistrationOptions().request_timeout);
344 if (ret != rclcpp::FutureReturnCode::SUCCESS) {
345 // Do nothing in case of INTERRUPT or TIMEOUT
346 return;
347 }
348 goal_handle_ = future_goal_handle_.get();
349 future_goal_handle_ = {};
350 goal_rejected_ = goal_handle_ == nullptr;
351 }
352
353 // If goal was rejected or handle has been invalidated, we do not need to cancel
354 if (goal_rejected_) {
355 RCLCPP_DEBUG(
356 logger_, "%s - Goal was rejected. Nothing to cancel.", context_.getFullyQualifiedTreeNodeName(this).c_str());
357 return;
358 };
359
360 // If goal was accepted, but goal handle is nullptr, result callback was already called which means that the goal has
361 // already reached a terminal state
362 if (!goal_handle_) {
363 RCLCPP_DEBUG(
364 logger_, "%s - Goal has already reached a terminal state. Nothing to cancel.",
365 context_.getFullyQualifiedTreeNodeName(this).c_str());
366 return;
367 };
368
369 const std::string uuid_str = rclcpp_action::to_string(goal_handle_->get_goal_id());
370 RCLCPP_DEBUG(
371 logger_, "%s - Canceling goal %s for action '%s'.", context_.getFullyQualifiedTreeNodeName(this).c_str(),
372 uuid_str.c_str(), client_instance_->name.c_str());
373
374 // Send the cancellation request
375 std::shared_future<std::shared_ptr<typename ActionClient::CancelResponse>> future_cancel_response =
376 client_instance_->action_client->async_cancel_goal(goal_handle_);
377 if (const auto code = executor_ptr->spin_until_future_complete(
378 future_cancel_response, context_.getRegistrationOptions().request_timeout);
379 code != rclcpp::FutureReturnCode::SUCCESS) {
380 RCLCPP_WARN(
381 logger_, "%s - Failed to wait for response for cancellation request (Code: %s).",
382 context_.getFullyQualifiedTreeNodeName(this).c_str(), rclcpp::to_string(code).c_str());
383
384 // Make sure goal handle is invalidated
385 goal_handle_ = nullptr;
386 return;
387 }
388
389 // Check the response for the cancellation request
390 if (!future_cancel_response.get()) {
391 throw std::logic_error("Shared pointer to cancel response is nullptr.");
392 }
393 typename ActionClient::CancelResponse cancel_response = *future_cancel_response.get();
394 std::string cancel_response_str;
395 switch (cancel_response.return_code) {
396 case action_msgs::srv::CancelGoal::Response::ERROR_REJECTED:
397 cancel_response_str = "ERROR_REJECTED";
398 break;
399 case action_msgs::srv::CancelGoal::Response::ERROR_UNKNOWN_GOAL_ID:
400 cancel_response_str = "ERROR_UNKNOWN_GOAL_ID";
401 break;
402 case action_msgs::srv::CancelGoal::Response::ERROR_GOAL_TERMINATED:
403 cancel_response_str = "ERROR_GOAL_TERMINATED";
404 break;
405 default:
406 cancel_response_str = "ERROR_NONE";
407 break;
408 }
409 if (cancel_response.return_code == action_msgs::srv::CancelGoal::Response::ERROR_NONE) {
410 RCLCPP_DEBUG(
411 logger_,
412 "%s - Cancellation request of goal %s for action '%s' was accepted (Response: %s). Awaiting completion...",
413 context_.getFullyQualifiedTreeNodeName(this).c_str(),
414 rclcpp_action::to_string(goal_handle_->get_goal_id()).c_str(), client_instance_->name.c_str(),
415 cancel_response_str.c_str());
416
417 // Wait for the cancellation to be complete (goal result received)
418 std::shared_future<WrappedResult> future_goal_result =
419 client_instance_->action_client->async_get_result(goal_handle_);
420 if (const auto code = executor_ptr->spin_until_future_complete(
421 future_goal_result, context_.getRegistrationOptions().request_timeout);
422 code == rclcpp::FutureReturnCode::SUCCESS) {
423 RCLCPP_DEBUG(
424 logger_, "%s - Goal %s for action '%s' was cancelled successfully.",
425 context_.getFullyQualifiedTreeNodeName(this).c_str(), uuid_str.c_str(), client_instance_->name.c_str());
426 } else {
427 RCLCPP_WARN(
428 logger_, "%s - Failed to wait until cancellation completed (Code: %s).",
429 context_.getFullyQualifiedTreeNodeName(this).c_str(), rclcpp::to_string(code).c_str());
430 }
431 } else {
432 // The cancellation request was rejected. If this was due to the goal having terminated normally before the request
433 // was processed by the server, we consider the cancellation as a success. Otherwise we warn.
434 if (cancel_response.return_code == action_msgs::srv::CancelGoal::Response::ERROR_GOAL_TERMINATED) {
435 RCLCPP_DEBUG(
436 logger_, "%s - Goal %s for action '%s' has already terminated (Response: %s). Nothing to cancel.",
437 context_.getFullyQualifiedTreeNodeName(this).c_str(), uuid_str.c_str(), client_instance_->name.c_str(),
438 cancel_response_str.c_str());
439 } else {
440 RCLCPP_WARN(
441 logger_, "%s - Cancellation request was rejected (Response: %s).",
442 context_.getFullyQualifiedTreeNodeName(this).c_str(), cancel_response_str.c_str());
443 }
444 }
445
446 // Make sure goal handle is invalidated
447 goal_handle_ = nullptr;
448}
449
450template <class T>
451inline void RosActionNode<T>::halt()
452{
453 if (status() == BT::NodeStatus::RUNNING) {
454 cancel_requested_ = true;
455 onHalt();
456 cancelGoal();
457 resetStatus();
458 }
459}
460
461template <class T>
462inline BT::NodeStatus RosActionNode<T>::tick()
463{
464 if (!rclcpp::ok()) {
465 halt();
466 throw exceptions::RosNodeError(
467 context_.getFullyQualifiedTreeNodeName(this) + " - ROS 2 context has been shut down.");
468 }
469
470 // If client has been set up in derived constructor, event though this constructor couldn't, we discard the intention
471 // of dynamically creating the client
472 if (dynamic_client_instance_ && client_instance_) {
473 dynamic_client_instance_ = false;
474 }
475
476 // Try again to create the client on first tick if this was not possible during construction or if client should be
477 // created from a blackboard entry on the start of every iteration
478 if (status() == BT::NodeStatus::IDLE && dynamic_client_instance_) {
479 const BT::Expected<std::string> expected_name = context_.getTopicName(this);
480 if (expected_name) {
481 createClient(expected_name.value());
482 } else {
483 throw exceptions::RosNodeError(
484 context_.getFullyQualifiedTreeNodeName(this) +
485 " - Cannot create the action client because the action name couldn't be resolved using "
486 "the expression specified by the node's registration parameters (" +
487 NodeRegistrationOptions::PARAM_NAME_ROS2TOPIC + ": " + context_.getRegistrationOptions().topic +
488 "). Error message: " + expected_name.error());
489 }
490 }
491
492 if (!client_instance_) {
493 throw exceptions::RosNodeError(context_.getFullyQualifiedTreeNodeName(this) + " - client_instance_ is nullptr.");
494 }
495
496 auto & action_client = client_instance_->action_client;
497
498 //------------------------------------------
499 auto check_status = [this](BT::NodeStatus status) {
500 if (!isStatusCompleted(status)) {
501 throw exceptions::RosNodeError(
502 context_.getFullyQualifiedTreeNodeName(this) + " - The callback must return either SUCCESS or FAILURE.");
503 }
504 return status;
505 };
506
507 // first step to be done only at the beginning of the Action
508 if (status() == BT::NodeStatus::IDLE) {
509 setStatus(BT::NodeStatus::RUNNING);
510
511 goal_response_received_ = false;
512 goal_rejected_ = false;
513 result_received_ = false;
514 cancel_requested_ = false;
515 on_feedback_state_change_ = BT::NodeStatus::RUNNING;
516 result_ = {};
517
518 // Check if server is ready
519 if (!action_client->action_server_is_ready()) {
520 return onFailure(SERVER_UNREACHABLE);
521 }
522
523 Goal goal;
524 if (!setGoal(goal)) {
525 return check_status(onFailure(INVALID_GOAL));
526 }
527
528 typename ActionClient::SendGoalOptions goal_options;
529 goal_options.goal_response_callback = [this](typename GoalHandle::SharedPtr goal_handle) {
530 // Indicate that a goal response has been received and let tick() do the rest
531 this->goal_response_received_ = true;
532 this->goal_rejected_ = goal_handle == nullptr;
533 this->goal_handle_ = goal_handle;
534 };
535 goal_options.feedback_callback =
536 [this](typename GoalHandle::SharedPtr /*goal_handle*/, const std::shared_ptr<const Feedback> feedback) {
537 this->on_feedback_state_change_ = onFeedback(*feedback);
538 if (this->on_feedback_state_change_ == BT::NodeStatus::IDLE) {
539 throw std::logic_error(
540 this->context_.getFullyQualifiedTreeNodeName(this) + " - onFeedback() must not return IDLE.");
541 }
542 this->emitWakeUpSignal();
543 };
544 goal_options.result_callback = [this](const WrappedResult & result) {
545 // The result callback is also invoked when goal is rejected (code: CANCELED), but we only want to call
546 // onResultReceived if the goal was accepted and we want to return prematurely. Therefore, we use the
547 // cancel_requested_ flag.
548 if (this->cancel_requested_) {
549 // If the node is to return prematurely, we must invoke onResultReceived here. The returned status has no effect
550 // in this case.
551 this->onResultReceived(result);
552 }
553 this->result_received_ = true;
554 this->goal_handle_ = nullptr; // Reset internal goal handle when result was received
555 this->result_ = result;
556 this->emitWakeUpSignal();
557 };
558
559 future_goal_handle_ = action_client->async_send_goal(goal, goal_options);
560 time_goal_sent_ = context_.getCurrentTime();
561 return BT::NodeStatus::RUNNING;
562 }
563
564 if (status() == BT::NodeStatus::RUNNING) {
565 // FIRST case: check if the goal request has a timeout as long as goal_response_received_ is false (Is set to true
566 // as soon as a goal response is received)
567 if (!goal_response_received_) {
568 // See if we must time out
569 if ((context_.getCurrentTime() - time_goal_sent_) > context_.getRegistrationOptions().request_timeout) {
570 return check_status(onFailure(SEND_GOAL_TIMEOUT));
571 }
572 return BT::NodeStatus::RUNNING;
573 } else if (future_goal_handle_.valid()) {
574 // We noticed, that a goal response has just been received and have to prepare the next steps now
575 future_goal_handle_ = {}; // Invalidate future since it's obsolete now and it indicates that we've done this step
576
577 if (goal_rejected_) return check_status(onFailure(GOAL_REJECTED_BY_SERVER));
578 RCLCPP_DEBUG(
579 logger_, "%s - Goal %s accepted by server, waiting for result.",
580 context_.getFullyQualifiedTreeNodeName(this).c_str(),
581 rclcpp_action::to_string(goal_handle_->get_goal_id()).c_str());
582 }
583
584 // SECOND case: onFeedback requested a stop
585 if (on_feedback_state_change_ != BT::NodeStatus::RUNNING) {
586 cancel_requested_ = true;
587 cancelGoal();
588 return on_feedback_state_change_;
589 }
590
591 // THIRD case: result received
592 if (result_received_) {
593 return check_status(onResultReceived(result_));
594 }
595 }
596 return BT::NodeStatus::RUNNING;
597}
598
599template <class ActionT>
600inline bool RosActionNode<ActionT>::createClient(const std::string & action_name)
601{
602 if (action_name.empty()) {
603 throw exceptions::RosNodeError(
604 context_.getFullyQualifiedTreeNodeName(this) +
605 " - Argument action_name is empty when trying to create the client.");
606 }
607
608 // Check if the action with given name is already set up
609 if (
610 client_instance_ && action_name == client_instance_->name &&
611 client_instance_->action_client->action_server_is_ready()) {
612 return true;
613 }
614
615 rclcpp::Node::SharedPtr node = context_.getRosNode();
616 rclcpp::CallbackGroup::SharedPtr group = context_.getWaitablesCallbackGroup();
617 if (!group) {
618 throw exceptions::RosNodeError(
619 context_.getFullyQualifiedTreeNodeName(this) +
620 " - The weak pointer to the ROS 2 callback group expired. The tree node doesn't "
621 "take ownership of it.");
622 }
623
624 // Reuse an action client shared across tree nodes with the same action name, or create one on first use.
625 client_instance_ = this->template getSharedEntity<ActionClientInstance>(
626 action_name, [&] { return std::make_shared<ActionClientInstance>(node, group, action_name); });
627
628 bool found = client_instance_->action_client->wait_for_action_server(context_.getRegistrationOptions().wait_timeout);
629 if (!found) {
630 std::string msg = context_.getFullyQualifiedTreeNodeName(this) + " - Action server with name '" +
631 client_instance_->name + "' is not reachable.";
632 if (context_.getRegistrationOptions().allow_unreachable) {
633 RCLCPP_WARN_STREAM(logger_, msg);
634 } else {
635 RCLCPP_ERROR_STREAM(logger_, msg);
636 throw exceptions::RosNodeError(msg);
637 }
638 }
639 return found;
640}
641
642template <class ActionT>
644{
645 if (client_instance_) return client_instance_->name;
646 return "unknown";
647}
648
649} // namespace auto_apms_behavior_tree::core
virtual BT::NodeStatus onFeedback(const Feedback &feedback)
Callback invoked after action feedback was received.
virtual void onHalt()
Callback invoked when the node is halted by the behavior tree.
virtual bool setGoal(Goal &goal)
Set the goal message to be sent to the ROS 2 action.
RosActionNode(const std::string &instance_name, const Config &config, Context context)
Constructor.
virtual BT::NodeStatus onFailure(ActionNodeErrorCode error)
Callback invoked when one of the errors in ActionNodeErrorCode occur.
void cancelGoal()
Synchronous method that sends a request to the server to cancel the current action.
static BT::PortsList providedBasicPorts(BT::PortsList addition)
Derived nodes implementing the static method RosActionNode::providedPorts may call this method to als...
bool createClient(const std::string &action_name)
Create the client of the ROS 2 action.
virtual BT::NodeStatus onResultReceived(const WrappedResult &result)
Callback invoked after the result that is sent by the action server when the goal terminated was rece...
static BT::PortsList providedPorts()
If a behavior tree requires input/output data ports, the developer must define this method accordingl...
std::string getActionName() const
Get the name of the action this node connects with.
std::shared_ptr< InstanceT > getSharedEntity(const std::string &entity_name, const std::function< std::shared_ptr< InstanceT >()> &factory)
Retrieve a process-wide shared ROS 2 entity, creating it via factory on first use.
Additional parameters specific to ROS 2 determined at runtime by TreeBuilder.
Core API for AutoAPMS's behavior tree implementation.
Definition behavior.hpp:32
const char * toStr(const ActionNodeErrorCode &err)
Convert the action error code to string.