SMACC2
Loading...
Searching...
No Matches
cb_action_client_behavior_base.hpp
Go to the documentation of this file.
1// Copyright 2026 RobosoftAI Inc.
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/*****************************************************************************************************************
16 *
17 * Authors: Brett Aldrich, Pablo Inigo Blasco
18 *
19 ******************************************************************************************************************/
20
21#pragma once
22
23#include <atomic>
24#include <chrono>
25
28
29namespace smacc2
30{
31namespace client_behavior_bases
32{
33
34// Generic base for client behaviors that send one goal to a ROS 2 action server
35// (through a CpActionClient<TAction> component) and finish when the action
36// finishes. Derived behaviors fill a Goal in onEntry() and call sendGoal();
37// the base turns the action result into the state-scoped behavior events
38// EvCbSuccess<TDerived, TOrthogonal> / EvCbFailure<TDerived, TOrthogonal>.
39//
40// Lifecycle contract (see the async-thread locking rule in CLAUDE.md):
41// - components are resolved and result signals wired in
42// onStateOrthogonalAllocation, on the state machine thread. Never from the
43// asynchronous onEntry/onExit threads.
44// - a derived class that declares its own onStateOrthogonalAllocation MUST
45// chain to this one (and this one chains to SmaccAsyncClientBehavior's, which
46// installs the event-posting functions - skipping the chain leaves them empty
47// and postSuccessEvent() throws std::bad_function_call).
48// - no machine-scoped events are posted by this base: transition tables react
49// to the EvCb* behavior events, which cannot leak across state transitions.
50template <typename TAction>
52{
53public:
54 using Goal = typename TAction::Goal;
55 using GoalHandle = rclcpp_action::ClientGoalHandle<TAction>;
56 using WrappedResult = typename GoalHandle::WrappedResult;
57 using Feedback = typename TAction::Feedback;
59
60 template <typename TOrthogonal, typename TSourceObject>
85
87
88 // If the state exits while a goal is still in flight (e.g. a keyboard or
89 // timeout transition), cancel it: unlike bt_navigator navigation - where the
90 // next goal implicitly preempts - each behavior/docking server action keeps
91 // executing an abandoned goal, leaving the robot moving under a command
92 // nobody owns. Derived classes overriding onExit must chain to this.
93 void onExit() override
94 {
95 if (goalInFlight_)
96 {
97 RCLCPP_WARN(
98 getLogger(), "[%s] State exited with the action goal still in flight - cancelling",
99 getName().c_str());
100 cancelGoal();
101 }
102 }
103
104protected:
105 // Sends the goal and waits (in the calling asynchronous onEntry thread) for the
106 // server's goal response. Returns true if the goal was accepted. On a
107 // not-ready server, a rejected goal, or a response timeout it posts the
108 // failure event and returns false - the caller can simply return from
109 // onEntry(). This closes the silent-hang gap of a rejected/unanswered goal:
110 // CpActionClient installs no goal_response handling, and a rejected goal
111 // never invokes the result callback.
112 bool sendGoal(Goal & goal)
113 {
114 if (actionClient_ == nullptr || !actionClient_->isServerReady())
115 {
116 RCLCPP_ERROR(
117 getLogger(), "[%s] Action server not available, cannot send goal", getName().c_str());
118 this->postFailureEvent();
119 return false;
120 }
121
122 goalActivitySeen_ = false;
123 auto goalHandleFuture = actionClient_->sendGoal(goal);
124
125 // wait for goal acceptance in short slices so a state exit is honored
126 auto deadline = std::chrono::steady_clock::now() + goalResponseTimeout_;
127 while (!this->isShutdownRequested() && std::chrono::steady_clock::now() < deadline)
128 {
129 // Feedback or a result arriving proves the goal was accepted even if
130 // the goal-response future hasn't been serviced yet: under spin_some, a
131 // high-rate feedback stream (NavigateToPose feeds back continuously
132 // from acceptance) can starve the action client's response processing
133 // for the whole execution. Rejected goals produce no feedback, so their
134 // response resolves the future promptly.
136 {
137 goalInFlight_ = true;
138 return true;
139 }
140
141 if (goalHandleFuture.wait_for(std::chrono::milliseconds(50)) == std::future_status::ready)
142 {
143 if (goalHandleFuture.get() != nullptr)
144 {
145 goalInFlight_ = true; // accepted; the result signals will finish the behavior
146 return true;
147 }
148
149 RCLCPP_ERROR(getLogger(), "[%s] Goal was rejected by the action server", getName().c_str());
150 this->postFailureEvent();
151 return false;
152 }
153 }
154
155 if (!this->isShutdownRequested())
156 {
157 // No response, no feedback, no result within the window: ambiguous, and
158 // observed only under executor starvation with an accepted goal - a
159 // genuine rejection resolves the future in milliseconds. Assume the
160 // goal is running and let the result signals finish the behavior
161 // (fire-and-forget, the pre-template behavior), rather than failing a
162 // healthy mission.
163 RCLCPP_WARN(
164 getLogger(),
165 "[%s] No goal response from the action server after %ld ms; assuming the goal was "
166 "accepted (a rejection responds promptly) - result signals will finish this behavior",
167 getName().c_str(), static_cast<long>(goalResponseTimeout_.count()));
168 goalInFlight_ = true;
169 }
170 return true;
171 }
172
174 {
175 if (actionClient_ != nullptr)
176 {
178 }
179 }
180
181 // Result handlers connected by onStateOrthogonalAllocation. Base
182 // implementations record the result code and post the behavior events;
183 // derived classes may override to customize result handling.
184 virtual void onActionSuccess(const WrappedResult & result)
185 {
186 actionResult_ = result.code;
187 RCLCPP_INFO(getLogger(), "[%s] Action succeeded, propagating success event", getName().c_str());
188 this->postSuccessEvent();
189 }
190
191 virtual void onActionAbort(const WrappedResult & result)
192 {
193 actionResult_ = result.code;
194 RCLCPP_INFO(getLogger(), "[%s] Action failed, propagating failure event", getName().c_str());
195 this->postFailureEvent();
196 }
197
198 // optional: override to consume action feedback (distance traveled etc.)
199 virtual void onActionFeedback(const Feedback & /*feedback*/) {}
200
201 // set from the derived constructor to bind to a named CpActionClient
202 // instance; empty binds the first of matching type. Must be set before
203 // onStateOrthogonalAllocation runs (i.e. NOT in runtimeConfigure, which
204 // executes after allocation)
205 void setActionClientName(std::string name) { actionClientName_ = std::move(name); }
206
208 std::string actionClientName_;
209
210 rclcpp_action::ResultCode actionResult_ = rclcpp_action::ResultCode::UNKNOWN;
211
212 // how long sendGoal waits for the server to accept/reject the goal
213 std::chrono::milliseconds goalResponseTimeout_ = std::chrono::milliseconds(10000);
214
215private:
216 // trampolines wired to the component signals: clear the in-flight flag
217 // regardless of what a derived handler override does, then dispatch to the
218 // overridable virtuals
220 {
221 goalActivitySeen_ = true;
222 goalInFlight_ = false;
223 this->onActionSuccess(result);
224 }
225
227 {
228 goalActivitySeen_ = true;
229 goalInFlight_ = false;
230 this->onActionAbort(result);
231 }
232
233 void dispatchActionFeedback(const Feedback & feedback)
234 {
235 goalActivitySeen_ = true;
236 this->onActionFeedback(feedback);
237 }
238
240 std::atomic<bool> goalInFlight_{false};
241 std::atomic<bool> goalActivitySeen_{false};
242};
243
244} // namespace client_behavior_bases
245} // namespace smacc2
virtual rclcpp::Logger getLogger() const
void requiresComponent(SmaccComponentType *&storage, ComponentRequirement requirementType=ComponentRequirement::SOFT)
bool isShutdownRequested()
onEntry is executed in a new thread. However the current state cannot be left until the onEntry threa...
smacc2::SmaccSignalConnection onSucceeded(void(T::*callback)(const WrappedResult &), T *object)
std::shared_future< typename GoalHandle::SharedPtr > sendGoal(Goal &goal, typename smacc2::SmaccSignal< void(const WrappedResult &)>::WeakPtr resultCallback=typename smacc2::SmaccSignal< void(const WrappedResult &)>::WeakPtr())
smacc2::SmaccSignalConnection onFeedback(void(T::*callback)(const Feedback &), T *object)
smacc2::SmaccSignalConnection onAborted(void(T::*callback)(const WrappedResult &), T *object)
smacc2::SmaccSignalConnection onCancelled(void(T::*callback)(const WrappedResult &), T *object)