Need better understanding of ROS2 Action Client with Python

Hello,

I am currently taking the ROS2 basics in 5 days course. I reached unit 6.2 which explains Action Clients.
I am having difficulties understanding certain lines of codes such as :

def get_result_callback(self, future):
    result = future.result().result
    self.get_logger().info('Result: {0}'.format(result.status))
    rclpy.shutdown()

In the code above, I understood that the ‘future’ variable is an object of class ‘rclpy.task.Future’. However, when I go to the definition of this class (in the ‘task.py’ file of the rclpy package), I can see the ‘result()’ method but I do not understand why do we have to add another ‘.result’ without any parenthesis to get the actual value. Same question for ‘result.status’ since I do not see any method called ‘status’ in the class definition.

I have a similar question for the feedback callback method below :

def feedback_callback(self, feedback_msg):
        feedback = feedback_msg.feedback
        self.get_logger().info(
            'Received feedback: {0}'.format(feedback.feedback))

I did not understand what is the type of the ‘feeback_msg’ variable and I did not understand how we are using ‘feedback_msg.feedback’ and ‘feedback.feedback’ without using any parentheses.

Finally, for the get_logger message :

self.get_logger().info('Result: {0}'.format(result.status))

How does it actually work ? Is there a reason ‘{0}.format()’ was used ? Personally I would have written it as follows:

self.get_logger().info(f'Result: {<variable>}')

You can find the Future class definition below for convenience :

# Copyright 2018 Open Source Robotics Foundation, Inc.
#
# 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.

import inspect
import sys
import threading
import warnings
import weakref


def _fake_weakref():
    """Return None when called to simulate a weak reference that has been garbage collected."""
    return None


class Future:
    """Represent the outcome of a task in the future."""

    def __init__(self, *, executor=None):
        # true if the task is done or cancelled
        self._done = False
        # true if the task is cancelled
        self._cancelled = False
        # the final return value of the handler
        self._result = None
        # An exception raised by the handler when called
        self._exception = None
        self._exception_fetched = False
        # callbacks to be scheduled after this task completes
        self._callbacks = []
        # Lock for threadsafety
        self._lock = threading.Lock()
        # An executor to use when scheduling done callbacks
        self._executor = None
        self._set_executor(executor)

    def __del__(self):
        if self._exception is not None and not self._exception_fetched:
            print(
                'The following exception was never retrieved: ' + str(self._exception),
                file=sys.stderr)

    def __await__(self):
        # Yield if the task is not finished
        while not self._done:
            yield
        return self.result()

    def cancel(self):
        """Request cancellation of the running task if it is not done already."""
        with self._lock:
            if not self._done:
                self._cancelled = True
        self._schedule_or_invoke_done_callbacks()

    def cancelled(self):
        """
        Indicate if the task has been cancelled.

        :return: True if the task was cancelled
        :rtype: bool
        """
        return self._cancelled

    def done(self):
        """
        Indicate if the task has finished executing.

        :return: True if the task is finished or raised while it was executing
        :rtype: bool
        """
        return self._done

    def result(self):
        """
        Get the result of a done task.

        :raises: Exception if one was set during the task.

        :return: The result set by the task
        """
        if self._exception:
            raise self.exception()
        return self._result

    def exception(self):
        """
        Get an exception raised by a done task.

        :return: The exception raised by the task
        """
        self._exception_fetched = True
        return self._exception

    def set_result(self, result):
        """
        Set the result returned by a task.

        :param result: The output of a long running task.
        """
        with self._lock:
            self._result = result
            self._done = True
            self._cancelled = False
        self._schedule_or_invoke_done_callbacks()

    def set_exception(self, exception):
        """
        Set the exception raised by the task.

        :param result: The output of a long running task.
        """
        with self._lock:
            self._exception = exception
            self._exception_fetched = False
            self._done = True
            self._cancelled = False
        self._schedule_or_invoke_done_callbacks()

    def _schedule_or_invoke_done_callbacks(self):
        """
        Schedule done callbacks on the executor if possible, else run them directly.

        This function assumes self._lock is not held.
        """
        with self._lock:
            executor = self._executor()
            callbacks = self._callbacks
            self._callbacks = []

        if executor is not None:
            # Have the executor take care of the callbacks
            for callback in callbacks:
                executor.create_task(callback, self)
        else:
            # No executor, call right away
            for callback in callbacks:
                try:
                    callback(self)
                except Exception as e:
                    # Don't let exceptions be raised because there may be more callbacks to call
                    warnings.warn('Unhandled exception in done callback: {}'.format(e))

    def _set_executor(self, executor):
        """Set the executor this future is associated with."""
        with self._lock:
            if executor is None:
                self._executor = _fake_weakref
            else:
                self._executor = weakref.ref(executor)

    def add_done_callback(self, callback):
        """
        Add a callback to be executed when the task is done.

        Callbacks should not raise exceptions.

        The callback may be called immediately by this method if the future is already done.
        If this happens and the callback raises, the exception will be raised by this method.

        :param callback: a callback taking the future as an agrument to be run when completed
        """
        invoke = False
        with self._lock:
            if self._done:
                executor = self._executor()
                if executor is not None:
                    executor.create_task(callback, self)
                else:
                    invoke = True
            else:
                self._callbacks.append(callback)

        # Invoke when not holding self._lock
        if invoke:
            callback(self)


class Task(Future):
    """
    Execute a function or coroutine.

    This executes either a normal function or a coroutine to completion. On completion it creates
    tasks for any 'done' callbacks.

    This class should only be instantiated by :class:`rclpy.executors.Executor`.
    """

    def __init__(self, handler, args=None, kwargs=None, executor=None):
        super().__init__(executor=executor)
        # _handler is either a normal function or a coroutine
        self._handler = handler
        # Arguments passed into the function
        if args is None:
            args = []
        self._args = args
        if kwargs is None:
            kwargs = {}
        self._kwargs = kwargs
        if inspect.iscoroutinefunction(handler):
            self._handler = handler(*args, **kwargs)
            self._args = None
            self._kwargs = None
        # True while the task is being executed
        self._executing = False
        # Lock acquired to prevent task from executing in parallel with itself
        self._task_lock = threading.Lock()

    def __call__(self):
        """
        Run or resume a task.

        This attempts to execute a handler. If the handler is a coroutine it will attempt to
        await it. If there are done callbacks it will schedule them with the executor.

        The return value of the handler is stored as the task result.
        """
        if self._done or self._executing or not self._task_lock.acquire(blocking=False):
            return
        try:
            if self._done:
                return
            self._executing = True

            if inspect.iscoroutine(self._handler):
                # Execute a coroutine
                try:
                    self._handler.send(None)
                except StopIteration as e:
                    # The coroutine finished; store the result
                    self._handler.close()
                    self.set_result(e.value)
                    self._complete_task()
                except Exception as e:
                    self.set_exception(e)
                    self._complete_task()
            else:
                # Execute a normal function
                try:
                    self.set_result(self._handler(*self._args, **self._kwargs))
                except Exception as e:
                    self.set_exception(e)
                self._complete_task()

            self._executing = False
        finally:
            self._task_lock.release()

    def _complete_task(self):
        """Cleanup after task finished."""
        self._handler = None
        self._args = None
        self._kwargs = None

    def executing(self):
        """
        Check if the task is currently being executed.

        :return: True if the task is currently executing.
        :rtype: bool
        """
        return self._executing

Thank you for your help

Hi @e.na.hatem ,

Just for reference:

user:~$ ros2 interface show t3_action_msg/action/Move
int32 secs
---
string status
---
string feedback

So, what happens is that, when you create a Action Client with its relevant callbacks that uses future, the entire Move action message is shared when you send the goal.
So when you set the goal, just the goal part of the message gets filled and the entire Move message is sent to the action server.
Once the action is completed, the action server sends the entire Move action message back with the result portion of the message filled out.
So when you call future, it is a shared object holding the Move message contents.
future.result() fetches the result, which is the filled data returned from action server but as a future object.
future.result().result would fetch the equivalent of Move.Result().
Finally when you do result.status, you are actually doing something similar to the following:

result = Move.Result()
status = result.status

This is actually similar to declaring the goal message, which is done as:

goal = Move.Goal()
goal.secs = 10

Do you see the similarity? In the goal message we set goal.secs whereas in result.status we access the status value.

The feedback_msg that is used in the feedback_callback function declaration does not use future.
It is just the way ROS2 is designed to handle this data.
So feedback_msg is of type Move.Feedback().
Referring to the interface definition (refer to the top of this post), we can access feedback by doing:

fdbk = Move.Feedback()   # <--- this is same as feedback_msg in the feedback_callback
print(fdbk.feedback)   # <--- this way you can access the feedback variable

It is practically the same. The one with {0} is more understandable than the one with {variable}.
I have always used c-style formatting. I feel that new python-style formatting is just extra words.
But I might start using the new style sometime soon.

I hope this clarified all your doubts. Let me know if you are still unclear.

Regards,
Girish

PS: Sorry to make it long. I want you to understand it better! :grin:

1 Like

Thank you so much @girishkumar.kannan for your prompt and detailed response! The code is much more clear to me now! I just have 2 more doubts about the code.
In the following method that I mentioned earlier:

    def feedback_callback(self, feedback_msg):
        feedback = feedback_msg.feedback
        self.get_logger().info(
            'Received feedback: {0}'.format(feedback.feedback))

If I understood correctly, feedback_msg.feedback is equivalent to Move.Feedback(). And even though the future variable is not used in the feedback_callback method, the feedback_msg variable is still an object of the Future() class. Is that correct ?

Moreover, for the following code below :

def goal_response_callback(self, future):
        # future.result() fetches the result, which is the filled data returned from the action server but as a future object.
        goal_handle = future.result()
        if not goal_handle.accepted:
            self.get_logger().info('Goal rejected :(')
            return

        self.get_logger().info('Goal accepted :)')

        self._get_result_future = goal_handle.get_result_async()
        self._get_result_future.add_done_callback(self.get_result_callback)

Why is future.result() considered as a goal handle in this case ? It’s not very clear to me, but I did not understand how the variable goal_handle is not of class Future() even though it is defined with the line:

goal_handle = future.result()

I am saying that because on the very next line we have:

if not goal_handle.accepted:
            self.get_logger().info('Goal rejected :(')
            return

So, it is as if goal_handle is an instance of the class ClientGoalHandle(). since .accepted is being used in the if-statement, which is a property attribute of ClientGoalHandle().
But how can that be the case if ClientGoalHandle is not even imported at the beginning of the code.

Sorry for the lengthy (and stupid) questions, and thanks for your help.

1 Like

Hi @e.na.hatem ,

In python, the add_done_callback(...) function is associated with future.
Refer: Futures — Python 3.12.1 documentation

If you have noticed in the Action Client program, you would add a add_done_callback() function inside send_goal(...) and goal_response_callback(...) functions.

Inside send_goal() function, you would do add_done_callback(self.goal_response_callback).
Inside goal_response_callback() function, you would do add_done_callback(self.get_result_callback)

Therefore, goal_response_callback() and get_result_callback() functions operate with future.
But, feedback_callback() function is not provided with a future reference. Thus, feedback_msg will not hold a future object type.

No.
feedback_msg is equivalent to Move.Feedback().
feedback_msg.feedback would be equivalent to Move.Feedback().feedback.

As I have mentioned earlier in this post, feedback_msg variable is not associated with future.

Good question! As I have mentioned to you in my earlier post (not this one), when you send the goal using send_goal_async() function, you are actually sending a Move action message object with the goal variable filled, as a “shared” future object to the action server.
Since the sent future object is shared, when the action server receives this object, the accepted flag is set to True.
Therefore, when you access future.result(), you will get the object with the changes done by the action server. When you do future.result(), the current state of all variables in the future object is returned, because this object is shared between the action client and server.
As the next step, you check if goal_handle.accepted is True or False to determine if the goal was accepted by the action server.

Yes, goal_handle would be a reference to Move as a shared future type. It will not explicitly tell you that it belongs to Future() class.

I am not sure from where you got the reference to ClientGoalHandle() method. I have not come across this.

Exactly! I am not sure from where you got this idea that goal_handle is an instance of ClientGoalHandle() class.

No problem!, except for the last part referring to ClientGoalHandle, other questions were not stupid! :wink:

I hope this clarified your recent set of doubts. Let me know anything is still unclear.

Regards,
Girish

PS:
Probably this would be my longest post in my history so far! :smile:

EDIT:
PPS:
I have taken the liberty to change the issue title with a better description, so it is easy for future people to access, if they have the same questions / doubts.

Thanks again @girishkumar.kannan ! Thank you for your detailed answers (and you’re patience :sweat_smile:).

I’m still having a problem understanding the feedback_msg being equivalent to Move.Feedback(). If I compare this with future.result().result in the code below:

def get_result_callback(self, future):
        result = future.result().result
        self.get_logger().info('Result: {0}'.format(result.status))
        rclpy.shutdown()

I understood that future.result().result is the equivalent of Move.Result(). Then, we are accessing the status variable of the result part of the Action message by using result.status.
So, if I apply the same logic to the the code containing the feedback_msg below:

def feedback_callback(self, feedback_msg):
        feedback = feedback_msg.feedback
        self.get_logger().info(
            'Received feedback: {0}'.format(feedback.feedback))

If feedback_msg is equivalent to Move.Feedback(), then by using feedback_msg.feedback, we would be directly accessing the the feedback variable of the Action message and we are storing it in the feedback variable of the feedback_callback method. If that is the case, why would we need to do feedback.feedback instead of just feedback to print the value?

As for the ClientGoalHandle() class, I saw it when I tried to access the definition of the ActionClient() class and the ClientGoalHandle() class was in the same file. The file is located in “client.py” in the “rclpy/action” package. Here is a link to the rclpy GitHub page showing the file.

def goal_response_callback(self, future):
        goal_handle = future.result()
        if not goal_handle.accepted:
            self.get_logger().info('Goal rejected :(')
            return

        self.get_logger().info('Goal accepted :)')

        self._get_result_future = goal_handle.get_result_async()
        self._get_result_future.add_done_callback(self.get_result_callback)

The issue I am having is that I did not see a class variable named accepted in the Future() class (link to the Future() class definition on Github). So, I did not understand how we are actually using it ?
However, I noticed that there is a property attribute named accepted in the ClientGoalHandle() class. This is why I thought maybe goal_handle is an instance of the ClientGoalHandle() class.

Thank you for your help.

Hi @e.na.hatem ,

I did something to explain this better. Here they are:
Warning: This code block contains just the 3 functions !!!

    def goal_response_callback(self, future):
        self.get_logger().info("@goal_resp_cb: future: " + str(type(future)))
        goal_handle = future.result()
        self.get_logger().info("@goal_resp_cb: goal_handle: " + str(type(goal_handle)))
        if not goal_handle.accepted:
            self.get_logger().info('Goal rejected :(')
            return
        self.get_logger().info('Goal accepted :)')
        self._get_result_future = goal_handle.get_result_async()
        self._get_result_future.add_done_callback(self.get_result_callback)

    def get_result_callback(self, future):
        self.get_logger().info("@get_res_cb: future: " + str(type(future)))
        result = future.result().result
        self.get_logger().info("@get_res_cb: result: " + str(type(result)))
        self.get_logger().info('Result: {0}'.format(result.status))
        rclpy.shutdown()

    def feedback_callback(self, feedback_msg):
        self.get_logger().info("@fdbk_cb: feedback_msg: " + str(type(feedback_msg)))
        feedback = feedback_msg.feedback
        self.get_logger().info("@fdbk_cb: feedback: " + str(type(feedback)))
        self.get_logger().info('Received feedback: {0}'.format(feedback.feedback))

Here is the output:

user:~$ ros2 launch my_action_client example62_launch_file.launch.py
[INFO] [launch]: All log files can be found below /home/user/.ros/log/2023-02-18-10-13-45-707318-2_xterm-2220
[INFO] [launch]: Default logging verbosity is set to INFO
[INFO] [example62-1]: process started with pid [2221]
[example62-1] [INFO] [1676715226.720603700] [my_action_client]: @goal_resp_cb: future: <class 'rclpy.task.Future'>
[example62-1] [INFO] [1676715226.721544288] [my_action_client]: @goal_resp_cb: goal_handle: <class 'rclpy.action.client.ClientGoalHandle'>
[example62-1] [INFO] [1676715226.722275756] [my_action_client]: Goal accepted :)
[example62-1] [INFO] [1676715226.723523243] [my_action_client]: @fdbk_cb: feedback_msg: <class 't3_action_msg.action._move.Move_FeedbackMessage'>
[example62-1] [INFO] [1676715226.724140653] [my_action_client]: @fdbk_cb: feedback: <class 't3_action_msg.action._move.Move_Feedback'>
[example62-1] [INFO] [1676715226.725271758] [my_action_client]: Received feedback: Movint to the left left left...
.
[feedback messages removed for brevity] ... [feedback messages removed for brevity] ... [feedback messages removed for brevity]
.
[example62-1] [INFO] [1676715230.687820099] [my_action_client]: @fdbk_cb: feedback_msg: <class 't3_action_msg.action._move.Move_FeedbackMessage'>
[example62-1] [INFO] [1676715230.689329450] [my_action_client]: @fdbk_cb: feedback: <class 't3_action_msg.action._move.Move_Feedback'>
[example62-1] [INFO] [1676715230.690561230] [my_action_client]: Received feedback: Movint to the left left left...
[example62-1] [INFO] [1676715231.687524105] [my_action_client]: @get_res_cb: future: <class 'rclpy.task.Future'>
[example62-1] [INFO] [1676715231.688984889] [my_action_client]: @get_res_cb: result: <class 't3_action_msg.action._move.Move_Result'>
[example62-1] [INFO] [1676715231.690191891] [my_action_client]: Result: Finished action server. Robot moved during 5 seconds
[INFO] [example62-1]: process has finished cleanly [pid 2221]

So, as you can see from the output,

= = = = = = = = = = = = = = = = = = = = = = = = = = = = = =

goal_response_callback()

def goal_response_callback(self, future):
    self.get_logger().info("@goal_resp_cb: future: " + str(type(future)))
    goal_handle = future.result()
    self.get_logger().info("@goal_resp_cb: goal_handle: " + str(type(goal_handle)))
@goal_resp_cb: future: <class 'rclpy.task.Future'>
@goal_resp_cb: goal_handle: <class 'rclpy.action.client.ClientGoalHandle'>

Here, variable future is a future object.
goal_handle is of the ClientGoalHandle type. (Now I see where you got the reference to ClientGoalHandle).
Here is a link to ClientGoalHandle properties: Actions — rclpy 0.6.1 documentation
As I said earlier, this is the shared object that is communicated from Action Client to Action Server.
Therefore, returns the full status of the goal from the action server side.

= = = = = = = = = = = = = = = = = = = = = = = = = = = = = =

get_result_callback()

def get_result_callback(self, future):
    self.get_logger().info("@get_res_cb: future: " + str(type(future)))
    result = future.result().result
    self.get_logger().info("@get_res_cb: result: " + str(type(result)))
@get_res_cb: future: <class 'rclpy.task.Future'>
@get_res_cb: result: <class 't3_action_msg.action._move.Move_Result'>

Here, just as I mentioned earlier, variable future is of future type.
But, result is of type Move.Result().
So, when you do future.result().result, future.result() get the ClientGoalHandle and uses this object to fetch the action result when you do .result on it.
Therefore, as I said, result is equivalent to Move.Result().
In case you are confused, t3_action_msg.action._move.Move_Result is same as Move.Result().
Therefore, you can extract the status by doing result.status.

= = = = = = = = = = = = = = = = = = = = = = = = = = = = = =

feedback_callback()

def feedback_callback(self, feedback_msg):
    self.get_logger().info("@fdbk_cb: feedback_msg: " + str(type(feedback_msg)))
    feedback = feedback_msg.feedback
    self.get_logger().info("@fdbk_cb: feedback: " + str(type(feedback)))
@fdbk_cb: feedback_msg: <class 't3_action_msg.action._move.Move_FeedbackMessage'>
@fdbk_cb: feedback: <class 't3_action_msg.action._move.Move_Feedback'>

Here, as you can see, feedback_msg is not of future type.
feedback_msg seems to be equivalent to Move.FeedbackMessage() [which is same as t3_action_msg.action._move.Move_FeedbackMessage].
This, I believe, is like carrier class for Feedback message.
So we do feedback = feedback_msg.feedback to extract the feedback message.
I was not exactly correct when I explained it earlier.
Therefore, feedback becomes equivalent to Move.Feedback() which is same as t3_action_msg.action._move.Move_Feedback.
Finally you do feedback.feedback to get the actual feedback message.

= = = = = = = = = = = = = = = = = = = = = = = = = = = = = =

I hope this post clarifies all your doubts now.

TIP:
When you do not know what type of object a variable holds, just print out its type.

print(type(variable))
# or for ROS2
self.get_logger().info("variable: " + str(type(variable)))

Regards,
Girish

1 Like

You’re the best @girishkumar.kannan ! Thank you so much!

1 Like

Hi @e.na.hatem ,

You’re welcome! Glad to know that I was able to help you understand this! Happy to help!

Regards,
Girish

1 Like

This topic was automatically closed 3 days after the last reply. New replies are no longer allowed.