From 406c842e3d04b16971c4e41f4ba9cb83d00d57b2 Mon Sep 17 00:00:00 2001 From: Marco Lampacrescia Date: Mon, 19 Aug 2024 15:56:39 +0200 Subject: [PATCH 01/23] Add support to BT Input Ports Signed-off-by: Marco Lampacrescia Signed-off-by: Christian Henkel --- docs/source/howto.rst | 86 +++++- .../ros_helpers/ros_services.py | 9 +- .../scxml_helpers/top_level_interpreter.py | 26 +- .../properties.jani | 12 +- .../happy_clients.jani | 8 +- .../test/test_systemtest_scxml_to_jani.py | 2 +- .../src/scxml_converter/bt_converter.py | 105 +++---- .../scxml_converter/scxml_entries/__init__.py | 8 +- .../scxml_converter/scxml_entries/bt_utils.py | 144 +++++++++ .../scxml_entries/ros_utils.py | 288 ++++++++++++++++++ .../scxml_entries/scxml_base.py | 4 + .../scxml_converter/scxml_entries/scxml_bt.py | 144 +++++++++ .../scxml_entries/scxml_data.py | 124 +++++--- .../scxml_entries/scxml_data_model.py | 5 + .../scxml_entries/scxml_executable_entries.py | 151 ++++++--- .../scxml_entries/scxml_param.py | 79 ++--- .../scxml_entries/scxml_root.py | 94 ++++-- .../scxml_entries/scxml_ros_field.py | 44 +-- .../scxml_entries/scxml_ros_service.py | 192 +++++++----- .../scxml_entries/scxml_ros_timer.py | 26 +- .../scxml_entries/scxml_ros_topic.py | 236 ++++++++------ .../scxml_entries/scxml_state.py | 36 ++- .../scxml_entries/scxml_transition.py | 35 ++- .../scxml_converter/scxml_entries/utils.py | 244 ++------------- .../scxml_entries/xml_utils.py | 109 +++++++ scxml_converter/test/_test_data/.gitignore | 1 + .../add_int_srv_example/client_1.scxml | 34 +++ .../gt_plain_scxml/client_1.scxml | 21 ++ .../gt_plain_scxml/server.scxml | 16 + .../add_int_srv_example/server.scxml | 27 ++ .../battery_drainer.scxml | 33 ++ .../battery_manager.scxml | 4 +- .../bt.xml | 0 .../bt_topic_action.scxml | 2 +- .../bt_topic_condition.scxml | 4 +- .../gt_bt_scxml}/10000_TopicCondition.scxml | 6 +- .../gt_bt_scxml}/1001_TopicAction.scxml | 6 +- .../gt_bt_scxml}/bt.scxml | 0 .../gt_parsed_scxml}/battery_drainer.scxml | 8 +- .../gt_parsed_scxml/bt_topic_condition.scxml | 31 ++ .../gt_plain_scxml}/battery_drainer.scxml | 0 .../gt_plain_scxml}/battery_manager.scxml | 0 .../gt_plain_scxml}/bt_topic_action.scxml | 0 .../gt_plain_scxml}/bt_topic_condition.scxml | 0 .../_test_data/bt_ports_blackboard/bt.xml | 8 + .../bt_ports_blackboard/bt_get_number.scxml | 21 ++ .../bt_ports_blackboard/bt_topic_action.scxml | 34 +++ .../test/_test_data/bt_ports_only/bt.xml | 8 + .../bt_ports_only/bt_topic_action.scxml | 38 +++ .../gt_bt_scxml/1000_TopicAction.scxml | 15 + .../gt_bt_scxml/1001_TopicAction.scxml | 15 + .../bt_ports_only/gt_bt_scxml/bt.scxml | 35 +++ .../gt_parsed_scxml/bt_topic_action.scxml | 26 ++ .../gt_plain_scxml/bt_topic_action.scxml | 14 + .../invalid_xmls/battery_drainer.scxml | 0 .../invalid_xmls/bt_topic_action.scxml | 0 .../test/test_systemtest_scxml_entries.py | 137 +++++---- scxml_converter/test/test_systemtest_xml.py | 136 +++++---- 58 files changed, 2062 insertions(+), 829 deletions(-) create mode 100644 scxml_converter/src/scxml_converter/scxml_entries/bt_utils.py create mode 100644 scxml_converter/src/scxml_converter/scxml_entries/ros_utils.py create mode 100644 scxml_converter/src/scxml_converter/scxml_entries/scxml_bt.py create mode 100644 scxml_converter/src/scxml_converter/scxml_entries/xml_utils.py create mode 100644 scxml_converter/test/_test_data/.gitignore create mode 100644 scxml_converter/test/_test_data/add_int_srv_example/client_1.scxml create mode 100644 scxml_converter/test/_test_data/add_int_srv_example/gt_plain_scxml/client_1.scxml create mode 100644 scxml_converter/test/_test_data/add_int_srv_example/gt_plain_scxml/server.scxml create mode 100644 scxml_converter/test/_test_data/add_int_srv_example/server.scxml create mode 100644 scxml_converter/test/_test_data/battery_drainer_w_bt/battery_drainer.scxml rename scxml_converter/test/_test_data/{input_files => battery_drainer_w_bt}/battery_manager.scxml (81%) rename scxml_converter/test/_test_data/{input_files => battery_drainer_w_bt}/bt.xml (100%) rename scxml_converter/test/_test_data/{input_files => battery_drainer_w_bt}/bt_topic_action.scxml (93%) rename scxml_converter/test/_test_data/{input_files => battery_drainer_w_bt}/bt_topic_condition.scxml (86%) rename scxml_converter/test/_test_data/{expected_output_bt_and_plugins => battery_drainer_w_bt/gt_bt_scxml}/10000_TopicCondition.scxml (83%) rename scxml_converter/test/_test_data/{expected_output_bt_and_plugins => battery_drainer_w_bt/gt_bt_scxml}/1001_TopicAction.scxml (79%) rename scxml_converter/test/_test_data/{expected_output_bt_and_plugins => battery_drainer_w_bt/gt_bt_scxml}/bt.scxml (100%) rename scxml_converter/test/_test_data/{input_files => battery_drainer_w_bt/gt_parsed_scxml}/battery_drainer.scxml (78%) create mode 100644 scxml_converter/test/_test_data/battery_drainer_w_bt/gt_parsed_scxml/bt_topic_condition.scxml rename scxml_converter/test/_test_data/{expected_output_ros_to_scxml => battery_drainer_w_bt/gt_plain_scxml}/battery_drainer.scxml (100%) rename scxml_converter/test/_test_data/{expected_output_ros_to_scxml => battery_drainer_w_bt/gt_plain_scxml}/battery_manager.scxml (100%) rename scxml_converter/test/_test_data/{expected_output_ros_to_scxml => battery_drainer_w_bt/gt_plain_scxml}/bt_topic_action.scxml (100%) rename scxml_converter/test/_test_data/{expected_output_ros_to_scxml => battery_drainer_w_bt/gt_plain_scxml}/bt_topic_condition.scxml (100%) create mode 100644 scxml_converter/test/_test_data/bt_ports_blackboard/bt.xml create mode 100644 scxml_converter/test/_test_data/bt_ports_blackboard/bt_get_number.scxml create mode 100644 scxml_converter/test/_test_data/bt_ports_blackboard/bt_topic_action.scxml create mode 100644 scxml_converter/test/_test_data/bt_ports_only/bt.xml create mode 100644 scxml_converter/test/_test_data/bt_ports_only/bt_topic_action.scxml create mode 100644 scxml_converter/test/_test_data/bt_ports_only/gt_bt_scxml/1000_TopicAction.scxml create mode 100644 scxml_converter/test/_test_data/bt_ports_only/gt_bt_scxml/1001_TopicAction.scxml create mode 100644 scxml_converter/test/_test_data/bt_ports_only/gt_bt_scxml/bt.scxml create mode 100644 scxml_converter/test/_test_data/bt_ports_only/gt_parsed_scxml/bt_topic_action.scxml create mode 100644 scxml_converter/test/_test_data/bt_ports_only/gt_plain_scxml/bt_topic_action.scxml rename scxml_converter/test/_test_data/{input_files => }/invalid_xmls/battery_drainer.scxml (100%) rename scxml_converter/test/_test_data/{input_files => }/invalid_xmls/bt_topic_action.scxml (100%) diff --git a/docs/source/howto.rst b/docs/source/howto.rst index 0a78a288..26e0aace 100644 --- a/docs/source/howto.rst +++ b/docs/source/howto.rst @@ -104,11 +104,14 @@ ROS Topics are used to publish (via a ROS Publisher) and receive (via a ROS Subs .. code-block:: xml - + - + -Once created, subscribers and publishers can be referenced using the `topic` name, and can be used in the states to send messages and perform callbacks upon receiving messages: +The two declarations above will create a ROS subscriber called `bool_topic` that reads messages of type `std_msgs/Bool` from the topic `/topic1` and a ROS publisher called `int_topic` that writes messages of type `std_msgs/Int32` on the topic `/topic2`. +The `name` argument is optional, and if not provided, it will be set to the same value as the `topic` argument. + +Once created, subscribers and publishers can be referenced using their names (`bool_topic` and `int_topic`), and can be used in the states to send messages and perform callbacks upon receiving messages: .. code-block:: xml @@ -117,7 +120,7 @@ Once created, subscribers and publishers can be referenced using the `topic` nam - + @@ -125,11 +128,11 @@ Once created, subscribers and publishers can be referenced using the `topic` nam - + - + @@ -154,11 +157,11 @@ The declaration of a ROS Service server and the one of a client can be achieved .. code-block:: xml - + - + -Once created, servers and clients can be referenced using the `service_name` name, and can be used in the states of a SCXML model to provide and request services. +Once created, servers and clients can be referenced using the provided `name` (i.e. `the_srv` and `the_client`), and can be used in the states of a SCXML model to provide and request services. In the following, an exemplary client is provided: .. code-block:: xml @@ -169,16 +172,16 @@ In the following, an exemplary client is provided: - + - + To send a request, the `ros_service_send_request` can be used where any other executable content may be used. -After the server has processed the service, `ros_service_handle_response`, can be used similarly to a SCXML transition and is triggered by the server. +After the server has processed the service, `ros_service_handle_response`, can be used similarly to a SCXML transition and is triggered when a response from the server is received. The data of the request can be accessed with the `_res` field. And here, an example of a server: @@ -190,9 +193,9 @@ And here, an example of a server: - + - + @@ -215,7 +218,60 @@ TODO Creating a SCXML model of a BT plugin ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -TODO +SCXML models of BT plugins can be done similarly to the ones for ROS nodes. However, in BT plugins there are a few special functionalities that are provided: + +* :ref:`BT communication `: A set of special events that are used in each BT plugins for starting a BT Node and provide results. +* :ref:`BT Ports `: A special BT interface to parametrize a specific plugin instance. + + +.. _bt_communication: + +BT Communication +_________________ + +TODO: describe `bt_tick`, `bt_running`, `bt_success`, `bt_failure`. + + +.. _bt_ports: + +BT Ports +________ + +Additionally, when loading a BT plugin in the BT XML Tree, it is possible to configure a specific plugin instance by means of the BT ports. + +As in the case of ROS functionalities, BT Ports need to be declared before being used, to provide the port name and expected type. + +.. code-block:: xml + + + + +Once declared, it is possible to reference to the port in multiple SCXML entries. + +For example, we can use `my_string_port` to define the topic used by a ROS publisher. + +.. code-block:: xml + + + + + + + +Or we can use `start_value` to define the initial value of a variable. + +.. code-block:: xml + + + + + + + + + + +BT ports can also be linked to variables in the `BT Blackboard` by wrapping the variable name in curly braces in the BT xml file. However, this feature is not yet supported. .. _additional_params_howto: diff --git a/jani_generator/src/jani_generator/ros_helpers/ros_services.py b/jani_generator/src/jani_generator/ros_helpers/ros_services.py index 62616c0f..93722eef 100644 --- a/jani_generator/src/jani_generator/ros_helpers/ros_services.py +++ b/jani_generator/src/jani_generator/ros_helpers/ros_services.py @@ -24,11 +24,10 @@ ScxmlDataModel, ScxmlParam, ScxmlRoot, ScxmlSend, ScxmlState, ScxmlTransition) -from scxml_converter.scxml_entries.utils import ( - generate_srv_request_event, generate_srv_response_event, - generate_srv_server_request_event, generate_srv_server_response_event, - get_default_expression_for_type, get_srv_type_params, - sanitize_ros_interface_name) +from scxml_converter.scxml_entries.utils import get_default_expression_for_type +from scxml_converter.scxml_entries.ros_utils import ( + generate_srv_request_event, generate_srv_response_event, generate_srv_server_request_event, + generate_srv_server_response_event, get_srv_type_params, sanitize_ros_interface_name) SRV_PREFIX = "srv_handler_" diff --git a/jani_generator/src/jani_generator/scxml_helpers/top_level_interpreter.py b/jani_generator/src/jani_generator/scxml_helpers/top_level_interpreter.py index f6215993..6ea24aff 100644 --- a/jani_generator/src/jani_generator/scxml_helpers/top_level_interpreter.py +++ b/jani_generator/src/jani_generator/scxml_helpers/top_level_interpreter.py @@ -20,11 +20,10 @@ import json import os from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import List, Optional, Tuple from xml.etree import ElementTree as ET from as2fm_common.common import remove_namespace -from jani_generator.jani_entries import JaniModel from jani_generator.ros_helpers.ros_services import RosService, RosServices from jani_generator.ros_helpers.ros_timer import RosTimer from jani_generator.scxml_helpers.scxml_to_jani import \ @@ -129,34 +128,33 @@ def generate_plain_scxml_models_and_timers( """ Generate plain SCXML models and ROS timers from the full model dictionary. """ - # Convert behavior tree and plugins to ROS-scxml + # Load the skills and components scxml files (ROS-SCXML) scxml_files_to_convert: list = model.skills + model.components + ros_scxmls: List[ScxmlRoot] = [] + for fname in scxml_files_to_convert: + ros_scxmls.append(ScxmlRoot.from_scxml_file(fname)) + # Convert behavior tree and plugins to ROS-SCXML if model.bt is not None: - bt_out_dir = os.path.join(os.path.dirname(model.bt), "generated_bt_scxml") - os.makedirs(bt_out_dir, exist_ok=True) - expanded_bt_plugin_scxmls = bt_converter( - model.bt, model.plugins, bt_out_dir) - scxml_files_to_convert.extend(expanded_bt_plugin_scxmls) - - # Convert ROS-SCXML FSMs to plain SCXML + ros_scxmls.extend(bt_converter(model.bt, model.plugins)) + # Convert the loaded entries to plain SCXML plain_scxml_models = [] all_timers: List[RosTimer] = [] all_services: RosServices = {} - for fname in scxml_files_to_convert: + for scxml_entry in ros_scxmls: plain_scxml, ros_declarations = \ - ScxmlRoot.from_scxml_file(fname).to_plain_scxml_and_declarations() + scxml_entry.to_plain_scxml_and_declarations() # Handle ROS timers for timer_name, timer_rate in ros_declarations._timers.items(): assert timer_name not in all_timers, \ f"Timer {timer_name} already exists." all_timers.append(RosTimer(timer_name, timer_rate)) # Handle ROS Services - for service_name, service_type in ros_declarations._service_clients.items(): + for service_name, service_type in ros_declarations._service_clients.values(): if service_name not in all_services: all_services[service_name] = RosService() all_services[service_name].append_service_client( service_name, service_type, plain_scxml.get_name()) - for service_name, service_type in ros_declarations._service_servers.items(): + for service_name, service_type in ros_declarations._service_servers.values(): if service_name not in all_services: all_services[service_name] = RosService() all_services[service_name].set_service_server( diff --git a/jani_generator/test/_test_data/multiple_senders_same_event/properties.jani b/jani_generator/test/_test_data/multiple_senders_same_event/properties.jani index 9d450983..25029a3e 100644 --- a/jani_generator/test/_test_data/multiple_senders_same_event/properties.jani +++ b/jani_generator/test/_test_data/multiple_senders_same_event/properties.jani @@ -13,19 +13,19 @@ "op": "∧", "left": { "op": "<", - "left": "ros_topic./sender_a_counter.data", + "left": "ros_topic.sender_a_counter.data", "right": 100 }, "right": { "op": "∧", "left": { "op": "<", - "left": "ros_topic./sender_b_counter.data", + "left": "ros_topic.sender_b_counter.data", "right": 100 }, "right": { "op": "<", - "left": "ros_topic./receiver_counter.data", + "left": "ros_topic.receiver_counter.data", "right": 100 } } @@ -35,7 +35,7 @@ "op": "∧", "left": { "op": ">", - "left": "ros_topic./receiver_counter.data", + "left": "ros_topic.receiver_counter.data", "right": 48 }, "right": { @@ -43,8 +43,8 @@ "left": 50, "right": { "op": "+", - "left": "ros_topic./sender_a_counter.data", - "right": "ros_topic./sender_b_counter.data" + "left": "ros_topic.sender_a_counter.data", + "right": "ros_topic.sender_b_counter.data" } } } diff --git a/jani_generator/test/_test_data/ros_add_int_srv_example/happy_clients.jani b/jani_generator/test/_test_data/ros_add_int_srv_example/happy_clients.jani index 418cab82..382a0882 100644 --- a/jani_generator/test/_test_data/ros_add_int_srv_example/happy_clients.jani +++ b/jani_generator/test/_test_data/ros_add_int_srv_example/happy_clients.jani @@ -13,13 +13,13 @@ "op": "∧", "left": { "op": "∧", - "left": "ros_topic./client_1_res.data", - "right": "ros_topic./client_1_res.valid" + "left": "ros_topic.client_1_res.data", + "right": "ros_topic.client_1_res.valid" }, "right": { "op": "∧", - "left": "ros_topic./client_2_res.data", - "right": "ros_topic./client_2_res.valid" + "left": "ros_topic.client_2_res.data", + "right": "ros_topic.client_2_res.valid" } } } diff --git a/jani_generator/test/test_systemtest_scxml_to_jani.py b/jani_generator/test/test_systemtest_scxml_to_jani.py index 45a454db..19fa0348 100644 --- a/jani_generator/test/test_systemtest_scxml_to_jani.py +++ b/jani_generator/test/test_systemtest_scxml_to_jani.py @@ -240,7 +240,7 @@ def test_with_main_fail(self): def test_with_w_bt_main_battery_depleted(self): """Here we expect the property to be *not* satisfied.""" # TODO: Improve properties under evaluation! - self._test_with_main('ros_example_w_bt', 'battery_depleted', False) + self._test_with_main('ros_example_w_bt', 'battery_depleted', False, True) def test_with_w_bt_main_battery_under_twenty(self): """Here we expect the property to be *not* satisfied.""" diff --git a/scxml_converter/src/scxml_converter/bt_converter.py b/scxml_converter/src/scxml_converter/bt_converter.py index e4e6b411..8ba83b9f 100644 --- a/scxml_converter/src/scxml_converter/bt_converter.py +++ b/scxml_converter/src/scxml_converter/bt_converter.py @@ -15,22 +15,21 @@ """ Convert Behavior Trees (BT xml) to SCXML. - - """ +from copy import deepcopy import os -import xml.etree.ElementTree as ET from enum import Enum, auto from typing import List +import re from btlib.bt_to_fsm.bt_to_fsm import Bt2FSM from btlib.bts import xml_to_networkx from btlib.common import NODE_CAT -from scxml_converter.scxml_entries import (RosRateCallback, RosTimeRate, - ScxmlRoot, ScxmlSend, ScxmlState, - ScxmlTransition) +from scxml_converter.scxml_entries import ( + RosRateCallback, RosTimeRate, ScxmlRoot, ScxmlSend, ScxmlState, ScxmlTransition, + RESERVED_BT_PORT_NAMES) class BT_EVENT_TYPE(Enum): @@ -56,34 +55,31 @@ def bt_event_name(node_id: str, event_type: BT_EVENT_TYPE) -> str: def bt_converter( bt_xml_path: str, bt_plugins_scxml_paths: List[str], - output_folder: str -): +) -> List[ScxmlRoot]: """ Convert a Behavior Tree (BT) in XML format to SCXML. Args: bt_xml_path: The path to the Behavior Tree in XML format. bt_plugins_scxml_paths: The paths to the SCXML files of BT plugins. - output_folder: The folder where the SCXML files will be saved. Returns: - A list of the generated SCXML files. + A list of the generated SCXML objects. """ - bt_graph, xpi = xml_to_networkx(bt_xml_path) - generated_files = [] + bt_graph, _ = xml_to_networkx(bt_xml_path) - bt_plugins_scxml = {} + bt_plugins_scxmls = {} for path in bt_plugins_scxml_paths: assert os.path.exists(path), f'SCXML must exist. {path} not found.' - with open(path, 'r', encoding='utf-8') as f: - content = f.read() - xml = ET.fromstring(content) - name = xml.attrib['name'] - assert name not in bt_plugins_scxml, \ - f'Plugin name must be unique. {name} already exists.' - bt_plugins_scxml[name] = content + bt_plugin_scxml = ScxmlRoot.from_scxml_file(path) + bt_plugin_name = bt_plugin_scxml.get_name() + assert bt_plugin_name not in bt_plugins_scxmls, \ + f'Plugin name must be unique. {bt_plugin_name} already exists.' + bt_plugins_scxmls[bt_plugin_name] = bt_plugin_scxml leaf_node_ids = [] + generated_scxmls: List[ScxmlRoot] = [] + # Generate the instances of the plugins used in the BT for node in bt_graph.nodes: assert 'category' in bt_graph.nodes[node], 'Node must have a category.' if bt_graph.nodes[node]['category'] == NODE_CAT.LEAF: @@ -91,45 +87,34 @@ def bt_converter( assert 'ID' in bt_graph.nodes[node], 'Leaf node must have a type.' node_type = bt_graph.nodes[node]['ID'] node_id = node - assert node_type in bt_plugins_scxml, \ + assert node_type in bt_plugins_scxmls, \ f'Leaf node must have a plugin. {node_type} not found.' instance_name = f'{node_id}_{node_type}' - output_fname = os.path.join( - output_folder, f'{instance_name}.scxml') - generated_files.append(output_fname) - this_plugin_content = bt_plugins_scxml[node_type] - event_names_to_replace = [ - f'bt_{t}' for t in [ - 'tick', 'success', 'failure', 'running']] - for event_name in event_names_to_replace: - declaration_old = f'event="{event_name}"' - new_event_name = bt_event_name( - node_id, BT_EVENT_TYPE.from_str(event_name)) - declaration_new = f'event="{new_event_name}"' - this_plugin_content = this_plugin_content.replace( - declaration_old, declaration_new) - # TODO: Replace arguments from the BT xml file. - # TODO: Change name to instance name - with open(output_fname, 'w', encoding='utf-8') as f: - f.write(this_plugin_content) + scxml_plugin_instance: ScxmlRoot = deepcopy(bt_plugins_scxmls[node_type]) + scxml_plugin_instance.set_name(instance_name) + scxml_plugin_instance.instantiate_bt_events(node_id) + bt_ports = [(p_name, p_value) for p_name, p_value in bt_graph.nodes[node].items() + if p_name not in RESERVED_BT_PORT_NAMES] + scxml_plugin_instance.set_bt_ports_values(bt_ports) + scxml_plugin_instance.update_bt_ports_values() + assert scxml_plugin_instance.check_validity(), \ + f"Error: SCXML plugin instance {instance_name} is not valid." + generated_scxmls.append(scxml_plugin_instance) + # Generate the BT SCXML fsm_graph = Bt2FSM(bt_graph).convert() - output_file_bt = os.path.join(output_folder, 'bt.scxml') - generated_files.append(output_file_bt) - - root_tag = ScxmlRoot("bt") + bt_scxml_root = ScxmlRoot("bt") + name_with_id_pattern = re.compile(r"[0-9]+_.+") for node in fsm_graph.nodes: state = ScxmlState(node) - if '_' in node: + node_id = None + if name_with_id_pattern.match(node): node_id = int(node.split('_')[0]) - else: - node_id = None - if node_id and node_id in leaf_node_ids: - state.append_on_entry(ScxmlSend( - bt_event_name(node_id, BT_EVENT_TYPE.TICK))) + if node_id in leaf_node_ids: + state.append_on_entry(ScxmlSend(bt_event_name(node_id, BT_EVENT_TYPE.TICK))) for edge in fsm_graph.edges(node): target = edge[1] transition = ScxmlTransition(target) - if node_id and node_id in leaf_node_ids: + if node_id is not None and node_id in leaf_node_ids: if 'label' not in fsm_graph.edges[edge]: continue label = fsm_graph.edges[edge]['label'] @@ -145,21 +130,17 @@ def bt_converter( transition.add_event(event_name) state.add_transition(transition) if node in ['success', 'failure', 'running']: - state.add_transition( - ScxmlTransition("wait_for_tick")) - root_tag.add_state(state) - + state.add_transition(ScxmlTransition("wait_for_tick")) + bt_scxml_root.add_state(state) + # TODO: Make BT rate configurable, e.g. from main.xml rtr = RosTimeRate("bt_tick", 1.0) - root_tag.add_ros_declaration(rtr) + bt_scxml_root.add_ros_declaration(rtr) wait_for_tick = ScxmlState("wait_for_tick") wait_for_tick.add_transition( RosRateCallback(rtr, "tick")) - root_tag.add_state(wait_for_tick, initial=True) - - assert root_tag.check_validity(), "Error: SCXML root tag is not valid." - - with open(output_file_bt, 'w', encoding='utf-8') as f: - f.write(root_tag.as_xml_string()) + bt_scxml_root.add_state(wait_for_tick, initial=True) + assert bt_scxml_root.check_validity(), "Error: SCXML root tag is not valid." + generated_scxmls.append(bt_scxml_root) - return generated_files + return generated_scxmls diff --git a/scxml_converter/src/scxml_converter/scxml_entries/__init__.py b/scxml_converter/src/scxml_converter/scxml_entries/__init__.py index c827fae1..124266b5 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/__init__.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/__init__.py @@ -1,16 +1,20 @@ # isort: skip_file # Skipping file to avoid circular import problem from .scxml_base import ScxmlBase # noqa: F401 +from .bt_utils import RESERVED_BT_PORT_NAMES # noqa: F401 +from .scxml_bt import ( # noqa: F401 + BtInputPortDeclaration, BtOutputPortDeclaration, BtGetValueInputPort) # noqa: F401 from .scxml_param import ScxmlParam # noqa: F401 from .scxml_ros_field import RosField # noqa: F401 -from .utils import ScxmlRosDeclarationsContainer # noqa: F401 from .scxml_data import ScxmlData # noqa: F401 from .scxml_data_model import ScxmlDataModel # noqa: F401 +from .ros_utils import ScxmlRosDeclarationsContainer # noqa: F401 from .scxml_executable_entries import ScxmlAssign, ScxmlIf, ScxmlSend # noqa: F401 from .scxml_executable_entries import ScxmlExecutableEntry, ScxmlExecutionBody # noqa: F401 from .scxml_executable_entries import ( # noqa: F401 execution_body_from_xml, as_plain_execution_body, # noqa: F401 - execution_entry_from_xml, valid_execution_body) # noqa: F401 + execution_entry_from_xml, valid_execution_body, # noqa: F401 + valid_execution_body_entry_types, instantiate_exec_body_bt_events) # noqa: F401 from .scxml_transition import ScxmlTransition # noqa: F401 from .scxml_ros_topic import ( # noqa: F401 RosTopicPublisher, RosTopicSubscriber, RosTopicCallback, RosTopicPublish) # noqa: F401 diff --git a/scxml_converter/src/scxml_converter/scxml_entries/bt_utils.py b/scxml_converter/src/scxml_converter/scxml_entries/bt_utils.py new file mode 100644 index 00000000..31e1f95c --- /dev/null +++ b/scxml_converter/src/scxml_converter/scxml_entries/bt_utils.py @@ -0,0 +1,144 @@ +# Copyright (c) 2024 - for information on the respective copyright owner +# see the NOTICE file + +# 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. + +"""Collection of SCXML utilities related to BT functionalities.""" + +from typing import Dict, Tuple, Any, Type + +import re + +from scxml_converter.scxml_entries.utils import SCXML_DATA_STR_TO_TYPE + + +VALID_BT_INPUT_PORT_TYPES: Dict[str, Type] = SCXML_DATA_STR_TO_TYPE | {"string": str} +VALID_BT_OUTPUT_PORT_TYPES: Dict[str, Type] = SCXML_DATA_STR_TO_TYPE + +"""List of keys that are not going to be read as BT ports from the BT XML definition.""" +RESERVED_BT_PORT_NAMES = ['NAME', 'ID', 'category'] + + +def is_bt_event(event_name: str) -> bool: + """Given an event name, returns whether it is related to a BT event or not.""" + bt_events = [f"bt_{suffix}" for suffix in ["tick", "running", "success", "failure"]] + return event_name in bt_events + + +def replace_bt_event(event_name: str, instance_id: str) -> str: + """Given a BT event name, returns the same event including the BT node instance.""" + assert is_bt_event(event_name), "Error: BT event instantiation: invalid BT event name." + return f"bt_{instance_id}_{event_name.removeprefix('bt_')}" + + +def is_blackboard_reference(port_value: str) -> bool: + """ + Check if a port value is a reference to a Blackboard variable. + + We consider a string to reference Blackboard variable if it is enclosed in curly braces. + """ + return re.match(r"\{.+\}", port_value) is not None + + +class BtPortsHandler: + """Collector for declared BT ports and their assigned value.""" + @staticmethod + def check_port_name_allowed(port_name: str) -> None: + """Check if the port name is allowed.""" + assert port_name not in RESERVED_BT_PORT_NAMES, \ + f"Error: Port name {port_name} is reserved in BT" + + def __init__(self): + # For each port name, store the port type string and value. + self._in_ports: Dict[str, Tuple[str, str]] = {} + self._out_ports: Dict[str, Tuple[Type, str]] = {} + + def in_port_exists(self, port_name: str) -> bool: + """Check if an input port exists.""" + return port_name in self._in_ports + + def out_port_exists(self, port_name: str) -> bool: + """Check if an output port exists.""" + return port_name in self._out_ports + + def declare_in_port(self, port_name: str, port_type: str) -> None: + """Add an input port to the handler.""" + BtPortsHandler.check_port_name_allowed(port_name) + assert not self.in_port_exists(port_name), \ + f"Error: Input port {port_name} already declared as input port." + assert not self.out_port_exists(port_name), \ + f"Error: Input port {port_name} already declared as output port." + assert port_type in VALID_BT_INPUT_PORT_TYPES, \ + f"Error: Unsupported input port type {port_type}." + self._in_ports[port_name] = (port_type, None) + + def declare_out_port(self, port_name: str, port_type: str) -> None: + """Add an output port to the handler.""" + BtPortsHandler.check_port_name_allowed(port_name) + assert not self.out_port_exists(port_name), \ + f"Error: Output port {port_name} already declared as output port." + assert not self.in_port_exists(port_name), \ + f"Error: Output port {port_name} already declared as input port." + assert port_type in VALID_BT_OUTPUT_PORT_TYPES, \ + f"Error: Unsupported output port type {port_type}." + self._out_ports[port_name] = (port_type, None) + + def get_port_value(self, port_name: str) -> Any: + """Get the value of a port.""" + if self.in_port_exists(port_name): + return self.get_in_port_value(port_name) + elif self.out_port_exists(port_name): + return self.get_out_port_value(port_name) + else: + raise RuntimeError(f"Error: Port {port_name} is not declared.") + + def get_in_port_value(self, port_name: str) -> str: + """Get the value of an input port.""" + assert self.in_port_exists(port_name), \ + f"Error: Port {port_name} is not declared as input port." + port_value = self._in_ports[port_name][1] + assert port_value is not None, f"Error: Port {port_name} has no assigned value." + return port_value + + def get_out_port_value(self, port_name: str) -> str: + """Get the value of an output port.""" + raise NotImplementedError("Error: Output ports are not supported yet.") + + def set_port_value(self, port_name: str, port_value: Any) -> None: + """Set the value of a port.""" + if self.in_port_exists(port_name): + self._set_in_port_value(port_name, port_value) + elif self.out_port_exists(port_name): + self._set_out_port_value(port_name, port_value) + else: + # The 'name' port can be set even if undeclared, since it defines the node name in BT. + if port_name != "name": + raise RuntimeError(f"Error: Port {port_name} is not declared.") + + def _set_in_port_value(self, port_name: str, port_value: str): + """Set the value of an input port.""" + assert self.in_port_exists(port_name), \ + f"Error: Port {port_name} is not declared as input port." + assert self._in_ports[port_name][1] is None, \ + f"Error: Port {port_name} already has a value assigned." + port_type = self._in_ports[port_name][0] + # Ensure this is not a Blackboard variable reference: currently not supported + if is_blackboard_reference(port_value): + raise NotImplementedError( + f"Error: {port_value} assigns a Blackboard variable to {port_name}. " + "This is not yet supported.") + self._in_ports[port_name] = (port_type, port_value) + + def _set_out_port_value(self, port_name: str, port_value: Any): + """Set the value of an output port.""" + raise NotImplementedError("Error: Output ports are not supported yet.") diff --git a/scxml_converter/src/scxml_converter/scxml_entries/ros_utils.py b/scxml_converter/src/scxml_converter/scxml_entries/ros_utils.py new file mode 100644 index 00000000..56cd6c16 --- /dev/null +++ b/scxml_converter/src/scxml_converter/scxml_entries/ros_utils.py @@ -0,0 +1,288 @@ +# Copyright (c) 2024 - for information on the respective copyright owner +# see the NOTICE file + +# 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. + +"""Collection of SCXML utilities related to ROS functionalities.""" + +from typing import Dict, List, Optional, Tuple + +from scxml_converter.scxml_entries.scxml_ros_field import RosField + +from scxml_converter.scxml_entries.utils import all_non_empty_strings + + +MSG_TYPE_SUBSTITUTIONS = { + "boolean": "bool", +} + +BASIC_FIELD_TYPES = ['boolean', + 'int8', 'int16', 'int32', 'int64', + 'float', 'double'] + +"""Container for the ROS interface (e.g. topic or service) name and the related type""" +RosInterfaceAndType = Tuple[str, str] + + +def is_ros_type_known(type_definition: str, ros_interface: str) -> bool: + """ + Check if python can import the provided type definition. + + :param type_definition: The type definition to check (e.g. std_msgs/Empty). + """ + if not (isinstance(type_definition, str) and type_definition.count("/") == 1): + return False + interface_ns, interface_type = type_definition.split("/") + if len(interface_ns) == 0 or len(interface_type) == 0: + return False + assert ros_interface in ["msg", "srv"], "Error: SCXML ROS declarations: unknown ROS interface." + try: + interface_importer = __import__(interface_ns + f'.{ros_interface}', fromlist=['']) + _ = getattr(interface_importer, interface_type) + except (ImportError, AttributeError): + print(f"Error: SCXML ROS declarations: topic type {type_definition} not found.") + return False + return True + + +def is_msg_type_known(topic_definition: str) -> bool: + """Check if python can import the provided topic definition.""" + return is_ros_type_known(topic_definition, "msg") + + +def is_srv_type_known(service_definition: str) -> bool: + """Check if python can import the provided service definition.""" + return is_ros_type_known(service_definition, "srv") + + +def get_srv_type_params(service_definition: str) -> Tuple[Dict[str, str], Dict[str, str]]: + """ + Get the data fields of a service request and response type as pairs of name and type objects. + """ + assert is_srv_type_known(service_definition), \ + f"Error: SCXML ROS declarations: service type {service_definition} not found." + interface_ns, interface_type = service_definition.split("/") + srv_module = __import__(interface_ns + '.srv', fromlist=['']) + srv_class = getattr(srv_module, interface_type) + + # TODO: Fields can be nested. Look AS2FM/scxml_converter/src/scxml_converter/scxml_converter.py + req = srv_class.Request.get_fields_and_field_types() + for key in req.keys(): + # TODO: Support nested fields + assert req[key] in BASIC_FIELD_TYPES, \ + f"Error: SCXML ROS declarations: service request type {req[key]} isn't a basic field." + req[key] = MSG_TYPE_SUBSTITUTIONS.get(req[key], req[key]) + + res = srv_class.Response.get_fields_and_field_types() + for key in res.keys(): + assert res[key] in BASIC_FIELD_TYPES, \ + "Error: SCXML ROS declarations: service response type contains non-basic fields." + res[key] = MSG_TYPE_SUBSTITUTIONS.get(res[key], res[key]) + + return req, res + + +def replace_ros_interface_expression(msg_expr: str) -> str: + """Convert a ROS interface expression (msg, req, res) to plain SCXML (event).""" + scxml_prefix = "_event." + # TODO: Use regex and ensure no other valid character exists before the initial underscore + for ros_prefix in ["_msg.", "_req.", "_res."]: + msg_expr = msg_expr.replace(ros_prefix, scxml_prefix) + return msg_expr + + +def sanitize_ros_interface_name(interface_name: str) -> str: + """Replace slashes in a ROS interface name.""" + assert isinstance(interface_name, str), \ + "Error: ROS interface sanitizer: interface name must be a string." + # Remove potential prepended slash + interface_name = interface_name.removeprefix("/") + assert len(interface_name) > 0, \ + "Error: ROS interface sanitizer: interface name must not be empty." + assert interface_name.count(" ") == 0, \ + "Error: ROS interface sanitizer: interface name must not contain spaces." + return interface_name.replace("/", "__") + + +def generate_srv_request_event(service_name: str, automaton_name: str) -> str: + """Generate the name of the event that triggers a service request.""" + return f"srv_{sanitize_ros_interface_name(service_name)}_req_client_{automaton_name}" + + +def generate_srv_response_event(service_name: str, automaton_name: str) -> str: + """Generate the name of the event that provides the service response.""" + return f"srv_{sanitize_ros_interface_name(service_name)}_response_client_{automaton_name}" + + +def generate_srv_server_request_event(service_name: str) -> str: + """Generate the name of the event that makes a service server start processing a request.""" + return f"srv_{sanitize_ros_interface_name(service_name)}_request" + + +def generate_srv_server_response_event(service_name: str) -> str: + """Generate the name of the event that makes a service server send a response.""" + return f"srv_{sanitize_ros_interface_name(service_name)}_response" + + +class ScxmlRosDeclarationsContainer: + """Object that contains a description of the ROS declarations in the SCXML root.""" + + def __init__(self, automaton_name: str): + """Constructor of container. + + :automaton_name: Name of the automaton these declarations belong to. + """ + self._automaton_name: str = automaton_name + # Dict of publishers and subscribers: topic name -> type + self._publishers: Dict[str, RosInterfaceAndType] = {} + self._subscribers: Dict[str, RosInterfaceAndType] = {} + self._service_servers: Dict[str, RosInterfaceAndType] = {} + self._service_clients: Dict[str, RosInterfaceAndType] = {} + self._timers: Dict[str, float] = {} + + def get_automaton_name(self) -> str: + """Get name of the automaton that these declarations are defined in.""" + return self._automaton_name + + def append_publisher(self, pub_name: str, topic_name: str, topic_type: str) -> None: + assert all_non_empty_strings(pub_name, topic_name, topic_type), \ + "Error: ROS declarations: publisher name, topic name and type must be strings." + assert pub_name not in self._publishers, \ + f"Error: ROS declarations: topic publisher {pub_name} already declared." + self._publishers[pub_name] = (topic_name, topic_type) + + def append_subscriber(self, sub_name: str, topic_name: str, topic_type: str) -> None: + assert all_non_empty_strings(sub_name, topic_name, topic_type), \ + "Error: ROS declarations: subscriber name, topic name and type must be strings." + assert sub_name not in self._subscribers, \ + f"Error: ROS declarations: topic subscriber {sub_name} already declared." + self._subscribers[sub_name] = (topic_name, topic_type) + + def append_service_client(self, client_name: str, service_name: str, service_type: str) -> None: + assert all_non_empty_strings(client_name, service_name, service_type), \ + "Error: ROS declarations: client name, service name and type must be strings." + assert client_name not in self._service_clients, \ + f"Error: ROS declarations: service client {client_name} already declared." + self._service_clients[client_name] = (service_name, service_type) + + def append_service_server(self, server_name: str, service_name: str, service_type: str) -> None: + assert all_non_empty_strings(server_name, service_name, service_type), \ + "Error: ROS declarations: server name, service name and type must be strings." + assert server_name not in self._service_servers, \ + f"Error: ROS declarations: service server {server_name} already declared." + self._service_servers[server_name] = (service_name, service_type) + + def append_timer(self, timer_name: str, timer_rate: float) -> None: + assert isinstance(timer_name, str), "Error: ROS declarations: timer name must be a string." + assert isinstance(timer_rate, float) and timer_rate > 0, \ + "Error: ROS declarations: timer rate must be a positive number." + assert timer_name not in self._timers, \ + f"Error: ROS declarations: timer {timer_name} already declared." + self._timers[timer_name] = timer_rate + + def is_publisher_defined(self, pub_name: str) -> bool: + return pub_name in self._publishers + + def is_subscriber_defined(self, sub_name: str) -> bool: + return sub_name in self._subscribers + + def is_timer_defined(self, timer_name: str) -> bool: + return timer_name in self._timers + + def get_timers(self) -> Dict[str, float]: + return self._timers + + def get_publisher_info(self, pub_name: str) -> Tuple[str, str]: + """Provide a publisher topic name and type""" + pub_info = self._publishers.get(pub_name) + assert pub_info is not None, f"Error: SCXML ROS declarations: unknown publisher {pub_name}." + return pub_info + + def get_subscriber_info(self, sub_name: str) -> Tuple[str, str]: + """Provide a subscriber topic name and type""" + sub_info = self._subscribers.get(sub_name) + assert sub_info is not None, \ + f"Error: SCXML ROS declarations: unknown subscriber {sub_name}." + return sub_info + + def get_service_server_info(self, server_name: str) -> Tuple[str, str]: + """Provide a server's service name and type""" + server_info = self._service_servers.get(server_name) + assert server_info is not None, \ + f"Error: SCXML ROS declarations: unknown service server {server_name}." + return server_info + + def get_service_client_info(self, client_name: str) -> Tuple[str, str]: + """Provide a client's service name and type""" + client_info = self._service_clients.get(client_name) + assert client_info is not None, \ + f"Error: SCXML ROS declarations: unknown service client {client_name}." + return client_info + + def is_service_client_defined(self, client_name: str) -> bool: + return client_name in self._service_clients + + def is_service_server_defined(self, server_name: str) -> bool: + return server_name in self._service_servers + + def get_service_client_type(self, client_name: str) -> Optional[str]: + client_definition = self._service_clients.get(client_name, None) + if client_definition is None: + return None + return client_definition[1] + + def get_service_server_type(self, server_name: str) -> Optional[str]: + server_definition = self._service_servers.get(server_name, None) + if server_definition is None: + return None + return server_definition[1] + + def check_valid_srv_req_fields(self, client_name: str, ros_fields: List[RosField]) -> bool: + """Check if the provided fields match the service request type.""" + req_type = self.get_service_client_type(client_name) + if req_type is None: + print(f"Error: SCXML ROS declarations: unknown service client {client_name}.") + return False + req_fields, _ = get_srv_type_params(req_type) + for ros_field in ros_fields: + if ros_field.get_name() not in req_fields: + print("Error: SCXML ROS declarations: " + f"unknown field {ros_field.get_name()} in service request.") + return False + req_fields.pop(ros_field.get_name()) + if len(req_fields) > 0: + print("Error: SCXML ROS declarations: missing fields in service request.") + for req_field in req_fields.keys(): + print(f"\t-{req_field}.") + return False + return True + + def check_valid_srv_res_fields(self, server_name: str, ros_fields: List[RosField]) -> bool: + """Check if the provided fields match the service response type.""" + res_type = self.get_service_server_type(server_name) + if res_type is None: + print(f"Error: SCXML ROS declarations: unknown service server {server_name}.") + return False + _, res_fields = get_srv_type_params(res_type) + for ros_field in ros_fields: + if ros_field.get_name() not in res_fields: + print("Error: SCXML ROS declarations: " + f"unknown field {ros_field.get_name()} in service response.") + return False + res_fields.pop(ros_field.get_name()) + if len(res_fields) > 0: + print("Error: SCXML ROS declarations: missing fields in service response.") + for res_field in res_fields.keys(): + print(f"\t-{res_field}.") + return False + return True diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_base.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_base.py index 8102baed..4e098c59 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/scxml_base.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_base.py @@ -37,6 +37,10 @@ def check_validity(self) -> bool: """Check if the object is valid.""" raise NotImplementedError + def update_bt_ports_values(self, bt_ports_handler): + """Update the values of potential entries making use of BT ports.""" + raise NotImplementedError + def as_plain_scxml(self, ros_declarations) -> "ScxmlBase": """Convert the object to its plain SCXML version.""" raise NotImplementedError diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_bt.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_bt.py new file mode 100644 index 00000000..6dad9892 --- /dev/null +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_bt.py @@ -0,0 +1,144 @@ +# Copyright (c) 2024 - for information on the respective copyright owner +# see the NOTICE file + +# 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. + +""" +SCXML entries related to Behavior Trees. +""" + +from typing import Union +from xml.etree import ElementTree as ET +from scxml_converter.scxml_entries import ScxmlBase +from scxml_converter.scxml_entries.xml_utils import assert_xml_tag_ok, get_xml_argument +from scxml_converter.scxml_entries.utils import is_non_empty_string + + +class BtInputPortDeclaration(ScxmlBase): + """ + Declare an input port in a bt plugin. + """ + + @staticmethod + def get_tag_name() -> str: + return "bt_declare_port_in" + + @staticmethod + def from_xml_tree(xml_tree: ET.Element) -> "BtInputPortDeclaration": + assert_xml_tag_ok(BtInputPortDeclaration, xml_tree) + key_str = get_xml_argument(BtInputPortDeclaration, xml_tree, "key") + type_str = get_xml_argument(BtInputPortDeclaration, xml_tree, "type") + return BtInputPortDeclaration(key_str, type_str) + + def __init__(self, key_str: str, type_str: str): + self._key = key_str + self._type = type_str + + def check_validity(self) -> bool: + return is_non_empty_string(BtInputPortDeclaration, "key", self._key) and \ + is_non_empty_string(BtInputPortDeclaration, "type", self._type) + + def get_key_name(self) -> str: + return self._key + + def get_key_type(self) -> str: + return self._type + + def as_plain_scxml(self, _) -> ScxmlBase: + # This is discarded in the to_plain_scxml_and_declarations method from ScxmlRoot + raise RuntimeError("Error: SCXML BT Ports declarations cannot be converted to plain SCXML.") + + def as_xml(self) -> ET.Element: + assert self.check_validity(), "Error: SCXML BT Input Port: invalid parameters." + xml_bt_in_port = ET.Element( + BtInputPortDeclaration.get_tag_name(), {"key": self._key, "type": self._type}) + return xml_bt_in_port + + +class BtOutputPortDeclaration(ScxmlBase): + """ + Declare an input port in a bt plugin. + """ + + @staticmethod + def get_tag_name() -> str: + return "bt_declare_port_out" + + @staticmethod + def from_xml_tree(xml_tree: ET.Element) -> "BtOutputPortDeclaration": + assert_xml_tag_ok(BtOutputPortDeclaration, xml_tree) + key_str = get_xml_argument(BtOutputPortDeclaration, xml_tree, "key") + type_str = get_xml_argument(BtOutputPortDeclaration, xml_tree, "type") + return BtOutputPortDeclaration(key_str, type_str) + + def __init__(self, key_str: str, type_str: str): + self._key = key_str + self._type = type_str + + def check_validity(self) -> bool: + return is_non_empty_string(BtOutputPortDeclaration, "key", self._key) and \ + is_non_empty_string(BtOutputPortDeclaration, "type", self._type) + + def get_key_name(self) -> str: + return self._key + + def get_key_type(self) -> str: + return self._type + + def as_plain_scxml(self, _) -> ScxmlBase: + # This is discarded in the to_plain_scxml_and_declarations method from ScxmlRoot + raise RuntimeError("Error: SCXML BT Ports declarations cannot be converted to plain SCXML.") + + def as_xml(self) -> ET.Element: + assert self.check_validity(), "Error: SCXML BT Input Port: invalid parameters." + xml_bt_in_port = ET.Element( + BtOutputPortDeclaration.get_tag_name(), {"key": self._key, "type": self._type}) + return xml_bt_in_port + + +class BtGetValueInputPort(ScxmlBase): + """ + Get the value of an input port in a bt plugin. + """ + + @staticmethod + def get_tag_name() -> str: + return "bt_get_input" + + @staticmethod + def from_xml_tree(xml_tree: ET.Element) -> "BtGetValueInputPort": + assert_xml_tag_ok(BtGetValueInputPort, xml_tree) + key_str = get_xml_argument(BtGetValueInputPort, xml_tree, "key") + return BtGetValueInputPort(key_str) + + def __init__(self, key_str: str): + self._key = key_str + + def check_validity(self) -> bool: + return is_non_empty_string(BtGetValueInputPort, "key", self._key) + + def get_key_name(self) -> str: + return self._key + + def as_plain_scxml(self, _) -> ScxmlBase: + # This is discarded in the to_plain_scxml_and_declarations method from ScxmlRoot + raise RuntimeError("Error: SCXML BT Ports declarations cannot be converted to plain SCXML.") + + def as_xml(self) -> ET.Element: + assert self.check_validity(), "Error: SCXML BT Input Port: invalid parameters." + xml_bt_in_port = ET.Element( + BtGetValueInputPort.get_tag_name(), {"key": self._key}) + return xml_bt_in_port + + +BtPortDeclarations = Union[BtInputPortDeclaration, BtOutputPortDeclaration] diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_data.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_data.py index e00dee73..9f96f02a 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/scxml_data.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_data.py @@ -17,11 +17,44 @@ Container for a single variable definition in SCXML. In XML, it has the tag `data`. """ -from typing import Any +from typing import Any, Union, Optional from xml.etree import ElementTree as ET -from scxml_converter.scxml_entries import ScxmlBase -from scxml_converter.scxml_entries.utils import SCXML_DATA_STR_TO_TYPE +from scxml_converter.scxml_entries import (ScxmlBase, BtGetValueInputPort) + +from scxml_converter.scxml_entries.bt_utils import BtPortsHandler +from scxml_converter.scxml_entries.xml_utils import ( + assert_xml_tag_ok, get_xml_argument, read_value_from_xml_arg_or_child) +from scxml_converter.scxml_entries.utils import SCXML_DATA_STR_TO_TYPE, is_non_empty_string + + +ValidExpr = Union[BtGetValueInputPort, str, int, float] + + +def get_valid_entry_data_type( + value: Optional[Union[str, int, float]], data_type: str) -> Optional[Any]: + """ + Convert a value to the provided data type. Raise if impossible. + """ + if value is None: + return None + assert data_type in SCXML_DATA_STR_TO_TYPE, \ + f"Error: SCXML conversion of data entry: Unknown data type {data_type}." + if isinstance(value, str): + assert len(value) > 0, "Error: SCXML conversion of data bounds: Empty string." + return SCXML_DATA_STR_TO_TYPE[data_type](value) + assert isinstance(value, SCXML_DATA_STR_TO_TYPE[data_type]), \ + f"Error: SCXML conversion of data entry: Expected {data_type}, but got {type(value)}." + return value + + +def valid_bound(bound_value: Any) -> bool: + """Check if a bound is invalid.""" + if bound_value is None: + return True + if isinstance(bound_value, str): + return len(bound_value) > 0 + return isinstance(bound_value, (int, float)) class ScxmlData(ScxmlBase): @@ -34,21 +67,20 @@ def get_tag_name() -> str: @staticmethod def from_xml_tree(xml_tree: ET.Element) -> "ScxmlData": """Create a ScxmlData object from an XML tree.""" - assert xml_tree.tag == ScxmlData.get_tag_name(), \ - f"Error: SCXML data: XML tag name is not {ScxmlData.get_tag_name()}." - data_id = xml_tree.attrib.get("id") - assert data_id is not None, "Error: SCXML data: 'id' not found." - data_expr = xml_tree.attrib.get("expr") - assert data_expr is not None, "Error: SCXML data: 'expr' not found." - data_type = xml_tree.attrib.get("type") - assert data_type is not None, "Error: SCXML data: 'type' not found." - lower_bound = xml_tree.attrib.get("lower_bound_incl", None) - upper_bound = xml_tree.attrib.get("upper_bound_incl", None) + assert_xml_tag_ok(ScxmlData, xml_tree) + data_id = get_xml_argument(ScxmlData, xml_tree, "id") + data_type = get_xml_argument(ScxmlData, xml_tree, "type") + data_expr = read_value_from_xml_arg_or_child(ScxmlData, xml_tree, "expr", + (BtGetValueInputPort, str)) + lower_bound = read_value_from_xml_arg_or_child(ScxmlData, xml_tree, "lower_bound_incl", + (BtGetValueInputPort, str), True) + upper_bound = read_value_from_xml_arg_or_child(ScxmlData, xml_tree, "upper_bound_incl", + (BtGetValueInputPort, str), True) return ScxmlData(data_id, data_expr, data_type, lower_bound, upper_bound) def __init__( - self, id_ : str, expr: str, data_type: str, - lower_bound: Any = None, upper_bound: Any = None): + self, id_: str, expr: ValidExpr, data_type: str, + lower_bound: Optional[ValidExpr] = None, upper_bound: Optional[ValidExpr] = None): self._id = id_ self._expr = expr self._data_type = data_type @@ -65,37 +97,23 @@ def get_expr(self) -> str: return self._expr def check_validity(self) -> bool: - validity = True - # ID - if not (isinstance(self._id, str) and len(self._id) > 0): - print(f"Error: SCXML data: 'id' {self._id} is not valid.") - validity = False - # Expression - if not (isinstance(self._expr, str) and len(self._expr) > 0): - print(f"Error: SCXML data: 'expr' {self._expr} is not valid.") - validity = False - # Data type - if not (isinstance(self._data_type, str) and self._data_type in SCXML_DATA_STR_TO_TYPE): - print(f"Error: SCXML data: 'type' {self._data_type} is not valid.") - validity = False - type_of_data = SCXML_DATA_STR_TO_TYPE[self._data_type] - # Lower bound - if self._lower_bound is not None: - if not isinstance(self._lower_bound, type_of_data): - print(f"Error: SCXML data: 'lower_bound_incl' type {self._lower_bound} is invalid.") - validity = False - # Upper bound - if self._upper_bound is not None: - if not isinstance(self._upper_bound, type_of_data): - print(f"Error: SCXML data: 'upper_bound_incl' type {self._upper_bound} is invalid.") - validity = False - # Check if lower bound is smaller than upper bound - if validity and self._upper_bound is not None and self._lower_bound is not None: - if self._lower_bound >= self._upper_bound: - print(f"Error: SCXML data: 'lower_bound_incl' {self._lower_bound} is not smaller " - f"than 'upper_bound_incl' {self._upper_bound}.") - validity = False - return validity + valid_id = is_non_empty_string(ScxmlData, "id", self._id) + valid_expr = is_non_empty_string(ScxmlData, "expr", self._expr) + valid_type = is_non_empty_string(ScxmlData, "type", self._data_type) and \ + self._data_type in SCXML_DATA_STR_TO_TYPE + if not (valid_bound(self._lower_bound) and valid_bound(self._upper_bound)): + print("Error: SCXML data: invalid lower_bound_incl or upper_bound_incl. " + f"lower_bound_incl: {self._lower_bound}, upper_bound_incl: {self._upper_bound}") + return False + lower_bound = get_valid_entry_data_type(self._lower_bound, self._data_type) + upper_bound = get_valid_entry_data_type(self._upper_bound, self._data_type) + valid_bounds = True + if lower_bound is not None and upper_bound is not None: + valid_bounds = lower_bound <= upper_bound + if not valid_bounds: + print(f"Error: SCXML data: 'lower_bound_incl' {lower_bound} is not smaller " + f"than 'upper_bound_incl' {upper_bound}.") + return valid_id and valid_expr and valid_type and valid_bounds def as_xml(self) -> ET.Element: assert self.check_validity(), "SCXML: found invalid data object." @@ -106,6 +124,14 @@ def as_xml(self) -> ET.Element: if self._upper_bound is not None: xml_data.set("upper_bound_incl", str(self._upper_bound)) return xml_data - - def as_plain_scxml(self, ros_declarations): - raise NotImplementedError("Error: SCXML data: as_plain_scxml not implemented.") + + def as_plain_scxml(self, _): + raise RuntimeError("Error: SCXML data: unexpected call to as_plain_scxml.") + + def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler): + if isinstance(self._expr, BtGetValueInputPort): + self._expr = bt_ports_handler.get_in_port_value(self._expr.get_key_name()) + if isinstance(self._lower_bound, BtGetValueInputPort): + self._lower_bound = bt_ports_handler.get_in_port_value(self._lower_bound.get_key_name()) + if isinstance(self._upper_bound, BtGetValueInputPort): + self._upper_bound = bt_ports_handler.get_in_port_value(self._upper_bound.get_key_name()) diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_data_model.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_data_model.py index e63db7a4..78c3ec1e 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/scxml_data_model.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_data_model.py @@ -21,6 +21,7 @@ from xml.etree import ElementTree as ET from scxml_converter.scxml_entries import ScxmlBase, ScxmlData +from scxml_converter.scxml_entries.bt_utils import BtPortsHandler class ScxmlDataModel(ScxmlBase): @@ -49,6 +50,10 @@ def from_xml_tree(xml_tree: ET.Element) -> "ScxmlDataModel": def get_data_entries(self) -> Optional[List[ScxmlData]]: return self._data_entries + def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler): + for data_entry in self._data_entries: + data_entry.update_bt_ports_values(bt_ports_handler) + def check_validity(self) -> bool: valid_data_entries = True if self._data_entries is not None: diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_executable_entries.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_executable_entries.py index e17ac00c..aae29039 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/scxml_executable_entries.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_executable_entries.py @@ -20,10 +20,13 @@ from typing import List, Optional, Tuple, Union, get_args from xml.etree import ElementTree as ET -from scxml_converter.scxml_entries import (ScxmlBase, ScxmlParam, - ScxmlRosDeclarationsContainer) -from scxml_converter.scxml_entries.utils import \ - replace_ros_interface_expression +from scxml_converter.scxml_entries import ( + ScxmlBase, ScxmlParam, ScxmlRosDeclarationsContainer, BtGetValueInputPort) +from scxml_converter.scxml_entries.ros_utils import replace_ros_interface_expression +from scxml_converter.scxml_entries.bt_utils import is_bt_event, replace_bt_event, BtPortsHandler +from scxml_converter.scxml_entries.xml_utils import ( + assert_xml_tag_ok, get_xml_argument, read_value_from_xml_child) +from scxml_converter.scxml_entries.utils import is_non_empty_string # Use delayed type evaluation: https://peps.python.org/pep-0484/#forward-references ScxmlExecutableEntry = Union['ScxmlAssign', 'ScxmlIf', 'ScxmlSend'] @@ -31,6 +34,29 @@ ConditionalExecutionBody = Tuple[str, ScxmlExecutionBody] +def instantiate_exec_body_bt_events( + exec_body: Optional[ScxmlExecutionBody], instance_id: str) -> None: + """ + Instantiate the behavior tree events in the execution body. + + :param exec_body: The execution body to instantiate the BT events in + :param instance_id: The instance ID of the BT node + """ + if exec_body is not None: + for entry in exec_body: + entry.instantiate_bt_events(instance_id) + + +def update_exec_body_bt_ports_values( + exec_body: Optional[ScxmlExecutionBody], bt_ports_handler: BtPortsHandler) -> None: + """ + Update the BT ports values in the execution body. + """ + if exec_body is not None: + for entry in exec_body: + entry.update_bt_ports_values(bt_ports_handler) + + class ScxmlIf(ScxmlBase): """This class represents SCXML conditionals.""" @@ -84,6 +110,17 @@ def get_else_execution(self) -> Optional[ScxmlExecutionBody]: """Get the else execution.""" return self._else_execution + def instantiate_bt_events(self, instance_id: str) -> None: + """Instantiate the behavior tree events in the If action, if available.""" + for _, exec_body in self._conditional_executions: + instantiate_exec_body_bt_events(exec_body, instance_id) + instantiate_exec_body_bt_events(self._else_execution, instance_id) + + def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler): + for _, exec_body in self._conditional_executions: + update_exec_body_bt_ports_values(exec_body, bt_ports_handler) + update_exec_body_bt_ports_values(self._else_execution, bt_ports_handler) + def check_validity(self) -> bool: valid_conditional_executions = len(self._conditional_executions) > 0 if not valid_conditional_executions: @@ -152,12 +189,6 @@ def as_xml(self) -> ET.Element: class ScxmlSend(ScxmlBase): """This class represents a send action.""" - def __init__(self, event: str, params: Optional[List[ScxmlParam]] = None): - if params is None: - params = [] - self._event = event - self._params = params - @staticmethod def get_tag_name() -> str: return "send" @@ -174,6 +205,12 @@ def from_xml_tree(xml_tree: ET.Element) -> "ScxmlSend": params.append(ScxmlParam.from_xml_tree(param_xml)) return ScxmlSend(event, params) + def __init__(self, event: str, params: Optional[List[ScxmlParam]] = None): + if params is None: + params = [] + self._event = event + self._params = params + def get_event(self) -> str: """Get the event to send.""" return self._event @@ -182,6 +219,18 @@ def get_params(self) -> List[ScxmlParam]: """Get the parameters to send.""" return self._params + def instantiate_bt_events(self, instance_id: str) -> None: + """Instantiate the behavior tree events in the send action, if available.""" + # Make sure this method is executed only on ScxmlSend objects, and not on derived classes + if type(self) is ScxmlSend and is_bt_event(self._event): + # Those are expected to be only bt_success, bt_failure and bt_running + self._event = replace_bt_event(self._event, instance_id) + + def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler): + """Update the values of potential entries making use of BT ports.""" + for param in self._params: + param.update_bt_ports_values(bt_ports_handler) + def check_validity(self) -> bool: valid_event = isinstance(self._event, str) and len(self._event) > 0 valid_params = True @@ -217,10 +266,6 @@ def as_xml(self) -> ET.Element: class ScxmlAssign(ScxmlBase): """This class represents a variable assignment.""" - def __init__(self, location: str, expr: str): - self._location = location - self._expr = expr - @staticmethod def get_tag_name() -> str: return "assign" @@ -228,33 +273,40 @@ def get_tag_name() -> str: @staticmethod def from_xml_tree(xml_tree: ET.Element) -> "ScxmlAssign": """Create a ScxmlAssign object from an XML tree.""" - assert xml_tree.tag == ScxmlAssign.get_tag_name(), \ - f"Error: SCXML assign: XML tag name is {xml_tree.tag} != {ScxmlAssign.get_tag_name()}." - location = xml_tree.attrib.get("location") - assert location is not None and len(location) > 0, \ - "Error: SCXML assign: location is not valid." - expr = xml_tree.attrib.get("expr") - assert expr is not None and len(expr) > 0, \ - "Error: SCXML assign: expr is not valid." + assert_xml_tag_ok(ScxmlAssign, xml_tree) + location = get_xml_argument(ScxmlAssign, xml_tree, "location") + expr = get_xml_argument(ScxmlAssign, xml_tree, "expr", none_allowed=True) + if expr is None: + expr = read_value_from_xml_child(xml_tree, "expr", (BtGetValueInputPort, str)) + assert expr is not None, "Error: SCXML assign: expr is not valid." return ScxmlAssign(location, expr) - + + def __init__(self, location: str, expr: Union[str, BtGetValueInputPort]): + self._location = location + self._expr = expr + print(f"ScxmlAssign: {location} = {expr}") + def get_location(self) -> str: """Get the location to assign.""" return self._location - def get_expr(self) -> str: + def get_expr(self) -> Union[str, BtGetValueInputPort]: """Get the expression to assign.""" return self._expr + def instantiate_bt_events(self, _) -> None: + """This functionality is not needed in this class.""" + return + + def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler) -> None: + """Update the values of potential entries making use of BT ports.""" + if isinstance(self._expr, BtGetValueInputPort): + self._expr = bt_ports_handler.get_in_port_value(self._expr.get_key_name()) def check_validity(self) -> bool: # TODO: Check that the location to assign exists in the data-model - valid_location = isinstance(self._location, str) and len(self._location) > 0 - valid_expr = isinstance(self._expr, str) and len(self._expr) > 0 - if not valid_location: - print("Error: SCXML assign: location is not valid.") - if not valid_expr: - print("Error: SCXML assign: expr is not valid.") + valid_location = is_non_empty_string(ScxmlAssign, "location", self._location) + valid_expr = is_non_empty_string(ScxmlAssign, "expr", self._expr) return valid_location and valid_expr def check_valid_ros_instantiations(self, _) -> bool: @@ -279,6 +331,24 @@ def as_xml(self) -> ET.Element: for entry in get_args(ScxmlExecutableEntry)) +def valid_execution_body_entry_types(exec_body: ScxmlExecutionBody) -> bool: + """ + Check if the type of the entries in an execution body are valid. + + :param exec_body: The execution body to check + :return: True if all types of the body entries are the expected ones, False otherwise + """ + if not isinstance(exec_body, list): + print("Error: SCXML execution body: invalid type found: expected a list.") + return False + for entry in exec_body: + if not isinstance(entry, _ResolvedScxmlExecutableEntry): + print(f"Error: SCXML execution body: entry type {type(entry)} not in valid set." + f" {_ResolvedScxmlExecutableEntry}.") + return False + return True + + def valid_execution_body(execution_body: ScxmlExecutionBody) -> bool: """ Check if an execution body is valid. @@ -286,20 +356,13 @@ def valid_execution_body(execution_body: ScxmlExecutionBody) -> bool: :param execution_body: The execution body to check :return: True if the execution body is valid, False otherwise """ - valid = isinstance(execution_body, list) - if not valid: - print("Error: SCXML execution body: invalid type found: expected a list.") - for entry in execution_body: - if not isinstance(entry, _ResolvedScxmlExecutableEntry): - valid = False - print(f"Error: SCXML execution body: entry type {type(entry)} not in valid set " - f" {_ResolvedScxmlExecutableEntry}.") - break - if not entry.check_validity(): - valid = False - print("Error: SCXML execution body: invalid entry content found.") - break - return valid + if valid_execution_body_entry_types(execution_body): + for entry in execution_body: + if not entry.check_validity(): + print(f"Error: SCXML execution body: content of {entry.get_tag_name()} is invalid.") + return False + return True + return False def execution_entry_from_xml(xml_tree: ET.Element) -> ScxmlExecutableEntry: diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_param.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_param.py index c524cb66..11bd353a 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/scxml_param.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_param.py @@ -17,21 +17,19 @@ Container for a single parameter, sent within an event. In XML, it has the tag `param`. """ -from typing import Optional +from typing import Optional, Union from xml.etree import ElementTree as ET -from scxml_converter.scxml_entries import ScxmlBase +from scxml_converter.scxml_entries import ScxmlBase, BtGetValueInputPort +from scxml_converter.scxml_entries.bt_utils import BtPortsHandler +from scxml_converter.scxml_entries.xml_utils import ( + assert_xml_tag_ok, get_xml_argument, read_value_from_xml_arg_or_child) +from scxml_converter.scxml_entries.utils import is_non_empty_string class ScxmlParam(ScxmlBase): """This class represents a single parameter.""" - def __init__(self, name: str, *, expr: Optional[str] = None, location: Optional[str] = None): - # TODO: We might need types in ScxmlParams as well, for later converting them to JANI. - self._name = name - self._expr = expr - self._location = location - @staticmethod def get_tag_name() -> str: return "param" @@ -39,18 +37,30 @@ def get_tag_name() -> str: @staticmethod def from_xml_tree(xml_tree: ET.Element) -> "ScxmlParam": """Create a ScxmlParam object from an XML tree.""" - assert xml_tree.tag == ScxmlParam.get_tag_name(), \ - f"Error: SCXML param: XML tag name is not {ScxmlParam.get_tag_name()}." - name = xml_tree.attrib.get("name") - assert name is not None and len(name) > 0, "Error: SCXML param: name is not valid." - expr = xml_tree.attrib.get("expr") - location = xml_tree.attrib.get("location") - assert not (expr is not None and location is not None), \ - "Error: SCXML param: expr and location are both set." - assert expr is not None or location is not None, \ - "Error: SCXML param: expr and location are both unset." + assert_xml_tag_ok(ScxmlParam, xml_tree) + name = get_xml_argument(ScxmlParam, xml_tree, "name") + expr = read_value_from_xml_arg_or_child(ScxmlParam, xml_tree, "expr", + (BtGetValueInputPort, str), True) + location = get_xml_argument(ScxmlParam, xml_tree, "location", none_allowed=True) return ScxmlParam(name, expr=expr, location=location) + def __init__(self, name: str, *, + expr: Optional[Union[BtGetValueInputPort, str]] = None, + location: Optional[str] = None): + """ + Initialize the SCXML Parameter object. + + The location entryu is kept for consistency, but using expr achieves the same result. + + :param name: The name of the parameter. + :param expr: The expression to assign to the parameter. Can come from a BT port. + :param location: The expression to assign to the parameter, if that's a data variable. + """ + # TODO: We might need types in ScxmlParams as well, for later converting them to JANI. + self._name = name + self._expr = expr + self._location = location + def get_name(self) -> str: return self._name @@ -60,26 +70,21 @@ def get_expr(self) -> Optional[str]: def get_location(self) -> Optional[str]: return self._location + def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler): + """Update the values of potential entries making use of BT ports.""" + if isinstance(self._expr, BtGetValueInputPort): + self._expr = bt_ports_handler.get_in_port_value(self._expr.get_key_name()) + def check_validity(self) -> bool: - valid_name = len(self._name) > 0 - if not valid_name: - print("Error: SCXML param: name is not valid") - valid_expr = isinstance(self._expr, str) and len(self._expr) > 0 and self._location is None - valid_location = isinstance(self._location, str) and len( - self._location) > 0 and self._expr is None - # Print possible errors - if self._expr is not None: - if not isinstance(self._expr, str) or len(self._expr) == 0: - print("Error: SCXML param: expr is not valid") - if self._location is not None: - if not isinstance(self._location, str) or len(self._location) == 0: - print("Error: SCXML param: location is not valid") - if self._expr is not None and self._location is not None: - print("Error: SCXML param: expr and location are both set") - if self._expr is None and self._location is None: - print("Error: SCXML param: expr and location are both unset") - - return valid_name and (valid_expr or valid_location) + valid_name = is_non_empty_string(ScxmlParam, "name", self._name) + valid_expr = False + if self._location is None: + valid_expr = is_non_empty_string(ScxmlParam, "expr", self._expr) + elif self._expr is None: + valid_expr = is_non_empty_string(ScxmlParam, "location", self._location) + else: + print("Error: SCXML param: expr and location are both set.") + return valid_name and valid_expr def as_xml(self) -> ET.Element: assert self.check_validity(), "SCXML: found invalid param." diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_root.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_root.py index 7dfd3d64..e061d4a7 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/scxml_root.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_root.py @@ -22,13 +22,15 @@ from typing import List, Optional, Tuple, get_args from xml.etree import ElementTree as ET -from scxml_converter.scxml_entries import (RosServiceClient, RosServiceServer, - RosTimeRate, RosTopicPublisher, - RosTopicSubscriber, ScxmlBase, - ScxmlDataModel, - ScxmlRosDeclarations, - ScxmlRosDeclarationsContainer, - ScxmlState) +from scxml_converter.scxml_entries import ( + BtInputPortDeclaration, BtOutputPortDeclaration, RosServiceClient, RosServiceServer, + RosTimeRate, RosTopicPublisher, RosTopicSubscriber, ScxmlBase, ScxmlDataModel, + ScxmlRosDeclarations, ScxmlRosDeclarationsContainer, ScxmlState) + +from scxml_converter.scxml_entries.xml_utils import get_children_as_scxml + +from scxml_converter.scxml_entries.scxml_bt import BtPortDeclarations +from scxml_converter.scxml_entries.bt_utils import BtPortsHandler class ScxmlRoot(ScxmlBase): @@ -53,18 +55,11 @@ def from_xml_tree(xml_tree: ET.Element) -> "ScxmlRoot": assert datamodel_elements is None or len(datamodel_elements) <= 1, \ f"Error: SCXML root: {len(datamodel_elements)} datamodels found, max 1 allowed." # ROS Declarations - ros_declarations: List[ScxmlRosDeclarations] = [] - for child in xml_tree: - if child.tag == RosTimeRate.get_tag_name(): - ros_declarations.append(RosTimeRate.from_xml_tree(child)) - elif child.tag == RosTopicSubscriber.get_tag_name(): - ros_declarations.append(RosTopicSubscriber.from_xml_tree(child)) - elif child.tag == RosTopicPublisher.get_tag_name(): - ros_declarations.append(RosTopicPublisher.from_xml_tree(child)) - elif child.tag == RosServiceServer.get_tag_name(): - ros_declarations.append(RosServiceServer.from_xml_tree(child)) - elif child.tag == RosServiceClient.get_tag_name(): - ros_declarations.append(RosServiceClient.from_xml_tree(child)) + ros_declarations: List[ScxmlRosDeclarations] = get_children_as_scxml( + xml_tree, get_args(ScxmlRosDeclarations)) + # BT Declarations + bt_port_declarations: List[BtPortDeclarations] = get_children_as_scxml( + xml_tree, get_args(BtPortDeclarations)) # States assert "initial" in xml_tree.attrib, \ "Error: SCXML root: 'initial' attribute not found in input xml." @@ -72,13 +67,16 @@ def from_xml_tree(xml_tree: ET.Element) -> "ScxmlRoot": state_elements = xml_tree.findall(ScxmlState.get_tag_name()) assert state_elements is not None and len(state_elements) > 0, \ "Error: SCXML root: no state found in input xml." - # Fill Data in the ScxmlRoot object + # --- Fill Data in the ScxmlRoot object scxml_root = ScxmlRoot(xml_tree.attrib["name"]) # Data Model if datamodel_elements is not None and len(datamodel_elements) > 0: scxml_root.set_data_model(ScxmlDataModel.from_xml_tree(datamodel_elements[0])) # ROS Declarations scxml_root._ros_declarations = ros_declarations + # BT Declarations + for bt_port_declaration in bt_port_declarations: + scxml_root.add_bt_port_declaration(bt_port_declaration) # States for state_element in state_elements: scxml_state = ScxmlState.from_xml_tree(state_element) @@ -109,11 +107,17 @@ def __init__(self, name: str): self._states: List[ScxmlState] = [] self._data_model: Optional[ScxmlDataModel] = None self._ros_declarations: List[ScxmlRosDeclarations] = [] + self._bt_ports_handler = BtPortsHandler() def get_name(self) -> str: """Get the name of the automaton represented by this SCXML model.""" return self._name + def set_name(self, name: str) -> None: + """Rename the automaton represented by this SCXML model.""" + assert isinstance(name, str) and len(name) > 0, "Error: SCXML root: invalid name." + self._name = name + def get_initial_state_id(self) -> str: """Get the ID of the initial state of the SCXML model.""" assert self._initial_state is not None, "Error: SCXML root: Initial state not set." @@ -131,6 +135,11 @@ def get_state_by_id(self, state_id: str) -> Optional[ScxmlState]: return state return None + def instantiate_bt_events(self, instance_id: str) -> None: + """Update all BT-related events to use the assigned instance ID.""" + for state in self._states: + state.instantiate_bt_events(instance_id) + def add_state(self, state: ScxmlState, *, initial: bool = False): """Append a state to the list of states. If initial is True, set it as the initial state.""" self._states.append(state) @@ -150,27 +159,62 @@ def add_ros_declaration(self, ros_declaration: ScxmlRosDeclarations): self._ros_declarations = [] self._ros_declarations.append(ros_declaration) + def add_bt_port_declaration(self, bt_port_decl: BtPortDeclarations): + """Add a BT port declaration to the handler.""" + if isinstance(bt_port_decl, BtInputPortDeclaration): + self._bt_ports_handler.declare_in_port( + bt_port_decl.get_key_name(), bt_port_decl.get_key_type()) + elif isinstance(bt_port_decl, BtOutputPortDeclaration): + self._bt_ports_handler.declare_out_port( + bt_port_decl.get_key_name(), bt_port_decl.get_key_type()) + else: + raise ValueError( + f"Error: SCXML root: invalid BT port declaration type {type(bt_port_decl)}.") + + def set_bt_port_value(self, port_name: str, port_value: str): + """Set the value of an input port.""" + self._bt_ports_handler.set_port_value(port_name, port_value) + + def set_bt_ports_values(self, ports_values: List[Tuple[str, str]]): + """Set the values of multiple input ports.""" + for port_name, port_value in ports_values: + self.set_bt_port_value(port_name, port_value) + + def update_bt_ports_values(self): + """Update the values of the declared BT ports in the SCXML object.""" + if self._data_model is not None: + self._data_model.update_bt_ports_values(self._bt_ports_handler) + for ros_decl_scxml in self._ros_declarations: + ros_decl_scxml.update_bt_ports_values(self._bt_ports_handler) + for state in self._states: + state.update_bt_ports_values(self._bt_ports_handler) + def _generate_ros_declarations_helper(self) -> Optional[ScxmlRosDeclarationsContainer]: """Generate a HelperRosDeclarations object from the existing ROS declarations.""" ros_decl_container = ScxmlRosDeclarationsContainer(self._name) if self._ros_declarations is not None: for ros_declaration in self._ros_declarations: - if not ros_declaration.check_validity(): + if not (ros_declaration.check_validity() and + ros_declaration.check_valid_instantiation()): return None if isinstance(ros_declaration, RosTimeRate): ros_decl_container.append_timer(ros_declaration.get_name(), ros_declaration.get_rate()) elif isinstance(ros_declaration, RosTopicSubscriber): - ros_decl_container.append_subscriber(ros_declaration.get_topic_name(), + ros_decl_container.append_subscriber(ros_declaration.get_name(), + ros_declaration.get_topic_name(), ros_declaration.get_topic_type()) elif isinstance(ros_declaration, RosTopicPublisher): - ros_decl_container.append_publisher(ros_declaration.get_topic_name(), + ros_decl_container.append_publisher(ros_declaration.get_name(), + ros_declaration.get_topic_name(), ros_declaration.get_topic_type()) elif isinstance(ros_declaration, RosServiceServer): - ros_decl_container.append_service_server(ros_declaration.get_service_name(), + ros_decl_container.append_service_server(ros_declaration.get_name(), + ros_declaration.get_service_name(), ros_declaration.get_service_type()) elif isinstance(ros_declaration, RosServiceClient): - ros_decl_container.append_service_client(ros_declaration.get_service_name(), + ros_decl_container.append_service_client(ros_declaration.get_name(), + ros_declaration.get_service_name(), ros_declaration.get_service_type()) else: raise ValueError("Error: SCXML root: invalid ROS declaration type.") diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_field.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_field.py index 3d790fbb..5d83fdec 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_field.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_field.py @@ -15,19 +15,20 @@ """Declaration of the ROS Field SCXML tag extension.""" +from typing import Optional, Union + from xml.etree import ElementTree as ET -from scxml_converter.scxml_entries import ScxmlParam +from scxml_converter.scxml_entries import ScxmlParam, BtGetValueInputPort +from scxml_converter.scxml_entries.bt_utils import BtPortsHandler +from scxml_converter.scxml_entries.xml_utils import ( + assert_xml_tag_ok, get_xml_argument, read_value_from_xml_arg_or_child) +from scxml_converter.scxml_entries.utils import is_non_empty_string class RosField(ScxmlParam): """Field of a ROS msg published in a topic.""" - def __init__(self, name: str, expr: str): - self._name = name - self._expr = expr - assert self.check_validity(), "Error: SCXML topic publish field: invalid parameters." - @staticmethod def get_tag_name() -> str: return "field" @@ -35,26 +36,29 @@ def get_tag_name() -> str: @staticmethod def from_xml_tree(xml_tree: ET.Element) -> "RosField": """Create a RosField object from an XML tree.""" - assert xml_tree.tag == RosField.get_tag_name(), \ - f"Error: SCXML topic publish field: XML tag name is not {RosField.get_tag_name()}" - name = xml_tree.attrib.get("name") - expr = xml_tree.attrib.get("expr") - assert name is not None and expr is not None, \ - "Error: SCXML topic publish field: 'name' or 'expr' attribute not found in input xml." + assert_xml_tag_ok(RosField, xml_tree) + name = get_xml_argument(RosField, xml_tree, "name") + expr = read_value_from_xml_arg_or_child(RosField, xml_tree, "expr", + (BtGetValueInputPort, str)) return RosField(name, expr) + def __init__(self, name: str, expr: Optional[Union[BtGetValueInputPort, str]]): + self._name = name + self._expr = expr + assert self.check_validity(), "Error: SCXML topic publish field: invalid parameters." + def check_validity(self) -> bool: - valid_name = isinstance(self._name, str) and len(self._name) > 0 - valid_expr = isinstance(self._expr, str) and len(self._expr) > 0 - if not valid_name: - print("Error: SCXML topic publish field: name is not valid.") - if not valid_expr: - print("Error: SCXML topic publish field: expr is not valid.") + valid_name = is_non_empty_string(RosField, "name", self._name) + valid_expr = is_non_empty_string(RosField, "expr", self._expr) return valid_name and valid_expr + def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler): + """Update the values of potential entries making use of BT ports.""" + if isinstance(self._expr, BtGetValueInputPort): + self._expr = bt_ports_handler.get_in_port_value(self._expr.get_key_name()) + def as_plain_scxml(self, _) -> ScxmlParam: - from scxml_converter.scxml_entries.utils import \ - replace_ros_interface_expression + from scxml_converter.scxml_entries.ros_utils import replace_ros_interface_expression return ScxmlParam(self._name, expr=replace_ros_interface_expression(self._expr)) def as_xml(self) -> ET.Element: diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_service.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_service.py index 6246cbab..a1f65fbd 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_service.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_service.py @@ -23,16 +23,18 @@ from typing import List, Optional, Union from xml.etree import ElementTree as ET -from scxml_converter.scxml_entries import (RosField, ScxmlBase, - ScxmlExecutionBody, ScxmlSend, - ScxmlTransition, - as_plain_execution_body, - execution_body_from_xml, - valid_execution_body) -from scxml_converter.scxml_entries.utils import ( +from scxml_converter.scxml_entries import ( + RosField, ScxmlBase, ScxmlExecutionBody, ScxmlSend, ScxmlTransition, BtGetValueInputPort, + as_plain_execution_body, execution_body_from_xml, valid_execution_body) + +from scxml_converter.scxml_entries.bt_utils import BtPortsHandler +from scxml_converter.scxml_entries.ros_utils import ( ScxmlRosDeclarationsContainer, generate_srv_request_event, generate_srv_response_event, generate_srv_server_request_event, generate_srv_server_response_event, is_srv_type_known) +from scxml_converter.scxml_entries.xml_utils import ( + assert_xml_tag_ok, get_xml_argument, read_value_from_xml_child) +from scxml_converter.scxml_entries.utils import is_non_empty_string class RosServiceServer(ScxmlBase): @@ -45,23 +47,35 @@ def get_tag_name() -> str: @staticmethod def from_xml_tree(xml_tree: ET.Element) -> "RosServiceServer": """Create a RosServiceServer object from an XML tree.""" - assert xml_tree.tag == RosServiceServer.get_tag_name(), \ - f"Error: SCXML Service Server: XML tag name is not '{RosServiceServer.get_tag_name()}'." - service_name = xml_tree.attrib.get("service_name") - service_type = xml_tree.attrib.get("type") - assert service_name is not None and service_type is not None, \ - "Error: SCXML Service Server: 'service_name' or 'type' cannot be found in input xml." - return RosServiceServer(service_name, service_type) - - def __init__(self, srv_name: str, srv_type: str) -> None: + assert_xml_tag_ok(RosServiceServer, xml_tree) + service_name = get_xml_argument( + RosServiceServer, xml_tree, "service_name", none_allowed=True) + service_type = get_xml_argument(RosServiceServer, xml_tree, "type") + service_alias = get_xml_argument( + RosServiceServer, xml_tree, "name", none_allowed=True) + if service_name is None: + service_name = read_value_from_xml_child(xml_tree, "service_name", + (BtGetValueInputPort, str)) + return RosServiceServer(service_name, service_type, service_alias) + + def __init__(self, srv_name: Union[str, BtGetValueInputPort], srv_type: str, + srv_alias: Optional[str] = None) -> None: """ Initialize a new RosServiceServer object. - :param srv_name: Topic used by the service. + :param srv_name: Service name used by the service for communication. :param srv_type: ROS type of the service. + :param srv_alias: Alias for the service server, for the handler to reference to it """ self._srv_name = srv_name self._srv_type = srv_type + self._srv_alias = srv_alias + assert isinstance(srv_name, (str, BtGetValueInputPort)), \ + "Error: SCXML Service Server: invalid service name." + if self._srv_alias is None: + assert is_non_empty_string(RosServiceServer, "service_name", self._srv_name), \ + "Error: SCXML Service Server: an alias name is required for dynamic service names." + self._srv_alias = srv_name def get_service_name(self) -> str: """Get the name of the service.""" @@ -71,6 +85,10 @@ def get_service_type(self) -> str: """Get the type of the service.""" return self._srv_type + def get_name(self) -> str: + """Get the alias of the service server.""" + return self._srv_alias + def check_validity(self) -> bool: valid_name = isinstance(self._srv_name, str) and len(self._srv_name) > 0 valid_type = is_srv_type_known(self._srv_type) @@ -80,6 +98,14 @@ def check_validity(self) -> bool: print("Error: SCXML Service Server: service type is not valid.") return valid_name and valid_type + def check_valid_instantiation(self) -> bool: + """Check if the service server has undefined entries (i.e. from BT ports).""" + return is_non_empty_string(RosServiceServer, "service_name", self._srv_name) + + def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler) -> None: + """Update the values of potential entries making use of BT ports.""" + pass + def as_plain_scxml(self, _) -> ScxmlBase: # This is discarded in the to_plain_scxml_and_declarations method from ScxmlRoot raise RuntimeError("Error: SCXML ROS declarations cannot be converted to plain SCXML.") @@ -88,7 +114,7 @@ def as_xml(self) -> ET.Element: assert self.check_validity(), "Error: SCXML Service Server: invalid parameters." xml_srv_server = ET.Element( RosServiceServer.get_tag_name(), - {"service_name": self._srv_name, "type": self._srv_type}) + {"name": self._srv_alias, "service_name": self._srv_name, "type": self._srv_type}) return xml_srv_server @@ -102,23 +128,35 @@ def get_tag_name() -> str: @staticmethod def from_xml_tree(xml_tree: ET.Element) -> "RosServiceClient": """Create a RosServiceClient object from an XML tree.""" - assert xml_tree.tag == RosServiceClient.get_tag_name(), \ - f"Error: SCXML Service Client: XML tag name is not '{RosServiceClient.get_tag_name()}'." - service_name = xml_tree.attrib.get("service_name") - service_type = xml_tree.attrib.get("type") - assert service_name is not None and service_type is not None, \ - "Error: SCXML Service Client: 'service_name' or 'type' cannot be found in input xml." - return RosServiceClient(service_name, service_type) - - def __init__(self, srv_name: str, srv_type: str) -> None: + assert_xml_tag_ok(RosServiceClient, xml_tree) + service_name = get_xml_argument( + RosServiceClient, xml_tree, "service_name", none_allowed=True) + service_type = get_xml_argument(RosServiceClient, xml_tree, "type") + service_alias = get_xml_argument( + RosServiceClient, xml_tree, "name", none_allowed=True) + if service_name is None: + service_name = read_value_from_xml_child(xml_tree, "service_name", + (BtGetValueInputPort, str)) + return RosServiceClient(service_name, service_type, service_alias) + + def __init__(self, srv_name: Union[str, BtGetValueInputPort], srv_type: str, + srv_alias: Optional[str] = None) -> None: """ Initialize a new RosServiceClient object. :param srv_name: Topic used by the service. :param srv_type: ROS type of the service. + :param srv_alias: Alias for the service client, for the handler to reference to it """ self._srv_name = srv_name self._srv_type = srv_type + self._srv_alias = srv_alias + assert isinstance(srv_name, (str, BtGetValueInputPort)), \ + "Error: SCXML Service Client: invalid service name." + if self._srv_alias is None: + assert is_non_empty_string(RosServiceClient, "service_name", self._srv_name), \ + "Error: SCXML Service Client: an alias name is required for dynamic service names." + self._srv_alias = srv_name def get_service_name(self) -> str: """Get the name of the service.""" @@ -128,6 +166,10 @@ def get_service_type(self) -> str: """Get the type of the service.""" return self._srv_type + def get_name(self) -> str: + """Get the alias of the service client.""" + return self._srv_alias + def check_validity(self) -> bool: valid_name = isinstance(self._srv_name, str) and len(self._srv_name) > 0 valid_type = is_srv_type_known(self._srv_type) @@ -137,6 +179,14 @@ def check_validity(self) -> bool: print("Error: SCXML Service Client: service type is not valid.") return valid_name and valid_type + def check_valid_instantiation(self) -> bool: + """Check if the topic publisher has undefined entries (i.e. from BT ports).""" + return is_non_empty_string(RosServiceClient, "service_name", self._srv_name) + + def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler) -> None: + """Update the values of potential entries making use of BT ports.""" + pass + def as_plain_scxml(self, _) -> ScxmlBase: # This is discarded in the to_plain_scxml_and_declarations method from ScxmlRoot raise RuntimeError("Error: SCXML ROS declarations cannot be converted to plain SCXML.") @@ -145,7 +195,7 @@ def as_xml(self) -> ET.Element: assert self.check_validity(), "Error: SCXML Service Client: invalid parameters." xml_srv_server = ET.Element( RosServiceClient.get_tag_name(), - {"service_name": self._srv_name, "type": self._srv_type}) + {"name": self._srv_alias, "service_name": self._srv_name, "type": self._srv_type}) return xml_srv_server @@ -159,12 +209,12 @@ def get_tag_name() -> str: @staticmethod def from_xml_tree(xml_tree: ET.Element) -> "RosServiceSendRequest": """Create a RosServiceServer object from an XML tree.""" - assert xml_tree.tag == RosServiceSendRequest.get_tag_name(), \ - "Error: SCXML service request: XML tag name is not " + \ - RosServiceSendRequest.get_tag_name() - srv_name = xml_tree.attrib.get("service_name") - assert srv_name is not None, \ - "Error: SCXML service request: 'service_name' attribute not found in input xml." + assert_xml_tag_ok(RosServiceSendRequest, xml_tree) + srv_name = get_xml_argument(RosServiceSendRequest, xml_tree, "name", none_allowed=True) + if srv_name is None: + srv_name = get_xml_argument(RosServiceSendRequest, xml_tree, "service_name") + print("Warning: SCXML service request: 'service_name' xml arg. is deprecated. " + "Use 'name' instead.") fields: List[RosField] = [] for field_xml in xml_tree: fields.append(RosField.from_xml_tree(field_xml)) @@ -180,7 +230,7 @@ def __init__(self, :param fields: List of fields to be sent in the request. """ if isinstance(service_decl, RosServiceClient): - self._srv_name = service_decl.get_service_name() + self._srv_name = service_decl.get_name() else: # Used for generating ROS entries from xml file assert isinstance(service_decl, str), \ @@ -219,15 +269,16 @@ def check_valid_ros_instantiations(self, def as_plain_scxml(self, ros_declarations: ScxmlRosDeclarationsContainer) -> ScxmlSend: assert self.check_valid_ros_instantiations(ros_declarations), \ "Error: SCXML service request: invalid ROS instantiations." - event_name = generate_srv_request_event( - self._srv_name, ros_declarations.get_automaton_name()) + automaton_name = ros_declarations.get_automaton_name() + srv_interface, _ = ros_declarations.get_service_client_info(self._srv_name) + event_name = generate_srv_request_event(srv_interface, automaton_name) event_params = [field.as_plain_scxml(ros_declarations) for field in self._fields] return ScxmlSend(event_name, event_params) def as_xml(self) -> ET.Element: assert self.check_validity(), "Error: SCXML Service Send Request: invalid parameters." xml_srv_request = ET.Element(RosServiceSendRequest.get_tag_name(), - {"service_name": self._srv_name}) + {"name": self._srv_name}) if self._fields is not None: for field in self._fields: xml_srv_request.append(field.as_xml()) @@ -244,14 +295,13 @@ def get_tag_name() -> str: @staticmethod def from_xml_tree(xml_tree: ET.Element) -> "RosServiceHandleRequest": """Create a RosServiceServer object from an XML tree.""" - assert xml_tree.tag == RosServiceHandleRequest.get_tag_name(), \ - "Error: SCXML service request handler: XML tag name is not " +\ - RosServiceHandleRequest.get_tag_name() - srv_name = xml_tree.attrib.get("service_name") - target_name = xml_tree.attrib.get("target") - assert srv_name is not None and target_name is not None, \ - "Error: SCXML service request handler: 'service_name' or 'target' attribute not " \ - "found in input xml." + assert_xml_tag_ok(RosServiceHandleRequest, xml_tree) + srv_name = get_xml_argument(RosServiceHandleRequest, xml_tree, "name", none_allowed=True) + if srv_name is None: + srv_name = get_xml_argument(RosServiceHandleRequest, xml_tree, "service_name") + print("Warning: SCXML service request handler: 'service_name' xml arg. is deprecated. " + "Use 'name' instead.") + target_name = get_xml_argument(RosServiceHandleRequest, xml_tree, "target") exec_body = execution_body_from_xml(xml_tree) return RosServiceHandleRequest(srv_name, target_name, exec_body) @@ -265,7 +315,7 @@ def __init__(self, service_decl: Union[str, RosServiceServer], target: str, :param body: Execution body to be executed upon request, before transitioning to target. """ if isinstance(service_decl, RosServiceServer): - self._service_name = service_decl.get_service_name() + self._service_name = service_decl.get_name() else: # Used for generating ROS entries from xml file assert isinstance(service_decl, str), \ @@ -304,7 +354,8 @@ def check_valid_ros_instantiations(self, def as_plain_scxml(self, ros_declarations: ScxmlRosDeclarationsContainer) -> ScxmlTransition: assert self.check_valid_ros_instantiations(ros_declarations), \ "Error: SCXML service request handler: invalid ROS instantiations." - event_name = generate_srv_server_request_event(self._service_name) + interface_name, _ = ros_declarations.get_service_server_info(self._service_name) + event_name = generate_srv_server_request_event(interface_name) target = self._target body = as_plain_execution_body(self._body, ros_declarations) return ScxmlTransition(target, [event_name], None, body) @@ -312,7 +363,7 @@ def as_plain_scxml(self, ros_declarations: ScxmlRosDeclarationsContainer) -> Scx def as_xml(self) -> ET.Element: assert self.check_validity(), "Error: SCXML Service Handle Request: invalid parameters." xml_srv_request = ET.Element(RosServiceHandleRequest.get_tag_name(), - {"service_name": self._service_name, "target": self._target}) + {"name": self._service_name, "target": self._target}) if self._body is not None: for body_elem in self._body: xml_srv_request.append(body_elem.as_xml()) @@ -329,12 +380,12 @@ def get_tag_name() -> str: @staticmethod def from_xml_tree(xml_tree: ET.Element) -> "RosServiceSendResponse": """Create a RosServiceServer object from an XML tree.""" - assert xml_tree.tag == RosServiceSendResponse.get_tag_name(), \ - "Error: SCXML service response: XML tag name is not " + \ - RosServiceSendResponse.get_tag_name() - srv_name = xml_tree.attrib.get("service_name") - assert srv_name is not None, \ - "Error: SCXML service response: 'service_name' attribute not found in input xml." + assert_xml_tag_ok(RosServiceSendResponse, xml_tree) + srv_name = get_xml_argument(RosServiceSendResponse, xml_tree, "name", none_allowed=True) + if srv_name is None: + srv_name = get_xml_argument(RosServiceSendResponse, xml_tree, "service_name") + print("Warning: SCXML service send response: 'service_name' xml arg. is deprecated. " + "Use 'name' instead.") fields: Optional[List[RosField]] = [] assert fields is not None, "Error: SCXML service response: fields is not valid." for field_xml in xml_tree: @@ -352,7 +403,7 @@ def __init__(self, service_name: Union[str, RosServiceServer], :param fields: List of fields to be sent in the response. """ if isinstance(service_name, RosServiceServer): - self._service_name = service_name.get_service_name() + self._service_name = service_name.get_name() else: # Used for generating ROS entries from xml file assert isinstance(service_name, str), \ @@ -389,14 +440,15 @@ def check_valid_ros_instantiations(self, def as_plain_scxml(self, ros_declarations: ScxmlRosDeclarationsContainer) -> ScxmlSend: assert self.check_valid_ros_instantiations(ros_declarations), \ "Error: SCXML service response: invalid ROS instantiations." - event_name = generate_srv_server_response_event(self._service_name) + interface_name, _ = ros_declarations.get_service_server_info(self._service_name) + event_name = generate_srv_server_response_event(interface_name) event_params = [field.as_plain_scxml(ros_declarations) for field in self._fields] return ScxmlSend(event_name, event_params) def as_xml(self) -> ET.Element: assert self.check_validity(), "Error: SCXML Service Send Response: invalid parameters." xml_srv_response = ET.Element(RosServiceSendResponse.get_tag_name(), - {"service_name": self._service_name}) + {"name": self._service_name}) if self._fields is not None: for field in self._fields: xml_srv_response.append(field.as_xml()) @@ -413,14 +465,13 @@ def get_tag_name() -> str: @staticmethod def from_xml_tree(xml_tree: ET.Element) -> "RosServiceHandleResponse": """Create a RosServiceServer object from an XML tree.""" - assert xml_tree.tag == RosServiceHandleResponse.get_tag_name(), \ - "Error: SCXML service response handler: XML tag name is not " + \ - RosServiceHandleResponse.get_tag_name() - srv_name = xml_tree.attrib.get("service_name") - target_name = xml_tree.attrib.get("target") - assert srv_name is not None and target_name is not None, \ - "Error: SCXML service response handler: 'service_name' or 'target' attribute not " \ - "found in input xml." + assert_xml_tag_ok(RosServiceHandleResponse, xml_tree) + srv_name = get_xml_argument(RosServiceHandleResponse, xml_tree, "name", none_allowed=True) + if srv_name is None: + srv_name = get_xml_argument(RosServiceHandleResponse, xml_tree, "service_name") + print("Warning: SCXML service response handler: 'service_name' xml arg. is deprecated. " + "Use 'name' instead.") + target_name = get_xml_argument(RosServiceHandleResponse, xml_tree, "target") exec_body = execution_body_from_xml(xml_tree) return RosServiceHandleResponse(srv_name, target_name, exec_body) @@ -433,7 +484,7 @@ def __init__(self, service_decl: Union[str, RosServiceClient], target: str, :param type: ROS type of the service. """ if isinstance(service_decl, RosServiceClient): - self._service_name = service_decl.get_service_name() + self._service_name = service_decl.get_name() else: # Used for generating ROS entries from xml file assert isinstance(service_decl, str), \ @@ -472,8 +523,9 @@ def check_valid_ros_instantiations(self, def as_plain_scxml(self, ros_declarations: ScxmlRosDeclarationsContainer) -> ScxmlTransition: assert self.check_valid_ros_instantiations(ros_declarations), \ "Error: SCXML service response handler: invalid ROS instantiations." - event_name = generate_srv_response_event( - self._service_name, ros_declarations.get_automaton_name()) + automaton_name = ros_declarations.get_automaton_name() + interface_name, _ = ros_declarations.get_service_client_info(self._service_name) + event_name = generate_srv_response_event(interface_name, automaton_name) target = self._target body = as_plain_execution_body(self._body, ros_declarations) return ScxmlTransition(target, [event_name], None, body) @@ -481,7 +533,7 @@ def as_plain_scxml(self, ros_declarations: ScxmlRosDeclarationsContainer) -> Scx def as_xml(self) -> ET.Element: assert self.check_validity(), "Error: SCXML Service Handle Response: invalid parameters." xml_srv_response = ET.Element(RosServiceHandleResponse.get_tag_name(), - {"service_name": self._service_name, "target": self._target}) + {"name": self._service_name, "target": self._target}) if self._body is not None: for body_elem in self._body: xml_srv_response.append(body_elem.as_xml()) diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_timer.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_timer.py index 9b10c7e4..93ab0e6c 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_timer.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_timer.py @@ -18,21 +18,15 @@ from typing import Optional, Union from xml.etree import ElementTree as ET -from scxml_converter.scxml_entries import (ScxmlBase, ScxmlExecutionBody, - ScxmlRosDeclarationsContainer, - ScxmlTransition, - as_plain_execution_body, - execution_body_from_xml, - valid_execution_body) +from scxml_converter.scxml_entries import ( + ScxmlBase, ScxmlExecutionBody, ScxmlRosDeclarationsContainer, ScxmlTransition, + as_plain_execution_body, execution_body_from_xml, valid_execution_body) +from scxml_converter.scxml_entries.bt_utils import BtPortsHandler class RosTimeRate(ScxmlBase): """Object used in the SCXML root to declare a new timer with its related tick rate.""" - def __init__(self, name: str, rate_hz: float): - self._name = name - self._rate_hz = float(rate_hz) - @staticmethod def get_tag_name() -> str: return "ros_time_rate" @@ -52,6 +46,14 @@ def from_xml_tree(xml_tree: ET.Element) -> "RosTimeRate": raise ValueError("Error: SCXML rate timer: rate is not a number.") from e return RosTimeRate(timer_name, timer_rate) + def __init__(self, name: str, rate_hz: float): + self._name = name + self._rate_hz = float(rate_hz) + + def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler) -> None: + """Update the values of potential entries making use of BT ports.""" + pass + def check_validity(self) -> bool: valid_name = isinstance(self._name, str) and len(self._name) > 0 valid_rate = isinstance(self._rate_hz, float) and self._rate_hz > 0 @@ -61,6 +63,10 @@ def check_validity(self) -> bool: print("Error: SCXML rate timer: rate is not valid.") return valid_name and valid_rate + def check_valid_instantiation(self) -> bool: + """Check if the topic publisher has undefined entries (i.e. from BT ports).""" + return True + def get_name(self) -> str: return self._name diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_topic.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_topic.py index 5fa2beda..e7297b05 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_topic.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_topic.py @@ -23,14 +23,15 @@ from typing import List, Optional, Union from xml.etree import ElementTree as ET -from scxml_converter.scxml_entries import (RosField, ScxmlBase, - ScxmlExecutionBody, ScxmlParam, - ScxmlRosDeclarationsContainer, - ScxmlSend, ScxmlTransition, - as_plain_execution_body, - execution_body_from_xml, - valid_execution_body) -from scxml_converter.scxml_entries.utils import is_msg_type_known +from scxml_converter.scxml_entries import ( + RosField, ScxmlBase, ScxmlExecutionBody, ScxmlParam, ScxmlRosDeclarationsContainer, ScxmlSend, + ScxmlTransition, BtGetValueInputPort, as_plain_execution_body, execution_body_from_xml, + valid_execution_body) +from scxml_converter.scxml_entries.bt_utils import BtPortsHandler +from scxml_converter.scxml_entries.ros_utils import is_msg_type_known, sanitize_ros_interface_name +from scxml_converter.scxml_entries.xml_utils import ( + assert_xml_tag_ok, get_xml_argument, get_children_as_scxml, read_value_from_xml_child) +from scxml_converter.scxml_entries.utils import is_non_empty_string class RosTopicPublisher(ScxmlBase): @@ -43,33 +44,70 @@ def get_tag_name() -> str: @staticmethod def from_xml_tree(xml_tree: ET.Element) -> "RosTopicPublisher": """Create a RosTopicPublisher object from an XML tree.""" - assert xml_tree.tag == RosTopicPublisher.get_tag_name(), \ - f"Error: SCXML topic publisher: XML tag name is not {RosTopicPublisher.get_tag_name()}" - topic_name = xml_tree.attrib.get("topic") - topic_type = xml_tree.attrib.get("type") - assert topic_name is not None and topic_type is not None, \ - "Error: SCXML topic publisher: 'topic' or 'type' attribute not found in input xml." - return RosTopicPublisher(topic_name, topic_type) - - def __init__(self, topic_name: str, topic_type: str) -> None: - self._topic_name = topic_name + assert_xml_tag_ok(RosTopicPublisher, xml_tree) + topic_name = get_xml_argument(RosTopicPublisher, xml_tree, "topic", none_allowed=True) + topic_type = get_xml_argument(RosTopicPublisher, xml_tree, "type") + pub_name = get_xml_argument(RosTopicPublisher, xml_tree, "name", none_allowed=True) + if topic_name is None: + topic_name = read_value_from_xml_child(xml_tree, "topic", (BtGetValueInputPort, str)) + assert topic_name is not None, "Error: SCXML topic publisher: topic name not found." + return RosTopicPublisher(topic_name, topic_type, pub_name) + + def __init__(self, + topic_name: Union[str, BtGetValueInputPort], topic_type: str, + pub_name: Optional[str] = None) -> None: + """ + Create a new ros_topic_publisher object instance. + + By default, its alias is the same as the topic name, if that is defined as a string. + If the topic is defined as a BtGetValueInputPort, an alias must be provided. + + :param topic_name: The name of the topic where messages are published. + :param topic_type: The type of the message to be published + :param pub_name: Alias used to reference the publisher in SCXML. + """ self._topic_type = topic_type + self._topic_name = topic_name + self._pub_name = pub_name + assert isinstance(self._topic_name, (str, BtGetValueInputPort)), \ + "Error: SCXML topic publisher: invalid topic name." + if self._pub_name is None: + assert is_non_empty_string(RosTopicPublisher, "topic", self._topic_name), \ + "Error: SCXML topic publisher: alias must be provided for dynamic topic names." + self._pub_name = self._topic_name def check_validity(self) -> bool: - valid_name = isinstance(self._topic_name, str) and len(self._topic_name) > 0 + valid_topic_name = isinstance(self._topic_name, BtGetValueInputPort) or \ + is_non_empty_string(RosTopicPublisher, "topic", self._topic_name) valid_type = is_msg_type_known(self._topic_type) - if not valid_name: - print("Error: SCXML topic subscriber: topic name is not valid.") + valid_alias = is_non_empty_string(RosTopicPublisher, "name", self._pub_name) if not valid_type: print("Error: SCXML topic subscriber: topic type is not valid.") - return valid_name and valid_type + return valid_topic_name and valid_type and valid_alias + + def check_valid_instantiation(self) -> bool: + """Check if the topic publisher has undefined entries (i.e. from BT ports).""" + return is_non_empty_string(RosTopicPublisher, "topic", self._topic_name) - def get_topic_name(self) -> str: + def get_topic_name(self) -> Union[str, BtGetValueInputPort]: + """Get the name of the topic where messages are published.""" return self._topic_name def get_topic_type(self) -> str: + """Get a string representation of the topic type.""" return self._topic_type + def get_name(self) -> str: + """Get the alias used to reference the publisher in SCXML.""" + return self._pub_name + + def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler) -> None: + """ + Update the value of the BT ports used in the publisher, if any. + """ + if isinstance(self._topic_name, BtGetValueInputPort): + self._topic_name = bt_ports_handler.get_in_port_value(self._topic_name.get_key_name()) + def as_plain_scxml(self, _) -> ScxmlBase: # This is discarded in the to_plain_scxml_and_declarations method from ScxmlRoot raise RuntimeError("Error: SCXML ROS declarations cannot be converted to plain SCXML.") @@ -77,7 +115,8 @@ def as_plain_scxml(self, _) -> ScxmlBase: def as_xml(self) -> ET.Element: assert self.check_validity(), "Error: SCXML topic subscriber: invalid parameters." xml_topic_publisher = ET.Element( - RosTopicPublisher.get_tag_name(), {"topic": self._topic_name, "type": self._topic_type}) + RosTopicPublisher.get_tag_name(), + {"name": self._pub_name, "topic": self._topic_name, "type": self._topic_type}) return xml_topic_publisher @@ -91,33 +130,53 @@ def get_tag_name() -> str: @staticmethod def from_xml_tree(xml_tree: ET.Element) -> "RosTopicSubscriber": """Create a RosTopicSubscriber object from an XML tree.""" - assert xml_tree.tag == RosTopicSubscriber.get_tag_name(), \ - f"Error: SCXML topic subscribe: XML tag name is not {RosTopicSubscriber.get_tag_name()}" - topic_name = xml_tree.attrib.get("topic") - topic_type = xml_tree.attrib.get("type") - assert topic_name is not None and topic_type is not None, \ - "Error: SCXML topic subscriber: 'topic' or 'type' attribute not found in input xml." - return RosTopicSubscriber(topic_name, topic_type) - - def __init__(self, topic_name: str, topic_type: str) -> None: - self._topic_name = topic_name + assert_xml_tag_ok(RosTopicSubscriber, xml_tree) + topic_name = get_xml_argument(RosTopicSubscriber, xml_tree, "topic", none_allowed=True) + topic_type = get_xml_argument(RosTopicSubscriber, xml_tree, "type") + sub_name = get_xml_argument(RosTopicSubscriber, xml_tree, "name", none_allowed=True) + if topic_name is None: + topic_name = read_value_from_xml_child(xml_tree, "topic", (BtGetValueInputPort, str)) + assert topic_name is not None, "Error: SCXML topic subscriber: topic name not found." + return RosTopicSubscriber(topic_name, topic_type, sub_name) + + def __init__(self, topic_name: Union[str, BtGetValueInputPort], topic_type: str, + sub_name: Optional[str] = None) -> None: self._topic_type = topic_type + self._topic_name = topic_name + self._sub_name = sub_name + assert isinstance(self._topic_name, (str, BtGetValueInputPort)), \ + "Error: SCXML topic subscriber: invalid topic name." + if self._sub_name is None: + assert is_non_empty_string(RosTopicSubscriber, "topic", self._topic_name), \ + "Error: SCXML topic subscriber: alias must be provided for dynamic topic names." + self._sub_name = self._topic_name def check_validity(self) -> bool: - valid_name = isinstance(self._topic_name, str) and len(self._topic_name) > 0 + valid_name = isinstance(self._topic_name, BtGetValueInputPort) or \ + is_non_empty_string(RosTopicSubscriber, "topic", self._topic_name) valid_type = is_msg_type_known(self._topic_type) - if not valid_name: - print("Error: SCXML topic subscriber: topic name is not valid.") + valid_alias = is_non_empty_string(RosTopicSubscriber, "name", self._sub_name) if not valid_type: print("Error: SCXML topic subscriber: topic type is not valid.") - return valid_name and valid_type + return valid_name and valid_type and valid_alias - def get_topic_name(self) -> str: + def check_valid_instantiation(self) -> bool: + """Check if the topic subscriber has undefined entries (i.e. from BT ports).""" + return is_non_empty_string(RosTopicSubscriber, "topic", self._topic_name) + + def get_topic_name(self) -> Union[str, BtGetValueInputPort]: return self._topic_name def get_topic_type(self) -> str: return self._topic_type + def get_name(self) -> str: + return self._sub_name + + def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler) -> None: + """Update the values of potential entries making use of BT ports.""" + pass + def as_plain_scxml(self, _) -> ScxmlBase: # This is discarded in the to_plain_scxml_and_declarations method from ScxmlRoot raise RuntimeError("Error: SCXML ROS declarations cannot be converted to plain SCXML.") @@ -126,7 +185,7 @@ def as_xml(self) -> ET.Element: assert self.check_validity(), "Error: SCXML topic subscriber: invalid parameters." xml_topic_subscriber = ET.Element( RosTopicSubscriber.get_tag_name(), - {"topic": self._topic_name, "type": self._topic_type}) + {"name": self._sub_name, "topic": self._topic_name, "type": self._topic_type}) return xml_topic_subscriber @@ -140,55 +199,52 @@ def get_tag_name() -> str: @staticmethod def from_xml_tree(xml_tree: ET.Element) -> "RosTopicCallback": """Create a RosTopicCallback object from an XML tree.""" - assert xml_tree.tag == RosTopicCallback.get_tag_name(), \ - f"Error: SCXML topic callback: XML tag name is not {RosTopicCallback.get_tag_name()}" - topic_name = xml_tree.attrib.get("topic") - target = xml_tree.attrib.get("target") - assert topic_name is not None and target is not None, \ - "Error: SCXML topic callback: 'topic' or 'target' attribute not found in input xml." + assert_xml_tag_ok(RosTopicCallback, xml_tree) + sub_name = get_xml_argument(RosTopicCallback, xml_tree, "name", none_allowed=True) + if sub_name is None: + sub_name = get_xml_argument(RosTopicCallback, xml_tree, "topic") + print("Warning: SCXML topic callback: the 'topic' argument is deprecated. " + "Use 'name' instead.") + target = get_xml_argument(RosTopicCallback, xml_tree, "target") exec_body = execution_body_from_xml(xml_tree) - return RosTopicCallback(topic_name, target, exec_body) + return RosTopicCallback(sub_name, target, exec_body) def __init__( - self, topic: Union[RosTopicSubscriber, str], target: str, + self, topic_sub: Union[RosTopicSubscriber, str], target: str, body: Optional[ScxmlExecutionBody] = None): """ Create a new ros_topic_callback object instance. - :param topic: The RosTopicSubscriber instance triggering the callback, or its name + :param topic_sub: The RosTopicSubscriber instance triggering the callback, or its name :param target: The target state of the transition :param body: Execution body executed at the time the received message gets processed """ - if isinstance(topic, RosTopicSubscriber): - self._topic = topic.get_topic_name() + if isinstance(topic_sub, RosTopicSubscriber): + self._sub_name = topic_sub.get_name() else: # Used for generating ROS entries from xml file - assert isinstance(topic, str), "Error: SCXML topic callback: invalid topic type." - self._topic = topic + assert is_non_empty_string(RosTopicCallback, "name", topic_sub) + self._sub_name = topic_sub self._target = target self._body = body assert self.check_validity(), "Error: SCXML topic callback: invalid parameters." def check_validity(self) -> bool: - valid_topic = isinstance(self._topic, str) and len(self._topic) > 0 - valid_target = isinstance(self._target, str) and len(self._target) > 0 + valid_sub_name = is_non_empty_string(RosTopicCallback, "name", self._sub_name) + valid_target = is_non_empty_string(RosTopicCallback, "target", self._target) valid_body = self._body is None or valid_execution_body(self._body) - if not valid_topic: - print("Error: SCXML topic callback: topic name is not valid.") - if not valid_target: - print("Error: SCXML topic callback: target is not valid.") if not valid_body: print("Error: SCXML topic callback: body is not valid.") - return valid_topic and valid_target and valid_body + return valid_sub_name and valid_target and valid_body def check_valid_ros_instantiations(self, ros_declarations: ScxmlRosDeclarationsContainer) -> bool: """Check if the ros instantiations have been declared.""" assert isinstance(ros_declarations, ScxmlRosDeclarationsContainer), \ "Error: SCXML topic callback: invalid ROS declarations container." - topic_cb_declared = ros_declarations.is_subscriber_defined(self._topic) + topic_cb_declared = ros_declarations.is_subscriber_defined(self._sub_name) if not topic_cb_declared: - print(f"Error: SCXML topic callback: topic subscriber {self._topic} not declared.") + print(f"Error: SCXML topic callback: topic subscriber {self._sub_name} not declared.") return False valid_body = super().check_valid_ros_instantiations(ros_declarations) if not valid_body: @@ -198,7 +254,8 @@ def check_valid_ros_instantiations(self, def as_plain_scxml(self, ros_declarations: ScxmlRosDeclarationsContainer) -> ScxmlTransition: assert self.check_valid_ros_instantiations(ros_declarations), \ "Error: SCXML topic callback: invalid ROS instantiations." - event_name = "ros_topic." + self._topic + topic_name, _ = ros_declarations.get_subscriber_info(self._sub_name) + event_name = "ros_topic." + sanitize_ros_interface_name(topic_name) target = self._target body = as_plain_execution_body(self._body, ros_declarations) return ScxmlTransition(target, [event_name], None, body) @@ -206,7 +263,7 @@ def as_plain_scxml(self, ros_declarations: ScxmlRosDeclarationsContainer) -> Scx def as_xml(self) -> ET.Element: assert self.check_validity(), "Error: SCXML topic callback: invalid parameters." xml_topic_callback = ET.Element( - "ros_topic_callback", {"topic": self._topic, "target": self._target}) + "ros_topic_callback", {"name": self._sub_name, "target": self._target}) if self._body is not None: for entry in self._body: xml_topic_callback.append(entry.as_xml()) @@ -223,47 +280,44 @@ def get_tag_name() -> str: @staticmethod def from_xml_tree(xml_tree: ET.Element) -> ScxmlSend: """Create a RosTopicPublish object from an XML tree.""" - assert xml_tree.tag == RosTopicPublish.get_tag_name(), \ - f"Error: SCXML topic publish: XML tag name is not {RosTopicPublish.get_tag_name()}" - topic_name = xml_tree.attrib.get("topic") - assert topic_name is not None, \ - "Error: SCXML topic publish: 'topic' attribute not found in input xml." - fields: List[RosField] = [] - for field_xml in xml_tree: - fields.append(RosField.from_xml_tree(field_xml)) - return RosTopicPublish(topic_name, fields) - - def __init__(self, topic: Union[RosTopicPublisher, str], - fields: List[RosField] = None) -> None: + assert_xml_tag_ok(RosTopicPublish, xml_tree) + pub_name = get_xml_argument(RosTopicPublish, xml_tree, "name", none_allowed=True) + if pub_name is None: + pub_name = get_xml_argument(RosTopicSubscriber, xml_tree, "topic") + print("Warning: SCXML topic publisher: the 'topic' argument is deprecated. " + "Use 'name' instead.") + fields: List[RosField] = get_children_as_scxml(xml_tree, (RosField,)) + return RosTopicPublish(pub_name, fields) + + def __init__(self, topic_pub: Union[RosTopicPublisher, str], + fields: Optional[List[RosField]] = None) -> None: if fields is None: fields = [] - if isinstance(topic, RosTopicPublisher): - self._topic = topic.get_topic_name() + if isinstance(topic_pub, RosTopicPublisher): + self._pub_name = topic_pub.get_name() else: # Used for generating ROS entries from xml file - assert isinstance(topic, str), "Error: SCXML topic publish: invalid topic type." - self._topic = topic + assert is_non_empty_string(RosTopicPublish, "name", topic_pub) + self._pub_name = topic_pub self._fields = fields assert self.check_validity(), "Error: SCXML topic publish: invalid parameters." def check_validity(self) -> bool: - valid_topic = isinstance(self._topic, str) and len(self._topic) > 0 + valid_pub_name = is_non_empty_string(RosTopicPublish, "name", self._pub_name) valid_fields = self._fields is None or \ all([isinstance(field, RosField) and field.check_validity() for field in self._fields]) - if not valid_topic: - print("Error: SCXML topic publish: topic name is not valid.") if not valid_fields: print("Error: SCXML topic publish: fields are not valid.") - return valid_topic and valid_fields + return valid_pub_name and valid_fields def check_valid_ros_instantiations(self, ros_declarations: ScxmlRosDeclarationsContainer) -> bool: """Check if the ros instantiations have been declared.""" assert isinstance(ros_declarations, ScxmlRosDeclarationsContainer), \ "Error: SCXML topic publish: invalid ROS declarations container." - topic_pub_declared = ros_declarations.is_publisher_defined(self._topic) + topic_pub_declared = ros_declarations.is_publisher_defined(self._pub_name) if not topic_pub_declared: - print(f"Error: SCXML topic publish: topic {self._topic} not declared.") + print(f"Error: SCXML topic publish: topic publisher {self._pub_name} not declared.") # TODO: Check for valid fields can be done here return topic_pub_declared @@ -277,17 +331,23 @@ def append_field(self, field: RosField) -> None: self._fields = [] self._fields.append(field) + def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler): + """Update the values of potential entries making use of BT ports.""" + for field in self._fields: + field.update_bt_ports_values(bt_ports_handler) + def as_plain_scxml(self, ros_declarations: ScxmlRosDeclarationsContainer) -> ScxmlSend: assert self.check_valid_ros_instantiations(ros_declarations), \ "Error: SCXML topic publish: invalid ROS instantiations." - event_name = "ros_topic." + self._topic + topic_name, _ = ros_declarations.get_publisher_info(self._pub_name) + event_name = "ros_topic." + sanitize_ros_interface_name(topic_name) params = None if self._fields is None else \ [field.as_plain_scxml(ros_declarations) for field in self._fields] return ScxmlSend(event_name, params) def as_xml(self) -> ET.Element: assert self.check_validity(), "Error: SCXML topic publish: invalid parameters." - xml_topic_publish = ET.Element(RosTopicPublish.get_tag_name(), {"topic": self._topic}) + xml_topic_publish = ET.Element(RosTopicPublish.get_tag_name(), {"name": self._pub_name}) if self._fields is not None: for field in self._fields: xml_topic_publish.append(field.as_xml()) diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_state.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_state.py index ad1be9db..6fd2da19 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/scxml_state.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_state.py @@ -17,17 +17,14 @@ A single state in SCXML. In XML, it has the tag `state`. """ -from typing import List, Optional, Sequence, Union +from typing import List, Sequence, Union from xml.etree import ElementTree as ET -from scxml_converter.scxml_entries import (ScxmlBase, ScxmlExecutableEntry, - ScxmlExecutionBody, - ScxmlRosDeclarationsContainer, - ScxmlRosTransitions, - ScxmlTransition, - as_plain_execution_body, - execution_body_from_xml, - valid_execution_body) +from scxml_converter.scxml_entries import ( + ScxmlBase, ScxmlExecutableEntry, ScxmlExecutionBody, ScxmlRosDeclarationsContainer, + ScxmlTransition, as_plain_execution_body, execution_body_from_xml, valid_execution_body, + instantiate_exec_body_bt_events) +from scxml_converter.scxml_entries.bt_utils import BtPortsHandler class ScxmlState(ScxmlBase): @@ -93,6 +90,22 @@ def get_body(self) -> List[ScxmlTransition]: """Return the transitions leaving the state.""" return self._body + def instantiate_bt_events(self, instance_id: str) -> None: + """Instantiate the BT events in all entries belonging to a state.""" + for transition in self._body: + transition.instantiate_bt_events(instance_id) + instantiate_exec_body_bt_events(self._on_entry, instance_id) + instantiate_exec_body_bt_events(self._on_exit, instance_id) + + def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler) -> None: + """Update the values of potential entries making use of BT ports.""" + for transition in self._body: + transition.update_bt_ports_values(bt_ports_handler) + for entry in self._on_entry: + entry.update_bt_ports_values(bt_ports_handler) + for entry in self._on_exit: + entry.update_bt_ports_values(bt_ports_handler) + @classmethod def _transitions_from_xml(cls, xml_tree: ET.Element) -> List[ScxmlTransition]: transitions: List[ScxmlTransition] = [] @@ -151,8 +164,9 @@ def check_valid_ros_instantiations(self, return valid_entry and valid_exit and valid_body @staticmethod - def _check_valid_ros_instantiations(body: Sequence[Union[ScxmlExecutableEntry, ScxmlTransition]], - ros_declarations: ScxmlRosDeclarationsContainer) -> bool: + def _check_valid_ros_instantiations( + body: Sequence[Union[ScxmlExecutableEntry, ScxmlTransition]], + ros_declarations: ScxmlRosDeclarationsContainer) -> bool: """Check if the ros instantiations have been declared in the body.""" return len(body) == 0 or \ all(entry.check_valid_ros_instantiations(ros_declarations) for entry in body) diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_transition.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_transition.py index 5b3fbc02..1bd4bf02 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/scxml_transition.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_transition.py @@ -20,11 +20,12 @@ from typing import List, Optional from xml.etree import ElementTree as ET -from scxml_converter.scxml_entries import (ScxmlBase, ScxmlExecutableEntry, - ScxmlExecutionBody, - ScxmlRosDeclarationsContainer, - execution_body_from_xml, - valid_execution_body) +from scxml_converter.scxml_entries import ( + ScxmlBase, ScxmlExecutableEntry, ScxmlExecutionBody, ScxmlRosDeclarationsContainer, + execution_body_from_xml, valid_execution_body, valid_execution_body_entry_types, + instantiate_exec_body_bt_events) + +from scxml_converter.scxml_entries.bt_utils import is_bt_event, replace_bt_event, BtPortsHandler class ScxmlTransition(ScxmlBase): @@ -65,8 +66,8 @@ def __init__(self, f"Error SCXML transition: events must be a list of non-empty strings. Found {events}." assert condition is None or (isinstance(condition, str) and len(condition) > 0), \ "Error SCXML transition: condition must be a non-empty string." - assert body is None or valid_execution_body( - body), "Error SCXML transition: invalid body provided." + assert body is None or valid_execution_body_entry_types(body), \ + "Error SCXML transition: invalid body provided." self._target = target self._body = body self._events = events if events is not None else [] @@ -88,6 +89,22 @@ def get_executable_body(self) -> ScxmlExecutionBody: """Return the executable content of this transition.""" return self._body if self._body is not None else [] + def instantiate_bt_events(self, instance_id: str): + """Instantiate the BT events of this transition.""" + # Make sure to replace received events only for ScxmlTransition objects. + if type(self) is ScxmlTransition: + for event_id, event_str in enumerate(self._events): + # Those are expected to be only ticks + if is_bt_event(event_str): + self._events[event_id] = replace_bt_event(event_str, instance_id) + # The body of a transition is needs to be replaced on derived classes, too + instantiate_exec_body_bt_events(self._body, instance_id) + + def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler) -> None: + """Update the values of potential entries making use of BT ports.""" + for entry in self._body: + entry.update_bt_ports_values(bt_ports_handler) + def add_event(self, event: str): self._events.append(event) @@ -95,8 +112,8 @@ def append_body_executable_entry(self, exec_entry: ScxmlExecutableEntry): if self._body is None: self._body = [] self._body.append(exec_entry) - assert valid_execution_body(self._body), \ - "Error SCXML transition: invalid body after extension." + assert valid_execution_body_entry_types(self._body), \ + "Error SCXML transition: invalid body entry found after extension." def check_validity(self) -> bool: valid_target = isinstance(self._target, str) and len(self._target) > 0 diff --git a/scxml_converter/src/scxml_converter/scxml_entries/utils.py b/scxml_converter/src/scxml_converter/scxml_entries/utils.py index 493ae98d..7e62e3bc 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/utils.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/utils.py @@ -15,21 +15,13 @@ """Collection of various utilities for scxml entries.""" -from typing import Dict, List, Optional, Tuple +from typing import Dict, Type -from scxml_converter.scxml_entries.scxml_ros_field import RosField - -MSG_TYPE_SUBSTITUTIONS = { - "boolean": "bool", -} - -BASIC_FIELD_TYPES = ['boolean', - 'int8', 'int16', 'int32', 'int64', - 'float', 'double'] +from scxml_converter.scxml_entries import ScxmlBase # TODO: add lower and upper bounds depending on the n. of bits used. # TODO: add support to uint -SCXML_DATA_STR_TO_TYPE = { +SCXML_DATA_STR_TO_TYPE: Dict[str, Type] = { "bool": bool, "float32": float, "float64": float, @@ -40,227 +32,35 @@ } -def is_ros_type_known(type_definition: str, ros_interface: str) -> bool: +def all_non_empty_strings(*in_args) -> bool: """ - Check if python can import the provided type definition. + Check if all the arguments are non-empty strings. - :param type_definition: The type definition to check (e.g. std_msgs/Empty). + :param kwargs: The arguments to be checked. + :return: True if all the arguments are non-empty strings, False otherwise. """ - if not (isinstance(type_definition, str) and type_definition.count("/") == 1): - return False - interface_ns, interface_type = type_definition.split("/") - if len(interface_ns) == 0 or len(interface_type) == 0: - return False - assert ros_interface in ["msg", "srv"], "Error: SCXML ROS declarations: unknown ROS interface." - try: - interface_importer = __import__(interface_ns + f'.{ros_interface}', fromlist=['']) - _ = getattr(interface_importer, interface_type) - except (ImportError, AttributeError): - print(f"Error: SCXML ROS declarations: topic type {type_definition} not found.") - return False + for arg_value in in_args: + if not isinstance(arg_value, str) or len(arg_value) == 0: + return False return True -def is_msg_type_known(topic_definition: str) -> bool: - """Check if python can import the provided topic definition.""" - return is_ros_type_known(topic_definition, "msg") - - -def is_srv_type_known(service_definition: str) -> bool: - """Check if python can import the provided service definition.""" - return is_ros_type_known(service_definition, "srv") - - -def get_srv_type_params(service_definition: str) -> Tuple[Dict[str, str], Dict[str, str]]: - """ - Get the data fields of a service request and response type as pairs of name and type objects. +def is_non_empty_string(scxml_type: Type[ScxmlBase], arg_name: str, arg_value: str) -> bool: """ - assert is_srv_type_known(service_definition), \ - "Error: SCXML ROS declarations: service type not found." - interface_ns, interface_type = service_definition.split("/") - srv_module = __import__(interface_ns + '.srv', fromlist=['']) - srv_class = getattr(srv_module, interface_type) - - # TODO: Fields can be nested. Look AS2FM/scxml_converter/src/scxml_converter/scxml_converter.py - req = srv_class.Request.get_fields_and_field_types() - for key in req.keys(): - # TODO: Support nested fields - assert req[key] in BASIC_FIELD_TYPES, \ - f"Error: SCXML ROS declarations: service request type {req[key]} isn't a basic field." - req[key] = MSG_TYPE_SUBSTITUTIONS.get(req[key], req[key]) - - res = srv_class.Response.get_fields_and_field_types() - for key in res.keys(): - assert res[key] in BASIC_FIELD_TYPES, \ - "Error: SCXML ROS declarations: service response type contains non-basic fields." - res[key] = MSG_TYPE_SUBSTITUTIONS.get(res[key], res[key]) - - return req, res + Check if a string is non-empty. - -def replace_ros_interface_expression(msg_expr: str) -> str: - """Convert a ROS interface expression (msg, req, res) to plain SCXML (event).""" - scxml_prefix = "_event." - # TODO: Use regex and ensure no other valid character exists before the initial underscore - for ros_prefix in ["_msg.", "_req.", "_res."]: - msg_expr = msg_expr.replace(ros_prefix, scxml_prefix) - return msg_expr - - -def sanitize_ros_interface_name(interface_name: str) -> str: - """Replace slashes in a ROS interface name.""" - assert isinstance(interface_name, str), \ - "Error: ROS interface sanitizer: interface name must be a string." - # Remove potential prepended slash - interface_name = interface_name.removeprefix("/") - assert len(interface_name) > 0, \ - "Error: ROS interface sanitizer: interface name must not be empty." - assert interface_name.count(" ") == 0, \ - "Error: ROS interface sanitizer: interface name must not contain spaces." - return interface_name.replace("/", "__") + :param scxml_type: The scxml entry where this function is called, to write error msgs. + :param arg_name: The name of the argument, to write error msgs. + :param arg_value: The value of the argument to be checked. + :return: True if the string is non-empty, False otherwise. + """ + valid_str = isinstance(arg_value, str) and len(arg_value) > 0 + if not valid_str: + print(f"Error: SCXML conversion of {scxml_type.get_tag_name()}: " + f"Expected non-empty argument {arg_name}.") + return valid_str def get_default_expression_for_type(field_type: str) -> str: """Generate a default expression for a field type.""" return str(SCXML_DATA_STR_TO_TYPE[field_type]()) - - -def generate_srv_request_event(service_name: str, automaton_name: str) -> str: - """Generate the name of the event that triggers a service request.""" - return f"srv_{sanitize_ros_interface_name(service_name)}_req_client_{automaton_name}" - - -def generate_srv_response_event(service_name: str, automaton_name: str) -> str: - """Generate the name of the event that provides the service response.""" - return f"srv_{sanitize_ros_interface_name(service_name)}_response_client_{automaton_name}" - - -def generate_srv_server_request_event(service_name: str) -> str: - """Generate the name of the event that makes a service server start processing a request.""" - return f"srv_{sanitize_ros_interface_name(service_name)}_request" - - -def generate_srv_server_response_event(service_name: str) -> str: - """Generate the name of the event that makes a service server send a response.""" - return f"srv_{sanitize_ros_interface_name(service_name)}_response" - - -class ScxmlRosDeclarationsContainer: - """Object that contains a description of the ROS declarations in the SCXML root.""" - - def __init__(self, automaton_name: str): - """Constructor of container. - - :automaton_name: Name of the automaton these declarations belong to. - """ - self._automaton_name: str = automaton_name - # Dict of publishers and subscribers: topic name -> type - self._publishers: Dict[str, str] = {} - self._subscribers: Dict[str, str] = {} - self._service_servers: Dict[str, str] = {} - self._service_clients: Dict[str, str] = {} - self._timers: Dict[str, float] = {} - - def get_automaton_name(self) -> str: - """Get name of the automaton that these declarations are defined in.""" - return self._automaton_name - - def append_publisher(self, topic_name: str, topic_type: str) -> None: - assert isinstance(topic_name, str) and isinstance(topic_type, str), \ - "Error: ROS declarations: topic name and type must be strings." - assert topic_name not in self._publishers, \ - f"Error: ROS declarations: topic publisher {topic_name} already declared." - self._publishers[topic_name] = topic_type - - def append_subscriber(self, topic_name: str, topic_type: str) -> None: - assert isinstance(topic_name, str) and isinstance(topic_type, str), \ - "Error: ROS declarations: topic name and type must be strings." - assert topic_name not in self._subscribers, \ - f"Error: ROS declarations: topic subscriber {topic_name} already declared." - self._subscribers[topic_name] = topic_type - - def append_service_client(self, service_name: str, service_type: str) -> None: - assert isinstance(service_name, str) and isinstance(service_type, str), \ - "Error: ROS declarations: service name and type must be strings." - assert service_name not in self._service_clients, \ - f"Error: ROS declarations: service client {service_name} already declared." - self._service_clients[service_name] = service_type - - def append_service_server(self, service_name: str, service_type: str) -> None: - assert isinstance(service_name, str) and isinstance(service_type, str), \ - "Error: ROS declarations: service name and type must be strings." - assert service_name not in self._service_servers, \ - f"Error: ROS declarations: service server {service_name} already declared." - self._service_servers[service_name] = service_type - - def append_timer(self, timer_name: str, timer_rate: float) -> None: - assert isinstance(timer_name, str), "Error: ROS declarations: timer name must be a string." - assert isinstance(timer_rate, float) and timer_rate > 0, \ - "Error: ROS declarations: timer rate must be a positive number." - assert timer_name not in self._timers, \ - f"Error: ROS declarations: timer {timer_name} already declared." - self._timers[timer_name] = timer_rate - - def is_publisher_defined(self, topic_name: str) -> bool: - return topic_name in self._publishers - - def is_subscriber_defined(self, topic_name: str) -> bool: - return topic_name in self._subscribers - - def is_timer_defined(self, timer_name: str) -> bool: - return timer_name in self._timers - - def get_timers(self) -> Dict[str, float]: - return self._timers - - def is_service_client_defined(self, service_name: str) -> bool: - return service_name in self._service_clients - - def is_service_server_defined(self, service_name: str) -> bool: - return service_name in self._service_servers - - def get_service_client_type(self, service_name: str) -> Optional[str]: - return self._service_clients.get(service_name, None) - - def get_service_server_type(self, service_name: str) -> Optional[str]: - return self._service_servers.get(service_name, None) - - def check_valid_srv_req_fields(self, service_name: str, ros_fields: List[RosField]) -> bool: - """Check if the provided fields match the service request type.""" - req_type = self.get_service_client_type(service_name) - if req_type is None: - print(f"Error: SCXML ROS declarations: unknown service client {service_name}.") - return False - req_fields, _ = get_srv_type_params(req_type) - for ros_field in ros_fields: - if ros_field.get_name() not in req_fields: - print("Error: SCXML ROS declarations: " - f"unknown field {ros_field.get_name()} in service request.") - return False - req_fields.pop(ros_field.get_name()) - if len(req_fields) > 0: - print("Error: SCXML ROS declarations: missing fields in service request.") - for req_field in req_fields.keys(): - print(f"\t-{req_field}.") - return False - return True - - def check_valid_srv_res_fields(self, service_name: str, ros_fields: List[RosField]) -> bool: - """Check if the provided fields match the service response type.""" - res_type = self.get_service_server_type(service_name) - if res_type is None: - print(f"Error: SCXML ROS declarations: unknown service server {service_name}.") - return False - _, res_fields = get_srv_type_params(res_type) - for ros_field in ros_fields: - if ros_field.get_name() not in res_fields: - print("Error: SCXML ROS declarations: " - f"unknown field {ros_field.get_name()} in service response.") - return False - res_fields.pop(ros_field.get_name()) - if len(res_fields) > 0: - print("Error: SCXML ROS declarations: missing fields in service response.") - for res_field in res_fields.keys(): - print(f"\t-{res_field}.") - return False - return True diff --git a/scxml_converter/src/scxml_converter/scxml_entries/xml_utils.py b/scxml_converter/src/scxml_converter/scxml_entries/xml_utils.py new file mode 100644 index 00000000..5b325379 --- /dev/null +++ b/scxml_converter/src/scxml_converter/scxml_entries/xml_utils.py @@ -0,0 +1,109 @@ +# Copyright (c) 2024 - for information on the respective copyright owner +# see the NOTICE file + +# 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. + +from typing import List, Optional, Tuple, Type, Union + +from scxml_converter.scxml_entries import ScxmlBase +from xml.etree.ElementTree import Element + + +def assert_xml_tag_ok(scxml_type: Type[ScxmlBase], xml_tree: Element): + """Ensures the xml_tree we are trying to parse has the expected name.""" + assert xml_tree.tag == scxml_type.get_tag_name(), \ + f"SCXML conversion: Expected tag {scxml_type.get_tag_name()}, but got {xml_tree.tag}" + + +def get_xml_argument(scxml_type: Type[ScxmlBase], xml_tree: Element, arg_name: str, *, + none_allowed=False, empty_allowed=False) -> Optional[str]: + """Load an argument from the xml tree's root tag.""" + arg_value = xml_tree.get(arg_name) + error_prefix = f"SCXML conversion of {scxml_type.get_tag_name()}" + if arg_value is None: + assert none_allowed, f"{error_prefix}: Expected argument {arg_name} in {xml_tree.tag}" + elif len(arg_value) == 0: + assert empty_allowed, \ + f"{error_prefix}: Expected non-empty argument {arg_name} in {xml_tree.tag}" + return arg_value + + +def get_children_as_scxml( + xml_tree: Element, scxml_types: Tuple[Type[ScxmlBase]]) -> List[ScxmlBase]: + """ + Load the children of the xml tree as scxml entries. + + :param xml_tree: The xml tree to read the children from. + :param scxml_types: The classes to read from the children. All others will be discarded. + :return: A list of scxml entries. + """ + scxml_list = [] + tag_to_type = {scxml_type.get_tag_name(): scxml_type for scxml_type in scxml_types} + for child in xml_tree: + if child.tag in tag_to_type: + scxml_list.append(tag_to_type[child.tag].from_xml_tree(child)) + return scxml_list + + +def read_value_from_xml_child( + xml_tree: Element, child_tag: str, valid_types: Tuple[Type[Union[ScxmlBase, str]]] + ) -> Optional[Union[str, ScxmlBase]]: + """ + Try to read the value of a child tag from the xml tree. If the child is not found, return None. + """ + xml_child = xml_tree.findall(child_tag) + if xml_child is None or len(xml_child) == 0: + print(f"Error reading from {xml_tree.tag}: Cannot find child '{child_tag}'.") + return None + if len(xml_child) > 1: + print(f"Error reading from {xml_tree.tag}: multiple children '{child_tag}', expected one.") + return None + n_tag_children = len(xml_child[0]) + if n_tag_children == 0 and str in valid_types: + # Try to read the text value + text_value = xml_child[0].text + if text_value is None or len(text_value) == 0: + print(f"Error reading from {xml_tree.tag}: Child '{child_tag}' has no text value.") + return None + return text_value + if n_tag_children > 1: + print(f"Error reading from {xml_tree.tag}: Child '{child_tag}' has multiple children.") + return None + # Remove string from valid types, if present + valid_types = tuple(t for t in valid_types if t != str) + scxml_entry = get_children_as_scxml(xml_child[0], valid_types) + if len(scxml_entry) == 0: + print(f"Error reading from {xml_tree.tag}: Child '{child_tag}' has no valid children.") + return None + return scxml_entry[0] + + +def read_value_from_xml_arg_or_child( + scxml_type: Type[ScxmlBase], xml_tree: Element, tag_name: str, + valid_types: Tuple[Type[Union[ScxmlBase, str]]], + none_allowed=False) -> Optional[Union[str, ScxmlBase]]: + """ + Read a value from an xml attribute or, if not found, the child tag with the same name. + + To read the value from the xml arguments, valid_types must include string. + """ + assert str in valid_types, \ + "Error: read_value_from_arg_or_child: valid_types must include str. " \ + "If strings are not expected, use 'read_value_from_xml_child'." + read_value = get_xml_argument(scxml_type, xml_tree, tag_name, none_allowed=True) + if read_value is None: + read_value = read_value_from_xml_child(xml_tree, tag_name, valid_types) + if not none_allowed: + assert read_value is not None, \ + f"Error: SCXML conversion of {scxml_type.get_tag_name()}: Missing argument {tag_name}." + return read_value diff --git a/scxml_converter/test/_test_data/.gitignore b/scxml_converter/test/_test_data/.gitignore new file mode 100644 index 00000000..5b0a2740 --- /dev/null +++ b/scxml_converter/test/_test_data/.gitignore @@ -0,0 +1 @@ +*/output \ No newline at end of file diff --git a/scxml_converter/test/_test_data/add_int_srv_example/client_1.scxml b/scxml_converter/test/_test_data/add_int_srv_example/client_1.scxml new file mode 100644 index 00000000..66d8237b --- /dev/null +++ b/scxml_converter/test/_test_data/add_int_srv_example/client_1.scxml @@ -0,0 +1,34 @@ + + + + + + + + + + /adder + + + + + + + + + + + + + + + + + + + diff --git a/scxml_converter/test/_test_data/add_int_srv_example/gt_plain_scxml/client_1.scxml b/scxml_converter/test/_test_data/add_int_srv_example/gt_plain_scxml/client_1.scxml new file mode 100644 index 00000000..b5c3d0c4 --- /dev/null +++ b/scxml_converter/test/_test_data/add_int_srv_example/gt_plain_scxml/client_1.scxml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/scxml_converter/test/_test_data/add_int_srv_example/gt_plain_scxml/server.scxml b/scxml_converter/test/_test_data/add_int_srv_example/gt_plain_scxml/server.scxml new file mode 100644 index 00000000..f0a5c913 --- /dev/null +++ b/scxml_converter/test/_test_data/add_int_srv_example/gt_plain_scxml/server.scxml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/scxml_converter/test/_test_data/add_int_srv_example/server.scxml b/scxml_converter/test/_test_data/add_int_srv_example/server.scxml new file mode 100644 index 00000000..8200ad63 --- /dev/null +++ b/scxml_converter/test/_test_data/add_int_srv_example/server.scxml @@ -0,0 +1,27 @@ + + + + + + + + + + /adder + + + + + + + + + + + + diff --git a/scxml_converter/test/_test_data/battery_drainer_w_bt/battery_drainer.scxml b/scxml_converter/test/_test_data/battery_drainer_w_bt/battery_drainer.scxml new file mode 100644 index 00000000..fb85f977 --- /dev/null +++ b/scxml_converter/test/_test_data/battery_drainer_w_bt/battery_drainer.scxml @@ -0,0 +1,33 @@ + + + + + + + + + charge + + + + + + + + + + + + + + + + + + + diff --git a/scxml_converter/test/_test_data/input_files/battery_manager.scxml b/scxml_converter/test/_test_data/battery_drainer_w_bt/battery_manager.scxml similarity index 81% rename from scxml_converter/test/_test_data/input_files/battery_manager.scxml rename to scxml_converter/test/_test_data/battery_drainer_w_bt/battery_manager.scxml index 1b092d32..e0291ef8 100644 --- a/scxml_converter/test/_test_data/input_files/battery_manager.scxml +++ b/scxml_converter/test/_test_data/battery_drainer_w_bt/battery_manager.scxml @@ -10,8 +10,8 @@ - - + + diff --git a/scxml_converter/test/_test_data/input_files/bt.xml b/scxml_converter/test/_test_data/battery_drainer_w_bt/bt.xml similarity index 100% rename from scxml_converter/test/_test_data/input_files/bt.xml rename to scxml_converter/test/_test_data/battery_drainer_w_bt/bt.xml diff --git a/scxml_converter/test/_test_data/input_files/bt_topic_action.scxml b/scxml_converter/test/_test_data/battery_drainer_w_bt/bt_topic_action.scxml similarity index 93% rename from scxml_converter/test/_test_data/input_files/bt_topic_action.scxml rename to scxml_converter/test/_test_data/battery_drainer_w_bt/bt_topic_action.scxml index ca15edbc..53736c4f 100644 --- a/scxml_converter/test/_test_data/input_files/bt_topic_action.scxml +++ b/scxml_converter/test/_test_data/battery_drainer_w_bt/bt_topic_action.scxml @@ -13,7 +13,7 @@ - + diff --git a/scxml_converter/test/_test_data/input_files/bt_topic_condition.scxml b/scxml_converter/test/_test_data/battery_drainer_w_bt/bt_topic_condition.scxml similarity index 86% rename from scxml_converter/test/_test_data/input_files/bt_topic_condition.scxml rename to scxml_converter/test/_test_data/battery_drainer_w_bt/bt_topic_condition.scxml index 4fa248cd..cd91b1a0 100644 --- a/scxml_converter/test/_test_data/input_files/bt_topic_condition.scxml +++ b/scxml_converter/test/_test_data/battery_drainer_w_bt/bt_topic_condition.scxml @@ -10,13 +10,13 @@ - + - + diff --git a/scxml_converter/test/_test_data/expected_output_bt_and_plugins/10000_TopicCondition.scxml b/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_bt_scxml/10000_TopicCondition.scxml similarity index 83% rename from scxml_converter/test/_test_data/expected_output_bt_and_plugins/10000_TopicCondition.scxml rename to scxml_converter/test/_test_data/battery_drainer_w_bt/gt_bt_scxml/10000_TopicCondition.scxml index f458bfc9..7e07abd3 100644 --- a/scxml_converter/test/_test_data/expected_output_bt_and_plugins/10000_TopicCondition.scxml +++ b/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_bt_scxml/10000_TopicCondition.scxml @@ -2,7 +2,7 @@ @@ -10,13 +10,13 @@ - + - + diff --git a/scxml_converter/test/_test_data/expected_output_bt_and_plugins/1001_TopicAction.scxml b/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_bt_scxml/1001_TopicAction.scxml similarity index 79% rename from scxml_converter/test/_test_data/expected_output_bt_and_plugins/1001_TopicAction.scxml rename to scxml_converter/test/_test_data/battery_drainer_w_bt/gt_bt_scxml/1001_TopicAction.scxml index b42be1e0..f76b81f0 100644 --- a/scxml_converter/test/_test_data/expected_output_bt_and_plugins/1001_TopicAction.scxml +++ b/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_bt_scxml/1001_TopicAction.scxml @@ -2,18 +2,18 @@ - + - + diff --git a/scxml_converter/test/_test_data/expected_output_bt_and_plugins/bt.scxml b/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_bt_scxml/bt.scxml similarity index 100% rename from scxml_converter/test/_test_data/expected_output_bt_and_plugins/bt.scxml rename to scxml_converter/test/_test_data/battery_drainer_w_bt/gt_bt_scxml/bt.scxml diff --git a/scxml_converter/test/_test_data/input_files/battery_drainer.scxml b/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_parsed_scxml/battery_drainer.scxml similarity index 78% rename from scxml_converter/test/_test_data/input_files/battery_drainer.scxml rename to scxml_converter/test/_test_data/battery_drainer_w_bt/gt_parsed_scxml/battery_drainer.scxml index f38f1ff2..71315301 100644 --- a/scxml_converter/test/_test_data/input_files/battery_drainer.scxml +++ b/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_parsed_scxml/battery_drainer.scxml @@ -10,21 +10,21 @@ - + - + - + - + diff --git a/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_parsed_scxml/bt_topic_condition.scxml b/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_parsed_scxml/bt_topic_condition.scxml new file mode 100644 index 00000000..cd91b1a0 --- /dev/null +++ b/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_parsed_scxml/bt_topic_condition.scxml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scxml_converter/test/_test_data/expected_output_ros_to_scxml/battery_drainer.scxml b/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_plain_scxml/battery_drainer.scxml similarity index 100% rename from scxml_converter/test/_test_data/expected_output_ros_to_scxml/battery_drainer.scxml rename to scxml_converter/test/_test_data/battery_drainer_w_bt/gt_plain_scxml/battery_drainer.scxml diff --git a/scxml_converter/test/_test_data/expected_output_ros_to_scxml/battery_manager.scxml b/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_plain_scxml/battery_manager.scxml similarity index 100% rename from scxml_converter/test/_test_data/expected_output_ros_to_scxml/battery_manager.scxml rename to scxml_converter/test/_test_data/battery_drainer_w_bt/gt_plain_scxml/battery_manager.scxml diff --git a/scxml_converter/test/_test_data/expected_output_ros_to_scxml/bt_topic_action.scxml b/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_plain_scxml/bt_topic_action.scxml similarity index 100% rename from scxml_converter/test/_test_data/expected_output_ros_to_scxml/bt_topic_action.scxml rename to scxml_converter/test/_test_data/battery_drainer_w_bt/gt_plain_scxml/bt_topic_action.scxml diff --git a/scxml_converter/test/_test_data/expected_output_ros_to_scxml/bt_topic_condition.scxml b/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_plain_scxml/bt_topic_condition.scxml similarity index 100% rename from scxml_converter/test/_test_data/expected_output_ros_to_scxml/bt_topic_condition.scxml rename to scxml_converter/test/_test_data/battery_drainer_w_bt/gt_plain_scxml/bt_topic_condition.scxml diff --git a/scxml_converter/test/_test_data/bt_ports_blackboard/bt.xml b/scxml_converter/test/_test_data/bt_ports_blackboard/bt.xml new file mode 100644 index 00000000..dae21f3f --- /dev/null +++ b/scxml_converter/test/_test_data/bt_ports_blackboard/bt.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/scxml_converter/test/_test_data/bt_ports_blackboard/bt_get_number.scxml b/scxml_converter/test/_test_data/bt_ports_blackboard/bt_get_number.scxml new file mode 100644 index 00000000..2752d317 --- /dev/null +++ b/scxml_converter/test/_test_data/bt_ports_blackboard/bt_get_number.scxml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + diff --git a/scxml_converter/test/_test_data/bt_ports_blackboard/bt_topic_action.scxml b/scxml_converter/test/_test_data/bt_ports_blackboard/bt_topic_action.scxml new file mode 100644 index 00000000..0f935e09 --- /dev/null +++ b/scxml_converter/test/_test_data/bt_ports_blackboard/bt_topic_action.scxml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/scxml_converter/test/_test_data/bt_ports_only/bt.xml b/scxml_converter/test/_test_data/bt_ports_only/bt.xml new file mode 100644 index 00000000..f690a4d4 --- /dev/null +++ b/scxml_converter/test/_test_data/bt_ports_only/bt.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/scxml_converter/test/_test_data/bt_ports_only/bt_topic_action.scxml b/scxml_converter/test/_test_data/bt_ports_only/bt_topic_action.scxml new file mode 100644 index 00000000..4dbce6c5 --- /dev/null +++ b/scxml_converter/test/_test_data/bt_ports_only/bt_topic_action.scxml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/scxml_converter/test/_test_data/bt_ports_only/gt_bt_scxml/1000_TopicAction.scxml b/scxml_converter/test/_test_data/bt_ports_only/gt_bt_scxml/1000_TopicAction.scxml new file mode 100644 index 00000000..8012bf94 --- /dev/null +++ b/scxml_converter/test/_test_data/bt_ports_only/gt_bt_scxml/1000_TopicAction.scxml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/scxml_converter/test/_test_data/bt_ports_only/gt_bt_scxml/1001_TopicAction.scxml b/scxml_converter/test/_test_data/bt_ports_only/gt_bt_scxml/1001_TopicAction.scxml new file mode 100644 index 00000000..ffbcd88a --- /dev/null +++ b/scxml_converter/test/_test_data/bt_ports_only/gt_bt_scxml/1001_TopicAction.scxml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/scxml_converter/test/_test_data/bt_ports_only/gt_bt_scxml/bt.scxml b/scxml_converter/test/_test_data/bt_ports_only/gt_bt_scxml/bt.scxml new file mode 100644 index 00000000..05cc4fb6 --- /dev/null +++ b/scxml_converter/test/_test_data/bt_ports_only/gt_bt_scxml/bt.scxml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/scxml_converter/test/_test_data/bt_ports_only/gt_parsed_scxml/bt_topic_action.scxml b/scxml_converter/test/_test_data/bt_ports_only/gt_parsed_scxml/bt_topic_action.scxml new file mode 100644 index 00000000..c29c0222 --- /dev/null +++ b/scxml_converter/test/_test_data/bt_ports_only/gt_parsed_scxml/bt_topic_action.scxml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/scxml_converter/test/_test_data/bt_ports_only/gt_plain_scxml/bt_topic_action.scxml b/scxml_converter/test/_test_data/bt_ports_only/gt_plain_scxml/bt_topic_action.scxml new file mode 100644 index 00000000..7ca960ef --- /dev/null +++ b/scxml_converter/test/_test_data/bt_ports_only/gt_plain_scxml/bt_topic_action.scxml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/scxml_converter/test/_test_data/input_files/invalid_xmls/battery_drainer.scxml b/scxml_converter/test/_test_data/invalid_xmls/battery_drainer.scxml similarity index 100% rename from scxml_converter/test/_test_data/input_files/invalid_xmls/battery_drainer.scxml rename to scxml_converter/test/_test_data/invalid_xmls/battery_drainer.scxml diff --git a/scxml_converter/test/_test_data/input_files/invalid_xmls/bt_topic_action.scxml b/scxml_converter/test/_test_data/invalid_xmls/bt_topic_action.scxml similarity index 100% rename from scxml_converter/test/_test_data/input_files/invalid_xmls/bt_topic_action.scxml rename to scxml_converter/test/_test_data/invalid_xmls/bt_topic_action.scxml diff --git a/scxml_converter/test/test_systemtest_scxml_entries.py b/scxml_converter/test/test_systemtest_scxml_entries.py index 2f79400a..c0fc9a02 100644 --- a/scxml_converter/test/test_systemtest_scxml_entries.py +++ b/scxml_converter/test/test_systemtest_scxml_entries.py @@ -17,37 +17,43 @@ from test_utils import canonicalize_xml, remove_empty_lines -from scxml_converter.scxml_entries import (RosField, RosRateCallback, - RosTimeRate, RosTopicCallback, - RosTopicPublish, RosTopicPublisher, - RosTopicSubscriber, ScxmlAssign, - ScxmlData, ScxmlDataModel, - ScxmlParam, ScxmlRoot, ScxmlSend, - ScxmlState, ScxmlTransition) +from scxml_converter.scxml_entries import ( + RosField, RosRateCallback, RosTimeRate, RosTopicCallback, RosTopicPublish, RosTopicPublisher, + RosTopicSubscriber, ScxmlAssign, ScxmlData, ScxmlDataModel, ScxmlParam, ScxmlRoot, ScxmlSend, + ScxmlState, ScxmlTransition, BtInputPortDeclaration, BtGetValueInputPort) + + +def _test_scxml_from_code(scxml_root: ScxmlRoot, ref_file_path: str): + # Check output xml + with open(ref_file_path, 'r', encoding='utf-8') as f_o: + expected_output = f_o.read() + test_output = scxml_root.as_xml_string() + test_xml_string = remove_empty_lines(canonicalize_xml(test_output)) + ref_xml_string = remove_empty_lines(canonicalize_xml(expected_output)) + assert test_xml_string == ref_xml_string + + +def _test_xml_parsing(xml_file_path: str, valid_xml: bool = True): + scxml_root = ScxmlRoot.from_scxml_file(xml_file_path) + # Check output xml + if valid_xml: + test_output = scxml_root.as_xml_string() + test_xml_string = remove_empty_lines(canonicalize_xml(test_output)) + ref_file_path = os.path.join(os.path.dirname(xml_file_path), 'gt_parsed_scxml', + os.path.basename(xml_file_path)) + with open(ref_file_path, 'r', encoding='utf-8') as f_o: + ref_xml_string = remove_empty_lines(canonicalize_xml(f_o.read())) + assert test_xml_string == ref_xml_string + # All the test scxml files we are using contain ROS declarations + assert not scxml_root.is_plain_scxml() + else: + assert not scxml_root.check_validity() def test_battery_drainer_from_code(): """ Test for scxml_entries generation and conversion to xml. - - It should support the following xml tree: - - scxml - - state - - onentry - - {executable content} - - onexit - - {executable content} - - transition - - {executable content} - - datamodel - - data - - Executable content consists of the following entries: - - send - - param - - if / elseif / else - - assign -""" + """ battery_drainer_scxml = ScxmlRoot("BatteryDrainer") battery_drainer_scxml.set_data_model(ScxmlDataModel([ ScxmlData("battery_percent", "100", "int16")])) @@ -60,17 +66,9 @@ def test_battery_drainer_from_code(): ScxmlTransition("use_battery", ["ros_topic.charge"], body=[ScxmlAssign("battery_percent", "100")])]) battery_drainer_scxml.add_state(use_battery_state, initial=True) - # Check output xml - ref_file = os.path.join(os.path.dirname(__file__), '_test_data', - 'expected_output_ros_to_scxml', 'battery_drainer.scxml') - assert os.path.exists(ref_file), f"Cannot find ref. file {ref_file}." - with open(ref_file, 'r', encoding='utf-8') as f_o: - expected_output = f_o.read() - test_output = battery_drainer_scxml.as_xml_string() - test_xml_string = remove_empty_lines(canonicalize_xml(test_output)) - ref_xml_string = remove_empty_lines(canonicalize_xml(expected_output)) - assert test_xml_string == ref_xml_string - assert battery_drainer_scxml.is_plain_scxml() + _test_scxml_from_code(battery_drainer_scxml, os.path.join( + os.path.dirname(__file__), '_test_data', 'battery_drainer_w_bt', + 'gt_plain_scxml', 'battery_drainer.scxml')) def test_battery_drainer_ros_from_code(): @@ -100,8 +98,8 @@ def test_battery_drainer_ros_from_code(): battery_drainer_scxml = ScxmlRoot("BatteryDrainer") battery_drainer_scxml.set_data_model(ScxmlDataModel([ ScxmlData("battery_percent", "100", "int16")])) - ros_topic_sub = RosTopicSubscriber("charge", "std_msgs/Empty") - ros_topic_pub = RosTopicPublisher("level", "std_msgs/Int32") + ros_topic_sub = RosTopicSubscriber("charge", "std_msgs/Empty", "sub") + ros_topic_pub = RosTopicPublisher("level", "std_msgs/Int32", "pub") ros_timer = RosTimeRate("my_timer", 1) battery_drainer_scxml.add_ros_declaration(ros_topic_sub) battery_drainer_scxml.add_ros_declaration(ros_topic_pub) @@ -116,53 +114,54 @@ def test_battery_drainer_ros_from_code(): use_battery_state.add_transition( RosTopicCallback(ros_topic_sub, "use_battery", [ScxmlAssign("battery_percent", "100")])) battery_drainer_scxml.add_state(use_battery_state, initial=True) - - # Check output xml - ref_file = os.path.join(os.path.dirname(__file__), '_test_data', - 'input_files', 'battery_drainer.scxml') - assert os.path.exists(ref_file), f"Cannot find ref. file {ref_file}." - with open(ref_file, 'r', encoding='utf-8') as f_o: - expected_output = f_o.read() - test_output = battery_drainer_scxml.as_xml_string() - test_xml_string = remove_empty_lines(canonicalize_xml(test_output)) - ref_xml_string = remove_empty_lines(canonicalize_xml(expected_output)) - assert test_xml_string == ref_xml_string - assert not battery_drainer_scxml.is_plain_scxml() + _test_scxml_from_code(battery_drainer_scxml, os.path.join( + os.path.dirname(__file__), '_test_data', 'battery_drainer_w_bt', + 'gt_parsed_scxml', 'battery_drainer.scxml')) -def _test_xml_parsing(xml_file_path: str, valid_xml: bool = True): - # TODO: Input path to scxml file from args - scxml_root = ScxmlRoot.from_scxml_file(xml_file_path) - # Check output xml - if valid_xml: - test_output = scxml_root.as_xml_string() - test_xml_string = remove_empty_lines(canonicalize_xml(test_output)) - with open(xml_file_path, 'r', encoding='utf-8') as f_o: - ref_xml_string = remove_empty_lines(canonicalize_xml(f_o.read())) - assert test_xml_string == ref_xml_string - # All the test scxml files we are using contain ROS declarations - assert not scxml_root.is_plain_scxml() - else: - assert not scxml_root.check_validity() +def test_bt_action_with_ports_from_code(): + """ + Test for generating scxml code containing BT Ports + """ + data_model = ScxmlDataModel([ScxmlData("number", "0", "int16")]) + topic_publisher = RosTopicPublisher(BtGetValueInputPort("name"), "std_msgs/Int16", "answer_pub") + init_state = ScxmlState("initial", body=[ + ScxmlTransition("initial", ["bt_tick"], None, [ + ScxmlAssign("number", BtGetValueInputPort("data")), + RosTopicPublish(topic_publisher, [RosField("data", "number")]) + ]) + ]) + scxml_root = ScxmlRoot("TopicAction") + scxml_root.set_data_model(data_model) + scxml_root.add_bt_port_declaration(BtInputPortDeclaration("name", "string")) + scxml_root.add_bt_port_declaration(BtInputPortDeclaration("data", "int16")) + scxml_root.add_ros_declaration(topic_publisher) + scxml_root.add_state(init_state, initial=True) + assert not scxml_root.check_validity(), "Currently, we handle unspecified BT entries as invalid" + scxml_root.set_bt_ports_values([("name", "/sys/add_srv"), ("data", "25")]) + scxml_root.update_bt_ports_values() + _test_scxml_from_code(scxml_root, os.path.join( + os.path.dirname(__file__), '_test_data', 'bt_ports_only', + 'gt_parsed_scxml', 'bt_topic_action.scxml')) def test_xml_parsing_battery_drainer(): _test_xml_parsing(os.path.join(os.path.dirname(__file__), '_test_data', - 'input_files', 'battery_drainer.scxml')) + 'battery_drainer_w_bt', 'battery_drainer.scxml')) def test_xml_parsing_bt_topic_condition(): _test_xml_parsing(os.path.join(os.path.dirname(__file__), '_test_data', - 'input_files', 'bt_topic_condition.scxml')) + 'battery_drainer_w_bt', 'bt_topic_condition.scxml')) def test_xml_parsing_invalid_battery_drainer_xml(): - _test_xml_parsing(os.path.join(os.path.dirname(__file__), '_test_data', 'input_files', + _test_xml_parsing(os.path.join(os.path.dirname(__file__), '_test_data', 'invalid_xmls', 'battery_drainer.scxml'), valid_xml=False) def test_xml_parsing_invalid_bt_topic_action_xml(): - _test_xml_parsing(os.path.join(os.path.dirname(__file__), '_test_data', 'input_files', + _test_xml_parsing(os.path.join(os.path.dirname(__file__), '_test_data', 'invalid_xmls', 'bt_topic_action.scxml'), valid_xml=False) diff --git a/scxml_converter/test/test_systemtest_xml.py b/scxml_converter/test/test_systemtest_xml.py index 42634141..8dcaa234 100644 --- a/scxml_converter/test/test_systemtest_xml.py +++ b/scxml_converter/test/test_systemtest_xml.py @@ -15,18 +15,20 @@ import os +from typing import Dict, List, Tuple + from test_utils import canonicalize_xml, remove_empty_lines from scxml_converter.bt_converter import bt_converter from scxml_converter.scxml_entries import ScxmlRoot -def get_output_folder(): - return os.path.join(os.path.dirname(__file__), 'output') +def get_output_folder(test_folder: str): + return os.path.join(os.path.dirname(__file__), '_test_data', test_folder, 'output') -def clear_output_folder(): - output_folder = get_output_folder() +def clear_output_folder(test_folder: str): + output_folder = get_output_folder(test_folder) if os.path.exists(output_folder): for f in os.listdir(output_folder): os.remove(os.path.join(output_folder, f)) @@ -34,57 +36,89 @@ def clear_output_folder(): os.makedirs(output_folder) -def test_ros_scxml_to_plain_scxml(): +def bt_to_scxml_test( + test_folder: str, bt_file: str, bt_plugins: List[str], store_generated: bool = False): + """ + Test the conversion of a BT to SCXML. + + :param test_folder: The name of the folder with the files to evaluate. + :param bt_file: The name to the BT xml file. + :param bt_plugins: The names of the BT plugins scxml files. + :param store_generated: If True, the generated SCXML files are stored in the output folder. + """ + test_data_path = os.path.join(os.path.dirname(__file__), '_test_data', test_folder) + bt_file = os.path.join(test_data_path, bt_file) + plugin_files = [os.path.join(test_data_path, f) for f in bt_plugins] + scxml_objs = bt_converter(bt_file, plugin_files) + assert len(scxml_objs) == 3, \ + f"Expecting 3 scxml objects, found {len(scxml_objs)}." + if store_generated: + clear_output_folder(test_folder) + for scxml_obj in scxml_objs: + output_file = os.path.join( + get_output_folder(test_folder), f'{scxml_obj.get_name()}.scxml') + with open(output_file, 'w') as f_o: + f_o.write(scxml_obj.as_xml_string()) + for scxml_root in scxml_objs: + scxml_name = scxml_root.get_name() + gt_scxml_path = os.path.join(test_data_path, 'gt_bt_scxml', + f'{scxml_name}.scxml') + with open(gt_scxml_path, 'r', encoding='utf-8') as f_o: + gt_xml = remove_empty_lines(canonicalize_xml(f_o.read())) + scxml_xml = remove_empty_lines(canonicalize_xml(scxml_root.as_xml_string())) + + assert scxml_xml == gt_xml + + +def ros_to_plain_scxml_test(test_folder: str, + scxml_bt_ports: Dict[str, List[Tuple[str, str]]], + store_generated: bool = False): """Test the conversion of SCXML with ROS-specific macros to plain SCXML.""" - clear_output_folder() - scxml_files = [file for file in os.listdir( - os.path.join(os.path.dirname(__file__), '_test_data', 'input_files') - ) if file.endswith('.scxml')] + test_data_path = os.path.join(os.path.dirname(__file__), '_test_data', test_folder) + scxml_files = [file for file in os.listdir(test_data_path) if file.endswith('.scxml')] + if store_generated: + clear_output_folder(test_folder) for fname in scxml_files: - input_file = os.path.join(os.path.dirname(__file__), - '_test_data', 'input_files', fname) - output_file = os.path.join(os.path.dirname(__file__), - '_test_data', 'expected_output_ros_to_scxml', fname) + input_file = os.path.join(test_data_path, fname) + gt_file = os.path.join(test_data_path, 'gt_plain_scxml', fname) try: - scxml, _ = ScxmlRoot.from_scxml_file(input_file).to_plain_scxml_and_declarations() - scxml_str = scxml.as_xml_string() - with open(output_file, 'r', encoding='utf-8') as f_o: - expected_output = f_o.read() + scxml_obj = ScxmlRoot.from_scxml_file(input_file) + if fname in scxml_bt_ports: + scxml_obj.set_bt_ports_values(scxml_bt_ports[fname]) + scxml_obj.update_bt_ports_values() + plain_scxml, _ = scxml_obj.to_plain_scxml_and_declarations() + if store_generated: + output_file = os.path.join(get_output_folder(test_folder), fname) + with open(output_file, 'w') as f_o: + f_o.write(plain_scxml.as_xml_string()) + scxml_str = plain_scxml.as_xml_string() + with open(gt_file, 'r', encoding='utf-8') as f_o: + gt_output = f_o.read() assert remove_empty_lines(canonicalize_xml(scxml_str)) == \ - remove_empty_lines(canonicalize_xml(expected_output)) + remove_empty_lines(canonicalize_xml(gt_output)) except Exception as e: - clear_output_folder() print(f"Error in file {fname}:") raise e - clear_output_folder() - - -def test_bt_to_scxml(): - clear_output_folder() - input_file = os.path.join( - os.path.dirname(__file__), '_test_data', 'input_files', 'bt.xml') - output_file_bt = os.path.join(get_output_folder(), 'bt.scxml') - plugins = [os.path.join(os.path.dirname(__file__), - '_test_data', 'input_files', f) - for f in ['bt_topic_action.scxml', 'bt_topic_condition.scxml']] - bt_converter(input_file, plugins, get_output_folder()) - files = os.listdir(get_output_folder()) - assert len(files) == 3, \ - f"Expecting 3 files, found {len(files)}" - # 1 for the main BT and 2 for the plugins - assert os.path.exists(output_file_bt), \ - f"Expecting {output_file_bt} to exist, but it does not." - for fname in files: - with open(os.path.join(get_output_folder(), fname), 'r', encoding='utf-8') as f_o: - output = f_o.read() - with open(os.path.join( - os.path.dirname(__file__), '_test_data', 'expected_output_bt_and_plugins', fname - ), 'r', encoding='utf-8') as f_o: - expected_output = f_o.read() - assert remove_empty_lines(canonicalize_xml(output)) == \ - remove_empty_lines(canonicalize_xml(expected_output)) - clear_output_folder() - - -if __name__ == '__main__': - test_ros_scxml_to_plain_scxml() + + +def test_bt_to_scxml_battery_drainer(): + bt_to_scxml_test('battery_drainer_w_bt', 'bt.xml', + ['bt_topic_action.scxml', 'bt_topic_condition.scxml'], False) + + +def test_ros_to_plain_scxml_battery_drainer(): + ros_to_plain_scxml_test('battery_drainer_w_bt', {}, True) + + +def test_bt_to_scxml_bt_ports(): + bt_to_scxml_test('bt_ports_only', 'bt.xml', ['bt_topic_action.scxml'], False) + + +def test_ros_to_plain_scxml_bt_ports(): + ros_to_plain_scxml_test('bt_ports_only', + {'bt_topic_action.scxml': [('name', 'out'), ('data', '123')]}, + True) + + +def test_ros_to_plain_scxml_add_int_srv(): + ros_to_plain_scxml_test('add_int_srv_example', {}, True) From ed0dfd5bdff43218b80d0ffa8c615d62b11634e3 Mon Sep 17 00:00:00 2001 From: Marco Lampacrescia Date: Mon, 26 Aug 2024 11:04:07 +0200 Subject: [PATCH 02/23] Implement scxml action interface. No conversion to Jani yet Signed-off-by: Marco Lampacrescia --- .github/workflows/lint.yml | 2 +- as2fm_common/src/as2fm_common/common.py | 27 +- .../graphics/ros_action_to_scxml.drawio.svg | 559 +++++ .../convince_to_plain_jani.py | 29 +- .../jani_entries/jani_automaton.py | 11 +- .../jani_entries/jani_composition.py | 4 + .../jani_entries/jani_constant.py | 49 +- .../jani_convince_expression_expansion.py | 5 +- .../jani_entries/jani_expression.py | 15 +- .../jani_entries/jani_expression_generator.py | 22 + .../jani_generator/jani_entries/jani_guard.py | 33 +- .../jani_generator/jani_entries/jani_model.py | 31 + .../jani_entries/jani_property.py | 10 +- .../jani_entries/jani_variable.py | 76 +- .../jani_generator/ros_helpers/ros_timer.py | 8 +- .../scxml_helpers/scxml_tags.py | 2 +- .../scxml_helpers/top_level_interpreter.py | 8 +- .../properties.jani | 12 +- .../plain_jani_examples/example_arrays.jani | 2193 +++++++++++++++++ .../happy_clients.jani | 8 +- .../ros_example/battery_depleted.jani | 8 +- .../ros_example_w_bt/battery_properties.jani | 16 +- .../ros_fibonacci_action_example/.gitignore | 3 + .../client_1.scxml | 46 + .../client_2.scxml | 45 + .../happy_clients.jani | 33 + .../ros_fibonacci_action_example/main.xml | 15 + .../ros_fibonacci_action_example/server.scxml | 149 ++ .../test/test_unittest_jani_model_loading.py | 34 + .../src/scxml_converter/scxml_converter.py | 5 +- .../scxml_converter/scxml_entries/__init__.py | 15 +- .../scxml_entries/ros_utils.py | 424 +++- .../scxml_entries/scxml_data.py | 72 +- .../scxml_entries/scxml_data_model.py | 23 +- .../scxml_entries/scxml_executable_entries.py | 90 +- .../scxml_entries/scxml_root.py | 182 +- .../scxml_entries/scxml_ros_action_client.py | 193 ++ .../scxml_entries/scxml_ros_action_server.py | 277 +++ .../scxml_ros_action_server_thread.py | 287 +++ .../scxml_entries/scxml_ros_base.py | 344 +++ .../scxml_entries/scxml_ros_entries.py | 38 - .../scxml_entries/scxml_ros_service.py | 513 +--- .../scxml_entries/scxml_ros_timer.py | 138 +- .../scxml_entries/scxml_ros_topic.py | 333 +-- .../scxml_entries/scxml_state.py | 91 +- .../scxml_entries/scxml_transition.py | 7 + .../scxml_converter/scxml_entries/utils.py | 33 +- .../scxml_entries/xml_utils.py | 26 +- .../{client_1.scxml => addition_client.scxml} | 0 .../{server.scxml => addition_server.scxml} | 0 .../{client_1.scxml => addition_client.scxml} | 2 +- .../{server.scxml => addition_server.scxml} | 0 .../_test_data/battery_drainer_w_bt/bt.xml | 4 +- .../bt_topic_action.scxml | 2 +- .../bt_topic_condition.scxml | 2 +- ...ion.scxml => 10000_BtTopicCondition.scxml} | 2 +- ...cAction.scxml => 1001_BtTopicAction.scxml} | 2 +- .../battery_drainer_w_bt/gt_bt_scxml/bt.scxml | 8 +- .../gt_parsed_scxml/bt_topic_condition.scxml | 2 +- .../gt_plain_scxml/battery_drainer.scxml | 4 +- .../gt_plain_scxml/battery_manager.scxml | 4 +- .../gt_plain_scxml/bt_topic_action.scxml | 4 +- .../gt_plain_scxml/bt_topic_condition.scxml | 4 +- .../test/_test_data/bt_ports_only/bt.xml | 4 +- .../bt_ports_only/bt_topic_action.scxml | 2 +- ...cAction.scxml => 1000_BtTopicAction.scxml} | 2 +- ...cAction.scxml => 1001_BtTopicAction.scxml} | 2 +- .../bt_ports_only/gt_bt_scxml/bt.scxml | 8 +- .../gt_parsed_scxml/bt_topic_action.scxml | 2 +- .../gt_plain_scxml/bt_topic_action.scxml | 4 +- .../fibonacci_action_example/.gitignore | 3 + .../fibonacci_action_example/client_1.scxml | 44 + .../fibonacci_action_example/client_2.scxml | 44 + .../gt_plain_scxml/client_1.scxml | 31 + .../gt_plain_scxml/client_2.scxml | 31 + .../gt_plain_scxml/fibonacci_thread_0.scxml | 47 + .../gt_plain_scxml/fibonacci_thread_1.scxml | 47 + .../gt_plain_scxml/server.scxml | 52 + .../fibonacci_action_example/server.scxml | 127 + .../test/test_systemtest_scxml_entries.py | 9 +- scxml_converter/test/test_systemtest_xml.py | 59 +- scxml_converter/test/test_utils.py | 6 + 82 files changed, 5815 insertions(+), 1293 deletions(-) create mode 100644 docs/source/graphics/ros_action_to_scxml.drawio.svg create mode 100644 jani_generator/test/_test_data/plain_jani_examples/example_arrays.jani create mode 100644 jani_generator/test/_test_data/ros_fibonacci_action_example/.gitignore create mode 100644 jani_generator/test/_test_data/ros_fibonacci_action_example/client_1.scxml create mode 100644 jani_generator/test/_test_data/ros_fibonacci_action_example/client_2.scxml create mode 100644 jani_generator/test/_test_data/ros_fibonacci_action_example/happy_clients.jani create mode 100644 jani_generator/test/_test_data/ros_fibonacci_action_example/main.xml create mode 100644 jani_generator/test/_test_data/ros_fibonacci_action_example/server.scxml create mode 100644 jani_generator/test/test_unittest_jani_model_loading.py create mode 100644 scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_action_client.py create mode 100644 scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_action_server.py create mode 100644 scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_action_server_thread.py create mode 100644 scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_base.py delete mode 100644 scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_entries.py rename scxml_converter/test/_test_data/add_int_srv_example/{client_1.scxml => addition_client.scxml} (100%) rename scxml_converter/test/_test_data/add_int_srv_example/{server.scxml => addition_server.scxml} (100%) rename scxml_converter/test/_test_data/add_int_srv_example/gt_plain_scxml/{client_1.scxml => addition_client.scxml} (93%) rename scxml_converter/test/_test_data/add_int_srv_example/gt_plain_scxml/{server.scxml => addition_server.scxml} (100%) rename scxml_converter/test/_test_data/battery_drainer_w_bt/gt_bt_scxml/{10000_TopicCondition.scxml => 10000_BtTopicCondition.scxml} (96%) rename scxml_converter/test/_test_data/battery_drainer_w_bt/gt_bt_scxml/{1001_TopicAction.scxml => 1001_BtTopicAction.scxml} (95%) rename scxml_converter/test/_test_data/bt_ports_only/gt_bt_scxml/{1000_TopicAction.scxml => 1000_BtTopicAction.scxml} (81%) rename scxml_converter/test/_test_data/bt_ports_only/gt_bt_scxml/{1001_TopicAction.scxml => 1001_BtTopicAction.scxml} (81%) create mode 100644 scxml_converter/test/_test_data/fibonacci_action_example/.gitignore create mode 100644 scxml_converter/test/_test_data/fibonacci_action_example/client_1.scxml create mode 100644 scxml_converter/test/_test_data/fibonacci_action_example/client_2.scxml create mode 100644 scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/client_1.scxml create mode 100644 scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/client_2.scxml create mode 100644 scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/fibonacci_thread_0.scxml create mode 100644 scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/fibonacci_thread_1.scxml create mode 100644 scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/server.scxml create mode 100644 scxml_converter/test/_test_data/fibonacci_action_example/server.scxml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 551052b1..991744fa 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -71,7 +71,7 @@ jobs: use-pydocstyle: false extra-pylint-options: "" extra-pycodestyle-options: "" - extra-flake8-options: "" + extra-flake8-options: "--max-line-length=100" extra-black-options: "" extra-mypy-options: "--ignore-missing-imports" extra-isort-options: "" \ No newline at end of file diff --git a/as2fm_common/src/as2fm_common/common.py b/as2fm_common/src/as2fm_common/common.py index f6e2d3f2..b6f7a2a3 100644 --- a/as2fm_common/src/as2fm_common/common.py +++ b/as2fm_common/src/as2fm_common/common.py @@ -17,22 +17,27 @@ Common functionalities used throughout the toolchain. """ -from typing import Union +from typing import List, Union -ValidTypes = Union[bool, int, float] -"""We define the basic types that are supported by the Jani language: +""" +Set of basic types that are supported by the Jani language. -// Types -// We cover only the most basic types at the moment. -// In the remainder of the specification, all requirements like "y must be of type x" are to be interpreted -// as "type x must be assignable from y's type". +Basic types (from Jani docs): +Types +We cover only the most basic types at the moment. +In the remainder of the specification, all requirements like "y must be of type x" are to be +interpreted as "type x must be assignable from y's type". var BasicType = schema([ "bool", // assignable from bool "int", // numeric; assignable from int and bounded int "real" // numeric; assignable from all numeric types ]); src https://docs.google.com/document/d/\ - 1BDQIzPBtscxJFFlDUEPIo8ivKHgXT8_X6hz5quq7jK0/edit""" + 1BDQIzPBtscxJFFlDUEPIo8ivKHgXT8_X6hz5quq7jK0/edit + +Additionally, we support the array types from the array extension. +""" +ValidTypes = Union[bool, int, float, List[int], List[float]] def ros_type_name_to_python_type(type_str: str) -> type: @@ -51,6 +56,10 @@ def ros_type_name_to_python_type(type_str: str) -> type: return int if type_str in ['float32', 'float64']: return float + if type_str in ['sequence', 'sequence']: + return List[int] + if type_str in ['sequence', 'sequence']: + return List[float] raise NotImplementedError(f"Type {type_str} not supported.") @@ -67,4 +76,4 @@ def remove_namespace(tag: str) -> str: tag_wo_ns = tag.split('}')[-1] else: tag_wo_ns = tag - return tag_wo_ns \ No newline at end of file + return tag_wo_ns diff --git a/docs/source/graphics/ros_action_to_scxml.drawio.svg b/docs/source/graphics/ros_action_to_scxml.drawio.svg new file mode 100644 index 00000000..93811e65 --- /dev/null +++ b/docs/source/graphics/ros_action_to_scxml.drawio.svg @@ -0,0 +1,559 @@ + + + + + + + +
+
+
+ Action Server-Client - SCXML +
+
+
+
+ + Action Server-Client - SCXML + +
+
+ + + + +
+
+
+ Action Communication Handler +
+
+
+
+ + Action Communication Handler + +
+
+ + + + + +
+
+
+ + transition srv_x_req_cllient_2 + +
+ + - send srv_x_req + +
+
+ - param req_field_<1, ..., n> +
+
+
+
+
+ + transition srv_x_req_cllient_2... + +
+
+ + + + + +
+
+
+ transition srv_x_req_cllient_1 +
+ - send srv_x_req +
+
+ - param req_field_<1, ..., n> +
+
+
+
+
+ + transition srv_x_req_cllient_1... + +
+
+ + + + +
+
+
+ + waiting + +
+
+
+
+ + waiting + +
+
+ + + + + +
+
+
+
+ transition srv_x_res +
+
+ - send srv_x_res_client_1 +
+
+ - param res_field_<1, ..., m> +
+
+
+
+
+ + transition srv_x_res... + +
+
+ + + + +
+
+
+ + processing_client_1 + +
+
+
+
+ + processing_client_1 + +
+
+ + + + + +
+
+
+
+ transition srv_x_res +
+
+ - send srv_x_res_client_2 +
+
+ - param res_field_<1, ..., m> +
+
+
+
+
+ + transition srv_x_res... + +
+
+ + + + +
+
+
+ + processing_client_2 + +
+
+
+
+ + processing_client_2 + +
+
+ + + + +
+
+
+ Server +
+
+
+
+ + Server + +
+
+ + + + + +
+
+
+
+ + transition action_handle_goal_request: + +
+
+ + - assign goal_id + +
+
+ + - assign goal_fields + +
+
+
+
+
+ + transition action_handle_goal_request:... + +
+
+ + + + +
+
+
+ + idle + +
+
+
+
+ + idle + +
+
+ + + + +
+
+
+ Client X +
+
+
+
+ + Client X + +
+
+ + + + +
+
+
+ + entry + +
+
+
+
+ + entry + +
+
+ + + + + +
+
+
+
+ + transition action_goal_accept_client_X + +
+
+
+
+
+ + transition action_goal_accept_client_X + +
+
+ + + + + +
+
+
+ + transition action_goal_reject_client_X + +
+
+
+
+ + transition action_goal_reject_client_X + +
+
+ + + + +
+
+
+ + send_request + +
+
+
+
+ + send_request + +
+
+ + + + + +
+
+
+ + transition: +
+ - send action_goal_req_client_X +
+ - param goal_field_<1, ..., n> +
+
+
+
+
+ + transition:... + +
+
+ + + + + +
+
+
+ + transition action_result_client_X: + +
+ + - param result_field_<1, .., m> + +
+
+
+
+ + transition action_result_client_X:... + +
+
+ + + + +
+
+
+ + wait_result + +
+
+
+
+ + wait_result + +
+
+ + + + + +
+
+
+ + transition action_feedback_client_X: +
+ - param feedback_field_<1, .., k> +
+
+
+
+
+ + transition action_feedback_client_X:... + +
+
+ + + + +
+
+
+ + done +
+
+
+
+
+
+ + done + +
+
+ + + + + +
+
+
+ transition action_thread_free: +
+ - assign thread_x_free +
+
+
+
+ + transition action_thread_free:... + +
+
+ + + + + +
+
+
+ transition: +
+ - if thread_available: +
+ - send action_goal_accepted +
+ - param goal_id +
+ - send action_thread_start +
+ - param thread_id +
+ - param goal_id +
+ - param goal_fields +
+ - else +
+ - send action_goal_rejected +
+ - param goal_id +
+
+
+
+ + transition:... + +
+
+ + + + +
+
+
+ + handle_goal + +
+
+
+
+ + handle_goal + +
+
+
+ + + + + Text is not SVG - cannot display + + + +
\ No newline at end of file diff --git a/jani_generator/src/jani_generator/convince_jani_helpers/convince_to_plain_jani.py b/jani_generator/src/jani_generator/convince_jani_helpers/convince_to_plain_jani.py index 0b98ab73..e63d00eb 100644 --- a/jani_generator/src/jani_generator/convince_jani_helpers/convince_to_plain_jani.py +++ b/jani_generator/src/jani_generator/convince_jani_helpers/convince_to_plain_jani.py @@ -37,7 +37,7 @@ def to_deg(value: float) -> int: def __convince_env_model_to_jani(base_model: JaniModel, env_model: dict): - """Add the converted entries from the convince environment model to the provided JaniModel object.""" + """Add the converted entries from the convince environment model to the provided JaniModel.""" # Check if the base_model is a JaniModel instance assert isinstance(base_model, JaniModel), "The base_model should be a JaniModel instance" # Check if the env_model is a dictionary @@ -61,7 +61,8 @@ def __convince_env_model_to_jani(base_model: JaniModel, env_model: dict): # The robot pose should be stored using integers -> centimeters and degrees base_model.add_variable(f"robots.{robot_name}.pose.x_cm", int, to_cm(robot_pose["x"])) base_model.add_variable(f"robots.{robot_name}.pose.y_cm", int, to_cm(robot_pose["y"])) - base_model.add_variable(f"robots.{robot_name}.pose.theta_deg", int, to_deg(robot_pose["theta"])) + base_model.add_variable(f"robots.{robot_name}.pose.theta_deg", int, + to_deg(robot_pose["theta"])) base_model.add_variable(f"robots.{robot_name}.pose.x", float, transient=True) base_model.add_variable(f"robots.{robot_name}.pose.y", float, transient=True) base_model.add_variable(f"robots.{robot_name}.pose.theta", float, transient=True) @@ -69,17 +70,23 @@ def __convince_env_model_to_jani(base_model: JaniModel, env_model: dict): base_model.add_variable(f"robots.{robot_name}.goal.y", float, transient=True) base_model.add_variable(f"robots.{robot_name}.goal.theta", float, transient=True) robot_shape = robot["shape"] - base_model.add_constant(f"robots.{robot_name}.shape.radius", float, float(robot_shape["radius"])) - base_model.add_constant(f"robots.{robot_name}.shape.height", float, float(robot_shape["height"])) - base_model.add_constant(f"robots.{robot_name}.linear_velocity", float, float(robot["linear_velocity"])) - base_model.add_constant(f"robots.{robot_name}.angular_velocity", float, float(robot["angular_velocity"])) + base_model.add_constant(f"robots.{robot_name}.shape.radius", float, + float(robot_shape["radius"])) + base_model.add_constant(f"robots.{robot_name}.shape.height", float, + float(robot_shape["height"])) + base_model.add_constant(f"robots.{robot_name}.linear_velocity", float, + float(robot["linear_velocity"])) + base_model.add_constant(f"robots.{robot_name}.angular_velocity", float, + float(robot["angular_velocity"])) if "obstacles" in env_model: # Extract the obstacles from the env_model # TODO pass # TODO: Discuss the perception part - # TODO: Discuss the possibility of generating a base automata for each robot + standard edges (i.e. switch_on/off, drive, rotate) - # This would make sense, allowing the definition of default mobile robots without the need of defining how they drive. + # TODO: Discuss the possibility of generating a base automata for each robot + standard edges + # (i.e. switch_on/off, drive, rotate) + # This would make sense, allowing the definition of default mobile robots without the need of + # defining how they drive. # By the way, keeping this out for now! @@ -104,7 +111,8 @@ def __convince_properties_to_jani(base_model: JaniModel, properties: List[dict]) assert isinstance(base_model, JaniModel), "The base_model should be a JaniModel instance" for property_dict in properties: assert isinstance(property_dict, dict), "The properties list should contain dictionaries" - base_model.add_jani_property(JaniProperty(property_dict["name"], property_dict["expression"])) + base_model.add_jani_property(JaniProperty(property_dict["name"], + property_dict["expression"])) def convince_jani_parser(base_model: JaniModel, convince_jani_path: str): @@ -119,7 +127,8 @@ def convince_jani_parser(base_model: JaniModel, convince_jani_path: str): # ---- Metadata ---- base_model.set_name(convince_jani_json["name"]) # Make sure we are loading a convince-jani file - assert "features" in convince_jani_json and "convince_extensions" in convince_jani_json["features"], \ + assert "features" in convince_jani_json and \ + "convince_extensions" in convince_jani_json["features"], \ "The provided file is not a convince-jani file (missing feature entry)" # Extract the environment model from the convince-jani file # ---- Environment Model ---- diff --git a/jani_generator/src/jani_generator/jani_entries/jani_automaton.py b/jani_generator/src/jani_generator/jani_entries/jani_automaton.py index 55f112de..b8868276 100644 --- a/jani_generator/src/jani_generator/jani_entries/jani_automaton.py +++ b/jani_generator/src/jani_generator/jani_entries/jani_automaton.py @@ -22,6 +22,10 @@ class JaniAutomaton: + @staticmethod + def from_dict(automaton_dict: dict) -> "JaniAutomaton": + return JaniAutomaton(automaton_dict=automaton_dict) + def __init__(self, *, automaton_dict: Optional[Dict[str, Any]] = None): self._locations: Set[str] = set() self._initial_locations: Set[str] = set() @@ -34,7 +38,7 @@ def __init__(self, *, automaton_dict: Optional[Dict[str, Any]] = None): self._name = automaton_dict["name"] self._generate_locations( automaton_dict["locations"], automaton_dict["initial-locations"]) - self._generate_variables(automaton_dict["variables"]) + self._generate_variables(automaton_dict.get("variables", [])) self._generate_edges(automaton_dict["edges"]) def get_name(self): @@ -86,7 +90,8 @@ def remove_empty_self_loop_edges(self): """Remove all self-loop edges from the automaton.""" self._edges = [edge for edge in self._edges if not edge.is_empty_self_loop()] - def _generate_locations(self, location_list: List[Dict[str, Any]], initial_locations: List[str]): + def _generate_locations(self, + location_list: List[Dict[str, Any]], initial_locations: List[str]): for location in location_list: self._locations.add(location["name"]) for init_location in initial_locations: @@ -100,7 +105,7 @@ def _generate_variables(self, variable_list: List[dict]): is_transient = False if "transient" in variable: is_transient = variable["transient"] - var_type = JaniVariable.jani_type_from_string(variable["type"]) + var_type = JaniVariable.python_type_from_json(variable["type"]) self._local_variables.update({variable["name"]: JaniVariable( variable["name"], var_type, init_expr, is_transient)}) diff --git a/jani_generator/src/jani_generator/jani_entries/jani_composition.py b/jani_generator/src/jani_generator/jani_entries/jani_composition.py index 75a5ebf4..51543515 100644 --- a/jani_generator/src/jani_generator/jani_entries/jani_composition.py +++ b/jani_generator/src/jani_generator/jani_entries/jani_composition.py @@ -19,6 +19,10 @@ class JaniComposition: + @staticmethod + def from_dict(composition_dict: dict) -> "JaniComposition": + return JaniComposition(composition_dict=composition_dict) + def __init__(self, composition_dict: Optional[Dict[str, Any]] = None): if composition_dict is None: self._elements = [] diff --git a/jani_generator/src/jani_generator/jani_entries/jani_constant.py b/jani_generator/src/jani_generator/jani_entries/jani_constant.py index 7031ced0..a6bfaeec 100644 --- a/jani_generator/src/jani_generator/jani_entries/jani_constant.py +++ b/jani_generator/src/jani_generator/jani_entries/jani_constant.py @@ -15,15 +15,38 @@ """A constant value expression.""" -from typing import Type, Union, get_args +from typing import Type, Optional, Union, get_args -from jani_generator.jani_entries import JaniExpression, JaniValue +from jani_generator.jani_entries import JaniExpression ValidTypes = Union[bool, int, float] class JaniConstant: - def __init__(self, c_name: str, c_type: Type, c_value: JaniExpression): + @staticmethod + def from_dict(constant_dict: dict) -> "JaniConstant": + constant_name = constant_dict["name"] + constant_type = JaniConstant.jani_type_from_string(constant_dict["type"]) + constant_value = constant_dict.get("value", None) + if constant_value is None: + return JaniConstant(constant_name, constant_type, None) + if isinstance(constant_value, str): + # Check if conversion from string to constant_type is possible + try: + const_value_cast = constant_type(constant_value) + return JaniConstant(constant_name, + constant_type, + JaniExpression(const_value_cast)) + except ValueError: + # If no conversion possible, raise an error (constant names are not supported) + raise ValueError( + f"Value {constant_value} for constant {constant_name} " + f"is not a valid value for type {constant_type}.") + return JaniConstant(constant_name, + constant_type, + JaniExpression(constant_value)) + + def __init__(self, c_name: str, c_type: Type, c_value: Optional[JaniExpression]): assert isinstance(c_value, JaniExpression), "Value should be a JaniExpression" assert c_type in get_args(ValidTypes), f"Type {c_type} not supported by Jani" self._name = c_name @@ -33,10 +56,12 @@ def __init__(self, c_name: str, c_type: Type, c_value: JaniExpression): def name(self) -> str: return self._name - def value(self) -> ValidTypes: - assert self._value is not None, "Value not set" + def value(self) -> Optional[ValidTypes]: + if self._value is None: + return None jani_value = self._value.value - assert jani_value is not None and jani_value.is_valid(), "The expression can't be evaluated to a constant value" + assert jani_value.is_valid(), \ + "The expression can't be evaluated to a constant value" return jani_value.value() @staticmethod @@ -61,8 +86,8 @@ def jani_type_to_string(c_type: Type[ValidTypes]) -> str: // Types // We cover only the most basic types at the moment. - // In the remainder of the specification, all requirements like "y must be of type x" are to be interpreted - // as "type x must be assignable from y's type". + // In the remainder of the specification, all requirements like "y must be of type x" are + // to be interpreted as "type x must be assignable from y's type". var BasicType = schema([ "bool", // assignable from bool "int", // numeric; assignable from int and bounded int @@ -81,8 +106,10 @@ def jani_type_to_string(c_type: Type[ValidTypes]) -> str: raise ValueError(f"Type {c_type} not supported by Jani") def as_dict(self): - return { + const_dict = { "name": self._name, - "type": JaniConstant.jani_type_to_string(self._type), - "value": self._value.as_dict() + "type": JaniConstant.jani_type_to_string(self._type) } + if self._value is not None: + const_dict["value"] = self._value.as_dict() + return const_dict diff --git a/jani_generator/src/jani_generator/jani_entries/jani_convince_expression_expansion.py b/jani_generator/src/jani_generator/jani_entries/jani_convince_expression_expansion.py index 9817dd6e..e062f15a 100644 --- a/jani_generator/src/jani_generator/jani_entries/jani_convince_expression_expansion.py +++ b/jani_generator/src/jani_generator/jani_entries/jani_convince_expression_expansion.py @@ -59,6 +59,8 @@ "ite": "ite", "⇒": "⇒", "=>": "⇒", + "aa": "aa", + "ac": "ac", } @@ -391,7 +393,8 @@ def __substitute_expression_op(expression: JaniExpression) -> JaniExpression: def expand_expression( - expression: Union[JaniExpression, JaniValue], jani_constants: Dict[str, JaniConstant]) -> JaniExpression: + expression: Union[JaniExpression, JaniValue], + jani_constants: Dict[str, JaniConstant]) -> JaniExpression: # Given a CONVINCE JaniExpression, expand it to a plain JaniExpression assert isinstance(expression, JaniExpression), \ f"The expression should be a JaniExpression instance, found {type(expression)} instead." diff --git a/jani_generator/src/jani_generator/jani_entries/jani_expression.py b/jani_generator/src/jani_generator/jani_entries/jani_expression.py index 0e161ccd..a15cfdea 100644 --- a/jani_generator/src/jani_generator/jani_entries/jani_expression.py +++ b/jani_generator/src/jani_generator/jani_entries/jani_expression.py @@ -49,8 +49,8 @@ def __init__(self, expression: Union[SupportedExp, 'JaniExpression', JaniValue]) elif isinstance(expression, JaniValue): self.value = expression else: - if (not isinstance(expression, SupportedExp)): # type: ignore - raise RuntimeError(f"Unexpected expression type: {type(expression)} should be a dict or a base type.") + assert isinstance(expression, SupportedExp), \ + f"Unexpected expression type: {type(expression)} should be a dict or a base type." if isinstance(expression, str): # If it is a reference to a constant or variable, we do not need to expand further self.identifier = expression @@ -100,6 +100,17 @@ def _get_operands(self, expression_dict: dict): "if": JaniExpression(expression_dict["if"]), "then": JaniExpression(expression_dict["then"]), "else": JaniExpression(expression_dict["else"])} + # Array-specific expressions + if (self.op == "ac"): + return { + "var": JaniExpression(expression_dict["var"]), + "length": JaniExpression(expression_dict["length"]), + "exp": JaniExpression(expression_dict["exp"])} + if (self.op == "aa"): + return { + "exp": JaniExpression(expression_dict["exp"]), + "index": JaniExpression(expression_dict["index"])} + # Convince specific expressions if (self.op in ("norm2d")): return { "x": JaniExpression(expression_dict["x"]), diff --git a/jani_generator/src/jani_generator/jani_entries/jani_expression_generator.py b/jani_generator/src/jani_generator/jani_entries/jani_expression_generator.py index 5ce6e68c..347f5f68 100644 --- a/jani_generator/src/jani_generator/jani_entries/jani_expression_generator.py +++ b/jani_generator/src/jani_generator/jani_entries/jani_expression_generator.py @@ -112,3 +112,25 @@ def or_operator(left, right) -> JaniExpression: # if operator def if_operator(condition, true_value, false_value) -> JaniExpression: return JaniExpression({"op": "ite", "if": condition, "then": true_value, "else": false_value}) + + +# array operators +def array_create_operator(var, length, exp) -> JaniExpression: + """ + Generate an array initialization expression + + :param var: The name of an int variable, used to iterate over the array indexes + :param length: The length of the array + :param exp: The expression to initialize the array with, based on the variable in var + """ + return JaniExpression({"op": "ac", "var": var, "length": length, "exp": exp}) + + +def array_access_operator(exp, index) -> JaniExpression: + """ + Generate an array access expression + + :param exp: The array variable to access + :param index: The index to access on exp + """ + return JaniExpression({"op": "aa", "exp": exp, "index": index}) diff --git a/jani_generator/src/jani_generator/jani_entries/jani_guard.py b/jani_generator/src/jani_generator/jani_entries/jani_guard.py index cf5c4d49..03f564f3 100644 --- a/jani_generator/src/jani_generator/jani_entries/jani_guard.py +++ b/jani_generator/src/jani_generator/jani_entries/jani_guard.py @@ -18,19 +18,40 @@ """ -from typing import Optional +from typing import Optional, Union from jani_generator.jani_entries.jani_expression import JaniExpression class JaniGuard: - def __init__(self, expression: Optional[JaniExpression]): - self.expression = expression - def as_dict(self, constants: Optional[dict] = None): + def __init__(self, guard_exp: Optional[Union['JaniGuard', JaniExpression, dict]]): + """ + Construct a new JaniGuard object. + + It checks against an expression that can be provided as a dict (with the 'exp' key) or a + JaniExpression variable. + + :param guard_exp: The expression that must hold for the guard to be ok. + """ + if guard_exp is None or isinstance(guard_exp, JaniExpression): + self._expression = guard_exp + elif isinstance(guard_exp, JaniGuard): + self._expression = guard_exp._expression + elif isinstance(guard_exp, dict): + assert "exp" in guard_exp, "Expected guard expression to be in the 'exp' dict entry" + self._expression = JaniExpression(guard_exp["exp"]) + else: + raise ValueError(f"Unexpected guard_exp type {type(guard_exp)}. " + "Should be None, JaniExpression or Dict.") + + def get_expression(self) -> Optional[JaniExpression]: + return self._expression + + def as_dict(self, _: Optional[dict] = None): d = {} - if self.expression: - exp = self.expression.as_dict() + if self._expression: + exp = self._expression.as_dict() if (isinstance(exp, dict) and list(exp.keys()) == ['exp']): d['exp'] = exp['exp'] else: diff --git a/jani_generator/src/jani_generator/jani_entries/jani_model.py b/jani_generator/src/jani_generator/jani_entries/jani_model.py index 8dfc5e59..aaf29fc2 100644 --- a/jani_generator/src/jani_generator/jani_entries/jani_model.py +++ b/jani_generator/src/jani_generator/jani_entries/jani_model.py @@ -32,9 +32,27 @@ class JaniModel: Class representing a complete Jani Model, containing all necessary information to generate a plain Jani file. """ + @staticmethod + def from_dict(model_dict: dict) -> "JaniModel": + model = JaniModel() + model.set_name(model_dict["name"]) + for feature in model_dict.get("features", []): + model.add_feature(feature) + for variable_dict in model_dict["variables"]: + model.add_jani_variable(JaniVariable.from_dict(variable_dict)) + for constant_dict in model_dict["constants"]: + model.add_jani_constant(JaniConstant.from_dict(constant_dict)) + for automaton_dict in model_dict["automata"]: + model.add_jani_automaton(JaniAutomaton.from_dict(automaton_dict)) + model.add_system_sync(JaniComposition.from_dict(model_dict["system"])) + for property_dict in model_dict["properties"]: + model.add_jani_property(JaniProperty.from_dict(property_dict)) + return model + def __init__(self): self._name = "" self._type = "mdp" + self._features: List[str] = [] self._variables: Dict[str, JaniVariable] = {} self._constants: Dict[str, JaniConstant] = {} self._automata: List[JaniAutomaton] = [] @@ -48,6 +66,13 @@ def set_name(self, name: str): def get_name(self): return self._name + def add_feature(self, feature: str): + assert feature in ["arrays", "trigonometric-functions"], f"Unknown Jani feature {feature}" + self._features.append(feature) + + def get_features(self) -> List[str]: + return self._features + def add_jani_variable(self, variable: JaniVariable): self._variables.update({variable.name(): variable}) @@ -82,6 +107,12 @@ def add_jani_automaton(self, automaton: JaniAutomaton): def get_automata(self) -> List[JaniAutomaton]: return self._automata + def get_constants(self) -> Dict[str, JaniConstant]: + return self._constants + + def get_variables(self) -> Dict[str, JaniVariable]: + return self._variables + def get_automaton(self, automaton_name: str) -> Optional[JaniAutomaton]: for automaton in self._automata: if automaton._name == automaton_name: diff --git a/jani_generator/src/jani_generator/jani_entries/jani_property.py b/jani_generator/src/jani_generator/jani_entries/jani_property.py index 1a0e3d86..e500b46d 100644 --- a/jani_generator/src/jani_generator/jani_entries/jani_property.py +++ b/jani_generator/src/jani_generator/jani_entries/jani_property.py @@ -29,14 +29,16 @@ class FilterProperty: """All Property operators must occur in a FilterProperty object.""" def __init__(self, property_filter_exp: Dict[str, Any]): assert isinstance(property_filter_exp, dict), "Unexpected FilterProperty initialization" - assert "op" in property_filter_exp and property_filter_exp["op"] == "filter", "Unexpected FilterProperty initialization" + assert "op" in property_filter_exp and property_filter_exp["op"] == "filter", \ + "Unexpected FilterProperty initialization" self._fun = property_filter_exp["fun"] raw_states = property_filter_exp["states"] assert isinstance(raw_states, dict) and raw_states["op"] == "initial" self._process_values(property_filter_exp["values"]) def _process_values(self, prop_values: Dict[str, Any]) -> None: - self._values: Union[ProbabilityProperty, RewardProperty, NumPathsProperty] = ProbabilityProperty(prop_values) + self._values: Union[ProbabilityProperty, RewardProperty, NumPathsProperty] = \ + ProbabilityProperty(prop_values) if self._values.is_valid(): return self._values = RewardProperty(prop_values) @@ -153,6 +155,10 @@ def as_dict(self, constants: Dict[str, JaniConstant]): class JaniProperty: + @staticmethod + def from_dict(property_dict: dict) -> "JaniProperty": + return JaniProperty(property_dict["name"], property_dict["expression"]) + def __init__(self, name, expression): self._name = name # TODO: For now copy as it is. Later we might expand it to support more functionalities diff --git a/jani_generator/src/jani_generator/jani_entries/jani_variable.py b/jani_generator/src/jani_generator/jani_entries/jani_variable.py index 93ec8e86..4a27d9f6 100644 --- a/jani_generator/src/jani_generator/jani_entries/jani_variable.py +++ b/jani_generator/src/jani_generator/jani_entries/jani_variable.py @@ -17,14 +17,42 @@ Variables in Jani """ -from typing import Optional, Union, get_args +from typing import List, Optional, Union, Type, get_args from as2fm_common.common import ValidTypes from jani_generator.jani_entries import JaniExpression, JaniValue class JaniVariable: - def __init__(self, v_name: str, v_type: ValidTypes, + @staticmethod + def from_dict(variable_dict: dict) -> "JaniVariable": + variable_name = variable_dict["name"] + initial_value = variable_dict.get("initial-value", None) + variable_type: type = JaniVariable.python_type_from_json(variable_dict["type"]) + if initial_value is None: + return JaniVariable(variable_name, + variable_type, + None, + variable_dict.get("transient", False)) + if isinstance(initial_value, str): + # Check if conversion from string to variable_type is possible + try: + init_value_cast = variable_type(initial_value) + return JaniVariable(variable_name, + variable_type, + JaniExpression(init_value_cast), + variable_dict.get("transient", False)) + except ValueError: + # If no conversion possible, raise an error (variable names are not supported) + raise ValueError( + f"Initial value {initial_value} for variable {variable_name} " + f"is not a valid value for type {variable_type}.") + return JaniVariable(variable_name, + variable_type, + JaniExpression(initial_value), + variable_dict.get("transient", False)) + + def __init__(self, v_name: str, v_type: Type[ValidTypes], init_value: Optional[Union[JaniExpression, JaniValue]] = None, v_transient: bool = False): assert init_value is None or isinstance(init_value, (JaniExpression, JaniValue)), \ @@ -43,10 +71,12 @@ def __init__(self, v_name: str, v_type: ValidTypes, self._init_expr = JaniExpression(False) elif self._type == float: self._init_expr = JaniExpression(0.0) + else: + raise ValueError(f"Type {self._type} needs an initial value") assert v_type in get_args(ValidTypes), f"Type {v_type} not supported by Jani" - if not self._transient and self._type == float: + if not self._transient and self._type in (float, List[float]): print(f"Warning: Variable {self._name} is not transient and has type float." - "This is not supported by STORM yet.") + "This is not supported by STORM.") def name(self): """Get name.""" @@ -60,7 +90,7 @@ def as_dict(self): """Return the variable as a dictionary.""" d = { "name": self._name, - "type": JaniVariable.jani_type_to_string(self._type), + "type": JaniVariable.python_type_to_json(self._type), "transient": self._transient } if self._init_expr is not None: @@ -68,21 +98,31 @@ def as_dict(self): return d @staticmethod - def jani_type_from_string(str_type: str) -> ValidTypes: + def python_type_from_json(json_type: Union[str, dict]) -> ValidTypes: """ - Translate a (Jani) type string to a Python type. + Translate a (Jani) type string or dict to a Python type. """ - if str_type == "bool": - return bool - elif str_type == "int": - return int - elif str_type == "real": - return float - else: - raise ValueError(f"Type {str_type} not supported by Jani") + if isinstance(json_type, str): + if json_type == "bool": + return bool + elif json_type == "int": + return int + elif json_type == "real": + return float + else: + raise ValueError(f"Type {json_type} not supported by Jani") + elif isinstance(json_type, dict): + assert "kind" in json_type, "Type dict should contain a 'kind' key" + if json_type["kind"] == "array": + assert "base" in json_type, "Array type should contain a 'base' key" + if json_type["base"] == "int": + return List[int] + if json_type["base"] == "real": + return List[float] + raise ValueError(f"Type {json_type} not supported by Jani") @staticmethod - def jani_type_to_string(v_type: ValidTypes) -> str: + def python_type_to_json(v_type: Type[ValidTypes]) -> Union[str, dict]: """ Translate a Python type to the name of the type in Jani. @@ -104,5 +144,9 @@ def jani_type_to_string(v_type: ValidTypes) -> str: return "int" elif v_type == float: return "real" + elif v_type == List[int]: + return {"kind": "array", "base": "int"} + elif v_type == List[float]: + return {"kind": "array", "base": "real"} else: raise ValueError(f"Type {v_type} not supported by Jani") diff --git a/jani_generator/src/jani_generator/ros_helpers/ros_timer.py b/jani_generator/src/jani_generator/ros_helpers/ros_timer.py index 84c6da6c..d662ea97 100644 --- a/jani_generator/src/jani_generator/ros_helpers/ros_timer.py +++ b/jani_generator/src/jani_generator/ros_helpers/ros_timer.py @@ -95,12 +95,10 @@ def make_global_timer_automaton(timers: List[RosTimer], try: max_time = _convert_time_between_units( max_time_ns, "ns", global_timer_period_unit) - except AssertionError as exc: - # TODO what to do with exc? + except AssertionError: raise ValueError( - f"Max time {max_time_ns} cannot be converted to " +\ - f"{global_timer_period_unit}. The max_time must have a unit " +\ - "that is the same or larger than the smallest timer period.") + f"Max time {max_time_ns} cannot be converted to {global_timer_period_unit}. " + "The max_time must have a unit that is greater or equal to the smallest timer period.") # Automaton LOC_NAME = "loc" diff --git a/jani_generator/src/jani_generator/scxml_helpers/scxml_tags.py b/jani_generator/src/jani_generator/scxml_helpers/scxml_tags.py index a8586954..5e978d65 100644 --- a/jani_generator/src/jani_generator/scxml_helpers/scxml_tags.py +++ b/jani_generator/src/jani_generator/scxml_helpers/scxml_tags.py @@ -116,7 +116,7 @@ def _append_scxml_body_to_jani_automaton(jani_automaton: JaniAutomaton, events_h new_edges.append(JaniEdge({ "location": source, "action": trigger_event_action, - "guard": guard.expression if guard is not None else None, + "guard": JaniGuard(guard), "destinations": [{ "location": None, "assignments": [] diff --git a/jani_generator/src/jani_generator/scxml_helpers/top_level_interpreter.py b/jani_generator/src/jani_generator/scxml_helpers/top_level_interpreter.py index 6ea24aff..4616ec33 100644 --- a/jani_generator/src/jani_generator/scxml_helpers/top_level_interpreter.py +++ b/jani_generator/src/jani_generator/scxml_helpers/top_level_interpreter.py @@ -141,7 +141,7 @@ def generate_plain_scxml_models_and_timers( all_timers: List[RosTimer] = [] all_services: RosServices = {} for scxml_entry in ros_scxmls: - plain_scxml, ros_declarations = \ + plain_scxmls, ros_declarations = \ scxml_entry.to_plain_scxml_and_declarations() # Handle ROS timers for timer_name, timer_rate in ros_declarations._timers.items(): @@ -153,13 +153,13 @@ def generate_plain_scxml_models_and_timers( if service_name not in all_services: all_services[service_name] = RosService() all_services[service_name].append_service_client( - service_name, service_type, plain_scxml.get_name()) + service_name, service_type, scxml_entry.get_name()) for service_name, service_type in ros_declarations._service_servers.values(): if service_name not in all_services: all_services[service_name] = RosService() all_services[service_name].set_service_server( - service_name, service_type, plain_scxml.get_name()) - plain_scxml_models.append(plain_scxml) + service_name, service_type, scxml_entry.get_name()) + plain_scxml_models.extend(plain_scxmls) # Generate service sync SCXML models for service_info in all_services.values(): plain_scxml_models.append(service_info.to_scxml()) diff --git a/jani_generator/test/_test_data/multiple_senders_same_event/properties.jani b/jani_generator/test/_test_data/multiple_senders_same_event/properties.jani index 25029a3e..4239746b 100644 --- a/jani_generator/test/_test_data/multiple_senders_same_event/properties.jani +++ b/jani_generator/test/_test_data/multiple_senders_same_event/properties.jani @@ -13,19 +13,19 @@ "op": "∧", "left": { "op": "<", - "left": "ros_topic.sender_a_counter.data", + "left": "topic_sender_a_counter_msg.data", "right": 100 }, "right": { "op": "∧", "left": { "op": "<", - "left": "ros_topic.sender_b_counter.data", + "left": "topic_sender_b_counter_msg.data", "right": 100 }, "right": { "op": "<", - "left": "ros_topic.receiver_counter.data", + "left": "topic_receiver_counter_msg.data", "right": 100 } } @@ -35,7 +35,7 @@ "op": "∧", "left": { "op": ">", - "left": "ros_topic.receiver_counter.data", + "left": "topic_receiver_counter_msg.data", "right": 48 }, "right": { @@ -43,8 +43,8 @@ "left": 50, "right": { "op": "+", - "left": "ros_topic.sender_a_counter.data", - "right": "ros_topic.sender_b_counter.data" + "left": "topic_sender_a_counter_msg.data", + "right": "topic_sender_b_counter_msg.data" } } } diff --git a/jani_generator/test/_test_data/plain_jani_examples/example_arrays.jani b/jani_generator/test/_test_data/plain_jani_examples/example_arrays.jani new file mode 100644 index 00000000..441fcf2d --- /dev/null +++ b/jani_generator/test/_test_data/plain_jani_examples/example_arrays.jani @@ -0,0 +1,2193 @@ +{ + "jani-version": 1, + "name": "example_arrays", + "type": "mdp", + "features": [ + "arrays" + ], + "metadata": { + "description": "Autogenerated with CONVINCE toolchain" + }, + "variables": [ + {"name": "array_idx", + "type": "int", + "initial-value": 0 + }, + { + "name": "are_array_ok", + "type": { + "kind": "array", + "base": "int" + }, + "transient": false, + "initial-value": { + "op": "ac", + "var": "array_idx", + "length": 2, + "exp": 100 + } + }, + { + "name": "ros_topic.level.data", + "type": "int", + "transient": false, + "initial-value": 0 + }, + { + "name": "ros_topic.level.valid", + "type": "bool", + "transient": false, + "initial-value": false + }, + { + "name": "ros_topic.charge.valid", + "type": "bool", + "transient": false, + "initial-value": false + }, + { + "name": "ros_topic.alarm.data", + "type": "bool", + "transient": false, + "initial-value": false + }, + { + "name": "ros_topic.alarm.valid", + "type": "bool", + "transient": false, + "initial-value": false + }, + { + "name": "bt_1000_tick.valid", + "type": "bool", + "transient": false, + "initial-value": false + }, + { + "name": "bt_1000_success.valid", + "type": "bool", + "transient": false, + "initial-value": false + }, + { + "name": "bt_1000_failure.valid", + "type": "bool", + "transient": false, + "initial-value": false + }, + { + "name": "bt_1001_tick.valid", + "type": "bool", + "transient": false, + "initial-value": false + }, + { + "name": "bt_1001_success.valid", + "type": "bool", + "transient": false, + "initial-value": false + } + ], + "constants": [], + "actions": [ + { + "name": "bt_1000_failure_on_receive" + }, + { + "name": "bt_1000_failure_on_send" + }, + { + "name": "bt_1000_success_on_receive" + }, + { + "name": "bt_1000_success_on_send" + }, + { + "name": "bt_1000_tick_on_receive" + }, + { + "name": "bt_1000_tick_on_send" + }, + { + "name": "bt_1001_success_on_receive" + }, + { + "name": "bt_1001_success_on_send" + }, + { + "name": "bt_1001_tick_on_receive" + }, + { + "name": "bt_1001_tick_on_send" + }, + { + "name": "check_battery-first-exec-check_battery-a449c803" + }, + { + "name": "failure-wait_for_tick-6568ac14" + }, + { + "name": "global_timer_action_0" + }, + { + "name": "initial-initial-5eebea38" + }, + { + "name": "initial_0_before_if-initial_0_after_if-5eebea38-860ebf50-else" + }, + { + "name": "initial_0_before_if-initial_0_after_if-5eebea38-860ebf50-last_msg" + }, + { + "name": "ros_time_rate.bt_tick_on_receive" + }, + { + "name": "ros_time_rate.my_timer_on_receive" + }, + { + "name": "ros_topic.alarm_on_receive" + }, + { + "name": "ros_topic.alarm_on_send" + }, + { + "name": "ros_topic.charge_on_receive" + }, + { + "name": "ros_topic.charge_on_send" + }, + { + "name": "ros_topic.level_on_receive" + }, + { + "name": "ros_topic.level_on_send" + }, + { + "name": "running-wait_for_tick-89afabc1" + }, + { + "name": "success-wait_for_tick-c305aa83" + }, + { + "name": "tick-1000_TopicCondition-8aab702a" + }, + { + "name": "use_battery-first-exec-use_battery-766fa6e4" + } + ], + "automata": [ + { + "name": "BatteryDrainer", + "locations": [ + { + "name": "use_battery" + }, + { + "name": "use_battery-1-59a11059" + }, + { + "name": "use_battery-1-c00ac01e" + }, + { + "name": "use_battery-first-exec" + }, + { + "name": "use_battery-first-exec-0-766fa6e4" + } + ], + "initial-locations": [ + "use_battery-first-exec" + ], + "edges": [ + { + "location": "use_battery", + "destinations": [ + { + "location": "use_battery-1-c00ac01e", + "assignments": [ + { + "ref": { + "op": "aa", + "exp": "are_array_ok", + "index": 1 + }, + "value": { + "op": "-", + "left": { + "op": "aa", + "exp": "are_array_ok", + "index": 1 + }, + "right": 1 + }, + "index": 0 + } + ] + } + ], + "action": "ros_time_rate.my_timer_on_receive", + "guard": { + "exp": { + "op": ">", + "left": { + "op": "aa", + "exp": "are_array_ok", + "index": 1 + }, + "right": 0 + } + } + }, + { + "location": "use_battery-1-c00ac01e", + "destinations": [ + { + "location": "use_battery", + "assignments": [ + { + "ref": "ros_topic.level.data", + "value": { + "op": "aa", + "exp": "are_array_ok", + "index": 1 + }, + "index": 0 + }, + { + "ref": "ros_topic.level.valid", + "value": true, + "index": 0 + } + ] + } + ], + "action": "ros_topic.level_on_send" + }, + { + "location": "use_battery", + "destinations": [ + { + "location": "use_battery-1-59a11059", + "assignments": [ + { + "ref": { + "op": "aa", + "exp": "are_array_ok", + "index": 1 + }, + "value": 100, + "index": 0 + } + ] + } + ], + "action": "ros_topic.charge_on_receive" + }, + { + "location": "use_battery-1-59a11059", + "destinations": [ + { + "location": "use_battery", + "assignments": [ + { + "ref": "ros_topic.level.data", + "value": { + "op": "aa", + "exp": "are_array_ok", + "index": 1 + }, + "index": 0 + }, + { + "ref": "ros_topic.level.valid", + "value": true, + "index": 0 + } + ] + } + ], + "action": "ros_topic.level_on_send" + }, + { + "location": "use_battery", + "destinations": [ + { + "location": "use_battery", + "assignments": [] + } + ], + "action": "ros_time_rate.my_timer_on_receive", + "guard": { + "exp": { + "op": "∧", + "left": true, + "right": { + "op": "¬", + "exp": { + "op": ">", + "left": { + "op": "aa", + "exp": "are_array_ok", + "index": 1 + }, + "right": 0 + } + } + } + } + }, + { + "location": "use_battery-first-exec", + "destinations": [ + { + "location": "use_battery-first-exec-0-766fa6e4", + "assignments": [] + } + ], + "action": "use_battery-first-exec-use_battery-766fa6e4" + }, + { + "location": "use_battery-first-exec-0-766fa6e4", + "destinations": [ + { + "location": "use_battery", + "assignments": [ + { + "ref": "ros_topic.level.data", + "value": { + "op": "aa", + "exp": "are_array_ok", + "index": 1 + }, + "index": 0 + }, + { + "ref": "ros_topic.level.valid", + "value": true, + "index": 0 + } + ] + } + ], + "action": "ros_topic.level_on_send" + } + ] + }, + { + "name": "BatteryManager", + "locations": [ + { + "name": "check_battery" + }, + { + "name": "check_battery-1-f1b4fdd4" + }, + { + "name": "check_battery-first-exec" + }, + { + "name": "check_battery-first-exec-0-a449c803" + } + ], + "initial-locations": [ + "check_battery-first-exec" + ], + "edges": [ + { + "location": "check_battery", + "destinations": [ + { + "location": "check_battery-1-f1b4fdd4", + "assignments": [ + { + "ref": "battery_alarm", + "value": { + "op": "<", + "left": "ros_topic.level.data", + "right": 30 + }, + "index": 0 + } + ] + } + ], + "action": "ros_topic.level_on_receive" + }, + { + "location": "check_battery-1-f1b4fdd4", + "destinations": [ + { + "location": "check_battery", + "assignments": [ + { + "ref": "ros_topic.alarm.data", + "value": "battery_alarm", + "index": 0 + }, + { + "ref": "ros_topic.alarm.valid", + "value": true, + "index": 0 + } + ] + } + ], + "action": "ros_topic.alarm_on_send" + }, + { + "location": "check_battery-first-exec", + "destinations": [ + { + "location": "check_battery-first-exec-0-a449c803", + "assignments": [] + } + ], + "action": "check_battery-first-exec-check_battery-a449c803" + }, + { + "location": "check_battery-first-exec-0-a449c803", + "destinations": [ + { + "location": "check_battery", + "assignments": [ + { + "ref": "ros_topic.alarm.data", + "value": "battery_alarm", + "index": 0 + }, + { + "ref": "ros_topic.alarm.valid", + "value": true, + "index": 0 + } + ] + } + ], + "action": "ros_topic.alarm_on_send" + } + ], + "variables": [ + { + "name": "battery_alarm", + "type": "bool", + "transient": false, + "initial-value": false + } + ] + }, + { + "name": "TopicCondition", + "locations": [ + { + "name": "initial" + }, + { + "name": "initial_0_after_if" + }, + { + "name": "initial_0_before_if" + }, + { + "name": "initial_0_before_if-0-5eebea38-860ebf50-else" + }, + { + "name": "initial_0_before_if-0-5eebea38-860ebf50-last_msg" + } + ], + "initial-locations": [ + "initial" + ], + "edges": [ + { + "location": "initial", + "destinations": [ + { + "location": "initial", + "assignments": [ + { + "ref": "last_msg", + "value": "ros_topic.alarm.data", + "index": 0 + } + ] + } + ], + "action": "ros_topic.alarm_on_receive" + }, + { + "location": "initial", + "destinations": [ + { + "location": "initial_0_before_if", + "assignments": [] + } + ], + "action": "bt_1000_tick_on_receive" + }, + { + "location": "initial_0_before_if", + "destinations": [ + { + "location": "initial_0_before_if-0-5eebea38-860ebf50-last_msg", + "assignments": [] + } + ], + "action": "initial_0_before_if-initial_0_after_if-5eebea38-860ebf50-last_msg", + "guard": { + "exp": "last_msg" + } + }, + { + "location": "initial_0_before_if-0-5eebea38-860ebf50-last_msg", + "destinations": [ + { + "location": "initial_0_after_if", + "assignments": [ + { + "ref": "bt_1000_success.valid", + "value": true, + "index": 0 + } + ] + } + ], + "action": "bt_1000_success_on_send" + }, + { + "location": "initial_0_before_if", + "destinations": [ + { + "location": "initial_0_before_if-0-5eebea38-860ebf50-else", + "assignments": [] + } + ], + "action": "initial_0_before_if-initial_0_after_if-5eebea38-860ebf50-else", + "guard": { + "exp": { + "op": "∧", + "left": true, + "right": { + "op": "¬", + "exp": "last_msg" + } + } + } + }, + { + "location": "initial_0_before_if-0-5eebea38-860ebf50-else", + "destinations": [ + { + "location": "initial_0_after_if", + "assignments": [ + { + "ref": "bt_1000_failure.valid", + "value": true, + "index": 0 + } + ] + } + ], + "action": "bt_1000_failure_on_send" + }, + { + "location": "initial_0_after_if", + "destinations": [ + { + "location": "initial", + "assignments": [] + } + ], + "action": "initial-initial-5eebea38" + } + ], + "variables": [ + { + "name": "last_msg", + "type": "bool", + "transient": false, + "initial-value": false + } + ] + }, + { + "name": "TopicAction", + "locations": [ + { + "name": "initial" + }, + { + "name": "initial-0-dd921629" + }, + { + "name": "initial-1-dd921629" + } + ], + "initial-locations": [ + "initial" + ], + "edges": [ + { + "location": "initial", + "destinations": [ + { + "location": "initial-0-dd921629", + "assignments": [] + } + ], + "action": "bt_1001_tick_on_receive" + }, + { + "location": "initial-0-dd921629", + "destinations": [ + { + "location": "initial-1-dd921629", + "assignments": [ + { + "ref": "ros_topic.charge.valid", + "value": true, + "index": 0 + } + ] + } + ], + "action": "ros_topic.charge_on_send" + }, + { + "location": "initial-1-dd921629", + "destinations": [ + { + "location": "initial", + "assignments": [ + { + "ref": "bt_1001_success.valid", + "value": true, + "index": 0 + } + ] + } + ], + "action": "bt_1001_success_on_send" + } + ] + }, + { + "name": "bt", + "locations": [ + { + "name": "1000_TopicCondition" + }, + { + "name": "1000_TopicCondition-0-28dfb6ec" + }, + { + "name": "1001_TopicAction" + }, + { + "name": "failure" + }, + { + "name": "running" + }, + { + "name": "success" + }, + { + "name": "tick" + }, + { + "name": "tick-0-8aab702a" + }, + { + "name": "wait_for_tick" + } + ], + "initial-locations": [ + "wait_for_tick" + ], + "edges": [ + { + "location": "tick", + "destinations": [ + { + "location": "tick-0-8aab702a", + "assignments": [] + } + ], + "action": "tick-1000_TopicCondition-8aab702a" + }, + { + "location": "tick-0-8aab702a", + "destinations": [ + { + "location": "1000_TopicCondition", + "assignments": [ + { + "ref": "bt_1000_tick.valid", + "value": true, + "index": 0 + } + ] + } + ], + "action": "bt_1000_tick_on_send" + }, + { + "location": "success", + "destinations": [ + { + "location": "wait_for_tick", + "assignments": [] + } + ], + "action": "success-wait_for_tick-c305aa83" + }, + { + "location": "failure", + "destinations": [ + { + "location": "wait_for_tick", + "assignments": [] + } + ], + "action": "failure-wait_for_tick-6568ac14" + }, + { + "location": "running", + "destinations": [ + { + "location": "wait_for_tick", + "assignments": [] + } + ], + "action": "running-wait_for_tick-89afabc1" + }, + { + "location": "1000_TopicCondition", + "destinations": [ + { + "location": "failure", + "assignments": [] + } + ], + "action": "bt_1000_failure_on_receive" + }, + { + "location": "1000_TopicCondition", + "destinations": [ + { + "location": "1000_TopicCondition-0-28dfb6ec", + "assignments": [] + } + ], + "action": "bt_1000_success_on_receive" + }, + { + "location": "1000_TopicCondition-0-28dfb6ec", + "destinations": [ + { + "location": "1001_TopicAction", + "assignments": [ + { + "ref": "bt_1001_tick.valid", + "value": true, + "index": 0 + } + ] + } + ], + "action": "bt_1001_tick_on_send" + }, + { + "location": "1001_TopicAction", + "destinations": [ + { + "location": "success", + "assignments": [] + } + ], + "action": "bt_1001_success_on_receive" + }, + { + "location": "wait_for_tick", + "destinations": [ + { + "location": "tick", + "assignments": [] + } + ], + "action": "ros_time_rate.bt_tick_on_receive" + }, + { + "location": "tick", + "destinations": [ + { + "location": "tick", + "assignments": [] + } + ], + "action": "bt_1000_failure_on_receive" + }, + { + "location": "tick", + "destinations": [ + { + "location": "tick", + "assignments": [] + } + ], + "action": "bt_1001_success_on_receive" + }, + { + "location": "tick", + "destinations": [ + { + "location": "tick", + "assignments": [] + } + ], + "action": "ros_time_rate.bt_tick_on_receive" + }, + { + "location": "tick", + "destinations": [ + { + "location": "tick", + "assignments": [] + } + ], + "action": "bt_1000_success_on_receive" + }, + { + "location": "success", + "destinations": [ + { + "location": "success", + "assignments": [] + } + ], + "action": "bt_1000_failure_on_receive" + }, + { + "location": "success", + "destinations": [ + { + "location": "success", + "assignments": [] + } + ], + "action": "bt_1001_success_on_receive" + }, + { + "location": "success", + "destinations": [ + { + "location": "success", + "assignments": [] + } + ], + "action": "ros_time_rate.bt_tick_on_receive" + }, + { + "location": "success", + "destinations": [ + { + "location": "success", + "assignments": [] + } + ], + "action": "bt_1000_success_on_receive" + }, + { + "location": "failure", + "destinations": [ + { + "location": "failure", + "assignments": [] + } + ], + "action": "bt_1000_failure_on_receive" + }, + { + "location": "failure", + "destinations": [ + { + "location": "failure", + "assignments": [] + } + ], + "action": "bt_1001_success_on_receive" + }, + { + "location": "failure", + "destinations": [ + { + "location": "failure", + "assignments": [] + } + ], + "action": "ros_time_rate.bt_tick_on_receive" + }, + { + "location": "failure", + "destinations": [ + { + "location": "failure", + "assignments": [] + } + ], + "action": "bt_1000_success_on_receive" + }, + { + "location": "running", + "destinations": [ + { + "location": "running", + "assignments": [] + } + ], + "action": "bt_1000_failure_on_receive" + }, + { + "location": "running", + "destinations": [ + { + "location": "running", + "assignments": [] + } + ], + "action": "bt_1001_success_on_receive" + }, + { + "location": "running", + "destinations": [ + { + "location": "running", + "assignments": [] + } + ], + "action": "ros_time_rate.bt_tick_on_receive" + }, + { + "location": "running", + "destinations": [ + { + "location": "running", + "assignments": [] + } + ], + "action": "bt_1000_success_on_receive" + }, + { + "location": "1000_TopicCondition", + "destinations": [ + { + "location": "1000_TopicCondition", + "assignments": [] + } + ], + "action": "bt_1001_success_on_receive" + }, + { + "location": "1000_TopicCondition", + "destinations": [ + { + "location": "1000_TopicCondition", + "assignments": [] + } + ], + "action": "ros_time_rate.bt_tick_on_receive" + }, + { + "location": "1001_TopicAction", + "destinations": [ + { + "location": "1001_TopicAction", + "assignments": [] + } + ], + "action": "bt_1000_failure_on_receive" + }, + { + "location": "1001_TopicAction", + "destinations": [ + { + "location": "1001_TopicAction", + "assignments": [] + } + ], + "action": "ros_time_rate.bt_tick_on_receive" + }, + { + "location": "1001_TopicAction", + "destinations": [ + { + "location": "1001_TopicAction", + "assignments": [] + } + ], + "action": "bt_1000_success_on_receive" + }, + { + "location": "wait_for_tick", + "destinations": [ + { + "location": "wait_for_tick", + "assignments": [] + } + ], + "action": "bt_1000_failure_on_receive" + }, + { + "location": "wait_for_tick", + "destinations": [ + { + "location": "wait_for_tick", + "assignments": [] + } + ], + "action": "bt_1001_success_on_receive" + }, + { + "location": "wait_for_tick", + "destinations": [ + { + "location": "wait_for_tick", + "assignments": [] + } + ], + "action": "bt_1000_success_on_receive" + } + ] + }, + { + "name": "global_timer", + "locations": [ + { + "name": "loc" + } + ], + "initial-locations": [ + "loc" + ], + "edges": [ + { + "location": "loc", + "destinations": [ + { + "location": "loc", + "assignments": [ + { + "ref": "t", + "value": { + "op": "+", + "left": "t", + "right": 1 + }, + "index": 0 + }, + { + "ref": "my_timer_needed", + "value": { + "op": "=", + "left": { + "op": "%", + "left": "t", + "right": 1 + }, + "right": 0 + }, + "index": 1 + }, + { + "ref": "bt_tick_needed", + "value": { + "op": "=", + "left": { + "op": "%", + "left": "t", + "right": 1 + }, + "right": 0 + }, + "index": 2 + } + ] + } + ], + "action": "global_timer_action_0", + "guard": { + "exp": { + "op": "∧", + "left": { + "op": "∧", + "left": { + "op": "<", + "left": "t", + "right": 100 + }, + "right": { + "op": "¬", + "exp": "my_timer_needed" + } + }, + "right": { + "op": "¬", + "exp": "bt_tick_needed" + } + } + } + }, + { + "location": "loc", + "destinations": [ + { + "location": "loc", + "assignments": [ + { + "ref": "my_timer_needed", + "value": false, + "index": 0 + } + ] + } + ], + "action": "ros_time_rate.my_timer_on_receive", + "guard": { + "exp": "my_timer_needed" + } + }, + { + "location": "loc", + "destinations": [ + { + "location": "loc", + "assignments": [ + { + "ref": "bt_tick_needed", + "value": false, + "index": 0 + } + ] + } + ], + "action": "ros_time_rate.bt_tick_on_receive", + "guard": { + "exp": "bt_tick_needed" + } + } + ], + "variables": [ + { + "name": "t", + "type": "int", + "transient": false, + "initial-value": 0 + }, + { + "name": "my_timer_needed", + "type": "bool", + "transient": false, + "initial-value": true + }, + { + "name": "bt_tick_needed", + "type": "bool", + "transient": false, + "initial-value": true + } + ] + }, + { + "name": "ros_topic.level", + "locations": [ + { + "name": "received" + }, + { + "name": "waiting" + } + ], + "initial-locations": [ + "waiting" + ], + "edges": [ + { + "location": "waiting", + "destinations": [ + { + "location": "received", + "probability": { + "exp": 1.0 + }, + "assignments": [] + } + ], + "action": "ros_topic.level_on_send" + }, + { + "location": "received", + "destinations": [ + { + "location": "waiting", + "probability": { + "exp": 1.0 + }, + "assignments": [] + } + ], + "action": "ros_topic.level_on_receive" + } + ] + }, + { + "name": "ros_topic.charge", + "locations": [ + { + "name": "received" + }, + { + "name": "waiting" + } + ], + "initial-locations": [ + "waiting" + ], + "edges": [ + { + "location": "waiting", + "destinations": [ + { + "location": "received", + "probability": { + "exp": 1.0 + }, + "assignments": [] + } + ], + "action": "ros_topic.charge_on_send" + }, + { + "location": "received", + "destinations": [ + { + "location": "waiting", + "probability": { + "exp": 1.0 + }, + "assignments": [] + } + ], + "action": "ros_topic.charge_on_receive" + } + ] + }, + { + "name": "ros_topic.alarm", + "locations": [ + { + "name": "received" + }, + { + "name": "waiting" + } + ], + "initial-locations": [ + "waiting" + ], + "edges": [ + { + "location": "waiting", + "destinations": [ + { + "location": "received", + "probability": { + "exp": 1.0 + }, + "assignments": [] + } + ], + "action": "ros_topic.alarm_on_send" + }, + { + "location": "received", + "destinations": [ + { + "location": "waiting", + "probability": { + "exp": 1.0 + }, + "assignments": [] + } + ], + "action": "ros_topic.alarm_on_receive" + } + ] + }, + { + "name": "bt_1000_tick", + "locations": [ + { + "name": "received" + }, + { + "name": "waiting" + } + ], + "initial-locations": [ + "waiting" + ], + "edges": [ + { + "location": "waiting", + "destinations": [ + { + "location": "received", + "probability": { + "exp": 1.0 + }, + "assignments": [] + } + ], + "action": "bt_1000_tick_on_send" + }, + { + "location": "received", + "destinations": [ + { + "location": "waiting", + "probability": { + "exp": 1.0 + }, + "assignments": [] + } + ], + "action": "bt_1000_tick_on_receive" + } + ] + }, + { + "name": "bt_1000_success", + "locations": [ + { + "name": "received" + }, + { + "name": "waiting" + } + ], + "initial-locations": [ + "waiting" + ], + "edges": [ + { + "location": "waiting", + "destinations": [ + { + "location": "received", + "probability": { + "exp": 1.0 + }, + "assignments": [] + } + ], + "action": "bt_1000_success_on_send" + }, + { + "location": "received", + "destinations": [ + { + "location": "waiting", + "probability": { + "exp": 1.0 + }, + "assignments": [] + } + ], + "action": "bt_1000_success_on_receive" + } + ] + }, + { + "name": "bt_1000_failure", + "locations": [ + { + "name": "received" + }, + { + "name": "waiting" + } + ], + "initial-locations": [ + "waiting" + ], + "edges": [ + { + "location": "waiting", + "destinations": [ + { + "location": "received", + "probability": { + "exp": 1.0 + }, + "assignments": [] + } + ], + "action": "bt_1000_failure_on_send" + }, + { + "location": "received", + "destinations": [ + { + "location": "waiting", + "probability": { + "exp": 1.0 + }, + "assignments": [] + } + ], + "action": "bt_1000_failure_on_receive" + } + ] + }, + { + "name": "bt_1001_tick", + "locations": [ + { + "name": "received" + }, + { + "name": "waiting" + } + ], + "initial-locations": [ + "waiting" + ], + "edges": [ + { + "location": "waiting", + "destinations": [ + { + "location": "received", + "probability": { + "exp": 1.0 + }, + "assignments": [] + } + ], + "action": "bt_1001_tick_on_send" + }, + { + "location": "received", + "destinations": [ + { + "location": "waiting", + "probability": { + "exp": 1.0 + }, + "assignments": [] + } + ], + "action": "bt_1001_tick_on_receive" + } + ] + }, + { + "name": "bt_1001_success", + "locations": [ + { + "name": "received" + }, + { + "name": "waiting" + } + ], + "initial-locations": [ + "waiting" + ], + "edges": [ + { + "location": "waiting", + "destinations": [ + { + "location": "received", + "probability": { + "exp": 1.0 + }, + "assignments": [] + } + ], + "action": "bt_1001_success_on_send" + }, + { + "location": "received", + "destinations": [ + { + "location": "waiting", + "probability": { + "exp": 1.0 + }, + "assignments": [] + } + ], + "action": "bt_1001_success_on_receive" + } + ] + } + ], + "system": { + "elements": [ + { + "automaton": "BatteryDrainer" + }, + { + "automaton": "BatteryManager" + }, + { + "automaton": "TopicCondition" + }, + { + "automaton": "TopicAction" + }, + { + "automaton": "bt" + }, + { + "automaton": "global_timer" + }, + { + "automaton": "ros_topic.level" + }, + { + "automaton": "ros_topic.charge" + }, + { + "automaton": "ros_topic.alarm" + }, + { + "automaton": "bt_1000_tick" + }, + { + "automaton": "bt_1000_success" + }, + { + "automaton": "bt_1000_failure" + }, + { + "automaton": "bt_1001_tick" + }, + { + "automaton": "bt_1001_success" + } + ], + "syncs": [ + { + "result": "bt_1000_failure_on_receive", + "synchronise": [ + null, + null, + null, + null, + "bt_1000_failure_on_receive", + null, + null, + null, + null, + null, + null, + "bt_1000_failure_on_receive", + null, + null + ] + }, + { + "result": "bt_1000_failure_on_send", + "synchronise": [ + null, + null, + "bt_1000_failure_on_send", + null, + null, + null, + null, + null, + null, + null, + null, + "bt_1000_failure_on_send", + null, + null + ] + }, + { + "result": "bt_1000_success_on_receive", + "synchronise": [ + null, + null, + null, + null, + "bt_1000_success_on_receive", + null, + null, + null, + null, + null, + "bt_1000_success_on_receive", + null, + null, + null + ] + }, + { + "result": "bt_1000_success_on_send", + "synchronise": [ + null, + null, + "bt_1000_success_on_send", + null, + null, + null, + null, + null, + null, + null, + "bt_1000_success_on_send", + null, + null, + null + ] + }, + { + "result": "bt_1000_tick_on_receive", + "synchronise": [ + null, + null, + "bt_1000_tick_on_receive", + null, + null, + null, + null, + null, + null, + "bt_1000_tick_on_receive", + null, + null, + null, + null + ] + }, + { + "result": "bt_1000_tick_on_send", + "synchronise": [ + null, + null, + null, + null, + "bt_1000_tick_on_send", + null, + null, + null, + null, + "bt_1000_tick_on_send", + null, + null, + null, + null + ] + }, + { + "result": "bt_1001_success_on_receive", + "synchronise": [ + null, + null, + null, + null, + "bt_1001_success_on_receive", + null, + null, + null, + null, + null, + null, + null, + null, + "bt_1001_success_on_receive" + ] + }, + { + "result": "bt_1001_success_on_send", + "synchronise": [ + null, + null, + null, + "bt_1001_success_on_send", + null, + null, + null, + null, + null, + null, + null, + null, + null, + "bt_1001_success_on_send" + ] + }, + { + "result": "bt_1001_tick_on_receive", + "synchronise": [ + null, + null, + null, + "bt_1001_tick_on_receive", + null, + null, + null, + null, + null, + null, + null, + null, + "bt_1001_tick_on_receive", + null + ] + }, + { + "result": "bt_1001_tick_on_send", + "synchronise": [ + null, + null, + null, + null, + "bt_1001_tick_on_send", + null, + null, + null, + null, + null, + null, + null, + "bt_1001_tick_on_send", + null + ] + }, + { + "result": "check_battery-first-exec-check_battery-a449c803", + "synchronise": [ + null, + "check_battery-first-exec-check_battery-a449c803", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + { + "result": "failure-wait_for_tick-6568ac14", + "synchronise": [ + null, + null, + null, + null, + "failure-wait_for_tick-6568ac14", + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + { + "result": "global_timer_action_0", + "synchronise": [ + null, + null, + null, + null, + null, + "global_timer_action_0", + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + { + "result": "initial-initial-5eebea38", + "synchronise": [ + null, + null, + "initial-initial-5eebea38", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + { + "result": "initial_0_before_if-initial_0_after_if-5eebea38-860ebf50-else", + "synchronise": [ + null, + null, + "initial_0_before_if-initial_0_after_if-5eebea38-860ebf50-else", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + { + "result": "initial_0_before_if-initial_0_after_if-5eebea38-860ebf50-last_msg", + "synchronise": [ + null, + null, + "initial_0_before_if-initial_0_after_if-5eebea38-860ebf50-last_msg", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + { + "result": "ros_time_rate.bt_tick_on_receive", + "synchronise": [ + null, + null, + null, + null, + "ros_time_rate.bt_tick_on_receive", + "ros_time_rate.bt_tick_on_receive", + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + { + "result": "ros_time_rate.my_timer_on_receive", + "synchronise": [ + "ros_time_rate.my_timer_on_receive", + null, + null, + null, + null, + "ros_time_rate.my_timer_on_receive", + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + { + "result": "ros_topic.alarm_on_receive", + "synchronise": [ + null, + null, + "ros_topic.alarm_on_receive", + null, + null, + null, + null, + null, + "ros_topic.alarm_on_receive", + null, + null, + null, + null, + null + ] + }, + { + "result": "ros_topic.alarm_on_send", + "synchronise": [ + null, + "ros_topic.alarm_on_send", + null, + null, + null, + null, + null, + null, + "ros_topic.alarm_on_send", + null, + null, + null, + null, + null + ] + }, + { + "result": "ros_topic.charge_on_receive", + "synchronise": [ + "ros_topic.charge_on_receive", + null, + null, + null, + null, + null, + null, + "ros_topic.charge_on_receive", + null, + null, + null, + null, + null, + null + ] + }, + { + "result": "ros_topic.charge_on_send", + "synchronise": [ + null, + null, + null, + "ros_topic.charge_on_send", + null, + null, + null, + "ros_topic.charge_on_send", + null, + null, + null, + null, + null, + null + ] + }, + { + "result": "ros_topic.level_on_receive", + "synchronise": [ + null, + "ros_topic.level_on_receive", + null, + null, + null, + null, + "ros_topic.level_on_receive", + null, + null, + null, + null, + null, + null, + null + ] + }, + { + "result": "ros_topic.level_on_send", + "synchronise": [ + "ros_topic.level_on_send", + null, + null, + null, + null, + null, + "ros_topic.level_on_send", + null, + null, + null, + null, + null, + null, + null + ] + }, + { + "result": "running-wait_for_tick-89afabc1", + "synchronise": [ + null, + null, + null, + null, + "running-wait_for_tick-89afabc1", + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + { + "result": "success-wait_for_tick-c305aa83", + "synchronise": [ + null, + null, + null, + null, + "success-wait_for_tick-c305aa83", + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + { + "result": "tick-1000_TopicCondition-8aab702a", + "synchronise": [ + null, + null, + null, + null, + "tick-1000_TopicCondition-8aab702a", + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + { + "result": "use_battery-first-exec-use_battery-766fa6e4", + "synchronise": [ + "use_battery-first-exec-use_battery-766fa6e4", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + } + ] + }, + "properties": [ + { + "name": "battery_depleted", + "expression": { + "op": "filter", + "fun": "values", + "values": { + "op": "Pmin", + "exp": { + "left": true, + "op": "U", + "right": { + "left": { + "op": "≤", + "left": "ros_topic.level.data", + "right": 0 + }, + "op": "∧", + "right": "ros_topic.level.valid" + } + } + }, + "states": { + "op": "initial" + } + } + }, + { + "name": "battery_below_20", + "expression": { + "op": "filter", + "fun": "values", + "values": { + "op": "Pmin", + "exp": { + "left": true, + "op": "U", + "right": { + "left": { + "op": "<", + "left": "ros_topic.level.data", + "right": 20 + }, + "op": "∧", + "right": "ros_topic.level.valid" + } + } + }, + "states": { + "op": "initial" + } + } + }, + { + "name": "battery_alarm_on", + "expression": { + "op": "filter", + "fun": "values", + "values": { + "op": "Pmin", + "exp": { + "left": true, + "op": "U", + "right": { + "op": "∧", + "left": "ros_topic.alarm.data", + "right": "ros_topic.charge.valid" + } + } + }, + "states": { + "op": "initial" + } + } + } + ] +} \ No newline at end of file diff --git a/jani_generator/test/_test_data/ros_add_int_srv_example/happy_clients.jani b/jani_generator/test/_test_data/ros_add_int_srv_example/happy_clients.jani index 382a0882..52f43069 100644 --- a/jani_generator/test/_test_data/ros_add_int_srv_example/happy_clients.jani +++ b/jani_generator/test/_test_data/ros_add_int_srv_example/happy_clients.jani @@ -13,13 +13,13 @@ "op": "∧", "left": { "op": "∧", - "left": "ros_topic.client_1_res.data", - "right": "ros_topic.client_1_res.valid" + "left": "topic_client_1_res_msg.data", + "right": "topic_client_1_res_msg.valid" }, "right": { "op": "∧", - "left": "ros_topic.client_2_res.data", - "right": "ros_topic.client_2_res.valid" + "left": "topic_client_2_res_msg.data", + "right": "topic_client_2_res_msg.valid" } } } diff --git a/jani_generator/test/_test_data/ros_example/battery_depleted.jani b/jani_generator/test/_test_data/ros_example/battery_depleted.jani index 7438d694..b4035ff1 100644 --- a/jani_generator/test/_test_data/ros_example/battery_depleted.jani +++ b/jani_generator/test/_test_data/ros_example/battery_depleted.jani @@ -13,11 +13,11 @@ "right": { "left": { "op": "≤", - "left": "ros_topic.level.data", + "left": "topic_level_msg.data", "right": 0 }, "op": "∧", - "right": "ros_topic.level.valid" + "right": "topic_level_msg.valid" } } }, @@ -39,11 +39,11 @@ "right": { "left": { "op": "≤", - "left": "ros_topic.level.data", + "left": "topic_level_msg.data", "right": -1 }, "op": "∧", - "right": "ros_topic.level.valid" + "right": "topic_level_msg.valid" } } }, diff --git a/jani_generator/test/_test_data/ros_example_w_bt/battery_properties.jani b/jani_generator/test/_test_data/ros_example_w_bt/battery_properties.jani index a8093a4f..c2d07091 100644 --- a/jani_generator/test/_test_data/ros_example_w_bt/battery_properties.jani +++ b/jani_generator/test/_test_data/ros_example_w_bt/battery_properties.jani @@ -13,11 +13,11 @@ "right": { "left": { "op": "≤", - "left": "ros_topic.level.data", + "left": "topic_level_msg.data", "right": 0 }, "op": "∧", - "right": "ros_topic.level.valid" + "right": "topic_level_msg.valid" } } }, @@ -39,11 +39,11 @@ "right": { "left": { "op": "<", - "left": "ros_topic.level.data", + "left": "topic_level_msg.data", "right": 20 }, "op": "∧", - "right": "ros_topic.level.valid" + "right": "topic_level_msg.valid" } } }, @@ -64,8 +64,8 @@ "op": "U", "right": { "op": "∧", - "left": "ros_topic.alarm.data", - "right": "ros_topic.charge.valid" + "left": "topic_alarm_msg.data", + "right": "topic_charge_msg.valid" } } }, @@ -90,11 +90,11 @@ "right": { "left": { "op": "=", - "left": "ros_topic.level.data", + "left": "topic_level_msg.data", "right": 100 }, "op": "∧", - "right": "ros_topic.level.valid" + "right": "topic_level_msg.valid" } } }, diff --git a/jani_generator/test/_test_data/ros_fibonacci_action_example/.gitignore b/jani_generator/test/_test_data/ros_fibonacci_action_example/.gitignore new file mode 100644 index 00000000..b941f4e4 --- /dev/null +++ b/jani_generator/test/_test_data/ros_fibonacci_action_example/.gitignore @@ -0,0 +1,3 @@ +generated_bt_scxml +generated_plain_scxml +main.jani \ No newline at end of file diff --git a/jani_generator/test/_test_data/ros_fibonacci_action_example/client_1.scxml b/jani_generator/test/_test_data/ros_fibonacci_action_example/client_1.scxml new file mode 100644 index 00000000..0fc88cd2 --- /dev/null +++ b/jani_generator/test/_test_data/ros_fibonacci_action_example/client_1.scxml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jani_generator/test/_test_data/ros_fibonacci_action_example/client_2.scxml b/jani_generator/test/_test_data/ros_fibonacci_action_example/client_2.scxml new file mode 100644 index 00000000..c4691bbd --- /dev/null +++ b/jani_generator/test/_test_data/ros_fibonacci_action_example/client_2.scxml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jani_generator/test/_test_data/ros_fibonacci_action_example/happy_clients.jani b/jani_generator/test/_test_data/ros_fibonacci_action_example/happy_clients.jani new file mode 100644 index 00000000..418cab82 --- /dev/null +++ b/jani_generator/test/_test_data/ros_fibonacci_action_example/happy_clients.jani @@ -0,0 +1,33 @@ +{ + "properties": [ + { + "name": "happy_clients", + "expression": { + "op": "filter", + "fun": "values", + "values": { + "op": "Pmin", + "exp": { + "op": "F", + "exp": { + "op": "∧", + "left": { + "op": "∧", + "left": "ros_topic./client_1_res.data", + "right": "ros_topic./client_1_res.valid" + }, + "right": { + "op": "∧", + "left": "ros_topic./client_2_res.data", + "right": "ros_topic./client_2_res.valid" + } + } + } + }, + "states": { + "op": "initial" + } + } + } + ] +} \ No newline at end of file diff --git a/jani_generator/test/_test_data/ros_fibonacci_action_example/main.xml b/jani_generator/test/_test_data/ros_fibonacci_action_example/main.xml new file mode 100644 index 00000000..36074c02 --- /dev/null +++ b/jani_generator/test/_test_data/ros_fibonacci_action_example/main.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jani_generator/test/_test_data/ros_fibonacci_action_example/server.scxml b/jani_generator/test/_test_data/ros_fibonacci_action_example/server.scxml new file mode 100644 index 00000000..8d4fdb1b --- /dev/null +++ b/jani_generator/test/_test_data/ros_fibonacci_action_example/server.scxml @@ -0,0 +1,149 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jani_generator/test/test_unittest_jani_model_loading.py b/jani_generator/test/test_unittest_jani_model_loading.py new file mode 100644 index 00000000..b9436fcf --- /dev/null +++ b/jani_generator/test/test_unittest_jani_model_loading.py @@ -0,0 +1,34 @@ +# Copyright (c) 2024 - for information on the respective copyright owner +# see the NOTICE file + +# 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. + +""""Test the SCXML data conversion""" + +import os +import json +from jani_generator.jani_entries import JaniModel + + +def test_jani_file_loading(): + jani_file = os.path.join(os.path.dirname(__file__), + '_test_data', 'plain_jani_examples', 'example_arrays.jani') + with open(jani_file, "r", encoding='utf-8') as file: + convince_jani_json = json.load(file) + jani_model = JaniModel.from_dict(convince_jani_json) + assert isinstance(jani_model, JaniModel) + assert jani_model.get_name() == "example_arrays" + assert "arrays" in jani_model.get_features() + assert len(jani_model.get_variables()) == 12 + assert len(jani_model.get_constants()) == 0 + assert len(jani_model.get_automata()) == 14 diff --git a/scxml_converter/src/scxml_converter/scxml_converter.py b/scxml_converter/src/scxml_converter/scxml_converter.py index 4791f663..fb42bf4f 100644 --- a/scxml_converter/src/scxml_converter/scxml_converter.py +++ b/scxml_converter/src/scxml_converter/scxml_converter.py @@ -20,13 +20,10 @@ into generic SCXML code. """ -import xml.etree.ElementTree as ET -from typing import Dict, Tuple, Union +from typing import Dict, Union from as2fm_common.common import ros_type_name_to_python_type from as2fm_common.ecmascript_interpretation import interpret_ecma_script_expr -from scxml_converter.scxml_entries import (ScxmlRoot, - ScxmlRosDeclarationsContainer) BASIC_FIELD_TYPES = ['boolean', 'int32', 'int16', 'float', 'double'] diff --git a/scxml_converter/src/scxml_converter/scxml_entries/__init__.py b/scxml_converter/src/scxml_converter/scxml_entries/__init__.py index 124266b5..38c4b723 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/__init__.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/__init__.py @@ -16,13 +16,20 @@ execution_entry_from_xml, valid_execution_body, # noqa: F401 valid_execution_body_entry_types, instantiate_exec_body_bt_events) # noqa: F401 from .scxml_transition import ScxmlTransition # noqa: F401 +from .scxml_state import ScxmlState # noqa: F401 +from .scxml_ros_timer import (RosTimeRate, RosRateCallback) # noqa: F401 from .scxml_ros_topic import ( # noqa: F401 RosTopicPublisher, RosTopicSubscriber, RosTopicCallback, RosTopicPublish) # noqa: F401 from .scxml_ros_service import ( # noqa: F401 RosServiceServer, RosServiceClient, RosServiceHandleRequest, # noqa: F401 RosServiceHandleResponse, RosServiceSendRequest, RosServiceSendResponse) # noqa: F401 -from .scxml_ros_timer import (RosTimeRate, RosRateCallback) # noqa: F401 -from .scxml_ros_entries import ( # noqa: F401 - ScxmlRosDeclarations, ScxmlRosSends, ScxmlRosTransitions) # noqa: F401 -from .scxml_state import ScxmlState # noqa: F401 +from .scxml_ros_action_client import ( # noqa: F401 + RosActionClient, RosActionSendGoal, RosActionHandleGoalResponse, # noqa: F401 + RosActionHandleFeedback, RosActionHandleResult) # noqa: F401 +from .scxml_ros_action_server import ( # noqa: F401 + RosActionServer, RosActionHandleGoalRequest, RosActionAcceptGoal, # noqa: F401 + RosActionRejectGoal, RosActionStartThread, RosActionSendFeedback, # noqa: F401 + RosActionSendResult) # noqa: F401 +from .scxml_ros_action_server_thread import ( # noqa: F401 + RosActionThread, RosActionHandleThreadStart) # noqa: F401 from .scxml_root import ScxmlRoot # noqa: F401 diff --git a/scxml_converter/src/scxml_converter/scxml_entries/ros_utils.py b/scxml_converter/src/scxml_converter/scxml_entries/ros_utils.py index 56cd6c16..f11462d3 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/ros_utils.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/ros_utils.py @@ -15,9 +15,9 @@ """Collection of SCXML utilities related to ROS functionalities.""" -from typing import Dict, List, Optional, Tuple +from typing import Any, Dict, List, Tuple, Type -from scxml_converter.scxml_entries.scxml_ros_field import RosField +from scxml_converter.scxml_entries import ScxmlBase, RosField from scxml_converter.scxml_entries.utils import all_non_empty_strings @@ -28,7 +28,16 @@ BASIC_FIELD_TYPES = ['boolean', 'int8', 'int16', 'int32', 'int64', - 'float', 'double'] + 'float', 'double', + 'sequence'] + +"""List of prefixes for ROS-related event data in SCXML.""" +ROS_EVENT_PREFIXES = [ + "_msg.", # Topic-related + "_req.", "_res.", # Service-related + "_goal.", "_feedback.", "_result." # Action-related +] + """Container for the ROS interface (e.g. topic or service) name and the related type""" RosInterfaceAndType = Tuple[str, str] @@ -45,7 +54,8 @@ def is_ros_type_known(type_definition: str, ros_interface: str) -> bool: interface_ns, interface_type = type_definition.split("/") if len(interface_ns) == 0 or len(interface_type) == 0: return False - assert ros_interface in ["msg", "srv"], "Error: SCXML ROS declarations: unknown ROS interface." + assert ros_interface in ["msg", "srv", "action"], \ + "Error: SCXML ROS declarations: unknown ROS interface." try: interface_importer = __import__(interface_ns + f'.{ros_interface}', fromlist=['']) _ = getattr(interface_importer, interface_type) @@ -65,9 +75,48 @@ def is_srv_type_known(service_definition: str) -> bool: return is_ros_type_known(service_definition, "srv") +def is_action_type_known(action_definition: str) -> bool: + """Check if python can import the provided action definition.""" + return is_ros_type_known(action_definition, "action") + + +def extract_params_from_ros_type(ros_interface_type: Type[Any]) -> Dict[str, str]: + """ + Extract the data fields of a ROS message type as pairs of name and type objects. + """ + fields = ros_interface_type.get_fields_and_field_types() + additional_fields = {} + for key in fields.keys(): + assert fields[key] in BASIC_FIELD_TYPES, \ + f"Error: SCXML ROS declarations: {ros_interface_type} {key} field is " \ + f"of type {fields[key]}, that is not supported." + fields[key] = MSG_TYPE_SUBSTITUTIONS.get(fields[key], fields[key]) + # For array fields (or sequences), we append also a "__len" entry + if fields[key].startswith("sequence<"): + additional_fields[key + "__len"] = "int32" + return fields | additional_fields + + +def check_all_fields_known(ros_fields: List[RosField], field_types: Dict[str, str]) -> bool: + """ + Check that all fields from ros_fields are in field_types, and that no field is missing. + """ + for ros_field in ros_fields: + if ros_field.get_name() not in field_types: + print(f"Error: SCXML ROS declarations: unknown field {ros_field.get_name()}.") + return False + field_types.pop(ros_field.get_name()) + if len(field_types) > 0: + print("Error: SCXML ROS declarations: there are missing fields:") + for field_key in field_types.keys(): + print(f"\t-{field_key}.") + return False + return True + + def get_srv_type_params(service_definition: str) -> Tuple[Dict[str, str], Dict[str, str]]: """ - Get the data fields of a service request and response type as pairs of name and type objects. + Get the fields of a service request and response as pairs of name and type objects. """ assert is_srv_type_known(service_definition), \ f"Error: SCXML ROS declarations: service type {service_definition} not found." @@ -76,27 +125,33 @@ def get_srv_type_params(service_definition: str) -> Tuple[Dict[str, str], Dict[s srv_class = getattr(srv_module, interface_type) # TODO: Fields can be nested. Look AS2FM/scxml_converter/src/scxml_converter/scxml_converter.py - req = srv_class.Request.get_fields_and_field_types() - for key in req.keys(): - # TODO: Support nested fields - assert req[key] in BASIC_FIELD_TYPES, \ - f"Error: SCXML ROS declarations: service request type {req[key]} isn't a basic field." - req[key] = MSG_TYPE_SUBSTITUTIONS.get(req[key], req[key]) + req_fields = extract_params_from_ros_type(srv_class.Request) + res_fields = extract_params_from_ros_type(srv_class.Response) - res = srv_class.Response.get_fields_and_field_types() - for key in res.keys(): - assert res[key] in BASIC_FIELD_TYPES, \ - "Error: SCXML ROS declarations: service response type contains non-basic fields." - res[key] = MSG_TYPE_SUBSTITUTIONS.get(res[key], res[key]) + return req_fields, res_fields - return req, res + +def get_action_type_params(action_definition: str + ) -> Tuple[Dict[str, str], Dict[str, str], Dict[str, str]]: + """ + Get the fields of an action goal, feedback and result as pairs of name and type objects. + """ + assert is_action_type_known(action_definition), \ + f"Error: SCXML ROS declarations: action type {action_definition} not found." + interface_ns, interface_type = action_definition.split("/") + action_module = __import__(interface_ns + '.action', fromlist=['']) + action_class = getattr(action_module, interface_type) + action_goal_fields = extract_params_from_ros_type(action_class.Goal) + action_feedback_fields = extract_params_from_ros_type(action_class.Feedback) + action_result_fields = extract_params_from_ros_type(action_class.Result) + return action_goal_fields, action_feedback_fields, action_result_fields def replace_ros_interface_expression(msg_expr: str) -> str: - """Convert a ROS interface expression (msg, req, res) to plain SCXML (event).""" + """Convert all ROS interface expressions (in ROS_EVENT_PREFIXES) to plain SCXML events.""" scxml_prefix = "_event." # TODO: Use regex and ensure no other valid character exists before the initial underscore - for ros_prefix in ["_msg.", "_req.", "_res."]: + for ros_prefix in ROS_EVENT_PREFIXES: msg_expr = msg_expr.replace(ros_prefix, scxml_prefix) return msg_expr @@ -114,6 +169,17 @@ def sanitize_ros_interface_name(interface_name: str) -> str: return interface_name.replace("/", "__") +def generate_rate_timer_event(timer_name: str) -> str: + """Generate the name of the event triggered by a rate timer.""" + # TODO: Remove dot notation + return f"ros_time_rate.{timer_name}" + + +def generate_topic_event(topic_name: str) -> str: + """Generate the name of the event that triggers a message reception from a topic.""" + return f"topic_{sanitize_ros_interface_name(topic_name)}_msg" + + def generate_srv_request_event(service_name: str, automaton_name: str) -> str: """Generate the name of the event that triggers a service request.""" return f"srv_{sanitize_ros_interface_name(service_name)}_req_client_{automaton_name}" @@ -134,6 +200,68 @@ def generate_srv_server_response_event(service_name: str) -> str: return f"srv_{sanitize_ros_interface_name(service_name)}_response" +def generate_action_goal_req_event(action_name: str, client_name: str) -> str: + """Generate the name of the event that sends an action goal from a client to the server.""" + return f"action_{sanitize_ros_interface_name(action_name)}_goal_req_client_{client_name}" + + +def generate_action_goal_accepted_event(action_name: str, client_name: str) -> str: + """Generate the name of the event that reports goal acceptance to a client.""" + return f"action_{sanitize_ros_interface_name(action_name)}_goal_accept_client_{client_name}" + + +def generate_action_goal_rejected_event(action_name: str, client_name: str) -> str: + """Generate the name of the event that reports goal rejection to a client.""" + return f"action_{sanitize_ros_interface_name(action_name)}_goal_reject_client_{client_name}" + + +def generate_action_goal_handle_event(action_name: str) -> str: + """Generate the name of the event that triggers an action goal handling in the server.""" + return f"action_{sanitize_ros_interface_name(action_name)}_goal_handle" + + +def generate_action_goal_handle_accepted_event(action_name: str) -> str: + """Generate the name of the event sent from the server in case of goal acceptance.""" + return f"action_{sanitize_ros_interface_name(action_name)}_goal_accepted" + + +def generate_action_goal_handle_rejected_event(action_name: str) -> str: + """Generate the name of the event sent from the server in case of goal rejection.""" + return f"action_{sanitize_ros_interface_name(action_name)}_goal_rejected" + + +def generate_action_thread_execution_start_event(action_name: str) -> str: + """Generate the name of the event that triggers the start of an action thread execution.""" + return f"action_{sanitize_ros_interface_name(action_name)}_thread_start" + + +def generate_action_thread_free_event(action_name: str) -> str: + """Generate the name of the event sent when an action thread becomes free.""" + return f"action_{sanitize_ros_interface_name(action_name)}_thread_free" + + +def generate_action_feedback_event(action_name: str) -> str: + """Generate the name of the event that sends a feedback from the action server.""" + return f"action_{sanitize_ros_interface_name(action_name)}_feedback" + + +def generate_action_feedback_handle_event(action_name: str, automaton_name: str) -> str: + """Generate the name of the event that handles a feedback in an action client.""" + return f"action_{sanitize_ros_interface_name(action_name)}_" \ + f"feedback_handle_client_{automaton_name}" + + +def generate_action_result_event(action_name: str) -> str: + """Generate the name of the event that sends a result from the action server.""" + return f"action_{sanitize_ros_interface_name(action_name)}_result" + + +def generate_action_result_handle_event(action_name: str, automaton_name: str) -> str: + """Generate the name of the event that handles a result in an action client.""" + return f"action_{sanitize_ros_interface_name(action_name)}_" \ + f"result_handle_client_{automaton_name}" + + class ScxmlRosDeclarationsContainer: """Object that contains a description of the ROS declarations in the SCXML root.""" @@ -143,46 +271,143 @@ def __init__(self, automaton_name: str): :automaton_name: Name of the automaton these declarations belong to. """ self._automaton_name: str = automaton_name - # Dict of publishers and subscribers: topic name -> type + # Dictionaries relating an interface ref. name to the comm. channel name and data type + # ROS Topics self._publishers: Dict[str, RosInterfaceAndType] = {} self._subscribers: Dict[str, RosInterfaceAndType] = {} + # ROS Services self._service_servers: Dict[str, RosInterfaceAndType] = {} self._service_clients: Dict[str, RosInterfaceAndType] = {} + # ROS Actions + self._action_servers: Dict[str, RosInterfaceAndType] = {} + self._action_clients: Dict[str, RosInterfaceAndType] = {} + # ROS Timers self._timers: Dict[str, float] = {} def get_automaton_name(self) -> str: """Get name of the automaton that these declarations are defined in.""" return self._automaton_name - def append_publisher(self, pub_name: str, topic_name: str, topic_type: str) -> None: + def append_ros_declaration(self, scxml_ros_declaration: ScxmlBase) -> None: + """ + Add a ROS declaration to the container instance. + + :param scxml_ros_declaration: The ROS declaration to add (inheriting from RosDeclaration). + """ + from scxml_converter.scxml_entries.scxml_ros_base import RosDeclaration + from scxml_converter.scxml_entries.scxml_ros_timer import RosTimeRate + from scxml_converter.scxml_entries.scxml_ros_topic import ( + RosTopicPublisher, RosTopicSubscriber) + from scxml_converter.scxml_entries.scxml_ros_service import ( + RosServiceServer, RosServiceClient) + from scxml_converter.scxml_entries.scxml_ros_action_server import RosActionServer + from scxml_converter.scxml_entries.scxml_ros_action_client import RosActionClient + assert isinstance(scxml_ros_declaration, RosDeclaration), \ + f"Error: SCXML ROS declarations: {type(scxml_ros_declaration)} isn't a ROS declaration." + if isinstance(scxml_ros_declaration, RosTimeRate): + self._append_timer(scxml_ros_declaration.get_name(), + scxml_ros_declaration.get_rate()) + elif isinstance(scxml_ros_declaration, RosTopicPublisher): + self._append_publisher(scxml_ros_declaration.get_name(), + scxml_ros_declaration.get_interface_name(), + scxml_ros_declaration.get_interface_type()) + elif isinstance(scxml_ros_declaration, RosTopicSubscriber): + self._append_subscriber(scxml_ros_declaration.get_name(), + scxml_ros_declaration.get_interface_name(), + scxml_ros_declaration.get_interface_type()) + elif isinstance(scxml_ros_declaration, RosServiceServer): + self._append_service_server(scxml_ros_declaration.get_name(), + scxml_ros_declaration.get_interface_name(), + scxml_ros_declaration.get_interface_type()) + elif isinstance(scxml_ros_declaration, RosServiceClient): + self._append_service_client(scxml_ros_declaration.get_name(), + scxml_ros_declaration.get_interface_name(), + scxml_ros_declaration.get_interface_type()) + elif isinstance(scxml_ros_declaration, RosActionServer): + self._append_action_server(scxml_ros_declaration.get_name(), + scxml_ros_declaration.get_interface_name(), + scxml_ros_declaration.get_interface_type()) + elif isinstance(scxml_ros_declaration, RosActionClient): + self._append_action_client(scxml_ros_declaration.get_name(), + scxml_ros_declaration.get_interface_name(), + scxml_ros_declaration.get_interface_type()) + else: + raise NotImplementedError(f"Error: SCXML ROS declaration type " + f"{type(scxml_ros_declaration)}.") + + def _append_publisher(self, pub_name: str, topic_name: str, topic_type: str) -> None: + """ + Add a publisher to the container. + + :param pub_name: Name of the publisher (alias, user-defined). + :param topic_name: Name of the topic to publish to. + :param topic_type: Type of the message to publish. + """ assert all_non_empty_strings(pub_name, topic_name, topic_type), \ "Error: ROS declarations: publisher name, topic name and type must be strings." assert pub_name not in self._publishers, \ f"Error: ROS declarations: topic publisher {pub_name} already declared." self._publishers[pub_name] = (topic_name, topic_type) - def append_subscriber(self, sub_name: str, topic_name: str, topic_type: str) -> None: + def _append_subscriber(self, sub_name: str, topic_name: str, topic_type: str) -> None: + """ + Add a subscriber to the container. + + :param sub_name: Name of the subscriber (alias, user-defined). + :param topic_name: Name of the topic to subscribe to. + :param topic_type: Type of the message to subscribe to. + """ assert all_non_empty_strings(sub_name, topic_name, topic_type), \ "Error: ROS declarations: subscriber name, topic name and type must be strings." assert sub_name not in self._subscribers, \ f"Error: ROS declarations: topic subscriber {sub_name} already declared." self._subscribers[sub_name] = (topic_name, topic_type) - def append_service_client(self, client_name: str, service_name: str, service_type: str) -> None: + def _append_service_client( + self, client_name: str, service_name: str, service_type: str) -> None: + """ + Add a service client to the container. + + :param client_name: Name of the service client (alias, user-defined). + :param service_name: Name of the service to call. + :param service_type: Type of data used in the service communication. + """ assert all_non_empty_strings(client_name, service_name, service_type), \ "Error: ROS declarations: client name, service name and type must be strings." assert client_name not in self._service_clients, \ f"Error: ROS declarations: service client {client_name} already declared." self._service_clients[client_name] = (service_name, service_type) - def append_service_server(self, server_name: str, service_name: str, service_type: str) -> None: + def _append_service_server( + self, server_name: str, service_name: str, service_type: str) -> None: + """ + Add a service server to the container. + + :param server_name: Name of the service server (alias, user-defined). + :param service_name: Name of the provided service (what the client needs to call). + :param service_type: Type of data used in the service communication. + """ assert all_non_empty_strings(server_name, service_name, service_type), \ "Error: ROS declarations: server name, service name and type must be strings." assert server_name not in self._service_servers, \ f"Error: ROS declarations: service server {server_name} already declared." self._service_servers[server_name] = (service_name, service_type) - def append_timer(self, timer_name: str, timer_rate: float) -> None: + def _append_action_client(self, client_name: str, action_name: str, action_type: str) -> None: + assert all_non_empty_strings(client_name, action_name, action_type), \ + "Error: ROS declarations: client name, action name and type must be strings." + assert client_name not in self._action_clients, \ + f"Error: ROS declarations: action client {client_name} already declared." + self._action_clients[client_name] = (action_name, action_type) + + def _append_action_server(self, server_name: str, action_name: str, action_type: str) -> None: + assert all_non_empty_strings(server_name, action_name, action_type), \ + "Error: ROS declarations: server name, action name and type must be strings." + assert server_name not in self._action_servers, \ + f"Error: ROS declarations: action server {server_name} already declared." + self._action_servers[server_name] = (action_name, action_type) + + def _append_timer(self, timer_name: str, timer_rate: float) -> None: assert isinstance(timer_name, str), "Error: ROS declarations: timer name must be a string." assert isinstance(timer_rate, float) and timer_rate > 0, \ "Error: ROS declarations: timer rate must be a positive number." @@ -196,12 +421,21 @@ def is_publisher_defined(self, pub_name: str) -> bool: def is_subscriber_defined(self, sub_name: str) -> bool: return sub_name in self._subscribers + def is_service_client_defined(self, client_name: str) -> bool: + return client_name in self._service_clients + + def is_service_server_defined(self, server_name: str) -> bool: + return server_name in self._service_servers + + def is_action_client_defined(self, client_name: str) -> bool: + return client_name in self._action_clients + + def is_action_server_defined(self, server_name: str) -> bool: + return server_name in self._action_servers + def is_timer_defined(self, timer_name: str) -> bool: return timer_name in self._timers - def get_timers(self) -> Dict[str, float]: - return self._timers - def get_publisher_info(self, pub_name: str) -> Tuple[str, str]: """Provide a publisher topic name and type""" pub_info = self._publishers.get(pub_name) @@ -229,60 +463,106 @@ def get_service_client_info(self, client_name: str) -> Tuple[str, str]: f"Error: SCXML ROS declarations: unknown service client {client_name}." return client_info - def is_service_client_defined(self, client_name: str) -> bool: - return client_name in self._service_clients - - def is_service_server_defined(self, server_name: str) -> bool: - return server_name in self._service_servers + def get_action_server_info(self, server_name: str) -> Tuple[str, str]: + """Given an action server name, provide the related action name and type.""" + server_info = self._action_servers.get(server_name) + assert server_info is not None, \ + f"Error: SCXML ROS declarations: unknown action server {server_name}." + return server_info - def get_service_client_type(self, client_name: str) -> Optional[str]: - client_definition = self._service_clients.get(client_name, None) - if client_definition is None: - return None - return client_definition[1] + def get_action_client_info(self, client_name: str) -> Tuple[str, str]: + """Given an action client name, provide the related action name and type.""" + client_info = self._action_clients.get(client_name) + assert client_info is not None, \ + f"Error: SCXML ROS declarations: unknown action client {client_name}." + return client_info - def get_service_server_type(self, server_name: str) -> Optional[str]: - server_definition = self._service_servers.get(server_name, None) - if server_definition is None: - return None - return server_definition[1] + def get_timers(self) -> Dict[str, float]: + return self._timers def check_valid_srv_req_fields(self, client_name: str, ros_fields: List[RosField]) -> bool: """Check if the provided fields match the service request type.""" - req_type = self.get_service_client_type(client_name) - if req_type is None: - print(f"Error: SCXML ROS declarations: unknown service client {client_name}.") - return False - req_fields, _ = get_srv_type_params(req_type) - for ros_field in ros_fields: - if ros_field.get_name() not in req_fields: - print("Error: SCXML ROS declarations: " - f"unknown field {ros_field.get_name()} in service request.") - return False - req_fields.pop(ros_field.get_name()) - if len(req_fields) > 0: - print("Error: SCXML ROS declarations: missing fields in service request.") - for req_field in req_fields.keys(): - print(f"\t-{req_field}.") + _, service_type = self.get_service_client_info(client_name) + req_fields, _ = get_srv_type_params(service_type) + if not check_all_fields_known(ros_fields, req_fields): + print(f"Error: SCXML ROS declarations: Srv request {client_name} has invalid fields.") return False return True def check_valid_srv_res_fields(self, server_name: str, ros_fields: List[RosField]) -> bool: """Check if the provided fields match the service response type.""" - res_type = self.get_service_server_type(server_name) - if res_type is None: - print(f"Error: SCXML ROS declarations: unknown service server {server_name}.") - return False + _, res_type = self.get_service_server_info(server_name) _, res_fields = get_srv_type_params(res_type) - for ros_field in ros_fields: - if ros_field.get_name() not in res_fields: - print("Error: SCXML ROS declarations: " - f"unknown field {ros_field.get_name()} in service response.") - return False - res_fields.pop(ros_field.get_name()) - if len(res_fields) > 0: - print("Error: SCXML ROS declarations: missing fields in service response.") - for res_field in res_fields.keys(): - print(f"\t-{res_field}.") + if not check_all_fields_known(ros_fields, res_fields): + print(f"Error: SCXML ROS declarations: Srv response {server_name} has invalid fields.") + return False + return True + + def check_valid_action_goal_fields( + self, alias_name: str, ros_fields: List[RosField], has_goal_id: bool = False) -> bool: + """ + Check if the provided fields match with the action type's goal entries. + + :param alias_name: Name of the action client. + :param ros_fields: List of fields to check. + :param has_goal_id: Whether the goal_id shall be included among the fields. + """ + if self.is_action_client_defined(alias_name): + action_type = self.get_action_client_info(alias_name)[1] + else: + assert self.is_action_server_defined(alias_name), \ + f"Error: SCXML ROS declarations: unknown action {alias_name}." + action_type = self.get_action_server_info(alias_name)[1] + goal_fields = get_action_type_params(action_type)[0] + # We use the goal ID as a reserved field for the action. Make sure it is available. + assert "goal_id" not in goal_fields, \ + f"Error: SCXML ROS declarations: action {action_type} goal has the 'goal_id' field." + if has_goal_id: + goal_fields["goal_id"] = "int32" + if not check_all_fields_known(ros_fields, goal_fields): + print(f"Error: SCXML ROS declarations: Action goal {alias_name} has invalid fields.") + return False + return True + + def check_valid_action_feedback_fields( + self, server_name: str, ros_fields: List[RosField], has_goal_id: bool = False) -> bool: + """ + Check if the provided fields match with the action type's feedback entries. + + :param client_name: Name of the action client. + :param ros_fields: List of fields to check. + :param has_goal_id: Whether the goal_id shall be included among the fields. + """ + _, action_type = self.get_action_server_info(server_name) + _, feedback_fields, _ = get_action_type_params(action_type) + # We use the goal ID as a reserved field for the action. Make sure it is available. + assert "goal_id" not in feedback_fields, \ + f"Error: SCXML ROS declarations: action {action_type} feedback has the 'goal_id' field." + if has_goal_id: + feedback_fields["goal_id"] = "int32" + if not check_all_fields_known(ros_fields, feedback_fields): + print(f"Error: SCXML ROS declarations: Action feedback {server_name} " + "has invalid fields.") + return False + return True + + def check_valid_action_result_fields( + self, server_name: str, ros_fields: List[RosField], has_goal_id: bool = False) -> bool: + """ + Check if the provided fields match with the action type's result entries. + + :param client_name: Name of the action client. + :param ros_fields: List of fields to check. + :param has_goal_id: Whether the goal_id shall be included among the fields. + """ + _, action_type = self.get_action_server_info(server_name) + _, _, result_fields = get_action_type_params(action_type) + # We use the goal ID as a reserved field for the action. Make sure it is available. + assert "goal_id" not in result_fields, \ + f"Error: SCXML ROS declarations: action {action_type} feedback has the 'goal_id' field." + if has_goal_id: + result_fields["goal_id"] = "int32" + if not check_all_fields_known(ros_fields, result_fields): + print(f"Error: SCXML ROS declarations: Action result {server_name} has invalid fields.") return False return True diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_data.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_data.py index 9f96f02a..b10b797e 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/scxml_data.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_data.py @@ -25,29 +25,13 @@ from scxml_converter.scxml_entries.bt_utils import BtPortsHandler from scxml_converter.scxml_entries.xml_utils import ( assert_xml_tag_ok, get_xml_argument, read_value_from_xml_arg_or_child) -from scxml_converter.scxml_entries.utils import SCXML_DATA_STR_TO_TYPE, is_non_empty_string +from scxml_converter.scxml_entries.utils import ( + SCXML_DATA_STR_TO_TYPE, convert_string_to_type, is_non_empty_string) ValidExpr = Union[BtGetValueInputPort, str, int, float] -def get_valid_entry_data_type( - value: Optional[Union[str, int, float]], data_type: str) -> Optional[Any]: - """ - Convert a value to the provided data type. Raise if impossible. - """ - if value is None: - return None - assert data_type in SCXML_DATA_STR_TO_TYPE, \ - f"Error: SCXML conversion of data entry: Unknown data type {data_type}." - if isinstance(value, str): - assert len(value) > 0, "Error: SCXML conversion of data bounds: Empty string." - return SCXML_DATA_STR_TO_TYPE[data_type](value) - assert isinstance(value, SCXML_DATA_STR_TO_TYPE[data_type]), \ - f"Error: SCXML conversion of data entry: Expected {data_type}, but got {type(value)}." - return value - - def valid_bound(bound_value: Any) -> bool: """Check if a bound is invalid.""" if bound_value is None: @@ -81,11 +65,11 @@ def from_xml_tree(xml_tree: ET.Element) -> "ScxmlData": def __init__( self, id_: str, expr: ValidExpr, data_type: str, lower_bound: Optional[ValidExpr] = None, upper_bound: Optional[ValidExpr] = None): - self._id = id_ - self._expr = expr - self._data_type = data_type - self._lower_bound = lower_bound - self._upper_bound = upper_bound + self._id: str = id_ + self._expr: str = expr + self._data_type: str = data_type + self._lower_bound: str = lower_bound + self._upper_bound: str = upper_bound def get_name(self) -> str: return self._id @@ -96,23 +80,37 @@ def get_type(self) -> type: def get_expr(self) -> str: return self._expr + def check_valid_bounds(self) -> bool: + if all(bound is None for bound in [self._lower_bound, self._upper_bound]): + # Nothing to check + return True + python_type = SCXML_DATA_STR_TO_TYPE[self._data_type] + if python_type not in (float, int): + print(f"Error: SCXML data: '{self._id}' has bounds but has type {self._data_type}, " + "not a number.") + return False + lower_bound = None + upper_bound = None + if self._lower_bound is not None: + lower_bound = convert_string_to_type(self._lower_bound, self._data_type) + if self._upper_bound is not None: + upper_bound = convert_string_to_type(self._upper_bound, self._data_type) + if all(bound is not None for bound in [lower_bound, upper_bound]): + if lower_bound > upper_bound: + print(f"Error: SCXML data: 'lower_bound_incl' {lower_bound} is not smaller " + f"than 'upper_bound_incl' {upper_bound}.") + return False + return True + def check_validity(self) -> bool: valid_id = is_non_empty_string(ScxmlData, "id", self._id) + valid_type = is_non_empty_string(ScxmlData, "type", self._data_type) + if valid_type: + if self._data_type not in SCXML_DATA_STR_TO_TYPE: + print(f"Error: SCXML data: '{self._id}' has unknown type '{self._data_type}'.") + return False valid_expr = is_non_empty_string(ScxmlData, "expr", self._expr) - valid_type = is_non_empty_string(ScxmlData, "type", self._data_type) and \ - self._data_type in SCXML_DATA_STR_TO_TYPE - if not (valid_bound(self._lower_bound) and valid_bound(self._upper_bound)): - print("Error: SCXML data: invalid lower_bound_incl or upper_bound_incl. " - f"lower_bound_incl: {self._lower_bound}, upper_bound_incl: {self._upper_bound}") - return False - lower_bound = get_valid_entry_data_type(self._lower_bound, self._data_type) - upper_bound = get_valid_entry_data_type(self._upper_bound, self._data_type) - valid_bounds = True - if lower_bound is not None and upper_bound is not None: - valid_bounds = lower_bound <= upper_bound - if not valid_bounds: - print(f"Error: SCXML data: 'lower_bound_incl' {lower_bound} is not smaller " - f"than 'upper_bound_incl' {upper_bound}.") + valid_bounds = self.check_valid_bounds() return valid_id and valid_expr and valid_type and valid_bounds def as_xml(self) -> ET.Element: diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_data_model.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_data_model.py index 78c3ec1e..eb1e2913 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/scxml_data_model.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_data_model.py @@ -55,19 +55,18 @@ def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler): data_entry.update_bt_ports_values(bt_ports_handler) def check_validity(self) -> bool: - valid_data_entries = True if self._data_entries is not None: - valid_data_entries = isinstance(self._data_entries, list) - if valid_data_entries: - for data_entry in self._data_entries: - valid_data_entry = isinstance(data_entry, ScxmlData) and \ - data_entry.check_validity() - if not valid_data_entry: - valid_data_entries = False - break - if not valid_data_entries: - print("Error: SCXML datamodel: data entries are not valid.") - return valid_data_entries + if not isinstance(self._data_entries, list): + print("Error: SCXML datamodel: data entries are not a list.") + return False + for data_entry in self._data_entries: + if not isinstance(data_entry, ScxmlData): + print(f"Error: SCXML datamodel: invalid data entry type {type(data_entry)}.") + return False + if not data_entry.check_validity(): + print(f"Error: SCXML datamodel: invalid data entry '{data_entry.get_name()}'.") + return False + return True def as_xml(self) -> Optional[ET.Element]: assert self.check_validity(), "SCXML: found invalid datamodel object." diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_executable_entries.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_executable_entries.py index aae29039..33abac62 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/scxml_executable_entries.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_executable_entries.py @@ -60,18 +60,6 @@ def update_exec_body_bt_ports_values( class ScxmlIf(ScxmlBase): """This class represents SCXML conditionals.""" - def __init__(self, - conditional_executions: List[ConditionalExecutionBody], - else_execution: Optional[ScxmlExecutionBody] = None): - """ - Class representing a conditional execution in SCXML. - - :param conditional_executions: List of (condition - exec. body) pairs. Min n. pairs is one. - :param else_execution: Execution to be done if no condition is met. - """ - self._conditional_executions = conditional_executions - self._else_execution = else_execution - @staticmethod def get_tag_name() -> str: return "if" @@ -79,28 +67,47 @@ def get_tag_name() -> str: @staticmethod def from_xml_tree(xml_tree: ET.Element) -> "ScxmlIf": """Create a ScxmlIf object from an XML tree.""" - assert xml_tree.tag == ScxmlIf.get_tag_name(), \ - f"Error: SCXML if: XML tag name is not {ScxmlIf.get_tag_name()}." + assert_xml_tag_ok(ScxmlIf, xml_tree) conditions: List[str] = [] exec_bodies: List[ScxmlExecutionBody] = [] conditions.append(xml_tree.attrib["cond"]) - current_body: Optional[ScxmlExecutionBody] = [] - assert current_body is not None, "Error: SCXML if: current body is not valid." + current_body: ScxmlExecutionBody = [] + else_tag_found = False for child in xml_tree: if child.tag == "elseif": + assert not else_tag_found, "Error: SCXML if: 'elseif' tag found after 'else' tag." conditions.append(child.attrib["cond"]) exec_bodies.append(current_body) current_body = [] elif child.tag == "else": + assert not else_tag_found, "Error: SCXML if: multiple 'else' tags found." + else_tag_found = True exec_bodies.append(current_body) current_body = [] else: current_body.append(execution_entry_from_xml(child)) + else_body: Optional[ScxmlExecutionBody] = None + if else_tag_found: + else_body = current_body + else: + exec_bodies.append(current_body) assert len(conditions) == len(exec_bodies), \ - "Error: SCXML if: number of conditions and bodies do not match." - if len(current_body) == 0: - current_body = None - return ScxmlIf(list(zip(conditions, exec_bodies)), current_body) + "Error: SCXML if: number of conditions and bodies do not match " \ + f"({len(conditions)} != {len(exec_bodies)}). Conditions: {conditions}." + return ScxmlIf(list(zip(conditions, exec_bodies)), else_body) + + def __init__(self, + conditional_executions: List[ConditionalExecutionBody], + else_execution: Optional[ScxmlExecutionBody] = None): + """ + Class representing a conditional execution in SCXML. + + :param conditional_executions: List of (condition - exec. body) pairs. Min n. pairs is one. + :param else_execution: Execution to be done if no condition is met. + """ + self._conditional_executions = conditional_executions + self._else_execution = else_execution + assert self.check_validity(), "Error: SCXML if: invalid if object." def get_conditional_executions(self) -> List[ConditionalExecutionBody]: """Get the conditional executions.""" @@ -122,25 +129,14 @@ def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler): update_exec_body_bt_ports_values(self._else_execution, bt_ports_handler) def check_validity(self) -> bool: - valid_conditional_executions = len(self._conditional_executions) > 0 + valid_conditional_executions = len(self._conditional_executions) > 0 and \ + all(isinstance(condition, str) and len(body) > 0 and valid_execution_body(body) + for condition, body in self._conditional_executions) if not valid_conditional_executions: - print("Error: SCXML if: no conditional executions found.") - for condition_execution in self._conditional_executions: - valid_tuple = isinstance(condition_execution, tuple) and len(condition_execution) == 2 - if not valid_tuple: - print("Error: SCXML if: invalid conditional execution found.") - condition, execution = condition_execution - valid_condition = isinstance(condition, str) and len(condition) > 0 - valid_execution = valid_execution_body(execution) - if not valid_condition: - print("Error: SCXML if: invalid condition found.") - if not valid_execution: - print("Error: SCXML if: invalid execution body found.") - valid_conditional_executions = valid_tuple and valid_condition and valid_execution - if not valid_conditional_executions: - break + print("Error: SCXML if: Found invalid entries in conditional executions.") valid_else_execution = \ - self._else_execution is None or valid_execution_body(self._else_execution) + self._else_execution is None or \ + (len(self._else_execution) > 0 and valid_execution_body(self._else_execution)) if not valid_else_execution: print("Error: SCXML if: invalid else execution body found.") return valid_conditional_executions and valid_else_execution @@ -161,6 +157,17 @@ def check_valid_ros_instantiations(self, return False return True + def set_thread_id(self, thread_id: int) -> None: + """Set the thread ID for the executable entries contained in the if object.""" + for _, exec_body in self._conditional_executions: + for entry in exec_body: + if hasattr(entry, "set_thread_id"): + entry.set_thread_id(thread_id) + if self._else_execution is not None: + for entry in self._else_execution: + if hasattr(entry, "set_thread_id"): + entry.set_thread_id(thread_id) + def as_plain_scxml(self, ros_declarations: ScxmlRosDeclarationsContainer) -> "ScxmlIf": condional_executions = [] for condition, execution in self._conditional_executions: @@ -249,6 +256,8 @@ def check_valid_ros_instantiations(self, _) -> bool: return True def append_param(self, param: ScxmlParam) -> None: + assert self.__class__ is ScxmlSend, \ + f"Error: SCXML send: cannot append param to derived class {self.__class__.__name__}." assert isinstance(param, ScxmlParam), "Error: SCXML send: invalid param." self._params.append(param) @@ -284,7 +293,6 @@ def from_xml_tree(xml_tree: ET.Element) -> "ScxmlAssign": def __init__(self, location: str, expr: Union[str, BtGetValueInputPort]): self._location = location self._expr = expr - print(f"ScxmlAssign: {location} = {expr}") def get_location(self) -> str: """Get the location to assign.""" @@ -372,11 +380,11 @@ def execution_entry_from_xml(xml_tree: ET.Element) -> ScxmlExecutableEntry: :param xml_tree: The XML tree to create the execution entry from :return: The execution entry """ - # TODO: This is pretty bad, need to re-check how to break the circle - from .scxml_ros_entries import ScxmlRosSends + from scxml_converter.scxml_entries.scxml_ros_base import RosTrigger # TODO: This should be generated only once, since it stays as it is - tag_to_cls = {cls.get_tag_name(): cls for cls in _ResolvedScxmlExecutableEntry + ScxmlRosSends} + tag_to_cls = {cls.get_tag_name(): cls for cls in _ResolvedScxmlExecutableEntry} + tag_to_cls.update({cls.get_tag_name(): cls for cls in RosTrigger.__subclasses__()}) exec_tag = xml_tree.tag assert exec_tag in tag_to_cls, \ f"Error: SCXML conversion: tag {exec_tag} isn't an executable entry." diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_root.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_root.py index e061d4a7..ae5066ff 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/scxml_root.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_root.py @@ -23,14 +23,16 @@ from xml.etree import ElementTree as ET from scxml_converter.scxml_entries import ( - BtInputPortDeclaration, BtOutputPortDeclaration, RosServiceClient, RosServiceServer, - RosTimeRate, RosTopicPublisher, RosTopicSubscriber, ScxmlBase, ScxmlDataModel, - ScxmlRosDeclarations, ScxmlRosDeclarationsContainer, ScxmlState) + BtInputPortDeclaration, BtOutputPortDeclaration, ScxmlBase, ScxmlDataModel, + ScxmlRosDeclarationsContainer, ScxmlState, RosActionThread) -from scxml_converter.scxml_entries.xml_utils import get_children_as_scxml +from scxml_converter.scxml_entries.scxml_ros_base import RosDeclaration from scxml_converter.scxml_entries.scxml_bt import BtPortDeclarations from scxml_converter.scxml_entries.bt_utils import BtPortsHandler +from scxml_converter.scxml_entries.xml_utils import ( + assert_xml_tag_ok, get_children_as_scxml, get_xml_argument) +from scxml_converter.scxml_entries.utils import is_non_empty_string class ScxmlRoot(ScxmlBase): @@ -44,43 +46,43 @@ def get_tag_name() -> str: def from_xml_tree(xml_tree: ET.Element) -> "ScxmlRoot": """Create a ScxmlRoot object from an XML tree.""" # --- Get the ElementTree objects - assert xml_tree.tag == ScxmlRoot.get_tag_name(), \ - f"Error: SCXML root: XML root tag {xml_tree.tag} is not {ScxmlRoot.get_tag_name()}." - assert "name" in xml_tree.attrib, \ - "Error: SCXML root: 'name' attribute not found in input xml." - assert "version" in xml_tree.attrib and xml_tree.attrib["version"] == "1.0", \ - "Error: SCXML root: 'version' attribute not found or invalid in input xml." + assert_xml_tag_ok(ScxmlRoot, xml_tree) + scxml_name = get_xml_argument(ScxmlRoot, xml_tree, "name") + scxml_version = get_xml_argument(ScxmlRoot, xml_tree, "version") + assert scxml_version == "1.0", \ + f"Error: SCXML root: expected version 1.0, found {scxml_version}." + scxml_init_state = get_xml_argument(ScxmlRoot, xml_tree, "initial") # Data Model - datamodel_elements = xml_tree.findall(ScxmlDataModel.get_tag_name()) - assert datamodel_elements is None or len(datamodel_elements) <= 1, \ + datamodel_elements = get_children_as_scxml(xml_tree, (ScxmlDataModel,)) + assert len(datamodel_elements) <= 1, \ f"Error: SCXML root: {len(datamodel_elements)} datamodels found, max 1 allowed." # ROS Declarations - ros_declarations: List[ScxmlRosDeclarations] = get_children_as_scxml( - xml_tree, get_args(ScxmlRosDeclarations)) + ros_declarations: List[RosDeclaration] = get_children_as_scxml( + xml_tree, RosDeclaration.__subclasses__()) # BT Declarations bt_port_declarations: List[BtPortDeclarations] = get_children_as_scxml( xml_tree, get_args(BtPortDeclarations)) + # Additional threads + additional_threads = get_children_as_scxml(xml_tree, (RosActionThread,)) # States - assert "initial" in xml_tree.attrib, \ - "Error: SCXML root: 'initial' attribute not found in input xml." - initial_state = xml_tree.attrib["initial"] - state_elements = xml_tree.findall(ScxmlState.get_tag_name()) - assert state_elements is not None and len(state_elements) > 0, \ - "Error: SCXML root: no state found in input xml." + scxml_states: List[ScxmlState] = get_children_as_scxml(xml_tree, (ScxmlState,)) + assert len(scxml_states) > 0, "Error: SCXML root: no state found in input xml." # --- Fill Data in the ScxmlRoot object - scxml_root = ScxmlRoot(xml_tree.attrib["name"]) + scxml_root = ScxmlRoot(scxml_name) # Data Model - if datamodel_elements is not None and len(datamodel_elements) > 0: - scxml_root.set_data_model(ScxmlDataModel.from_xml_tree(datamodel_elements[0])) + if len(datamodel_elements) > 0: + scxml_root.set_data_model(datamodel_elements[0]) # ROS Declarations scxml_root._ros_declarations = ros_declarations # BT Declarations for bt_port_declaration in bt_port_declarations: scxml_root.add_bt_port_declaration(bt_port_declaration) + # Additional threads + for scxml_thread in additional_threads: + scxml_root.add_action_thread(scxml_thread) # States - for state_element in state_elements: - scxml_state = ScxmlState.from_xml_tree(state_element) - is_initial = scxml_state.get_id() == initial_state + for scxml_state in scxml_states: + is_initial = scxml_state.get_id() == scxml_init_state scxml_root.add_state(scxml_state, initial=is_initial) return scxml_root @@ -106,8 +108,9 @@ def __init__(self, name: str): self._initial_state: Optional[str] = None self._states: List[ScxmlState] = [] self._data_model: Optional[ScxmlDataModel] = None - self._ros_declarations: List[ScxmlRosDeclarations] = [] + self._ros_declarations: List[RosDeclaration] = [] self._bt_ports_handler = BtPortsHandler() + self._additional_threads: List[RosActionThread] = [] def get_name(self) -> str: """Get the name of the automaton represented by this SCXML model.""" @@ -115,7 +118,7 @@ def get_name(self) -> str: def set_name(self, name: str) -> None: """Rename the automaton represented by this SCXML model.""" - assert isinstance(name, str) and len(name) > 0, "Error: SCXML root: invalid name." + assert is_non_empty_string(ScxmlRoot, "name", name) self._name = name def get_initial_state_id(self) -> str: @@ -151,12 +154,10 @@ def set_data_model(self, data_model: ScxmlDataModel): assert self._data_model is None, "Data model already set" self._data_model = data_model - def add_ros_declaration(self, ros_declaration: ScxmlRosDeclarations): - assert isinstance(ros_declaration, get_args(ScxmlRosDeclarations)), \ + def add_ros_declaration(self, ros_declaration: RosDeclaration): + assert isinstance(ros_declaration, RosDeclaration), \ "Error: SCXML root: invalid ROS declaration type." assert ros_declaration.check_validity(), "Error: SCXML root: invalid ROS declaration." - if self._ros_declarations is None: - self._ros_declarations = [] self._ros_declarations.append(ros_declaration) def add_bt_port_declaration(self, bt_port_decl: BtPortDeclarations): @@ -171,6 +172,11 @@ def add_bt_port_declaration(self, bt_port_decl: BtPortDeclarations): raise ValueError( f"Error: SCXML root: invalid BT port declaration type {type(bt_port_decl)}.") + def add_action_thread(self, action_thread: RosActionThread): + assert isinstance(action_thread, RosActionThread), \ + f"Error: SCXML root: invalid action thread type {type(action_thread)}." + self._additional_threads.append(action_thread) + def set_bt_port_value(self, port_name: str, port_value: str): """Set the value of an input port.""" self._bt_ports_handler.set_port_value(port_name, port_value) @@ -186,58 +192,36 @@ def update_bt_ports_values(self): self._data_model.update_bt_ports_values(self._bt_ports_handler) for ros_decl_scxml in self._ros_declarations: ros_decl_scxml.update_bt_ports_values(self._bt_ports_handler) + for scxml_thread in self._additional_threads: + scxml_thread.update_bt_ports_values(self._bt_ports_handler) for state in self._states: state.update_bt_ports_values(self._bt_ports_handler) def _generate_ros_declarations_helper(self) -> Optional[ScxmlRosDeclarationsContainer]: """Generate a HelperRosDeclarations object from the existing ROS declarations.""" ros_decl_container = ScxmlRosDeclarationsContainer(self._name) - if self._ros_declarations is not None: - for ros_declaration in self._ros_declarations: - if not (ros_declaration.check_validity() and - ros_declaration.check_valid_instantiation()): - return None - if isinstance(ros_declaration, RosTimeRate): - ros_decl_container.append_timer(ros_declaration.get_name(), - ros_declaration.get_rate()) - elif isinstance(ros_declaration, RosTopicSubscriber): - ros_decl_container.append_subscriber(ros_declaration.get_name(), - ros_declaration.get_topic_name(), - ros_declaration.get_topic_type()) - elif isinstance(ros_declaration, RosTopicPublisher): - ros_decl_container.append_publisher(ros_declaration.get_name(), - ros_declaration.get_topic_name(), - ros_declaration.get_topic_type()) - elif isinstance(ros_declaration, RosServiceServer): - ros_decl_container.append_service_server(ros_declaration.get_name(), - ros_declaration.get_service_name(), - ros_declaration.get_service_type()) - elif isinstance(ros_declaration, RosServiceClient): - ros_decl_container.append_service_client(ros_declaration.get_name(), - ros_declaration.get_service_name(), - ros_declaration.get_service_type()) - else: - raise ValueError("Error: SCXML root: invalid ROS declaration type.") + for ros_declaration in self._ros_declarations: + if not (ros_declaration.check_validity() and + ros_declaration.check_valid_instantiation()): + return None + ros_decl_container.append_ros_declaration(ros_declaration) return ros_decl_container def check_validity(self) -> bool: - valid_name = isinstance(self._name, str) and len(self._name) > 0 - valid_initial_state = self._initial_state is not None - valid_states = isinstance(self._states, list) and len(self._states) > 0 - if valid_states: - for state in self._states: - valid_states = isinstance(state, ScxmlState) and state.check_validity() - if not valid_states: - break + valid_name = is_non_empty_string(ScxmlRoot, "name", self._name) + valid_initial_state = is_non_empty_string(ScxmlRoot, "initial state", self._initial_state) valid_data_model = self._data_model is None or self._data_model.check_validity() - if not valid_name: - print("Error: SCXML root: name is not valid.") - if not valid_initial_state: - print("Error: SCXML root: no initial state set.") - if not valid_states: - print("Error: SCXML root: states are not valid.") + valid_states = all(isinstance(state, ScxmlState) and state.check_validity() + for state in self._states) + valid_threads = all(isinstance(scxml_thread, RosActionThread) and + scxml_thread.check_validity() for scxml_thread in + self._additional_threads) if not valid_data_model: print("Error: SCXML root: datamodel is not valid.") + if not valid_states: + print("Error: SCXML root: states are not valid.") + if not valid_threads: + print("Error: SCXML root: additional threads are not valid.") valid_ros = self._check_valid_ros_declarations() if not valid_ros: print("Error: SCXML root: ROS declarations are not valid.") @@ -251,36 +235,53 @@ def _check_valid_ros_declarations(self) -> bool: if ros_decl_container is None: return False # Check the ROS instantiations - for state in self._states: - if not state.check_valid_ros_instantiations(ros_decl_container): - return False + if not all(state.check_valid_ros_instantiations(ros_decl_container) + for state in self._states): + return False + if not all(scxml_thread.check_valid_ros_instantiations(ros_decl_container) + for scxml_thread in self._additional_threads): + return False return True def is_plain_scxml(self) -> bool: """Check whether there are ROS specific features or all entries are plain SCXML.""" assert self.check_validity(), "SCXML: found invalid root object." - # If this is a valid scxml object, checking the absence of declarations is enough - return self._ros_declarations is None or len(self._ros_declarations) == 0 + # If this is a valid scxml object, just check the absence of ROS and thread declarations + return len(self._ros_declarations) == 0 and len(self._additional_threads) == 0 - def to_plain_scxml_and_declarations(self) -> Tuple["ScxmlRoot", ScxmlRosDeclarationsContainer]: + def to_plain_scxml_and_declarations(self) -> Tuple[List["ScxmlRoot"], + ScxmlRosDeclarationsContainer]: """ Convert all internal ROS specific entries to plain SCXML. :return: A tuple with: - - a new ScxmlRoot object with all ROS specific entries converted to plain SCXML - - A list of timers with related rate in Hz + - a list of ScxmlRoot objects with all ROS specific entries converted to plain SCXML + - The Ros declarations contained in the original SCXML object """ if self.is_plain_scxml(): - return self, ScxmlRosDeclarationsContainer(self._name) + return [self], ScxmlRosDeclarationsContainer(self._name) + converted_scxmls: List[ScxmlRoot] = [] # Convert the ROS specific entries to plain SCXML - plain_root = ScxmlRoot(self._name) - plain_root._data_model = deepcopy(self._data_model) - plain_root._initial_state = self._initial_state + main_scxml = ScxmlRoot(self._name) + main_scxml._data_model = deepcopy(self._data_model) + main_scxml._initial_state = self._initial_state ros_declarations = self._generate_ros_declarations_helper() assert ros_declarations is not None, "Error: SCXML root: invalid ROS declarations." - plain_root._states = [state.as_plain_scxml(ros_declarations) for state in self._states] - assert plain_root.is_plain_scxml(), "SCXML root: conversion to plain SCXML failed." - return (plain_root, ros_declarations) + main_scxml._states = [state.as_plain_scxml(ros_declarations) for state in self._states] + converted_scxmls.append(main_scxml) + for scxml_thread in self._additional_threads: + converted_scxmls.extend(scxml_thread.as_plain_scxml(ros_declarations)) + for plain_scxml in converted_scxmls: + assert isinstance(plain_scxml, ScxmlRoot), \ + "Error: SCXML root: conversion to plain SCXML resulted in invalid object " \ + f"(expected ScxmlRoot, obtained {type(plain_scxml)}." + assert plain_scxml.check_validity(), \ + f"The SCXML root object {plain_scxml.get_name()} is not valid: " \ + "conversion to plain SCXML failed." + assert plain_scxml.is_plain_scxml(), \ + f"The SCXML root object {plain_scxml.get_name()} is not plain SCXML: " \ + "conversion to plain SCXML failed." + return (converted_scxmls, ros_declarations) def as_xml(self) -> ET.Element: assert self.check_validity(), "SCXML: found invalid root object." @@ -296,9 +297,10 @@ def as_xml(self) -> ET.Element: data_model_xml = self._data_model.as_xml() assert data_model_xml is not None, "Error: SCXML root: invalid data model." xml_root.append(data_model_xml) - if self._ros_declarations is not None: - for ros_declaration in self._ros_declarations: - xml_root.append(ros_declaration.as_xml()) + for ros_declaration in self._ros_declarations: + xml_root.append(ros_declaration.as_xml()) + for scxml_thread in self._additional_threads: + xml_root.append(scxml_thread.as_xml()) for state in self._states: xml_root.append(state.as_xml()) ET.indent(xml_root, " ") diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_action_client.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_action_client.py new file mode 100644 index 00000000..449f22c4 --- /dev/null +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_action_client.py @@ -0,0 +1,193 @@ +# Copyright (c) 2024 - for information on the respective copyright owner +# see the NOTICE file + +# 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. + +""" +Declaration of SCXML tags related to ROS Action Clients. + +Based loosely on https://design.ros2.org/articles/actions.html +""" + +from typing import List, Union, Type +from xml.etree import ElementTree as ET + +from scxml_converter.scxml_entries import ScxmlTransition, ScxmlRosDeclarationsContainer +from scxml_converter.scxml_entries.scxml_ros_base import RosDeclaration, RosCallback, RosTrigger + +from scxml_converter.scxml_entries.ros_utils import ( + is_action_type_known, generate_action_goal_req_event, + generate_action_goal_accepted_event, generate_action_goal_rejected_event, + generate_action_feedback_handle_event, generate_action_result_handle_event) +from scxml_converter.scxml_entries.xml_utils import assert_xml_tag_ok, get_xml_argument +from scxml_converter.scxml_entries.utils import is_non_empty_string + + +class RosActionClient(RosDeclaration): + """Object used in SCXML root to declare a new action client.""" + + @staticmethod + def get_tag_name() -> str: + return "ros_action_client" + + @staticmethod + def get_communication_interface() -> str: + return "action" + + def check_valid_interface_type(self) -> bool: + if not is_action_type_known(self._interface_type): + print(f"Error: SCXML RosActionServer: invalid action type {self._interface_type}.") + return False + return True + + +class RosActionSendGoal(RosTrigger): + """Object representing a ROS Action Goal (request, from the client side) in SCXML.""" + + @staticmethod + def get_tag_name() -> str: + return "ros_action_send_goal" + + @staticmethod + def get_declaration_type() -> Type[RosActionClient]: + return RosActionClient + + def check_interface_defined(self, ros_declarations: ScxmlRosDeclarationsContainer) -> bool: + return ros_declarations.is_action_client_defined(self._interface_name) + + def check_fields_validity(self, ros_declarations: ScxmlRosDeclarationsContainer) -> bool: + return ros_declarations.check_valid_action_goal_fields(self._interface_name, self._fields) + + def get_plain_scxml_event(self, ros_declarations: ScxmlRosDeclarationsContainer) -> str: + return generate_action_goal_req_event( + ros_declarations.get_action_client_info(self._interface_name)[0], + ros_declarations.get_automaton_name()) + + +class RosActionHandleGoalResponse(ScxmlTransition): + """ + SCXML object representing the handler of an action response upon a goal request. + + A server might accept or refuse a goal request, based on its internal state. + This handler is meant to handle both acceptance or refusal of a request. + Translating this to plain-SCXML, it results to two conditional transitions. + """ + + @staticmethod + def get_tag_name() -> str: + return "ros_action_handle_goal_response" + + @staticmethod + def from_xml_tree(xml_tree: ET.Element) -> "RosActionHandleGoalResponse": + """Create a RosServiceServer object from an XML tree.""" + assert_xml_tag_ok(RosActionHandleGoalResponse, xml_tree) + action_name = get_xml_argument(RosActionHandleGoalResponse, xml_tree, "name") + accept_target = get_xml_argument(RosActionHandleGoalResponse, xml_tree, "accept") + reject_target = get_xml_argument(RosActionHandleGoalResponse, xml_tree, "reject") + return RosActionHandleGoalResponse(action_name, accept_target, reject_target) + + def __init__(self, action_client: Union[str, RosActionClient], + accept_target: str, reject_target: str) -> None: + """ + Initialize a new RosActionHandleGoalResponse object. + + :param action_client: Action client used by this handler, or its name. + :param accept_target: State to transition to, in case of goal acceptance. + :param reject_target: State to transition to, in case of goal refusal. + """ + if isinstance(action_client, RosActionClient): + self._client_name = action_client.get_name() + else: + assert is_non_empty_string(RosActionHandleGoalResponse, "name", action_client) + self._client_name = action_client + self._accept_target = accept_target + self._reject_target = reject_target + assert self.check_validity(), "Error: SCXML RosActionHandleGoalResponse: invalid params." + + def check_validity(self) -> bool: + valid_name = is_non_empty_string(RosActionHandleGoalResponse, "name", self._client_name) + valid_accept = is_non_empty_string(RosActionHandleGoalResponse, "accept", + self._accept_target) + valid_reject = is_non_empty_string(RosActionHandleGoalResponse, "reject", + self._reject_target) + return valid_name and valid_accept and valid_reject + + def check_valid_ros_instantiations(self, + ros_declarations: ScxmlRosDeclarationsContainer) -> bool: + assert isinstance(ros_declarations, ScxmlRosDeclarationsContainer), \ + "Error: SCXML Service Handle Response: invalid ROS declarations container." + assert isinstance(ros_declarations, ScxmlRosDeclarationsContainer), \ + "Error: SCXML action goal request: invalid ROS declarations container." + if not ros_declarations.is_action_client_defined(self._client_name): + print("Error: SCXML action goal request: " + f"action client {self._client_name} not declared.") + return False + return True + + def as_plain_scxml(self, + ros_declarations: ScxmlRosDeclarationsContainer) -> List[ScxmlTransition]: + assert self.check_valid_ros_instantiations(ros_declarations), \ + "Error: SCXML service response handler: invalid ROS instantiations." + automaton_name = ros_declarations.get_automaton_name() + interface_name, _ = ros_declarations.get_action_client_info(self._client_name) + accept_event = generate_action_goal_accepted_event(interface_name, automaton_name) + reject_event = generate_action_goal_rejected_event(interface_name, automaton_name) + accept_transition = ScxmlTransition(self._accept_target, [accept_event]) + reject_transition = ScxmlTransition(self._reject_target, [reject_event]) + return [accept_transition, reject_transition] + + def as_xml(self) -> ET.Element: + assert self.check_validity(), "Error: SCXML Service Handle Response: invalid parameters." + return ET.Element(RosActionHandleGoalResponse.get_tag_name(), + {"name": self._client_name, + "accept": self._accept_target, "reject": self._reject_target}) + + +class RosActionHandleFeedback(RosCallback): + """SCXML object representing the handler of an action feedback.""" + + @staticmethod + def get_tag_name() -> str: + return "ros_action_handle_feedback" + + @staticmethod + def get_declaration_type() -> Type[RosActionClient]: + return RosActionClient + + def check_interface_defined(self, ros_declarations: ScxmlRosDeclarationsContainer) -> bool: + return ros_declarations.is_action_client_defined(self._interface_name) + + def get_plain_scxml_event(self, ros_declarations: ScxmlRosDeclarationsContainer) -> str: + return generate_action_feedback_handle_event( + ros_declarations.get_action_client_info(self._interface_name)[0], + ros_declarations.get_automaton_name()) + + +class RosActionHandleResult(RosCallback): + """SCXML object representing the handler of am action result for a service client.""" + + @staticmethod + def get_tag_name() -> str: + return "ros_action_handle_result" + + @staticmethod + def get_declaration_type() -> Type[RosActionClient]: + return RosActionClient + + def check_interface_defined(self, ros_declarations: ScxmlRosDeclarationsContainer) -> bool: + return ros_declarations.is_action_client_defined(self._interface_name) + + def get_plain_scxml_event(self, ros_declarations: ScxmlRosDeclarationsContainer) -> str: + return generate_action_result_handle_event( + ros_declarations.get_action_client_info(self._interface_name)[0], + ros_declarations.get_automaton_name()) diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_action_server.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_action_server.py new file mode 100644 index 00000000..0b9f4b9f --- /dev/null +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_action_server.py @@ -0,0 +1,277 @@ +# Copyright (c) 2024 - for information on the respective copyright owner +# see the NOTICE file + +# 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. + +""" +Declaration of SCXML tags related to ROS Action Clients. + +Based loosely on https://design.ros2.org/articles/actions.html +""" + +from typing import List, Union, Type +from xml.etree import ElementTree as ET + +from scxml_converter.scxml_entries import ( + RosField, ScxmlSend, ScxmlParam, ScxmlRosDeclarationsContainer) + +from scxml_converter.scxml_entries.scxml_ros_base import RosDeclaration, RosCallback, RosTrigger + +from scxml_converter.scxml_entries.ros_utils import ( + is_action_type_known, generate_action_goal_handle_event, + generate_action_goal_handle_accepted_event, generate_action_goal_handle_rejected_event, + generate_action_thread_execution_start_event, generate_action_feedback_event, + generate_action_result_event, generate_action_thread_free_event) +from scxml_converter.scxml_entries.xml_utils import ( + assert_xml_tag_ok, get_xml_argument, get_children_as_scxml) + + +class RosActionServer(RosDeclaration): + """Object used in SCXML root to declare a new action client.""" + + @staticmethod + def get_tag_name() -> str: + return "ros_action_server" + + @staticmethod + def get_communication_interface() -> str: + return "action" + + def check_valid_interface_type(self) -> bool: + if not is_action_type_known(self._interface_type): + print(f"Error: SCXML RosActionServer: invalid action type {self._interface_type}.") + return False + return True + + +class RosActionHandleGoalRequest(RosCallback): + """ + SCXML object representing the handler for a goal request. + + A server receives the request, containing the action goal fields and the goal_id. + The goal_id is set from the action handler, based on the client. + """ + + @staticmethod + def get_tag_name() -> str: + return "ros_action_handle_goal" + + @staticmethod + def get_declaration_type() -> Type[RosActionServer]: + return RosActionServer + + def check_interface_defined(self, ros_declarations: ScxmlRosDeclarationsContainer) -> bool: + return ros_declarations.is_action_server_defined(self._interface_name) + + def get_plain_scxml_event(self, ros_declarations: ScxmlRosDeclarationsContainer) -> str: + return generate_action_goal_handle_event( + ros_declarations.get_action_server_info(self._interface_name)[0]) + + +class RosActionAcceptGoal(RosTrigger): + """ + Object representing the SCXML ROS Event sent from the server when an action Goal is accepted. + """ + + @staticmethod + def get_tag_name() -> str: + return "ros_action_accept_goal" + + @staticmethod + def get_declaration_type() -> Type[RosActionServer]: + return RosActionServer + + def check_interface_defined(self, ros_declarations: ScxmlRosDeclarationsContainer) -> bool: + return ros_declarations.is_action_server_defined(self._interface_name) + + def check_fields_validity(self, _) -> bool: + # When accepting the goal, we send only the goal_id of the accepted goal + return len(self._fields) == 1 and self._fields[0].get_name() == "goal_id" + + def get_plain_scxml_event(self, ros_declarations: ScxmlRosDeclarationsContainer) -> str: + return generate_action_goal_handle_accepted_event( + ros_declarations.get_action_server_info(self._interface_name)[0]) + + def as_xml(self) -> ET.Element: + assert self.check_fields_validity(None), "Error: SCXML RosActionAcceptGoal: invalid fields." + return super().as_xml() + + +class RosActionRejectGoal(RosTrigger): + + """ + Object representing the SCXML ROS Event sent from the server when an action Goal is rejected. + """ + + @staticmethod + def get_tag_name() -> str: + return "ros_action_reject_goal" + + @staticmethod + def get_declaration_type() -> Type[RosActionServer]: + return RosActionServer + + def check_interface_defined(self, ros_declarations: ScxmlRosDeclarationsContainer) -> bool: + return ros_declarations.is_action_server_defined(self._interface_name) + + def check_fields_validity(self, _) -> bool: + # When accepting the goal, we send only the goal_id of the accepted goal + return len(self._fields) == 1 and self._fields[0].get_name() == "goal_id" + + def get_plain_scxml_event(self, ros_declarations: ScxmlRosDeclarationsContainer) -> str: + return generate_action_goal_handle_rejected_event( + ros_declarations.get_action_server_info(self._interface_name)[0]) + + def as_xml(self) -> ET.Element: + assert self.check_fields_validity(None), "Error: SCXML RosActionRejectGoal: invalid fields." + return super().as_xml() + + +class RosActionStartThread(RosTrigger): + """ + Object representing the request, from an action server, to start a new execute thread instance. + """ + + @staticmethod + def get_tag_name() -> str: + return "ros_action_start_thread" + + @staticmethod + def get_declaration_type() -> Type[RosActionServer]: + return RosActionServer + + @classmethod + def from_xml_tree(cls: Type[RosTrigger], xml_tree: ET.Element) -> "RosActionStartThread": + """Create a RosActionStartThread object from an XML tree.""" + assert_xml_tag_ok(cls, xml_tree) + action_name = get_xml_argument(cls, xml_tree, "name") + thread_id = get_xml_argument(cls, xml_tree, "thread_id") + fields: List[RosField] = get_children_as_scxml(xml_tree, (RosField,)) + return cls(action_name, thread_id, fields) + + def __init__(self, action_name: Union[str, RosActionServer], thread_id: str, + fields: List[RosField] = None) -> None: + """ + Initialize a new RosActionStartThread object. + + :param action_name: The ActionServer object used by the sender, or its name. + :param thread_id: The ID of the new thread instance. + :param fields: List of fields to be sent in the goal request. + """ + self._thread_id = thread_id + super().__init__(action_name, fields) + + def check_interface_defined(self, ros_declarations: ScxmlRosDeclarationsContainer) -> bool: + """Check if the action server has been declared.""" + return ros_declarations.is_action_server_defined(self._interface_name) + + def check_fields_validity(self, ros_declarations: ScxmlRosDeclarationsContainer) -> bool: + """Check if the goal_id and the request fields have been defined.""" + if not ros_declarations.check_valid_action_goal_fields(self._interface_name, self._fields, + has_goal_id=True): + print(f"Error: SCXML {self.__class__.__name__}: " + f"invalid fields in goal request {self._interface_name}.") + return False + return True + + def get_plain_scxml_event(self, ros_declarations: ScxmlRosDeclarationsContainer) -> str: + return generate_action_thread_execution_start_event( + ros_declarations.get_action_server_info(self._interface_name)[0]) + + def as_plain_scxml(self, ros_declarations: ScxmlRosDeclarationsContainer) -> ScxmlSend: + plain_send = super().as_plain_scxml(ros_declarations) + # Append the thread ID to the param list + plain_send.append_param(ScxmlParam("thread_id", expr=self._thread_id)) + return plain_send + + def as_xml(self) -> ET.Element: + xml_thread_start_req = super().as_xml() + xml_thread_start_req.set("thread_id", self._thread_id) + return xml_thread_start_req + + +class RosActionSendFeedback(RosTrigger): + """Object representing a ROS Action Goal (request, from the client side) in SCXML.""" + + @staticmethod + def get_tag_name() -> str: + return "ros_action_feedback" + + @staticmethod + def get_declaration_type() -> Type[RosActionServer]: + return RosActionServer + + def check_interface_defined(self, ros_declarations: ScxmlRosDeclarationsContainer) -> bool: + return ros_declarations.is_action_server_defined(self._interface_name) + + def check_fields_validity(self, ros_declarations: ScxmlRosDeclarationsContainer) -> bool: + """Check if the goal_id and the request fields have been defined.""" + if not ros_declarations.check_valid_action_feedback_fields(self._interface_name, + self._fields, has_goal_id=True): + print(f"Error: SCXML {self.__class__.__name__}: " + f"invalid fields in feedback request {self._interface_name}.") + return False + return True + + def get_plain_scxml_event(self, ros_declarations: ScxmlRosDeclarationsContainer) -> str: + return generate_action_feedback_event( + ros_declarations.get_action_server_info(self._interface_name)[0]) + + +class RosActionSendResult(RosTrigger): + """Object representing a ROS Action Goal (request, from the client side) in SCXML.""" + + @staticmethod + def get_tag_name() -> str: + return "ros_action_succeed" + + @staticmethod + def get_declaration_type() -> Type[RosActionServer]: + return RosActionServer + + def check_interface_defined(self, ros_declarations: ScxmlRosDeclarationsContainer) -> bool: + return ros_declarations.is_action_server_defined(self._interface_name) + + def check_fields_validity(self, ros_declarations: ScxmlRosDeclarationsContainer) -> bool: + """Check if the goal_id and the request fields have been defined.""" + if not ros_declarations.check_valid_action_result_fields(self._interface_name, + self._fields, has_goal_id=True): + print(f"Error: SCXML {self.__class__.__name__}: " + f"invalid fields in result request {self._interface_name}.") + return False + return True + + def get_plain_scxml_event(self, ros_declarations: ScxmlRosDeclarationsContainer) -> str: + return generate_action_result_event( + ros_declarations.get_action_server_info(self._interface_name)[0]) + + +class RosActionHandleThreadFree(RosCallback): + """ + Object representing the callback executed when an action thread report it is free. + """ + + @staticmethod + def get_tag_name() -> str: + return "ros_action_handle_thread_free" + + @staticmethod + def get_declaration_type() -> Type[RosActionServer]: + return RosActionServer + + def check_interface_defined(self, ros_declarations: ScxmlRosDeclarationsContainer) -> bool: + return ros_declarations.is_action_server_defined(self._interface_name) + + def get_plain_scxml_event(self, ros_declarations: ScxmlRosDeclarationsContainer) -> str: + return generate_action_thread_free_event( + ros_declarations.get_action_server_info(self._interface_name)[0]) diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_action_server_thread.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_action_server_thread.py new file mode 100644 index 00000000..cdbb01c2 --- /dev/null +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_action_server_thread.py @@ -0,0 +1,287 @@ +# Copyright (c) 2024 - for information on the respective copyright owner +# see the NOTICE file + +# 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. + +""" +Declaration of SCXML tags related to ROS Action Clients. + +Based loosely on https://design.ros2.org/articles/actions.html +""" + +from typing import List, Optional, Type, Union +from xml.etree import ElementTree as ET + +from scxml_converter.scxml_entries import ( + ScxmlBase, ScxmlDataModel, ScxmlExecutionBody, ScxmlState, ScxmlTransition, ScxmlParam, + ScxmlRosDeclarationsContainer, RosField) +from scxml_converter.scxml_entries.scxml_ros_action_server import RosActionServer +from scxml_converter.scxml_entries.scxml_ros_base import RosCallback, RosTrigger + +from scxml_converter.scxml_entries.bt_utils import BtPortsHandler +from scxml_converter.scxml_entries.ros_utils import ( + generate_action_thread_execution_start_event, generate_action_thread_free_event, + sanitize_ros_interface_name) +from scxml_converter.scxml_entries.xml_utils import ( + assert_xml_tag_ok, get_xml_argument, get_children_as_scxml) +from scxml_converter.scxml_entries.utils import is_non_empty_string + + +class RosActionThread(ScxmlBase): + """ + SCXML declaration of a set of threads for executing the action server code. + """ + + @staticmethod + def get_tag_name() -> str: + return "ros_action_thread" + + @staticmethod + def from_xml_tree(xml_tree: ET.Element) -> "RosActionThread": + """Create a RosActionThread object from an XML tree.""" + assert_xml_tag_ok(RosActionThread, xml_tree) + action_alias = get_xml_argument(RosActionThread, xml_tree, "name") + n_threads = get_xml_argument(RosActionThread, xml_tree, "n_threads") + n_threads = int(n_threads) + assert n_threads > 0, f"Error: SCXML Action Thread: invalid n. of threads ({n_threads})." + initial_state = get_xml_argument(RosActionThread, xml_tree, "initial") + datamodel = get_children_as_scxml(xml_tree, (ScxmlDataModel,)) + # ros declarations and bt ports are expected to be defined in the parent tag (scxml_root) + scxml_states: List[ScxmlState] = get_children_as_scxml(xml_tree, (ScxmlState,)) + assert len(datamodel) <= 1, "Error: SCXML Action Thread: multiple datamodels." + assert len(scxml_states) > 0, "Error: SCXML Action Thread: no states defined." + # The non-plain SCXML Action thread has the same name as the action + scxml_action_thread = RosActionThread(action_alias, n_threads) + # Fill the thread with the states and datamodel + if len(datamodel) == 1: + scxml_action_thread.set_data_model(datamodel[0]) + for scxml_state in scxml_states: + is_initial = scxml_state.get_id() == initial_state + scxml_action_thread.add_state(scxml_state, initial=is_initial) + return scxml_action_thread + + @staticmethod + def from_scxml_file(_): + raise RuntimeError("Error: Cannot load a RosActionThread directly from SCXML file.") + + def __init__(self, action_server: Union[str, RosActionServer], n_threads: int) -> None: + """ + Initialize a new RosActionThread object. + + :param action_server: ActionServer declaration, or its alias name. + :param n_threads: Max. n. of parallel action requests that can be handled. + """ + self._name: str = "" + if isinstance(action_server, RosActionServer): + self._name = action_server.get_name() + else: + assert is_non_empty_string(RosActionThread, "name", action_server) + self._name = action_server + self._n_threads: int = n_threads + self._initial_state: Optional[str] = None + self._data_model: Optional[ScxmlDataModel] = None + self._states: List[ScxmlState] = [] + + def add_state(self, state: ScxmlState, *, initial: bool = False): + """Append a state to the list of states. If initial is True, set it as the initial state.""" + self._states.append(state) + if initial: + assert self._initial_state is None, "Error: SCXML root: Initial state already set" + self._initial_state = state.get_id() + + def set_data_model(self, data_model: ScxmlDataModel): + assert self._data_model is None, "Data model already set" + self._data_model = data_model + + def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler) -> None: + if self._data_model is not None: + self._data_model.update_bt_ports_values(bt_ports_handler) + for state in self._states: + state.update_bt_ports_values(bt_ports_handler) + + def check_validity(self) -> bool: + valid_name = is_non_empty_string(RosActionThread, "name", self._name) + valid_n_threads = isinstance(self._n_threads, int) and self._n_threads > 0 + valid_initial_state = self._initial_state is not None + valid_data_model = self._data_model is None or self._data_model.check_validity() + valid_states = all(isinstance(state, ScxmlState) and state.check_validity() + for state in self._states) + if not valid_name: + return False + if not valid_n_threads: + print("Error: SCXML RosActionThread: " + f"{self._name} has invalid n_threads ({self._n_threads}).") + if not valid_initial_state: + print(f"Error: SCXML RosActionThread: {self._name} has no initial state.") + if not valid_data_model: + print(f"Error: SCXML RosActionThread: {self._name} has an invalid datamodel.") + if not valid_states: + print(f"Error: SCXML RosActionThread: {self._name} has invalid states.") + return valid_n_threads and valid_initial_state and valid_data_model and valid_states + + def check_valid_ros_instantiations(self, ros_decls: ScxmlRosDeclarationsContainer) -> bool: + assert isinstance(ros_decls, ScxmlRosDeclarationsContainer), \ + "Error: SCXML RosActionThread: Invalid ROS declarations container." + if not ros_decls.is_action_server_defined(self._name): + print(f"Error: SCXML RosActionThread: undeclared thread action server '{self._name}'.") + return False + if not all(state.check_valid_ros_instantiations(ros_decls) for state in self._states): + print("Error: SCXML RosActionThread: " + f"invalid ROS instantiation for states in thread '{self._name}'.") + return False + return True + + def as_plain_scxml(self, ros_declarations: ScxmlRosDeclarationsContainer) -> List[ScxmlBase]: + """ + Convert the ROS-specific entries to be plain SCXML. + + This returns a list of ScxmlRoot objects, using ScxmlBase to avoid circular dependencies. + """ + from scxml_converter.scxml_entries import ScxmlRoot + thread_instances: List[ScxmlRoot] = [] + action_name = sanitize_ros_interface_name( + ros_declarations.get_action_server_info(self._name)[0]) + for thread_idx in range(self._n_threads): + thread_name = f"{action_name}_thread_{thread_idx}" + plain_thread_instance = ScxmlRoot(thread_name) + plain_thread_instance.set_data_model(self._data_model) + for state in self._states: + initial_state = state.get_id() == self._initial_state + state.set_thread_id(thread_idx) + plain_thread_instance.add_state(state.as_plain_scxml(ros_declarations), + initial=initial_state) + assert plain_thread_instance.is_plain_scxml(), \ + "Error: SCXML RosActionThread: " \ + f"failed to generate a plain-SCXML instance from thread '{self._name}'" + thread_instances.append(plain_thread_instance) + return thread_instances + + def as_xml(self) -> ET.Element: + assert self.check_validity(), "SCXML: found invalid state object." + # TODO + pass + + +class RosActionHandleThreadStart(RosCallback): + """ + SCXML object receiving a trigger from the action server to start a thread. + + The selection of the thread is encoded in the event name. + The thread ID is set from the parent, via a dedicated method. + """ + + @staticmethod + def get_tag_name() -> str: + return "ros_action_handle_thread_start" + + @staticmethod + def get_declaration_type() -> Type[RosActionServer]: + return RosActionServer + + def __init__(self, server_alias: Union[str, RosActionServer], target_state: str, + condition: Optional[str] = None, exec_body: Optional[ScxmlExecutionBody] = None + ) -> None: + """ + Initialize a new RosActionHandleResult object. + + :param server_alias: Action Server used by this handler, or its name. + :param target_state: Target state to transition to after the start trigger is received. + :param condition: Condition to be met for the callback to be executed. Expected None. + :param exec_body: Execution body to be executed upon thread start (before transition). + """ + super().__init__(server_alias, target_state, condition, exec_body) + # The thread ID depends on the plain scxml instance, so it is set later + self._thread_id: Optional[int] = None + + def check_validity(self) -> bool: + # A condition for the thread start will be autogenerated. Avoid having more than one + if self._condition is not None: + print("Error: SCXML RosActionHandleThreadStart: no condition expected.") + return False + return super().check_validity() + + def check_interface_defined(self, ros_declarations: ScxmlRosDeclarationsContainer) -> bool: + """Check if the action server has been declared.""" + return ros_declarations.is_action_server_defined(self._interface_name) + + def set_thread_id(self, thread_id: int) -> None: + """Set the thread ID for this handler.""" + # The thread ID is expected to be overwritten every time a new thread is generated. + assert isinstance(thread_id, int) and thread_id >= 0, \ + f"Error: SCXML {self.__class__.__name__}: invalid thread ID ({thread_id})." + self._thread_id = thread_id + super().set_thread_id(thread_id) + + def get_plain_scxml_event(self, ros_declarations: ScxmlRosDeclarationsContainer) -> str: + return generate_action_thread_execution_start_event( + ros_declarations.get_action_server_info(self._interface_name)[0]) + + def as_plain_scxml(self, ros_declarations: ScxmlRosDeclarationsContainer) -> ScxmlTransition: + assert self._thread_id is not None, \ + f"Error: SCXML {self.__class__.__name__}: thread ID not set." + # Append a condition checking the thread ID matches the request + self._condition = "_req.thread_id == " + str(self._thread_id) + return super().as_plain_scxml(ros_declarations) + + +class RosActionThreadFree(RosTrigger): + """ + SCXML object receiving a trigger from the action server to stop a thread. + + The selection of the thread is encoded in the event name. + The thread ID is set from the parent, via a dedicated method. + """ + + @staticmethod + def get_tag_name() -> str: + return "ros_action_thread_free" + + @staticmethod + def get_declaration_type() -> Type[RosActionServer]: + return RosActionServer + + def __init__(self, action_name: Union[str, RosActionServer], + fields: Optional[List[RosField]] = None) -> None: + super().__init__(action_name, fields) + self._thread_id: Optional[int] = None + + def check_validity(self) -> bool: + if len(self._fields) > 0: + print("Error: SCXML RosActionThreadFree: no fields expected.") + return False + return super().check_validity() + + def check_interface_defined(self, ros_declarations: ScxmlRosDeclarationsContainer) -> bool: + return ros_declarations.is_action_server_defined(self._interface_name) + + def check_fields_validity(self, _) -> bool: + return len(self._fields) == 0 + + def set_thread_id(self, thread_id: int) -> None: + """Set the thread ID for this handler.""" + # The thread ID is expected to be overwritten every time a new thread is generated. + assert isinstance(thread_id, int) and thread_id >= 0, \ + f"Error: SCXML {self.__class__.__name__}: invalid thread ID ({thread_id})." + self._thread_id = thread_id + + def get_plain_scxml_event(self, ros_declarations: ScxmlRosDeclarationsContainer) -> str: + return generate_action_thread_free_event( + ros_declarations.get_action_server_info(self._interface_name)[0]) + + def as_plain_scxml(self, ros_declarations: ScxmlRosDeclarationsContainer) -> ScxmlTransition: + assert self._thread_id is not None, \ + f"Error: SCXML {self.__class__.__name__}: thread ID not set." + plain_trigger = super().as_plain_scxml(ros_declarations) + # Add the thread id to the (empty) param list + plain_trigger.append_param(ScxmlParam("thread_id", expr=str(self._thread_id))) + return plain_trigger diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_base.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_base.py new file mode 100644 index 00000000..aa0b51e8 --- /dev/null +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_base.py @@ -0,0 +1,344 @@ +# Copyright (c) 2024 - for information on the respective copyright owner +# see the NOTICE file + +# 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. + +"""Collection of SCXML ROS Base classes to derive from.""" + +from typing import Optional, List, Union, Type + +from scxml_converter.scxml_entries import ( + ScxmlBase, ScxmlTransition, ScxmlSend, ScxmlExecutionBody, RosField, BtGetValueInputPort, + ScxmlRosDeclarationsContainer, execution_body_from_xml, as_plain_execution_body, + valid_execution_body) + +from scxml_converter.scxml_entries.bt_utils import BtPortsHandler +from scxml_converter.scxml_entries.xml_utils import ( + assert_xml_tag_ok, get_xml_argument, read_value_from_xml_arg_or_child) + +from scxml_converter.scxml_entries.utils import is_non_empty_string + +from xml.etree import ElementTree as ET + + +class RosDeclaration(ScxmlBase): + """Base class for ROS declarations in SCXML.""" + + @classmethod + def get_tag_name(cls) -> str: + """The xml tag related to the ROS declaration.""" + raise NotImplementedError(f"{cls.__name__} doesn't implement get_tag_name.") + + @classmethod + def get_communication_interface(cls) -> str: + """ + Which communication interface is used by the ROS declaration. + + Expected values: "service", "action" + """ + raise NotImplementedError(f"{cls.__name__} doesn't implement get_communication_interface.") + + @classmethod + def get_xml_arg_interface_name(cls) -> str: + return f"{cls.get_communication_interface()}_name" + + @classmethod + def from_xml_tree(cls: Type['RosDeclaration'], xml_tree: ET.Element) -> 'RosDeclaration': + """Create an instance of the class from an XML tree.""" + assert_xml_tag_ok(cls, xml_tree) + interface_name = read_value_from_xml_arg_or_child( + cls, xml_tree, cls.get_xml_arg_interface_name(), (BtGetValueInputPort, str)) + interface_type = get_xml_argument(cls, xml_tree, "type") + interface_alias = get_xml_argument(cls, xml_tree, "name", none_allowed=True) + return cls(interface_name, interface_type, interface_alias) + + def __init__(self, interface_name: Union[str, BtGetValueInputPort], interface_type: str, + interface_alias: Optional[str] = None): + """ + Constructor of ROS declaration. + + :param interface_name: Comm. interface used by the declared ROS interface. + :param interface_type: ROS type used for communication. + :param interface_alias: Alias for the defined interface, used for ref. by the the handlers + """ + self._interface_name = interface_name + self._interface_type = interface_type + self._interface_alias = interface_alias + assert isinstance(interface_name, (str, BtGetValueInputPort)), \ + f"Error: SCXML {self.get_tag_name()}: " \ + f"invalid type of interface_name {type(interface_name)}." + if self._interface_alias is None: + assert is_non_empty_string(self.__class__, "interface_name", self._interface_name), \ + f"Error: SCXML {self.__class__.__name__}: " \ + "an alias name is required for dynamic ROS interfaces." + self._interface_alias = interface_name + + def get_interface_name(self) -> str: + """Get the name of the ROS comm. interface.""" + return self._interface_name + + def get_interface_type(self) -> str: + """Get the ROS type used for communication.""" + return self._interface_type + + def get_name(self) -> str: + """Get the alias name of the ROS interface.""" + return self._interface_alias + + def check_valid_interface_type(self) -> bool: + return NotImplementedError( + f"{self.__class__.__name__} doesn't implement check_valid_interface_type.") + + def check_validity(self) -> bool: + valid_alias = is_non_empty_string(self.__class__, "name", self._interface_alias) + valid_action_name = isinstance(self._interface_name, BtGetValueInputPort) or \ + is_non_empty_string(self.__class__, "interface_name", self._interface_name) + valid_action_type = self.check_valid_interface_type() + return valid_alias and valid_action_name and valid_action_type + + def check_valid_instantiation(self) -> bool: + """Check if the interface name is still undefined (i.e. from BT ports).""" + return is_non_empty_string(self.__class__, "interface_name", self._interface_name) + + def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler) -> None: + """Update the values of potential entries making use of BT ports.""" + if isinstance(self._interface_name, BtGetValueInputPort): + self._interface_name = \ + bt_ports_handler.get_in_port_value(self._interface_name.get_key_name()) + + def as_plain_scxml(self, _) -> ScxmlBase: + # This is discarded in the to_plain_scxml_and_declarations method from ScxmlRoot + raise RuntimeError( + f"Error: SCXML {self.__class__.__name__} cannot be converted to plain SCXML.") + + def as_xml(self) -> ET.Element: + assert self.check_validity(), f"Error: SCXML {self.__class__.__name__}: invalid parameters." + xml_declaration = ET.Element(self.get_tag_name(), + {"name": self._interface_alias, + self.get_xml_arg_interface_name(): self._interface_name, + "type": self._interface_type}) + return xml_declaration + + +class RosCallback(ScxmlTransition): + """Base class for ROS callbacks in SCXML.""" + + @classmethod + def get_tag_name(cls) -> str: + """XML tag name for the ROS callback type.""" + raise NotImplementedError(f"{cls.__name__} doesn't implement get_tag_name.") + + @classmethod + def get_declaration_type(cls) -> Type[RosDeclaration]: + """ + Get the type of ROS declaration related to the callback. + + Examples: RosSubscriber, RosPublisher, ... + """ + raise NotImplementedError(f"{cls.__name__} doesn't implement get_declaration_type.") + + @classmethod + def from_xml_tree(cls: Type['RosCallback'], xml_tree: ET.Element) -> 'RosCallback': + """Create an instance of the class from an XML tree.""" + assert_xml_tag_ok(cls, xml_tree) + interface_name = get_xml_argument(cls, xml_tree, "name") + target_state = get_xml_argument(cls, xml_tree, "target") + condition = get_xml_argument(cls, xml_tree, "cond", none_allowed=True) + exec_body = execution_body_from_xml(xml_tree) + return cls(interface_name, target_state, condition, exec_body) + + def __init__(self, interface_decl: Union[str, RosDeclaration], target_state: str, + condition: Optional[str] = None, exec_body: Optional[ScxmlExecutionBody] = None + ) -> None: + """ + Constructor of ROS callback. + + :param interface_decl: ROS interface declaration to be used in the callback, or its name. + :param target_state: Name of the state to transition to after the callback. + :param condition: Condition to be met for the callback to be executed. + :param exec_body: Executable body of the callback. + """ + if exec_body is None: + exec_body: ScxmlExecutionBody = [] + self._interface_name: str = "" + if isinstance(interface_decl, self.get_declaration_type()): + self._interface_name = interface_decl.get_name() + else: + assert is_non_empty_string(self.__class__, "name", interface_decl) + self._interface_name = interface_decl + self._target: str = target_state + self._condition: Optional[str] = condition + self._body: ScxmlExecutionBody = exec_body + assert self.check_validity(), \ + f"Error: SCXML {self.__class__.__name__}: invalid parameters." + + def check_validity(self) -> bool: + valid_name = is_non_empty_string(self.__class__, "name", self._interface_name) + valid_target = is_non_empty_string(self.__class__, "target", self._target) + valid_condition = self._condition is None or \ + is_non_empty_string(self.__class__, "cond", self._condition) + valid_body = self._body is None or valid_execution_body(self._body) + if not valid_body: + print(f"Error: SCXML {self.__class__.__name__}: invalid entries in executable body.") + return valid_name and valid_target and valid_condition and valid_body + + def check_interface_defined(self, ros_declarations: ScxmlRosDeclarationsContainer) -> bool: + """Check if the ROS interface used in the callback exists.""" + raise NotImplementedError( + f"{self.__class__.__name__} doesn't implement check_interface_defined.") + + def get_plain_scxml_event(self, ros_declarations: ScxmlRosDeclarationsContainer) -> str: + """Translate the ROS interface name to a plain scxml event.""" + raise NotImplementedError( + f"{self.__class__.__name__} doesn't implement get_plain_scxml_event.") + + def check_valid_ros_instantiations(self, + ros_declarations: ScxmlRosDeclarationsContainer) -> bool: + """Check if the ROS entries in the callback are correctly defined.""" + assert isinstance(ros_declarations, ScxmlRosDeclarationsContainer), \ + f"Error: SCXML {self.__class__.__name__}: invalid type of ROS declarations container." + if not self.check_interface_defined(ros_declarations): + print(f"Error: SCXML {self.__class__.__name__}: " + f"undefined ROS interface {self._interface_name}.") + return False + valid_body = super().check_valid_ros_instantiations(ros_declarations) + if not valid_body: + print(f"Error: SCXML {self.__class__.__name__}: " + f"body of {self._interface_name} has invalid ROS instantiations.") + return valid_body + + def as_plain_scxml(self, ros_declarations: ScxmlRosDeclarationsContainer) -> ScxmlTransition: + assert self.check_valid_ros_instantiations(ros_declarations), \ + f"Error: SCXML {self.__class__.__name__}: invalid ROS instantiations." + event_name = self.get_plain_scxml_event(ros_declarations) + target = self._target + condition = self._condition + body = as_plain_execution_body(self._body, ros_declarations) + return ScxmlTransition(target, [event_name], condition, body) + + def as_xml(self) -> ET.Element: + """Convert the ROS callback to an XML element.""" + assert self.check_validity(), f"Error: SCXML {self.__class__.__name__}: invalid parameters." + xml_callback = ET.Element(self.get_tag_name(), + {"name": self._interface_name, "target": self._target}) + if self._condition is not None: + xml_callback.set("cond", self._condition) + for body_elem in self._body: + xml_callback.append(body_elem.as_xml()) + return xml_callback + + +class RosTrigger(ScxmlSend): + """Base class for ROS triggers in SCXML.""" + + @classmethod + def get_tag_name(cls) -> str: + """XML tag name for the ROS trigger type.""" + raise NotImplementedError(f"{cls.__name__} doesn't implement get_tag_name.") + + @classmethod + def get_declaration_type(cls) -> Type[RosDeclaration]: + """ + Get the type of ROS declaration related to the trigger. + + Examples: RosServiceClient, RosActionClient, ... + """ + raise NotImplementedError(f"{cls.__name__} doesn't implement get_declaration_type.") + + @classmethod + def from_xml_tree(cls: Type['RosTrigger'], xml_tree: ET.Element) -> 'RosTrigger': + """Create an instance of the class from an XML tree.""" + assert_xml_tag_ok(cls, xml_tree) + interface_name = get_xml_argument(cls, xml_tree, "name") + fields = [RosField.from_xml_tree(field) for field in xml_tree] + return cls(interface_name, fields) + + def __init__(self, interface_decl: Union[str, RosDeclaration], + fields: Optional[List[RosField]] = None) -> None: + """ + Constructor of a generic ROS trigger. + + :param interface_decl: ROS interface declaration to be used in the trigger, or its name. + :param fields: Name of fields that are sent together with the trigger. + """ + if fields is None: + fields = [] + self._interface_name: str = "" + if isinstance(interface_decl, self.get_declaration_type()): + self._interface_name = interface_decl.get_name() + else: + assert is_non_empty_string(self.__class__, "name", interface_decl) + self._interface_name = interface_decl + self._fields: List[RosField] = fields + assert self.check_validity(), f"Error: SCXML {self.__class__.__name__}: invalid parameters." + + def append_field(self, field: RosField) -> None: + assert isinstance(field, RosField), "Error: SCXML topic publish: invalid field." + self._fields.append(field) + + def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler): + """Update the values of potential entries making use of BT ports.""" + for field in self._fields: + field.update_bt_ports_values(bt_ports_handler) + + def check_validity(self) -> bool: + valid_name = is_non_empty_string(self.__class__, "name", self._interface_name) + valid_fields = all(isinstance(field, RosField) for field in self._fields) + if not valid_fields: + print(f"Error: SCXML {self.__class__.__name__}: " + f"invalid entries in fields of {self._interface_name}.") + return valid_name and valid_fields + + def check_interface_defined(self, ros_declarations: ScxmlRosDeclarationsContainer) -> bool: + """Check if the ROS interface used in the trigger exists.""" + raise NotImplementedError( + f"{self.__class__.__name__} doesn't implement check_interface_defined.") + + def check_fields_validity(self, ros_declarations: ScxmlRosDeclarationsContainer) -> bool: + """Check if all fields are assigned, given the ROS interface definition.""" + raise NotImplementedError( + f"{self.__class__.__name__} doesn't implement check_fields_validity.") + + def get_plain_scxml_event(self, ros_declarations: ScxmlRosDeclarationsContainer) -> str: + """Translate the ROS interface name to a plain scxml event.""" + raise NotImplementedError( + f"{self.__class__.__name__} doesn't implement get_plain_scxml_event.") + + def check_valid_ros_instantiations(self, + ros_declarations: ScxmlRosDeclarationsContainer) -> bool: + """Check if the ROS entries in the trigger are correctly defined.""" + assert isinstance(ros_declarations, ScxmlRosDeclarationsContainer), \ + f"Error: SCXML {self.__class__.__name__}: invalid type of ROS declarations container." + if not self.check_interface_defined(ros_declarations): + print(f"Error: SCXML {self.__class__.__name__}: " + f"undefined ROS interface {self._interface_name}.") + return False + if not self.check_fields_validity(ros_declarations): + print(f"Error: SCXML {self.__class__.__name__}: " + f"invalid fields for {self._interface_name}.") + return False + return True + + def as_plain_scxml(self, ros_declarations: ScxmlRosDeclarationsContainer) -> ScxmlSend: + assert self.check_valid_ros_instantiations(ros_declarations), \ + f"Error: SCXML {self.__class__.__name__}: invalid ROS instantiations." + event_name = self.get_plain_scxml_event(ros_declarations) + params = [field.as_plain_scxml(ros_declarations) for field in self._fields] + return ScxmlSend(event_name, params) + + def as_xml(self) -> ET.Element: + assert self.check_validity(), f"Error: SCXML {self.__class__.__name__}: invalid parameters." + xml_trigger = ET.Element(self.get_tag_name(), {"name": self._interface_name}) + for field in self._fields: + xml_trigger.append(field.as_xml()) + return xml_trigger diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_entries.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_entries.py deleted file mode 100644 index b9a4dfb3..00000000 --- a/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_entries.py +++ /dev/null @@ -1,38 +0,0 @@ -# Copyright (c) 2024 - for information on the respective copyright owner -# see the NOTICE file - -# 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. - -"""Declaration of ROS-Specific SCXML tags extensions.""" - -from typing import Union - -from scxml_converter.scxml_entries import (RosRateCallback, RosServiceClient, - RosServiceHandleRequest, - RosServiceHandleResponse, - RosServiceSendRequest, - RosServiceSendResponse, - RosServiceServer, RosTimeRate, - RosTopicCallback, RosTopicPublish, - RosTopicPublisher, - RosTopicSubscriber) - -ScxmlRosDeclarations = Union[RosTimeRate, RosTopicPublisher, RosTopicSubscriber, - RosServiceServer, RosServiceClient] - -# List of Ros entries inheriting from ScxmlTransition -ScxmlRosTransitions = (RosServiceHandleRequest, RosServiceHandleResponse, - RosTopicCallback, RosRateCallback) - -# List of Ros entries inheriting from ScxmlSend -ScxmlRosSends = (RosServiceSendRequest, RosServiceSendResponse, RosTopicPublish) diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_service.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_service.py index a1f65fbd..d5d291d9 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_service.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_service.py @@ -20,24 +20,21 @@ https://docs.ros.org/en/iron/Tutorials/Beginner-CLI-Tools/Understanding-ROS2-Services/Understanding-ROS2-Services.html """ -from typing import List, Optional, Union +from typing import List, Optional, Type from xml.etree import ElementTree as ET from scxml_converter.scxml_entries import ( - RosField, ScxmlBase, ScxmlExecutionBody, ScxmlSend, ScxmlTransition, BtGetValueInputPort, - as_plain_execution_body, execution_body_from_xml, valid_execution_body) + RosField, ScxmlRosDeclarationsContainer, execution_body_from_xml) + +from scxml_converter.scxml_entries.scxml_ros_base import RosDeclaration, RosCallback, RosTrigger -from scxml_converter.scxml_entries.bt_utils import BtPortsHandler from scxml_converter.scxml_entries.ros_utils import ( - ScxmlRosDeclarationsContainer, generate_srv_request_event, - generate_srv_response_event, generate_srv_server_request_event, + generate_srv_request_event, generate_srv_response_event, generate_srv_server_request_event, generate_srv_server_response_event, is_srv_type_known) -from scxml_converter.scxml_entries.xml_utils import ( - assert_xml_tag_ok, get_xml_argument, read_value_from_xml_child) -from scxml_converter.scxml_entries.utils import is_non_empty_string +from scxml_converter.scxml_entries.xml_utils import (assert_xml_tag_ok, get_xml_argument) -class RosServiceServer(ScxmlBase): +class RosServiceServer(RosDeclaration): """Object used in SCXML root to declare a new service server.""" @staticmethod @@ -45,80 +42,17 @@ def get_tag_name() -> str: return "ros_service_server" @staticmethod - def from_xml_tree(xml_tree: ET.Element) -> "RosServiceServer": - """Create a RosServiceServer object from an XML tree.""" - assert_xml_tag_ok(RosServiceServer, xml_tree) - service_name = get_xml_argument( - RosServiceServer, xml_tree, "service_name", none_allowed=True) - service_type = get_xml_argument(RosServiceServer, xml_tree, "type") - service_alias = get_xml_argument( - RosServiceServer, xml_tree, "name", none_allowed=True) - if service_name is None: - service_name = read_value_from_xml_child(xml_tree, "service_name", - (BtGetValueInputPort, str)) - return RosServiceServer(service_name, service_type, service_alias) - - def __init__(self, srv_name: Union[str, BtGetValueInputPort], srv_type: str, - srv_alias: Optional[str] = None) -> None: - """ - Initialize a new RosServiceServer object. - - :param srv_name: Service name used by the service for communication. - :param srv_type: ROS type of the service. - :param srv_alias: Alias for the service server, for the handler to reference to it - """ - self._srv_name = srv_name - self._srv_type = srv_type - self._srv_alias = srv_alias - assert isinstance(srv_name, (str, BtGetValueInputPort)), \ - "Error: SCXML Service Server: invalid service name." - if self._srv_alias is None: - assert is_non_empty_string(RosServiceServer, "service_name", self._srv_name), \ - "Error: SCXML Service Server: an alias name is required for dynamic service names." - self._srv_alias = srv_name - - def get_service_name(self) -> str: - """Get the name of the service.""" - return self._srv_name - - def get_service_type(self) -> str: - """Get the type of the service.""" - return self._srv_type - - def get_name(self) -> str: - """Get the alias of the service server.""" - return self._srv_alias - - def check_validity(self) -> bool: - valid_name = isinstance(self._srv_name, str) and len(self._srv_name) > 0 - valid_type = is_srv_type_known(self._srv_type) - if not valid_name: - print("Error: SCXML Service Server: service name is not valid.") - if not valid_type: - print("Error: SCXML Service Server: service type is not valid.") - return valid_name and valid_type - - def check_valid_instantiation(self) -> bool: - """Check if the service server has undefined entries (i.e. from BT ports).""" - return is_non_empty_string(RosServiceServer, "service_name", self._srv_name) - - def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler) -> None: - """Update the values of potential entries making use of BT ports.""" - pass - - def as_plain_scxml(self, _) -> ScxmlBase: - # This is discarded in the to_plain_scxml_and_declarations method from ScxmlRoot - raise RuntimeError("Error: SCXML ROS declarations cannot be converted to plain SCXML.") - - def as_xml(self) -> ET.Element: - assert self.check_validity(), "Error: SCXML Service Server: invalid parameters." - xml_srv_server = ET.Element( - RosServiceServer.get_tag_name(), - {"name": self._srv_alias, "service_name": self._srv_name, "type": self._srv_type}) - return xml_srv_server - - -class RosServiceClient(ScxmlBase): + def get_communication_interface() -> str: + return "service" + + def check_valid_interface_type(self) -> bool: + if not is_srv_type_known(self._interface_type): + print("Error: SCXML RosServiceServer: service type is not valid.") + return False + return True + + +class RosServiceClient(RosDeclaration): """Object used in SCXML root to declare a new service client.""" @staticmethod @@ -126,89 +60,30 @@ def get_tag_name() -> str: return "ros_service_client" @staticmethod - def from_xml_tree(xml_tree: ET.Element) -> "RosServiceClient": - """Create a RosServiceClient object from an XML tree.""" - assert_xml_tag_ok(RosServiceClient, xml_tree) - service_name = get_xml_argument( - RosServiceClient, xml_tree, "service_name", none_allowed=True) - service_type = get_xml_argument(RosServiceClient, xml_tree, "type") - service_alias = get_xml_argument( - RosServiceClient, xml_tree, "name", none_allowed=True) - if service_name is None: - service_name = read_value_from_xml_child(xml_tree, "service_name", - (BtGetValueInputPort, str)) - return RosServiceClient(service_name, service_type, service_alias) - - def __init__(self, srv_name: Union[str, BtGetValueInputPort], srv_type: str, - srv_alias: Optional[str] = None) -> None: - """ - Initialize a new RosServiceClient object. - - :param srv_name: Topic used by the service. - :param srv_type: ROS type of the service. - :param srv_alias: Alias for the service client, for the handler to reference to it - """ - self._srv_name = srv_name - self._srv_type = srv_type - self._srv_alias = srv_alias - assert isinstance(srv_name, (str, BtGetValueInputPort)), \ - "Error: SCXML Service Client: invalid service name." - if self._srv_alias is None: - assert is_non_empty_string(RosServiceClient, "service_name", self._srv_name), \ - "Error: SCXML Service Client: an alias name is required for dynamic service names." - self._srv_alias = srv_name - - def get_service_name(self) -> str: - """Get the name of the service.""" - return self._srv_name - - def get_service_type(self) -> str: - """Get the type of the service.""" - return self._srv_type - - def get_name(self) -> str: - """Get the alias of the service client.""" - return self._srv_alias - - def check_validity(self) -> bool: - valid_name = isinstance(self._srv_name, str) and len(self._srv_name) > 0 - valid_type = is_srv_type_known(self._srv_type) - if not valid_name: - print("Error: SCXML Service Client: service name is not valid.") - if not valid_type: - print("Error: SCXML Service Client: service type is not valid.") - return valid_name and valid_type - - def check_valid_instantiation(self) -> bool: - """Check if the topic publisher has undefined entries (i.e. from BT ports).""" - return is_non_empty_string(RosServiceClient, "service_name", self._srv_name) - - def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler) -> None: - """Update the values of potential entries making use of BT ports.""" - pass - - def as_plain_scxml(self, _) -> ScxmlBase: - # This is discarded in the to_plain_scxml_and_declarations method from ScxmlRoot - raise RuntimeError("Error: SCXML ROS declarations cannot be converted to plain SCXML.") - - def as_xml(self) -> ET.Element: - assert self.check_validity(), "Error: SCXML Service Client: invalid parameters." - xml_srv_server = ET.Element( - RosServiceClient.get_tag_name(), - {"name": self._srv_alias, "service_name": self._srv_name, "type": self._srv_type}) - return xml_srv_server - - -class RosServiceSendRequest(ScxmlSend): + def get_communication_interface() -> str: + return "service" + + def check_valid_interface_type(self) -> bool: + if not is_srv_type_known(self._interface_type): + print("Error: SCXML RosServiceClient: service type is not valid.") + return False + return True + + +class RosServiceSendRequest(RosTrigger): """Object representing a ROS service request (from the client side) in SCXML.""" @staticmethod def get_tag_name() -> str: return "ros_service_send_request" + @staticmethod + def get_declaration_type() -> Type[RosServiceClient]: + return RosServiceClient + @staticmethod def from_xml_tree(xml_tree: ET.Element) -> "RosServiceSendRequest": - """Create a RosServiceServer object from an XML tree.""" + """Create a RosServiceSendRequest object from an XML tree.""" assert_xml_tag_ok(RosServiceSendRequest, xml_tree) srv_name = get_xml_argument(RosServiceSendRequest, xml_tree, "name", none_allowed=True) if srv_name is None: @@ -220,81 +95,32 @@ def from_xml_tree(xml_tree: ET.Element) -> "RosServiceSendRequest": fields.append(RosField.from_xml_tree(field_xml)) return RosServiceSendRequest(srv_name, fields) - def __init__(self, - service_decl: Union[str, RosServiceClient], - fields: List[RosField] = None) -> None: - """ - Initialize a new RosServiceSendRequest object. - - :param service_decl: Name of the service of Scxml obj. of Service Client declaration. - :param fields: List of fields to be sent in the request. - """ - if isinstance(service_decl, RosServiceClient): - self._srv_name = service_decl.get_name() - else: - # Used for generating ROS entries from xml file - assert isinstance(service_decl, str), \ - "Error: SCXML Service Send Request: invalid service name." - self._srv_name = service_decl - if fields is None: - fields = [] - self._fields = fields - assert self.check_validity(), "Error: SCXML Service Send Request: invalid parameters." - - def check_validity(self) -> bool: - valid_name = isinstance(self._srv_name, str) and len(self._srv_name) > 0 - valid_fields = self._fields is None or \ - all([isinstance(field, RosField) and field.check_validity() for field in self._fields]) - if not valid_name: - print("Error: SCXML service request: service name is not valid.") - if not valid_fields: - print("Error: SCXML service request: fields are not valid.") - return valid_name and valid_fields - - def check_valid_ros_instantiations(self, - ros_declarations: ScxmlRosDeclarationsContainer) -> bool: - """Check if the ros instantiations have been declared.""" - assert isinstance(ros_declarations, ScxmlRosDeclarationsContainer), \ - "Error: SCXML service request: invalid ROS declarations container." - srv_client_declared = ros_declarations.is_service_client_defined(self._srv_name) - if not srv_client_declared: - print(f"Error: SCXML service request: srv client {self._srv_name} not declared.") - return False - valid_fields = ros_declarations.check_valid_srv_req_fields(self._srv_name, self._fields) - if not valid_fields: - print("Error: SCXML service request: invalid fields in request.") - return False - return True + def check_interface_defined(self, ros_declarations: ScxmlRosDeclarationsContainer) -> bool: + return ros_declarations.is_service_client_defined(self._interface_name) + + def check_fields_validity(self, ros_declarations: ScxmlRosDeclarationsContainer) -> bool: + return ros_declarations.check_valid_srv_req_fields(self._interface_name, self._fields) + + def get_plain_scxml_event(self, ros_declarations: ScxmlRosDeclarationsContainer) -> str: + return generate_srv_request_event( + ros_declarations.get_service_client_info(self._interface_name)[0], + ros_declarations.get_automaton_name()) - def as_plain_scxml(self, ros_declarations: ScxmlRosDeclarationsContainer) -> ScxmlSend: - assert self.check_valid_ros_instantiations(ros_declarations), \ - "Error: SCXML service request: invalid ROS instantiations." - automaton_name = ros_declarations.get_automaton_name() - srv_interface, _ = ros_declarations.get_service_client_info(self._srv_name) - event_name = generate_srv_request_event(srv_interface, automaton_name) - event_params = [field.as_plain_scxml(ros_declarations) for field in self._fields] - return ScxmlSend(event_name, event_params) - - def as_xml(self) -> ET.Element: - assert self.check_validity(), "Error: SCXML Service Send Request: invalid parameters." - xml_srv_request = ET.Element(RosServiceSendRequest.get_tag_name(), - {"name": self._srv_name}) - if self._fields is not None: - for field in self._fields: - xml_srv_request.append(field.as_xml()) - return xml_srv_request - - -class RosServiceHandleRequest(ScxmlTransition): + +class RosServiceHandleRequest(RosCallback): """SCXML object representing a ROS service callback on the server, acting upon a request.""" @staticmethod def get_tag_name() -> str: return "ros_service_handle_request" + @staticmethod + def get_declaration_type() -> Type[RosServiceServer]: + return RosServiceServer + @staticmethod def from_xml_tree(xml_tree: ET.Element) -> "RosServiceHandleRequest": - """Create a RosServiceServer object from an XML tree.""" + """Create a RosServiceHandleRequest object from an XML tree.""" assert_xml_tag_ok(RosServiceHandleRequest, xml_tree) srv_name = get_xml_argument(RosServiceHandleRequest, xml_tree, "name", none_allowed=True) if srv_name is None: @@ -303,83 +129,30 @@ def from_xml_tree(xml_tree: ET.Element) -> "RosServiceHandleRequest": "Use 'name' instead.") target_name = get_xml_argument(RosServiceHandleRequest, xml_tree, "target") exec_body = execution_body_from_xml(xml_tree) - return RosServiceHandleRequest(srv_name, target_name, exec_body) - - def __init__(self, service_decl: Union[str, RosServiceServer], target: str, - body: Optional[ScxmlExecutionBody] = None) -> None: - """ - Initialize a new RosServiceHandleRequest object. - - :param service_decl: The service server declaration, or its name. - :param target: Target state after the request has been received. - :param body: Execution body to be executed upon request, before transitioning to target. - """ - if isinstance(service_decl, RosServiceServer): - self._service_name = service_decl.get_name() - else: - # Used for generating ROS entries from xml file - assert isinstance(service_decl, str), \ - "Error: SCXML Service Handle Request: invalid service name." - self._service_name = service_decl - self._target = target - self._body = body - assert self.check_validity(), "Error: SCXML Service Handle Request: invalid parameters." - - def check_validity(self) -> bool: - valid_name = isinstance(self._service_name, str) and len(self._service_name) > 0 - valid_target = isinstance(self._target, str) and len(self._target) > 0 - valid_body = self._body is None or valid_execution_body(self._body) - if not valid_name: - print("Error: SCXML Service Handle Request: service name is not valid.") - if not valid_target: - print("Error: SCXML Service Handle Request: target is not valid.") - if not valid_body: - print("Error: SCXML Service Handle Request: body is not valid.") - return valid_name and valid_target and valid_body - - def check_valid_ros_instantiations(self, - ros_declarations: ScxmlRosDeclarationsContainer) -> bool: - assert isinstance(ros_declarations, ScxmlRosDeclarationsContainer), \ - "Error: SCXML service request handler: invalid ROS declarations container." - srv_server_declared = ros_declarations.is_service_server_defined(self._service_name) - if not srv_server_declared: - print("Error: SCXML service request handler: " - f"srv server {self._service_name} not declared.") - return False - valid_body = super().check_valid_ros_instantiations(ros_declarations) - if not valid_body: - print("Error: SCXML service request handler: body has invalid ROS instantiations.") - return valid_body - - def as_plain_scxml(self, ros_declarations: ScxmlRosDeclarationsContainer) -> ScxmlTransition: - assert self.check_valid_ros_instantiations(ros_declarations), \ - "Error: SCXML service request handler: invalid ROS instantiations." - interface_name, _ = ros_declarations.get_service_server_info(self._service_name) - event_name = generate_srv_server_request_event(interface_name) - target = self._target - body = as_plain_execution_body(self._body, ros_declarations) - return ScxmlTransition(target, [event_name], None, body) - - def as_xml(self) -> ET.Element: - assert self.check_validity(), "Error: SCXML Service Handle Request: invalid parameters." - xml_srv_request = ET.Element(RosServiceHandleRequest.get_tag_name(), - {"name": self._service_name, "target": self._target}) - if self._body is not None: - for body_elem in self._body: - xml_srv_request.append(body_elem.as_xml()) - return xml_srv_request - - -class RosServiceSendResponse(ScxmlSend): + return RosServiceHandleRequest(srv_name, target_name, None, exec_body) + + def check_interface_defined(self, ros_declarations: ScxmlRosDeclarationsContainer) -> bool: + return ros_declarations.is_service_server_defined(self._interface_name) + + def get_plain_scxml_event(self, ros_declarations: ScxmlRosDeclarationsContainer) -> str: + return generate_srv_server_request_event( + ros_declarations.get_service_server_info(self._interface_name)[0]) + + +class RosServiceSendResponse(RosTrigger): """SCXML object representing the response from a service server.""" @staticmethod def get_tag_name() -> str: return "ros_service_send_response" + @staticmethod + def get_declaration_type() -> Type[RosServiceServer]: + return RosServiceServer + @staticmethod def from_xml_tree(xml_tree: ET.Element) -> "RosServiceSendResponse": - """Create a RosServiceServer object from an XML tree.""" + """Create a RosServiceSendResponse object from an XML tree.""" assert_xml_tag_ok(RosServiceSendResponse, xml_tree) srv_name = get_xml_argument(RosServiceSendResponse, xml_tree, "name", none_allowed=True) if srv_name is None: @@ -394,77 +167,31 @@ def from_xml_tree(xml_tree: ET.Element) -> "RosServiceSendResponse": fields = None return RosServiceSendResponse(srv_name, fields) - def __init__(self, service_name: Union[str, RosServiceServer], - fields: Optional[List[RosField]]) -> None: - """ - Initialize a new RosServiceClient object. - - :param service_name: Topic used by the service. - :param fields: List of fields to be sent in the response. - """ - if isinstance(service_name, RosServiceServer): - self._service_name = service_name.get_name() - else: - # Used for generating ROS entries from xml file - assert isinstance(service_name, str), \ - "Error: SCXML Service Send Response: invalid service name." - self._service_name = service_name - self._fields = fields if fields is not None else [] - assert self.check_validity(), "Error: SCXML Service Send Response: invalid parameters." - - def check_validity(self) -> bool: - valid_name = isinstance(self._service_name, str) and len(self._service_name) > 0 - valid_fields = self._fields is None or \ - all([isinstance(field, RosField) and field.check_validity() for field in self._fields]) - if not valid_name: - print("Error: SCXML service response: service name is not valid.") - if not valid_fields: - print("Error: SCXML service response: fields are not valid.") - return valid_name and valid_fields - - def check_valid_ros_instantiations(self, - ros_declarations: ScxmlRosDeclarationsContainer) -> bool: - assert isinstance(ros_declarations, ScxmlRosDeclarationsContainer), \ - "Error: SCXML service response: invalid ROS declarations container." - srv_declared = ros_declarations.is_service_server_defined(self._service_name) - if not srv_declared: - print("Error: SCXML service response: " - f"srv server {self._service_name} not declared.") - return False - valid_fields = ros_declarations.check_valid_srv_res_fields(self._service_name, self._fields) - if not valid_fields: - print("Error: SCXML service response: invalid fields in response.") - return False - return True + def check_interface_defined(self, ros_declarations: ScxmlRosDeclarationsContainer) -> bool: + return ros_declarations.is_service_server_defined(self._interface_name) + + def check_fields_validity(self, ros_declarations: ScxmlRosDeclarationsContainer) -> bool: + return ros_declarations.check_valid_srv_res_fields(self._interface_name, self._fields) + + def get_plain_scxml_event(self, ros_declarations: ScxmlRosDeclarationsContainer) -> str: + return generate_srv_server_response_event( + ros_declarations.get_service_server_info(self._interface_name)[0]) - def as_plain_scxml(self, ros_declarations: ScxmlRosDeclarationsContainer) -> ScxmlSend: - assert self.check_valid_ros_instantiations(ros_declarations), \ - "Error: SCXML service response: invalid ROS instantiations." - interface_name, _ = ros_declarations.get_service_server_info(self._service_name) - event_name = generate_srv_server_response_event(interface_name) - event_params = [field.as_plain_scxml(ros_declarations) for field in self._fields] - return ScxmlSend(event_name, event_params) - - def as_xml(self) -> ET.Element: - assert self.check_validity(), "Error: SCXML Service Send Response: invalid parameters." - xml_srv_response = ET.Element(RosServiceSendResponse.get_tag_name(), - {"name": self._service_name}) - if self._fields is not None: - for field in self._fields: - xml_srv_response.append(field.as_xml()) - return xml_srv_response - - -class RosServiceHandleResponse(ScxmlTransition): + +class RosServiceHandleResponse(RosCallback): """SCXML object representing the handler of a service response for a service client.""" @staticmethod def get_tag_name() -> str: return "ros_service_handle_response" + @staticmethod + def get_declaration_type() -> Type[RosServiceClient]: + return RosServiceClient + @staticmethod def from_xml_tree(xml_tree: ET.Element) -> "RosServiceHandleResponse": - """Create a RosServiceServer object from an XML tree.""" + """Create a RosServiceHandleResponse object from an XML tree.""" assert_xml_tag_ok(RosServiceHandleResponse, xml_tree) srv_name = get_xml_argument(RosServiceHandleResponse, xml_tree, "name", none_allowed=True) if srv_name is None: @@ -473,68 +200,12 @@ def from_xml_tree(xml_tree: ET.Element) -> "RosServiceHandleResponse": "Use 'name' instead.") target_name = get_xml_argument(RosServiceHandleResponse, xml_tree, "target") exec_body = execution_body_from_xml(xml_tree) - return RosServiceHandleResponse(srv_name, target_name, exec_body) - - def __init__(self, service_decl: Union[str, RosServiceClient], target: str, - body: Optional[ScxmlExecutionBody] = None) -> None: - """ - Initialize a new RosServiceClient object. - - :param service_name: Topic used by the service. - :param type: ROS type of the service. - """ - if isinstance(service_decl, RosServiceClient): - self._service_name = service_decl.get_name() - else: - # Used for generating ROS entries from xml file - assert isinstance(service_decl, str), \ - "Error: SCXML Service Handle Response: invalid service name." - self._service_name = service_decl - self._target = target - self._body = body - assert self.check_validity(), "Error: SCXML Service Handle Response: invalid parameters." - - def check_validity(self) -> bool: - valid_name = isinstance(self._service_name, str) and len(self._service_name) > 0 - valid_target = isinstance(self._target, str) and len(self._target) > 0 - valid_body = self._body is None or valid_execution_body(self._body) - if not valid_name: - print("Error: SCXML Service Handle Response: service name is not valid.") - if not valid_target: - print("Error: SCXML Service Handle Response: target is not valid.") - if not valid_body: - print("Error: SCXML Service Handle Response: body is not valid.") - return valid_name and valid_target and valid_body - - def check_valid_ros_instantiations(self, - ros_declarations: ScxmlRosDeclarationsContainer) -> bool: - assert isinstance(ros_declarations, ScxmlRosDeclarationsContainer), \ - "Error: SCXML Service Handle Response: invalid ROS declarations container." - srv_declared = ros_declarations.is_service_client_defined(self._service_name) - if not srv_declared: - print("Error: SCXML Service Handle Response: " - f"srv server {self._service_name} not declared.") - return False - valid_body = super().check_valid_ros_instantiations(ros_declarations) - if not valid_body: - print("Error: SCXML Service Handle Response: body has invalid ROS instantiations.") - return valid_body - - def as_plain_scxml(self, ros_declarations: ScxmlRosDeclarationsContainer) -> ScxmlTransition: - assert self.check_valid_ros_instantiations(ros_declarations), \ - "Error: SCXML service response handler: invalid ROS instantiations." - automaton_name = ros_declarations.get_automaton_name() - interface_name, _ = ros_declarations.get_service_client_info(self._service_name) - event_name = generate_srv_response_event(interface_name, automaton_name) - target = self._target - body = as_plain_execution_body(self._body, ros_declarations) - return ScxmlTransition(target, [event_name], None, body) - - def as_xml(self) -> ET.Element: - assert self.check_validity(), "Error: SCXML Service Handle Response: invalid parameters." - xml_srv_response = ET.Element(RosServiceHandleResponse.get_tag_name(), - {"name": self._service_name, "target": self._target}) - if self._body is not None: - for body_elem in self._body: - xml_srv_response.append(body_elem.as_xml()) - return xml_srv_response + return RosServiceHandleResponse(srv_name, target_name, None, exec_body) + + def check_interface_defined(self, ros_declarations: ScxmlRosDeclarationsContainer) -> bool: + return ros_declarations.is_service_client_defined(self._interface_name) + + def get_plain_scxml_event(self, ros_declarations: ScxmlRosDeclarationsContainer) -> str: + return generate_srv_response_event( + ros_declarations.get_service_client_info(self._interface_name)[0], + ros_declarations.get_automaton_name()) diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_timer.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_timer.py index 93ab0e6c..1bce4e46 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_timer.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_timer.py @@ -15,16 +15,19 @@ """Declaration of SCXML tags related to ROS Timers.""" -from typing import Optional, Union +from typing import Type from xml.etree import ElementTree as ET -from scxml_converter.scxml_entries import ( - ScxmlBase, ScxmlExecutionBody, ScxmlRosDeclarationsContainer, ScxmlTransition, - as_plain_execution_body, execution_body_from_xml, valid_execution_body) +from scxml_converter.scxml_entries import ScxmlRosDeclarationsContainer +from scxml_converter.scxml_entries.scxml_ros_base import RosDeclaration, RosCallback + from scxml_converter.scxml_entries.bt_utils import BtPortsHandler +from scxml_converter.scxml_entries.ros_utils import generate_rate_timer_event +from scxml_converter.scxml_entries.xml_utils import assert_xml_tag_ok, get_xml_argument +from scxml_converter.scxml_entries.utils import is_non_empty_string -class RosTimeRate(ScxmlBase): +class RosTimeRate(RosDeclaration): """Object used in the SCXML root to declare a new timer with its related tick rate.""" @staticmethod @@ -34,12 +37,9 @@ def get_tag_name() -> str: @staticmethod def from_xml_tree(xml_tree: ET.Element) -> "RosTimeRate": """Create a RosTimeRate object from an XML tree.""" - assert xml_tree.tag == RosTimeRate.get_tag_name(), \ - f"Error: SCXML rate timer: XML tag name is not {RosTimeRate.get_tag_name()}" - timer_name = xml_tree.attrib.get("name") - timer_rate_str = xml_tree.attrib.get("rate_hz") - assert timer_name is not None and timer_rate_str is not None, \ - "Error: SCXML rate timer: 'name' or 'rate_hz' attribute not found in input xml." + assert_xml_tag_ok(RosTimeRate, xml_tree) + timer_name = get_xml_argument(RosTimeRate, xml_tree, "name") + timer_rate_str = get_xml_argument(RosTimeRate, xml_tree, "rate_hz") try: timer_rate = float(timer_rate_str) except ValueError as e: @@ -50,33 +50,33 @@ def __init__(self, name: str, rate_hz: float): self._name = name self._rate_hz = float(rate_hz) + def get_interface_name(self) -> str: + raise RuntimeError("Error: SCXML rate timer: deleted method 'get_interface_name'.") + + def get_interface_type(self) -> str: + raise RuntimeError("Error: SCXML rate timer: deleted method 'get_interface_type'.") + + def get_name(self) -> str: + return self._name + + def get_rate(self) -> float: + return self._rate_hz + def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler) -> None: """Update the values of potential entries making use of BT ports.""" pass def check_validity(self) -> bool: - valid_name = isinstance(self._name, str) and len(self._name) > 0 + valid_name = is_non_empty_string(RosTimeRate, "name", self._name) valid_rate = isinstance(self._rate_hz, float) and self._rate_hz > 0 - if not valid_name: - print("Error: SCXML rate timer: name is not valid.") if not valid_rate: print("Error: SCXML rate timer: rate is not valid.") return valid_name and valid_rate def check_valid_instantiation(self) -> bool: - """Check if the topic publisher has undefined entries (i.e. from BT ports).""" + """Check if the timer has undefined entries (i.e. from BT ports).""" return True - def get_name(self) -> str: - return self._name - - def get_rate(self) -> float: - return self._rate_hz - - def as_plain_scxml(self, _) -> ScxmlBase: - # This is discarded in the as_plain_scxml method from ScxmlRoot - raise RuntimeError("Error: SCXML ROS declarations cannot be converted to plain SCXML.") - def as_xml(self) -> ET.Element: assert self.check_validity(), "Error: SCXML rate timer: invalid parameters." xml_time_rate = ET.Element( @@ -84,7 +84,7 @@ def as_xml(self) -> ET.Element: return xml_time_rate -class RosRateCallback(ScxmlTransition): +class RosRateCallback(RosCallback): """Callback that triggers each time the associated timer ticks.""" @staticmethod @@ -92,85 +92,11 @@ def get_tag_name() -> str: return "ros_rate_callback" @staticmethod - def from_xml_tree(xml_tree: ET.Element) -> "RosRateCallback": - """Create a RosRateCallback object from an XML tree.""" - assert xml_tree.tag == RosRateCallback.get_tag_name(), \ - f"Error: SCXML rate callback: XML tag name is not {RosRateCallback.get_tag_name()}" - timer_name = xml_tree.attrib.get("name") - target = xml_tree.attrib.get("target") - assert timer_name is not None and target is not None, \ - "Error: SCXML rate callback: 'name' or 'target' attribute not found in input xml." - condition = xml_tree.get("cond") - condition = condition if condition is not None and len(condition) > 0 else None - exec_body = execution_body_from_xml(xml_tree) - exec_body = exec_body if exec_body is not None else None - return RosRateCallback(timer_name, target, condition, exec_body) - - def __init__(self, timer: Union[RosTimeRate, str], target: str, condition: Optional[str] = None, - body: Optional[ScxmlExecutionBody] = None): - """ - Generate a new rate timer and callback. - - Multiple rate callbacks can share the same timer name, but the rate must match. - - :param timer: The RosTimeRate instance triggering the callback, or its name - :param body: The body of the callback - """ - if isinstance(timer, RosTimeRate): - self._timer_name = timer.get_name() - else: - # Used for generating ROS entries from xml file - assert isinstance(timer, str), "Error: SCXML rate callback: invalid timer type." - self._timer_name = timer - self._target = target - self._condition = condition - self._body = body - assert self.check_validity(), "Error: SCXML rate callback: invalid parameters." + def get_declaration_type() -> Type[RosTimeRate]: + return RosTimeRate - def check_validity(self) -> bool: - valid_timer = isinstance(self._timer_name, str) and len(self._timer_name) > 0 - valid_target = isinstance(self._target, str) and len(self._target) > 0 - valid_cond = self._condition is None or ( - isinstance(self._condition, str) and len(self._condition) > 0) - valid_body = self._body is None or valid_execution_body(self._body) - if not valid_timer: - print("Error: SCXML rate callback: timer name is not valid.") - if not valid_target: - print("Error: SCXML rate callback: target is not valid.") - if not valid_cond: - print("Error: SCXML rate callback: condition is not valid.") - if not valid_body: - print("Error: SCXML rate callback: body is not valid.") - return valid_timer and valid_target and valid_cond and valid_body - - def check_valid_ros_instantiations(self, - ros_declarations: ScxmlRosDeclarationsContainer) -> bool: - """Check if the ros instantiations have been declared.""" - assert isinstance(ros_declarations, ScxmlRosDeclarationsContainer), \ - "Error: SCXML rate callback: invalid ROS declarations container." - timer_cb_declared = ros_declarations.is_timer_defined(self._timer_name) - if not timer_cb_declared: - print(f"Error: SCXML rate callback: timer {self._timer_name} not declared.") - return False - valid_body = super().check_valid_ros_instantiations(ros_declarations) - if not valid_body: - print("Error: SCXML rate callback: body has invalid ROS instantiations.") - return valid_body - - def as_plain_scxml(self, ros_declarations: ScxmlRosDeclarationsContainer) -> ScxmlTransition: - event_name = "ros_time_rate." + self._timer_name - target = self._target - cond = self._condition - body = as_plain_execution_body(self._body, ros_declarations) - return ScxmlTransition(target, [event_name], cond, body) + def check_interface_defined(self, ros_declarations: ScxmlRosDeclarationsContainer) -> bool: + return ros_declarations.is_timer_defined(self._interface_name) - def as_xml(self) -> ET.Element: - assert self.check_validity(), "Error: SCXML rate callback: invalid parameters." - xml_rate_callback = ET.Element( - "ros_rate_callback", {"name": self._timer_name, "target": self._target}) - if self._condition is not None: - xml_rate_callback.set("cond", self._condition) - if self._body is not None: - for entry in self._body: - xml_rate_callback.append(entry.as_xml()) - return xml_rate_callback + def get_plain_scxml_event(self, _) -> str: + return generate_rate_timer_event(self._interface_name) diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_topic.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_topic.py index e7297b05..f5a6f10c 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_topic.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_topic.py @@ -20,21 +20,19 @@ https://docs.ros.org/en/iron/Tutorials/Beginner-CLI-Tools/Understanding-ROS2-Topics/Understanding-ROS2-Topics.html """ -from typing import List, Optional, Union +from typing import List, Type from xml.etree import ElementTree as ET from scxml_converter.scxml_entries import ( - RosField, ScxmlBase, ScxmlExecutionBody, ScxmlParam, ScxmlRosDeclarationsContainer, ScxmlSend, - ScxmlTransition, BtGetValueInputPort, as_plain_execution_body, execution_body_from_xml, - valid_execution_body) -from scxml_converter.scxml_entries.bt_utils import BtPortsHandler -from scxml_converter.scxml_entries.ros_utils import is_msg_type_known, sanitize_ros_interface_name + RosField, ScxmlRosDeclarationsContainer, ScxmlSend, execution_body_from_xml) +from scxml_converter.scxml_entries.scxml_ros_base import RosCallback, RosTrigger, RosDeclaration + +from scxml_converter.scxml_entries.ros_utils import (is_msg_type_known, generate_topic_event) from scxml_converter.scxml_entries.xml_utils import ( - assert_xml_tag_ok, get_xml_argument, get_children_as_scxml, read_value_from_xml_child) -from scxml_converter.scxml_entries.utils import is_non_empty_string + assert_xml_tag_ok, get_xml_argument, get_children_as_scxml) -class RosTopicPublisher(ScxmlBase): +class RosTopicPublisher(RosDeclaration): """Object used in SCXML root to declare a new topic publisher.""" @staticmethod @@ -42,85 +40,17 @@ def get_tag_name() -> str: return "ros_topic_publisher" @staticmethod - def from_xml_tree(xml_tree: ET.Element) -> "RosTopicPublisher": - """Create a RosTopicPublisher object from an XML tree.""" - assert_xml_tag_ok(RosTopicPublisher, xml_tree) - topic_name = get_xml_argument(RosTopicPublisher, xml_tree, "topic", none_allowed=True) - topic_type = get_xml_argument(RosTopicPublisher, xml_tree, "type") - pub_name = get_xml_argument(RosTopicPublisher, xml_tree, "name", none_allowed=True) - if topic_name is None: - topic_name = read_value_from_xml_child(xml_tree, "topic", (BtGetValueInputPort, str)) - assert topic_name is not None, "Error: SCXML topic publisher: topic name not found." - return RosTopicPublisher(topic_name, topic_type, pub_name) - - def __init__(self, - topic_name: Union[str, BtGetValueInputPort], topic_type: str, - pub_name: Optional[str] = None) -> None: - """ - Create a new ros_topic_publisher object instance. - - By default, its alias is the same as the topic name, if that is defined as a string. - If the topic is defined as a BtGetValueInputPort, an alias must be provided. - - :param topic_name: The name of the topic where messages are published. - :param topic_type: The type of the message to be published - :param pub_name: Alias used to reference the publisher in SCXML. - """ - self._topic_type = topic_type - self._topic_name = topic_name - self._pub_name = pub_name - assert isinstance(self._topic_name, (str, BtGetValueInputPort)), \ - "Error: SCXML topic publisher: invalid topic name." - if self._pub_name is None: - assert is_non_empty_string(RosTopicPublisher, "topic", self._topic_name), \ - "Error: SCXML topic publisher: alias must be provided for dynamic topic names." - self._pub_name = self._topic_name - - def check_validity(self) -> bool: - valid_topic_name = isinstance(self._topic_name, BtGetValueInputPort) or \ - is_non_empty_string(RosTopicPublisher, "topic", self._topic_name) - valid_type = is_msg_type_known(self._topic_type) - valid_alias = is_non_empty_string(RosTopicPublisher, "name", self._pub_name) - if not valid_type: - print("Error: SCXML topic subscriber: topic type is not valid.") - return valid_topic_name and valid_type and valid_alias - - def check_valid_instantiation(self) -> bool: - """Check if the topic publisher has undefined entries (i.e. from BT ports).""" - return is_non_empty_string(RosTopicPublisher, "topic", self._topic_name) - - def get_topic_name(self) -> Union[str, BtGetValueInputPort]: - """Get the name of the topic where messages are published.""" - return self._topic_name - - def get_topic_type(self) -> str: - """Get a string representation of the topic type.""" - return self._topic_type - - def get_name(self) -> str: - """Get the alias used to reference the publisher in SCXML.""" - return self._pub_name - - def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler) -> None: - """ - Update the value of the BT ports used in the publisher, if any. - """ - if isinstance(self._topic_name, BtGetValueInputPort): - self._topic_name = bt_ports_handler.get_in_port_value(self._topic_name.get_key_name()) - - def as_plain_scxml(self, _) -> ScxmlBase: - # This is discarded in the to_plain_scxml_and_declarations method from ScxmlRoot - raise RuntimeError("Error: SCXML ROS declarations cannot be converted to plain SCXML.") - - def as_xml(self) -> ET.Element: - assert self.check_validity(), "Error: SCXML topic subscriber: invalid parameters." - xml_topic_publisher = ET.Element( - RosTopicPublisher.get_tag_name(), - {"name": self._pub_name, "topic": self._topic_name, "type": self._topic_type}) - return xml_topic_publisher - - -class RosTopicSubscriber(ScxmlBase): + def get_xml_arg_interface_name() -> str: + return "topic" + + def check_valid_interface_type(self) -> bool: + if not is_msg_type_known(self._interface_type): + print(f"Error: SCXML RosTopicPublisher: invalid msg type {self._interface_type}.") + return False + return True + + +class RosTopicSubscriber(RosDeclaration): """Object used in SCXML root to declare a new topic subscriber.""" @staticmethod @@ -128,74 +58,27 @@ def get_tag_name() -> str: return "ros_topic_subscriber" @staticmethod - def from_xml_tree(xml_tree: ET.Element) -> "RosTopicSubscriber": - """Create a RosTopicSubscriber object from an XML tree.""" - assert_xml_tag_ok(RosTopicSubscriber, xml_tree) - topic_name = get_xml_argument(RosTopicSubscriber, xml_tree, "topic", none_allowed=True) - topic_type = get_xml_argument(RosTopicSubscriber, xml_tree, "type") - sub_name = get_xml_argument(RosTopicSubscriber, xml_tree, "name", none_allowed=True) - if topic_name is None: - topic_name = read_value_from_xml_child(xml_tree, "topic", (BtGetValueInputPort, str)) - assert topic_name is not None, "Error: SCXML topic subscriber: topic name not found." - return RosTopicSubscriber(topic_name, topic_type, sub_name) - - def __init__(self, topic_name: Union[str, BtGetValueInputPort], topic_type: str, - sub_name: Optional[str] = None) -> None: - self._topic_type = topic_type - self._topic_name = topic_name - self._sub_name = sub_name - assert isinstance(self._topic_name, (str, BtGetValueInputPort)), \ - "Error: SCXML topic subscriber: invalid topic name." - if self._sub_name is None: - assert is_non_empty_string(RosTopicSubscriber, "topic", self._topic_name), \ - "Error: SCXML topic subscriber: alias must be provided for dynamic topic names." - self._sub_name = self._topic_name - - def check_validity(self) -> bool: - valid_name = isinstance(self._topic_name, BtGetValueInputPort) or \ - is_non_empty_string(RosTopicSubscriber, "topic", self._topic_name) - valid_type = is_msg_type_known(self._topic_type) - valid_alias = is_non_empty_string(RosTopicSubscriber, "name", self._sub_name) - if not valid_type: - print("Error: SCXML topic subscriber: topic type is not valid.") - return valid_name and valid_type and valid_alias - - def check_valid_instantiation(self) -> bool: - """Check if the topic subscriber has undefined entries (i.e. from BT ports).""" - return is_non_empty_string(RosTopicSubscriber, "topic", self._topic_name) - - def get_topic_name(self) -> Union[str, BtGetValueInputPort]: - return self._topic_name - - def get_topic_type(self) -> str: - return self._topic_type - - def get_name(self) -> str: - return self._sub_name - - def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler) -> None: - """Update the values of potential entries making use of BT ports.""" - pass - - def as_plain_scxml(self, _) -> ScxmlBase: - # This is discarded in the to_plain_scxml_and_declarations method from ScxmlRoot - raise RuntimeError("Error: SCXML ROS declarations cannot be converted to plain SCXML.") - - def as_xml(self) -> ET.Element: - assert self.check_validity(), "Error: SCXML topic subscriber: invalid parameters." - xml_topic_subscriber = ET.Element( - RosTopicSubscriber.get_tag_name(), - {"name": self._sub_name, "topic": self._topic_name, "type": self._topic_type}) - return xml_topic_subscriber - - -class RosTopicCallback(ScxmlTransition): + def get_xml_arg_interface_name() -> str: + return "topic" + + def check_valid_interface_type(self) -> bool: + if not is_msg_type_known(self._interface_type): + print(f"Error: SCXML RosTopicSubscriber: invalid msg type {self._interface_type}.") + return False + return True + + +class RosTopicCallback(RosCallback): """Object representing a transition to perform when a new ROS msg is received.""" @staticmethod def get_tag_name() -> str: return "ros_topic_callback" + @staticmethod + def get_declaration_type() -> Type[RosTopicSubscriber]: + return RosTopicSubscriber + @staticmethod def from_xml_tree(xml_tree: ET.Element) -> "RosTopicCallback": """Create a RosTopicCallback object from an XML tree.""" @@ -207,76 +90,26 @@ def from_xml_tree(xml_tree: ET.Element) -> "RosTopicCallback": "Use 'name' instead.") target = get_xml_argument(RosTopicCallback, xml_tree, "target") exec_body = execution_body_from_xml(xml_tree) - return RosTopicCallback(sub_name, target, exec_body) - - def __init__( - self, topic_sub: Union[RosTopicSubscriber, str], target: str, - body: Optional[ScxmlExecutionBody] = None): - """ - Create a new ros_topic_callback object instance. - - :param topic_sub: The RosTopicSubscriber instance triggering the callback, or its name - :param target: The target state of the transition - :param body: Execution body executed at the time the received message gets processed - """ - if isinstance(topic_sub, RosTopicSubscriber): - self._sub_name = topic_sub.get_name() - else: - # Used for generating ROS entries from xml file - assert is_non_empty_string(RosTopicCallback, "name", topic_sub) - self._sub_name = topic_sub - self._target = target - self._body = body - assert self.check_validity(), "Error: SCXML topic callback: invalid parameters." - - def check_validity(self) -> bool: - valid_sub_name = is_non_empty_string(RosTopicCallback, "name", self._sub_name) - valid_target = is_non_empty_string(RosTopicCallback, "target", self._target) - valid_body = self._body is None or valid_execution_body(self._body) - if not valid_body: - print("Error: SCXML topic callback: body is not valid.") - return valid_sub_name and valid_target and valid_body - - def check_valid_ros_instantiations(self, - ros_declarations: ScxmlRosDeclarationsContainer) -> bool: - """Check if the ros instantiations have been declared.""" - assert isinstance(ros_declarations, ScxmlRosDeclarationsContainer), \ - "Error: SCXML topic callback: invalid ROS declarations container." - topic_cb_declared = ros_declarations.is_subscriber_defined(self._sub_name) - if not topic_cb_declared: - print(f"Error: SCXML topic callback: topic subscriber {self._sub_name} not declared.") - return False - valid_body = super().check_valid_ros_instantiations(ros_declarations) - if not valid_body: - print("Error: SCXML topic callback: body has invalid ROS instantiations.") - return valid_body - - def as_plain_scxml(self, ros_declarations: ScxmlRosDeclarationsContainer) -> ScxmlTransition: - assert self.check_valid_ros_instantiations(ros_declarations), \ - "Error: SCXML topic callback: invalid ROS instantiations." - topic_name, _ = ros_declarations.get_subscriber_info(self._sub_name) - event_name = "ros_topic." + sanitize_ros_interface_name(topic_name) - target = self._target - body = as_plain_execution_body(self._body, ros_declarations) - return ScxmlTransition(target, [event_name], None, body) - - def as_xml(self) -> ET.Element: - assert self.check_validity(), "Error: SCXML topic callback: invalid parameters." - xml_topic_callback = ET.Element( - "ros_topic_callback", {"name": self._sub_name, "target": self._target}) - if self._body is not None: - for entry in self._body: - xml_topic_callback.append(entry.as_xml()) - return xml_topic_callback - - -class RosTopicPublish(ScxmlSend): + return RosTopicCallback(sub_name, target, None, exec_body) + + def check_interface_defined(self, ros_declarations: ScxmlRosDeclarationsContainer) -> bool: + return ros_declarations.is_subscriber_defined(self._interface_name) + + def get_plain_scxml_event(self, ros_declarations: ScxmlRosDeclarationsContainer) -> str: + return generate_topic_event(ros_declarations.get_subscriber_info(self._interface_name)[0]) + + +class RosTopicPublish(RosTrigger): """Object representing the shipping of a ROS msg through a topic.""" @staticmethod def get_tag_name() -> str: return "ros_topic_publish" + @staticmethod + def get_declaration_type() -> Type[RosTopicPublisher]: + return RosTopicPublisher + @staticmethod def from_xml_tree(xml_tree: ET.Element) -> ScxmlSend: """Create a RosTopicPublish object from an XML tree.""" @@ -289,66 +122,12 @@ def from_xml_tree(xml_tree: ET.Element) -> ScxmlSend: fields: List[RosField] = get_children_as_scxml(xml_tree, (RosField,)) return RosTopicPublish(pub_name, fields) - def __init__(self, topic_pub: Union[RosTopicPublisher, str], - fields: Optional[List[RosField]] = None) -> None: - if fields is None: - fields = [] - if isinstance(topic_pub, RosTopicPublisher): - self._pub_name = topic_pub.get_name() - else: - # Used for generating ROS entries from xml file - assert is_non_empty_string(RosTopicPublish, "name", topic_pub) - self._pub_name = topic_pub - self._fields = fields - assert self.check_validity(), "Error: SCXML topic publish: invalid parameters." - - def check_validity(self) -> bool: - valid_pub_name = is_non_empty_string(RosTopicPublish, "name", self._pub_name) - valid_fields = self._fields is None or \ - all([isinstance(field, RosField) and field.check_validity() for field in self._fields]) - if not valid_fields: - print("Error: SCXML topic publish: fields are not valid.") - return valid_pub_name and valid_fields - - def check_valid_ros_instantiations(self, - ros_declarations: ScxmlRosDeclarationsContainer) -> bool: - """Check if the ros instantiations have been declared.""" - assert isinstance(ros_declarations, ScxmlRosDeclarationsContainer), \ - "Error: SCXML topic publish: invalid ROS declarations container." - topic_pub_declared = ros_declarations.is_publisher_defined(self._pub_name) - if not topic_pub_declared: - print(f"Error: SCXML topic publish: topic publisher {self._pub_name} not declared.") - # TODO: Check for valid fields can be done here - return topic_pub_declared - - def append_param(self, param: ScxmlParam) -> None: - raise RuntimeError( - "Error: SCXML topic publish: cannot append scxml params, use append_field instead.") - - def append_field(self, field: RosField) -> None: - assert isinstance(field, RosField), "Error: SCXML topic publish: invalid field." - if self._fields is None: - self._fields = [] - self._fields.append(field) - - def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler): - """Update the values of potential entries making use of BT ports.""" - for field in self._fields: - field.update_bt_ports_values(bt_ports_handler) - - def as_plain_scxml(self, ros_declarations: ScxmlRosDeclarationsContainer) -> ScxmlSend: - assert self.check_valid_ros_instantiations(ros_declarations), \ - "Error: SCXML topic publish: invalid ROS instantiations." - topic_name, _ = ros_declarations.get_publisher_info(self._pub_name) - event_name = "ros_topic." + sanitize_ros_interface_name(topic_name) - params = None if self._fields is None else \ - [field.as_plain_scxml(ros_declarations) for field in self._fields] - return ScxmlSend(event_name, params) - - def as_xml(self) -> ET.Element: - assert self.check_validity(), "Error: SCXML topic publish: invalid parameters." - xml_topic_publish = ET.Element(RosTopicPublish.get_tag_name(), {"name": self._pub_name}) - if self._fields is not None: - for field in self._fields: - xml_topic_publish.append(field.as_xml()) - return xml_topic_publish + def check_interface_defined(self, ros_declarations: ScxmlRosDeclarationsContainer) -> bool: + return ros_declarations.is_publisher_defined(self._interface_name) + + def check_fields_validity(self, ros_declarations: ScxmlRosDeclarationsContainer) -> bool: + # TODO: CHeck fields for topics, too + return True + + def get_plain_scxml_event(self, ros_declarations: ScxmlRosDeclarationsContainer) -> str: + return generate_topic_event(ros_declarations.get_publisher_info(self._interface_name)[0]) diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_state.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_state.py index 6fd2da19..a6c07116 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/scxml_state.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_state.py @@ -34,15 +34,6 @@ class ScxmlState(ScxmlBase): def get_tag_name() -> str: return "state" - def __init__(self, id_: str, *, - on_entry: ScxmlExecutionBody = None, - on_exit: ScxmlExecutionBody = None, - body: List[ScxmlTransition] = None): - self._id = id_ - self._on_entry = on_entry if on_entry is not None else [] - self._on_exit = on_exit if on_exit is not None else [] - self._body: List[ScxmlTransition] = body if body is not None else [] - @staticmethod def from_xml_tree(xml_tree: ET.Element) -> "ScxmlState": """Create a ScxmlState object from an XML tree.""" @@ -52,19 +43,11 @@ def from_xml_tree(xml_tree: ET.Element) -> "ScxmlState": assert id_ is not None and len(id_) > 0, "Error: SCXML state: id is not valid." scxml_state = ScxmlState(id_) # Get the onentry and onexit execution bodies - on_entry_xml = xml_tree.findall("onentry") - if on_entry_xml is None: - on_entry = [] - else: - on_entry = on_entry_xml - assert len(on_entry) == 0 or len(on_entry) == 1, \ + on_entry = xml_tree.findall("onentry") + assert len(on_entry) <= 1, \ f"Error: SCXML state: {len(on_entry)} onentry tags found, expected 0 or 1." - on_exit_xml = xml_tree.findall("onexit") - if on_exit_xml is None: - on_exit = [] - else: - on_exit = on_exit_xml - assert len(on_exit) == 0 or len(on_exit) == 1, \ + on_exit = xml_tree.findall("onexit") + assert len(on_exit) <= 1, \ f"Error: SCXML state: {len(on_exit)} onexit tags found, expected 0 or 1." if len(on_entry) > 0: for exec_entry in execution_body_from_xml(on_entry[0]): @@ -73,10 +56,27 @@ def from_xml_tree(xml_tree: ET.Element) -> "ScxmlState": for exec_entry in execution_body_from_xml(on_exit[0]): scxml_state.append_on_exit(exec_entry) # Get the transitions in the state body - for body_entry in ScxmlState._transitions_from_xml(xml_tree): + for body_entry in ScxmlState._transitions_from_xml(id_, xml_tree): scxml_state.add_transition(body_entry) return scxml_state + def __init__(self, state_id: str, *, + on_entry: ScxmlExecutionBody = None, + on_exit: ScxmlExecutionBody = None, + body: List[ScxmlTransition] = None): + """ + Initialize a new ScxmlState object. + + :param state_id: The id of the state, unique in the ScxmlRoot object. + :param on_entry: The executable entries to be executed on entry. + :param on_exit: The executable entries to be executed on exit. + :param body: The transitions leaving the state. + """ + self._id: str = state_id + self._on_entry: ScxmlExecutionBody = on_entry if on_entry is not None else [] + self._on_exit: ScxmlExecutionBody = on_exit if on_exit is not None else [] + self._body: List[ScxmlTransition] = body if body is not None else [] + def get_id(self) -> str: return self._id @@ -90,6 +90,13 @@ def get_body(self) -> List[ScxmlTransition]: """Return the transitions leaving the state.""" return self._body + def set_thread_id(self, thread_idx: int): + """Assign the thread ID to the thread-specific transitions in the body.""" + for entry in self._on_entry + self._on_exit + self._body: + # Assign the thread only to the entries supporting it + if hasattr(entry, 'set_thread_id'): + entry.set_thread_id(thread_idx) + def instantiate_bt_events(self, instance_id: str) -> None: """Instantiate the BT events in all entries belonging to a state.""" for transition in self._body: @@ -106,14 +113,23 @@ def update_bt_ports_values(self, bt_ports_handler: BtPortsHandler) -> None: for entry in self._on_exit: entry.update_bt_ports_values(bt_ports_handler) - @classmethod - def _transitions_from_xml(cls, xml_tree: ET.Element) -> List[ScxmlTransition]: + @staticmethod + def _transitions_from_xml(state_id: str, xml_tree: ET.Element) -> List[ScxmlTransition]: + from scxml_converter.scxml_entries.scxml_ros_base import RosCallback transitions: List[ScxmlTransition] = [] - tag_to_cls = {cls.get_tag_name(): cls for cls in ScxmlTransition.__subclasses__()} + tag_to_cls = {cls.get_tag_name(): cls + for cls in ScxmlTransition.__subclasses__() + if cls != RosCallback} + tag_to_cls.update({cls.get_tag_name(): cls for cls in RosCallback.__subclasses__()}) tag_to_cls.update({ScxmlTransition.get_tag_name(): ScxmlTransition}) for child in xml_tree: if child.tag in tag_to_cls: transitions.append(tag_to_cls[child.tag].from_xml_tree(child)) + expected_transitions = \ + len(xml_tree) - len(xml_tree.findall("onentry")) - len(xml_tree.findall("onexit")) + assert len(transitions) == expected_transitions, \ + f"Error: SCXML state {state_id}: Expected {expected_transitions} transitions, " \ + f"found {len(transitions)}." return transitions def add_transition(self, transition: ScxmlTransition): @@ -142,11 +158,11 @@ def check_validity(self) -> bool: if not valid_id: print("Error: SCXML state: id is not valid.") if not valid_on_entry: - print("Error: SCXML state: on_entry is not valid.") + print(f"Error: SCXML state {self._id}: on_entry is not valid.") if not valid_on_exit: - print("Error: SCXML state: on_exit is not valid.") + print(f"Error: SCXML state {self._id}: on_exit is not valid.") if not valid_body: - print("Error: SCXML state: executable body is not valid.") + print(f"Error: SCXML state {self._id}: executable body is not valid.") return valid_on_entry and valid_on_exit and valid_body def check_valid_ros_instantiations(self, @@ -156,11 +172,11 @@ def check_valid_ros_instantiations(self, valid_exit = ScxmlState._check_valid_ros_instantiations(self._on_exit, ros_declarations) valid_body = ScxmlState._check_valid_ros_instantiations(self._body, ros_declarations) if not valid_entry: - print("Error: SCXML state: onentry has invalid ROS instantiations.") + print(f"Error: SCXML state {self._id}: onentry has invalid ROS instantiations.") if not valid_exit: - print("Error: SCXML state: onexit has invalid ROS instantiations.") + print(f"Error: SCXML state {self._id}: onexit has invalid ROS instantiations.") if not valid_body: - print("Error: SCXML state: found invalid transition in state body.") + print(f"Error: SCXML state {self._id}: found invalid transition in state body.") return valid_entry and valid_exit and valid_body @staticmethod @@ -175,7 +191,18 @@ def as_plain_scxml(self, ros_declarations: ScxmlRosDeclarationsContainer) -> "Sc """Convert the ROS-specific entries to be plain SCXML""" plain_entry = as_plain_execution_body(self._on_entry, ros_declarations) plain_exit = as_plain_execution_body(self._on_exit, ros_declarations) - plain_body = [entry.as_plain_scxml(ros_declarations) for entry in self._body] + plain_body: List[ScxmlTransition] = [] + for entry in self._body: + plain_entries = entry.as_plain_scxml(ros_declarations) + if isinstance(plain_entries, ScxmlTransition): + plain_body.append(plain_entries) + elif isinstance(plain_entries, list) and \ + all(isinstance(e, ScxmlTransition) for e in plain_entries): + # Some special entries return multiple transitions + plain_body.extend(plain_entries) + else: + raise ValueError(f"Error: SCXML state {self._id}: found invalid transition in " + "state body after conversion to plain SCXML.") return ScxmlState(self._id, on_entry=plain_entry, on_exit=plain_exit, body=plain_body) def as_xml(self) -> ET.Element: diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_transition.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_transition.py index 1bd4bf02..9d62fed7 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/scxml_transition.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_transition.py @@ -141,6 +141,13 @@ def check_valid_ros_instantiations(self, return self._body is None or \ all(entry.check_valid_ros_instantiations(ros_declarations) for entry in self._body) + def set_thread_id(self, thread_id: int) -> None: + """Set the thread ID for the executable entries of this transition.""" + if self._body is not None: + for entry in self._body: + if hasattr(entry, "set_thread_id"): + entry.set_thread_id(thread_id) + def as_plain_scxml(self, ros_declarations: ScxmlRosDeclarationsContainer) -> "ScxmlTransition": assert isinstance(ros_declarations, ScxmlRosDeclarationsContainer), \ "Error: SCXML transition: invalid ROS declarations container." diff --git a/scxml_converter/src/scxml_converter/scxml_entries/utils.py b/scxml_converter/src/scxml_converter/scxml_entries/utils.py index 7e62e3bc..3810a73a 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/utils.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/utils.py @@ -15,7 +15,8 @@ """Collection of various utilities for scxml entries.""" -from typing import Dict, Type +from typing import Any, Dict, Type, MutableSequence +from array import array from scxml_converter.scxml_entries import ScxmlBase @@ -28,7 +29,8 @@ "int8": int, "int16": int, "int32": int, - "int64": int + "int64": int, + "int32[]": MutableSequence[int], # array.array('i): https://stackoverflow.com/a/67775675 } @@ -56,11 +58,36 @@ def is_non_empty_string(scxml_type: Type[ScxmlBase], arg_name: str, arg_value: s """ valid_str = isinstance(arg_value, str) and len(arg_value) > 0 if not valid_str: - print(f"Error: SCXML conversion of {scxml_type.get_tag_name()}: " + print(f"Error: SCXML entry from {scxml_type.__name__}: " f"Expected non-empty argument {arg_name}.") return valid_str def get_default_expression_for_type(field_type: str) -> str: """Generate a default expression for a field type.""" + if field_type not in SCXML_DATA_STR_TO_TYPE: + raise ValueError(f"Error: SCXML conversion of data entry: Unknown data type {field_type}.") + if '[' in field_type: + # array type, special handling + if field_type.startswith('int'): + return array('i') + elif field_type.startswith('float'): + return array('f') + else: + raise ValueError( + f"Error: SCXML conversion of data entry: unhandled array type {field_type}.") return str(SCXML_DATA_STR_TO_TYPE[field_type]()) + + +def convert_string_to_type(value: str, data_type: str) -> Any: + """ + Convert a value to the provided data type. Raise if impossible. + """ + assert data_type in SCXML_DATA_STR_TO_TYPE, \ + f"Error: SCXML conversion of data entry: Unknown data type {data_type}." + assert isinstance(value, str), \ + f"Error: SCXML conversion of data entry: expected a string, got {type(value)}." + assert len(value) > 0, "Error: SCXML conversion of data entry: Empty string." + assert '[' not in data_type, \ + f"Error: SCXML conversion of data entry: Cannot convert array type {data_type}." + return SCXML_DATA_STR_TO_TYPE[data_type](value) diff --git a/scxml_converter/src/scxml_converter/scxml_entries/xml_utils.py b/scxml_converter/src/scxml_converter/scxml_entries/xml_utils.py index 5b325379..2ed167fa 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/xml_utils.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/xml_utils.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import List, Optional, Tuple, Type, Union +from typing import List, Iterable, Optional, Union, Type from scxml_converter.scxml_entries import ScxmlBase from xml.etree.ElementTree import Element @@ -39,7 +39,7 @@ def get_xml_argument(scxml_type: Type[ScxmlBase], xml_tree: Element, arg_name: s def get_children_as_scxml( - xml_tree: Element, scxml_types: Tuple[Type[ScxmlBase]]) -> List[ScxmlBase]: + xml_tree: Element, scxml_types: Iterable[Type[ScxmlBase]]) -> List[ScxmlBase]: """ Load the children of the xml tree as scxml entries. @@ -56,42 +56,43 @@ def get_children_as_scxml( def read_value_from_xml_child( - xml_tree: Element, child_tag: str, valid_types: Tuple[Type[Union[ScxmlBase, str]]] - ) -> Optional[Union[str, ScxmlBase]]: + xml_tree: Element, child_tag: str, valid_types: Iterable[Type[Union[ScxmlBase, str]]], *, + none_allowed: bool = False) -> Optional[Union[str, ScxmlBase]]: """ Try to read the value of a child tag from the xml tree. If the child is not found, return None. """ xml_child = xml_tree.findall(child_tag) if xml_child is None or len(xml_child) == 0: - print(f"Error reading from {xml_tree.tag}: Cannot find child '{child_tag}'.") + if not none_allowed: + print(f"Error: reading from {xml_tree.tag}: Cannot find child '{child_tag}'.") return None if len(xml_child) > 1: - print(f"Error reading from {xml_tree.tag}: multiple children '{child_tag}', expected one.") + print(f"Error: reading from {xml_tree.tag}: multiple children '{child_tag}', expected one.") return None n_tag_children = len(xml_child[0]) if n_tag_children == 0 and str in valid_types: # Try to read the text value text_value = xml_child[0].text if text_value is None or len(text_value) == 0: - print(f"Error reading from {xml_tree.tag}: Child '{child_tag}' has no text value.") + print(f"Error: reading from {xml_tree.tag}: Child '{child_tag}' has no text value.") return None return text_value if n_tag_children > 1: - print(f"Error reading from {xml_tree.tag}: Child '{child_tag}' has multiple children.") + print(f"Error: reading from {xml_tree.tag}: Child '{child_tag}' has multiple children.") return None # Remove string from valid types, if present valid_types = tuple(t for t in valid_types if t != str) scxml_entry = get_children_as_scxml(xml_child[0], valid_types) if len(scxml_entry) == 0: - print(f"Error reading from {xml_tree.tag}: Child '{child_tag}' has no valid children.") + print(f"Error: reading from {xml_tree.tag}: Child '{child_tag}' has no valid children.") return None return scxml_entry[0] def read_value_from_xml_arg_or_child( scxml_type: Type[ScxmlBase], xml_tree: Element, tag_name: str, - valid_types: Tuple[Type[Union[ScxmlBase, str]]], - none_allowed=False) -> Optional[Union[str, ScxmlBase]]: + valid_types: Iterable[Type[Union[ScxmlBase, str]]], + none_allowed: bool = False) -> Optional[Union[str, ScxmlBase]]: """ Read a value from an xml attribute or, if not found, the child tag with the same name. @@ -102,7 +103,8 @@ def read_value_from_xml_arg_or_child( "If strings are not expected, use 'read_value_from_xml_child'." read_value = get_xml_argument(scxml_type, xml_tree, tag_name, none_allowed=True) if read_value is None: - read_value = read_value_from_xml_child(xml_tree, tag_name, valid_types) + read_value = read_value_from_xml_child(xml_tree, tag_name, valid_types, + none_allowed=none_allowed) if not none_allowed: assert read_value is not None, \ f"Error: SCXML conversion of {scxml_type.get_tag_name()}: Missing argument {tag_name}." diff --git a/scxml_converter/test/_test_data/add_int_srv_example/client_1.scxml b/scxml_converter/test/_test_data/add_int_srv_example/addition_client.scxml similarity index 100% rename from scxml_converter/test/_test_data/add_int_srv_example/client_1.scxml rename to scxml_converter/test/_test_data/add_int_srv_example/addition_client.scxml diff --git a/scxml_converter/test/_test_data/add_int_srv_example/server.scxml b/scxml_converter/test/_test_data/add_int_srv_example/addition_server.scxml similarity index 100% rename from scxml_converter/test/_test_data/add_int_srv_example/server.scxml rename to scxml_converter/test/_test_data/add_int_srv_example/addition_server.scxml diff --git a/scxml_converter/test/_test_data/add_int_srv_example/gt_plain_scxml/client_1.scxml b/scxml_converter/test/_test_data/add_int_srv_example/gt_plain_scxml/addition_client.scxml similarity index 93% rename from scxml_converter/test/_test_data/add_int_srv_example/gt_plain_scxml/client_1.scxml rename to scxml_converter/test/_test_data/add_int_srv_example/gt_plain_scxml/addition_client.scxml index b5c3d0c4..fca9af5c 100644 --- a/scxml_converter/test/_test_data/add_int_srv_example/gt_plain_scxml/client_1.scxml +++ b/scxml_converter/test/_test_data/add_int_srv_example/gt_plain_scxml/addition_client.scxml @@ -12,7 +12,7 @@
- + diff --git a/scxml_converter/test/_test_data/add_int_srv_example/gt_plain_scxml/server.scxml b/scxml_converter/test/_test_data/add_int_srv_example/gt_plain_scxml/addition_server.scxml similarity index 100% rename from scxml_converter/test/_test_data/add_int_srv_example/gt_plain_scxml/server.scxml rename to scxml_converter/test/_test_data/add_int_srv_example/gt_plain_scxml/addition_server.scxml diff --git a/scxml_converter/test/_test_data/battery_drainer_w_bt/bt.xml b/scxml_converter/test/_test_data/battery_drainer_w_bt/bt.xml index 2a4cb98b..b0c4c5b2 100644 --- a/scxml_converter/test/_test_data/battery_drainer_w_bt/bt.xml +++ b/scxml_converter/test/_test_data/battery_drainer_w_bt/bt.xml @@ -2,9 +2,9 @@ - + - + \ No newline at end of file diff --git a/scxml_converter/test/_test_data/battery_drainer_w_bt/bt_topic_action.scxml b/scxml_converter/test/_test_data/battery_drainer_w_bt/bt_topic_action.scxml index 53736c4f..e641fd10 100644 --- a/scxml_converter/test/_test_data/battery_drainer_w_bt/bt_topic_action.scxml +++ b/scxml_converter/test/_test_data/battery_drainer_w_bt/bt_topic_action.scxml @@ -2,7 +2,7 @@ diff --git a/scxml_converter/test/_test_data/battery_drainer_w_bt/bt_topic_condition.scxml b/scxml_converter/test/_test_data/battery_drainer_w_bt/bt_topic_condition.scxml index cd91b1a0..1947b660 100644 --- a/scxml_converter/test/_test_data/battery_drainer_w_bt/bt_topic_condition.scxml +++ b/scxml_converter/test/_test_data/battery_drainer_w_bt/bt_topic_condition.scxml @@ -2,7 +2,7 @@ diff --git a/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_bt_scxml/10000_TopicCondition.scxml b/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_bt_scxml/10000_BtTopicCondition.scxml similarity index 96% rename from scxml_converter/test/_test_data/battery_drainer_w_bt/gt_bt_scxml/10000_TopicCondition.scxml rename to scxml_converter/test/_test_data/battery_drainer_w_bt/gt_bt_scxml/10000_BtTopicCondition.scxml index 7e07abd3..ebdd927b 100644 --- a/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_bt_scxml/10000_TopicCondition.scxml +++ b/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_bt_scxml/10000_BtTopicCondition.scxml @@ -2,7 +2,7 @@ diff --git a/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_bt_scxml/1001_TopicAction.scxml b/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_bt_scxml/1001_BtTopicAction.scxml similarity index 95% rename from scxml_converter/test/_test_data/battery_drainer_w_bt/gt_bt_scxml/1001_TopicAction.scxml rename to scxml_converter/test/_test_data/battery_drainer_w_bt/gt_bt_scxml/1001_BtTopicAction.scxml index f76b81f0..e41a3659 100644 --- a/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_bt_scxml/1001_TopicAction.scxml +++ b/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_bt_scxml/1001_BtTopicAction.scxml @@ -2,7 +2,7 @@ diff --git a/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_bt_scxml/bt.scxml b/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_bt_scxml/bt.scxml index 0dd4e515..c8eb9860 100644 --- a/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_bt_scxml/bt.scxml +++ b/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_bt_scxml/bt.scxml @@ -2,7 +2,7 @@ - + @@ -13,15 +13,15 @@ - + - + - + diff --git a/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_parsed_scxml/bt_topic_condition.scxml b/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_parsed_scxml/bt_topic_condition.scxml index cd91b1a0..1947b660 100644 --- a/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_parsed_scxml/bt_topic_condition.scxml +++ b/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_parsed_scxml/bt_topic_condition.scxml @@ -2,7 +2,7 @@ diff --git a/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_plain_scxml/battery_drainer.scxml b/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_plain_scxml/battery_drainer.scxml index 3ce31988..9144e2f8 100644 --- a/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_plain_scxml/battery_drainer.scxml +++ b/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_plain_scxml/battery_drainer.scxml @@ -12,14 +12,14 @@ - + - + diff --git a/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_plain_scxml/battery_manager.scxml b/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_plain_scxml/battery_manager.scxml index 9ce00239..b91434e3 100644 --- a/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_plain_scxml/battery_manager.scxml +++ b/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_plain_scxml/battery_manager.scxml @@ -7,8 +7,8 @@ xmlns="http://www.w3.org/2005/07/scxml"> - - + + diff --git a/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_plain_scxml/bt_topic_action.scxml b/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_plain_scxml/bt_topic_action.scxml index afc8a495..569ab465 100644 --- a/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_plain_scxml/bt_topic_action.scxml +++ b/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_plain_scxml/bt_topic_action.scxml @@ -1,8 +1,8 @@ - + - + diff --git a/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_plain_scxml/bt_topic_condition.scxml b/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_plain_scxml/bt_topic_condition.scxml index e81ee036..f4fdadc1 100644 --- a/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_plain_scxml/bt_topic_condition.scxml +++ b/scxml_converter/test/_test_data/battery_drainer_w_bt/gt_plain_scxml/bt_topic_condition.scxml @@ -1,11 +1,11 @@ - + - + diff --git a/scxml_converter/test/_test_data/bt_ports_only/bt.xml b/scxml_converter/test/_test_data/bt_ports_only/bt.xml index f690a4d4..b526d0cd 100644 --- a/scxml_converter/test/_test_data/bt_ports_only/bt.xml +++ b/scxml_converter/test/_test_data/bt_ports_only/bt.xml @@ -1,8 +1,8 @@ - - + + \ No newline at end of file diff --git a/scxml_converter/test/_test_data/bt_ports_only/bt_topic_action.scxml b/scxml_converter/test/_test_data/bt_ports_only/bt_topic_action.scxml index 4dbce6c5..f8b0a64a 100644 --- a/scxml_converter/test/_test_data/bt_ports_only/bt_topic_action.scxml +++ b/scxml_converter/test/_test_data/bt_ports_only/bt_topic_action.scxml @@ -3,7 +3,7 @@ xmlns="http://www.w3.org/2005/07/scxml" initial="initial" version="1.0" - name="TopicAction" + name="BtTopicAction" model_src=""> diff --git a/scxml_converter/test/_test_data/bt_ports_only/gt_bt_scxml/1000_TopicAction.scxml b/scxml_converter/test/_test_data/bt_ports_only/gt_bt_scxml/1000_BtTopicAction.scxml similarity index 81% rename from scxml_converter/test/_test_data/bt_ports_only/gt_bt_scxml/1000_TopicAction.scxml rename to scxml_converter/test/_test_data/bt_ports_only/gt_bt_scxml/1000_BtTopicAction.scxml index 8012bf94..a63883e6 100644 --- a/scxml_converter/test/_test_data/bt_ports_only/gt_bt_scxml/1000_TopicAction.scxml +++ b/scxml_converter/test/_test_data/bt_ports_only/gt_bt_scxml/1000_BtTopicAction.scxml @@ -1,5 +1,5 @@ - + diff --git a/scxml_converter/test/_test_data/bt_ports_only/gt_bt_scxml/1001_TopicAction.scxml b/scxml_converter/test/_test_data/bt_ports_only/gt_bt_scxml/1001_BtTopicAction.scxml similarity index 81% rename from scxml_converter/test/_test_data/bt_ports_only/gt_bt_scxml/1001_TopicAction.scxml rename to scxml_converter/test/_test_data/bt_ports_only/gt_bt_scxml/1001_BtTopicAction.scxml index ffbcd88a..2ca7c28e 100644 --- a/scxml_converter/test/_test_data/bt_ports_only/gt_bt_scxml/1001_TopicAction.scxml +++ b/scxml_converter/test/_test_data/bt_ports_only/gt_bt_scxml/1001_BtTopicAction.scxml @@ -1,5 +1,5 @@ - + diff --git a/scxml_converter/test/_test_data/bt_ports_only/gt_bt_scxml/bt.scxml b/scxml_converter/test/_test_data/bt_ports_only/gt_bt_scxml/bt.scxml index 05cc4fb6..143a3edb 100644 --- a/scxml_converter/test/_test_data/bt_ports_only/gt_bt_scxml/bt.scxml +++ b/scxml_converter/test/_test_data/bt_ports_only/gt_bt_scxml/bt.scxml @@ -2,7 +2,7 @@ - + @@ -13,15 +13,15 @@ - + - + - + diff --git a/scxml_converter/test/_test_data/bt_ports_only/gt_parsed_scxml/bt_topic_action.scxml b/scxml_converter/test/_test_data/bt_ports_only/gt_parsed_scxml/bt_topic_action.scxml index c29c0222..7d47ca6f 100644 --- a/scxml_converter/test/_test_data/bt_ports_only/gt_parsed_scxml/bt_topic_action.scxml +++ b/scxml_converter/test/_test_data/bt_ports_only/gt_parsed_scxml/bt_topic_action.scxml @@ -3,7 +3,7 @@ xmlns="http://www.w3.org/2005/07/scxml" initial="initial" version="1.0" - name="TopicAction" + name="BtTopicAction" model_src=""> diff --git a/scxml_converter/test/_test_data/bt_ports_only/gt_plain_scxml/bt_topic_action.scxml b/scxml_converter/test/_test_data/bt_ports_only/gt_plain_scxml/bt_topic_action.scxml index 7ca960ef..b578cb46 100644 --- a/scxml_converter/test/_test_data/bt_ports_only/gt_plain_scxml/bt_topic_action.scxml +++ b/scxml_converter/test/_test_data/bt_ports_only/gt_plain_scxml/bt_topic_action.scxml @@ -1,12 +1,12 @@ - + - + diff --git a/scxml_converter/test/_test_data/fibonacci_action_example/.gitignore b/scxml_converter/test/_test_data/fibonacci_action_example/.gitignore new file mode 100644 index 00000000..b941f4e4 --- /dev/null +++ b/scxml_converter/test/_test_data/fibonacci_action_example/.gitignore @@ -0,0 +1,3 @@ +generated_bt_scxml +generated_plain_scxml +main.jani \ No newline at end of file diff --git a/scxml_converter/test/_test_data/fibonacci_action_example/client_1.scxml b/scxml_converter/test/_test_data/fibonacci_action_example/client_1.scxml new file mode 100644 index 00000000..560058b7 --- /dev/null +++ b/scxml_converter/test/_test_data/fibonacci_action_example/client_1.scxml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scxml_converter/test/_test_data/fibonacci_action_example/client_2.scxml b/scxml_converter/test/_test_data/fibonacci_action_example/client_2.scxml new file mode 100644 index 00000000..caa2cfbf --- /dev/null +++ b/scxml_converter/test/_test_data/fibonacci_action_example/client_2.scxml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/client_1.scxml b/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/client_1.scxml new file mode 100644 index 00000000..7de2b775 --- /dev/null +++ b/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/client_1.scxml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/client_2.scxml b/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/client_2.scxml new file mode 100644 index 00000000..db07c273 --- /dev/null +++ b/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/client_2.scxml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/fibonacci_thread_0.scxml b/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/fibonacci_thread_0.scxml new file mode 100644 index 00000000..4771f6c4 --- /dev/null +++ b/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/fibonacci_thread_0.scxml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/fibonacci_thread_1.scxml b/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/fibonacci_thread_1.scxml new file mode 100644 index 00000000..a8cca946 --- /dev/null +++ b/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/fibonacci_thread_1.scxml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/server.scxml b/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/server.scxml new file mode 100644 index 00000000..2019c327 --- /dev/null +++ b/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/server.scxml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/scxml_converter/test/_test_data/fibonacci_action_example/server.scxml b/scxml_converter/test/_test_data/fibonacci_action_example/server.scxml new file mode 100644 index 00000000..5e6d6420 --- /dev/null +++ b/scxml_converter/test/_test_data/fibonacci_action_example/server.scxml @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scxml_converter/test/test_systemtest_scxml_entries.py b/scxml_converter/test/test_systemtest_scxml_entries.py index c0fc9a02..88f03a3b 100644 --- a/scxml_converter/test/test_systemtest_scxml_entries.py +++ b/scxml_converter/test/test_systemtest_scxml_entries.py @@ -59,11 +59,11 @@ def test_battery_drainer_from_code(): ScxmlData("battery_percent", "100", "int16")])) use_battery_state = ScxmlState( "use_battery", - on_entry=[ScxmlSend("ros_topic.level", + on_entry=[ScxmlSend("topic_level_msg", [ScxmlParam("data", expr="battery_percent")])], body=[ScxmlTransition("use_battery", ["ros_time_rate.my_timer"], body=[ScxmlAssign("battery_percent", "battery_percent - 1")]), - ScxmlTransition("use_battery", ["ros_topic.charge"], + ScxmlTransition("use_battery", ["topic_charge_msg"], body=[ScxmlAssign("battery_percent", "100")])]) battery_drainer_scxml.add_state(use_battery_state, initial=True) _test_scxml_from_code(battery_drainer_scxml, os.path.join( @@ -112,7 +112,8 @@ def test_battery_drainer_ros_from_code(): RosRateCallback(ros_timer, "use_battery", None, [ScxmlAssign("battery_percent", "battery_percent - 1")])) use_battery_state.add_transition( - RosTopicCallback(ros_topic_sub, "use_battery", [ScxmlAssign("battery_percent", "100")])) + RosTopicCallback(ros_topic_sub, "use_battery", None, + [ScxmlAssign("battery_percent", "100")])) battery_drainer_scxml.add_state(use_battery_state, initial=True) _test_scxml_from_code(battery_drainer_scxml, os.path.join( os.path.dirname(__file__), '_test_data', 'battery_drainer_w_bt', @@ -131,7 +132,7 @@ def test_bt_action_with_ports_from_code(): RosTopicPublish(topic_publisher, [RosField("data", "number")]) ]) ]) - scxml_root = ScxmlRoot("TopicAction") + scxml_root = ScxmlRoot("BtTopicAction") scxml_root.set_data_model(data_model) scxml_root.add_bt_port_declaration(BtInputPortDeclaration("name", "string")) scxml_root.add_bt_port_declaration(BtInputPortDeclaration("data", "int16")) diff --git a/scxml_converter/test/test_systemtest_xml.py b/scxml_converter/test/test_systemtest_xml.py index 8dcaa234..b59d9971 100644 --- a/scxml_converter/test/test_systemtest_xml.py +++ b/scxml_converter/test/test_systemtest_xml.py @@ -17,7 +17,7 @@ from typing import Dict, List, Tuple -from test_utils import canonicalize_xml, remove_empty_lines +from test_utils import canonicalize_xml, remove_empty_lines, to_snake_case from scxml_converter.bt_converter import bt_converter from scxml_converter.scxml_entries import ScxmlRoot @@ -72,30 +72,52 @@ def bt_to_scxml_test( def ros_to_plain_scxml_test(test_folder: str, scxml_bt_ports: Dict[str, List[Tuple[str, str]]], + expected_scxmls: Dict[str, List[str]], store_generated: bool = False): - """Test the conversion of SCXML with ROS-specific macros to plain SCXML.""" + """ + Test the conversion of SCXML with ROS-specific macros to plain SCXML. + + :param test_folder: The path of the folder with the files to evaluate. + :param scxml_bt_ports: The BT ports to set to the specified SCXML file. + :param expected_scxmls: The SCXML object names expected from the specified input files. + :param store_generated: If True, the generated SCXML files are stored in the output folder. + """ test_data_path = os.path.join(os.path.dirname(__file__), '_test_data', test_folder) scxml_files = [file for file in os.listdir(test_data_path) if file.endswith('.scxml')] if store_generated: clear_output_folder(test_folder) for fname in scxml_files: input_file = os.path.join(test_data_path, fname) - gt_file = os.path.join(test_data_path, 'gt_plain_scxml', fname) + # gt_file = os.path.join(test_data_path, 'gt_plain_scxml', fname) try: scxml_obj = ScxmlRoot.from_scxml_file(input_file) if fname in scxml_bt_ports: scxml_obj.set_bt_ports_values(scxml_bt_ports[fname]) scxml_obj.update_bt_ports_values() - plain_scxml, _ = scxml_obj.to_plain_scxml_and_declarations() + plain_scxmls, _ = scxml_obj.to_plain_scxml_and_declarations() if store_generated: - output_file = os.path.join(get_output_folder(test_folder), fname) - with open(output_file, 'w') as f_o: - f_o.write(plain_scxml.as_xml_string()) - scxml_str = plain_scxml.as_xml_string() - with open(gt_file, 'r', encoding='utf-8') as f_o: - gt_output = f_o.read() - assert remove_empty_lines(canonicalize_xml(scxml_str)) == \ - remove_empty_lines(canonicalize_xml(gt_output)) + for generated_scxml in plain_scxmls: + output_file = os.path.join(get_output_folder(test_folder), + f'{generated_scxml.get_name()}.scxml') + with open(output_file, 'w') as f_o: + f_o.write(generated_scxml.as_xml_string()) + if fname not in expected_scxmls: + gt_files: List[str] = [fname.removesuffix('.scxml')] + else: + gt_files: List[str] = expected_scxmls[fname] + assert len(plain_scxmls) == len(gt_files), \ + f"Expecting {len(gt_files)} scxml objects, found {len(plain_scxmls)}." + for generated_scxml in plain_scxmls: + # Make sure the comparison uses snake case + scxml_object_name = to_snake_case(generated_scxml.get_name()) + assert scxml_object_name in gt_files, \ + f"Generated SCXML {scxml_object_name} not in gt SCXMLs {gt_files}." + gt_file_path = os.path.join(test_data_path, 'gt_plain_scxml', + f'{scxml_object_name}.scxml') + with open(gt_file_path, 'r', encoding='utf-8') as f_o: + gt_output = f_o.read() + assert remove_empty_lines(canonicalize_xml(generated_scxml.as_xml_string())) == \ + remove_empty_lines(canonicalize_xml(gt_output)) except Exception as e: print(f"Error in file {fname}:") raise e @@ -107,7 +129,7 @@ def test_bt_to_scxml_battery_drainer(): def test_ros_to_plain_scxml_battery_drainer(): - ros_to_plain_scxml_test('battery_drainer_w_bt', {}, True) + ros_to_plain_scxml_test('battery_drainer_w_bt', {}, {}, True) def test_bt_to_scxml_bt_ports(): @@ -116,9 +138,16 @@ def test_bt_to_scxml_bt_ports(): def test_ros_to_plain_scxml_bt_ports(): ros_to_plain_scxml_test('bt_ports_only', - {'bt_topic_action.scxml': [('name', 'out'), ('data', '123')]}, + {'bt_topic_action.scxml': [('name', 'out'), ('data', '123')]}, {}, True) def test_ros_to_plain_scxml_add_int_srv(): - ros_to_plain_scxml_test('add_int_srv_example', {}, True) + ros_to_plain_scxml_test('add_int_srv_example', {}, {}, True) + + +def test_ros_to_plain_scxml_fibonacci_action(): + ros_to_plain_scxml_test( + 'fibonacci_action_example', {}, + {"server.scxml": ["server", "fibonacci_thread_0", "fibonacci_thread_1"]}, + True) diff --git a/scxml_converter/test/test_utils.py b/scxml_converter/test/test_utils.py index 063871e1..01ac6da9 100644 --- a/scxml_converter/test/test_utils.py +++ b/scxml_converter/test/test_utils.py @@ -14,6 +14,12 @@ # limitations under the License. from xml.etree import ElementTree as ET +import re + + +def to_snake_case(text: str) -> str: + """Convert a string to snake case.""" + return re.sub(r'(? str: From ded0f7efcb30670f063ed57079030c37382a0e12 Mon Sep 17 00:00:00 2001 From: Marco Lampacrescia Date: Wed, 28 Aug 2024 16:06:12 +0200 Subject: [PATCH 03/23] Add array support in scxml and jani Signed-off-by: Marco Lampacrescia --- .github/workflows/test.yml | 2 +- as2fm_common/src/as2fm_common/common.py | 67 +- .../as2fm_common/ecmascript_interpretation.py | 24 +- ...test_unittest_ecmascript_interpretation.py | 56 + as2fm_common/test/test_utilities_smc_storm.py | 2 +- .../jani_generator/jani_entries/__init__.py | 2 +- .../jani_entries/jani_assignment.py | 4 +- .../jani_entries/jani_expression.py | 48 +- .../jani_generator/jani_entries/jani_model.py | 1 + .../jani_entries/jani_property.py | 28 +- .../jani_entries/jani_variable.py | 24 +- .../ros_helpers/ros_services.py | 6 +- .../scxml_helpers/scxml_data.py | 166 -- .../scxml_helpers/scxml_event_processor.py | 24 +- .../scxml_helpers/scxml_expression.py | 74 +- .../scxml_helpers/scxml_tags.py | 157 +- .../scxml_helpers/scxml_to_jani.py | 19 +- .../scxml_helpers/top_level_interpreter.py | 5 +- .../test/_test_data/array_model/.gitignore | 1 + .../test/_test_data/array_model/main.xml | 15 + .../_test_data/array_model/properties.jani | 38 + .../_test_data/array_model/receiver.scxml | 14 + .../test/_test_data/array_model/sender.scxml | 28 + .../battery_example/output_GROUND_TRUTH.jani | 1 + .../plain_jani_examples/array_test.jani | 145 ++ .../plain_jani_examples/example_arrays.jani | 2193 ----------------- .../ros_fibonacci_action_example/.gitignore | 3 - .../client_1.scxml | 46 - .../client_2.scxml | 45 - .../happy_clients.jani | 33 - .../ros_fibonacci_action_example/main.xml | 15 - .../ros_fibonacci_action_example/server.scxml | 149 -- .../test/test_systemtest_scxml_to_jani.py | 21 +- .../test/test_unittest_jani_model_loading.py | 8 +- .../test/test_unittest_scxml_data.py | 195 -- .../src/scxml_converter/scxml_converter.py | 59 +- .../scxml_entries/ros_utils.py | 9 +- .../scxml_entries/scxml_data.py | 43 +- .../scxml_entries/scxml_data_model.py | 15 +- .../scxml_entries/scxml_executable_entries.py | 7 +- .../scxml_entries/scxml_root.py | 9 +- .../scxml_entries/scxml_ros_base.py | 3 +- .../scxml_entries/scxml_ros_service.py | 6 +- .../scxml_entries/scxml_state.py | 12 +- .../scxml_converter/scxml_entries/utils.py | 17 - .../scxml_entries/xml_utils.py | 14 +- .../fibonacci_action_example/client_1.scxml | 3 +- .../fibonacci_action_example/client_2.scxml | 3 +- .../gt_plain_scxml/client_1.scxml | 2 +- .../gt_plain_scxml/client_2.scxml | 2 +- .../gt_plain_scxml/fibonacci_thread_0.scxml | 16 +- .../gt_plain_scxml/fibonacci_thread_1.scxml | 16 +- .../fibonacci_action_example/server.scxml | 16 +- .../test/test_unittest_scxml_data.py | 146 ++ 54 files changed, 941 insertions(+), 3116 deletions(-) create mode 100644 as2fm_common/test/test_unittest_ecmascript_interpretation.py delete mode 100644 jani_generator/src/jani_generator/scxml_helpers/scxml_data.py create mode 100644 jani_generator/test/_test_data/array_model/.gitignore create mode 100644 jani_generator/test/_test_data/array_model/main.xml create mode 100644 jani_generator/test/_test_data/array_model/properties.jani create mode 100644 jani_generator/test/_test_data/array_model/receiver.scxml create mode 100644 jani_generator/test/_test_data/array_model/sender.scxml create mode 100644 jani_generator/test/_test_data/plain_jani_examples/array_test.jani delete mode 100644 jani_generator/test/_test_data/plain_jani_examples/example_arrays.jani delete mode 100644 jani_generator/test/_test_data/ros_fibonacci_action_example/.gitignore delete mode 100644 jani_generator/test/_test_data/ros_fibonacci_action_example/client_1.scxml delete mode 100644 jani_generator/test/_test_data/ros_fibonacci_action_example/client_2.scxml delete mode 100644 jani_generator/test/_test_data/ros_fibonacci_action_example/happy_clients.jani delete mode 100644 jani_generator/test/_test_data/ros_fibonacci_action_example/main.xml delete mode 100644 jani_generator/test/_test_data/ros_fibonacci_action_example/server.scxml delete mode 100644 jani_generator/test/test_unittest_scxml_data.py create mode 100644 scxml_converter/test/test_unittest_scxml_data.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 57973028..fb6a08af 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,7 +42,7 @@ jobs: - name: Get smc_storm id: get_smc_storm run: | - wget https://github.com/convince-project/smc_storm/releases/download/0.0.2/smc_storm_executable.tar.gz + wget https://github.com/convince-project/smc_storm/releases/download/0.0.3/smc_storm_executable.tar.gz tar -xzf smc_storm_executable.tar.gz ./install.sh --install-dependencies # Save the path to the smc_storm executable diff --git a/as2fm_common/src/as2fm_common/common.py b/as2fm_common/src/as2fm_common/common.py index b6f7a2a3..d326eea8 100644 --- a/as2fm_common/src/as2fm_common/common.py +++ b/as2fm_common/src/as2fm_common/common.py @@ -17,7 +17,8 @@ Common functionalities used throughout the toolchain. """ -from typing import List, Union +from typing import get_args, MutableSequence, Union, Type +from array import array """ Set of basic types that are supported by the Jani language. @@ -37,30 +38,7 @@ Additionally, we support the array types from the array extension. """ -ValidTypes = Union[bool, int, float, List[int], List[float]] - - -def ros_type_name_to_python_type(type_str: str) -> type: - """Convert a string representing a type to a python type. - - Source: https://docs.ros.org/en/rolling/Concepts/Basic/\ - About-Interfaces.html#field-types - - :param type_str: The string representing the type - :return: The python type - """ - if type_str in ['bool', 'boolean']: - return bool - if type_str in ['int8', 'int16', 'int32', 'int64', - 'uint8', 'uint16', 'uint32', 'uint64']: - return int - if type_str in ['float32', 'float64']: - return float - if type_str in ['sequence', 'sequence']: - return List[int] - if type_str in ['sequence', 'sequence']: - return List[float] - raise NotImplementedError(f"Type {type_str} not supported.") +ValidTypes = Union[bool, int, float, MutableSequence[int], MutableSequence[float]] def remove_namespace(tag: str) -> str: @@ -77,3 +55,42 @@ def remove_namespace(tag: str) -> str: else: tag_wo_ns = tag return tag_wo_ns + + +def get_default_expression_for_type(field_type: Type[ValidTypes]) -> ValidTypes: + """Generate a default expression for a field type.""" + assert field_type in get_args(ValidTypes), f"Error: Unsupported data type {field_type}." + if field_type is MutableSequence[int]: + return array('i') + elif field_type is MutableSequence[float]: + return array('d') + else: + return field_type() + + +def value_to_type(value: ValidTypes) -> Type[ValidTypes]: + """Convert a value to a type.""" + if isinstance(value, array): + if value.typecode == 'i': + return MutableSequence[int] + elif value.typecode == 'd': + return MutableSequence[float] + else: + raise ValueError(f"Type of array '{value.typecode}' not supported.") + elif isinstance(value, (int, float, bool)): + return type(value) + else: + raise ValueError(f"Unsupported value type {type(value)}.") + + +def value_to_string(value: ValidTypes) -> str: + """Convert a value to a string.""" + if isinstance(value, MutableSequence): + # Expect value to be an array + return f'[{",".join(str(v) for v in value)}]' + elif isinstance(value, bool): + return str(value).lower() + elif isinstance(value, (int, float)): + return str(value) + else: + raise ValueError(f"Unsupported value type {type(value)}.") diff --git a/as2fm_common/src/as2fm_common/ecmascript_interpretation.py b/as2fm_common/src/as2fm_common/ecmascript_interpretation.py index 95c70ab7..fdd4a2a9 100644 --- a/as2fm_common/src/as2fm_common/ecmascript_interpretation.py +++ b/as2fm_common/src/as2fm_common/ecmascript_interpretation.py @@ -17,16 +17,21 @@ Module for interpreting ecmascript. """ -from typing import Dict, Optional +from typing import Dict, Optional, Union +from array import array import js2py from as2fm_common.common import ValidTypes +BASIC_JS_TYPES = Union[int, float, bool] + + def interpret_ecma_script_expr( expr: str, variables: Optional[Dict[str, ValidTypes]] = None) -> object: - """Interpret the ECMA script expression. + """ + Interpret the ECMA script expression. :param expr: The ECMA script expression :return: The interpreted object @@ -35,4 +40,17 @@ def interpret_ecma_script_expr( variables = {} context = js2py.EvalJs(variables) context.execute("result = " + expr) - return context.result + expr_result = context.result + if isinstance(expr_result, BASIC_JS_TYPES): + return expr_result + elif isinstance(expr_result, js2py.base.JsObjectWrapper): + if isinstance(expr_result._obj, js2py.base.PyJsArray): + return expr_result.to_list() + else: + raise ValueError(f"Expected expr. {expr} to be of type {BASIC_JS_TYPES} or " + f"an array, got '{type(expr_result._obj)}'") + elif isinstance(expr_result, array): + return expr_result + else: + raise ValueError(f"Expected expr. {expr} to be of type {BASIC_JS_TYPES} or " + f"JsObjectWrapper, got '{type(expr_result)}'") diff --git a/as2fm_common/test/test_unittest_ecmascript_interpretation.py b/as2fm_common/test/test_unittest_ecmascript_interpretation.py new file mode 100644 index 00000000..09805ac1 --- /dev/null +++ b/as2fm_common/test/test_unittest_ecmascript_interpretation.py @@ -0,0 +1,56 @@ +# Copyright (c) 2024 - for information on the respective copyright owner +# see the NOTICE file + +# 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. + +""""Test the SCXML data conversion""" + +import unittest +import pytest + +from as2fm_common.ecmascript_interpretation import interpret_ecma_script_expr + + +class TestEcmascriptInterpreter(unittest.TestCase): + + def test_ecmascript_types(self): + """ + Test with ECMAScript expression that evaluates to different types. + + src https://alexzhornyak.github.io/SCXML-tutorial/Doc/\ + datamodel.html#ecmascript + """ + self.assertEqual(interpret_ecma_script_expr("1"), 1) + self.assertEqual(interpret_ecma_script_expr("1.1"), 1.1) + self.assertEqual(interpret_ecma_script_expr("true"), True) + self.assertEqual(interpret_ecma_script_expr("false"), False) + self.assertEqual(interpret_ecma_script_expr("[1,2,3]"), [1, 2, 3]) + + def test_ecmascript_unsupported(self): + """ + Test with ECMA script expressions that evaluates to unsupported types. + + This should raise a ValueError because the types are not supported + by Jani. + + src https://alexzhornyak.github.io/SCXML-tutorial/Doc/\ + datamodel.html#ecmascript + """ + self.assertRaises(ValueError, interpret_ecma_script_expr, "\'this is a string\'") + self.assertRaises(ValueError, interpret_ecma_script_expr, "null") + self.assertRaises(ValueError, interpret_ecma_script_expr, "undefined") + self.assertRaises(ValueError, interpret_ecma_script_expr, "new Date()") + + +if __name__ == '__main__': + pytest.main(['-s', '-v', __file__]) diff --git a/as2fm_common/test/test_utilities_smc_storm.py b/as2fm_common/test/test_utilities_smc_storm.py index 6bc794fa..708c9261 100644 --- a/as2fm_common/test/test_utilities_smc_storm.py +++ b/as2fm_common/test/test_utilities_smc_storm.py @@ -43,7 +43,7 @@ def _run_smc_storm(args: str): def test_run_smc_storm(): """Testing if it is possible to run smc_storm.""" - result =_run_smc_storm("-v") + result = _run_smc_storm("-v") assert result, "smc_storm failed to run" diff --git a/jani_generator/src/jani_generator/jani_entries/__init__.py b/jani_generator/src/jani_generator/jani_entries/__init__.py index b5c1b5b9..a947bb31 100644 --- a/jani_generator/src/jani_generator/jani_entries/__init__.py +++ b/jani_generator/src/jani_generator/jani_entries/__init__.py @@ -1,7 +1,7 @@ # isort: skip_file # Skipping file to avoid circular import problem from .jani_value import JaniValue # noqa: F401 -from .jani_expression import JaniExpression # noqa: F401 +from .jani_expression import JaniExpression, JaniExpressionType # noqa: F401 from .jani_constant import JaniConstant # noqa: F401 from .jani_variable import JaniVariable # noqa: F401 from .jani_assignment import JaniAssignment # noqa: F401 diff --git a/jani_generator/src/jani_generator/jani_entries/jani_assignment.py b/jani_generator/src/jani_generator/jani_entries/jani_assignment.py index 597932dc..7aa0e1db 100644 --- a/jani_generator/src/jani_generator/jani_entries/jani_assignment.py +++ b/jani_generator/src/jani_generator/jani_entries/jani_assignment.py @@ -31,7 +31,7 @@ class JaniAssignment: def __init__(self, assignment_dict: dict): """Initialize the assignment from a dictionary""" - self._var_name = assignment_dict["ref"] + self._var_name = JaniExpression(assignment_dict["ref"]) self._value = JaniExpression(assignment_dict["value"]) self._index = 0 if "index" in assignment_dict: @@ -41,7 +41,7 @@ def as_dict(self, constants: Dict[str, JaniConstant]): """Transform the assignment to a dictionary""" expanded_value = expand_expression(self._value, constants) return { - "ref": self._var_name, + "ref": self._var_name.as_dict(), "value": expanded_value.as_dict(), "index": self._index } diff --git a/jani_generator/src/jani_generator/jani_entries/jani_expression.py b/jani_generator/src/jani_generator/jani_entries/jani_expression.py index a15cfdea..61539381 100644 --- a/jani_generator/src/jani_generator/jani_entries/jani_expression.py +++ b/jani_generator/src/jani_generator/jani_entries/jani_expression.py @@ -17,21 +17,29 @@ Expressions in Jani """ -from typing import Any, Dict, Optional, Union +from enum import Enum +from typing import Any, Dict, Optional, Tuple, Union from jani_generator.jani_entries import JaniValue SupportedExp = Union[str, int, float, bool, dict] +class JaniExpressionType(Enum): + """Enumeration of the different types of Jani expressions.""" + IDENTIFIER = 1 # Reference to a constant or variable id + LITERAL = 2 # Reference to a literal value + OPERATOR = 3 # Reference to an operator (a composition of expressions) + + class JaniExpression: """ Jani Expression class. Content of an instance of this class can be: - - identifier: a string representing a reference to a constant or variable (literal) + - identifier: a string representing a reference to a constant or variable or - - value: a JaniValue object + - value: a JaniValue object (literal expression) or - op: a string representing an operator - operands: a dictionary of operands, related to the specified operator @@ -40,7 +48,7 @@ def __init__(self, expression: Union[SupportedExp, 'JaniExpression', JaniValue]) self.identifier: Optional[str] = None self.value: Optional[JaniValue] = None self.op: Optional[str] = None - self.operands: Dict[str, Union[JaniExpression, JaniValue]] = {} + self.operands: Dict[str, JaniExpression] = {} if isinstance(expression, JaniExpression): self.identifier = expression.identifier self.value = expression.value @@ -66,7 +74,7 @@ def __init__(self, expression: Union[SupportedExp, 'JaniExpression', JaniValue]) self.op = expression["op"] self.operands = self._get_operands(expression) - def _get_operands(self, expression_dict: dict): + def _get_operands(self, expression_dict: dict) -> Dict[str, 'JaniExpression']: assert self.op is not None, "Operator not set" if (self.op in ("intersect", "distance")): # intersect: returns a value in [0.0, 1.0], indicating where on the robot trajectory @@ -123,7 +131,18 @@ def _get_operands(self, expression_dict: dict): "y2": JaniExpression(expression_dict["y2"])} assert False, f"Unknown operator \"{self.op}\" found." - def replace_event(self, replacement): + def get_expression_type(self) -> JaniExpressionType: + """Get the type of the expression.""" + assert self.is_valid(), "Expression is not valid" + if self.identifier is not None: + return JaniExpressionType.IDENTIFIER + if self.value is not None: + return JaniExpressionType.LITERAL + if self.op is not None: + return JaniExpressionType.OPERATOR + raise RuntimeError("Unknown expression type") + + def replace_event(self, replacement: Optional[str]): """Replace `_event` with `replacement`. Within a transitions, scxml can access data of events from the `_event` variable. We @@ -149,6 +168,23 @@ def replace_event(self, replacement): def is_valid(self) -> bool: return self.identifier is not None or self.value is not None or self.op is not None + def as_literal(self) -> Optional[JaniValue]: + """Provide the expression as a literal (JaniValue), if possible. None otherwise.""" + assert self.is_valid(), "Expression is not valid" + return self.value + + def as_identifier(self) -> Optional[str]: + """Provide the expression as an identifier, if possible. None otherwise.""" + assert self.is_valid(), "Expression is not valid" + return self.identifier + + def as_operator(self) -> Optional[Tuple[str, Dict[str, 'JaniExpression']]]: + """Provide the expression as an operator, if possible. None otherwise.""" + assert self.is_valid(), "Expression is not valid" + if self.op is None: + return None + return (self.op, self.operands) + def as_dict(self) -> Union[str, int, float, bool, dict]: assert hasattr(self, "identifier"), f"Identifier not set for {self.__dict__}" if self.identifier is not None: diff --git a/jani_generator/src/jani_generator/jani_entries/jani_model.py b/jani_generator/src/jani_generator/jani_entries/jani_model.py index aaf29fc2..23130c40 100644 --- a/jani_generator/src/jani_generator/jani_entries/jani_model.py +++ b/jani_generator/src/jani_generator/jani_entries/jani_model.py @@ -158,6 +158,7 @@ def as_dict(self): "jani-version": 1, "name": self._name, "type": self._type, + "features": self._features, "metadata": { "description": "Autogenerated with CONVINCE toolchain", }, diff --git a/jani_generator/src/jani_generator/jani_entries/jani_property.py b/jani_generator/src/jani_generator/jani_entries/jani_property.py index e500b46d..96ec1857 100644 --- a/jani_generator/src/jani_generator/jani_entries/jani_property.py +++ b/jani_generator/src/jani_generator/jani_entries/jani_property.py @@ -102,12 +102,20 @@ class PathProperty: """Mainly Until properties. Need to check support of Next and Global properties in Jani.""" def __init__(self, prop_values: Dict[str, Any]): self._valid = False - for entry in ("op", "left", "right"): - if entry not in prop_values: - return - self._op = prop_values["op"] - self._left = JaniExpression(prop_values["left"]) - self._right = JaniExpression(prop_values["right"]) + if "op" not in prop_values: + return + self._op: str = prop_values["op"] + self._operands: Dict[str, JaniExpression] = {} + if self._op == "F": + self._operands = {"exp": JaniExpression(prop_values["exp"])} + elif self._op in ("U", "W"): + self._operands = { + "left": JaniExpression(prop_values["left"]), + "right": JaniExpression(prop_values["right"]) + } + else: + print(f"Warning: Unsupported PathProperty operator {self._op}") + return self._bounds = None if "step-bounds" in prop_values: self._bounds = PathPropertyStepBounds(prop_values["step-bounds"]) @@ -120,11 +128,9 @@ def is_valid(self) -> bool: return self._valid def as_dict(self, constants: Dict[str, JaniConstant]): - ret_dict = { - "op": self._op, - "left": expand_expression(self._left, constants).as_dict(), - "right": expand_expression(self._right, constants).as_dict() - } + ret_dict = {"op": self._op} + ret_dict.update({operand: expand_expression(expr, constants).as_dict() for + operand, expr in self._operands.items()}) if self._bounds is not None: ret_dict["step-bounds"] = self._bounds.as_dict(constants) return ret_dict diff --git a/jani_generator/src/jani_generator/jani_entries/jani_variable.py b/jani_generator/src/jani_generator/jani_entries/jani_variable.py index 4a27d9f6..f2ac8836 100644 --- a/jani_generator/src/jani_generator/jani_entries/jani_variable.py +++ b/jani_generator/src/jani_generator/jani_entries/jani_variable.py @@ -17,7 +17,7 @@ Variables in Jani """ -from typing import List, Optional, Union, Type, get_args +from typing import MutableSequence, Optional, Union, Type, get_args from as2fm_common.common import ValidTypes from jani_generator.jani_entries import JaniExpression, JaniValue @@ -56,10 +56,11 @@ def __init__(self, v_name: str, v_type: Type[ValidTypes], init_value: Optional[Union[JaniExpression, JaniValue]] = None, v_transient: bool = False): assert init_value is None or isinstance(init_value, (JaniExpression, JaniValue)), \ - "Init value should be a JaniExpression or a JaniValue" - self._name = v_name - self._type = v_type - self._transient = v_transient + f"Expected {v_name} init_value {init_value} to be of type " \ + f"(JaniExpression, JaniValue), found {type(init_value)} instead." + self._name: str = v_name + self._type: Type[ValidTypes] = v_type + self._transient: bool = v_transient self._init_expr: Optional[JaniExpression] = None if init_value is not None: self._init_expr = JaniExpression(init_value) @@ -72,9 +73,10 @@ def __init__(self, v_name: str, v_type: Type[ValidTypes], elif self._type == float: self._init_expr = JaniExpression(0.0) else: - raise ValueError(f"Type {self._type} needs an initial value") + raise ValueError( + f"JaniVariable {self._name} of type {self._type} needs an initial value") assert v_type in get_args(ValidTypes), f"Type {v_type} not supported by Jani" - if not self._transient and self._type in (float, List[float]): + if not self._transient and self._type in (float, MutableSequence[float]): print(f"Warning: Variable {self._name} is not transient and has type float." "This is not supported by STORM.") @@ -116,9 +118,9 @@ def python_type_from_json(json_type: Union[str, dict]) -> ValidTypes: if json_type["kind"] == "array": assert "base" in json_type, "Array type should contain a 'base' key" if json_type["base"] == "int": - return List[int] + return MutableSequence[int] if json_type["base"] == "real": - return List[float] + return MutableSequence[float] raise ValueError(f"Type {json_type} not supported by Jani") @staticmethod @@ -144,9 +146,9 @@ def python_type_to_json(v_type: Type[ValidTypes]) -> Union[str, dict]: return "int" elif v_type == float: return "real" - elif v_type == List[int]: + elif v_type == MutableSequence[int]: return {"kind": "array", "base": "int"} - elif v_type == List[float]: + elif v_type == MutableSequence[float]: return {"kind": "array", "base": "real"} else: raise ValueError(f"Type {v_type} not supported by Jani") diff --git a/jani_generator/src/jani_generator/ros_helpers/ros_services.py b/jani_generator/src/jani_generator/ros_helpers/ros_services.py index 93722eef..b3e8f85c 100644 --- a/jani_generator/src/jani_generator/ros_helpers/ros_services.py +++ b/jani_generator/src/jani_generator/ros_helpers/ros_services.py @@ -19,15 +19,16 @@ from typing import Dict, List, Optional +from as2fm_common.common import get_default_expression_for_type, value_to_string from jani_generator.jani_entries import JaniModel from scxml_converter.scxml_entries import (ScxmlAssign, ScxmlData, ScxmlDataModel, ScxmlParam, ScxmlRoot, ScxmlSend, ScxmlState, ScxmlTransition) -from scxml_converter.scxml_entries.utils import get_default_expression_for_type from scxml_converter.scxml_entries.ros_utils import ( generate_srv_request_event, generate_srv_response_event, generate_srv_server_request_event, generate_srv_server_response_event, get_srv_type_params, sanitize_ros_interface_name) +from scxml_converter.scxml_entries.utils import SCXML_DATA_STR_TO_TYPE SRV_PREFIX = "srv_handler_" @@ -105,7 +106,8 @@ def to_scxml(self) -> ScxmlRoot: # Hack: Using support variables in the data model to avoid having _event in send params req_fields_as_data = [] for field_name, field_type in req_params.items() | res_params.items(): - default_expr = get_default_expression_for_type(field_type) + default_expr = value_to_string( + get_default_expression_for_type(SCXML_DATA_STR_TO_TYPE[field_type])) req_fields_as_data.append(ScxmlData(field_name, default_expr, field_type)) # Make sure the service name has no slashes and spaces scxml_root_name = SRV_PREFIX + sanitize_ros_interface_name(self._service_name) diff --git a/jani_generator/src/jani_generator/scxml_helpers/scxml_data.py b/jani_generator/src/jani_generator/scxml_helpers/scxml_data.py deleted file mode 100644 index 8fa5a640..00000000 --- a/jani_generator/src/jani_generator/scxml_helpers/scxml_data.py +++ /dev/null @@ -1,166 +0,0 @@ -# Copyright (c) 2024 - for information on the respective copyright owner -# see the NOTICE file - -# 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. - -""" -Module handling SCXML data tags. -""" - -import re -import xml.etree.ElementTree as ET -from typing import Dict, List, Optional, get_args - -from as2fm_common.common import ros_type_name_to_python_type -from as2fm_common.ecmascript_interpretation import interpret_ecma_script_expr -from jani_generator.jani_entries.jani_expression import JaniExpression -from jani_generator.jani_entries.jani_variable import JaniVariable, ValidTypes - - -class ScxmlData: - """Object representing a data tag from a SCXML file. - - See https://www.w3.org/TR/scxml/#data - """ - - def __init__(self, element: ET.Element, - comment_above: Optional[str] = None) -> None: - """Initialize the ScxmlData object from an xml element. - - :param element: The xml element representing the data tag. - :param comment_above: The comment in the line above the data tag. - """ - - # reading official attributes - self.id: str = element.attrib['id'] - self.xml_src: Optional[str] = element.attrib.get('src', None) - self.xml_expr: Optional[str] = element.attrib.get('expr', None) - if self.xml_src is not None: - raise NotImplementedError( - "src attribute in data tag is not supported yet.") - - # unofficial attributes - self.xml_type: Optional[str] = element.attrib.get('type', None) - - # trying to find the type of the data - types_from_comment_above: Optional[Dict[str, type]] = \ - self._interpret_type_from_comment_above(comment_above) - type_from_comment_above: Optional[type] = \ - types_from_comment_above.get(self.id, None) \ - if types_from_comment_above is not None else None - type_from_xml_type_attr: Optional[type] = \ - ros_type_name_to_python_type(self.xml_type) \ - if self.xml_type is not None else None - type_from_expr: Optional[type] = \ - self._interpret_ecma_script_expr_to_type(self.xml_expr) \ - if self.xml_expr is not None else None - self.type: type = self._evalute_possible_types( - type_from_comment_above, - type_from_xml_type_attr, - type_from_expr) - if self.type not in get_args(ValidTypes): - raise ValueError(f"Type {self.type} not supported by Jani.") - - # trying to find the initial value of the data - self.initial_value: ValidTypes = ( - self.type(interpret_ecma_script_expr(self.xml_expr)) - if self.xml_expr is not None - else self.type()) - - def _interpret_type_from_comment_above( - self, comment_above: Optional[str]) -> Optional[Dict[str, type]]: - """Interpret the type of the data from the comment above the data tag. - - :param comment_above: The comment above the data tag (optional) - :return: The type of the data, None if not found - """ - if comment_above is None: - return None - # match string inside xml comment brackets - match = re.match(r'', comment_above.strip()) - if match is None: - return None - comment_content = match.group(1).strip() - if 'TYPE' not in comment_content: - return None - type_infos = {} - for type_info in comment_content.split(): - if ':' not in type_info: - continue - key, value = type_info.split(':') - type_infos[key] = ros_type_name_to_python_type(value) - if len(type_infos) == 0: - return None - return type_infos - - def _interpret_ecma_script_expr_to_type(self, expr: str) -> type: - """Interpret the type of the data from the ECMA script expression. - - :param expr: The ECMA script expression - :return: The type of the data - """ - my_type = type(interpret_ecma_script_expr(expr)) - if my_type not in get_args(ValidTypes): - raise ValueError( - f"Type {my_type} must be supported by Jani.") - return my_type - - def _evalute_possible_types( - self, - type_from_comment_above: Optional[type], - type_from_xml_type_attr: Optional[type], - type_from_expr: Optional[type]) -> type: - """Evaluate the possible types of the data. - - This is done by comparing the types from the comment above, the xml type - attribute and the expression tag. - - :param type_from_comment_above: The type from the comment above the data tag - :param type_from_xml_type_attr: The type from the xml type attribute - :param type_from_expr: The type from the expression tag - :raises ValueError: If no type or multiple conflicting types are found - :return: The evaluated type - """ - types = set() - if type_from_comment_above is not None: - types.add(type_from_comment_above) - if type_from_xml_type_attr is not None: - types.add(type_from_xml_type_attr) - if type_from_expr is not None: - types.add(type_from_expr) - if len(types) == 0: - raise ValueError( - f"Could not determine type for data {self.id}") - elif len(types) == 1: - return types.pop() - else: # len(types) > 1 - raise ValueError( - f"Multiple types found for data {self.id}: {types}") - - def get_type(self) -> type: - """Get the type of the data. - - :return: The type of the data - """ - return self.type - - def to_jani_variable(self) -> JaniVariable: - """Convert the ScxmlData object to a JaniVariable object. - - :return: The JaniVariable object - """ - return JaniVariable( - self.id, - self.type, - JaniExpression(self.initial_value) - ) diff --git a/jani_generator/src/jani_generator/scxml_helpers/scxml_event_processor.py b/jani_generator/src/jani_generator/scxml_helpers/scxml_event_processor.py index 61f6a15e..6caf4ea7 100644 --- a/jani_generator/src/jani_generator/scxml_helpers/scxml_event_processor.py +++ b/jani_generator/src/jani_generator/scxml_helpers/scxml_event_processor.py @@ -17,12 +17,13 @@ Module to process events from scxml and implement them as syncs between jani automata. """ -from typing import List +from typing import List, MutableSequence from jani_generator.jani_entries import JaniModel from jani_generator.jani_entries.jani_automaton import JaniAutomaton from jani_generator.jani_entries.jani_composition import JaniComposition from jani_generator.jani_entries.jani_edge import JaniEdge +from jani_generator.jani_entries.jani_expression_generator import array_create_operator from jani_generator.ros_helpers.ros_timer import RosTimer from jani_generator.scxml_helpers.scxml_event import EventsHolder from scxml_converter.scxml_converter import ROS_TIMER_RATE_EVENT_PREFIX @@ -31,6 +32,7 @@ def implement_scxml_events_as_jani_syncs( events_holder: EventsHolder, timers: List[RosTimer], + max_array_size: int, jani_model: JaniModel) -> List[str]: """ Implement the scxml events as jani syncs. @@ -120,12 +122,28 @@ def implement_scxml_events_as_jani_syncs( sender.automaton_name: action_name} jc.add_sync(action_name, senders_syncs) # Add the global data, if needed - for p_name, p_type_str in event.get_data_structure().items(): + for p_name, p_type in event.get_data_structure().items(): + init_value = None + is_array = False + if p_type is MutableSequence[int]: + init_value = array_create_operator("__array_iterator", max_array_size, 0) + is_array = True + elif p_type is MutableSequence[float]: + init_value = array_create_operator("__array_iterator", max_array_size, 0.0) + is_array = True # TODO: Dots are likely to create problems in the future. Consider replacing them jani_model.add_variable( variable_name=f"{event_name}.{p_name}", - variable_type=p_type_str + variable_type=p_type, + variable_init_expression=init_value ) + # In case of arrays, add a variable representing the array size, too + if is_array: + jani_model.add_variable( + variable_name=f"{event_name}.{p_name}.length", + variable_type=int, + variable_init_expression=0 + ) # For each event, we add an extra boolean flag for data validity jani_model.add_variable( variable_name=f"{event_name}.valid", diff --git a/jani_generator/src/jani_generator/scxml_helpers/scxml_expression.py b/jani_generator/src/jani_generator/scxml_helpers/scxml_expression.py index fb0f4848..e87c8b1a 100644 --- a/jani_generator/src/jani_generator/scxml_helpers/scxml_expression.py +++ b/jani_generator/src/jani_generator/scxml_helpers/scxml_expression.py @@ -17,56 +17,90 @@ Module producing jani expressions from ecmascript. """ +from typing import Optional, Type, Union +from dataclasses import dataclass import esprima from jani_generator.jani_entries.jani_convince_expression_expansion import \ BASIC_EXPRESSIONS_MAPPING from jani_generator.jani_entries.jani_expression import JaniExpression +from jani_generator.jani_entries.jani_expression_generator import ( + array_access_operator, array_create_operator) from jani_generator.jani_entries.jani_value import JaniValue -def parse_ecmascript_to_jani_expression(ecmascript: str) -> JaniExpression: +@dataclass() +class ArrayInfo: + array_type: Type[Union[int, float]] + array_max_size: int + + +def parse_scxml_identifier(identifier: str) -> JaniExpression: + """ + Parse an scxml identifier to a jani expression. + + :param identifier: The scxml identifier to parse. + :return: The jani expression. + """ + return JaniExpression(parse_ecmascript_to_jani_expression(identifier)) + + +def parse_ecmascript_to_jani_expression( + ecmascript: str, array_info: Optional[ArrayInfo] = None) -> JaniExpression: """ Parse ecmascript to jani expression. :param ecmascript: The ecmascript to parse. + :param array_info: The type and max size of the array, if required. :return: The jani expression. """ ast = esprima.parseScript(ecmascript) assert len(ast.body) == 1, "The ecmascript must contain exactly one expression." ast = ast.body[0] - return _parse_ecmascript_to_jani_expression(ast) + return _parse_ecmascript_to_jani_expression(ast, array_info) -def _parse_ecmascript_to_jani_expression(ast: esprima.nodes.Script) -> JaniExpression: +def _parse_ecmascript_to_jani_expression( + ast: esprima.nodes.Script, array_info: Optional[ArrayInfo] = None) -> JaniExpression: """ Parse ecmascript to jani expression. :param ecmascript: The ecmascript to parse. + :param array_info: The type and max size of the array, if required. :return: The jani expression. """ if ast.type == "Literal": return JaniExpression(JaniValue(ast.value)) + elif ast.type == "ArrayExpression": + assert array_info is not None, "Array info must be provided for ArrayExpressions." + assert len(ast.elements) == 0, "Array expressions with elements are not supported." + return array_create_operator("__array_iterator", array_info.array_max_size, + JaniValue(array_info.array_type(0))) elif ast.type == "Identifier": # If it is an identifier, we do not need to expand further return JaniExpression(ast.name) elif ast.type == "MemberExpression": - # A identifier in the style of object.property - name = f'{ast.object.name}.{ast.property.name}' - return JaniExpression(name) + if ast.computed: + # This is an array access, like array[0] + # For now, prevent nested arrays + assert ast.object.type == "Identifier", "Nested arrays are not supported." + array_name = ast.object.name + array_index = _parse_ecmascript_to_jani_expression(ast.property, array_info) + return array_access_operator(array_name, array_index) + else: + # A identifier in the style of object.property + name = f'{ast.object.name}.{ast.property.name}' + return JaniExpression(name) elif ast.type == "ExpressionStatement": - return _parse_ecmascript_to_jani_expression(ast.expression) - else: + return _parse_ecmascript_to_jani_expression(ast.expression, array_info) + elif ast.type == "BinaryExpression": # It is a more complex expression - if ast.type == "BinaryExpression": - if ast.operator in BASIC_EXPRESSIONS_MAPPING: - operator = BASIC_EXPRESSIONS_MAPPING[ast.operator] - else: - operator = ast.operator - return JaniExpression({ - "op": operator, - "left": _parse_ecmascript_to_jani_expression(ast.left), - "right": _parse_ecmascript_to_jani_expression(ast.right) - }) - else: - raise NotImplementedError(f"Unsupported ecmascript type: {ast.type}") + assert ast.operator in BASIC_EXPRESSIONS_MAPPING, \ + f"ecmascript to jani expression: unknown operator {ast.operator}" + return JaniExpression({ + "op": BASIC_EXPRESSIONS_MAPPING[ast.operator], + "left": _parse_ecmascript_to_jani_expression(ast.left, array_info), + "right": _parse_ecmascript_to_jani_expression(ast.right, array_info) + }) + else: + raise NotImplementedError(f"Unsupported ecmascript type: {ast.type}") diff --git a/jani_generator/src/jani_generator/scxml_helpers/scxml_tags.py b/jani_generator/src/jani_generator/scxml_helpers/scxml_tags.py index 5e978d65..c9b07c63 100644 --- a/jani_generator/src/jani_generator/scxml_helpers/scxml_tags.py +++ b/jani_generator/src/jani_generator/scxml_helpers/scxml_tags.py @@ -19,17 +19,18 @@ import xml.etree.ElementTree as ET from hashlib import sha256 -from typing import Dict, List, Optional, Set, Tuple, Union +from typing import Dict, List, MutableSequence, Optional, Set, Tuple, Union +from as2fm_common.common import get_default_expression_for_type, value_to_type from as2fm_common.ecmascript_interpretation import interpret_ecma_script_expr -from jani_generator.jani_entries import (JaniAssignment, JaniAutomaton, - JaniEdge, JaniExpression, JaniGuard, - JaniVariable) +from jani_generator.jani_entries import ( + JaniAssignment, JaniAutomaton, JaniEdge, JaniExpression, JaniExpressionType, JaniGuard, + JaniValue, JaniVariable) from jani_generator.jani_entries.jani_expression_generator import ( - and_operator, not_operator) + and_operator, not_operator, max_operator, plus_operator) from jani_generator.scxml_helpers.scxml_event import Event, EventsHolder -from jani_generator.scxml_helpers.scxml_expression import \ - parse_ecmascript_to_jani_expression +from jani_generator.scxml_helpers.scxml_expression import ( + ArrayInfo, parse_ecmascript_to_jani_expression, parse_scxml_identifier) from scxml_converter.scxml_entries import (ScxmlAssign, ScxmlBase, ScxmlData, ScxmlDataModel, ScxmlExecutionBody, ScxmlIf, ScxmlRoot, ScxmlSend, @@ -56,24 +57,70 @@ def _hash_element(element: Union[ET.Element, ScxmlBase, List[str]]) -> str: return sha256(s.encode()).hexdigest()[:8] -def _interpret_scxml_assign(elem: ScxmlAssign, event_substitution: Optional[str] = None, - assign_index: int = 0) -> JaniAssignment: +def _is_variable_array(jani_automaton: JaniAutomaton, variable_name: Optional[str]) -> bool: + """Check if a variable is an array. + + :param jani_automaton: The Jani automaton to check the variable in. + :param variable_name: The name of the variable to check. + :return: True if the variable is an array, False otherwise. + """ + assert variable_name is not None, "Variable name must be provided." + variable = jani_automaton.get_variables().get(variable_name) + assert variable is not None, \ + f"Variable {variable_name} not found in {jani_automaton.get_variables()}." + return variable.get_type() in (MutableSequence[int], MutableSequence[float]) + + +def _interpret_scxml_assign( + elem: ScxmlAssign, jani_automaton: JaniAutomaton, event_substitution: Optional[str] = None, + assign_index: int = 0) -> List[JaniAssignment]: """Interpret SCXML assign element. :param element: The SCXML element to interpret. + :param jani_automaton: The Jani automaton related to the current scxml. Used for variable types. + :param event_substitution: The event to substitute in the expression. :return: The action or expression to be executed. """ assert isinstance(elem, ScxmlAssign), \ f"Expected ScxmlAssign, got {type(elem)}" + assignment_target = parse_scxml_identifier(elem.get_location()) + # Check if the target is an array, in case copy the length too assignment_value = parse_ecmascript_to_jani_expression( - elem.get_expr()) - if isinstance(assignment_value, JaniExpression): - assignment_value.replace_event(event_substitution) - return JaniAssignment({ - "ref": elem.get_location(), - "value": assignment_value, - "index": assign_index - }) + elem.get_expr()).replace_event(event_substitution) + assignments: List[JaniAssignment] = [ + JaniAssignment({"ref": assignment_target, "value": assignment_value, "index": assign_index}) + ] + # Handle array types + target_expr_type = assignment_target.get_expression_type() + if target_expr_type == JaniExpressionType.IDENTIFIER: + assignment_identifier = assignment_target.as_identifier() + if _is_variable_array(jani_automaton, assignment_identifier): + # We are dealing with an array, so we need to ensure: + # 1. The assignment_value is another identifier (and it is an array) + source_array_id = assignment_value.as_identifier() + assert source_array_id is not None, \ + "Array assignments can only copy another array identifier." + # 2. The length of the array is copied too + assignments.append(JaniAssignment({ + "ref": f"{assignment_identifier}.length", + "value": JaniExpression(f"{source_array_id}.length") + })) + elif target_expr_type == JaniExpressionType.OPERATOR: + op_type, operands = assignment_target.as_operator() + if op_type == "aa": + # We are dealing with an array assignment. Update the length too + array_name = operands['exp'].as_identifier() + assert array_name is not None, "Array assignments expects an array identifier exp." + array_length_id = f"{array_name}.length" + array_idx = operands['index'] + # Note: we do not make sure the max length increase is 1 (that is our assumption) + # One way to do it could be to set the array length to -1 in case of broken assumptions + new_length = max_operator(plus_operator(array_idx, 1), array_length_id) + assignments.append(JaniAssignment({ + "ref": array_length_id, + "value": new_length + })) + return assignments def _merge_conditions( @@ -124,9 +171,9 @@ def _append_scxml_body_to_jani_automaton(jani_automaton: JaniAutomaton, events_h })) for i, ec in enumerate(body): if isinstance(ec, ScxmlAssign): - assign_index = len(new_edges[-1].destinations[0]['assignments']) - jani_assignment = _interpret_scxml_assign(ec, trigger_event, assign_index) - new_edges[-1].destinations[0]['assignments'].append(jani_assignment) + assign_idx = len(new_edges[-1].destinations[0]['assignments']) + jani_assigns = _interpret_scxml_assign(ec, jani_automaton, trigger_event, assign_idx) + new_edges[-1].destinations[0]['assignments'].extend(jani_assigns) elif isinstance(ec, ScxmlSend): event_name = ec.get_event() event_send_action_name = event_name + "_on_send" @@ -141,23 +188,31 @@ def _append_scxml_body_to_jani_automaton(jani_automaton: JaniAutomaton, events_h "assignments": [] }] }) - data_structure_for_event = {} + data_structure_for_event: Dict[str, type] = {} for param in ec.get_params(): + param_assign_name = f'{ec.get_event()}.{param.get_name()}' expr = param.get_expr() if param.get_expr() is not None else \ param.get_location() + jani_expr = parse_ecmascript_to_jani_expression(expr).replace_event(trigger_event) new_edge.destinations[0]['assignments'].append(JaniAssignment({ - "ref": f'{ec.get_event()}.{param.get_name()}', - "value": parse_ecmascript_to_jani_expression( - expr).replace_event(trigger_event) + "ref": param_assign_name, + "value": jani_expr })) + # If we are sending an array, set the length as well + if jani_expr.get_expression_type() == JaniExpressionType.IDENTIFIER: + variable_name = jani_expr.as_identifier() + if _is_variable_array(jani_automaton, variable_name): + new_edge.destinations[0]['assignments'].append(JaniAssignment({ + "ref": f'{param_assign_name}.length', + "value": f"{variable_name}.length"})) variables = {} for n, v in jani_automaton.get_variables().items(): - variables[n] = v.get_type()() + variables[n] = get_default_expression_for_type(v.get_type()) # TODO: We should get the type explicitly: sometimes the expression is underdefined print(f"Interpreting {expr} with {variables}") # This might contain reference to event variables, that have no type specified - data_structure_for_event[param.get_name()] = \ - type(interpret_ecma_script_expr(expr, variables)) + data_structure_for_event[param.get_name()] = value_to_type( + interpret_ecma_script_expr(expr, variables)) new_edge.destinations[0]['assignments'].append(JaniAssignment({ "ref": f'{ec.get_event()}.valid', "value": True @@ -183,28 +238,26 @@ def _append_scxml_body_to_jani_automaton(jani_automaton: JaniAutomaton, events_h interm_loc_after = f"{source}_{i}_after_if" new_edges[-1].destinations[0]['location'] = interm_loc_before previous_conditions: List[JaniExpression] = [] - for cond_str, conditional_body in ec.get_conditional_executions(): - print(f"Condition: {cond_str}") - print(f"Body: {conditional_body}") + for if_idx, (cond_str, conditional_body) in enumerate(ec.get_conditional_executions()): current_cond = parse_ecmascript_to_jani_expression(cond_str) jani_cond = _merge_conditions( previous_conditions, current_cond).replace_event(trigger_event) sub_edges, sub_locs = _append_scxml_body_to_jani_automaton( jani_automaton, events_holder, conditional_body, interm_loc_before, - interm_loc_after, '-'.join([hash_str, _hash_element(ec), cond_str]), + interm_loc_after, '-'.join([hash_str, _hash_element(ec), str(if_idx)]), JaniGuard(jani_cond), None) new_edges.extend(sub_edges) new_locations.extend(sub_locs) previous_conditions.append(current_cond) # Add else branch: if no else is provided, we assume an empty else body! else_execution_body = ec.get_else_execution() + else_execution_id = str(len(ec.get_conditional_executions())) else_execution_body = [] if else_execution_body is None else else_execution_body - print(f"Else: {ec.get_else_execution()}") jani_cond = _merge_conditions( previous_conditions).replace_event(trigger_event) sub_edges, sub_locs = _append_scxml_body_to_jani_automaton( jani_automaton, events_holder, ec.get_else_execution(), interm_loc_before, - interm_loc_after, '-'.join([hash_str, _hash_element(ec), 'else']), + interm_loc_after, '-'.join([hash_str, _hash_element(ec), else_execution_id]), JaniGuard(jani_cond), None) new_edges.extend(sub_edges) new_locations.extend(sub_locs) @@ -230,30 +283,38 @@ class BaseTag: @staticmethod def from_element(element: ScxmlBase, call_trace: List[ScxmlBase], - model: ModelTupleType) -> 'BaseTag': + model: ModelTupleType, + max_array_size: int) -> 'BaseTag': """Return the correct tag object based on the xml element. :param element: The xml element representing the tag. + :param call_trace: The call trace of the element, to access the parents. + :param model: The model to write the tag to. + :param max_array_size: The maximum index of the arrays in the model. :return: The corresponding tag object. """ if type(element) not in CLASS_BY_TYPE: raise NotImplementedError(f"Support for SCXML type >{type(element)}< not implemented.") - return CLASS_BY_TYPE[type(element)](element, call_trace, model) + return CLASS_BY_TYPE[type(element)](element, call_trace, model, max_array_size) def __init__(self, element: ScxmlBase, call_trace: List[ScxmlBase], - model: ModelTupleType) -> None: + model: ModelTupleType, + max_array_size: int) -> None: """Initialize the ScxmlTag object from an xml element. :param element: The xml element representing the tag. + :param call_trace: The call trace of the element, to access the parents. + :param model: The model to write the tag to. + :param max_array_size: The maximum index of the arrays in the model. """ + self.max_array_size = max_array_size self.element = element - self.model = model self.automaton, self.events_holder = model self.call_trace = call_trace scxml_children = self.get_children() self.children = [ - BaseTag.from_element(child, call_trace + [element], model) + BaseTag.from_element(child, call_trace + [element], model, max_array_size) for child in scxml_children] def get_children(self) -> List[ScxmlBase]: @@ -290,13 +351,27 @@ def write_model(self): assert scxml_data.check_validity(), "Found invalid data entry." # TODO: ScxmlData from scxml_helpers provide many more options. # It should be ported to scxml_entries.ScxmlDataModel - init_value = parse_ecmascript_to_jani_expression(scxml_data.get_expr()) + expected_type = scxml_data.get_type() + array_info: Optional[ArrayInfo] = None + if expected_type is MutableSequence[int]: + array_info = ArrayInfo(int, self.max_array_size) + expected_type = list + elif expected_type is MutableSequence[float]: + array_info = ArrayInfo(float, self.max_array_size) + expected_type = list + init_value = parse_ecmascript_to_jani_expression(scxml_data.get_expr(), array_info) expr_type = type(interpret_ecma_script_expr(scxml_data.get_expr())) - assert expr_type == scxml_data.get_type(), \ - f"Expected type {scxml_data.get_type()}, got {expr_type}." + assert expr_type == expected_type, \ + f"Expected type {expected_type}, got {expr_type}." # TODO: Add support for lower and upper bounds self.automaton.add_variable( JaniVariable(scxml_data.get_name(), scxml_data.get_type(), init_value)) + # In case of arrays, declare an additional 'length' variable + # In this case, use dot notation, as in JS arrays + if expected_type is list: + # TODO: The length variable NEEDS to be bounded + self.automaton.add_variable( + JaniVariable(f"{scxml_data.get_name()}.length", int, JaniValue(0))) class ScxmlTag(BaseTag): diff --git a/jani_generator/src/jani_generator/scxml_helpers/scxml_to_jani.py b/jani_generator/src/jani_generator/scxml_helpers/scxml_to_jani.py index 3afacdb2..3538ec76 100644 --- a/jani_generator/src/jani_generator/scxml_helpers/scxml_to_jani.py +++ b/jani_generator/src/jani_generator/scxml_helpers/scxml_to_jani.py @@ -33,7 +33,8 @@ def convert_scxml_root_to_jani_automaton( - scxml_root: ScxmlRoot, jani_automaton: JaniAutomaton, events_holder: EventsHolder + scxml_root: ScxmlRoot, jani_automaton: JaniAutomaton, events_holder: EventsHolder, + max_array_size: int ) -> None: """ Convert an SCXML element to a Jani automaton. @@ -41,23 +42,29 @@ def convert_scxml_root_to_jani_automaton( :param element: The SCXML element to convert (Must be the root scxml tag of the file). :param jani_automaton: The Jani automaton to write the converted element to. :param events_holder: The holder for the events to be implemented as Jani syncs. + :param max_array_size: The max size of the arrays in the model. """ BaseTag.from_element(scxml_root, [], (jani_automaton, - events_holder)).write_model() + events_holder), max_array_size).write_model() def convert_multiple_scxmls_to_jani( scxmls: List[Union[str, ScxmlRoot]], timers: List[RosTimer], - max_time_ns: int + max_time_ns: int, + max_array_size: int ) -> JaniModel: """ Assemble automata from multiple SCXML files into a Jani model. - :param scxml_paths: The paths to the SCXML files to convert. + :param scxmls: List of SCXML Root objects (or file paths) to be included in the Jani model. + :param timers: List of ROS timers to be included in the Jani model. + :param max_time_ns: The maximum time in nanoseconds. + :param max_array_size: The max size of the arrays in the model. :return: The Jani model containing the converted automata. """ base_model = JaniModel() + base_model.add_feature("arrays") events_holder = EventsHolder() for input_scxml in scxmls: if isinstance(input_scxml, str): @@ -68,11 +75,11 @@ def convert_multiple_scxmls_to_jani( assert scxml_root.is_plain_scxml(), \ f"Input model {scxml_root.get_name()} does not contain a plain SCXML model." automaton = JaniAutomaton() - convert_scxml_root_to_jani_automaton(scxml_root, automaton, events_holder) + convert_scxml_root_to_jani_automaton(scxml_root, automaton, events_holder, max_array_size) base_model.add_jani_automaton(automaton) timer_automaton = make_global_timer_automaton(timers, max_time_ns) if timer_automaton is not None: base_model.add_jani_automaton(timer_automaton) - implement_scxml_events_as_jani_syncs(events_holder, timers, base_model) + implement_scxml_events_as_jani_syncs(events_holder, timers, max_array_size, base_model) remove_empty_self_loops_from_srv_handlers_in_jani(base_model) return base_model diff --git a/jani_generator/src/jani_generator/scxml_helpers/top_level_interpreter.py b/jani_generator/src/jani_generator/scxml_helpers/top_level_interpreter.py index 4616ec33..96261f6c 100644 --- a/jani_generator/src/jani_generator/scxml_helpers/top_level_interpreter.py +++ b/jani_generator/src/jani_generator/scxml_helpers/top_level_interpreter.py @@ -35,6 +35,7 @@ @dataclass() class FullModel: max_time: Optional[int] = None + max_array_size: int = field(default=100) bt: Optional[str] = None plugins: List[str] = field(default_factory=list) skills: List[str] = field(default_factory=list) @@ -86,6 +87,8 @@ def parse_main_xml(xml_path: str) -> FullModel: # time_resolution = _parse_time_element(mc_parameter) if remove_namespace(mc_parameter.tag) == "max_time": model.max_time = _parse_time_element(mc_parameter) + elif remove_namespace(mc_parameter.tag) == "max_array_size": + model.max_array_size = int(mc_parameter.attrib["value"]) else: raise ValueError( f"Invalid mc_parameter tag: {mc_parameter.tag}") @@ -188,7 +191,7 @@ def interpret_top_level_xml(xml_path: str, store_generated_scxmls: bool = False) f.write(scxml_model.as_xml_string()) jani_model = convert_multiple_scxmls_to_jani( - plain_scxml_models, all_timers, model.max_time) + plain_scxml_models, all_timers, model.max_time, model.max_array_size) jani_dict = jani_model.as_dict() assert len(model.properties) == 1, "Only one property is supported right now." diff --git a/jani_generator/test/_test_data/array_model/.gitignore b/jani_generator/test/_test_data/array_model/.gitignore new file mode 100644 index 00000000..bc3f33dc --- /dev/null +++ b/jani_generator/test/_test_data/array_model/.gitignore @@ -0,0 +1 @@ +main.jani \ No newline at end of file diff --git a/jani_generator/test/_test_data/array_model/main.xml b/jani_generator/test/_test_data/array_model/main.xml new file mode 100644 index 00000000..2a8b4f5a --- /dev/null +++ b/jani_generator/test/_test_data/array_model/main.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jani_generator/test/_test_data/array_model/properties.jani b/jani_generator/test/_test_data/array_model/properties.jani new file mode 100644 index 00000000..447b2915 --- /dev/null +++ b/jani_generator/test/_test_data/array_model/properties.jani @@ -0,0 +1,38 @@ +{ + "properties": [ + { + "name": "array_check", + "comment": "Not working due to array access not supported in properties", + "expression": { + "op": "filter", + "fun": "values", + "values": { + "op": "Pmin", + "exp": { + "op": "F", + "exp": { + "op": "∧", + "left": { + "op": "=", + "left": 11, + "right": "new_msg_array.msg.length" + }, + "right": { + "op": "=", + "left": { + "op": "aa", + "exp": "new_msg_array.msg", + "index": 10 + }, + "right": 10 + } + } + } + }, + "states": { + "op": "initial" + } + } + } + ] +} \ No newline at end of file diff --git a/jani_generator/test/_test_data/array_model/receiver.scxml b/jani_generator/test/_test_data/array_model/receiver.scxml new file mode 100644 index 00000000..4fd6be8a --- /dev/null +++ b/jani_generator/test/_test_data/array_model/receiver.scxml @@ -0,0 +1,14 @@ + + + + + + + + + \ No newline at end of file diff --git a/jani_generator/test/_test_data/array_model/sender.scxml b/jani_generator/test/_test_data/array_model/sender.scxml new file mode 100644 index 00000000..dc36bb4f --- /dev/null +++ b/jani_generator/test/_test_data/array_model/sender.scxml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jani_generator/test/_test_data/battery_example/output_GROUND_TRUTH.jani b/jani_generator/test/_test_data/battery_example/output_GROUND_TRUTH.jani index 33dddfef..829b9664 100644 --- a/jani_generator/test/_test_data/battery_example/output_GROUND_TRUTH.jani +++ b/jani_generator/test/_test_data/battery_example/output_GROUND_TRUTH.jani @@ -2,6 +2,7 @@ "jani-version": 1, "name": "", "type": "mdp", + "features": ["arrays"], "metadata": { "description": "Autogenerated with CONVINCE toolchain" }, diff --git a/jani_generator/test/_test_data/plain_jani_examples/array_test.jani b/jani_generator/test/_test_data/plain_jani_examples/array_test.jani new file mode 100644 index 00000000..eb65c8cd --- /dev/null +++ b/jani_generator/test/_test_data/plain_jani_examples/array_test.jani @@ -0,0 +1,145 @@ +{ + "jani-version": 1, + "name": "array_example", + "type": "mdp", + "features": [ + "arrays" + ], + "variables": [ + { + "name": "normal_array", + "type": { + "kind": "array", + "base": "int" + }, + "transient": false, + "initial-value": { + "op": "ac", + "var": "__array_iterator", + "length": 15, + "exp": 0 + } + }, + { + "name": "next_id", + "type": "int", + "transient": false, + "initial-value": 0 + } + ], + "constants": [], + "actions": [ + { + "name": "step" + } + ], + "automata": [ + { + "name": "array_writer", + "locations": [ + { + "name": "step" + } + ], + "initial-locations": [ + "step" + ], + "edges": [ + { + "location": "step", + "destinations": [ + { + "location": "step", + "assignments": [ + { + "ref": { + "op": "aa", + "exp": "normal_array", + "index": "next_id" + }, + "value": "next_id", + "index": 0 + }, + { + "ref": "next_id", + "value": { + "op": "+", + "left": "next_id", + "right": 1 + }, + "index": 1 + } + ] + } + ], + "action": "step" + } + ], + "variables": [] + } + ], + "system": { + "elements": [ + { + "automaton": "array_writer" + } + ], + "syncs": [ + { + "result": "step", + "synchronise": [ + "step" + ] + } + ] + }, + "properties": [ + { + "name": "property_no_array_access", + "expression": { + "op": "filter", + "fun": "values", + "values": { + "op": "Pmin", + "exp": { + "op": "F", + "exp": { + "op": "=", + "left": "next_id", + "right": 10 + } + } + }, + "states": { + "op": "initial" + } + } + }, + { + "name": "property_with_array_access", + "comment": "Not working due to array access not supported in properties", + "expression": { + "op": "filter", + "fun": "values", + "values": { + "op": "Pmin", + "exp": { + "op": "F", + "exp": { + "op": "=", + "left": { + "op": "aa", + "exp": "normal_array", + "index": 10 + }, + "right": 10 + } + } + }, + "states": { + "op": "initial" + } + } + } + ] +} \ No newline at end of file diff --git a/jani_generator/test/_test_data/plain_jani_examples/example_arrays.jani b/jani_generator/test/_test_data/plain_jani_examples/example_arrays.jani deleted file mode 100644 index 441fcf2d..00000000 --- a/jani_generator/test/_test_data/plain_jani_examples/example_arrays.jani +++ /dev/null @@ -1,2193 +0,0 @@ -{ - "jani-version": 1, - "name": "example_arrays", - "type": "mdp", - "features": [ - "arrays" - ], - "metadata": { - "description": "Autogenerated with CONVINCE toolchain" - }, - "variables": [ - {"name": "array_idx", - "type": "int", - "initial-value": 0 - }, - { - "name": "are_array_ok", - "type": { - "kind": "array", - "base": "int" - }, - "transient": false, - "initial-value": { - "op": "ac", - "var": "array_idx", - "length": 2, - "exp": 100 - } - }, - { - "name": "ros_topic.level.data", - "type": "int", - "transient": false, - "initial-value": 0 - }, - { - "name": "ros_topic.level.valid", - "type": "bool", - "transient": false, - "initial-value": false - }, - { - "name": "ros_topic.charge.valid", - "type": "bool", - "transient": false, - "initial-value": false - }, - { - "name": "ros_topic.alarm.data", - "type": "bool", - "transient": false, - "initial-value": false - }, - { - "name": "ros_topic.alarm.valid", - "type": "bool", - "transient": false, - "initial-value": false - }, - { - "name": "bt_1000_tick.valid", - "type": "bool", - "transient": false, - "initial-value": false - }, - { - "name": "bt_1000_success.valid", - "type": "bool", - "transient": false, - "initial-value": false - }, - { - "name": "bt_1000_failure.valid", - "type": "bool", - "transient": false, - "initial-value": false - }, - { - "name": "bt_1001_tick.valid", - "type": "bool", - "transient": false, - "initial-value": false - }, - { - "name": "bt_1001_success.valid", - "type": "bool", - "transient": false, - "initial-value": false - } - ], - "constants": [], - "actions": [ - { - "name": "bt_1000_failure_on_receive" - }, - { - "name": "bt_1000_failure_on_send" - }, - { - "name": "bt_1000_success_on_receive" - }, - { - "name": "bt_1000_success_on_send" - }, - { - "name": "bt_1000_tick_on_receive" - }, - { - "name": "bt_1000_tick_on_send" - }, - { - "name": "bt_1001_success_on_receive" - }, - { - "name": "bt_1001_success_on_send" - }, - { - "name": "bt_1001_tick_on_receive" - }, - { - "name": "bt_1001_tick_on_send" - }, - { - "name": "check_battery-first-exec-check_battery-a449c803" - }, - { - "name": "failure-wait_for_tick-6568ac14" - }, - { - "name": "global_timer_action_0" - }, - { - "name": "initial-initial-5eebea38" - }, - { - "name": "initial_0_before_if-initial_0_after_if-5eebea38-860ebf50-else" - }, - { - "name": "initial_0_before_if-initial_0_after_if-5eebea38-860ebf50-last_msg" - }, - { - "name": "ros_time_rate.bt_tick_on_receive" - }, - { - "name": "ros_time_rate.my_timer_on_receive" - }, - { - "name": "ros_topic.alarm_on_receive" - }, - { - "name": "ros_topic.alarm_on_send" - }, - { - "name": "ros_topic.charge_on_receive" - }, - { - "name": "ros_topic.charge_on_send" - }, - { - "name": "ros_topic.level_on_receive" - }, - { - "name": "ros_topic.level_on_send" - }, - { - "name": "running-wait_for_tick-89afabc1" - }, - { - "name": "success-wait_for_tick-c305aa83" - }, - { - "name": "tick-1000_TopicCondition-8aab702a" - }, - { - "name": "use_battery-first-exec-use_battery-766fa6e4" - } - ], - "automata": [ - { - "name": "BatteryDrainer", - "locations": [ - { - "name": "use_battery" - }, - { - "name": "use_battery-1-59a11059" - }, - { - "name": "use_battery-1-c00ac01e" - }, - { - "name": "use_battery-first-exec" - }, - { - "name": "use_battery-first-exec-0-766fa6e4" - } - ], - "initial-locations": [ - "use_battery-first-exec" - ], - "edges": [ - { - "location": "use_battery", - "destinations": [ - { - "location": "use_battery-1-c00ac01e", - "assignments": [ - { - "ref": { - "op": "aa", - "exp": "are_array_ok", - "index": 1 - }, - "value": { - "op": "-", - "left": { - "op": "aa", - "exp": "are_array_ok", - "index": 1 - }, - "right": 1 - }, - "index": 0 - } - ] - } - ], - "action": "ros_time_rate.my_timer_on_receive", - "guard": { - "exp": { - "op": ">", - "left": { - "op": "aa", - "exp": "are_array_ok", - "index": 1 - }, - "right": 0 - } - } - }, - { - "location": "use_battery-1-c00ac01e", - "destinations": [ - { - "location": "use_battery", - "assignments": [ - { - "ref": "ros_topic.level.data", - "value": { - "op": "aa", - "exp": "are_array_ok", - "index": 1 - }, - "index": 0 - }, - { - "ref": "ros_topic.level.valid", - "value": true, - "index": 0 - } - ] - } - ], - "action": "ros_topic.level_on_send" - }, - { - "location": "use_battery", - "destinations": [ - { - "location": "use_battery-1-59a11059", - "assignments": [ - { - "ref": { - "op": "aa", - "exp": "are_array_ok", - "index": 1 - }, - "value": 100, - "index": 0 - } - ] - } - ], - "action": "ros_topic.charge_on_receive" - }, - { - "location": "use_battery-1-59a11059", - "destinations": [ - { - "location": "use_battery", - "assignments": [ - { - "ref": "ros_topic.level.data", - "value": { - "op": "aa", - "exp": "are_array_ok", - "index": 1 - }, - "index": 0 - }, - { - "ref": "ros_topic.level.valid", - "value": true, - "index": 0 - } - ] - } - ], - "action": "ros_topic.level_on_send" - }, - { - "location": "use_battery", - "destinations": [ - { - "location": "use_battery", - "assignments": [] - } - ], - "action": "ros_time_rate.my_timer_on_receive", - "guard": { - "exp": { - "op": "∧", - "left": true, - "right": { - "op": "¬", - "exp": { - "op": ">", - "left": { - "op": "aa", - "exp": "are_array_ok", - "index": 1 - }, - "right": 0 - } - } - } - } - }, - { - "location": "use_battery-first-exec", - "destinations": [ - { - "location": "use_battery-first-exec-0-766fa6e4", - "assignments": [] - } - ], - "action": "use_battery-first-exec-use_battery-766fa6e4" - }, - { - "location": "use_battery-first-exec-0-766fa6e4", - "destinations": [ - { - "location": "use_battery", - "assignments": [ - { - "ref": "ros_topic.level.data", - "value": { - "op": "aa", - "exp": "are_array_ok", - "index": 1 - }, - "index": 0 - }, - { - "ref": "ros_topic.level.valid", - "value": true, - "index": 0 - } - ] - } - ], - "action": "ros_topic.level_on_send" - } - ] - }, - { - "name": "BatteryManager", - "locations": [ - { - "name": "check_battery" - }, - { - "name": "check_battery-1-f1b4fdd4" - }, - { - "name": "check_battery-first-exec" - }, - { - "name": "check_battery-first-exec-0-a449c803" - } - ], - "initial-locations": [ - "check_battery-first-exec" - ], - "edges": [ - { - "location": "check_battery", - "destinations": [ - { - "location": "check_battery-1-f1b4fdd4", - "assignments": [ - { - "ref": "battery_alarm", - "value": { - "op": "<", - "left": "ros_topic.level.data", - "right": 30 - }, - "index": 0 - } - ] - } - ], - "action": "ros_topic.level_on_receive" - }, - { - "location": "check_battery-1-f1b4fdd4", - "destinations": [ - { - "location": "check_battery", - "assignments": [ - { - "ref": "ros_topic.alarm.data", - "value": "battery_alarm", - "index": 0 - }, - { - "ref": "ros_topic.alarm.valid", - "value": true, - "index": 0 - } - ] - } - ], - "action": "ros_topic.alarm_on_send" - }, - { - "location": "check_battery-first-exec", - "destinations": [ - { - "location": "check_battery-first-exec-0-a449c803", - "assignments": [] - } - ], - "action": "check_battery-first-exec-check_battery-a449c803" - }, - { - "location": "check_battery-first-exec-0-a449c803", - "destinations": [ - { - "location": "check_battery", - "assignments": [ - { - "ref": "ros_topic.alarm.data", - "value": "battery_alarm", - "index": 0 - }, - { - "ref": "ros_topic.alarm.valid", - "value": true, - "index": 0 - } - ] - } - ], - "action": "ros_topic.alarm_on_send" - } - ], - "variables": [ - { - "name": "battery_alarm", - "type": "bool", - "transient": false, - "initial-value": false - } - ] - }, - { - "name": "TopicCondition", - "locations": [ - { - "name": "initial" - }, - { - "name": "initial_0_after_if" - }, - { - "name": "initial_0_before_if" - }, - { - "name": "initial_0_before_if-0-5eebea38-860ebf50-else" - }, - { - "name": "initial_0_before_if-0-5eebea38-860ebf50-last_msg" - } - ], - "initial-locations": [ - "initial" - ], - "edges": [ - { - "location": "initial", - "destinations": [ - { - "location": "initial", - "assignments": [ - { - "ref": "last_msg", - "value": "ros_topic.alarm.data", - "index": 0 - } - ] - } - ], - "action": "ros_topic.alarm_on_receive" - }, - { - "location": "initial", - "destinations": [ - { - "location": "initial_0_before_if", - "assignments": [] - } - ], - "action": "bt_1000_tick_on_receive" - }, - { - "location": "initial_0_before_if", - "destinations": [ - { - "location": "initial_0_before_if-0-5eebea38-860ebf50-last_msg", - "assignments": [] - } - ], - "action": "initial_0_before_if-initial_0_after_if-5eebea38-860ebf50-last_msg", - "guard": { - "exp": "last_msg" - } - }, - { - "location": "initial_0_before_if-0-5eebea38-860ebf50-last_msg", - "destinations": [ - { - "location": "initial_0_after_if", - "assignments": [ - { - "ref": "bt_1000_success.valid", - "value": true, - "index": 0 - } - ] - } - ], - "action": "bt_1000_success_on_send" - }, - { - "location": "initial_0_before_if", - "destinations": [ - { - "location": "initial_0_before_if-0-5eebea38-860ebf50-else", - "assignments": [] - } - ], - "action": "initial_0_before_if-initial_0_after_if-5eebea38-860ebf50-else", - "guard": { - "exp": { - "op": "∧", - "left": true, - "right": { - "op": "¬", - "exp": "last_msg" - } - } - } - }, - { - "location": "initial_0_before_if-0-5eebea38-860ebf50-else", - "destinations": [ - { - "location": "initial_0_after_if", - "assignments": [ - { - "ref": "bt_1000_failure.valid", - "value": true, - "index": 0 - } - ] - } - ], - "action": "bt_1000_failure_on_send" - }, - { - "location": "initial_0_after_if", - "destinations": [ - { - "location": "initial", - "assignments": [] - } - ], - "action": "initial-initial-5eebea38" - } - ], - "variables": [ - { - "name": "last_msg", - "type": "bool", - "transient": false, - "initial-value": false - } - ] - }, - { - "name": "TopicAction", - "locations": [ - { - "name": "initial" - }, - { - "name": "initial-0-dd921629" - }, - { - "name": "initial-1-dd921629" - } - ], - "initial-locations": [ - "initial" - ], - "edges": [ - { - "location": "initial", - "destinations": [ - { - "location": "initial-0-dd921629", - "assignments": [] - } - ], - "action": "bt_1001_tick_on_receive" - }, - { - "location": "initial-0-dd921629", - "destinations": [ - { - "location": "initial-1-dd921629", - "assignments": [ - { - "ref": "ros_topic.charge.valid", - "value": true, - "index": 0 - } - ] - } - ], - "action": "ros_topic.charge_on_send" - }, - { - "location": "initial-1-dd921629", - "destinations": [ - { - "location": "initial", - "assignments": [ - { - "ref": "bt_1001_success.valid", - "value": true, - "index": 0 - } - ] - } - ], - "action": "bt_1001_success_on_send" - } - ] - }, - { - "name": "bt", - "locations": [ - { - "name": "1000_TopicCondition" - }, - { - "name": "1000_TopicCondition-0-28dfb6ec" - }, - { - "name": "1001_TopicAction" - }, - { - "name": "failure" - }, - { - "name": "running" - }, - { - "name": "success" - }, - { - "name": "tick" - }, - { - "name": "tick-0-8aab702a" - }, - { - "name": "wait_for_tick" - } - ], - "initial-locations": [ - "wait_for_tick" - ], - "edges": [ - { - "location": "tick", - "destinations": [ - { - "location": "tick-0-8aab702a", - "assignments": [] - } - ], - "action": "tick-1000_TopicCondition-8aab702a" - }, - { - "location": "tick-0-8aab702a", - "destinations": [ - { - "location": "1000_TopicCondition", - "assignments": [ - { - "ref": "bt_1000_tick.valid", - "value": true, - "index": 0 - } - ] - } - ], - "action": "bt_1000_tick_on_send" - }, - { - "location": "success", - "destinations": [ - { - "location": "wait_for_tick", - "assignments": [] - } - ], - "action": "success-wait_for_tick-c305aa83" - }, - { - "location": "failure", - "destinations": [ - { - "location": "wait_for_tick", - "assignments": [] - } - ], - "action": "failure-wait_for_tick-6568ac14" - }, - { - "location": "running", - "destinations": [ - { - "location": "wait_for_tick", - "assignments": [] - } - ], - "action": "running-wait_for_tick-89afabc1" - }, - { - "location": "1000_TopicCondition", - "destinations": [ - { - "location": "failure", - "assignments": [] - } - ], - "action": "bt_1000_failure_on_receive" - }, - { - "location": "1000_TopicCondition", - "destinations": [ - { - "location": "1000_TopicCondition-0-28dfb6ec", - "assignments": [] - } - ], - "action": "bt_1000_success_on_receive" - }, - { - "location": "1000_TopicCondition-0-28dfb6ec", - "destinations": [ - { - "location": "1001_TopicAction", - "assignments": [ - { - "ref": "bt_1001_tick.valid", - "value": true, - "index": 0 - } - ] - } - ], - "action": "bt_1001_tick_on_send" - }, - { - "location": "1001_TopicAction", - "destinations": [ - { - "location": "success", - "assignments": [] - } - ], - "action": "bt_1001_success_on_receive" - }, - { - "location": "wait_for_tick", - "destinations": [ - { - "location": "tick", - "assignments": [] - } - ], - "action": "ros_time_rate.bt_tick_on_receive" - }, - { - "location": "tick", - "destinations": [ - { - "location": "tick", - "assignments": [] - } - ], - "action": "bt_1000_failure_on_receive" - }, - { - "location": "tick", - "destinations": [ - { - "location": "tick", - "assignments": [] - } - ], - "action": "bt_1001_success_on_receive" - }, - { - "location": "tick", - "destinations": [ - { - "location": "tick", - "assignments": [] - } - ], - "action": "ros_time_rate.bt_tick_on_receive" - }, - { - "location": "tick", - "destinations": [ - { - "location": "tick", - "assignments": [] - } - ], - "action": "bt_1000_success_on_receive" - }, - { - "location": "success", - "destinations": [ - { - "location": "success", - "assignments": [] - } - ], - "action": "bt_1000_failure_on_receive" - }, - { - "location": "success", - "destinations": [ - { - "location": "success", - "assignments": [] - } - ], - "action": "bt_1001_success_on_receive" - }, - { - "location": "success", - "destinations": [ - { - "location": "success", - "assignments": [] - } - ], - "action": "ros_time_rate.bt_tick_on_receive" - }, - { - "location": "success", - "destinations": [ - { - "location": "success", - "assignments": [] - } - ], - "action": "bt_1000_success_on_receive" - }, - { - "location": "failure", - "destinations": [ - { - "location": "failure", - "assignments": [] - } - ], - "action": "bt_1000_failure_on_receive" - }, - { - "location": "failure", - "destinations": [ - { - "location": "failure", - "assignments": [] - } - ], - "action": "bt_1001_success_on_receive" - }, - { - "location": "failure", - "destinations": [ - { - "location": "failure", - "assignments": [] - } - ], - "action": "ros_time_rate.bt_tick_on_receive" - }, - { - "location": "failure", - "destinations": [ - { - "location": "failure", - "assignments": [] - } - ], - "action": "bt_1000_success_on_receive" - }, - { - "location": "running", - "destinations": [ - { - "location": "running", - "assignments": [] - } - ], - "action": "bt_1000_failure_on_receive" - }, - { - "location": "running", - "destinations": [ - { - "location": "running", - "assignments": [] - } - ], - "action": "bt_1001_success_on_receive" - }, - { - "location": "running", - "destinations": [ - { - "location": "running", - "assignments": [] - } - ], - "action": "ros_time_rate.bt_tick_on_receive" - }, - { - "location": "running", - "destinations": [ - { - "location": "running", - "assignments": [] - } - ], - "action": "bt_1000_success_on_receive" - }, - { - "location": "1000_TopicCondition", - "destinations": [ - { - "location": "1000_TopicCondition", - "assignments": [] - } - ], - "action": "bt_1001_success_on_receive" - }, - { - "location": "1000_TopicCondition", - "destinations": [ - { - "location": "1000_TopicCondition", - "assignments": [] - } - ], - "action": "ros_time_rate.bt_tick_on_receive" - }, - { - "location": "1001_TopicAction", - "destinations": [ - { - "location": "1001_TopicAction", - "assignments": [] - } - ], - "action": "bt_1000_failure_on_receive" - }, - { - "location": "1001_TopicAction", - "destinations": [ - { - "location": "1001_TopicAction", - "assignments": [] - } - ], - "action": "ros_time_rate.bt_tick_on_receive" - }, - { - "location": "1001_TopicAction", - "destinations": [ - { - "location": "1001_TopicAction", - "assignments": [] - } - ], - "action": "bt_1000_success_on_receive" - }, - { - "location": "wait_for_tick", - "destinations": [ - { - "location": "wait_for_tick", - "assignments": [] - } - ], - "action": "bt_1000_failure_on_receive" - }, - { - "location": "wait_for_tick", - "destinations": [ - { - "location": "wait_for_tick", - "assignments": [] - } - ], - "action": "bt_1001_success_on_receive" - }, - { - "location": "wait_for_tick", - "destinations": [ - { - "location": "wait_for_tick", - "assignments": [] - } - ], - "action": "bt_1000_success_on_receive" - } - ] - }, - { - "name": "global_timer", - "locations": [ - { - "name": "loc" - } - ], - "initial-locations": [ - "loc" - ], - "edges": [ - { - "location": "loc", - "destinations": [ - { - "location": "loc", - "assignments": [ - { - "ref": "t", - "value": { - "op": "+", - "left": "t", - "right": 1 - }, - "index": 0 - }, - { - "ref": "my_timer_needed", - "value": { - "op": "=", - "left": { - "op": "%", - "left": "t", - "right": 1 - }, - "right": 0 - }, - "index": 1 - }, - { - "ref": "bt_tick_needed", - "value": { - "op": "=", - "left": { - "op": "%", - "left": "t", - "right": 1 - }, - "right": 0 - }, - "index": 2 - } - ] - } - ], - "action": "global_timer_action_0", - "guard": { - "exp": { - "op": "∧", - "left": { - "op": "∧", - "left": { - "op": "<", - "left": "t", - "right": 100 - }, - "right": { - "op": "¬", - "exp": "my_timer_needed" - } - }, - "right": { - "op": "¬", - "exp": "bt_tick_needed" - } - } - } - }, - { - "location": "loc", - "destinations": [ - { - "location": "loc", - "assignments": [ - { - "ref": "my_timer_needed", - "value": false, - "index": 0 - } - ] - } - ], - "action": "ros_time_rate.my_timer_on_receive", - "guard": { - "exp": "my_timer_needed" - } - }, - { - "location": "loc", - "destinations": [ - { - "location": "loc", - "assignments": [ - { - "ref": "bt_tick_needed", - "value": false, - "index": 0 - } - ] - } - ], - "action": "ros_time_rate.bt_tick_on_receive", - "guard": { - "exp": "bt_tick_needed" - } - } - ], - "variables": [ - { - "name": "t", - "type": "int", - "transient": false, - "initial-value": 0 - }, - { - "name": "my_timer_needed", - "type": "bool", - "transient": false, - "initial-value": true - }, - { - "name": "bt_tick_needed", - "type": "bool", - "transient": false, - "initial-value": true - } - ] - }, - { - "name": "ros_topic.level", - "locations": [ - { - "name": "received" - }, - { - "name": "waiting" - } - ], - "initial-locations": [ - "waiting" - ], - "edges": [ - { - "location": "waiting", - "destinations": [ - { - "location": "received", - "probability": { - "exp": 1.0 - }, - "assignments": [] - } - ], - "action": "ros_topic.level_on_send" - }, - { - "location": "received", - "destinations": [ - { - "location": "waiting", - "probability": { - "exp": 1.0 - }, - "assignments": [] - } - ], - "action": "ros_topic.level_on_receive" - } - ] - }, - { - "name": "ros_topic.charge", - "locations": [ - { - "name": "received" - }, - { - "name": "waiting" - } - ], - "initial-locations": [ - "waiting" - ], - "edges": [ - { - "location": "waiting", - "destinations": [ - { - "location": "received", - "probability": { - "exp": 1.0 - }, - "assignments": [] - } - ], - "action": "ros_topic.charge_on_send" - }, - { - "location": "received", - "destinations": [ - { - "location": "waiting", - "probability": { - "exp": 1.0 - }, - "assignments": [] - } - ], - "action": "ros_topic.charge_on_receive" - } - ] - }, - { - "name": "ros_topic.alarm", - "locations": [ - { - "name": "received" - }, - { - "name": "waiting" - } - ], - "initial-locations": [ - "waiting" - ], - "edges": [ - { - "location": "waiting", - "destinations": [ - { - "location": "received", - "probability": { - "exp": 1.0 - }, - "assignments": [] - } - ], - "action": "ros_topic.alarm_on_send" - }, - { - "location": "received", - "destinations": [ - { - "location": "waiting", - "probability": { - "exp": 1.0 - }, - "assignments": [] - } - ], - "action": "ros_topic.alarm_on_receive" - } - ] - }, - { - "name": "bt_1000_tick", - "locations": [ - { - "name": "received" - }, - { - "name": "waiting" - } - ], - "initial-locations": [ - "waiting" - ], - "edges": [ - { - "location": "waiting", - "destinations": [ - { - "location": "received", - "probability": { - "exp": 1.0 - }, - "assignments": [] - } - ], - "action": "bt_1000_tick_on_send" - }, - { - "location": "received", - "destinations": [ - { - "location": "waiting", - "probability": { - "exp": 1.0 - }, - "assignments": [] - } - ], - "action": "bt_1000_tick_on_receive" - } - ] - }, - { - "name": "bt_1000_success", - "locations": [ - { - "name": "received" - }, - { - "name": "waiting" - } - ], - "initial-locations": [ - "waiting" - ], - "edges": [ - { - "location": "waiting", - "destinations": [ - { - "location": "received", - "probability": { - "exp": 1.0 - }, - "assignments": [] - } - ], - "action": "bt_1000_success_on_send" - }, - { - "location": "received", - "destinations": [ - { - "location": "waiting", - "probability": { - "exp": 1.0 - }, - "assignments": [] - } - ], - "action": "bt_1000_success_on_receive" - } - ] - }, - { - "name": "bt_1000_failure", - "locations": [ - { - "name": "received" - }, - { - "name": "waiting" - } - ], - "initial-locations": [ - "waiting" - ], - "edges": [ - { - "location": "waiting", - "destinations": [ - { - "location": "received", - "probability": { - "exp": 1.0 - }, - "assignments": [] - } - ], - "action": "bt_1000_failure_on_send" - }, - { - "location": "received", - "destinations": [ - { - "location": "waiting", - "probability": { - "exp": 1.0 - }, - "assignments": [] - } - ], - "action": "bt_1000_failure_on_receive" - } - ] - }, - { - "name": "bt_1001_tick", - "locations": [ - { - "name": "received" - }, - { - "name": "waiting" - } - ], - "initial-locations": [ - "waiting" - ], - "edges": [ - { - "location": "waiting", - "destinations": [ - { - "location": "received", - "probability": { - "exp": 1.0 - }, - "assignments": [] - } - ], - "action": "bt_1001_tick_on_send" - }, - { - "location": "received", - "destinations": [ - { - "location": "waiting", - "probability": { - "exp": 1.0 - }, - "assignments": [] - } - ], - "action": "bt_1001_tick_on_receive" - } - ] - }, - { - "name": "bt_1001_success", - "locations": [ - { - "name": "received" - }, - { - "name": "waiting" - } - ], - "initial-locations": [ - "waiting" - ], - "edges": [ - { - "location": "waiting", - "destinations": [ - { - "location": "received", - "probability": { - "exp": 1.0 - }, - "assignments": [] - } - ], - "action": "bt_1001_success_on_send" - }, - { - "location": "received", - "destinations": [ - { - "location": "waiting", - "probability": { - "exp": 1.0 - }, - "assignments": [] - } - ], - "action": "bt_1001_success_on_receive" - } - ] - } - ], - "system": { - "elements": [ - { - "automaton": "BatteryDrainer" - }, - { - "automaton": "BatteryManager" - }, - { - "automaton": "TopicCondition" - }, - { - "automaton": "TopicAction" - }, - { - "automaton": "bt" - }, - { - "automaton": "global_timer" - }, - { - "automaton": "ros_topic.level" - }, - { - "automaton": "ros_topic.charge" - }, - { - "automaton": "ros_topic.alarm" - }, - { - "automaton": "bt_1000_tick" - }, - { - "automaton": "bt_1000_success" - }, - { - "automaton": "bt_1000_failure" - }, - { - "automaton": "bt_1001_tick" - }, - { - "automaton": "bt_1001_success" - } - ], - "syncs": [ - { - "result": "bt_1000_failure_on_receive", - "synchronise": [ - null, - null, - null, - null, - "bt_1000_failure_on_receive", - null, - null, - null, - null, - null, - null, - "bt_1000_failure_on_receive", - null, - null - ] - }, - { - "result": "bt_1000_failure_on_send", - "synchronise": [ - null, - null, - "bt_1000_failure_on_send", - null, - null, - null, - null, - null, - null, - null, - null, - "bt_1000_failure_on_send", - null, - null - ] - }, - { - "result": "bt_1000_success_on_receive", - "synchronise": [ - null, - null, - null, - null, - "bt_1000_success_on_receive", - null, - null, - null, - null, - null, - "bt_1000_success_on_receive", - null, - null, - null - ] - }, - { - "result": "bt_1000_success_on_send", - "synchronise": [ - null, - null, - "bt_1000_success_on_send", - null, - null, - null, - null, - null, - null, - null, - "bt_1000_success_on_send", - null, - null, - null - ] - }, - { - "result": "bt_1000_tick_on_receive", - "synchronise": [ - null, - null, - "bt_1000_tick_on_receive", - null, - null, - null, - null, - null, - null, - "bt_1000_tick_on_receive", - null, - null, - null, - null - ] - }, - { - "result": "bt_1000_tick_on_send", - "synchronise": [ - null, - null, - null, - null, - "bt_1000_tick_on_send", - null, - null, - null, - null, - "bt_1000_tick_on_send", - null, - null, - null, - null - ] - }, - { - "result": "bt_1001_success_on_receive", - "synchronise": [ - null, - null, - null, - null, - "bt_1001_success_on_receive", - null, - null, - null, - null, - null, - null, - null, - null, - "bt_1001_success_on_receive" - ] - }, - { - "result": "bt_1001_success_on_send", - "synchronise": [ - null, - null, - null, - "bt_1001_success_on_send", - null, - null, - null, - null, - null, - null, - null, - null, - null, - "bt_1001_success_on_send" - ] - }, - { - "result": "bt_1001_tick_on_receive", - "synchronise": [ - null, - null, - null, - "bt_1001_tick_on_receive", - null, - null, - null, - null, - null, - null, - null, - null, - "bt_1001_tick_on_receive", - null - ] - }, - { - "result": "bt_1001_tick_on_send", - "synchronise": [ - null, - null, - null, - null, - "bt_1001_tick_on_send", - null, - null, - null, - null, - null, - null, - null, - "bt_1001_tick_on_send", - null - ] - }, - { - "result": "check_battery-first-exec-check_battery-a449c803", - "synchronise": [ - null, - "check_battery-first-exec-check_battery-a449c803", - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null - ] - }, - { - "result": "failure-wait_for_tick-6568ac14", - "synchronise": [ - null, - null, - null, - null, - "failure-wait_for_tick-6568ac14", - null, - null, - null, - null, - null, - null, - null, - null, - null - ] - }, - { - "result": "global_timer_action_0", - "synchronise": [ - null, - null, - null, - null, - null, - "global_timer_action_0", - null, - null, - null, - null, - null, - null, - null, - null - ] - }, - { - "result": "initial-initial-5eebea38", - "synchronise": [ - null, - null, - "initial-initial-5eebea38", - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null - ] - }, - { - "result": "initial_0_before_if-initial_0_after_if-5eebea38-860ebf50-else", - "synchronise": [ - null, - null, - "initial_0_before_if-initial_0_after_if-5eebea38-860ebf50-else", - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null - ] - }, - { - "result": "initial_0_before_if-initial_0_after_if-5eebea38-860ebf50-last_msg", - "synchronise": [ - null, - null, - "initial_0_before_if-initial_0_after_if-5eebea38-860ebf50-last_msg", - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null - ] - }, - { - "result": "ros_time_rate.bt_tick_on_receive", - "synchronise": [ - null, - null, - null, - null, - "ros_time_rate.bt_tick_on_receive", - "ros_time_rate.bt_tick_on_receive", - null, - null, - null, - null, - null, - null, - null, - null - ] - }, - { - "result": "ros_time_rate.my_timer_on_receive", - "synchronise": [ - "ros_time_rate.my_timer_on_receive", - null, - null, - null, - null, - "ros_time_rate.my_timer_on_receive", - null, - null, - null, - null, - null, - null, - null, - null - ] - }, - { - "result": "ros_topic.alarm_on_receive", - "synchronise": [ - null, - null, - "ros_topic.alarm_on_receive", - null, - null, - null, - null, - null, - "ros_topic.alarm_on_receive", - null, - null, - null, - null, - null - ] - }, - { - "result": "ros_topic.alarm_on_send", - "synchronise": [ - null, - "ros_topic.alarm_on_send", - null, - null, - null, - null, - null, - null, - "ros_topic.alarm_on_send", - null, - null, - null, - null, - null - ] - }, - { - "result": "ros_topic.charge_on_receive", - "synchronise": [ - "ros_topic.charge_on_receive", - null, - null, - null, - null, - null, - null, - "ros_topic.charge_on_receive", - null, - null, - null, - null, - null, - null - ] - }, - { - "result": "ros_topic.charge_on_send", - "synchronise": [ - null, - null, - null, - "ros_topic.charge_on_send", - null, - null, - null, - "ros_topic.charge_on_send", - null, - null, - null, - null, - null, - null - ] - }, - { - "result": "ros_topic.level_on_receive", - "synchronise": [ - null, - "ros_topic.level_on_receive", - null, - null, - null, - null, - "ros_topic.level_on_receive", - null, - null, - null, - null, - null, - null, - null - ] - }, - { - "result": "ros_topic.level_on_send", - "synchronise": [ - "ros_topic.level_on_send", - null, - null, - null, - null, - null, - "ros_topic.level_on_send", - null, - null, - null, - null, - null, - null, - null - ] - }, - { - "result": "running-wait_for_tick-89afabc1", - "synchronise": [ - null, - null, - null, - null, - "running-wait_for_tick-89afabc1", - null, - null, - null, - null, - null, - null, - null, - null, - null - ] - }, - { - "result": "success-wait_for_tick-c305aa83", - "synchronise": [ - null, - null, - null, - null, - "success-wait_for_tick-c305aa83", - null, - null, - null, - null, - null, - null, - null, - null, - null - ] - }, - { - "result": "tick-1000_TopicCondition-8aab702a", - "synchronise": [ - null, - null, - null, - null, - "tick-1000_TopicCondition-8aab702a", - null, - null, - null, - null, - null, - null, - null, - null, - null - ] - }, - { - "result": "use_battery-first-exec-use_battery-766fa6e4", - "synchronise": [ - "use_battery-first-exec-use_battery-766fa6e4", - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null - ] - } - ] - }, - "properties": [ - { - "name": "battery_depleted", - "expression": { - "op": "filter", - "fun": "values", - "values": { - "op": "Pmin", - "exp": { - "left": true, - "op": "U", - "right": { - "left": { - "op": "≤", - "left": "ros_topic.level.data", - "right": 0 - }, - "op": "∧", - "right": "ros_topic.level.valid" - } - } - }, - "states": { - "op": "initial" - } - } - }, - { - "name": "battery_below_20", - "expression": { - "op": "filter", - "fun": "values", - "values": { - "op": "Pmin", - "exp": { - "left": true, - "op": "U", - "right": { - "left": { - "op": "<", - "left": "ros_topic.level.data", - "right": 20 - }, - "op": "∧", - "right": "ros_topic.level.valid" - } - } - }, - "states": { - "op": "initial" - } - } - }, - { - "name": "battery_alarm_on", - "expression": { - "op": "filter", - "fun": "values", - "values": { - "op": "Pmin", - "exp": { - "left": true, - "op": "U", - "right": { - "op": "∧", - "left": "ros_topic.alarm.data", - "right": "ros_topic.charge.valid" - } - } - }, - "states": { - "op": "initial" - } - } - } - ] -} \ No newline at end of file diff --git a/jani_generator/test/_test_data/ros_fibonacci_action_example/.gitignore b/jani_generator/test/_test_data/ros_fibonacci_action_example/.gitignore deleted file mode 100644 index b941f4e4..00000000 --- a/jani_generator/test/_test_data/ros_fibonacci_action_example/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -generated_bt_scxml -generated_plain_scxml -main.jani \ No newline at end of file diff --git a/jani_generator/test/_test_data/ros_fibonacci_action_example/client_1.scxml b/jani_generator/test/_test_data/ros_fibonacci_action_example/client_1.scxml deleted file mode 100644 index 0fc88cd2..00000000 --- a/jani_generator/test/_test_data/ros_fibonacci_action_example/client_1.scxml +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/jani_generator/test/_test_data/ros_fibonacci_action_example/client_2.scxml b/jani_generator/test/_test_data/ros_fibonacci_action_example/client_2.scxml deleted file mode 100644 index c4691bbd..00000000 --- a/jani_generator/test/_test_data/ros_fibonacci_action_example/client_2.scxml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/jani_generator/test/_test_data/ros_fibonacci_action_example/happy_clients.jani b/jani_generator/test/_test_data/ros_fibonacci_action_example/happy_clients.jani deleted file mode 100644 index 418cab82..00000000 --- a/jani_generator/test/_test_data/ros_fibonacci_action_example/happy_clients.jani +++ /dev/null @@ -1,33 +0,0 @@ -{ - "properties": [ - { - "name": "happy_clients", - "expression": { - "op": "filter", - "fun": "values", - "values": { - "op": "Pmin", - "exp": { - "op": "F", - "exp": { - "op": "∧", - "left": { - "op": "∧", - "left": "ros_topic./client_1_res.data", - "right": "ros_topic./client_1_res.valid" - }, - "right": { - "op": "∧", - "left": "ros_topic./client_2_res.data", - "right": "ros_topic./client_2_res.valid" - } - } - } - }, - "states": { - "op": "initial" - } - } - } - ] -} \ No newline at end of file diff --git a/jani_generator/test/_test_data/ros_fibonacci_action_example/main.xml b/jani_generator/test/_test_data/ros_fibonacci_action_example/main.xml deleted file mode 100644 index 36074c02..00000000 --- a/jani_generator/test/_test_data/ros_fibonacci_action_example/main.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/jani_generator/test/_test_data/ros_fibonacci_action_example/server.scxml b/jani_generator/test/_test_data/ros_fibonacci_action_example/server.scxml deleted file mode 100644 index 8d4fdb1b..00000000 --- a/jani_generator/test/_test_data/ros_fibonacci_action_example/server.scxml +++ /dev/null @@ -1,149 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/jani_generator/test/test_systemtest_scxml_to_jani.py b/jani_generator/test/test_systemtest_scxml_to_jani.py index 19fa0348..1c9a875a 100644 --- a/jani_generator/test/test_systemtest_scxml_to_jani.py +++ b/jani_generator/test/test_systemtest_scxml_to_jani.py @@ -43,6 +43,9 @@ def test_basic_example(self): version="1.0" name="BasicExample" initial="Initial"> + + + @@ -52,7 +55,7 @@ def test_basic_example(self): scxml_root = ScxmlRoot.from_xml_tree(ET.fromstring(basic_scxml)) jani_a = JaniAutomaton() eh = EventsHolder() - convert_scxml_root_to_jani_automaton(scxml_root, jani_a, eh) + convert_scxml_root_to_jani_automaton(scxml_root, jani_a, eh, 100) automaton = jani_a.as_dict(constant={}) self.assertEqual(len(automaton["locations"]), 2) @@ -71,7 +74,7 @@ def test_battery_drainer(self): scxml_root = ScxmlRoot.from_scxml_file(scxml_battery_drainer) jani_a = JaniAutomaton() eh = EventsHolder() - convert_scxml_root_to_jani_automaton(scxml_root, jani_a, eh) + convert_scxml_root_to_jani_automaton(scxml_root, jani_a, eh, 100) automaton = jani_a.as_dict(constant={}) self.assertEqual(automaton["name"], "BatteryDrainer") @@ -99,7 +102,7 @@ def test_battery_manager(self): scxml_root = ScxmlRoot.from_scxml_file(scxml_battery_manager) jani_a = JaniAutomaton() eh = EventsHolder() - convert_scxml_root_to_jani_automaton(scxml_root, jani_a, eh) + convert_scxml_root_to_jani_automaton(scxml_root, jani_a, eh, 100) automaton = jani_a.as_dict(constant={}) self.assertEqual(automaton["name"], "BatteryManager") @@ -132,12 +135,8 @@ def test_example_with_sync(self): with open(scxml_battery_manager_path, 'r', encoding='utf-8') as f: scxml_battery_manager = f.read() - jani_model = convert_multiple_scxmls_to_jani([ - scxml_battery_drainer, - scxml_battery_manager], - [], - 0 - ) + jani_model = convert_multiple_scxmls_to_jani( + [scxml_battery_drainer, scxml_battery_manager], [], 0, 100) jani_dict = jani_model.as_dict() # pprint(jani_dict) @@ -267,6 +266,10 @@ def test_multiple_senders_same_event(self): being sent in different orders without deadlocks.""" self._test_with_main('multiple_senders_same_event', 'seq_check', True) + def test_array_model(self): + """Test the array model.""" + self._test_with_main('array_model', 'array_check', True) + def test_ros_add_int_srv_example(self): """Test the services are properly handled in Jani.""" self._test_with_main('ros_add_int_srv_example', 'happy_clients', True, True) diff --git a/jani_generator/test/test_unittest_jani_model_loading.py b/jani_generator/test/test_unittest_jani_model_loading.py index b9436fcf..d6d4f638 100644 --- a/jani_generator/test/test_unittest_jani_model_loading.py +++ b/jani_generator/test/test_unittest_jani_model_loading.py @@ -22,13 +22,13 @@ def test_jani_file_loading(): jani_file = os.path.join(os.path.dirname(__file__), - '_test_data', 'plain_jani_examples', 'example_arrays.jani') + '_test_data', 'plain_jani_examples', 'array_test.jani') with open(jani_file, "r", encoding='utf-8') as file: convince_jani_json = json.load(file) jani_model = JaniModel.from_dict(convince_jani_json) assert isinstance(jani_model, JaniModel) - assert jani_model.get_name() == "example_arrays" + assert jani_model.get_name() == "array_example" assert "arrays" in jani_model.get_features() - assert len(jani_model.get_variables()) == 12 + assert len(jani_model.get_variables()) == 2 assert len(jani_model.get_constants()) == 0 - assert len(jani_model.get_automata()) == 14 + assert len(jani_model.get_automata()) == 1 diff --git a/jani_generator/test/test_unittest_scxml_data.py b/jani_generator/test/test_unittest_scxml_data.py deleted file mode 100644 index b40dbbbe..00000000 --- a/jani_generator/test/test_unittest_scxml_data.py +++ /dev/null @@ -1,195 +0,0 @@ -# Copyright (c) 2024 - for information on the respective copyright owner -# see the NOTICE file - -# 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. - -""""Test the SCXML data conversion""" - -import unittest -import xml.etree.ElementTree as ET - -import pytest - -from jani_generator.scxml_helpers.scxml_data import ScxmlData - - -class TestScxmlData(unittest.TestCase): - - def test_no_type_information(self): - """ - Test with no type information should raise a ValueError. - """ - tag = ET.fromstring( - '') - self.assertRaises(ValueError, ScxmlData, tag) - - # Tests with comment above the data tag #################################### - def test_comment_int32(self): - """ - Test with comment above and type int32. - - src https://github.com/convince-project/data-model/blob/\ - 00d8b3356f632db3d6a564cf467c482f900a8657/examples/museum-guide/\ - environment-XML/batteryDriverCmp.scxml#L11C1-L11C28 - """ - comment_above = " " - tag = ET.fromstring( - '') - scxml_data = ScxmlData(tag, comment_above) - self.assertEqual(scxml_data.id, "level") - self.assertEqual(scxml_data.xml_src, None) - self.assertEqual(scxml_data.xml_expr, "0") - self.assertEqual(scxml_data.type, int) - self.assertEqual(scxml_data.initial_value, 0) - - def test_comment_boolean(self): - """ - Test with comment above and type boolean. - - src https://github.com/convince-project/data-model/blob/\ - 00d8b3356f632db3d6a564cf467c482f900a8657/examples/museum-guide/\ - environment-XML/batteryDriverCmp.scxml#L13 - """ - comment_above = " " - tag = ET.fromstring( - '') - scxml_data = ScxmlData(tag, comment_above) - self.assertEqual(scxml_data.id, "notify") - self.assertEqual(scxml_data.xml_src, None) - self.assertEqual(scxml_data.xml_expr, "false") - self.assertEqual(scxml_data.type, bool) - self.assertEqual(scxml_data.initial_value, False) - - # Tests with type attribute ################################################ - def test_type_attr_int32(self): - """ - Test with type int32 defined in the type attribute. - - src https://github.com/convince-project/data-model/blob/\ - 00d8b3356f632db3d6a564cf467c482f900a8657/examples/museum-guide/\ - property-XML/properties.xml#L3 - """ - tag = ET.fromstring( - '') - scxml_data = ScxmlData(tag) - self.assertEqual(scxml_data.id, "level") - self.assertEqual(scxml_data.xml_src, None) - self.assertEqual(scxml_data.xml_expr, None) - self.assertEqual(scxml_data.type, int) - self.assertEqual(scxml_data.initial_value, 0) - - def test_type_attr_bool(self): - """ - Test with type bool defined in the type attribute. - - src https://github.com/convince-project/data-model/blob/\ - 00d8b3356f632db3d6a564cf467c482f900a8657/examples/museum-guide/\ - property-XML/properties.xml#L11 - """ - tag = ET.fromstring( - '') - scxml_data = ScxmlData(tag) - self.assertEqual(scxml_data.id, "alarm") - self.assertEqual(scxml_data.xml_src, None) - self.assertEqual(scxml_data.xml_expr, None) - self.assertEqual(scxml_data.type, bool) - self.assertEqual(scxml_data.initial_value, False) - - def test_comment_conflicting_type(self): - """ - Test with conflicting type in comment and type attribute. - """ - comment_above = " " - tag = ET.fromstring( - '') - self.assertRaises(ValueError, ScxmlData, tag, comment_above) - - # Tests with ECMAScript expressions ######################################## - def test_ecmascript_bool(self): - """ - Test with ECMAScript expression that evaluates to a boolean. - - src https://alexzhornyak.github.io/SCXML-tutorial/Doc/\ - datamodel.html#ecmascript - """ - tag = ET.fromstring( - '') - scxml_data = ScxmlData(tag) - self.assertEqual(scxml_data.id, "VarBool") - self.assertEqual(scxml_data.xml_src, None) - self.assertEqual(scxml_data.xml_expr, "true") - self.assertEqual(scxml_data.type, bool) - self.assertEqual(scxml_data.initial_value, True) - - def test_ecmascript_int(self): - """ - Test with ECMAScript expression that evaluates to a boolean. - - src https://alexzhornyak.github.io/SCXML-tutorial/Doc/\ - datamodel.html#ecmascript - """ - tag = ET.fromstring( - '') - scxml_data = ScxmlData(tag) - self.assertEqual(scxml_data.id, "VarInt") - self.assertEqual(scxml_data.xml_src, None) - self.assertEqual(scxml_data.xml_expr, "555") - self.assertEqual(scxml_data.type, int) - self.assertEqual(scxml_data.initial_value, 555) - - def test_ecmascript_float(self): - """ - Test with ECMAScript expression that evaluates to a boolean. - - src https://alexzhornyak.github.io/SCXML-tutorial/Doc/\ - datamodel.html#ecmascript - """ - tag = ET.fromstring( - '') - scxml_data = ScxmlData(tag) - self.assertEqual(scxml_data.id, "VarFloat") - self.assertEqual(scxml_data.xml_src, None) - self.assertEqual(scxml_data.xml_expr, "777.777") - self.assertEqual(scxml_data.type, float) - self.assertEqual(scxml_data.initial_value, 777.777) - - def test_ecmascript_unsupported(self): - """ - Test with ECMA script expressions that evaluates to unsupported types. - - This should raise a ValueError because the types are not supported - by Jani. - - src https://alexzhornyak.github.io/SCXML-tutorial/Doc/\ - datamodel.html#ecmascript - """ - tag_str = ET.fromstring( - '') - self.assertRaises(ValueError, ScxmlData, tag_str) - tag_function = ET.fromstring( - '') - self.assertRaises(ValueError, ScxmlData, tag_function) - tag_null = ET.fromstring( - '') - self.assertRaises(ValueError, ScxmlData, tag_null) - tag_undefined = ET.fromstring( - '') - self.assertRaises(ValueError, ScxmlData, tag_undefined) - tag_object = ET.fromstring( - '') - self.assertRaises(ValueError, ScxmlData, tag_object) - - -if __name__ == '__main__': - pytest.main(['-s', '-v', __file__]) diff --git a/scxml_converter/src/scxml_converter/scxml_converter.py b/scxml_converter/src/scxml_converter/scxml_converter.py index fb42bf4f..48543cff 100644 --- a/scxml_converter/src/scxml_converter/scxml_converter.py +++ b/scxml_converter/src/scxml_converter/scxml_converter.py @@ -22,9 +22,6 @@ from typing import Dict, Union -from as2fm_common.common import ros_type_name_to_python_type -from as2fm_common.ecmascript_interpretation import interpret_ecma_script_expr - BASIC_FIELD_TYPES = ['boolean', 'int32', 'int16', 'float', 'double'] ROS_TIMER_RATE_EVENT_PREFIX = 'ros_time_rate.' @@ -69,31 +66,31 @@ def _ros_type_fields(type_str: str) -> Dict[str, Union[str, dict]]: return msg_fields -# TODO: Unused, keeping as reference to output types in low level SCXML -def _check_topic_type( - name: str, - type_dict: dict, - this_topic: str, - cb_topic: str, - expr: str): - """Check if the field type is correct. - - It matches the expression type with the type declared for the given - publisher or subscriber. - - :param name: The name of the field. - :param type_dict: The type dictionary of the topic. - :param expr: The ecmascript expression to check. - :throws: ConversionStaticAnalysisError if the type is incorrect. - """ - if name not in type_dict: - raise ConversionStaticAnalysisError( - f"Field {name} not found in the type dictionary {type_dict}") - expected_ros_type = type_dict[this_topic][name] - expected_python_type = ros_type_name_to_python_type(expected_ros_type) - expression_value = interpret_ecma_script_expr(expr) - expression_type = type(expression_value) - if expression_type != expected_python_type: - raise ConversionStaticAnalysisError( - f"Field {name} has type {expression_type}, " + - f"expected {expected_python_type}") +# # TODO: Unused, keeping as reference to output types in low level SCXML +# def _check_topic_type( +# name: str, +# type_dict: dict, +# this_topic: str, +# cb_topic: str, +# expr: str): +# """Check if the field type is correct. + +# It matches the expression type with the type declared for the given +# publisher or subscriber. + +# :param name: The name of the field. +# :param type_dict: The type dictionary of the topic. +# :param expr: The ecmascript expression to check. +# :throws: ConversionStaticAnalysisError if the type is incorrect. +# """ +# if name not in type_dict: +# raise ConversionStaticAnalysisError( +# f"Field {name} not found in the type dictionary {type_dict}") +# expected_ros_type = type_dict[this_topic][name] +# expected_python_type = ros_type_name_to_python_type(expected_ros_type) +# expression_value = interpret_ecma_script_expr(expr) +# expression_type = type(expression_value) +# if expression_type != expected_python_type: +# raise ConversionStaticAnalysisError( +# f"Field {name} has type {expression_type}, " + +# f"expected {expected_python_type}") diff --git a/scxml_converter/src/scxml_converter/scxml_entries/ros_utils.py b/scxml_converter/src/scxml_converter/scxml_entries/ros_utils.py index f11462d3..7cc25709 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/ros_utils.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/ros_utils.py @@ -23,7 +23,8 @@ MSG_TYPE_SUBSTITUTIONS = { - "boolean": "bool", + 'boolean': 'bool', + 'sequence': 'int32[]' } BASIC_FIELD_TYPES = ['boolean', @@ -85,16 +86,12 @@ def extract_params_from_ros_type(ros_interface_type: Type[Any]) -> Dict[str, str Extract the data fields of a ROS message type as pairs of name and type objects. """ fields = ros_interface_type.get_fields_and_field_types() - additional_fields = {} for key in fields.keys(): assert fields[key] in BASIC_FIELD_TYPES, \ f"Error: SCXML ROS declarations: {ros_interface_type} {key} field is " \ f"of type {fields[key]}, that is not supported." fields[key] = MSG_TYPE_SUBSTITUTIONS.get(fields[key], fields[key]) - # For array fields (or sequences), we append also a "__len" entry - if fields[key].startswith("sequence<"): - additional_fields[key + "__len"] = "int32" - return fields | additional_fields + return fields def check_all_fields_known(ros_fields: List[RosField], field_types: Dict[str, str]) -> bool: diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_data.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_data.py index b10b797e..bbc7c9a8 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/scxml_data.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_data.py @@ -17,7 +17,9 @@ Container for a single variable definition in SCXML. In XML, it has the tag `data`. """ -from typing import Any, Union, Optional +import re + +from typing import Any, Union, Optional, Tuple from xml.etree import ElementTree as ET from scxml_converter.scxml_entries import (ScxmlBase, BtGetValueInputPort) @@ -49,17 +51,40 @@ def get_tag_name() -> str: return "data" @staticmethod - def from_xml_tree(xml_tree: ET.Element) -> "ScxmlData": + def _interpret_type_from_comment_above( + comment_above: Optional[str]) -> Optional[Tuple[str, str]]: + """Interpret the type of the data from the comment above the data tag. + + :param comment_above: The comment above the data tag (optional) + :return: The type of the data, None if not found + """ + if comment_above is None: + return None + # match string inside xml comment brackets + type_match = re.search(r'TYPE\ (.+):(.+)', comment_above.strip()) + if type_match is None: + return None + return type_match.group(1), type_match.group(2) + + @staticmethod + def from_xml_tree(xml_tree: ET.Element, comment_above: Optional[str] = None) -> "ScxmlData": """Create a ScxmlData object from an XML tree.""" assert_xml_tag_ok(ScxmlData, xml_tree) data_id = get_xml_argument(ScxmlData, xml_tree, "id") - data_type = get_xml_argument(ScxmlData, xml_tree, "type") - data_expr = read_value_from_xml_arg_or_child(ScxmlData, xml_tree, "expr", - (BtGetValueInputPort, str)) - lower_bound = read_value_from_xml_arg_or_child(ScxmlData, xml_tree, "lower_bound_incl", - (BtGetValueInputPort, str), True) - upper_bound = read_value_from_xml_arg_or_child(ScxmlData, xml_tree, "upper_bound_incl", - (BtGetValueInputPort, str), True) + data_type = get_xml_argument(ScxmlData, xml_tree, "type", none_allowed=True) + if data_type is None: + comment_tuple = ScxmlData._interpret_type_from_comment_above(comment_above) + assert comment_tuple is not None, f"Error: SCXML data: type of {data_id} not found." + assert comment_tuple[0] == data_id, \ + "Error: SCXML data: unexpected ID in type in comment " \ + f"({comment_tuple[0]}!={data_id})." + data_type = comment_tuple[1] + data_expr = read_value_from_xml_arg_or_child( + ScxmlData, xml_tree, "expr", (BtGetValueInputPort, str)) + lower_bound = read_value_from_xml_arg_or_child( + ScxmlData, xml_tree, "lower_bound_incl", (BtGetValueInputPort, str), none_allowed=True) + upper_bound = read_value_from_xml_arg_or_child( + ScxmlData, xml_tree, "upper_bound_incl", (BtGetValueInputPort, str), none_allowed=True) return ScxmlData(data_id, data_expr, data_type, lower_bound, upper_bound) def __init__( diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_data_model.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_data_model.py index eb1e2913..a8c06138 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/scxml_data_model.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_data_model.py @@ -22,6 +22,7 @@ from scxml_converter.scxml_entries import ScxmlBase, ScxmlData from scxml_converter.scxml_entries.bt_utils import BtPortsHandler +from scxml_converter.scxml_entries.xml_utils import assert_xml_tag_ok class ScxmlDataModel(ScxmlBase): @@ -38,13 +39,15 @@ def get_tag_name() -> str: @staticmethod def from_xml_tree(xml_tree: ET.Element) -> "ScxmlDataModel": """Create a ScxmlDataModel object from an XML tree.""" - assert xml_tree.tag == ScxmlDataModel.get_tag_name(), \ - f"Error: SCXML datamodel: XML tag name is not {ScxmlDataModel.get_tag_name()}." - data_entries_xml = xml_tree.findall("data") - assert data_entries_xml is not None, "Error: SCXML datamodel: No data entries found." + assert_xml_tag_ok(ScxmlDataModel, xml_tree) data_entries = [] - for data_entry_xml in data_entries_xml: - data_entries.append(ScxmlData.from_xml_tree(data_entry_xml)) + prev_xml_comment: Optional[str] = None + for data_entry_xml in xml_tree: + if data_entry_xml.tag is ET.Comment: + prev_xml_comment = data_entry_xml.text + else: + data_entries.append(ScxmlData.from_xml_tree(data_entry_xml, prev_xml_comment)) + prev_xml_comment = None return ScxmlDataModel(data_entries) def get_data_entries(self) -> Optional[List[ScxmlData]]: diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_executable_entries.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_executable_entries.py index 33abac62..eecf857e 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/scxml_executable_entries.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_executable_entries.py @@ -74,6 +74,8 @@ def from_xml_tree(xml_tree: ET.Element) -> "ScxmlIf": current_body: ScxmlExecutionBody = [] else_tag_found = False for child in xml_tree: + if child.tag is ET.Comment: + continue if child.tag == "elseif": assert not else_tag_found, "Error: SCXML if: 'elseif' tag found after 'else' tag." conditions.append(child.attrib["cond"]) @@ -209,6 +211,8 @@ def from_xml_tree(xml_tree: ET.Element) -> "ScxmlSend": params: List[ScxmlParam] = [] assert params is not None, "Error: SCXML send: params is not valid." for param_xml in xml_tree: + if param_xml.tag is ET.Comment: + continue params.append(ScxmlParam.from_xml_tree(param_xml)) return ScxmlSend(event, params) @@ -400,7 +404,8 @@ def execution_body_from_xml(xml_tree: ET.Element) -> ScxmlExecutionBody: """ exec_body: ScxmlExecutionBody = [] for exec_elem_xml in xml_tree: - exec_body.append(execution_entry_from_xml(exec_elem_xml)) + if exec_elem_xml.tag is not ET.Comment: + exec_body.append(execution_entry_from_xml(exec_elem_xml)) return exec_body diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_root.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_root.py index ae5066ff..fee16e1d 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/scxml_root.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_root.py @@ -90,15 +90,18 @@ def from_xml_tree(xml_tree: ET.Element) -> "ScxmlRoot": def from_scxml_file(xml_file: str) -> "ScxmlRoot": """Create a ScxmlRoot object from an SCXML file.""" if isfile(xml_file): - xml_element = ET.parse(xml_file).getroot() + # Custom parser to include comments + xml_parser = ET.XMLParser(target=ET.TreeBuilder(insert_comments=True)) + xml_element = ET.parse(xml_file, xml_parser).getroot() elif xml_file.startswith(" 'RosTrigger' """Create an instance of the class from an XML tree.""" assert_xml_tag_ok(cls, xml_tree) interface_name = get_xml_argument(cls, xml_tree, "name") - fields = [RosField.from_xml_tree(field) for field in xml_tree] + fields = [RosField.from_xml_tree(field) for field in xml_tree + if field.tag is not ET.Comment] return cls(interface_name, fields) def __init__(self, interface_decl: Union[str, RosDeclaration], diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_service.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_service.py index d5d291d9..839bd54e 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_service.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_service.py @@ -92,7 +92,8 @@ def from_xml_tree(xml_tree: ET.Element) -> "RosServiceSendRequest": "Use 'name' instead.") fields: List[RosField] = [] for field_xml in xml_tree: - fields.append(RosField.from_xml_tree(field_xml)) + if field_xml.tag is not ET.Comment: + fields.append(RosField.from_xml_tree(field_xml)) return RosServiceSendRequest(srv_name, fields) def check_interface_defined(self, ros_declarations: ScxmlRosDeclarationsContainer) -> bool: @@ -162,7 +163,8 @@ def from_xml_tree(xml_tree: ET.Element) -> "RosServiceSendResponse": fields: Optional[List[RosField]] = [] assert fields is not None, "Error: SCXML service response: fields is not valid." for field_xml in xml_tree: - fields.append(RosField.from_xml_tree(field_xml)) + if field_xml.tag is not ET.Comment: + fields.append(RosField.from_xml_tree(field_xml)) if len(fields) == 0: fields = None return RosServiceSendResponse(srv_name, fields) diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_state.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_state.py index a6c07116..c25f1c6d 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/scxml_state.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_state.py @@ -123,13 +123,13 @@ def _transitions_from_xml(state_id: str, xml_tree: ET.Element) -> List[ScxmlTran tag_to_cls.update({cls.get_tag_name(): cls for cls in RosCallback.__subclasses__()}) tag_to_cls.update({ScxmlTransition.get_tag_name(): ScxmlTransition}) for child in xml_tree: - if child.tag in tag_to_cls: + if child.tag is ET.Comment: + continue + elif child.tag in tag_to_cls: transitions.append(tag_to_cls[child.tag].from_xml_tree(child)) - expected_transitions = \ - len(xml_tree) - len(xml_tree.findall("onentry")) - len(xml_tree.findall("onexit")) - assert len(transitions) == expected_transitions, \ - f"Error: SCXML state {state_id}: Expected {expected_transitions} transitions, " \ - f"found {len(transitions)}." + else: + assert child.tag in ("onentry", "onexit"), \ + f"Error: SCXML state {state_id}: unexpected tag {child.tag}." return transitions def add_transition(self, transition: ScxmlTransition): diff --git a/scxml_converter/src/scxml_converter/scxml_entries/utils.py b/scxml_converter/src/scxml_converter/scxml_entries/utils.py index 3810a73a..398f74f3 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/utils.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/utils.py @@ -16,7 +16,6 @@ """Collection of various utilities for scxml entries.""" from typing import Any, Dict, Type, MutableSequence -from array import array from scxml_converter.scxml_entries import ScxmlBase @@ -63,22 +62,6 @@ def is_non_empty_string(scxml_type: Type[ScxmlBase], arg_name: str, arg_value: s return valid_str -def get_default_expression_for_type(field_type: str) -> str: - """Generate a default expression for a field type.""" - if field_type not in SCXML_DATA_STR_TO_TYPE: - raise ValueError(f"Error: SCXML conversion of data entry: Unknown data type {field_type}.") - if '[' in field_type: - # array type, special handling - if field_type.startswith('int'): - return array('i') - elif field_type.startswith('float'): - return array('f') - else: - raise ValueError( - f"Error: SCXML conversion of data entry: unhandled array type {field_type}.") - return str(SCXML_DATA_STR_TO_TYPE[field_type]()) - - def convert_string_to_type(value: str, data_type: str) -> Any: """ Convert a value to the provided data type. Raise if impossible. diff --git a/scxml_converter/src/scxml_converter/scxml_entries/xml_utils.py b/scxml_converter/src/scxml_converter/scxml_entries/xml_utils.py index 2ed167fa..04684f88 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/xml_utils.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/xml_utils.py @@ -16,16 +16,16 @@ from typing import List, Iterable, Optional, Union, Type from scxml_converter.scxml_entries import ScxmlBase -from xml.etree.ElementTree import Element +from xml.etree import ElementTree as ET -def assert_xml_tag_ok(scxml_type: Type[ScxmlBase], xml_tree: Element): +def assert_xml_tag_ok(scxml_type: Type[ScxmlBase], xml_tree: ET.Element): """Ensures the xml_tree we are trying to parse has the expected name.""" assert xml_tree.tag == scxml_type.get_tag_name(), \ f"SCXML conversion: Expected tag {scxml_type.get_tag_name()}, but got {xml_tree.tag}" -def get_xml_argument(scxml_type: Type[ScxmlBase], xml_tree: Element, arg_name: str, *, +def get_xml_argument(scxml_type: Type[ScxmlBase], xml_tree: ET.Element, arg_name: str, *, none_allowed=False, empty_allowed=False) -> Optional[str]: """Load an argument from the xml tree's root tag.""" arg_value = xml_tree.get(arg_name) @@ -39,7 +39,7 @@ def get_xml_argument(scxml_type: Type[ScxmlBase], xml_tree: Element, arg_name: s def get_children_as_scxml( - xml_tree: Element, scxml_types: Iterable[Type[ScxmlBase]]) -> List[ScxmlBase]: + xml_tree: ET.Element, scxml_types: Iterable[Type[ScxmlBase]]) -> List[ScxmlBase]: """ Load the children of the xml tree as scxml entries. @@ -50,13 +50,15 @@ def get_children_as_scxml( scxml_list = [] tag_to_type = {scxml_type.get_tag_name(): scxml_type for scxml_type in scxml_types} for child in xml_tree: + if child.tag is ET.Comment: + continue if child.tag in tag_to_type: scxml_list.append(tag_to_type[child.tag].from_xml_tree(child)) return scxml_list def read_value_from_xml_child( - xml_tree: Element, child_tag: str, valid_types: Iterable[Type[Union[ScxmlBase, str]]], *, + xml_tree: ET.Element, child_tag: str, valid_types: Iterable[Type[Union[ScxmlBase, str]]], *, none_allowed: bool = False) -> Optional[Union[str, ScxmlBase]]: """ Try to read the value of a child tag from the xml tree. If the child is not found, return None. @@ -90,7 +92,7 @@ def read_value_from_xml_child( def read_value_from_xml_arg_or_child( - scxml_type: Type[ScxmlBase], xml_tree: Element, tag_name: str, + scxml_type: Type[ScxmlBase], xml_tree: ET.Element, tag_name: str, valid_types: Iterable[Type[Union[ScxmlBase, str]]], none_allowed: bool = False) -> Optional[Union[str, ScxmlBase]]: """ diff --git a/scxml_converter/test/_test_data/fibonacci_action_example/client_1.scxml b/scxml_converter/test/_test_data/fibonacci_action_example/client_1.scxml index 560058b7..1d30c6b2 100644 --- a/scxml_converter/test/_test_data/fibonacci_action_example/client_1.scxml +++ b/scxml_converter/test/_test_data/fibonacci_action_example/client_1.scxml @@ -7,8 +7,7 @@ xmlns="http://www.w3.org/2005/07/scxml"> - - + diff --git a/scxml_converter/test/_test_data/fibonacci_action_example/client_2.scxml b/scxml_converter/test/_test_data/fibonacci_action_example/client_2.scxml index caa2cfbf..95bf93c5 100644 --- a/scxml_converter/test/_test_data/fibonacci_action_example/client_2.scxml +++ b/scxml_converter/test/_test_data/fibonacci_action_example/client_2.scxml @@ -7,8 +7,7 @@ xmlns="http://www.w3.org/2005/07/scxml"> - - + diff --git a/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/client_1.scxml b/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/client_1.scxml index 7de2b775..0ac6c5e1 100644 --- a/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/client_1.scxml +++ b/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/client_1.scxml @@ -1,7 +1,7 @@ - + diff --git a/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/client_2.scxml b/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/client_2.scxml index db07c273..3bb2bfbe 100644 --- a/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/client_2.scxml +++ b/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/client_2.scxml @@ -1,7 +1,7 @@ - + diff --git a/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/fibonacci_thread_0.scxml b/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/fibonacci_thread_0.scxml index 4771f6c4..febc425f 100644 --- a/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/fibonacci_thread_0.scxml +++ b/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/fibonacci_thread_0.scxml @@ -3,12 +3,11 @@ - - + - + @@ -20,27 +19,24 @@ - + - + - + - - + - - diff --git a/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/fibonacci_thread_1.scxml b/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/fibonacci_thread_1.scxml index a8cca946..dc621256 100644 --- a/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/fibonacci_thread_1.scxml +++ b/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/fibonacci_thread_1.scxml @@ -3,12 +3,11 @@ - - + - + @@ -20,27 +19,24 @@ - + - + - + - - + - - diff --git a/scxml_converter/test/_test_data/fibonacci_action_example/server.scxml b/scxml_converter/test/_test_data/fibonacci_action_example/server.scxml index 5e6d6420..de34c6da 100644 --- a/scxml_converter/test/_test_data/fibonacci_action_example/server.scxml +++ b/scxml_converter/test/_test_data/fibonacci_action_example/server.scxml @@ -18,13 +18,12 @@ - - + - + @@ -37,31 +36,28 @@ - + - + - + - - + - - diff --git a/scxml_converter/test/test_unittest_scxml_data.py b/scxml_converter/test/test_unittest_scxml_data.py new file mode 100644 index 00000000..15719824 --- /dev/null +++ b/scxml_converter/test/test_unittest_scxml_data.py @@ -0,0 +1,146 @@ +# Copyright (c) 2024 - for information on the respective copyright owner +# see the NOTICE file + +# 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. + +""""Test the SCXML data conversion from all possible declaration types""" + +from typing import MutableSequence +import unittest +import xml.etree.ElementTree as ET +from scxml_converter.scxml_entries import ScxmlData, ScxmlDataModel + +import pytest + + +class TestScxmlData(unittest.TestCase): + + def test_no_type_information(self): + """ + Test with no type information should raise a ValueError. + """ + tag = ET.fromstring( + '') + self.assertRaises(AssertionError, ScxmlData.from_xml_tree, tag) + tag = ET.fromstring( + '') + self.assertRaises(AssertionError, ScxmlData.from_xml_tree, tag) + + def test_no_expr_information(self): + tag = ET.fromstring( + '') + self.assertRaises(AssertionError, ScxmlData.from_xml_tree, tag) + + def test_no_id_information(self): + tag = ET.fromstring( + '') + self.assertRaises(AssertionError, ScxmlData.from_xml_tree, tag) + + def test_regular_int_tag(self): + """ + Test with regular tag with type int32. + """ + tag = ET.fromstring( + '') + scxml_data = ScxmlData.from_xml_tree(tag) + self.assertEqual(scxml_data.get_name(), "level") + self.assertEqual(scxml_data.get_type(), int) + self.assertEqual(scxml_data.get_expr(), "0") + + def test_regular_float_tag(self): + """ + Test with regular tag with type int32. + """ + tag = ET.fromstring( + '') + scxml_data = ScxmlData.from_xml_tree(tag) + self.assertEqual(scxml_data.get_name(), "level_float") + self.assertEqual(scxml_data.get_type(), float) + self.assertEqual(scxml_data.get_expr(), "1.1") + + def test_regular_bool_tag(self): + """ + Test with regular tag with type int32. + """ + tag = ET.fromstring( + '') + scxml_data = ScxmlData.from_xml_tree(tag) + self.assertEqual(scxml_data.get_name(), "condition") + self.assertEqual(scxml_data.get_type(), bool) + self.assertEqual(scxml_data.get_expr(), "true") + + def test_regular_int_array_tag(self): + """ + Test with regular tag with type int32. + """ + tag = ET.fromstring( + '') + scxml_data = ScxmlData.from_xml_tree(tag) + self.assertEqual(scxml_data.get_name(), "some_array") + self.assertEqual(scxml_data.get_type(), MutableSequence[int]) + self.assertEqual(scxml_data.get_expr(), "[]") + + # Tests with comment above the data tag #################################### + def test_comment_int32(self): + """ + Test with comment above and type int32. + + src https://github.com/convince-project/data-model/blob/\ + 00d8b3356f632db3d6a564cf467c482f900a8657/examples/museum-guide/\ + environment-XML/batteryDriverCmp.scxml#L11C1-L11C28 + """ + comment_above = "TYPE level:int32" + tag = ET.fromstring( + '') + scxml_data = ScxmlData.from_xml_tree(tag, comment_above) + self.assertEqual(scxml_data.get_name(), "level") + self.assertEqual(scxml_data.get_expr(), "0") + self.assertEqual(scxml_data.get_type(), int) + + def test_invalid_id_in_comment(self): + """ + Test with comment above and type int32. + + src https://github.com/convince-project/data-model/blob/\ + 00d8b3356f632db3d6a564cf467c482f900a8657/examples/museum-guide/\ + environment-XML/batteryDriverCmp.scxml#L11C1-L11C28 + """ + comment_above = "TYPE other:int32" + tag = ET.fromstring( + '') + self.assertRaises(AssertionError, ScxmlData.from_xml_tree, tag, comment_above) + + def test_datamodel_loading(self): + """ + Test the loading of the datamodel. + """ + xml_parser = ET.XMLParser(target=ET.TreeBuilder(insert_comments=True)) + xml_tree = ET.fromstring( + '' + '' + '' + '' + '' + '' + '', xml_parser) + scxml_data_model = ScxmlDataModel.from_xml_tree(xml_tree) + data_entries = scxml_data_model.get_data_entries() + self.assertEqual(len(data_entries), 4) + self.assertEqual(data_entries[0].get_name(), "level") + self.assertEqual(data_entries[1].get_name(), "level_float") + self.assertEqual(data_entries[2].get_name(), "condition") + self.assertEqual(data_entries[3].get_name(), "some_array") + + +if __name__ == '__main__': + pytest.main(['-s', '-v', __file__]) From 15c4265bf24fd5991410bb41040448d4f0f3d894 Mon Sep 17 00:00:00 2001 From: Marco Lampacrescia Date: Wed, 28 Aug 2024 17:26:34 +0200 Subject: [PATCH 04/23] Add missing field to thread start and start action diagram Signed-off-by: Marco Lampacrescia --- .../graphics/ros_action_to_scxml.drawio.svg | 322 ++++++++---------- .../gt_plain_scxml/fibonacci_thread_0.scxml | 1 + .../gt_plain_scxml/fibonacci_thread_1.scxml | 1 + 3 files changed, 151 insertions(+), 173 deletions(-) diff --git a/docs/source/graphics/ros_action_to_scxml.drawio.svg b/docs/source/graphics/ros_action_to_scxml.drawio.svg index 93811e65..99a6ab34 100644 --- a/docs/source/graphics/ros_action_to_scxml.drawio.svg +++ b/docs/source/graphics/ros_action_to_scxml.drawio.svg @@ -1,7 +1,7 @@ - + - + @@ -18,181 +18,20 @@ - + -
+
- Action Communication Handler + Action Thread X
- Action Communication Handler - - - - - - - - -
-
-
- - transition srv_x_req_cllient_2 - -
- - - send srv_x_req - -
-
- - param req_field_<1, ..., n> -
-
-
-
-
- - transition srv_x_req_cllient_2... - -
-
- - - - - -
-
-
- transition srv_x_req_cllient_1 -
- - send srv_x_req -
-
- - param req_field_<1, ..., n> -
-
-
-
-
- - transition srv_x_req_cllient_1... - -
-
- - - - -
-
-
- - waiting - -
-
-
-
- - waiting - -
-
- - - - - -
-
-
-
- transition srv_x_res -
-
- - send srv_x_res_client_1 -
-
- - param res_field_<1, ..., m> -
-
-
-
-
- - transition srv_x_res... - -
-
- - - - -
-
-
- - processing_client_1 - -
-
-
-
- - processing_client_1 - -
-
- - - - - -
-
-
-
- transition srv_x_res -
-
- - send srv_x_res_client_2 -
-
- - param res_field_<1, ..., m> -
-
-
-
-
- - transition srv_x_res... - -
-
- - - - -
-
-
- - processing_client_2 - -
-
-
-
- - processing_client_2 + Action Thread X
@@ -218,7 +57,7 @@ -
+
@@ -240,7 +79,7 @@
- + transition action_handle_goal_request:... @@ -302,10 +141,10 @@ - + -
+
@@ -317,7 +156,7 @@
- + transition action_goal_accept_client_X @@ -404,7 +243,7 @@
- + transition action_result_client_X:... @@ -547,6 +386,143 @@ + + + + + +
+
+
+ transition action_thread_start if thread_id == X +
+ - assign goal_id +
+
+ - assign goal_fields +
+
+
+
+
+ + transition action_thread_start if thread_id == X... + +
+
+ + + + +
+
+
+ + entry + +
+
+
+
+ + entry + +
+
+ + + + + +
+
+
+ on_entry: +
+ - send action_thread_free +
+ - param thread_id=X +
+
+
+
+ + on_entry:... + +
+
+ + + + + +
+
+
+
+ transition if result_available +
+
+ - send action_result +
+
+ - param goal_id +
+
+ - param result_fields +
+
+
+
+
+ + transition if result_available... + +
+
+ + + + +
+
+
+ + execute + +
+
+
+
+ + execute + +
+
+ + + + + +
+
+
+ transition if feedback_available +
+ - send action_feedback +
+ - param goal_id +
+ - param feedback_fields +
+
+
+
+ + transition if feedback_available... + +
+
diff --git a/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/fibonacci_thread_0.scxml b/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/fibonacci_thread_0.scxml index febc425f..25a4878f 100644 --- a/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/fibonacci_thread_0.scxml +++ b/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/fibonacci_thread_0.scxml @@ -14,6 +14,7 @@ + diff --git a/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/fibonacci_thread_1.scxml b/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/fibonacci_thread_1.scxml index dc621256..e5078984 100644 --- a/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/fibonacci_thread_1.scxml +++ b/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/fibonacci_thread_1.scxml @@ -14,6 +14,7 @@ + From 18acac9a3e0d12bf57816b1bf1f0bedd7a5b11cf Mon Sep 17 00:00:00 2001 From: Marco Lampacrescia Date: Thu, 29 Aug 2024 09:27:00 +0200 Subject: [PATCH 05/23] Complete overview of action conversion to plain scxml Signed-off-by: Marco Lampacrescia --- .../graphics/ros_action_to_scxml.drawio.svg | 279 ++++++++++++++++-- 1 file changed, 249 insertions(+), 30 deletions(-) diff --git a/docs/source/graphics/ros_action_to_scxml.drawio.svg b/docs/source/graphics/ros_action_to_scxml.drawio.svg index 99a6ab34..cd7363b4 100644 --- a/docs/source/graphics/ros_action_to_scxml.drawio.svg +++ b/docs/source/graphics/ros_action_to_scxml.drawio.svg @@ -1,11 +1,11 @@ - + - + -
+
Action Server-Client - SCXML @@ -35,11 +35,11 @@ - + -
+
Server @@ -52,12 +52,12 @@ - - + + -
+
@@ -79,16 +79,16 @@
- + transition action_handle_goal_request:... - + -
+
@@ -98,7 +98,7 @@
- + idle @@ -141,10 +141,10 @@ - + -
+
@@ -156,7 +156,7 @@
- + transition action_goal_accept_client_X @@ -166,7 +166,7 @@ -
+
@@ -176,7 +176,7 @@
- + transition action_goal_reject_client_X @@ -309,12 +309,12 @@ - - + + -
+
transition action_thread_free: @@ -324,17 +324,17 @@
- + transition action_thread_free:... - - + + -
+
transition: @@ -362,16 +362,16 @@
- + transition:... - + -
+
@@ -381,7 +381,7 @@
- + handle_goal @@ -504,7 +504,7 @@ -
+
transition if feedback_available @@ -518,11 +518,230 @@
- + transition if feedback_available... + + + + +
+
+
+ Action Handler +
+
+
+
+ + Action Handler + +
+
+ + + + + +
+
+
+ transition action_goal_req_client_x +
+ - assign: goal_fields +
+
+
+
+ + transition action_goal_req_client_x... + +
+
+ + + + + +
+
+
+
+ + transition action_<reply> + +
+
+ + - assign goal_id + +
+
+ + - assign <reply>_fields + +
+
+
+
+
+ + transition action_<reply>... + +
+
+ + + + +
+
+
+ + idle + +
+
+
+
+ + idle + +
+
+ + + + + +
+
+
+
+ transition +
+
+ - send action_goal_handle +
+
+ - param goal_id +
+
+ - param goal_fields +
+
+
+
+
+ + transition... + +
+
+ + + + +
+
+
+ + goal_client_x + +
+
+
+
+ + goal_client_x + +
+
+ + + + + +
+
+
+ The goal_id is assigned from the Action Handler, and is mapped to exactly one client +
+
+
+
+ + The goal_id is assigned from... + +
+
+ + + + + +
+
+
+ transition +
+ - if goal_id == client_1_goal_id +
+ - send action_<reply>_client_1 +
+ - param <reply>_fields +
+ - elseif goal_id == client_2_goal_id +
+ - ... +
+
+
+
+ + transition... + +
+
+ + + + +
+
+
+ + handle_<reply> + +
+
+
+
+ + handle_<reply> + +
+
+ + + + + +
+
+
+ The same strategy is applied for all different replies coming from the action server +
+
+
+
+ + The same strategy is applied... + +
+
From 6cee7a573010fb18038dca265da5450ccaf341c0 Mon Sep 17 00:00:00 2001 From: Marco Lampacrescia Date: Fri, 30 Aug 2024 09:31:19 +0200 Subject: [PATCH 06/23] Refactor ros service handler for reusability Signed-off-by: Marco Lampacrescia --- .../ros_helpers/ros_communication_handler.py | 126 ++++++++++++++++++ ...ros_services.py => ros_service_handler.py} | 105 ++++----------- .../scxml_helpers/scxml_to_jani.py | 6 +- .../scxml_helpers/top_level_interpreter.py | 10 +- .../fibonacci_action_example/client_1.scxml | 4 +- .../fibonacci_action_example/server.scxml | 1 + 6 files changed, 160 insertions(+), 92 deletions(-) create mode 100644 jani_generator/src/jani_generator/ros_helpers/ros_communication_handler.py rename jani_generator/src/jani_generator/ros_helpers/{ros_services.py => ros_service_handler.py} (50%) diff --git a/jani_generator/src/jani_generator/ros_helpers/ros_communication_handler.py b/jani_generator/src/jani_generator/ros_helpers/ros_communication_handler.py new file mode 100644 index 00000000..16efa34a --- /dev/null +++ b/jani_generator/src/jani_generator/ros_helpers/ros_communication_handler.py @@ -0,0 +1,126 @@ +# Copyright (c) 2024 - for information on the respective copyright owner +# see the NOTICE file + +# 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. + +""" +Generic class for generators of SCXML state machine for specific ROS communication interfaces. +""" + +from typing import List, Optional + +from jani_generator.jani_entries import JaniModel +from scxml_converter.scxml_entries import ScxmlRoot + + +class RosCommunicationHandler: + """ + Object storing the declarations related to a ROS interface and creating an handler for them. + """ + + @staticmethod + def get_interface_prefix() -> str: + """ + Get the prefix used for the interface. + + :return: The prefix used for the interface. + """ + raise NotImplementedError("Method get_interface_prefix must be implemented.") + + def __init__(self): + """Initialize the object completely empty.""" + # The name of the communication channel, shared across all automata + self._interface_name: Optional[str] = None + # The type of the communication channel + self._interface_type: Optional[str] = None + # The name of the automaton providing the server of the communication channel + self._server_automaton: Optional[str] = None + # The names of the automata providing the clients of the communication channel + self._clients_automata: List[str] = [] + + def _set_name_and_type(self, interface_name: str, interface_type: str) -> None: + """Setter to add and verify the name and type of a new ROS declaration.""" + if self._interface_name is None: + self._interface_name = interface_name + self._interface_type = interface_type + else: + assert self._interface_name == interface_name, \ + f"Error: Interface name {interface_name} does not match {self._interface_name}." + assert self._interface_type == interface_type, \ + f"Error: Interface type {interface_type} does not match {self._interface_type}." + + def _assert_validity(self): + """ + Make sure service_name and service_type are set and a server and at least one client exist. + """ + assert self._interface_name is not None, "Service name not set." + assert self._interface_type is not None, "Service type not set." + assert self._server_automaton is not None, \ + f"ROS server not provided for {self._interface_name}." + assert len(self._clients_automata) > 0, \ + f"No ROS clients provided for {self._interface_name}." + + def set_server(self, interface_name: str, interface_type: str, automaton_name: str) -> None: + """ + Set the server of the service. + There must be exactly one. + + :interface_name: The name of the ROS service. + :interface_type: The type of the ROS service (e.g. std_srvs/SetBool). + :automaton_name: The name of the JANI automaton that implements this server. + """ + self._set_name_and_type(interface_name, interface_type) + assert self._server_automaton is None, \ + f"Found more than one server for service {interface_name}." + self._server_automaton = automaton_name + + def add_client(self, interface_name: str, interface_type: str, automaton_name: str) -> None: + """ + Set the client of the service. + There must be one or more. + + :interface_name: The name of the ROS service. + :interface_type: The type of the ROS service (e.g. std_srvs/SetBool). + :automaton_name: The name of the JANI automaton that implements this client. + """ + self._set_name_and_type(interface_name, interface_type) + assert automaton_name not in self._clients_automata, \ + f"Service client for {automaton_name} already declared for service {interface_name}." + self._clients_automata.append(automaton_name) + + def to_scxml(self) -> ScxmlRoot: + """ + Generate the srv_handler automaton that implements the link between the server of this + service and its clients. + This ensures that only one request can be processed at the time and that the client receives + only the response related to it's request. + + :return: Scxml object representing the necessary file content. + """ + NotImplementedError("Method to_scxml must be implemented.") + + +def remove_empty_self_loops_from_interface_handlers_in_jani(jani_model: JaniModel) -> None: + """ + Remove self-loops from srv_handler automata in the Jani model. + + :param jani_model: The Jani model to modify. + """ + handlers_prefixes = [handler.get_interface_prefix() + for handler in RosCommunicationHandler.__subclasses__()] + for automaton in jani_model.get_automata(): + # Modify the automaton in place + for prefix in handlers_prefixes: + if automaton.get_name().startswith(prefix): + automaton.remove_empty_self_loop_edges() + break diff --git a/jani_generator/src/jani_generator/ros_helpers/ros_services.py b/jani_generator/src/jani_generator/ros_helpers/ros_service_handler.py similarity index 50% rename from jani_generator/src/jani_generator/ros_helpers/ros_services.py rename to jani_generator/src/jani_generator/ros_helpers/ros_service_handler.py index b3e8f85c..96a4a07e 100644 --- a/jani_generator/src/jani_generator/ros_helpers/ros_services.py +++ b/jani_generator/src/jani_generator/ros_helpers/ros_service_handler.py @@ -17,10 +17,10 @@ Representation of ROS Services. """ -from typing import Dict, List, Optional +from typing import Dict from as2fm_common.common import get_default_expression_for_type, value_to_string -from jani_generator.jani_entries import JaniModel + from scxml_converter.scxml_entries import (ScxmlAssign, ScxmlData, ScxmlDataModel, ScxmlParam, ScxmlRoot, ScxmlSend, ScxmlState, @@ -30,67 +30,17 @@ generate_srv_server_response_event, get_srv_type_params, sanitize_ros_interface_name) from scxml_converter.scxml_entries.utils import SCXML_DATA_STR_TO_TYPE -SRV_PREFIX = "srv_handler_" - - -class RosService: - """Object that contains a description of a ROS service with its server and clients.""" - - def __init__(self): - self._service_name: Optional[str] = None - self._service_type: Optional[str] = None - self._service_server_automaton: Optional[str] = None - self._service_client_automata: List[str] = [] - - def _set_name_and_type(self, service_name: str, service_type: str) -> None: - if self._service_name is None: - self._service_name = service_name - self._service_type = service_type - else: - assert self._service_name == service_name, \ - f"Service name {service_name} does not match {self._service_name}." - assert self._service_type == service_type, \ - f"Service type {service_type} does not match {self._service_type}." - - def _assert_validity(self): - """ - Make sure service_name and service_type are set and a server and at least one client exist. - """ - assert self._service_name is not None, "Service name not set." - assert self._service_type is not None, "Service type not set." - assert self._service_server_automaton is not None, \ - f"Service server not set for {self._service_name}." - assert len(self._service_client_automata) > 0, \ - f"No service clients set for {self._service_name}." - - def set_service_server(self, service_name: str, service_type: str, automaton_name: str) -> None: - """ - Set the server of the service. - There must be exactly one. +from jani_generator.ros_helpers.ros_communication_handler import RosCommunicationHandler - :service_name: The name of the ROS service. - :service_type: The type of the ROS service (e.g. std_srvs/SetBool). - :automaton_name: The name of the JANI automaton that implements this server. - """ - self._set_name_and_type(service_name, service_type) - assert self._service_server_automaton is None, \ - f"Found more than one server for service {service_name}." - self._service_server_automaton = automaton_name - def append_service_client(self, - service_name: str, service_type: str, automaton_name: str) -> None: - """ - Set the client of the service. - There must be one or more. +class RosServiceHandler(RosCommunicationHandler): + """ + Object storing the declarations related to a ROS Service and creating an handler for them. + """ - :service_name: The name of the ROS service. - :service_type: The type of the ROS service (e.g. std_srvs/SetBool). - :automaton_name: The name of the JANI automaton that implements this client. - """ - self._set_name_and_type(service_name, service_type) - assert automaton_name not in self._service_client_automata, \ - f"Service client for {automaton_name} already declared for service {service_name}." - self._service_client_automata.append(automaton_name) + @staticmethod + def get_interface_prefix() -> str: + return "srv_handler_" def to_scxml(self) -> ScxmlRoot: """ @@ -102,7 +52,7 @@ def to_scxml(self) -> ScxmlRoot: :return: Scxml object representing the necessary file content. """ self._assert_validity() - req_params, res_params = get_srv_type_params(self._service_type) + req_params, res_params = get_srv_type_params(self._interface_type) # Hack: Using support variables in the data model to avoid having _event in send params req_fields_as_data = [] for field_name, field_type in req_params.items() | res_params.items(): @@ -110,32 +60,35 @@ def to_scxml(self) -> ScxmlRoot: get_default_expression_for_type(SCXML_DATA_STR_TO_TYPE[field_type])) req_fields_as_data.append(ScxmlData(field_name, default_expr, field_type)) # Make sure the service name has no slashes and spaces - scxml_root_name = SRV_PREFIX + sanitize_ros_interface_name(self._service_name) + scxml_root_name = \ + self.get_interface_prefix() + sanitize_ros_interface_name(self._interface_name) wait_state = ScxmlState("waiting", body=[ ScxmlTransition( f"processing_client_{client_id}", - [generate_srv_request_event(self._service_name, client_id)], + [generate_srv_request_event(self._interface_name, + client_id)], body=[ScxmlAssign(field_name, f"_event.{field_name}") for field_name in req_params] + [ ScxmlSend(generate_srv_server_request_event( - self._service_name), + self._interface_name), [ScxmlParam(field_name, expr=field_name) for field_name in req_params])]) - for client_id in self._service_client_automata]) + for client_id in self._clients_automata]) processing_states = [ ScxmlState(f"processing_client_{client_id}", body=[ ScxmlTransition( - "waiting", [generate_srv_server_response_event(self._service_name)], + "waiting", [ + generate_srv_server_response_event(self._interface_name)], body=[ ScxmlAssign(field_name, f"_event.{field_name}") for field_name in res_params] + [ ScxmlSend(generate_srv_response_event( - self._service_name, client_id), + self._interface_name, client_id), [ScxmlParam(field_name, expr=field_name) for field_name in res_params])])]) - for client_id in self._service_client_automata] + for client_id in self._clients_automata] # Prepare the ScxmlRoot object and return it scxml_root = ScxmlRoot(scxml_root_name) scxml_root.set_data_model(ScxmlDataModel(req_fields_as_data)) @@ -146,17 +99,5 @@ def to_scxml(self) -> ScxmlRoot: return scxml_root -# Mapping from RosService name and RosService information -RosServices = Dict[str, RosService] - - -def remove_empty_self_loops_from_srv_handlers_in_jani(jani_model: JaniModel) -> None: - """ - Remove self-loops from srv_handler automata in the Jani model. - - :param jani_model: The Jani model to modify. - """ - for automaton in jani_model.get_automata(): - # Modify the automaton in place - if automaton.get_name().startswith(SRV_PREFIX): - automaton.remove_empty_self_loop_edges() +# Mapping from RosService name and their handler instance +RosServices = Dict[str, RosServiceHandler] diff --git a/jani_generator/src/jani_generator/scxml_helpers/scxml_to_jani.py b/jani_generator/src/jani_generator/scxml_helpers/scxml_to_jani.py index 3538ec76..dcb5e461 100644 --- a/jani_generator/src/jani_generator/scxml_helpers/scxml_to_jani.py +++ b/jani_generator/src/jani_generator/scxml_helpers/scxml_to_jani.py @@ -21,8 +21,8 @@ from jani_generator.jani_entries.jani_automaton import JaniAutomaton from jani_generator.jani_entries.jani_model import JaniModel -from jani_generator.ros_helpers.ros_services import \ - remove_empty_self_loops_from_srv_handlers_in_jani +from jani_generator.ros_helpers.ros_communication_handler import \ + remove_empty_self_loops_from_interface_handlers_in_jani from jani_generator.ros_helpers.ros_timer import (RosTimer, make_global_timer_automaton) from jani_generator.scxml_helpers.scxml_event import EventsHolder @@ -81,5 +81,5 @@ def convert_multiple_scxmls_to_jani( if timer_automaton is not None: base_model.add_jani_automaton(timer_automaton) implement_scxml_events_as_jani_syncs(events_holder, timers, max_array_size, base_model) - remove_empty_self_loops_from_srv_handlers_in_jani(base_model) + remove_empty_self_loops_from_interface_handlers_in_jani(base_model) return base_model diff --git a/jani_generator/src/jani_generator/scxml_helpers/top_level_interpreter.py b/jani_generator/src/jani_generator/scxml_helpers/top_level_interpreter.py index 96261f6c..56f30318 100644 --- a/jani_generator/src/jani_generator/scxml_helpers/top_level_interpreter.py +++ b/jani_generator/src/jani_generator/scxml_helpers/top_level_interpreter.py @@ -24,7 +24,7 @@ from xml.etree import ElementTree as ET from as2fm_common.common import remove_namespace -from jani_generator.ros_helpers.ros_services import RosService, RosServices +from jani_generator.ros_helpers.ros_service_handler import RosServiceHandler, RosServices from jani_generator.ros_helpers.ros_timer import RosTimer from jani_generator.scxml_helpers.scxml_to_jani import \ convert_multiple_scxmls_to_jani @@ -154,13 +154,13 @@ def generate_plain_scxml_models_and_timers( # Handle ROS Services for service_name, service_type in ros_declarations._service_clients.values(): if service_name not in all_services: - all_services[service_name] = RosService() - all_services[service_name].append_service_client( + all_services[service_name] = RosServiceHandler() + all_services[service_name].add_client( service_name, service_type, scxml_entry.get_name()) for service_name, service_type in ros_declarations._service_servers.values(): if service_name not in all_services: - all_services[service_name] = RosService() - all_services[service_name].set_service_server( + all_services[service_name] = RosServiceHandler() + all_services[service_name].set_server( service_name, service_type, scxml_entry.get_name()) plain_scxml_models.extend(plain_scxmls) # Generate service sync SCXML models diff --git a/scxml_converter/test/_test_data/fibonacci_action_example/client_1.scxml b/scxml_converter/test/_test_data/fibonacci_action_example/client_1.scxml index 1d30c6b2..0cab4fc8 100644 --- a/scxml_converter/test/_test_data/fibonacci_action_example/client_1.scxml +++ b/scxml_converter/test/_test_data/fibonacci_action_example/client_1.scxml @@ -11,8 +11,8 @@ - - + + diff --git a/scxml_converter/test/_test_data/fibonacci_action_example/server.scxml b/scxml_converter/test/_test_data/fibonacci_action_example/server.scxml index de34c6da..985b794f 100644 --- a/scxml_converter/test/_test_data/fibonacci_action_example/server.scxml +++ b/scxml_converter/test/_test_data/fibonacci_action_example/server.scxml @@ -30,6 +30,7 @@ + From b58a8b9342495903625f53369d2fb33cb7f1d144 Mon Sep 17 00:00:00 2001 From: Marco Lampacrescia Date: Fri, 30 Aug 2024 10:30:47 +0200 Subject: [PATCH 07/23] Prepare foundation for Ros Action Handler conversion Signed-off-by: Marco Lampacrescia --- .../graphics/ros_action_to_scxml.drawio.svg | 221 ++++++------------ .../ros_helpers/ros_action_handler.py | 108 +++++++++ .../ros_helpers/ros_communication_handler.py | 30 ++- .../ros_helpers/ros_service_handler.py | 17 +- 4 files changed, 208 insertions(+), 168 deletions(-) create mode 100644 jani_generator/src/jani_generator/ros_helpers/ros_action_handler.py diff --git a/docs/source/graphics/ros_action_to_scxml.drawio.svg b/docs/source/graphics/ros_action_to_scxml.drawio.svg index cd7363b4..b69608ea 100644 --- a/docs/source/graphics/ros_action_to_scxml.drawio.svg +++ b/docs/source/graphics/ros_action_to_scxml.drawio.svg @@ -1,11 +1,11 @@ - + - + -
+
Action Server-Client - SCXML @@ -35,11 +35,11 @@ - + -
+
Server @@ -52,12 +52,12 @@ - - + + -
+
@@ -79,16 +79,16 @@
- + transition action_handle_goal_request:... - + -
+
@@ -98,7 +98,7 @@
- + idle @@ -141,10 +141,10 @@ - + -
+
@@ -156,7 +156,7 @@
- + transition action_goal_accept_client_X @@ -166,7 +166,7 @@ -
+
@@ -176,7 +176,7 @@
- + transition action_goal_reject_client_X @@ -309,12 +309,12 @@ - - + + -
+
transition action_thread_free: @@ -324,17 +324,17 @@
- + transition action_thread_free:... - - + + -
+
transition: @@ -362,16 +362,16 @@
- + transition:... - + -
+
@@ -381,7 +381,7 @@
- + handle_goal @@ -504,7 +504,7 @@ -
+
transition if feedback_available @@ -518,16 +518,16 @@
- + transition if feedback_available... - + -
+
Action Handler @@ -540,32 +540,38 @@ - - - + + + -
+
transition action_goal_req_client_x
- assign: goal_fields +
+ - send action_goal_handle +
+ - param goal_id +
+ - param goal_fields
- + transition action_goal_req_client_x... - - + + -
+
@@ -583,88 +589,54 @@ - assign <reply>_fields
-
-
-
- - - transition action_<reply>... - - - - - - - -
-
-
- - idle - -
-
-
-
- - idle - -
-
- - - - - -
-
-
-
- transition -
-
- - send action_goal_handle -
- - param goal_id -
-
- - param goal_fields + - if goal_id == client_1_goal_id +
+ - send action_<reply>_client_1 +
+ - param <reply>_fields +
+ - elseif goal_id == client_2_goal_id +
+ - ... + +
+
- - transition... + + transition action_<reply>...
- + -
+
- goal_client_x + waiting
- - goal_client_x + + waiting - - + + -
+
The goal_id is assigned from the Action Handler, and is mapped to exactly one client @@ -672,64 +644,17 @@
- + The goal_id is assigned from... - - - - - -
-
-
- transition -
- - if goal_id == client_1_goal_id -
- - send action_<reply>_client_1 -
- - param <reply>_fields -
- - elseif goal_id == client_2_goal_id -
- - ... -
-
-
-
- - transition... - -
-
- - - - -
-
-
- - handle_<reply> - -
-
-
-
- - handle_<reply> - -
-
- - + + -
+
The same strategy is applied for all different replies coming from the action server @@ -737,7 +662,7 @@
- + The same strategy is applied... diff --git a/jani_generator/src/jani_generator/ros_helpers/ros_action_handler.py b/jani_generator/src/jani_generator/ros_helpers/ros_action_handler.py new file mode 100644 index 00000000..95a60208 --- /dev/null +++ b/jani_generator/src/jani_generator/ros_helpers/ros_action_handler.py @@ -0,0 +1,108 @@ +# Copyright (c) 2024 - for information on the respective copyright owner +# see the NOTICE file + +# 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. + +""" +Helper to create an orchestrator out of ROS Actions declarations. +""" + +from typing import Dict, List, Tuple + +from scxml_converter.scxml_entries import ( + ScxmlAssign, ScxmlDataModel, ScxmlParam, ScxmlRoot, ScxmlSend, ScxmlState, ScxmlTransition) +from scxml_converter.scxml_entries.ros_utils import ( + get_action_type_params, sanitize_ros_interface_name) + +from jani_generator.ros_helpers.ros_communication_handler import RosCommunicationHandler + + +class RosActionHandler(RosCommunicationHandler): + """ + Object storing the declarations related to a ROS Action and creating an handler for them. + """ + + @staticmethod + def get_interface_prefix() -> str: + return "action_handler_" + + @staticmethod + def _generate_goal_request_transition( + client_id: str, goal_id: int, req_params: Dict[str, str]) -> ScxmlTransition: + pass + + @staticmethod + def _generate_goal_accept_transition( + client_to_goal_id: List[Tuple[str, int]]) -> ScxmlTransition: + pass + + @staticmethod + def _generate_goal_reject_transition( + client_to_goal_id: List[Tuple[str, int]]) -> ScxmlTransition: + pass + + @staticmethod + def _generate_feedback_response_transition( + client_to_goal_id: List[Tuple[str, int]], + feedback_params: Dict[str, str]) -> ScxmlTransition: + pass + + @staticmethod + def _generate_result_response_transition( + client_to_goal_id: List[Tuple[str, int]], + result_params: Dict[str, str]) -> ScxmlTransition: + pass + + def to_scxml(self) -> ScxmlRoot: + """ + Generate the srv_handler automaton that implements the link between the server of this + service and its clients. + This ensures that only one request can be processed at the time and that the client receives + only the response related to it's request. + + :return: Scxml object representing the necessary file content. + """ + self._assert_validity() + + # Design choice: we generate a unique goal_id for each client, and we use it to identify + # the recipient of the response. + client_to_goal_id: List[Tuple[str, int]] = [ + (client_id, goal_id) for goal_id, client_id in enumerate(self._clients_automata)] + + goal_params, feedback_params, result_params = get_action_type_params(self._interface_type) + + # Hack: Using support variables in the data model to avoid having _event in send params + req_fields_as_data = self._generate_datamodel_from_ros_fields( + goal_params | feedback_params | result_params) + # Make sure the service name has no slashes and spaces + scxml_root_name = \ + self.get_interface_prefix() + sanitize_ros_interface_name(self._interface_name) + wait_state = ScxmlState("waiting") + for client_id, goal_id in client_to_goal_id: + wait_state.add_transition( + self._generate_goal_request_transition(client_id, goal_id, goal_params)) + wait_state.add_transition(self._generate_goal_accept_transition(client_to_goal_id)) + wait_state.add_transition(self._generate_goal_reject_transition(client_to_goal_id)) + wait_state.add_transition(self._generate_feedback_response_transition( + client_to_goal_id, feedback_params)) + wait_state.add_transition(self._generate_result_response_transition( + client_to_goal_id, result_params)) + scxml_root = ScxmlRoot(scxml_root_name) + scxml_root.set_data_model(ScxmlDataModel(req_fields_as_data)) + scxml_root.add_state(wait_state, initial=True) + assert scxml_root.is_plain_scxml(), "Generated SCXML for srv sync is not plain SCXML." + return scxml_root + + +# Mapping from Ros Action name and their handler instance +RosActions = Dict[str, RosActionHandler] diff --git a/jani_generator/src/jani_generator/ros_helpers/ros_communication_handler.py b/jani_generator/src/jani_generator/ros_helpers/ros_communication_handler.py index 16efa34a..597a9333 100644 --- a/jani_generator/src/jani_generator/ros_helpers/ros_communication_handler.py +++ b/jani_generator/src/jani_generator/ros_helpers/ros_communication_handler.py @@ -17,10 +17,12 @@ Generic class for generators of SCXML state machine for specific ROS communication interfaces. """ -from typing import List, Optional +from typing import Dict, List, Optional +from as2fm_common.common import get_default_expression_for_type, value_to_string +from scxml_converter.scxml_entries import ScxmlData, ScxmlRoot +from scxml_converter.scxml_entries.utils import SCXML_DATA_STR_TO_TYPE from jani_generator.jani_entries import JaniModel -from scxml_converter.scxml_entries import ScxmlRoot class RosCommunicationHandler: @@ -72,11 +74,11 @@ def _assert_validity(self): def set_server(self, interface_name: str, interface_type: str, automaton_name: str) -> None: """ - Set the server of the service. + Set the server of the ROS interface. There must be exactly one. - :interface_name: The name of the ROS service. - :interface_type: The type of the ROS service (e.g. std_srvs/SetBool). + :interface_name: The name of the ROS interface. + :interface_type: The type of the ROS interface (e.g. std_srvs/SetBool). :automaton_name: The name of the JANI automaton that implements this server. """ self._set_name_and_type(interface_name, interface_type) @@ -89,8 +91,8 @@ def add_client(self, interface_name: str, interface_type: str, automaton_name: s Set the client of the service. There must be one or more. - :interface_name: The name of the ROS service. - :interface_type: The type of the ROS service (e.g. std_srvs/SetBool). + :interface_name: The name of the ROS interface. + :interface_type: The type of the ROS interface (e.g. std_srvs/SetBool). :automaton_name: The name of the JANI automaton that implements this client. """ self._set_name_and_type(interface_name, interface_type) @@ -109,6 +111,20 @@ def to_scxml(self) -> ScxmlRoot: """ NotImplementedError("Method to_scxml must be implemented.") + def _generate_datamodel_from_ros_fields(self, fields: Dict[str, str]) -> List[ScxmlData]: + """ + Generate the ScxmlDataModel object from the ROS fields. + + :param fields: The field names and types of the ROS interface. + :return: A list of ScxmlData object. + """ + scxml_fields: List[ScxmlData] = [] + for field_name, field_type in fields.items(): + default_expr = value_to_string( + get_default_expression_for_type(SCXML_DATA_STR_TO_TYPE[field_type])) + scxml_fields.append(ScxmlData(field_name, default_expr, field_type)) + return scxml_fields + def remove_empty_self_loops_from_interface_handlers_in_jani(jani_model: JaniModel) -> None: """ diff --git a/jani_generator/src/jani_generator/ros_helpers/ros_service_handler.py b/jani_generator/src/jani_generator/ros_helpers/ros_service_handler.py index 96a4a07e..485ecf1f 100644 --- a/jani_generator/src/jani_generator/ros_helpers/ros_service_handler.py +++ b/jani_generator/src/jani_generator/ros_helpers/ros_service_handler.py @@ -19,16 +19,11 @@ from typing import Dict -from as2fm_common.common import get_default_expression_for_type, value_to_string - -from scxml_converter.scxml_entries import (ScxmlAssign, ScxmlData, - ScxmlDataModel, ScxmlParam, - ScxmlRoot, ScxmlSend, ScxmlState, - ScxmlTransition) +from scxml_converter.scxml_entries import ( + ScxmlAssign, ScxmlDataModel, ScxmlParam, ScxmlRoot, ScxmlSend, ScxmlState, ScxmlTransition) from scxml_converter.scxml_entries.ros_utils import ( generate_srv_request_event, generate_srv_response_event, generate_srv_server_request_event, generate_srv_server_response_event, get_srv_type_params, sanitize_ros_interface_name) -from scxml_converter.scxml_entries.utils import SCXML_DATA_STR_TO_TYPE from jani_generator.ros_helpers.ros_communication_handler import RosCommunicationHandler @@ -54,11 +49,7 @@ def to_scxml(self) -> ScxmlRoot: self._assert_validity() req_params, res_params = get_srv_type_params(self._interface_type) # Hack: Using support variables in the data model to avoid having _event in send params - req_fields_as_data = [] - for field_name, field_type in req_params.items() | res_params.items(): - default_expr = value_to_string( - get_default_expression_for_type(SCXML_DATA_STR_TO_TYPE[field_type])) - req_fields_as_data.append(ScxmlData(field_name, default_expr, field_type)) + req_fields_as_data = self._generate_datamodel_from_ros_fields(req_params | res_params) # Make sure the service name has no slashes and spaces scxml_root_name = \ self.get_interface_prefix() + sanitize_ros_interface_name(self._interface_name) @@ -99,5 +90,5 @@ def to_scxml(self) -> ScxmlRoot: return scxml_root -# Mapping from RosService name and their handler instance +# Mapping from Ros Service name and their handler instance RosServices = Dict[str, RosServiceHandler] From c21e61dc397e4e8dc4e4096dba1e8a20756c9e01 Mon Sep 17 00:00:00 2001 From: Marco Lampacrescia Date: Fri, 30 Aug 2024 11:08:03 +0200 Subject: [PATCH 08/23] Implement goal_request transition Signed-off-by: Marco Lampacrescia --- .../ros_helpers/ros_action_handler.py | 45 ++++++++++++------- .../ros_helpers/ros_communication_handler.py | 2 +- .../ros_helpers/ros_service_handler.py | 4 +- .../scxml_entries/ros_utils.py | 32 +++++++++---- 4 files changed, 54 insertions(+), 29 deletions(-) diff --git a/jani_generator/src/jani_generator/ros_helpers/ros_action_handler.py b/jani_generator/src/jani_generator/ros_helpers/ros_action_handler.py index 95a60208..61b4e475 100644 --- a/jani_generator/src/jani_generator/ros_helpers/ros_action_handler.py +++ b/jani_generator/src/jani_generator/ros_helpers/ros_action_handler.py @@ -19,12 +19,15 @@ from typing import Dict, List, Tuple +from jani_generator.ros_helpers.ros_communication_handler import RosCommunicationHandler + from scxml_converter.scxml_entries import ( ScxmlAssign, ScxmlDataModel, ScxmlParam, ScxmlRoot, ScxmlSend, ScxmlState, ScxmlTransition) from scxml_converter.scxml_entries.ros_utils import ( - get_action_type_params, sanitize_ros_interface_name) - -from jani_generator.ros_helpers.ros_communication_handler import RosCommunicationHandler + get_action_type_params, + generate_action_goal_handle_event, generate_action_goal_req_event, + get_action_goal_id_definition, + sanitize_ros_interface_name) class RosActionHandler(RosCommunicationHandler): @@ -36,31 +39,38 @@ class RosActionHandler(RosCommunicationHandler): def get_interface_prefix() -> str: return "action_handler_" - @staticmethod def _generate_goal_request_transition( - client_id: str, goal_id: int, req_params: Dict[str, str]) -> ScxmlTransition: - pass + self, client_id: str, goal_id: int, req_params: Dict[str, str]) -> ScxmlTransition: + action_client_req_event = generate_action_goal_req_event(self._interface_name, client_id) + action_srv_handle_event = generate_action_goal_handle_event(self._interface_name) + goal_req_transition = ScxmlTransition("waiting", [action_client_req_event]) + send_params = [ScxmlParam("goal_id", str(goal_id))] + for field_name in req_params: + # Add preliminary assignments (part of the hack mentioned in self.to_scxml()) + goal_req_transition.append_body_executable_entry( + ScxmlAssign(field_name, f"_event.{field_name}")) + send_params.append(ScxmlParam(field_name, field_name)) + # Add the send to the server + goal_req_transition.append_body_executable_entry( + ScxmlSend(action_srv_handle_event, send_params)) + return goal_req_transition - @staticmethod def _generate_goal_accept_transition( - client_to_goal_id: List[Tuple[str, int]]) -> ScxmlTransition: + self, client_to_goal_id: List[Tuple[str, int]]) -> ScxmlTransition: pass - @staticmethod def _generate_goal_reject_transition( - client_to_goal_id: List[Tuple[str, int]]) -> ScxmlTransition: + self, client_to_goal_id: List[Tuple[str, int]]) -> ScxmlTransition: pass - @staticmethod def _generate_feedback_response_transition( - client_to_goal_id: List[Tuple[str, int]], - feedback_params: Dict[str, str]) -> ScxmlTransition: + self, client_to_goal_id: List[Tuple[str, int]], feedback_params: Dict[str, str] + ) -> ScxmlTransition: pass - @staticmethod def _generate_result_response_transition( - client_to_goal_id: List[Tuple[str, int]], - result_params: Dict[str, str]) -> ScxmlTransition: + self, client_to_goal_id: List[Tuple[str, int]], result_params: Dict[str, str] + ) -> ScxmlTransition: pass def to_scxml(self) -> ScxmlRoot: @@ -82,8 +92,9 @@ def to_scxml(self) -> ScxmlRoot: goal_params, feedback_params, result_params = get_action_type_params(self._interface_type) # Hack: Using support variables in the data model to avoid having _event in send params + goal_id_def = get_action_goal_id_definition() req_fields_as_data = self._generate_datamodel_from_ros_fields( - goal_params | feedback_params | result_params) + goal_params | feedback_params | result_params | {goal_id_def[0]: goal_id_def[1]}) # Make sure the service name has no slashes and spaces scxml_root_name = \ self.get_interface_prefix() + sanitize_ros_interface_name(self._interface_name) diff --git a/jani_generator/src/jani_generator/ros_helpers/ros_communication_handler.py b/jani_generator/src/jani_generator/ros_helpers/ros_communication_handler.py index 597a9333..1bf1580e 100644 --- a/jani_generator/src/jani_generator/ros_helpers/ros_communication_handler.py +++ b/jani_generator/src/jani_generator/ros_helpers/ros_communication_handler.py @@ -20,9 +20,9 @@ from typing import Dict, List, Optional from as2fm_common.common import get_default_expression_for_type, value_to_string +from jani_generator.jani_entries import JaniModel from scxml_converter.scxml_entries import ScxmlData, ScxmlRoot from scxml_converter.scxml_entries.utils import SCXML_DATA_STR_TO_TYPE -from jani_generator.jani_entries import JaniModel class RosCommunicationHandler: diff --git a/jani_generator/src/jani_generator/ros_helpers/ros_service_handler.py b/jani_generator/src/jani_generator/ros_helpers/ros_service_handler.py index 485ecf1f..43de9ef5 100644 --- a/jani_generator/src/jani_generator/ros_helpers/ros_service_handler.py +++ b/jani_generator/src/jani_generator/ros_helpers/ros_service_handler.py @@ -19,14 +19,14 @@ from typing import Dict +from jani_generator.ros_helpers.ros_communication_handler import RosCommunicationHandler + from scxml_converter.scxml_entries import ( ScxmlAssign, ScxmlDataModel, ScxmlParam, ScxmlRoot, ScxmlSend, ScxmlState, ScxmlTransition) from scxml_converter.scxml_entries.ros_utils import ( generate_srv_request_event, generate_srv_response_event, generate_srv_server_request_event, generate_srv_server_response_event, get_srv_type_params, sanitize_ros_interface_name) -from jani_generator.ros_helpers.ros_communication_handler import RosCommunicationHandler - class RosServiceHandler(RosCommunicationHandler): """ diff --git a/scxml_converter/src/scxml_converter/scxml_entries/ros_utils.py b/scxml_converter/src/scxml_converter/scxml_entries/ros_utils.py index 7cc25709..98f3cc2a 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/ros_utils.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/ros_utils.py @@ -144,6 +144,11 @@ def get_action_type_params(action_definition: str return action_goal_fields, action_feedback_fields, action_result_fields +def get_action_goal_id_definition() -> Tuple[str, str]: + """Provide the definition of the goal_id field in ROS actions.""" + return "goal_id", "int32" + + def replace_ros_interface_expression(msg_expr: str) -> str: """Convert all ROS interface expressions (in ROS_EVENT_PREFIXES) to plain SCXML events.""" scxml_prefix = "_event." @@ -512,10 +517,13 @@ def check_valid_action_goal_fields( action_type = self.get_action_server_info(alias_name)[1] goal_fields = get_action_type_params(action_type)[0] # We use the goal ID as a reserved field for the action. Make sure it is available. - assert "goal_id" not in goal_fields, \ - f"Error: SCXML ROS declarations: action {action_type} goal has the 'goal_id' field." + goal_id_name, goal_id_type = get_action_goal_id_definition() + assert goal_id_name not in goal_fields, \ + "Error: SCXML ROS declarations: "\ + f"found reserved '{goal_id_name}' field in action {action_type} goal." if has_goal_id: - goal_fields["goal_id"] = "int32" + # Add the goal_id to the expected fields + goal_fields[goal_id_name] = goal_id_type if not check_all_fields_known(ros_fields, goal_fields): print(f"Error: SCXML ROS declarations: Action goal {alias_name} has invalid fields.") return False @@ -533,10 +541,13 @@ def check_valid_action_feedback_fields( _, action_type = self.get_action_server_info(server_name) _, feedback_fields, _ = get_action_type_params(action_type) # We use the goal ID as a reserved field for the action. Make sure it is available. - assert "goal_id" not in feedback_fields, \ - f"Error: SCXML ROS declarations: action {action_type} feedback has the 'goal_id' field." + goal_id_name, goal_id_type = get_action_goal_id_definition() + assert goal_id_name not in feedback_fields, \ + "Error: SCXML ROS declarations: "\ + f"found reserved '{goal_id_name}' field in action {action_type} feedback." if has_goal_id: - feedback_fields["goal_id"] = "int32" + # Add the goal_id to the expected fields + feedback_fields[goal_id_name] = goal_id_type if not check_all_fields_known(ros_fields, feedback_fields): print(f"Error: SCXML ROS declarations: Action feedback {server_name} " "has invalid fields.") @@ -555,10 +566,13 @@ def check_valid_action_result_fields( _, action_type = self.get_action_server_info(server_name) _, _, result_fields = get_action_type_params(action_type) # We use the goal ID as a reserved field for the action. Make sure it is available. - assert "goal_id" not in result_fields, \ - f"Error: SCXML ROS declarations: action {action_type} feedback has the 'goal_id' field." + goal_id_name, goal_id_type = get_action_goal_id_definition() + assert goal_id_name not in result_fields, \ + "Error: SCXML ROS declarations: "\ + f"found reserved '{goal_id_name}' field in action {action_type} result." if has_goal_id: - result_fields["goal_id"] = "int32" + # Add the goal_id to the expected fields + result_fields[goal_id_name] = goal_id_type if not check_all_fields_known(ros_fields, result_fields): print(f"Error: SCXML ROS declarations: Action result {server_name} has invalid fields.") return False From 9b1a91c4d3e190dc9f01b511b01406b53abfe1f1 Mon Sep 17 00:00:00 2001 From: Marco Lampacrescia Date: Fri, 30 Aug 2024 11:59:23 +0200 Subject: [PATCH 09/23] Goal accepted and rejected transitions Signed-off-by: Marco Lampacrescia --- .../graphics/ros_action_to_scxml.drawio.svg | 18 ++++---- .../ros_helpers/ros_action_handler.py | 41 ++++++++++++++++--- 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/docs/source/graphics/ros_action_to_scxml.drawio.svg b/docs/source/graphics/ros_action_to_scxml.drawio.svg index b69608ea..a1130014 100644 --- a/docs/source/graphics/ros_action_to_scxml.drawio.svg +++ b/docs/source/graphics/ros_action_to_scxml.drawio.svg @@ -1,4 +1,4 @@ - + @@ -141,10 +141,10 @@ - + -
+
@@ -156,7 +156,7 @@
- + transition action_goal_accept_client_X @@ -166,7 +166,7 @@ -
+
@@ -176,7 +176,7 @@
- + transition action_goal_reject_client_X @@ -362,7 +362,7 @@
- + transition:...
@@ -504,7 +504,7 @@ -
+
transition if feedback_available @@ -518,7 +518,7 @@
- + transition if feedback_available... diff --git a/jani_generator/src/jani_generator/ros_helpers/ros_action_handler.py b/jani_generator/src/jani_generator/ros_helpers/ros_action_handler.py index 61b4e475..73b1d176 100644 --- a/jani_generator/src/jani_generator/ros_helpers/ros_action_handler.py +++ b/jani_generator/src/jani_generator/ros_helpers/ros_action_handler.py @@ -17,15 +17,18 @@ Helper to create an orchestrator out of ROS Actions declarations. """ -from typing import Dict, List, Tuple +from typing import Callable, Dict, List, Optional, Tuple from jani_generator.ros_helpers.ros_communication_handler import RosCommunicationHandler from scxml_converter.scxml_entries import ( - ScxmlAssign, ScxmlDataModel, ScxmlParam, ScxmlRoot, ScxmlSend, ScxmlState, ScxmlTransition) + ScxmlAssign, ScxmlDataModel, ScxmlIf, ScxmlParam, ScxmlRoot, ScxmlSend, ScxmlState, + ScxmlTransition) from scxml_converter.scxml_entries.ros_utils import ( get_action_type_params, generate_action_goal_handle_event, generate_action_goal_req_event, + generate_action_goal_accepted_event, generate_action_goal_handle_accepted_event, + generate_action_goal_rejected_event, generate_action_goal_handle_rejected_event, get_action_goal_id_definition, sanitize_ros_interface_name) @@ -41,10 +44,11 @@ def get_interface_prefix() -> str: def _generate_goal_request_transition( self, client_id: str, goal_id: int, req_params: Dict[str, str]) -> ScxmlTransition: + goal_id_name = get_action_goal_id_definition()[0] action_client_req_event = generate_action_goal_req_event(self._interface_name, client_id) action_srv_handle_event = generate_action_goal_handle_event(self._interface_name) goal_req_transition = ScxmlTransition("waiting", [action_client_req_event]) - send_params = [ScxmlParam("goal_id", str(goal_id))] + send_params = [ScxmlParam(goal_id_name, str(goal_id))] for field_name in req_params: # Add preliminary assignments (part of the hack mentioned in self.to_scxml()) goal_req_transition.append_body_executable_entry( @@ -55,13 +59,40 @@ def _generate_goal_request_transition( ScxmlSend(action_srv_handle_event, send_params)) return goal_req_transition + def _generate_conditional_send_to_bodies( + self, client_to_goal_id: List[Tuple[str, int]], send_params: Optional[List[ScxmlParam]], + client_event_function: Callable[[str, str], str]) -> ScxmlIf: + goal_id_name = get_action_goal_id_definition()[0] + condition_send_pairs: List[Tuple[str, ScxmlSend]] = [] + for client_id, goal_id in client_to_goal_id: + client_event = client_event_function(self._interface_name, client_id) + condition_send_pairs.append((f"{goal_id_name} == {goal_id}", + ScxmlSend(client_event, send_params))) + return ScxmlIf(condition_send_pairs) + def _generate_goal_accept_transition( self, client_to_goal_id: List[Tuple[str, int]]) -> ScxmlTransition: - pass + goal_id_name = get_action_goal_id_definition()[0] + accepted_event_srv = generate_action_goal_handle_accepted_event(self._interface_name) + accepted_transition = ScxmlTransition("waiting", [accepted_event_srv]) + accepted_transition.append_body_executable_entry( + ScxmlAssign(goal_id_name, f"_event.{goal_id_name}")) + accepted_transition.append_body_executable_entry( + self._generate_conditional_send_to_bodies( + client_to_goal_id, None, generate_action_goal_accepted_event)) + return accepted_transition def _generate_goal_reject_transition( self, client_to_goal_id: List[Tuple[str, int]]) -> ScxmlTransition: - pass + goal_id_name = get_action_goal_id_definition()[0] + rejected_event_srv = generate_action_goal_handle_rejected_event(self._interface_name) + rejected_transition = ScxmlTransition("waiting", [rejected_event_srv]) + rejected_transition.append_body_executable_entry( + ScxmlAssign(goal_id_name, f"_event.{goal_id_name}")) + rejected_transition.append_body_executable_entry( + self._generate_conditional_send_to_bodies( + client_to_goal_id, None, generate_action_goal_rejected_event)) + return rejected_transition def _generate_feedback_response_transition( self, client_to_goal_id: List[Tuple[str, int]], feedback_params: Dict[str, str] From c46314cde62d9f606ef13f03cc8879c5f552bc36 Mon Sep 17 00:00:00 2001 From: Marco Lampacrescia Date: Fri, 30 Aug 2024 13:41:05 +0200 Subject: [PATCH 10/23] Switch names in event generator function and finish first implementation of ros_action_handler Signed-off-by: Marco Lampacrescia --- .../ros_helpers/ros_action_handler.py | 95 +++++++++++++------ .../scxml_entries/ros_utils.py | 24 ++--- .../scxml_entries/scxml_ros_action_client.py | 6 +- .../scxml_entries/scxml_ros_action_server.py | 6 +- .../fibonacci_action_example/client_1.scxml | 4 +- 5 files changed, 88 insertions(+), 47 deletions(-) diff --git a/jani_generator/src/jani_generator/ros_helpers/ros_action_handler.py b/jani_generator/src/jani_generator/ros_helpers/ros_action_handler.py index 73b1d176..323ec96f 100644 --- a/jani_generator/src/jani_generator/ros_helpers/ros_action_handler.py +++ b/jani_generator/src/jani_generator/ros_helpers/ros_action_handler.py @@ -29,8 +29,9 @@ generate_action_goal_handle_event, generate_action_goal_req_event, generate_action_goal_accepted_event, generate_action_goal_handle_accepted_event, generate_action_goal_rejected_event, generate_action_goal_handle_rejected_event, - get_action_goal_id_definition, - sanitize_ros_interface_name) + generate_action_feedback_event, generate_action_feedback_handle_event, + generate_action_result_event, generate_action_result_handle_event, + get_action_goal_id_definition, sanitize_ros_interface_name) class RosActionHandler(RosCommunicationHandler): @@ -44,6 +45,13 @@ def get_interface_prefix() -> str: def _generate_goal_request_transition( self, client_id: str, goal_id: int, req_params: Dict[str, str]) -> ScxmlTransition: + """ + Generate a scxml transition that, given a client request, sends an event to the server. + + :param client_id: Id of the client that sent the request. + :param goal_id: Id of the goal associated with the client. + :param req_params: Dictionary of the parameters of the request. + """ goal_id_name = get_action_goal_id_definition()[0] action_client_req_event = generate_action_goal_req_event(self._interface_name, client_id) action_srv_handle_event = generate_action_goal_handle_event(self._interface_name) @@ -59,50 +67,83 @@ def _generate_goal_request_transition( ScxmlSend(action_srv_handle_event, send_params)) return goal_req_transition - def _generate_conditional_send_to_bodies( - self, client_to_goal_id: List[Tuple[str, int]], send_params: Optional[List[ScxmlParam]], - client_event_function: Callable[[str, str], str]) -> ScxmlIf: + def _generate_srv_event_transition( + self, client_to_goal_id: List[Tuple[str, int]], event_fields: Optional[Dict[str, str]], + srv_event_function: Callable[[str], str], + client_event_function: Callable[[str, str], str]) -> ScxmlTransition: + """ + Generate a scxml transition that triggers the client related to the input event's goal_id. + + :param client_to_goal_id: List of tuples (client_id, goal_id) relating clients to goal ids. + :param event_fields: Dictionary of the parameters of the event. + :param srv_event_function: Function to generate the server (input) event name. + :param client_event_function: Function to generate the client (output) event name. + """ goal_id_name = get_action_goal_id_definition()[0] + srv_event_name = srv_event_function(self._interface_name) + scxml_transition = ScxmlTransition("waiting", [srv_event_name]) + scxml_transition.append_body_executable_entry( + ScxmlAssign(goal_id_name, f"_event.{goal_id_name}")) + out_params: List[ScxmlParam] = [] + for field_name in event_fields: + scxml_transition.append_body_executable_entry( + ScxmlAssign(field_name, f"_event.{field_name}")) + out_params.append(ScxmlParam(field_name, field_name)) condition_send_pairs: List[Tuple[str, ScxmlSend]] = [] for client_id, goal_id in client_to_goal_id: client_event = client_event_function(self._interface_name, client_id) condition_send_pairs.append((f"{goal_id_name} == {goal_id}", - ScxmlSend(client_event, send_params))) - return ScxmlIf(condition_send_pairs) + ScxmlSend(client_event, out_params))) + scxml_transition.append_body_executable_entry(ScxmlIf(condition_send_pairs)) + return scxml_transition def _generate_goal_accept_transition( self, client_to_goal_id: List[Tuple[str, int]]) -> ScxmlTransition: - goal_id_name = get_action_goal_id_definition()[0] - accepted_event_srv = generate_action_goal_handle_accepted_event(self._interface_name) - accepted_transition = ScxmlTransition("waiting", [accepted_event_srv]) - accepted_transition.append_body_executable_entry( - ScxmlAssign(goal_id_name, f"_event.{goal_id_name}")) - accepted_transition.append_body_executable_entry( - self._generate_conditional_send_to_bodies( - client_to_goal_id, None, generate_action_goal_accepted_event)) - return accepted_transition + """ + Generate a scxml transition that sends an event to the client to report an accepted goal. + + :param client_to_goal_id: List of tuples (client_id, goal_id) relating clients to goal ids. + """ + return self._generate_srv_event_transition( + client_to_goal_id, None, generate_action_goal_accepted_event, + generate_action_goal_handle_accepted_event) def _generate_goal_reject_transition( self, client_to_goal_id: List[Tuple[str, int]]) -> ScxmlTransition: - goal_id_name = get_action_goal_id_definition()[0] - rejected_event_srv = generate_action_goal_handle_rejected_event(self._interface_name) - rejected_transition = ScxmlTransition("waiting", [rejected_event_srv]) - rejected_transition.append_body_executable_entry( - ScxmlAssign(goal_id_name, f"_event.{goal_id_name}")) - rejected_transition.append_body_executable_entry( - self._generate_conditional_send_to_bodies( - client_to_goal_id, None, generate_action_goal_rejected_event)) - return rejected_transition + """ + Generate a scxml transition that sends an event to the client to report a rejected goal. + + :param client_to_goal_id: List of tuples (client_id, goal_id) relating clients to goal ids. + """ + return self._generate_srv_event_transition( + client_to_goal_id, None, generate_action_goal_rejected_event, + generate_action_goal_handle_rejected_event) def _generate_feedback_response_transition( self, client_to_goal_id: List[Tuple[str, int]], feedback_params: Dict[str, str] ) -> ScxmlTransition: - pass + """ + Generate a scxml transition that sends an event to the client to report feedback. + + :param client_to_goal_id: List of tuples (client_id, goal_id) relating clients to goal ids. + :param feedback_params: Dictionary of the parameters of the feedback. + """ + return self._generate_srv_event_transition( + client_to_goal_id, feedback_params, generate_action_feedback_event, + generate_action_feedback_handle_event) def _generate_result_response_transition( self, client_to_goal_id: List[Tuple[str, int]], result_params: Dict[str, str] ) -> ScxmlTransition: - pass + """ + Generate a scxml transition that sends an event to the client to report the result. + + :param client_to_goal_id: List of tuples (client_id, goal_id) relating clients to goal ids. + :param result_params: Dictionary of the parameters of the result. + """ + return self._generate_srv_event_transition( + client_to_goal_id, result_params, generate_action_result_event, + generate_action_result_handle_event) def to_scxml(self) -> ScxmlRoot: """ diff --git a/scxml_converter/src/scxml_converter/scxml_entries/ros_utils.py b/scxml_converter/src/scxml_converter/scxml_entries/ros_utils.py index 98f3cc2a..4283197e 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/ros_utils.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/ros_utils.py @@ -207,14 +207,14 @@ def generate_action_goal_req_event(action_name: str, client_name: str) -> str: return f"action_{sanitize_ros_interface_name(action_name)}_goal_req_client_{client_name}" -def generate_action_goal_accepted_event(action_name: str, client_name: str) -> str: - """Generate the name of the event that reports goal acceptance to a client.""" - return f"action_{sanitize_ros_interface_name(action_name)}_goal_accept_client_{client_name}" +def generate_action_goal_accepted_event(action_name: str) -> str: + """Generate the name of the event sent from the server in case of goal acceptance.""" + return f"action_{sanitize_ros_interface_name(action_name)}_goal_accepted" -def generate_action_goal_rejected_event(action_name: str, client_name: str) -> str: - """Generate the name of the event that reports goal rejection to a client.""" - return f"action_{sanitize_ros_interface_name(action_name)}_goal_reject_client_{client_name}" +def generate_action_goal_rejected_event(action_name: str) -> str: + """Generate the name of the event sent from the server in case of goal rejection.""" + return f"action_{sanitize_ros_interface_name(action_name)}_goal_rejected" def generate_action_goal_handle_event(action_name: str) -> str: @@ -222,14 +222,14 @@ def generate_action_goal_handle_event(action_name: str) -> str: return f"action_{sanitize_ros_interface_name(action_name)}_goal_handle" -def generate_action_goal_handle_accepted_event(action_name: str) -> str: - """Generate the name of the event sent from the server in case of goal acceptance.""" - return f"action_{sanitize_ros_interface_name(action_name)}_goal_accepted" +def generate_action_goal_handle_accepted_event(action_name: str, client_name: str) -> str: + """Generate the name of the event that reports goal acceptance to a client.""" + return f"action_{sanitize_ros_interface_name(action_name)}_goal_accept_client_{client_name}" -def generate_action_goal_handle_rejected_event(action_name: str) -> str: - """Generate the name of the event sent from the server in case of goal rejection.""" - return f"action_{sanitize_ros_interface_name(action_name)}_goal_rejected" +def generate_action_goal_handle_rejected_event(action_name: str, client_name: str) -> str: + """Generate the name of the event that reports goal rejection to a client.""" + return f"action_{sanitize_ros_interface_name(action_name)}_goal_reject_client_{client_name}" def generate_action_thread_execution_start_event(action_name: str) -> str: diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_action_client.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_action_client.py index 449f22c4..9475073b 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_action_client.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_action_client.py @@ -27,7 +27,7 @@ from scxml_converter.scxml_entries.ros_utils import ( is_action_type_known, generate_action_goal_req_event, - generate_action_goal_accepted_event, generate_action_goal_rejected_event, + generate_action_goal_handle_accepted_event, generate_action_goal_handle_rejected_event, generate_action_feedback_handle_event, generate_action_result_handle_event) from scxml_converter.scxml_entries.xml_utils import assert_xml_tag_ok, get_xml_argument from scxml_converter.scxml_entries.utils import is_non_empty_string @@ -140,8 +140,8 @@ def as_plain_scxml(self, "Error: SCXML service response handler: invalid ROS instantiations." automaton_name = ros_declarations.get_automaton_name() interface_name, _ = ros_declarations.get_action_client_info(self._client_name) - accept_event = generate_action_goal_accepted_event(interface_name, automaton_name) - reject_event = generate_action_goal_rejected_event(interface_name, automaton_name) + accept_event = generate_action_goal_handle_accepted_event(interface_name, automaton_name) + reject_event = generate_action_goal_handle_rejected_event(interface_name, automaton_name) accept_transition = ScxmlTransition(self._accept_target, [accept_event]) reject_transition = ScxmlTransition(self._reject_target, [reject_event]) return [accept_transition, reject_transition] diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_action_server.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_action_server.py index 0b9f4b9f..ac7cb5b5 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_action_server.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_action_server.py @@ -29,7 +29,7 @@ from scxml_converter.scxml_entries.ros_utils import ( is_action_type_known, generate_action_goal_handle_event, - generate_action_goal_handle_accepted_event, generate_action_goal_handle_rejected_event, + generate_action_goal_accepted_event, generate_action_goal_rejected_event, generate_action_thread_execution_start_event, generate_action_feedback_event, generate_action_result_event, generate_action_thread_free_event) from scxml_converter.scxml_entries.xml_utils import ( @@ -99,7 +99,7 @@ def check_fields_validity(self, _) -> bool: return len(self._fields) == 1 and self._fields[0].get_name() == "goal_id" def get_plain_scxml_event(self, ros_declarations: ScxmlRosDeclarationsContainer) -> str: - return generate_action_goal_handle_accepted_event( + return generate_action_goal_accepted_event( ros_declarations.get_action_server_info(self._interface_name)[0]) def as_xml(self) -> ET.Element: @@ -129,7 +129,7 @@ def check_fields_validity(self, _) -> bool: return len(self._fields) == 1 and self._fields[0].get_name() == "goal_id" def get_plain_scxml_event(self, ros_declarations: ScxmlRosDeclarationsContainer) -> str: - return generate_action_goal_handle_rejected_event( + return generate_action_goal_rejected_event( ros_declarations.get_action_server_info(self._interface_name)[0]) def as_xml(self) -> ET.Element: diff --git a/scxml_converter/test/_test_data/fibonacci_action_example/client_1.scxml b/scxml_converter/test/_test_data/fibonacci_action_example/client_1.scxml index 0cab4fc8..18ba612e 100644 --- a/scxml_converter/test/_test_data/fibonacci_action_example/client_1.scxml +++ b/scxml_converter/test/_test_data/fibonacci_action_example/client_1.scxml @@ -27,13 +27,13 @@ - + - + From ee5efe664657abd54e7c228a784c05cb837e5a01 Mon Sep 17 00:00:00 2001 From: Marco Lampacrescia Date: Fri, 30 Aug 2024 14:10:58 +0200 Subject: [PATCH 11/23] First untested integration of action handler Signed-off-by: Marco Lampacrescia --- .../ros_helpers/ros_action_handler.py | 4 --- .../ros_helpers/ros_communication_handler.py | 27 ++++++++++++++++++- .../ros_helpers/ros_service_handler.py | 6 ----- .../scxml_helpers/top_level_interpreter.py | 26 +++++++++--------- .../scxml_entries/ros_utils.py | 2 +- 5 files changed, 40 insertions(+), 25 deletions(-) diff --git a/jani_generator/src/jani_generator/ros_helpers/ros_action_handler.py b/jani_generator/src/jani_generator/ros_helpers/ros_action_handler.py index 323ec96f..1d8f4031 100644 --- a/jani_generator/src/jani_generator/ros_helpers/ros_action_handler.py +++ b/jani_generator/src/jani_generator/ros_helpers/ros_action_handler.py @@ -185,7 +185,3 @@ def to_scxml(self) -> ScxmlRoot: scxml_root.add_state(wait_state, initial=True) assert scxml_root.is_plain_scxml(), "Generated SCXML for srv sync is not plain SCXML." return scxml_root - - -# Mapping from Ros Action name and their handler instance -RosActions = Dict[str, RosActionHandler] diff --git a/jani_generator/src/jani_generator/ros_helpers/ros_communication_handler.py b/jani_generator/src/jani_generator/ros_helpers/ros_communication_handler.py index 1bf1580e..a672df24 100644 --- a/jani_generator/src/jani_generator/ros_helpers/ros_communication_handler.py +++ b/jani_generator/src/jani_generator/ros_helpers/ros_communication_handler.py @@ -17,7 +17,7 @@ Generic class for generators of SCXML state machine for specific ROS communication interfaces. """ -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Type from as2fm_common.common import get_default_expression_for_type, value_to_string from jani_generator.jani_entries import JaniModel @@ -126,6 +126,31 @@ def _generate_datamodel_from_ros_fields(self, fields: Dict[str, str]) -> List[Sc return scxml_fields +def update_ros_communication_handlers( + automaton_name: str, handler_class: Type[RosCommunicationHandler], + handlers_dict: Dict[str, RosCommunicationHandler], + servers_dict: Dict[str, tuple], clients_dict: Dict[str, tuple]): + """ + Update the ROS communication handlers with the given clients and servers. + + :param automaton_name: The name of the automaton where the interfaces are declared. + :param handlers_dict: The dictionary of ROS communication handlers to update. + :param servers_dict: The dictionary of servers to add. + :param clients_dict: The dictionary of clients to add. + """ + assert issubclass(handler_class, RosCommunicationHandler), \ + f"The handler class {handler_class} must be a subclass of RosCommunicationHandler." + for service_name, service_type in servers_dict.values(): + if service_name not in handlers_dict: + handlers_dict[service_name] = handler_class() + handlers_dict[service_name].set_server(service_name, service_type, automaton_name) + for service_name, service_type in clients_dict.values(): + if service_name not in handlers_dict: + handlers_dict[service_name] = handler_class() + handlers_dict[service_name].add_client( + service_name, service_type, automaton_name) + + def remove_empty_self_loops_from_interface_handlers_in_jani(jani_model: JaniModel) -> None: """ Remove self-loops from srv_handler automata in the Jani model. diff --git a/jani_generator/src/jani_generator/ros_helpers/ros_service_handler.py b/jani_generator/src/jani_generator/ros_helpers/ros_service_handler.py index 43de9ef5..231ca216 100644 --- a/jani_generator/src/jani_generator/ros_helpers/ros_service_handler.py +++ b/jani_generator/src/jani_generator/ros_helpers/ros_service_handler.py @@ -17,8 +17,6 @@ Representation of ROS Services. """ -from typing import Dict - from jani_generator.ros_helpers.ros_communication_handler import RosCommunicationHandler from scxml_converter.scxml_entries import ( @@ -88,7 +86,3 @@ def to_scxml(self) -> ScxmlRoot: scxml_root.add_state(processing_state) assert scxml_root.is_plain_scxml(), "Generated SCXML for srv sync is not plain SCXML." return scxml_root - - -# Mapping from Ros Service name and their handler instance -RosServices = Dict[str, RosServiceHandler] diff --git a/jani_generator/src/jani_generator/scxml_helpers/top_level_interpreter.py b/jani_generator/src/jani_generator/scxml_helpers/top_level_interpreter.py index 56f30318..25e799cf 100644 --- a/jani_generator/src/jani_generator/scxml_helpers/top_level_interpreter.py +++ b/jani_generator/src/jani_generator/scxml_helpers/top_level_interpreter.py @@ -20,11 +20,13 @@ import json import os from dataclasses import dataclass, field -from typing import List, Optional, Tuple +from typing import Dict, List, Optional, Tuple from xml.etree import ElementTree as ET from as2fm_common.common import remove_namespace -from jani_generator.ros_helpers.ros_service_handler import RosServiceHandler, RosServices +from jani_generator.ros_helpers.ros_communication_handler import update_ros_communication_handlers +from jani_generator.ros_helpers.ros_service_handler import RosServiceHandler +from jani_generator.ros_helpers.ros_action_handler import RosActionHandler from jani_generator.ros_helpers.ros_timer import RosTimer from jani_generator.scxml_helpers.scxml_to_jani import \ convert_multiple_scxmls_to_jani @@ -142,7 +144,8 @@ def generate_plain_scxml_models_and_timers( # Convert the loaded entries to plain SCXML plain_scxml_models = [] all_timers: List[RosTimer] = [] - all_services: RosServices = {} + all_services: Dict[str, RosServiceHandler] = {} + all_actions: Dict[str, RosActionHandler] = {} for scxml_entry in ros_scxmls: plain_scxmls, ros_declarations = \ scxml_entry.to_plain_scxml_and_declarations() @@ -152,16 +155,13 @@ def generate_plain_scxml_models_and_timers( f"Timer {timer_name} already exists." all_timers.append(RosTimer(timer_name, timer_rate)) # Handle ROS Services - for service_name, service_type in ros_declarations._service_clients.values(): - if service_name not in all_services: - all_services[service_name] = RosServiceHandler() - all_services[service_name].add_client( - service_name, service_type, scxml_entry.get_name()) - for service_name, service_type in ros_declarations._service_servers.values(): - if service_name not in all_services: - all_services[service_name] = RosServiceHandler() - all_services[service_name].set_server( - service_name, service_type, scxml_entry.get_name()) + update_ros_communication_handlers( + scxml_entry.get_name(), RosServiceHandler, all_services, + ros_declarations._service_servers, ros_declarations._service_clients) + # Handle ROS Actions + update_ros_communication_handlers( + scxml_entry.get_name(), RosActionHandler, all_actions, + ros_declarations._action_servers, ros_declarations._action_clients) plain_scxml_models.extend(plain_scxmls) # Generate service sync SCXML models for service_info in all_services.values(): diff --git a/scxml_converter/src/scxml_converter/scxml_entries/ros_utils.py b/scxml_converter/src/scxml_converter/scxml_entries/ros_utils.py index 4283197e..6c78cb3c 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/ros_utils.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/ros_utils.py @@ -40,7 +40,7 @@ ] -"""Container for the ROS interface (e.g. topic or service) name and the related type""" +"""Container for the ROS interface name (e.g. topic or service name) and the related type""" RosInterfaceAndType = Tuple[str, str] From 2d2513c720459fff330f3604cc6137c7c31eec62 Mon Sep 17 00:00:00 2001 From: Marco Lampacrescia Date: Fri, 30 Aug 2024 14:48:02 +0200 Subject: [PATCH 12/23] Integrate fibonacci jani conversion test Signed-off-by: Marco Lampacrescia --- .../fibonacci_action_example/.gitignore | 2 + .../fibonacci_action_example/client_1.scxml | 43 ++++++ .../fibonacci_action_example/client_2.scxml | 43 ++++++ .../fibonacci_action_example/main.xml | 16 +++ .../fibonacci_action_example/properties.jani | 41 ++++++ .../fibonacci_action_example/server.scxml | 124 ++++++++++++++++++ .../test/test_systemtest_scxml_to_jani.py | 4 + 7 files changed, 273 insertions(+) create mode 100644 jani_generator/test/_test_data/fibonacci_action_example/.gitignore create mode 100644 jani_generator/test/_test_data/fibonacci_action_example/client_1.scxml create mode 100644 jani_generator/test/_test_data/fibonacci_action_example/client_2.scxml create mode 100644 jani_generator/test/_test_data/fibonacci_action_example/main.xml create mode 100644 jani_generator/test/_test_data/fibonacci_action_example/properties.jani create mode 100644 jani_generator/test/_test_data/fibonacci_action_example/server.scxml diff --git a/jani_generator/test/_test_data/fibonacci_action_example/.gitignore b/jani_generator/test/_test_data/fibonacci_action_example/.gitignore new file mode 100644 index 00000000..6bef9767 --- /dev/null +++ b/jani_generator/test/_test_data/fibonacci_action_example/.gitignore @@ -0,0 +1,2 @@ +generated_plain_scxml +main.jani \ No newline at end of file diff --git a/jani_generator/test/_test_data/fibonacci_action_example/client_1.scxml b/jani_generator/test/_test_data/fibonacci_action_example/client_1.scxml new file mode 100644 index 00000000..f52491b8 --- /dev/null +++ b/jani_generator/test/_test_data/fibonacci_action_example/client_1.scxml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jani_generator/test/_test_data/fibonacci_action_example/client_2.scxml b/jani_generator/test/_test_data/fibonacci_action_example/client_2.scxml new file mode 100644 index 00000000..41f129fb --- /dev/null +++ b/jani_generator/test/_test_data/fibonacci_action_example/client_2.scxml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jani_generator/test/_test_data/fibonacci_action_example/main.xml b/jani_generator/test/_test_data/fibonacci_action_example/main.xml new file mode 100644 index 00000000..ddea8d52 --- /dev/null +++ b/jani_generator/test/_test_data/fibonacci_action_example/main.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jani_generator/test/_test_data/fibonacci_action_example/properties.jani b/jani_generator/test/_test_data/fibonacci_action_example/properties.jani new file mode 100644 index 00000000..38cc3f6c --- /dev/null +++ b/jani_generator/test/_test_data/fibonacci_action_example/properties.jani @@ -0,0 +1,41 @@ +{ + "properties": [ + { + "name": "clients_ok", + "expression": { + "op": "filter", + "fun": "values", + "values": { + "op": "Pmin", + "exp": { + "op": "F", + "exp": { + "op": "∧", + "left": { + "op": "∧", + "left": { + "op": "=", + "left": "client_1_last_fibonacci.data", + "right": "5" + }, + "right": "topic_client_1_res_msg.valid" + }, + "right": { + "op": "∧", + "left": { + "op": "=", + "left": "client_2_last_fibonacci.data", + "right": "13" + }, + "right": "topic_client_2_res_msg.valid" + } + } + } + }, + "states": { + "op": "initial" + } + } + } + ] +} \ No newline at end of file diff --git a/jani_generator/test/_test_data/fibonacci_action_example/server.scxml b/jani_generator/test/_test_data/fibonacci_action_example/server.scxml new file mode 100644 index 00000000..47524b78 --- /dev/null +++ b/jani_generator/test/_test_data/fibonacci_action_example/server.scxml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jani_generator/test/test_systemtest_scxml_to_jani.py b/jani_generator/test/test_systemtest_scxml_to_jani.py index 1c9a875a..c762cfd8 100644 --- a/jani_generator/test/test_systemtest_scxml_to_jani.py +++ b/jani_generator/test/test_systemtest_scxml_to_jani.py @@ -274,6 +274,10 @@ def test_ros_add_int_srv_example(self): """Test the services are properly handled in Jani.""" self._test_with_main('ros_add_int_srv_example', 'happy_clients', True, True) + def test_ros_fibonacci_action_example(self): + """Test the actions are properly handled in Jani.""" + self._test_with_main('fibonacci_action_example', 'clients_ok', True, True) + if __name__ == '__main__': pytest.main(['-s', '-v', __file__]) From 384d98311abcd4c1ad59be9fe582b7144432d79b Mon Sep 17 00:00:00 2001 From: Marco Lampacrescia Date: Fri, 30 Aug 2024 15:40:04 +0200 Subject: [PATCH 13/23] Initial set of bugfixes Signed-off-by: Marco Lampacrescia --- .../as2fm_common/ecmascript_interpretation.py | 8 +++++++- .../ros_helpers/ros_action_handler.py | 16 ++++++++-------- .../ros_helpers/ros_communication_handler.py | 14 +++++++++++++- .../scxml_helpers/scxml_expression.py | 16 +++++++++++++++- .../scxml_helpers/top_level_interpreter.py | 9 +++++---- 5 files changed, 48 insertions(+), 15 deletions(-) diff --git a/as2fm_common/src/as2fm_common/ecmascript_interpretation.py b/as2fm_common/src/as2fm_common/ecmascript_interpretation.py index fdd4a2a9..f00a462e 100644 --- a/as2fm_common/src/as2fm_common/ecmascript_interpretation.py +++ b/as2fm_common/src/as2fm_common/ecmascript_interpretation.py @@ -39,7 +39,13 @@ def interpret_ecma_script_expr( if variables is None: variables = {} context = js2py.EvalJs(variables) - context.execute("result = " + expr) + try: + context.execute("result = " + expr) + except js2py.base.PyJsException: + msg_addition = "" + if expr in ("True", "False"): + msg_addition = "Did you mean to use 'true' or 'false' instead?" + raise RuntimeError(f"Failed to interpret JS expression: 'result = {expr}'. {msg_addition}") expr_result = context.result if isinstance(expr_result, BASIC_JS_TYPES): return expr_result diff --git a/jani_generator/src/jani_generator/ros_helpers/ros_action_handler.py b/jani_generator/src/jani_generator/ros_helpers/ros_action_handler.py index 1d8f4031..614783f8 100644 --- a/jani_generator/src/jani_generator/ros_helpers/ros_action_handler.py +++ b/jani_generator/src/jani_generator/ros_helpers/ros_action_handler.py @@ -56,19 +56,19 @@ def _generate_goal_request_transition( action_client_req_event = generate_action_goal_req_event(self._interface_name, client_id) action_srv_handle_event = generate_action_goal_handle_event(self._interface_name) goal_req_transition = ScxmlTransition("waiting", [action_client_req_event]) - send_params = [ScxmlParam(goal_id_name, str(goal_id))] + send_params = [ScxmlParam(goal_id_name, expr=str(goal_id))] for field_name in req_params: # Add preliminary assignments (part of the hack mentioned in self.to_scxml()) goal_req_transition.append_body_executable_entry( ScxmlAssign(field_name, f"_event.{field_name}")) - send_params.append(ScxmlParam(field_name, field_name)) + send_params.append(ScxmlParam(field_name, expr=field_name)) # Add the send to the server goal_req_transition.append_body_executable_entry( ScxmlSend(action_srv_handle_event, send_params)) return goal_req_transition def _generate_srv_event_transition( - self, client_to_goal_id: List[Tuple[str, int]], event_fields: Optional[Dict[str, str]], + self, client_to_goal_id: List[Tuple[str, int]], event_fields: Dict[str, str], srv_event_function: Callable[[str], str], client_event_function: Callable[[str, str], str]) -> ScxmlTransition: """ @@ -88,12 +88,12 @@ def _generate_srv_event_transition( for field_name in event_fields: scxml_transition.append_body_executable_entry( ScxmlAssign(field_name, f"_event.{field_name}")) - out_params.append(ScxmlParam(field_name, field_name)) - condition_send_pairs: List[Tuple[str, ScxmlSend]] = [] + out_params.append(ScxmlParam(field_name, expr=field_name)) + condition_send_pairs: List[Tuple[str, List[ScxmlSend]]] = [] for client_id, goal_id in client_to_goal_id: client_event = client_event_function(self._interface_name, client_id) condition_send_pairs.append((f"{goal_id_name} == {goal_id}", - ScxmlSend(client_event, out_params))) + [ScxmlSend(client_event, out_params)])) scxml_transition.append_body_executable_entry(ScxmlIf(condition_send_pairs)) return scxml_transition @@ -105,7 +105,7 @@ def _generate_goal_accept_transition( :param client_to_goal_id: List of tuples (client_id, goal_id) relating clients to goal ids. """ return self._generate_srv_event_transition( - client_to_goal_id, None, generate_action_goal_accepted_event, + client_to_goal_id, {}, generate_action_goal_accepted_event, generate_action_goal_handle_accepted_event) def _generate_goal_reject_transition( @@ -116,7 +116,7 @@ def _generate_goal_reject_transition( :param client_to_goal_id: List of tuples (client_id, goal_id) relating clients to goal ids. """ return self._generate_srv_event_transition( - client_to_goal_id, None, generate_action_goal_rejected_event, + client_to_goal_id, {}, generate_action_goal_rejected_event, generate_action_goal_handle_rejected_event) def _generate_feedback_response_transition( diff --git a/jani_generator/src/jani_generator/ros_helpers/ros_communication_handler.py b/jani_generator/src/jani_generator/ros_helpers/ros_communication_handler.py index a672df24..bffc5919 100644 --- a/jani_generator/src/jani_generator/ros_helpers/ros_communication_handler.py +++ b/jani_generator/src/jani_generator/ros_helpers/ros_communication_handler.py @@ -17,7 +17,7 @@ Generic class for generators of SCXML state machine for specific ROS communication interfaces. """ -from typing import Dict, List, Optional, Type +from typing import Dict, Iterator, List, Optional, Type from as2fm_common.common import get_default_expression_for_type, value_to_string from jani_generator.jani_entries import JaniModel @@ -151,6 +151,18 @@ def update_ros_communication_handlers( service_name, service_type, automaton_name) +def generate_plain_scxml_from_handlers( + handlers_dict: Dict[str, RosCommunicationHandler]) -> Iterator[ScxmlRoot]: + """ + Generate the plain SCXML models from the ROS communication handlers. + + :param handlers_dict: The dictionary of ROS communication handlers. + :return: A generator of ScxmlRoot objects. + """ + for handler in handlers_dict.values(): + yield handler.to_scxml() + + def remove_empty_self_loops_from_interface_handlers_in_jani(jani_model: JaniModel) -> None: """ Remove self-loops from srv_handler automata in the Jani model. diff --git a/jani_generator/src/jani_generator/scxml_helpers/scxml_expression.py b/jani_generator/src/jani_generator/scxml_helpers/scxml_expression.py index e87c8b1a..f76964a0 100644 --- a/jani_generator/src/jani_generator/scxml_helpers/scxml_expression.py +++ b/jani_generator/src/jani_generator/scxml_helpers/scxml_expression.py @@ -57,7 +57,11 @@ def parse_ecmascript_to_jani_expression( ast = esprima.parseScript(ecmascript) assert len(ast.body) == 1, "The ecmascript must contain exactly one expression." ast = ast.body[0] - return _parse_ecmascript_to_jani_expression(ast, array_info) + try: + jani_expression = _parse_ecmascript_to_jani_expression(ast, array_info) + except NotImplementedError: + raise RuntimeError(f"Unsupported ecmascript: {ecmascript}") + return jani_expression def _parse_ecmascript_to_jani_expression( @@ -71,6 +75,13 @@ def _parse_ecmascript_to_jani_expression( """ if ast.type == "Literal": return JaniExpression(JaniValue(ast.value)) + elif ast.type == "UnaryExpression": + assert ast.prefix is True and ast.operator == "-", "Only unary minus is supported." + return JaniExpression({ + "op": BASIC_EXPRESSIONS_MAPPING[ast.operator], + "left": JaniValue(0), + "right": _parse_ecmascript_to_jani_expression(ast.argument, array_info) + }) elif ast.type == "ArrayExpression": assert array_info is not None, "Array info must be provided for ArrayExpressions." assert len(ast.elements) == 0, "Array expressions with elements are not supported." @@ -78,6 +89,9 @@ def _parse_ecmascript_to_jani_expression( JaniValue(array_info.array_type(0))) elif ast.type == "Identifier": # If it is an identifier, we do not need to expand further + assert ast.name != ("True", "False"), \ + f"Boolean {ast.name} mistaken for an identifier. "\ + "Did you mean to use 'true' or 'false' instead?" return JaniExpression(ast.name) elif ast.type == "MemberExpression": if ast.computed: diff --git a/jani_generator/src/jani_generator/scxml_helpers/top_level_interpreter.py b/jani_generator/src/jani_generator/scxml_helpers/top_level_interpreter.py index 25e799cf..73816a2e 100644 --- a/jani_generator/src/jani_generator/scxml_helpers/top_level_interpreter.py +++ b/jani_generator/src/jani_generator/scxml_helpers/top_level_interpreter.py @@ -24,7 +24,8 @@ from xml.etree import ElementTree as ET from as2fm_common.common import remove_namespace -from jani_generator.ros_helpers.ros_communication_handler import update_ros_communication_handlers +from jani_generator.ros_helpers.ros_communication_handler import ( + generate_plain_scxml_from_handlers, update_ros_communication_handlers) from jani_generator.ros_helpers.ros_service_handler import RosServiceHandler from jani_generator.ros_helpers.ros_action_handler import RosActionHandler from jani_generator.ros_helpers.ros_timer import RosTimer @@ -163,9 +164,9 @@ def generate_plain_scxml_models_and_timers( scxml_entry.get_name(), RosActionHandler, all_actions, ros_declarations._action_servers, ros_declarations._action_clients) plain_scxml_models.extend(plain_scxmls) - # Generate service sync SCXML models - for service_info in all_services.values(): - plain_scxml_models.append(service_info.to_scxml()) + # Generate sync SCXML models for services and actions + for plain_scxml in generate_plain_scxml_from_handlers(all_services | all_actions): + plain_scxml_models.append(plain_scxml) return plain_scxml_models, all_timers From 2358217d6d651446dce0a73a905e16fee32ee225 Mon Sep 17 00:00:00 2001 From: Marco Lampacrescia Date: Fri, 30 Aug 2024 16:07:10 +0200 Subject: [PATCH 14/23] Avoid else body in ScxmlIf to be None Signed-off-by: Marco Lampacrescia --- .../ros_helpers/ros_action_handler.py | 2 +- .../scxml_entries/scxml_executable_entries.py | 26 +++++++++---------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/jani_generator/src/jani_generator/ros_helpers/ros_action_handler.py b/jani_generator/src/jani_generator/ros_helpers/ros_action_handler.py index 614783f8..9f451ce6 100644 --- a/jani_generator/src/jani_generator/ros_helpers/ros_action_handler.py +++ b/jani_generator/src/jani_generator/ros_helpers/ros_action_handler.py @@ -17,7 +17,7 @@ Helper to create an orchestrator out of ROS Actions declarations. """ -from typing import Callable, Dict, List, Optional, Tuple +from typing import Callable, Dict, List, Tuple from jani_generator.ros_helpers.ros_communication_handler import RosCommunicationHandler diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_executable_entries.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_executable_entries.py index eecf857e..046440c4 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/scxml_executable_entries.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_executable_entries.py @@ -107,8 +107,10 @@ def __init__(self, :param conditional_executions: List of (condition - exec. body) pairs. Min n. pairs is one. :param else_execution: Execution to be done if no condition is met. """ - self._conditional_executions = conditional_executions - self._else_execution = else_execution + self._conditional_executions: List[ConditionalExecutionBody] = conditional_executions + self._else_execution: ScxmlExecutionBody = [] + if else_execution is not None: + self._else_execution = else_execution assert self.check_validity(), "Error: SCXML if: invalid if object." def get_conditional_executions(self) -> List[ConditionalExecutionBody]: @@ -136,9 +138,7 @@ def check_validity(self) -> bool: for condition, body in self._conditional_executions) if not valid_conditional_executions: print("Error: SCXML if: Found invalid entries in conditional executions.") - valid_else_execution = \ - self._else_execution is None or \ - (len(self._else_execution) > 0 and valid_execution_body(self._else_execution)) + valid_else_execution = valid_execution_body(self._else_execution) if not valid_else_execution: print("Error: SCXML if: invalid else execution body found.") return valid_conditional_executions and valid_else_execution @@ -153,10 +153,9 @@ def check_valid_ros_instantiations(self, for exec_entry in exec_body: if not exec_entry.check_valid_ros_instantiations(ros_declarations): return False - if self._else_execution is not None: - for exec_entry in self._else_execution: - if not exec_entry.check_valid_ros_instantiations(ros_declarations): - return False + for exec_entry in self._else_execution: + if not exec_entry.check_valid_ros_instantiations(ros_declarations): + return False return True def set_thread_id(self, thread_id: int) -> None: @@ -165,10 +164,9 @@ def set_thread_id(self, thread_id: int) -> None: for entry in exec_body: if hasattr(entry, "set_thread_id"): entry.set_thread_id(thread_id) - if self._else_execution is not None: - for entry in self._else_execution: - if hasattr(entry, "set_thread_id"): - entry.set_thread_id(thread_id) + for entry in self._else_execution: + if hasattr(entry, "set_thread_id"): + entry.set_thread_id(thread_id) def as_plain_scxml(self, ros_declarations: ScxmlRosDeclarationsContainer) -> "ScxmlIf": condional_executions = [] @@ -189,7 +187,7 @@ def as_xml(self) -> ET.Element: for condition, execution in self._conditional_executions[1:]: xml_if.append(ET.Element('elseif', {"cond": condition})) append_execution_body_to_xml(xml_if, execution) - if self._else_execution is not None: + if len(self._else_execution) > 0: xml_if.append(ET.Element('else')) append_execution_body_to_xml(xml_if, self._else_execution) return xml_if From 0f6c410dda87b4cba147d68e5a0cce62fd8407f7 Mon Sep 17 00:00:00 2001 From: Marco Lampacrescia Date: Fri, 30 Aug 2024 17:26:09 +0200 Subject: [PATCH 15/23] Hacky solution for array and more fixes Signed-off-by: Marco Lampacrescia --- .../src/jani_generator/scxml_helpers/scxml_expression.py | 4 +++- .../src/jani_generator/scxml_helpers/scxml_tags.py | 7 ++++++- .../test/_test_data/fibonacci_action_example/server.scxml | 4 ++-- .../src/scxml_converter/scxml_entries/scxml_ros_base.py | 3 +++ .../src/scxml_converter/scxml_entries/scxml_transition.py | 3 +++ .../gt_plain_scxml/fibonacci_thread_0.scxml | 2 +- .../gt_plain_scxml/fibonacci_thread_1.scxml | 2 +- 7 files changed, 19 insertions(+), 6 deletions(-) diff --git a/jani_generator/src/jani_generator/scxml_helpers/scxml_expression.py b/jani_generator/src/jani_generator/scxml_helpers/scxml_expression.py index f76964a0..fab4ed92 100644 --- a/jani_generator/src/jani_generator/scxml_helpers/scxml_expression.py +++ b/jani_generator/src/jani_generator/scxml_helpers/scxml_expression.py @@ -61,6 +61,8 @@ def parse_ecmascript_to_jani_expression( jani_expression = _parse_ecmascript_to_jani_expression(ast, array_info) except NotImplementedError: raise RuntimeError(f"Unsupported ecmascript: {ecmascript}") + except AssertionError: + raise RuntimeError(f"Assertion from ecmascript: {ecmascript}") return jani_expression @@ -89,7 +91,7 @@ def _parse_ecmascript_to_jani_expression( JaniValue(array_info.array_type(0))) elif ast.type == "Identifier": # If it is an identifier, we do not need to expand further - assert ast.name != ("True", "False"), \ + assert ast.name not in ("True", "False"), \ f"Boolean {ast.name} mistaken for an identifier. "\ "Did you mean to use 'true' or 'false' instead?" return JaniExpression(ast.name) diff --git a/jani_generator/src/jani_generator/scxml_helpers/scxml_tags.py b/jani_generator/src/jani_generator/scxml_helpers/scxml_tags.py index c9b07c63..def0a361 100644 --- a/jani_generator/src/jani_generator/scxml_helpers/scxml_tags.py +++ b/jani_generator/src/jani_generator/scxml_helpers/scxml_tags.py @@ -36,7 +36,7 @@ ScxmlIf, ScxmlRoot, ScxmlSend, ScxmlState, ScxmlTransition) -# The type to be exctended by parsing the scxml file +# The resulting types from the SCXML conversion to Jani ModelTupleType = Tuple[JaniAutomaton, EventsHolder] @@ -205,9 +205,14 @@ def _append_scxml_body_to_jani_automaton(jani_automaton: JaniAutomaton, events_h new_edge.destinations[0]['assignments'].append(JaniAssignment({ "ref": f'{param_assign_name}.length', "value": f"{variable_name}.length"})) + # TODO: get the expected type from a jani expression, w/o setting dummy def. values variables = {} for n, v in jani_automaton.get_variables().items(): variables[n] = get_default_expression_for_type(v.get_type()) + # Hack to solve issue for expressions with explicit access to array entries + if isinstance(variables[n], MutableSequence): + for _ in range(50): + variables[n].append(0) # TODO: We should get the type explicitly: sometimes the expression is underdefined print(f"Interpreting {expr} with {variables}") # This might contain reference to event variables, that have no type specified diff --git a/jani_generator/test/_test_data/fibonacci_action_example/server.scxml b/jani_generator/test/_test_data/fibonacci_action_example/server.scxml index 47524b78..328aade6 100644 --- a/jani_generator/test/_test_data/fibonacci_action_example/server.scxml +++ b/jani_generator/test/_test_data/fibonacci_action_example/server.scxml @@ -91,10 +91,10 @@ - + - + diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_base.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_base.py index baee2251..8bd46c99 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_base.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_ros_base.py @@ -23,6 +23,7 @@ valid_execution_body) from scxml_converter.scxml_entries.bt_utils import BtPortsHandler +from scxml_converter.scxml_entries.ros_utils import replace_ros_interface_expression from scxml_converter.scxml_entries.xml_utils import ( assert_xml_tag_ok, get_xml_argument, read_value_from_xml_arg_or_child) @@ -223,6 +224,8 @@ def as_plain_scxml(self, ros_declarations: ScxmlRosDeclarationsContainer) -> Scx event_name = self.get_plain_scxml_event(ros_declarations) target = self._target condition = self._condition + if condition is not None: + condition = replace_ros_interface_expression(condition) body = as_plain_execution_body(self._body, ros_declarations) return ScxmlTransition(target, [event_name], condition, body) diff --git a/scxml_converter/src/scxml_converter/scxml_entries/scxml_transition.py b/scxml_converter/src/scxml_converter/scxml_entries/scxml_transition.py index 9d62fed7..4942aa3f 100644 --- a/scxml_converter/src/scxml_converter/scxml_entries/scxml_transition.py +++ b/scxml_converter/src/scxml_converter/scxml_entries/scxml_transition.py @@ -26,6 +26,7 @@ instantiate_exec_body_bt_events) from scxml_converter.scxml_entries.bt_utils import is_bt_event, replace_bt_event, BtPortsHandler +from scxml_converter.scxml_entries.ros_utils import replace_ros_interface_expression class ScxmlTransition(ScxmlBase): @@ -156,6 +157,8 @@ def as_plain_scxml(self, ros_declarations: ScxmlRosDeclarationsContainer) -> "Sc new_body = None if self._body is not None: new_body = [entry.as_plain_scxml(ros_declarations) for entry in self._body] + if self._condition is not None: + self._condition = replace_ros_interface_expression(self._condition) return ScxmlTransition(self._target, self._events, self._condition, new_body) def as_xml(self) -> ET.Element: diff --git a/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/fibonacci_thread_0.scxml b/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/fibonacci_thread_0.scxml index 25a4878f..d31e315e 100644 --- a/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/fibonacci_thread_0.scxml +++ b/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/fibonacci_thread_0.scxml @@ -13,7 +13,7 @@ - + diff --git a/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/fibonacci_thread_1.scxml b/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/fibonacci_thread_1.scxml index e5078984..016c45ed 100644 --- a/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/fibonacci_thread_1.scxml +++ b/scxml_converter/test/_test_data/fibonacci_action_example/gt_plain_scxml/fibonacci_thread_1.scxml @@ -13,7 +13,7 @@ - + From 5615a44198479d62dbd5f6d80e5c3fe5895674a6 Mon Sep 17 00:00:00 2001 From: Marco Lampacrescia Date: Fri, 30 Aug 2024 18:02:06 +0200 Subject: [PATCH 16/23] First running jani file. To be debugged Signed-off-by: Marco Lampacrescia --- .../jani_entries/jani_expression.py | 4 ++-- .../scxml_helpers/scxml_tags.py | 22 ++++++++++--------- .../fibonacci_action_example/properties.jani | 12 +++++----- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/jani_generator/src/jani_generator/jani_entries/jani_expression.py b/jani_generator/src/jani_generator/jani_entries/jani_expression.py index 61539381..407088a9 100644 --- a/jani_generator/src/jani_generator/jani_entries/jani_expression.py +++ b/jani_generator/src/jani_generator/jani_entries/jani_expression.py @@ -155,8 +155,8 @@ def replace_event(self, replacement: Optional[str]): if replacement is None: # No replacement needed! return self - if self.identifier is not None: - self.identifier = self.identifier.replace("_event", replacement) + if self.identifier is not None and self.identifier.startswith("_event."): + self.identifier = f"{replacement}.{self.identifier.removeprefix('_event.')}" return self if self.value is not None: return self diff --git a/jani_generator/src/jani_generator/scxml_helpers/scxml_tags.py b/jani_generator/src/jani_generator/scxml_helpers/scxml_tags.py index def0a361..2e35c9b6 100644 --- a/jani_generator/src/jani_generator/scxml_helpers/scxml_tags.py +++ b/jani_generator/src/jani_generator/scxml_helpers/scxml_tags.py @@ -146,7 +146,7 @@ def _merge_conditions( def _append_scxml_body_to_jani_automaton(jani_automaton: JaniAutomaton, events_holder: EventsHolder, body: ScxmlExecutionBody, source: str, target: str, - hash_str: str, guard: Optional[JaniGuard], + hash_str: str, guard_exp: Optional[JaniExpression], trigger_event: Optional[str]) \ -> Tuple[List[JaniEdge], List[str]]: """ @@ -159,11 +159,13 @@ def _append_scxml_body_to_jani_automaton(jani_automaton: JaniAutomaton, events_h edge_action_name if trigger_event is None else f"{trigger_event}_on_receive" new_edges = [] new_locations = [] + if guard_exp is not None: + guard_exp.replace_event(trigger_event) # First edge. Has to evaluate guard and trigger event of original transition. new_edges.append(JaniEdge({ "location": source, "action": trigger_event_action, - "guard": JaniGuard(guard), + "guard": JaniGuard(guard_exp), "destinations": [{ "location": None, "assignments": [] @@ -250,7 +252,7 @@ def _append_scxml_body_to_jani_automaton(jani_automaton: JaniAutomaton, events_h sub_edges, sub_locs = _append_scxml_body_to_jani_automaton( jani_automaton, events_holder, conditional_body, interm_loc_before, interm_loc_after, '-'.join([hash_str, _hash_element(ec), str(if_idx)]), - JaniGuard(jani_cond), None) + jani_cond, None) new_edges.extend(sub_edges) new_locations.extend(sub_locs) previous_conditions.append(current_cond) @@ -263,7 +265,7 @@ def _append_scxml_body_to_jani_automaton(jani_automaton: JaniAutomaton, events_h sub_edges, sub_locs = _append_scxml_body_to_jani_automaton( jani_automaton, events_holder, ec.get_else_execution(), interm_loc_before, interm_loc_after, '-'.join([hash_str, _hash_element(ec), else_execution_id]), - JaniGuard(jani_cond), None) + jani_cond, None) new_edges.extend(sub_edges) new_locations.extend(sub_locs) # Prepare the edge from the end of the if-else block @@ -456,7 +458,7 @@ def get_handled_events(self) -> Set[str]: transition_events.add(event_name) return transition_events - def get_guard_for_prev_conditions(self, event_name: str) -> Optional[JaniGuard]: + def get_guard_exp_for_prev_conditions(self, event_name: str) -> Optional[JaniExpression]: """Return the guard negating all previous conditions for a specific event. This is required to make sure each event is processed, even in case of conditionals like: @@ -470,7 +472,7 @@ def get_guard_for_prev_conditions(self, event_name: str) -> Optional[JaniGuard]: parse_ecmascript_to_jani_expression(cond) for cond in self._event_to_conditions.get(event_name, [])] if len(previous_expressions) > 0: - return JaniGuard(_merge_conditions(previous_expressions)) + return _merge_conditions(previous_expressions) else: return None @@ -479,10 +481,10 @@ def add_unhandled_transitions(self, transitions_set: Set[str]): for event_name in transitions_set: if event_name in self._events_no_condition or len(event_name) == 0: continue - guard = self.get_guard_for_prev_conditions(event_name) + guard_exp = self.get_guard_exp_for_prev_conditions(event_name) edges, locations = _append_scxml_body_to_jani_automaton( self.automaton, self.events_holder, [], self.element.get_id(), - self.element.get_id(), "", guard, event_name) + self.element.get_id(), "", guard_exp, event_name) assert len(locations) == 0 and len(edges) == 1, \ f"Expected one edge for self-loops, got {len(edges)} edges." self.automaton.add_edge(edges[0]) @@ -568,10 +570,10 @@ def write_model(self): current_expression.replace_event(transition_trigger_event) # If there are multiple transitions for an event, consider the previous conditions merged_expression = _merge_conditions(previous_expressions, current_expression) - guard = JaniGuard(merged_expression) + guard = merged_expression else: if len(previous_expressions) > 0: - guard = JaniGuard(_merge_conditions(previous_expressions)) + guard = _merge_conditions(previous_expressions) else: guard = None diff --git a/jani_generator/test/_test_data/fibonacci_action_example/properties.jani b/jani_generator/test/_test_data/fibonacci_action_example/properties.jani index 38cc3f6c..c49b717d 100644 --- a/jani_generator/test/_test_data/fibonacci_action_example/properties.jani +++ b/jani_generator/test/_test_data/fibonacci_action_example/properties.jani @@ -15,19 +15,19 @@ "op": "∧", "left": { "op": "=", - "left": "client_1_last_fibonacci.data", - "right": "5" + "left": "topic_client_1_last_fibonacci_msg.data", + "right": 5 }, - "right": "topic_client_1_res_msg.valid" + "right": "topic_client_1_last_fibonacci_msg.valid" }, "right": { "op": "∧", "left": { "op": "=", - "left": "client_2_last_fibonacci.data", - "right": "13" + "left": "topic_client_2_last_fibonacci_msg.data", + "right": 13 }, - "right": "topic_client_2_res_msg.valid" + "right": "topic_client_2_last_fibonacci_msg.valid" } } } From ed3afe369e97e8debb40789fc5f5384ce083014a Mon Sep 17 00:00:00 2001 From: Marco Lampacrescia Date: Mon, 2 Sep 2024 09:33:42 +0200 Subject: [PATCH 17/23] SCXML adjustments Signed-off-by: Marco Lampacrescia --- .../test/_test_data/fibonacci_action_example/.gitignore | 3 ++- .../test/_test_data/fibonacci_action_example/main.xml | 2 +- .../test/_test_data/fibonacci_action_example/server.scxml | 7 +++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/jani_generator/test/_test_data/fibonacci_action_example/.gitignore b/jani_generator/test/_test_data/fibonacci_action_example/.gitignore index 6bef9767..30d4567b 100644 --- a/jani_generator/test/_test_data/fibonacci_action_example/.gitignore +++ b/jani_generator/test/_test_data/fibonacci_action_example/.gitignore @@ -1,2 +1,3 @@ generated_plain_scxml -main.jani \ No newline at end of file +main.jani +*.csv \ No newline at end of file diff --git a/jani_generator/test/_test_data/fibonacci_action_example/main.xml b/jani_generator/test/_test_data/fibonacci_action_example/main.xml index ddea8d52..dbcd395f 100644 --- a/jani_generator/test/_test_data/fibonacci_action_example/main.xml +++ b/jani_generator/test/_test_data/fibonacci_action_example/main.xml @@ -1,7 +1,7 @@ - + diff --git a/jani_generator/test/_test_data/fibonacci_action_example/server.scxml b/jani_generator/test/_test_data/fibonacci_action_example/server.scxml index 328aade6..6b9d0fbb 100644 --- a/jani_generator/test/_test_data/fibonacci_action_example/server.scxml +++ b/jani_generator/test/_test_data/fibonacci_action_example/server.scxml @@ -15,7 +15,7 @@ - + @@ -24,7 +24,7 @@ - + @@ -76,6 +76,9 @@ + + + From 7d2c40203d37ff944b7684de4a40e21a74c892b8 Mon Sep 17 00:00:00 2001 From: Marco Lampacrescia Date: Mon, 2 Sep 2024 13:54:45 +0200 Subject: [PATCH 18/23] Temp single thread test for action Signed-off-by: Marco Lampacrescia --- .../fibonacci_action_single_thread/.gitignore | 3 + .../client_1.scxml | 43 ++++++ .../fibonacci_action_single_thread/main.xml | 16 +++ .../properties.jani | 29 ++++ .../server.scxml | 127 ++++++++++++++++++ .../test/test_systemtest_scxml_to_jani.py | 2 +- 6 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 jani_generator/test/_test_data/fibonacci_action_single_thread/.gitignore create mode 100644 jani_generator/test/_test_data/fibonacci_action_single_thread/client_1.scxml create mode 100644 jani_generator/test/_test_data/fibonacci_action_single_thread/main.xml create mode 100644 jani_generator/test/_test_data/fibonacci_action_single_thread/properties.jani create mode 100644 jani_generator/test/_test_data/fibonacci_action_single_thread/server.scxml diff --git a/jani_generator/test/_test_data/fibonacci_action_single_thread/.gitignore b/jani_generator/test/_test_data/fibonacci_action_single_thread/.gitignore new file mode 100644 index 00000000..30d4567b --- /dev/null +++ b/jani_generator/test/_test_data/fibonacci_action_single_thread/.gitignore @@ -0,0 +1,3 @@ +generated_plain_scxml +main.jani +*.csv \ No newline at end of file diff --git a/jani_generator/test/_test_data/fibonacci_action_single_thread/client_1.scxml b/jani_generator/test/_test_data/fibonacci_action_single_thread/client_1.scxml new file mode 100644 index 00000000..f52491b8 --- /dev/null +++ b/jani_generator/test/_test_data/fibonacci_action_single_thread/client_1.scxml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jani_generator/test/_test_data/fibonacci_action_single_thread/main.xml b/jani_generator/test/_test_data/fibonacci_action_single_thread/main.xml new file mode 100644 index 00000000..c190987b --- /dev/null +++ b/jani_generator/test/_test_data/fibonacci_action_single_thread/main.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jani_generator/test/_test_data/fibonacci_action_single_thread/properties.jani b/jani_generator/test/_test_data/fibonacci_action_single_thread/properties.jani new file mode 100644 index 00000000..796ba582 --- /dev/null +++ b/jani_generator/test/_test_data/fibonacci_action_single_thread/properties.jani @@ -0,0 +1,29 @@ +{ + "properties": [ + { + "name": "client1_ok", + "expression": { + "op": "filter", + "fun": "values", + "values": { + "op": "Pmin", + "exp": { + "op": "F", + "exp": { + "op": "∧", + "left": { + "op": "=", + "left": "topic_client_1_last_fibonacci_msg.data", + "right": 5 + }, + "right": "topic_client_1_last_fibonacci_msg.valid" + } + } + }, + "states": { + "op": "initial" + } + } + } + ] +} \ No newline at end of file diff --git a/jani_generator/test/_test_data/fibonacci_action_single_thread/server.scxml b/jani_generator/test/_test_data/fibonacci_action_single_thread/server.scxml new file mode 100644 index 00000000..7372548a --- /dev/null +++ b/jani_generator/test/_test_data/fibonacci_action_single_thread/server.scxml @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jani_generator/test/test_systemtest_scxml_to_jani.py b/jani_generator/test/test_systemtest_scxml_to_jani.py index c762cfd8..31ed3f08 100644 --- a/jani_generator/test/test_systemtest_scxml_to_jani.py +++ b/jani_generator/test/test_systemtest_scxml_to_jani.py @@ -276,7 +276,7 @@ def test_ros_add_int_srv_example(self): def test_ros_fibonacci_action_example(self): """Test the actions are properly handled in Jani.""" - self._test_with_main('fibonacci_action_example', 'clients_ok', True, True) + self._test_with_main('fibonacci_action_single_thread', 'client1_ok', True, True) if __name__ == '__main__': From 2a728638d2d55a8a7b41dee1f749a58bc32179e0 Mon Sep 17 00:00:00 2001 From: Marco Lampacrescia Date: Mon, 2 Sep 2024 14:24:50 +0200 Subject: [PATCH 19/23] Fixed bug causing state name overlap Signed-off-by: Marco Lampacrescia --- .../src/jani_generator/scxml_helpers/scxml_tags.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/jani_generator/src/jani_generator/scxml_helpers/scxml_tags.py b/jani_generator/src/jani_generator/scxml_helpers/scxml_tags.py index 2e35c9b6..1e17aa09 100644 --- a/jani_generator/src/jani_generator/scxml_helpers/scxml_tags.py +++ b/jani_generator/src/jani_generator/scxml_helpers/scxml_tags.py @@ -241,8 +241,9 @@ def _append_scxml_body_to_jani_automaton(jani_automaton: JaniAutomaton, events_h new_edges.append(new_edge) new_locations.append(interm_loc) elif isinstance(ec, ScxmlIf): - interm_loc_before = f"{source}_{i}_before_if" - interm_loc_after = f"{source}_{i}_after_if" + if_prefix = f"{source}_{hash_str}_{i}" + interm_loc_before = f"{if_prefix}_before_if" + interm_loc_after = f"{if_prefix}_after_if" new_edges[-1].destinations[0]['location'] = interm_loc_before previous_conditions: List[JaniExpression] = [] for if_idx, (cond_str, conditional_body) in enumerate(ec.get_conditional_executions()): From 1c1536af4212469d9881af09bea8e019dcbeb4a4 Mon Sep 17 00:00:00 2001 From: Marco Lampacrescia Date: Mon, 2 Sep 2024 14:27:39 +0200 Subject: [PATCH 20/23] Enable multi-threaded action test Signed-off-by: Marco Lampacrescia --- jani_generator/test/test_systemtest_scxml_to_jani.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/jani_generator/test/test_systemtest_scxml_to_jani.py b/jani_generator/test/test_systemtest_scxml_to_jani.py index 31ed3f08..a71134f7 100644 --- a/jani_generator/test/test_systemtest_scxml_to_jani.py +++ b/jani_generator/test/test_systemtest_scxml_to_jani.py @@ -275,6 +275,10 @@ def test_ros_add_int_srv_example(self): self._test_with_main('ros_add_int_srv_example', 'happy_clients', True, True) def test_ros_fibonacci_action_example(self): + """Test the actions are properly handled in Jani.""" + self._test_with_main('fibonacci_action_example', 'clients_ok', True, True) + + def test_ros_fibonacci_action_single_client_example(self): """Test the actions are properly handled in Jani.""" self._test_with_main('fibonacci_action_single_thread', 'client1_ok', True, True) From eed223d5a92b2e17e366d629a307bcedb4e71fad Mon Sep 17 00:00:00 2001 From: Marco Lampacrescia Date: Mon, 2 Sep 2024 14:41:12 +0200 Subject: [PATCH 21/23] Add intermediate state in handler for action goal request Signed-off-by: Marco Lampacrescia --- .../graphics/ros_action_to_scxml.drawio.svg | 95 +++++++++++++------ .../ros_helpers/ros_action_handler.py | 48 ++++++---- 2 files changed, 95 insertions(+), 48 deletions(-) diff --git a/docs/source/graphics/ros_action_to_scxml.drawio.svg b/docs/source/graphics/ros_action_to_scxml.drawio.svg index a1130014..3c0ffc1f 100644 --- a/docs/source/graphics/ros_action_to_scxml.drawio.svg +++ b/docs/source/graphics/ros_action_to_scxml.drawio.svg @@ -1,4 +1,4 @@ - + @@ -57,7 +57,7 @@ -
+
@@ -79,7 +79,7 @@
- + transition action_handle_goal_request:... @@ -141,7 +141,7 @@ - +
@@ -166,7 +166,7 @@ -
+
@@ -176,7 +176,7 @@
- + transition action_goal_reject_client_X @@ -243,7 +243,7 @@
- + transition action_result_client_X:...
@@ -362,7 +362,7 @@
- + transition:... @@ -540,12 +540,12 @@ - - + + -
+
transition action_goal_req_client_x @@ -561,17 +561,17 @@
- + transition action_goal_req_client_x... - - + + -
+
@@ -607,16 +607,16 @@
- + transition action_<reply>... - + -
+
@@ -626,17 +626,17 @@
- + waiting - - + + -
+
The goal_id is assigned from the Action Handler, and is mapped to exactly one client @@ -644,17 +644,17 @@
- - The goal_id is assigned from... + + The goal_id is a... - - + + -
+
The same strategy is applied for all different replies coming from the action server @@ -662,11 +662,50 @@
- + The same strategy is applied... + + + + + +
+
+
+ + transition action_goal_<acc/rej> + +
+
+
+
+ + transition action_goal_<acc/rej> + +
+
+ + + + +
+
+
+ + goal_requested + +
+
+
+
+ + goal_requested + +
+
diff --git a/jani_generator/src/jani_generator/ros_helpers/ros_action_handler.py b/jani_generator/src/jani_generator/ros_helpers/ros_action_handler.py index 9f451ce6..030623a1 100644 --- a/jani_generator/src/jani_generator/ros_helpers/ros_action_handler.py +++ b/jani_generator/src/jani_generator/ros_helpers/ros_action_handler.py @@ -44,7 +44,8 @@ def get_interface_prefix() -> str: return "action_handler_" def _generate_goal_request_transition( - self, client_id: str, goal_id: int, req_params: Dict[str, str]) -> ScxmlTransition: + self, goal_state: ScxmlState, client_id: str, goal_id: int, req_params: Dict[str, str] + ) -> ScxmlTransition: """ Generate a scxml transition that, given a client request, sends an event to the server. @@ -55,7 +56,7 @@ def _generate_goal_request_transition( goal_id_name = get_action_goal_id_definition()[0] action_client_req_event = generate_action_goal_req_event(self._interface_name, client_id) action_srv_handle_event = generate_action_goal_handle_event(self._interface_name) - goal_req_transition = ScxmlTransition("waiting", [action_client_req_event]) + goal_req_transition = ScxmlTransition(goal_state.get_id(), [action_client_req_event]) send_params = [ScxmlParam(goal_id_name, expr=str(goal_id))] for field_name in req_params: # Add preliminary assignments (part of the hack mentioned in self.to_scxml()) @@ -68,8 +69,8 @@ def _generate_goal_request_transition( return goal_req_transition def _generate_srv_event_transition( - self, client_to_goal_id: List[Tuple[str, int]], event_fields: Dict[str, str], - srv_event_function: Callable[[str], str], + self, goal_state: ScxmlState, client_to_goal_id: List[Tuple[str, int]], + event_fields: Dict[str, str], srv_event_function: Callable[[str], str], client_event_function: Callable[[str, str], str]) -> ScxmlTransition: """ Generate a scxml transition that triggers the client related to the input event's goal_id. @@ -81,7 +82,7 @@ def _generate_srv_event_transition( """ goal_id_name = get_action_goal_id_definition()[0] srv_event_name = srv_event_function(self._interface_name) - scxml_transition = ScxmlTransition("waiting", [srv_event_name]) + scxml_transition = ScxmlTransition(goal_state.get_id(), [srv_event_name]) scxml_transition.append_body_executable_entry( ScxmlAssign(goal_id_name, f"_event.{goal_id_name}")) out_params: List[ScxmlParam] = [] @@ -98,30 +99,32 @@ def _generate_srv_event_transition( return scxml_transition def _generate_goal_accept_transition( - self, client_to_goal_id: List[Tuple[str, int]]) -> ScxmlTransition: + self, goal_state: ScxmlState, client_to_goal_id: List[Tuple[str, int]] + ) -> ScxmlTransition: """ Generate a scxml transition that sends an event to the client to report an accepted goal. :param client_to_goal_id: List of tuples (client_id, goal_id) relating clients to goal ids. """ return self._generate_srv_event_transition( - client_to_goal_id, {}, generate_action_goal_accepted_event, + goal_state, client_to_goal_id, {}, generate_action_goal_accepted_event, generate_action_goal_handle_accepted_event) def _generate_goal_reject_transition( - self, client_to_goal_id: List[Tuple[str, int]]) -> ScxmlTransition: + self, goal_state: ScxmlState, client_to_goal_id: List[Tuple[str, int]] + ) -> ScxmlTransition: """ Generate a scxml transition that sends an event to the client to report a rejected goal. :param client_to_goal_id: List of tuples (client_id, goal_id) relating clients to goal ids. """ return self._generate_srv_event_transition( - client_to_goal_id, {}, generate_action_goal_rejected_event, + goal_state, client_to_goal_id, {}, generate_action_goal_rejected_event, generate_action_goal_handle_rejected_event) def _generate_feedback_response_transition( - self, client_to_goal_id: List[Tuple[str, int]], feedback_params: Dict[str, str] - ) -> ScxmlTransition: + self, goal_state: ScxmlState, client_to_goal_id: List[Tuple[str, int]], + feedback_params: Dict[str, str]) -> ScxmlTransition: """ Generate a scxml transition that sends an event to the client to report feedback. @@ -129,12 +132,12 @@ def _generate_feedback_response_transition( :param feedback_params: Dictionary of the parameters of the feedback. """ return self._generate_srv_event_transition( - client_to_goal_id, feedback_params, generate_action_feedback_event, + goal_state, client_to_goal_id, feedback_params, generate_action_feedback_event, generate_action_feedback_handle_event) def _generate_result_response_transition( - self, client_to_goal_id: List[Tuple[str, int]], result_params: Dict[str, str] - ) -> ScxmlTransition: + self, goal_state: ScxmlState, client_to_goal_id: List[Tuple[str, int]], + result_params: Dict[str, str]) -> ScxmlTransition: """ Generate a scxml transition that sends an event to the client to report the result. @@ -142,7 +145,7 @@ def _generate_result_response_transition( :param result_params: Dictionary of the parameters of the result. """ return self._generate_srv_event_transition( - client_to_goal_id, result_params, generate_action_result_event, + goal_state, client_to_goal_id, result_params, generate_action_result_event, generate_action_result_handle_event) def to_scxml(self) -> ScxmlRoot: @@ -171,17 +174,22 @@ def to_scxml(self) -> ScxmlRoot: scxml_root_name = \ self.get_interface_prefix() + sanitize_ros_interface_name(self._interface_name) wait_state = ScxmlState("waiting") + goal_requested_state = ScxmlState("goal_requested") for client_id, goal_id in client_to_goal_id: wait_state.add_transition( - self._generate_goal_request_transition(client_id, goal_id, goal_params)) - wait_state.add_transition(self._generate_goal_accept_transition(client_to_goal_id)) - wait_state.add_transition(self._generate_goal_reject_transition(client_to_goal_id)) + self._generate_goal_request_transition( + goal_requested_state, client_id, goal_id, goal_params)) + goal_requested_state.add_transition( + self._generate_goal_accept_transition(wait_state, client_to_goal_id)) + goal_requested_state.add_transition( + self._generate_goal_reject_transition(wait_state, client_to_goal_id)) wait_state.add_transition(self._generate_feedback_response_transition( - client_to_goal_id, feedback_params)) + wait_state, client_to_goal_id, feedback_params)) wait_state.add_transition(self._generate_result_response_transition( - client_to_goal_id, result_params)) + wait_state, client_to_goal_id, result_params)) scxml_root = ScxmlRoot(scxml_root_name) scxml_root.set_data_model(ScxmlDataModel(req_fields_as_data)) scxml_root.add_state(wait_state, initial=True) + scxml_root.add_state(goal_requested_state) assert scxml_root.is_plain_scxml(), "Generated SCXML for srv sync is not plain SCXML." return scxml_root From 2957188862be922fbc0fef879b3b319014d77179 Mon Sep 17 00:00:00 2001 From: Marco Lampacrescia Date: Mon, 2 Sep 2024 15:08:28 +0200 Subject: [PATCH 22/23] Fix problem in action server SCXML model Signed-off-by: Marco Lampacrescia --- .../test/_test_data/fibonacci_action_example/server.scxml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jani_generator/test/_test_data/fibonacci_action_example/server.scxml b/jani_generator/test/_test_data/fibonacci_action_example/server.scxml index 6b9d0fbb..98d70e61 100644 --- a/jani_generator/test/_test_data/fibonacci_action_example/server.scxml +++ b/jani_generator/test/_test_data/fibonacci_action_example/server.scxml @@ -70,8 +70,8 @@ - - + + From 5020fe45d888f1b4149fce873641f2e15c0758b1 Mon Sep 17 00:00:00 2001 From: Marco Lampacrescia Date: Tue, 3 Sep 2024 11:17:47 +0200 Subject: [PATCH 23/23] Fix action test and add todo Signed-off-by: Marco Lampacrescia --- .../fibonacci_action_example/server.scxml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/jani_generator/test/_test_data/fibonacci_action_example/server.scxml b/jani_generator/test/_test_data/fibonacci_action_example/server.scxml index 98d70e61..cbe25a4b 100644 --- a/jani_generator/test/_test_data/fibonacci_action_example/server.scxml +++ b/jani_generator/test/_test_data/fibonacci_action_example/server.scxml @@ -73,6 +73,7 @@ + @@ -84,9 +85,17 @@ - + + + + - +