diff --git a/README.md b/README.md index f5cb6bb..373033d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@
-YASMIN is a project focused on implementing robot behaviors using Finite State Machines (FSM). It is available for ROS 2, Python and C++. +**YASMIN** is a project focused on implementing robot behaviors using Finite State Machines (FSM). It is available for ROS 2, Python and C++. [![License: MIT](https://img.shields.io/badge/GitHub-GPL--3.0-informational)](https://opensource.org/license/gpl-3-0) [![GitHub release](https://img.shields.io/github/release/uleroboticsgroup/yasmin.svg)](https://github.com/uleroboticsgroup/yasmin/releases) [![Code Size](https://img.shields.io/github/languages/code-size/uleroboticsgroup/yasmin.svg?branch=main)](https://github.com/uleroboticsgroup/yasmin?branch=main) [![Dependencies](https://img.shields.io/librariesio/github/uleroboticsgroup/yasmin?branch=main)](https://libraries.io/github/uleroboticsgroup/yasmin?branch=main) [![Last Commit](https://img.shields.io/github/last-commit/uleroboticsgroup/yasmin.svg)](https://github.com/uleroboticsgroup/yasmin/commits/main) [![GitHub issues](https://img.shields.io/github/issues/uleroboticsgroup/yasmin)](https://github.com/uleroboticsgroup/yasmin/issues) [![GitHub pull requests](https://img.shields.io/github/issues-pr/uleroboticsgroup/yasmin)](https://github.com/uleroboticsgroup/yasmin/pulls) [![Contributors](https://img.shields.io/github/contributors/uleroboticsgroup/yasmin.svg)](https://github.com/uleroboticsgroup/yasmin/graphs/contributors) [![Python Formatter Check](https://github.com/uleroboticsgroup/yasmin/actions/workflows/python-formatter.yml/badge.svg?branch=main)](https://github.com/uleroboticsgroup/yasmin/actions/workflows/python-formatter.yml?branch=main) [![C++ Formatter Check](https://github.com/uleroboticsgroup/yasmin/actions/workflows/cpp-formatter.yml/badge.svg?branch=main)](https://github.com/uleroboticsgroup/yasmin/actions/workflows/cpp-formatter.yml?branch=main) @@ -32,15 +32,15 @@ YASMIN is a project focused on implementing robot behaviors using Finite State M 5. [YASMIN Viewer](#yasmin-viewer) 6. [Citations](#citations) -## Features +## Key Features -- Fully integrated into ROS 2. -- Available for Python and C++. -- Fast prototyping. -- Default states for ROS 2 action and service clients. -- Blackboards are used to share data between states and state machines. -- State machines can be canceled and stopped, which means stopping the current executing state. -- A web viewer is included, which allows monitoring of the execution of the state machines. +- **ROS 2 Integration**: Integrates with ROS 2 for easy deployment and interaction. +- **Support for Python and C++**: Available for both Python and C++, making it flexible for a variety of use cases. +- **Rapid Prototyping**: Designed for fast prototyping, allowing quick iteration of state machine behaviors. +- **Predefined States**: Includes states for interacting with ROS 2 action clients, service clients, and topics. +- **Data Sharing with Blackboards**: Utilizes blackboards for data sharing between states and state machines. +- **State Management**: Supports cancellation and stopping of state machines, including halting the current executing state. +- **Web Viewer**: Features an integrated web viewer for real-time monitoring of state machine execution. ## Installation @@ -119,15 +119,41 @@ from yasmin_ros.ros_logs import set_ros_loggers from yasmin_viewer import YasminViewerPub -# define state Foo +# Define the FooState class, inheriting from the State class class FooState(State): + """ + Represents the Foo state in the state machine. + + Attributes: + counter (int): Counter to track the number of executions of this state. + """ + def __init__(self) -> None: + """ + Initializes the FooState instance, setting up the outcomes. + + Outcomes: + outcome1: Indicates the state should continue to the Bar state. + outcome2: Indicates the state should finish execution and return. + """ super().__init__(["outcome1", "outcome2"]) self.counter = 0 def execute(self, blackboard: Blackboard) -> str: + """ + Executes the logic for the Foo state. + + Args: + blackboard (Blackboard): The shared data structure for states. + + Returns: + str: The outcome of the execution, which can be "outcome1" or "outcome2". + + Raises: + Exception: May raise exceptions related to state execution. + """ yasmin.YASMIN_LOG_INFO("Executing state FOO") - time.sleep(3) + time.sleep(3) # Simulate work by sleeping if self.counter < 3: self.counter += 1 @@ -137,34 +163,64 @@ class FooState(State): return "outcome2" -# define state Bar +# Define the BarState class, inheriting from the State class class BarState(State): + """ + Represents the Bar state in the state machine. + """ + def __init__(self) -> None: + """ + Initializes the BarState instance, setting up the outcome. + + Outcomes: + outcome3: Indicates the state should transition back to the Foo state. + """ super().__init__(outcomes=["outcome3"]) def execute(self, blackboard: Blackboard) -> str: + """ + Executes the logic for the Bar state. + + Args: + blackboard (Blackboard): The shared data structure for states. + + Returns: + str: The outcome of the execution, which will always be "outcome3". + + Raises: + Exception: May raise exceptions related to state execution. + """ yasmin.YASMIN_LOG_INFO("Executing state BAR") - time.sleep(3) + time.sleep(3) # Simulate work by sleeping yasmin.YASMIN_LOG_INFO(blackboard["foo_str"]) return "outcome3" -# main +# Main function to initialize and run the state machine def main(): + """ + The main entry point of the application. + Initializes the ROS 2 environment, sets up the state machine, + and handles execution and termination. + + Raises: + KeyboardInterrupt: If the execution is interrupted by the user. + """ yasmin.YASMIN_LOG_INFO("yasmin_demo") - # init ROS 2 + # Initialize ROS 2 rclpy.init() - # set ROS 2 logs + # Set ROS 2 loggers set_ros_loggers() - # create a FSM + # Create a finite state machine (FSM) sm = StateMachine(outcomes=["outcome4"]) - # add states + # Add states to the FSM sm.add_state( "FOO", FooState(), @@ -180,17 +236,19 @@ def main(): "outcome3": "FOO", }, ) - # pub FSM info + + # Publish FSM information for visualization YasminViewerPub("yasmin_demo", sm) - # execute FSM + # Execute the FSM try: outcome = sm() yasmin.YASMIN_LOG_INFO(outcome) except KeyboardInterrupt: - sm.cancel_state() + if sm.is_running(): + sm.cancel_state() - # shutdown ROS 2 + # Shutdown ROS 2 if it's running if rclpy.ok(): rclpy.shutdown() @@ -229,7 +287,26 @@ from yasmin_viewer import YasminViewerPub class AddTwoIntsState(ServiceState): + """ + A state that calls the AddTwoInts service to add two integers. + + This class is a state in a finite state machine that sends a request + to the AddTwoInts service, retrieves the response, and updates the + blackboard with the result. + + Attributes: + service_type (type): The service type being used (AddTwoInts). + service_name (str): The name of the service. + outcomes (list): The list of possible outcomes for this state. + """ + def __init__(self) -> None: + """ + Initializes the AddTwoIntsState. + + Calls the parent constructor with the specific service type, + service name, request handler, outcomes, and response handler. + """ super().__init__( AddTwoInts, # srv type "/add_two_ints", # service name @@ -239,7 +316,15 @@ class AddTwoIntsState(ServiceState): ) def create_request_handler(self, blackboard: Blackboard) -> AddTwoInts.Request: + """ + Creates the service request from the blackboard data. + Args: + blackboard (Blackboard): The blackboard containing the input values. + + Returns: + AddTwoInts.Request: The request object populated with values from the blackboard. + """ req = AddTwoInts.Request() req.a = blackboard["a"] req.b = blackboard["b"] @@ -248,37 +333,77 @@ class AddTwoIntsState(ServiceState): def response_handler( self, blackboard: Blackboard, response: AddTwoInts.Response ) -> str: + """ + Processes the response from the AddTwoInts service. + + Updates the blackboard with the sum result from the response. + Args: + blackboard (Blackboard): The blackboard to update with the sum. + response (AddTwoInts.Response): The response from the service call. + + Returns: + str: The outcome of the operation, which is "outcome1". + """ blackboard["sum"] = response.sum return "outcome1" def set_ints(blackboard: Blackboard) -> str: + """ + Sets the integer values in the blackboard. + + This function initializes the blackboard with two integer values to be added. + + Args: + blackboard (Blackboard): The blackboard to update with integer values. + + Returns: + str: The outcome of the operation, which is SUCCEED. + """ blackboard["a"] = 10 blackboard["b"] = 5 return SUCCEED def print_sum(blackboard: Blackboard) -> str: + """ + Logs the sum value from the blackboard. + + This function retrieves the sum from the blackboard and logs it. + + Args: + blackboard (Blackboard): The blackboard from which to retrieve the sum. + + Returns: + str: The outcome of the operation, which is SUCCEED. + """ yasmin.YASMIN_LOG_INFO(f"Sum: {blackboard['sum']}") return SUCCEED -# main def main(): + """ + The main function to execute the finite state machine (FSM). + + This function initializes the ROS 2 environment, sets up logging, + creates the FSM with defined states, and executes the FSM. + Raises: + KeyboardInterrupt: If the user interrupts the program. + """ yasmin.YASMIN_LOG_INFO("yasmin_service_client_demo") - # init ROS 2 + # Init ROS 2 rclpy.init() - # set ROS 2 logs + # Set ROS 2 logs set_ros_loggers() - # create a FSM + # Create a FSM sm = StateMachine(outcomes=["outcome4"]) - # add states + # Add states sm.add_state( "SETTING_INTS", CbState([SUCCEED], set_ints), @@ -301,17 +426,18 @@ def main(): }, ) - # pub FSM info + # Publish FSM info YasminViewerPub("YASMIN_SERVICE_CLIENT_DEMO", sm) - # execute FSM + # Execute FSM try: outcome = sm() yasmin.YASMIN_LOG_INFO(outcome) except KeyboardInterrupt: - sm.cancel_state() + if sm.is_running(): + sm.cancel_state() - # shutdown ROS 2 + # Shutdown ROS 2 if rclpy.ok(): rclpy.shutdown() @@ -340,9 +466,7 @@ import rclpy from example_interfaces.action import Fibonacci import yasmin -from yasmin import CbState -from yasmin import Blackboard -from yasmin import StateMachine +from yasmin import CbState, Blackboard, StateMachine from yasmin_ros import ActionState from yasmin_ros.ros_logs import set_ros_loggers from yasmin_ros.basic_outcomes import SUCCEED, ABORT, CANCEL @@ -350,55 +474,150 @@ from yasmin_viewer import YasminViewerPub class FibonacciState(ActionState): + """ + Class representing the state of the Fibonacci action. + + Inherits from ActionState and implements methods to handle the + Fibonacci action in a finite state machine. + + Attributes: + None + """ + def __init__(self) -> None: + """ + Initializes the FibonacciState. + + Sets up the action type and the action name for the Fibonacci + action. Initializes goal, response handler, and feedback + processing callbacks. + + Parameters: + None + + Returns: + None + """ super().__init__( Fibonacci, # action type "/fibonacci", # action name - self.create_goal_handler, # cb to create the goal + self.create_goal_handler, # callback to create the goal None, # outcomes. Includes (SUCCEED, ABORT, CANCEL) - self.response_handler, # cb to process the response - self.print_feedback, # cb to process the feedback + self.response_handler, # callback to process the response + self.print_feedback, # callback to process the feedback ) def create_goal_handler(self, blackboard: Blackboard) -> Fibonacci.Goal: + """ + Creates the goal for the Fibonacci action. + This method retrieves the input value from the blackboard and + populates the Fibonacci goal. + + Parameters: + blackboard (Blackboard): The blackboard containing the state + information. + + Returns: + Fibonacci.Goal: The populated goal object for the Fibonacci action. + + Raises: + KeyError: If the expected key is not present in the blackboard. + """ goal = Fibonacci.Goal() - goal.order = blackboard["n"] + goal.order = blackboard["n"] # Retrieve the input value 'n' from the blackboard return goal - def response_handler( - self, blackboard: Blackboard, response: Fibonacci.Result - ) -> str: + def response_handler(self, blackboard: Blackboard, response: Fibonacci.Result) -> str: + """ + Handles the response from the Fibonacci action. + + This method processes the result of the Fibonacci action and + stores it in the blackboard. + + Parameters: + blackboard (Blackboard): The blackboard to store the result. + response (Fibonacci.Result): The result object from the Fibonacci action. + + Returns: + str: Outcome of the operation, typically SUCCEED. - blackboard["fibo_res"] = response.sequence + Raises: + None + """ + blackboard["fibo_res"] = ( + response.sequence + ) # Store the result sequence in the blackboard return SUCCEED def print_feedback( self, blackboard: Blackboard, feedback: Fibonacci.Feedback ) -> None: + """ + Prints feedback from the Fibonacci action. + + This method logs the partial sequence received during the action. + + Parameters: + blackboard (Blackboard): The blackboard (not used in this method). + feedback (Fibonacci.Feedback): The feedback object from the Fibonacci action. + + Returns: + None + + Raises: + None + """ yasmin.YASMIN_LOG_INFO(f"Received feedback: {list(feedback.sequence)}") def print_result(blackboard: Blackboard) -> str: + """ + Prints the result of the Fibonacci action. + + This function logs the final result stored in the blackboard. + + Parameters: + blackboard (Blackboard): The blackboard containing the result. + + Returns: + str: Outcome of the operation, typically SUCCEED. + + Raises: + None + """ yasmin.YASMIN_LOG_INFO(f"Result: {blackboard['fibo_res']}") return SUCCEED -# main def main(): + """ + Main function to execute the ROS 2 action client demo. + This function initializes the ROS 2 client, sets up the finite state + machine, adds the states, and starts the action processing. + + Parameters: + None + + Returns: + None + + Raises: + KeyboardInterrupt: If the user interrupts the execution. + """ yasmin.YASMIN_LOG_INFO("yasmin_action_client_demo") - # init ROS 2 + # Initialize ROS 2 rclpy.init() - # set ROS 2 logs + # Set up ROS 2 logs set_ros_loggers() - # create a FSM + # Create a finite state machine (FSM) sm = StateMachine(outcomes=["outcome4"]) - # add states + # Add states to the FSM sm.add_state( "CALLING_FIBONACCI", FibonacciState(), @@ -416,21 +635,22 @@ def main(): }, ) - # pub FSM info + # Publish FSM information YasminViewerPub("YASMIN_ACTION_CLIENT_DEMO", sm) - # create an initial blackoard + # Create an initial blackboard with the input value blackboard = Blackboard() - blackboard["n"] = 10 + blackboard["n"] = 10 # Set the Fibonacci order to 10 - # execute FSM + # Execute the FSM try: outcome = sm(blackboard) yasmin.YASMIN_LOG_INFO(outcome) except KeyboardInterrupt: - sm.cancel_state() + if sm.is_running(): + sm.cancel_state() # Cancel the state if interrupted - # shutdown ROS 2 + # Shutdown ROS 2 if rclpy.ok(): rclpy.shutdown() @@ -465,20 +685,64 @@ from yasmin_viewer import YasminViewerPub class PrintOdometryState(MonitorState): + """ + MonitorState subclass to handle Odometry messages. + + This state monitors Odometry messages from the specified ROS topic, + logging them and transitioning based on the number of messages received. + + Attributes: + times (int): The number of messages to monitor before transitioning + to the next outcome. + + Parameters: + times (int): The initial count of how many Odometry messages to + process before changing state. + + Methods: + monitor_handler(blackboard: Blackboard, msg: Odometry) -> str: + Handles incoming Odometry messages, logging the message and + returning the appropriate outcome based on the remaining count. + """ + def __init__(self, times: int) -> None: + """ + Initializes the PrintOdometryState. + + Parameters: + times (int): The number of Odometry messages to monitor before + transitioning to the next outcome. + """ super().__init__( Odometry, # msg type "odom", # topic name ["outcome1", "outcome2"], # outcomes self.monitor_handler, # monitor handler callback - qos=qos_profile_sensor_data, # qos for the topic sbscription - msg_queue=10, # queue of the monitor handler callback - timeout=10, # timeout to wait for msgs in seconds - # if not None, CANCEL outcome is added + qos=qos_profile_sensor_data, # qos for the topic subscription + msg_queue=10, # queue for the monitor handler callback + timeout=10, # timeout to wait for messages in seconds ) self.times = times def monitor_handler(self, blackboard: Blackboard, msg: Odometry) -> str: + """ + Handles incoming Odometry messages. + + This method is called whenever a new Odometry message is received. + It logs the message, decrements the count of messages to process, + and determines the next state outcome. + + Parameters: + blackboard (Blackboard): The shared data storage for states. + msg (Odometry): The incoming Odometry message. + + Returns: + str: The next state outcome, either "outcome1" to continue + monitoring or "outcome2" to transition to the next state. + + Exceptions: + None + """ yasmin.YASMIN_LOG_INFO(msg) self.times -= 1 @@ -489,21 +753,30 @@ class PrintOdometryState(MonitorState): return "outcome1" -# main def main(): + """ + Main function to initialize and run the ROS 2 state machine. + + This function initializes ROS 2, sets up logging, creates a finite state + machine (FSM), adds states to the FSM, and executes the FSM. It handles + cleanup and shutdown of ROS 2 gracefully. + Exceptions: + KeyboardInterrupt: Caught to allow graceful cancellation of the + state machine during execution. + """ yasmin.YASMIN_LOG_INFO("yasmin_monitor_demo") - # init ROS 2 + # Initialize ROS 2 rclpy.init() - # set ROS 2 logs + # Set ROS 2 logs set_ros_loggers() - # create a FSM + # Create a finite state machine (FSM) sm = StateMachine(outcomes=["outcome4"]) - # add states + # Add states to the FSM sm.add_state( "PRINTING_ODOM", PrintOdometryState(5), @@ -514,17 +787,18 @@ def main(): }, ) - # pub FSM info + # Publish FSM information YasminViewerPub("YASMIN_MONITOR_DEMO", sm) - # execute FSM + # Execute FSM try: outcome = sm() yasmin.YASMIN_LOG_INFO(outcome) except KeyboardInterrupt: - sm.cancel_state() + if sm.is_running(): + sm.cancel_state() - # shutdown ROS 2 + # Shutdown ROS 2 if rclpy.ok(): rclpy.shutdown() @@ -546,7 +820,6 @@ $ ros2 run yasmin_demos nav_demo.py ```python import random - import rclpy from geometry_msgs.msg import Pose from nav2_msgs.action import NavigateToPose @@ -560,29 +833,67 @@ from yasmin_ros.ros_logs import set_ros_loggers from yasmin_ros.basic_outcomes import SUCCEED, ABORT, CANCEL from yasmin_viewer import YasminViewerPub -HAS_NEXT = "has_next" -END = "end" +# Constants for state outcomes +HAS_NEXT = "has_next" ##< Indicates there are more waypoints +END = "end" ##< Indicates no more waypoints class Nav2State(ActionState): + """ + ActionState for navigating to a specified pose using ROS 2 Navigation. + + Attributes: + None + + Methods: + create_goal_handler(blackboard: Blackboard) -> NavigateToPose.Goal: + Creates the navigation goal from the blackboard. + """ + def __init__(self) -> None: + """ + Initializes the Nav2State. + + Calls the parent constructor to set up the action with: + - Action type: NavigateToPose + - Action name: /navigate_to_pose + - Callback for goal creation: create_goal_handler + - Outcomes: None, since it will use default outcomes (SUCCEED, ABORT, CANCEL) + """ super().__init__( NavigateToPose, # action type "/navigate_to_pose", # action name - self.create_goal_handler, # cb to create the goal - None, # outcomes. Includes (SUCCEED, ABORT, CANCEL) - None, # cb to process the response + self.create_goal_handler, # callback to create the goal + None, # outcomes + None, # callback to process the response ) def create_goal_handler(self, blackboard: Blackboard) -> NavigateToPose.Goal: + """ + Creates a goal for navigation based on the current pose in the blackboard. + + Args: + blackboard (Blackboard): The blackboard instance holding current state data. + Returns: + NavigateToPose.Goal: The constructed goal for the navigation action. + """ goal = NavigateToPose.Goal() goal.pose.pose = blackboard["pose"] - goal.pose.header.frame_id = "map" + goal.pose.header.frame_id = "map" # Set the reference frame to 'map' return goal def create_waypoints(blackboard: Blackboard) -> str: + """ + Initializes waypoints in the blackboard for navigation. + + Args: + blackboard (Blackboard): The blackboard instance to store waypoints. + + Returns: + str: Outcome indicating success (SUCCEED). + """ blackboard["waypoints"] = { "entrance": [1.25, 6.30, -0.78, 0.67], "bathroom": [4.89, 1.64, 0.0, 1.0], @@ -593,7 +904,16 @@ def create_waypoints(blackboard: Blackboard) -> str: return SUCCEED -def take_random_waypoint(blackboard) -> str: +def take_random_waypoint(blackboard: Blackboard) -> str: + """ + Selects a random set of waypoints from the available waypoints. + + Args: + blackboard (Blackboard): The blackboard instance to store random waypoints. + + Returns: + str: Outcome indicating success (SUCCEED). + """ blackboard["random_waypoints"] = random.sample( list(blackboard["waypoints"].keys()), blackboard["waypoints_num"] ) @@ -601,42 +921,56 @@ def take_random_waypoint(blackboard) -> str: def get_next_waypoint(blackboard: Blackboard) -> str: + """ + Retrieves the next waypoint from the list of random waypoints. + Updates the blackboard with the pose of the next waypoint. + + Args: + blackboard (Blackboard): The blackboard instance holding current state data. + + Returns: + str: Outcome indicating whether there is a next waypoint (HAS_NEXT) or if + navigation is complete (END). + """ if not blackboard["random_waypoints"]: return END - wp_name = blackboard["random_waypoints"].pop(0) - wp = blackboard["waypoints"][wp_name] + wp_name = blackboard["random_waypoints"].pop(0) # Get the next waypoint name + wp = blackboard["waypoints"][wp_name] # Get the waypoint coordinates pose = Pose() pose.position.x = wp[0] pose.position.y = wp[1] - pose.orientation.z = wp[2] pose.orientation.w = wp[3] - blackboard["pose"] = pose + blackboard["pose"] = pose # Update blackboard with new pose blackboard["text"] = f"I have reached waypoint {wp_name}" return HAS_NEXT -# main -def main(): +# main function +def main() -> None: + """ + Initializes the ROS 2 node, sets up state machines for navigation, and executes the FSM. + Handles cleanup and shutdown of the ROS 2 node upon completion. + """ yasmin.YASMIN_LOG_INFO("yasmin_nav2_demo") - # init ROS 2 + # Initialize ROS 2 rclpy.init() - # set ROS 2 logs + # Set ROS 2 loggers for debugging set_ros_loggers() - # create state machines + # Create state machines sm = StateMachine(outcomes=[SUCCEED, ABORT, CANCEL]) nav_sm = StateMachine(outcomes=[SUCCEED, ABORT, CANCEL]) - # add states + # Add states to the state machine sm.add_state( "CREATING_WAYPOINTS", CbState([SUCCEED], create_waypoints), @@ -670,22 +1004,33 @@ def main(): }, ) - # pub FSM info + sm.add_state( + "NAVIGATING", + nav_sm, + transitions={ + SUCCEED: SUCCEED, + CANCEL: CANCEL, + ABORT: ABORT, + }, + ) + + # Publish FSM information for visualization YasminViewerPub("YASMIN_NAV_DEMO", sm) - # execute FSM + # Execute the state machine blackboard = Blackboard() - blackboard["waypoints_num"] = 2 + blackboard["waypoints_num"] = 2 # Set the number of waypoints to navigate try: - outcome = sm(blackboard) + outcome = sm(blackboard) # Run the state machine with the blackboard yasmin.YASMIN_LOG_INFO(outcome) except KeyboardInterrupt: - sm.cancel_state() + sm.cancel_state() # Handle manual interruption - # shutdown ROS 2 + # Shutdown ROS 2 if rclpy.ok(): - rclpy.shutdown() + if sm.is_running(): + rclpy.shutdown() if __name__ == "__main__": @@ -712,7 +1057,6 @@ $ ros2 run yasmin_demos yasmin_demo #include